feat(jvm): 增加 JVM 写入预览与审计

- 打通 JVM 变更预览、执行确认与审计记录链路
- 增加 Guard 校验、模式约束与审计写入失败回传
- 补齐审计页签、预览弹窗和 Task 5 回归覆盖
This commit is contained in:
Syngnat
2026-04-23 12:14:36 +08:00
parent 21f2b29d1d
commit 9a61622568
14 changed files with 1475 additions and 78 deletions

View File

@@ -26,7 +26,7 @@
- [x] 阶段 2影响分析完成
- [x] 阶段 3方案设计完成已形成正式设计文档
- [x] 阶段 4实施计划完成已形成正式实施计划
- [ ] 阶段 5实现与自检进行中Task 1、Task 2、Task 3、Task 4 已完成并通过回归)
- [ ] 阶段 5实现与自检进行中Task 1Task 5 已完成并通过回归Task 6/7 待继续
- [ ] 阶段 6评审与交付
- [ ] 阶段 7发布与观察
@@ -43,11 +43,12 @@
- 已完成 Task 1JVM 共享契约与配置归一化
- 已完成 Task 2Provider 注册、连接测试与能力探测 API
- 已完成 Task 3JVM 连接表单、图标与展示文案接入
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 4只读资源浏览与 JVM Tab
- 已完成 Task 5写入预览、Guard 和审计记录
- 进行中:
- Task 5Guard/Audit、AI 结构化计划等后续任务准备
- Task 6AI 结构化变更计划
- 待处理:
- Task 5+Guard/Audit、AI 结构化计划等后续任务实现
- Task 7全量回归、文档回填与交付检查
## 5. 风险与阻塞
- 风险:
@@ -57,6 +58,7 @@
- 若目标 JVM 不允许预埋或动态注入 Agent则“通用型”能力边界会明显收缩
- 多接入模式会带来能力不一致问题UI 与权限模型必须显式展示“当前模式支持什么/不支持什么”
- 当前 JMX / Endpoint provider 的资源浏览与值读取仍是骨架实现Task 4 已打通接口与 UI 链路,但真实资源展开会返回 `not implemented`
- Task 5 已打通 Guard/Preview/Audit 主链路,但真实 provider 的 `PreviewChange` / `ApplyChange` 能力仍依赖后续 provider 细化实现
- 阻塞:
- 目标应用技术栈、缓存框架与接入约束尚未明确
- 缓解措施:
@@ -90,6 +92,9 @@
- Task 3 已完成规格审查与代码质量审查;过程中修复了 JVM 标题文案偏差、模式选项暴露范围、编辑态模式静默降级和 endpoint timeout 失真问题
- 已完成 JVM 只读资源浏览链路:后端新增 `JVMListResources` / `JVMGetValue`,前端新增 `jvm-overview` / `jvm-resource` tab 与侧边栏 JVM 模式/资源节点
- Task 4 已完成规格复审;代码质量复审确认真实 provider 浏览能力仍为后续任务范围,另外已修正 JVM 资源 tab 同名问题
- 已完成 Task 5后端新增 `JVMPreviewChange` / `JVMApplyChange` / `JVMListAuditRecords`,补齐 Guard、审计 JSONL 落盘与审计读取能力
- Task 5 已补齐只读拦截、`prod` 环境确认、provider preview 错误透出、审计写入失败显式回传、连接 `allowedModes` 约束和局部快照合并保底
- 前端已完成 JVM 变更草稿区、预览弹窗、执行确认、审计记录页签与按 provider mode 的审计过滤
- 证据(日志/截图/链接):
- `cmd/optional-driver-agent/main.go`
- `internal/db/database.go`
@@ -144,7 +149,18 @@
- `go test ./internal/app -run 'TestJVMProbeCapabilities|TestTestJVMConnection' -count=1`
- `cd frontend && npm test -- src/utils/jvmRuntimePresentation.test.ts`
- `cd frontend && npm run build`
- `internal/jvm/guard.go`
- `internal/jvm/guard_test.go`
- `internal/jvm/audit_store.go`
- `internal/jvm/audit_store_test.go`
- `internal/app/methods_jvm.go`
- `internal/app/methods_jvm_test.go`
- `frontend/src/components/JVMAuditViewer.tsx`
- `frontend/src/components/jvm/JVMChangePreviewModal.tsx`
- `go test ./internal/jvm ./internal/app -run 'TestPreviewChangeBlocksReadOnlyConnection|TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed|TestPreviewChangeMarksProdWritesAsConfirmationRequired|TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults|TestJVMApplyChangeReturnsProviderPayload|TestJVMPreviewChangeRejectsModeOutsideAllowedModes|TestJVMListAuditRecordsReturnsLatestRecords|TestJVMApplyChangeSurfacesAuditWriteFailure' -count=1`
- `go test ./internal/jvm ./internal/app -count=1`
- `cd frontend && npm run build`
## 8. 下一步
- 下一步行动:进入 Task 5补齐 Guard/Audit 和 AI 结构化修改计划能力,并收敛真实 provider 的资源浏览实现边界
- 下一步行动:进入 Task 6接入 AI 结构化修改计划能力,并在 Task 7 做全量交付检查
- 负责人Codex

View File

