mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-27 07:16:19 +09:00
refactor(workspace): new workspace v3 container architecture (#244)
* feat(mcp): workspace container with bridge architecture
Migrate MCP containers to use UDS-based bridge communication instead of
TCP gRPC. Containers now mount runtime binaries and Unix domain sockets
from the host, eliminating the need for a dedicated MCP Docker image.
- Remove Dockerfile.mcp and entrypoint.sh in favor of standard base images
- Add toolkit Dockerfile for building MCP binary separately
- Containers use bind mounts for /opt/memoh (runtime) and /run/memoh (UDS)
- Update all config files with new runtime_path and socket_dir settings
- Support custom base images per bot (debian, alpine, ubuntu, etc.)
- Legacy container detection and TCP fallback for pre-bridge containers
- Frontend: add base image selector in container creation UI
* feat(container): SSE progress bar for container creation
Add real-time progress feedback during container image pull and creation
using Server-Sent Events, without breaking the existing synchronous JSON
API (content negotiation via Accept header).
Backend:
- Add PullProgress/LayerStatus types and OnProgress callback to
PullImageOptions (containerd service layer)
- DefaultService.PullImage polls ContentStore.ListStatuses every 500ms
when OnProgress is set; AppleService ignores it
- CreateContainer handler checks Accept: text/event-stream and switches
to SSE branch: pulling → pull_progress → creating → complete/error
Frontend:
- handleCreateContainer/handleRecreateContainer use fetch + SSE instead
of the SDK's synchronous postBotsByBotIdContainer
- Progress bar shows layer-level pull progress (offset/total) during
pulling phase and indeterminate animation during creating phase
- i18n keys added for pullingImage and creatingContainer (en/zh)
* fix(container): clear stale legacy route and type create SSE
* fix(ci): resolve lint errors and arm64 musl node.js download
- Fix unused-receiver lint: rename `s` to `_` on stub methods in
manager_legacy_test.go
- Fix sloglint: use slog.DiscardHandler instead of
slog.NewTextHandler(io.Discard, nil)
- Handle missing arm64 musl Node.js builds: unofficial-builds.nodejs.org
does not provide arm64 musl binaries, fall back to glibc build
* fix(lint): address errcheck, staticcheck, and gosec findings
- Discard os.Setenv/os.Remove return values explicitly with _
- Use omitted receiver name instead of _ (staticcheck ST1006)
- Tighten directory permissions from 0o755 to 0o750 (gosec G301)
* fix(lint): sanitize socket path to satisfy gosec G703
filepath.Clean the env-sourced socket path before os.Remove
to avoid path-traversal taint warning.
* fix(lint): use nolint directive for gosec G703 on socket path
filepath.Clean does not satisfy gosec's taint analysis. The socket
path comes from MCP_SOCKET_PATH env (operator-configured) or a
compiled-in default, not from end-user input.
* refactor: rename MCP container/bridge to workspace/bridge
Split internal/mcp/ to separate container lifecycle management from
Model Context Protocol connections, eliminating naming confusion:
- internal/mcp/ (container mgmt) → internal/workspace/
- internal/mcp/mcpclient/ → internal/workspace/bridge/
- internal/mcp/mcpcontainer/ → internal/workspace/bridgepb/
- cmd/mcp/ → cmd/bridge/
- config: MCPConfig → WorkspaceConfig, [mcp] → [workspace]
- container prefix: mcp-{id} → workspace-{id}
- labels: mcp.bot_id → memoh.bot_id, add memoh.workspace=v1
- socket: mcp.sock → bridge.sock, env BRIDGE_SOCKET_PATH
- runtime: /opt/memoh/runtime/mcp → /opt/memoh/runtime/bridge
- devenv: mcp-build.sh → bridge-build.sh
Legacy containers (mcp- prefix) detected by container name prefix
and handled via existing fallback path.
* fix(container): use memoh.workspace=v3 label value
* refactor(container): drop LegacyBotLabelKey, infer bot ID from container name
Legacy containers use mcp-{botID} naming, so bot ID can be derived
via TrimPrefix instead of looking up the mcp.bot_id label.
* fix(workspace): resolve containers via manager and drop gateway container ID
* docs: fix stale mcp references in AGENTS.md and DEPLOYMENT.md
* refactor(workspace): move container lifecycle ownership into manager
* dev: isolate local devenv from prod config
* toolkit: support musl node runtime
* containerd: fix fallback resolv.conf permissions
* web: preserve container create progress on completion
* web: add bot creation wait hint
* fix(workspace): preserve image selection across recreate
* feat(web): shorten default docker hub image refs
* fix(container): address code review findings
- Remove synchronous CreateContainer path (SSE-only now)
- Move flusher check before WriteHeader to avoid committed 200 on error
- Fix legacy container IP not cached via ensureContainerAndTask path
- Add atomic guard to prevent stale pull_progress after PullImage returns
- Defensive copy for tzEnv slice to avoid mutating shared backing array
- Restore network failure severity in restartContainer (return + Error)
- Extract duplicate progress bar into ContainerCreateProgress component
- Fix codesync comments to use repo-relative paths
- Add SaaS image validation note and kernel version comment on reaper
* refactor(devenv): extract toolkit install into shared script
Unify the Node.js + uv download logic into docker/toolkit/install.sh,
used by the production Dockerfile and runnable locally for dev.
Dev environment no longer bakes toolkit into the Docker image — it is
volume-mounted from .toolkit/ instead, so wrapper script changes take
effect immediately without rebuilding. The entrypoint checks for the
toolkit directory and prints a clear error if missing.
* fix(ci): address go ci failures
* chore(docker): remove unused containerd image
* refactor(config): rename workspace image key
* fix(workspace): fix legacy container data loss on migration and stop swallowing errors
Three root causes were identified and fixed:
1. Delete() used hardcoded "workspace-" prefix to look up legacy "mcp-"
containers, causing GetContainer to return NotFound. CleanupBotContainer
then silently skipped the error and deleted the DB record without ever
calling PreserveData. Fix: resolve the actual container ID via
ContainerID() (DB → label → scan) before operating.
2. Multiple restore error paths were silently swallowed (logged as Warn
but not returned), so the user saw HTTP 200/204 with no data and no
error. Fix: all errors in the preserve/restore chain now block the
workflow and propagate to the caller.
3. tarGzDir used cached DirEntry.Info() for tar header size, which on
overlayfs can differ from the actual file size, causing "archive/tar:
write too long". Fix: open the file first, Fstat the fd for a
race-free size, and use LimitReader as a safeguard.
Also adds a "restoring" SSE phase so the frontend shows a progress
indicator ("Restoring data, this may take a while...") during data
migration on container recreation.
* refactor(workspace): single-point container ID resolution
Replace the `containerID func(string) string` field with a single
`resolveContainerID(ctx, botID)` method that resolves the actual
container ID via DB → label → scan → fallback. All ~16 lookup
callsites across manager.go, dataio.go, versioning.go, and
manager_lifecycle.go now go through this single resolver, which
correctly handles both legacy "mcp-" and new "workspace-" containers.
Only `ensureBotWithImage` inlines `ContainerPrefix + botID` for
creating brand-new containers — every other path resolves dynamically.
* fix(web): show progress during data backup phase of container recreate
The recreate flow (delete with preserve_data + create with restore_data)
blocked on the DELETE call while backing up /data with no progress
indication. Add a 'preserving' phase to the progress component so
users see "正在备份数据..." instead of an unexplained hang.
* chore: remove [MYDEBUG] debug logging
Clean up all 112 temporary debug log statements added during the
legacy container migration investigation. Kept only meaningful
warn-level logs for non-fatal errors (network teardown, rename
failures).
This commit is contained in:
@@ -486,6 +486,7 @@
|
||||
"title": "Bots",
|
||||
"searchPlaceholder": "Search bots…",
|
||||
"createBot": "New Bot",
|
||||
"createBotWaitHint": "Creating a bot may need to pull the base image on first use. Please wait a moment after submission.",
|
||||
"editBot": "Edit Bot",
|
||||
"deleteConfirm": "Are you sure you want to delete this bot?",
|
||||
"renameSuccess": "Bot name updated",
|
||||
@@ -636,9 +637,14 @@
|
||||
"subtitle": "Manage the runtime container attached to this bot.",
|
||||
"botNotReady": "This bot is in lifecycle transition. Container actions are temporarily disabled.",
|
||||
"empty": "No container found for this bot. Create one to enable runtime tooling.",
|
||||
"legacyWarning": "This container uses an older architecture and needs to be recreated for full compatibility. Your data will be preserved automatically.",
|
||||
"legacyRecreate": "Recreate Container",
|
||||
"legacyRecreateSuccess": "Container recreated successfully",
|
||||
"createHint": "The container is created from the current image. If you explicitly enable restore, preserved data will be restored after creation.",
|
||||
"createRestoreDataLabel": "Restore preserved data after creation",
|
||||
"createRestoreDataDescription": "If a previously exported backup or legacy bind-mounted data exists, it will be restored into `/data` after the container is created.",
|
||||
"createImageLabel": "Base image",
|
||||
"createImageDescription": "Docker image to use as the container base (e.g. debian:bookworm-slim, alpine:latest, ubuntu:24.04). Leave empty for the default.",
|
||||
"deleteConfirm": "Are you sure you want to permanently delete this container? Unpreserved data cannot be recovered.",
|
||||
"deletePreserveConfirm": "Are you sure you want to export `/data` and then delete this container?",
|
||||
"restoreConfirm": "Are you sure you want to restore preserved data into this container's `/data`?",
|
||||
@@ -664,6 +670,10 @@
|
||||
"importSuccess": "Data imported",
|
||||
"restoreSuccess": "Preserved data restored",
|
||||
"rollbackSuccess": "Snapshot rolled back",
|
||||
"pullingImage": "Pulling image...",
|
||||
"creatingContainer": "Creating container...",
|
||||
"preservingData": "Backing up data, this may take a while for large volumes...",
|
||||
"restoringData": "Restoring data, this may take a while for large volumes...",
|
||||
"snapshotEmpty": "No snapshots found",
|
||||
"snapshotLoadFailed": "Failed to load snapshots",
|
||||
"snapshotNamePlaceholder": "Snapshot display name (optional)",
|
||||
|
||||
@@ -482,6 +482,7 @@
|
||||
"title": "Bots",
|
||||
"searchPlaceholder": "搜索 Bot…",
|
||||
"createBot": "新建 Bot",
|
||||
"createBotWaitHint": "首次创建时可能需要拉取基础镜像,提交后请耐心等待片刻。",
|
||||
"editBot": "编辑 Bot",
|
||||
"deleteConfirm": "确定要删除这个 Bot 吗?",
|
||||
"renameSuccess": "Bot 名称已更新",
|
||||
@@ -632,9 +633,14 @@
|
||||
"subtitle": "管理当前 Bot 对应的运行容器。",
|
||||
"botNotReady": "当前 Bot 正在生命周期变更中,暂时不可操作容器。",
|
||||
"empty": "当前 Bot 尚未创建容器,创建后可启用运行环境能力。",
|
||||
"legacyWarning": "当前容器使用旧版架构,需要重建以获得完整兼容性。重建时数据会自动保留。",
|
||||
"legacyRecreate": "重建容器",
|
||||
"legacyRecreateSuccess": "容器重建成功",
|
||||
"createHint": "容器会基于当前镜像创建;如你显式开启恢复,则会在创建后尝试恢复已保留的数据。",
|
||||
"createRestoreDataLabel": "创建后恢复已保留数据",
|
||||
"createRestoreDataDescription": "如果存在之前导出的备份或旧版 bind mount 数据,将在容器创建后恢复到 `/data`。",
|
||||
"createImageLabel": "基础镜像",
|
||||
"createImageDescription": "作为容器基础环境的 Docker 镜像(如 debian:bookworm-slim、alpine:latest、ubuntu:24.04)。留空则使用默认镜像。",
|
||||
"deleteConfirm": "确定要彻底删除这个容器吗?未保留的数据将无法恢复。",
|
||||
"deletePreserveConfirm": "确定要先导出 `/data` 再删除这个容器吗?",
|
||||
"restoreConfirm": "确定要将已保留的数据恢复到当前容器的 `/data` 吗?",
|
||||
@@ -660,6 +666,10 @@
|
||||
"importSuccess": "数据导入成功",
|
||||
"restoreSuccess": "已恢复保留数据",
|
||||
"rollbackSuccess": "快照回滚成功",
|
||||
"pullingImage": "正在拉取镜像...",
|
||||
"creatingContainer": "正在创建容器...",
|
||||
"preservingData": "正在备份数据,数据量较大时可能需要一段时间...",
|
||||
"restoringData": "正在迁移数据,数据量较大时可能需要一段时间...",
|
||||
"snapshotEmpty": "暂无快照",
|
||||
"snapshotLoadFailed": "加载快照失败",
|
||||
"snapshotNamePlaceholder": "快照显示名称(可选)",
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
getBotsByBotIdContainer,
|
||||
getBotsByBotIdContainerSnapshots,
|
||||
getBotsById,
|
||||
postBotsByBotIdContainer,
|
||||
postBotsByBotIdContainerDataExport,
|
||||
postBotsByBotIdContainerDataImport,
|
||||
postBotsByBotIdContainerDataRestore,
|
||||
@@ -17,15 +16,23 @@ import {
|
||||
postBotsByBotIdContainerSnapshotsRollback,
|
||||
postBotsByBotIdContainerStart,
|
||||
postBotsByBotIdContainerStop,
|
||||
type HandlersCreateContainerRequest,
|
||||
type HandlersGetContainerResponse,
|
||||
type HandlersListSnapshotsResponse,
|
||||
} from '@memoh/sdk'
|
||||
import {
|
||||
postBotsByBotIdContainerStream,
|
||||
type ContainerCreateLayerStatus,
|
||||
type ContainerCreateStreamEvent,
|
||||
} from '@memoh/sdk/extra'
|
||||
import { Button, Input, Label, Separator, Spinner, Switch } from '@memoh/ui'
|
||||
import ConfirmPopover from '@/components/confirm-popover/index.vue'
|
||||
import ContainerCreateProgress from './container-create-progress.vue'
|
||||
import { useSyncedQueryParam } from '@/composables/useSyncedQueryParam'
|
||||
import { useBotStatusMeta } from '@/composables/useBotStatusMeta'
|
||||
import { useCapabilitiesStore } from '@/store/capabilities'
|
||||
import { formatDateTime } from '@/utils/date-time'
|
||||
import { shortenImageRef } from '@/utils/image-ref'
|
||||
import { resolveApiErrorMessage } from '@/utils/api-error'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -43,15 +50,38 @@ type ContainerAction =
|
||||
| 'import'
|
||||
| 'restore'
|
||||
| 'rollback'
|
||||
| 'recreate'
|
||||
| ''
|
||||
|
||||
const containerLoading = ref(false)
|
||||
const containerAction = ref<ContainerAction>('')
|
||||
const rollbackVersion = ref<number | null>(null)
|
||||
const createRestoreData = ref(false)
|
||||
const createImage = ref('')
|
||||
const createImagePrefilled = ref(false)
|
||||
const newSnapshotName = ref('')
|
||||
const importInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
interface CreateProgress {
|
||||
phase: 'preserving' | 'pulling' | 'creating' | 'restoring' | 'complete' | 'error'
|
||||
layers?: ContainerCreateLayerStatus[]
|
||||
image?: string
|
||||
error?: string
|
||||
}
|
||||
const createProgress = ref<CreateProgress | null>(null)
|
||||
|
||||
const createProgressPercent = computed(() => {
|
||||
const layers = createProgress.value?.layers
|
||||
if (!layers || layers.length === 0) return 0
|
||||
let totalOffset = 0
|
||||
let totalSize = 0
|
||||
for (const l of layers) {
|
||||
totalOffset += l.offset
|
||||
totalSize += l.total
|
||||
}
|
||||
return totalSize > 0 ? Math.round((totalOffset / totalSize) * 100) : 0
|
||||
})
|
||||
|
||||
const capabilitiesStore = useCapabilitiesStore()
|
||||
const botId = computed(() => route.params.botId as string)
|
||||
const containerBusy = computed(() => containerLoading.value || containerAction.value !== '')
|
||||
@@ -157,29 +187,88 @@ const { data: bot } = useQuery({
|
||||
enabled: () => !!botId.value,
|
||||
})
|
||||
|
||||
function rememberedWorkspaceImage(metadata: Record<string, unknown> | undefined): string {
|
||||
const workspace = metadata?.workspace
|
||||
if (!workspace || typeof workspace !== 'object' || Array.isArray(workspace)) return ''
|
||||
const image = (workspace as Record<string, unknown>).image
|
||||
return typeof image === 'string' ? shortenImageRef(image) : ''
|
||||
}
|
||||
|
||||
const rememberedCreateImage = computed(() => rememberedWorkspaceImage(bot.value?.metadata as Record<string, unknown> | undefined))
|
||||
const displayedContainerImage = computed(() => shortenImageRef(containerInfo.value?.image))
|
||||
|
||||
const { isPending: botLifecyclePending } = useBotStatusMeta(bot, t)
|
||||
|
||||
function applyCreateContainerEvent(event: ContainerCreateStreamEvent): boolean {
|
||||
switch (event.type) {
|
||||
case 'pulling':
|
||||
createProgress.value = { phase: 'pulling', image: event.image }
|
||||
return false
|
||||
case 'pull_progress':
|
||||
createProgress.value = {
|
||||
phase: 'pulling',
|
||||
image: createProgress.value?.image,
|
||||
layers: event.layers,
|
||||
}
|
||||
return false
|
||||
case 'creating':
|
||||
createProgress.value = { phase: 'creating' }
|
||||
return false
|
||||
case 'restoring':
|
||||
createProgress.value = { phase: 'restoring' }
|
||||
return false
|
||||
case 'complete':
|
||||
// Keep the last visible progress state until the container detail view loads.
|
||||
// Rendering a separate "complete" phase here looks like the bar jumped back to 0.
|
||||
return !!event.container.data_restored
|
||||
case 'error':
|
||||
createProgress.value = { phase: 'error', error: event.message }
|
||||
throw new Error(event.message || 'Unknown error')
|
||||
}
|
||||
}
|
||||
|
||||
async function createContainerSSE(body: HandlersCreateContainerRequest): Promise<{ dataRestored: boolean }> {
|
||||
const { stream } = await postBotsByBotIdContainerStream({
|
||||
path: { bot_id: botId.value },
|
||||
body,
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
let dataRestored = false
|
||||
for await (const event of stream) {
|
||||
dataRestored = applyCreateContainerEvent(event) || dataRestored
|
||||
}
|
||||
|
||||
return { dataRestored }
|
||||
}
|
||||
|
||||
async function handleCreateContainer() {
|
||||
if (botLifecyclePending.value) return
|
||||
|
||||
await runContainerAction(
|
||||
'create',
|
||||
async () => {
|
||||
const { data } = await postBotsByBotIdContainer({
|
||||
path: { bot_id: botId.value },
|
||||
body: {
|
||||
restore_data: createRestoreData.value,
|
||||
},
|
||||
throwOnError: true,
|
||||
})
|
||||
createRestoreData.value = false
|
||||
await loadContainerData(false)
|
||||
return data
|
||||
},
|
||||
(result) => result.data_restored
|
||||
containerAction.value = 'create'
|
||||
createProgress.value = { phase: 'pulling' }
|
||||
try {
|
||||
const body: HandlersCreateContainerRequest = {
|
||||
restore_data: createRestoreData.value,
|
||||
}
|
||||
const trimmedImage = createImage.value.trim()
|
||||
if (trimmedImage) body.image = trimmedImage
|
||||
|
||||
const { dataRestored } = await createContainerSSE(body)
|
||||
createRestoreData.value = false
|
||||
createImage.value = ''
|
||||
await loadContainerData(false)
|
||||
toast.success(dataRestored
|
||||
? t('bots.container.createRestoreSuccess')
|
||||
: t('bots.container.createSuccess'),
|
||||
)
|
||||
: t('bots.container.createSuccess'))
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(resolveErrorMessage(error, t('bots.container.actionFailed')))
|
||||
}
|
||||
finally {
|
||||
containerAction.value = ''
|
||||
createProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const isContainerTaskRunning = computed(() => {
|
||||
@@ -192,6 +281,33 @@ const isContainerTaskRunning = computed(() => {
|
||||
})
|
||||
|
||||
const hasPreservedData = computed(() => !!containerInfo.value?.has_preserved_data)
|
||||
const isLegacy = computed(() => !!containerInfo.value?.legacy)
|
||||
|
||||
async function handleRecreateContainer() {
|
||||
if (botLifecyclePending.value || !containerInfo.value) return
|
||||
|
||||
containerAction.value = 'recreate'
|
||||
try {
|
||||
createProgress.value = { phase: 'preserving' }
|
||||
await deleteBotsByBotIdContainer({
|
||||
path: { bot_id: botId.value },
|
||||
query: { preserve_data: true },
|
||||
throwOnError: true,
|
||||
})
|
||||
|
||||
createProgress.value = { phase: 'pulling' }
|
||||
await createContainerSSE({ restore_data: true })
|
||||
await loadContainerData(false)
|
||||
toast.success(t('bots.container.legacyRecreateSuccess'))
|
||||
}
|
||||
catch (error) {
|
||||
toast.error(resolveErrorMessage(error, t('bots.container.actionFailed')))
|
||||
}
|
||||
finally {
|
||||
containerAction.value = ''
|
||||
createProgress.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStopContainer() {
|
||||
if (botLifecyclePending.value || !containerInfo.value) return
|
||||
@@ -226,6 +342,7 @@ async function handleDeleteContainer(preserveData: boolean) {
|
||||
const successMessage = preserveData
|
||||
? t('bots.container.deletePreserveSuccess')
|
||||
: t('bots.container.deleteSuccess')
|
||||
const lastImage = shortenImageRef(containerInfo.value.image)
|
||||
|
||||
await runContainerAction(
|
||||
action,
|
||||
@@ -239,6 +356,8 @@ async function handleDeleteContainer(preserveData: boolean) {
|
||||
containerMissing.value = true
|
||||
snapshots.value = []
|
||||
createRestoreData.value = preserveData
|
||||
createImage.value = lastImage
|
||||
createImagePrefilled.value = !!lastImage
|
||||
},
|
||||
successMessage,
|
||||
)
|
||||
@@ -445,6 +564,19 @@ const sortedSnapshots = computed(() => {
|
||||
|
||||
const activeTab = useSyncedQueryParam('tab', 'overview')
|
||||
|
||||
watch(containerMissing, (missing) => {
|
||||
if (!missing) {
|
||||
createImagePrefilled.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([containerMissing, rememberedCreateImage], ([missing, remembered]) => {
|
||||
if (!missing || createImagePrefilled.value) return
|
||||
if (!remembered || createImage.value.trim()) return
|
||||
createImage.value = remembered
|
||||
createImagePrefilled.value = true
|
||||
}, { immediate: true })
|
||||
|
||||
watch([activeTab, botId], ([tab]) => {
|
||||
if (!botId.value) return
|
||||
if (tab === 'container') {
|
||||
@@ -540,6 +672,19 @@ watch([activeTab, botId], ([tab]) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label>{{ $t('bots.container.createImageLabel') }}</Label>
|
||||
<Input
|
||||
v-model="createImage"
|
||||
placeholder="debian:bookworm-slim"
|
||||
:disabled="containerBusy || botLifecyclePending"
|
||||
class="font-mono"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{{ $t('bots.container.createImageDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button
|
||||
:disabled="containerBusy || botLifecyclePending"
|
||||
@@ -552,6 +697,17 @@ watch([activeTab, botId], ([tab]) => {
|
||||
{{ $t('bots.container.actions.create') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="createProgress && (containerAction === 'create')"
|
||||
class="space-y-2"
|
||||
>
|
||||
<ContainerCreateProgress
|
||||
:phase="createProgress.phase"
|
||||
:percent="createProgressPercent"
|
||||
:error="createProgress.error"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -559,6 +715,39 @@ watch([activeTab, botId], ([tab]) => {
|
||||
v-else-if="containerInfo"
|
||||
class="space-y-5"
|
||||
>
|
||||
<div
|
||||
v-if="isLegacy"
|
||||
class="flex items-center justify-between gap-3 rounded-md border border-amber-300/50 bg-amber-50/70 p-3 dark:border-amber-800/50 dark:bg-amber-900/10"
|
||||
>
|
||||
<p class="text-sm text-amber-800 dark:text-amber-200">
|
||||
{{ $t('bots.container.legacyWarning') }}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="shrink-0"
|
||||
:disabled="containerBusy || botLifecyclePending"
|
||||
@click="handleRecreateContainer"
|
||||
>
|
||||
<Spinner
|
||||
v-if="containerAction === 'recreate'"
|
||||
class="mr-1.5"
|
||||
/>
|
||||
{{ $t('bots.container.legacyRecreate') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="createProgress && containerAction === 'recreate'"
|
||||
class="space-y-2 rounded-md border p-3"
|
||||
>
|
||||
<ContainerCreateProgress
|
||||
:phase="createProgress.phase"
|
||||
:percent="createProgressPercent"
|
||||
:error="createProgress.error"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-md border p-4">
|
||||
<dl class="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
||||
<div class="space-y-1">
|
||||
@@ -592,7 +781,7 @@ watch([activeTab, botId], ([tab]) => {
|
||||
{{ $t('bots.container.fields.image') }}
|
||||
</dt>
|
||||
<dd class="break-all">
|
||||
{{ containerInfo.image }}
|
||||
{{ displayedContainerImage }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="space-y-1 sm:col-span-2">
|
||||
@@ -969,4 +1158,4 @@ watch([activeTab, botId], ([tab]) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { Spinner } from '@memoh/ui'
|
||||
|
||||
defineProps<{
|
||||
phase: 'preserving' | 'pulling' | 'creating' | 'restoring' | 'complete' | 'error'
|
||||
percent: number
|
||||
error?: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Spinner class="size-3.5" />
|
||||
<span v-if="phase === 'preserving'">
|
||||
{{ $t('bots.container.preservingData') }}
|
||||
</span>
|
||||
<span v-else-if="phase === 'pulling'">
|
||||
{{ $t('bots.container.pullingImage') }}
|
||||
<span
|
||||
v-if="percent > 0"
|
||||
class="tabular-nums"
|
||||
>{{ percent }}%</span>
|
||||
</span>
|
||||
<span v-else-if="phase === 'creating'">
|
||||
{{ $t('bots.container.creatingContainer') }}
|
||||
</span>
|
||||
<span v-else-if="phase === 'restoring'">
|
||||
{{ $t('bots.container.restoringData') }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="phase === 'error'"
|
||||
class="text-destructive"
|
||||
>
|
||||
{{ error }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
v-if="phase === 'pulling'"
|
||||
class="h-full rounded-full bg-primary transition-all duration-300 ease-out"
|
||||
:style="{ width: `${percent}%` }"
|
||||
/>
|
||||
<div
|
||||
v-else-if="phase === 'creating' || phase === 'restoring' || phase === 'preserving'"
|
||||
class="h-full w-full animate-pulse rounded-full bg-primary/60"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -57,6 +57,9 @@
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
</FormField>
|
||||
<div class="rounded-md border bg-muted/40 px-3 py-2 text-sm text-muted-foreground">
|
||||
{{ $t('bots.createBotWaitHint') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter class="mt-6">
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { shortenImageRef } from './image-ref'
|
||||
|
||||
describe('shortenImageRef', () => {
|
||||
it('returns empty string for missing values', () => {
|
||||
expect(shortenImageRef(undefined)).toBe('')
|
||||
expect(shortenImageRef(null)).toBe('')
|
||||
expect(shortenImageRef('')).toBe('')
|
||||
})
|
||||
|
||||
it('strips docker hub library prefix', () => {
|
||||
expect(shortenImageRef('docker.io/library/nginx:latest')).toBe('nginx:latest')
|
||||
})
|
||||
|
||||
it('strips docker hub registry prefix for namespaced images', () => {
|
||||
expect(shortenImageRef('docker.io/memohai/memoh:latest')).toBe('memohai/memoh:latest')
|
||||
})
|
||||
|
||||
it('preserves non-docker-hub registries', () => {
|
||||
expect(shortenImageRef('ghcr.io/memohai/memoh:latest')).toBe('ghcr.io/memohai/memoh:latest')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,8 @@
|
||||
// Keep this display helper aligned with internal/config.NormalizeImageRef.
|
||||
export function shortenImageRef(value: string | null | undefined): string {
|
||||
const ref = value?.trim() ?? ''
|
||||
if (!ref) return ''
|
||||
if (ref.startsWith('docker.io/library/')) return ref.slice('docker.io/library/'.length)
|
||||
if (ref.startsWith('docker.io/')) return ref.slice('docker.io/'.length)
|
||||
return ref
|
||||
}
|
||||
Reference in New Issue
Block a user