mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-13 06:00:58 +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:
@@ -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';
|
} from '@/components/ui/tooltip';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
|
||||||
function StatusDot({ ok }: { ok: boolean | null }) {
|
type StatusState = 'ok' | 'disabled' | 'failed' | null;
|
||||||
if (ok === null)
|
|
||||||
|
function StatusDot({ state }: { state: StatusState }) {
|
||||||
|
if (state === null)
|
||||||
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
|
return <span className="w-2 h-2 rounded-full bg-muted-foreground/40" />;
|
||||||
return ok ? (
|
if (state === 'ok')
|
||||||
<span className="w-2 h-2 rounded-full bg-green-500" />
|
return <span className="w-2 h-2 rounded-full bg-green-500" />;
|
||||||
) : (
|
if (state === 'disabled')
|
||||||
<span className="w-2 h-2 rounded-full bg-red-500" />
|
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 {
|
interface SystemStatusCardProps {
|
||||||
@@ -86,7 +88,25 @@ export default function SystemStatusCard({
|
|||||||
const pluginOk = pluginStatus
|
const pluginOk = pluginStatus
|
||||||
? pluginStatus.is_enable && pluginStatus.is_connected
|
? pluginStatus.is_enable && pluginStatus.is_connected
|
||||||
: null;
|
: null;
|
||||||
|
const pluginState: StatusState = pluginStatus
|
||||||
|
? pluginStatus.is_enable && pluginStatus.is_connected
|
||||||
|
? 'ok'
|
||||||
|
: !pluginStatus.is_enable
|
||||||
|
? 'disabled'
|
||||||
|
: 'failed'
|
||||||
|
: null;
|
||||||
const boxOk = boxStatus ? boxStatus.available : 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) => {
|
const handleOpenDialog = (e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -129,12 +149,12 @@ export default function SystemStatusCard({
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center gap-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" />
|
<Plug className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm">{t('monitoring.pluginRuntime')}</span>
|
<span className="text-sm">{t('monitoring.pluginRuntime')}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<StatusDot ok={boxOk} />
|
<StatusDot state={boxState} />
|
||||||
<Box className="w-3.5 h-3.5 text-muted-foreground" />
|
<Box className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
<span className="text-sm">{t('monitoring.boxRuntime')}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -207,24 +227,39 @@ export default function SystemStatusCard({
|
|||||||
</div>
|
</div>
|
||||||
<div className="ml-6 text-sm space-y-1">
|
<div className="ml-6 text-sm space-y-1">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{boxOk ? (
|
{boxState === 'ok' ? (
|
||||||
<CircleCheck className="w-4 h-4 text-green-600" />
|
<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
|
<span
|
||||||
className={
|
className={
|
||||||
boxOk
|
boxState === 'ok'
|
||||||
? 'text-green-600 font-medium'
|
? '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.connected')
|
||||||
: t('monitoring.disconnected')}
|
: boxState === 'disabled'
|
||||||
|
? t('monitoring.disabled')
|
||||||
|
: t('monitoring.disconnected')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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">
|
<p className="text-red-400 text-xs break-all">
|
||||||
{boxStatus.connector_error}
|
{boxStatus.connector_error}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { Label } from '@/components/ui/label';
|
|||||||
import { Plugin } from '@/app/infra/entities/plugin';
|
import { Plugin } from '@/app/infra/entities/plugin';
|
||||||
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
import { MCPServer, Skill } from '@/app/infra/entities/api';
|
||||||
import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
|
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({
|
export default function PipelineExtension({
|
||||||
pipelineId,
|
pipelineId,
|
||||||
@@ -26,6 +28,7 @@ export default function PipelineExtension({
|
|||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { available: boxAvailable, hint: boxHint } = 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);
|
||||||
@@ -519,9 +522,11 @@ export default function PipelineExtension({
|
|||||||
id="enable-all-skills"
|
id="enable-all-skills"
|
||||||
checked={enableAllSkills}
|
checked={enableAllSkills}
|
||||||
onCheckedChange={handleToggleEnableAllSkills}
|
onCheckedChange={handleToggleEnableAllSkills}
|
||||||
|
disabled={!boxAvailable}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!boxAvailable && <BoxUnavailableNotice hint={boxHint} />}
|
||||||
<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">
|
||||||
@@ -559,6 +564,7 @@ export default function PipelineExtension({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
onClick={() => handleRemoveSkill(skill.name)}
|
onClick={() => handleRemoveSkill(skill.name)}
|
||||||
|
disabled={!boxAvailable}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -572,7 +578,7 @@ export default function PipelineExtension({
|
|||||||
onClick={handleOpenSkillDialog}
|
onClick={handleOpenSkillDialog}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="w-full"
|
className="w-full"
|
||||||
disabled={enableAllSkills}
|
disabled={enableAllSkills || !boxAvailable}
|
||||||
>
|
>
|
||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
{t('pipelines.extensions.addSkill')}
|
{t('pipelines.extensions.addSkill')}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
} from '@/app/infra/entities/pipeline';
|
} from '@/app/infra/entities/pipeline';
|
||||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||||
import N8nAuthFormComponent from '@/app/home/components/dynamic-form/N8nAuthFormComponent';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -75,6 +77,7 @@ export default function PipelineFormComponent({
|
|||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||||
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
|
||||||
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
const [isDefaultPipeline, setIsDefaultPipeline] = useState<boolean>(false);
|
||||||
|
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
|
||||||
|
|
||||||
const formSchema = isEditMode
|
const formSchema = isEditMode
|
||||||
? z.object({
|
? 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 (
|
return (
|
||||||
<Card key={stage.name}>
|
<div key={stage.name} className="space-y-3">
|
||||||
<CardHeader>
|
{showBoxNoticeForStage && <BoxUnavailableNotice hint={boxHint} />}
|
||||||
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
<Card>
|
||||||
{stage.description && (
|
<CardHeader>
|
||||||
<CardDescription>
|
<CardTitle>{extractI18nObject(stage.label)}</CardTitle>
|
||||||
{extractI18nObject(stage.description)}
|
{stage.description && (
|
||||||
</CardDescription>
|
<CardDescription>
|
||||||
)}
|
{extractI18nObject(stage.description)}
|
||||||
</CardHeader>
|
</CardDescription>
|
||||||
<CardContent className="space-y-6">
|
)}
|
||||||
<DynamicFormComponent
|
</CardHeader>
|
||||||
itemConfigList={stage.config}
|
<CardContent className="space-y-6">
|
||||||
initialValues={
|
<DynamicFormComponent
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
itemConfigList={stage.config}
|
||||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
initialValues={
|
||||||
}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
onSubmit={(values) => {
|
(form.watch(formName) as Record<string, any>)?.[stage.name] ||
|
||||||
handleDynamicFormEmit(formName, stage.name, values);
|
{}
|
||||||
}}
|
}
|
||||||
/>
|
onSubmit={(values) => {
|
||||||
</CardContent>
|
handleDynamicFormEmit(formName, stage.name, values);
|
||||||
</Card>
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
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';
|
import { Sparkles, Trash2 } from 'lucide-react';
|
||||||
|
|
||||||
export default function SkillDetailContent({ id }: { id: string }) {
|
export default function SkillDetailContent({ id }: { id: string }) {
|
||||||
@@ -30,6 +32,7 @@ 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();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isCreateMode) {
|
if (isCreateMode) {
|
||||||
@@ -81,11 +84,22 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</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')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!boxAvailable && (
|
||||||
|
<div className="pb-4 shrink-0">
|
||||||
|
<BoxUnavailableNotice hint={boxHint} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<SkillForm
|
<SkillForm
|
||||||
key="new-skill"
|
key="new-skill"
|
||||||
@@ -150,11 +164,22 @@ export default function SkillDetailContent({ id }: { id: string }) {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</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')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!boxAvailable && (
|
||||||
|
<div className="pb-4 shrink-0">
|
||||||
|
<BoxUnavailableNotice hint={boxHint} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<SkillForm
|
<SkillForm
|
||||||
key={id}
|
key={id}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/button';
|
|||||||
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
import SkillDetailContent from '@/app/home/skills/SkillDetailContent';
|
||||||
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
import SkillForm from '@/app/home/skills/components/skill-form/SkillForm';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
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() {
|
export default function SkillsPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -15,6 +17,7 @@ export default function SkillsPage() {
|
|||||||
const { refreshSkills } = useSidebarData();
|
const { refreshSkills } = useSidebarData();
|
||||||
|
|
||||||
const isCreateView = actionParam === 'create';
|
const isCreateView = actionParam === 'create';
|
||||||
|
const { available: boxAvailable, hint: boxHint } = useBoxStatus();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailId && !isCreateView) {
|
if (!detailId && !isCreateView) {
|
||||||
@@ -54,11 +57,16 @@ export default function SkillsPage() {
|
|||||||
<Button variant="outline" onClick={handleCancel}>
|
<Button variant="outline" onClick={handleCancel}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" form="skill-form">
|
<Button type="submit" form="skill-form" disabled={!boxAvailable}>
|
||||||
{t('common.save')}
|
{t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!boxAvailable && (
|
||||||
|
<div className="pb-4 shrink-0">
|
||||||
|
<BoxUnavailableNotice hint={boxHint} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="min-h-0 flex-1">
|
<div className="min-h-0 flex-1">
|
||||||
<SkillForm
|
<SkillForm
|
||||||
key="new-skill"
|
key="new-skill"
|
||||||
|
|||||||
@@ -362,6 +362,9 @@ export interface ApiRespPluginSystemStatus {
|
|||||||
|
|
||||||
export interface ApiRespBoxStatus {
|
export interface ApiRespBoxStatus {
|
||||||
available: boolean;
|
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;
|
profile: string;
|
||||||
recent_error_count: number;
|
recent_error_count: number;
|
||||||
connector_error?: string;
|
connector_error?: string;
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -1303,6 +1303,12 @@ const enUS = {
|
|||||||
disabled: 'Disabled',
|
disabled: 'Disabled',
|
||||||
statusDetail: 'Status',
|
statusDetail: 'Status',
|
||||||
pluginDisabled: 'Plugin system is disabled',
|
pluginDisabled: 'Plugin system is disabled',
|
||||||
|
boxDisabled:
|
||||||
|
'Box sandbox is disabled in config — sandbox tools, skill add/edit, and stdio MCP are unavailable',
|
||||||
|
boxUnavailable:
|
||||||
|
'Box sandbox is unavailable — sandbox tools, skill add/edit, and stdio MCP are unavailable',
|
||||||
|
boxRequiredHint:
|
||||||
|
'This feature requires the Box runtime. Enable it in config (box.enabled = true) and ensure the runtime is healthy.',
|
||||||
boxBackend: 'Backend',
|
boxBackend: 'Backend',
|
||||||
boxProfile: 'Profile',
|
boxProfile: 'Profile',
|
||||||
boxSandboxes: 'Sandboxes',
|
boxSandboxes: 'Sandboxes',
|
||||||
|
|||||||
@@ -1336,6 +1336,12 @@ const esES = {
|
|||||||
disabled: 'Desactivado',
|
disabled: 'Desactivado',
|
||||||
statusDetail: 'Estado',
|
statusDetail: 'Estado',
|
||||||
pluginDisabled: 'El sistema de plugins está desactivado',
|
pluginDisabled: 'El sistema de plugins está desactivado',
|
||||||
|
boxDisabled:
|
||||||
|
'El sandbox de Box está desactivado en la configuración — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
|
||||||
|
boxUnavailable:
|
||||||
|
'El sandbox de Box no está disponible — herramientas de sandbox, alta/edición de skills y MCP stdio no están disponibles',
|
||||||
|
boxRequiredHint:
|
||||||
|
'Esta función requiere el runtime de Box. Actívelo en la configuración (box.enabled = true) y asegúrese de que el runtime está conectado.',
|
||||||
boxBackend: 'Backend',
|
boxBackend: 'Backend',
|
||||||
boxProfile: 'Perfil',
|
boxProfile: 'Perfil',
|
||||||
boxSandboxes: 'Sandboxes',
|
boxSandboxes: 'Sandboxes',
|
||||||
|
|||||||
@@ -1308,6 +1308,12 @@ const jaJP = {
|
|||||||
disabled: '無効',
|
disabled: '無効',
|
||||||
statusDetail: 'ステータス',
|
statusDetail: 'ステータス',
|
||||||
pluginDisabled: 'プラグインシステムが無効です',
|
pluginDisabled: 'プラグインシステムが無効です',
|
||||||
|
boxDisabled:
|
||||||
|
'Box サンドボックスは設定で無効化されています — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
||||||
|
boxUnavailable:
|
||||||
|
'Box サンドボックスは利用できません — サンドボックスツール / スキルの追加・編集 / stdio MCP は利用できません',
|
||||||
|
boxRequiredHint:
|
||||||
|
'この機能には Box ランタイムが必要です。設定で有効化(box.enabled = true)し、ランタイムが正常に接続できることを確認してください。',
|
||||||
boxBackend: 'バックエンド',
|
boxBackend: 'バックエンド',
|
||||||
boxProfile: 'プロファイル',
|
boxProfile: 'プロファイル',
|
||||||
boxSandboxes: 'サンドボックス',
|
boxSandboxes: 'サンドボックス',
|
||||||
|
|||||||
@@ -1311,6 +1311,12 @@ const ruRU = {
|
|||||||
disabled: 'Отключено',
|
disabled: 'Отключено',
|
||||||
statusDetail: 'Статус',
|
statusDetail: 'Статус',
|
||||||
pluginDisabled: 'Система плагинов отключена',
|
pluginDisabled: 'Система плагинов отключена',
|
||||||
|
boxDisabled:
|
||||||
|
'Песочница Box отключена в конфигурации — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
||||||
|
boxUnavailable:
|
||||||
|
'Песочница Box недоступна — инструменты песочницы, добавление/редактирование навыков и stdio MCP недоступны',
|
||||||
|
boxRequiredHint:
|
||||||
|
'Для этой функции требуется среда Box. Включите её в конфигурации (box.enabled = true) и убедитесь, что соединение работает.',
|
||||||
boxBackend: 'Бэкенд',
|
boxBackend: 'Бэкенд',
|
||||||
boxProfile: 'Профиль',
|
boxProfile: 'Профиль',
|
||||||
boxSandboxes: 'Песочницы',
|
boxSandboxes: 'Песочницы',
|
||||||
|
|||||||
@@ -1282,6 +1282,12 @@ const thTH = {
|
|||||||
disabled: 'ปิดใช้งาน',
|
disabled: 'ปิดใช้งาน',
|
||||||
statusDetail: 'สถานะ',
|
statusDetail: 'สถานะ',
|
||||||
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
|
pluginDisabled: 'ระบบปลั๊กอินถูกปิดใช้งาน',
|
||||||
|
boxDisabled:
|
||||||
|
'Sandbox Box ถูกปิดใช้งานในการตั้งค่า — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
||||||
|
boxUnavailable:
|
||||||
|
'Sandbox Box ไม่พร้อมใช้งาน — เครื่องมือ sandbox, การเพิ่ม/แก้ไข skill และ stdio MCP ใช้งานไม่ได้',
|
||||||
|
boxRequiredHint:
|
||||||
|
'ฟีเจอร์นี้ต้องใช้ Box runtime กรุณาเปิดใช้งานในการตั้งค่า (box.enabled = true) และตรวจสอบว่าการเชื่อมต่อปกติ',
|
||||||
boxBackend: 'แบ็กเอนด์',
|
boxBackend: 'แบ็กเอนด์',
|
||||||
boxProfile: 'โปรไฟล์',
|
boxProfile: 'โปรไฟล์',
|
||||||
boxSandboxes: 'แซนด์บ็อกซ์',
|
boxSandboxes: 'แซนด์บ็อกซ์',
|
||||||
|
|||||||
@@ -1305,6 +1305,12 @@ const viVN = {
|
|||||||
disabled: 'Đã tắt',
|
disabled: 'Đã tắt',
|
||||||
statusDetail: 'Trạng thái',
|
statusDetail: 'Trạng thái',
|
||||||
pluginDisabled: 'Hệ thống plugin đã tắt',
|
pluginDisabled: 'Hệ thống plugin đã tắt',
|
||||||
|
boxDisabled:
|
||||||
|
'Sandbox Box đã tắt trong cấu hình — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
|
||||||
|
boxUnavailable:
|
||||||
|
'Sandbox Box không khả dụng — công cụ sandbox, thêm/chỉnh sửa skill và stdio MCP đều không khả dụng',
|
||||||
|
boxRequiredHint:
|
||||||
|
'Tính năng này cần Box runtime. Hãy bật trong cấu hình (box.enabled = true) và đảm bảo runtime đang hoạt động.',
|
||||||
boxBackend: 'Backend',
|
boxBackend: 'Backend',
|
||||||
boxProfile: 'Hồ sơ',
|
boxProfile: 'Hồ sơ',
|
||||||
boxSandboxes: 'Sandbox',
|
boxSandboxes: 'Sandbox',
|
||||||
|
|||||||
@@ -1249,6 +1249,12 @@ const zhHans = {
|
|||||||
disabled: '已禁用',
|
disabled: '已禁用',
|
||||||
statusDetail: '状态',
|
statusDetail: '状态',
|
||||||
pluginDisabled: '插件系统已禁用',
|
pluginDisabled: '插件系统已禁用',
|
||||||
|
boxDisabled:
|
||||||
|
'Box 沙箱已在配置中禁用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
||||||
|
boxUnavailable:
|
||||||
|
'Box 沙箱不可用——沙箱工具、技能添加/编辑与 stdio MCP 均不可用',
|
||||||
|
boxRequiredHint:
|
||||||
|
'此功能依赖 Box 运行时。请在配置中启用(box.enabled = true)并确认运行时连接正常。',
|
||||||
boxBackend: '后端',
|
boxBackend: '后端',
|
||||||
boxProfile: '配置',
|
boxProfile: '配置',
|
||||||
boxSandboxes: '沙箱数',
|
boxSandboxes: '沙箱数',
|
||||||
|
|||||||
@@ -1248,6 +1248,12 @@ const zhHant = {
|
|||||||
disabled: '已停用',
|
disabled: '已停用',
|
||||||
statusDetail: '狀態',
|
statusDetail: '狀態',
|
||||||
pluginDisabled: '外掛系統已停用',
|
pluginDisabled: '外掛系統已停用',
|
||||||
|
boxDisabled:
|
||||||
|
'Box 沙箱已在設定中停用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
||||||
|
boxUnavailable:
|
||||||
|
'Box 沙箱無法使用——沙箱工具、技能新增/編輯與 stdio MCP 均無法使用',
|
||||||
|
boxRequiredHint:
|
||||||
|
'此功能需要 Box 執行時。請在設定中啟用(box.enabled = true)並確認執行時連線正常。',
|
||||||
boxBackend: '後端',
|
boxBackend: '後端',
|
||||||
boxProfile: '設定檔',
|
boxProfile: '設定檔',
|
||||||
boxSandboxes: '沙箱數',
|
boxSandboxes: '沙箱數',
|
||||||
|
|||||||
Reference in New Issue
Block a user