@@ -0,0 +1,204 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Empty, Select, Space, Table, Tag, Typography } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { ReloadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import type { JVMAuditRecord, TabData } from '../types';
import JVMModeBadge from './jvm/JVMModeBadge';
const { Text } = Typography;
type JVMAuditViewerProps = {
tab: TabData;
};
const LIMIT_OPTIONS = [20, 50, 100, 200];
const normalizeAuditRecords = (value: any): JVMAuditRecord[] => {
if (Array.isArray(value)) {
return value as JVMAuditRecord[];
}
if (Array.isArray(value?.data)) {
return value.data as JVMAuditRecord[];
}
return [];
};
const filterAuditRecordsByMode = (records: JVMAuditRecord[], providerMode?: string): JVMAuditRecord[] => {
const normalizedMode = String(providerMode || '').trim().toLowerCase();
if (!normalizedMode) {
return records;
}
return records.filter((record) => String(record.providerMode || '').trim().toLowerCase() === normalizedMode);
};
const formatTimestamp = (timestamp: number): string => {
if (!timestamp) {
return '-';
}
const normalized = timestamp > 1e12 ? timestamp : timestamp * 1000;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) {
return String(timestamp);
}
return date.toLocaleString('zh-CN', { hour12: false });
};
const resultColor = (result: string): string => {
const normalized = String(result || '').trim().toLowerCase();
if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('done')) {
return 'green';
}
if (normalized.includes('warn')) {
return 'gold';
}
if (normalized.includes('fail') || normalized.includes('error')) {
return 'red';
}
return 'default';
};
const JVMAuditViewer: React.FC<JVMAuditViewerProps> = ({ tab }) => {
const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId));
const [limit, setLimit] = useState(50);
const [loading, setLoading] = useState(true);
const [records, setRecords] = useState<JVMAuditRecord[]>([]);
const [error, setError] = useState('');
const columns = useMemo<ColumnsType<JVMAuditRecord>>(
() => [
{
title: '时间',
dataIndex: 'timestamp',
key: 'timestamp',
width: 180,
render: (value: number) => formatTimestamp(value),
},
{
title: '模式',
dataIndex: 'providerMode',
key: 'providerMode',
width: 120,
render: (value: string) => <JVMModeBadge mode={value || tab.providerMode || 'jmx'} />,
},
{
title: '动作',
dataIndex: 'action',
key: 'action',
width: 120,
render: (value: string) => value || '-',
},
{
title: '资源',
dataIndex: 'resourceId',
key: 'resourceId',
ellipsis: true,
render: (value: string) => value || '-',
},
{
title: '原因',
dataIndex: 'reason',
key: 'reason',
ellipsis: true,
render: (value: string) => value || '-',
},
{
title: '结果',
dataIndex: 'result',
key: 'result',
width: 140,
render: (value: string) => <Tag color={resultColor(value)}>{value || 'unknown'}</Tag>,
},
],
[tab.providerMode],
);
const loadRecords = async () => {
if (!connection) {
setLoading(false);
setRecords([]);
setError('连接不存在或已被删除');
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMListAuditRecords !== 'function') {
setLoading(false);
setRecords([]);
setError('JVMListAuditRecords 后端方法不可用');
return;
}
setLoading(true);
setError('');
try {
const result = await backendApp.JVMListAuditRecords(connection.id, limit);
if (result?.success === false) {
setRecords([]);
setError(String(result?.message || '读取 JVM 审计记录失败'));
return;
}
setRecords(filterAuditRecordsByMode(normalizeAuditRecords(result), tab.providerMode));
} catch (err: any) {
setRecords([]);
setError(err?.message || '读取 JVM 审计记录失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
void loadRecords();
}, [connection, limit, tab.connectionId]);
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<JVMModeBadge mode={tab.providerMode || connection.config.jvm?.preferredMode || 'jmx'} />
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadRecords()}>
</Button>
<Select
size="small"
value={limit}
onChange={setLimit}
options={LIMIT_OPTIONS.map((item) => ({ value: item, label: `最近 ${item}` }))}
style={{ width: 128 }}
/>
</Space>
<Space size={8} wrap>
<Text strong>{connection.name}</Text>
<Text type="secondary">{connection.id}</Text>
</Space>
</Space>
</Card>
<Card title="审计记录">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
<Table<JVMAuditRecord>
rowKey={(record) => `${record.timestamp}-${record.resourceId}-${record.action}`}
loading={loading}
columns={columns}
dataSource={records}
pagination={false}
locale={{
emptyText: error ? '当前无法加载审计记录' : '暂无审计记录',
}}
scroll={{ x: 960 }}
size="small"
/>
</Space>
</Card>
</div>
);
};
export default JVMAuditViewer;

View File

