mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-20 04:11:56 +08:00
✨ feat(jvm): 增加 JVM 写入预览与审计
- 打通 JVM 变更预览、执行确认与审计记录链路 - 增加 Guard 校验、模式约束与审计写入失败回传 - 补齐审计页签、预览弹窗和 Task 5 回归覆盖
This commit is contained in:
@@ -26,7 +26,7 @@
|
||||
- [x] 阶段 2(影响分析):完成
|
||||
- [x] 阶段 3(方案设计):完成(已形成正式设计文档)
|
||||
- [x] 阶段 4(实施计划):完成(已形成正式实施计划)
|
||||
- [ ] 阶段 5(实现与自检):进行中(Task 1、Task 2、Task 3、Task 4 已完成并通过回归)
|
||||
- [ ] 阶段 5(实现与自检):进行中(Task 1 至 Task 5 已完成并通过回归,Task 6/7 待继续)
|
||||
- [ ] 阶段 6(评审与交付):
|
||||
- [ ] 阶段 7(发布与观察):
|
||||
|
||||
@@ -43,11 +43,12 @@
|
||||
- 已完成 Task 1:JVM 共享契约与配置归一化
|
||||
- 已完成 Task 2:Provider 注册、连接测试与能力探测 API
|
||||
- 已完成 Task 3:JVM 连接表单、图标与展示文案接入
|
||||
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||
- 已完成 Task 4:只读资源浏览与 JVM Tab
|
||||
- 已完成 Task 5:写入预览、Guard 和审计记录
|
||||
- 进行中:
|
||||
- Task 5:Guard/Audit、AI 结构化计划等后续任务准备
|
||||
- Task 6:AI 结构化变更计划
|
||||
- 待处理:
|
||||
- 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
|
||||
|
||||
204
frontend/src/components/JVMAuditViewer.tsx
Normal file
204
frontend/src/components/JVMAuditViewer.tsx
Normal 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;
|
||||
@@ -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()}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
127
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal file
127
frontend/src/components/jvm/JVMChangePreviewModal.tsx
Normal 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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
85
internal/jvm/audit_store.go
Normal file
85
internal/jvm/audit_store.go
Normal 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
|
||||
}
|
||||
30
internal/jvm/audit_store_test.go
Normal file
30
internal/jvm/audit_store_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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
144
internal/jvm/guard.go
Normal 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
200
internal/jvm/guard_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user