From 0af67f3281c72118debb3485150847b67253d45a Mon Sep 17 00:00:00 2001
From: Gustavo Carvalho
Date: Mon, 5 Oct 2026 07:40:54 -0300
Subject: [PATCH 1/2] feat(agent-config): plugin tabs and add-plugin dialog
Layer 18 of 21 in the stacked split of compliance-framework/ui#318.
Co-Authored-By: Claude Opus 5.5
---
.../config/AgentConfigEffectiveView.vue | 151 ++++++++++
src/components/agents/config/PluginTabs.vue | 263 ++++++++++++++++++
.../AgentConfigEffectiveView.spec.ts | 123 ++++++++
.../config/__tests__/PluginLibVersion.spec.ts | 54 ++++
.../agents/config/editor/AddPluginAction.vue | 57 ++++
.../agents/config/editor/AddPluginDialog.vue | 205 ++++++++++++++
6 files changed, 853 insertions(+)
create mode 100644 src/components/agents/config/AgentConfigEffectiveView.vue
create mode 100644 src/components/agents/config/PluginTabs.vue
create mode 100644 src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
create mode 100644 src/components/agents/config/__tests__/PluginLibVersion.spec.ts
create mode 100644 src/components/agents/config/editor/AddPluginAction.vue
create mode 100644 src/components/agents/config/editor/AddPluginDialog.vue
diff --git a/src/components/agents/config/AgentConfigEffectiveView.vue b/src/components/agents/config/AgentConfigEffectiveView.vue
new file mode 100644
index 00000000..5d6a9796
--- /dev/null
+++ b/src/components/agents/config/AgentConfigEffectiveView.vue
@@ -0,0 +1,151 @@
+
+
+
+ {{ NOT_REPORTED_TEXT }}
+
+
+
+
+
+ Could not load r{{ appliedRevisionNote }}, the revision this
+ instance runs; provenance is shown against the desired revision.
+
+
+ Provenance reflects r{{ appliedRevisionNote }}, the revision this
+ instance runs.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/agents/config/PluginTabs.vue b/src/components/agents/config/PluginTabs.vue
new file mode 100644
index 00000000..4e4b13eb
--- /dev/null
+++ b/src/components/agents/config/PluginTabs.vue
@@ -0,0 +1,263 @@
+
+
+
+
+ Plugins
+
+
+
+ No plugins configured.
+
+
+
+
+
+
+
+ {{ card.name }}
+ {{ hint.label }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ installText(card) }}
+
+
+
+
+
+
diff --git a/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
new file mode 100644
index 00000000..16bf2c84
--- /dev/null
+++ b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
@@ -0,0 +1,123 @@
+import { afterEach, describe, expect, it } from 'vitest';
+import { mount, enableAutoUnmount } from '@vue/test-utils';
+import type { ConfigDoc, OverlayDoc } from '@/types/agent-config';
+import { baseConfig } from '@/composables/agent-config/__tests__/fixtures';
+import { mergePatch } from '@/utils/agent-config/merge-patch';
+import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue';
+import { globalWith, piniaWith, READER } from './helpers';
+
+// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper
+// left mounted lets it fire after this file's jsdom environment is torn down
+// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op.
+enableAutoUnmount(afterEach);
+
+function mountView(
+ overlay: OverlayDoc,
+ effectiveOverride?: ConfigDoc,
+ note: number | null = null,
+) {
+ const effective =
+ effectiveOverride ?? mergePatch(baseConfig, overlay);
+ return mount(AgentConfigEffectiveView, {
+ props: {
+ effectiveDoc: effective,
+ base: baseConfig,
+ appliedOverlay: overlay,
+ appliedRevisionNote: note,
+ filename: 'a-effective-r7.yaml',
+ },
+ global: globalWith(piniaWith(READER)),
+ });
+}
+
+describe('AgentConfigEffectiveView', () => {
+ it('renders provenance badges for file / overlay / overrides / removed', () => {
+ const wrapper = mountView({
+ verbosity: 2,
+ plugins: {
+ 'ubuntu-packages': null,
+ extra: { source: 'ghcr.io/compliance-framework/extra:v1' },
+ 'local-ssh': { schedule: '@hourly' },
+ },
+ });
+ const ssh = wrapper.find('[data-test="plugin-card-local-ssh"]');
+ expect(ssh.find('[data-provenance="overrides-file"]').exists()).toBe(true);
+ const extra = wrapper.find('[data-test="plugin-card-extra"]');
+ expect(extra.find('[data-provenance="overlay"]').exists()).toBe(true);
+ const removed = wrapper.find('[data-test="plugin-card-ubuntu-packages"]');
+ expect(removed.text()).toContain('Removed by overlay');
+ expect(
+ removed.find('[data-provenance="removed-by-overlay"]').exists(),
+ ).toBe(true);
+ const flags = wrapper.find('[data-test="flags-summary"]');
+ expect(flags.text()).toContain('Trace');
+ expect(flags.find('[data-provenance="overrides-file"]').exists()).toBe(
+ true,
+ );
+ });
+
+ it('shows plain file provenance when the applied overlay is empty, and the pending note', () => {
+ const wrapper = mountView({}, undefined, 6);
+ expect(
+ wrapper
+ .find('[data-test="plugin-card-local-ssh"] [data-provenance="file"]')
+ .exists(),
+ ).toBe(true);
+ expect(wrapper.find('[data-test="provenance-note"]').text()).toContain(
+ 'r6',
+ );
+ });
+
+ it('shows the forbidden keys locked and muted (R71) and never renders client_secret', () => {
+ const leaky = mergePatch(baseConfig, {}) as ConfigDoc;
+ leaky.api = {
+ url: 'https://x',
+ auth: { client_id: 'cid', client_secret: 'SUPER-SECRET' },
+ };
+ const wrapper = mountView({}, leaky);
+ const locked = wrapper.find('[data-test="locked-keys"]');
+ expect(locked.text()).toContain('api.auth.client_id');
+ expect(locked.text()).toContain('cid');
+ expect(locked.findAll('[data-test="lock-icon"]').length).toBe(8);
+ expect(
+ locked.find('[data-test="lock-icon"]').attributes('aria-label'),
+ ).toBe('Set on the agent host; can never be changed remotely');
+ expect(locked.findAll('[data-state="forbidden"]').length).toBe(8);
+ // No pencil on a forbidden field.
+ expect(locked.find('[data-test^="edit-"]').exists()).toBe(false);
+ expect(wrapper.html()).not.toContain('SUPER-SECRET');
+ });
+
+ it('never renders client_secret in YAML mode either', async () => {
+ const leaky = mergePatch(baseConfig, {}) as ConfigDoc;
+ leaky.api = {
+ url: 'https://x',
+ auth: { client_id: 'cid', client_secret: 'SUPER-SECRET' },
+ };
+ const wrapper = mountView({}, leaky);
+ wrapper
+ .findComponent({ name: 'SelectButton' })
+ .vm.$emit('update:modelValue', 'yaml');
+ await wrapper.vm.$nextTick();
+ expect(wrapper.find('[data-test="yaml-text"]').text()).toContain(
+ 'client_id: cid',
+ );
+ expect(wrapper.html()).not.toContain('SUPER-SECRET');
+ });
+
+ it('shows the not-reported text without an effective config', () => {
+ const wrapper = mount(AgentConfigEffectiveView, {
+ props: {
+ effectiveDoc: null,
+ base: null,
+ appliedOverlay: null,
+ appliedRevisionNote: null,
+ filename: 'x.yaml',
+ },
+ global: globalWith(piniaWith(READER)),
+ });
+ expect(wrapper.find('[data-test="effective-empty"]').text()).toContain(
+ 'No configuration reported yet.',
+ );
+ });
+});
diff --git a/src/components/agents/config/__tests__/PluginLibVersion.spec.ts b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts
new file mode 100644
index 00000000..956617f4
--- /dev/null
+++ b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts
@@ -0,0 +1,54 @@
+// R76 on the Configuration tab: the plugin's agent library on the Effective view.
+import { afterEach, describe, expect, it } from 'vitest';
+import { mount, enableAutoUnmount } from '@vue/test-utils';
+import {
+ SSH_SOURCE,
+ baseConfig,
+} from '@/composables/agent-config/__tests__/fixtures';
+import type { ConfigDoc, PluginReport } from '@/types/agent-config';
+import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue';
+import { READER, globalWith, piniaWith } from './helpers';
+
+// PrimeVue's TabList schedules a 150 ms ink-bar update on mount and never clears it; a wrapper
+// left mounted lets it fire after this file's jsdom environment is torn down
+// ("HTMLElement is not defined"). Unmounting nulls its refs, so the timer becomes a no-op.
+enableAutoUnmount(afterEach);
+
+describe('plugin agent library badge (R76)', () => {
+ function view(plugins: PluginReport[] | null) {
+ return mount(AgentConfigEffectiveView, {
+ props: {
+ effectiveDoc: baseConfig as ConfigDoc,
+ base: baseConfig,
+ appliedOverlay: {},
+ appliedRevisionNote: null,
+ filename: 'x.yaml',
+ pluginReports: plugins,
+ },
+ global: globalWith(piniaWith(READER)),
+ });
+ }
+
+ it('shows the reported library, and nothing when it is unknown', () => {
+ const w = view([
+ { name: 'local-ssh', source: SSH_SOURCE, libVersion: 'v0.1.9' },
+ { name: 'ubuntu-packages', libVersion: '' },
+ ]);
+ expect(
+ w
+ .find('[data-test="plugin-card-local-ssh"] [data-test="plugin-lib"]')
+ .text(),
+ ).toBe('agent v0.1.9');
+ expect(
+ w
+ .find(
+ '[data-test="plugin-card-ubuntu-packages"] [data-test="plugin-lib"]',
+ )
+ .exists(),
+ ).toBe(false);
+ });
+
+ it('shows nothing for agents that do not report plugins', () => {
+ expect(view(null).find('[data-test="plugin-lib"]').exists()).toBe(false);
+ });
+});
diff --git a/src/components/agents/config/editor/AddPluginAction.vue b/src/components/agents/config/editor/AddPluginAction.vue
new file mode 100644
index 00000000..43c4bf75
--- /dev/null
+++ b/src/components/agents/config/editor/AddPluginAction.vue
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Add plugin
+
+
+
+ {{
+ tooltip
+ }}
+
+
+
+
diff --git a/src/components/agents/config/editor/AddPluginDialog.vue b/src/components/agents/config/editor/AddPluginDialog.vue
new file mode 100644
index 00000000..c4d1c6b9
--- /dev/null
+++ b/src/components/agents/config/editor/AddPluginDialog.vue
@@ -0,0 +1,205 @@
+
+
+
+
+
+
+
From c5da8fa3903c95bedde68f6f2cdfa3d3ab2f4d57 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:23:59 -0300
Subject: [PATCH 2/2] fix(agent-config): plugin tabs by own name; Add plugin
never replaces a defined plugin
- the tab's removal hint and the "Removed by overlay" card read plugins
as own properties (getOwn), so a plugin named "constructor" is
matched like any other
- Add plugin refuses every name the saved overlay or the draft defines
(non-null), not only the shown cards and loaded files: adding writes
the whole plugin and replaced a saved definition this instance does
not run yet
- Add plugin waits, with the reason, until every reporting instance's
file is loaded; the Effective view says files are loading, or names
the ones that failed with a Retry
Co-Authored-By: Claude Opus 5.5
---
.../config/AgentConfigEffectiveView.vue | 47 +++++-
src/components/agents/config/PluginTabs.vue | 17 +-
.../AgentConfigEffectiveView.spec.ts | 23 +++
.../config/__tests__/PluginLibVersion.spec.ts | 5 +-
.../__tests__/PluginTabsWorkspace.spec.ts | 158 ++++++++++++++++++
.../agents/config/editor/AddPluginAction.vue | 14 +-
6 files changed, 255 insertions(+), 9 deletions(-)
create mode 100644 src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts
diff --git a/src/components/agents/config/AgentConfigEffectiveView.vue b/src/components/agents/config/AgentConfigEffectiveView.vue
index 5d6a9796..b5b24eda 100644
--- a/src/components/agents/config/AgentConfigEffectiveView.vue
+++ b/src/components/agents/config/AgentConfigEffectiveView.vue
@@ -8,6 +8,32 @@
{{ NOT_REPORTED_TEXT }}
+
+
+
+
+ {{ basesNotice }}
+ Retry
+
+
+
+ {{ basesNotice }}
+
+
);
const ws = useWorkspace();
+/** Why editing waits (instance files loading or failed): editors only. */
+const basesNotice = computed(() =>
+ ws && ws.ready.value && ws.canConfigure.value
+ ? ws.basesBlockedReason.value
+ : '',
+);
const pluginCards = computed(() => {
const cards: PluginCard[] = [];
@@ -115,11 +149,16 @@ const pluginCards = computed(() => {
const overlayPlugins = props.appliedOverlay?.plugins;
if (isPlainObject(overlayPlugins)) {
for (const [name, v] of Object.entries(overlayPlugins)) {
- const basePlugin = props.base?.plugins?.[name];
- if (v === null && basePlugin && !(name in eff)) {
+ // Own properties only: a plugin may be named "constructor".
+ const basePlugin = getOwn(props.base?.plugins ?? {}, name);
+ if (
+ v === null &&
+ isPlainObject(basePlugin) &&
+ getOwn(eff, name) === undefined
+ ) {
cards.push({
name,
- plugin: basePlugin,
+ plugin: basePlugin as PluginCard['plugin'],
removed: true,
pendingNew: false,
});
diff --git a/src/components/agents/config/PluginTabs.vue b/src/components/agents/config/PluginTabs.vue
index 4e4b13eb..b3027d4b 100644
--- a/src/components/agents/config/PluginTabs.vue
+++ b/src/components/agents/config/PluginTabs.vue
@@ -19,6 +19,7 @@
@@ -70,6 +71,7 @@
v-if="canAddPlugin && addAccess"
class="mb-1 flex-shrink-0"
:access="addAccess"
+ :blocked-reason="addBlockedReason"
@open="addPluginOpen = true"
/>
@@ -128,6 +130,7 @@ import type {
PluginReport,
} from '@/types/agent-config';
import { pointer } from '@/utils/agent-config/json-pointer';
+import { getOwn, isPlainObject } from '@/utils/agent-config/merge-patch';
import { addPluginTooltip } from '@/utils/agent-config/field-access';
import { useWorkspace } from '@/composables/agent-config/useConfigWorkspace';
import PluginSummaryCard from './PluginSummaryCard.vue';
@@ -188,7 +191,7 @@ function hintsOf(card: PluginCard) {
const effDraft = ws?.draft.effectiveDraft.value.plugins ?? {};
if (card.pendingNew) {
hints.push({ key: 'new', label: 'new', severity: 'info' });
- } else if (pending && !card.removed && !effDraft[card.name]) {
+ } else if (pending && !card.removed && !getOwn(effDraft, card.name)) {
hints.push({ key: 'removal', label: 'removal', severity: 'danger' });
} else if (pending) {
hints.push({ key: 'pending', label: 'pending', severity: 'info' });
@@ -224,11 +227,23 @@ const canAddPlugin = computed(
const addAccess = computed(() =>
canAddPlugin.value ? ws!.addPluginAccess() : null,
);
+/** Adding computes the install access over every instance's file: it waits for all of them. */
+const addBlockedReason = computed(() => ws?.basesBlockedReason.value ?? '');
const addPluginOpen = ref(false);
+/**
+ * Names the dialog refuses: adding writes the whole plugin, so it would replace one that a
+ * host's file, the saved overlay or the draft already defines (even one this instance does
+ * not run yet).
+ */
const existingPluginNames = computed(() => {
const names = new Set(props.cards.map((c) => c.name));
for (const b of ws?.bases.value ?? [])
Object.keys(b.plugins ?? {}).forEach((n) => names.add(n));
+ for (const o of [ws?.draft.original.value, ws?.draft.overlay.value]) {
+ const plugins = o?.plugins;
+ if (!isPlainObject(plugins)) continue;
+ for (const [n, v] of Object.entries(plugins)) if (v !== null) names.add(n);
+ }
return Array.from(names);
});
/** Adds the plugin to the draft and selects its (new) tab. */
diff --git a/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
index 16bf2c84..1f2ddd39 100644
--- a/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
+++ b/src/components/agents/config/__tests__/AgentConfigEffectiveView.spec.ts
@@ -120,4 +120,27 @@ describe('AgentConfigEffectiveView', () => {
'No configuration reported yet.',
);
});
+
+ it('matches removed plugins by own name only (a plugin may be named "constructor")', () => {
+ const removedCtor = (base: ConfigDoc) =>
+ mount(AgentConfigEffectiveView, {
+ props: {
+ effectiveDoc: mergePatch(base, CTOR_REMOVED),
+ base,
+ appliedOverlay: CTOR_REMOVED,
+ appliedRevisionNote: null,
+ filename: 'x.yaml',
+ },
+ global: globalWith(piniaWith(READER)),
+ }).find('[data-test="plugin-card-constructor"]');
+ // The file has it and the overlay removes it: a "Removed by overlay" card.
+ const withCtor = mergePatch(baseConfig, {
+ plugins: { constructor: { source: 'ghcr.io/x/ctor:v1' } },
+ });
+ expect(removedCtor(withCtor).text()).toContain('Removed by overlay');
+ // The file lacks it: no card (Object#constructor is not a plugin).
+ expect(removedCtor(baseConfig).exists()).toBe(false);
+ });
});
+
+const CTOR_REMOVED: OverlayDoc = JSON.parse('{"plugins":{"constructor":null}}');
diff --git a/src/components/agents/config/__tests__/PluginLibVersion.spec.ts b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts
index 956617f4..3c4fafd1 100644
--- a/src/components/agents/config/__tests__/PluginLibVersion.spec.ts
+++ b/src/components/agents/config/__tests__/PluginLibVersion.spec.ts
@@ -49,6 +49,9 @@ describe('plugin agent library badge (R76)', () => {
});
it('shows nothing for agents that do not report plugins', () => {
- expect(view(null).find('[data-test="plugin-lib"]').exists()).toBe(false);
+ const w = view(null);
+ // The card renders: only its library badge is absent.
+ expect(w.find('[data-test="plugin-card-local-ssh"]').exists()).toBe(true);
+ expect(w.find('[data-test="plugin-lib"]').exists()).toBe(false);
});
});
diff --git a/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts b/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts
new file mode 100644
index 00000000..2c1ce18f
--- /dev/null
+++ b/src/components/agents/config/__tests__/PluginTabsWorkspace.spec.ts
@@ -0,0 +1,158 @@
+// The plugin tabs with a workspace: removal hints for any plugin name, "Add plugin" never
+// replacing a plugin the saved overlay defines, and editing that waits for every instance file.
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import {
+ enableAutoUnmount,
+ flushPromises,
+ mount,
+ type VueWrapper,
+} from '@vue/test-utils';
+import type { AgentConfigApi } from '@/composables/agent-config/api-types';
+import type { ConfigWorkspace } from '@/composables/agent-config/useConfigWorkspace';
+import { resetAgentDrafts } from '@/composables/agent-config/draftRegistry';
+import type { AgentInstanceDetail, PluginDoc } from '@/types/agent-config';
+import {
+ configRev7,
+ instanceIds,
+} from '@/composables/agent-config/__tests__/fixtures';
+import { clone } from '@/utils/agent-config/merge-patch';
+import {
+ ADMIN,
+ fakeApi,
+ globalWith,
+ piniaWith,
+ workspaceHost,
+} from './helpers';
+
+vi.mock('@/components/code-editor', () => import('./codeEditorMock'));
+
+import AgentConfigEffectiveView from '../AgentConfigEffectiveView.vue';
+
+enableAutoUnmount(afterEach);
+
+async function mountView(api: AgentConfigApi) {
+ const out: { ws?: ConfigWorkspace } = {};
+ const host = workspaceHost(
+ api,
+ AgentConfigEffectiveView,
+ () => ({
+ effectiveDoc: out.ws!.state.selectedInstance.value?.effective ?? null,
+ base: out.ws!.state.selectedInstance.value?.base ?? null,
+ appliedOverlay: out.ws!.state.appliedOverlay.value,
+ appliedRevisionNote: null,
+ filename: 'x.yaml',
+ }),
+ out,
+ );
+ const wrapper = mount(host, {
+ global: globalWith(piniaWith(ADMIN), { teleport: true }),
+ attachTo: document.body,
+ });
+ await flushPromises();
+ return { wrapper, ws: out.ws! };
+}
+
+/** ip-b's detail comes from `b` (deferred or failing); the others load at once. */
+function apiWithB(b: (d: AgentInstanceDetail) => Promise) {
+ const fallback = fakeApi().getInstance;
+ return fakeApi({
+ getInstance: vi.fn(async (agentId: string, id: string) => {
+ const d = await fallback(agentId, id);
+ return id === instanceIds.b ? b(d) : d;
+ }),
+ });
+}
+
+const addButton = (w: VueWrapper) => w.find('[data-test="add-plugin"]');
+
+describe('PluginTabs with a workspace', () => {
+ beforeEach(() => resetAgentDrafts());
+
+ it('marks the pending removal of a plugin named "constructor"', async () => {
+ // Typed as a plain string, so `plugins[name]` is the index signature.
+ const name: string = 'constructor';
+ const ctor: PluginDoc = { source: 'ghcr.io/compliance-framework/ctor:v1' };
+ const fallback = fakeApi().getInstance;
+ const { wrapper, ws } = await mountView(
+ fakeApi({
+ getInstance: vi.fn(async (agentId: string, id: string) => {
+ const d = clone(await fallback(agentId, id));
+ if (d.base) d.base.plugins![name] = clone(ctor);
+ if (d.effective) d.effective.plugins![name] = clone(ctor);
+ return d;
+ }),
+ }),
+ );
+ const tab = () => wrapper.find('[data-test="plugin-tab-constructor"]');
+ expect(tab().exists()).toBe(true);
+ ws.draft.makeAbsent('/plugins/constructor');
+ await flushPromises();
+ expect(tab().find('[data-test="plugin-tab-hint-removal"]').exists()).toBe(
+ true,
+ );
+ });
+
+ it('refuses to add a plugin the saved overlay defines, and keeps its definition', async () => {
+ // r7 also adds `extra`; the instances do not run it yet, so it has no tab.
+ const saved = clone(configRev7);
+ saved.overlay!.plugins!.extra = {
+ source: 'ghcr.io/compliance-framework/plugin-extra:v1',
+ config: { region: 'eu-west-1' },
+ policy_data: { threshold: 5 },
+ };
+ const { wrapper, ws } = await mountView(
+ fakeApi({ getConfig: vi.fn().mockResolvedValue(saved) }),
+ );
+ expect(wrapper.find('[data-test="plugin-tab-extra"]').exists()).toBe(false);
+
+ await addButton(wrapper).trigger('click');
+ await wrapper.find('[data-test="add-plugin-name"]').setValue('extra');
+ await wrapper
+ .find('[data-test="add-plugin-source"]')
+ .setValue('ghcr.io/compliance-framework/plugin-extra:v2');
+ expect(wrapper.text()).toContain('A plugin with this name exists');
+ await wrapper.find('[data-test="add-plugin-form"]').trigger('submit');
+ await flushPromises();
+
+ const extra = ws.draft.overlay.value.plugins?.extra;
+ expect(extra?.source).toBe('ghcr.io/compliance-framework/plugin-extra:v1');
+ expect(extra?.config).toEqual({ region: 'eu-west-1' });
+ expect(extra?.policy_data).toEqual({ threshold: 5 });
+ });
+
+ it('waits for every instance file before Add plugin', async () => {
+ let releaseB!: () => void;
+ const { wrapper } = await mountView(
+ apiWithB(
+ (d) => new Promise((r) => (releaseB = () => r(d))),
+ ),
+ );
+ expect(wrapper.find('[data-test="bases-loading"]').exists()).toBe(true);
+ expect(addButton(wrapper).attributes('disabled')).toBeDefined();
+
+ releaseB();
+ await flushPromises();
+ expect(wrapper.find('[data-test="bases-loading"]').exists()).toBe(false);
+ expect(addButton(wrapper).attributes('disabled')).toBeUndefined();
+ });
+
+ it('names an instance file that failed to load, and Retry loads it', async () => {
+ let fail = true;
+ const { wrapper } = await mountView(
+ apiWithB(async (d) => {
+ if (fail) throw new Error('502');
+ return d;
+ }),
+ );
+ const notice = wrapper.find('[data-test="bases-failed"]');
+ expect(notice.exists()).toBe(true);
+ expect(notice.text()).toContain('ip-b');
+ expect(addButton(wrapper).attributes('disabled')).toBeDefined();
+
+ fail = false;
+ await wrapper.find('[data-test="bases-retry"]').trigger('click');
+ await flushPromises();
+ expect(wrapper.find('[data-test="bases-failed"]').exists()).toBe(false);
+ expect(addButton(wrapper).attributes('disabled')).toBeUndefined();
+ });
+});
diff --git a/src/components/agents/config/editor/AddPluginAction.vue b/src/components/agents/config/editor/AddPluginAction.vue
index 43c4bf75..1eb81b3a 100644
--- a/src/components/agents/config/editor/AddPluginAction.vue
+++ b/src/components/agents/config/editor/AddPluginAction.vue
@@ -48,10 +48,18 @@ import {
type FieldAccess,
} from '@/utils/agent-config/field-access';
-const props = defineProps<{ access: FieldAccess }>();
+const props = defineProps<{
+ access: FieldAccess;
+ /** Why adding waits regardless of R71 (e.g. instance files still loading); '' = it does not. */
+ blockedReason?: string;
+}>();
defineEmits<{ open: [] }>();
const descId = `add-plugin-${useId()}`;
-const disabled = computed(() => props.access.state === 'readonly');
-const tooltip = computed(() => addPluginTooltip(props.access));
+const disabled = computed(
+ () => props.access.state === 'readonly' || !!props.blockedReason,
+);
+const tooltip = computed(
+ () => props.blockedReason || addPluginTooltip(props.access),
+);