@@ -1,13 +1,24 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Alert, Button, Card, Descriptions, Empty, Skeleton, Space, Typography } from 'antd';
import { ReloadOutlined } from '@ant-design/icons';
import { Alert, Button, Card, Descriptions, Empty, Input, Skeleton, Space, Tag, Typography } from 'antd';
import { FileSearchOutlined, ReloadOutlined } from '@ant-design/icons';
import { useStore } from '../store';
import type { JVMValueSnapshot, SavedConnection, TabData } from '../types';
import type {
JVMApplyResult,
JVMChangePreview,
JVMChangeRequest,
JVMValueSnapshot,
SavedConnection,
TabData,
} from '../types';
import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig';
import { buildJVMTabTitle } from '../utils/jvmRuntimePresentation';
import JVMModeBadge from './jvm/JVMModeBadge';
import JVMChangePreviewModal from './jvm/JVMChangePreviewModal';
const { Paragraph, Text } = Typography;
const { TextArea } = Input;
const DEFAULT_PAYLOAD_TEXT = '{\n \n}';
type JVMResourceBrowserProps = {
tab: TabData;
@@ -35,12 +46,44 @@ const formatValue = (value: unknown): string => {
}
};
const normalizePreviewResult = (value: any): JVMChangePreview | null => {
if (value && typeof value === 'object' && typeof value.allowed === 'boolean') {
return value as JVMChangePreview;
}
if (value?.data && typeof value.data.allowed === 'boolean') {
return value.data as JVMChangePreview;
}
return null;
};
const normalizeApplyResult = (value: any): JVMApplyResult | null => {
if (value && typeof value === 'object' && typeof value.status === 'string') {
return value as JVMApplyResult;
}
if (value?.data && typeof value.data.status === 'string') {
return value.data as JVMApplyResult;
}
return null;
};
const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
const connection = useStore((state) => state.connections.find((item) => item.id === tab.connectionId));
const providerMode = tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx';
const addTab = useStore((state) => state.addTab);
const providerMode = (tab.providerMode || connection?.config.jvm?.preferredMode || 'jmx') as 'jmx' | 'endpoint' | 'agent';
const resourcePath = String(tab.resourcePath || '').trim();
const readOnly = connection?.config.jvm?.readOnly !== false;
const [loading, setLoading] = useState(true);
const [snapshot, setSnapshot] = useState<JVMValueSnapshot | null>(null);
const [error, setError] = useState('');
const [action, setAction] = useState('update');
const [reason, setReason] = useState('');
const [payloadText, setPayloadText] = useState(DEFAULT_PAYLOAD_TEXT);
const [draftError, setDraftError] = useState('');
const [applyMessage, setApplyMessage] = useState('');
const [previewLoading, setPreviewLoading] = useState(false);
const [previewOpen, setPreviewOpen] = useState(false);
const [previewResult, setPreviewResult] = useState<JVMChangePreview | null>(null);
const [applyLoading, setApplyLoading] = useState(false);
const displayValue = useMemo(() => formatValue(snapshot?.value), [snapshot]);
@@ -52,7 +95,6 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
return;
}
const resourcePath = String(tab.resourcePath || '').trim();
if (!resourcePath) {
setLoading(false);
setSnapshot(null);
@@ -91,77 +133,306 @@ const JVMResourceBrowser: React.FC<JVMResourceBrowserProps> = ({ tab }) => {
useEffect(() => {
void loadSnapshot();
}, [connection, providerMode, tab.connectionId, tab.resourcePath]);
}, [connection, providerMode, resourcePath, tab.connectionId]);
useEffect(() => {
setAction('update');
setReason('');
setPayloadText(DEFAULT_PAYLOAD_TEXT);
setDraftError('');
setApplyMessage('');
setPreviewOpen(false);
setPreviewResult(null);
}, [providerMode, resourcePath, tab.connectionId]);
const buildDraftPlan = (): JVMChangeRequest => {
const trimmedAction = String(action || '').trim() || 'update';
const trimmedReason = String(reason || '').trim();
if (!trimmedReason) {
throw new Error('请填写变更原因');
}
const rawPayload = String(payloadText || '').trim();
let payload: Record<string, any> = {};
if (rawPayload) {
const parsed = JSON.parse(rawPayload);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error('Payload 必须是 JSON 对象');
}
payload = parsed as Record<string, any>;
}
const resourceId = String(snapshot?.resourceId || resourcePath).trim();
if (!resourceId) {
throw new Error('资源 ID 为空,无法生成变更草稿');
}
return {
providerMode,
resourceId,
action: trimmedAction,
reason: trimmedReason,
expectedVersion: snapshot?.version || undefined,
payload,
};
};
const handleOpenAudit = () => {
if (!connection) {
return;
}
addTab({
id: `jvm-audit-${connection.id}-${providerMode}`,
title: buildJVMTabTitle(connection.name, 'audit', providerMode),
type: 'jvm-audit',
connectionId: connection.id,
providerMode,
});
};
const handlePreview = async () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMPreviewChange !== 'function') {
setDraftError('JVMPreviewChange 后端方法不可用');
return;
}
let draftPlan: JVMChangeRequest;
try {
draftPlan = buildDraftPlan();
} catch (err: any) {
setDraftError(err?.message || '变更草稿不合法');
return;
}
setPreviewLoading(true);
setDraftError('');
setApplyMessage('');
try {
const result = await backendApp.JVMPreviewChange(
buildJVMRuntimeConfig(connection, providerMode),
draftPlan,
);
if (result?.success === false) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError(String(result?.message || '预览 JVM 变更失败'));
return;
}
const preview = normalizePreviewResult(result);
if (!preview) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError('预览结果格式不正确');
return;
}
setPreviewResult(preview);
setPreviewOpen(true);
} catch (err: any) {
setPreviewResult(null);
setPreviewOpen(false);
setDraftError(err?.message || '预览 JVM 变更失败');
} finally {
setPreviewLoading(false);
}
};
const handleApply = async () => {
if (!connection) {
setDraftError('连接不存在或已被删除');
return;
}
const backendApp = (window as any).go?.app?.App;
if (typeof backendApp?.JVMApplyChange !== 'function') {
setDraftError('JVMApplyChange 后端方法不可用');
return;
}
let draftPlan: JVMChangeRequest;
try {
draftPlan = buildDraftPlan();
} catch (err: any) {
setDraftError(err?.message || '变更草稿不合法');
return;
}
setApplyLoading(true);
setDraftError('');
setApplyMessage('');
try {
const result = await backendApp.JVMApplyChange(
buildJVMRuntimeConfig(connection, providerMode),
draftPlan,
);
if (result?.success === false) {
setDraftError(String(result?.message || '执行 JVM 变更失败'));
return;
}
const applyResult = normalizeApplyResult(result);
if (applyResult?.updatedValue) {
setSnapshot(applyResult.updatedValue);
}
setPreviewOpen(false);
setPreviewResult(null);
setApplyMessage(applyResult?.message || result?.message || 'JVM 变更已执行');
await loadSnapshot();
} catch (err: any) {
setDraftError(err?.message || '执行 JVM 变更失败');
} finally {
setApplyLoading(false);
}
};
if (!connection) {
return <Empty description="连接不存在或已被删除" style={{ marginTop: 64 }} />;
}
return (
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadSnapshot()}>
</Button>
<>
<div style={{ padding: 20, display: 'grid', gap: 16 }}>
<Card>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Space size={12} wrap>
<JVMModeBadge mode={providerMode} />
<Tag color={readOnly ? 'blue' : 'red'}>{readOnly ? '只读连接' : '可写连接'}</Tag>
<Button size="small" icon={<ReloadOutlined />} onClick={() => void loadSnapshot()}>
</Button>
<Button size="small" icon={<FileSearchOutlined />} onClick={handleOpenAudit}>
</Button>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
</Paragraph>
<Text type="secondary">{resourcePath || '-'}</Text>
</Space>
<Paragraph style={{ marginBottom: 0 }}>
<Text strong>{connection.name}</Text>
</Paragraph>
<Text type="secondary">{tab.resourcePath}</Text>
</Space>
</Card>
</Card>
<Card title="资源快照">
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
{snapshot ? (
<>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源 ID">{snapshot.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="资源类型">{snapshot.kind || tab.resourceKind || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{snapshot.format || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{snapshot.version || '-'}</Descriptions.Item>
</Descriptions>
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.04)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{displayValue}
</pre>
{snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? (
<Card title="资源快照">
{loading ? (
<Skeleton active paragraph={{ rows: 6 }} />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{error ? <Alert type="error" showIcon message={error} /> : null}
{snapshot ? (
<>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源 ID">{snapshot.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="资源类型">{snapshot.kind || tab.resourceKind || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{snapshot.format || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{snapshot.version || '-'}</Descriptions.Item>
</Descriptions>
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.03)',
background: 'rgba(0, 0, 0, 0.04)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{JSON.stringify(snapshot.metadata, null, 2)}
{displayValue}
</pre>
) : null}
</>
) : error ? null : <Empty description="暂无资源数据" />}
{snapshot.metadata && Object.keys(snapshot.metadata).length > 0 ? (
<pre
style={{
margin: 0,
padding: 16,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.03)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{JSON.stringify(snapshot.metadata, null, 2)}
</pre>
) : null}
</>
) : error ? null : <Empty description="暂无资源数据" />}
</Space>
)}
</Card>
<Card title="变更草稿">
<Space direction="vertical" size={16} style={{ width: '100%' }}>
{readOnly ? (
<Alert
type="warning"
showIcon
message="当前连接默认只读,预览或执行可能被后端策略拒绝。"
/>
) : null}
{draftError ? <Alert type="error" showIcon message={draftError} /> : null}
{applyMessage ? <Alert type="success" showIcon message={applyMessage} /> : null}
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="资源路径">{resourcePath || '-'}</Descriptions.Item>
<Descriptions.Item label="资源版本">{snapshot?.version || '-'}</Descriptions.Item>
</Descriptions>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Action</Text>
<Input
value={action}
onChange={(event) => setAction(event.target.value)}
placeholder="例如 update"
maxLength={64}
/>
</Space>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong></Text>
<Input
value={reason}
onChange={(event) => setReason(event.target.value)}
placeholder="填写本次 JVM 资源变更原因"
maxLength={200}
/>
</Space>
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text strong>Payload(JSON)</Text>
<Text type="secondary"> JSON 使 payload</Text>
<TextArea
value={payloadText}
onChange={(event) => setPayloadText(event.target.value)}
autoSize={{ minRows: 8, maxRows: 18 }}
spellCheck={false}
/>
</Space>
<Space size={12} wrap>
<Button type="primary" loading={previewLoading} onClick={() => void handlePreview()}>
</Button>
</Space>
</Space>
)}
</Card>
</div>
</Card>
</div>
<JVMChangePreviewModal
open={previewOpen}
preview={previewResult}
applying={applyLoading}
onCancel={() => {
if (applyLoading) {
return;
}
setPreviewOpen(false);
}}
onConfirm={() => void handleApply()}
/>
</>
);
};

