import React, { useMemo, useState } from "react";
|
import {
|
FilterButton,
|
List,
|
SearchInput,
|
SelectInput,
|
TopToolbar,
|
useDelete,
|
useListContext,
|
useNotify,
|
useRefresh,
|
useTranslate,
|
} from "react-admin";
|
import {
|
Box,
|
Button,
|
Card,
|
CardActions,
|
CardContent,
|
Chip,
|
CircularProgress,
|
Divider,
|
Grid,
|
Stack,
|
Typography,
|
} from "@mui/material";
|
import AddRoundedIcon from "@mui/icons-material/AddRounded";
|
import DeleteOutlineOutlinedIcon from "@mui/icons-material/DeleteOutlineOutlined";
|
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import PlayCircleOutlineOutlinedIcon from "@mui/icons-material/PlayCircleOutlineOutlined";
|
import MyExportButton from "@/page/components/MyExportButton";
|
import AiMcpMountForm from "./AiMcpMountForm";
|
import AiConfigDialog from "../aiShared/AiConfigDialog";
|
import AiMcpMountToolsPanel from "./AiMcpMountToolsPanel";
|
import { testMcpConnectivity } from "@/api/ai/mcpMount";
|
import AiRuntimeSummary from "../aiShared/AiRuntimeSummary";
|
|
const filters = [
|
<SearchInput source="condition" alwaysOn />,
|
<SelectInput
|
source="transportType"
|
label="ai.mcp.fields.transportType"
|
choices={[
|
{ id: "SSE_HTTP", name: "SSE_HTTP" },
|
{ id: "STDIO", name: "STDIO" },
|
{ id: "BUILTIN", name: "BUILTIN" },
|
]}
|
/>,
|
<SelectInput
|
source="status"
|
label="common.field.status"
|
choices={[
|
{ id: "1", name: "common.enums.statusTrue" },
|
{ id: "0", name: "common.enums.statusFalse" },
|
]}
|
/>,
|
];
|
|
const defaultValues = {
|
transportType: "SSE_HTTP",
|
endpoint: "/sse",
|
requestTimeoutMs: 60000,
|
sort: 0,
|
status: 1,
|
healthStatus: "NOT_TESTED",
|
};
|
|
const truncateText = (value, max = 96) => {
|
if (!value) {
|
return "--";
|
}
|
return value.length > max ? `${value.slice(0, max)}...` : value;
|
};
|
|
const resolveTargetLabel = (record) => {
|
if (record.transportType === "BUILTIN") {
|
return record.builtinCode || "--";
|
}
|
if (record.transportType === "STDIO") {
|
return record.command || "--";
|
}
|
return record.serverUrl || "--";
|
};
|
|
const transportGroups = [
|
{ key: "BUILTIN", titleKey: "ai.mcp.groups.builtin.title", descriptionKey: "ai.mcp.groups.builtin.description" },
|
{ key: "SSE_HTTP", titleKey: "ai.mcp.groups.sse.title", descriptionKey: "ai.mcp.groups.sse.description" },
|
{ key: "STDIO", titleKey: "ai.mcp.groups.stdio.title", descriptionKey: "ai.mcp.groups.stdio.description" },
|
];
|
|
const resolveHealthMeta = (record, translate) => {
|
if (record.healthStatus === "HEALTHY") {
|
return { color: "success", label: translate("ai.mcp.health.healthy") };
|
}
|
if (record.healthStatus === "UNHEALTHY") {
|
return { color: "error", label: translate("ai.mcp.health.unhealthy") };
|
}
|
return { color: "default", label: translate("ai.common.notTested") };
|
};
|
|
const AiMcpMountCards = ({ onView, onEdit, onDelete, onConnectivityTest, deleting, testingConnectivityId }) => {
|
const translate = useTranslate();
|
const { data, isLoading } = useListContext();
|
const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
const groupedRecords = useMemo(() => {
|
return transportGroups.map((group) => ({
|
...group,
|
records: records.filter((item) => item.transportType === group.key),
|
})).filter((group) => group.records.length > 0);
|
}, [records]);
|
|
if (isLoading) {
|
return (
|
<Box display="flex" justifyContent="center" py={8}>
|
<CircularProgress size={28} />
|
</Box>
|
);
|
}
|
|
if (!records.length) {
|
return (
|
<Box px={2} py={6}>
|
<Card variant="outlined" sx={{ p: 3, textAlign: "center", borderStyle: "dashed" }}>
|
<Typography variant="subtitle1">{translate("ai.mcp.list.emptyTitle")}</Typography>
|
<Typography variant="body2" color="text.secondary" mt={1}>
|
{translate("ai.mcp.list.emptyDescription")}
|
</Typography>
|
</Card>
|
</Box>
|
);
|
}
|
|
return (
|
<Box px={2} py={2}>
|
<Stack spacing={3}>
|
{groupedRecords.map((group) => (
|
<Box key={group.key}>
|
<Box mb={1.5}>
|
<Typography variant="h6">{translate(group.titleKey)}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate(group.descriptionKey)}
|
</Typography>
|
</Box>
|
<Grid container spacing={2}>
|
{group.records.map((record) => {
|
const healthMeta = resolveHealthMeta(record, translate);
|
return (
|
<Grid item xs={12} md={6} xl={4} key={record.id}>
|
<Card
|
variant="outlined"
|
sx={{
|
height: "100%",
|
borderRadius: 3,
|
boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)",
|
}}
|
>
|
<CardContent sx={{ pb: 1.5 }}>
|
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}>
|
<Box>
|
<Typography variant="h6" sx={{ mb: 0.5 }}>
|
{record.name}
|
</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{record.transportType$ || record.transportType || "--"}
|
</Typography>
|
</Box>
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap justifyContent="flex-end">
|
<Chip
|
size="small"
|
color={record.statusBool ? "success" : "default"}
|
label={translate(record.statusBool ? "ai.common.enabled" : "ai.common.disabled")}
|
/>
|
<Chip size="small" color={healthMeta.color} label={healthMeta.label} />
|
</Stack>
|
</Stack>
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
|
<Chip size="small" variant="outlined" label={translate("ai.mcp.list.sortValue", { value: record.sort ?? 0 })} />
|
<Chip
|
size="small"
|
variant="outlined"
|
label={translate("ai.mcp.list.timeoutValue", { value: record.requestTimeoutMs ?? "--" })}
|
/>
|
<Chip
|
size="small"
|
variant="outlined"
|
label={translate("ai.mcp.list.initValue", { value: record.lastInitElapsedMs ?? "--" })}
|
/>
|
</Stack>
|
<Divider sx={{ my: 1.5 }} />
|
<Typography variant="caption" color="text.secondary">{translate("ai.common.target")}</Typography>
|
<Typography variant="body2" sx={{ mt: 0.5, wordBreak: "break-all" }}>
|
{truncateText(resolveTargetLabel(record), 120)}
|
</Typography>
|
<Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("ai.common.lastTest")}</Typography>
|
<Typography variant="body2">
|
{record.lastTestTime$ ? `${record.lastTestTime$} · ${truncateText(record.lastTestMessage, 72)}` : translate("ai.mcp.list.noConnectivityTest")}
|
</Typography>
|
<Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("common.field.memo")}</Typography>
|
<Typography variant="body2">{truncateText(record.memo)}</Typography>
|
</CardContent>
|
<CardActions sx={{ px: 2, pb: 2, pt: 0, justifyContent: "space-between", alignItems: "flex-start" }}>
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap>
|
<Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record.id)}>
|
{translate("ai.common.detail")}
|
</Button>
|
<Button size="small" startIcon={<EditOutlinedIcon />} onClick={() => onEdit(record.id)}>
|
{translate("common.button.edit")}
|
</Button>
|
<Button
|
size="small"
|
startIcon={<PlayCircleOutlineOutlinedIcon />}
|
onClick={() => onConnectivityTest(record)}
|
disabled={testingConnectivityId === record.id}
|
>
|
{testingConnectivityId === record.id ? translate("ai.common.testing") : translate("ai.mcp.list.connectivityTest")}
|
</Button>
|
</Stack>
|
<Button
|
size="small"
|
color="error"
|
startIcon={<DeleteOutlineOutlinedIcon />}
|
onClick={() => onDelete(record)}
|
disabled={deleting}
|
>
|
{translate("ai.common.delete")}
|
</Button>
|
</CardActions>
|
</Card>
|
</Grid>
|
);
|
})}
|
</Grid>
|
</Box>
|
))}
|
</Stack>
|
</Box>
|
);
|
};
|
|
const AiMcpMountList = () => {
|
const translate = useTranslate();
|
const notify = useNotify();
|
const refresh = useRefresh();
|
const [deleteOne, { isPending: deleting }] = useDelete();
|
const [dialogState, setDialogState] = useState({ open: false, mode: "create", recordId: null });
|
const [testingConnectivityId, setTestingConnectivityId] = useState(null);
|
|
const openDialog = (mode, recordId = null) => setDialogState({ open: true, mode, recordId });
|
const closeDialog = () => setDialogState({ open: false, mode: "create", recordId: null });
|
|
const handleDelete = (record) => {
|
if (!record?.id || !window.confirm(translate("ai.common.confirmDelete", { name: record.name }))) {
|
return;
|
}
|
deleteOne(
|
"aiMcpMount",
|
{ id: record.id },
|
{
|
onSuccess: () => {
|
notify(translate("ai.common.deleteSuccess"));
|
refresh();
|
},
|
onError: (error) => {
|
notify(error?.message || translate("ai.common.deleteFailed"), { type: "error" });
|
},
|
}
|
);
|
};
|
|
const handleConnectivityTest = async (record) => {
|
if (!record?.id) {
|
return;
|
}
|
setTestingConnectivityId(record.id);
|
try {
|
const result = await testMcpConnectivity(record.id);
|
notify(result?.message || translate("ai.mcp.connectivity.success"));
|
refresh();
|
} catch (error) {
|
notify(error?.message || translate("ai.mcp.connectivity.failed"), { type: "error" });
|
} finally {
|
setTestingConnectivityId(null);
|
}
|
};
|
|
const dialogTitle = {
|
create: translate("ai.mcp.dialog.create"),
|
edit: translate("ai.mcp.dialog.edit"),
|
show: translate("ai.mcp.dialog.show"),
|
}[dialogState.mode];
|
|
return (
|
<>
|
<List
|
title="menu.aiMcpMount"
|
filters={filters}
|
sort={{ field: "sort", order: "asc" }}
|
actions={(
|
<TopToolbar>
|
<FilterButton />
|
<Button variant="contained" startIcon={<AddRoundedIcon />} onClick={() => openDialog("create")}>
|
{translate("ai.common.new")}
|
</Button>
|
<MyExportButton />
|
</TopToolbar>
|
)}
|
>
|
<AiRuntimeSummary />
|
<AiMcpMountCards
|
onView={(id) => openDialog("show", id)}
|
onEdit={(id) => openDialog("edit", id)}
|
onDelete={handleDelete}
|
onConnectivityTest={handleConnectivityTest}
|
deleting={deleting}
|
testingConnectivityId={testingConnectivityId}
|
/>
|
</List>
|
<AiConfigDialog
|
open={dialogState.open}
|
mode={dialogState.mode}
|
title={dialogTitle}
|
resource="aiMcpMount"
|
recordId={dialogState.recordId}
|
defaultValues={defaultValues}
|
maxWidth="lg"
|
onClose={closeDialog}
|
>
|
<>
|
<AiMcpMountForm readOnly={dialogState.mode === "show"} />
|
{dialogState.mode !== "create" && (
|
<AiMcpMountToolsPanel mountId={dialogState.recordId} />
|
)}
|
</>
|
</AiConfigDialog>
|
</>
|
);
|
};
|
|
export default AiMcpMountList;
|