feat(jvm-diagnostic): 优化诊断控制台命令体验

- 诊断命令输入使用编辑器外观并支持 Arthas 命令补全

- 新增命令执行 pending 输出、前端终态兜底和历史刷新

- 会话、输出、历史记录统一展示中文语义状态

- 补充诊断控制台和补全展示测试
This commit is contained in:
Syngnat
2026-04-26 14:34:23 +08:00
parent ff2b86819d
commit 38e71119a4
8 changed files with 888 additions and 225 deletions

View File

@@ -1,45 +1,166 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole from "./JVMDiagnosticConsole";
import JVMDiagnosticConsole, {
createJVMDiagnosticLocalPendingChunk,
createJVMDiagnosticRunningRecord,
isJVMDiagnosticTerminalPhase,
} from "./JVMDiagnosticConsole";
const baseState = {
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
};
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
const mockMonaco = {
Range: class {
startLineNumber: number;
startColumn: number;
endLineNumber: number;
endColumn: number;
constructor(
startLineNumber: number,
startColumn: number,
endLineNumber: number,
endColumn: number,
) {
this.startLineNumber = startLineNumber;
this.startColumn = startColumn;
this.endLineNumber = endLineNumber;
this.endColumn = endColumn;
}
},
KeyMod: { CtrlCmd: 2048 },
KeyCode: { Enter: 3 },
editor: {
setTheme: vi.fn(),
},
languages: {
CompletionItemKind: {
Keyword: 1,
Snippet: 2,
Value: 3,
},
CompletionItemInsertTextRule: {
InsertAsSnippet: 4,
},
register: vi.fn(),
registerCompletionItemProvider: vi.fn((language: string, provider: any) => {
if (language === "jvm-diagnostic") {
registeredCompletionProvider = provider;
}
return { dispose: vi.fn() };
}),
},
};
const mockEditor = {
addCommand: vi.fn(),
};
vi.mock("@monaco-editor/react", () => ({
default: ({ language, value }: { language?: string; value?: string }) => (
<div data-monaco-editor-mock="true" data-language={language}>
{value}
</div>
),
default: ({
beforeMount,
language,
onMount,
value,
}: {
beforeMount?: (monaco: any) => void;
language?: string;
onMount?: (editor: any, monaco: any) => void;
value?: string;
}) => {
beforeMount?.(mockMonaco);
onMount?.(mockEditor, mockMonaco);
return (
<div
data-before-mount={beforeMount ? "true" : "false"}
data-monaco-editor-mock="true"
data-language={language}
>
{value}
</div>
);
},
}));
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) =>
selector({
connections: [
{
id: "conn-1",
name: "orders-jvm",
config: {
host: "orders.internal",
jvm: {
diagnostic: {
enabled: true,
transport: "agent-bridge",
},
},
},
},
],
jvmDiagnosticDrafts: {},
jvmDiagnosticOutputs: {},
setJVMDiagnosticDraft: vi.fn(),
appendJVMDiagnosticOutput: vi.fn(),
clearJVMDiagnosticOutput: vi.fn(),
}),
useStore: (selector: (state: any) => any) => selector(mockState),
}));
describe("JVMDiagnosticConsole", () => {
it("shows observe command presets by default", () => {
beforeEach(() => {
registeredCompletionProvider = null;
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
mockEditor.addCommand.mockClear();
});
it("builds local pending output and history while a command is waiting for backend events", () => {
const chunk = createJVMDiagnosticLocalPendingChunk({
sessionId: "session-1",
commandId: "cmd-1",
command: "thread -n 5",
});
const record = createJVMDiagnosticRunningRecord({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
source: "manual",
reason: "排查线程",
});
expect(chunk).toMatchObject({
sessionId: "session-1",
commandId: "cmd-1",
event: "diagnostic",
phase: "running",
});
expect(chunk.content).toContain("thread -n 5");
expect(record).toMatchObject({
connectionId: "conn-1",
sessionId: "session-1",
commandId: "cmd-1",
transport: "arthas-tunnel",
command: "thread -n 5",
status: "running",
reason: "排查线程",
});
expect(isJVMDiagnosticTerminalPhase("completed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("failed")).toBe(true);
expect(isJVMDiagnosticTerminalPhase("running")).toBe(false);
});
it("explains the workflow and hides command inputs before session creation", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
@@ -51,10 +172,98 @@ describe("JVMDiagnosticConsole", () => {
/>,
);
expect(markup).toContain("使用流程");
expect(markup).toContain("检查能力(可选)");
expect(markup).toContain("先新建会话,再显示命令编辑区");
expect(markup).not.toContain("命令模板");
expect(markup).not.toContain('data-monaco-editor-mock="true"');
});
it("shows command input, reason field, and presets after a session exists", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thread -n 5",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain("overflow:auto");
expect(markup).toContain("JVM 诊断工作台");
expect(markup).toContain("会话与能力");
expect(markup).toContain("实时输出");
expect(markup).toContain("审计历史");
expect(markup.indexOf("命令输入")).toBeGreaterThanOrEqual(0);
expect(markup).toContain("诊断命令");
expect(markup).toContain("诊断原因(可选)");
expect(markup).toContain("用于审计记录");
expect(markup.indexOf("命令输入")).toBeLessThan(markup.indexOf("实时输出"));
expect(markup).toContain("观察类命令");
expect(markup).toContain("thread");
expect(markup).toContain("执行命令");
expect(markup).toContain('data-monaco-editor-mock="true"');
expect(markup).toContain('data-language="jvm-diagnostic"');
});
it("uses the same styled editor shell and registers command completion before mount", () => {
mockState = {
...baseState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
command: "thr",
reason: "排查 CPU 线程",
},
},
};
const markup = renderToStaticMarkup(
<JVMDiagnosticConsole
tab={{
id: "tab-1",
title: "诊断增强",
type: "jvm-diagnostic",
connectionId: "conn-1",
}}
/>,
);
expect(markup).toContain(
'data-jvm-diagnostic-command-editor-shell="true"',
);
expect(markup).toContain('data-before-mount="true"');
expect(markup).toContain("border-radius:14px");
expect(registeredCompletionProvider).toBeTruthy();
const result = registeredCompletionProvider.provideCompletionItems(
{
getValueInRange: () => "thr",
getWordUntilPosition: () => ({ startColumn: 1, endColumn: 4 }),
},
{ lineNumber: 1, column: 4 },
);
expect(result.suggestions).toEqual(
expect.arrayContaining([
expect.objectContaining({
label: "thread",
insertText: "thread ",
}),
]),
);
});
});

View File

@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import Editor, { type OnMount } from "@monaco-editor/react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Editor, { type BeforeMount, type OnMount } from "@monaco-editor/react";
import {
Alert,
Button,
@@ -30,8 +30,7 @@ import JVMDiagnosticOutput from "./jvm/JVMDiagnosticOutput";
const { Text, Paragraph } = Typography;
const JVM_DIAGNOSTIC_EDITOR_LANGUAGE = "jvm-diagnostic";
let jvmDiagnosticLanguageRegistered = false;
let jvmDiagnosticCompletionRegistered = false;
let jvmDiagnosticCompletionDisposable: { dispose?: () => void } | null = null;
type JVMDiagnosticConsoleProps = {
tab: TabData;
@@ -41,6 +40,131 @@ const DEFAULT_COMMAND =
JVM_DIAGNOSTIC_COMMAND_PRESETS.find((item) => item.category === "observe")
?.command || "thread -n 5";
const commandEditorShellStyle = (darkMode: boolean): React.CSSProperties => ({
borderRadius: 14,
border: darkMode ? "1px solid #303030" : "1px solid #e6eef8",
background: darkMode ? "rgba(255,255,255,0.04)" : "rgba(0,0,0,0.04)",
overflow: "hidden",
});
const registerJVMDiagnosticMonacoSupport = (monaco: any) => {
const languageRegistry = monaco.languages as Record<string, any>;
if (!languageRegistry.__gonaviJvmDiagnosticLanguageRegistered) {
languageRegistry.__gonaviJvmDiagnosticLanguageRegistered = true;
monaco.languages.register({ id: JVM_DIAGNOSTIC_EDITOR_LANGUAGE });
}
if (jvmDiagnosticCompletionDisposable?.dispose) {
jvmDiagnosticCompletionDisposable.dispose();
}
jvmDiagnosticCompletionDisposable =
monaco.languages.registerCompletionItemProvider(
JVM_DIAGNOSTIC_EDITOR_LANGUAGE,
{
triggerCharacters: [" ", "-", ".", "@", "'", "\"", "{", "/"],
provideCompletionItems: (model: any, position: any) => {
const textBeforeCursor = model.getValueInRange(
new monaco.Range(1, 1, position.lineNumber, position.column),
);
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = resolveJVMDiagnosticCompletionItems(
textBeforeCursor,
).map((item, index) => ({
label: item.label,
kind:
item.scope === "command"
? monaco.languages.CompletionItemKind.Keyword
: item.isSnippet
? monaco.languages.CompletionItemKind.Snippet
: monaco.languages.CompletionItemKind.Value,
insertText:
item.scope === "command"
? `${item.insertText} `
: item.insertText,
insertTextRules: item.isSnippet
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail,
documentation: item.documentation,
range,
sortText: `${item.scope === "command" ? "0" : "1"}-${String(index).padStart(3, "0")}`,
command:
item.scope === "command"
? { id: "editor.action.triggerSuggest" }
: undefined,
}));
return { suggestions };
},
},
);
};
export const isJVMDiagnosticTerminalPhase = (phase?: string): boolean =>
["completed", "failed", "canceled"].includes(
String(phase || "").toLowerCase().trim(),
);
export const createJVMDiagnosticLocalPendingChunk = ({
sessionId,
commandId,
command,
timestamp = Date.now(),
}: {
sessionId: string;
commandId: string;
command: string;
timestamp?: number;
}): JVMDiagnosticEventChunk => ({
sessionId,
commandId,
event: "diagnostic",
phase: "running",
content: `已提交诊断命令,等待后端输出:${command}`,
timestamp,
metadata: {
source: "local-pending",
},
});
export const createJVMDiagnosticRunningRecord = ({
connectionId,
sessionId,
commandId,
transport,
command,
source,
reason,
timestamp = Date.now(),
}: {
connectionId: string;
sessionId: string;
commandId: string;
transport: string;
command: string;
source?: string;
reason?: string;
timestamp?: number;
}): JVMDiagnosticAuditRecord => ({
timestamp,
connectionId,
sessionId,
commandId,
transport,
command,
source,
reason,
status: "running",
});
const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
@@ -63,6 +187,17 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
const [commandRunning, setCommandRunning] = useState(false);
const [activeCommandId, setActiveCommandId] = useState("");
const [error, setError] = useState("");
const activeCommandIdRef = useRef("");
const terminalCommandIdsRef = useRef<Set<string>>(new Set());
const finishActiveCommand = useCallback((commandId: string) => {
if (!commandId || activeCommandIdRef.current !== commandId) {
return;
}
activeCommandIdRef.current = "";
setCommandRunning(false);
setActiveCommandId("");
}, []);
useEffect(() => {
if (!draft.command) {
@@ -93,6 +228,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
: null),
[diagnosticTransport, draft.sessionId, session],
);
const hasSession = Boolean(effectiveSession?.sessionId);
const loadAuditRecords = useCallback(async () => {
if (!connection) {
@@ -165,14 +301,9 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
if (payload.chunk.phase === "failed") {
setError(payload.chunk.content || "诊断命令执行失败");
}
if (
payload.chunk.commandId &&
["completed", "failed", "canceled"].includes(payload.chunk.phase || "")
) {
if (payload.chunk.commandId === activeCommandId) {
setCommandRunning(false);
setActiveCommandId("");
}
if (payload.chunk.commandId && isJVMDiagnosticTerminalPhase(payload.chunk.phase)) {
terminalCommandIdsRef.current.add(payload.chunk.commandId);
finishActiveCommand(payload.chunk.commandId);
void loadAuditRecords();
}
});
@@ -182,7 +313,7 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
stopListening();
}
};
}, [activeCommandId, appendOutput, loadAuditRecords, tab.id]);
}, [appendOutput, finishActiveCommand, loadAuditRecords, tab.id]);
const handleProbe = async () => {
if (!rpcConnectionConfig) {
@@ -201,12 +332,12 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
rpcConnectionConfig,
);
if (result?.success === false) {
throw new Error(String(result?.message || "探测诊断能力失败"));
throw new Error(String(result?.message || "检查诊断能力失败"));
}
setCapabilities(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setCapabilities([]);
setError(err?.message || "探测诊断能力失败");
setError(err?.message || "检查诊断能力失败");
} finally {
setLoading(false);
}
@@ -262,25 +393,50 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
setError("请先创建诊断会话,再执行命令");
return;
}
if (!draft.command.trim()) {
const command = draft.command.trim();
if (!command) {
setError("诊断命令不能为空");
return;
}
const sessionId = effectiveSession.sessionId;
const commandId = `diag-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const source = draft.source || "manual";
const reason = (draft.reason || "").trim();
activeCommandIdRef.current = commandId;
terminalCommandIdsRef.current.delete(commandId);
setCommandRunning(true);
setActiveCommandId(commandId);
setError("");
appendOutput(tab.id, [
createJVMDiagnosticLocalPendingChunk({
sessionId,
commandId,
command,
}),
]);
setRecords((current) => [
createJVMDiagnosticRunningRecord({
connectionId: connection?.id || rpcConnectionConfig.id || "",
sessionId,
commandId,
transport: diagnosticTransport,
command,
source,
reason,
}),
...current.filter((record) => record.commandId !== commandId),
].slice(0, 20));
try {
const result = await backendApp.JVMExecuteDiagnosticCommand(
rpcConnectionConfig,
tab.id,
{
sessionId: effectiveSession.sessionId,
sessionId,
commandId,
command: draft.command.trim(),
source: draft.source || "manual",
reason: (draft.reason || "").trim(),
command,
source,
reason,
},
);
if (result?.success === false) {
@@ -289,11 +445,75 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
if (result?.message) {
message.warning(result.message);
}
const terminalSeen = terminalCommandIdsRef.current.has(commandId);
if (!terminalSeen) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "completed",
content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
},
]);
}
finishActiveCommand(commandId);
await loadAuditRecords();
if (!terminalSeen) {
setRecords((current) => {
const index = current.findIndex((record) => record.commandId === commandId);
if (index >= 0) {
const next = [...current];
next[index] = { ...next[index], status: "completed" };
return next;
}
return [
{
...createJVMDiagnosticRunningRecord({
connectionId: connection?.id || rpcConnectionConfig.id || "",
sessionId,
commandId,
transport: diagnosticTransport,
command,
source,
reason,
}),
status: "completed",
},
...current,
].slice(0, 20);
});
}
} catch (err: any) {
setCommandRunning(false);
setActiveCommandId("");
setError(err?.message || "执行诊断命令失败");
const messageText = err?.message || "执行诊断命令失败";
if (!terminalCommandIdsRef.current.has(commandId)) {
appendOutput(tab.id, [
{
sessionId,
commandId,
event: "diagnostic",
phase: "failed",
content: messageText,
timestamp: Date.now(),
metadata: {
source: "frontend-fallback",
},
},
]);
setRecords((current) =>
current.map((record) =>
record.commandId === commandId
? { ...record, status: "failed" }
: record,
),
);
}
finishActiveCommand(commandId);
setError(messageText);
}
};
@@ -327,198 +547,305 @@ const JVMDiagnosticConsole: React.FC<JVMDiagnosticConsoleProps> = ({ tab }) => {
}
};
const handleCommandEditorBeforeMount: BeforeMount = (monaco) => {
registerJVMDiagnosticMonacoSupport(monaco);
};
const handleCommandEditorMount: OnMount = (editor, monaco) => {
monaco.editor.setTheme(darkMode ? "transparent-dark" : "transparent-light");
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => {
void handleExecuteCommand();
});
if (!jvmDiagnosticLanguageRegistered) {
jvmDiagnosticLanguageRegistered = true;
monaco.languages.register({ id: JVM_DIAGNOSTIC_EDITOR_LANGUAGE });
}
if (!jvmDiagnosticCompletionRegistered) {
jvmDiagnosticCompletionRegistered = true;
monaco.languages.registerCompletionItemProvider(
JVM_DIAGNOSTIC_EDITOR_LANGUAGE,
{
triggerCharacters: [" ", "-", ".", "@", "'", "\"", "{", "/"],
provideCompletionItems: (model: any, position: any) => {
const textBeforeCursor = model.getValueInRange(
new monaco.Range(1, 1, position.lineNumber, position.column),
);
const word = model.getWordUntilPosition(position);
const range = {
startLineNumber: position.lineNumber,
endLineNumber: position.lineNumber,
startColumn: word.startColumn,
endColumn: word.endColumn,
};
const suggestions = resolveJVMDiagnosticCompletionItems(
textBeforeCursor,
).map((item, index) => ({
label: item.label,
kind:
item.scope === "command"
? monaco.languages.CompletionItemKind.Keyword
: item.isSnippet
? monaco.languages.CompletionItemKind.Snippet
: monaco.languages.CompletionItemKind.Value,
insertText:
item.scope === "command"
? `${item.insertText} `
: item.insertText,
insertTextRules: item.isSnippet
? monaco.languages.CompletionItemInsertTextRule.InsertAsSnippet
: undefined,
detail: item.detail,
documentation: item.documentation,
range,
sortText: `${item.scope === "command" ? "0" : "1"}-${String(index).padStart(3, "0")}`,
command:
item.scope === "command"
? { id: "editor.action.triggerSuggest" }
: undefined,
}));
return { suggestions };
},
},
);
}
};
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
const pageBackground = darkMode
? "linear-gradient(135deg, #101820 0%, #141414 48%, #1f1f1f 100%)"
: "linear-gradient(135deg, #eef4ff 0%, #f7f9fc 45%, #ffffff 100%)";
const heroBackground = 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))";
const cardStyle = {
borderRadius: 16,
boxShadow: darkMode
? "0 18px 44px rgba(0, 0, 0, 0.22)"
: "0 18px 44px rgba(24, 54, 96, 0.08)",
};
return (
<div
style={{ padding: 20, display: "grid", gap: 16, height: "100%" }}
style={{
padding: 24,
display: "grid",
gap: 18,
height: "100%",
minHeight: 0,
overflow: "auto",
alignContent: "start",
background: pageBackground,
}}
data-jvm-diagnostic-console="true"
>
<Card>
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Space size={8} wrap>
<Text strong>{connection.name}</Text>
<Tag color="blue">{diagnosticTransport}</Tag>
{effectiveSession ? <Tag color="green"></Tag> : <Tag></Tag>}
<Card
variant="borderless"
style={{
...cardStyle,
background: heroBackground,
border: darkMode ? "1px solid rgba(255,255,255,0.08)" : "1px solid rgba(22,119,255,0.12)",
}}
>
<div
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) auto",
gap: 18,
alignItems: "center",
}}
>
<div style={{ minWidth: 0 }}>
<Text type="secondary">JVM Diagnostics</Text>
<Typography.Title level={3} style={{ margin: "4px 0 8px" }}>
JVM
</Typography.Title>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
<Text type="secondary">
{" "}· {connection.config.host || "unknown"}:{connection.config.port || 0}
{" "}· {diagnosticTransport}
</Text>
</Paragraph>
</div>
<Space wrap style={{ justifyContent: "flex-end" }}>
<Tag color={hasSession ? "green" : "default"}>
{hasSession ? "会话已建立" : "未建会话"}
</Tag>
{commandRunning ? <Tag color="processing"></Tag> : null}
</Space>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
AI
</Paragraph>
<Space wrap>
<Button size="small" onClick={() => void handleProbe()} loading={loading}>
<Button onClick={() => void handleProbe()} loading={loading}>
</Button>
<Button
size="small"
type="primary"
type={hasSession ? "default" : "primary"}
onClick={() => void handleStartSession()}
loading={loading}
>
</Button>
<Button
size="small"
type="primary"
onClick={() => void handleExecuteCommand()}
loading={commandRunning}
>
</Button>
<Button
size="small"
danger
disabled={!commandRunning || !effectiveSession?.sessionId || !activeCommandId}
onClick={() => void handleCancelCommand()}
loading={loading && commandRunning}
>
</Button>
<Button size="small" onClick={() => clearOutput(tab.id)}>
</Button>
<Button size="small" onClick={() => void loadAuditRecords()} loading={historyLoading}>
{hasSession ? "重建会话" : "新建会话"}
</Button>
{hasSession ? (
<Button
type="primary"
onClick={() => void handleExecuteCommand()}
loading={commandRunning}
>
</Button>
) : null}
{hasSession ? (
<Button
danger
disabled={!commandRunning || !effectiveSession?.sessionId || !activeCommandId}
onClick={() => void handleCancelCommand()}
loading={loading && commandRunning}
>
</Button>
) : null}
</Space>
{error ? <Alert type="error" showIcon message={error} /> : null}
{capabilities.length ? (
<Space size={8} wrap>
{capabilities.map((item) => (
<Tag key={item.transport} color="processing">
{item.transport}
</Tag>
))}
</Space>
) : null}
</Space>
</Card>
<Card title="命令模板">
<JVMCommandPresetBar
onSelectPreset={(preset) =>
setDraft(tab.id, {
command: preset.command,
reason: preset.description,
source: "manual",
})
}
/>
</Card>
<Card title="命令输入">
<div style={{ display: "grid", gap: 12 }}>
<Editor
height={180}
language={JVM_DIAGNOSTIC_EDITOR_LANGUAGE}
theme={darkMode ? "transparent-dark" : "transparent-light"}
value={draft.command}
onMount={handleCommandEditorMount}
options={{
minimap: { enabled: false },
fontSize: 13,
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: "on",
quickSuggestions: true,
suggestOnTriggerCharacters: true,
lineNumbers: "off",
folding: false,
glyphMargin: false,
}}
onChange={(value) =>
setDraft(tab.id, {
command: value || "",
source: "manual",
})
}
/>
<Input
value={draft.reason || ""}
placeholder="输入诊断原因,便于审计和 AI 上下文理解"
onChange={(event) => setDraft(tab.id, { reason: event.target.value })}
/>
</div>
{error ? <Alert type="error" showIcon message={error} style={{ marginTop: 16 }} /> : null}
</Card>
{!hasSession ? (
<Card title="使用流程" variant="borderless" style={cardStyle}>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 220px), 1fr))",
gap: 12,
}}
>
{[
["1", "检查能力(可选)", "读取诊断通道、流式输出和命令权限,不创建会话、不执行命令。"],
["2", "新建会话", "创建一次诊断上下文。Arthas Tunnel 的目标连接会在测试或执行命令时发生。"],
["3", "执行命令", "先新建会话,再显示命令编辑区;会话创建后才显示原因输入和模板区。"],
].map(([index, title, description]) => (
<div
key={index}
style={{
padding: 14,
borderRadius: 14,
border: darkMode ? "1px solid #303030" : "1px solid #e6eef8",
background: darkMode ? "rgba(255,255,255,0.03)" : "rgba(255,255,255,0.72)",
}}
>
<Tag color="blue">{index}</Tag>
<Text strong>{title}</Text>
<Paragraph type="secondary" style={{ margin: "8px 0 0" }}>
{description}
</Paragraph>
</div>
))}
</div>
</Card>
) : (
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))",
gap: 18,
alignItems: "start",
}}
>
<div style={{ display: "grid", gap: 18, minWidth: 0 }}>
<Card title="命令输入" variant="borderless" style={cardStyle}>
<div style={{ display: "grid", gap: 14 }}>
<div style={{ display: "grid", gap: 6 }}>
<Text strong></Text>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
Arthas/ thread -n 5dashboardjvm Ctrl/Cmd + Enter
</Paragraph>
<div
data-jvm-diagnostic-command-editor-shell="true"
style={commandEditorShellStyle(darkMode)}
>
<Editor
beforeMount={handleCommandEditorBeforeMount}
height={220}
language={JVM_DIAGNOSTIC_EDITOR_LANGUAGE}
theme={
darkMode ? "transparent-dark" : "transparent-light"
}
value={draft.command}
onMount={handleCommandEditorMount}
options={{
minimap: { enabled: false },
fontSize: 13,
automaticLayout: true,
scrollBeyondLastLine: false,
wordWrap: "on",
quickSuggestions: {
other: true,
comments: false,
strings: true,
},
suggestOnTriggerCharacters: true,
lineNumbers: "off",
folding: false,
glyphMargin: false,
renderLineHighlight: "all",
roundedSelection: true,
}}
onChange={(value) =>
setDraft(tab.id, {
command: value || "",
source: "manual",
})
}
/>
</div>
</div>
<div style={{ display: "grid", gap: 6 }}>
<Text strong></Text>
<Input
value={draft.reason || ""}
placeholder="例如:排查 CPU 飙高、确认线程阻塞、定位慢方法"
onChange={(event) => setDraft(tab.id, { reason: event.target.value })}
/>
<Text type="secondary">
AI Arthas JVM
</Text>
</div>
</div>
</Card>
<Card title="命令模板" variant="borderless" style={cardStyle}>
<JVMCommandPresetBar
onSelectPreset={(preset) =>
setDraft(tab.id, {
command: preset.command,
reason: preset.description,
source: "manual",
})
}
/>
</Card>
</div>
<Card title="会话与能力" variant="borderless" style={cardStyle}>
<Space direction="vertical" size={12} style={{ width: "100%" }}>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Tag color="blue">{effectiveSession?.sessionId}</Tag>
<Tag>{effectiveSession?.transport || diagnosticTransport}</Tag>
<Tag color={commandRunning ? "processing" : "green"}>
{commandRunning ? "命令执行中" : "空闲"}
</Tag>
</div>
<Paragraph type="secondary" style={{ marginBottom: 0 }}>
</Paragraph>
<Space wrap>
<Button size="small" onClick={() => clearOutput(tab.id)}>
</Button>
<Button size="small" onClick={() => void loadAuditRecords()} loading={historyLoading}>
</Button>
</Space>
{capabilities.length ? (
<Alert
type="info"
showIcon
message="能力检查结果"
description={
<Space size={8} wrap>
{capabilities.map((item) => (
<Space key={item.transport} size={4} wrap>
<Tag color="processing">{item.transport}</Tag>
<Tag color={item.canOpenSession ? "green" : "red"}>
{item.canOpenSession ? "可建会话" : "不可建会话"}
</Tag>
<Tag color={item.canStream ? "green" : "red"}>
{item.canStream ? "支持流式输出" : "不支持流式输出"}
</Tag>
<Tag color={item.allowObserveCommands ? "green" : "red"}>
{item.allowObserveCommands ? "允许观察命令" : "禁止观察命令"}
</Tag>
{item.allowTraceCommands ? <Tag color="gold"> Trace</Tag> : null}
{item.allowMutatingCommands ? <Tag color="red"></Tag> : null}
</Space>
))}
</Space>
}
/>
) : (
<Alert
type="info"
showIcon
message="尚未检查能力"
description="如需确认当前连接是否允许 observe/trace/高风险命令,可点击顶部“检查能力”。"
/>
)}
</Space>
</Card>
</div>
)}
<div
style={{
display: "grid",
gap: 16,
gridTemplateColumns: "minmax(0, 2fr) minmax(320px, 1fr)",
gap: 18,
gridTemplateColumns: "repeat(auto-fit, minmax(min(100%, 360px), 1fr))",
alignItems: "start",
}}
>
<Card title="输出面板">
<Card title="实时输出" variant="borderless" style={cardStyle}>
<JVMDiagnosticOutput chunks={chunks} />
</Card>
<Card title="会话与历史">
<Card title="审计历史" variant="borderless" style={cardStyle}>
<JVMDiagnosticHistory session={effectiveSession} records={records} />
</Card>
</div>

View File

@@ -5,6 +5,13 @@ import type {
JVMDiagnosticAuditRecord,
JVMDiagnosticSessionHandle,
} from "../../types";
import {
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticTransportLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
@@ -23,7 +30,7 @@ const JVMDiagnosticHistory: React.FC<JVMDiagnosticHistoryProps> = ({
{session ? (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<Tag color="blue">{session.sessionId}</Tag>
<Tag>{session.transport}</Tag>
<Tag>{formatJVMDiagnosticTransportLabel(session.transport)}</Tag>
</div>
) : (
<Empty
@@ -55,10 +62,16 @@ const JVMDiagnosticHistory: React.FC<JVMDiagnosticHistoryProps> = ({
{record.command}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{record.status ? <Tag color="green">{record.status}</Tag> : null}
{record.riskLevel ? <Tag color="gold">{record.riskLevel}</Tag> : null}
{record.commandType ? <Tag color="blue">{record.commandType}</Tag> : null}
{record.source ? <Tag>{record.source}</Tag> : null}
{record.status ? (
<Tag color="green">{formatJVMDiagnosticPhaseLabel(record.status)}</Tag>
) : null}
{record.riskLevel ? (
<Tag color="gold">{formatJVMDiagnosticRiskLabel(record.riskLevel)}</Tag>
) : null}
{record.commandType ? (
<Tag color="blue">{formatJVMDiagnosticCommandTypeLabel(record.commandType)}</Tag>
) : null}
{record.source ? <Tag>{formatJVMDiagnosticSourceLabel(record.source)}</Tag> : null}
</div>
<Text type="secondary">
{record.reason || "未填写诊断原因"}

View File

@@ -2,7 +2,11 @@ import React from "react";
import { Empty, List, Tag, Typography } from "antd";
import type { JVMDiagnosticEventChunk } from "../../types";
import { formatJVMDiagnosticChunkText } from "../../utils/jvmDiagnosticPresentation";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
} from "../../utils/jvmDiagnosticPresentation";
const { Text } = Typography;
@@ -35,8 +39,10 @@ const JVMDiagnosticOutput: React.FC<JVMDiagnosticOutputProps> = ({ chunks }) =>
{formatJVMDiagnosticChunkText(chunk)}
</Text>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{chunk.phase ? <Tag color="geekblue">{chunk.phase}</Tag> : null}
{chunk.event ? <Tag>{chunk.event}</Tag> : null}
{chunk.phase ? (
<Tag color="geekblue">{formatJVMDiagnosticPhaseLabel(chunk.phase)}</Tag>
) : null}
{chunk.event ? <Tag>{formatJVMDiagnosticEventLabel(chunk.event)}</Tag> : null}
{chunk.commandId ? <Tag color="blue">{chunk.commandId}</Tag> : null}
</div>
</div>

View File

@@ -13,6 +13,12 @@ describe("jvmDiagnosticCompletion", () => {
expect(items.some((item) => item.label === "trace")).toBe(true);
});
it("suggests the jvm command from the command input hint", () => {
const items = resolveJVMDiagnosticCompletionItems("jv");
expect(items.some((item) => item.label === "jvm")).toBe(true);
});
it("switches to argument mode after the command head", () => {
expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({
head: "thread",

View File

@@ -29,6 +29,11 @@ const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
detail: "观察类命令",
documentation: "查看 JVM 运行总览。",
},
{
head: "jvm",
detail: "观察类命令",
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
},
{
head: "thread",
detail: "观察类命令",
@@ -158,6 +163,15 @@ const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
scope: "argument",
},
],
jvm: [
{
label: "jvm",
insertText: "",
detail: "直接执行",
documentation: "查看 JVM 内存、线程、类加载、GC 和运行参数信息。",
scope: "argument",
},
],
thread: [
{
label: "繁忙线程 TOP N (-n)",

View File

@@ -2,6 +2,11 @@ import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
formatJVMDiagnosticTransportLabel,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
@@ -17,14 +22,22 @@ describe("jvmDiagnosticPresentation", () => {
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
});
it("formats chunk text with phase prefix when content exists", () => {
it("formats chunk text with localized phase prefix when content exists", () => {
expect(
formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content: "thread -n 5",
}),
).toBe("running: thread -n 5");
).toBe("执行中:thread -n 5");
});
it("localizes diagnostic status, transport, risk and source labels", () => {
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成");
expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel");
expect(formatJVMDiagnosticRiskLabel("high")).toBe("高风险");
expect(formatJVMDiagnosticCommandTypeLabel("trace")).toBe("跟踪类");
expect(formatJVMDiagnosticSourceLabel("ai-plan")).toBe("AI 计划");
});
it("maps risk levels to tag colors", () => {

View File

@@ -66,6 +66,43 @@ const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
high: "red",
};
const PHASE_LABELS: Record<string, string> = {
running: "执行中",
completed: "已完成",
failed: "失败",
canceled: "已取消",
canceling: "取消中",
diagnostic: "诊断事件",
};
const EVENT_LABELS: Record<string, string> = {
diagnostic: "诊断输出",
chunk: "输出片段",
done: "执行结束",
};
const TRANSPORT_LABELS: Record<string, string> = {
"agent-bridge": "Agent Bridge",
"arthas-tunnel": "Arthas Tunnel",
};
const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
};
const COMMAND_TYPE_LABELS: Record<string, string> = {
observe: "观察类",
trace: "跟踪类",
mutating: "高风险类",
};
const SOURCE_LABELS: Record<string, string> = {
manual: "手动输入",
"ai-plan": "AI 计划",
};
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
@@ -74,6 +111,41 @@ export const resolveJVMDiagnosticRiskColor = (
riskLevel: "low" | "medium" | "high",
): string => RISK_COLORS[riskLevel];
const normalizeLabelKey = (value?: string | null): string =>
String(value || "").trim().toLowerCase();
const formatWithFallback = (
value: string | undefined | null,
labels: Record<string, string>,
fallback = "未知",
): string => {
const normalized = normalizeLabelKey(value);
if (!normalized) {
return fallback;
}
return labels[normalized] || String(value || "").trim();
};
export const formatJVMDiagnosticPhaseLabel = (phase?: string | null): string =>
formatWithFallback(phase, PHASE_LABELS);
export const formatJVMDiagnosticEventLabel = (event?: string | null): string =>
formatWithFallback(event, EVENT_LABELS);
export const formatJVMDiagnosticTransportLabel = (
transport?: string | null,
): string => formatWithFallback(transport, TRANSPORT_LABELS);
export const formatJVMDiagnosticRiskLabel = (risk?: string | null): string =>
formatWithFallback(risk, RISK_LABELS);
export const formatJVMDiagnosticCommandTypeLabel = (
type?: string | null,
): string => formatWithFallback(type, COMMAND_TYPE_LABELS);
export const formatJVMDiagnosticSourceLabel = (source?: string | null): string =>
formatWithFallback(source, SOURCE_LABELS);
export const groupJVMDiagnosticPresets = (
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
): Array<{
@@ -90,16 +162,19 @@ export const groupJVMDiagnosticPresets = (
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string => {
const phase = String(chunk.phase || chunk.event || "").trim();
const rawPhase = String(chunk.phase || chunk.event || "").trim();
const phase = chunk.phase
? formatJVMDiagnosticPhaseLabel(chunk.phase)
: formatJVMDiagnosticEventLabel(chunk.event);
const content = String(chunk.content || "").trim();
if (!phase && !content) {
if (!rawPhase && !content) {
return "空事件";
}
if (!phase) {
if (!rawPhase) {
return content;
}
if (!content) {
return phase;
}
return `${phase}: ${content}`;
return `${phase}${content}`;
};