View File

@@ -18,6 +18,7 @@ import DefinitionViewer from './DefinitionViewer';
import TableOverview from './TableOverview';
import JVMOverview from './JVMOverview';
import JVMResourceBrowser from './JVMResourceBrowser';
import JVMAuditViewer from './JVMAuditViewer';
import type { TabData } from '../types';
import { buildTabDisplayTitle } from '../utils/tabDisplay';
@@ -210,11 +211,7 @@ const TabManager: React.FC = () => {
} else if (tab.type === 'jvm-resource') {
content = <JVMResourceBrowser tab={tab} />;
} else if (tab.type === 'jvm-audit') {
content = (
<div style={{ padding: 24 }}>
JVM
</div>
);
content = <JVMAuditViewer tab={tab} />;
}
const menuItems: MenuProps['items'] = [

View File

@@ -0,0 +1,127 @@
import React, { useMemo } from 'react';
import { Alert, Descriptions, Modal, Space, Tag, Typography } from 'antd';
import type { JVMChangePreview } from '../../types';
const { Text } = Typography;
type JVMChangePreviewModalProps = {
open: boolean;
preview: JVMChangePreview | null;
applying?: boolean;
onCancel: () => void;
onConfirm: () => void;
};
const riskColorMap: Record<string, string> = {
low: 'green',
medium: 'orange',
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,
borderRadius: 8,
background: 'rgba(0, 0, 0, 0.04)',
overflow: 'auto',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: 280,
};
const JVMChangePreviewModal: React.FC<JVMChangePreviewModalProps> = ({
open,
preview,
applying = false,
onCancel,
onConfirm,
}) => {
const summary = useMemo(() => {
if (!preview) {
return '暂无预览结果';
}
return preview.summary || '预览已生成';
}, [preview]);
return (
<Modal
title="JVM 变更预览"
open={open}
onCancel={onCancel}
onOk={onConfirm}
okText="确认执行"
cancelText="关闭"
okButtonProps={{ disabled: !preview?.allowed, loading: applying }}
width={880}
destroyOnClose
>
{!preview ? (
<Alert type="info" showIcon message="暂无预览结果" />
) : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }}>
<Descriptions.Item label="变更摘要">
<Space size={8} wrap>
<Text>{summary}</Text>
<Tag color={riskColorMap[preview.riskLevel] || 'default'}>
{preview.riskLevel || 'unknown'}
</Tag>
{preview.requiresConfirmation ? <Tag color="gold"></Tag> : null}
{preview.allowed ? <Tag color="green"></Tag> : <Tag color="red"></Tag>}
</Space>
</Descriptions.Item>
{preview.blockingReason ? (
<Descriptions.Item label="阻断原因">
<Text type="danger">{preview.blockingReason}</Text>
</Descriptions.Item>
) : null}
</Descriptions>
{!preview.allowed && preview.blockingReason ? (
<Alert type="error" showIcon message={preview.blockingReason} />
) : (
<Alert type="info" showIcon message={summary} />
)}
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }} style={{ marginBottom: 12 }}>
<Descriptions.Item label="资源 ID">{preview.before?.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{preview.before?.version || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{preview.before?.format || '-'}</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>{formatValue(preview.before?.value)}</pre>
</div>
<div>
<Text strong style={{ display: 'block', marginBottom: 8 }}>
</Text>
<Descriptions column={1} size="small" labelStyle={{ width: 120 }} style={{ marginBottom: 12 }}>
<Descriptions.Item label="资源 ID">{preview.after?.resourceId || '-'}</Descriptions.Item>
<Descriptions.Item label="版本">{preview.after?.version || '-'}</Descriptions.Item>
<Descriptions.Item label="格式">{preview.after?.format || '-'}</Descriptions.Item>
</Descriptions>
<pre style={previewBlockStyle}>{formatValue(preview.after?.value)}</pre>
</div>
</Space>
)}
</Modal>
);
};
export default JVMChangePreviewModal;

