feat(web): surface Box disabled/unavailable state across consumers

When Box is disabled in config (``box.enabled = false``) or fails to
connect, every dependent UI surface now degrades visibly:

- ``useBoxStatus`` hook: shared, polled 30s, exposes ``available``,
  ``disabled`` (config-off) and a single ``hint`` key so callers don't
  have to re-derive the three states
- ``BoxUnavailableNotice`` reusable Alert banner driven by that hint
- Dashboard SystemStatusCards: three-state dot + label
  (connected / disabled-gray / disconnected-red); disabled state shows
  the ``boxDisabled`` hint, failed state continues to show the connector
  error. Plugin block kept untouched
- Skills page (create view) and SkillDetailContent (edit view):
  Save button disabled and banner inserted above the form when Box is
  unavailable — matches the backend gate added in the previous commit
- PipelineExtension skill section: ``enable_all_skills`` switch, Add
  Skill button and Remove buttons all gate on Box availability;
  banner inline under the section header
- PipelineFormComponent: banner above the ``local-agent`` stage card
  when Box is unavailable, since that stage carries the sandbox-bound
  ``box-session-id-template`` field
- Box status payload type (``ApiRespBoxStatus.enabled``) and 8 locale
  files updated with ``boxDisabled`` / ``boxUnavailable`` /
  ``boxRequiredHint`` strings

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-05-20 17:18:44 +08:00
parent ec2d21fe63
commit 446099ecda
16 changed files with 285 additions and 42 deletions

View File

@@ -0,0 +1,48 @@
import { useTranslation } from 'react-i18next';
import { Info, ShieldAlert } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
/**
* Banner shown when a feature depends on the Box sandbox runtime but it is
* currently disabled in config or otherwise unavailable. Pass the ``hint``
* key returned by ``useBoxStatus`` (``'boxDisabled' | 'boxUnavailable'``).
*
* Renders nothing when there is no hint — safe to drop at the top of any
* page that may or may not need to surface the notice.
*/
export interface BoxUnavailableNoticeProps {
hint: 'boxDisabled' | 'boxUnavailable' | null;
/** Optional extra context line (e.g. the specific consumer name). */
context?: string;
/** When true, render as muted; default uses the destructive variant only
* for failed (boxUnavailable) state so a deliberate disable looks calm. */
className?: string;
}
export function BoxUnavailableNotice({
hint,
context,
className,
}: BoxUnavailableNoticeProps) {
const { t } = useTranslation();
if (!hint) return null;
const variant = hint === 'boxDisabled' ? 'default' : 'destructive';
const Icon = hint === 'boxDisabled' ? Info : ShieldAlert;
return (
<Alert variant={variant} className={className}>
<Icon className="h-4 w-4" />
<AlertDescription className="space-y-1">
<div>{t(`monitoring.${hint}`)}</div>
{context && <div className="text-xs opacity-80">{context}</div>}
<div className="text-xs opacity-80">
{t('monitoring.boxRequiredHint')}
</div>
</AlertDescription>
</Alert>
);
}
export default BoxUnavailableNotice;

View File

