diff --git a/packages/realm-server/handlers/handle-indexing-dashboard.ts b/packages/realm-server/handlers/handle-indexing-dashboard.ts index a4d7d9f2cb8..4d3efb6001e 100644 --- a/packages/realm-server/handlers/handle-indexing-dashboard.ts +++ b/packages/realm-server/handlers/handle-indexing-dashboard.ts @@ -57,17 +57,31 @@ function durationMs(startMs: number, endMs?: number): string { return `${minutes}m ${seconds % 60}s`; } -function renderActiveCard(state: RealmIndexingState): string { +// Index passes report themselves to the event sink as 'from-scratch' / +// 'incremental'; the prerender pass reports the queue's own job_type +// spelling, 'prerender_html'. +function isPrerenderJob(jobType: string): boolean { + return jobType === 'prerender_html'; +} + +function jobTypeLabel(jobType: string): string { + return isPrerenderJob(jobType) ? 'prerender HTML' : `${jobType} index`; +} + +function renderJobSection(state: RealmIndexingState): string { + let prerender = isPrerenderJob(state.jobType); let remaining = state.totalFiles - state.filesCompleted; let pct = state.totalFiles > 0 ? Math.round((state.filesCompleted / state.totalFiles) * 100) : 0; - // The job announces itself at kickoff with a total of 0 and fills it in - // once invalidation discovery + pre-warm have determined how much work + // An index job announces itself at kickoff with a total of 0 and fills it + // in once invalidation discovery + pre-warm have determined how much work // there is. Show a "calculating" state for that window instead of a - // misleading 0 / 0 (0%). - let calculating = state.status === 'indexing' && state.totalFiles === 0; + // misleading 0 / 0 (0%). A prerender job's URL set arrives fully computed + // in its args, so its total is real from the first event. + let calculating = + !prerender && state.status === 'indexing' && state.totalFiles === 0; const completedSet = new Set(state.completedFiles); let remainingFiles = state.files.filter((f) => !completedSet.has(f)); @@ -79,17 +93,13 @@ function renderActiveCard(state: RealmIndexingState): string { .join(''); return ` -
-
- -

${escapeHtml(state.realmURL)}

-
+
- ${escapeHtml(state.jobType)} index + ${escapeHtml(jobTypeLabel(state.jobType))} job #${state.jobId} · started ${timeSince(state.startedAt)} · ${durationMs(state.startedAt)} elapsed
-
+
${ calculating ? 'Calculating files to index…' @@ -104,7 +114,7 @@ function renderActiveCard(state: RealmIndexingState): string { ${ !calculating && remainingFiles.length > 0 ? `
- ${remaining} file${remaining !== 1 ? 's' : ''} left to index + ${remaining} file${remaining !== 1 ? 's' : ''} left to ${prerender ? 'render' : 'index'}
    ${remainingList}
` : '' @@ -117,6 +127,57 @@ function renderActiveCard(state: RealmIndexingState): string { ` : '' } +
`; +} + +function renderFinishedLine(state: RealmIndexingState): string { + return ` +
+ + ${escapeHtml(jobTypeLabel(state.jobType))} + job #${state.jobId} · ${state.totalFiles} file${state.totalFiles !== 1 ? 's' : ''} · finished ${timeSince(state.lastUpdatedAt)} +
`; +} + +function renderRealmCard( + realmURL: string, + jobs: RealmIndexingState[], + history: RealmIndexingState[], +): string { + // Both passes of the split indexing pipeline can be in flight for one + // realm at once (the index job spawns the prerender job), so a realm's + // card holds a section per active job — index first, prerender after, + // mirroring the pipeline order. + let indexJobs = jobs.filter((j) => !isPrerenderJob(j.jobType)); + let prerenderJobs = jobs.filter((j) => isPrerenderJob(j.jobType)); + + let sections: string[] = []; + if (indexJobs.length > 0) { + sections.push(...indexJobs.map(renderJobSection)); + } else { + // A prerender job is what's keeping this card alive, and the index + // pass that spawned it has already finished — show that pass as a + // compact ✓ line (from the event sink's history) so the realm reads + // as one story: "index ✓, prerender 40/93". There is no mirror-image + // line for a finished prerender while an index pass runs: that + // prerender belongs to the previous run, and a ✓ next to a running + // index would read as if this run's HTML were already done. + let finished = history.find( + (h) => h.realmURL === realmURL && !isPrerenderJob(h.jobType), + ); + if (finished) { + sections.push(renderFinishedLine(finished)); + } + } + sections.push(...prerenderJobs.map(renderJobSection)); + + return ` +
+
+ +

${escapeHtml(realmURL)}

+
+ ${sections.join('')}
`; } @@ -158,7 +219,18 @@ export interface DashboardSnapshot { export function renderIndexingDashboard(snapshot: DashboardSnapshot): string { let { active, pending, history } = snapshot; - let activeCards = active.map(renderActiveCard).join(''); + let activeByRealm = new Map(); + for (let state of active) { + let realmJobs = activeByRealm.get(state.realmURL); + if (!realmJobs) { + realmJobs = []; + activeByRealm.set(state.realmURL, realmJobs); + } + realmJobs.push(state); + } + let activeCards = [...activeByRealm] + .map(([realmURL, jobs]) => renderRealmCard(realmURL, jobs, history)) + .join(''); let pendingRows = pending.map(renderPendingRow).join(''); let historyRows = history.map(renderHistoryRow).join(''); @@ -266,7 +338,25 @@ export function renderIndexingDashboard(snapshot: DashboardSnapshot): string { color: #f0883e; text-transform: capitalize; } + .job-type.prerender { color: #a371f7; } .job-meta { color: #8b949e; font-size: 12px; } + .job-section + .job-section, + .job-done + .job-section, + .job-section + .job-done { + margin-top: 12px; + border-top: 1px solid #21262d; + padding-top: 12px; + } + .job-done { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + } + .job-done .done-check { + color: #3fb950; + font-weight: 700; + } .progress-bar-container { position: relative; background: #21262d; @@ -281,6 +371,9 @@ export function renderIndexingDashboard(snapshot: DashboardSnapshot): string { border-radius: 4px; transition: width 0.3s ease; } + .progress-bar.prerender { + background: linear-gradient(90deg, #6e40c9, #a371f7); + } /* "Calculating" state: full-width subdued bar with moving diagonal stripes, shown while the invalidation/pre-warm phase determines how much work the job has. The animation runs continuously across diff --git a/packages/realm-server/tests/index.ts b/packages/realm-server/tests/index.ts index 16cc1c18fb4..264057c7960 100644 --- a/packages/realm-server/tests/index.ts +++ b/packages/realm-server/tests/index.ts @@ -356,6 +356,7 @@ const ALL_TEST_FILES: string[] = [ './sanitize-head-html-test', './node-realm-test', './session-room-queries-test', + './indexing-dashboard-test', './indexing-event-sink-test', './skip-query-backed-expansion-test', './worker-request-signature-test', diff --git a/packages/realm-server/tests/indexing-dashboard-test.ts b/packages/realm-server/tests/indexing-dashboard-test.ts new file mode 100644 index 00000000000..2ebf5c2a0a9 --- /dev/null +++ b/packages/realm-server/tests/indexing-dashboard-test.ts @@ -0,0 +1,231 @@ +import QUnit from 'qunit'; +const { module, test } = QUnit; +import { basename } from 'path'; +import { + renderIndexingDashboard, + type DashboardSnapshot, + type PendingJob, +} from '../handlers/handle-indexing-dashboard.ts'; +import type { RealmIndexingState } from '../indexing-event-sink.ts'; + +const realmA = 'http://example.com/realm-a/'; +const realmB = 'http://example.com/realm-b/'; + +function jobState( + overrides: Partial = {}, +): RealmIndexingState { + return { + realmURL: realmA, + jobId: 1, + jobType: 'incremental', + status: 'indexing', + totalFiles: 10, + filesCompleted: 4, + files: [], + completedFiles: [], + startedAt: Date.now() - 5_000, + lastUpdatedAt: Date.now(), + ...overrides, + }; +} + +function snapshot(overrides: Partial = {}): string { + return renderIndexingDashboard({ + active: [], + pending: [], + history: [], + ...overrides, + }); +} + +function count(haystack: string, needle: string): number { + return haystack.split(needle).length - 1; +} + +module(basename(import.meta.filename), function () { + module('indexing dashboard rendering', function () { + test('a prerender job renders its own label and real totals, never the calculating state', function (assert) { + let html = snapshot({ + active: [ + jobState({ + jobId: 7, + jobType: 'prerender_html', + totalFiles: 93, + filesCompleted: 40, + }), + ], + }); + assert.true( + html.includes('prerender HTML'), + 'the job is labeled as a prerender job, not " index"', + ); + assert.false( + html.includes('prerender_html index'), + 'the raw queue job type is not conflated with the index label', + ); + assert.true( + html.includes('40 / 93 files (43%)'), + 'the progress fraction renders from the real totals', + ); + assert.true( + html.includes('class="progress-bar prerender"'), + 'the prerender progress bar carries its distinguishing class', + ); + }); + + test('a zero-total prerender job renders its counts rather than the calculating state', function (assert) { + let html = snapshot({ + active: [ + jobState({ + jobType: 'prerender_html', + totalFiles: 0, + filesCompleted: 0, + }), + ], + }); + assert.false( + html.includes('Calculating files to index'), + 'a prerender job announces its real total up front, so there is no calculating window', + ); + assert.true(html.includes('0 / 0 files (0%)')); + }); + + test('an index job announcing a zero total shows the calculating state', function (assert) { + let html = snapshot({ + active: [jobState({ totalFiles: 0, filesCompleted: 0 })], + }); + assert.true(html.includes('incremental index'), 'index label unchanged'); + assert.true(html.includes('Calculating files to index')); + assert.true(html.includes('class="progress-bar calculating"')); + }); + + test('concurrent index and prerender jobs for one realm group into a single card, index pass first', function (assert) { + let html = snapshot({ + active: [ + jobState({ + jobId: 8, + jobType: 'prerender_html', + totalFiles: 93, + filesCompleted: 40, + }), + jobState({ jobId: 7, totalFiles: 93, filesCompleted: 90 }), + ], + }); + assert.strictEqual( + count(html, 'class="realm-card indexing"'), + 1, + 'one card for the realm, not one per job', + ); + assert.strictEqual(count(html, 'class="job-section"'), 2); + assert.true( + html.indexOf('incremental index') < html.indexOf('prerender HTML'), + 'the index section renders before the prerender section regardless of event order', + ); + }); + + test('a finished index pass shows as a ✓ line while the prerender pass runs', function (assert) { + let html = snapshot({ + active: [ + jobState({ + jobId: 8, + jobType: 'prerender_html', + totalFiles: 93, + filesCompleted: 40, + }), + ], + history: [ + jobState({ + jobId: 7, + status: 'finished', + totalFiles: 93, + filesCompleted: 93, + }), + // Another realm's finished job must not leak into realm A's card. + jobState({ + jobId: 5, + jobType: 'from-scratch', + status: 'finished', + realmURL: realmB, + }), + ], + }); + assert.strictEqual(count(html, 'class="job-done"'), 1); + assert.true( + html.includes('incremental index'), + 'the ✓ line names the finished index pass', + ); + assert.false( + html.includes('from-scratch index'), + 'the other realm’s finished job stays out of this card', + ); + assert.true(html.includes('job #7 · 93 files · finished')); + assert.true( + html.indexOf('class="job-done"') < html.indexOf('class="job-section"'), + 'the finished index line renders above the running prerender section', + ); + }); + + test('a running index pass never shows a finished-prerender ✓ line', function (assert) { + let html = snapshot({ + active: [jobState()], + history: [ + // Same realm — but this prerender belongs to the previous run, + // and a ✓ next to a running index would read as if this run's + // HTML were already done. + jobState({ + jobId: 2, + jobType: 'prerender_html', + status: 'finished', + }), + ], + }); + assert.strictEqual(count(html, 'class="job-section"'), 1); + assert.strictEqual( + count(html, 'class="job-done"'), + 0, + 'the ✓ fallback is one-directional: finished index under a running prerender only', + ); + }); + + test('active jobs in different realms render separate cards', function (assert) { + let html = snapshot({ + active: [ + jobState(), + jobState({ + jobId: 2, + jobType: 'prerender_html', + realmURL: realmB, + }), + ], + }); + assert.strictEqual(count(html, 'class="realm-card indexing"'), 2); + }); + + test('pending and completed prerender jobs render in their tables under the queue job type', function (assert) { + let pending: PendingJob[] = [ + { + jobId: 12, + jobType: 'prerender_html', + realmURL: realmA, + createdAt: Date.now() - 1_000, + priority: 0, + }, + ]; + let html = snapshot({ + pending, + history: [ + jobState({ + jobId: 9, + jobType: 'prerender_html', + status: 'finished', + }), + ], + }); + assert.strictEqual( + count(html, 'prerender_html'), + 2, + 'one row in Pending Jobs, one in Recent Completed', + ); + }); + }); +}); diff --git a/packages/realm-server/worker-manager.ts b/packages/realm-server/worker-manager.ts index 8507c92abdd..c6c224e55a2 100644 --- a/packages/realm-server/worker-manager.ts +++ b/packages/realm-server/worker-manager.ts @@ -237,7 +237,7 @@ if (port != null) { `SELECT j.id, j.job_type, j.args, j.priority, EXTRACT(EPOCH FROM j.created_at) * 1000 AS created_at_ms`, `FROM jobs j`, `WHERE j.status = 'unfulfilled'`, - `AND j.job_type IN ('from-scratch-index', 'incremental-index')`, + `AND j.job_type IN ('from-scratch-index', 'incremental-index', 'prerender_html')`, `AND NOT EXISTS (`, ` SELECT 1 FROM job_reservations jr`, ` WHERE jr.job_id = j.id AND jr.completed_at IS NULL`,