From 2cddc7efad1a7ad6228f0af0891ef4814b4c68bc Mon Sep 17 00:00:00 2001 From: Junyan Qin Date: Wed, 20 May 2026 23:43:39 +0800 Subject: [PATCH] feat(web): surface the specific Box failure reason in unavailable banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Box is configured but the runtime reports its backend is dead (e.g. ``box.backend = nsjail`` but the binary is missing, or Docker daemon crashed), the backend now returns a structured ``connector_error`` like ``Configured sandbox backend "nsjail" is unavailable``. The previous notice only said "Box sandbox is unavailable" + a generic "enable Box" hint, hiding the actionable detail. - ``useBoxStatus``: derive ``reason`` from ``status.connector_error``. Only exposed for the failed-state (``hint === 'boxUnavailable'``), since the disabled-by-config message already carries its reason - ``BoxUnavailableNotice``: insert the reason as a small monospaced line between the state message and the action hint. The disabled variant is unchanged (operator chose the state) - Wire ``reason`` through every existing call site (Skills page + detail, PipelineExtension, both MCP forms). Old unused ``context`` prop dropped Net layout (3 lines, still compact): ⚠ Box sandbox is unavailable — sandbox tools, skill add/edit, ... Configured sandbox backend "nsjail" is unavailable This feature requires the Box runtime. Enable it in config ... Co-Authored-By: Claude Opus 4.7 (1M context) --- .../home/components/BoxUnavailableNotice.tsx | 17 +++++++++++------ .../home/mcp/components/mcp-form/MCPForm.tsx | 12 ++++++++++-- .../pipeline-extensions/PipelineExtension.tsx | 10 ++++++++-- .../mcp-server/mcp-form/MCPFormDialog.tsx | 12 ++++++++++-- web/src/app/home/skills/SkillDetailContent.tsx | 10 +++++++--- web/src/app/home/skills/page.tsx | 8 ++++++-- web/src/app/infra/hooks/useBoxStatus.ts | 10 +++++++++- 7 files changed, 61 insertions(+), 18 deletions(-) diff --git a/web/src/app/home/components/BoxUnavailableNotice.tsx b/web/src/app/home/components/BoxUnavailableNotice.tsx index 99eba045..5fe54a80 100644 --- a/web/src/app/home/components/BoxUnavailableNotice.tsx +++ b/web/src/app/home/components/BoxUnavailableNotice.tsx @@ -13,16 +13,18 @@ import { Alert, AlertDescription } from '@/components/ui/alert'; */ 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. */ + /** Specific failure reason from the backend (``connector_error``). Shown + * on a dedicated line so the user sees WHY (e.g. ``Configured sandbox + * backend "nsjail" is unavailable``) instead of just the generic + * "unavailable" wording. Ignored when ``hint === 'boxDisabled'`` + * because the disabled-by-config message already carries the reason. */ + reason?: string | null; className?: string; } export function BoxUnavailableNotice({ hint, - context, + reason, className, }: BoxUnavailableNoticeProps) { const { t } = useTranslation(); @@ -30,13 +32,16 @@ export function BoxUnavailableNotice({ const variant = hint === 'boxDisabled' ? 'default' : 'destructive'; const Icon = hint === 'boxDisabled' ? Info : ShieldAlert; + const showReason = hint === 'boxUnavailable' && reason; return (
{t(`monitoring.${hint}`)}
- {context &&
{context}
} + {showReason && ( +
{reason}
+ )}
{t('monitoring.boxRequiredHint')}
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx index 09b73b27..b78cc4d5 100644 --- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx +++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx @@ -421,7 +421,11 @@ const MCPForm = forwardRef(function MCPForm( ); const pollingIntervalRef = useRef(null); const watchMode = form.watch('mode'); - const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + const { + available: boxAvailable, + hint: boxHint, + reason: boxReason, + } = useBoxStatus(); // stdio mode requires the Box sandbox at runtime. If the user picks // stdio while Box is disabled / unreachable, the server would refuse // to start anyway — block creation upfront so they aren't surprised @@ -868,7 +872,11 @@ const MCPForm = forwardRef(function MCPForm( {stdioBlockedByBox && ( - + )} 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 696e2433..a9610179 100644 --- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx +++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx @@ -28,7 +28,11 @@ export default function PipelineExtension({ pipelineId: string; }) { const { t } = useTranslation(); - const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + const { + available: boxAvailable, + hint: boxHint, + reason: boxReason, + } = useBoxStatus(); const [loading, setLoading] = useState(true); const [enableAllPlugins, setEnableAllPlugins] = useState(true); const [enableAllMCPServers, setEnableAllMCPServers] = useState(true); @@ -526,7 +530,9 @@ export default function PipelineExtension({ /> - {!boxAvailable && } + {!boxAvailable && ( + + )}
{enableAllSkills ? (
diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index 746a2e22..1e9de9df 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -239,7 +239,11 @@ export default function MCPFormDialog({ const pollingIntervalRef = useRef(null); const watchMode = form.watch('mode'); - const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + const { + available: boxAvailable, + hint: boxHint, + reason: boxReason, + } = useBoxStatus(); // stdio mode requires the Box sandbox at runtime. Block creation here // so users aren't surprised by a connection failure on the detail page. const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable; @@ -676,7 +680,11 @@ export default function MCPFormDialog({ {stdioBlockedByBox && ( - + )} diff --git a/web/src/app/home/skills/SkillDetailContent.tsx b/web/src/app/home/skills/SkillDetailContent.tsx index 59c7c627..6828bf9e 100644 --- a/web/src/app/home/skills/SkillDetailContent.tsx +++ b/web/src/app/home/skills/SkillDetailContent.tsx @@ -32,7 +32,11 @@ 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(); + const { + available: boxAvailable, + hint: boxHint, + reason: boxReason, + } = useBoxStatus(); useEffect(() => { if (isCreateMode) { @@ -96,7 +100,7 @@ export default function SkillDetailContent({ id }: { id: string }) { {!boxAvailable && (
- +
)} @@ -176,7 +180,7 @@ export default function SkillDetailContent({ id }: { id: string }) { {!boxAvailable && (
- +
)} diff --git a/web/src/app/home/skills/page.tsx b/web/src/app/home/skills/page.tsx index 0b93b610..9d50040d 100644 --- a/web/src/app/home/skills/page.tsx +++ b/web/src/app/home/skills/page.tsx @@ -17,7 +17,11 @@ export default function SkillsPage() { const { refreshSkills } = useSidebarData(); const isCreateView = actionParam === 'create'; - const { available: boxAvailable, hint: boxHint } = useBoxStatus(); + const { + available: boxAvailable, + hint: boxHint, + reason: boxReason, + } = useBoxStatus(); useEffect(() => { if (!detailId && !isCreateView) { @@ -64,7 +68,7 @@ export default function SkillsPage() {
{!boxAvailable && (
- +
)}
diff --git a/web/src/app/infra/hooks/useBoxStatus.ts b/web/src/app/infra/hooks/useBoxStatus.ts index 7a28f34f..f999bb9f 100644 --- a/web/src/app/infra/hooks/useBoxStatus.ts +++ b/web/src/app/infra/hooks/useBoxStatus.ts @@ -51,6 +51,14 @@ export function useBoxStatus(refreshMs = 30_000) { : status ? 'boxUnavailable' : null; + // Specific reason from the backend (e.g. + // ``Configured sandbox backend "nsjail" is unavailable`` or + // ``docker daemon not running``). Surface this in the failed-state + // banner so the user sees WHY instead of a generic "unavailable". + // For the disabled-by-config case the boxDisabled i18n string already + // carries the reason, so we suppress the duplicate. + const reason = + hint === 'boxUnavailable' ? status?.connector_error?.trim() || null : null; - return { status, loading, available, disabled, hint, refresh }; + return { status, loading, available, disabled, hint, reason, refresh }; }