Remove standalone Activity log page (Refs #153)

This commit is contained in:
2026-10-13 15:39:05 -04:00
parent 82e07ea33e
commit eceb38a6bc
21 changed files with 260 additions and 132 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ Campaigns retain managed-file deployment evidence; Collections define lineups.
when the running revision is unverified. when the running revision is unverified.
- **Policies & schedules** controls health recovery, configuration drift, automatic - **Policies & schedules** controls health recovery, configuration drift, automatic
Plutonium updates, and the optional daily restart schedule independently. Plutonium updates, and the optional daily restart schedule independently.
- **Activity** uses the shared Activity log, scoped to maintenance and recovery. - **Activity** shows retained history scoped to maintenance and recovery.
Filter retained findings, update results, and scheduled restarts in one timeline. Filter retained findings, update results, and scheduled restarts in one timeline.
Follow a finding for evidence or an operation for execution progress. Use Follow a finding for evidence or an operation for execution progress. Use
Overview’s **Active work & attention** to cancel waiting work or retry failed Overview’s **Active work & attention** to cancel waiting work or retry failed
+4
View File
@@ -525,6 +525,10 @@ local credentials already exist.
## Host and game-file API ## Host and game-file API
Open **Fleet → Hosts → Operation history** for retained operations across all
hosts, including deleted hosts. A host's **Operation history** action scopes the
same history to that host. Filters and pagination retain the selected scope.
- `GET /api/hosts/:id`: host details and deletion blockers. - `GET /api/hosts/:id`: host details and deletion blockers.
- `GET /api/hosts/operations?page=1&filter=important`: operation history across - `GET /api/hosts/operations?page=1&filter=important`: operation history across
hosts, newest first, with 25 summaries per page and filtered total/page counts. hosts, newest first, with 25 summaries per page and filtered total/page counts.
+17 -3
View File
@@ -1,8 +1,7 @@
# Provisioning # Provisioning
Open **Activity → Activity log** for retained lifecycle, host, campaign, and Open **Activity → Provisioning** for server queues, bulk cancellation, lifecycle
maintenance history. Use **Manage servers** from that page or command search actions, and retained lifecycle history. **Fleet → Collections** owns reusable lineups and collection launch
for server queues, bulk cancellation, and lifecycle actions. **Fleet → Collections** owns reusable lineups and collection launch
progress; their individual operations remain visible in Provisioning. progress; their individual operations remain visible in Provisioning.
Provisioning owns the queues, safety checks, execution, and Provisioning owns the queues, safety checks, execution, and
recovery for all six actions. Create a server using **Create server**. For an existing recovery for all six actions. Create a server using **Create server**. For an existing
@@ -305,6 +304,21 @@ query parameters. Collection launch links at `/provisioning/collections/:id` or
query parameters. `/api/deployments` remains compatible, including old campaign query parameters. `/api/deployments` remains compatible, including old campaign
detail endpoints. No history or IDs are renamed or discarded. detail endpoints. No history or IDs are renamed or discarded.
The standalone Activity log has been retired. Use a server's **History** tab for
its combined history, **Activity → Maintenance → Activity** for maintenance and
recovery, **Releases → Campaigns** or **Releases → Betas → History** for release
history, and **Fleet → Hosts → Operation history** for retained host operations.
Host operation history remains available after a host is deleted.
Legacy `/activity` and `/operations` bookmarks redirect to Provisioning history
by default. Server-scoped bookmarks open the server's History tab; host-scoped
bookmarks open host operation history. Source-only bookmarks open the matching
Campaigns, Betas, Maintenance, host operations, or autoscaling provisioning
history. Shared history filters are retained when opening server or Maintenance
history; pagination is reset when switching to a different kind of history.
`GET /api/activity` and the shared history views remain available. Historical
records, event collection, and retention are unchanged.
Start and Stop use durable `instance.start` and `instance.stop` host-agent Start and Stop use durable `instance.start` and `instance.stop` host-agent
operations. Rebuild uses `instance.rebuild` and replaces the instance files. operations. Rebuild uses `instance.rebuild` and replaces the instance files.
See [native host management](native-hosts.md) for command delivery and recovery. See [native host management](native-hosts.md) for command delivery and recovery.
+44
View File
@@ -0,0 +1,44 @@
const historyFilters = ['host', 'type', 'source', 'state', 'days', 'q', 'limit', 'offset'];
/** Route retired activity bookmarks to the existing history that owns their scope. */
export function activityRedirectHref(url: URL): string {
const params = new URLSearchParams();
const copy = (keys: string[]) => {
for (const key of keys) {
const value = url.searchParams.get(key);
if (value) params.set(key, value);
}
};
const server = url.searchParams.get('server')?.trim();
const host = url.searchParams.get('host')?.trim();
const source = url.searchParams.get('source');
let path: string;
let hash = '';
if (server) {
path = `/servers/${encodeURIComponent(server)}`;
params.set('tab', 'history');
copy(historyFilters);
hash = url.hash;
} else if (host || source === 'host') {
path = '/hosts/operations';
if (host) params.set('host', host);
} else if (source === 'maintenance' || source === 'recovery') {
path = '/maintenance';
params.set('view', 'activity');
copy(historyFilters);
hash = url.hash;
} else if (source === 'campaign') {
path = '/campaigns';
copy(['q']);
} else if (source === 'beta') {
path = '/betas';
params.set('view', 'history');
copy(['q']);
} else {
path = '/provisioning';
params.set('view', 'history');
if (source === 'autoscaling') params.set('source', source);
}
copy(['returnTo']);
return path + (params.size ? `?${params}` : '') + hash;
}
+4 -2
View File
@@ -1,3 +1,5 @@
import { activityRedirectHref } from './activity-redirect.js';
const origin = 'https://electric-cherry.invalid'; const origin = 'https://electric-cherry.invalid';
const pagePath = const pagePath =
/^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/; /^\/(?:$|activity|operations|autoscaling|account|bank|capacity|metrics|server-keys|settings(?:\/users)?|servers\/[^/]+|hosts(?:\/[^/]+)?|collections(?:\/launches\/[^/]+)?|(?:scripts|configs|plugins|betas|campaigns|maintenance|provisioning|matches)(?:\/[^/]+)?|game-files|leaderboard(?:\/players\/[^/]+(?:\/[^/]+)?)?)$/;
@@ -16,6 +18,8 @@ export function safeReturnHref(value: string | null | undefined, fallback: strin
try { try {
const url = new URL(value, origin); const url = new URL(value, origin);
if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback; if (url.origin !== origin || !pagePath.test(url.pathname)) return fallback;
if (url.pathname === '/activity' || url.pathname === '/operations')
return activityRedirectHref(url);
return url.pathname + url.search + url.hash; return url.pathname + url.search + url.hash;
} catch { } catch {
return fallback; return fallback;
@@ -45,8 +49,6 @@ export function returnLabel(href: string): string {
if (path.startsWith('/hosts/')) return 'Back to host'; if (path.startsWith('/hosts/')) return 'Back to host';
const names: Record<string, string> = { const names: Record<string, string> = {
'': 'Servers', '': 'Servers',
activity: 'Activity',
operations: 'Activity',
autoscaling: 'Autoscaling', autoscaling: 'Autoscaling',
collections: 'Collections', collections: 'Collections',
hosts: 'Hosts', hosts: 'Hosts',
+1 -9
View File
@@ -28,8 +28,7 @@ export const navigationGroups: NavigationGroup[] = [
label: 'Hosts', label: 'Hosts',
href: '/hosts', href: '/hosts',
shortcut: 'o', shortcut: 'o',
matches: (path) => matches: (path) => path === '/hosts' || path.startsWith('/hosts/')
path === '/hosts' || /^\/hosts\/(?!operations(?:\/|$))/.test(path)
}, },
{ label: 'Capacity', href: '/capacity' }, { label: 'Capacity', href: '/capacity' },
{ label: 'Autoscaling', href: '/autoscaling' }, { label: 'Autoscaling', href: '/autoscaling' },
@@ -52,13 +51,6 @@ export const navigationGroups: NavigationGroup[] = [
id: 'activity', id: 'activity',
label: 'Activity', label: 'Activity',
items: [ items: [
{
label: 'Activity log',
href: '/activity',
shortcut: 'd',
matches: (path) =>
path === '/activity' || path === '/operations' || path === '/hosts/operations'
},
{ {
label: 'Provisioning', label: 'Provisioning',
href: '/provisioning', href: '/provisioning',
+3 -9
View File
@@ -1,12 +1,6 @@
import { parseActivityQuery } from '$lib/activity.js'; import { redirect } from '@sveltejs/kit';
import { activityHistory } from '$lib/server/activity.js'; import { activityRedirectHref } from '$lib/activity-redirect.js';
export function load({ url }: { url: URL }) { export function load({ url }: { url: URL }) {
return { redirect(308, activityRedirectHref(url));
activity: activityHistory({
...parseActivityQuery(url.searchParams),
excludeProvisioning: true,
excludeMaintenance: true
})
};
} }
-23
View File
@@ -1,23 +0,0 @@
<script lang="ts">
import ActivityLog from '$ui/components/ActivityLog.svelte';
import SectionHeader from '$ui/components/primitives/SectionHeader.svelte';
let { data }: { data: import('./$types').PageData } = $props();
</script>
<svelte:head><title>Activity · Electric Cherry</title></svelte:head>
<div class="page">
<SectionHeader
title="Activity"
subtitle="Releases and host activity. Server jobs are in Provisioning; maintenance and recovery are in Maintenance history."
/>
<ActivityLog initial={data.activity} excludeProvisioning excludeMaintenance />
</div>
<style>
.page {
display: grid;
gap: var(--space-6);
min-width: 0;
}
</style>
+2 -1
View File
@@ -86,7 +86,8 @@
<svelte:head><title>Hosts · Electric Cherry</title></svelte:head> <svelte:head><title>Hosts · Electric Cherry</title></svelte:head>
<section class="page"> <section class="page">
<SectionHeader title="Hosts"> <SectionHeader title="Hosts">
{#snippet actions()}<Button href="/capacity?rebalance=1">Rebalance</Button {#snippet actions()}<Button href="/hosts/operations">Operation history</Button><Button
href="/capacity?rebalance=1">Rebalance</Button
>{#if canManage}<Button variant="primary" onclick={() => (addOpen = true)} >{#if canManage}<Button variant="primary" onclick={() => (addOpen = true)}
>Add host</Button >Add host</Button
>{/if}{/snippet} >{/if}{/snippet}
+2 -2
View File
@@ -141,7 +141,7 @@
<section class="page" aria-busy={changingPage}> <section class="page" aria-busy={changingPage}>
<SectionHeader <SectionHeader
title="Host operation history" title="Host operation history"
eyebrow="Operations" eyebrow="Hosts"
subtitle="Remote operations, newest first. Filtered views retain their host and server context." subtitle="Remote operations, newest first. Filtered views retain their host and server context."
> >
{#snippet actions()}<Freshness {#snippet actions()}<Freshness
@@ -152,7 +152,7 @@
/>{/snippet} />{/snippet}
</SectionHeader> </SectionHeader>
{#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink {#if data.scope.operation || $page.url.searchParams.has('returnTo')}<BackLink
fallback={data.scope.operation ? '/activity' : '/hosts'} fallback={data.scope.operation ? allOperationsHref : '/hosts'}
/>{/if} />{/if}
{#if data.scope.operation}<div class="selected-operation"> {#if data.scope.operation}<div class="selected-operation">
<div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div> <div><strong>Selected host operation</strong><code>{data.scope.operation}</code></div>
+3 -2
View File
@@ -1,5 +1,6 @@
import { redirect } from '@sveltejs/kit'; import { redirect } from '@sveltejs/kit';
import { activityRedirectHref } from '$lib/activity-redirect.js';
export function load() { export function load({ url }: { url: URL }) {
redirect(308, '/activity'); redirect(308, activityRedirectHref(url));
} }
-10
View File
@@ -837,11 +837,6 @@
{:else if activeSection === 'matches'} {:else if activeSection === 'matches'}
{#key name}<MatchHistory server={name} />{/key} {#key name}<MatchHistory server={name} />{/key}
{:else if activeSection === 'history'} {:else if activeSection === 'history'}
<div class="history-links">
<a href={contextHref(`/activity?server=${encodeURIComponent(name)}`, $page.url)}
>Open in Activity</a
>
</div>
{#key name}<ActivityLog server={name} />{/key} {#key name}<ActivityLog server={name} />{/key}
{/if} {/if}
</div> </div>
@@ -866,11 +861,6 @@
min-width: 0; min-width: 0;
scroll-margin-top: 80px; scroll-margin-top: 80px;
} }
.history-links {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.server-actions { .server-actions {
text-align: right; text-align: right;
} }
+40
View File
@@ -0,0 +1,40 @@
import { describe, expect, it } from 'vitest';
import { load as activity } from '../../src/routes/activity/+page.server.js';
import { load as operations } from '../../src/routes/operations/+page.server.js';
describe.each([
['/activity', activity],
['/operations', operations]
] as const)('retired %s page', (path, load) => {
it.each([
['', '/provisioning?view=history'],
['?source=provisioning', '/provisioning?view=history'],
['?source=autoscaling', '/provisioning?view=history&source=autoscaling'],
['?source=unknown&offset=100', '/provisioning?view=history'],
['?server=town', '/servers/town?tab=history'],
['?server=town%20%2F%201', '/servers/town%20%2F%201?tab=history'],
[
'?server=town&host=host-1&source=campaign&state=failed&days=7&q=files&limit=10&offset=20',
'/servers/town?tab=history&host=host-1&source=campaign&state=failed&days=7&q=files&limit=10&offset=20'
],
[
'?server=town&tab=overview&excludeProvisioning=true&excludeMaintenance=true#operation-1',
'/servers/town?tab=history#operation-1'
],
['?host=host%20%2F%201&offset=50', '/hosts/operations?host=host+%2F+1'],
['?server=%20&host=deleted-host', '/hosts/operations?host=deleted-host'],
['?source=host', '/hosts/operations'],
['?source=campaign&q=files&offset=50', '/campaigns?q=files'],
['?source=beta&q=release&offset=50', '/betas?view=history&q=release'],
[
'?source=maintenance&type=update&state=failed&offset=25',
'/maintenance?view=activity&type=update&source=maintenance&state=failed&offset=25'
],
['?source=recovery', '/maintenance?view=activity&source=recovery'],
['?returnTo=%2Fhosts', '/provisioning?view=history&returnTo=%2Fhosts']
])('redirects %s directly to %s', (query, location) => {
expect(() => load({ url: new URL(`https://ec.test${path}${query}`) })).toThrow(
expect.objectContaining({ status: 308, location })
);
});
});
+11 -15
View File
@@ -475,7 +475,7 @@ describe('unified retained Activity', () => {
expect(api.activityHistory({ source: 'campaign' }).total).toBe(1); expect(api.activityHistory({ source: 'campaign' }).total).toBe(1);
}); });
it('uses the same query contract for API and page loaders with no-store API responses', async () => { it('keeps the shared history query contract and no-store API responses', async () => {
const api = await setup(); const api = await setup();
api.operation('a'); api.operation('a');
api.operation('b', { state: 'failed' }); api.operation('b', { state: 'failed' });
@@ -483,23 +483,19 @@ describe('unified retained Activity', () => {
api.maintenance(1, 'failed'); api.maintenance(1, 'failed');
api.incident('finding', 'health', 'attention'); api.incident('finding', 'health', 'attention');
const url = new URL( const url = new URL(
'http://localhost/activity?state=failed&limit=1&excludeProvisioning=true&excludeMaintenance=true' 'http://localhost/api/activity?state=failed&limit=1&excludeProvisioning=true&excludeMaintenance=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 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))); const body = await response.json();
expect(load({ url }).activity.rows[0].id).toBe('host:failed-check'); expect(body).toEqual(
expect( JSON.parse(JSON.stringify(api.activityHistory(parseActivityQuery(url.searchParams))))
load({ );
url: new URL('http://localhost/activity?excludeProvisioning=false') expect(body.rows[0].id).toBe('host:failed-check');
}).activity.rows.some((row) => row.id.startsWith('provisioning:')) const history = await GET({ url: new URL('http://localhost/api/activity') }).json();
).toBe(false); expect(history.rows.map((row: { id: string }) => row.id)).toEqual(
expect( expect.arrayContaining(['provisioning:a', 'provisioning:b', 'host:failed-check'])
load({ );
url: new URL('http://localhost/activity?excludeMaintenance=false')
}).activity.rows.map((row) => row.id)
).toEqual(['host:failed-check']);
}); });
}); });
+18
View File
@@ -8,6 +8,24 @@ import {
} from '../../src/lib/ui/command-palette.js'; } from '../../src/lib/ui/command-palette.js';
describe('command palette destinations', () => { describe('command palette destinations', () => {
it('omits the retired Activity log while retaining the pages that own history', () => {
for (const role of ['admin', 'operator', 'viewer', 'super_viewer'] as const) {
const destinations = palettePageResults(role, '').map((item) => item.href);
expect(destinations).not.toContain('/activity');
expect(destinations).not.toContain('/operations');
expect(palettePageResults(role, 'Activity log')).toEqual([]);
expect(destinations).toEqual(
expect.arrayContaining([
'/provisioning',
'/maintenance',
'/campaigns',
'/betas',
'/hosts'
])
);
}
});
it('lets Super Viewers search administration pages without offering operations', () => { it('lets Super Viewers search administration pages without offering operations', () => {
const results = palettePageResults('super_viewer', ''); const results = palettePageResults('super_viewer', '');
expect(results.filter((item) => item.group === 'Actions')).toEqual([]); expect(results.filter((item) => item.group === 'Actions')).toEqual([]);
+15 -3
View File
@@ -20,13 +20,25 @@ describe('navigation context', () => {
expect(returnLabel('/collections?view=history&launches=30')).toBe('Back to launch history'); expect(returnLabel('/collections?view=history&launches=30')).toBe('Back to launch history');
}); });
it('retains Activity filters and a selected result when opening a detail', () => { it('retains Maintenance filters and a selected result when opening a detail', () => {
const source = '/activity?source=maintenance&state=failed&offset=25#operation-12'; const source = '/maintenance?view=activity&state=failed&offset=25#operation-12';
const target = new URL(contextHref('/provisioning/12', source), 'http://localhost'); const target = new URL(contextHref('/provisioning/12', source), 'http://localhost');
expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source); expect(safeReturnHref(target.searchParams.get('returnTo'), '/')).toBe(source);
expect(returnLabel(source)).toBe('Back to Activity'); expect(returnLabel(source)).toBe('Back to Maintenance');
}); });
it.each(['/activity', '/operations'])(
'replaces retired %s return links with their current history',
(path) => {
const source = `${path}?server=town&host=host-1&state=failed&offset=25`;
const expected = '/servers/town?tab=history&host=host-1&state=failed&offset=25';
expect(safeReturnHref(source, '/')).toBe(expected);
const target = new URL(contextHref('/provisioning/12', source), 'http://localhost');
expect(target.searchParams.get('returnTo')).toBe(expected);
expect(returnLabel(expected)).toBe('Back to server');
}
);
it('preserves the selected diagnostic tab on both sides of a contextual link', () => { it('preserves the selected diagnostic tab on both sides of a contextual link', () => {
const url = new URL( const url = new URL(
contextHref( contextHref(
+6 -6
View File
@@ -11,11 +11,10 @@ describe('application navigation', () => {
['/servers/t5_zombie_theater_1', 'fleet', '/'], ['/servers/t5_zombie_theater_1', 'fleet', '/'],
['/collections/launches/42', 'fleet', '/collections'], ['/collections/launches/42', 'fleet', '/collections'],
['/hosts/42', 'fleet', '/hosts'], ['/hosts/42', 'fleet', '/hosts'],
['/hosts/operations', 'activity', '/activity'], ['/hosts/operations', 'fleet', '/hosts'],
['/provisioning', 'activity', '/provisioning'], ['/provisioning', 'activity', '/provisioning'],
['/provisioning/job-1', 'activity', '/provisioning'], ['/provisioning/job-1', 'activity', '/provisioning'],
['/deployments/job-1', 'activity', '/provisioning'], ['/deployments/job-1', 'activity', '/provisioning'],
['/activity', 'activity', '/activity'],
['/betas/new', 'releases', '/betas'], ['/betas/new', 'releases', '/betas'],
['/campaigns/42', 'releases', '/campaigns'], ['/campaigns/42', 'releases', '/campaigns'],
['/configs/42', 'releases', '/configs'], ['/configs/42', 'releases', '/configs'],
@@ -33,14 +32,15 @@ describe('application navigation', () => {
).toEqual([href]); ).toEqual([href]);
}); });
it('keeps Operations reachable from Activity and redirects legacy overview links', async () => { it('keeps Provisioning and Maintenance in Activity without the retired log or shortcut', () => {
expect( expect(
navigationGroups navigationGroups
.find((group) => group.id === 'activity') .find((group) => group.id === 'activity')
?.items.map((item) => item.label) ?.items.map((item) => item.label)
).toEqual(['Activity log', 'Provisioning', 'Maintenance']); ).toEqual(['Provisioning', 'Maintenance']);
const { load } = await import('../../src/routes/operations/+page.server.js'); const items = navigationGroups.flatMap((group) => group.items);
expect(load).toThrow(expect.objectContaining({ status: 308, location: '/activity' })); expect(items.some((item) => ['/activity', '/operations'].includes(item.href))).toBe(false);
expect(items.some((item) => item.shortcut === 'd')).toBe(false);
}); });
it('keeps administration and its shortcuts out of operator and viewer navigation', () => { it('keeps administration and its shortcuts out of operator and viewer navigation', () => {
+17 -42
View File
@@ -1,10 +1,8 @@
import { beforeEach, afterEach, expect, it, vi } from 'vitest'; import { beforeEach, afterEach, expect, it, vi } from 'vitest';
import { render } from 'svelte/server'; import { render } from 'svelte/server';
import type { ComponentProps } from 'svelte';
import { writable, type Writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { page } from '$app/stores'; import { page } from '$app/stores';
import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte'; import ActivityLog from '../../src/lib/ui/components/ActivityLog.svelte';
import ActivityPage from '../../src/routes/activity/+page.svelte';
import { import {
activityApiHref, activityApiHref,
activityHistoryHref, activityHistoryHref,
@@ -13,7 +11,9 @@ import {
} from '../../src/lib/ui/activity-history.js'; } from '../../src/lib/ui/activity-history.js';
import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js'; import type { ActivityPage as ActivityReport, ActivityRow } from '../../src/lib/activity.js';
vi.mock('$app/stores', () => ({ page: writable({ url: new URL('http://localhost/activity') }) })); vi.mock('$app/stores', () => ({
page: writable({ url: new URL('http://localhost/servers/town?tab=history') })
}));
vi.mock('$app/navigation', () => ({ goto: vi.fn() })); vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
vi.mock('$ui/stores/fleet.js', () => ({ vi.mock('$ui/stores/fleet.js', () => ({
fleet: writable({ servers: [], operations: [], receivedAt: 0 }) fleet: writable({ servers: [], operations: [], receivedAt: 0 })
@@ -43,7 +43,7 @@ const report = (): ActivityReport => ({
offset: 0, offset: 0,
query: { limit: 25, offset: 0 } query: { limit: 25, offset: 0 }
}); });
function scope(path = '/activity') { function scope(path = '/servers/town?tab=history') {
(page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) }); (page as unknown as Writable<{ url: URL }>).set({ url: new URL(`http://localhost${path}`) });
} }
beforeEach(() => { beforeEach(() => {
@@ -62,7 +62,7 @@ it.each([
['90', '90', 'Last 90 days'], ['90', '90', 'Last 90 days'],
['14', '14', 'Last 14 days'] ['14', '14', 'Last 14 days']
])('selects the current date range for days=%s on desktop and mobile', (days, value, label) => { ])('selects the current date range for days=%s on desktop and mobile', (days, value, label) => {
scope(days ? `/activity?days=${days}` : '/activity'); scope(days ? `/servers/town?tab=history&days=${days}` : '/servers/town?tab=history');
const body = render(ActivityLog, { props: { initial: report() } }).body; const body = render(ActivityLog, { props: { initial: report() } }).body;
for (const suffix of ['', '-mobile']) { for (const suffix of ['', '-mobile']) {
const select = body.match( const select = body.match(
@@ -88,7 +88,8 @@ it('sends only supported normalized filters and enforces scoped identities', ()
expect(params.has('tab')).toBe(false); expect(params.has('tab')).toBe(false);
expect(params.has('returnTo')).toBe(false); expect(params.has('returnTo')).toBe(false);
expect( expect(
activityQuery(new URL('https://example.test/activity?type=unknown&days=0')).type activityQuery(new URL('https://example.test/servers/town?tab=history&type=unknown&days=0'))
.type
).toBeUndefined(); ).toBeUndefined();
}); });
@@ -112,7 +113,9 @@ it('resets pagination when filtering and preserves the containing page, hash, an
}); });
it('keeps provisioning excluded from log polling after filtering or clearing filters', () => { it('keeps provisioning excluded from log polling after filtering or clearing filters', () => {
const url = new URL('http://localhost/activity?type=create&excludeProvisioning=false'); const url = new URL(
'http://localhost/servers/town?tab=history&type=create&excludeProvisioning=false'
);
const scope = { excludeProvisioning: true }; const scope = { excludeProvisioning: true };
expect(new URL(activityApiHref(url, scope), url).searchParams.get('excludeProvisioning')).toBe( expect(new URL(activityApiHref(url, scope), url).searchParams.get('excludeProvisioning')).toBe(
'true' 'true'
@@ -125,7 +128,9 @@ it('keeps provisioning excluded from log polling after filtering or clearing fil
}); });
it('keeps maintenance excluded from log polling while preserving the Maintenance history scope', () => { it('keeps maintenance excluded from log polling while preserving the Maintenance history scope', () => {
const url = new URL('http://localhost/activity?source=maintenance&excludeMaintenance=false'); const url = new URL(
'http://localhost/servers/town?tab=history&source=maintenance&excludeMaintenance=false'
);
const logScope = { excludeProvisioning: true, excludeMaintenance: true }; const logScope = { excludeProvisioning: true, excludeMaintenance: true };
expect( expect(
new URL(activityApiHref(url, logScope), url).searchParams.get('excludeMaintenance') new URL(activityApiHref(url, logScope), url).searchParams.get('excludeMaintenance')
@@ -155,39 +160,8 @@ it('shows relative recent timestamps and preserves accurate older dates and inva
); );
}); });
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 ComponentProps<typeof ActivityPage>['data']
}
}).body;
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"');
expect(body).not.toContain('<option value="restart"');
for (const value of ['update', 'health', 'maintenance', 'recovery'])
expect(body).not.toContain(`<option value="${value}"`);
expect(body).toContain('<option value="drift"');
const viewer = render(ActivityPage, {
props: {
data: {
activity: report(),
user: { role: 'viewer' },
setupRequired: false
} as ComponentProps<typeof ActivityPage>['data']
}
}).body;
expect(viewer).not.toContain('Manage servers');
});
it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => { it('renders named operation links, shared source memberships, friendly resources, and one freshness indicator', () => {
scope('/activity?state=succeeded&offset=25'); scope('/servers/town?tab=history&state=succeeded&offset=25');
const data = report(); const data = report();
data.offset = 25; data.offset = 25;
const body = render(ActivityLog, { props: { initial: data } }).body; const body = render(ActivityLog, { props: { initial: data } }).body;
@@ -199,7 +173,7 @@ it('renders named operation links, shared source memberships, friendly resources
expect(body).toContain('shape-circle'); expect(body).toContain('shape-circle');
expect(body).toContain('15 s ago'); expect(body).toContain('15 s ago');
expect(body).toContain('datetime="2026-10-07T17:59:45Z"'); expect(body).toContain('datetime="2026-10-07T17:59:45Z"');
expect(body).toContain('returnTo=%2Factivity'); expect(body).toContain('returnTo=%2Fservers%2Ftown%3Ftab%3Dhistory');
expect(body).toContain('state%3Dsucceeded'); expect(body).toContain('state%3Dsucceeded');
expect(body).toContain('offset%3D25'); expect(body).toContain('offset%3D25');
expect(body).toContain('26–26 of 52'); expect(body).toContain('26–26 of 52');
@@ -209,6 +183,7 @@ it('renders named operation links, shared source memberships, friendly resources
}); });
it('preserves deleted server evidence without linking to a replacement instance', () => { it('preserves deleted server evidence without linking to a replacement instance', () => {
scope('/maintenance?view=activity');
const data = report(); const data = report();
data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }]; data.rows = [{ ...row, serverDeletedAt: '2026-10-07T17:59:46Z' }];
const body = render(ActivityLog, { props: { initial: data } }).body; const body = render(ActivityLog, { props: { initial: data } }).body;
@@ -239,7 +214,7 @@ it('offers one mobile filter sheet with distinct IDs and hides fixed scope contr
}); });
it('offers useful empty and out-of-range page recovery', () => { it('offers useful empty and out-of-range page recovery', () => {
scope('/activity?q=missing&offset=100'); scope('/servers/town?tab=history&q=missing&offset=100');
const data = report(); const data = report();
data.rows = []; data.rows = [];
data.offset = 100; data.offset = 100;
+64 -2
View File
@@ -1,8 +1,10 @@
import { describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from 'svelte/server'; import { render } from 'svelte/server';
import type { ComponentProps } from 'svelte'; import type { ComponentProps } from 'svelte';
import { writable } from 'svelte/store'; import { writable, type Writable } from 'svelte/store';
import { page } from '$app/stores';
import HostOperations from '../../src/routes/hosts/operations/+page.svelte'; import HostOperations from '../../src/routes/hosts/operations/+page.svelte';
import Hosts from '../../src/routes/hosts/+page.svelte';
import type { HostOperationSummary } from '../../src/lib/hosts.js'; import type { HostOperationSummary } from '../../src/lib/hosts.js';
vi.mock('$app/stores', () => ({ vi.mock('$app/stores', () => ({
@@ -11,6 +13,14 @@ vi.mock('$app/stores', () => ({
})); }));
vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn() })); vi.mock('$app/navigation', () => ({ beforeNavigate: vi.fn() }));
function setPage(path: string) {
(page as unknown as Writable<unknown>).set({
url: new URL(`http://localhost${path}`),
data: { user: { role: 'viewer' } }
});
}
beforeEach(() => setPage('/hosts/operations'));
const operation: HostOperationSummary = { const operation: HostOperationSummary = {
id: 'retained-op', id: 'retained-op',
hostId: 'host-1', hostId: 'host-1',
@@ -29,6 +39,58 @@ const operation: HostOperationSummary = {
}; };
describe('host operation history presentation', () => { describe('host operation history presentation', () => {
it('keeps fleet-wide operation history reachable even when all hosts have been deleted', () => {
setPage('/hosts');
const body = render(Hosts, {
props: {
data: { user: null, setupRequired: false, hosts: [], serverNow: Date.now() }
}
}).body;
expect(body).toContain('href="/hosts/operations"');
expect(body).toContain('Operation history');
});
it.each([
[
'/hosts/operations?operation=retained-op&host=host-1&filter=all',
'/hosts/operations?host=host-1&amp;filter=all',
'Back to host operation history'
],
[
'/hosts/operations?operation=retained-op&returnTo=%2Fservers%2Ftown%3Ftab%3Dhistory',
'/servers/town?tab=history',
'Back to server'
],
[
'/hosts/operations?operation=retained-op&returnTo=%2Factivity%3Fhost%3Dhost-1',
'/hosts/operations?host=host-1',
'Back to host operation history'
]
])('returns from a selected operation to existing history: %s', (path, href, label) => {
setPage(path);
const body = render(HostOperations, {
props: {
data: {
user: null,
setupRequired: false,
history: {
operations: [],
page: 1,
pageSize: 25,
totalPages: 1,
total: 0,
filter: 'all'
},
scope: { operation: operation.id, host: operation.hostId },
hostName: operation.hostName
}
}
}).body;
expect(body).toContain(`href="${href}"`);
expect(body).toContain(label);
expect(body).not.toContain('href="/activity');
});
it('uses readable operation, server and state labels while keeping identifiers in technical detail', () => { it('uses readable operation, server and state labels while keeping identifiers in technical detail', () => {
const body = render(HostOperations, { const body = render(HostOperations, {
props: { props: {
+5 -1
View File
@@ -31,7 +31,9 @@ describe('sidebar navigation', () => {
expect(body).toContain('Main navigation'); expect(body).toContain('Main navigation');
expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/); expect(body).toMatch(/<a[^>]*href="\/campaigns"[^>]*aria-current="page"/);
expect(body).toContain('href="/betas"'); expect(body).toContain('href="/betas"');
expect(body).toContain('href="/activity"'); expect(body).not.toContain('href="/activity"');
expect(body).toContain('href="/provisioning"');
expect(body).toContain('href="/maintenance"');
expect(body).toContain('href="/metrics"'); expect(body).toContain('href="/metrics"');
expect(body).toContain('href="/bank"'); expect(body).toContain('href="/bank"');
expect(body).not.toContain('href="/settings"'); expect(body).not.toContain('href="/settings"');
@@ -120,6 +122,8 @@ describe('application shell', () => {
expect(body).toContain('aria-label="Keyboard shortcuts"'); expect(body).toContain('aria-label="Keyboard shortcuts"');
expect(body).not.toContain('class="primary-navigation"'); expect(body).not.toContain('class="primary-navigation"');
expect(body).not.toContain('variant="secondary"'); expect(body).not.toContain('variant="secondary"');
expect(body).not.toContain('g d');
expect(body).not.toContain('Activity log');
}); });
it('preserves the viewer notice and hides navigation while a password change is required', () => { it('preserves the viewer notice and hides navigation while a password change is required', () => {
+3 -1
View File
@@ -164,7 +164,9 @@ it('keeps Super Viewer diagnostics readable without offering probes or lifecycle
it('uses the shared Activity view for server history', () => { it('uses the shared Activity view for server history', () => {
const body = pageBody('history'); const body = pageBody('history');
expect(body).toContain('Open in Activity'); expect(body).toContain('Activity filters');
expect(body).not.toContain('Open in Activity');
expect(body).not.toContain('href="/activity');
expect(body).not.toContain('Loading campaign history'); expect(body).not.toContain('Loading campaign history');
expect(body).not.toContain('>Provisioning history</a>'); expect(body).not.toContain('>Provisioning history</a>');
}); });