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
@@ -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>