mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user