-
{t('mcp.editServer')}
- {enableLoaded && (
-
-
+
+
+
{displayName}
+
+
+ {t('mcp.title')}
+
+
+
-
- {t('common.enable')}
-
-
- )}
+ {connectionStatusLabel[currentConnectionState]}
+
+
-
+
{t('common.test')}
-
+
{t('common.save')}
- {/* Content */}
-
-
-
-
- {/* Card: Danger Zone */}
-
-
-
- {t('mcp.dangerZone')}
-
-
- {t('mcp.dangerZoneDescription')}
-
-
-
-
-
-
- {t('mcp.deleteMCPAction')}
-
-
- {t('mcp.deleteMCPHint')}
-
-
-
setShowDeleteConfirm(true)}
- >
-
- {t('common.delete')}
-
-
-
-
-
+
+
+ setDetailRuntimeStatus(runtimeInfo?.status ?? null)
+ }
+ />
diff --git a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
index f2732126e..b78cc4d50 100644
--- a/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
+++ b/web/src/app/home/mcp/components/mcp-form/MCPForm.tsx
@@ -1,4 +1,5 @@
import React, {
+ type ReactNode,
useState,
useEffect,
useRef,
@@ -6,17 +7,12 @@ import React, {
useImperativeHandle,
} from 'react';
import { useTranslation } from 'react-i18next';
+import type { TFunction } from 'i18next';
+import { Braces, Loader2, Trash2, Wrench, XCircle } from 'lucide-react';
import { Resolver, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { toast } from 'sonner';
-import {
- Card,
- CardContent,
- CardHeader,
- CardTitle,
- CardDescription,
-} from '@/components/ui/card';
import {
Form,
FormControl,
@@ -35,6 +31,14 @@ import {
} from '@/components/ui/select';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
+import { Badge } from '@/components/ui/badge';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
import { httpClient } from '@/app/infra/http/HttpClient';
import {
MCPServerRuntimeInfo,
@@ -46,8 +50,9 @@ import {
MCPServerExtraArgsStdio,
} from '@/app/infra/entities/api';
import { CustomApiError } from '@/app/infra/entities/common';
+import { BoxUnavailableNotice } from '@/app/home/components/BoxUnavailableNotice';
+import { useBoxStatus } from '@/app/infra/hooks/useBoxStatus';
-// Status display for test / connecting / error states
function StatusDisplay({
testing,
runtimeInfo,
@@ -55,31 +60,12 @@ function StatusDisplay({
}: {
testing: boolean;
runtimeInfo: MCPServerRuntimeInfo;
- t: (key: string) => string;
+ t: TFunction;
}) {
if (testing) {
return (
-
-
-
-
+
{t('mcp.testing')}
);
@@ -88,52 +74,46 @@ function StatusDisplay({
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
return (
-
-
-
-
+
{t('mcp.connecting')}
);
}
+ // Stdio MCP refused because Box is disabled / unreachable. The backend
+ // marks the phase so we can show a localized, actionable message instead
+ // of the raw "box_disabled_in_config" / "box_unavailable" marker.
+ if (runtimeInfo.error_phase === 'box_unavailable') {
+ const isDisabledByConfig =
+ runtimeInfo.error_message === 'box_disabled_in_config';
+ return (
+
+
+
+ {t('mcp.connectionFailed')}
+
+
+
+ {isDisabledByConfig
+ ? t('mcp.boxDisabledStdioRefused')
+ : t('mcp.boxUnavailableStdioRefused')}
+
+
+ {t('mcp.boxStdioRefusedSuggestion')}
+
+
+
+ );
+ }
+
return (
-
-
-
+
{t('mcp.connectionFailed')}
{runtimeInfo.error_message && (
-
+
{runtimeInfo.error_message}
)}
@@ -141,27 +121,181 @@ function StatusDisplay({
);
}
-// Tools list component
-function ToolsList({ tools }: { tools: MCPTool[] }) {
+type ToolParameter = {
+ name: string;
+ type?: string;
+ description?: string;
+ required?: boolean;
+};
+
+function getToolParameters(parameters?: object): ToolParameter[] {
+ if (!parameters || typeof parameters !== 'object') return [];
+
+ const schema = parameters as {
+ properties?: Record<
+ string,
+ { type?: string; description?: string; title?: string }
+ >;
+ required?: string[];
+ };
+
+ if (schema.properties && typeof schema.properties === 'object') {
+ const required = new Set(schema.required ?? []);
+ return Object.entries(schema.properties).map(([name, parameter]) => ({
+ name,
+ type: parameter?.type,
+ description: parameter?.description || parameter?.title,
+ required: required.has(name),
+ }));
+ }
+
+ return Object.keys(parameters).map((name) => ({ name }));
+}
+
+function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
return (
-
- {tools.map((tool, index) => (
-
-
- {tool.name}
- {tool.description && (
-
- {tool.description}
-
- )}
-
-
- ))}
+
+ {tools.map((tool, index) => {
+ const parameters = getToolParameters(tool.parameters);
+ const visibleParameters = parameters.slice(0, 4);
+ const hiddenParameterCount =
+ parameters.length - visibleParameters.length;
+
+ return (
+
+
+
+
+
+
+
+
+
+ {tool.name}
+
+
+ #{index + 1}
+
+
+
+ {tool.description || t('market.noDescription')}
+
+
+
+
+
+
+
+ {t('mcp.parameterCount', {
+ count: parameters.length,
+ })}
+
+
+
+ {visibleParameters.length > 0 ? (
+
+ {visibleParameters.map((parameter) => (
+
+
+ {parameter.name}
+
+ {parameter.type && (
+
+ {parameter.type}
+
+ )}
+ {parameter.required && (
+ *
+ )}
+
+ ))}
+ {hiddenParameterCount > 0 && (
+
+ +{hiddenParameterCount}
+
+ )}
+
+ ) : (
+
+ {t('mcp.noParameters')}
+
+ )}
+
+
+
+
+ );
+ })}
);
}
-const getFormSchema = (t: (key: string) => string) =>
+function RuntimePanel({
+ isEditMode,
+ mcpTesting,
+ runtimeInfo,
+ t,
+}: {
+ isEditMode: boolean;
+ mcpTesting: boolean;
+ runtimeInfo: MCPServerRuntimeInfo | null;
+ t: TFunction;
+}) {
+ if (!isEditMode || !runtimeInfo) {
+ return (
+
+ {t('mcp.noToolsFound')}
+
+ );
+ }
+
+ const isConnected =
+ !mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
+ const tools = runtimeInfo.tools || [];
+
+ return (
+
+
+
+
{t('mcp.title')}
+
+ {isConnected
+ ? t('mcp.toolCount', { count: tools.length })
+ : t('mcp.connectionFailedStatus')}
+
+
+ {isConnected && (
+
+ {t('mcp.toolCount', { count: tools.length })}
+
+ )}
+
+
+ {!isConnected && (
+
+
+
+ )}
+
+ {isConnected && tools.length > 0 && }
+
+ {isConnected && tools.length === 0 && (
+
+ {t('mcp.noToolsFound')}
+
+ )}
+
+ );
+}
+
+const getFormSchema = (t: TFunction) =>
z
.object({
name: z
@@ -214,15 +348,26 @@ type FormValues = z.infer
> & {
ssereadtimeout: number;
};
+export type MCPFormDraft = Partial;
+
interface MCPFormProps {
initServerName?: string;
+ initialDraft?: MCPFormDraft;
onFormSubmit: () => void;
onNewServerCreated: (serverName: string) => void;
+ onDraftChange?: (draft: MCPFormDraft) => void;
onDirtyChange?: (dirty: boolean) => void;
onTestingChange?: (testing: boolean) => void;
+ onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
+ /** Reported when the form cannot be saved because the current mode is
+ * ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
+ * render the Save button outside this component should disable it. */
+ onSaveBlockedChange?: (blocked: boolean) => void;
+ layout?: 'stacked' | 'split';
+ sideHeader?: ReactNode;
+ sideFooter?: ReactNode;
}
-// Handle exposed to parent via ref
export interface MCPFormHandle {
testMcp: () => void;
isTesting: boolean;
@@ -231,16 +376,24 @@ export interface MCPFormHandle {
const MCPForm = forwardRef(function MCPForm(
{
initServerName,
+ initialDraft,
onFormSubmit,
onNewServerCreated,
+ onDraftChange,
onDirtyChange,
onTestingChange,
+ onRuntimeInfoChange,
+ onSaveBlockedChange,
+ layout = 'stacked',
+ sideHeader,
+ sideFooter,
},
ref,
) {
const { t } = useTranslation();
const formSchema = getFormSchema(t);
const isEditMode = !!initServerName;
+ const initialDraftRef = useRef(initialDraft);
const form = useForm({
resolver: zodResolver(formSchema) as unknown as Resolver,
@@ -253,12 +406,11 @@ const MCPForm = forwardRef(function MCPForm(
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
+ ...initialDraftRef.current,
},
});
- // Track whether initial data loading is complete (to avoid marking form dirty)
const isInitializing = useRef(true);
-
const [extraArgs, setExtraArgs] = useState<
{ key: string; type: 'string' | 'number' | 'boolean'; value: string }[]
>([]);
@@ -268,21 +420,35 @@ const MCPForm = forwardRef(function MCPForm(
null,
);
const pollingIntervalRef = useRef(null);
-
const watchMode = form.watch('mode');
+ const {
+ available: boxAvailable,
+ hint: boxHint,
+ reason: boxReason,
+ } = useBoxStatus();
+ // stdio mode requires the Box sandbox at runtime. If the user picks
+ // stdio while Box is disabled / unreachable, the server would refuse
+ // to start anyway — block creation upfront so they aren't surprised
+ // by an immediate "Connection failed" on the detail page.
+ const stdioBlockedByBox = watchMode === 'stdio' && !boxAvailable;
- // Notify parent when dirty state changes
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
}, [isDirty, onDirtyChange]);
- // Notify parent when testing state changes
+ useEffect(() => {
+ onSaveBlockedChange?.(stdioBlockedByBox);
+ }, [stdioBlockedByBox, onSaveBlockedChange]);
+
useEffect(() => {
onTestingChange?.(mcpTesting);
}, [mcpTesting, onTestingChange]);
- // Expose test action and testing state to parent
+ useEffect(() => {
+ onRuntimeInfoChange?.(runtimeInfo);
+ }, [onRuntimeInfoChange, runtimeInfo]);
+
useImperativeHandle(
ref,
() => ({
@@ -292,7 +458,6 @@ const MCPForm = forwardRef(function MCPForm(
[mcpTesting],
);
- // Load server data
useEffect(() => {
isInitializing.current = true;
if (isEditMode && initServerName) {
@@ -309,9 +474,10 @@ const MCPForm = forwardRef(function MCPForm(
timeout: 30,
ssereadtimeout: 300,
extra_args: [],
+ ...initialDraftRef.current,
});
- setExtraArgs([]);
- setStdioArgs([]);
+ setExtraArgs(initialDraftRef.current?.extra_args ?? []);
+ setStdioArgs(initialDraftRef.current?.args ?? []);
setRuntimeInfo(null);
isInitializing.current = false;
}
@@ -324,7 +490,20 @@ const MCPForm = forwardRef(function MCPForm(
};
}, [initServerName]);
- // Poll for updates when runtime_info status is CONNECTING
+ useEffect(() => {
+ if (!onDraftChange || isEditMode) return;
+
+ const subscription = form.watch((values) => {
+ onDraftChange({
+ ...values,
+ extra_args: extraArgs,
+ args: stdioArgs,
+ } as MCPFormDraft);
+ });
+
+ return () => subscription.unsubscribe();
+ }, [form, isEditMode, onDraftChange, extraArgs, stdioArgs]);
+
useEffect(() => {
if (
!isEditMode ||
@@ -359,7 +538,7 @@ const MCPForm = forwardRef(function MCPForm(
const server = resp.server ?? resp;
const formValues: FormValues = {
- name: server.name,
+ name: server.name.replace(/__/g, '/'),
mode: server.mode,
url: '',
command: '',
@@ -415,15 +594,8 @@ const MCPForm = forwardRef(function MCPForm(
setExtraArgs(newExtraArgs);
setStdioArgs(newStdioArgs);
-
- // Use form.reset so isDirty stays false after initial load
form.reset(formValues);
-
- if (server.runtime_info) {
- setRuntimeInfo(server.runtime_info);
- } else {
- setRuntimeInfo(null);
- }
+ setRuntimeInfo(server.runtime_info ?? null);
} catch (error) {
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
@@ -431,6 +603,12 @@ const MCPForm = forwardRef(function MCPForm(
}
async function handleFormSubmit(value: z.infer) {
+ // Belt-and-suspenders: even though the Save button is disabled when
+ // stdio is unselectable, intercept programmatic submits too.
+ if (value.mode === 'stdio' && !boxAvailable) {
+ toast.error(t('mcp.stdioBlockedByBoxToast'));
+ return;
+ }
try {
let serverConfig: MCPServer;
@@ -447,7 +625,7 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
url: value.url!,
- headers: headers,
+ headers,
timeout: value.timeout,
ssereadtimeout: value.ssereadtimeout,
},
@@ -459,7 +637,7 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
url: value.url!,
- headers: headers,
+ headers,
timeout: value.timeout,
},
};
@@ -469,7 +647,6 @@ const MCPForm = forwardRef(function MCPForm(
value.extra_args?.forEach((arg) => {
env[arg.key] = String(arg.value);
});
- const args = value.args?.map((arg) => arg.value) || [];
serverConfig = {
name: value.name,
@@ -477,8 +654,8 @@ const MCPForm = forwardRef(function MCPForm(
enable: true,
extra_args: {
command: value.command!,
- args: args,
- env: env,
+ args: value.args?.map((arg) => arg.value) || [],
+ env,
},
};
}
@@ -486,7 +663,6 @@ const MCPForm = forwardRef(function MCPForm(
if (isEditMode && initServerName) {
await httpClient.updateMCPServer(initServerName, serverConfig);
toast.success(t('mcp.updateSuccess'));
- // Reset dirty baseline to current values
form.reset(form.getValues());
onFormSubmit();
} else {
@@ -540,7 +716,7 @@ const MCPForm = forwardRef(function MCPForm(
const { task_id } = await httpClient.testMCPServer('_', {
name: form.getValues('name'),
- mode: mode,
+ mode,
enable: true,
extra_args: extraArgsData,
} as MCPServer);
@@ -640,69 +816,86 @@ const MCPForm = forwardRef(function MCPForm(
form.setValue('args', newArgs, { shouldDirty: !isInitializing.current });
};
- return (
-
+
+ );
+ }
+
+ return (
+
);
diff --git a/web/src/app/home/monitoring/components/FeedbackCard.tsx b/web/src/app/home/monitoring/components/FeedbackCard.tsx
index 5196fb894..c2e93e52b 100644
--- a/web/src/app/home/monitoring/components/FeedbackCard.tsx
+++ b/web/src/app/home/monitoring/components/FeedbackCard.tsx
@@ -6,6 +6,8 @@ import {
TrendingUp,
TrendingDown,
Minus,
+ Heart,
+ Smile,
} from 'lucide-react';
interface FeedbackCardProps {
@@ -133,11 +135,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.totalFeedback'),
value: stats?.totalFeedback ?? 0,
- icon: (
-
-
-
- ),
+ icon: ,
variant: 'default' as const,
},
{
@@ -155,11 +153,7 @@ export function FeedbackStatsCards({ stats, loading }: FeedbackStatsProps) {
{
title: t('monitoring.feedback.satisfactionRate'),
value: stats ? `${stats.satisfactionRate}%` : '0%',
- icon: (
-
-
-
- ),
+ icon: ,
variant: (stats && stats.satisfactionRate >= 80
? 'success'
: stats && stats.satisfactionRate >= 50
diff --git a/web/src/app/home/monitoring/components/FeedbackList.tsx b/web/src/app/home/monitoring/components/FeedbackList.tsx
index a64fdec16..10f590f2c 100644
--- a/web/src/app/home/monitoring/components/FeedbackList.tsx
+++ b/web/src/app/home/monitoring/components/FeedbackList.tsx
@@ -6,6 +6,7 @@ import {
ChevronRight,
ChevronDown,
ExternalLink,
+ Heart,
} from 'lucide-react';
import { FeedbackRecord } from '../types/monitoring';
import { Button } from '@/components/ui/button';
@@ -40,19 +41,7 @@ export function FeedbackList({
if (!feedback || feedback.length === 0) {
return (
-
-
-
+
{t('monitoring.feedback.noFeedback')}
diff --git a/web/src/app/home/monitoring/components/MessageContentRenderer.tsx b/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
index c73a98186..cf11f5f1c 100644
--- a/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
+++ b/web/src/app/home/monitoring/components/MessageContentRenderer.tsx
@@ -1,4 +1,5 @@
import React, { useState } from 'react';
+import { Paperclip, AudioLines } from 'lucide-react';
import {
MessageChainComponent,
Image as ImageComponent,
@@ -104,13 +105,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
-
-
-
+
{file.name || 'File'}
);
@@ -123,13 +118,7 @@ export function MessageContentRenderer({
key={index}
className="inline-flex items-center px-1.5 py-0.5 mx-0.5 rounded bg-muted text-muted-foreground text-sm"
>
-
-
-
+
Voice{voice.length ? ` ${voice.length}s` : ''}
);
diff --git a/web/src/app/home/monitoring/components/MessageDetailsCard.tsx b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx
index e50b80d36..ef46cf102 100644
--- a/web/src/app/home/monitoring/components/MessageDetailsCard.tsx
+++ b/web/src/app/home/monitoring/components/MessageDetailsCard.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
+import { Info, Clock, AlertCircle, Braces } from 'lucide-react';
import { MessageDetails } from '../types/monitoring';
interface MessageDetailsCardProps {
@@ -25,14 +26,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.message && (
-
-
-
+
{t('monitoring.messageList.viewDetails')}
@@ -92,14 +86,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.llmCalls && details.llmCalls.length > 0 && (
-
-
-
+
{t('monitoring.llmCalls.title')} ({details.llmCalls.length})
@@ -183,14 +170,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
{details.errors && details.errors.length > 0 && (
-
-
-
+
{t('monitoring.errors.title')} ({details.errors.length})
@@ -227,14 +207,7 @@ export function MessageDetailsCard({ details }: MessageDetailsCardProps) {
details.message?.runnerName !== 'local-agent' && (
-
-
-
+
{t('monitoring.queryVariables.title')}
diff --git a/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx b/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx
index 7c53e33d1..2a3e573de 100644
--- a/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx
+++ b/web/src/app/home/monitoring/components/overview-cards/MetricCard.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { TrendingUp, TrendingDown } from 'lucide-react';
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
interface MetricCardProps {
@@ -61,21 +62,11 @@ export default function MetricCard({
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
}`}
>
-
- {trend.direction === 'up' ? (
-
- ) : (
-
- )}
-
+ {trend.direction === 'up' ? (
+
+ ) : (
+
+ )}
{Math.abs(trend.value)}%
diff --git a/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx
index 5dcc4b188..f37d1523f 100644
--- a/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx
+++ b/web/src/app/home/monitoring/components/overview-cards/OverviewCards.tsx
@@ -1,6 +1,8 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
+import { MessageSquare, Sparkles, Check, Users } from 'lucide-react';
import MetricCard from './MetricCard';
+import SystemStatusCard from './SystemStatusCards';
import TrafficChart from './TrafficChart';
import {
OverviewMetrics,
@@ -13,6 +15,7 @@ interface OverviewCardsProps {
messages?: MonitoringMessage[];
llmCalls?: LLMCall[];
loading?: boolean;
+ refreshKey?: number;
}
export default function OverviewCards({
@@ -20,6 +23,7 @@ export default function OverviewCards({
messages = [],
llmCalls = [],
loading,
+ refreshKey,
}: OverviewCardsProps) {
const { t } = useTranslation();
@@ -27,15 +31,7 @@ export default function OverviewCards({
{
title: t('monitoring.totalMessages'),
value: metrics?.totalMessages || 0,
- icon: (
-
-
-
- ),
+ icon: ,
trend: metrics?.trends
? {
value: metrics.trends.messages,
@@ -48,15 +44,7 @@ export default function OverviewCards({
{
title: t('monitoring.modelCallsCount'),
value: metrics?.modelCalls || 0,
- icon: (
-
-
-
- ),
+ icon: ,
trend: metrics?.trends
? {
value: metrics.trends.llmCalls,
@@ -69,15 +57,7 @@ export default function OverviewCards({
{
title: t('monitoring.successRate'),
value: metrics ? `${metrics.successRate}%` : '0%',
- icon: (
-
-
-
- ),
+ icon: ,
trend: metrics?.trends
? {
value: metrics.trends.successRate,
@@ -90,15 +70,7 @@ export default function OverviewCards({
{
title: t('monitoring.activeSessions'),
value: metrics?.activeSessions || 0,
- icon: (
-
-
-
- ),
+ icon: ,
trend: metrics?.trends
? {
value: metrics.trends.sessions,
@@ -112,8 +84,8 @@ export default function OverviewCards({
return (
- {/* Metric Cards */}
-
+ {/* Metric Cards + System Status */}
+
{cards.map((card, index) => (
))}
+
{/* Traffic Chart */}
diff --git a/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx b/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx
new file mode 100644
index 000000000..8b2f65ea8
--- /dev/null
+++ b/web/src/app/home/monitoring/components/overview-cards/SystemStatusCards.tsx
@@ -0,0 +1,399 @@
+import React, { useEffect, useState, useCallback } from 'react';
+import { useTranslation } from 'react-i18next';
+import {
+ Plug,
+ Box,
+ CircleCheck,
+ CircleX,
+ Loader2,
+ Info,
+ Container,
+ Clock,
+ Cpu,
+ HardDrive,
+ Network,
+ Image,
+ FolderOpen,
+} from 'lucide-react';
+import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card';
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ ApiRespPluginSystemStatus,
+ ApiRespBoxStatus,
+ BoxSessionInfo,
+} from '@/app/infra/entities/api';
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from '@/components/ui/tooltip';
+import { httpClient } from '@/app/infra/http/HttpClient';
+
+type StatusState = 'ok' | 'disabled' | 'failed' | null;
+
+function StatusDot({ state }: { state: StatusState }) {
+ if (state === null)
+ return
;
+ if (state === 'ok')
+ return
;
+ if (state === 'disabled')
+ return
;
+ return
;
+}
+
+interface SystemStatusCardProps {
+ refreshKey?: number;
+}
+
+export default function SystemStatusCard({
+ refreshKey,
+}: SystemStatusCardProps) {
+ const { t } = useTranslation();
+ const [pluginStatus, setPluginStatus] =
+ useState
(null);
+ const [boxStatus, setBoxStatus] = useState(null);
+ const [boxSessions, setBoxSessions] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [dialogOpen, setDialogOpen] = useState(false);
+
+ const fetchStatus = useCallback(async () => {
+ try {
+ const [plugin, box, sessions] = await Promise.all([
+ httpClient.getPluginSystemStatus().catch(() => null),
+ httpClient.getBoxStatus().catch(() => null),
+ httpClient.getBoxSessions().catch(() => [] as BoxSessionInfo[]),
+ ]);
+ setPluginStatus(plugin);
+ setBoxStatus(box);
+ setBoxSessions(sessions);
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchStatus();
+ const interval = setInterval(fetchStatus, 30_000);
+ return () => clearInterval(interval);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [fetchStatus, refreshKey]);
+
+ 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();
+ fetchStatus();
+ setDialogOpen(true);
+ };
+
+ if (loading) {
+ return (
+
+
+
+ {t('monitoring.systemStatus')}
+
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ <>
+
+
+
+ {t('monitoring.systemStatus')}
+
+
+
+
+
+
+
+
+
+
{t('monitoring.pluginRuntime')}
+
+
+
+
+ {t('monitoring.boxRuntime')}
+
+
+
+
+
+
+
+ {t('monitoring.systemStatus')}
+
+
+
+
+ {/* Plugin Runtime */}
+
+
+
+
+ {t('monitoring.pluginRuntime')}
+
+
+
+
+ {pluginOk ? (
+
+ ) : (
+
+ )}
+
+ {pluginOk
+ ? t('monitoring.connected')
+ : pluginStatus && !pluginStatus.is_enable
+ ? t('monitoring.disabled')
+ : t('monitoring.disconnected')}
+
+
+ {pluginStatus && !pluginStatus.is_enable && (
+
+ {t('monitoring.pluginDisabled')}
+
+ )}
+ {pluginStatus &&
+ !pluginOk &&
+ pluginStatus.is_enable &&
+ pluginStatus.plugin_connector_error &&
+ pluginStatus.plugin_connector_error !== 'ok' && (
+
+ {pluginStatus.plugin_connector_error}
+
+ )}
+
+
+
+
+
+ {/* Box Runtime */}
+
+
+
+
+ {t('monitoring.boxRuntime')}
+
+
+
+
+ {boxState === 'ok' ? (
+
+ ) : (
+
+ )}
+
+ {boxState === 'ok'
+ ? t('monitoring.connected')
+ : boxState === 'disabled'
+ ? t('monitoring.disabled')
+ : t('monitoring.disconnected')}
+
+
+ {boxState === 'disabled' && (
+
+ {t('monitoring.boxDisabled')}
+
+ )}
+ {boxState === 'failed' && boxStatus?.connector_error && (
+
+ {boxStatus.connector_error}
+
+ )}
+ {boxStatus && (
+
+ {boxStatus.backend && (
+
+ {t('monitoring.boxBackend')}:{' '}
+
+ {boxStatus.backend.name}
+
+
+ )}
+
+ {t('monitoring.boxProfile')}:{' '}
+
+ {boxStatus.profile}
+
+
+ {boxOk && boxStatus.active_sessions !== undefined && (
+
+ {t('monitoring.boxSandboxes')}:{' '}
+
+ {boxStatus.active_sessions}
+
+
+ )}
+
+ )}
+
+ {/* Active Sandboxes */}
+ {boxSessions.length > 0 && (
+
+ {boxSessions.map((session) => (
+
+
+
+
+
+
+ {session.session_id}
+
+
+
+ {session.session_id}
+
+
+
+
+
+
+
+
+
+ {session.image}
+
+
+ {session.image}
+
+
+
+
+
+ {session.backend_name}
+
+
+
+
+
+ {session.cpus} CPU / {session.memory_mb} MB
+
+
+
+
+
+ {session.network}
+
+
+ {session.host_path && (
+
+
+
+
+
+ {session.host_path} : {session.mount_path}{' '}
+
+ ({session.host_path_mode})
+
+
+
+
+ {session.host_path} : {session.mount_path} (
+ {session.host_path_mode})
+
+
+
+ )}
+
+
+
+ {t('monitoring.boxSessionCreated')}:{' '}
+
+ {new Date(
+ session.created_at,
+ ).toLocaleString()}
+
+
+
+
+
+
+ {t('monitoring.boxSessionLastUsed')}:{' '}
+
+ {new Date(
+ session.last_used_at,
+ ).toLocaleString()}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx
index d1d82b6e7..827623bf3 100644
--- a/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx
+++ b/web/src/app/home/monitoring/components/overview-cards/TrafficChart.tsx
@@ -1,5 +1,6 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
+import { BarChart3 } from 'lucide-react';
import {
AreaChart,
Area,
@@ -146,14 +147,7 @@ export default function TrafficChart({
{t('monitoring.trafficChart.title')}
-
-
-
+
{t('monitoring.trafficChart.noData')}
diff --git a/web/src/app/home/monitoring/page.tsx b/web/src/app/home/monitoring/page.tsx
index 6fa6f1214..5a75df0a3 100644
--- a/web/src/app/home/monitoring/page.tsx
+++ b/web/src/app/home/monitoring/page.tsx
@@ -2,7 +2,15 @@ import React, { Suspense, useState, useMemo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
-import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
+import {
+ ChevronRight,
+ ChevronDown,
+ ExternalLink,
+ RefreshCw,
+ MessageSquare,
+ Sparkles,
+ CheckCircle2,
+} from 'lucide-react';
import OverviewCards from './components/overview-cards/OverviewCards';
import MonitoringFilters from './components/filters/MonitoringFilters';
import { ExportDropdown } from './components/ExportDropdown';
@@ -259,8 +267,8 @@ function MonitoringPageContent() {
return (
{/* Filters and Refresh Button - Sticky */}
-
-
+
+
-
-
-
+
{t('monitoring.refreshData')}
@@ -294,7 +295,7 @@ function MonitoringPageContent() {
{/* Content Area */}
-
+
{/* Overview Section */}
-
-
-
+
{t('monitoring.messageList.noMessages')}
@@ -665,14 +659,7 @@ function MonitoringPageContent() {
!data.modelCalls ||
data.modelCalls.length === 0) && (
-
-
-
+
{t('monitoring.modelCalls.noData')}
@@ -867,14 +854,7 @@ function MonitoringPageContent() {
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
-
-
-
+
{t('monitoring.errors.noErrors')}
diff --git a/web/src/app/home/pipelines/components/debug-dialog/ImagePreviewDialog.tsx b/web/src/app/home/pipelines/components/debug-dialog/ImagePreviewDialog.tsx
index 89eb9656e..c744c850a 100644
--- a/web/src/app/home/pipelines/components/debug-dialog/ImagePreviewDialog.tsx
+++ b/web/src/app/home/pipelines/components/debug-dialog/ImagePreviewDialog.tsx
@@ -1,4 +1,5 @@
import React from 'react';
+import { X } from 'lucide-react';
interface ImagePreviewDialogProps {
open: boolean;
@@ -28,19 +29,7 @@ export default function ImagePreviewDialog({
onClick={onClose}
className="self-end w-9 h-9 rounded-full bg-white hover:bg-gray-100 dark:bg-gray-800 dark:hover:bg-gray-700 text-gray-800 dark:text-gray-100 shadow-lg transition-all hover:scale-105 flex items-center justify-center"
>
-
-
-
+
{/* 图片 */}
diff --git a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx
index bd59b4da1..9364d9fea 100644
--- a/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx
+++ b/web/src/app/home/pipelines/components/monitoring-tab/PipelineMonitoringTab.tsx
@@ -2,7 +2,15 @@ import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
-import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
+import {
+ ChevronRight,
+ ChevronDown,
+ ExternalLink,
+ RefreshCw,
+ MessageCircle,
+ CheckCircle2,
+ Monitor,
+} from 'lucide-react';
import { useMonitoringData } from '@/app/home/monitoring/hooks/useMonitoringData';
import { MessageContentRenderer } from '@/app/home/monitoring/components/MessageContentRenderer';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
@@ -205,14 +213,7 @@ export default function PipelineMonitoringTab({
onClick={onNavigateToMonitoring}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
-
-
-
+
{t('pipelines.monitoring.detailedLogs')}
)}
@@ -222,14 +223,7 @@ export default function PipelineMonitoringTab({
onClick={refetch}
className="bg-white dark:bg-[#2a2a2e] hover:bg-gray-50 dark:hover:bg-gray-800 border-gray-300 dark:border-gray-600"
>
-
-
-
+
{t('monitoring.refreshData')}
@@ -431,19 +425,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.messages || data.messages.length === 0) && (
-
-
-
+
{t('monitoring.messageList.noMessages')}
@@ -543,19 +525,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.errors || data.errors.length === 0) && (
-
-
-
+
{t('monitoring.errors.noErrors')}
@@ -638,19 +608,7 @@ export default function PipelineMonitoringTab({
{!loading &&
(!data || !data.llmCalls || data.llmCalls.length === 0) && (
-
-
-
+
{t('monitoring.llmCalls.noData')}
diff --git a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
index 27e3b3b5c..95ad758a5 100644
--- a/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-card/PipelineCard.tsx
@@ -1,6 +1,7 @@
import styles from './pipelineCard.module.css';
import { PipelineCardVO } from '@/app/home/pipelines/components/pipeline-card/PipelineCardVO';
import { useTranslation } from 'react-i18next';
+import { Clock, Star } from 'lucide-react';
export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
const { t } = useTranslation();
@@ -21,14 +22,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
-
-
-
+
{t('pipelines.updateTime')}
{cardVO.lastUpdatedTimeAgo}
@@ -39,14 +33,7 @@ export default function PipelineCard({ cardVO }: { cardVO: PipelineCardVO }) {
{cardVO.isDefault && (
-
-
-
+
{t('pipelines.defaultBadge')}
diff --git a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx
index e1ee3492c..a96101798 100644
--- a/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-extensions/PipelineExtension.tsx
@@ -12,13 +12,15 @@ import {
DialogFooter,
} from '@/components/ui/dialog';
import { Checkbox } from '@/components/ui/checkbox';
-import { Plus, X, Server, Wrench } from 'lucide-react';
+import { Plus, X, Server, Wrench, Sparkles } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Plugin } from '@/app/infra/entities/plugin';
-import { MCPServer } from '@/app/infra/entities/api';
+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,19 +28,31 @@ export default function PipelineExtension({
pipelineId: string;
}) {
const { t } = useTranslation();
+ const {
+ available: boxAvailable,
+ hint: boxHint,
+ reason: boxReason,
+ } = useBoxStatus();
const [loading, setLoading] = useState(true);
const [enableAllPlugins, setEnableAllPlugins] = useState(true);
const [enableAllMCPServers, setEnableAllMCPServers] = useState(true);
+ const [enableAllSkills, setEnableAllSkills] = useState(true);
const [selectedPlugins, setSelectedPlugins] = useState
([]);
const [allPlugins, setAllPlugins] = useState([]);
const [selectedMCPServers, setSelectedMCPServers] = useState([]);
const [allMCPServers, setAllMCPServers] = useState([]);
+ const [selectedSkills, setSelectedSkills] = useState([]);
+ const [allSkills, setAllSkills] = useState([]);
const [pluginDialogOpen, setPluginDialogOpen] = useState(false);
const [mcpDialogOpen, setMcpDialogOpen] = useState(false);
+ const [skillDialogOpen, setSkillDialogOpen] = useState(false);
const [tempSelectedPluginIds, setTempSelectedPluginIds] = useState(
[],
);
const [tempSelectedMCPIds, setTempSelectedMCPIds] = useState([]);
+ const [tempSelectedSkillIds, setTempSelectedSkillIds] = useState(
+ [],
+ );
useEffect(() => {
loadExtensions();
@@ -57,6 +71,7 @@ export default function PipelineExtension({
setEnableAllPlugins(data.enable_all_plugins ?? true);
setEnableAllMCPServers(data.enable_all_mcp_servers ?? true);
+ setEnableAllSkills(data.enable_all_skills ?? true);
const boundPluginIds = new Set(
data.bound_plugins.map((p) => `${p.author}/${p.name}`),
@@ -77,6 +92,15 @@ export default function PipelineExtension({
setSelectedMCPServers(selectedMCP);
setAllMCPServers(data.available_mcp_servers);
+
+ // Load Skills
+ const boundSkillNames = new Set(data.bound_skills || []);
+ const selectedSkill = (data.available_skills || []).filter((skill) =>
+ boundSkillNames.has(skill.name),
+ );
+
+ setSelectedSkills(selectedSkill);
+ setAllSkills(data.available_skills || []);
} catch (error) {
console.error('Failed to load extensions:', error);
toast.error(t('pipelines.extensions.loadError'));
@@ -88,8 +112,10 @@ export default function PipelineExtension({
const saveToBackend = async (
plugins: Plugin[],
mcpServers: MCPServer[],
+ skills: Skill[],
newEnableAllPlugins?: boolean,
newEnableAllMCPServers?: boolean,
+ newEnableAllSkills?: boolean,
) => {
try {
const boundPluginsArray = plugins.map((plugin) => {
@@ -101,6 +127,7 @@ export default function PipelineExtension({
});
const boundMCPServerIds = mcpServers.map((server) => server.uuid || '');
+ const boundSkillIds = skills.map((skill) => skill.name);
await backendClient.updatePipelineExtensions(
pipelineId,
@@ -108,6 +135,8 @@ export default function PipelineExtension({
boundMCPServerIds,
newEnableAllPlugins ?? enableAllPlugins,
newEnableAllMCPServers ?? enableAllMCPServers,
+ boundSkillIds,
+ newEnableAllSkills ?? enableAllSkills,
);
toast.success(t('pipelines.extensions.saveSuccess'));
} catch (error) {
@@ -123,13 +152,19 @@ export default function PipelineExtension({
(p) => getPluginId(p) !== pluginId,
);
setSelectedPlugins(newPlugins);
- await saveToBackend(newPlugins, selectedMCPServers);
+ await saveToBackend(newPlugins, selectedMCPServers, selectedSkills);
};
const handleRemoveMCPServer = async (serverUuid: string) => {
const newServers = selectedMCPServers.filter((s) => s.uuid !== serverUuid);
setSelectedMCPServers(newServers);
- await saveToBackend(selectedPlugins, newServers);
+ await saveToBackend(selectedPlugins, newServers, selectedSkills);
+ };
+
+ const handleRemoveSkill = async (skillName: string) => {
+ const newSkills = selectedSkills.filter((s) => s.name !== skillName);
+ setSelectedSkills(newSkills);
+ await saveToBackend(selectedPlugins, selectedMCPServers, newSkills);
};
const handleOpenPluginDialog = () => {
@@ -142,6 +177,11 @@ export default function PipelineExtension({
setMcpDialogOpen(true);
};
+ const handleOpenSkillDialog = () => {
+ setTempSelectedSkillIds(selectedSkills.map((s) => s.name));
+ setSkillDialogOpen(true);
+ };
+
const handleTogglePlugin = (pluginId: string) => {
setTempSelectedPluginIds((prev) =>
prev.includes(pluginId)
@@ -158,33 +198,45 @@ export default function PipelineExtension({
);
};
+ const handleToggleSkill = (skillName: string) => {
+ setTempSelectedSkillIds((prev) =>
+ prev.includes(skillName)
+ ? prev.filter((id) => id !== skillName)
+ : [...prev, skillName],
+ );
+ };
+
const handleToggleAllPlugins = () => {
if (tempSelectedPluginIds.length === allPlugins.length) {
- // Deselect all
setTempSelectedPluginIds([]);
} else {
- // Select all
setTempSelectedPluginIds(allPlugins.map((p) => getPluginId(p)));
}
};
const handleToggleAllMCPServers = () => {
if (tempSelectedMCPIds.length === allMCPServers.length) {
- // Deselect all
setTempSelectedMCPIds([]);
} else {
- // Select all
setTempSelectedMCPIds(allMCPServers.map((s) => s.uuid || ''));
}
};
+ const handleToggleAllSkills = () => {
+ if (tempSelectedSkillIds.length === allSkills.length) {
+ setTempSelectedSkillIds([]);
+ } else {
+ setTempSelectedSkillIds(allSkills.map((s) => s.name));
+ }
+ };
+
const handleConfirmPluginSelection = async () => {
const newSelected = allPlugins.filter((p) =>
tempSelectedPluginIds.includes(getPluginId(p)),
);
setSelectedPlugins(newSelected);
setPluginDialogOpen(false);
- await saveToBackend(newSelected, selectedMCPServers);
+ await saveToBackend(newSelected, selectedMCPServers, selectedSkills);
};
const handleConfirmMCPSelection = async () => {
@@ -193,7 +245,16 @@ export default function PipelineExtension({
);
setSelectedMCPServers(newSelected);
setMcpDialogOpen(false);
- await saveToBackend(selectedPlugins, newSelected);
+ await saveToBackend(selectedPlugins, newSelected, selectedSkills);
+ };
+
+ const handleConfirmSkillSelection = async () => {
+ const newSelected = allSkills.filter((s) =>
+ tempSelectedSkillIds.includes(s.name),
+ );
+ setSelectedSkills(newSelected);
+ setSkillDialogOpen(false);
+ await saveToBackend(selectedPlugins, selectedMCPServers, newSelected);
};
const handleToggleEnableAllPlugins = async (checked: boolean) => {
@@ -201,8 +262,10 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
+ selectedSkills,
checked,
undefined,
+ undefined,
);
};
@@ -211,6 +274,20 @@ export default function PipelineExtension({
await saveToBackend(
selectedPlugins,
selectedMCPServers,
+ selectedSkills,
+ undefined,
+ checked,
+ undefined,
+ );
+ };
+
+ const handleToggleEnableAllSkills = async (checked: boolean) => {
+ setEnableAllSkills(checked);
+ await saveToBackend(
+ selectedPlugins,
+ selectedMCPServers,
+ selectedSkills,
+ undefined,
undefined,
checked,
);
@@ -432,6 +509,88 @@ export default function PipelineExtension({
+ {/* Skills Section */}
+
+
+
+ {t('pipelines.extensions.skillsTitle')}
+
+
+
+ {t('pipelines.extensions.enableAllSkills')}
+
+
+
+
+ {!boxAvailable && (
+
+ )}
+
+ {enableAllSkills ? (
+
+
+ {t('pipelines.extensions.allSkillsEnabled')}
+
+
+ ) : selectedSkills.length === 0 ? (
+
+
+ {t('pipelines.extensions.noSkillsSelected')}
+
+
+ ) : (
+
+ {selectedSkills.map((skill) => (
+
+
+
+
+
+
+
+ {skill.display_name || skill.name}
+
+
+ {skill.description}
+
+
+
+
handleRemoveSkill(skill.name)}
+ disabled={!boxAvailable}
+ >
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ {t('pipelines.extensions.addSkill')}
+
+
+
{/* Plugin Selection Dialog */}
@@ -620,6 +779,73 @@ export default function PipelineExtension({
+
+ {/* Skill Selection Dialog */}
+
+
+
+ {t('pipelines.extensions.selectSkills')}
+
+ {allSkills.length > 0 && (
+
+ 0
+ }
+ onCheckedChange={handleToggleAllSkills}
+ />
+
+ {t('pipelines.extensions.selectAll')}
+
+
+ )}
+
+ {allSkills.length === 0 ? (
+
+
+ {t('pipelines.extensions.noSkillsAvailable')}
+
+
+ ) : (
+ allSkills.map((skill) => {
+ const isSelected = tempSelectedSkillIds.includes(skill.name);
+ return (
+
handleToggleSkill(skill.name)}
+ >
+
+
+
+
+
+
+ {skill.display_name || skill.name}
+
+
+ {skill.description}
+
+
+
+ );
+ })
+ )}
+
+
+ setSkillDialogOpen(false)}>
+ {t('common.cancel')}
+
+
+ {t('common.confirm')}
+
+
+
+
);
}
diff --git a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
index a516a09d8..de192298f 100644
--- a/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
+++ b/web/src/app/home/pipelines/components/pipeline-form/PipelineFormComponent.tsx
@@ -7,6 +7,7 @@ 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 { 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 +76,7 @@ export default function PipelineFormComponent({
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showCopyConfirm, setShowCopyConfirm] = useState(false);
const [isDefaultPipeline, setIsDefaultPipeline] = useState
(false);
+ const { available: boxAvailable } = useBoxStatus();
const formSchema = isEditMode
? z.object({
@@ -185,6 +187,10 @@ export default function PipelineFormComponent({
if (!isEditMode || !savedSnapshotRef.current) return false;
return JSON.stringify(watchedValues) !== savedSnapshotRef.current;
}, [isEditMode, watchedValues]);
+ // Keep a ref so that non-reactive callbacks (handleDynamicFormEmit) can
+ // read the latest dirty state without stale closures.
+ const hasUnsavedChangesRef = useRef(hasUnsavedChanges);
+ hasUnsavedChangesRef.current = hasUnsavedChanges;
// Notify parent when dirty state changes
useEffect(() => {
@@ -304,6 +310,9 @@ export default function PipelineFormComponent({
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
// On the first emission for a stage (mount-time default filling), the
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
+ // However, if the form is already dirty (the user has made real changes),
+ // we must NOT re-capture the snapshot — otherwise we would silently absorb
+ // those real changes and flip hasUnsavedChanges back to false.
function handleDynamicFormEmit(
formName: keyof FormValues,
stageName: string,
@@ -322,9 +331,14 @@ export default function PipelineFormComponent({
if (isFirstEmission) {
initializedStagesRef.current.add(stageKey);
- // Synchronously re-capture snapshot so that the useMemo comparison
- // in the same render cycle still returns false.
- savedSnapshotRef.current = JSON.stringify(form.getValues());
+ // Only re-capture the snapshot when the form has no other pending
+ // changes. If the user already modified something (e.g. switched
+ // runner), the snapshot must remain at the last-saved state so that
+ // hasUnsavedChanges stays true.
+ const currentSnapshot = JSON.stringify(form.getValues());
+ if (savedSnapshotRef.current === '' || !hasUnsavedChangesRef.current) {
+ savedSnapshotRef.current = currentSnapshot;
+ }
}
}
@@ -401,6 +415,16 @@ export default function PipelineFormComponent({
}
}
+ // Box availability is exposed through ``systemContext.__system.box_available``
+ // so individual yaml-driven fields (e.g. ``box-session-id-template``) can
+ // opt-in via ``disable_if`` + ``disabled_tooltip`` rather than every page
+ // hard-coding a banner. Field-level gating keeps unrelated fields
+ // untouched.
+ const stageSystemContext =
+ stage.name === 'local-agent'
+ ? { box_available: boxAvailable }
+ : undefined;
+
return (
@@ -421,6 +445,7 @@ export default function PipelineFormComponent({
onSubmit={(values) => {
handleDynamicFormEmit(formName, stage.name, values);
}}
+ systemContext={stageSystemContext}
/>
diff --git a/web/src/app/home/plugins/PluginDetailContent.tsx b/web/src/app/home/plugins/PluginDetailContent.tsx
index 30d1f8bb5..829e68684 100644
--- a/web/src/app/home/plugins/PluginDetailContent.tsx
+++ b/web/src/app/home/plugins/PluginDetailContent.tsx
@@ -1,10 +1,34 @@
-import { useEffect } from 'react';
+import { useEffect, useState } from 'react';
+import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
import PluginReadme from '@/app/home/plugins/components/plugin-installed/plugin-readme/PluginReadme';
+import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { Badge } from '@/components/ui/badge';
-import { Bug } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import { httpClient } from '@/app/infra/http/HttpClient';
+import { Plugin } from '@/app/infra/entities/plugin';
+import { extractI18nObject } from '@/i18n/I18nProvider';
+import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
+import { Bug, Puzzle, Trash2 } from 'lucide-react';
+import { toast } from 'sonner';
/**
* Plugin detail page content.
@@ -12,7 +36,11 @@ import { Bug } from 'lucide-react';
*/
export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
+ const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
+ const [pluginInfo, setPluginInfo] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [deleteData, setDeleteData] = useState(false);
// Parse "author/name" composite key
const slashIndex = id.indexOf('/');
@@ -20,6 +48,23 @@ export default function PluginDetailContent({ id }: { id: string }) {
const pluginName = slashIndex >= 0 ? id.substring(slashIndex + 1) : id;
const plugin = plugins.find((p) => p.id === id);
+ const title =
+ pluginInfo?.manifest.manifest.metadata.label &&
+ extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
+ ? extractI18nObject(pluginInfo.manifest.manifest.metadata.label)
+ : plugin?.name || `${pluginAuthor}/${pluginName}`;
+ const description = pluginInfo?.manifest.manifest.metadata.description
+ ? extractI18nObject(pluginInfo.manifest.manifest.metadata.description)
+ : plugin?.description;
+
+ const asyncTask = useAsyncTask({
+ onSuccess: () => {
+ toast.success(t('plugins.deleteSuccess'));
+ setShowDeleteConfirm(false);
+ void refreshPlugins();
+ navigate('/home/extensions');
+ },
+ });
// Set breadcrumb entity name
useEffect(() => {
@@ -27,6 +72,18 @@ export default function PluginDetailContent({ id }: { id: string }) {
return () => setDetailEntityName(null);
}, [plugin, pluginAuthor, pluginName, setDetailEntityName]);
+ useEffect(() => {
+ let cancelled = false;
+ httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
+ if (!cancelled) {
+ setPluginInfo(res.plugin);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [pluginAuthor, pluginName]);
+
function handleFormSubmit(timeout?: number) {
if (timeout) {
setTimeout(() => {
@@ -37,60 +94,199 @@ export default function PluginDetailContent({ id }: { id: string }) {
}
}
- return (
-
-
-
- {pluginAuthor}/{pluginName}
-
- {plugin?.debug ? (
-
-
- {t('plugins.debugging')}
-
- ) : plugin?.installSource === 'github' ? (
-
- {t('plugins.fromGithub')}
-
- ) : plugin?.installSource === 'local' ? (
-
- {t('plugins.fromLocal')}
-
- ) : plugin?.installSource === 'marketplace' ? (
-
{
+ asyncTask.startTask(res.task_id);
+ })
+ .catch((error) => {
+ toast.error(t('plugins.deleteError') + error.message);
+ });
+ }
+
+ const sourceBadge = plugin?.debug ? (
+
+
+ {t('plugins.debugging')}
+
+ ) : plugin?.installSource === 'github' ? (
+
+ {t('plugins.fromGithub')}
+
+ ) : plugin?.installSource === 'local' ? (
+
+ {t('plugins.fromLocal')}
+
+ ) : plugin?.installSource === 'marketplace' ? (
+
+ {t('plugins.fromMarketplace')}
+
+ ) : null;
+
+ const componentBadges = pluginInfo && (
+ >(
+ (acc, component) => {
+ const kind = component.manifest.manifest.kind;
+ acc[kind] = (acc[kind] ?? 0) + 1;
+ return acc;
+ },
+ {},
+ )}
+ showComponentName
+ showTitle={false}
+ useBadge
+ t={t}
+ />
+ );
+
+ const dangerZone = (
+
+
+
+ {t('plugins.dangerZone')}
+
+ {t('plugins.dangerZoneDescription')}
+
+
+
+
+
{t('plugins.deletePlugin')}
+
+ {t('plugins.confirmDeletePlugin', {
+ author: pluginAuthor,
+ name: pluginName,
+ })}
+
+
+
setShowDeleteConfirm(true)}
+ className="shrink-0"
>
- {t('plugins.fromMarketplace')}
-
- ) : null}
-
+
+ {t('common.delete')}
+
+
+
+
+ );
-
- {/* Left side - Config */}
-
-
+ return (
+ <>
+
+
+
+
{title}
+
+
+ {t('market.typePlugin')}
+
+ {sourceBadge}
+ {componentBadges}
+
+ {description && (
+
+ {description}
+
+ )}
- {/* Divider */}
-
- {/* Right side - Readme */}
-
-
+
+
+
+
+ {t('plugins.deleteConfirm')}
+
+ {asyncTask.status === AsyncTaskStatus.RUNNING
+ ? t('plugins.deleting')
+ : t('plugins.confirmDeletePlugin', {
+ author: pluginAuthor,
+ name: pluginName,
+ })}
+
+
+ {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
+
+ setDeleteData(checked === true)}
+ />
+
+ {t('plugins.deleteDataCheckbox')}
+
+
+ )}
+ {asyncTask.status === AsyncTaskStatus.ERROR && (
+ {asyncTask.error}
+ )}
+
+ {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
+ setShowDeleteConfirm(false)}
+ >
+ {t('common.cancel')}
+
+ )}
+ {asyncTask.status === AsyncTaskStatus.WAIT_INPUT && (
+
+ {t('common.confirmDelete')}
+
+ )}
+ {asyncTask.status === AsyncTaskStatus.RUNNING && (
+
+ {t('plugins.deleting')}
+
+ )}
+ {asyncTask.status === AsyncTaskStatus.ERROR && (
+ {
+ setShowDeleteConfirm(false);
+ asyncTask.reset();
+ }}
+ >
+ {t('plugins.close')}
+
+ )}
+
+
+
+ >
);
}
diff --git a/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx b/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
new file mode 100644
index 000000000..52434ed2b
--- /dev/null
+++ b/web/src/app/home/plugins/components/PluginLocalPreviewPanel.tsx
@@ -0,0 +1,203 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { toast } from 'sonner';
+import { Archive, CheckCircle2, Loader2, Package } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { httpClient } from '@/app/infra/http/HttpClient';
+import { extractI18nObject } from '@/i18n/I18nProvider';
+import { usePluginInstallTasks } from '@/app/home/plugins/components/plugin-install-task';
+import PluginComponentList from '@/app/home/plugins/components/plugin-installed/PluginComponentList';
+
+type PluginLocalPreview = Awaited<
+ ReturnType
+>;
+
+interface PluginLocalPreviewPanelProps {
+ file: File;
+ onInstallStarted?: () => void;
+ onCancel?: () => void;
+}
+
+function formatFileSize(bytes: number): string {
+ if (bytes === 0) return '0 B';
+ const k = 1024;
+ const sizes = ['B', 'KB', 'MB', 'GB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
+}
+
+export default function PluginLocalPreviewPanel({
+ file,
+ onInstallStarted,
+ onCancel,
+}: PluginLocalPreviewPanelProps) {
+ const { t } = useTranslation();
+ const { addTask, setSelectedTaskId } = usePluginInstallTasks();
+ const [preview, setPreview] = useState(null);
+ const [previewing, setPreviewing] = useState(false);
+ const [installing, setInstalling] = useState(false);
+ const [errorMessage, setErrorMessage] = useState(null);
+
+ const loadPreview = useCallback(async () => {
+ setPreviewing(true);
+ setPreview(null);
+ setErrorMessage(null);
+ try {
+ const result = await httpClient.previewPluginInstallFromLocal(file);
+ setPreview(result);
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : typeof error === 'object' && error && 'msg' in error
+ ? String((error as { msg?: string }).msg || '')
+ : String(error);
+ setErrorMessage(message || t('plugins.localPreview.failed'));
+ } finally {
+ setPreviewing(false);
+ }
+ }, [file, t]);
+
+ useEffect(() => {
+ void loadPreview();
+ }, [loadPreview]);
+
+ async function handleInstall() {
+ setInstalling(true);
+ setErrorMessage(null);
+ try {
+ const resp = await httpClient.installPluginFromLocal(file);
+ const taskId = resp.task_id;
+ const taskKey = `local-${taskId}`;
+ const pluginName =
+ preview?.metadata.label && extractI18nObject(preview.metadata.label)
+ ? extractI18nObject(preview.metadata.label)
+ : preview?.metadata.name || file.name;
+
+ addTask({
+ taskId,
+ pluginName,
+ source: 'local',
+ extensionType: 'plugin',
+ fileSize: file.size,
+ });
+ setSelectedTaskId(taskKey);
+ toast.success(t('plugins.installSuccess'));
+ onInstallStarted?.();
+ } catch (error: unknown) {
+ const message =
+ error instanceof Error
+ ? error.message
+ : typeof error === 'object' && error && 'msg' in error
+ ? String((error as { msg?: string }).msg || '')
+ : String(error);
+ setErrorMessage(message || t('plugins.installFailed'));
+ } finally {
+ setInstalling(false);
+ }
+ }
+
+ const metadata = preview?.metadata;
+ const label = metadata?.label ? extractI18nObject(metadata.label) : '';
+ const description = metadata?.description
+ ? extractI18nObject(metadata.description)
+ : '';
+ const componentCounts = preview?.component_counts || {};
+
+ return (
+