View File

@@ -87,6 +87,31 @@ export interface JVMChangePreview {
after: JVMValueSnapshot;
}
export interface JVMChangeRequest {
providerMode: 'jmx' | 'endpoint' | 'agent';
resourceId: string;
action: string;
reason: string;
expectedVersion?: string;
payload?: Record<string, any>;
}
export interface JVMApplyResult {
status: string;
message?: string;
updatedValue: JVMValueSnapshot;
}
export interface JVMAuditRecord {
timestamp: number;
connectionId: string;
providerMode: string;
resourceId: string;
action: string;
reason: string;
result: string;
}
export interface ConnectionConfig {
id?: string;
type: string;

View File

@@ -1,6 +1,9 @@
package app
import (
"path/filepath"
"strings"
"GoNavi-Wails/internal/connection"
"GoNavi-Wails/internal/jvm"
)
@@ -8,12 +11,18 @@ import (
var newJVMProvider = jvm.NewProvider
func resolveJVMProvider(cfg connection.ConnectionConfig) (connection.ConnectionConfig, jvm.Provider, error) {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
return resolveJVMProviderForMode(cfg, "")
}
func resolveJVMProviderForMode(cfg connection.ConnectionConfig, mode string) (connection.ConnectionConfig, jvm.Provider, error) {
normalized, selectedMode, err := jvm.ResolveProviderMode(cfg, mode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
provider, err := newJVMProvider(normalized.JVM.PreferredMode)
normalized.JVM.PreferredMode = selectedMode
provider, err := newJVMProvider(selectedMode)
if err != nil {
return connection.ConnectionConfig{}, nil, err
}
@@ -62,6 +71,69 @@ func (a *App) JVMGetValue(cfg connection.ConnectionConfig, resourcePath string)
return connection.QueryResult{Success: true, Data: value}
}
func (a *App) JVMPreviewChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult {
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: preview}
}
func (a *App) JVMApplyChange(cfg connection.ConnectionConfig, req jvm.ChangeRequest) connection.QueryResult {
normalized, provider, err := resolveJVMProviderForMode(cfg, req.ProviderMode)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
preview, err := jvm.BuildChangePreview(a.ctx, provider, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if !preview.Allowed {
message := strings.TrimSpace(preview.BlockingReason)
if message == "" {
message = "当前变更被 Guard 拦截"
}
return connection.QueryResult{Success: false, Message: message}
}
result, err := provider.ApplyChange(a.ctx, normalized, req)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
if err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).Append(jvm.AuditRecord{
ConnectionID: normalized.ID,
ProviderMode: normalized.JVM.PreferredMode,
ResourceID: req.ResourceID,
Action: req.Action,
Reason: req.Reason,
Result: result.Status,
}); err != nil {
if strings.TrimSpace(result.Message) == "" {
result.Message = "变更已执行,但审计记录写入失败: " + err.Error()
} else {
result.Message += ";审计记录写入失败: " + err.Error()
}
}
return connection.QueryResult{Success: true, Data: result}
}
func (a *App) JVMListAuditRecords(connectionID string, limit int) connection.QueryResult {
records, err := jvm.NewAuditStore(filepath.Join(a.auditRootDir(), "jvm_audit.jsonl")).List(connectionID, limit)
if err != nil {
return connection.QueryResult{Success: false, Message: err.Error()}
}
return connection.QueryResult{Success: true, Data: records}
}
func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.QueryResult {
normalized, err := jvm.NormalizeConnectionConfig(cfg)
if err != nil {
@@ -95,3 +167,10 @@ func (a *App) JVMProbeCapabilities(cfg connection.ConnectionConfig) connection.Q
return connection.QueryResult{Success: true, Data: items}
}
func (a *App) auditRootDir() string {
if strings.TrimSpace(a.configDir) != "" {
return a.configDir
}
return resolveAppConfigDir()
}

View File

@@ -3,6 +3,8 @@ package app
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -11,14 +13,18 @@ import (
)
type fakeJVMProvider struct {
testErr error
probe []jvm.Capability
probeErr error
list []jvm.ResourceSummary
listErr error
value jvm.ValueSnapshot
valueErr error
apply jvm.ApplyResult
testErr error
probe []jvm.Capability
probeErr error
list []jvm.ResourceSummary
listErr error
value jvm.ValueSnapshot
valueErr error
preview jvm.ChangePreview
previewSet bool
previewErr error
apply jvm.ApplyResult
applyErr error
}
func (f fakeJVMProvider) Mode() string { return jvm.ModeJMX }
@@ -35,10 +41,13 @@ func (f fakeJVMProvider) GetValue(context.Context, connection.ConnectionConfig,
return f.value, f.valueErr
}
func (f fakeJVMProvider) PreviewChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ChangePreview, error) {
return jvm.ChangePreview{Allowed: true, Summary: "preview"}, nil
if !f.previewSet {
return jvm.ChangePreview{Allowed: true, Summary: "preview", RiskLevel: "low"}, f.previewErr
}
return f.preview, f.previewErr
}
func (f fakeJVMProvider) ApplyChange(context.Context, connection.ConnectionConfig, jvm.ChangeRequest) (jvm.ApplyResult, error) {
return f.apply, nil
return f.apply, f.applyErr
}
func swapJVMProviderFactory(factory func(mode string) (jvm.Provider, error)) func() {
@@ -321,3 +330,181 @@ func TestJVMGetValueReturnsProviderPayload(t *testing.T) {
t.Fatalf("expected unit metadata %q, got %#v", "MiB", snapshot.Metadata)
}
}
func TestJVMApplyChangeReturnsProviderPayload(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
Message: "ok",
UpdatedValue: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "ready",
},
},
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
result, ok := res.Data.(jvm.ApplyResult)
if !ok {
t.Fatalf("expected apply result, got %#v", res.Data)
}
if result.Status != "applied" {
t.Fatalf("expected status %q, got %#v", "applied", result)
}
if result.UpdatedValue.ResourceID != "/cache/orders" {
t.Fatalf("expected updated resource id %q, got %#v", "/cache/orders", result.UpdatedValue)
}
}
func TestJVMPreviewChangeRejectsModeOutsideAllowedModes(t *testing.T) {
app := NewAppWithSecretStore(nil)
res := app.JVMPreviewChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
PreferredMode: "endpoint",
AllowedModes: []string{"endpoint"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
})
if res.Success {
t.Fatalf("expected preview request to be rejected, got %+v", res)
}
if !strings.Contains(res.Message, "不允许使用") {
t.Fatalf("expected disallowed mode error, got %+v", res)
}
}
func TestJVMListAuditRecordsReturnsLatestRecords(t *testing.T) {
app := NewAppWithSecretStore(nil)
app.configDir = t.TempDir()
store := jvm.NewAuditStore(filepath.Join(app.configDir, "jvm_audit.jsonl"))
for _, record := range []jvm.AuditRecord{
{Timestamp: 100, ConnectionID: "conn-orders", ProviderMode: "jmx", ResourceID: "/cache/orders", Action: "put", Reason: "first", Result: "applied"},
{Timestamp: 200, ConnectionID: "conn-other", ProviderMode: "jmx", ResourceID: "/cache/other", Action: "put", Reason: "other", Result: "applied"},
{Timestamp: 300, ConnectionID: "conn-orders", ProviderMode: "jmx", ResourceID: "/cache/orders", Action: "put", Reason: "latest", Result: "applied"},
} {
if err := store.Append(record); err != nil {
t.Fatalf("Append returned error: %v", err)
}
}
res := app.JVMListAuditRecords("conn-orders", 1)
if !res.Success {
t.Fatalf("expected success, got %+v", res)
}
records, ok := res.Data.([]jvm.AuditRecord)
if !ok {
t.Fatalf("expected audit record slice, got %#v", res.Data)
}
if len(records) != 1 {
t.Fatalf("expected one audit record, got %#v", records)
}
if records[0].Timestamp != 300 {
t.Fatalf("expected latest timestamp %d, got %#v", 300, records[0])
}
}
func TestJVMApplyChangeSurfacesAuditWriteFailure(t *testing.T) {
app := NewAppWithSecretStore(nil)
tempDir := t.TempDir()
blockerPath := filepath.Join(tempDir, "audit-blocker")
if err := os.WriteFile(blockerPath, []byte("blocker"), 0o600); err != nil {
t.Fatalf("WriteFile returned error: %v", err)
}
app.configDir = blockerPath
readOnly := false
restore := swapJVMProviderFactory(func(mode string) (jvm.Provider, error) {
return fakeJVMProvider{
value: jvm.ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
apply: jvm.ApplyResult{
Status: "applied",
},
}, nil
})
defer restore()
res := app.JVMApplyChange(connection.ConnectionConfig{
Type: "jvm",
ID: "conn-orders",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: "jmx",
AllowedModes: []string{"jmx"},
},
}, jvm.ChangeRequest{
ProviderMode: "jmx",
ResourceID: "/cache/orders",
Action: "put",
Reason: "repair cache",
Payload: map[string]any{
"status": "ready",
},
})
if !res.Success {
t.Fatalf("expected success despite audit failure, got %+v", res)
}
result, ok := res.Data.(jvm.ApplyResult)
if !ok {
t.Fatalf("expected apply result, got %#v", res.Data)
}
if !strings.Contains(result.Message, "审计记录写入失败") {
t.Fatalf("expected audit failure message, got %#v", result)
}
}

