From 44fd16c6ee504ffb5dd48192bd9022767b579f03 Mon Sep 17 00:00:00 2001 From: Gustavo Carvalho Date: Mon, 5 Oct 2026 07:40:53 -0300 Subject: [PATCH 1/4] feat(agent-config): field editor building blocks, header and instance picker Layer 12 of 21 in the stacked split of compliance-framework/ui#318. Co-Authored-By: Claude Opus 5.5 --- .../agents/config/AgentConfigHeader.vue | 109 ++++++++++ .../agents/config/AgentInstancePicker.vue | 90 ++++++++ .../config/__tests__/KeyValueEditor.spec.ts | 38 ++++ .../agents/config/editor/FieldHints.vue | 53 +++++ .../agents/config/editor/FieldIssues.vue | 36 ++++ .../agents/config/editor/KeyValueEditor.vue | 203 ++++++++++++++++++ .../config/editor/PolicySourcesEditor.vue | 147 +++++++++++++ .../agents/config/editor/useEditor.ts | 132 ++++++++++++ 8 files changed, 808 insertions(+) create mode 100644 src/components/agents/config/AgentConfigHeader.vue create mode 100644 src/components/agents/config/AgentInstancePicker.vue create mode 100644 src/components/agents/config/__tests__/KeyValueEditor.spec.ts create mode 100644 src/components/agents/config/editor/FieldHints.vue create mode 100644 src/components/agents/config/editor/FieldIssues.vue create mode 100644 src/components/agents/config/editor/KeyValueEditor.vue create mode 100644 src/components/agents/config/editor/PolicySourcesEditor.vue create mode 100644 src/components/agents/config/editor/useEditor.ts diff --git a/src/components/agents/config/AgentConfigHeader.vue b/src/components/agents/config/AgentConfigHeader.vue new file mode 100644 index 00000000..39d56852 --- /dev/null +++ b/src/components/agents/config/AgentConfigHeader.vue @@ -0,0 +1,109 @@ + + + diff --git a/src/components/agents/config/AgentInstancePicker.vue b/src/components/agents/config/AgentInstancePicker.vue new file mode 100644 index 00000000..0389288f --- /dev/null +++ b/src/components/agents/config/AgentInstancePicker.vue @@ -0,0 +1,90 @@ + + + diff --git a/src/components/agents/config/__tests__/KeyValueEditor.spec.ts b/src/components/agents/config/__tests__/KeyValueEditor.spec.ts new file mode 100644 index 00000000..596f34b8 --- /dev/null +++ b/src/components/agents/config/__tests__/KeyValueEditor.spec.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import KeyValueEditor, { type KeyValueRow } from '../editor/KeyValueEditor.vue'; +import { ADMIN, globalWith, piniaWith } from './helpers'; + +const rows: KeyValueRow[] = [{ key: 'foo', value: '1', provenance: 'file' }]; + +function mountEditor() { + return mount(KeyValueEditor, { + props: { rows, label: 'Label' }, + global: globalWith(piniaWith(ADMIN)), + }); +} + +describe('KeyValueEditor: new keys', () => { + it('trims the key: "foo " duplicates "foo"', async () => { + const w = mountEditor(); + await w.find('[data-test="kv-new-key"]').setValue('foo '); + expect(w.text()).toContain('Duplicate key'); + expect(w.find('[data-test="kv-add"]').attributes('disabled')).toBeDefined(); + }); + + it('emits the trimmed key', async () => { + const w = mountEditor(); + await w.find('[data-test="kv-new-key"]').setValue(' bar '); + await w.find('[data-test="kv-new-value"]').setValue('2'); + await w.find('form').trigger('submit'); + expect(w.emitted('add')).toEqual([['bar', '2']]); + }); + + it('rejects a blank key', async () => { + const w = mountEditor(); + await w.find('[data-test="kv-new-key"]').setValue(' '); + expect(w.text()).toContain('The key is empty'); + await w.find('form').trigger('submit'); + expect(w.emitted('add')).toBeUndefined(); + }); +}); diff --git a/src/components/agents/config/editor/FieldHints.vue b/src/components/agents/config/editor/FieldHints.vue new file mode 100644 index 00000000..638ac9b7 --- /dev/null +++ b/src/components/agents/config/editor/FieldHints.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/components/agents/config/editor/FieldIssues.vue b/src/components/agents/config/editor/FieldIssues.vue new file mode 100644 index 00000000..b3d6aacc --- /dev/null +++ b/src/components/agents/config/editor/FieldIssues.vue @@ -0,0 +1,36 @@ + + + diff --git a/src/components/agents/config/editor/KeyValueEditor.vue b/src/components/agents/config/editor/KeyValueEditor.vue new file mode 100644 index 00000000..70dc72e0 --- /dev/null +++ b/src/components/agents/config/editor/KeyValueEditor.vue @@ -0,0 +1,203 @@ + + + diff --git a/src/components/agents/config/editor/PolicySourcesEditor.vue b/src/components/agents/config/editor/PolicySourcesEditor.vue new file mode 100644 index 00000000..06ad259c --- /dev/null +++ b/src/components/agents/config/editor/PolicySourcesEditor.vue @@ -0,0 +1,147 @@ + + + diff --git a/src/components/agents/config/editor/useEditor.ts b/src/components/agents/config/editor/useEditor.ts new file mode 100644 index 00000000..8f712e24 --- /dev/null +++ b/src/components/agents/config/editor/useEditor.ts @@ -0,0 +1,132 @@ +// Shared access to the editor draft and context for the components that edit the +// pending-changes draft (the inline Effective-view editors), plus the preview-derived hints +// (shields, trust hints) and lock/"differs" hints (LLD U2.3). + +import { inject } from 'vue'; +import type { ConfigChange, ConfigPreview } from '@/types/agent-config'; +import { + EDITOR_CONTEXT_KEY, + OVERLAY_DRAFT_KEY, +} from '@/composables/agent-config/editorContext'; +import { deepEqual } from '@/utils/agent-config/merge-patch'; +import { getAt, hasAt, isPrefix } from '@/utils/agent-config/json-pointer'; +import type { FieldAccess } from '@/utils/agent-config/field-access'; +import { CHANGE_REASON_LABELS, labelFor } from '../constants'; + +export interface Shield { + level: 'unsafe' | 'forbidden'; + tooltip: string; +} + +export interface TrustHint { + safe: number; + total: number; + label: string; +} + +function covers(changePath: string, ptr: string): boolean { + return isPrefix(changePath, ptr) || isPrefix(ptr, changePath); +} + +/** Pure: a shield when the field is unsafe/forbidden on EVERY non-stale previewed instance. */ +export function shieldFor( + preview: ConfigPreview | null, + ptr: string, +): Shield | null { + const fresh = (preview?.instances ?? []).filter((i) => !i.stale); + if (!fresh.length) return null; + let allForbidden = true; + for (const inst of fresh) { + const hits = inst.changes.filter( + (c) => + covers(c.path, ptr) && + (c.safety === 'unsafe' || c.safety === 'forbidden'), + ); + if (!hits.length) return null; + if (!hits.some((c) => c.safety === 'forbidden')) allForbidden = false; + } + const applyAll = fresh + .filter((i) => i.mode === 'apply_all') + .map((i) => i.hostname || i.instanceId.slice(0, 8)); + if (allForbidden) return { level: 'forbidden', tooltip: 'Forbidden' }; + let tooltip = 'Requires apply_all on the agent'; + if (applyAll.length) tooltip += `. Applied anyway by: ${applyAll.join(', ')}`; + return { level: 'unsafe', tooltip }; +} + +/** Pure: "trusted on n/m instances" for a source written at `ptr` (U2.3). */ +export function trustHintFor( + preview: ConfigPreview | null, + ptr: string, + source: string, +): TrustHint | null { + const fresh = (preview?.instances ?? []).filter((i) => !i.stale); + let safe = 0; + let total = 0; + let first: ConfigChange | null = null; + for (const inst of fresh) { + const c = inst.changes.find((ch) => ch.path === ptr && ch.value === source); + if (!c) continue; + total++; + if (c.safety === 'safe') safe++; + first ??= c; + } + if (!total || !first) return null; + return { + safe, + total, + label: labelFor(CHANGE_REASON_LABELS, first.reason) ?? first.reason, + }; +} + +export function useEditor() { + const draft = inject(OVERLAY_DRAFT_KEY)!; + const ctx = inject(EDITOR_CONTEXT_KEY)!; + + function has(ptr: string): boolean { + return hasAt(draft.overlay.value, ptr); + } + function overlayValue(ptr: string): unknown { + return getAt(draft.overlay.value, ptr); + } + function baseValue(ptr: string): unknown { + return getAt(ctx.placeholderBase.value ?? {}, ptr); + } + function effectiveValue(ptr: string): unknown { + return getAt(draft.effectiveDraft.value, ptr); + } + function issuesAt(ptr: string) { + return draft.issues.value.filter((i) => i.ptr === ptr); + } + /** True when the known instances' file values differ at `ptr`. */ + function differsAcrossInstances(ptr: string): boolean { + const bases = ctx.bases.value; + if (bases.length < 2) return false; + const first = getAt(bases[0], ptr); + return bases.some((b) => !deepEqual(getAt(b, ptr), first)); + } + /** R71 three-state access of the field at `ptr` (field-access.ts). */ + function access(ptr: string): FieldAccess { + return ctx.accessAt(ptr); + } + function shield(ptr: string): Shield | null { + return shieldFor(ctx.currentPreview.value, ptr); + } + function trustHint(ptr: string, source: string): TrustHint | null { + return trustHintFor(ctx.currentPreview.value, ptr, source); + } + + return { + draft, + ctx, + has, + overlayValue, + baseValue, + effectiveValue, + issuesAt, + differsAcrossInstances, + access, + shield, + trustHint, + }; +} From 6c4c938bf4bb36b6181591bef0aa6cf1930cb4d5 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:16:00 -0300 Subject: [PATCH 2/4] fix(agent-config): editor tooltips live in tooltips.ts KeyValueEditor's key-case warning and the "Reset to file value" tooltip (KeyValueEditor, FieldHints) read TOOLTIPS['agents.config.*'], per docs/TOOLTIPS.md (UI-COMP-001). Co-Authored-By: Claude Opus 5.5 --- src/components/agents/config/editor/FieldHints.vue | 5 ++++- src/components/agents/config/editor/KeyValueEditor.vue | 7 ++++--- src/config/tooltips.ts | 3 +++ 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/components/agents/config/editor/FieldHints.vue b/src/components/agents/config/editor/FieldHints.vue index 638ac9b7..19208a36 100644 --- a/src/components/agents/config/editor/FieldHints.vue +++ b/src/components/agents/config/editor/FieldHints.vue @@ -22,7 +22,7 @@ > -
+
+ {{ page * PICKER_PAGE_SIZE + 1 }}–{{ + Math.min((page + 1) * PICKER_PAGE_SIZE, shown.length) + }} + of {{ shown.length }} + -
- -
+
@@ -51,6 +71,9 @@ import type { InstanceUiState } from '@/utils/agent-config/instance-status'; import InstanceStatusChip from './InstanceStatusChip.vue'; import ConfigPill from './ConfigPill.vue'; +/** Instances per picker page (the API's page size). */ +const PICKER_PAGE_SIZE = 25; + const props = defineProps<{ instances: AgentInstanceSummary[]; states: InstanceUiState[]; @@ -66,12 +89,34 @@ const items = computed(() => const freshItems = computed(() => items.value.filter((i) => !i.inst.stale)); const staleItems = computed(() => items.value.filter((i) => i.inst.stale)); const showStale = ref(false); +/** Fresh instances, then the stale ones when shown; rendered a page at a time. */ +const shown = computed(() => + showStale.value + ? [...freshItems.value, ...staleItems.value] + : freshItems.value, +); +const page = ref(0); +const pageCount = computed(() => + Math.max(1, Math.ceil(shown.value.length / PICKER_PAGE_SIZE)), +); +const pageItems = computed(() => + shown.value.slice( + page.value * PICKER_PAGE_SIZE, + (page.value + 1) * PICKER_PAGE_SIZE, + ), +); +watch(pageCount, (n) => { + if (page.value > n - 1) page.value = n - 1; +}); +// The selected instance is always on the shown page (stale ones expand the list). watch( () => props.selectedId, (id) => { if (staleItems.value.some((i) => i.inst.instanceId === id)) showStale.value = true; + const at = shown.value.findIndex((i) => i.inst.instanceId === id); + if (at >= 0) page.value = Math.floor(at / PICKER_PAGE_SIZE); }, { immediate: true }, ); diff --git a/src/components/agents/config/__tests__/InstanceListPaging.spec.ts b/src/components/agents/config/__tests__/InstanceListPaging.spec.ts new file mode 100644 index 00000000..b673c27e --- /dev/null +++ b/src/components/agents/config/__tests__/InstanceListPaging.spec.ts @@ -0,0 +1,114 @@ +// A paginated instance list: the picker shows one page of instances at a time, and the header +// says when the loaded rows are only part of the fleet. +import { describe, expect, it } from 'vitest'; +import { mount } from '@vue/test-utils'; +import type { AgentInstanceSummary } from '@/types/agent-config'; +import { instancesMixed } from '@/composables/agent-config/__tests__/fixtures'; +import { + deriveInstanceState, + summarizeSync, +} from '@/utils/agent-config/instance-status'; +import AgentInstancePicker from '../AgentInstancePicker.vue'; +import AgentConfigHeader from '../AgentConfigHeader.vue'; +import { READER, globalWith, piniaWith } from './helpers'; + +function rows(n: number, over: Partial = {}) { + return Array.from({ length: n }, (_, i) => ({ + ...instancesMixed.items[0], + instanceId: `i${i + 1}`, + hostname: `ip-${i + 1}`, + ...over, + })); +} + +function picker(instances: AgentInstanceSummary[], selectedId: string | null) { + return mount(AgentInstancePicker, { + props: { + instances, + states: instances.map((i) => deriveInstanceState(i, 7)), + selectedId, + }, + global: globalWith(piniaWith(READER)), + }); +} + +const picks = (w: ReturnType) => + w.findAll('[data-test^="pick-"]').map((b) => b.attributes('data-test')); + +describe('AgentInstancePicker paging', () => { + it('renders one page of 25 instances and pages through the rest', async () => { + const w = picker(rows(60), 'i1'); + expect(picks(w)).toHaveLength(25); + expect(picks(w)[0]).toBe('pick-i1'); + expect(w.find('[data-test="picker-range"]').text()).toBe('1–25 of 60'); + expect(w.find('[data-test="picker-prev"]').attributes('disabled')).toBe(''); + await w.find('[data-test="picker-next"]').trigger('click'); + await w.find('[data-test="picker-next"]').trigger('click'); + expect(picks(w)).toEqual( + Array.from({ length: 10 }, (_, i) => `pick-i${i + 51}`), + ); + expect(w.find('[data-test="picker-next"]').attributes('disabled')).toBe(''); + }); + + it('opens on the page of the selected instance', () => { + const w = picker(rows(60), 'i40'); + expect(w.find('[data-test="picker-range"]').text()).toBe('26–50 of 60'); + expect(w.find('[data-test="pick-i40"]').exists()).toBe(true); + }); + + it('a short list has no pager', () => { + const w = picker(rows(3), 'i1'); + expect(picks(w)).toHaveLength(3); + expect(w.find('[data-test="picker-pager"]').exists()).toBe(false); + }); + + it('stale instances join the pages when shown', async () => { + const list = [ + ...rows(24), + ...rows(3, { stale: true }).map((r, i) => ({ + ...r, + instanceId: `s${i + 1}`, + })), + ]; + const w = picker(list, 'i1'); + expect(picks(w)).toHaveLength(24); + await w.find('[data-test="toggle-stale"]').trigger('click'); + expect(picks(w)).toHaveLength(25); + await w.find('[data-test="picker-next"]').trigger('click'); + expect(picks(w)).toEqual(['pick-s2', 'pick-s3']); + }); +}); + +describe('AgentConfigHeader on part of the fleet', () => { + function header(loaded: AgentInstanceSummary[], total: number) { + const counts = { ...instancesMixed.meta.counts, total }; + return mount(AgentConfigHeader, { + props: { + config: null, + instanceCount: total, + syncSummary: summarizeSync( + loaded, + loaded.map((i) => deriveInstanceState(i, 7)), + counts, + ), + }, + global: globalWith(piniaWith(READER)), + }); + } + + it('says the loaded rows are part of the fleet, with fleet-wide counts', () => { + const w = header(rows(100), 130); + expect(w.find('[data-test="sync-summary"]').text()).toContain( + 'In sync: 3/5 instances', + ); + expect(w.find('[data-test="partial-fleet"]').text()).toContain( + 'Showing 100 of 130 instances', + ); + }); + + it('says nothing more when every instance is loaded', () => { + const w = header(instancesMixed.items, 7); + expect(w.find('[data-test="sync-summary"]').exists()).toBe(true); + expect(w.find('[data-test="partial-fleet"]').exists()).toBe(false); + }); +}); From ec210f9c111b89f93b54f73c7582b0d6847110ef Mon Sep 17 00:00:00 2001 From: "ccf-lisa[bot]" <286799724+ccf-lisa[bot]@users.noreply.github.com> Date: Tue, 6 Oct 2026 11:25:39 -0300 Subject: [PATCH 4/4] fix(agent-config): the header says when the in-sync ratio comes from fleet counts On a partial instance list the in-sync ratio is the API's fleet-wide counts, which include stale instances: the header now reads "In sync: X/Y instances (incl. stale, from the API's fleet counts)". A complete list shows the row-based ratio as before (2/4 for the fixture fleet). Co-Authored-By: Claude Opus 5.5 --- src/components/agents/config/AgentConfigHeader.vue | 3 +++ .../config/__tests__/InstanceListPaging.spec.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/agents/config/AgentConfigHeader.vue b/src/components/agents/config/AgentConfigHeader.vue index 9bf95e0a..52726e80 100644 --- a/src/components/agents/config/AgentConfigHeader.vue +++ b/src/components/agents/config/AgentConfigHeader.vue @@ -41,6 +41,9 @@ > In sync: {{ syncSummary.inSync }}/{{ syncSummary.expected }} instances + { it('says the loaded rows are part of the fleet, with fleet-wide counts', () => { const w = header(rows(100), 130); - expect(w.find('[data-test="sync-summary"]').text()).toContain( - 'In sync: 3/5 instances', + expect(w.find('[data-test="sync-summary"]').text()).toMatch( + /In sync: 3\/5 instances\s+\(incl\. stale, from the API's fleet counts\)/, ); expect(w.find('[data-test="partial-fleet"]').text()).toContain( 'Showing 100 of 130 instances', @@ -108,7 +108,13 @@ describe('AgentConfigHeader on part of the fleet', () => { it('says nothing more when every instance is loaded', () => { const w = header(instancesMixed.items, 7); - expect(w.find('[data-test="sync-summary"]').exists()).toBe(true); + // Every row loaded: the row-based numbers (stale instances not in sync). + expect(w.find('[data-test="sync-summary"]').text()).toContain( + 'In sync: 2/4 instances', + ); + expect(w.find('[data-test="sync-summary"]').text()).not.toContain( + 'incl. stale', + ); expect(w.find('[data-test="partial-fleet"]').exists()).toBe(false); }); });