结果视图
diff --git a/frontend/src/components/JVMDiagnosticConsole.test.tsx b/frontend/src/components/JVMDiagnosticConsole.test.tsx
index 1bb82f9..2b3b952 100644
--- a/frontend/src/components/JVMDiagnosticConsole.test.tsx
+++ b/frontend/src/components/JVMDiagnosticConsole.test.tsx
@@ -1,5 +1,7 @@
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
+import { act, create } from "react-test-renderer";
+import { message } from "antd";
import { beforeEach, describe, expect, it, vi } from "vitest";
import JVMDiagnosticConsole, {
@@ -33,6 +35,11 @@ const baseState = {
let mockState: any = baseState;
let registeredCompletionProvider: any = null;
+let registeredDiagnosticChunkHandler: any = null;
+const mockBackendApp = {
+ JVMListDiagnosticAuditRecords: vi.fn(),
+ JVMExecuteDiagnosticCommand: vi.fn(),
+};
const mockMonaco = {
Range: class {
startLineNumber: number;
@@ -105,6 +112,58 @@ vi.mock("@monaco-editor/react", () => ({
},
}));
+vi.mock("../../wailsjs/runtime", () => ({
+ EventsOn: vi.fn((_eventName: string, handler: any) => {
+ registeredDiagnosticChunkHandler = handler;
+ return vi.fn();
+ }),
+}));
+
+vi.mock("@ant-design/icons", () => {
+ const Icon = () =>
;
+ return {
+ ClearOutlined: Icon,
+ HistoryOutlined: Icon,
+ PauseCircleOutlined: Icon,
+ PlayCircleOutlined: Icon,
+ ReloadOutlined: Icon,
+ RocketOutlined: Icon,
+ ToolOutlined: Icon,
+ };
+});
+
+vi.mock("antd", () => {
+ const passthrough = ({ children, style }: any) =>
{children}
;
+ const Text = ({ children, style }: any) =>
{children};
+ const Paragraph = ({ children, style }: any) =>
{children}
;
+ const Title = ({ children, style }: any) =>
{children}
;
+ const Empty = ({ description }: any) =>
{description}
;
+ Empty.PRESENTED_IMAGE_SIMPLE = "simple";
+ const List = ({ dataSource = [], renderItem }: any) => (
+
{dataSource.map((item: any, index: number) => renderItem(item, index))}
+ );
+ List.Item = ({ children, style }: any) =>
{children}
;
+ const Typography = { Text, Paragraph, Title };
+ return {
+ Alert: ({ message: alertMessage, description, style }: any) => (
+
{alertMessage}{description}
+ ),
+ Button: ({ children, onClick, disabled, style }: any) =>
,
+ Card: ({ children, title, style }: any) =>
,
+ Empty,
+ Input: ({ value, onChange, placeholder }: any) =>
,
+ List,
+ Space: passthrough,
+ Tag: ({ children, style }: any) =>
{children},
+ Typography,
+ message: {
+ success: vi.fn(),
+ warning: vi.fn(),
+ info: vi.fn(),
+ },
+ };
+});
+
vi.mock("../store", () => ({
useStore: (selector: (state: any) => any) => selector(mockState),
}));
@@ -112,6 +171,27 @@ vi.mock("../store", () => ({
describe("JVMDiagnosticConsole", () => {
beforeEach(() => {
registeredCompletionProvider = null;
+ registeredDiagnosticChunkHandler = null;
+ mockState = {
+ ...baseState,
+ setJVMDiagnosticDraft: vi.fn(),
+ appendJVMDiagnosticOutput: vi.fn(),
+ clearJVMDiagnosticOutput: vi.fn(),
+ };
+ mockBackendApp.JVMListDiagnosticAuditRecords.mockResolvedValue({
+ success: true,
+ data: [],
+ });
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReset();
+ vi.mocked(message.success).mockClear();
+ vi.mocked(message.warning).mockClear();
+ vi.mocked(message.info).mockClear();
+ (globalThis as any).window = {
+ ...(globalThis as any).window,
+ go: { app: { App: mockBackendApp } },
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ };
mockMonaco.editor.setTheme.mockClear();
mockMonaco.languages.register.mockClear();
mockMonaco.languages.registerCompletionItemProvider.mockClear();
@@ -222,9 +302,48 @@ describe("JVMDiagnosticConsole", () => {
expect(markup).toContain('data-language="jvm-diagnostic"');
});
- it("uses the same styled editor shell and registers command completion before mount", () => {
+ it("redacts sensitive diagnostic output in the rendered console", () => {
mockState = {
...baseState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "watch com.foo.SecretService read '{returnObj}'",
+ },
+ },
+ jvmDiagnosticOutputs: {
+ "tab-1": [
+ {
+ sessionId: "session-1",
+ commandId: "cmd-1",
+ event: "diagnostic",
+ phase: "running",
+ content: "password=secret-token\napiKey: api-key-secret",
+ },
+ ],
+ },
+ };
+
+ const markup = renderToStaticMarkup(
+
,
+ );
+
+ expect(markup).toContain("password=********");
+ expect(markup).toContain("apiKey: ********");
+ expect(markup).not.toContain("secret-token");
+ expect(markup).not.toContain("api-key-secret");
+ });
+
+ it("uses the same styled editor shell and registers command completion before mount", () => {
+ mockState = {
+ ...mockState,
jvmDiagnosticDrafts: {
"tab-1": {
sessionId: "session-1",
@@ -269,4 +388,559 @@ describe("JVMDiagnosticConsole", () => {
]),
);
});
+
+ it("redacts failed diagnostic event content before storing and alerting", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create(
+
,
+ );
+ });
+
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: "session-1",
+ commandId: "cmd-1",
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: "session-1",
+ commandId: "cmd-1",
+ event: "diagnostic",
+ phase: "failed",
+ content: "def456\n-----END PRIVATE KEY-----",
+ },
+ });
+ });
+
+ const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
+ (call: any[]) => call[1],
+ );
+ expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
+ expect(JSON.stringify(appendedChunks)).not.toContain("def456");
+ expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
+ });
+
+ it("redacts successful diagnostic warning messages", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockResolvedValue({
+ success: true,
+ message: "api_key=query-secret",
+ });
+
+ await act(async () => {
+ create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ expect(message.warning).toHaveBeenCalledWith("api_key=********");
+ expect(message.warning).not.toHaveBeenCalledWith(
+ expect.stringContaining("query-secret"),
+ );
+ });
+
+ it("redacts successful diagnostic warning messages with the active diagnostic stream state", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let resolveCommand: (value: any) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCommand = resolve;
+ }),
+ );
+
+ await act(async () => {
+ create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ });
+
+ await act(async () => {
+ resolveCommand({
+ success: true,
+ message: "def456\n-----END PRIVATE KEY-----",
+ });
+ });
+
+ expect(JSON.stringify((message.warning as any).mock.calls)).not.toContain(
+ "def456",
+ );
+ });
+
+ it("keeps diagnostic redaction state after clearing visible output", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create(
+
,
+ );
+ });
+
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: "session-1",
+ commandId: "cmd-1",
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIV",
+ },
+ });
+ });
+
+ const clearButton = renderer.root
+ .findAllByType("button")
+ .find((button: any) => button.children.includes("清空输出"));
+ await act(async () => {
+ clearButton.props.onClick();
+ });
+
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: "session-1",
+ commandId: "cmd-1",
+ event: "diagnostic",
+ phase: "failed",
+ content: "ATE KEY-----\nabc123\n-----END PRIVATE KEY-----",
+ },
+ });
+ });
+
+ const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
+ (call: any[]) => call[1],
+ );
+ expect(mockState.clearJVMDiagnosticOutput).toHaveBeenCalledWith("tab-1");
+ expect(JSON.stringify(appendedChunks)).not.toContain("ATE KEY");
+ expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
+ });
+
+ it("redacts frontend fallback errors with the active diagnostic stream state", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let rejectCommand: (error: Error) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectCommand = reject;
+ }),
+ );
+
+ await act(async () => {
+ create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ });
+
+ await act(async () => {
+ rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
+ });
+
+ const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
+ (call: any[]) => call[1],
+ );
+ expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
+ expect(JSON.stringify(appendedChunks)).not.toContain("def456");
+ });
+
+ it("keeps diagnostic redaction state after local completion fallback", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let resolveCommand: (value: any) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((resolve) => {
+ resolveCommand = resolve;
+ }),
+ );
+
+ await act(async () => {
+ create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ });
+
+ await act(async () => {
+ resolveCommand({ success: true });
+ });
+
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "completed",
+ content: "def456\n-----END PRIVATE KEY-----",
+ },
+ });
+ });
+
+ const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
+ (call: any[]) => call[1],
+ );
+ expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
+ expect(JSON.stringify(appendedChunks)).not.toContain("def456");
+ });
+
+ it("redacts terminal-seen execute errors with the active diagnostic stream state", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let rejectCommand: (error: Error) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectCommand = reject;
+ }),
+ );
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "completed",
+ content: "still waiting for execute call",
+ },
+ });
+ });
+
+ await act(async () => {
+ rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
+ });
+
+ expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
+ });
+
+ it("redacts execute errors after a real failed terminal event closes the active PEM stream", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let rejectCommand: (error: Error) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectCommand = reject;
+ }),
+ );
+
+ let renderer: any;
+ await act(async () => {
+ renderer = create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "failed",
+ content: "def456\n-----END PRIVATE KEY-----",
+ },
+ });
+ });
+
+ await act(async () => {
+ rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
+ });
+
+ expect(JSON.stringify(renderer.toJSON())).not.toContain("def456");
+ });
+
+ it("redacts delayed failed terminal events after frontend fallback closes the active PEM stream", async () => {
+ mockState = {
+ ...mockState,
+ jvmDiagnosticDrafts: {
+ "tab-1": {
+ sessionId: "session-1",
+ command: "thread -n 5",
+ },
+ },
+ };
+ let rejectCommand: (error: Error) => void = () => {};
+ mockBackendApp.JVMExecuteDiagnosticCommand.mockReturnValue(
+ new Promise((_resolve, reject) => {
+ rejectCommand = reject;
+ }),
+ );
+
+ await act(async () => {
+ create(
+
,
+ );
+ });
+
+ await act(async () => {
+ mockEditor.addCommand.mock.calls[0][1]();
+ });
+
+ const executeRequest = mockBackendApp.JVMExecuteDiagnosticCommand.mock.calls[0][2];
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "running",
+ content: "PRIVATE_KEY=-----BEGIN PRIVATE KEY-----\nabc123",
+ },
+ });
+ });
+
+ await act(async () => {
+ rejectCommand(new Error("def456\n-----END PRIVATE KEY-----"));
+ });
+
+ await act(async () => {
+ registeredDiagnosticChunkHandler({
+ tabId: "tab-1",
+ chunk: {
+ sessionId: executeRequest.sessionId,
+ commandId: executeRequest.commandId,
+ event: "diagnostic",
+ phase: "failed",
+ content: "def456\n-----END PRIVATE KEY-----",
+ },
+ });
+ });
+
+ const appendedChunks = mockState.appendJVMDiagnosticOutput.mock.calls.flatMap(
+ (call: any[]) => call[1],
+ );
+ expect(JSON.stringify(appendedChunks)).not.toContain("abc123");
+ expect(JSON.stringify(appendedChunks)).not.toContain("def456");
+ });
});
diff --git a/frontend/src/components/JVMDiagnosticConsole.tsx b/frontend/src/components/JVMDiagnosticConsole.tsx
index b059555..4094620 100644
--- a/frontend/src/components/JVMDiagnosticConsole.tsx
+++ b/frontend/src/components/JVMDiagnosticConsole.tsx
@@ -33,8 +33,12 @@ import type {
import { buildRpcConnectionConfig } from "../utils/connectionRpcConfig";
import { resolveJVMDiagnosticCompletionItems } from "../utils/jvmDiagnosticCompletion";
import {
+ createJVMDiagnosticRedactionState,
formatJVMDiagnosticTransportLabel,
JVM_DIAGNOSTIC_COMMAND_PRESETS,
+ redactJVMDiagnosticChunkContent,
+ redactJVMDiagnosticOutput,
+ type JVMDiagnosticRedactionState,
} from "../utils/jvmDiagnosticPresentation";
import JVMCommandPresetBar from "./jvm/JVMCommandPresetBar";
import JVMDiagnosticHistory from "./jvm/JVMDiagnosticHistory";
@@ -200,6 +204,10 @@ export const createJVMDiagnosticRunningRecord = ({
status: "running",
});
+const buildJVMDiagnosticRedactionKey = (
+ chunk: Pick
,
+): string => `${chunk.sessionId || "unknown-session"}::${chunk.commandId || "unknown-command"}`;
+
const JVMDiagnosticConsole: React.FC = ({ tab }) => {
const connection = useStore((state) =>
state.connections.find((item) => item.id === tab.connectionId),
@@ -224,6 +232,41 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
const [error, setError] = useState("");
const activeCommandIdRef = useRef("");
const terminalCommandIdsRef = useRef>(new Set());
+ const redactionStatesRef = useRef>({});
+
+ const redactDiagnosticContent = useCallback(
+ (
+ content: string,
+ chunk: Pick,
+ ) => {
+ const key = buildJVMDiagnosticRedactionKey(chunk);
+ const state =
+ redactionStatesRef.current[key] || createJVMDiagnosticRedactionState();
+ redactionStatesRef.current[key] = state;
+ return redactJVMDiagnosticChunkContent(content, state);
+ },
+ [],
+ );
+
+ const redactDiagnosticChunk = useCallback(
+ (chunk: JVMDiagnosticEventChunk, options: { keepState?: boolean } = {}) => {
+ const key = buildJVMDiagnosticRedactionKey(chunk);
+ const safeChunk = {
+ ...chunk,
+ content: redactDiagnosticContent(String(chunk.content || ""), chunk),
+ };
+ if (
+ !options.keepState &&
+ isJVMDiagnosticTerminalPhase(chunk.phase) &&
+ !redactionStatesRef.current[key]?.insideSensitivePem &&
+ !redactionStatesRef.current[key]?.sawSensitivePem
+ ) {
+ delete redactionStatesRef.current[key];
+ }
+ return safeChunk;
+ },
+ [redactDiagnosticContent],
+ );
const finishActiveCommand = useCallback((commandId: string) => {
if (!commandId || activeCommandIdRef.current !== commandId) {
@@ -283,7 +326,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
}
setRecords(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
- setError(err?.message || "加载诊断历史失败");
+ setError(redactJVMDiagnosticOutput(err?.message || "加载诊断历史失败"));
} finally {
setHistoryLoading(false);
}
@@ -332,13 +375,14 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
return;
}
- appendOutput(tab.id, [payload.chunk]);
- if (payload.chunk.phase === "failed") {
- setError(payload.chunk.content || "诊断命令执行失败");
+ const safeChunk = redactDiagnosticChunk(payload.chunk);
+ appendOutput(tab.id, [safeChunk]);
+ if (safeChunk.phase === "failed") {
+ setError(safeChunk.content || "诊断命令执行失败");
}
- if (payload.chunk.commandId && isJVMDiagnosticTerminalPhase(payload.chunk.phase)) {
- terminalCommandIdsRef.current.add(payload.chunk.commandId);
- finishActiveCommand(payload.chunk.commandId);
+ if (safeChunk.commandId && isJVMDiagnosticTerminalPhase(safeChunk.phase)) {
+ terminalCommandIdsRef.current.add(safeChunk.commandId);
+ finishActiveCommand(safeChunk.commandId);
void loadAuditRecords();
}
});
@@ -348,7 +392,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
stopListening();
}
};
- }, [appendOutput, finishActiveCommand, loadAuditRecords, tab.id]);
+ }, [appendOutput, finishActiveCommand, loadAuditRecords, redactDiagnosticChunk, tab.id]);
const handleProbe = async () => {
if (!rpcConnectionConfig) {
@@ -372,7 +416,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
setCapabilities(Array.isArray(result?.data) ? result.data : []);
} catch (err: any) {
setCapabilities([]);
- setError(err?.message || "检查诊断能力失败");
+ setError(redactJVMDiagnosticOutput(err?.message || "检查诊断能力失败"));
} finally {
setLoading(false);
}
@@ -409,7 +453,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
void loadAuditRecords();
} catch (err: any) {
setSession(null);
- setError(err?.message || "创建诊断会话失败");
+ setError(redactJVMDiagnosticOutput(err?.message || "创建诊断会话失败"));
} finally {
setLoading(false);
}
@@ -478,22 +522,27 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
throw new Error(String(result?.message || "执行诊断命令失败"));
}
if (result?.message) {
- message.warning(result.message);
+ message.warning(
+ redactDiagnosticContent(String(result.message), { sessionId, commandId }),
+ );
}
const terminalSeen = terminalCommandIdsRef.current.has(commandId);
if (!terminalSeen) {
appendOutput(tab.id, [
- {
- sessionId,
- commandId,
- event: "diagnostic",
- phase: "completed",
- content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
- timestamp: Date.now(),
- metadata: {
- source: "frontend-fallback",
+ redactDiagnosticChunk(
+ {
+ sessionId,
+ commandId,
+ event: "diagnostic",
+ phase: "completed",
+ content: "诊断命令调用已返回,但未收到后端终态事件,前端已兜底结束等待状态。",
+ timestamp: Date.now(),
+ metadata: {
+ source: "frontend-fallback",
+ },
},
- },
+ { keepState: true },
+ ),
]);
}
finishActiveCommand(commandId);
@@ -524,21 +573,22 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
});
}
} catch (err: any) {
- const messageText = err?.message || "执行诊断命令失败";
+ const rawMessageText = String(err?.message || "执行诊断命令失败");
+ let messageText = "";
if (!terminalCommandIdsRef.current.has(commandId)) {
- appendOutput(tab.id, [
- {
- sessionId,
- commandId,
- event: "diagnostic",
- phase: "failed",
- content: messageText,
- timestamp: Date.now(),
- metadata: {
- source: "frontend-fallback",
- },
+ const safeChunk = redactDiagnosticChunk({
+ sessionId,
+ commandId,
+ event: "diagnostic",
+ phase: "failed",
+ content: rawMessageText,
+ timestamp: Date.now(),
+ metadata: {
+ source: "frontend-fallback",
},
- ]);
+ });
+ messageText = safeChunk.content;
+ appendOutput(tab.id, [safeChunk]);
setRecords((current) =>
current.map((record) =>
record.commandId === commandId
@@ -546,6 +596,8 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
: record,
),
);
+ } else {
+ messageText = redactDiagnosticContent(rawMessageText, { sessionId, commandId });
}
finishActiveCommand(commandId);
setError(messageText);
@@ -576,7 +628,7 @@ const JVMDiagnosticConsole: React.FC = ({ tab }) => {
}
message.info("已发送取消请求");
} catch (err: any) {
- setError(err?.message || "取消诊断命令失败");
+ setError(redactJVMDiagnosticOutput(err?.message || "取消诊断命令失败"));
} finally {
setLoading(false);
}
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/QueryEditor.external-sql-save.test.tsx b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
new file mode 100644
index 0000000..711ea7c
--- /dev/null
+++ b/frontend/src/components/QueryEditor.external-sql-save.test.tsx
@@ -0,0 +1,279 @@
+import React from 'react';
+import { act, create, type ReactTestRenderer } from 'react-test-renderer';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import type { SavedQuery, TabData } from '../types';
+import QueryEditor from './QueryEditor';
+
+const storeState = vi.hoisted(() => ({
+ connections: [
+ {
+ id: 'conn-1',
+ name: 'local',
+ config: {
+ type: 'mysql',
+ host: '127.0.0.1',
+ port: 3306,
+ user: 'root',
+ password: '',
+ database: 'main',
+ },
+ },
+ ],
+ addSqlLog: vi.fn(),
+ addTab: vi.fn(),
+ savedQueries: [] as SavedQuery[],
+ saveQuery: vi.fn(),
+ theme: 'light',
+ sqlFormatOptions: { keywordCase: 'upper' as const },
+ setSqlFormatOptions: vi.fn(),
+ queryOptions: { maxRows: 5000 },
+ setQueryOptions: vi.fn(),
+ shortcutOptions: {
+ runQuery: { enabled: false, combo: '' },
+ },
+ activeTabId: 'tab-1',
+ aiPanelVisible: false,
+ setAIPanelVisible: vi.fn(),
+}));
+
+const backendApp = vi.hoisted(() => ({
+ DBQueryWithCancel: vi.fn(),
+ DBQueryMulti: vi.fn(),
+ DBGetTables: vi.fn(),
+ DBGetAllColumns: vi.fn(),
+ DBGetDatabases: vi.fn(),
+ DBGetColumns: vi.fn(),
+ CancelQuery: vi.fn(),
+ GenerateQueryID: vi.fn(),
+ WriteSQLFile: vi.fn(),
+}));
+
+const messageApi = vi.hoisted(() => ({
+ error: vi.fn(),
+ info: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+}));
+
+const editorState = vi.hoisted(() => {
+ const state = {
+ value: '',
+ editor: null as any,
+ };
+ state.editor = {
+ getValue: vi.fn(() => state.value),
+ setValue: vi.fn((value: string) => {
+ state.value = value;
+ }),
+ getModel: vi.fn(() => ({
+ getValue: () => state.value,
+ setValue: (value: string) => {
+ state.value = value;
+ },
+ getValueInRange: () => '',
+ getLineContent: () => '',
+ getWordUntilPosition: () => ({ startColumn: 1, endColumn: 1 }),
+ })),
+ getSelection: vi.fn(() => null),
+ addAction: vi.fn(),
+ onDidChangeModelContent: vi.fn(() => ({ dispose: vi.fn() })),
+ hasTextFocus: vi.fn(() => true),
+ };
+ return state;
+});
+
+vi.mock('../store', () => {
+ const useStore = Object.assign(
+ (selector: (state: typeof storeState) => any) => selector(storeState),
+ { getState: () => storeState },
+ );
+ return { useStore };
+});
+
+vi.mock('../../wailsjs/go/app/App', () => backendApp);
+
+vi.mock('../utils/autoFetchVisibility', () => ({
+ useAutoFetchVisibility: () => false,
+}));
+
+vi.mock('@monaco-editor/react', () => ({
+ default: ({ defaultValue, onMount }: any) => {
+ React.useEffect(() => {
+ editorState.value = String(defaultValue || '');
+ onMount?.(editorState.editor, {
+ editor: { setTheme: vi.fn() },
+ languages: {
+ CompletionItemKind: { Keyword: 1, Function: 2, Field: 3 },
+ registerCompletionItemProvider: vi.fn(),
+ },
+ });
+ }, []);
+ return ;
+ },
+}));
+
+vi.mock('./DataGrid', () => ({
+ default: () => null,
+ GONAVI_ROW_KEY: '__gonavi_row_key__',
+}));
+
+vi.mock('@ant-design/icons', () => {
+ const Icon = () => ;
+ return {
+ PlayCircleOutlined: Icon,
+ SaveOutlined: Icon,
+ FormatPainterOutlined: Icon,
+ SettingOutlined: Icon,
+ CloseOutlined: Icon,
+ StopOutlined: Icon,
+ RobotOutlined: Icon,
+ };
+});
+
+vi.mock('antd', () => {
+ const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => (
+
+ );
+ Button.Group = ({ children }: any) => {children}
;
+
+ const Form: any = ({ children }: any) => ;
+ Form.Item = ({ children }: any) => <>{children}>;
+ Form.useForm = () => [{ setFieldsValue: vi.fn(), validateFields: vi.fn(() => Promise.resolve({ name: '查询' })) }];
+
+ return {
+ Button,
+ message: messageApi,
+ Modal: ({ children, open }: any) => (open ? : null),
+ Input: ({ value, onChange, placeholder }: any) => ,
+ Form,
+ Dropdown: ({ children }: any) => <>{children}>,
+ Tooltip: ({ children }: any) => <>{children}>,
+ Select: () => null,
+ Tabs: () => null,
+ };
+});
+
+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 createTab = (overrides: Partial = {}): TabData => ({
+ id: 'tab-1',
+ title: 'query.sql',
+ type: 'query',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ query: 'select 1;',
+ ...overrides,
+});
+
+describe('QueryEditor external SQL save', () => {
+ beforeEach(() => {
+ vi.stubGlobal('window', {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ dispatchEvent: vi.fn(),
+ });
+ storeState.addTab.mockReset();
+ storeState.saveQuery.mockReset();
+ storeState.savedQueries = [];
+ storeState.activeTabId = 'tab-1';
+ messageApi.success.mockReset();
+ messageApi.error.mockReset();
+ backendApp.WriteSQLFile.mockResolvedValue({ success: true });
+ editorState.value = '';
+ editorState.editor.getValue.mockClear();
+ editorState.editor.setValue.mockClear();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.clearAllMocks();
+ });
+
+ it('writes external SQL file tabs back to disk without creating saved queries', async () => {
+ let renderer: ReactTestRenderer;
+ const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
+
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 2;';
+
+ await act(async () => {
+ await findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 2;');
+ expect(storeState.saveQuery).not.toHaveBeenCalled();
+ expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({
+ filePath,
+ query: 'select 2;',
+ savedQueryId: undefined,
+ }));
+ expect(messageApi.success).toHaveBeenCalledWith('SQL 文件已保存!');
+ });
+
+ it('does not create saved queries when external SQL file writes fail', async () => {
+ let renderer: ReactTestRenderer;
+ const filePath = '/Users/me/Documents/gonavi-queries/report.sql';
+ backendApp.WriteSQLFile.mockResolvedValueOnce({ success: false, message: '磁盘只读' });
+
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 4;';
+
+ await act(async () => {
+ await findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).toHaveBeenCalledWith(filePath, 'select 4;');
+ expect(storeState.saveQuery).not.toHaveBeenCalled();
+ expect(storeState.addTab).not.toHaveBeenCalled();
+ expect(messageApi.error).toHaveBeenCalledWith('保存 SQL 文件失败: 磁盘只读');
+ });
+
+ it('keeps saved query quick-save behavior for non-file tabs', async () => {
+ storeState.savedQueries = [
+ {
+ id: 'saved-1',
+ name: '常用查询',
+ sql: 'select 1;',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ createdAt: 100,
+ },
+ ];
+
+ let renderer: ReactTestRenderer;
+ await act(async () => {
+ renderer = create();
+ });
+
+ editorState.value = 'select 3;';
+
+ await act(async () => {
+ findButton(renderer!, '保存').props.onClick();
+ });
+
+ expect(backendApp.WriteSQLFile).not.toHaveBeenCalled();
+ expect(storeState.saveQuery).toHaveBeenCalledWith(expect.objectContaining({
+ id: 'saved-1',
+ name: '常用查询',
+ sql: 'select 3;',
+ connectionId: 'conn-1',
+ dbName: 'main',
+ createdAt: 100,
+ }));
+ });
+});
diff --git a/frontend/src/components/QueryEditor.tsx b/frontend/src/components/QueryEditor.tsx
index 3cc8fcd..ac7a4f4 100644
--- a/frontend/src/components/QueryEditor.tsx
+++ b/frontend/src/components/QueryEditor.tsx
@@ -6,7 +6,7 @@ import { format } from 'sql-formatter';
import { v4 as uuidv4 } from 'uuid';
import { TabData, ColumnDefinition } from '../types';
import { useStore } from '../store';
-import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID } from '../../wailsjs/go/app/App';
+import { DBQueryWithCancel, DBQueryMulti, DBGetTables, DBGetAllColumns, DBGetDatabases, DBGetColumns, CancelQuery, GenerateQueryID, WriteSQLFile } from '../../wailsjs/go/app/App';
import DataGrid, { GONAVI_ROW_KEY } from './DataGrid';
import { getDataSourceCapabilities } from '../utils/dataSourceCapabilities';
import { convertMongoShellToJsonCommand } from '../utils/mongodb';
@@ -2204,7 +2204,31 @@ const QueryEditor: React.FC<{ tab: TabData; isActive?: boolean }> = ({ tab, isAc
return saved;
};
- const handleQuickSave = () => {
+ const handleQuickSave = async () => {
+ const filePath = String(tab.filePath || '').trim();
+ if (filePath) {
+ const sql = getCurrentQuery();
+ try {
+ const res = await WriteSQLFile(filePath, sql);
+ if (!res.success) {
+ message.error('保存 SQL 文件失败: ' + (res.message || '未知错误'));
+ return;
+ }
+ addTab({
+ ...tab,
+ query: sql,
+ connectionId: currentConnectionId,
+ dbName: currentDb || tab.dbName || '',
+ filePath,
+ savedQueryId: undefined,
+ });
+ message.success('SQL 文件已保存!');
+ } catch (error) {
+ message.error('保存 SQL 文件失败: ' + (error instanceof Error ? error.message : String(error)));
+ }
+ return;
+ }
+
const existed = currentSavedQuery || null;
const fallbackSavedId = String(tab.savedQueryId || '').trim();
const saveId = existed?.id || fallbackSavedId || '';
diff --git a/frontend/src/components/RedisViewer.tsx b/frontend/src/components/RedisViewer.tsx
index 2edeaf4..c9cfe7e 100644
--- a/frontend/src/components/RedisViewer.tsx
+++ b/frontend/src/components/RedisViewer.tsx
@@ -1852,7 +1852,7 @@ const RedisViewer: React.FC = ({ connectionId, redisDB }) => {
void }>
connectionId,
dbName,
query: String(data || ''),
+ filePath,
});
};
@@ -4211,6 +4212,8 @@ const Sidebar: React.FC<{ onEditConnection?: (conn: SavedConnection) => void }>
size="small"
type="text"
icon={}
+ title="添加外部 SQL 目录"
+ aria-label="添加外部 SQL 目录"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx
index f8b75e6..43df44b 100644
--- a/frontend/src/components/ai/AIChatInput.notice.test.tsx
+++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx
@@ -36,6 +36,7 @@ describe('AIChatInput notice layout', () => {
activeProvider={{ model: '', models: [] }}
dynamicModels={[]}
loadingModels={false}
+ sendShortcutBinding={{ combo: 'Enter', enabled: true }}
composerNotice={{
tone: 'error',
title: '模型列表加载失败',
@@ -58,4 +59,35 @@ describe('AIChatInput notice layout', () => {
expect(inputIndex).toBeGreaterThanOrEqual(0);
expect(noticeIndex).toBeLessThan(inputIndex);
});
+
+ it('renders the selected send shortcut in the composer placeholder', () => {
+ const markup = renderToStaticMarkup(
+ {}}
+ draftImages={[]}
+ setDraftImages={() => {}}
+ sending={false}
+ onSend={() => {}}
+ onStop={() => {}}
+ handleKeyDown={() => {}}
+ activeConnName=""
+ activeContext={null}
+ activeProvider={{ model: '', models: [] }}
+ dynamicModels={[]}
+ loadingModels={false}
+ sendShortcutBinding={{ combo: 'Meta+Enter', enabled: true }}
+ composerNotice={null}
+ onModelChange={() => {}}
+ onFetchModels={() => {}}
+ textareaRef={React.createRef()}
+ darkMode={false}
+ textColor="#162033"
+ mutedColor="rgba(16,24,40,0.55)"
+ overlayTheme={buildOverlayWorkbenchTheme(false)}
+ />
+ );
+
+ expect(markup).toContain('Meta+Enter 发送');
+ });
});
diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx
index 0640cfc..4f14f12 100644
--- a/frontend/src/components/ai/AIChatInput.tsx
+++ b/frontend/src/components/ai/AIChatInput.tsx
@@ -2,10 +2,13 @@ import React from 'react';
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
import { useStore } from '../../store';
-import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
+import { DBGetTables, DBShowCreateTable, DBGetDatabases, DBGetColumns } from '../../../wailsjs/go/app/App';
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig';
+import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool';
+import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut';
+import type { ShortcutBinding } from '../../utils/shortcuts';
interface AIChatInputProps {
input: string;
@@ -21,6 +24,7 @@ interface AIChatInputProps {
activeProvider: any;
dynamicModels: string[];
loadingModels: boolean;
+ sendShortcutBinding: ShortcutBinding;
composerNotice?: AIComposerNotice | null;
onModelChange: (val: string) => void;
onFetchModels: () => void;
@@ -36,7 +40,7 @@ interface AIChatInputProps {
export const AIChatInput: React.FC = ({
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
- composerNotice,
+ sendShortcutBinding, composerNotice,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars
}) => {
@@ -202,24 +206,21 @@ export const AIChatInput: React.FC = ({
if (activeContextItems.find(c => c.dbName === dbName && c.tableName === tableName)) {
continue;
}
- const res = await DBShowCreateTable(buildRpcConnectionConfig(conn.config) as any, dbName, tableName);
- let createSql = '';
- if (res.success && res.data) {
- if (typeof res.data === 'string') {
- createSql = res.data;
- } else if (Array.isArray(res.data) && res.data.length > 0) {
- const row = res.data[0];
- createSql = (Object.values(row).find(v => typeof v === 'string' && (v.toUpperCase().includes('CREATE TABLE') || v.toUpperCase().includes('CREATE'))) || Object.values(row)[1] || Object.values(row)[0]) as string;
- }
- } else {
- message.error(`获取表 ${dbName}.${tableName} 结构失败: ` + (res.message || '未知错误'));
+ const rpcConfig = buildRpcConnectionConfig(conn.config) as any;
+ const schemaResult = await resolveAITableSchemaToolResult({
+ tableName,
+ fetchDDL: () => DBShowCreateTable(rpcConfig, dbName, tableName),
+ fetchColumns: () => DBGetColumns(rpcConfig, dbName, tableName),
+ });
+ if (!schemaResult.success) {
+ message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`);
}
-
- if (createSql) {
+
+ if (schemaResult.success && schemaResult.content) {
addAIContext(connectionKey, {
dbName: dbName,
tableName: tableName,
- ddl: createSql
+ ddl: schemaResult.content
});
addedCount++;
}
@@ -381,7 +382,7 @@ export const AIChatInput: React.FC = ({
}
}}
onKeyDown={handleKeyDown as any}
- placeholder="输入消息... (Enter 发送,Shift+Enter 换行,/ 快捷命令)"
+ placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding)},Shift+Enter 换行,/ 快捷命令)`}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
diff --git a/frontend/src/components/ai/AIMessageBubble.tsx b/frontend/src/components/ai/AIMessageBubble.tsx
index b449142..3cfad53 100644
--- a/frontend/src/components/ai/AIMessageBubble.tsx
+++ b/frontend/src/components/ai/AIMessageBubble.tsx
@@ -15,6 +15,7 @@ import {
parseJVMDiagnosticPlan,
resolveJVMDiagnosticPlanTargetTabId,
} from '../../utils/jvmDiagnosticPlan';
+import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit';
// 🔧 性能优化:将 ReactMarkdown 包装为 Memo 组件并提取固定的 plugins
const remarkPlugins = [remarkGfm];
@@ -260,7 +261,13 @@ const AIBlockHashRender = ({ match, darkMode, overlayTheme, children, activeConn
setPreviewData(null);
try {
const { DBQuery } = await import('../../../wailsjs/go/app/App');
- const res = await DBQuery(activeConnectionConfig, activeDbName || '', displayText + ' LIMIT 50');
+ const previewSql = buildAIReadonlyPreviewSQL(
+ activeConnectionConfig?.type || '',
+ displayText,
+ 50,
+ activeConnectionConfig?.driver || '',
+ );
+ const res = await DBQuery(activeConnectionConfig, activeDbName || '', previewSql);
if (res.success && Array.isArray(res.data)) {
const rows = res.data as any[];
const cols = rows.length > 0 ? Object.keys(rows[0]) : [];
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