View File

@@ -0,0 +1,85 @@
package jvm
import (
"bufio"
"encoding/json"
"errors"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type AuditStore struct {
path string
}
func NewAuditStore(path string) *AuditStore {
return &AuditStore{path: path}
}
func (s *AuditStore) Append(record AuditRecord) error {
if strings.TrimSpace(s.path) == "" {
return errors.New("audit store path is empty")
}
if record.Timestamp == 0 {
record.Timestamp = time.Now().UnixMilli()
}
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
file, err := os.OpenFile(s.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
if err != nil {
return err
}
defer file.Close()
return json.NewEncoder(file).Encode(record)
}
func (s *AuditStore) List(connectionID string, limit int) ([]AuditRecord, error) {
if strings.TrimSpace(s.path) == "" {
return nil, errors.New("audit store path is empty")
}
file, err := os.Open(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return []AuditRecord{}, nil
}
return nil, err
}
defer file.Close()
normalizedConnectionID := strings.TrimSpace(connectionID)
records := make([]AuditRecord, 0, 16)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var record AuditRecord
if err := json.Unmarshal([]byte(line), &record); err != nil {
return nil, err
}
if normalizedConnectionID != "" && strings.TrimSpace(record.ConnectionID) != normalizedConnectionID {
continue
}
records = append(records, record)
}
if err := scanner.Err(); err != nil {
return nil, err
}
sort.SliceStable(records, func(i, j int) bool {
return records[i].Timestamp > records[j].Timestamp
})
if limit > 0 && len(records) > limit {
return records[:limit], nil
}
return records, nil
}

