import React, { useEffect, useMemo, useState } from "react";
|
import {
|
FilterButton,
|
List,
|
SearchInput,
|
SelectInput,
|
TextInput,
|
TopToolbar,
|
useListContext,
|
useNotify,
|
useTranslate,
|
} from "react-admin";
|
import {
|
Alert,
|
Box,
|
Button,
|
Card,
|
CardActions,
|
CardContent,
|
Chip,
|
CircularProgress,
|
Dialog,
|
DialogActions,
|
DialogContent,
|
DialogTitle,
|
Divider,
|
Grid,
|
Stack,
|
Typography,
|
} from "@mui/material";
|
import VisibilityOutlinedIcon from "@mui/icons-material/VisibilityOutlined";
|
import { getAiCallLogMcpLogs, getAiObserveStats } from "@/api/ai/observe";
|
|
const filters = [
|
<SearchInput source="condition" alwaysOn />,
|
<TextInput source="requestId" label="ai.observe.fields.requestId" />,
|
<TextInput source="promptCode" label="ai.observe.fields.promptCode" />,
|
<TextInput source="userId" label="ai.observe.fields.userId" />,
|
<SelectInput
|
source="status"
|
label="common.field.status"
|
choices={[
|
{ id: "RUNNING", name: "RUNNING" },
|
{ id: "COMPLETED", name: "COMPLETED" },
|
{ id: "FAILED", name: "FAILED" },
|
{ id: "ABORTED", name: "ABORTED" },
|
]}
|
/>,
|
];
|
|
const ObserveSummary = () => {
|
const translate = useTranslate();
|
const [stats, setStats] = useState(null);
|
const [loading, setLoading] = useState(true);
|
const [error, setError] = useState("");
|
|
useEffect(() => {
|
let active = true;
|
setLoading(true);
|
getAiObserveStats()
|
.then((data) => {
|
if (active) {
|
setStats(data);
|
setError("");
|
}
|
})
|
.catch((err) => {
|
if (active) {
|
setError(err?.message || translate("ai.observe.summary.fetchFailed"));
|
}
|
})
|
.finally(() => {
|
if (active) {
|
setLoading(false);
|
}
|
});
|
return () => {
|
active = false;
|
};
|
}, []);
|
|
return (
|
<Box px={2} pt={2}>
|
<Card variant="outlined" sx={{ borderRadius: 3, boxShadow: "0 8px 24px rgba(15, 23, 42, 0.06)" }}>
|
<CardContent>
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={2}>
|
<Box>
|
<Typography variant="h6">{translate("ai.observe.summary.title")}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.summary.description")}
|
</Typography>
|
</Box>
|
{loading && <CircularProgress size={24} />}
|
</Stack>
|
{error && <Alert severity="error">{error}</Alert>}
|
{!loading && !error && stats && (
|
<Grid container spacing={2}>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.callCount")}</Typography>
|
<Typography variant="h5">{stats.callCount ?? 0}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.summary.successFailure", { success: stats.successCount ?? 0, failure: stats.failureCount ?? 0 })}
|
</Typography>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.avgElapsed")}</Typography>
|
<Typography variant="h5">{stats.avgElapsedMs ?? 0} ms</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.summary.firstToken", { value: stats.avgFirstTokenLatencyMs ?? 0 })}
|
</Typography>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.tokenUsage")}</Typography>
|
<Typography variant="h5">{stats.totalTokens ?? 0}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.summary.avgToken", { value: stats.avgTotalTokens ?? 0 })}
|
</Typography>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.summary.toolSuccessRate")}</Typography>
|
<Typography variant="h5">{Number(stats.toolSuccessRate || 0).toFixed(2)}%</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.summary.toolCallFailure", { call: stats.toolCallCount ?? 0, failure: stats.toolFailureCount ?? 0 })}
|
</Typography>
|
</Grid>
|
</Grid>
|
)}
|
</CardContent>
|
</Card>
|
</Box>
|
);
|
};
|
|
const resolveStatusChip = (status, translate) => {
|
if (status === "COMPLETED") {
|
return { color: "success", label: translate("ai.observe.status.completed") };
|
}
|
if (status === "FAILED") {
|
return { color: "error", label: translate("ai.observe.status.failed") };
|
}
|
if (status === "ABORTED") {
|
return { color: "warning", label: translate("ai.observe.status.aborted") };
|
}
|
return { color: "default", label: status || "--" };
|
};
|
|
const AiCallLogDetailDialog = ({ record, open, onClose }) => {
|
const notify = useNotify();
|
const translate = useTranslate();
|
const [logs, setLogs] = useState([]);
|
const [loading, setLoading] = useState(false);
|
|
useEffect(() => {
|
if (!open || !record?.id) {
|
return;
|
}
|
let active = true;
|
setLoading(true);
|
getAiCallLogMcpLogs(record.id)
|
.then((data) => {
|
if (active) {
|
setLogs(data);
|
}
|
})
|
.catch((error) => {
|
if (active) {
|
notify(error?.message || translate("ai.observe.detail.mcpLogsFailed"), { type: "error" });
|
}
|
})
|
.finally(() => {
|
if (active) {
|
setLoading(false);
|
}
|
});
|
return () => {
|
active = false;
|
};
|
}, [open, record, notify]);
|
|
if (!record) {
|
return null;
|
}
|
|
return (
|
<Dialog open={open} onClose={onClose} fullWidth maxWidth="lg">
|
<DialogTitle>{translate("ai.observe.detail.title")}</DialogTitle>
|
<DialogContent dividers>
|
<Grid container spacing={2}>
|
<Grid item xs={12} md={6}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.requestId")}</Typography>
|
<Typography variant="body2">{record.requestId || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.userId")}</Typography>
|
<Typography variant="body2">{record.userId || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12} md={3}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.sessionId")}</Typography>
|
<Typography variant="body2">{record.sessionId || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12} md={4}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.common.prompt")}</Typography>
|
<Typography variant="body2">{record.promptName || "--"} / {record.promptCode || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12} md={4}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.common.model")}</Typography>
|
<Typography variant="body2">{record.model || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12} md={4}>
|
<Typography variant="caption" color="text.secondary">{translate("common.field.status")}</Typography>
|
<Typography variant="body2">{record.status || "--"}</Typography>
|
</Grid>
|
<Grid item xs={12}>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.mountedMcp")}</Typography>
|
<Typography variant="body2">{record.mountedMcpNames || "--"}</Typography>
|
</Grid>
|
{record.errorMessage && (
|
<Grid item xs={12}>
|
<Alert severity="error">{record.errorMessage}</Alert>
|
</Grid>
|
)}
|
<Grid item xs={12}>
|
<Divider sx={{ my: 1 }} />
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
|
<Typography variant="h6">{translate("ai.observe.detail.mcpLogs")}</Typography>
|
{loading && <CircularProgress size={20} />}
|
</Stack>
|
{!loading && !logs.length && (
|
<Typography variant="body2" color="text.secondary">
|
{translate("ai.observe.detail.noMcpLogs")}
|
</Typography>
|
)}
|
<Stack spacing={1.5}>
|
{logs.map((item) => (
|
<Card key={item.id} variant="outlined">
|
<CardContent>
|
<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
|
<Box>
|
<Typography variant="subtitle2">{item.toolName || "--"}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{item.mountName || "--"} · {item.createTime$ || "--"}
|
</Typography>
|
</Box>
|
<Chip
|
size="small"
|
color={item.status === "COMPLETED" ? "success" : "error"}
|
label={item.status || "--"}
|
/>
|
</Stack>
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.detail.inputSummary")}</Typography>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
{item.inputSummary || "--"}
|
</Typography>
|
<Typography variant="caption" color="text.secondary" display="block" mt={1}>{translate("ai.observe.detail.outputSummary")}</Typography>
|
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
|
{item.outputSummary || item.errorMessage || "--"}
|
</Typography>
|
</CardContent>
|
</Card>
|
))}
|
</Stack>
|
</Grid>
|
</Grid>
|
</DialogContent>
|
<DialogActions>
|
<Button onClick={onClose}>{translate("ai.common.close")}</Button>
|
</DialogActions>
|
</Dialog>
|
);
|
};
|
|
const AiCallLogCards = ({ onView }) => {
|
const translate = useTranslate();
|
const { data, isLoading } = useListContext();
|
const records = useMemo(() => (Array.isArray(data) ? data : []), [data]);
|
|
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.observe.list.emptyTitle")}</Typography>
|
<Typography variant="body2" color="text.secondary" mt={1}>
|
{translate("ai.observe.list.emptyDescription")}
|
</Typography>
|
</Card>
|
</Box>
|
);
|
}
|
|
return (
|
<Box px={2} py={2}>
|
<Grid container spacing={2}>
|
{records.map((record) => {
|
const statusMeta = resolveStatusChip(record.status, 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">{record.promptName || translate("ai.drawer.title")}</Typography>
|
<Typography variant="body2" color="text.secondary">
|
{record.promptCode || "--"} / {record.model || "--"}
|
</Typography>
|
</Box>
|
<Chip size="small" color={statusMeta.color} label={statusMeta.label} />
|
</Stack>
|
<Stack direction="row" spacing={1} flexWrap="wrap" useFlexGap mt={1.5}>
|
<Chip size="small" variant="outlined" label={translate("ai.observe.list.userValue", { value: record.userId || "--" })} />
|
<Chip size="small" variant="outlined" label={translate("ai.observe.list.elapsedValue", { value: record.elapsedMs ?? 0 })} />
|
<Chip size="small" variant="outlined" label={translate("ai.observe.list.tokenValue", { value: record.totalTokens ?? 0 })} />
|
</Stack>
|
<Divider sx={{ my: 1.5 }} />
|
<Typography variant="caption" color="text.secondary">{translate("ai.observe.fields.requestId")}</Typography>
|
<Typography variant="body2" sx={{ wordBreak: "break-all" }}>{record.requestId || "--"}</Typography>
|
<Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("ai.observe.list.mcpToolCalls")}</Typography>
|
<Typography variant="body2">
|
{translate("ai.observe.list.mcpToolSummary", {
|
mcp: record.mountedMcpCount ?? 0,
|
success: record.toolSuccessCount ?? 0,
|
failure: record.toolFailureCount ?? 0,
|
})}
|
</Typography>
|
{record.errorMessage && (
|
<>
|
<Typography variant="caption" color="text.secondary" display="block" mt={1.5}>{translate("ai.common.error")}</Typography>
|
<Typography variant="body2">{record.errorMessage}</Typography>
|
</>
|
)}
|
</CardContent>
|
<CardActions sx={{ px: 2, pb: 2, pt: 0 }}>
|
<Button size="small" startIcon={<VisibilityOutlinedIcon />} onClick={() => onView(record)}>
|
{translate("ai.common.detail")}
|
</Button>
|
</CardActions>
|
</Card>
|
</Grid>
|
);
|
})}
|
</Grid>
|
</Box>
|
);
|
};
|
|
const AiCallLogList = () => {
|
const [selectedRecord, setSelectedRecord] = useState(null);
|
|
return (
|
<>
|
<List
|
title="menu.aiCallLog"
|
filters={filters}
|
sort={{ field: "create_time", order: "desc" }}
|
actions={(
|
<TopToolbar>
|
<FilterButton />
|
</TopToolbar>
|
)}
|
>
|
<ObserveSummary />
|
<AiCallLogCards onView={setSelectedRecord} />
|
</List>
|
<AiCallLogDetailDialog
|
record={selectedRecord}
|
open={Boolean(selectedRecord)}
|
onClose={() => setSelectedRecord(null)}
|
/>
|
</>
|
);
|
};
|
|
export default AiCallLogList;
|