diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index d6c584f..2b0ef39 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -33,8 +33,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
+ "@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
+ "react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"
@@ -2037,6 +2039,16 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-test-renderer": {
+ "version": "18.0.7",
+ "resolved": "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.0.7.tgz",
+ "integrity": "sha512-1+ANPOWc6rB3IkSnElhjv6VLlKg2dSv/OWClUyZimbLsQyBn8Js9Vtdsi3UICJ2rIQ3k2la06dkB+C92QfhKmg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/react": "*"
+ }
+ },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -5645,6 +5657,20 @@
"react-dom": ">= 16.3"
}
},
+ "node_modules/react-shallow-renderer": {
+ "version": "16.15.0",
+ "resolved": "https://registry.npmjs.org/react-shallow-renderer/-/react-shallow-renderer-16.15.0.tgz",
+ "integrity": "sha512-oScf2FqQ9LFVQgA73vr86xl2NaOIX73rh+YFqcOp68CWj56tSfgtGKrEbyhCj0rSijyG9M1CYprTh39fBi5hzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "react-is": "^16.12.0 || ^17.0.0 || ^18.0.0"
+ },
+ "peerDependencies": {
+ "react": "^16.0.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-syntax-highlighter": {
"version": "16.1.1",
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
@@ -5665,6 +5691,21 @@
"react": ">= 0.14.0"
}
},
+ "node_modules/react-test-renderer": {
+ "version": "18.2.0",
+ "resolved": "https://registry.npmjs.org/react-test-renderer/-/react-test-renderer-18.2.0.tgz",
+ "integrity": "sha512-JWD+aQ0lh2gvh4NM3bBM42Kx+XybOxCpgYK7F8ugAlpaTSnWsX+39Z4XkOykGZAHrjwwTZT3x3KxswVWxHPUqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "react-is": "^18.2.0",
+ "react-shallow-renderer": "^16.15.0",
+ "scheduler": "^0.23.0"
+ },
+ "peerDependencies": {
+ "react": "^18.2.0"
+ }
+ },
"node_modules/recharts": {
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index daddbfb..4181217 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -35,8 +35,10 @@
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@types/react-resizable": "^3.0.8",
+ "@types/react-test-renderer": "^18.0.7",
"@types/uuid": "^9.0.7",
"@vitejs/plugin-react": "^4.2.1",
+ "react-test-renderer": "^18.2.0",
"typescript": "^5.2.2",
"vite": "^5.0.8",
"vitest": "^3.2.4"
diff --git a/frontend/src/components/JVMResourceBrowser.interaction.test.tsx b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx
new file mode 100644
index 0000000..b4f831d
--- /dev/null
+++ b/frontend/src/components/JVMResourceBrowser.interaction.test.tsx
@@ -0,0 +1,563 @@
+import React from "react";
+import { act, create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+import JVMResourceBrowser from "./JVMResourceBrowser";
+import type { JVMValueSnapshot } from "../types";
+
+const storeState = vi.hoisted(() => ({
+ connections: [
+ {
+ id: "conn-jvm-writable",
+ name: "orders-jvm",
+ config: {
+ host: "127.0.0.1",
+ user: "jmx-user",
+ port: 9010,
+ type: "jvm",
+ jvm: {
+ preferredMode: "jmx",
+ readOnly: false,
+ jmx: {
+ password: "initial-jmx-secret",
+ },
+ },
+ },
+ },
+ ],
+ addTab: vi.fn(),
+ aiPanelVisible: false,
+ setAIPanelVisible: vi.fn(),
+ theme: "light",
+}));
+
+const backendApp = vi.hoisted(() => ({
+ JVMGetValue: vi.fn(),
+ JVMPreviewChange: vi.fn(),
+ JVMApplyChange: vi.fn(),
+}));
+
+vi.mock("@monaco-editor/react", () => ({
+ default: ({ value }: { value?: string }) =>
{value},
+}));
+
+vi.mock("@ant-design/icons", () => ({
+ FileSearchOutlined: () => ,
+ ReloadOutlined: () => ,
+ RobotOutlined: () => ,
+}));
+
+vi.mock("antd", () => {
+ const Text = ({ children }: any) => {children};
+ const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
+
+ );
+ const Card = ({ children, title }: any) => (
+
+ );
+ const Descriptions: any = ({ children }: any) => {children}
;
+ Descriptions.Item = ({ children, label }: any) => (
+
+
{label}
+ {children}
+
+ );
+ const Input: any = ({ value, onChange, placeholder }: any) => (
+
+ );
+ Input.TextArea = ({ value, onChange }: any) => (
+
+ );
+
+ return {
+ Alert: ({ message }: any) => {message}
,
+ Button,
+ Card,
+ Descriptions,
+ Empty: ({ description }: any) => {description}
,
+ Input,
+ Skeleton: () => loading
,
+ Space: ({ children }: any) => {children}
,
+ Tag: ({ children }: any) => {children},
+ Typography: { Text },
+ };
+});
+
+vi.mock("../store", () => {
+ const useStore = (selector: (state: typeof storeState) => any) => selector(storeState);
+ useStore.getState = () => storeState;
+ return { useStore };
+});
+
+vi.mock("./jvm/JVMModeBadge", () => ({
+ default: ({ mode }: { mode: string }) => {mode},
+}));
+
+vi.mock("./jvm/JVMWorkspaceLayout", () => ({
+ getJVMWorkspaceCardStyle: () => ({}),
+ JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
+
+ {title}
+ {description}
+ {badges}
+ {actions}
+
+ ),
+ JVMWorkspaceShell: ({ children }: any) => {children},
+}));
+
+vi.mock("./jvm/JVMChangePreviewModal", () => ({
+ default: ({ open, onConfirm }: any) =>
+ open ? : null,
+}));
+
+const writableTab = {
+ id: "tab-jvm-resource",
+ type: "jvm-resource",
+ title: "[orders-jvm] JVM 资源",
+ connectionId: "conn-jvm-writable",
+ providerMode: "jmx",
+ resourcePath: "jmx:/attribute/app/Mode",
+ resourceKind: "attribute",
+} as any;
+
+const textContent = (node: any): string =>
+ (node.children || [])
+ .map((item: any) => (typeof item === "string" ? item : textContent(item)))
+ .join("");
+
+const findButton = (renderer: ReactTestRenderer, text: string) =>
+ renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0];
+
+const waitForEffects = async () => {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+};
+
+describe("JVMResourceBrowser interactions", () => {
+ beforeEach(() => {
+ storeState.connections = [
+ {
+ id: "conn-jvm-writable",
+ name: "orders-jvm",
+ config: {
+ host: "127.0.0.1",
+ user: "jmx-user",
+ port: 9010,
+ type: "jvm",
+ jvm: {
+ preferredMode: "jmx",
+ readOnly: false,
+ jmx: {
+ password: "initial-jmx-secret",
+ },
+ },
+ },
+ },
+ ];
+
+ const snapshot: JVMValueSnapshot = {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ version: "v1",
+ value: "cold",
+ supportedActions: [
+ {
+ action: "set",
+ label: "设置属性",
+ payloadExample: { value: "warm" },
+ },
+ ],
+ };
+
+ backendApp.JVMGetValue.mockResolvedValue({ success: true, data: snapshot });
+ backendApp.JVMPreviewChange.mockResolvedValue({
+ allowed: true,
+ requiresConfirmation: true,
+ confirmationToken: "token-from-preview",
+ summary: "设置 Mode",
+ riskLevel: "high",
+ before: snapshot,
+ after: { ...snapshot, value: "warm", version: "v2" },
+ });
+ backendApp.JVMApplyChange.mockResolvedValue({
+ success: true,
+ data: {
+ status: "applied",
+ updatedValue: { ...snapshot, value: "warm", version: "v2" },
+ },
+ });
+
+ vi.stubGlobal("window", {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ go: {
+ app: {
+ App: backendApp,
+ },
+ },
+ });
+ });
+
+ afterEach(() => {
+ backendApp.JVMGetValue.mockReset();
+ backendApp.JVMPreviewChange.mockReset();
+ backendApp.JVMApplyChange.mockReset();
+ vi.unstubAllGlobals();
+ });
+
+ it("applies the latest successful preview request even when the draft is edited afterward", async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ });
+
+ const payloadEditor = () => renderer!.root.findByType("textarea");
+ await act(async () => {
+ payloadEditor().props.onChange({ target: { value: '{"value":"previewed"}' } });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ payloadEditor().props.onChange({ target: { value: '{"value":"edited-after-preview"}' } });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "确认执行").props.onClick();
+ });
+ await waitForEffects();
+
+ expect(backendApp.JVMApplyChange).toHaveBeenCalledTimes(1);
+ expect(backendApp.JVMApplyChange.mock.calls[0][0]).toBe(
+ backendApp.JVMPreviewChange.mock.calls[0][0],
+ );
+ expect(backendApp.JVMApplyChange.mock.calls[0][1]).toMatchObject({
+ action: "set",
+ confirmationToken: "token-from-preview",
+ payload: { value: "previewed" },
+ });
+ });
+
+ it("does not let a stale snapshot resource id override the current resource preview", async () => {
+ backendApp.JVMGetValue.mockResolvedValueOnce({
+ success: true,
+ data: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ version: "v1",
+ value: "cold",
+ supportedActions: [
+ {
+ action: "set",
+ label: "设置属性",
+ payloadExample: { value: "warm" },
+ },
+ ],
+ } as JVMValueSnapshot,
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.update(
+ ,
+ );
+ });
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ renderer!.root.findByType("textarea").props.onChange({
+ target: { value: '{"value":"previewed"}' },
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+ await waitForEffects();
+
+ expect(backendApp.JVMPreviewChange.mock.calls[backendApp.JVMPreviewChange.mock.calls.length - 1]?.[1]).toMatchObject({
+ resourceId: "jmx:/attribute/app/OtherMode",
+ });
+ });
+
+ it("ignores stale preview responses after the resource context changes", async () => {
+ let resolvePreview: (value: any) => void = () => {};
+ backendApp.JVMPreviewChange.mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolvePreview = resolve;
+ }),
+ );
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ renderer!.root.findByType("textarea").props.onChange({
+ target: { value: '{"value":"previewed"}' },
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+
+ await act(async () => {
+ renderer!.update(
+ ,
+ );
+ resolvePreview({
+ allowed: true,
+ requiresConfirmation: true,
+ confirmationToken: "stale-token",
+ summary: "旧预览",
+ riskLevel: "high",
+ before: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "cold",
+ },
+ after: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "warm",
+ },
+ });
+ });
+ await waitForEffects();
+
+ expect(findButton(renderer!, "确认执行")).toBeUndefined();
+ expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
+ });
+
+ it("rejects confirming a preview after the resource context changes", async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ renderer!.root.findByType("textarea").props.onChange({
+ target: { value: '{"value":"previewed"}' },
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+ await waitForEffects();
+
+ await act(async () => {
+ renderer!.update(
+ ,
+ );
+ findButton(renderer!, "确认执行").props.onClick();
+ });
+ await waitForEffects();
+
+ expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
+ });
+
+ it("rejects confirming a preview after the connection config changes", async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ renderer!.root.findByType("textarea").props.onChange({
+ target: { value: '{"value":"previewed"}' },
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+ await waitForEffects();
+
+ storeState.connections = [
+ {
+ ...storeState.connections[0],
+ config: {
+ ...storeState.connections[0].config,
+ jvm: {
+ ...storeState.connections[0].config.jvm,
+ readOnly: true,
+ },
+ },
+ },
+ ];
+
+ await act(async () => {
+ renderer!.update();
+ });
+
+ const confirmButton = findButton(renderer!, "确认执行");
+ if (confirmButton) {
+ await act(async () => {
+ confirmButton.props.onClick();
+ });
+ }
+ await waitForEffects();
+
+ expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
+ });
+
+ it("rejects confirming a preview after JVM credentials change", async () => {
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+ await waitForEffects();
+
+ const reasonInput = renderer!.root
+ .findAllByType("input")
+ .find((item) => item.props.placeholder === "填写本次 JVM 资源变更原因");
+ await act(async () => {
+ reasonInput!.props.onChange({ target: { value: "修复运行模式" } });
+ renderer!.root.findByType("textarea").props.onChange({
+ target: { value: '{"value":"previewed"}' },
+ });
+ });
+
+ await act(async () => {
+ findButton(renderer!, "预览变更").props.onClick();
+ });
+ await waitForEffects();
+
+ storeState.connections = [
+ {
+ ...storeState.connections[0],
+ config: {
+ ...storeState.connections[0].config,
+ jvm: {
+ ...storeState.connections[0].config.jvm,
+ jmx: {
+ ...storeState.connections[0].config.jvm.jmx,
+ password: "rotated-jmx-secret",
+ },
+ },
+ },
+ },
+ ];
+
+ await act(async () => {
+ renderer!.update();
+ });
+
+ const confirmButton = findButton(renderer!, "确认执行");
+ if (confirmButton) {
+ await act(async () => {
+ confirmButton.props.onClick();
+ });
+ }
+ await waitForEffects();
+
+ expect(backendApp.JVMApplyChange).not.toHaveBeenCalled();
+ });
+
+ it("does not seed sensitive payload examples into the draft editor", async () => {
+ backendApp.JVMGetValue.mockResolvedValueOnce({
+ success: true,
+ data: {
+ resourceId: "jmx:/attribute/app/Password",
+ kind: "attribute",
+ format: "string",
+ version: "v1",
+ value: "secret-token",
+ sensitive: true,
+ supportedActions: [
+ {
+ action: "set",
+ label: "设置属性",
+ payloadExample: { value: "secret-token" },
+ },
+ ],
+ } as JVMValueSnapshot,
+ });
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create(
+ ,
+ );
+ });
+ await waitForEffects();
+
+ expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
+ });
+});
diff --git a/frontend/src/components/JVMResourceBrowser.tsx b/frontend/src/components/JVMResourceBrowser.tsx
index 71505fb..e88d809 100644
--- a/frontend/src/components/JVMResourceBrowser.tsx
+++ b/frontend/src/components/JVMResourceBrowser.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useRef, useState } from "react";
import Editor from "@monaco-editor/react";
import {
Alert,
@@ -38,9 +38,14 @@ import {
type JVMAIChangePlan,
} from "../utils/jvmAiPlan";
import {
+ buildJVMActionPayloadTemplate,
+ buildJVMPreviewApplyRequest,
estimateJVMResourceEditorHeight,
formatJVMActionDisplayText,
formatJVMActionSummary,
+ formatJVMMetadataForDisplay,
+ formatJVMValueForDisplay,
+ JVM_DEFAULT_PAYLOAD_TEMPLATE,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
} from "../utils/jvmResourcePresentation";
@@ -56,7 +61,7 @@ import {
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
const { TextArea } = Input;
-const DEFAULT_PAYLOAD_TEXT = "{\n \n}";
+const DEFAULT_PAYLOAD_TEXT = JVM_DEFAULT_PAYLOAD_TEMPLATE;
type JVMResourceBrowserProps = {
tab: TabData;
@@ -76,6 +81,66 @@ const buildJVMRuntimeConfig = (
});
};
+const buildJVMPreviewConfigRevision = (value: unknown): string => {
+ let text = "";
+ try {
+ text = JSON.stringify(value ?? null);
+ } catch {
+ return "unserializable";
+ }
+
+ let hash = 2166136261;
+ for (let index = 0; index < text.length; index += 1) {
+ hash ^= text.charCodeAt(index);
+ hash = Math.imul(hash, 16777619);
+ }
+ return (hash >>> 0).toString(16);
+};
+
+const buildJVMPreviewRuntimeFingerprint = (
+ connection: SavedConnection | undefined,
+ providerMode: string,
+): string => {
+ const config = connection?.config;
+ const jvm = config?.jvm || {};
+ return JSON.stringify({
+ configRevision: buildJVMPreviewConfigRevision(config),
+ type: config?.type || "",
+ host: config?.host || "",
+ port: config?.port || 0,
+ user: config?.user || "",
+ providerMode,
+ environment: jvm.environment || "",
+ readOnly: jvm.readOnly !== false,
+ allowedModes: jvm.allowedModes || [],
+ preferredMode: jvm.preferredMode || "",
+ jmx: {
+ enabled: jvm.jmx?.enabled || false,
+ host: jvm.jmx?.host || "",
+ port: jvm.jmx?.port || 0,
+ username: jvm.jmx?.username || "",
+ domainAllowlist: jvm.jmx?.domainAllowlist || [],
+ },
+ endpoint: {
+ enabled: jvm.endpoint?.enabled || false,
+ baseUrl: jvm.endpoint?.baseUrl || "",
+ timeoutSeconds: jvm.endpoint?.timeoutSeconds || 0,
+ },
+ agent: {
+ enabled: jvm.agent?.enabled || false,
+ baseUrl: jvm.agent?.baseUrl || "",
+ timeoutSeconds: jvm.agent?.timeoutSeconds || 0,
+ },
+ });
+};
+
+const buildJVMPreviewContextKey = (
+ connectionId: string,
+ mode: string,
+ path: string,
+ runtimeFingerprint: string,
+): string => `${connectionId}::${mode}::${path}::${runtimeFingerprint}`;
+
const snapshotBlockStyle = (background: string): React.CSSProperties => ({
margin: 0,
borderRadius: 8,
@@ -83,17 +148,6 @@ const snapshotBlockStyle = (background: string): React.CSSProperties => ({
overflow: "auto",
});
-const formatValue = (value: unknown): string => {
- if (typeof value === "string") {
- return value;
- }
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
-};
-
const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
try {
return JSON.stringify(draft.payload ?? {}, null, 2);
@@ -102,19 +156,6 @@ const formatDraftPayload = (draft: JVMAIChangeDraft): string => {
}
};
-const buildActionPayloadTemplate = (
- definition?: JVMActionDefinition | null,
-): string => {
- if (definition?.payloadExample) {
- try {
- return JSON.stringify(definition.payloadExample, null, 2);
- } catch {
- return DEFAULT_PAYLOAD_TEXT;
- }
- }
- return DEFAULT_PAYLOAD_TEXT;
-};
-
const resolveDefaultAction = (
actions: JVMActionDefinition[] | undefined,
providerMode: "jmx" | "endpoint" | "agent",
@@ -164,6 +205,10 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
"jmx") as "jmx" | "endpoint" | "agent";
const resourcePath = String(tab.resourcePath || "").trim();
const readOnly = connection?.config.jvm?.readOnly !== false;
+ const runtimeFingerprint = useMemo(
+ () => buildJVMPreviewRuntimeFingerprint(connection, providerMode),
+ [connection, providerMode],
+ );
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState(null);
const [error, setError] = useState("");
@@ -181,24 +226,50 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
const [previewResult, setPreviewResult] = useState(
null,
);
+ const [previewRequest, setPreviewRequest] = useState(
+ null,
+ );
+ const [previewRuntimeConfig, setPreviewRuntimeConfig] = useState(
+ null,
+ );
+ const [previewContextKey, setPreviewContextKey] = useState("");
const [applyLoading, setApplyLoading] = useState(false);
+ const previewSequenceRef = useRef(0);
+ const currentPreviewContextKey = buildJVMPreviewContextKey(
+ tab.connectionId,
+ providerMode,
+ resourcePath,
+ runtimeFingerprint,
+ );
+ const previewContextKeyRef = useRef(currentPreviewContextKey);
+ previewContextKeyRef.current = currentPreviewContextKey;
- const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
+ const clearPreviewState = () => {
+ setPreviewOpen(false);
+ setPreviewResult(null);
+ setPreviewRequest(null);
+ setPreviewRuntimeConfig(null);
+ setPreviewContextKey("");
+ };
+
+ const displayValue = useMemo(() => formatJVMValueForDisplay(snapshot), [snapshot]);
const displayLanguage = useMemo(
() =>
- resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
- [snapshot?.format, snapshot?.value],
+ snapshot?.sensitive
+ ? "plaintext"
+ : resolveJVMValueEditorLanguage(snapshot?.format || "", snapshot?.value),
+ [snapshot?.format, snapshot?.sensitive, snapshot?.value],
);
const metadataText = useMemo(
- () =>
- snapshot?.metadata && Object.keys(snapshot.metadata).length > 0
- ? JSON.stringify(snapshot.metadata, null, 2)
- : "",
- [snapshot?.metadata],
+ () => formatJVMMetadataForDisplay(snapshot),
+ [snapshot],
);
const metadataLanguage = useMemo(
- () => resolveJVMValueEditorLanguage("json", snapshot?.metadata),
- [snapshot?.metadata],
+ () =>
+ snapshot?.sensitive
+ ? "plaintext"
+ : resolveJVMValueEditorLanguage("json", snapshot?.metadata),
+ [snapshot?.metadata, snapshot?.sensitive],
);
const supportedActions = useMemo(() => {
if (!Array.isArray(snapshot?.supportedActions)) {
@@ -218,6 +289,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
);
const loadSnapshot = async () => {
+ const loadContextKey = currentPreviewContextKey;
if (!connection) {
setLoading(false);
setSnapshot(null);
@@ -247,6 +319,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
buildJVMRuntimeConfig(connection, providerMode),
resourcePath,
);
+ if (loadContextKey !== previewContextKeyRef.current) {
+ return;
+ }
if (!result?.success) {
setSnapshot(null);
setError(String(result?.message || "读取 JVM 资源失败"));
@@ -263,9 +338,10 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
useEffect(() => {
void loadSnapshot();
- }, [connection, providerMode, resourcePath, tab.connectionId]);
+ }, [connection, providerMode, resourcePath, runtimeFingerprint, tab.connectionId]);
useEffect(() => {
+ setSnapshot(null);
setAction("");
setReason("");
setPayloadText(DEFAULT_PAYLOAD_TEXT);
@@ -273,9 +349,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
setDraftResourceId("");
setDraftError("");
setApplyMessage("");
- setPreviewOpen(false);
- setPreviewResult(null);
- }, [providerMode, resourcePath, tab.connectionId]);
+ previewSequenceRef.current += 1;
+ clearPreviewState();
+ }, [currentPreviewContextKey]);
useEffect(() => {
if (action.trim()) {
@@ -290,7 +366,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
String(payloadText || "").trim() === "" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
- setPayloadText(buildActionPayloadTemplate(nextDefinition));
+ setPayloadText(buildJVMActionPayloadTemplate(nextDefinition, snapshot?.sensitive));
}
}, [action, payloadText, providerMode, supportedActions]);
@@ -328,8 +404,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
);
setApplyMessage("");
- setPreviewOpen(false);
- setPreviewResult(null);
+ clearPreviewState();
return;
}
@@ -338,8 +413,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
);
setApplyMessage("");
- setPreviewOpen(false);
- setPreviewResult(null);
+ clearPreviewState();
return;
}
@@ -349,8 +423,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
} catch (err: any) {
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
setApplyMessage("");
- setPreviewOpen(false);
- setPreviewResult(null);
+ clearPreviewState();
return;
}
@@ -363,8 +436,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
setApplyMessage(
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
);
- setPreviewOpen(false);
- setPreviewResult(null);
+ clearPreviewState();
};
window.addEventListener(
@@ -393,7 +465,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
currentPayload === "{}" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
- setPayloadText(buildActionPayloadTemplate(definition));
+ setPayloadText(buildJVMActionPayloadTemplate(definition, snapshot?.sensitive));
}
};
@@ -414,9 +486,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
payload = parsed as Record;
}
- const resourceId = String(
- draftResourceId || snapshot?.resourceId || resourcePath,
- ).trim();
+ const resourceId = String(draftResourceId || resourcePath).trim();
if (!resourceId) {
throw new Error("资源 ID 为空,无法生成变更草稿");
}
@@ -497,34 +567,45 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
return;
}
+ const previewSequence = ++previewSequenceRef.current;
+ const previewContextKey = currentPreviewContextKey;
+ const runtimeConfig = buildJVMRuntimeConfig(connection, providerMode);
+
setPreviewLoading(true);
setDraftError("");
setApplyMessage("");
try {
const result = await backendApp.JVMPreviewChange(
- buildJVMRuntimeConfig(connection, providerMode),
+ runtimeConfig,
draftPlan,
);
+ if (
+ previewSequence !== previewSequenceRef.current ||
+ previewContextKey !== previewContextKeyRef.current
+ ) {
+ return;
+ }
+
if (result?.success === false) {
- setPreviewResult(null);
- setPreviewOpen(false);
+ clearPreviewState();
setDraftError(String(result?.message || "预览 JVM 变更失败"));
return;
}
const preview = normalizePreviewResult(result);
if (!preview) {
- setPreviewResult(null);
- setPreviewOpen(false);
+ clearPreviewState();
setDraftError("预览结果格式不正确");
return;
}
setPreviewResult(preview);
+ setPreviewRequest(draftPlan);
+ setPreviewRuntimeConfig(runtimeConfig);
+ setPreviewContextKey(previewContextKey);
setPreviewOpen(true);
} catch (err: any) {
- setPreviewResult(null);
- setPreviewOpen(false);
+ clearPreviewState();
setDraftError(err?.message || "预览 JVM 变更失败");
} finally {
setPreviewLoading(false);
@@ -532,6 +613,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
};
const handleApply = async () => {
+ await Promise.resolve();
+
if (!connection) {
setDraftError("连接不存在或已被删除");
return;
@@ -543,11 +626,21 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
return;
}
- let draftPlan: JVMChangeRequest;
+ if (!previewResult || !previewRequest || !previewRuntimeConfig) {
+ setDraftError("请先预览变更,再确认执行");
+ return;
+ }
+ if (previewContextKey !== previewContextKeyRef.current) {
+ clearPreviewState();
+ setDraftError("资源上下文已变化,请重新预览后再执行");
+ return;
+ }
+
+ let applyRequest: JVMChangeRequest;
try {
- draftPlan = buildDraftPlan();
+ applyRequest = buildJVMPreviewApplyRequest(previewRequest, previewResult);
} catch (err: any) {
- setDraftError(err?.message || "变更草稿不合法");
+ setDraftError(err?.message || "确认令牌缺失,请重新预览后再执行");
return;
}
@@ -556,8 +649,8 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
setApplyMessage("");
try {
const result = await backendApp.JVMApplyChange(
- buildJVMRuntimeConfig(connection, providerMode),
- draftPlan,
+ previewRuntimeConfig,
+ applyRequest,
);
if (result?.success === false) {
setDraftError(String(result?.message || "执行 JVM 变更失败"));
@@ -569,8 +662,7 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
setSnapshot(applyResult.updatedValue);
}
- setPreviewOpen(false);
- setPreviewResult(null);
+ clearPreviewState();
setApplyMessage(
applyResult?.message || result?.message || "JVM 变更已执行",
);
@@ -897,8 +989,9 @@ const JVMResourceBrowser: React.FC = ({ tab }) => {
Payload(JSON)
- 需要输入 JSON 对象,预览和执行都会直接使用这份 payload。
- {selectedActionDefinition?.payloadExample
+ 预览会使用当前草稿;确认执行会使用最近一次成功预览的
+ request,修改草稿后请重新预览。
+ {selectedActionDefinition?.payloadExample && !snapshot?.sensitive
? " 已按当前动作填充推荐模板。"
: ""}
diff --git a/frontend/src/components/jvm/JVMChangePreviewModal.tsx b/frontend/src/components/jvm/JVMChangePreviewModal.tsx
index e146ac8..cd40b83 100644
--- a/frontend/src/components/jvm/JVMChangePreviewModal.tsx
+++ b/frontend/src/components/jvm/JVMChangePreviewModal.tsx
@@ -2,7 +2,10 @@ import React, { useMemo } from "react";
import { Alert, Descriptions, Modal, Space, Tag, Typography } from "antd";
import type { JVMChangePreview } from "../../types";
-import { formatJVMRiskLevelText } from "../../utils/jvmResourcePresentation";
+import {
+ formatJVMRiskLevelText,
+ formatJVMValueForDisplay,
+} from "../../utils/jvmResourcePresentation";
const { Text } = Typography;
const DESCRIPTION_STYLES = { label: { width: 120 } } as const;
@@ -21,17 +24,6 @@ const riskColorMap: Record = {
high: "red",
};
-const formatValue = (value: unknown): string => {
- if (typeof value === "string") {
- return value;
- }
- try {
- return JSON.stringify(value, null, 2);
- } catch {
- return String(value);
- }
-};
-
const previewBlockStyle: React.CSSProperties = {
margin: 0,
padding: 12,
@@ -135,7 +127,7 @@ const JVMChangePreviewModal: React.FC = ({
- {formatValue(preview.before?.value)}
+ {formatJVMValueForDisplay(preview.before)}
@@ -160,7 +152,7 @@ const JVMChangePreviewModal: React.FC = ({
- {formatValue(preview.after?.value)}
+ {formatJVMValueForDisplay(preview.after)}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts
index 70af0ad..ac28578 100644
--- a/frontend/src/types.ts
+++ b/frontend/src/types.ts
@@ -237,6 +237,7 @@ export interface JVMValueSnapshot {
export interface JVMChangePreview {
allowed: boolean;
requiresConfirmation?: boolean;
+ confirmationToken?: string;
summary: string;
riskLevel: "low" | "medium" | "high";
blockingReason?: string;
@@ -251,6 +252,7 @@ export interface JVMChangeRequest {
reason: string;
source?: "manual" | "ai-plan";
expectedVersion?: string;
+ confirmationToken?: string;
payload?: Record;
}
diff --git a/frontend/src/utils/jvmAiPlan.test.ts b/frontend/src/utils/jvmAiPlan.test.ts
index 23559ac..6ca99f7 100644
--- a/frontend/src/utils/jvmAiPlan.test.ts
+++ b/frontend/src/utils/jvmAiPlan.test.ts
@@ -1,6 +1,12 @@
import { describe, expect, it } from 'vitest';
-import { buildJVMChangeDraftFromAIPlan, extractJVMChangePlan, resolveJVMAIPlanResourceId, resolveJVMAIPlanTargetTabId } from './jvmAiPlan';
+import {
+ buildJVMChangeDraftFromAIPlan,
+ buildJVMAIPlanPrompt,
+ extractJVMChangePlan,
+ resolveJVMAIPlanResourceId,
+ resolveJVMAIPlanTargetTabId,
+} from './jvmAiPlan';
describe('extractJVMChangePlan', () => {
it('parses fenced json plan with namespace and key selector', () => {
@@ -102,6 +108,34 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
});
});
+describe('buildJVMAIPlanPrompt', () => {
+ it('masks sensitive snapshot values before injecting the AI prompt', () => {
+ const prompt = buildJVMAIPlanPrompt({
+ connectionName: 'orders-jvm',
+ host: '127.0.0.1',
+ providerMode: 'jmx',
+ resourcePath: 'jmx:/attribute/app/Password',
+ readOnly: false,
+ snapshot: {
+ resourceId: 'jmx:/attribute/app/Password',
+ kind: 'attribute',
+ format: 'string',
+ value: 'secret-token',
+ sensitive: true,
+ supportedActions: [
+ {
+ action: 'set',
+ payloadExample: { value: 'secret-token' },
+ },
+ ],
+ },
+ });
+
+ expect(prompt).toContain('********');
+ expect(prompt).not.toContain('secret-token');
+ });
+});
+
describe('resolveJVMAIPlanTargetTabId', () => {
it('prefers the original tab when message context still matches', () => {
expect(
diff --git a/frontend/src/utils/jvmAiPlan.ts b/frontend/src/utils/jvmAiPlan.ts
index e7c2043..3cc2981 100644
--- a/frontend/src/utils/jvmAiPlan.ts
+++ b/frontend/src/utils/jvmAiPlan.ts
@@ -1,4 +1,5 @@
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
+import { JVM_SENSITIVE_VALUE_MASK } from './jvmResourcePresentation';
export type JVMAIChangePlan = {
targetType: 'cacheEntry' | 'managedBean' | 'attribute' | 'operation';
@@ -106,6 +107,9 @@ const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
if (!snapshot) {
return '当前资源快照尚未加载成功。';
}
+ if (snapshot.sensitive) {
+ return JVM_SENSITIVE_VALUE_MASK;
+ }
if (typeof snapshot.value === 'string') {
return snapshot.value;
}
diff --git a/frontend/src/utils/jvmResourcePresentation.test.ts b/frontend/src/utils/jvmResourcePresentation.test.ts
index d1cec8b..6e92e29 100644
--- a/frontend/src/utils/jvmResourcePresentation.test.ts
+++ b/frontend/src/utils/jvmResourcePresentation.test.ts
@@ -1,10 +1,14 @@
import { describe, expect, it } from "vitest";
import {
+ buildJVMActionPayloadTemplate,
+ buildJVMPreviewApplyRequest,
estimateJVMResourceEditorHeight,
formatJVMAuditResultLabel,
formatJVMActionSummary,
+ formatJVMMetadataForDisplay,
formatJVMRiskLevelText,
+ formatJVMValueForDisplay,
resolveJVMAuditResultColor,
resolveJVMActionDisplay,
resolveJVMValueEditorLanguage,
@@ -68,6 +72,121 @@ describe("jvmResourcePresentation", () => {
);
});
+ it("masks sensitive JVM snapshot values for display", () => {
+ expect(
+ formatJVMValueForDisplay({
+ resourceId: "jmx:/attribute/app/Password",
+ kind: "attribute",
+ format: "string",
+ value: "secret-token",
+ sensitive: true,
+ }),
+ ).toBe("********");
+ expect(
+ formatJVMValueForDisplay({
+ resourceId: "jmx:/attribute/app/State",
+ kind: "attribute",
+ format: "json",
+ value: { state: "READY" },
+ }),
+ ).toBe(JSON.stringify({ state: "READY" }, null, 2));
+ });
+
+ it("masks sensitive JVM snapshot metadata for display", () => {
+ expect(
+ formatJVMMetadataForDisplay({
+ metadata: { token: "secret-token" },
+ sensitive: true,
+ }),
+ ).toBe("********");
+ expect(
+ formatJVMMetadataForDisplay({
+ metadata: { owner: "orders" },
+ }),
+ ).toBe(JSON.stringify({ owner: "orders" }, null, 2));
+ });
+
+ it("masks sensitive action payload examples", () => {
+ expect(
+ buildJVMActionPayloadTemplate(
+ {
+ action: "set",
+ payloadExample: { value: "secret-token" },
+ },
+ true,
+ ),
+ ).toBe("{\n \n}");
+ });
+
+ it("builds apply requests from the previewed request and confirmation token", () => {
+ const previewedRequest = {
+ providerMode: "jmx" as const,
+ resourceId: "jmx:/attribute/app/Mode",
+ action: "set",
+ reason: "修复运行模式",
+ source: "manual" as const,
+ expectedVersion: "v1",
+ payload: { value: "warm" },
+ };
+
+ expect(
+ buildJVMPreviewApplyRequest(previewedRequest, {
+ allowed: true,
+ requiresConfirmation: true,
+ confirmationToken: "token-from-preview",
+ summary: "设置 Mode",
+ riskLevel: "high",
+ before: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "cold",
+ },
+ after: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "warm",
+ },
+ }),
+ ).toEqual({
+ ...previewedRequest,
+ confirmationToken: "token-from-preview",
+ });
+ });
+
+ it("rejects confirmed apply requests when preview token is missing", () => {
+ expect(() =>
+ buildJVMPreviewApplyRequest(
+ {
+ providerMode: "jmx",
+ resourceId: "jmx:/attribute/app/Mode",
+ action: "set",
+ reason: "修复运行模式",
+ payload: { value: "warm" },
+ },
+ {
+ allowed: true,
+ requiresConfirmation: true,
+ summary: "设置 Mode",
+ riskLevel: "high",
+ before: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "cold",
+ },
+ after: {
+ resourceId: "jmx:/attribute/app/Mode",
+ kind: "attribute",
+ format: "string",
+ value: "warm",
+ },
+ },
+ ),
+ ).toThrow("确认令牌缺失");
+ });
+
it("caps editor height for very long payloads while keeping short content compact", () => {
expect(estimateJVMResourceEditorHeight("line-1")).toBe(180);
expect(
diff --git a/frontend/src/utils/jvmResourcePresentation.ts b/frontend/src/utils/jvmResourcePresentation.ts
index 544041d..43eed67 100644
--- a/frontend/src/utils/jvmResourcePresentation.ts
+++ b/frontend/src/utils/jvmResourcePresentation.ts
@@ -1,4 +1,9 @@
-import type { JVMActionDefinition } from "../types";
+import type {
+ JVMActionDefinition,
+ JVMChangePreview,
+ JVMChangeRequest,
+ JVMValueSnapshot,
+} from "../types";
type JVMActionDisplay = {
action: string;
@@ -191,6 +196,69 @@ export const formatJVMAuditResultLabel = (value?: string | null): string => {
return normalizeText(value);
};
+export const JVM_SENSITIVE_VALUE_MASK = "********";
+export const JVM_DEFAULT_PAYLOAD_TEMPLATE = "{\n \n}";
+
+const formatRawJVMValue = (value: unknown): string => {
+ if (typeof value === "string") {
+ return value;
+ }
+ try {
+ return JSON.stringify(value, null, 2);
+ } catch {
+ return String(value);
+ }
+};
+
+export const formatJVMValueForDisplay = (
+ snapshot?: JVMValueSnapshot | null,
+): string => {
+ if (snapshot?.sensitive) {
+ return JVM_SENSITIVE_VALUE_MASK;
+ }
+ return formatRawJVMValue(snapshot?.value);
+};
+
+export const formatJVMMetadataForDisplay = (
+ snapshot?: Pick | null,
+): string => {
+ if (!snapshot?.metadata || Object.keys(snapshot.metadata).length === 0) {
+ return "";
+ }
+ if (snapshot.sensitive) {
+ return JVM_SENSITIVE_VALUE_MASK;
+ }
+ return formatRawJVMValue(snapshot.metadata);
+};
+
+export const buildJVMActionPayloadTemplate = (
+ definition?: JVMActionDefinition | null,
+ sensitive = false,
+): string => {
+ if (sensitive || !definition?.payloadExample) {
+ return JVM_DEFAULT_PAYLOAD_TEMPLATE;
+ }
+ try {
+ return JSON.stringify(definition.payloadExample, null, 2);
+ } catch {
+ return JVM_DEFAULT_PAYLOAD_TEMPLATE;
+ }
+};
+
+export const buildJVMPreviewApplyRequest = (
+ previewRequest: JVMChangeRequest,
+ preview: JVMChangePreview,
+): JVMChangeRequest => {
+ const confirmationToken = String(preview.confirmationToken || "").trim();
+ if (preview.requiresConfirmation && !confirmationToken) {
+ throw new Error("确认令牌缺失,请重新预览后再执行");
+ }
+ return {
+ ...previewRequest,
+ confirmationToken: confirmationToken || undefined,
+ };
+};
+
export const resolveJVMValueEditorLanguage = (
format: string,
value: unknown,