- ${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'}
`
: ''
@@ -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 `
+
+
+ ${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`,