🐛 fix(jvm): 绑定前端变更执行到预览上下文

将 JVM 资源变更执行绑定到最近一次成功预览和连接配置指纹,并遮蔽敏感快照、payload 示例和 AI 上下文中的敏感值。
This commit is contained in:
Syngnat
2026-04-28 09:42:48 +08:00
parent ec2eefc9d2
commit fa4f2a938a
10 changed files with 1003 additions and 85 deletions

View File

@@ -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",

View File

@@ -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"

View File

@@ -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 }) => <pre>{value}</pre>,
}));
vi.mock("@ant-design/icons", () => ({
FileSearchOutlined: () => <span />,
ReloadOutlined: () => <span />,
RobotOutlined: () => <span />,
}));
vi.mock("antd", () => {
const Text = ({ children }: any) => <span>{children}</span>;
const Button = ({ children, disabled, loading, onClick, type, ...rest }: any) => (
<button
type="button"
data-button-type={type}
disabled={disabled || loading}
onClick={onClick}
{...rest}
>
{children}
</button>
);
const Card = ({ children, title }: any) => (
<section>
<h2>{title}</h2>
{children}
</section>
);
const Descriptions: any = ({ children }: any) => <dl>{children}</dl>;
Descriptions.Item = ({ children, label }: any) => (
<div>
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
const Input: any = ({ value, onChange, placeholder }: any) => (
<input value={value} onChange={onChange} placeholder={placeholder} />
);
Input.TextArea = ({ value, onChange }: any) => (
<textarea value={value} onChange={onChange} />
);
return {
Alert: ({ message }: any) => <div role="alert">{message}</div>,
Button,
Card,
Descriptions,
Empty: ({ description }: any) => <div>{description}</div>,
Input,
Skeleton: () => <div>loading</div>,
Space: ({ children }: any) => <div>{children}</div>,
Tag: ({ children }: any) => <span>{children}</span>,
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 }) => <span>{mode}</span>,
}));
vi.mock("./jvm/JVMWorkspaceLayout", () => ({
getJVMWorkspaceCardStyle: () => ({}),
JVMWorkspaceHero: ({ actions, badges, description, title }: any) => (
<header>
<h1>{title}</h1>
{description}
{badges}
{actions}
</header>
),
JVMWorkspaceShell: ({ children }: any) => <main>{children}</main>,
}));
vi.mock("./jvm/JVMChangePreviewModal", () => ({
default: ({ open, onConfirm }: any) =>
open ? <button type="button" onClick={onConfirm}></button> : 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(<JVMResourceBrowser tab={writableTab} />);
});
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(<JVMResourceBrowser tab={writableTab} />);
});
await waitForEffects();
await act(async () => {
renderer!.update(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
});
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(<JVMResourceBrowser tab={writableTab} />);
});
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(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
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(<JVMResourceBrowser tab={writableTab} />);
});
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(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/OtherMode",
}}
/>,
);
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(<JVMResourceBrowser tab={writableTab} />);
});
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(<JVMResourceBrowser tab={writableTab} />);
});
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(<JVMResourceBrowser tab={writableTab} />);
});
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(<JVMResourceBrowser tab={writableTab} />);
});
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(
<JVMResourceBrowser
tab={{
...writableTab,
resourcePath: "jmx:/attribute/app/Password",
}}
/>,
);
});
await waitForEffects();
expect(renderer!.root.findByType("textarea").props.value).not.toContain("secret-token");
});
});

View File

@@ -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<JVMResourceBrowserProps> = ({ 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<JVMValueSnapshot | null>(null);
const [error, setError] = useState("");
@@ -181,24 +226,50 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(
null,
);
const [previewRequest, setPreviewRequest] = useState<JVMChangeRequest | null>(
null,
);
const [previewRuntimeConfig, setPreviewRuntimeConfig] = useState<any | null>(
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<JVMResourceBrowserProps> = ({ tab }) => {
);
const loadSnapshot = async () => {
const loadContextKey = currentPreviewContextKey;
if (!connection) {
setLoading(false);
setSnapshot(null);
@@ -247,6 +319,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ tab }) => {
"AI 计划缺少来源上下文,请在目标 JVM 资源页重新生成后再应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -338,8 +413,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
"当前 JVM 页签与 AI 计划的来源上下文不一致,已拒绝自动应用。",
);
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -349,8 +423,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
} catch (err: any) {
setDraftError(err?.message || "AI 计划暂时无法转换为 JVM 预览草稿");
setApplyMessage("");
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
return;
}
@@ -363,8 +436,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
setApplyMessage(
`已从 AI 计划填充草稿,目标资源为 ${draftFromPlan.resourceId},请先执行“预览变更”再确认写入。`,
);
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
};
window.addEventListener(
@@ -393,7 +465,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
currentPayload === "{}" ||
payloadText === DEFAULT_PAYLOAD_TEXT
) {
setPayloadText(buildActionPayloadTemplate(definition));
setPayloadText(buildJVMActionPayloadTemplate(definition, snapshot?.sensitive));
}
};
@@ -414,9 +486,7 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
payload = parsed as Record<string, any>;
}
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<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ tab }) => {
};
const handleApply = async () => {
await Promise.resolve();
if (!connection) {
setDraftError("连接不存在或已被删除");
return;
@@ -543,11 +626,21 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ 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<JVMResourceBrowserProps> = ({ tab }) => {
setSnapshot(applyResult.updatedValue);
}
setPreviewOpen(false);
setPreviewResult(null);
clearPreviewState();
setApplyMessage(
applyResult?.message || result?.message || "JVM 变更已执行",
);
@@ -897,8 +989,9 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
<Space direction="vertical" size={8} style={{ width: "100%" }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary">
JSON 使 payload
{selectedActionDefinition?.payloadExample
使稿使
request稿
{selectedActionDefinition?.payloadExample && !snapshot?.sensitive
? " 已按当前动作填充推荐模板。"
: ""}
</Text>

View File

@@ -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<string, string> = {
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<JVMChangePreviewModalProps> = ({
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.before?.value)}
{formatJVMValueForDisplay(preview.before)}
</pre>
</div>
@@ -160,7 +152,7 @@ const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>
{formatValue(preview.after?.value)}
{formatJVMValueForDisplay(preview.after)}
</pre>
</div>
</Space>

View File

@@ -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<string, any>;
}

View File

@@ -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(

View File

@@ -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;
}

View File

@@ -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(

View File

@@ -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<JVMValueSnapshot, "metadata" | "sensitive"> | 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,