Separate provisioning jobs from activity log

This commit is contained in:
2026-10-07 23:15:55 -04:00
parent a223180835
commit 58375e6db0
10 changed files with 64 additions and 21 deletions
+2
View File
@@ -34,6 +34,7 @@ export interface ActivityRow {
} }
export interface ActivityQuery { export interface ActivityQuery {
excludeProvisioning?: boolean;
server?: string; server?: string;
host?: string; host?: string;
type?: ActivityType; type?: ActivityType;
@@ -67,6 +68,7 @@ export function parseActivityQuery(params: URLSearchParams): ActivityQuery {
limit: integer(params.get('limit'), 25, 1, 100), limit: integer(params.get('limit'), 25, 1, 100),
offset: integer(params.get('offset'), 0, 0, 1_000_000_000) offset: integer(params.get('offset'), 0, 0, 1_000_000_000)
}; };
if (params.get('excludeProvisioning') === 'true') query.excludeProvisioning = true;
for (const key of ['server', 'host', 'q'] as const) { for (const key of ['server', 'host', 'q'] as const) {
const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255); const value = params.get(key)?.trim().slice(0, key === 'q' ? 200 : 255);
if (value) query[key] = value; if (value) query[key] = value;
+2 -1
View File
@@ -20,7 +20,8 @@ export const navigationGroups: NavigationGroup[] = [
{ label: 'Campaigns', href: '/campaigns', shortcut: 'a' } { label: 'Campaigns', href: '/campaigns', shortcut: 'a' }
] }, ] },
{ id: 'activity', label: 'Activity', items: [ { id: 'activity', label: 'Activity', items: [
{ label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' || /^\/(provisioning|deployments)(\/|$)/.test(path) }, { label: 'Activity log', href: '/activity', shortcut: 'd', matches: path => path === '/activity' || path === '/operations' || path === '/hosts/operations' },
{ label: 'Provisioning', href: '/provisioning', matches: path => /^\/(provisioning|deployments)(\/|$)/.test(path) },
{ label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) } { label: 'Maintenance', href: '/maintenance', shortcut: 'w', matches: path => /^\/(maintenance|watchdog)(\/|$)/.test(path) }
] }, ] },
{ id: 'players', label: 'Players', items: [ { id: 'players', label: 'Players', items: [
+3
View File
@@ -151,6 +151,9 @@ export function activityHistory(input: ActivityQuery = {}, now = Date.now()): Ac
initialized.add(handle); initialized.add(handle);
} }
const conditions: string[] = []; const conditions: string[] = [];
// Keep lifecycle jobs in Provisioning, including jobs triggered by automation.
// Apply this before totals and pagination; resource histories can still include them.
if (query.excludeProvisioning) conditions.push("route <> 'provisioning'");
const bindings: Record<string, string | number> = {}; const bindings: Record<string, string | number> = {};
for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; } for (const key of ['server', 'type', 'state'] as const) if (query[key]) { conditions.push(`${key}=@${key}`); bindings[key] = query[key]; }
if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; } if (query.host) { conditions.push('(hostId=@host OR secondaryHostId=@host)'); bindings.host = query.host; }
+2 -1
View File
@@ -1,7 +1,7 @@
import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js'; import { parseActivityQuery, type ActivityQuery, type ActivitySource } from '../activity.js';
import { createTimeFormatter } from '../time-zone.js'; import { createTimeFormatter } from '../time-zone.js';
export interface ActivityScope { server?: string; source?: ActivitySource } export interface ActivityScope { server?: string; source?: ActivitySource; excludeProvisioning?: boolean }
const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const; const keys = ['server', 'host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'] as const;
/** Scoped views always retain their server/source, including after reset or a pasted URL. */ /** Scoped views always retain their server/source, including after reset or a pasted URL. */
@@ -9,6 +9,7 @@ export function activityQuery(url: URL, scope: ActivityScope = {}): ActivityQuer
const params = new URLSearchParams(url.searchParams); const params = new URLSearchParams(url.searchParams);
if (scope.server !== undefined) params.set('server', scope.server); if (scope.server !== undefined) params.set('server', scope.server);
if (scope.source !== undefined) params.set('source', scope.source); if (scope.source !== undefined) params.set('source', scope.source);
if (scope.excludeProvisioning !== undefined) params.set('excludeProvisioning', String(scope.excludeProvisioning));
return parseActivityQuery(params); return parseActivityQuery(params);
} }
+9 -7
View File
@@ -17,7 +17,7 @@
import Freshness from './Freshness.svelte'; import Freshness from './Freshness.svelte';
import HistoricalServerIdentity from './HistoricalServerIdentity.svelte'; import HistoricalServerIdentity from './HistoricalServerIdentity.svelte';
let { server, source, initial }: { server?: string; source?: ActivitySource; initial?: ActivityPage } = $props(); let { server, source, initial, excludeProvisioning = false }: { server?: string; source?: ActivitySource; initial?: ActivityPage; excludeProvisioning?: boolean } = $props();
const id = $props.id(); const id = $props.id();
const headingId = `${id}-activity-heading`; const headingId = `${id}-activity-heading`;
let data = $state<ActivityPage | undefined>(untrack(() => initial)); let data = $state<ActivityPage | undefined>(untrack(() => initial));
@@ -27,12 +27,14 @@
let filtersOpen = $state(false); let filtersOpen = $state(false);
let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null)); let checkedAt = $state<number | null>(untrack(() => initial ? Date.now() : null));
let results = $state<HTMLOListElement>(); let results = $state<HTMLOListElement>();
let search = $state(untrack(() => activityQuery($page.url, { server, source }).q ?? '')); let search = $state(untrack(() => activityQuery($page.url, { server, source, excludeProvisioning }).q ?? ''));
let pendingUrl: URL | undefined; let pendingUrl: URL | undefined;
let navigationId = 0; let navigationId = 0;
const filters = $derived(activityQuery($page.url, { server, source })); const filters = $derived(activityQuery($page.url, { server, source, excludeProvisioning }));
const apiUrl = $derived(activityApiHref($page.url, { server, source })); const apiUrl = $derived(activityApiHref($page.url, { server, source, excludeProvisioning }));
let loadedUrl = $state(untrack(() => initial ? apiUrl : '')); let loadedUrl = $state(untrack(() => initial ? apiUrl : ''));
const typeOptions = $derived(Object.entries(activityTypes).filter(([key]) => !excludeProvisioning || !['create', 'start', 'rebuild', 'stop', 'remove'].includes(key)));
const sourceOptions = $derived(Object.entries(activitySources).filter(([key]) => !excludeProvisioning || !['provisioning', 'autoscaling'].includes(key)));
const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length); const filterCount = $derived([filters.type, filters.state, source === undefined ? filters.source : '', server === undefined ? filters.server : '', filters.host, filters.days, filters.q].filter(Boolean).length);
const serverOptions = $derived.by(() => { const serverOptions = $derived.by(() => {
const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]); const names = new Set([...$fleet.servers.map(item => item.name), ...(data?.rows.flatMap(row => row.server ? [row.server] : []) ?? [])]);
@@ -89,9 +91,9 @@
<h2 class="sr-only" id={headingId}>Activity history</h2> <h2 class="sr-only" id={headingId}>Activity history</h2>
{#snippet filterFields(suffix: string)} {#snippet filterFields(suffix: string)}
<div class="filters"> <div class="filters">
<label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each Object.entries(activityTypes) as [value, label]}<option {value}>{label}</option>{/each}</select></label> <label for={`activity-type${suffix}`}>Type<select id={`activity-type${suffix}`} value={filters.type ?? ''} onchange={event => update({ type: event.currentTarget.value })}><option value="">All types</option>{#each typeOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label> <label for={`activity-state${suffix}`}>State<select id={`activity-state${suffix}`} value={filters.state ?? ''} onchange={event => update({ state: event.currentTarget.value })}><option value="">All states</option>{#each Object.entries(activityStates) as [value, label]}<option {value}>{label}</option>{/each}</select></label>
{#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each Object.entries(activitySources) as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if} {#if source === undefined}<label for={`activity-source${suffix}`}>Source<select id={`activity-source${suffix}`} value={filters.source ?? ''} onchange={event => update({ source: event.currentTarget.value })}><option value="">All sources</option>{#each sourceOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>{/if}
{#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if} {#if server === undefined}<label for={`activity-server${suffix}`}>Server<select id={`activity-server${suffix}`} value={filters.server ?? ''} onchange={event => update({ server: event.currentTarget.value })}><option value="">All servers</option>{#each serverOptions as name}<option value={name}>{serverTitle(name)}</option>{/each}</select></label>{/if}
<label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label> <label for={`activity-host${suffix}`}>Host<select id={`activity-host${suffix}`} value={filters.host ?? ''} onchange={event => update({ host: event.currentTarget.value })}><option value="">All hosts</option>{#each hostOptions as [value, label]}<option {value}>{label}</option>{/each}</select></label>
<label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label> <label for={`activity-days${suffix}`}>Date range<select id={`activity-days${suffix}`} value={filters.days ?? 0} onchange={event => update({ days: event.currentTarget.value })}><option value="0">All retained history</option><option value="1">Last 24 hours</option><option value="7">Last 7 days</option><option value="30">Last 30 days</option><option value="90">Last 90 days</option>{#if filters.days && ![1, 7, 30, 90].includes(filters.days)}<option value={filters.days}>Last {filters.days} days</option>{/if}</select></label>
@@ -123,7 +125,7 @@
</li> </li>
{/each} {/each}
</ol> </ol>
{:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : 'Server operations, file changes, maintenance, and host work appear here.'}> {:else if !error}<EmptyState title={filterCount ? 'No matching activity' : 'No activity recorded yet'} description={filterCount ? 'Try another type, state, server, host, or date range.' : excludeProvisioning ? 'File changes, maintenance, and host work appear here.' : 'Server operations, file changes, maintenance, and host work appear here.'}>
{#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet} {#snippet actions()}{#if filterCount}<Button size="sm" onclick={reset}>Clear filters</Button>{/if}{/snippet}
</EmptyState>{/if} </EmptyState>{/if}
{#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)} {#if data && !loading && loadedUrl === apiUrl && (data.total > 0 || data.offset > 0)}
+1 -1
View File
@@ -2,5 +2,5 @@ import { parseActivityQuery } from '$lib/activity.js';
import { activityHistory } from '$lib/server/activity.js'; import { activityHistory } from '$lib/server/activity.js';
export function load({ url }: { url: URL }) { export function load({ url }: { url: URL }) {
return { activity: activityHistory(parseActivityQuery(url.searchParams)) }; return { activity: activityHistory({ ...parseActivityQuery(url.searchParams), excludeProvisioning: true }) };
} }
+2 -5
View File
@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import ActivityLog from '$ui/components/ActivityLog.svelte'; import ActivityLog from '$ui/components/ActivityLog.svelte';
import Button from '$ui/components/primitives/Button.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte'; import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let { data }: { data: import('./$types').PageData } = $props(); let { data }: { data: import('./$types').PageData } = $props();
</script> </script>
@@ -8,10 +7,8 @@
<svelte:head><title>Activity · Electric Cherry</title></svelte:head> <svelte:head><title>Activity · Electric Cherry</title></svelte:head>
<div class="page"> <div class="page">
<SectionHeader title="Activity" subtitle="Server operations, releases, maintenance, and host work in one history."> <SectionHeader title="Activity" subtitle="Releases, maintenance, and host activity. Server jobs are in Provisioning." />
{#snippet actions()}{#if data.user && data.user.role !== 'viewer'}<Button href="/provisioning">Manage servers</Button>{/if}{/snippet} <ActivityLog initial={data.activity} excludeProvisioning />
</SectionHeader>
<ActivityLog initial={data.activity} />
</div> </div>
<style> <style>
+27 -2
View File
@@ -58,6 +58,29 @@ describe('Activity query parameters', () => {
}); });
describe('unified retained Activity', () => { describe('unified retained Activity', () => {
it('excludes provisioning jobs from log totals and pages regardless of action or trigger, while retaining contextual history', async () => {
const api = await setup();
for (const action of ['create', 'start', 'restart', 'rebuild', 'stop', 'destroy']) {
for (const source of ['ui', 'autoscaling:policy', 'maintenance:1', 'campaign:release', 'beta:1', 'watchdog:finding']) {
api.operation(`${action}-${source}`, { action, source });
}
}
api.remote('check', { kind: 'instance.inspect', routine: 1 });
api.incident('finding');
api.maintenance(2);
const query = { excludeProvisioning: true, limit: 2 };
const first = api.activityHistory(query);
const last = api.activityHistory({ ...query, offset: 2 });
expect(first.total).toBe(3);
expect(last.total).toBe(3);
expect(first.rows).toHaveLength(2);
expect(last.rows).toHaveLength(1);
expect([...first.rows, ...last.rows].map(row => row.id).sort()).toEqual(['host:check', 'maintenance:2', 'recovery:finding']);
expect(api.activityHistory({ ...query, type: 'create' }).total).toBe(0);
expect(api.activityHistory({ ...query, source: 'autoscaling' }).total).toBe(0);
expect(api.activityHistory({ server }).total).toBe(39);
});
it('paginates the full union with exact totals, stable ties and removed-server history', async () => { it('paginates the full union with exact totals, stable ties and removed-server history', async () => {
const api = await setup(); const api = await setup();
api.handle.transaction(() => { api.handle.transaction(() => {
@@ -205,12 +228,14 @@ describe('unified retained Activity', () => {
it('uses the same query contract for API and page loaders with no-store API responses', async () => { it('uses the same query contract for API and page loaders with no-store API responses', async () => {
const api = await setup(); const api = await setup();
api.operation('a'); api.operation('b', { state: 'failed' }); api.operation('a'); api.operation('b', { state: 'failed' });
const url = new URL('http://localhost/activity?state=failed&limit=1'); api.remote('failed-check', { kind: 'instance.inspect', routine: 1, state: 'failed' });
const url = new URL('http://localhost/activity?state=failed&limit=1&excludeProvisioning=true');
const { GET } = await import('../../src/routes/api/activity/+server.js'); const { GET } = await import('../../src/routes/api/activity/+server.js');
const { load } = await import('../../src/routes/activity/+page.server.js'); const { load } = await import('../../src/routes/activity/+page.server.js');
const response = GET({ url }); const response = GET({ url });
expect(response.headers.get('cache-control')).toBe('no-store'); expect(response.headers.get('cache-control')).toBe('no-store');
expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity))); expect(await response.json()).toEqual(JSON.parse(JSON.stringify(load({ url }).activity)));
expect(load({ url }).activity.rows[0].id).toBe('provisioning:b'); expect(load({ url }).activity.rows[0].id).toBe('host:failed-check');
expect(load({ url: new URL('http://localhost/activity?excludeProvisioning=false') }).activity.rows.some(row => row.id.startsWith('provisioning:'))).toBe(false);
}); });
}); });
+4 -2
View File
@@ -7,7 +7,9 @@ describe('application navigation', () => {
['/collections/launches/42', 'fleet', '/collections'], ['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'], ['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'activity', '/activity'], ['/hosts/operations', 'activity', '/activity'],
['/provisioning', 'activity', '/activity'], ['/provisioning', 'activity', '/provisioning'],
['/provisioning/job-1', 'activity', '/provisioning'],
['/deployments/job-1', 'activity', '/provisioning'],
['/activity', 'activity', '/activity'], ['/activity', 'activity', '/activity'],
['/betas/new', 'releases', '/betas'], ['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'releases', '/campaigns'], ['/campaigns/42', 'releases', '/campaigns'],
@@ -21,7 +23,7 @@ describe('application navigation', () => {
}); });
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => { it('keeps Operations reachable from Activity and redirects legacy overview links', async () => {
expect(navigationGroups.find(group => group.id === 'activity')?.items[0].href).toBe('/activity'); expect(navigationGroups.find(group => group.id === 'activity')?.items.map(item => item.label)).toEqual(['Activity log', 'Provisioning', 'Maintenance']);
const { load } = await import('../../src/routes/operations/+page.server.js'); const { load } = await import('../../src/routes/operations/+page.server.js');
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/activity' })); expect(load).toThrow(expect.objectContaining({ status: 308, location: '/activity' }));
}); });
+12 -2
View File
@@ -37,6 +37,15 @@ it('resets pagination when filtering and preserves the containing page, hash, an
expect(new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')).toBe('failed'); expect(new URL(activityHistoryHref(filtered, { offset: '25' }), current).searchParams.get('state')).toBe('failed');
}); });
it('keeps provisioning excluded from log polling after filtering or clearing filters', () => {
const url = new URL('http://localhost/activity?type=create&excludeProvisioning=false');
const scope = { excludeProvisioning: true };
expect(new URL(activityApiHref(url, scope), url).searchParams.get('excludeProvisioning')).toBe('true');
const cleared = new URL(activityHistoryHref(url, { type: '', source: '', offset: '' }), url);
expect(activityQuery(cleared, scope).excludeProvisioning).toBe(true);
expect(activityQuery(new URL('http://localhost/servers/town?tab=history')).excludeProvisioning).toBeUndefined();
});
it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => { it('shows relative recent timestamps and preserves accurate older dates and invalid evidence', () => {
const now = Date.parse('2026-10-07T18:00:00Z'); const now = Date.parse('2026-10-07T18:00:00Z');
expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago'); expect(activityTimeLabel('2026-10-07 17:59:45', 'America/New_York', now)).toBe('15 s ago');
@@ -47,9 +56,10 @@ it('shows relative recent timestamps and preserves accurate older dates and inva
expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2026, 2:00 PM EDT'); expect(activityTimeLabel('2026-10-08T18:00:00Z', 'America/New_York', now)).toBe('Oct 8, 2026, 2:00 PM EDT');
}); });
it('renders one page heading and keeps management tools available only to operators', () => { it('renders one page heading with provisioning management and filters removed', () => {
const body = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'admin' }, setupRequired: false } as any } }).body; const body = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'admin' }, setupRequired: false } as any } }).body;
expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).toContain('Manage servers'); expect(body).toContain('href="/provisioning"'); expect(body.match(/<h1[ >]/g)).toHaveLength(1); expect(body).not.toContain('Manage servers'); expect(body).not.toContain('href="/provisioning"');
expect(body).not.toContain('<option value="create"'); expect(body).not.toContain('<option value="provisioning"');
const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body; const viewer = render(ActivityPage, { props: { data: { activity: report(), user: { role: 'viewer' }, setupRequired: false } as any } }).body;
expect(viewer).not.toContain('Manage servers'); expect(viewer).not.toContain('Manage servers');
}); });