View File

@@ -0,0 +1,30 @@
package jvm
import (
"path/filepath"
"testing"
)
func TestAuditStoreListFiltersAndReturnsLatestFirst(t *testing.T) {
store := NewAuditStore(filepath.Join(t.TempDir(), "jvm_audit.jsonl"))
for _, record := range []AuditRecord{
{Timestamp: 100, ConnectionID: "conn-orders", ProviderMode: ModeJMX, ResourceID: "/cache/orders", Action: "put", Reason: "first", Result: "applied"},
{Timestamp: 200, ConnectionID: "conn-other", ProviderMode: ModeJMX, ResourceID: "/cache/other", Action: "put", Reason: "other", Result: "applied"},
{Timestamp: 300, ConnectionID: "conn-orders", ProviderMode: ModeJMX, ResourceID: "/cache/orders", Action: "put", Reason: "latest", Result: "applied"},
} {
if err := store.Append(record); err != nil {
t.Fatalf("Append returned error: %v", err)
}
}
records, err := store.List("conn-orders", 1)
if err != nil {
t.Fatalf("List returned error: %v", err)
}
if len(records) != 1 {
t.Fatalf("expected one record, got %#v", records)
}
if records[0].Timestamp != 300 {
t.Fatalf("expected latest record first, got %#v", records[0])
}
}

View File

