feat(web): surface the specific Box failure reason in unavailable banner

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) <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-05-20 23:43:39 +08:00
parent a2a9f426fa
commit 2cddc7efad
7 changed files with 61 additions and 18 deletions

View File

@@ -13,16 +13,18 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
*/ */
export interface BoxUnavailableNoticeProps { export interface BoxUnavailableNoticeProps {
hint: 'boxDisabled' | 'boxUnavailable' | null; hint: 'boxDisabled' | 'boxUnavailable' | null;
/** Optional extra context line (e.g. the specific consumer name). */ /** Specific failure reason from the backend (``connector_error``). Shown
context?: string; * on a dedicated line so the user sees WHY (e.g. ``Configured sandbox
/** When true, render as muted; default uses the destructive variant only * backend "nsjail" is unavailable``) instead of just the generic
* for failed (boxUnavailable) state so a deliberate disable looks calm. */ * "unavailable" wording. Ignored when ``hint === 'boxDisabled'``
* because the disabled-by-config message already carries the reason. */
reason?: string | null;
className?: string; className?: string;
} }
export function BoxUnavailableNotice({ export function BoxUnavailableNotice({
hint, hint,
context, reason,
className, className,
}: BoxUnavailableNoticeProps) { }: BoxUnavailableNoticeProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,13 +32,16 @@ export function BoxUnavailableNotice({
const variant = hint === 'boxDisabled' ? 'default' : 'destructive'; const variant = hint === 'boxDisabled' ? 'default' : 'destructive';
const Icon = hint === 'boxDisabled' ? Info : ShieldAlert; const Icon = hint === 'boxDisabled' ? Info : ShieldAlert;
const showReason = hint === 'boxUnavailable' && reason;
return ( return (
<Alert variant={variant} className={className}> <Alert variant={variant} className={className}>
<Icon className="h-4 w-4" /> <Icon className="h-4 w-4" />
<AlertDescription className="space-y-1"> <AlertDescription className="space-y-1">
<div>{t(`monitoring.${hint}`)}</div> <div>{t(`monitoring.${hint}`)}</div>
{context && <div className="text-xs opacity-80">{context}</div>} {showReason && (
<div className="text-xs font-mono opacity-80 break-all">{reason}</div>
)}
<div className="text-xs opacity-80"> <div className="text-xs opacity-80">
{t('monitoring.boxRequiredHint')} {t('monitoring.boxRequiredHint')}
</div> </div>

View File

@@ -421,7 +421,11 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
); );
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode'); 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 mode requires the Box sandbox at runtime. If the user picks
// stdio while Box is disabled / unreachable, the server would refuse // stdio while Box is disabled / unreachable, the server would refuse
// to start anyway — block creation upfront so they aren't surprised // to start anyway — block creation upfront so they aren't surprised
@@ -868,7 +872,11 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
</SelectContent> </SelectContent>
</Select> </Select>
{stdioBlockedByBox && ( {stdioBlockedByBox && (
<BoxUnavailableNotice hint={boxHint} className="mt-2" /> <BoxUnavailableNotice
hint={boxHint}
reason={boxReason}
className="mt-2"
/>
)} )}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -28,7 +28,11 @@ export default function PipelineExtension({
pipelineId: string; pipelineId: string;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const { available: boxAvailable, hint: boxHint } = useBoxStatus(); const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true); const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true); const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
@@ -526,7 +530,9 @@ export default function PipelineExtension({
/> />
</div> </div>
</div> </div>
{!boxAvailable && <BoxUnavailableNotice hint={boxHint} />} {!boxAvailable && (
<BoxUnavailableNotice hint={boxHint} reason={boxReason} />
)}
<div className="space-y-2"> <div className="space-y-2">
{enableAllSkills ? ( {enableAllSkills ? (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30"> <div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">

View File

@@ -239,7 +239,11 @@ export default function MCPFormDialog({
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null); const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode'); 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 // stdio mode requires the Box sandbox at runtime. Block creation here
// so users aren't surprised by a connection failure on the detail page. // so users aren't surprised by a connection failure on the detail page.
const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable; const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
@@ -676,7 +680,11 @@ export default function MCPFormDialog({
</SelectContent> </SelectContent>
</Select> </Select>
{stdioBlockedByBox && ( {stdioBlockedByBox && (
<BoxUnavailableNotice hint={boxHint} className="mt-2" /> <BoxUnavailableNotice
hint={boxHint}
reason={boxReason}
className="mt-2"
/>
)} )}
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -32,7 +32,11 @@ export default function SkillDetailContent({ id }: { id: string }) {
const { refreshSkills, skills, setDetailEntityName } = useSidebarData(); const { refreshSkills, skills, setDetailEntityName } = useSidebarData();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id); const skill = skills.find((item) => item.id === id);
const { available: boxAvailable, hint: boxHint } = useBoxStatus(); const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => { useEffect(() => {
if (isCreateMode) { if (isCreateMode) {
@@ -96,7 +100,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
{!boxAvailable && ( {!boxAvailable && (
<div className="pb-4 shrink-0"> <div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} /> <BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div> </div>
)} )}
@@ -176,7 +180,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
{!boxAvailable && ( {!boxAvailable && (
<div className="pb-4 shrink-0"> <div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} /> <BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div> </div>
)} )}

View File

@@ -17,7 +17,11 @@ export default function SkillsPage() {
const { refreshSkills } = useSidebarData(); const { refreshSkills } = useSidebarData();
const isCreateView = actionParam === 'create'; const isCreateView = actionParam === 'create';
const { available: boxAvailable, hint: boxHint } = useBoxStatus(); const {
available: boxAvailable,
hint: boxHint,
reason: boxReason,
} = useBoxStatus();
useEffect(() => { useEffect(() => {
if (!detailId && !isCreateView) { if (!detailId && !isCreateView) {
@@ -64,7 +68,7 @@ export default function SkillsPage() {
</div> </div>
{!boxAvailable && ( {!boxAvailable && (
<div className="pb-4 shrink-0"> <div className="pb-4 shrink-0">
<BoxUnavailableNotice hint={boxHint} /> <BoxUnavailableNotice hint={boxHint} reason={boxReason} />
</div> </div>
)} )}
<div className="min-h-0 flex-1"> <div className="min-h-0 flex-1">

View File

@@ -51,6 +51,14 @@ export function useBoxStatus(refreshMs = 30_000) {
: status : status
? 'boxUnavailable' ? 'boxUnavailable'
: null; : 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 };
} }