mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-12 00:36:03 +00:00
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:
48
web/src/app/home/components/BoxUnavailableNotice.tsx
Normal file
48
web/src/app/home/components/BoxUnavailableNotice.tsx
Normal file
@@ -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 (
|
||||
<Alert variant={variant} className={className}>
|
||||
<Icon className="h-4 w-4" />
|
||||
<AlertDescription className="space-y-1">
|
||||
<div>{t(`monitoring.${hint}`)}</div>
|
||||
{context && <div className="text-xs opacity-80">{context}</div>}
|
||||
<div className="text-xs opacity-80">
|
||||
{t('monitoring.boxRequiredHint')}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoxUnavailableNotice;
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!boxAvailable && <BoxUnavailableNotice hint={boxHint} />}
|
||||
<div className="space-y-2">
|
||||
{enableAllSkills ? (
|
||||
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border bg-muted/30">
|
||||
@@ -559,6 +564,7 @@ export default function PipelineExtension({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveSkill(skill.name)}
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
@@ -572,7 +578,7 @@ export default function PipelineExtension({
|
||||
onClick={handleOpenSkillDialog}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
disabled={enableAllSkills}
|
||||
disabled={enableAllSkills || !boxAvailable}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
{t('pipelines.extensions.addSkill')}
|
||||
|
||||
@@ -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<boolean>(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 (
|
||||
<Card key={stage.name}>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div key={stage.name} className="space-y-3">
|
||||
{showBoxNoticeForStage && <BoxUnavailableNotice hint={boxHint} />}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||
{stage.description && (
|
||||
<CardDescription>
|
||||
{extractI18nObject(stage.description)}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<DynamicFormComponent
|
||||
itemConfigList={stage.config}
|
||||
initialValues={
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }) {
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" form="skill-form" className="shrink-0">
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
@@ -150,11 +164,22 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button type="submit" form="skill-form" className="shrink-0">
|
||||
<Button
|
||||
type="submit"
|
||||
form="skill-form"
|
||||
className="shrink-0"
|
||||
disabled={!boxAvailable}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key={id}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
|
||||
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
|
||||
import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
|
||||
|
||||
export default function SkillsPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -15,6 +17,7 @@ export default function SkillsPage() {
|
||||
const { refreshSkills } = useSidebarData();
|
||||
|
||||
const isCreateView = actionParam === 'create';
|
||||
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailId && !isCreateView) {
|
||||
@@ -54,11 +57,16 @@ export default function SkillsPage() {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" form="skill-form">
|
||||
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{!boxAvailable && (
|
||||
<div className="pb-4 shrink-0">
|
||||
<BoxUnavailableNotice hint={boxHint} />
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 flex-1">
|
||||
<SkillForm
|
||||
key="new-skill"
|
||||
|
||||
@@ -362,6 +362,9 @@ export interface ApiRespPluginSystemStatus {
|
||||
|
||||
export interface ApiRespBoxStatus {
|
||||
available: boolean;
|
||||
/** Whether ``box.enabled`` is true in config. When false, the sandbox
|
||||
* is deliberately disabled — distinct from "configured but failed". */
|
||||
enabled?: boolean;
|
||||
profile: string;
|
||||
recent_error_count: number;
|
||||
connector_error?: string;
|
||||
|
||||
56
web/src/app/infra/hooks/useBoxStatus.ts
Normal file
56
web/src/app/infra/hooks/useBoxStatus.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import type { ApiRespBoxStatus } from '@/app/infra/entities/api';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
|
||||
/**
|
||||
* Shared hook for Box runtime status — used by every UI surface that needs
|
||||
* to gate behaviour on whether the sandbox is available. Returns:
|
||||
*
|
||||
* - status: full payload (or null while loading / on error)
|
||||
* - available: convenience flag (status?.available === true)
|
||||
* - disabled: true iff Box is explicitly disabled by config
|
||||
* (status.enabled === false), distinguishing it from
|
||||
* "configured but currently failed"
|
||||
* - hint: a single i18n-key choice for the banner message —
|
||||
* 'boxDisabled' / 'boxUnavailable' / null
|
||||
* - refresh: imperative re-fetch
|
||||
*
|
||||
* Polls every ``refreshMs`` (default 30s) so a flapping runtime is picked
|
||||
* up without a page reload.
|
||||
*/
|
||||
export function useBoxStatus(refreshMs = 30_000) {
|
||||
const [status, setStatus] = useState<ApiRespBoxStatus | null>(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 };
|
||||
}
|
||||
Reference in New Issue
Block a user