diff --git a/web/src/app/home/components/BoxUnavailableNotice.tsx b/web/src/app/home/components/BoxUnavailableNotice.tsx new file mode 100644 index 00000000..99eba045 --- /dev/null +++ b/web/src/app/home/components/BoxUnavailableNotice.tsx @@ -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 ( + + + +
{t(`monitoring.${hint}`)}
+ {context &&
{context}
} +
+ {t('monitoring.boxRequiredHint')} +
+
+
+ ); +} + +export default BoxUnavailableNotice; diff --git a/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx b/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx index 51b34685..8b2f65ea 100644 --- a/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx +++ b/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx @@ -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 ; - return ok ? ( - - ) : ( - - ); + if (state === 'ok') + return ; + if (state === 'disabled') + return ; + return ; } 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({
- + {t('monitoring.pluginRuntime')}
- + {t('monitoring.boxRuntime')}
@@ -207,24 +227,39 @@ export default function SystemStatusCard({
- {boxOk ? ( + {boxState === 'ok' ? ( ) : ( - + )} - {boxOk + {boxState === 'ok' ? t('monitoring.connected') - : t('monitoring.disconnected')} + : boxState === 'disabled' + ? t('monitoring.disabled') + : t('monitoring.disconnected')}
- {boxStatus && !boxOk && boxStatus.connector_error && ( + {boxState === 'disabled' && ( +

+ {t('monitoring.boxDisabled')} +

+ )} + {boxState === 'failed' && boxStatus?.connector_error && (

{boxStatus.connector_error}

diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx index 6336ee84..696e2433 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -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} />
+ {!boxAvailable && }
{enableAllSkills ? (
@@ -559,6 +564,7 @@ export default function PipelineExtension({ variant="ghost" size="icon" onClick={() => handleRemoveSkill(skill.name)} + disabled={!boxAvailable} > @@ -572,7 +578,7 @@ export default function PipelineExtension({ onClick={handleOpenSkillDialog} variant="outline" className="w-full" - disabled={enableAllSkills} + disabled={enableAllSkills || !boxAvailable} > {t('pipelines.extensions.addSkill')} diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx index 2cd80c1c..beb442a9 100644 --- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx +++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx @@ -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(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 ( - - - {extractI18nObject(stage.label)} - {stage.description && ( - - {extractI18nObject(stage.description)} - - )} - - - )?.[stage.name] || {} - } - onSubmit={(values) => { - handleDynamicFormEmit(formName, stage.name, values); - }} - /> - - +
+ {showBoxNoticeForStage && } + + + {extractI18nObject(stage.label)} + {stage.description && ( + + {extractI18nObject(stage.description)} + + )} + + + )?.[stage.name] || + {} + } + onSubmit={(values) => { + handleDynamicFormEmit(formName, stage.name, values); + }} + /> + + +
); } diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx index c8c4d386..59c7c627 100644 --- a/web/src/app/home/skills/SkillDetailContent.tsx +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -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 }) {
- + {!boxAvailable && ( +
+ +
+ )} +
)}
- + {!boxAvailable && ( +
+ +
+ )} +
{ if (!detailId && !isCreateView) { @@ -54,11 +57,16 @@ export default function SkillsPage() { -
+ {!boxAvailable && ( +
+ +
+ )}
(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 }; +} diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 530218db..50e0f2ae 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -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', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 9034f461..318b9e7a 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -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', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index d9e5c974..44db911a 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -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: 'サンドボックス', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index 69ece0d3..8ea61770 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -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: 'Песочницы', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index 4e760e7c..48f924f2 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -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: 'แซนด์บ็อกซ์', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index b01010cb..19fba640 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -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', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index cb2b5ae2..203108df 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -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: '沙箱数', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 45175829..6a3f2223 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -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: '沙箱數',