Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 121 additions & 0 deletions src/components/agents/config/AgentConfigHeader.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
<template>
<header
class="flex flex-wrap items-start justify-between gap-4 rounded-md border border-ccf-300 p-4 dark:border-slate-700"
data-test="config-header"
>
<div class="space-y-1 text-sm">
<p
v-if="!config || config.revision === 0"
class="text-gray-700 dark:text-slate-300"
>
No overlay saved: agents run their local configuration.
</p>
<p
v-else
class="text-gray-700 dark:text-slate-300"
data-test="desired-revision"
>
Desired revision
<span class="font-semibold">r{{ config.revision }}</span>
<template v-if="config.createdBy">
· by {{ config.createdBy }}</template
>
<template v-if="config.createdAt">
·
<span v-tooltip.top="formatAbsolute(config.createdAt)">{{
formatRelative(config.createdAt)
}}</span>
</template>
<template v-if="config.comment"> · “{{ config.comment }}”</template>
<ConfigPill v-if="config.revertOf" severity="info" class="ml-1">
Revert of r{{ config.revertOf }}
</ConfigPill>
</p>
<p v-if="instanceCount === 0" class="text-gray-500 dark:text-slate-400">
No instances have connected yet.
</p>
<p
v-else
class="flex flex-wrap items-center gap-x-3 gap-y-1"
data-test="sync-summary"
>
<span class="font-medium text-gray-900 dark:text-slate-200">
In sync: {{ syncSummary.inSync }}/{{ syncSummary.expected }} instances
<template v-if="syncSummary.partial">
(incl. stale, from the API's fleet counts)</template
>
</span>
<span
v-if="syncSummary.reportOnly"
class="text-gray-500 dark:text-slate-400"
>
{{ syncSummary.reportOnly }} report-only
</span>
<span
v-if="syncSummary.notReported"
class="text-gray-500 dark:text-slate-400"
>
{{ syncSummary.notReported }} not reporting
</span>
<span
v-if="syncSummary.stale"
class="text-gray-500 dark:text-slate-400"
>
{{ syncSummary.stale }} stale
</span>
<span
v-if="syncSummary.partial"
class="text-amber-700 dark:text-amber-300"
data-test="partial-fleet"
>
Showing {{ syncSummary.loaded }} of {{ syncSummary.total }} instances:
the list and problems below cover only those
</span>
<button
v-for="p in syncSummary.problems"
:key="p.instanceId"
type="button"
class="inline-flex items-center gap-1 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-700 hover:underline dark:bg-red-500/15 dark:text-red-300"
data-test="problem-chip"
@click="$emit('select-instance', p.instanceId)"
>
<i class="pi pi-exclamation-triangle text-[0.7rem]" />
{{ p.hostname || p.instanceId.slice(0, 8) }}: {{ p.label }}
</button>
</p>
</div>
<div class="flex items-center gap-2">
<SecondaryButton
size="small"
:disabled="loading"
aria-label="Refresh configuration"
data-test="refresh"
@click="$emit('refresh')"
>
<i class="pi pi-refresh" :class="{ 'animate-spin': loading }" />
</SecondaryButton>
<slot name="actions" />
</div>
</header>
</template>

<script setup lang="ts">
import SecondaryButton from '@/volt/SecondaryButton.vue';
import type { AgentConfigRevision } from '@/types/agent-config';
import type { SyncSummary } from '@/utils/agent-config/instance-status';
import { formatAbsolute, formatRelative } from '@/utils/agent-config/display';
import ConfigPill from './ConfigPill.vue';

withDefaults(
defineProps<{
config: AgentConfigRevision | null;
syncSummary: SyncSummary;
/** Every instance of the agent (the API's count), loaded or not. */
instanceCount: number;
loading?: boolean;
}>(),
{ loading: false },
);

defineEmits<{ refresh: []; 'select-instance': [id: string] }>();
</script>
135 changes: 135 additions & 0 deletions src/components/agents/config/AgentInstancePicker.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
<template>
<div class="space-y-2" data-test="instance-picker">
<div class="flex flex-wrap gap-2">
<button
v-for="item in pageItems"
:key="item.inst.instanceId"
type="button"
:class="[
itemClass(item.inst.instanceId),
{ 'opacity-70': item.inst.stale },
]"
:aria-pressed="item.inst.instanceId === selectedId"
:data-test="`pick-${item.inst.instanceId}`"
@click="$emit('select', item.inst.instanceId)"
>
<span class="font-mono text-xs">{{ label(item.inst) }}</span>
<ConfigPill v-if="item.inst.stale" severity="secondary"
>stale</ConfigPill
>
<InstanceStatusChip :state="item.state" />
</button>
</div>
<div
v-if="pageCount > 1"
class="flex items-center gap-2 text-xs text-gray-500 dark:text-slate-400"
data-test="picker-pager"
>
<button
type="button"
class="underline disabled:no-underline disabled:opacity-40"
:disabled="page === 0"
data-test="picker-prev"
@click="page--"
>
Previous
</button>
<span data-test="picker-range"
>{{ page * PICKER_PAGE_SIZE + 1 }}–{{
Math.min((page + 1) * PICKER_PAGE_SIZE, shown.length)
}}
of {{ shown.length }}</span
>
<button
type="button"
class="underline disabled:no-underline disabled:opacity-40"
:disabled="page >= pageCount - 1"
data-test="picker-next"
@click="page++"
>
Next
</button>
</div>
<button
v-if="staleItems.length"
type="button"
class="text-xs text-gray-500 underline dark:text-slate-400"
data-test="toggle-stale"
@click="showStale = !showStale"
>
{{ showStale ? 'Hide' : 'Show' }} {{ staleItems.length }} stale instance{{
staleItems.length === 1 ? '' : 's'
}}
</button>
</div>
</template>

<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import type { AgentInstanceSummary } from '@/types/agent-config';
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[];
selectedId: string | null;
}>();

defineEmits<{ select: [id: string] }>();

const items = computed(() =>
props.instances.map((inst, i) => ({ inst, state: props.states[i] })),
);
// Staleness is the API's flag (R14); stale instances are collapsed.
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 },
);

function itemClass(id: string): string {
const base =
'inline-flex items-center gap-2 rounded-md border px-2 py-1 text-left hover:bg-slate-50 dark:hover:bg-slate-800';
return id === props.selectedId
? `${base} border-sky-500 bg-sky-50 dark:border-sky-400 dark:bg-sky-500/10`
: `${base} border-ccf-300 dark:border-slate-700`;
}

function label(inst: AgentInstanceSummary): string {
return inst.hostname || inst.instanceId.slice(0, 8);
}
</script>
120 changes: 120 additions & 0 deletions src/components/agents/config/__tests__/InstanceListPaging.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// 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<AgentInstanceSummary> = {}) {
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<typeof picker>) =>
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()).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',
);
});

it('says nothing more when every instance is loaded', () => {
const w = header(instancesMixed.items, 7);
// 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);
});
});
Loading
Loading