From 99e114c3558fdbd09b28fc5f79d0d66cf98fea74 Mon Sep 17 00:00:00 2001
From: Gustavo Carvalho
Date: Mon, 5 Oct 2026 07:40:53 -0300
Subject: [PATCH 1/2] feat(agent-config): save preview with diff rows and
safety tags
Layer 13 of 21 in the stacked split of compliance-framework/ui#318.
Co-Authored-By: Claude Opus 5.5
---
.../config/__tests__/SavePreviewPanel.spec.ts | 243 +++++++++++
.../agents/config/editor/DiffRows.vue | 80 ++++
.../agents/config/editor/SafetyTag.vue | 32 ++
.../agents/config/editor/SavePreviewPanel.vue | 411 ++++++++++++++++++
4 files changed, 766 insertions(+)
create mode 100644 src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
create mode 100644 src/components/agents/config/editor/DiffRows.vue
create mode 100644 src/components/agents/config/editor/SafetyTag.vue
create mode 100644 src/components/agents/config/editor/SavePreviewPanel.vue
diff --git a/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts b/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
new file mode 100644
index 00000000..05d7df24
--- /dev/null
+++ b/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it, vi } from 'vitest';
+import { mount } from '@vue/test-utils';
+import type { ConfigPreview, InstancePreview } from '@/types/agent-config';
+import {
+ detailFor,
+ error422,
+ instanceIds,
+ instancesMixed,
+ previewMixed,
+ previewStandalone,
+} from '@/composables/agent-config/__tests__/fixtures';
+import PrimeDialog from 'primevue/dialog';
+import SavePreviewPanel from '../editor/SavePreviewPanel.vue';
+import DiffRows from '../editor/DiffRows.vue';
+import { safetyForRow } from '../editor/review';
+import { globalWith, piniaWith, ADMIN } from './helpers';
+
+// The async CodeMirror wrappers are replaced by synchronous stubs.
+vi.mock('@/components/code-editor', () => import('./codeEditorMock'));
+
+const details = new Map(
+ instancesMixed.items
+ .filter((i) => i.reportedAt)
+ .map((i) => [i.instanceId, detailFor(i, {})]),
+);
+
+function mountPanel(
+ preview: ConfigPreview,
+ extra: Record = {},
+) {
+ return mount(SavePreviewPanel, {
+ props: {
+ preview,
+ instanceDetails: details,
+ currentOverlay: {},
+ draftOverlay: { plugins: { 'local-ssh': { schedule: '@hourly' } } },
+ baseRevision: 7,
+ ...extra,
+ },
+ global: globalWith(piniaWith(ADMIN)),
+ });
+}
+
+const inst = (over: Partial): InstancePreview => ({
+ instanceId: instanceIds.a,
+ hostname: 'ip-a',
+ mode: 'apply_safe',
+ stale: false,
+ validated: true,
+ effective: null,
+ errors: [],
+ warnings: [],
+ changes: [],
+ willApply: true,
+ ...over,
+});
+const clean = (instances: InstancePreview[]): ConfigPreview => ({
+ desiredRevision: 7,
+ standalone: false,
+ overlayErrors: [],
+ instances,
+});
+const saveButton = (w: ReturnType) =>
+ w.find('[data-test="save-config"]');
+
+describe('SavePreviewPanel (U2.5)', () => {
+ it('summarises will-apply with willApplyReason labels and offending changes', () => {
+ const w = mountPanel(previewMixed);
+ expect(w.find('[data-test="apply-summary"]').text()).toContain(
+ '0 of 3 instances will apply',
+ );
+ const lines = w.findAll('[data-test="not-applying"]').map((l) => l.text());
+ expect(lines[0]).toContain('Needs apply_all for some changes');
+ expect(lines[0]).toContain('/plugins/local-ssh/source');
+ expect(lines[0]).toContain('Untrusted source');
+ expect(lines[2]).toContain('Report-only mode');
+ });
+
+ it('tags rows by the most specific change prefix', () => {
+ const changes = [
+ { path: '/plugins/a', safety: 'unsafe' as const, reason: 'x' },
+ {
+ path: '/plugins/a/schedule',
+ safety: 'safe' as const,
+ reason: 'logging',
+ },
+ ];
+ expect(safetyForRow('/plugins/a/schedule', changes)).toBe('safe');
+ expect(safetyForRow('/plugins/a/config/k', changes)).toBe('unsafe');
+ expect(safetyForRow('/verbosity', changes)).toBe('no-effect');
+ expect(
+ safetyForRow('/plugins', [
+ { path: '/plugins/b/source', safety: 'forbidden', reason: 'y' },
+ ]),
+ ).toBe('forbidden');
+ });
+
+ it('blocks saving on overlay errors / error policy errors / validated instance errors only', () => {
+ // previewMixed: overlay error + validated ip-d error → blocked.
+ expect(
+ saveButton(mountPanel(previewMixed)).attributes('disabled'),
+ ).toBeDefined();
+
+ // Errors only on a non-validated instance → warnings, not blocking.
+ const nonValidated = clean([
+ inst({
+ validated: false,
+ mode: 'report',
+ errors: [{ path: '/x', message: 'bad' }],
+ }),
+ ]);
+ const w = mountPanel(nonValidated);
+ expect(saveButton(w).attributes('disabled')).toBeUndefined();
+ expect(w.find('[data-test="instance-error-nonblocking"]').text()).toContain(
+ 'Not validated on save',
+ );
+
+ const validated = clean([
+ inst({ errors: [{ path: '/x', message: 'bad' }] }),
+ ]);
+ expect(
+ saveButton(mountPanel(validated)).attributes('disabled'),
+ ).toBeDefined();
+ });
+
+ it('R59 file warnings do not block', () => {
+ const p = clean([
+ inst({
+ warnings: [{ path: '/plugins/y/schedule', message: 'file cron' }],
+ }),
+ ]);
+ const w = mountPanel(p);
+ expect(saveButton(w).attributes('disabled')).toBeUndefined();
+ expect(w.find('[data-test="instance-warnings"]').text()).toContain(
+ 'does not block',
+ );
+ expect(saveButton(w).text()).toBe('Save as r8');
+ });
+
+ it('shows the standalone banner and an overlay diff', () => {
+ const w = mountPanel(previewStandalone);
+ expect(w.find('[data-test="standalone"]').exists()).toBe(true);
+ expect(w.find('[data-test="apply-summary"]').exists()).toBe(false);
+ expect(w.find('[data-test="diff-rows"]').text()).toContain('/plugins');
+ });
+
+ it('diffs merge(base, current) vs merge(base, draft) per instance with safety tags', () => {
+ const p = clean([
+ inst({
+ changes: [
+ {
+ path: '/plugins/local-ssh/schedule',
+ safety: 'safe',
+ reason: 'logging',
+ },
+ ],
+ }),
+ ]);
+ const w = mountPanel(p);
+ const row = w.find('[data-path="/plugins/local-ssh/schedule"]');
+ expect(row.text()).toContain('*/5 * * * *');
+ expect(row.text()).toContain('@hourly');
+ expect(row.find('[data-safety="safe"]').exists()).toBe(true);
+ });
+
+ it('falls back to the change list when the base is unavailable', () => {
+ const p = clean([
+ inst({
+ instanceId: 'unknown-id',
+ changes: [{ path: '/v', safety: 'safe', reason: 'logging' }],
+ }),
+ ]);
+ expect(mountPanel(p).find('[data-test="changes-fallback"]').exists()).toBe(
+ true,
+ );
+ });
+
+ it('treats errors as blocking on non-stale instances when validated is absent (older API)', () => {
+ const p = clean([
+ inst({ validated: undefined, errors: [{ path: '/x', message: 'bad' }] }),
+ ]);
+ const w = mountPanel(p);
+ expect(saveButton(w).attributes('disabled')).toBeDefined();
+ expect(w.find('[data-test="legacy-validation"]').exists()).toBe(true);
+ });
+
+ it('maps a 422 body onto the lists and instance panels', () => {
+ const w = mountPanel(clean([inst({})]), { saveErrors: error422.errors });
+ expect(w.find('[data-test="overlay-error"]').text()).toContain('/api');
+ expect(
+ w.find(`[data-test="instance-panel-${instanceIds.a}"]`).text(),
+ ).toContain('invalid cron');
+ expect(saveButton(w).attributes('disabled')).toBeDefined();
+ });
+
+ it('emits save with the comment', async () => {
+ const w = mountPanel(clean([inst({})]));
+ await w.find('[data-test="save-comment"]').setValue('why');
+ await saveButton(w).trigger('click');
+ expect(w.emitted('save')?.[0]).toEqual(['why']);
+ });
+
+ it('disables Back while saving', () => {
+ const w = mountPanel(clean([inst({})]), { saving: true });
+ expect(
+ w.find('[data-test="review-back"]').attributes('disabled'),
+ ).toBeDefined();
+ expect(saveButton(w).attributes('disabled')).toBeDefined();
+ });
+
+ it('reports its nested diff dialog opening and closing (Esc gate)', async () => {
+ const w = mountPanel(clean([inst({})]));
+ const row = { path: '/x', kind: 'changed', before: 'a', after: 'b' };
+ w.findComponent(DiffRows).vm.$emit('open-diff', row);
+ await w.vm.$nextTick();
+ expect(w.emitted('childOpen')).toEqual([[true]]);
+ const diff = w
+ .findAllComponents(PrimeDialog)
+ .find((d) => d.props('header') === '/x')!;
+ diff.vm.$emit('update:visible', false);
+ await w.vm.$nextTick();
+ expect(w.emitted('childOpen')).toEqual([[true], [false]]);
+ });
+});
+
+describe('review rows for policy_data arrays', () => {
+ it('shows a whole-array write as element-level rows', () => {
+ const w = mountPanel(clean([inst({})]), {
+ currentOverlay: {
+ plugins: { 'local-ssh': { policy_data: { users: ['a', 'b'] } } },
+ },
+ draftOverlay: {
+ plugins: { 'local-ssh': { policy_data: { users: ['a', 'B', 'c'] } } },
+ },
+ });
+ const paths = w
+ .findAll('[data-test="diff-rows"] tr[data-path]')
+ .map((r) => r.attributes('data-path'));
+ expect(paths).toContain('/plugins/local-ssh/policy_data/users/1');
+ expect(paths).toContain('/plugins/local-ssh/policy_data/users/2');
+ expect(paths).not.toContain('/plugins/local-ssh/policy_data/users');
+ });
+});
diff --git a/src/components/agents/config/editor/DiffRows.vue b/src/components/agents/config/editor/DiffRows.vue
new file mode 100644
index 00000000..1934e3eb
--- /dev/null
+++ b/src/components/agents/config/editor/DiffRows.vue
@@ -0,0 +1,80 @@
+
+
+
+
+ No differences.
+
+
+ {{ row.path }}
+
+
+
+ View diff
+
+
+
+
+ →
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/agents/config/editor/SafetyTag.vue b/src/components/agents/config/editor/SafetyTag.vue
new file mode 100644
index 00000000..5bb40c21
--- /dev/null
+++ b/src/components/agents/config/editor/SafetyTag.vue
@@ -0,0 +1,32 @@
+
+ {{ label }}
+
+
+
diff --git a/src/components/agents/config/editor/SavePreviewPanel.vue b/src/components/agents/config/editor/SavePreviewPanel.vue
new file mode 100644
index 00000000..7201b46d
--- /dev/null
+++ b/src/components/agents/config/editor/SavePreviewPanel.vue
@@ -0,0 +1,411 @@
+
+
+
+
+
+ Problems
+
+
+
+ {{ e.path || '/' }}
+
+
+
+ — {{ e.message }}
+
+
+
+
+
+ No agent instance has reported a configuration yet, so only overlay-level
+ checks ran. Agents validate it again when they fetch it.
+
+
+
+
+
+ {{ applying }} of {{ preview.instances.length }} instances will apply
+ this revision.
+
+
+
+ {{
+ inst.hostname || inst.instanceId.slice(0, 8)
+ }}
+ · {{ inst.mode || 'unknown mode' }} ·
+
+
+
+ {{ c.path }} ·
+
+
+ ·
+ {{ c.value }}
+
+
+
+
+
+ Changes are classified against each instance's local file, so this list
+ can include changes saved in earlier revisions.
+
+
+ This API does not say which instances a save validates against; errors
+ on every non-stale instance are treated as blocking.
+
+
+
+
+
+
+
+
+
+
+ {{
+ inst.preview.hostname || inst.preview.instanceId.slice(0, 8)
+ }}
+ {{
+ inst.preview.mode || 'no mode'
+ }}
+ stale
+
+ {{ inst.preview.willApply ? 'will apply' : 'will not apply' }}
+
+ blocks save
+
+
+
+
+
+ {{ e.path || '/' }} —
+ {{ e.message }}
+
+ (Not validated on save: stale or not in an apply mode)
+
+
+
+
+
+ {{ w.path || '/' }} —
+ {{ w.message }}
+ (in this host's file; does not block)
+
+
+
+
+
+
+ {{ yamlOpen.has(inst.preview.instanceId) ? 'Hide' : 'Show' }} YAML
+ diff
+
+
+
+
+
+ The file of this instance is unavailable; classified changes:
+
+
+
+
+
+
+
+
+
+ Comment (optional)
+
+
+
+ {{ comment.length }}/{{ LIMITS.commentChars }}
+
+
+
{{ backLabel }}
+
+
+ {{ saving ? 'Saving…' : `Save as r${baseRevision + 1}` }}
+
+
+
+
+
+
+
+
+
+
+
+
From 7b9959637e89da9722a5db6c23b4009ba9fc591a Mon Sep 17 00:00:00 2001
From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com>
Date: Tue, 6 Oct 2026 08:17:21 -0300
Subject: [PATCH 2/2] fix(agent-config): show what the save preview left out
- the apply summary says how many instances the API's bounded preview
omitted (omitted-instances): a save still validates against them
- 422 errors of instances without a preview panel (an omitted one, or
one that reported after the preview) are listed under "Other
instances": they block Save, so they must be visible
Co-Authored-By: Claude Opus 5.5
---
.../config/__tests__/SavePreviewPanel.spec.ts | 57 +++++++++++++++++
.../agents/config/editor/SavePreviewPanel.vue | 61 +++++++++++++++++++
2 files changed, 118 insertions(+)
diff --git a/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts b/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
index 05d7df24..491eb6f1 100644
--- a/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
+++ b/src/components/agents/config/__tests__/SavePreviewPanel.spec.ts
@@ -241,3 +241,60 @@ describe('review rows for policy_data arrays', () => {
expect(paths).not.toContain('/plugins/local-ssh/policy_data/users');
});
});
+
+describe('instances the preview did not include', () => {
+ // What the API sends when it bounds the preview (configPreviewResponse).
+ const bounded: ConfigPreview = {
+ ...clean([inst({})]),
+ omittedInstances: 60,
+ };
+ const omittedId = 'ffffffff-0000-4000-8000-0000000000ff';
+
+ it('says how many instances were left out of the preview', () => {
+ const w = mountPanel(bounded, { draftOverlay: { verbosity: 2 } });
+ const summary = w.find('[data-test="apply-summary"]').text();
+ expect(summary).toContain('1 of 1 instances will apply');
+ expect(w.find('[data-test="omitted-instances"]').text()).toContain(
+ '60 more instances were not previewed; a save still validates against them',
+ );
+ });
+
+ it('says nothing when the preview covers every instance', () => {
+ const w = mountPanel(clean([inst({})]));
+ expect(w.find('[data-test="apply-summary"]').exists()).toBe(true);
+ expect(w.find('[data-test="omitted-instances"]').exists()).toBe(false);
+ });
+
+ it('shows a 422 error for an instance that is not in the preview', () => {
+ const w = mountPanel(bounded, {
+ draftOverlay: { verbosity: 2 },
+ saveErrors: {
+ body: 'invalid',
+ instances: [
+ {
+ 'instance-id': omittedId,
+ hostname: 'ip-omitted',
+ errors: [{ path: '/verbosity', message: 'BOOM-not-allowed' }],
+ },
+ {
+ 'instance-id': instanceIds.a,
+ hostname: 'ip-a',
+ errors: [{ path: '/verbosity', message: 'in-its-panel' }],
+ },
+ ],
+ },
+ });
+ // Save is blocked by that error...
+ expect(saveButton(w).attributes('disabled')).toBeDefined();
+ // ...so the user must be able to see it, with the host it is about.
+ const other = w.find(`[data-test="other-instance-${omittedId}"]`);
+ expect(other.exists()).toBe(true);
+ expect(other.text()).toContain('ip-omitted');
+ expect(other.text()).toContain('BOOM-not-allowed');
+ // A previewed instance's errors stay in its own panel only.
+ expect(w.findAll('[data-test="other-instance-error"]')).toHaveLength(1);
+ expect(
+ w.find(`[data-test="instance-panel-${instanceIds.a}"]`).text(),
+ ).toContain('in-its-panel');
+ });
+});
diff --git a/src/components/agents/config/editor/SavePreviewPanel.vue b/src/components/agents/config/editor/SavePreviewPanel.vue
index 7201b46d..68e93f93 100644
--- a/src/components/agents/config/editor/SavePreviewPanel.vue
+++ b/src/components/agents/config/editor/SavePreviewPanel.vue
@@ -39,6 +39,15 @@
{{ applying }} of {{ preview.instances.length }} instances will apply
this revision.
+
+ {{ omitted }} more
+ {{ omitted === 1 ? 'instance was' : 'instances were' }} not previewed; a
+ save still validates against them.
+
+
+
+
+ Other instances
+
+
+
+ {{
+ inst.hostname || inst['instance-id'].slice(0, 8)
+ }}
+
+
+ {{ e.path || '/' }} —
+ {{ e.message }}
+
+
+ {{ w.path || '/' }} —
+ {{ w.message }}
+ (in this host's file; does not block)
+
+
+
+
+
+
props.preview.instances.filter((i) => !i.willApply),
);
+/** Instances beyond the preview's bound (50 instances / 16 MiB); a save validates them. */
+const omitted = computed(() => props.preview.omittedInstances ?? 0);
+/** 422 instances with no preview panel: shown on their own, since they block the save. */
+const otherSaveErrors = computed(() =>
+ (props.saveErrors?.instances ?? []).filter(
+ (s) =>
+ (s.errors?.length || s.warnings?.length) &&
+ !props.preview.instances.some((p) => p.instanceId === s['instance-id']),
+ ),
+);
function offending(inst: InstancePreview) {
return inst.changes.filter((c) => c.safety !== 'safe');