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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this

## [Unreleased]

### Fixed

- **Logs page unresponsiveness and freeze when loading older entries (#454):** Fixed microtask starvation caused by concurrent older-page requests during initial mount and background loading loops (`loadAll` and search). Active older-load requests now share the in-flight Promise across callers, older requests are bound to the store context sequence to discard stale responses, and background loading loops terminate when no progress is made. Also added a store `dispose()` method, called from the synchronous `onMount` cleanup in `Logs.svelte`, to abort background activity on page navigation. Closes #454.

## [v2026.10.00] - 2026-10-07

### Added
Expand Down
126 changes: 0 additions & 126 deletions CONTRIBUTIONS.md

This file was deleted.

78 changes: 78 additions & 0 deletions tests/logs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,4 +72,82 @@ test.describe('Activity Logs & Run Log Viewer', () => {
// Logs are cleared
await expect(page.getByText('No logs', { exact: true })).toBeVisible();
});

test('renders initial rows and remains responsive while older page is pending (#454)', async ({ page }) => {
// Generate 60 activity rows to exercise pagination (default batch is 30)
const rows = Array.from({ length: 60 }, (_, i) => {
const id = 60 - i;
return {
id,
level: 'info',
category: 'backup',
message: `Activity entry #${id}`,
details: null,
created_at: new Date(Date.UTC(2026, 7, 21, 15, 0, 0) + id * 1000).toISOString(),
};
});

let releaseGate: () => void = () => {};
const gateHeld = new Promise<void>(resolve => { releaseGate = resolve; });
let olderCalls = 0;
let gateReleased = false;

// Route-specific override registered after shared mock
await page.route('**/api/v1/activity*', async route => {
if (route.request().method() !== 'GET') {
return route.fallback();
}
const url = new URL(route.request().url());
const beforeIdStr = url.searchParams.get('before_id');
const beforeId = beforeIdStr ? parseInt(beforeIdStr, 10) : 0;
const limit = parseInt(url.searchParams.get('limit') || '30', 10);
const category = url.searchParams.get('category') || '';

if (beforeId > 0) {
olderCalls++;
if (!gateReleased) {
await gateHeld;
}
}

let filtered = rows;
if (category) filtered = filtered.filter(r => r.category === category);
if (beforeId > 0) filtered = filtered.filter(r => r.id < beforeId);
filtered.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() || b.id - a.id);
const pageRows = filtered.slice(0, limit);

await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(pageRows),
});
});

await page.goto('/#/logs');

// Initial rows render in the role="log" console
const logConsole = page.getByRole('log');
await expect(logConsole.locator('[data-entry-id="60"]')).toBeVisible();

// "Loading logs..." spinner disappears
await expect(page.getByText('Loading logs...')).toHaveCount(0);

// Search input remains interactive while older-page request is held
const searchInput = page.getByPlaceholder(/filter logs/i);
await expect(searchInput).toBeVisible();
await searchInput.fill('Entry #60');
await expect(searchInput).toHaveValue('Entry #60');
await searchInput.fill('');

// Release the gate
gateReleased = true;
releaseGate();

// Older rows load and older entries become visible
await expect(logConsole.locator('[data-entry-id="1"]')).toBeVisible();

// Request count remains bounded
expect(olderCalls).toBeGreaterThanOrEqual(1);
expect(olderCalls).toBeLessThanOrEqual(5);
});
});
Loading
Loading