mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-05-30 15:19:59 +08:00
✨ feat(jvm-ui): 完善 JVM 工作台与监控入口
- 新增 JVM 持续监控仪表盘、图表、状态卡和详情面板 - 统一概览、资源浏览、审计页面的 JVM 工作台布局 - Sidebar 和 TabManager 支持监控入口、诊断入口兜底和上下文切换 - 补充前端状态模型、展示文案和组件回归测试
This commit is contained in:
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