@@ -36,14 +36,16 @@ import {
} from '@/components/ui/tooltip';
import { httpClient } from '@/app/infra/http/HttpClient';
function StatusDot({ ok }: { ok: boolean | null }) {
if (ok === null)
type StatusState = 'ok' | 'disabled' | 'failed' | null;
function StatusDot({ state }: { state: StatusState }) {
if (state === null)
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
return ok ? (
<span className="w-2 h-2 rounded-full bg-green-500" />
) : (
<span className="w-2 h-2 rounded-full bg-red-500" />
);
if (state === 'ok')
return <span className="w-2 h-2 rounded-full bg-green-500" />;
if (state === 'disabled')
return <span className="w-2 h-2 rounded-full bg-muted-foreground/60" />;
return <span className="w-2 h-2 rounded-full bg-red-500" />;
}
interface SystemStatusCardProps {
@@ -86,7 +88,25 @@ export default function SystemStatusCard({
const pluginOk = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
: null;
const pluginState: StatusState = pluginStatus
? pluginStatus.is_enable && pluginStatus.is_connected
? 'ok'
: !pluginStatus.is_enable
? 'disabled'
: 'failed'
: null;
const boxOk = boxStatus ? boxStatus.available : null;
// Box has three observable states: connected (ok), disabled by config
// (enabled = false → distinct gray dot + "disabled" hint), and configured
// but failed (red dot + connector_error). The dashboard must distinguish
// them so operators can tell intentional-off from misconfigured.
const boxState: StatusState = boxStatus
? boxStatus.available
? 'ok'
: boxStatus.enabled === false
? 'disabled'
: 'failed'
: null;
const handleOpenDialog = (e: React.MouseEvent) => {
e.stopPropagation();
@@ -129,12 +149,12 @@ export default function SystemStatusCard({
</CardHeader>
<CardContent className="space-y-2">
<div className="flex items-center gap-2">
<StatusDot ok={pluginOk} />
<StatusDot state={pluginState} />
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.pluginRuntime')}</span>
</div>
<div className="flex items-center gap-2">
<StatusDot ok={boxOk} />
<StatusDot state={boxState} />
<Box className="w-3.5 h-3.5 text-muted-foreground" />
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
</div>
@@ -207,24 +227,39 @@ export default function SystemStatusCard({
</div>
<div className="ml-6 text-sm space-y-1">
<div className="flex items-center gap-1.5">
{boxOk ? (
{boxState === 'ok' ? (
<CircleCheck className="w-4 h-4 text-green-600" />
) : (
<CircleX className="w-4 h-4 text-red-500" />
<CircleX
className={
boxState === 'disabled'
? 'w-4 h-4 text-muted-foreground'
: 'w-4 h-4 text-red-500'
}
/>
)}
<span
className={
boxOk
boxState === 'ok'
? 'text-green-600 font-medium'
: 'text-red-500 font-medium'
: boxState === 'disabled'
? 'text-muted-foreground font-medium'
: 'text-red-500 font-medium'
}
>
{boxOk
{boxState === 'ok'
? t('monitoring.connected')
: t('monitoring.disconnected')}
: boxState === 'disabled'
? t('monitoring.disabled')
: t('monitoring.disconnected')}
</span>
</div>
{boxStatus && !boxOk && boxStatus.connector_error && (
{boxState === 'disabled' && (
<p className="text-muted-foreground text-xs">
{t('monitoring.boxDisabled')}
</p>
)}
{boxState === 'failed' && boxStatus?.connector_error && (
<p className="text-red-400 text-xs break-all">
{boxStatus.connector_error}
</p>

View File

@@ -19,6 +19,8 @@ import { Label } from '@/components/ui/label';
import { Plugin } from '@/app/infra/entities/plugin';
import { MCPServer, Skill } from '@/app/infra/entities/api';
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
export default function PipelineExtension({
pipelineId,
@@ -26,6 +28,7 @@ export default function PipelineExtension({
pipelineId: string;
}) {
const { t } = useTranslation();
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
@@ -519,9 +522,11 @@ export default function PipelineExtension({
id="enable-all-skills"
checked={enableAllSkills}
onCheckedChange={handleToggleEnableAllSkills}
disabled={!boxAvailable}
/>
</div>
</div>
{!boxAvailable && <BoxUnavailableNotice hint={boxHint} />}
<div className="space-y-2">
{enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
@@ -559,6 +564,7 @@ export default function PipelineExtension({
variant="ghost"
size="icon"
onClick={() => handleRemoveSkill(skill.name)}
disabled={!boxAvailable}
>
<X className="h-4 w-4" />
</Button>
@@ -572,7 +578,7 @@ export default function PipelineExtension({
onClick={handleOpenSkillDialog}
variant="outline"
className="w-full"
disabled={enableAllSkills}
disabled={enableAllSkills || !boxAvailable}
>
<Plus className="mr-2 h-4 w-4" />
{t('pipelines.extensions.addSkill')}

View File

@@ -7,6 +7,8 @@ import {
} from '@/app/infra/entities/pipeline';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Button } from '@/components/ui/button';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -75,6 +77,7 @@ export default function PipelineFormComponent({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
const formSchema = isEditMode
? z.object({
@@ -413,29 +416,40 @@ export default function PipelineFormComponent({
}
}
// The local-agent stage carries sandbox-bound fields (e.g.
// ``box-session-id-template``). When Box is disabled / unavailable, show
// a banner so operators know those fields will have no effect. We render
// the banner above the card rather than per-field because the field set
// is driven by yaml metadata and a single notice keeps the UI calm.
const showBoxNoticeForStage = stage.name === 'local-agent' && !boxAvailable;
return (
<Card key={stage.name}>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
<div key={stage.name} className="space-y-3">
{showBoxNoticeForStage && <BoxUnavailableNotice hint={boxHint} />}
<Card>
<CardHeader>
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
{stage.description && (
<CardDescription>
{extractI18nObject(stage.description)}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-6">
<DynamicFormComponent
itemConfigList={stage.config}
initialValues={
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
{}
}
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
/>
</CardContent>
</Card>
</div>
);
}

View File

@@ -21,6 +21,8 @@ import {
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { httpClient } from '@/app/infra/http/HttpClient';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
import { Sparkles, Trash2 } from 'lucide-react';
export default function SkillDetailContent({ id }: { id: string }) {
@@ -30,6 +32,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
useEffect(() => {
if (isCreateMode) {
@@ -81,11 +84,22 @@ export default function SkillDetailContent({ id }: { id: string }) {
</Badge>
</div>
</div>
<Button type="submit" form="skill-form" className="shrink-0">
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"
@@ -150,11 +164,22 @@ export default function SkillDetailContent({ id }: { id: string }) {
</p>
)}
</div>
<Button type="submit" form="skill-form" className="shrink-0">
<Button
type="submit"
form="skill-form"
className="shrink-0"
disabled={!boxAvailable}
>
{t('common.save')}
</Button>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key={id}

View File

@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
export default function SkillsPage() {
const { t } = useTranslation();
@@ -15,6 +17,7 @@ export default function SkillsPage() {
const { refreshSkills } = useSidebarData();
const isCreateView = actionParam === 'create';
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
useEffect(() => {
if (!detailId && !isCreateView) {
@@ -54,11 +57,16 @@ export default function SkillsPage() {
<Button variant="outline" onClick={handleCancel}>
{t('common.cancel')}
</Button>
<Button type="submit" form="skill-form">
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
{t('common.save')}
</Button>
</div>
</div>
{!boxAvailable && (
<div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} />
</div>
)}
<div className="min-h-0 flex-1">
<SkillForm
key="new-skill"

View File

@@ -362,6 +362,9 @@ export interface ApiRespPluginSystemStatus {
export interface ApiRespBoxStatus {
available: boolean;
/** Whether ``box.enabled`` is true in config. When false, the sandbox
* is deliberately disabled — distinct from "configured but failed". */
enabled?: boolean;
profile: string;
recent_error_count: number;
connector_error?: string;

View File

@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import type { ApiRespBoxStatus } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
/**
* Shared hook for Box runtime status — used by every UI surface that needs
* to gate behaviour on whether the sandbox is available. Returns:
*
* - status: full payload (or null while loading / on error)
* - available: convenience flag (status?.available === true)
* - disabled: true iff Box is explicitly disabled by config
* (status.enabled === false), distinguishing it from
* "configured but currently failed"
* - hint: a single i18n-key choice for the banner message —
* 'boxDisabled' / 'boxUnavailable' / null
* - refresh: imperative re-fetch
*
* Polls every ``refreshMs`` (default 30s) so a flapping runtime is picked
* up without a page reload.
*/
export function useBoxStatus(refreshMs = 30_000) {
const [status, setStatus] = useState<ApiRespBoxStatus | null>(null);
const [loading, setLoading] = useState(true);
const refresh = useCallback(async () => {
try {
const data = await httpClient.getBoxStatus();
setStatus(data);
} catch {
// Keep last-known status; the dashboard polls separately so a
// transient failure here should not blank the UI for sandbox
// consumers.
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const id = setInterval(() => void refresh(), refreshMs);
return () => clearInterval(id);
}, [refresh, refreshMs]);
const available = status?.available === true;
const disabled = status?.available === false && status?.enabled === false;
const hint: 'boxDisabled' | 'boxUnavailable' | null = available
? null
: disabled
? 'boxDisabled'
: status
? 'boxUnavailable'
: null;
return { status, loading, available, disabled, hint, refresh };
}

View File

@@ -1303,6 +1303,12 @@ const enUS = {
disabled: 'Disabled',
statusDetail: 'Status',
pluginDisabled: 'Plugin system is disabled',
boxDisabled:
'Box sandbox is disabled in config — sandbox tools, skill add/edit, and stdio MCP are unavailable',
boxUnavailable:
'Box sandbox is unavailable — sandbox tools, skill add/edit, and stdio MCP are unavailable',
boxRequiredHint:
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
boxBackend: 'Backend',
boxProfile: 'Profile',
boxSandboxes: 'Sandboxes',

View File

@@ -1336,6 +1336,12 @@ const esES = {
disabled: 'Desactivado',
statusDetail: 'Estado',
pluginDisabled: 'El sistema de plugins está desactivado',
boxDisabled:
'El sandbox de Box está desactivado en la configuración — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
boxUnavailable:
'El sandbox de Box no está disponible — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
boxRequiredHint:
'Esta función requiere el runtime de Box. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime está conectado.',
boxBackend: 'Backend',
boxProfile: 'Perfil',
boxSandboxes: 'Sandboxes',

View File

@@ -1308,6 +1308,12 @@ const jaJP = {
disabled: '無効',
statusDetail: 'ステータス',
pluginDisabled: 'プラグインシステムが無効です',
boxDisabled:
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
boxUnavailable:
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
boxRequiredHint:
'この機能には Box ランタイムが必要です。設定で有効化box.enabled = trueし、ランタイムが正常に接続できることを確認してください。',
boxBackend: 'バックエンド',
boxProfile: 'プロファイル',
boxSandboxes: 'サンドボックス',

View File

@@ -1311,6 +1311,12 @@ const ruRU = {
disabled: 'Отключено',
statusDetail: 'Статус',
pluginDisabled: 'Система плагинов отключена',
boxDisabled:
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
boxUnavailable:
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
boxRequiredHint:
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
boxBackend: 'Бэкенд',
boxProfile: 'Профиль',
boxSandboxes: 'Песочницы',

View File

@@ -1282,6 +1282,12 @@ const thTH = {
disabled: 'ปิดใช้งาน',
statusDetail: 'สถานะ',
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
boxDisabled:
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
boxUnavailable:
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
boxRequiredHint:
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
boxBackend: 'แบ็กเอนด์',
boxProfile: 'โปรไฟล์',
boxSandboxes: 'แซนด์บ็อกซ์',

View File

@@ -1305,6 +1305,12 @@ const viVN = {
disabled: 'Đã tắt',
statusDetail: 'Trạng thái',
pluginDisabled: 'Hệ thống plugin đã tắt',
boxDisabled:
'Sandbox Box đã tắt trong cấu hình — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
boxUnavailable:
'Sandbox Box không khả dụng — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
boxRequiredHint:
'Tính năng này cần Box runtime. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime đang hoạt động.',
boxBackend: 'Backend',
boxProfile: 'Hồ sơ',
boxSandboxes: 'Sandbox',

View File

@@ -1249,6 +1249,12 @@ const zhHans = {
disabled: '已禁用',
statusDetail: '状态',
pluginDisabled: '插件系统已禁用',
boxDisabled:
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxUnavailable:
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
boxRequiredHint:
'此功能依赖 Box 运行时。请在配置中启用box.enabled = true并确认运行时连接正常。',
boxBackend: '后端',
boxProfile: '配置',
boxSandboxes: '沙箱数',

View File

@@ -1248,6 +1248,12 @@ const zhHant = {
disabled: '已停用',
statusDetail: '狀態',
pluginDisabled: '外掛系統已停用',
boxDisabled:
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxUnavailable:
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
boxRequiredHint:
'此功能需要 Box 執行時。請在設定中啟用box.enabled = true並確認執行時連線正常。',
boxBackend: '後端',
boxProfile: '設定檔',
boxSandboxes: '沙箱數',