feat(jvm): 完成资源治理与诊断增强

- 新增 JMX/Endpoint/Agent 三种 JVM 连接模式与配置归一化链路
- 支持资源浏览、变更预览、写入应用、审计记录与只读约束
- 接入 AI 结构化写入计划与诊断计划回填能力
- 新增 Agent Bridge、Arthas Tunnel、JMX Helper 诊断传输实现
- 增加诊断控制台、命令模板、输出历史与自动补全交互
- 补齐前后端契约、运行夹具与 JVM 相关回归测试
This commit is contained in:
Syngnat
2026-04-24 16:45:34 +08:00
parent d9b4c6a21b
commit 6f14e827ab
79 changed files with 18941 additions and 4349 deletions

View File

@@ -4,6 +4,7 @@ import {
getStoredSecretPlaceholder,
normalizeConnectionSecretErrorMessage,
resolveConnectionTestFailureFeedback,
summarizeConnectionTestFailureMessage,
} from './connectionModalPresentation';
describe('connectionModalPresentation', () => {
@@ -33,14 +34,14 @@ describe('connectionModalPresentation', () => {
expect(normalizeConnectionSecretErrorMessage('连接测试超时')).toBe('连接测试超时');
});
it('shows a toast-worthy failure message for saved-secret lookup errors during connection tests', () => {
it('keeps saved-secret lookup errors inside the modal instead of raising a global toast', () => {
expect(resolveConnectionTestFailureFeedback({
kind: 'runtime',
reason: 'saved connection not found: conn-1',
fallback: '连接失败',
})).toEqual({
message: '测试失败: 未找到当前连接对应的已保存密文,请重新填写密码并保存后再试',
shouldToast: true,
shouldToast: false,
});
});
@@ -54,4 +55,10 @@ describe('connectionModalPresentation', () => {
shouldToast: false,
});
});
});
it('uses only the first line for connection failure toast summaries', () => {
expect(summarizeConnectionTestFailureMessage(`测试失败: 当前端口不是 JMX 远程管理端口\n建议请改填 JMX 端口\n技术细节raw error`)).toBe(
'测试失败: 当前端口不是 JMX 远程管理端口',
);
});
});

View File