@@ -40,6 +40,24 @@ func NormalizeConnectionConfig(raw connection.ConnectionConfig) (connection.Conn
return cfg, nil
}
func ResolveProviderMode(raw connection.ConnectionConfig, requestedMode string) (connection.ConnectionConfig, string, error) {
cfg, err := NormalizeConnectionConfig(raw)
if err != nil {
return connection.ConnectionConfig{}, "", err
}
selectedMode := strings.ToLower(strings.TrimSpace(requestedMode))
if selectedMode == "" {
selectedMode = cfg.JVM.PreferredMode
}
if !containsMode(cfg.JVM.AllowedModes, selectedMode) {
return connection.ConnectionConfig{}, "", fmt.Errorf("当前连接不允许使用 %q 模式", selectedMode)
}
cfg.JVM.PreferredMode = selectedMode
return cfg, selectedMode, nil
}
func normalizeModes(input []string) []string {
if len(input) == 0 {
return []string{ModeJMX}

View File

@@ -91,3 +91,17 @@ func TestNormalizeConnectionConfigDefaultsJMXPortTo9010WhenPortsMissing(t *testi
t.Fatalf("expected JMX port default 9010, got %d", got.JVM.JMX.Port)
}
}
func TestResolveProviderModeRejectsDisallowedRequestedMode(t *testing.T) {
_, _, err := ResolveProviderMode(connection.ConnectionConfig{
Type: "jvm",
Host: "orders.internal",
JVM: connection.JVMConfig{
AllowedModes: []string{ModeEndpoint},
PreferredMode: ModeEndpoint,
},
}, ModeJMX)
if err == nil {
t.Fatalf("expected disallowed requested mode to fail")
}
}

144
internal/jvm/guard.go Normal file
View File

@@ -0,0 +1,144 @@
package jvm
import (
"context"
"fmt"
"strings"
"GoNavi-Wails/internal/connection"
)
// BuildChangePreview builds a guarded preview for JVM mutations.
// It always produces a local before/after baseline and, when writes are still
// allowed, merges provider preview details on top of that baseline.
func BuildChangePreview(
ctx context.Context,
provider Provider,
cfg connection.ConnectionConfig,
req ChangeRequest,
) (ChangePreview, error) {
normalized, err := NormalizeConnectionConfig(cfg)
if err != nil {
return ChangePreview{}, err
}
resourceID := strings.TrimSpace(req.ResourceID)
if resourceID == "" {
return ChangePreview{}, fmt.Errorf("resource id is required")
}
action := strings.TrimSpace(req.Action)
if action == "" {
return ChangePreview{}, fmt.Errorf("action is required")
}
before := ValueSnapshot{
ResourceID: resourceID,
Kind: "resource",
Format: "json",
}
if provider != nil {
if snapshot, snapshotErr := provider.GetValue(ctx, normalized, resourceID); snapshotErr == nil {
before = snapshot
if strings.TrimSpace(before.ResourceID) == "" {
before.ResourceID = resourceID
}
if strings.TrimSpace(before.Format) == "" {
before.Format = "json"
}
}
}
after := before
after.ResourceID = resourceID
if req.ExpectedVersion != "" {
after.Version = req.ExpectedVersion
}
if req.Payload != nil {
after.Value = req.Payload
}
preview := ChangePreview{
Allowed: true,
Summary: fmt.Sprintf("%s -> %s", resourceID, action),
RiskLevel: "medium",
Before: before,
After: after,
}
if normalized.JVM.ReadOnly != nil && *normalized.JVM.ReadOnly {
preview.Allowed = false
preview.RiskLevel = "high"
preview.BlockingReason = "当前连接为只读,禁止写入"
}
if normalized.JVM.Environment == EnvPROD {
preview.RequiresConfirmation = true
if preview.RiskLevel == "" || preview.RiskLevel == "low" {
preview.RiskLevel = "medium"
}
}
if !preview.Allowed || provider == nil {
return preview, nil
}
providerPreview, err := provider.PreviewChange(ctx, normalized, req)
if err != nil {
return ChangePreview{}, err
}
if strings.TrimSpace(providerPreview.Summary) != "" {
preview.Summary = providerPreview.Summary
}
if strings.TrimSpace(providerPreview.RiskLevel) != "" {
preview.RiskLevel = providerPreview.RiskLevel
}
if providerPreview.RequiresConfirmation {
preview.RequiresConfirmation = true
}
if !providerPreview.Allowed {
preview.Allowed = false
}
if strings.TrimSpace(providerPreview.BlockingReason) != "" {
preview.BlockingReason = providerPreview.BlockingReason
}
if hasSnapshotOverride(providerPreview.Before) {
preview.Before = mergeValueSnapshot(preview.Before, providerPreview.Before)
}
if hasSnapshotOverride(providerPreview.After) {
preview.After = mergeValueSnapshot(preview.After, providerPreview.After)
}
return preview, nil
}
func hasSnapshotOverride(snapshot ValueSnapshot) bool {
return strings.TrimSpace(snapshot.ResourceID) != "" ||
strings.TrimSpace(snapshot.Kind) != "" ||
strings.TrimSpace(snapshot.Format) != "" ||
strings.TrimSpace(snapshot.Version) != "" ||
snapshot.Value != nil ||
snapshot.Metadata != nil
}
func mergeValueSnapshot(base ValueSnapshot, override ValueSnapshot) ValueSnapshot {
merged := base
if strings.TrimSpace(override.ResourceID) != "" {
merged.ResourceID = override.ResourceID
}
if strings.TrimSpace(override.Kind) != "" {
merged.Kind = override.Kind
}
if strings.TrimSpace(override.Format) != "" {
merged.Format = override.Format
}
if strings.TrimSpace(override.Version) != "" {
merged.Version = override.Version
}
if override.Value != nil {
merged.Value = override.Value
}
if override.Metadata != nil {
merged.Metadata = override.Metadata
}
return merged
}

200
internal/jvm/guard_test.go Normal file
View File

@@ -0,0 +1,200 @@
package jvm
import (
"context"
"errors"
"strings"
"testing"
"GoNavi-Wails/internal/connection"
)
type fakeGuardProvider struct {
before ValueSnapshot
beforeErr error
preview ChangePreview
previewErr error
apply ApplyResult
applyErr error
}
func (f fakeGuardProvider) Mode() string { return ModeJMX }
func (f fakeGuardProvider) TestConnection(context.Context, connection.ConnectionConfig) error {
return nil
}
func (f fakeGuardProvider) ProbeCapabilities(context.Context, connection.ConnectionConfig) ([]Capability, error) {
return nil, nil
}
func (f fakeGuardProvider) ListResources(context.Context, connection.ConnectionConfig, string) ([]ResourceSummary, error) {
return nil, nil
}
func (f fakeGuardProvider) GetValue(context.Context, connection.ConnectionConfig, string) (ValueSnapshot, error) {
return f.before, f.beforeErr
}
func (f fakeGuardProvider) PreviewChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ChangePreview, error) {
return f.preview, f.previewErr
}
func (f fakeGuardProvider) ApplyChange(context.Context, connection.ConnectionConfig, ChangeRequest) (ApplyResult, error) {
return f.apply, f.applyErr
}
func TestPreviewChangeBlocksReadOnlyConnection(t *testing.T) {
readOnly := true
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-readonly",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders",
Action: "put",
Reason: "fix cache",
Payload: map[string]any{
"status": "ready",
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if preview.Allowed {
t.Fatalf("expected preview to be blocked, got %#v", preview)
}
if preview.BlockingReason == "" || !strings.Contains(preview.BlockingReason, "只读") {
t.Fatalf("expected readonly blocking reason, got %#v", preview)
}
if preview.Before.ResourceID != "/cache/orders" {
t.Fatalf("expected before snapshot resource id to be preserved, got %#v", preview.Before)
}
if preview.After.ResourceID != "/cache/orders" {
t.Fatalf("expected after snapshot resource id to be preserved, got %#v", preview.After)
}
}
func TestPreviewChangeReturnsProviderPreviewErrorWhenWriteAllowed(t *testing.T) {
readOnly := false
_, err := BuildChangePreview(context.Background(), fakeGuardProvider{
previewErr: errors.New("preview not implemented"),
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders",
Action: "put",
Reason: "fix cache",
Payload: map[string]any{
"status": "ready",
},
})
if err == nil || !strings.Contains(err.Error(), "preview not implemented") {
t.Fatalf("expected provider preview error, got %v", err)
}
}
func TestPreviewChangeMarksProdWritesAsConfirmationRequired(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
preview: ChangePreview{
Allowed: true,
Summary: "provider preview",
RiskLevel: "low",
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-prod",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
Environment: EnvPROD,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders",
Action: "put",
Reason: "fix cache",
Payload: map[string]any{
"status": "ready",
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if !preview.RequiresConfirmation {
t.Fatalf("expected prod preview to require confirmation, got %#v", preview)
}
if preview.RiskLevel != "low" {
t.Fatalf("expected provider risk level to be preserved, got %#v", preview)
}
}
func TestPreviewChangeMergesProviderSnapshotsWithoutDroppingDefaults(t *testing.T) {
readOnly := false
preview, err := BuildChangePreview(context.Background(), fakeGuardProvider{
before: ValueSnapshot{
ResourceID: "/cache/orders",
Kind: "entry",
Format: "json",
Value: map[string]any{
"status": "stale",
},
},
preview: ChangePreview{
Allowed: true,
Summary: "provider preview",
RiskLevel: "medium",
Before: ValueSnapshot{
Value: map[string]any{
"status": "provider-before",
},
},
After: ValueSnapshot{
Value: map[string]any{
"status": "provider-after",
},
},
},
}, connection.ConnectionConfig{
Type: "jvm",
ID: "conn-writable",
Host: "orders.internal",
JVM: connection.JVMConfig{
ReadOnly: &readOnly,
PreferredMode: ModeJMX,
AllowedModes: []string{ModeJMX},
},
}, ChangeRequest{
ProviderMode: ModeJMX,
ResourceID: "/cache/orders",
Action: "put",
Reason: "fix cache",
Payload: map[string]any{
"status": "ready",
},
})
if err != nil {
t.Fatalf("BuildChangePreview returned error: %v", err)
}
if preview.Before.ResourceID != "/cache/orders" || preview.Before.Format != "json" {
t.Fatalf("expected before snapshot defaults to be preserved, got %#v", preview.Before)
}
if preview.After.ResourceID != "/cache/orders" || preview.After.Format != "json" {
t.Fatalf("expected after snapshot defaults to be preserved, got %#v", preview.After)
}
}