mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-10 17:43:15 +08:00
✨ feat(jvm-ui): 完善 JVM 工作台与监控入口
- 新增 JVM 持续监控仪表盘、图表、状态卡和详情面板 - 统一概览、资源浏览、审计页面的 JVM 工作台布局 - Sidebar 和 TabManager 支持监控入口、诊断入口兜底和上下文切换 - 补充前端状态模型、展示文案和组件回归测试
This commit is contained in:
48
frontend/src/components/JVMAuditViewer.test.tsx
Normal file
48
frontend/src/components/JVMAuditViewer.test.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMAuditViewer from "./JVMAuditViewer";
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "endpoint",
|
||||
readOnly: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
theme: "light",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JVMAuditViewer", () => {
|
||||
it("renders a unified JVM workspace audit shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMAuditViewer
|
||||
tab={{
|
||||
id: "tab-jvm-audit",
|
||||
type: "jvm-audit",
|
||||
title: "[orders-jvm] JVM 审计",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "endpoint",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||
expect(markup).toContain("JVM 变更审计");
|
||||
expect(markup).toContain("审计记录");
|
||||
expect(markup).toContain("最近 50 条");
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,11 @@ import {
|
||||
resolveJVMAuditResultColor,
|
||||
} from "../utils/jvmResourcePresentation";
|
||||
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||
import {
|
||||
getJVMWorkspaceCardStyle,
|
||||
JVMWorkspaceHero,
|
||||
JVMWorkspaceShell,
|
||||
} from "./jvm/JVMWorkspaceLayout";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
@@ -74,6 +79,8 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
const theme = useStore((state) => state.theme);
|
||||
const darkMode = theme === "dark";
|
||||
const [limit, setLimit] = useState(50);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
|
||||
@@ -197,18 +204,26 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const activeMode =
|
||||
tab.providerMode || connection.config.jvm?.preferredMode || "jmx";
|
||||
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: "grid", gap: 16 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Space size={12} wrap>
|
||||
<JVMModeBadge
|
||||
mode={
|
||||
tab.providerMode ||
|
||||
connection.config.jvm?.preferredMode ||
|
||||
"jmx"
|
||||
}
|
||||
/>
|
||||
<JVMWorkspaceShell darkMode={darkMode}>
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Audit"
|
||||
title="JVM 变更审计"
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary"> · {connection.id}</Text>
|
||||
<Text type="secondary"> · 当前范围:最近 {limit} 条</Text>
|
||||
</>
|
||||
}
|
||||
badges={<JVMModeBadge mode={activeMode} />}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -224,17 +239,13 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
value: item,
|
||||
label: `最近 ${item} 条`,
|
||||
}))}
|
||||
style={{ width: 128 }}
|
||||
style={{ width: 132 }}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={8} wrap>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary">{connection.id}</Text>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="审计记录">
|
||||
<Card title="审计记录" variant="borderless" style={cardStyle}>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
<Table<JVMAuditRecord>
|
||||
@@ -253,7 +264,7 @@ const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
</div>
|
||||
</JVMWorkspaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
85
frontend/src/components/JVMMonitoringDashboard.test.tsx
Normal file
85
frontend/src/components/JVMMonitoringDashboard.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMMonitoringDashboard from "./JVMMonitoringDashboard";
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
theme: "light",
|
||||
connections: [
|
||||
{
|
||||
id: "conn-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "orders.internal",
|
||||
port: 9010,
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
allowedModes: ["jmx"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JVMMonitoringDashboard", () => {
|
||||
it("shows start action and empty-state guidance before monitoring starts", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-1",
|
||||
title: "持续监控",
|
||||
type: "jvm-monitoring",
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("开始监控");
|
||||
expect(markup).toContain("当前尚未开始持续监控");
|
||||
expect(markup).toContain("堆内存");
|
||||
expect(markup).toContain("暂无堆内存采样数据");
|
||||
expect(markup).not.toContain("暂无 Heap 采样数据");
|
||||
expect(markup).not.toContain("当前 provider 未提供 Heap 指标");
|
||||
});
|
||||
|
||||
it("renders a dedicated vertical scroll shell for tall monitoring content", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-scroll",
|
||||
title: "持续监控",
|
||||
type: "jvm-monitoring",
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-monitoring-dashboard-scroll-shell="true"');
|
||||
expect(markup).toContain("height:100%");
|
||||
expect(markup).toContain("overflow-y:auto");
|
||||
});
|
||||
|
||||
it("stacks monitoring charts before detail panels so charts keep full content width", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDashboard
|
||||
tab={{
|
||||
id: "tab-monitor-layout",
|
||||
title: "持续监控",
|
||||
type: "jvm-monitoring",
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-monitoring-content-stack="true"');
|
||||
expect(markup).toContain("gap:24px");
|
||||
expect(markup).not.toContain("minmax(min(100%, 320px), 1fr)");
|
||||
});
|
||||
});
|
||||
392
frontend/src/components/JVMMonitoringDashboard.tsx
Normal file
392
frontend/src/components/JVMMonitoringDashboard.tsx
Normal file
@@ -0,0 +1,392 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Button, Card, Empty, Space, Spin, Tag, Typography } from "antd";
|
||||
import { DashboardOutlined, PauseCircleOutlined, PlayCircleOutlined, ReloadOutlined } from "@ant-design/icons";
|
||||
|
||||
import { useStore } from "../store";
|
||||
import type { JVMMonitoringSessionState, TabData } from "../types";
|
||||
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import {
|
||||
buildMonitoringAvailabilityText,
|
||||
normalizeMonitoringProviderMode,
|
||||
type JVMMonitoringProviderMode,
|
||||
} from "../utils/jvmMonitoringPresentation";
|
||||
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
|
||||
import JVMMonitoringCharts from "./jvm/JVMMonitoringCharts";
|
||||
import JVMMonitoringDetailPanel from "./jvm/JVMMonitoringDetailPanel";
|
||||
import JVMMonitoringStatusCards from "./jvm/JVMMonitoringStatusCards";
|
||||
|
||||
const { Paragraph, Text, Title } = Typography;
|
||||
|
||||
const POLL_INTERVAL_MS = 2000;
|
||||
|
||||
type JVMMonitoringDashboardProps = {
|
||||
tab: TabData;
|
||||
};
|
||||
|
||||
const isMonitoringSessionMissing = (message: string): boolean =>
|
||||
/monitoring session not found/i.test(String(message || ""));
|
||||
|
||||
const createEmptySession = (
|
||||
connectionId: string,
|
||||
providerMode: JVMMonitoringProviderMode,
|
||||
): JVMMonitoringSessionState => ({
|
||||
connectionId,
|
||||
providerMode,
|
||||
running: false,
|
||||
points: [],
|
||||
recentGcEvents: [],
|
||||
availableMetrics: [],
|
||||
missingMetrics: [],
|
||||
providerWarnings: [],
|
||||
});
|
||||
|
||||
const normalizeMonitoringSession = (
|
||||
payload: any,
|
||||
connectionId: string,
|
||||
providerMode: JVMMonitoringProviderMode,
|
||||
): JVMMonitoringSessionState => ({
|
||||
connectionId: String(payload?.connectionId || connectionId),
|
||||
providerMode: normalizeMonitoringProviderMode(payload?.providerMode, providerMode),
|
||||
running: payload?.running === true,
|
||||
points: Array.isArray(payload?.points) ? payload.points : [],
|
||||
recentGcEvents: Array.isArray(payload?.recentGcEvents) ? payload.recentGcEvents : [],
|
||||
availableMetrics: Array.isArray(payload?.availableMetrics)
|
||||
? payload.availableMetrics
|
||||
: [],
|
||||
missingMetrics: Array.isArray(payload?.missingMetrics) ? payload.missingMetrics : [],
|
||||
providerWarnings: Array.isArray(payload?.providerWarnings)
|
||||
? payload.providerWarnings
|
||||
: [],
|
||||
});
|
||||
|
||||
const resolveBackendApp = () =>
|
||||
typeof window === "undefined" ? undefined : (window as any).go?.app?.App;
|
||||
|
||||
const JVMMonitoringDashboard: React.FC<JVMMonitoringDashboardProps> = ({ tab }) => {
|
||||
const theme = useStore((state) => state.theme);
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
const darkMode = theme === "dark";
|
||||
const providerMode = normalizeMonitoringProviderMode(
|
||||
tab.providerMode,
|
||||
normalizeMonitoringProviderMode(connection?.config.jvm?.preferredMode, "jmx"),
|
||||
);
|
||||
const [session, setSession] = useState<JVMMonitoringSessionState>(() =>
|
||||
createEmptySession(tab.connectionId, providerMode),
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [pollSeed, setPollSeed] = useState(0);
|
||||
|
||||
const rpcConnectionConfig = useMemo(() => {
|
||||
if (!connection) {
|
||||
return null;
|
||||
}
|
||||
return buildRpcConnectionConfig(connection.config, {
|
||||
database: "",
|
||||
jvm: {
|
||||
...(connection.config.jvm || {}),
|
||||
preferredMode: providerMode,
|
||||
allowedModes: [providerMode],
|
||||
},
|
||||
});
|
||||
}, [connection, providerMode]);
|
||||
|
||||
const latestPoint = useMemo(() => {
|
||||
const points = session.points || [];
|
||||
return points.length > 0 ? points[points.length - 1] : undefined;
|
||||
}, [session.points]);
|
||||
|
||||
useEffect(() => {
|
||||
setSession(createEmptySession(tab.connectionId, providerMode));
|
||||
}, [tab.connectionId, providerMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connection || !rpcConnectionConfig) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
const backendApp = resolveBackendApp();
|
||||
|
||||
const poll = async () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
if (typeof backendApp?.JVMGetMonitoringHistory !== "function") {
|
||||
setError("JVMGetMonitoringHistory 后端方法不可用");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await backendApp.JVMGetMonitoringHistory(
|
||||
rpcConnectionConfig,
|
||||
providerMode,
|
||||
);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.success === false) {
|
||||
const message = String(result?.message || "读取监控历史失败");
|
||||
if (isMonitoringSessionMissing(message)) {
|
||||
setSession(createEmptySession(tab.connectionId, providerMode));
|
||||
setError("");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const nextSession = normalizeMonitoringSession(
|
||||
result?.data,
|
||||
tab.connectionId,
|
||||
providerMode,
|
||||
);
|
||||
setSession(nextSession);
|
||||
setError("");
|
||||
setLoading(false);
|
||||
|
||||
if (nextSession.running) {
|
||||
timer = setTimeout(poll, POLL_INTERVAL_MS);
|
||||
}
|
||||
} catch (fetchError: any) {
|
||||
if (!cancelled) {
|
||||
setError(fetchError?.message || "读取监控历史失败");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
void poll();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
}, [connection, providerMode, rpcConnectionConfig, tab.connectionId, pollSeed]);
|
||||
|
||||
if (!connection) {
|
||||
return <Empty description="连接不存在或已被删除" style={{ marginTop: 80 }} />;
|
||||
}
|
||||
|
||||
const backendApp = resolveBackendApp();
|
||||
const availabilityText = buildMonitoringAvailabilityText(session);
|
||||
const modeMeta = resolveJVMModeMeta(providerMode);
|
||||
const emptyState = !session.running && (session.points || []).length === 0;
|
||||
|
||||
const handleStart = async () => {
|
||||
if (!rpcConnectionConfig || typeof backendApp?.JVMStartMonitoring !== "function") {
|
||||
setError("JVMStartMonitoring 后端方法不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await backendApp.JVMStartMonitoring(rpcConnectionConfig);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "开始监控失败"));
|
||||
}
|
||||
setSession(
|
||||
normalizeMonitoringSession(result?.data, tab.connectionId, providerMode),
|
||||
);
|
||||
setPollSeed((current) => current + 1);
|
||||
} catch (startError: any) {
|
||||
setError(startError?.message || "开始监控失败");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
if (!rpcConnectionConfig || typeof backendApp?.JVMStopMonitoring !== "function") {
|
||||
setError("JVMStopMonitoring 后端方法不可用");
|
||||
return;
|
||||
}
|
||||
|
||||
setActionLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await backendApp.JVMStopMonitoring(
|
||||
rpcConnectionConfig,
|
||||
providerMode,
|
||||
);
|
||||
if (result?.success === false) {
|
||||
throw new Error(String(result?.message || "停止监控失败"));
|
||||
}
|
||||
setSession((current) => ({ ...current, running: false }));
|
||||
setPollSeed((current) => current + 1);
|
||||
} catch (stopError: any) {
|
||||
setError(stopError?.message || "停止监控失败");
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="jvm-monitoring-dashboard-scroll-shell"
|
||||
data-jvm-monitoring-dashboard-scroll-shell="true"
|
||||
style={{
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 20,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
alignContent: "start",
|
||||
background: darkMode ? "#141414" : "#f5f7fb",
|
||||
}}
|
||||
>
|
||||
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={12}
|
||||
style={{ width: "100%", alignItems: "stretch" }}
|
||||
>
|
||||
<Space size={12} wrap style={{ justifyContent: "space-between" }}>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
<DashboardOutlined style={{ color: "#1677ff", marginRight: 8 }} />
|
||||
JVM 持续监控
|
||||
</Title>
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary">
|
||||
{" "}
|
||||
· {connection.config.host}:{connection.config.port}
|
||||
</Text>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Space wrap>
|
||||
<Tag color={modeMeta.color} style={{ marginInlineEnd: 0 }}>
|
||||
{modeMeta.label}
|
||||
</Tag>
|
||||
{session.running ? (
|
||||
<Tag color="green">采样中</Tag>
|
||||
) : (
|
||||
<Tag>未运行</Tag>
|
||||
)}
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
onClick={() => setPollSeed((current) => current + 1)}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
{session.running ? (
|
||||
<Button
|
||||
danger
|
||||
type="primary"
|
||||
icon={<PauseCircleOutlined />}
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStop()}
|
||||
>
|
||||
停止监控
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
开始监控
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Space>
|
||||
|
||||
{(session.missingMetrics?.length || session.providerWarnings?.length) ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="监控能力存在降级"
|
||||
description={availabilityText}
|
||||
/>
|
||||
) : null}
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
{loading && emptyState ? (
|
||||
<div style={{ display: "flex", justifyContent: "center", padding: "24px 0" }}>
|
||||
<Spin />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{emptyState ? (
|
||||
<div
|
||||
data-jvm-monitoring-content-stack="true"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 24,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<Card variant="borderless" style={{ borderRadius: 12 }}>
|
||||
<Empty
|
||||
description="当前尚未开始持续监控"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
>
|
||||
<Paragraph type="secondary" style={{ maxWidth: 520, margin: "0 auto 16px" }}>
|
||||
点击“开始监控”后,GoNavi 会在当前会话内持续保留该连接的采样结果;切换页签不会停止采样。
|
||||
</Paragraph>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlayCircleOutlined />}
|
||||
loading={actionLoading}
|
||||
onClick={() => void handleStart()}
|
||||
>
|
||||
开始监控
|
||||
</Button>
|
||||
</Empty>
|
||||
</Card>
|
||||
<JVMMonitoringCharts
|
||||
points={session.points || []}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
data-jvm-monitoring-content-stack="true"
|
||||
style={{
|
||||
display: "grid",
|
||||
gap: 24,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<JVMMonitoringStatusCards
|
||||
latestPoint={latestPoint}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
<JVMMonitoringCharts
|
||||
points={session.points || []}
|
||||
session={session}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
latestPoint={latestPoint}
|
||||
darkMode={darkMode}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default JVMMonitoringDashboard;
|
||||
65
frontend/src/components/JVMOverview.test.tsx
Normal file
65
frontend/src/components/JVMOverview.test.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMOverview from "./JVMOverview";
|
||||
|
||||
vi.mock("../../wailsjs/go/app/App", () => ({
|
||||
JVMProbeCapabilities: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../store", () => ({
|
||||
useStore: (selector: (state: any) => any) =>
|
||||
selector({
|
||||
connections: [
|
||||
{
|
||||
id: "conn-jvm-1",
|
||||
name: "orders-jvm",
|
||||
config: {
|
||||
host: "localhost",
|
||||
port: 10990,
|
||||
jvm: {
|
||||
preferredMode: "jmx",
|
||||
allowedModes: ["jmx", "endpoint", "agent"],
|
||||
readOnly: true,
|
||||
environment: "dev",
|
||||
endpoint: {
|
||||
enabled: true,
|
||||
baseUrl: "http://localhost:8080/actuator",
|
||||
},
|
||||
agent: {
|
||||
enabled: true,
|
||||
baseUrl: "http://localhost:8563",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
theme: "light",
|
||||
}),
|
||||
}));
|
||||
|
||||
describe("JVMOverview", () => {
|
||||
it("renders a unified JVM workspace overview shell", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMOverview
|
||||
tab={{
|
||||
id: "tab-jvm-overview",
|
||||
type: "jvm-overview",
|
||||
title: "[orders-jvm] JVM 概览",
|
||||
connectionId: "conn-jvm-1",
|
||||
providerMode: "jmx",
|
||||
} as any}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||
expect(markup).toContain("JVM 运行时概览");
|
||||
expect(markup).toContain("连接摘要");
|
||||
expect(markup).toContain("模式能力");
|
||||
expect(markup).toContain("JMX 地址");
|
||||
expect(markup).toContain("Endpoint");
|
||||
expect(markup).toContain("Agent");
|
||||
});
|
||||
});
|
||||
@@ -16,8 +16,13 @@ import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
|
||||
import { resolveJVMModeMeta } from "../utils/jvmRuntimePresentation";
|
||||
import type { JVMCapability, TabData } from "../types";
|
||||
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||
import {
|
||||
getJVMWorkspaceCardStyle,
|
||||
JVMWorkspaceHero,
|
||||
JVMWorkspaceShell,
|
||||
} from "./jvm/JVMWorkspaceLayout";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||
|
||||
type JVMOverviewProps = {
|
||||
@@ -28,6 +33,8 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
const connection = useStore((state) =>
|
||||
state.connections.find((item) => item.id === tab.connectionId),
|
||||
);
|
||||
const theme = useStore((state) => state.theme);
|
||||
const darkMode = theme === "dark";
|
||||
const providerMode =
|
||||
tab.providerMode || connection?.config.jvm?.preferredMode || "jmx";
|
||||
const readOnly = connection?.config.jvm?.readOnly !== false;
|
||||
@@ -119,28 +126,35 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
const jmxHost = connection.config.jvm?.jmx?.host || connection.config.host;
|
||||
const jmxPort = connection.config.jvm?.jmx?.port || connection.config.port;
|
||||
|
||||
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 20, display: "grid", gap: 16 }}>
|
||||
<Card>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Space size={12} wrap>
|
||||
<JVMModeBadge mode={providerMode} />
|
||||
<Tag color={readOnly ? "blue" : "red"}>
|
||||
{readOnly ? "只读连接" : "可写连接"}
|
||||
</Tag>
|
||||
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
|
||||
</Space>
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
<JVMWorkspaceShell darkMode={darkMode}>
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Runtime"
|
||||
title="JVM 运行时概览"
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary">
|
||||
{" "}
|
||||
· {connection.config.host}:{connection.config.port}
|
||||
</Text>
|
||||
</Paragraph>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
<JVMModeBadge mode={providerMode} />
|
||||
<Tag color={readOnly ? "blue" : "red"}>
|
||||
{readOnly ? "只读连接" : "可写连接"}
|
||||
</Tag>
|
||||
<Tag>{connection.config.jvm?.environment || "dev"}</Tag>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="连接摘要">
|
||||
<Card title="连接摘要" variant="borderless" style={cardStyle}>
|
||||
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
|
||||
<Descriptions.Item label="当前模式">
|
||||
{resolveJVMModeMeta(providerMode).label}
|
||||
@@ -161,7 +175,7 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="模式能力">
|
||||
<Card title="模式能力" variant="borderless" style={cardStyle}>
|
||||
{capabilityLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : capabilityError ? (
|
||||
@@ -218,7 +232,7 @@ const JVMOverview: React.FC<JVMOverviewProps> = ({ tab }) => {
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</JVMWorkspaceShell>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -69,8 +69,12 @@ describe('JVMResourceBrowser layout', () => {
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-jvm-resource-browser-scroll-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-shell="true"');
|
||||
expect(markup).toContain('data-jvm-workspace-hero="true"');
|
||||
expect(markup).toContain('data-jvm-resource-workbench="true"');
|
||||
expect(markup).toContain('height:100%');
|
||||
expect(markup).toContain('overflow-y:auto');
|
||||
expect(markup).toContain('grid-template-columns:minmax(0, 1fr) minmax(360px, 440px)');
|
||||
});
|
||||
|
||||
it('shows the draft action field with a Chinese label', () => {
|
||||
|
||||
@@ -47,8 +47,13 @@ import {
|
||||
import { buildJVMTabTitle } from "../utils/jvmRuntimePresentation";
|
||||
import JVMModeBadge from "./jvm/JVMModeBadge";
|
||||
import JVMChangePreviewModal from "./jvm/JVMChangePreviewModal";
|
||||
import {
|
||||
getJVMWorkspaceCardStyle,
|
||||
JVMWorkspaceHero,
|
||||
JVMWorkspaceShell,
|
||||
} from "./jvm/JVMWorkspaceLayout";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
const { Text } = Typography;
|
||||
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
|
||||
const { TextArea } = Input;
|
||||
const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
|
||||
@@ -583,6 +588,8 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle = getJVMWorkspaceCardStyle(darkMode);
|
||||
|
||||
return (
|
||||
<>
|
||||
<style>{`
|
||||
@@ -603,28 +610,37 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
.jvm-resource-browser-code-block::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@media (max-width: 1120px) {
|
||||
.jvm-resource-workbench {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
}
|
||||
`}</style>
|
||||
<div
|
||||
<JVMWorkspaceShell
|
||||
darkMode={darkMode}
|
||||
className="jvm-resource-browser-scroll-shell"
|
||||
data-jvm-resource-browser-scroll-shell="true"
|
||||
style={{
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 20,
|
||||
display: "grid",
|
||||
gap: 16,
|
||||
alignContent: "start",
|
||||
}}
|
||||
>
|
||||
<Card>
|
||||
<Space direction="vertical" size={12} style={{ width: "100%" }}>
|
||||
<Space size={12} wrap>
|
||||
<JVMWorkspaceHero
|
||||
darkMode={darkMode}
|
||||
eyebrow="JVM Resource"
|
||||
title="JVM 资源工作台"
|
||||
description={
|
||||
<>
|
||||
<Text strong>{connection.name}</Text>
|
||||
<Text type="secondary"> · {resourcePath || "-"}</Text>
|
||||
</>
|
||||
}
|
||||
badges={
|
||||
<>
|
||||
<JVMModeBadge mode={providerMode} />
|
||||
<Tag color={readOnly ? "blue" : "red"}>
|
||||
{readOnly ? "只读连接" : "可写连接"}
|
||||
</Tag>
|
||||
</>
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -646,99 +662,80 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
>
|
||||
AI 生成计划
|
||||
</Button>
|
||||
</Space>
|
||||
<Paragraph style={{ marginBottom: 0 }}>
|
||||
<Text strong>{connection.name}</Text>
|
||||
</Paragraph>
|
||||
<Text type="secondary">{resourcePath || "-"}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card title="资源快照">
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
{snapshot ? (
|
||||
<>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
styles={DESCRIPTION_STYLES}
|
||||
>
|
||||
<Descriptions.Item label="资源 ID">
|
||||
{snapshot.resourceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源类型">
|
||||
{snapshot.kind || tab.resourceKind || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="格式">
|
||||
{snapshot.format || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
{snapshot.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可用动作">
|
||||
{formatJVMActionSummary(supportedActions)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{snapshot.description ? (
|
||||
<Text type="secondary">{snapshot.description}</Text>
|
||||
) : null}
|
||||
<div>
|
||||
<Text strong style={{ display: "block", marginBottom: 8 }}>
|
||||
资源值
|
||||
</Text>
|
||||
<div
|
||||
className="jvm-resource-browser-code-block"
|
||||
style={{
|
||||
...snapshotBlockStyle("rgba(0, 0, 0, 0.04)"),
|
||||
height: estimateJVMResourceEditorHeight(displayValue),
|
||||
}}
|
||||
<div
|
||||
className="jvm-resource-workbench"
|
||||
data-jvm-resource-workbench="true"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "minmax(0, 1fr) minmax(360px, 440px)",
|
||||
gap: 18,
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
title="资源快照"
|
||||
variant="borderless"
|
||||
style={{
|
||||
...cardStyle,
|
||||
gridColumn: readOnly ? "1 / -1" : undefined,
|
||||
}}
|
||||
>
|
||||
{loading ? (
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
) : (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{error ? <Alert type="error" showIcon message={error} /> : null}
|
||||
{snapshot ? (
|
||||
<>
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
styles={DESCRIPTION_STYLES}
|
||||
>
|
||||
<Editor
|
||||
height="100%"
|
||||
language={displayLanguage}
|
||||
theme={
|
||||
darkMode ? "transparent-dark" : "transparent-light"
|
||||
}
|
||||
value={displayValue}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: "on",
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
folding: true,
|
||||
renderValidationDecorations: "off",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{metadataText ? (
|
||||
<Descriptions.Item label="资源 ID">
|
||||
{snapshot.resourceId || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源类型">
|
||||
{snapshot.kind || tab.resourceKind || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="格式">
|
||||
{snapshot.format || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="版本">
|
||||
{snapshot.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="可用动作">
|
||||
{formatJVMActionSummary(supportedActions)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{snapshot.description ? (
|
||||
<Text type="secondary">{snapshot.description}</Text>
|
||||
) : null}
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ display: "block", marginBottom: 8 }}
|
||||
>
|
||||
元数据
|
||||
资源值
|
||||
</Text>
|
||||
<div
|
||||
className="jvm-resource-browser-code-block"
|
||||
style={{
|
||||
...snapshotBlockStyle("rgba(0, 0, 0, 0.03)"),
|
||||
height: estimateJVMResourceEditorHeight(metadataText),
|
||||
...snapshotBlockStyle("rgba(0, 0, 0, 0.04)"),
|
||||
height: estimateJVMResourceEditorHeight(displayValue),
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
height="100%"
|
||||
language={metadataLanguage}
|
||||
language={displayLanguage}
|
||||
theme={
|
||||
darkMode ? "transparent-dark" : "transparent-light"
|
||||
}
|
||||
value={metadataText}
|
||||
value={displayValue}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
@@ -752,136 +749,183 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : error ? null : (
|
||||
<Empty description="暂无资源数据" />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
{metadataText ? (
|
||||
<div>
|
||||
<Text
|
||||
strong
|
||||
style={{ display: "block", marginBottom: 8 }}
|
||||
>
|
||||
元数据
|
||||
</Text>
|
||||
<div
|
||||
className="jvm-resource-browser-code-block"
|
||||
style={{
|
||||
...snapshotBlockStyle("rgba(0, 0, 0, 0.03)"),
|
||||
height:
|
||||
estimateJVMResourceEditorHeight(metadataText),
|
||||
}}
|
||||
>
|
||||
<Editor
|
||||
height="100%"
|
||||
language={metadataLanguage}
|
||||
theme={
|
||||
darkMode
|
||||
? "transparent-dark"
|
||||
: "transparent-light"
|
||||
}
|
||||
value={metadataText}
|
||||
options={{
|
||||
readOnly: true,
|
||||
minimap: { enabled: false },
|
||||
lineNumbers: "on",
|
||||
wordWrap: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
automaticLayout: true,
|
||||
folding: true,
|
||||
renderValidationDecorations: "off",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
) : error ? null : (
|
||||
<Empty description="暂无资源数据" />
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{!readOnly ? (
|
||||
<Card title="变更草稿">
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{draftError ? (
|
||||
<Alert type="error" showIcon message={draftError} />
|
||||
) : null}
|
||||
{applyMessage ? (
|
||||
<Alert type="success" showIcon message={applyMessage} />
|
||||
) : null}
|
||||
<Descriptions column={1} size="small" styles={DESCRIPTION_STYLES}>
|
||||
<Descriptions.Item label="资源路径">
|
||||
{resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标资源">
|
||||
{draftResourceId || resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源版本">
|
||||
{snapshot?.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="草稿来源">
|
||||
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{supportedActions.length > 0 ? (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>资源支持动作</Text>
|
||||
<Space size={8} wrap>
|
||||
{supportedActions.map((item) => (
|
||||
<Button
|
||||
key={item.action}
|
||||
size="small"
|
||||
type={action === item.action ? "primary" : "default"}
|
||||
danger={item.dangerous}
|
||||
onClick={() => handleSelectAction(item.action, item)}
|
||||
>
|
||||
{resolveJVMActionDisplay(item).label}
|
||||
</Button>
|
||||
))}
|
||||
{!readOnly ? (
|
||||
<Card title="变更草稿" variant="borderless" style={cardStyle}>
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
{draftError ? (
|
||||
<Alert type="error" showIcon message={draftError} />
|
||||
) : null}
|
||||
{applyMessage ? (
|
||||
<Alert type="success" showIcon message={applyMessage} />
|
||||
) : null}
|
||||
<Descriptions
|
||||
column={1}
|
||||
size="small"
|
||||
styles={DESCRIPTION_STYLES}
|
||||
>
|
||||
<Descriptions.Item label="资源路径">
|
||||
{resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="目标资源">
|
||||
{draftResourceId || resourcePath || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="资源版本">
|
||||
{snapshot?.version || "-"}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="草稿来源">
|
||||
{draftSource === "ai-plan" ? "AI 辅助草稿" : "手工编辑"}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{supportedActions.length > 0 ? (
|
||||
<Space
|
||||
direction="vertical"
|
||||
size={8}
|
||||
style={{ width: "100%" }}
|
||||
>
|
||||
<Text strong>资源支持动作</Text>
|
||||
<Space size={8} wrap>
|
||||
{supportedActions.map((item) => (
|
||||
<Button
|
||||
key={item.action}
|
||||
size="small"
|
||||
type={action === item.action ? "primary" : "default"}
|
||||
danger={item.dangerous}
|
||||
onClick={() => handleSelectAction(item.action, item)}
|
||||
>
|
||||
{resolveJVMActionDisplay(item).label}
|
||||
</Button>
|
||||
))}
|
||||
</Space>
|
||||
{selectedActionDisplay.description ? (
|
||||
<Text type="secondary">
|
||||
{selectedActionDisplay.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{selectedActionDefinition?.payloadFields?.length ? (
|
||||
<Text type="secondary">
|
||||
Payload 字段:
|
||||
{selectedActionDefinition.payloadFields
|
||||
.map(
|
||||
(field) =>
|
||||
`${field.name}${field.required ? "(必填)" : ""}`,
|
||||
)
|
||||
.join("、")}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
{selectedActionDisplay.description ? (
|
||||
) : null}
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>动作</Text>
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) =>
|
||||
handleSelectAction(
|
||||
event.target.value,
|
||||
selectedActionDefinition,
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
providerMode === "jmx"
|
||||
? "例如 set 或 invoke"
|
||||
: "例如 put / clear / evict"
|
||||
}
|
||||
maxLength={64}
|
||||
/>
|
||||
{action ? (
|
||||
<Text type="secondary">
|
||||
{selectedActionDisplay.description}
|
||||
</Text>
|
||||
) : null}
|
||||
{selectedActionDefinition?.payloadFields?.length ? (
|
||||
<Text type="secondary">
|
||||
Payload 字段:
|
||||
{selectedActionDefinition.payloadFields
|
||||
.map(
|
||||
(field) =>
|
||||
`${field.name}${field.required ? "(必填)" : ""}`,
|
||||
)
|
||||
.join("、")}
|
||||
当前动作:
|
||||
{formatJVMActionDisplayText(selectedActionDisplay)}
|
||||
</Text>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>动作</Text>
|
||||
<Input
|
||||
value={action}
|
||||
onChange={(event) =>
|
||||
handleSelectAction(
|
||||
event.target.value,
|
||||
selectedActionDefinition,
|
||||
)
|
||||
}
|
||||
placeholder={
|
||||
providerMode === "jmx"
|
||||
? "例如 set 或 invoke"
|
||||
: "例如 put / clear / evict"
|
||||
}
|
||||
maxLength={64}
|
||||
/>
|
||||
{action ? (
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>变更原因</Text>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="填写本次 JVM 资源变更原因"
|
||||
maxLength={200}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>Payload(JSON)</Text>
|
||||
<Text type="secondary">
|
||||
当前动作:
|
||||
{formatJVMActionDisplayText(selectedActionDisplay)}
|
||||
需要输入 JSON 对象,预览和执行都会直接使用这份 payload。
|
||||
{selectedActionDefinition?.payloadExample
|
||||
? " 已按当前动作填充推荐模板。"
|
||||
: ""}
|
||||
</Text>
|
||||
) : null}
|
||||
<TextArea
|
||||
value={payloadText}
|
||||
onChange={(event) => setPayloadText(event.target.value)}
|
||||
autoSize={{ minRows: 8, maxRows: 18 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={12} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
预览变更
|
||||
</Button>
|
||||
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
|
||||
让 AI 生成计划
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>变更原因</Text>
|
||||
<Input
|
||||
value={reason}
|
||||
onChange={(event) => setReason(event.target.value)}
|
||||
placeholder="填写本次 JVM 资源变更原因"
|
||||
maxLength={200}
|
||||
/>
|
||||
</Space>
|
||||
<Space direction="vertical" size={8} style={{ width: "100%" }}>
|
||||
<Text strong>Payload(JSON)</Text>
|
||||
<Text type="secondary">
|
||||
需要输入 JSON 对象,预览和执行都会直接使用这份 payload。
|
||||
{selectedActionDefinition?.payloadExample
|
||||
? " 已按当前动作填充推荐模板。"
|
||||
: ""}
|
||||
</Text>
|
||||
<TextArea
|
||||
value={payloadText}
|
||||
onChange={(event) => setPayloadText(event.target.value)}
|
||||
autoSize={{ minRows: 8, maxRows: 18 }}
|
||||
spellCheck={false}
|
||||
/>
|
||||
</Space>
|
||||
<Space size={12} wrap>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={previewLoading}
|
||||
onClick={() => void handlePreview()}
|
||||
>
|
||||
预览变更
|
||||
</Button>
|
||||
<Button icon={<RobotOutlined />} onClick={handleAskAIForPlan}>
|
||||
让 AI 生成计划
|
||||
</Button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
</JVMWorkspaceShell>
|
||||
|
||||
<JVMChangePreviewModal
|
||||
open={previewOpen}
|
||||
|
||||
@@ -49,6 +49,7 @@ import { noAutoCapInputProps } from '../utils/inputAutoCap';
|
||||
import { normalizeSidebarViewName, resolveSidebarRuntimeDatabase } from '../utils/sidebarMetadata';
|
||||
import { resolveConnectionHostTokens } from '../utils/tabDisplay';
|
||||
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
|
||||
import { buildJVMDiagnosticActionDescriptor, buildJVMMonitoringActionDescriptors } from '../utils/jvmSidebarActions';
|
||||
import { buildTableSelectQuery } from '../utils/objectQueryTemplates';
|
||||
import { buildExternalSQLDirectoryId, buildExternalSQLRootNode, buildExternalSQLTabId, type ExternalSQLTreeNode } from '../utils/externalSqlTree';
|
||||
import JVMModeBadge from './jvm/JVMModeBadge';
|
||||
@@ -62,7 +63,7 @@ interface TreeNode {
|
||||
children?: TreeNode[];
|
||||
icon?: React.ReactNode;
|
||||
dataRef?: any;
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic';
|
||||
type?: 'connection' | 'database' | 'table' | 'view' | 'db-trigger' | 'routine' | 'object-group' | 'queries-folder' | 'saved-query' | 'external-sql-root' | 'external-sql-directory' | 'external-sql-folder' | 'external-sql-file' | 'folder-columns' | 'folder-indexes' | 'folder-fks' | 'folder-triggers' | 'redis-db' | 'tag' | 'jvm-mode' | 'jvm-resource' | 'jvm-diagnostic' | 'jvm-monitoring';
|
||||
}
|
||||
|
||||
type BatchTableExportMode = 'schema' | 'backup' | 'dataOnly';
|
||||
@@ -991,30 +992,40 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
},
|
||||
isLeaf: capability.canBrowse !== true,
|
||||
}));
|
||||
const diagnosticNode: TreeNode[] =
|
||||
conn.config.jvm?.diagnostic?.enabled
|
||||
? [{
|
||||
title: '诊断增强',
|
||||
key: `${conn.id}-jvm-diagnostic`,
|
||||
icon: <DashboardOutlined />,
|
||||
type: 'jvm-diagnostic',
|
||||
dataRef: {
|
||||
...conn,
|
||||
diagnosticTransport: conn.config.jvm?.diagnostic?.transport || 'agent-bridge',
|
||||
},
|
||||
isLeaf: true,
|
||||
}]
|
||||
: [];
|
||||
setTreeData(origin => updateTreeData(origin, node.key, [...modeNodes, ...diagnosticNode]));
|
||||
const monitoringNodes: TreeNode[] = buildJVMMonitoringActionDescriptors(conn.id, capabilities).map((item) => ({
|
||||
title: item.title,
|
||||
key: item.key,
|
||||
icon: <DashboardOutlined />,
|
||||
type: 'jvm-monitoring',
|
||||
dataRef: {
|
||||
...conn,
|
||||
providerMode: item.providerMode,
|
||||
},
|
||||
isLeaf: true,
|
||||
}));
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setTreeData(origin => updateTreeData(origin, node.key, [...monitoringNodes, ...modeNodes, ...diagnosticNode]));
|
||||
} else {
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
message.warning({ content: `JVM Provider 探测失败:${res.message || '未知错误'};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: res.message, key: `conn-${conn.id}-jvm-caps` });
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
const diagnosticNode = buildJVMDiagnosticTreeNodes(conn);
|
||||
setConnectionStates(prev => ({ ...prev, [conn.id]: 'error' }));
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-jvm-caps` });
|
||||
if (diagnosticNode.length > 0) {
|
||||
setTreeData(origin => updateTreeData(origin, node.key, diagnosticNode));
|
||||
message.warning({ content: `JVM Provider 探测异常:${e?.message || String(e)};已保留诊断增强入口`, key: `conn-${conn.id}-jvm-caps` });
|
||||
} else {
|
||||
setLoadedKeys(prev => prev.filter(k => k !== node.key));
|
||||
message.error({ content: '连接失败: ' + (e?.message || String(e)), key: `conn-${conn.id}-jvm-caps` });
|
||||
}
|
||||
} finally {
|
||||
loadingNodesRef.current.delete(loadKey);
|
||||
}
|
||||
@@ -1563,7 +1574,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
} else if (type === 'table') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic') {
|
||||
} else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: '' });
|
||||
} else if (type === 'view' || type === 'db-trigger' || type === 'routine') {
|
||||
setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
@@ -1611,7 +1622,7 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const { type, dataRef, key: nodeKey } = node;
|
||||
if (type === 'connection') setActiveContext({ connectionId: nodeKey, dbName: '' });
|
||||
else if (type === 'database') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic') setActiveContext({ connectionId: dataRef.id, dbName: '' });
|
||||
else if (type === 'jvm-mode' || type === 'jvm-resource' || type === 'jvm-diagnostic' || type === 'jvm-monitoring') setActiveContext({ connectionId: dataRef.id, dbName: '' });
|
||||
else if (type === 'table' || type === 'view' || type === 'db-trigger' || type === 'routine') setActiveContext({ connectionId: dataRef.id, dbName: dataRef.dbName });
|
||||
else if (type === 'saved-query') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
else if (type === 'external-sql-root' || type === 'external-sql-directory' || type === 'external-sql-folder' || type === 'external-sql-file') setActiveContext({ connectionId: dataRef.connectionId, dbName: dataRef.dbName });
|
||||
@@ -1700,6 +1711,11 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
|
||||
openJVMResourceTab(conn, providerMode, resourcePath, resourceKind);
|
||||
return;
|
||||
} else if (node.type === 'jvm-monitoring') {
|
||||
const { providerMode, id } = node.dataRef;
|
||||
const conn = (connections.find((item) => item.id === id) || node.dataRef) as SavedConnection;
|
||||
openJVMMonitoringTab(conn, providerMode);
|
||||
return;
|
||||
} else if (node.type === 'jvm-diagnostic') {
|
||||
const conn = (connections.find((item) => item.id === node.dataRef.id) || node.dataRef) as SavedConnection;
|
||||
openJVMDiagnosticTab(conn);
|
||||
@@ -2521,6 +2537,34 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
|
||||
});
|
||||
};
|
||||
|
||||
const openJVMMonitoringTab = (conn: SavedConnection, providerMode: string) => {
|
||||
addTab({
|
||||
id: `jvm-monitoring-${conn.id}-${providerMode}`,
|
||||
title: buildJVMTabTitle(conn.name, 'monitoring', providerMode),
|
||||
type: 'jvm-monitoring',
|
||||
connectionId: conn.id,
|
||||
providerMode: providerMode as 'jmx' | 'endpoint' | 'agent',
|
||||
});
|
||||
};
|
||||
|
||||
const buildJVMDiagnosticTreeNodes = (conn: SavedConnection): TreeNode[] => {
|
||||
const descriptor = buildJVMDiagnosticActionDescriptor(conn.id, conn.config.jvm?.diagnostic);
|
||||
if (!descriptor) {
|
||||
return [];
|
||||
}
|
||||
return [{
|
||||
title: descriptor.title,
|
||||
key: descriptor.key,
|
||||
icon: <DashboardOutlined />,
|
||||
type: 'jvm-diagnostic',
|
||||
dataRef: {
|
||||
...conn,
|
||||
diagnosticTransport: descriptor.transport,
|
||||
},
|
||||
isLeaf: true,
|
||||
}];
|
||||
};
|
||||
|
||||
const openJVMResourceTab = (conn: SavedConnection, providerMode: string, resourcePath: string, resourceKind?: string) => {
|
||||
const trimmedResourcePath = String(resourcePath || '').trim();
|
||||
addTab({
|
||||
|
||||
@@ -20,6 +20,7 @@ import JVMOverview from './JVMOverview';
|
||||
import JVMResourceBrowser from './JVMResourceBrowser';
|
||||
import JVMAuditViewer from './JVMAuditViewer';
|
||||
import JVMDiagnosticConsole from './JVMDiagnosticConsole';
|
||||
import JVMMonitoringDashboard from './JVMMonitoringDashboard';
|
||||
import type { TabData } from '../types';
|
||||
import { buildTabDisplayTitle } from '../utils/tabDisplay';
|
||||
|
||||
@@ -215,6 +216,8 @@ const TabManager: React.FC = () => {
|
||||
content = <JVMAuditViewer tab={tab} />;
|
||||
} else if (tab.type === 'jvm-diagnostic') {
|
||||
content = <JVMDiagnosticConsole tab={tab} />;
|
||||
} else if (tab.type === 'jvm-monitoring') {
|
||||
content = <JVMMonitoringDashboard tab={tab} />;
|
||||
}
|
||||
|
||||
const menuItems: MenuProps['items'] = [
|
||||
|
||||
119
frontend/src/components/jvm/JVMMonitoringCharts.test.tsx
Normal file
119
frontend/src/components/jvm/JVMMonitoringCharts.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import JVMMonitoringCharts from "./JVMMonitoringCharts";
|
||||
|
||||
vi.mock("recharts", () => {
|
||||
const passthrough =
|
||||
(tag: string) =>
|
||||
({ children, name }: { children?: React.ReactNode; name?: string }) =>
|
||||
React.createElement(tag, null, name ? <span>{name}</span> : children);
|
||||
const svgChild =
|
||||
({ name }: { name?: string }) =>
|
||||
name ? <text>{name}</text> : <g />;
|
||||
|
||||
return {
|
||||
Area: svgChild,
|
||||
AreaChart: passthrough("svg"),
|
||||
CartesianGrid: svgChild,
|
||||
Legend: svgChild,
|
||||
Line: svgChild,
|
||||
LineChart: passthrough("svg"),
|
||||
ResponsiveContainer: passthrough("div"),
|
||||
Tooltip: svgChild,
|
||||
XAxis: svgChild,
|
||||
YAxis: svgChild,
|
||||
};
|
||||
});
|
||||
|
||||
describe("JVMMonitoringCharts", () => {
|
||||
it("renders chart titles, empty text, and legends in Chinese", () => {
|
||||
const emptyMarkup = renderToStaticMarkup(
|
||||
<JVMMonitoringCharts
|
||||
darkMode={false}
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: false,
|
||||
availableMetrics: [],
|
||||
missingMetrics: [],
|
||||
providerWarnings: [],
|
||||
}}
|
||||
points={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(emptyMarkup).toContain("堆内存");
|
||||
expect(emptyMarkup).toContain("暂无堆内存采样数据");
|
||||
expect(emptyMarkup).not.toContain("暂无 Heap 采样数据");
|
||||
|
||||
const dataMarkup = renderToStaticMarkup(
|
||||
<JVMMonitoringCharts
|
||||
darkMode={false}
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: true,
|
||||
availableMetrics: [
|
||||
"heap.used",
|
||||
"gc.count",
|
||||
"thread.count",
|
||||
"class.loading",
|
||||
],
|
||||
missingMetrics: [],
|
||||
providerWarnings: [],
|
||||
}}
|
||||
points={[
|
||||
{
|
||||
timestamp: 1713945600000,
|
||||
heapUsedBytes: 64 * 1024 * 1024,
|
||||
heapCommittedBytes: 128 * 1024 * 1024,
|
||||
gcCollectionCount: 20,
|
||||
gcCollectionTimeMs: 50,
|
||||
threadCount: 33,
|
||||
daemonThreadCount: 12,
|
||||
peakThreadCount: 44,
|
||||
loadedClassCount: 13282,
|
||||
unloadedClassCount: 3,
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(dataMarkup).toContain("堆内存已使用");
|
||||
expect(dataMarkup).toContain("堆内存已提交");
|
||||
expect(dataMarkup).toContain("垃圾回收次数");
|
||||
expect(dataMarkup).toContain("垃圾回收耗时(ms)");
|
||||
expect(dataMarkup).toContain("线程数");
|
||||
expect(dataMarkup).toContain("守护线程数");
|
||||
expect(dataMarkup).toContain("线程峰值");
|
||||
expect(dataMarkup).toContain("已加载类");
|
||||
expect(dataMarkup).toContain("已卸载类");
|
||||
expect(dataMarkup).not.toContain("Heap Used");
|
||||
expect(dataMarkup).not.toContain("GC Count");
|
||||
expect(dataMarkup).not.toContain("Threads");
|
||||
expect(dataMarkup).not.toContain("ClassLoading");
|
||||
});
|
||||
|
||||
it("uses relaxed card spacing so charts do not feel crowded", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringCharts
|
||||
darkMode={false}
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: false,
|
||||
availableMetrics: [],
|
||||
missingMetrics: [],
|
||||
providerWarnings: [],
|
||||
}}
|
||||
points={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("row-gap:24px");
|
||||
expect(markup).toContain("height:380px");
|
||||
expect(markup).toContain("padding:20px 22px 14px");
|
||||
});
|
||||
});
|
||||
185
frontend/src/components/jvm/JVMMonitoringCharts.tsx
Normal file
185
frontend/src/components/jvm/JVMMonitoringCharts.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Empty, Row } from "antd";
|
||||
import {
|
||||
Area,
|
||||
AreaChart,
|
||||
CartesianGrid,
|
||||
Legend,
|
||||
Line,
|
||||
LineChart,
|
||||
ResponsiveContainer,
|
||||
Tooltip as RechartsTooltip,
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
buildMonitoringChartPoints,
|
||||
formatCompactNumber,
|
||||
formatMonitoringAxisBytes,
|
||||
monitoringMetricAvailable,
|
||||
} from "../../utils/jvmMonitoringPresentation";
|
||||
|
||||
type JVMMonitoringChartsProps = {
|
||||
points: JVMMonitoringPoint[];
|
||||
session: JVMMonitoringSessionState;
|
||||
darkMode: boolean;
|
||||
};
|
||||
|
||||
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
borderRadius: 18,
|
||||
height: 380,
|
||||
background: darkMode ? "#1f1f1f" : "#ffffff",
|
||||
boxShadow: "0 8px 28px rgba(15, 23, 42, 0.06)",
|
||||
});
|
||||
|
||||
const chartMargin = { top: 18, right: 28, bottom: 26, left: 8 };
|
||||
const axisTickStyle = (color: string) => ({ fill: color, fontSize: 11 });
|
||||
const legendProps = {
|
||||
iconSize: 8,
|
||||
verticalAlign: "bottom" as const,
|
||||
wrapperStyle: {
|
||||
paddingTop: 14,
|
||||
lineHeight: "22px",
|
||||
},
|
||||
};
|
||||
|
||||
const JVMMonitoringCharts: React.FC<JVMMonitoringChartsProps> = ({
|
||||
points,
|
||||
session,
|
||||
darkMode,
|
||||
}) => {
|
||||
const data = buildMonitoringChartPoints(points);
|
||||
const textColor = darkMode ? "rgba(255,255,255,0.72)" : "rgba(0,0,0,0.65)";
|
||||
const gridColor = darkMode ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.08)";
|
||||
const tooltipStyle = {
|
||||
backgroundColor: darkMode ? "#141414" : "#ffffff",
|
||||
border: `1px solid ${gridColor}`,
|
||||
borderRadius: 8,
|
||||
};
|
||||
|
||||
const renderEmpty = (description: string) => (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={description}
|
||||
style={{ marginTop: 96 }}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderCard = (title: string, content: React.ReactNode) => (
|
||||
<Card
|
||||
variant="borderless"
|
||||
title={title}
|
||||
style={buildCardStyle(darkMode)}
|
||||
styles={{ body: { height: 304, padding: "20px 22px 14px" } }}
|
||||
>
|
||||
{content}
|
||||
</Card>
|
||||
);
|
||||
|
||||
const hasData = data.length > 0;
|
||||
|
||||
return (
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"堆内存",
|
||||
!hasData
|
||||
? renderEmpty("暂无堆内存采样数据")
|
||||
: !monitoringMetricAvailable(session, "heap.used")
|
||||
? renderEmpty("当前监控来源未提供堆内存指标")
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={data} margin={chartMargin}>
|
||||
<defs>
|
||||
<linearGradient id="jvmHeapGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#fa8c16" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="#fa8c16" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatMonitoringAxisBytes} width={74} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Area type="monotone" dataKey="heapUsedBytes" name="堆内存已使用" stroke="#fa8c16" fill="url(#jvmHeapGradient)" isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="heapCommittedBytes" name="堆内存已提交" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"垃圾回收",
|
||||
!hasData
|
||||
? renderEmpty("暂无垃圾回收采样数据")
|
||||
: !monitoringMetricAvailable(session, "gc.count")
|
||||
? renderEmpty("当前监控来源未提供垃圾回收指标")
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
|
||||
<YAxis yAxisId="left" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
|
||||
<YAxis yAxisId="right" orientation="right" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line yAxisId="left" type="monotone" dataKey="gcCollectionCount" name="垃圾回收次数" stroke="#52c41a" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="gcCollectionTimeMs" name="垃圾回收耗时(ms)" stroke="#722ed1" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"线程",
|
||||
!hasData
|
||||
? renderEmpty("暂无线程采样数据")
|
||||
: !monitoringMetricAvailable(session, "thread.count")
|
||||
? renderEmpty("当前监控来源未提供线程指标")
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} width={42} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line type="monotone" dataKey="threadCount" name="线程数" stroke="#1677ff" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="daemonThreadCount" name="守护线程数" stroke="#13c2c2" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="peakThreadCount" name="线程峰值" stroke="#faad14" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
)}
|
||||
</Col>
|
||||
<Col xs={24} xl={12}>
|
||||
{renderCard(
|
||||
"类加载",
|
||||
!hasData
|
||||
? renderEmpty("暂无类加载采样数据")
|
||||
: !monitoringMetricAvailable(session, "class.loading")
|
||||
? renderEmpty("当前监控来源未提供类加载指标")
|
||||
: (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={chartMargin}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={gridColor} vertical={false} />
|
||||
<XAxis dataKey="timeLabel" tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} minTickGap={32} />
|
||||
<YAxis tick={axisTickStyle(textColor)} axisLine={false} tickLine={false} tickFormatter={formatCompactNumber} width={58} />
|
||||
<RechartsTooltip contentStyle={tooltipStyle} />
|
||||
<Legend {...legendProps} />
|
||||
<Line type="monotone" dataKey="loadedClassCount" name="已加载类" stroke="#eb2f96" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
<Line type="monotone" dataKey="unloadedClassCount" name="已卸载类" stroke="#8c8c8c" strokeWidth={2} dot={false} isAnimationActive={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
),
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default JVMMonitoringCharts;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { JVMMonitoringSessionState } from "../../types";
|
||||
import JVMMonitoringDetailPanel from "./JVMMonitoringDetailPanel";
|
||||
|
||||
describe("JVMMonitoringDetailPanel", () => {
|
||||
it("explains why process physical memory can be unavailable for JMX", () => {
|
||||
const session: JVMMonitoringSessionState = {
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: true,
|
||||
missingMetrics: ["memory.rss"],
|
||||
availableMetrics: ["memory.virtual"],
|
||||
providerWarnings: [],
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
latestPoint={{
|
||||
timestamp: 1713945600000,
|
||||
committedVirtualMemoryBytes: 385 * 1024 * 1024,
|
||||
}}
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("进程物理内存");
|
||||
expect(markup).toContain("JMX 连接未暴露进程驻留物理内存属性");
|
||||
expect(markup).toContain("HTTP 端点或增强代理");
|
||||
expect(markup).not.toContain("CommittedVirtualMemorySize");
|
||||
expect(markup).not.toContain("Endpoint/Agent");
|
||||
});
|
||||
|
||||
it("renders thread state names with Chinese semantic labels", () => {
|
||||
const session: JVMMonitoringSessionState = {
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: true,
|
||||
missingMetrics: [],
|
||||
availableMetrics: ["thread.states"],
|
||||
providerWarnings: [],
|
||||
};
|
||||
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringDetailPanel
|
||||
session={session}
|
||||
latestPoint={{
|
||||
timestamp: 1713945600000,
|
||||
threadStateCounts: {
|
||||
WAITING: 12,
|
||||
RUNNABLE: 11,
|
||||
TIMED_WAITING: 10,
|
||||
},
|
||||
}}
|
||||
darkMode={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("等待中 12");
|
||||
expect(markup).toContain("可运行 11");
|
||||
expect(markup).toContain("限时等待 10");
|
||||
expect(markup).not.toContain("WAITING 12");
|
||||
expect(markup).not.toContain("RUNNABLE 11");
|
||||
expect(markup).not.toContain("TIMED_WAITING 10");
|
||||
});
|
||||
});
|
||||
154
frontend/src/components/jvm/JVMMonitoringDetailPanel.tsx
Normal file
154
frontend/src/components/jvm/JVMMonitoringDetailPanel.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
import React from "react";
|
||||
import { Alert, Card, Descriptions, Empty, List, Space, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
buildMonitoringAvailabilityText,
|
||||
extractThreadStateRows,
|
||||
formatBytes,
|
||||
formatCompactNumber,
|
||||
formatPercent,
|
||||
formatRecentGCLabel,
|
||||
} from "../../utils/jvmMonitoringPresentation";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type JVMMonitoringDetailPanelProps = {
|
||||
session: JVMMonitoringSessionState;
|
||||
latestPoint?: JVMMonitoringPoint;
|
||||
darkMode: boolean;
|
||||
};
|
||||
|
||||
const buildCardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
borderRadius: 12,
|
||||
background: darkMode ? "#1f1f1f" : "#ffffff",
|
||||
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
|
||||
});
|
||||
|
||||
const buildProcessMemoryMissingHint = (
|
||||
session: JVMMonitoringSessionState,
|
||||
): string | null => {
|
||||
if (!(session.missingMetrics || []).includes("memory.rss")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (session.providerMode === "jmx") {
|
||||
return "JMX 连接未暴露进程驻留物理内存属性,当前只能读取进程虚拟内存指标;如需进程物理内存,请切换到 HTTP 端点或增强代理采集。";
|
||||
}
|
||||
|
||||
return "当前监控来源未返回进程驻留物理内存指标;请确认 HTTP 端点或增强代理已采集并上报进程物理内存。";
|
||||
};
|
||||
|
||||
const JVMMonitoringDetailPanel: React.FC<JVMMonitoringDetailPanelProps> = ({
|
||||
session,
|
||||
latestPoint,
|
||||
darkMode,
|
||||
}) => {
|
||||
const threadRows = extractThreadStateRows(latestPoint);
|
||||
const recentGcEvents = session.recentGcEvents || [];
|
||||
const missingMetrics = session.missingMetrics || [];
|
||||
const processMemoryMissingHint = buildProcessMemoryMissingHint(session);
|
||||
|
||||
return (
|
||||
<Space direction="vertical" size={16} style={{ width: "100%" }}>
|
||||
<Card variant="borderless" title="排障指标" style={buildCardStyle(darkMode)}>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="进程 CPU">
|
||||
{formatPercent(latestPoint?.processCpuLoad)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="系统 CPU">
|
||||
{formatPercent(latestPoint?.systemCpuLoad)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="进程物理内存">
|
||||
{formatBytes(latestPoint?.processRssBytes)}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="进程虚拟内存">
|
||||
{formatBytes(latestPoint?.committedVirtualMemoryBytes)}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
{processMemoryMissingHint ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="进程物理内存缺失原因"
|
||||
description={processMemoryMissingHint}
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="线程状态分布" style={buildCardStyle(darkMode)}>
|
||||
{threadRows.length === 0 ? (
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无线程状态采样" />
|
||||
) : (
|
||||
<Space wrap size={[8, 8]}>
|
||||
{threadRows.map((item) => (
|
||||
<Tag key={item.state} color="blue">
|
||||
{item.label} {formatCompactNumber(item.count)}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="最近垃圾回收明细" style={buildCardStyle(darkMode)}>
|
||||
{recentGcEvents.length === 0 ? (
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description={
|
||||
missingMetrics.includes("gc.events")
|
||||
? "当前监控来源未提供事件级垃圾回收数据"
|
||||
: "最近窗口暂无垃圾回收事件"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<List
|
||||
dataSource={recentGcEvents}
|
||||
renderItem={(event) => (
|
||||
<List.Item>
|
||||
<List.Item.Meta
|
||||
title={formatRecentGCLabel(event)}
|
||||
description={
|
||||
<Space size={12} wrap>
|
||||
{typeof event.beforeUsedBytes === "number" ? (
|
||||
<Text type="secondary">
|
||||
回收前 {formatBytes(event.beforeUsedBytes)}
|
||||
</Text>
|
||||
) : null}
|
||||
{typeof event.afterUsedBytes === "number" ? (
|
||||
<Text type="secondary">
|
||||
回收后 {formatBytes(event.afterUsedBytes)}
|
||||
</Text>
|
||||
) : null}
|
||||
{event.action ? <Tag>{event.action}</Tag> : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card variant="borderless" title="能力与降级" style={buildCardStyle(darkMode)}>
|
||||
<Paragraph type="secondary" style={{ whiteSpace: "pre-wrap", marginBottom: 12 }}>
|
||||
{buildMonitoringAvailabilityText(session)}
|
||||
</Paragraph>
|
||||
<Space size={[8, 8]} wrap>
|
||||
{(session.missingMetrics || []).map((metric) => (
|
||||
<Tag key={metric} color="warning">
|
||||
{metric}
|
||||
</Tag>
|
||||
))}
|
||||
{(session.providerWarnings || []).map((warning, index) => (
|
||||
<Tag key={`${warning}-${index}`} color="default">
|
||||
{warning}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export default JVMMonitoringDetailPanel;
|
||||
@@ -0,0 +1,47 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import JVMMonitoringStatusCards from "./JVMMonitoringStatusCards";
|
||||
|
||||
describe("JVMMonitoringStatusCards", () => {
|
||||
it("renders monitoring summary labels in Chinese", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<JVMMonitoringStatusCards
|
||||
darkMode={false}
|
||||
session={{
|
||||
connectionId: "conn-1",
|
||||
providerMode: "jmx",
|
||||
running: true,
|
||||
}}
|
||||
latestPoint={{
|
||||
timestamp: 1713945600000,
|
||||
heapUsedBytes: 64 * 1024 * 1024,
|
||||
heapCommittedBytes: 128 * 1024 * 1024,
|
||||
gcCollectionCount: 20,
|
||||
gcCollectionTimeMs: 50,
|
||||
threadCount: 33,
|
||||
peakThreadCount: 44,
|
||||
threadStateCounts: {
|
||||
RUNNABLE: 11,
|
||||
},
|
||||
loadedClassCount: 13282,
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain("堆内存");
|
||||
expect(markup).toContain("已提交");
|
||||
expect(markup).toContain("垃圾回收压力");
|
||||
expect(markup).toContain("累计 50ms");
|
||||
expect(markup).toContain("线程");
|
||||
expect(markup).toContain("峰值 44");
|
||||
expect(markup).toContain("可运行 11");
|
||||
expect(markup).toContain("类加载");
|
||||
expect(markup).not.toContain("Committed");
|
||||
expect(markup).not.toContain("Total");
|
||||
expect(markup).not.toContain("Peak");
|
||||
expect(markup).not.toContain("RUNNABLE");
|
||||
expect(markup).not.toContain("ClassLoading");
|
||||
});
|
||||
});
|
||||
92
frontend/src/components/jvm/JVMMonitoringStatusCards.tsx
Normal file
92
frontend/src/components/jvm/JVMMonitoringStatusCards.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
import React from "react";
|
||||
import { Card, Col, Row, Space, Statistic, Tag, Typography } from "antd";
|
||||
|
||||
import type { JVMMonitoringPoint, JVMMonitoringSessionState } from "../../types";
|
||||
import {
|
||||
formatBytes,
|
||||
formatCompactNumber,
|
||||
formatDurationMs,
|
||||
resolveThreadStateLabel,
|
||||
} from "../../utils/jvmMonitoringPresentation";
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
type JVMMonitoringStatusCardsProps = {
|
||||
latestPoint?: JVMMonitoringPoint;
|
||||
session?: JVMMonitoringSessionState;
|
||||
darkMode: boolean;
|
||||
};
|
||||
|
||||
const cardStyle = (darkMode: boolean): React.CSSProperties => ({
|
||||
borderRadius: 12,
|
||||
background: darkMode ? "#1f1f1f" : "#ffffff",
|
||||
boxShadow: "0 1px 2px rgba(5, 5, 5, 0.06)",
|
||||
});
|
||||
|
||||
const JVMMonitoringStatusCards: React.FC<JVMMonitoringStatusCardsProps> = ({
|
||||
latestPoint,
|
||||
session,
|
||||
darkMode,
|
||||
}) => {
|
||||
const runnableCount = latestPoint?.threadStateCounts?.RUNNABLE || 0;
|
||||
const heapMeta =
|
||||
latestPoint?.heapCommittedBytes && latestPoint.heapCommittedBytes > 0
|
||||
? `已提交 ${formatBytes(latestPoint.heapCommittedBytes)}`
|
||||
: "等待采样";
|
||||
const gcMeta =
|
||||
typeof latestPoint?.gcDeltaTimeMs === "number" && latestPoint.gcDeltaTimeMs >= 0
|
||||
? `Δ ${formatDurationMs(latestPoint.gcDeltaTimeMs)}`
|
||||
: typeof latestPoint?.gcCollectionTimeMs === "number"
|
||||
? `累计 ${formatDurationMs(latestPoint.gcCollectionTimeMs)}`
|
||||
: "等待采样";
|
||||
const threadMeta =
|
||||
latestPoint?.peakThreadCount && latestPoint.peakThreadCount > 0
|
||||
? `峰值 ${formatCompactNumber(latestPoint.peakThreadCount)}`
|
||||
: "等待采样";
|
||||
const classMeta =
|
||||
typeof latestPoint?.classLoadDelta === "number"
|
||||
? `Δ ${formatCompactNumber(latestPoint.classLoadDelta)}`
|
||||
: "等待采样";
|
||||
const runnableLabel = resolveThreadStateLabel("RUNNABLE");
|
||||
|
||||
return (
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="堆内存">
|
||||
<Statistic value={formatBytes(latestPoint?.heapUsedBytes)} />
|
||||
<Text type="secondary">{heapMeta}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="垃圾回收压力">
|
||||
<Statistic
|
||||
value={formatCompactNumber(
|
||||
latestPoint?.gcDeltaCount ?? latestPoint?.gcCollectionCount,
|
||||
)}
|
||||
/>
|
||||
<Text type="secondary">{gcMeta}</Text>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="线程">
|
||||
<Statistic value={formatCompactNumber(latestPoint?.threadCount)} />
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{threadMeta}</Text>
|
||||
{runnableCount > 0 ? <Tag color="blue">{runnableLabel} {runnableCount}</Tag> : null}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} xl={6}>
|
||||
<Card variant="borderless" style={cardStyle(darkMode)} title="类加载">
|
||||
<Statistic value={formatCompactNumber(latestPoint?.loadedClassCount)} />
|
||||
<Space size={8} wrap>
|
||||
<Text type="secondary">{classMeta}</Text>
|
||||
{session?.running ? <Tag color="green">采样中</Tag> : <Tag>未运行</Tag>}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
};
|
||||
|
||||
export default JVMMonitoringStatusCards;
|
||||
128
frontend/src/components/jvm/JVMWorkspaceLayout.tsx
Normal file
128
frontend/src/components/jvm/JVMWorkspaceLayout.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from "react";
|
||||
import { Card, Typography } from "antd";
|
||||
|
||||
const { Paragraph, Text } = Typography;
|
||||
|
||||
type JVMWorkspaceShellProps = React.HTMLAttributes<HTMLDivElement> & {
|
||||
darkMode?: boolean;
|
||||
};
|
||||
|
||||
type JVMWorkspaceHeroProps = {
|
||||
darkMode?: boolean;
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description?: React.ReactNode;
|
||||
badges?: React.ReactNode;
|
||||
actions?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const getJVMWorkspaceCardStyle = (
|
||||
darkMode?: boolean,
|
||||
): React.CSSProperties => ({
|
||||
borderRadius: 18,
|
||||
boxShadow: darkMode
|
||||
? "0 16px 38px rgba(0, 0, 0, 0.26)"
|
||||
: "0 18px 44px rgba(24, 54, 96, 0.08)",
|
||||
});
|
||||
|
||||
const getShellBackground = (darkMode?: boolean): string =>
|
||||
darkMode
|
||||
? "linear-gradient(135deg, #101820 0%, #141414 48%, #1f1f1f 100%)"
|
||||
: "linear-gradient(135deg, #eef4ff 0%, #f7f9fc 45%, #ffffff 100%)";
|
||||
|
||||
const getHeroBackground = (darkMode?: boolean): string =>
|
||||
darkMode
|
||||
? "linear-gradient(135deg, rgba(22,119,255,0.22), rgba(82,196,26,0.08))"
|
||||
: "linear-gradient(135deg, rgba(22,119,255,0.14), rgba(19,194,194,0.08))";
|
||||
|
||||
export const JVMWorkspaceShell: React.FC<JVMWorkspaceShellProps> = ({
|
||||
children,
|
||||
darkMode,
|
||||
style,
|
||||
...rest
|
||||
}) => (
|
||||
<div
|
||||
{...rest}
|
||||
data-jvm-workspace-shell="true"
|
||||
style={{
|
||||
height: "100%",
|
||||
minHeight: 0,
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
padding: 24,
|
||||
display: "grid",
|
||||
gap: 18,
|
||||
alignContent: "start",
|
||||
background: getShellBackground(darkMode),
|
||||
...style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export const JVMWorkspaceHero: React.FC<JVMWorkspaceHeroProps> = ({
|
||||
darkMode,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
badges,
|
||||
actions,
|
||||
}) => (
|
||||
<Card
|
||||
data-jvm-workspace-hero="true"
|
||||
variant="borderless"
|
||||
style={{
|
||||
...getJVMWorkspaceCardStyle(darkMode),
|
||||
background: getHeroBackground(darkMode),
|
||||
border: darkMode
|
||||
? "1px solid rgba(255,255,255,0.08)"
|
||||
: "1px solid rgba(22,119,255,0.12)",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 320px), 1fr))",
|
||||
gap: 18,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text type="secondary">{eyebrow}</Text>
|
||||
<Typography.Title level={3} style={{ margin: "4px 0 8px" }}>
|
||||
{title}
|
||||
</Typography.Title>
|
||||
{description ? (
|
||||
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
{badges ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 8,
|
||||
flexWrap: "wrap",
|
||||
marginTop: 14,
|
||||
}}
|
||||
>
|
||||
{badges}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{actions ? (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
flexWrap: "wrap",
|
||||
justifyContent: "flex-end",
|
||||
}}
|
||||
>
|
||||
{actions}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
@@ -119,6 +119,51 @@ describe('store appearance persistence', () => {
|
||||
expect(useStore.getState().connections[0]?.config.password).toBe('secret');
|
||||
});
|
||||
|
||||
it('preserves JVM Arthas diagnostic config when replacing saved connections', async () => {
|
||||
const { useStore } = await importStore();
|
||||
|
||||
useStore.getState().replaceConnections([
|
||||
{
|
||||
id: 'jvm-1',
|
||||
name: 'Orders JVM',
|
||||
config: {
|
||||
id: 'jvm-1',
|
||||
type: 'jvm',
|
||||
host: '127.0.0.1',
|
||||
port: 9010,
|
||||
user: '',
|
||||
jvm: {
|
||||
allowedModes: ['jmx'],
|
||||
preferredMode: 'jmx',
|
||||
diagnostic: {
|
||||
enabled: true,
|
||||
transport: 'arthas-tunnel',
|
||||
baseUrl: 'http://127.0.0.1:7777',
|
||||
targetId: 'gonavi-local-test',
|
||||
apiKey: 'diag-token',
|
||||
allowObserveCommands: true,
|
||||
allowTraceCommands: true,
|
||||
allowMutatingCommands: false,
|
||||
timeoutSeconds: 20,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(useStore.getState().connections[0]?.config.jvm?.diagnostic).toEqual({
|
||||
enabled: true,
|
||||
transport: 'arthas-tunnel',
|
||||
baseUrl: 'http://127.0.0.1:7777',
|
||||
targetId: 'gonavi-local-test',
|
||||
apiKey: 'diag-token',
|
||||
allowObserveCommands: true,
|
||||
allowTraceCommands: true,
|
||||
allowMutatingCommands: false,
|
||||
timeoutSeconds: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps legacy global proxy password during hydration until explicit cleanup', async () => {
|
||||
storage.setItem('lite-db-storage', JSON.stringify({
|
||||
state: {
|
||||
|
||||
@@ -58,6 +58,8 @@ const MAX_HOST_ENTRY_LENGTH = 512;
|
||||
const MAX_HOST_ENTRIES = 64;
|
||||
const DEFAULT_TIMEOUT_SECONDS = 30;
|
||||
const MAX_TIMEOUT_SECONDS = 3600;
|
||||
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
|
||||
const MAX_DIAGNOSTIC_TIMEOUT_SECONDS = 300;
|
||||
const PERSIST_VERSION = 8;
|
||||
const DEFAULT_CONNECTION_TYPE = "mysql";
|
||||
const DEFAULT_JVM_PORT = 9010;
|
||||
@@ -309,6 +311,18 @@ const sanitizeJVMConfig = (
|
||||
raw.agent && typeof raw.agent === "object"
|
||||
? (raw.agent as Record<string, unknown>)
|
||||
: {};
|
||||
const diagnosticRaw =
|
||||
raw.diagnostic && typeof raw.diagnostic === "object"
|
||||
? (raw.diagnostic as Record<string, unknown>)
|
||||
: {};
|
||||
const diagnosticTransportRaw = toTrimmedString(
|
||||
diagnosticRaw.transport,
|
||||
"agent-bridge",
|
||||
).toLowerCase();
|
||||
const diagnosticTransport =
|
||||
diagnosticTransportRaw === "arthas-tunnel"
|
||||
? "arthas-tunnel"
|
||||
: "agent-bridge";
|
||||
const fallbackPort = options.port > 0 ? options.port : DEFAULT_JVM_PORT;
|
||||
const fallbackTimeout =
|
||||
options.timeout > 0 ? options.timeout : DEFAULT_TIMEOUT_SECONDS;
|
||||
@@ -348,6 +362,24 @@ const sanitizeJVMConfig = (
|
||||
MAX_TIMEOUT_SECONDS,
|
||||
),
|
||||
},
|
||||
diagnostic: {
|
||||
enabled: diagnosticRaw.enabled === true,
|
||||
transport: diagnosticTransport,
|
||||
baseUrl: toTrimmedString(diagnosticRaw.baseUrl),
|
||||
targetId: toTrimmedString(diagnosticRaw.targetId),
|
||||
apiKey: options.persistSecrets
|
||||
? toTrimmedString(diagnosticRaw.apiKey)
|
||||
: "",
|
||||
allowObserveCommands: diagnosticRaw.allowObserveCommands !== false,
|
||||
allowTraceCommands: diagnosticRaw.allowTraceCommands === true,
|
||||
allowMutatingCommands: diagnosticRaw.allowMutatingCommands === true,
|
||||
timeoutSeconds: normalizeIntegerInRange(
|
||||
diagnosticRaw.timeoutSeconds,
|
||||
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||||
1,
|
||||
MAX_DIAGNOSTIC_TIMEOUT_SECONDS,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -148,6 +148,51 @@ export interface JVMCapability {
|
||||
displayLabel: string;
|
||||
}
|
||||
|
||||
export interface JVMMonitoringPoint {
|
||||
timestamp: number;
|
||||
heapUsedBytes?: number;
|
||||
heapCommittedBytes?: number;
|
||||
heapMaxBytes?: number;
|
||||
nonHeapUsedBytes?: number;
|
||||
nonHeapCommittedBytes?: number;
|
||||
gcCollectionCount?: number;
|
||||
gcCollectionTimeMs?: number;
|
||||
gcDeltaCount?: number;
|
||||
gcDeltaTimeMs?: number;
|
||||
threadCount?: number;
|
||||
daemonThreadCount?: number;
|
||||
peakThreadCount?: number;
|
||||
threadStateCounts?: Record<string, number>;
|
||||
loadedClassCount?: number;
|
||||
unloadedClassCount?: number;
|
||||
classLoadDelta?: number;
|
||||
processCpuLoad?: number;
|
||||
systemCpuLoad?: number;
|
||||
processRssBytes?: number;
|
||||
committedVirtualMemoryBytes?: number;
|
||||
}
|
||||
|
||||
export interface JVMMonitoringRecentGCEvent {
|
||||
timestamp: number;
|
||||
name?: string;
|
||||
cause?: string;
|
||||
action?: string;
|
||||
durationMs?: number;
|
||||
beforeUsedBytes?: number;
|
||||
afterUsedBytes?: number;
|
||||
}
|
||||
|
||||
export interface JVMMonitoringSessionState {
|
||||
connectionId: string;
|
||||
providerMode: "jmx" | "endpoint" | "agent";
|
||||
running: boolean;
|
||||
points?: JVMMonitoringPoint[];
|
||||
recentGcEvents?: JVMMonitoringRecentGCEvent[];
|
||||
availableMetrics?: string[];
|
||||
missingMetrics?: string[];
|
||||
providerWarnings?: string[];
|
||||
}
|
||||
|
||||
export interface JVMResourceSummary {
|
||||
id: string;
|
||||
parentId?: string;
|
||||
@@ -354,7 +399,8 @@ export interface TabData {
|
||||
| "jvm-overview"
|
||||
| "jvm-resource"
|
||||
| "jvm-audit"
|
||||
| "jvm-diagnostic";
|
||||
| "jvm-diagnostic"
|
||||
| "jvm-monitoring";
|
||||
connectionId: string;
|
||||
dbName?: string;
|
||||
tableName?: string;
|
||||
|
||||
41
frontend/src/utils/jvmMonitoringPresentation.test.ts
Normal file
41
frontend/src/utils/jvmMonitoringPresentation.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildMonitoringAvailabilityText,
|
||||
formatMonitoringAxisBytes,
|
||||
formatRecentGCLabel,
|
||||
normalizeMonitoringProviderMode,
|
||||
} from "./jvmMonitoringPresentation";
|
||||
|
||||
describe("jvmMonitoringPresentation", () => {
|
||||
it("summarizes degraded metrics with missing items and warnings", () => {
|
||||
expect(
|
||||
buildMonitoringAvailabilityText({
|
||||
missingMetrics: ["cpu.process", "memory.rss"],
|
||||
providerWarnings: ["endpoint cpu metric unavailable"],
|
||||
}),
|
||||
).toContain("缺失指标");
|
||||
});
|
||||
|
||||
it("formats recent gc event label with duration", () => {
|
||||
expect(
|
||||
formatRecentGCLabel({
|
||||
timestamp: 1713945600000,
|
||||
name: "G1 Young Generation",
|
||||
durationMs: 21,
|
||||
}),
|
||||
).toContain("21ms");
|
||||
});
|
||||
|
||||
it("formats byte axis ticks with compact units instead of raw byte numbers", () => {
|
||||
expect(formatMonitoringAxisBytes(120_000_000)).toBe("114 MB");
|
||||
expect(formatMonitoringAxisBytes(0)).toBe("0 B");
|
||||
expect(formatMonitoringAxisBytes(undefined)).toBe("--");
|
||||
});
|
||||
|
||||
it("normalizes provider mode and falls back on unknown values", () => {
|
||||
expect(normalizeMonitoringProviderMode("AGENT", "jmx")).toBe("agent");
|
||||
expect(normalizeMonitoringProviderMode("unsupported", "endpoint")).toBe("endpoint");
|
||||
expect(normalizeMonitoringProviderMode(undefined, "jmx")).toBe("jmx");
|
||||
});
|
||||
});
|
||||
176
frontend/src/utils/jvmMonitoringPresentation.ts
Normal file
176
frontend/src/utils/jvmMonitoringPresentation.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import type {
|
||||
JVMMonitoringPoint,
|
||||
JVMMonitoringRecentGCEvent,
|
||||
JVMMonitoringSessionState,
|
||||
} from "../types";
|
||||
|
||||
const METRIC_LABELS: Record<string, string> = {
|
||||
"heap.used": "堆内存",
|
||||
"heap.non_heap": "非堆内存",
|
||||
"gc.count": "垃圾回收次数",
|
||||
"gc.time": "垃圾回收耗时",
|
||||
"gc.events": "最近垃圾回收事件",
|
||||
"thread.count": "线程数",
|
||||
"thread.states": "线程状态",
|
||||
"class.loading": "类加载",
|
||||
"cpu.process": "进程 CPU",
|
||||
"cpu.system": "系统 CPU",
|
||||
"memory.rss": "进程物理内存",
|
||||
"memory.virtual": "进程虚拟内存",
|
||||
};
|
||||
|
||||
export type JVMMonitoringProviderMode = JVMMonitoringSessionState["providerMode"];
|
||||
|
||||
const MONITORING_PROVIDER_MODES: JVMMonitoringProviderMode[] = [
|
||||
"jmx",
|
||||
"endpoint",
|
||||
"agent",
|
||||
];
|
||||
|
||||
const THREAD_STATE_LABELS: Record<string, string> = {
|
||||
NEW: "新建",
|
||||
RUNNABLE: "可运行",
|
||||
BLOCKED: "阻塞",
|
||||
WAITING: "等待中",
|
||||
TIMED_WAITING: "限时等待",
|
||||
TERMINATED: "已终止",
|
||||
};
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
export type MonitoringChartPoint = JVMMonitoringPoint & {
|
||||
timeLabel: string;
|
||||
};
|
||||
|
||||
export const resolveMonitoringMetricLabel = (metric: string): string =>
|
||||
METRIC_LABELS[String(metric || "").trim()] || String(metric || "").trim();
|
||||
|
||||
export const resolveThreadStateLabel = (state?: string | null): string => {
|
||||
const normalized = String(state || "").trim().toUpperCase();
|
||||
return THREAD_STATE_LABELS[normalized] || String(state || "").trim();
|
||||
};
|
||||
|
||||
export const formatMonitoringTime = (timestamp?: number): string => {
|
||||
if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) {
|
||||
return "--";
|
||||
}
|
||||
return timeFormatter.format(new Date(timestamp));
|
||||
};
|
||||
|
||||
export const formatBytes = (value?: number): string => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return "--";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let next = value;
|
||||
let unitIndex = 0;
|
||||
while (next >= 1024 && unitIndex < units.length - 1) {
|
||||
next /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
const precision = next >= 100 || unitIndex === 0 ? 0 : next >= 10 ? 1 : 2;
|
||||
return `${next.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatMonitoringAxisBytes = (value?: number): string => formatBytes(value);
|
||||
|
||||
export const formatPercent = (value?: number): string => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return "--";
|
||||
}
|
||||
return `${(value * 100).toFixed(1)}%`;
|
||||
};
|
||||
|
||||
export const formatCompactNumber = (value?: number): string => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) {
|
||||
return "--";
|
||||
}
|
||||
return value.toLocaleString("zh-CN");
|
||||
};
|
||||
|
||||
export const formatDurationMs = (value?: number): string => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return "--";
|
||||
}
|
||||
return `${Math.round(value)}ms`;
|
||||
};
|
||||
|
||||
export const normalizeMonitoringProviderMode = (
|
||||
value: unknown,
|
||||
fallback: JVMMonitoringProviderMode = "jmx",
|
||||
): JVMMonitoringProviderMode => {
|
||||
const normalized = String(value || "").trim().toLowerCase();
|
||||
if (MONITORING_PROVIDER_MODES.includes(normalized as JVMMonitoringProviderMode)) {
|
||||
return normalized as JVMMonitoringProviderMode;
|
||||
}
|
||||
return MONITORING_PROVIDER_MODES.includes(fallback) ? fallback : "jmx";
|
||||
};
|
||||
|
||||
export const buildMonitoringAvailabilityText = ({
|
||||
missingMetrics,
|
||||
providerWarnings,
|
||||
}: Pick<JVMMonitoringSessionState, "missingMetrics" | "providerWarnings">): string => {
|
||||
const fragments: string[] = [];
|
||||
|
||||
if (Array.isArray(missingMetrics) && missingMetrics.length > 0) {
|
||||
fragments.push(
|
||||
`缺失指标:${missingMetrics
|
||||
.map((metric) => resolveMonitoringMetricLabel(metric))
|
||||
.join("、")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(providerWarnings) && providerWarnings.length > 0) {
|
||||
fragments.push(`监控来源告警:${providerWarnings.join(";")}`);
|
||||
}
|
||||
|
||||
if (fragments.length === 0) {
|
||||
return "当前监控会话未发现明显降级。";
|
||||
}
|
||||
|
||||
return fragments.join(" | ");
|
||||
};
|
||||
|
||||
export const formatRecentGCLabel = (
|
||||
event: JVMMonitoringRecentGCEvent,
|
||||
): string => {
|
||||
const parts = [
|
||||
formatMonitoringTime(event.timestamp),
|
||||
String(event.name || "").trim(),
|
||||
typeof event.durationMs === "number" ? `${event.durationMs}ms` : "",
|
||||
String(event.cause || "").trim(),
|
||||
].filter(Boolean);
|
||||
|
||||
return parts.join(" · ");
|
||||
};
|
||||
|
||||
export const buildMonitoringChartPoints = (
|
||||
points: JVMMonitoringPoint[] = [],
|
||||
): MonitoringChartPoint[] =>
|
||||
points.map((point) => ({
|
||||
...point,
|
||||
timeLabel: formatMonitoringTime(point.timestamp),
|
||||
}));
|
||||
|
||||
export const extractThreadStateRows = (
|
||||
point?: JVMMonitoringPoint,
|
||||
): Array<{ state: string; label: string; count: number }> =>
|
||||
Object.entries(point?.threadStateCounts || {})
|
||||
.map(([state, count]) => ({
|
||||
state,
|
||||
label: resolveThreadStateLabel(state),
|
||||
count: Number(count) || 0,
|
||||
}))
|
||||
.sort((left, right) => right.count - left.count);
|
||||
|
||||
export const monitoringMetricAvailable = (
|
||||
session: Pick<JVMMonitoringSessionState, "availableMetrics"> | undefined,
|
||||
metric: string,
|
||||
): boolean =>
|
||||
Array.isArray(session?.availableMetrics) &&
|
||||
session.availableMetrics.includes(metric);
|
||||
@@ -1,5 +1,5 @@
|
||||
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
|
||||
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic';
|
||||
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic' | 'monitoring';
|
||||
|
||||
export type JVMModeMeta = {
|
||||
mode: string;
|
||||
@@ -36,6 +36,7 @@ const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
|
||||
resource: 'JVM 资源',
|
||||
audit: 'JVM 审计',
|
||||
diagnostic: 'JVM 诊断',
|
||||
monitoring: 'JVM 监控',
|
||||
};
|
||||
|
||||
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();
|
||||
|
||||
64
frontend/src/utils/jvmSidebarActions.test.ts
Normal file
64
frontend/src/utils/jvmSidebarActions.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildJVMDiagnosticActionDescriptor,
|
||||
buildJVMMonitoringActionDescriptors,
|
||||
} from "./jvmSidebarActions";
|
||||
|
||||
describe("jvmSidebarActions", () => {
|
||||
it("builds direct JVM monitoring entries from probed provider capabilities", () => {
|
||||
expect(
|
||||
buildJVMMonitoringActionDescriptors("conn-1", [
|
||||
{ mode: "jmx" },
|
||||
{ mode: "endpoint" },
|
||||
{ mode: "jmx" },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
key: "conn-1-jvm-monitoring-jmx",
|
||||
title: "持续监控 · JMX",
|
||||
providerMode: "jmx",
|
||||
},
|
||||
{
|
||||
key: "conn-1-jvm-monitoring-endpoint",
|
||||
title: "持续监控 · Endpoint",
|
||||
providerMode: "endpoint",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips providers that cannot be browsed when building monitoring entries", () => {
|
||||
expect(
|
||||
buildJVMMonitoringActionDescriptors("conn-1", [
|
||||
{ mode: "jmx", canBrowse: true },
|
||||
{ mode: "agent", canBrowse: false },
|
||||
]),
|
||||
).toEqual([
|
||||
{
|
||||
key: "conn-1-jvm-monitoring-jmx",
|
||||
title: "持续监控 · JMX",
|
||||
providerMode: "jmx",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("builds diagnostic entry independently from provider probing", () => {
|
||||
expect(
|
||||
buildJVMDiagnosticActionDescriptor("conn-1", {
|
||||
enabled: true,
|
||||
transport: "arthas-tunnel",
|
||||
}),
|
||||
).toEqual({
|
||||
key: "conn-1-jvm-diagnostic",
|
||||
title: "诊断增强 · Arthas Tunnel",
|
||||
transport: "arthas-tunnel",
|
||||
});
|
||||
|
||||
expect(
|
||||
buildJVMDiagnosticActionDescriptor("conn-1", {
|
||||
enabled: false,
|
||||
transport: "agent-bridge",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
77
frontend/src/utils/jvmSidebarActions.ts
Normal file
77
frontend/src/utils/jvmSidebarActions.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { JVMCapability } from "../types";
|
||||
import {
|
||||
JVM_RUNTIME_MODES,
|
||||
resolveJVMModeMeta,
|
||||
type JVMRuntimeMode,
|
||||
} from "./jvmRuntimePresentation";
|
||||
|
||||
export type JVMMonitoringActionDescriptor = {
|
||||
key: string;
|
||||
title: string;
|
||||
providerMode: JVMRuntimeMode;
|
||||
};
|
||||
|
||||
export type JVMDiagnosticActionDescriptor = {
|
||||
key: string;
|
||||
title: string;
|
||||
transport: "agent-bridge" | "arthas-tunnel";
|
||||
};
|
||||
|
||||
const normalizeMonitoringMode = (value: unknown): JVMRuntimeMode | null => {
|
||||
const mode = String(value || "").trim().toLowerCase();
|
||||
return JVM_RUNTIME_MODES.includes(mode as JVMRuntimeMode)
|
||||
? (mode as JVMRuntimeMode)
|
||||
: null;
|
||||
};
|
||||
|
||||
export const buildJVMMonitoringActionDescriptors = (
|
||||
connectionId: string,
|
||||
capabilities: Array<Pick<JVMCapability, "mode"> & Partial<Pick<JVMCapability, "canBrowse">>>,
|
||||
): JVMMonitoringActionDescriptor[] => {
|
||||
const id = String(connectionId || "").trim();
|
||||
if (!id) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const seen = new Set<JVMRuntimeMode>();
|
||||
const descriptors: JVMMonitoringActionDescriptor[] = [];
|
||||
|
||||
capabilities.forEach((capability) => {
|
||||
if (capability.canBrowse === false) {
|
||||
return;
|
||||
}
|
||||
const providerMode = normalizeMonitoringMode(capability.mode);
|
||||
if (!providerMode || seen.has(providerMode)) {
|
||||
return;
|
||||
}
|
||||
seen.add(providerMode);
|
||||
|
||||
descriptors.push({
|
||||
key: `${id}-jvm-monitoring-${providerMode}`,
|
||||
title: `持续监控 · ${resolveJVMModeMeta(providerMode).label}`,
|
||||
providerMode,
|
||||
});
|
||||
});
|
||||
|
||||
return descriptors;
|
||||
};
|
||||
|
||||
export const buildJVMDiagnosticActionDescriptor = (
|
||||
connectionId: string,
|
||||
diagnostic: { enabled?: boolean; transport?: unknown } | undefined,
|
||||
): JVMDiagnosticActionDescriptor | null => {
|
||||
const id = String(connectionId || "").trim();
|
||||
if (!id || diagnostic?.enabled !== true) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const transport =
|
||||
String(diagnostic.transport || "").trim() === "arthas-tunnel"
|
||||
? "arthas-tunnel"
|
||||
: "agent-bridge";
|
||||
return {
|
||||
key: `${id}-jvm-diagnostic`,
|
||||
title: `诊断增强 · ${transport === "arthas-tunnel" ? "Arthas Tunnel" : "Agent Bridge"}`,
|
||||
transport,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user