@@ -50,6 +50,18 @@ export const normalizeConnectionSecretErrorMessage = (
return text;
};
export const summarizeConnectionTestFailureMessage = (
value: unknown,
fallback = '',
): string => {
const text = normalizeConnectionSecretErrorMessage(value, fallback);
const [firstLine] = text
.split(/\r?\n/)
.map((item) => item.trim())
.filter((item) => item !== '');
return firstLine || text;
};
export const resolveConnectionTestFailureFeedback = ({
kind,
reason,
@@ -68,7 +80,7 @@ export const resolveConnectionTestFailureFeedback = ({
return {
message: `测试失败: ${normalizeConnectionSecretErrorMessage(reason, fallback)}`,
shouldToast: true,
shouldToast: false,
};
};

View File

@@ -53,6 +53,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
resourceId: 'orders/user:1',
action: 'put',
reason: '修复缓存脏值',
source: 'ai-plan',
payload: {
status: 'ACTIVE',
},
@@ -69,6 +70,7 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
resourceId: '/cache/orders',
action: 'clear',
reason: '受控清理',
source: 'ai-plan',
payload: {},
});
});
@@ -79,7 +81,24 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览要求 payload 仍然是 JSON 对象');
});
it('keeps generic action for managed bean payload updates', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"attribute","selector":{"resourcePath":"jmx://java.lang/type=Memory/attribute/Verbose"},"action":"set","payload":{"format":"json","value":{"value":true}},"reason":"开启诊断日志"}\n```',
);
expect(plan).not.toBeNull();
expect(buildJVMChangeDraftFromAIPlan(plan!)).toEqual({
resourceId: 'jmx://java.lang/type=Memory/attribute/Verbose',
action: 'set',
reason: '开启诊断日志',
source: 'ai-plan',
payload: {
value: true,
},
});
});
});

View File

@@ -1,13 +1,13 @@
import type { JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean';
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
selector: {
namespace?: string;
key?: string;
resourcePath?: string;
};
action: 'updateValue' | 'evict' | 'clear';
action: string;
payload?: {
format: 'json' | 'text';
value: unknown;
@@ -15,7 +15,7 @@ export type JVMAIChangePlan = {
reason: string;
};
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'payload'>;
export type JVMAIChangeDraft = Pick<JVMChangeRequest, 'resourceId' | 'action' | 'reason' | 'source' | 'payload'>;
type JVMAIPlanPromptContext = {
connectionName: string;
@@ -28,8 +28,7 @@ type JVMAIPlanPromptContext = {
};
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean']);
const allowedActions = new Set<JVMAIChangePlan['action']>(['updateValue', 'evict', 'clear']);
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean', 'attribute', 'operation']);
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
@@ -90,7 +89,7 @@ const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
const selector = normalizeSelector(value.selector);
const payload = normalizePayload(value.payload);
if (!allowedTargetTypes.has(targetType) || !allowedActions.has(action) || !reason || !selector) {
if (!allowedTargetTypes.has(targetType) || !action || !reason || !selector) {
return null;
}
@@ -189,27 +188,74 @@ export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChang
throw new Error('AI 计划缺少变更原因');
}
const action = asTrimmedString(plan.action);
if (!action) {
throw new Error('AI 计划缺少可执行 action');
}
if (plan.action === 'updateValue') {
const value = plan.payload?.value;
if (plan.payload?.format !== 'json' || !isRecord(value)) {
throw new Error('当前 JVM 预览仅支持 JSON 对象作为变更 payload');
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action: 'put',
reason,
source: 'ai-plan',
payload: value as Record<string, any>,
};
}
const payloadValue = plan.payload?.value;
if (plan.payload && plan.payload.format === 'json') {
if (!isRecord(payloadValue)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
}
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: payloadValue as Record<string, any>,
};
}
if (plan.payload && plan.payload.format === 'text') {
return {
resourceId,
action,
reason,
source: 'ai-plan',
payload: {
value: payloadValue == null ? '' : String(payloadValue),
},
};
}
return {
resourceId,
action: plan.action,
action,
reason,
source: 'ai-plan',
payload: {},
};
};
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
if (!actions || actions.length === 0) {
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action并保持 payload 为 JSON 对象。';
}
return actions
.map((item) => {
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
? `payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
: '';
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `${item.description}` : ''}${payloadFields}`;
})
.join('\n');
};
export const buildJVMAIPlanPrompt = ({
connectionName,
host,
@@ -222,6 +268,7 @@ export const buildJVMAIPlanPrompt = ({
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
const environmentLabel = asTrimmedString(environment) || 'unknown';
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
return [
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
@@ -238,12 +285,15 @@ export const buildJVMAIPlanPrompt = ({
formatSnapshotValue(snapshot),
'```',
'',
'当前资源支持动作:',
supportedActionsText,
'',
'输出要求:',
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
'2. 代码块里的 JSON 字段必须严格是targetType、selector、action、payload、reason。',
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
'4. action 只能使用 updateValue、evict、clear 之一。',
'5. 当前 MVP 只支持 JSON 对象变更:如果 action=updateValue则 payload 必须是 {"format":"json","value":{...}},且 value 必须是 JSON 对象evict/clear 时 payload 可以省略。',
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
'',
'JSON 示例:',
@@ -254,7 +304,7 @@ export const buildJVMAIPlanPrompt = ({
selector: {
resourcePath: normalizedPath,
},
action: 'updateValue',
action: 'put',
payload: {
format: 'json',
value: {

View File

@@ -1,119 +1,183 @@
import { describe, expect, it } from 'vitest';
import { describe, expect, it } from "vitest";
import {
buildDefaultJVMConnectionValues,
buildJVMConnectionConfig,
hasUnsupportedJVMDiagnosticTransport,
hasUnsupportedJVMEditableModes,
normalizeEditableJVMModes,
resolveEditableJVMModeSelection,
} from './jvmConnectionConfig';
} from "./jvmConnectionConfig";
describe('jvmConnectionConfig', () => {
it('defaults to readonly jmx mode', () => {
describe("jvmConnectionConfig", () => {
it("defaults to readonly jmx mode", () => {
const values = buildDefaultJVMConnectionValues();
expect(values.type).toBe('jvm');
expect(values.type).toBe("jvm");
expect(values.jvmReadOnly).toBe(true);
expect(values.jvmAllowedModes).toEqual(['jmx']);
expect(values.jvmPreferredMode).toBe('jmx');
expect(values.jvmAllowedModes).toEqual(["jmx"]);
expect(values.jvmPreferredMode).toBe("jmx");
expect(values.jvmDiagnosticEnabled).toBe(false);
expect(values.jvmDiagnosticTransport).toBe("agent-bridge");
expect(values.jvmDiagnosticAllowObserveCommands).toBe(true);
expect(values.jvmDiagnosticAllowTraceCommands).toBe(false);
expect(values.jvmDiagnosticAllowMutatingCommands).toBe(false);
expect(values.jvmDiagnosticTimeoutSeconds).toBe(15);
});
it('builds nested jvm config payload', () => {
it("builds nested jvm config payload", () => {
const config = buildJVMConnectionConfig({
name: 'Orders JVM',
type: 'jvm',
host: 'orders.internal',
name: "Orders JVM",
type: "jvm",
host: "orders.internal",
port: 9010,
jvmReadOnly: true,
jvmAllowedModes: ['jmx', 'endpoint'],
jvmPreferredMode: 'endpoint',
jvmEnvironment: 'prod',
jvmAllowedModes: ["jmx", "endpoint", "agent"],
jvmPreferredMode: "agent",
jvmEnvironment: "prod",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
jvmEndpointApiKey: 'token-1',
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmEndpointApiKey: "token-1",
jvmAgentEnabled: true,
jvmAgentBaseUrl: "http://127.0.0.1:19090/gonavi/agent/jvm",
jvmAgentApiKey: "agent-token",
timeout: 45,
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.jvm?.preferredMode).toBe("agent");
expect(config.jvm?.endpoint?.baseUrl).toBe(
"https://orders.internal/manage/jvm",
);
expect(config.jvm?.agent?.baseUrl).toBe(
"http://127.0.0.1:19090/gonavi/agent/jvm",
);
expect(config.jvm?.diagnostic).toEqual({
enabled: true,
transport: "arthas-tunnel",
baseUrl: "https://orders.internal/diag",
targetId: "orders-01",
apiKey: "diag-token",
allowObserveCommands: true,
allowTraceCommands: true,
allowMutatingCommands: false,
timeoutSeconds: 18,
});
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.endpoint?.baseUrl).toBe('https://orders.internal/manage/jvm');
});
it('normalizes allowed modes and falls back preferred mode to first allowed mode', () => {
it("normalizes allowed modes and falls back preferred mode to first allowed mode", () => {
const config = buildJVMConnectionConfig({
host: 'cache.internal',
host: "cache.internal",
port: 9010,
jvmAllowedModes: [' Endpoint ', 'invalid', 'JMX', 'endpoint'],
jvmPreferredMode: 'AGENT',
jvmAllowedModes: [" Endpoint ", "invalid", "JMX", "endpoint"],
jvmPreferredMode: "AGENT",
});
expect(config.jvm?.allowedModes).toEqual(['endpoint', 'jmx']);
expect(config.jvm?.preferredMode).toBe('endpoint');
expect(config.jvm?.allowedModes).toEqual(["endpoint", "jmx"]);
expect(config.jvm?.preferredMode).toBe("endpoint");
expect(config.jvm?.jmx?.enabled).toBe(true);
});
it('normalizes environment and port defaults when input is invalid', () => {
it("normalizes environment and port defaults when input is invalid", () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
host: "orders.internal",
port: 0,
jvmJmxPort: '',
jvmEnvironment: ' PROD ',
jvmJmxPort: "",
jvmEnvironment: " PROD ",
jvmReadOnly: false,
jvmAllowedModes: ['JMX'],
jvmPreferredMode: 'jmx',
jvmAllowedModes: ["JMX"],
jvmPreferredMode: "jmx",
});
expect(config.port).toBe(9010);
expect(config.jvm?.jmx?.port).toBe(9010);
expect(config.jvm?.environment).toBe('prod');
expect(config.jvm?.environment).toBe("prod");
expect(config.jvm?.readOnly).toBe(false);
});
it('keeps the visible timeout as the source of truth for endpoint probing', () => {
it("keeps endpoint timeout aligned to the visible connection timeout", () => {
const config = buildJVMConnectionConfig({
host: 'orders.internal',
host: "orders.internal",
port: 9010,
timeout: 45,
jvmEndpointTimeoutSeconds: 30,
jvmAllowedModes: ['endpoint'],
jvmPreferredMode: 'endpoint',
jvmAllowedModes: ["endpoint"],
jvmPreferredMode: "endpoint",
jvmEndpointEnabled: true,
jvmEndpointBaseUrl: 'https://orders.internal/manage/jvm',
jvmEndpointBaseUrl: "https://orders.internal/manage/jvm",
jvmDiagnosticEnabled: true,
jvmDiagnosticTransport: "arthas-tunnel",
jvmDiagnosticBaseUrl: "https://orders.internal/diag",
jvmDiagnosticTargetId: "orders-01",
jvmDiagnosticApiKey: "diag-token",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: true,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: 18,
});
expect(config.timeout).toBe(45);
expect(config.jvm?.endpoint?.timeoutSeconds).toBe(45);
expect(config.jvm?.diagnostic?.timeoutSeconds).toBe(18);
});
it('normalizes editable JVM modes to the supported form subset', () => {
expect(normalizeEditableJVMModes([' endpoint ', 'agent', 'JMX', 'endpoint'])).toEqual(['endpoint', 'jmx']);
it("detects unsupported diagnostic transport without silently accepting it", () => {
expect(hasUnsupportedJVMDiagnosticTransport("legacy-bridge")).toBe(true);
expect(hasUnsupportedJVMDiagnosticTransport("agent-bridge")).toBe(false);
expect(hasUnsupportedJVMDiagnosticTransport("")).toBe(false);
});
it('detects unsupported editable JVM modes without downgrading them silently', () => {
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['agent', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toBe(true);
expect(hasUnsupportedJVMEditableModes({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'endpoint',
})).toBe(false);
it("normalizes editable JVM modes to the supported form subset", () => {
expect(
normalizeEditableJVMModes([" endpoint ", "agent", "JMX", "endpoint"]),
).toEqual(["endpoint", "agent", "jmx"]);
});
it('preserves preferred mode when rebuilding editable mode selection from stored config', () => {
expect(resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['agent'],
preferredMode: 'agent',
it("detects unsupported editable JVM modes without downgrading them silently", () => {
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["agent", "jmx"],
preferredMode: "agent",
}),
).toBe(false);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "otel",
}),
).toBe(true);
expect(
hasUnsupportedJVMEditableModes({
allowedModes: ["endpoint", "jmx"],
preferredMode: "endpoint",
}),
).toBe(false);
});
it("preserves preferred mode when rebuilding editable mode selection from stored config", () => {
expect(
resolveEditableJVMModeSelection({
allowedModes: [],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["agent"],
preferredMode: "agent",
});
expect(resolveEditableJVMModeSelection({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
})).toEqual({
allowedModes: ['endpoint', 'jmx'],
preferredMode: 'agent',
expect(
resolveEditableJVMModeSelection({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
}),
).toEqual({
allowedModes: ["endpoint", "jmx"],
preferredMode: "agent",
});
});
});

View File

@@ -1,31 +1,40 @@
import type { ConnectionConfig } from '../types';
import type { ConnectionConfig } from "../types";
const DEFAULT_JMX_PORT = 9010;
const DEFAULT_TIMEOUT_SECONDS = 30;
const DEFAULT_ENVIRONMENT = 'dev';
const JVM_MODES = ['jmx', 'endpoint', 'agent'] as const;
export const JVM_EDITABLE_MODES = ['jmx', 'endpoint'] as const;
const DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS = 15;
const DEFAULT_ENVIRONMENT = "dev";
const JVM_MODES = ["jmx", "endpoint", "agent"] as const;
export const JVM_EDITABLE_MODES = ["jmx", "endpoint", "agent"] as const;
const JVM_DIAGNOSTIC_TRANSPORTS = ["agent-bridge", "arthas-tunnel"] as const;
type JVMMode = typeof JVM_MODES[number];
type JVMEditableMode = typeof JVM_EDITABLE_MODES[number];
type JVMEnvironment = 'dev' | 'uat' | 'prod';
type JVMMode = (typeof JVM_MODES)[number];
type JVMEditableMode = (typeof JVM_EDITABLE_MODES)[number];
type JVMDiagnosticTransport = (typeof JVM_DIAGNOSTIC_TRANSPORTS)[number];
type JVMEnvironment = "dev" | "uat" | "prod";
type JVMConnectionFormValues = Record<string, unknown>;
const isJVMMode = (value: string): value is JVMMode => JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode => JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const isJVMMode = (value: string): value is JVMMode =>
JVM_MODES.includes(value as JVMMode);
const isJVMEditableMode = (value: string): value is JVMEditableMode =>
JVM_EDITABLE_MODES.includes(value as JVMEditableMode);
const isJVMDiagnosticTransport = (
value: string,
): value is JVMDiagnosticTransport =>
JVM_DIAGNOSTIC_TRANSPORTS.includes(value as JVMDiagnosticTransport);
const toStringValue = (value: unknown): string => {
if (typeof value === 'string') {
if (typeof value === "string") {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
if (typeof value === "number" || typeof value === "boolean") {
return String(value).trim();
}
return '';
return "";
};
const toInteger = (value: unknown, fallback: number): number => {
if (value === undefined || value === null || value === '') {
if (value === undefined || value === null || value === "") {
return fallback;
}
const parsed = Number(value);
@@ -38,7 +47,7 @@ const toInteger = (value: unknown, fallback: number): number => {
const normalizeModes = (value: unknown): JVMMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
return ["jmx"];
}
const result: JVMMode[] = [];
@@ -51,12 +60,14 @@ const normalizeModes = (value: unknown): JVMMode[] => {
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
return result.length > 0 ? result : ["jmx"];
};
export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] => {
export const normalizeEditableJVMModes = (
value: unknown,
): JVMEditableMode[] => {
if (!Array.isArray(value)) {
return ['jmx'];
return ["jmx"];
}
const result: JVMEditableMode[] = [];
@@ -69,7 +80,7 @@ export const normalizeEditableJVMModes = (value: unknown): JVMEditableMode[] =>
seen.add(mode);
result.push(mode);
}
return result.length > 0 ? result : ['jmx'];
return result.length > 0 ? result : ["jmx"];
};
export const hasUnsupportedJVMEditableModes = ({
@@ -80,12 +91,23 @@ export const hasUnsupportedJVMEditableModes = ({
preferredMode: unknown;
}): boolean => {
const allowed = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const preferred = toStringValue(preferredMode).toLowerCase();
return allowed.some((mode) => !isJVMEditableMode(mode))
|| (preferred !== '' && !isJVMEditableMode(preferred));
return (
allowed.some((mode) => !isJVMEditableMode(mode)) ||
(preferred !== "" && !isJVMEditableMode(preferred))
);
};
export const hasUnsupportedJVMDiagnosticTransport = (
value: unknown,
): boolean => {
const transport = toStringValue(value).toLowerCase();
return transport !== "" && !isJVMDiagnosticTransport(transport);
};
export const resolveEditableJVMModeSelection = ({
@@ -96,12 +118,17 @@ export const resolveEditableJVMModeSelection = ({
preferredMode: unknown;
}): { allowedModes: string[]; preferredMode: string } => {
const normalizedAllowedModes = Array.isArray(allowedModes)
? allowedModes.map((item) => toStringValue(item).toLowerCase()).filter((item) => item !== '')
? allowedModes
.map((item) => toStringValue(item).toLowerCase())
.filter((item) => item !== "")
: [];
const normalizedPreferredMode = toStringValue(preferredMode).toLowerCase();
const resolvedAllowedModes = normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: (normalizedPreferredMode ? [normalizedPreferredMode] : ['jmx']);
const resolvedAllowedModes =
normalizedAllowedModes.length > 0
? Array.from(new Set(normalizedAllowedModes))
: normalizedPreferredMode
? [normalizedPreferredMode]
: ["jmx"];
return {
allowedModes: resolvedAllowedModes,
@@ -109,7 +136,10 @@ export const resolveEditableJVMModeSelection = ({
};
};
const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMode => {
const normalizePreferredMode = (
value: unknown,
allowedModes: JVMMode[],
): JVMMode => {
const preferred = toStringValue(value).toLowerCase();
if (isJVMMode(preferred) && allowedModes.includes(preferred)) {
return preferred;
@@ -119,46 +149,80 @@ const normalizePreferredMode = (value: unknown, allowedModes: JVMMode[]): JVMMod
const normalizeEnvironment = (value: unknown): JVMEnvironment => {
const env = toStringValue(value).toLowerCase();
if (env === 'uat' || env === 'prod') {
if (env === "uat" || env === "prod") {
return env;
}
return DEFAULT_ENVIRONMENT;
};
const normalizeReadOnly = (value: unknown): boolean => {
if (typeof value === 'boolean') {
if (typeof value === "boolean") {
return value;
}
return true;
};
const normalizeDiagnosticTransport = (
value: unknown,
): JVMDiagnosticTransport => {
const transport = toStringValue(value).toLowerCase();
if (isJVMDiagnosticTransport(transport)) {
return transport;
}
return "agent-bridge";
};
export const buildDefaultJVMConnectionValues = () => ({
type: 'jvm',
host: 'localhost',
type: "jvm",
host: "localhost",
port: DEFAULT_JMX_PORT,
jvmReadOnly: true,
jvmAllowedModes: ['jmx'],
jvmPreferredMode: 'jmx',
jvmAllowedModes: ["jmx"],
jvmPreferredMode: "jmx",
jvmEnvironment: DEFAULT_ENVIRONMENT,
jvmEndpointEnabled: false,
jvmEndpointBaseUrl: '',
jvmEndpointApiKey: '',
jvmEndpointBaseUrl: "",
jvmEndpointApiKey: "",
jvmAgentEnabled: false,
jvmAgentBaseUrl: "",
jvmAgentApiKey: "",
jvmDiagnosticEnabled: false,
jvmDiagnosticTransport: "agent-bridge",
jvmDiagnosticBaseUrl: "",
jvmDiagnosticTargetId: "",
jvmDiagnosticApiKey: "",
jvmDiagnosticAllowObserveCommands: true,
jvmDiagnosticAllowTraceCommands: false,
jvmDiagnosticAllowMutatingCommands: false,
jvmDiagnosticTimeoutSeconds: DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
});
export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): ConnectionConfig => {
export const buildJVMConnectionConfig = (
values: JVMConnectionFormValues,
): ConnectionConfig => {
const allowedModes = normalizeModes(values.jvmAllowedModes);
const preferredMode = normalizePreferredMode(values.jvmPreferredMode, allowedModes);
const preferredMode = normalizePreferredMode(
values.jvmPreferredMode,
allowedModes,
);
const port = toInteger(values.port, DEFAULT_JMX_PORT);
const timeout = values.timeout === undefined || values.timeout === null || values.timeout === ''
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const timeout =
values.timeout === undefined ||
values.timeout === null ||
values.timeout === ""
? toInteger(values.jvmEndpointTimeoutSeconds, DEFAULT_TIMEOUT_SECONDS)
: toInteger(values.timeout, DEFAULT_TIMEOUT_SECONDS);
const diagnosticTimeout = toInteger(
values.jvmDiagnosticTimeoutSeconds,
DEFAULT_DIAGNOSTIC_TIMEOUT_SECONDS,
);
return {
type: 'jvm',
type: "jvm",
host: toStringValue(values.host),
port,
user: '',
password: '',
user: "",
password: "",
timeout,
jvm: {
environment: normalizeEnvironment(values.jvmEnvironment),
@@ -166,7 +230,7 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
allowedModes,
preferredMode,
jmx: {
enabled: allowedModes.includes('jmx'),
enabled: allowedModes.includes("jmx"),
host: toStringValue(values.jvmJmxHost) || toStringValue(values.host),
port: toInteger(values.jvmJmxPort, port),
username: toStringValue(values.jvmJmxUsername),
@@ -178,6 +242,24 @@ export const buildJVMConnectionConfig = (values: JVMConnectionFormValues): Conne
apiKey: toStringValue(values.jvmEndpointApiKey),
timeoutSeconds: timeout,
},
agent: {
enabled: values.jvmAgentEnabled === true,
baseUrl: toStringValue(values.jvmAgentBaseUrl),
apiKey: toStringValue(values.jvmAgentApiKey),
timeoutSeconds: timeout,
},
diagnostic: {
enabled: values.jvmDiagnosticEnabled === true,
transport: normalizeDiagnosticTransport(values.jvmDiagnosticTransport),
baseUrl: toStringValue(values.jvmDiagnosticBaseUrl),
targetId: toStringValue(values.jvmDiagnosticTargetId),
apiKey: toStringValue(values.jvmDiagnosticApiKey),
allowObserveCommands: values.jvmDiagnosticAllowObserveCommands !== false,
allowTraceCommands: values.jvmDiagnosticAllowTraceCommands === true,
allowMutatingCommands:
values.jvmDiagnosticAllowMutatingCommands === true,
timeoutSeconds: diagnosticTimeout,
},
},
};
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import {
resolveJVMDiagnosticCompletionItems,
resolveJVMDiagnosticCompletionMode,
} from "./jvmDiagnosticCompletion";
describe("jvmDiagnosticCompletion", () => {
it("suggests command keywords when typing the first token", () => {
const items = resolveJVMDiagnosticCompletionItems("t");
expect(items.some((item) => item.label === "thread")).toBe(true);
expect(items.some((item) => item.label === "trace")).toBe(true);
});
it("switches to argument mode after the command head", () => {
expect(resolveJVMDiagnosticCompletionMode("thread -")).toEqual({
head: "thread",
mode: "argument",
search: "-",
});
});
it("returns command-specific snippets for trace style commands", () => {
const items = resolveJVMDiagnosticCompletionItems("watch ");
expect(items.some((item) => item.label === "watch 模板")).toBe(true);
expect(items.some((item) => item.label === "展开层级 -x 2")).toBe(true);
expect(items.every((item) => item.scope === "argument")).toBe(true);
});
it("supports multiline commands by using the current line before cursor", () => {
const items = resolveJVMDiagnosticCompletionItems(
"thread -n 5\nclas",
);
expect(items.some((item) => item.label === "classloader")).toBe(true);
expect(items.some((item) => item.label === "watch")).toBe(false);
});
it("falls back to command suggestions for unknown heads", () => {
const items = resolveJVMDiagnosticCompletionItems("unknown ");
expect(items.some((item) => item.label === "dashboard")).toBe(true);
expect(items.some((item) => item.label === "thread")).toBe(true);
});
});

View File

@@ -0,0 +1,485 @@
import { JVM_DIAGNOSTIC_COMMAND_PRESETS } from "./jvmDiagnosticPresentation";
export type JVMDiagnosticCompletionMode = "command" | "argument";
export interface JVMDiagnosticCompletionState {
mode: JVMDiagnosticCompletionMode;
head: string;
search: string;
}
export interface JVMDiagnosticCompletionItem {
label: string;
insertText: string;
detail: string;
documentation?: string;
scope: JVMDiagnosticCompletionMode;
isSnippet?: boolean;
}
type DiagnosticCommandDefinition = {
head: string;
detail: string;
documentation: string;
};
const BASE_COMMAND_DEFINITIONS: DiagnosticCommandDefinition[] = [
{
head: "dashboard",
detail: "观察类命令",
documentation: "查看 JVM 运行总览。",
},
{
head: "thread",
detail: "观察类命令",
documentation: "查看热点线程、线程栈和阻塞线程。",
},
{
head: "sc",
detail: "观察类命令",
documentation: "搜索匹配类信息。",
},
{
head: "sm",
detail: "观察类命令",
documentation: "查看类的方法签名。",
},
{
head: "jad",
detail: "观察类命令",
documentation: "反编译指定类。",
},
{
head: "sysprop",
detail: "观察类命令",
documentation: "查看系统属性。",
},
{
head: "sysenv",
detail: "观察类命令",
documentation: "查看环境变量。",
},
{
head: "classloader",
detail: "观察类命令",
documentation: "查看类加载器信息。",
},
{
head: "trace",
detail: "跟踪类命令",
documentation: "跟踪方法调用耗时路径。",
},
{
head: "watch",
detail: "跟踪类命令",
documentation: "观察入参、返回值或异常。",
},
{
head: "stack",
detail: "跟踪类命令",
documentation: "输出方法调用栈。",
},
{
head: "monitor",
detail: "跟踪类命令",
documentation: "周期性统计方法调用。",
},
{
head: "tt",
detail: "跟踪类命令",
documentation: "方法时光隧道,记录和回放调用。",
},
{
head: "ognl",
detail: "高风险命令",
documentation: "执行 OGNL 表达式,默认需要额外授权。",
},
{
head: "vmtool",
detail: "高风险命令",
documentation: "直接操作 JVM 对象或执行 VMTool 动作。",
},
{
head: "redefine",
detail: "高风险命令",
documentation: "重新定义类字节码。",
},
{
head: "retransform",
detail: "高风险命令",
documentation: "重新触发类转换。",
},
{
head: "stop",
detail: "控制命令",
documentation: "停止当前后台任务。",
},
];
const buildBaseCommandItems = (): JVMDiagnosticCompletionItem[] => {
const itemsByHead = new Map<string, JVMDiagnosticCompletionItem>();
BASE_COMMAND_DEFINITIONS.forEach((item) => {
itemsByHead.set(item.head, {
label: item.head,
insertText: item.head,
detail: item.detail,
documentation: item.documentation,
scope: "command",
});
});
JVM_DIAGNOSTIC_COMMAND_PRESETS.forEach((item) => {
const head = item.command.split(/\s+/, 1)[0]?.trim().toLowerCase() || item.label;
if (itemsByHead.has(head)) {
return;
}
itemsByHead.set(head, {
label: head,
insertText: head,
detail: `${item.category} 命令`,
documentation: item.description,
scope: "command",
});
});
return Array.from(itemsByHead.values());
};
const BASE_COMMAND_ITEMS = buildBaseCommandItems();
const ARGUMENT_ITEMS_BY_HEAD: Record<string, JVMDiagnosticCompletionItem[]> = {
dashboard: [
{
label: "dashboard",
insertText: "",
detail: "直接执行",
documentation: "查看当前 JVM 运行总览。",
scope: "argument",
},
],
thread: [
{
label: "繁忙线程 TOP N (-n)",
insertText: "-n ${1:5}",
detail: "线程参数",
documentation: "查看 CPU 最繁忙的前 N 个线程。",
scope: "argument",
isSnippet: true,
},
{
label: "阻塞线程 (-b)",
insertText: "-b",
detail: "线程参数",
documentation: "查找当前阻塞其他线程的线程。",
scope: "argument",
},
{
label: "指定线程 ID",
insertText: "${1:1}",
detail: "线程参数",
documentation: "查看指定线程的详细栈信息。",
scope: "argument",
isSnippet: true,
},
],
sc: [
{
label: "类匹配模板",
insertText: "${1:com.foo.*}",
detail: "类搜索模板",
documentation: "按类名模式搜索。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService}",
detail: "类搜索模板",
documentation: "输出类的详细信息。",
scope: "argument",
isSnippet: true,
},
],
sm: [
{
label: "方法签名模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "查看类的方法签名。",
scope: "argument",
isSnippet: true,
},
{
label: "详细模式 (-d)",
insertText: "-d ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "方法搜索模板",
documentation: "输出方法详细签名。",
scope: "argument",
isSnippet: true,
},
],
jad: [
{
label: "反编译模板",
insertText: "${1:com.foo.OrderService}",
detail: "反编译模板",
documentation: "反编译指定类。",
scope: "argument",
isSnippet: true,
},
],
sysprop: [
{
label: "查看属性",
insertText: "${1:java.version}",
detail: "系统属性模板",
documentation: "读取指定系统属性。",
scope: "argument",
isSnippet: true,
},
],
sysenv: [
{
label: "查看环境变量",
insertText: "${1:JAVA_HOME}",
detail: "环境变量模板",
documentation: "读取指定环境变量。",
scope: "argument",
isSnippet: true,
},
],
classloader: [
{
label: "树形视图 (-t)",
insertText: "-t",
detail: "类加载器模板",
documentation: "输出类加载器树形结构。",
scope: "argument",
},
{
label: "全部 URL 统计 (--url-stat)",
insertText: "--url-stat",
detail: "类加载器模板",
documentation: "查看类加载器 URL 统计。",
scope: "argument",
},
{
label: "指定类加载器 Hash",
insertText: "${1:19469ea2}",
detail: "类加载器模板",
documentation: "查看指定类加载器详情。",
scope: "argument",
isSnippet: true,
},
],
trace: [
{
label: "trace 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "跟踪模板",
documentation: "跟踪慢方法调用链路。",
scope: "argument",
isSnippet: true,
},
{
label: "条件过滤 '#cost > 100'",
insertText: "'${1:#cost > 100}'",
detail: "跟踪参数",
documentation: "追加 trace 条件表达式。",
scope: "argument",
isSnippet: true,
},
],
watch: [
{
label: "watch 模板",
insertText:
"${1:com.foo.OrderService} ${2:submitOrder} '${3:{params,returnObj}}' -x ${4:2}",
detail: "观察模板",
documentation: "观察入参、返回值或异常。",
scope: "argument",
isSnippet: true,
},
{
label: "展开层级 -x 2",
insertText: "-x ${1:2}",
detail: "观察参数",
documentation: "设置对象展开层级。",
scope: "argument",
isSnippet: true,
},
],
stack: [
{
label: "stack 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} '${3:#cost > 100}'",
detail: "调用栈模板",
documentation: "输出方法调用栈。",
scope: "argument",
isSnippet: true,
},
],
monitor: [
{
label: "monitor 模板",
insertText: "${1:com.foo.OrderService} ${2:submitOrder} -c ${3:5}",
detail: "监控模板",
documentation: "按周期统计方法调用情况。",
scope: "argument",
isSnippet: true,
},
],
tt: [
{
label: "tt 录制模板",
insertText: "-t ${1:com.foo.OrderService} ${2:submitOrder}",
detail: "时光隧道模板",
documentation: "录制指定方法调用。",
scope: "argument",
isSnippet: true,
},
{
label: "查看记录列表 (-l)",
insertText: "-l",
detail: "时光隧道模板",
documentation: "查看当前录制列表。",
scope: "argument",
},
{
label: "回放记录 (-i)",
insertText: "-i ${1:1000} -p",
detail: "时光隧道模板",
documentation: "查看指定记录详情。",
scope: "argument",
isSnippet: true,
},
],
ognl: [
{
label: "ognl 模板",
insertText: "'${1:@java.lang.System@getProperty(\"user.dir\")}'",
detail: "高风险模板",
documentation: "执行 OGNL 表达式,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
vmtool: [
{
label: "vmtool getInstances",
insertText:
"--action getInstances --className ${1:com.foo.OrderService} --limit ${2:10}",
detail: "高风险模板",
documentation: "获取指定类实例,高风险命令默认受策略限制。",
scope: "argument",
isSnippet: true,
},
],
redefine: [
{
label: "redefine 模板",
insertText: "${1:/tmp/OrderService.class}",
detail: "高风险模板",
documentation: "重新定义类字节码文件路径。",
scope: "argument",
isSnippet: true,
},
],
retransform: [
{
label: "retransform 模板",
insertText: "${1:com.foo.OrderService}",
detail: "高风险模板",
documentation: "重新转换指定类。",
scope: "argument",
isSnippet: true,
},
],
stop: [
{
label: "stop",
insertText: "",
detail: "控制命令",
documentation: "停止当前后台任务。",
scope: "argument",
},
],
};
const COMMAND_HEAD_SET = new Set(
BASE_COMMAND_ITEMS.map((item) => item.label.toLowerCase()),
);
const normalizeSearchText = (value: string): string =>
String(value || "").trim().toLowerCase();
const resolveCurrentLine = (textBeforeCursor: string): string =>
String(textBeforeCursor || "").split(/\r?\n/).pop() || "";
const matchesSearch = (
item: JVMDiagnosticCompletionItem,
search: string,
): boolean => {
if (!search) {
return true;
}
const normalizedSearch = normalizeSearchText(search);
const candidates = [item.label, item.insertText, item.detail];
return candidates.some((candidate) =>
String(candidate || "").toLowerCase().includes(normalizedSearch),
);
};
export const resolveJVMDiagnosticCompletionMode = (
textBeforeCursor: string,
): JVMDiagnosticCompletionState => {
const currentLine = resolveCurrentLine(textBeforeCursor);
const normalizedLine = currentLine.replace(/^\s+/, "");
if (!normalizedLine) {
return {
mode: "command",
head: "",
search: "",
};
}
const head = normalizedLine.split(/\s+/, 1)[0]?.toLowerCase() || "";
const hasWhitespaceAfterHead = /\s/.test(normalizedLine);
if (!hasWhitespaceAfterHead) {
return {
mode: "command",
head,
search: head,
};
}
const search = (normalizedLine.match(/([^\s]*)$/)?.[1] || "").toLowerCase();
if (COMMAND_HEAD_SET.has(head)) {
return {
mode: "argument",
head,
search,
};
}
return {
mode: "command",
head: "",
search,
};
};
export const resolveJVMDiagnosticCompletionItems = (
textBeforeCursor: string,
): JVMDiagnosticCompletionItem[] => {
const state = resolveJVMDiagnosticCompletionMode(textBeforeCursor);
const source =
state.mode === "argument" && state.head
? ARGUMENT_ITEMS_BY_HEAD[state.head] || []
: BASE_COMMAND_ITEMS;
return source.filter((item) => matchesSearch(item, state.search));
};

View File

@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";
import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from "./jvmDiagnosticPlan";
describe("jvmDiagnosticPlan", () => {
it("parses arthas-style diagnostic plan payload", () => {
const plan = parseJVMDiagnosticPlan(`{
"intent": "trace_slow_method",
"transport": "agent-bridge",
"command": "trace com.foo.OrderService submitOrder '#cost > 100'",
"riskLevel": "medium",
"reason": "定位慢调用"
}`);
expect(plan?.command).toContain("trace com.foo.OrderService");
expect(plan?.riskLevel).toBe("medium");
});
it("parses fenced json blocks mixed with analysis text", () => {
const plan = parseJVMDiagnosticPlan(
[
"建议先观察再做下一步:",
"```json",
'{"intent":"dump_threads","transport":"arthas-tunnel","command":"thread -n 5","riskLevel":"low","reason":"观察阻塞线程","expectedSignals":["Top N busy threads"]}',
"```",
].join("\n"),
);
expect(plan).toEqual({
intent: "dump_threads",
transport: "arthas-tunnel",
command: "thread -n 5",
riskLevel: "low",
reason: "观察阻塞线程",
expectedSignals: ["Top N busy threads"],
});
});
it("returns null for malformed diagnostic payload", () => {
expect(parseJVMDiagnosticPlan('{"command":1}')).toBeNull();
});
});
describe("resolveJVMDiagnosticPlanTargetTabId", () => {
it("prefers the original diagnostic tab when context still matches", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "agent-bridge",
},
},
},
},
],
{
tabId: "tab-diagnostic",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("tab-diagnostic");
});
it("rejects fallback tabs whose connection transport does not match", () => {
expect(
resolveJVMDiagnosticPlanTargetTabId(
[
{
id: "tab-diagnostic",
title: "诊断控制台",
type: "jvm-diagnostic",
connectionId: "conn-orders",
},
],
[
{
id: "conn-orders",
config: {
type: "jvm",
host: "orders.internal",
port: 9010,
user: "",
jvm: {
diagnostic: {
transport: "arthas-tunnel",
},
},
},
},
],
{
tabId: "tab-missing",
connectionId: "conn-orders",
transport: "agent-bridge",
},
),
).toBe("");
});
});

View File

@@ -0,0 +1,135 @@
import type {
JVMDiagnosticPlan,
JVMDiagnosticPlanContext,
SavedConnection,
TabData,
} from "../types";
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTransports = new Set<JVMDiagnosticPlan["transport"]>([
"agent-bridge",
"arthas-tunnel",
]);
const allowedRiskLevels = new Set<JVMDiagnosticPlan["riskLevel"]>([
"low",
"medium",
"high",
]);
const asTrimmedString = (value: unknown): string => String(value ?? "").trim();
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
const normalizeTransport = (value: unknown): JVMDiagnosticPlan["transport"] => {
const transport = asTrimmedString(value) as JVMDiagnosticPlan["transport"];
return allowedTransports.has(transport) ? transport : "agent-bridge";
};
const normalizeRiskLevel = (value: unknown): JVMDiagnosticPlan["riskLevel"] => {
const riskLevel = asTrimmedString(value) as JVMDiagnosticPlan["riskLevel"];
return allowedRiskLevels.has(riskLevel) ? riskLevel : "low";
};
const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
if (!isRecord(value)) {
return null;
}
if (typeof value.command !== "string") {
return null;
}
const command = asTrimmedString(value.command);
if (!command) {
return null;
}
const intent = asTrimmedString(value.intent) || "generic_diagnostic";
const reason = asTrimmedString(value.reason) || `AI 诊断计划:${intent}`;
return {
intent,
transport: normalizeTransport(value.transport),
command,
riskLevel: normalizeRiskLevel(value.riskLevel),
reason,
expectedSignals: Array.isArray(value.expectedSignals)
? value.expectedSignals
.map((item) => asTrimmedString(item))
.filter(Boolean)
: [],
};
};
const tryParsePlan = (content: string): JVMDiagnosticPlan | null => {
try {
return normalizePlan(JSON.parse(content));
} catch {
return null;
}
};
const resolveDiagnosticTransport = (
connection?: Pick<SavedConnection, "config">,
): JVMDiagnosticPlan["transport"] =>
normalizeTransport(connection?.config?.jvm?.diagnostic?.transport);
export const parseJVMDiagnosticPlan = (
content: string,
): JVMDiagnosticPlan | null => {
const source = String(content || "").trim();
if (!source) {
return null;
}
planFencePattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = planFencePattern.exec(source)) !== null) {
const parsed = tryParsePlan(match[1]);
if (parsed) {
return parsed;
}
}
return tryParsePlan(source);
};
export const matchesJVMDiagnosticPlanTargetTab = (
tab: Pick<TabData, "id" | "type" | "connectionId">,
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): boolean => {
if (!context || tab.type !== "jvm-diagnostic") {
return false;
}
const connection = connections.find((item) => item.id === tab.connectionId);
return (
tab.connectionId === context.connectionId &&
resolveDiagnosticTransport(connection) === normalizeTransport(context.transport)
);
};
export const resolveJVMDiagnosticPlanTargetTabId = (
tabs: TabData[],
connections: Pick<SavedConnection, "id" | "config">[],
context?: JVMDiagnosticPlanContext,
): string => {
if (!context) {
return "";
}
const exactMatch = tabs.find(
(tab) =>
tab.id === context.tabId &&
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
if (exactMatch) {
return exactMatch.id;
}
const fallbackMatch = tabs.find((tab) =>
matchesJVMDiagnosticPlanTargetTab(tab, connections, context),
);
return fallbackMatch?.id || "";
};

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
groupJVMDiagnosticPresets,
resolveJVMDiagnosticRiskColor,
} from "./jvmDiagnosticPresentation";
describe("jvmDiagnosticPresentation", () => {
it("groups presets by category in a stable order", () => {
const groups = groupJVMDiagnosticPresets();
expect(groups.map((group) => group.label)).toEqual([
"观察类命令",
"跟踪类命令",
"高风险命令",
]);
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
});
it("formats chunk text with phase prefix when content exists", () => {
expect(
formatJVMDiagnosticChunkText({
sessionId: "sess-1",
phase: "running",
content: "thread -n 5",
}),
).toBe("running: thread -n 5");
});
it("maps risk levels to tag colors", () => {
expect(resolveJVMDiagnosticRiskColor("low")).toBe("green");
expect(resolveJVMDiagnosticRiskColor("medium")).toBe("gold");
expect(resolveJVMDiagnosticRiskColor("high")).toBe("red");
});
});

View File

@@ -0,0 +1,105 @@
import type { JVMDiagnosticEventChunk } from "../types";
export type JVMDiagnosticPresetCategory = "observe" | "trace" | "mutating";
export interface JVMDiagnosticCommandPreset {
key: string;
label: string;
category: JVMDiagnosticPresetCategory;
command: string;
description: string;
riskLevel: "low" | "medium" | "high";
}
export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
{
key: "thread-top",
label: "thread",
category: "observe",
command: "thread -n 5",
description: "查看最繁忙线程,快速定位阻塞或高 CPU 线程。",
riskLevel: "low",
},
{
key: "dashboard",
label: "dashboard",
category: "observe",
command: "dashboard",
description: "查看 JVM 运行总览。",
riskLevel: "low",
},
{
key: "trace-slow-method",
label: "trace",
category: "trace",
command: "trace com.foo.OrderService submitOrder '#cost > 100'",
description: "跟踪慢方法调用路径。",
riskLevel: "medium",
},
{
key: "watch-return",
label: "watch",
category: "trace",
command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
description: "观察入参与返回值。",
riskLevel: "medium",
},
{
key: "ognl-sample",
label: "ognl",
category: "mutating",
command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
description: "高风险表达式命令,默认只作示意。",
riskLevel: "high",
},
];
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, string> = {
observe: "观察类命令",
trace: "跟踪类命令",
mutating: "高风险命令",
};
const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
low: "green",
medium: "gold",
high: "red",
};
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
export const resolveJVMDiagnosticRiskColor = (
riskLevel: "low" | "medium" | "high",
): string => RISK_COLORS[riskLevel];
export const groupJVMDiagnosticPresets = (
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
): Array<{
category: JVMDiagnosticPresetCategory;
label: string;
items: JVMDiagnosticCommandPreset[];
}> =>
(["observe", "trace", "mutating"] as const).map((category) => ({
category,
label: formatJVMDiagnosticPresetCategory(category),
items: presets.filter((item) => item.category === category),
}));
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
): string => {
const phase = String(chunk.phase || chunk.event || "").trim();
const content = String(chunk.content || "").trim();
if (!phase && !content) {
return "空事件";
}
if (!phase) {
return content;
}
if (!content) {
return phase;
}
return `${phase}: ${content}`;
};

View File

@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import {
estimateJVMResourceEditorHeight,
formatJVMAuditResultLabel,
formatJVMActionSummary,
formatJVMRiskLevelText,
resolveJVMAuditResultColor,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "./jvmResourcePresentation";
describe("jvmResourcePresentation", () => {
it("provides a localized fallback label for built-in JVM actions", () => {
expect(resolveJVMActionDisplay({ action: "set" })).toMatchObject({
action: "set",
label: "设置属性",
});
});
it("keeps provider-supplied action labels when they already exist", () => {
expect(
resolveJVMActionDisplay({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
}),
).toEqual({
action: "invoke",
label: "执行重置",
description: "调用 reset 操作",
});
});
it("formats the supported action summary with both localized label and code", () => {
expect(
formatJVMActionSummary([
{ action: "set" },
{ action: "invoke", label: "执行重置" },
]),
).toBe("设置属性set, 执行重置invoke");
});
it("localizes risk levels and audit result states", () => {
expect(formatJVMRiskLevelText("medium")).toBe("中");
expect(formatJVMRiskLevelText("")).toBe("未知");
expect(formatJVMAuditResultLabel("applied")).toBe("已执行");
expect(formatJVMAuditResultLabel("error")).toBe("失败");
expect(resolveJVMAuditResultColor("warning")).toBe("gold");
});
it("uses json mode for structured snapshots", () => {
expect(resolveJVMValueEditorLanguage("json", { name: "orders" })).toBe(
"json",
);
expect(resolveJVMValueEditorLanguage("array", [{ id: 1 }])).toBe("json");
});
it("detects JSON-looking strings so the preview can use the structured editor", () => {
expect(
resolveJVMValueEditorLanguage("string", '{\"name\":\"orders\"}'),
).toBe("json");
});
it("falls back to plaintext for ordinary string values", () => {
expect(resolveJVMValueEditorLanguage("string", "cache-enabled")).toBe(
"plaintext",
);
});
it("caps editor height for very long payloads while keeping short content compact", () => {
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
expect(
estimateJVMResourceEditorHeight(new Array(80).fill("line").join("\n")),
).toBe(420);
});
});

View File

@@ -0,0 +1,238 @@
import type { JVMActionDefinition } from "../types";
type JVMActionDisplay = {
action: string;
label: string;
description?: string;
};
const ACTION_FALLBACK_META: Record<
string,
{ label: string; description?: string }
> = {
set: {
label: "设置属性",
description: "更新当前资源暴露的可写属性值。",
},
invoke: {
label: "调用操作",
description: "调用当前资源暴露的管理操作。",
},
put: {
label: "写入资源",
description: "将 payload 内容写入当前 JVM 资源。",
},
clear: {
label: "清空资源",
description: "清空当前 JVM 资源里的数据或状态。",
},
evict: {
label: "驱逐缓存",
description: "将目标缓存项从当前 JVM 运行时中驱逐。",
},
remove: {
label: "删除条目",
description: "删除当前资源中的指定条目。",
},
delete: {
label: "删除资源",
description: "删除或注销当前资源。",
},
refresh: {
label: "刷新资源",
description: "刷新当前资源的运行时状态。",
},
reload: {
label: "重新加载",
description: "重新加载当前资源或其配置。",
},
reset: {
label: "重置状态",
description: "将当前资源恢复到初始或默认状态。",
},
};
const normalizeText = (value: unknown): string => String(value || "").trim();
const looksLikeStructuredJSONText = (value: string): boolean => {
const trimmed = normalizeText(value);
if (!trimmed) {
return false;
}
if (
!(
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
(trimmed.startsWith("[") && trimmed.endsWith("]"))
)
) {
return false;
}
try {
JSON.parse(trimmed);
return true;
} catch {
return false;
}
};
export const resolveJVMActionDisplay = (
value?: Partial<JVMActionDefinition> | string | null,
): JVMActionDisplay => {
const action = normalizeText(
typeof value === "string" ? value : value?.action,
);
const fallback = ACTION_FALLBACK_META[action.toLowerCase()] || null;
const label =
normalizeText(typeof value === "string" ? "" : value?.label) ||
fallback?.label ||
action ||
"未命名动作";
const description =
normalizeText(typeof value === "string" ? "" : value?.description) ||
fallback?.description ||
"";
return {
action,
label,
description: description || undefined,
};
};
export const formatJVMActionDisplayText = (
value?: Partial<JVMActionDefinition> | string | null,
): string => {
const resolved = resolveJVMActionDisplay(value);
if (!resolved.action || resolved.label === resolved.action) {
return resolved.label;
}
return `${resolved.label}${resolved.action}`;
};
export const formatJVMActionSummary = (
actions?: JVMActionDefinition[] | null,
): string => {
if (!Array.isArray(actions) || actions.length === 0) {
return "-";
}
return actions
.map((item) => formatJVMActionDisplayText(item))
.filter((item) => item !== "")
.join(", ");
};
export const formatJVMRiskLevelText = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (normalized === "low") {
return "低";
}
if (normalized === "medium") {
return "中";
}
if (normalized === "high") {
return "高";
}
return normalizeText(value) || "未知";
};
export const resolveJVMAuditResultColor = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (
normalized === "applied" ||
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "green";
}
if (normalized.includes("warn")) {
return "gold";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid") ||
normalized.includes("fail") ||
normalized.includes("error")
) {
return "red";
}
return "default";
};
export const formatJVMAuditResultLabel = (value?: string | null): string => {
const normalized = normalizeText(value).toLowerCase();
if (!normalized) {
return "未知";
}
if (normalized === "applied") {
return "已执行";
}
if (
normalized.includes("success") ||
normalized.includes("ok") ||
normalized.includes("done")
) {
return "成功";
}
if (normalized.includes("warn")) {
return "警告";
}
if (
normalized.includes("block") ||
normalized.includes("deny") ||
normalized.includes("forbid")
) {
return "已阻断";
}
if (normalized.includes("fail") || normalized.includes("error")) {
return "失败";
}
return normalizeText(value);
};
export const resolveJVMValueEditorLanguage = (
format: string,
value: unknown,
): string => {
const normalizedFormat = normalizeText(format).toLowerCase();
if (
["json", "array", "object", "number", "boolean", "null"].includes(
normalizedFormat,
)
) {
return "json";
}
if (normalizedFormat === "sql") {
return "sql";
}
if (normalizedFormat === "xml") {
return "xml";
}
if (normalizedFormat === "yaml" || normalizedFormat === "yml") {
return "yaml";
}
if (typeof value === "string") {
return looksLikeStructuredJSONText(value) ? "json" : "plaintext";
}
if (
value === null ||
typeof value === "number" ||
typeof value === "boolean" ||
Array.isArray(value)
) {
return "json";
}
if (value && typeof value === "object") {
return "json";
}
return "plaintext";
};
export const estimateJVMResourceEditorHeight = (value: unknown): number => {
const text = String(value ?? "");
const lineCount = Math.max(1, text.split(/\r?\n/).length);
return Math.min(420, Math.max(180, lineCount * 22 + 24));
};
export type { JVMActionDisplay };

View File

@@ -1,5 +1,5 @@
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
export type JVMTabKind = 'overview' | 'resource' | 'audit';
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic';
export type JVMModeMeta = {
mode: string;
@@ -35,6 +35,7 @@ const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
overview: 'JVM 概览',
resource: 'JVM 资源',
audit: 'JVM 审计',
diagnostic: 'JVM 诊断',
};
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();