From d7aab5f4cff62abd41fc7cd2aed3c5714630d853 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 16:50:18 +0100 Subject: [PATCH 1/4] feat(core): expose media usage indexing progress --- packages/core/src/api/errors.ts | 3 + packages/core/src/api/handlers/index.ts | 2 + packages/core/src/api/handlers/media-usage.ts | 39 +++++ packages/core/src/api/openapi/document.ts | 21 +++ packages/core/src/api/schemas/media-usage.ts | 9 ++ packages/core/src/astro/integration/routes.ts | 4 + .../routes/api/admin/media-usage/progress.ts | 21 +++ packages/core/src/client/index.ts | 11 ++ .../src/database/repositories/media-usage.ts | 123 ++++++++++++++- .../media-usage-progress-plan.test.ts | 94 ++++++++++++ .../media-usage-read-repository.test.ts | 132 ++++++++++++++++ .../api/media-usage-progress-route.test.ts | 145 ++++++++++++++++++ packages/core/tests/unit/api/openapi.test.ts | 19 +++ .../core/tests/unit/client/client.test.ts | 24 +++ .../workerd/media-usage-activation-d1.test.ts | 7 + 15 files changed, 653 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/astro/routes/api/admin/media-usage/progress.ts create mode 100644 packages/core/tests/integration/database/media-usage-progress-plan.test.ts create mode 100644 packages/core/tests/unit/api/media-usage-progress-route.test.ts diff --git a/packages/core/src/api/errors.ts b/packages/core/src/api/errors.ts index fefbbb3cbd..de2f20f178 100644 --- a/packages/core/src/api/errors.ts +++ b/packages/core/src/api/errors.ts @@ -88,6 +88,8 @@ export const ErrorCode = { MEDIA_DELETE_ERROR: "MEDIA_DELETE_ERROR", MEDIA_USAGE_READ_ERROR: "MEDIA_USAGE_READ_ERROR", MEDIA_USAGE_REPAIR_ERROR: "MEDIA_USAGE_REPAIR_ERROR", + MEDIA_USAGE_PROGRESS_READ_ERROR: "MEDIA_USAGE_PROGRESS_READ_ERROR", + MEDIA_USAGE_PROGRESS_NOT_ACTIVE: "MEDIA_USAGE_PROGRESS_NOT_ACTIVE", MEDIA_USAGE_WORK_LIST_ERROR: "MEDIA_USAGE_WORK_LIST_ERROR", MEDIA_USAGE_WORK_RETRY_ERROR: "MEDIA_USAGE_WORK_RETRY_ERROR", MEDIA_USAGE_COLLECTION_DELETION_LIST_ERROR: "MEDIA_USAGE_COLLECTION_DELETION_LIST_ERROR", @@ -469,6 +471,7 @@ export function mapErrorStatus(code: string | undefined): number { case ErrorCode.MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH: case ErrorCode.MEDIA_USAGE_ACTIVATION_BUSY: case ErrorCode.MEDIA_USAGE_ACTIVATION_CONFLICT: + case ErrorCode.MEDIA_USAGE_PROGRESS_NOT_ACTIVE: return 409; // 410 Gone diff --git a/packages/core/src/api/handlers/index.ts b/packages/core/src/api/handlers/index.ts index a18426d839..746472bbe7 100644 --- a/packages/core/src/api/handlers/index.ts +++ b/packages/core/src/api/handlers/index.ts @@ -64,6 +64,7 @@ export { export { aggregateMediaUsageCoverageStatus, handleMediaUsageDetails, + handleMediaUsageProgress, handleMediaUsageSummaries, handleMediaUsageRepair, toMediaUsageRepairResponse, @@ -72,6 +73,7 @@ export { type MediaUsageDetailsResponse, type MediaUsageEntryDetail, type MediaUsageOccurrenceDetail, + type MediaUsageProgress, type MediaUsageRepairResponse, type MediaUsageSourceDetail, type MediaUsageSummary, diff --git a/packages/core/src/api/handlers/media-usage.ts b/packages/core/src/api/handlers/media-usage.ts index 7e2bd98547..67a3017c17 100644 --- a/packages/core/src/api/handlers/media-usage.ts +++ b/packages/core/src/api/handlers/media-usage.ts @@ -8,6 +8,7 @@ import { import { MediaRepository } from "../../database/repositories/media.js"; import { InvalidCursorError } from "../../database/repositories/types.js"; import type { Database } from "../../database/types.js"; +import { MediaUsageActivationVersionMismatchError } from "../../media/usage/activation.js"; import { CONTENT_MEDIA_USAGE_ADAPTER_ID, CONTENT_MEDIA_USAGE_COLLECTION_SCOPE, @@ -26,6 +27,7 @@ import type { MediaUsageDetailsResponse, MediaUsageEntryDetail, MediaUsageOccurrenceDetail, + MediaUsageProgress, MediaUsageRepairRequest, MediaUsageRepairResponse, MediaUsageSummary, @@ -38,6 +40,7 @@ export type { MediaUsageDetailsResponse, MediaUsageEntryDetail, MediaUsageOccurrenceDetail, + MediaUsageProgress, MediaUsageSourceDetail, MediaUsageRepairRequest, MediaUsageRepairResponse, @@ -48,6 +51,42 @@ type ContentMediaUsageRepairResult = | ContentMediaUsageRepairCollectionResult | ContentMediaUsageRepairAllResult; +export async function handleMediaUsageProgress( + db: Kysely, +): Promise> { + try { + const progress = await new MediaUsageRepository(db).findCollectionProgress(); + if (!progress) { + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_PROGRESS_NOT_ACTIVE, + message: "Media Usage is not active", + }, + }; + } + return { success: true, data: progress }; + } catch (error) { + if (error instanceof MediaUsageActivationVersionMismatchError) { + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH, + message: "Media Usage activation version does not match this runtime", + }, + }; + } + console.error("[media-usage] progress read failed:", error); + return { + success: false, + error: { + code: ErrorCode.MEDIA_USAGE_PROGRESS_READ_ERROR, + message: "Failed to read media usage progress", + }, + }; + } +} + export function aggregateMediaUsageCoverageStatus( scopes: readonly MediaUsageCollectionIndexStatusScope[], ): MediaUsageCoverageStatus { diff --git a/packages/core/src/api/openapi/document.ts b/packages/core/src/api/openapi/document.ts index 37a0125000..a005ee1200 100644 --- a/packages/core/src/api/openapi/document.ts +++ b/packages/core/src/api/openapi/document.ts @@ -40,6 +40,7 @@ import { import { mediaUsageDetailsQuery, mediaUsageDetailsResponseSchema, + mediaUsageProgressSchema, mediaUsageCollectionDeletionListQuery, mediaUsageCollectionDeletionListResponseSchema, mediaUsageCollectionDeletionRetryBody, @@ -806,6 +807,26 @@ function buildMediaPaths(maxUploadSize: number) { }, }, }, + "/_emdash/api/admin/media-usage/progress": { + get: { + operationId: "getMediaUsageProgress", + summary: "Get media usage indexing progress", + description: + "Returns aggregate indexing readiness for current content collections after controlled activation is active. Requires `schema:manage`; bearer tokens also require the `admin` scope.", + tags: ["Media"], + responses: { + "200": { + description: "Aggregate media usage indexing progress", + content: { + [JSON_CONTENT]: { schema: successEnvelope(mediaUsageProgressSchema) }, + }, + }, + ...authErrors, + ...standardErrors(409), + ...standardErrors(500), + }, + }, + }, "/_emdash/api/admin/media-usage/work": { get: { operationId: "listMediaUsageWork", diff --git a/packages/core/src/api/schemas/media-usage.ts b/packages/core/src/api/schemas/media-usage.ts index d09345fb34..3ed49d89c5 100644 --- a/packages/core/src/api/schemas/media-usage.ts +++ b/packages/core/src/api/schemas/media-usage.ts @@ -13,6 +13,14 @@ export const mediaUsageCoverageSchema = z }) .meta({ id: "MediaUsageCoverage" }); +export const mediaUsageProgressSchema = z + .object({ + status: z.enum(["indexing", "ready", "needs_attention"]), + readyCollections: z.number().int().min(0), + totalCollections: z.number().int().min(0), + }) + .meta({ id: "MediaUsageProgress" }); + export const mediaUsageSummarySchema = z .object({ count: z.number().int().min(0).nullable(), @@ -280,6 +288,7 @@ export const mediaUsageCollectionDeletionRetryResponseSchema = z export type MediaUsageRepairRequest = z.infer; export type MediaUsageRepairResponse = z.infer; +export type MediaUsageProgress = z.infer; export type MediaUsageWorkListQuery = z.infer; export type MediaUsageWorkItem = z.infer; export type MediaUsageWorkListResponse = z.infer; diff --git a/packages/core/src/astro/integration/routes.ts b/packages/core/src/astro/integration/routes.ts index 9dc1db9957..2870a3ddda 100644 --- a/packages/core/src/astro/integration/routes.ts +++ b/packages/core/src/astro/integration/routes.ts @@ -262,6 +262,10 @@ export function injectCoreRoutes( pattern: "/_emdash/api/admin/media-usage/activation", entrypoint: resolveRoute("api/admin/media-usage/activation.ts"), }); + injectRoute({ + pattern: "/_emdash/api/admin/media-usage/progress", + entrypoint: resolveRoute("api/admin/media-usage/progress.ts"), + }); injectRoute({ pattern: "/_emdash/api/admin/media-usage/collection-deletions", entrypoint: resolveRoute("api/admin/media-usage/collection-deletions/index.ts"), diff --git a/packages/core/src/astro/routes/api/admin/media-usage/progress.ts b/packages/core/src/astro/routes/api/admin/media-usage/progress.ts new file mode 100644 index 0000000000..4edd4b1ec5 --- /dev/null +++ b/packages/core/src/astro/routes/api/admin/media-usage/progress.ts @@ -0,0 +1,21 @@ +import type { APIRoute } from "astro"; + +import { requirePerm } from "#api/authorize.js"; +import { requireDb, unwrapResult } from "#api/error.js"; +import { handleMediaUsageProgress } from "#api/handlers/media-usage.js"; +import { requireScope } from "#auth/scopes.js"; + +export const prerender = false; + +export const GET: APIRoute = async ({ locals }) => { + const { emdash, user } = locals; + const dbErr = requireDb(emdash?.db); + if (dbErr) return dbErr; + + const denied = requirePerm(user, "schema:manage"); + if (denied) return denied; + const scopeDenied = requireScope(locals, "admin"); + if (scopeDenied) return scopeDenied; + + return unwrapResult(await handleMediaUsageProgress(emdash.db)); +}; diff --git a/packages/core/src/client/index.ts b/packages/core/src/client/index.ts index 9d152d4554..8c4c197fb6 100644 --- a/packages/core/src/client/index.ts +++ b/packages/core/src/client/index.ts @@ -228,6 +228,12 @@ export interface MediaUsageRepairResponse { collections: MediaUsageRepairCollectionSummary[]; } +export interface MediaUsageProgress { + status: "indexing" | "ready" | "needs_attention"; + readyCollections: number; + totalCollections: number; +} + /** Durable media usage entry-work state */ export type MediaUsageWorkState = "pending" | "retry" | "leased" | "failed"; @@ -909,6 +915,11 @@ export class EmDashClient { return this.request("POST", "/admin/media-usage/repair", input); } + /** Read aggregate Media Usage indexing progress */ + async mediaGetUsageProgress(): Promise { + return this.request("GET", "/admin/media-usage/progress"); + } + /** Read the redacted controlled-activation status */ async mediaGetUsageActivation(): Promise { return this.request("GET", "/admin/media-usage/activation"); diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 49ccb3f6f4..4ecf470629 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -9,9 +9,17 @@ import { } from "kysely"; import { ulid } from "ulidx"; +import { + MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION, + MediaUsageActivationVersionMismatchError, +} from "../../media/usage/activation.js"; import { isMediaUsageProjectionFingerprint } from "../../media/usage/projection-fingerprint.js"; import type { MediaUsageContentSourceVariant } from "../../media/usage/source-key.js"; -import type { MediaKind, MediaUsageReferenceType } from "../../media/usage/types.js"; +import { + CONTENT_SOURCE_SCHEMA_VERSION, + type MediaKind, + type MediaUsageReferenceType, +} from "../../media/usage/types.js"; import { chunks, SQL_BATCH_SIZE } from "../../utils/chunks.js"; import { isPostgres } from "../dialect-helpers.js"; import { withTransaction } from "../transaction.js"; @@ -340,6 +348,12 @@ export interface MediaUsageCollectionIndexStatusScope { reconciliationRequired: boolean; } +export interface MediaUsageCollectionProgress { + status: "indexing" | "ready" | "needs_attention"; + readyCollections: number; + totalCollections: number; +} + export interface MediaUsageEntrySource { source: MediaUsageSource; occurrences: MediaUsageOccurrence[]; @@ -762,6 +776,113 @@ export class MediaUsageRepository { })); } + async findCollectionProgress(): Promise { + const result = await sql<{ + activation_active: boolean | number; + activation_generation: number | string | null; + needs_attention: boolean | number; + ready_collections: number | string; + total_collections: number | string; + }>` + WITH collection_progress AS ( + SELECT + CASE WHEN status.status = 'complete' + AND status.schema_version = ${CONTENT_SOURCE_SCHEMA_VERSION} + AND status.reconciliation_required = 0 + AND status.capture_state = 'active' + AND NOT EXISTS ( + SELECT 1 FROM _emdash_media_usage_work AS work + WHERE work.collection_id = collection.id + AND work.collection_slug = collection.slug + ) + THEN 1 ELSE 0 END AS is_ready, + CASE WHEN status.collection_id IS NULL + OR COALESCE(status.capture_state, '') <> 'active' + OR status.status NOT IN ('complete', 'never', 'running', 'partial', 'failed', 'stale') + OR status.status = 'failed' + OR ( + status.reconciliation_required = 0 + AND ( + status.status <> 'complete' + OR COALESCE(status.schema_version, -1) <> ${CONTENT_SOURCE_SCHEMA_VERSION} + ) + AND NOT EXISTS ( + SELECT 1 FROM _emdash_media_usage_work AS work + WHERE work.collection_id = collection.id + AND work.collection_slug = collection.slug + ) + ) + OR EXISTS ( + SELECT 1 FROM _emdash_media_usage_work AS work + WHERE work.collection_id = collection.id + AND work.collection_slug = collection.slug + AND work.state = 'failed' + ) + OR EXISTS ( + SELECT 1 FROM _emdash_media_usage_reconciliations AS reconciliation + WHERE reconciliation.collection_id = collection.id + AND reconciliation.collection_slug = collection.slug + AND reconciliation.state = 'failed' + AND status.reconciliation_required = 1 + ) + THEN 1 ELSE 0 END AS needs_attention + FROM _emdash_collections AS collection + LEFT JOIN _emdash_media_usage_index_status AS status + ON status.adapter_id = 'content-media' + AND status.scope_type = 'collection' + AND status.collection_id = collection.id + AND status.scope_key = collection.slug + WHERE NOT EXISTS ( + SELECT 1 FROM _emdash_media_usage_collection_deletions AS deletion + WHERE deletion.collection_id = collection.id + AND deletion.collection_slug = collection.slug + ) + ) + SELECT + ( + SELECT activation.runtime_generation + FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = 'incremental_capture' + ) AS activation_generation, + EXISTS ( + SELECT 1 FROM _emdash_media_usage_activation AS activation + WHERE activation.task_key = 'incremental_capture' + AND activation.state = 'active' + ) AS activation_active, + COUNT(*) AS total_collections, + COALESCE(SUM(is_ready), 0) AS ready_collections, + COALESCE(MAX(needs_attention), 0) AS needs_attention + FROM collection_progress + `.execute(this.db); + const row = result.rows[0]; + if (!row) throw new Error("Media usage progress query returned no result"); + if (!row.activation_active) return null; + if (Number(row.activation_generation) !== MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION) { + throw new MediaUsageActivationVersionMismatchError( + "Media usage activation runtime generation is incompatible", + ); + } + const readyCollections = Number(row.ready_collections); + const totalCollections = Number(row.total_collections); + if ( + !Number.isSafeInteger(readyCollections) || + !Number.isSafeInteger(totalCollections) || + readyCollections < 0 || + totalCollections < readyCollections + ) { + throw new Error("Media usage progress query returned invalid counts"); + } + return { + status: row.needs_attention + ? "needs_attention" + : readyCollections === totalCollections + ? "ready" + : "indexing", + readyCollections, + totalCollections, + }; + } + async findCurrentEntryUsagePageByMediaId( mediaId: string, options: FindMediaUsageOptions = {}, diff --git a/packages/core/tests/integration/database/media-usage-progress-plan.test.ts b/packages/core/tests/integration/database/media-usage-progress-plan.test.ts new file mode 100644 index 0000000000..89d7ed66d5 --- /dev/null +++ b/packages/core/tests/integration/database/media-usage-progress-plan.test.ts @@ -0,0 +1,94 @@ +import Database from "better-sqlite3"; +import { Kysely, SqliteDialect } from "kysely"; +import { afterEach, beforeEach, expect, it } from "vitest"; + +import { runMigrations } from "../../../src/database/migrations/runner.js"; +import { MediaUsageRepository } from "../../../src/database/repositories/media-usage.js"; +import type { Database as DatabaseSchema } from "../../../src/database/types.js"; + +interface CapturedQuery { + sql: string; + parameters: readonly unknown[]; +} + +let sqlite: Database.Database; +let db: Kysely; +let repo: MediaUsageRepository; +let captured: CapturedQuery[]; + +beforeEach(async () => { + captured = []; + sqlite = new Database(":memory:"); + db = new Kysely({ + dialect: new SqliteDialect({ database: sqlite }), + log(event) { + if (event.level === "query") { + captured.push({ sql: event.query.sql, parameters: event.query.parameters }); + } + }, + }); + await runMigrations(db); + repo = new MediaUsageRepository(db); +}); + +afterEach(async () => { + await db.destroy(); +}); + +it("keeps aggregate progress to one indexed metadata statement as collections grow", async () => { + for (let offset = 0; offset < 1_000; offset += 50) { + const collections = Array.from({ length: 50 }, (_, index) => { + const suffix = String(offset + index).padStart(4, "0"); + return { + id: `collection-${suffix}`, + slug: `collection_${suffix}`, + label: `Collection ${suffix}`, + has_seo: 0, + }; + }); + await db.insertInto("_emdash_collections").values(collections).execute(); + await db + .insertInto("_emdash_media_usage_index_status") + .values( + collections.map((collection) => ({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: collection.slug, + status: "complete", + schema_version: 1, + collection_id: collection.id, + reconciliation_required: 0, + capture_state: "active", + })), + ) + .execute(); + } + await db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active" }) + .where("task_key", "=", "incremental_capture") + .execute(); + captured = []; + + await expect(repo.findCollectionProgress()).resolves.toEqual({ + status: "ready", + readyCollections: 1_000, + totalCollections: 1_000, + }); + + expect(captured).toHaveLength(1); + const query = captured[0]!; + expect(query.parameters.length).toBeLessThan(10); + expect(query.sql).not.toContain("ec_"); + expect(query.sql).not.toContain("_emdash_media_usage_sources"); + expect(query.sql).not.toMatch(/FROM ["`]?_emdash_media_usage["`]?\s/i); + const plan = sqlite + .prepare(`EXPLAIN QUERY PLAN ${query.sql}`) + .all(...query.parameters) + .map((row) => (row as { detail: string }).detail) + .join("\n"); + expect(plan).toContain("idx__emdash_media_usage_status_collection"); + expect(plan).toContain("idx__emdash_media_usage_work_operator"); + expect(plan).toMatch(/SEARCH reconciliation USING INDEX .*reconciliations/); + expect(plan).not.toContain("USE TEMP B-TREE"); +}); diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts index 51503d836b..e16877d406 100644 --- a/packages/core/tests/integration/database/media-usage-read-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -357,6 +357,117 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { ]); }); + it("summarizes current collection indexing progress and terminal work", async () => { + await registerCollection(ctx, "pages"); + await registerCollection(ctx, "posts"); + await insertCollectionStatus(ctx, "pages", "complete", 0); + await insertCollectionStatus(ctx, "posts", "stale", 1); + + const progress = () => repo.findCollectionProgress(); + await expect(progress()).resolves.toBeNull(); + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active" }) + .where("task_key", "=", "incremental_capture") + .execute(); + + await expect(progress()).resolves.toEqual({ + status: "indexing", + readyCollections: 1, + totalCollections: 2, + }); + + await ctx.db + .insertInto("_emdash_media_usage_work") + .values({ + collection_id: "collection-posts", + collection_slug: "posts", + content_id: "failed-entry", + change_epoch: 1, + state: "failed", + next_attempt_at: "2026-08-16T00:00:00.000Z", + last_error_code: "MEDIA_USAGE_PROCESSING_FAILED", + }) + .execute(); + await expect(progress()).resolves.toEqual({ + status: "needs_attention", + readyCollections: 1, + totalCollections: 2, + }); + + await ctx.db.deleteFrom("_emdash_media_usage_work").execute(); + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ status: "complete", reconciliation_required: 0 }) + .where("collection_id", "=", "collection-posts") + .execute(); + await expect(progress()).resolves.toEqual({ + status: "ready", + readyCollections: 2, + totalCollections: 2, + }); + + await ctx.db + .insertInto("_emdash_media_usage_reconciliations") + .values({ + collection_id: "collection-posts", + collection_slug: "posts", + run_token: "obsolete-failed-run", + state: "failed", + next_attempt_at: "2026-08-16T00:00:00.000Z", + last_error_code: "MEDIA_USAGE_RECONCILIATION_FAILED", + }) + .execute(); + await expect(progress()).resolves.toEqual({ + status: "ready", + readyCollections: 2, + totalCollections: 2, + }); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: null }) + .where("collection_id", "=", "collection-pages") + .execute(); + await expect(progress()).resolves.toEqual({ + status: "needs_attention", + readyCollections: 1, + totalCollections: 2, + }); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ capture_state: "active" }) + .where("collection_id", "=", "collection-pages") + .execute(); + await ctx.db + .insertInto("_emdash_media_usage_collection_deletions") + .values({ + collection_id: "collection-posts", + collection_slug: "posts", + force_delete: 0, + state: "pending", + next_attempt_at: "2026-08-16T00:00:00.000Z", + }) + .execute(); + await expect(progress()).resolves.toEqual({ + status: "ready", + readyCollections: 1, + totalCollections: 1, + }); + + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ schema_version: 0 }) + .where("collection_id", "=", "collection-pages") + .execute(); + await expect(progress()).resolves.toEqual({ + status: "needs_attention", + readyCollections: 0, + totalCollections: 1, + }); + }); + it("paginates complete entry groups with nested sources and occurrences", async () => { await registerCollection(ctx, "pages"); await registerCollection(ctx, "posts"); @@ -499,6 +610,27 @@ async function registerCollection(ctx: DialectTestContext, slug: string): Promis .execute(); } +async function insertCollectionStatus( + ctx: DialectTestContext, + slug: string, + status: string, + reconciliationRequired: number, +): Promise { + await ctx.db + .insertInto("_emdash_media_usage_index_status") + .values({ + adapter_id: "content-media", + scope_type: "collection", + scope_key: slug, + status, + schema_version: 1, + collection_id: `collection-${slug}`, + reconciliation_required: reconciliationRequired, + capture_state: "active", + }) + .execute(); +} + async function installCanonicalContentFixture( ctx: DialectTestContext, collectionSlug: string, diff --git a/packages/core/tests/unit/api/media-usage-progress-route.test.ts b/packages/core/tests/unit/api/media-usage-progress-route.test.ts new file mode 100644 index 0000000000..734f153cbc --- /dev/null +++ b/packages/core/tests/unit/api/media-usage-progress-route.test.ts @@ -0,0 +1,145 @@ +import { Role, type RoleLevel } from "@emdash-cms/auth"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { injectCoreRoutes } from "../../../src/astro/integration/routes.js"; +import { GET } from "../../../src/astro/routes/api/admin/media-usage/progress.js"; +import { + setupForDialectWithCollections, + teardownForDialect, + type DialectTestContext, +} from "../../utils/test-db.js"; + +type GetContext = Parameters[0]; + +describe("admin media usage progress route", () => { + let ctx: DialectTestContext | undefined; + let collectionId: string; + + beforeEach(async () => { + ctx = await setupForDialectWithCollections("sqlite"); + const collections = await ctx.db + .selectFrom("_emdash_collections") + .select(["id", "slug"]) + .execute(); + collectionId = collections.find(({ slug }) => slug === "post")!.id; + for (const collection of collections) { + await ctx.db + .updateTable("_emdash_media_usage_index_status") + .set({ + collection_id: collection.id, + capture_state: "active", + status: "complete", + schema_version: 1, + reconciliation_required: 0, + }) + .where("adapter_id", "=", "content-media") + .where("scope_type", "=", "collection") + .where("scope_key", "=", collection.slug) + .execute(); + } + await ctx.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "active" }) + .where("task_key", "=", "incremental_capture") + .execute(); + }); + + afterEach(async () => { + await teardownForDialect(ctx); + ctx = undefined; + }); + + it("registers the progress route", () => { + const routes: Array<{ pattern: string; entrypoint: string }> = []; + injectCoreRoutes((route) => routes.push(route)); + + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pattern: "/_emdash/api/admin/media-usage/progress", + entrypoint: expect.stringContaining("api/admin/media-usage/progress"), + }), + ]), + ); + }); + + it("requires authentication, schema permission, and admin token scope", async () => { + await expectError(await GET(routeContext(null)), 401, "UNAUTHORIZED"); + await expectError(await GET(routeContext(Role.EDITOR)), 403, "FORBIDDEN"); + await expectError( + await GET(routeContext(Role.ADMIN, ["content:read"])), + 403, + "INSUFFICIENT_SCOPE", + ); + }); + + it("returns aggregate readiness without exposing collection or work details", async () => { + const response = await GET(routeContext(Role.ADMIN, ["admin"])); + + expect(response.status).toBe(200); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); + const body = await response.json(); + expect(body).toEqual({ + success: true, + data: { status: "ready", readyCollections: 2, totalCollections: 2 }, + }); + expect(JSON.stringify(body)).not.toContain(collectionId); + expect(JSON.stringify(body)).not.toContain("post"); + }); + + it("rejects progress reads before activation is active", async () => { + await ctx!.db + .updateTable("_emdash_media_usage_activation") + .set({ state: "activating" }) + .where("task_key", "=", "incremental_capture") + .execute(); + + await expectError( + await GET(routeContext(Role.ADMIN, ["admin"])), + 409, + "MEDIA_USAGE_PROGRESS_NOT_ACTIVE", + ); + }); + + it("rejects an incompatible activation runtime generation", async () => { + await ctx!.db + .updateTable("_emdash_media_usage_activation") + .set({ runtime_generation: 2 }) + .where("task_key", "=", "incremental_capture") + .execute(); + + await expectError( + await GET(routeContext(Role.ADMIN, ["admin"])), + 409, + "MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH", + ); + }); + + it("returns a stable redacted read error", async () => { + await ctx!.db.schema.dropTable("_emdash_media_usage_index_status").execute(); + + const response = await GET(routeContext(Role.ADMIN, ["admin"])); + const body = await response.clone().json(); + + await expectError(response, 500, "MEDIA_USAGE_PROGRESS_READ_ERROR"); + expect(JSON.stringify(body)).not.toContain("_emdash_media_usage_index_status"); + }); + + function routeContext(role: RoleLevel | null, tokenScopes?: string[]): GetContext { + return { + request: new Request("http://localhost/_emdash/api/admin/media-usage/progress"), + locals: { + emdash: { db: ctx!.db }, + user: role == null ? null : { id: "user-1", role }, + tokenScopes, + }, + } as GetContext; + } +}); + +async function expectError(response: Response, status: number, code: string): Promise { + expect(response.status).toBe(status); + const body = (await response.json()) as { error: { code: string } }; + expect(body.error.code).toBe(code); + expect(response.headers.get("Cache-Control")).toBe("private, no-store"); +} diff --git a/packages/core/tests/unit/api/openapi.test.ts b/packages/core/tests/unit/api/openapi.test.ts index 03db925cf9..e7ae865d61 100644 --- a/packages/core/tests/unit/api/openapi.test.ts +++ b/packages/core/tests/unit/api/openapi.test.ts @@ -159,6 +159,25 @@ describe("OpenAPI document generation", () => { ); }); + it("documents aggregate media usage indexing progress", () => { + const doc = generateOpenApiDocument(); + const get = doc.paths?.["/_emdash/api/admin/media-usage/progress"]?.get as + | { operationId?: string; responses?: Record } + | undefined; + + expect(get?.operationId).toBe("getMediaUsageProgress"); + expect(get?.responses).toEqual( + expect.objectContaining({ + "200": expect.any(Object), + "401": expect.any(Object), + "403": expect.any(Object), + "409": expect.any(Object), + "500": expect.any(Object), + }), + ); + expect(JSON.stringify(get?.responses?.["200"])).toContain("MediaUsageProgress"); + }); + it("includes schema paths", () => { const doc = generateOpenApiDocument(); const paths = Object.keys(doc.paths ?? {}); diff --git a/packages/core/tests/unit/client/client.test.ts b/packages/core/tests/unit/client/client.test.ts index 3a91b568ca..ddc2a3307b 100644 --- a/packages/core/tests/unit/client/client.test.ts +++ b/packages/core/tests/unit/client/client.test.ts @@ -987,6 +987,30 @@ describe("EmDashClient", () => { }); describe("media usage work operators", () => { + it("reads aggregate media usage indexing progress", async () => { + let capturedRequest: Request | undefined; + const progress = { + status: "indexing" as const, + readyCollections: 2, + totalCollections: 3, + }; + const client = new EmDashClient({ + baseUrl: "http://localhost:4321", + token: "test", + interceptors: [ + async (request) => { + capturedRequest = request; + return jsonResponse(progress); + }, + ], + }); + await expect(client.mediaGetUsageProgress()).resolves.toEqual(progress); + expect(capturedRequest?.method).toBe("GET"); + expect(new URL(capturedRequest!.url).pathname).toBe( + "/_emdash/api/admin/media-usage/progress", + ); + }); + it("reads the redacted activation status", async () => { let capturedRequest: Request | undefined; const status = { diff --git a/packages/core/tests/workerd/media-usage-activation-d1.test.ts b/packages/core/tests/workerd/media-usage-activation-d1.test.ts index 2fac11f356..0c00181091 100644 --- a/packages/core/tests/workerd/media-usage-activation-d1.test.ts +++ b/packages/core/tests/workerd/media-usage-activation-d1.test.ts @@ -5,6 +5,7 @@ import { afterAll, beforeAll, expect, it } from "vitest"; import { EmDashD1Dialect, RawBindingD1Dialect } from "../../../cloudflare/src/db/d1-dialect.js"; import { GET, POST } from "../../src/astro/routes/api/admin/media-usage/activation.js"; +import { GET as GET_PROGRESS } from "../../src/astro/routes/api/admin/media-usage/progress.js"; import { runMigrations } from "../../src/database/migrations/runner.js"; import type { Database } from "../../src/database/types.js"; import { MEDIA_USAGE_ACTIVATION_RUNTIME_GENERATION } from "../../src/media/usage/activation.js"; @@ -58,6 +59,8 @@ it("keeps complete authenticated activation route costs within the D1 envelope", await record("get-activating", "session", GET, activationGet(), 200, evidence); await record("final-collection", "session", POST, activationPost(), 200, evidence); await record("get-active", "raw", GET, activationGet(), 200, evidence); + await record("progress-indexing", "session", GET_PROGRESS, progressGet(), 200, evidence); + expect(evidence.at(-1)).toEqual(expect.objectContaining({ queries: 1, rowsWritten: 0 })); await record("active-idempotent", "session", POST, activationPost(), 200, evidence); await adminDb @@ -143,6 +146,10 @@ function activationPost(): Request { }); } +function progressGet(): Request { + return new Request("http://localhost/_emdash/api/admin/media-usage/progress"); +} + async function resetActivation(state: "expanded" | "activating", cursor: string | null) { await adminDb .updateTable("_emdash_media_usage_activation") From b22c22adb9cb4185b5bb79ad23d27a9f3e08be1b Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:06:16 +0100 Subject: [PATCH 2/4] feat(admin): add guided media usage setup --- .../admin/src/components/ConfirmDialog.tsx | 4 +- packages/admin/src/components/Settings.tsx | 15 + .../settings/MediaUsageSettings.tsx | 510 ++++++++++++++++++ .../src/lib/api/media-usage-activation.ts | 161 ++++++ packages/admin/src/router.tsx | 8 + .../admin/tests/components/Settings.test.tsx | 26 + .../settings/MediaUsageSettings.test.tsx | 284 ++++++++++ .../lib/media-usage-activation-api.test.ts | 188 +++++++ 8 files changed, 1195 insertions(+), 1 deletion(-) create mode 100644 packages/admin/src/components/settings/MediaUsageSettings.tsx create mode 100644 packages/admin/src/lib/api/media-usage-activation.ts create mode 100644 packages/admin/tests/components/settings/MediaUsageSettings.test.tsx create mode 100644 packages/admin/tests/lib/media-usage-activation-api.test.ts diff --git a/packages/admin/src/components/ConfirmDialog.tsx b/packages/admin/src/components/ConfirmDialog.tsx index 25d370b6f9..9115908567 100644 --- a/packages/admin/src/components/ConfirmDialog.tsx +++ b/packages/admin/src/components/ConfirmDialog.tsx @@ -24,6 +24,7 @@ export interface ConfirmDialogProps { /** Button variant — defaults to "destructive" */ variant?: "destructive" | "primary"; isPending: boolean; + disabled?: boolean; /** Error from a mutation — pass mutation.error directly */ error: unknown; onConfirm: () => void; @@ -40,6 +41,7 @@ export function ConfirmDialog({ pendingLabel, variant = "destructive", isPending, + disabled = false, error, onConfirm, children, @@ -56,7 +58,7 @@ export function ConfirmDialog({ - diff --git a/packages/admin/src/components/Settings.tsx b/packages/admin/src/components/Settings.tsx index 872614c2b7..6b8b6299fb 100644 --- a/packages/admin/src/components/Settings.tsx +++ b/packages/admin/src/components/Settings.tsx @@ -11,11 +11,13 @@ import { Envelope, DownloadSimple, CaretDown, + ChartLineUp, } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import * as React from "react"; import { fetchManifest } from "../lib/api"; +import { useCurrentUser } from "../lib/api/current-user.js"; import { SUPPORTED_LOCALES } from "../locales/index.js"; import { useLocale } from "../locales/useLocale.js"; import { SettingsNavRow, SettingsSection } from "./settings/SettingsLayout.js"; @@ -24,6 +26,7 @@ import { SettingsNavRow, SettingsSection } from "./settings/SettingsLayout.js"; * Settings hub page — links to all settings sub-pages. */ export function Settings() { + const { data: currentUser } = useCurrentUser(); const { data: manifest } = useQuery({ queryKey: ["manifest"], queryFn: fetchManifest, @@ -32,6 +35,7 @@ export function Settings() { const { t } = useLingui(); const { locale, setLocale } = useLocale(); const showSecuritySettings = manifest?.authMode === "passkey"; + const showMediaUsageSettings = (currentUser?.role ?? 0) >= 50; const selectedLocale = SUPPORTED_LOCALES.find((option) => option.code === locale) ?? null; return ( @@ -62,6 +66,17 @@ export function Settings() { /> + {showMediaUsageSettings ? ( + + } + title={t`Media Usage`} + description={t`Track where media is used across your content`} + /> + + ) : null} + {showSecuritySettings && ( = ROLE_ADMIN; + const [confirmations, setConfirmations] = React.useState(EMPTY_CONFIRMATIONS); + const [dialogOpen, setDialogOpen] = React.useState(false); + const [notice, setNotice] = React.useState(null); + const [liveMessage, setLiveMessage] = React.useState(""); + const awayGenerationRef = React.useRef(0); + const returnGenerationRef = React.useRef(0); + const refreshSequenceRef = React.useRef(0); + const submittingRef = React.useRef(false); + const focusActiveRef = React.useRef(false); + const activeHeadingRef = React.useRef(null); + + const activationQuery = useQuery({ + queryKey: MEDIA_USAGE_ACTIVATION_QUERY_KEY, + queryFn: fetchMediaUsageActivationStatus, + enabled: isAdmin, + retry: false, + refetchOnMount: "always", + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + + const resetConfirmations = React.useCallback(() => { + setConfirmations(EMPTY_CONFIRMATIONS); + setDialogOpen(false); + }, []); + React.useEffect(() => { + if (activationQuery.data?.state === "active" || activationQuery.data?.lastErrorCode) { + resetConfirmations(); + } + }, [activationQuery.data?.lastErrorCode, activationQuery.data?.state, resetConfirmations]); + + const refreshStatus = React.useCallback( + async (reason: "manual" | "ownership" | "ambiguous") => { + const awayGeneration = awayGenerationRef.current; + const refreshSequence = ++refreshSequenceRef.current; + if (reason === "ownership" || reason === "ambiguous") setNotice("unconfirmed"); + if (reason !== "manual") resetConfirmations(); + const result = await activationQuery.refetch(); + if ( + awayGeneration !== awayGenerationRef.current || + refreshSequence !== refreshSequenceRef.current + ) { + return; + } + if (!result.isSuccess) { + if (reason === "ownership" || reason === "ambiguous") setNotice("unconfirmed"); + return; + } + setNotice((current) => + current === "validation" || current === "version" + ? current + : reason === "ownership" + ? "ownership" + : reason === "ambiguous" + ? "ambiguous" + : null, + ); + if (result.data.state !== "active") focusActiveRef.current = false; + setLiveMessage( + result.data.state === "active" ? t`Media Usage is active.` : t`Setup status updated.`, + ); + }, + [activationQuery, resetConfirmations, t], + ); + + React.useEffect(() => { + if (!isAdmin) return; + const markAway = () => { + awayGenerationRef.current++; + resetConfirmations(); + }; + const returnToPage = () => { + const generation = awayGenerationRef.current; + if (returnGenerationRef.current === generation) return; + returnGenerationRef.current = generation; + void refreshStatus("manual"); + }; + const visibilityChanged = () => { + if (document.visibilityState === "hidden") markAway(); + else returnToPage(); + }; + window.addEventListener("pagehide", markAway); + window.addEventListener("pageshow", returnToPage); + document.addEventListener("visibilitychange", visibilityChanged); + return () => { + window.removeEventListener("pagehide", markAway); + window.removeEventListener("pageshow", returnToPage); + document.removeEventListener("visibilitychange", visibilityChanged); + }; + }, [isAdmin, refreshStatus, resetConfirmations]); + + const advanceMutation = useMutation({ + mutationFn: () => advanceMediaUsageActivation({ writersDrained: true, maintenanceReady: true }), + retry: false, + onSuccess: (result) => { + queryClient.setQueryData(MEDIA_USAGE_ACTIVATION_QUERY_KEY, result.activation); + setNotice(null); + setDialogOpen(false); + setLiveMessage( + result.activation.state === "active" + ? t`Media Usage is active.` + : result.processedCollections === 1 + ? t`One content type is ready. Continue setup.` + : t`Setup status updated. Continue setup.`, + ); + if (result.activation.state !== "active") focusActiveRef.current = false; + if (result.activation.state === "active" || result.activation.lastErrorCode) { + resetConfirmations(); + } + }, + onError: (caught) => { + void handleAdvanceError(caught); + }, + onSettled: () => { + submittingRef.current = false; + }, + }); + + const handleAdvanceError = async (caught: unknown) => { + const error = + caught instanceof MediaUsageActivationRequestError + ? caught + : new MediaUsageActivationRequestError("unknown", null); + if (error.kind === "busy") { + setDialogOpen(false); + setNotice("busy"); + return; + } + resetConfirmations(); + if (error.kind === "denied") return setNotice("denied"); + if (error.kind === "version_mismatch") return setNotice("version"); + if (error.kind === "validation") return setNotice("validation"); + await refreshStatus(error.kind === "ownership_conflict" ? "ownership" : "ambiguous"); + }; + + const activation = activationQuery.data; + const active = activation?.state === "active"; + React.useEffect(() => { + if (!active || !focusActiveRef.current) return; + focusActiveRef.current = false; + activeHeadingRef.current?.focus(); + }, [active]); + + const title = t`Media Usage`; + const description = t`Track where media is used across your content.`; + const queryDenied = isActivationError(activationQuery.error, "denied"); + const queryVersion = isActivationError(activationQuery.error, "version_mismatch"); + if (userLoading) return ; + if (!isAdmin || queryDenied || notice === "denied") { + return ( + + ); + } + if (queryVersion || notice === "version") { + return ( + + ); + } + if (notice === "validation") { + return ( + + ); + } + if (notice === "unconfirmed") { + return ( + void refreshStatus("manual")}> + {t`Refresh status`} + + } + /> + ); + } + if (activationQuery.isError || activationQuery.isRefetchError) { + return ( + void refreshStatus("manual")}> + {t`Try again`} + + } + /> + ); + } + if (activationQuery.isPending || activationQuery.isFetching || !activation) { + return ; + } + + const allConfirmed = Object.values(confirmations).every(Boolean); + const storedFailure = activation.lastErrorCode !== null; + const blocked = notice === "busy" || advanceMutation.isPending; + const actionLabel = storedFailure + ? t`Retry setup` + : activation.state === "expanded" + ? t`Enable Media Usage` + : t`Continue setup`; + const submit = () => { + if (submittingRef.current) return; + submittingRef.current = true; + focusActiveRef.current = true; + advanceMutation.mutate(); + }; + const startOrContinue = () => { + if (allConfirmed && activation.state === "activating" && !storedFailure) { + submit(); + } else { + setDialogOpen(true); + } + }; + + return ( + + + + {storedFailure || notice ? ( + + + + ) : null} + {!active ? ( + + + + ) : null} + + + setConfirmations((current) => ({ ...current, [key]: checked }))} + onConfirm={submit} + /> + + {liveMessage} + + + ); +} + +function StatusRow({ + activation, + activeHeadingRef, +}: { + activation: MediaUsageActivationStatus; + activeHeadingRef: React.RefObject; +}) { + const { t } = useLingui(); + const active = activation.state === "active"; + const settingUp = activation.state === "activating"; + return ( + +
+
+

+ {active + ? t`Media Usage is active` + : settingUp + ? t`Setup is in progress` + : t`Automatic indexing is off`} +

+

+ {active + ? t`New changes are tracked automatically. Existing content may still be indexing.` + : settingUp + ? t`Keep editing paused until setup is complete.` + : t`Enable Media Usage to index existing content and keep references up to date.`} +

+
+ + {active ? t`Active` : settingUp ? t`Setting up` : t`Off`} + +
+
+ ); +} + +function ConfirmationDialog({ + open, + activation, + confirmations, + pending, + onOpenChange, + onChange, + onConfirm, +}: { + open: boolean; + activation: MediaUsageActivationStatus; + confirmations: typeof EMPTY_CONFIRMATIONS; + pending: boolean; + onOpenChange: (open: boolean) => void; + onChange: (key: Confirmation, checked: boolean) => void; + onConfirm: () => void; +}) { + const { t } = useLingui(); + const rows: Array<{ key: Confirmation; title: string; description: string }> = [ + { + key: "maintenance", + title: t`Background tasks are running.`, + description: t`Use the Media Usage Cron on Cloudflare or keep a Node process running.`, + }, + { + key: "writers", + title: t`Editing and direct database writes are paused.`, + description: t`Keep them paused until setup is complete.`, + }, + { + key: "irreversible", + title: t`I understand setup can’t be cancelled or reset.`, + description: t`This is a one-way change.`, + }, + ]; + const confirmed = Object.values(confirmations).every(Boolean); + return ( + onOpenChange(false)} + title={t`Enable Media Usage`} + description={t`Existing content will be indexed automatically after setup is complete.`} + confirmLabel={ + activation.state === "expanded" ? t`Enable and start indexing` : t`Continue setup` + } + pendingLabel={t`Enabling…`} + variant="primary" + isPending={pending} + disabled={!confirmed} + error={null} + onConfirm={onConfirm} + > +
+ {t`Before you continue`} + {rows.map((row) => ( + onChange(row.key, checked)} + label={ + + {row.title} + {row.description} + + } + /> + ))} +
+
+ ); +} + +function SetupNotice({ + notice, + storedFailure, + onRefresh, +}: { + notice: Notice; + storedFailure: boolean; + onRefresh: (reason: "manual") => Promise; +}) { + const { t } = useLingui(); + if (notice === "busy") { + return ( + void onRefresh("manual")} + >{t`Refresh status`} + } + /> + ); + } + if (storedFailure) { + return ( + + ); + } + return ( + + ); +} + +function LoadingPage({ title, description }: { title: string; description: string }) { + const { t } = useLingui(); + return ( + + + +
+ + {t`Loading Media Usage settings…`} +
+
+
+
+ ); +} + +function MessagePage({ + title, + description, + message, + action, +}: { + title: string; + description: string; + message: string; + action?: React.ReactNode; +}) { + const { t } = useLingui(); + return ( + + + + + + + + ); +} + +function isActivationError(error: unknown, kind: MediaUsageActivationRequestError["kind"]) { + return error instanceof MediaUsageActivationRequestError && error.kind === kind; +} diff --git a/packages/admin/src/lib/api/media-usage-activation.ts b/packages/admin/src/lib/api/media-usage-activation.ts new file mode 100644 index 0000000000..51d7ca967a --- /dev/null +++ b/packages/admin/src/lib/api/media-usage-activation.ts @@ -0,0 +1,161 @@ +import { API_BASE, apiFetch } from "./client.js"; + +const ACTIVATION_URL = `${API_BASE}/admin/media-usage/activation`; + +export const MEDIA_USAGE_ACTIVATION_QUERY_KEY = ["media-usage-activation"] as const; + +export type MediaUsageActivationState = "expanded" | "activating" | "active"; + +export interface MediaUsageActivationStatus { + state: MediaUsageActivationState; + collectionCursor: string | null; + attemptCount: number; + drainConfirmedAt: string | null; + lastAttemptedAt: string | null; + lastErrorCode: "MEDIA_USAGE_ACTIVATION_FAILED" | null; + leaseExpiresAt: string | null; + activatedAt: string | null; + updatedAt: string; +} + +export interface MediaUsageActivationAdvanceResponse { + outcome: "activating" | "active"; + processedCollections: number; + activation: MediaUsageActivationStatus; +} + +export type MediaUsageActivationErrorKind = + | "busy" + | "ownership_conflict" + | "version_mismatch" + | "denied" + | "validation" + | "read_failure" + | "advance_failure" + | "unknown"; + +export class MediaUsageActivationRequestError extends Error { + constructor( + readonly kind: MediaUsageActivationErrorKind, + readonly status: number | null, + ) { + super("Media usage activation request failed"); + this.name = "MediaUsageActivationRequestError"; + } +} + +export async function fetchMediaUsageActivationStatus(): Promise { + const response = await activationFetch(ACTIVATION_URL); + if (!response.ok) throw await parseActivationError(response); + const data = await readSuccessData(response); + if (!isActivationStatus(data)) throw unknownResponse(response.status); + return data; +} + +export async function advanceMediaUsageActivation(input: { + writersDrained: true; + maintenanceReady: true; +}): Promise { + const response = await activationFetch(ACTIVATION_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + writersDrained: input.writersDrained, + maintenanceReady: input.maintenanceReady, + }), + }); + if (!response.ok) throw await parseActivationError(response); + const data = await readSuccessData(response); + if (!isAdvanceResponse(data)) throw unknownResponse(response.status); + return data; +} + +async function activationFetch(input: string, init?: RequestInit): Promise { + try { + return await apiFetch(input, init); + } catch { + throw unknownResponse(null); + } +} + +async function parseActivationError(response: Response): Promise { + if (response.status === 401 || response.status === 403) { + return new MediaUsageActivationRequestError("denied", response.status); + } + + const body = await readJson(response); + const error = isRecord(body) && isRecord(body.error) ? body.error : null; + const code = error && typeof error.code === "string" ? error.code : null; + + switch (code) { + case "MEDIA_USAGE_ACTIVATION_BUSY": + return new MediaUsageActivationRequestError("busy", response.status); + case "MEDIA_USAGE_ACTIVATION_CONFLICT": + return new MediaUsageActivationRequestError("ownership_conflict", response.status); + case "MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH": + return new MediaUsageActivationRequestError("version_mismatch", response.status); + case "UNAUTHORIZED": + case "FORBIDDEN": + case "INSUFFICIENT_SCOPE": + return new MediaUsageActivationRequestError("denied", response.status); + case "VALIDATION_ERROR": + return new MediaUsageActivationRequestError("validation", response.status); + case "MEDIA_USAGE_ACTIVATION_READ_ERROR": + return new MediaUsageActivationRequestError("read_failure", response.status); + case "MEDIA_USAGE_ACTIVATION_ADVANCE_ERROR": + return new MediaUsageActivationRequestError("advance_failure", response.status); + default: + return unknownResponse(response.status); + } +} + +function unknownResponse(status: number | null): MediaUsageActivationRequestError { + return new MediaUsageActivationRequestError("unknown", status); +} + +async function readSuccessData(response: Response): Promise { + const body = await readJson(response); + if (!isRecord(body) || body.success !== true || !("data" in body)) return undefined; + return body.data; +} + +async function readJson(response: Response): Promise { + return response.json().catch(() => undefined); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isNullableString(value: unknown): value is string | null { + return value === null || typeof value === "string"; +} + +function isActivationStatus(value: unknown): value is MediaUsageActivationStatus { + if (!isRecord(value)) return false; + return ( + (value.state === "expanded" || value.state === "activating" || value.state === "active") && + isNullableString(value.collectionCursor) && + Number.isInteger(value.attemptCount) && + typeof value.attemptCount === "number" && + value.attemptCount >= 0 && + isNullableString(value.drainConfirmedAt) && + isNullableString(value.lastAttemptedAt) && + (value.lastErrorCode === null || value.lastErrorCode === "MEDIA_USAGE_ACTIVATION_FAILED") && + isNullableString(value.leaseExpiresAt) && + isNullableString(value.activatedAt) && + typeof value.updatedAt === "string" + ); +} + +function isAdvanceResponse(value: unknown): value is MediaUsageActivationAdvanceResponse { + if (!isRecord(value) || !isActivationStatus(value.activation)) return false; + return ( + (value.outcome === "activating" || value.outcome === "active") && + value.activation.state === value.outcome && + typeof value.processedCollections === "number" && + Number.isInteger(value.processedCollections) && + value.processedCollections >= 0 && + value.processedCollections <= 1 + ); +} diff --git a/packages/admin/src/router.tsx b/packages/admin/src/router.tsx index eefe71fcb0..f671aa4860 100644 --- a/packages/admin/src/router.tsx +++ b/packages/admin/src/router.tsx @@ -56,6 +56,7 @@ import { ApiTokenSettings } from "./components/settings/ApiTokenSettings"; import { BackupSettings } from "./components/settings/BackupSettings"; import { EmailSettings } from "./components/settings/EmailSettings"; import { GeneralSettings } from "./components/settings/GeneralSettings"; +import { MediaUsageSettings } from "./components/settings/MediaUsageSettings"; import { SecuritySettings } from "./components/settings/SecuritySettings"; import { SeoSettings } from "./components/settings/SeoSettings"; import { SocialSettings } from "./components/settings/SocialSettings"; @@ -1586,6 +1587,12 @@ const settingsRoute = createRoute({ component: Settings, }); +const mediaUsageSettingsRoute = createRoute({ + getParentRoute: () => adminLayoutRoute, + path: "/settings/media-usage", + component: MediaUsageSettings, +}); + // Security settings route const securitySettingsRoute = createRoute({ getParentRoute: () => adminLayoutRoute, @@ -2168,6 +2175,7 @@ const adminRoutes = adminLayoutRoute.addChildren([ bylineSchemaRoute, widgetsRoute, settingsRoute, + mediaUsageSettingsRoute, generalSettingsRoute, socialSettingsRoute, seoSettingsRoute, diff --git a/packages/admin/tests/components/Settings.test.tsx b/packages/admin/tests/components/Settings.test.tsx index 317f98dd28..04a3b7e9d8 100644 --- a/packages/admin/tests/components/Settings.test.tsx +++ b/packages/admin/tests/components/Settings.test.tsx @@ -22,6 +22,7 @@ vi.mock("@tanstack/react-router", async () => { const mockFetchManifest = vi.fn<() => Promise>(); const mockSetLocale = vi.fn<(locale: string) => void>(); +const currentUser = vi.hoisted(() => ({ role: 50 })); vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); @@ -35,6 +36,13 @@ vi.mock("../../src/locales/useLocale.js", () => ({ useLocale: () => ({ locale: "en", setLocale: mockSetLocale }), })); +vi.mock("../../src/lib/api/current-user.js", () => ({ + useCurrentUser: () => ({ + data: { id: "user-1", email: "admin@example.com", role: currentUser.role }, + isLoading: false, + }), +})); + // Import after mocks const { Settings } = await import("../../src/components/Settings"); @@ -57,6 +65,7 @@ function Wrapper({ children }: { children: React.ReactNode }) { describe("Settings", () => { beforeEach(() => { vi.clearAllMocks(); + currentUser.role = 50; mockFetchManifest.mockResolvedValue(defaultManifest); }); @@ -92,6 +101,23 @@ describe("Settings", () => { await expect.element(screen.getByText("Email", { exact: true })).toBeInTheDocument(); }); + it("shows Media Usage setup only to administrators", async () => { + const admin = await render( + + + , + ); + await expect.element(admin.getByRole("link", { name: /Media Usage/ })).toBeInTheDocument(); + + currentUser.role = 40; + const editor = await render( + + + , + ); + expect(editor.getByRole("link", { name: /Media Usage/ }).query()).toBeNull(); + }); + it("groups settings into clear semantic sections", async () => { const screen = await render( diff --git a/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx b/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx new file mode 100644 index 0000000000..3a26ce4e2a --- /dev/null +++ b/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx @@ -0,0 +1,284 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import * as React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { userEvent } from "vitest/browser"; + +import { render } from "../../utils/render.tsx"; + +const activationMocks = vi.hoisted(() => ({ + fetchStatus: vi.fn(), + advance: vi.fn(), +})); +const currentUserMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../../src/lib/api/media-usage-activation.js", async () => { + const actual = await vi.importActual< + typeof import("../../../src/lib/api/media-usage-activation.js") + >("../../../src/lib/api/media-usage-activation.js"); + return { + ...actual, + fetchMediaUsageActivationStatus: activationMocks.fetchStatus, + advanceMediaUsageActivation: activationMocks.advance, + }; +}); + +vi.mock("../../../src/lib/api/current-user.js", () => ({ + useCurrentUser: currentUserMock, +})); + +vi.mock("../../../src/components/settings/BackToSettingsLink.js", () => ({ + BackToSettingsLink: () => Back to Settings, +})); + +const { MEDIA_USAGE_ACTIVATION_QUERY_KEY, MediaUsageActivationRequestError } = + await import("../../../src/lib/api/media-usage-activation.js"); +const { MediaUsageSettings } = + await import("../../../src/components/settings/MediaUsageSettings.js"); + +type ActivationState = "expanded" | "activating" | "active"; + +function status(state: ActivationState, options: { failed?: boolean } = {}) { + return { + state, + collectionCursor: state === "activating" ? "posts" : null, + attemptCount: state === "expanded" ? 0 : 1, + drainConfirmedAt: state === "expanded" ? null : "2026-08-16T09:00:00.000Z", + lastAttemptedAt: state === "expanded" ? null : "2026-08-16T09:00:00.000Z", + lastErrorCode: options.failed ? ("MEDIA_USAGE_ACTIVATION_FAILED" as const) : null, + leaseExpiresAt: null, + activatedAt: state === "active" ? "2026-08-16T09:00:01.000Z" : null, + updatedAt: "2026-08-16T09:00:01.000Z", + }; +} + +function setCurrentUser(role: number | null, isLoading = false) { + currentUserMock.mockReturnValue({ + data: role === null ? null : { id: "user-1", email: "admin@example.com", role }, + isLoading, + }); +} + +async function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + const screen = await render( + + + , + ); + return { queryClient, screen }; +} + +async function openConfirmation(screen: Awaited>["screen"]) { + await userEvent.click(screen.getByRole("button", { name: "Enable Media Usage" })); + await expect.element(screen.getByRole("dialog", { name: "Enable Media Usage" })).toBeVisible(); +} + +async function confirmAll(screen: Awaited>["screen"]) { + const dialog = screen.getByRole("dialog", { name: "Enable Media Usage" }); + for (const checkbox of [ + dialog.getByRole("checkbox", { name: /Background tasks are running/ }), + dialog.getByRole("checkbox", { name: /Editing and direct database writes are paused/ }), + dialog.getByRole("checkbox", { name: /can’t be cancelled or reset/ }), + ]) { + checkbox.element().focus(); + await userEvent.keyboard(" "); + } +} + +async function submitConfirmation(screen: Awaited>["screen"]) { + const confirm = screen.getByRole("button", { name: "Enable and start indexing" }); + confirm.element().focus(); + await userEvent.keyboard("{Enter}"); +} + +describe("MediaUsageSettings", () => { + beforeEach(() => { + vi.clearAllMocks(); + setCurrentUser(50); + activationMocks.fetchStatus.mockResolvedValue(status("expanded")); + }); + + it("denies a direct Editor visit before requesting activation status", async () => { + setCurrentUser(40); + + const { screen } = await renderPage(); + + await expect + .element(screen.getByRole("heading", { name: "Access denied" })) + .toBeInTheDocument(); + expect(activationMocks.fetchStatus).not.toHaveBeenCalled(); + expect(screen.getByRole("button", { name: "Enable Media Usage" }).query()).toBeNull(); + }); + + it("keeps safety confirmations behind one clear enable action", async () => { + const activating = status("activating"); + activationMocks.advance.mockResolvedValue({ + outcome: "activating", + processedCollections: 1, + activation: activating, + }); + const { queryClient, screen } = await renderPage(); + + await expect.element(screen.getByText("Automatic indexing is off")).toBeInTheDocument(); + expect(screen.getByRole("checkbox").query()).toBeNull(); + await openConfirmation(screen); + const confirm = screen.getByRole("button", { name: "Enable and start indexing" }); + await expect.element(confirm).toBeDisabled(); + await confirmAll(screen); + await expect.element(confirm).toBeEnabled(); + confirm.element().focus(); + await userEvent.keyboard("{Enter}"); + + await expect + .element(screen.getByRole("button", { name: "Continue setup" })) + .toBeInTheDocument(); + expect(screen.getByRole("dialog").query()).toBeNull(); + expect(activationMocks.advance).toHaveBeenCalledOnce(); + expect(activationMocks.advance).toHaveBeenCalledWith({ + writersDrained: true, + maintenanceReady: true, + }); + expect(activationMocks.fetchStatus).toHaveBeenCalledOnce(); + expect(queryClient.getQueryData(MEDIA_USAGE_ACTIVATION_QUERY_KEY)).toEqual(activating); + + await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); + expect(activationMocks.advance).toHaveBeenCalledTimes(2); + expect(screen.getByRole("dialog").query()).toBeNull(); + }); + + it("blocks duplicate activation and renders the confirmed active state", async () => { + let resolveAdvance!: (value: unknown) => void; + activationMocks.advance.mockImplementation( + () => new Promise((resolve) => (resolveAdvance = resolve)), + ); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + + await submitConfirmation(screen); + const pending = screen.getByRole("button", { name: "Enabling…" }); + await expect.element(pending).toBeDisabled(); + pending.element().click(); + expect(activationMocks.advance).toHaveBeenCalledOnce(); + + resolveAdvance({ outcome: "active", processedCollections: 1, activation: status("active") }); + await expect + .element(screen.getByRole("heading", { name: "Media Usage is active" })) + .toBeInTheDocument(); + expect(screen.getByRole("button", { name: /setup/i }).query()).toBeNull(); + }); + + it("resets confirmations after leaving and returning", async () => { + activationMocks.advance.mockResolvedValue({ + outcome: "activating", + processedCollections: 1, + activation: status("activating"), + }); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + await submitConfirmation(screen); + + activationMocks.fetchStatus.mockResolvedValue(status("activating")); + window.dispatchEvent(new PageTransitionEvent("pagehide")); + window.dispatchEvent(new PageTransitionEvent("pageshow")); + await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); + + await expect.element(screen.getByRole("dialog", { name: "Enable Media Usage" })).toBeVisible(); + for (const checkbox of await screen.getByRole("checkbox").all()) { + await expect.element(checkbox).not.toBeChecked(); + } + }); + + it("recovers an ambiguous activation response with one status read", async () => { + activationMocks.advance.mockRejectedValue( + new MediaUsageActivationRequestError("advance_failure", 500), + ); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + activationMocks.fetchStatus.mockResolvedValue(status("active")); + + await submitConfirmation(screen); + + await expect + .element(screen.getByRole("heading", { name: "Media Usage is active" })) + .toBeInTheDocument(); + expect(activationMocks.fetchStatus).toHaveBeenCalledTimes(2); + expect(activationMocks.advance).toHaveBeenCalledOnce(); + }); + + it("keeps writers paused when an ambiguous response cannot be confirmed", async () => { + activationMocks.advance.mockRejectedValue( + new MediaUsageActivationRequestError("advance_failure", 500), + ); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + activationMocks.fetchStatus.mockRejectedValue( + new MediaUsageActivationRequestError("read_failure", 500), + ); + + await submitConfirmation(screen); + + await expect + .element( + screen.getByText( + "Activation cannot be confirmed. Keep editing paused and refresh the status.", + ), + ) + .toBeInTheDocument(); + expect(activationMocks.fetchStatus).toHaveBeenCalledTimes(2); + }); + + it("keeps validation blocking after leaving and returning", async () => { + activationMocks.advance.mockRejectedValue( + new MediaUsageActivationRequestError("validation", 400), + ); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + await submitConfirmation(screen); + await expect + .element(screen.getByText("Reload after updating EmDash before trying again.")) + .toBeInTheDocument(); + + activationMocks.fetchStatus.mockResolvedValue(status("activating")); + window.dispatchEvent(new PageTransitionEvent("pagehide")); + window.dispatchEvent(new PageTransitionEvent("pageshow")); + + await expect + .element(screen.getByText("Reload after updating EmDash before trying again.")) + .toBeInTheDocument(); + expect(activationMocks.advance).toHaveBeenCalledOnce(); + }); + + it("does not move focus when an already-active page first loads", async () => { + activationMocks.fetchStatus.mockResolvedValue(status("active")); + + const { screen } = await renderPage(); + const heading = screen.getByRole("heading", { name: "Media Usage is active" }); + await expect.element(heading).toBeInTheDocument(); + expect(document.activeElement).not.toBe(heading.element()); + }); + + it("keeps a busy request resumable through deliberate refresh", async () => { + activationMocks.advance.mockRejectedValue( + new MediaUsageActivationRequestError("busy", 409, "2026-08-16T09:05:00.000Z"), + ); + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + await submitConfirmation(screen); + + await expect + .element(screen.getByRole("button", { name: "Refresh status" })) + .toBeInTheDocument(); + activationMocks.fetchStatus.mockResolvedValue(status("activating")); + await userEvent.click(screen.getByRole("button", { name: "Refresh status" })); + await expect.element(screen.getByRole("button", { name: "Continue setup" })).toBeEnabled(); + expect(activationMocks.fetchStatus).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/admin/tests/lib/media-usage-activation-api.test.ts b/packages/admin/tests/lib/media-usage-activation-api.test.ts new file mode 100644 index 0000000000..10eb34d688 --- /dev/null +++ b/packages/admin/tests/lib/media-usage-activation-api.test.ts @@ -0,0 +1,188 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + MediaUsageActivationRequestError, + advanceMediaUsageActivation, + fetchMediaUsageActivationStatus, +} from "../../src/lib/api/media-usage-activation.js"; + +const activationUrl = "/_emdash/api/admin/media-usage/activation"; + +function activationStatus(state: "expanded" | "activating" | "active" = "expanded") { + return { + state, + collectionCursor: state === "activating" ? "posts" : null, + attemptCount: state === "expanded" ? 0 : 1, + drainConfirmedAt: state === "expanded" ? null : "2026-08-16T09:00:00.000Z", + lastAttemptedAt: state === "expanded" ? null : "2026-08-16T09:00:00.000Z", + lastErrorCode: null, + leaseExpiresAt: null, + activatedAt: state === "active" ? "2026-08-16T09:00:01.000Z" : null, + updatedAt: "2026-08-16T09:00:01.000Z", + } as const; +} + +function success(data: unknown): Response { + return Response.json({ success: true, data }); +} + +function failure(status: number, code: string, details?: unknown): Response { + return Response.json( + { + success: false, + error: { code, message: "private server detail", ...(details ? { details } : {}) }, + }, + { status }, + ); +} + +async function caught(run: () => Promise): Promise { + const error = await run().catch((value: unknown) => value); + expect(error).toBeInstanceOf(MediaUsageActivationRequestError); + return error as MediaUsageActivationRequestError; +} + +describe("media usage activation admin API", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("reads and validates every public activation status field", async () => { + const data = activationStatus("activating"); + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(success(data)); + + await expect(fetchMediaUsageActivationStatus()).resolves.toEqual(data); + expect(fetch).toHaveBeenCalledOnce(); + expect(fetch.mock.calls[0]?.[0]).toBe(activationUrl); + const headers = new Headers(fetch.mock.calls[0]?.[1]?.headers); + expect(headers.get("X-EmDash-Request")).toBe("1"); + }); + + it("advances once with only the two backend confirmations", async () => { + const activation = activationStatus("activating"); + const data = { outcome: "activating", processedCollections: 1, activation }; + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(success(data)); + const input = { + writersDrained: true, + maintenanceReady: true, + extra: "must not cross the API boundary", + } as const; + + await expect(advanceMediaUsageActivation(input)).resolves.toEqual(data); + expect(fetch).toHaveBeenCalledOnce(); + const [url, init] = fetch.mock.calls[0]!; + expect(url).toBe(activationUrl); + expect(init?.method).toBe("POST"); + const headers = new Headers(init?.headers); + expect(headers.get("Content-Type")).toBe("application/json"); + expect(headers.get("X-EmDash-Request")).toBe("1"); + expect(typeof init?.body).toBe("string"); + const requestBody = typeof init?.body === "string" ? init.body : ""; + expect(JSON.parse(requestBody)).toEqual({ + writersDrained: true, + maintenanceReady: true, + }); + }); + + it.each([ + ["MEDIA_USAGE_ACTIVATION_CONFLICT", "ownership_conflict"], + ["MEDIA_USAGE_ACTIVATION_VERSION_MISMATCH", "version_mismatch"], + ["VALIDATION_ERROR", "validation"], + ["MEDIA_USAGE_ACTIVATION_READ_ERROR", "read_failure"], + ["MEDIA_USAGE_ACTIVATION_ADVANCE_ERROR", "advance_failure"], + ] as const)("maps %s to %s without retaining the server message", async (code, kind) => { + const status = code === "VALIDATION_ERROR" ? 400 : code.includes("ERROR") ? 500 : 409; + vi.spyOn(globalThis, "fetch").mockResolvedValue(failure(status, code)); + + const error = await caught(() => fetchMediaUsageActivationStatus()); + + expect(error).toMatchObject({ kind, status }); + expect(error.message).not.toContain("private server detail"); + }); + + it.each([401, 403])("maps a malformed %s response to denied before parsing", async (status) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response("not json", { status, statusText: "private detail" }), + ); + + await expect(caught(() => fetchMediaUsageActivationStatus())).resolves.toMatchObject({ + kind: "denied", + status, + }); + }); + + it.each(["UNAUTHORIZED", "FORBIDDEN", "INSUFFICIENT_SCOPE"])( + "maps %s to denied", + async (code) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(failure(500, code)); + + await expect(caught(() => fetchMediaUsageActivationStatus())).resolves.toMatchObject({ + kind: "denied", + }); + }, + ); + + it("maps a busy response without retaining server details", async () => { + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce( + failure(409, "MEDIA_USAGE_ACTIVATION_BUSY", { + leaseExpiresAt: "2026-08-16T09:05:00.000Z", + }), + ) + .mockResolvedValueOnce(failure(409, "MEDIA_USAGE_ACTIVATION_BUSY", { leaseExpiresAt: 123 })); + + await expect(caught(() => fetchMediaUsageActivationStatus())).resolves.toMatchObject({ + kind: "busy", + status: 409, + }); + const malformed = await caught(() => fetchMediaUsageActivationStatus()); + expect(malformed.kind).toBe("busy"); + expect(malformed).not.toHaveProperty("leaseExpiresAt"); + }); + + it.each([ + ["invalid state", { ...activationStatus(), state: "invalid" }], + ["negative attempts", { ...activationStatus(), attemptCount: -1 }], + ["unknown error", failure(418, "UNKNOWN_CODE")], + ["malformed JSON", new Response("not json")], + ] as const)("rejects %s as an unknown read error", async (_label, value) => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + value instanceof Response ? value : success(value), + ); + + await expect(caught(() => fetchMediaUsageActivationStatus())).resolves.toMatchObject({ + kind: "unknown", + }); + }); + + it("wraps network failures without retaining their message", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("secret upstream hostname")); + + const error = await caught(() => fetchMediaUsageActivationStatus()); + + expect(error).toMatchObject({ kind: "unknown", status: null }); + expect(error.message).not.toContain("secret upstream hostname"); + }); + + it.each([ + ["negative count", { outcome: "activating", processedCollections: -1 }], + ["count above limit", { outcome: "activating", processedCollections: 2 }], + ["fractional count", { outcome: "activating", processedCollections: 0.5 }], + ["outcome mismatch", { outcome: "active", processedCollections: 1 }], + ["nested expanded", { outcome: "activating", processedCollections: 0, state: "expanded" }], + ] as const)("treats malformed POST success (%s) as unknown", async (_label, shape) => { + const state = + "state" in shape ? shape.state : shape.outcome === "active" ? "activating" : shape.outcome; + vi.spyOn(globalThis, "fetch").mockResolvedValue( + success({ + outcome: shape.outcome, + processedCollections: shape.processedCollections, + activation: activationStatus(state as "expanded" | "activating" | "active"), + }), + ); + + await expect( + caught(() => advanceMediaUsageActivation({ writersDrained: true, maintenanceReady: true })), + ).resolves.toMatchObject({ kind: "unknown", status: 200 }); + }); +}); From 5af959335808a1c82b2d115ee9a591758fbef7f2 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Sun, 16 Aug 2026 18:34:48 +0100 Subject: [PATCH 3/4] feat(admin): show media usage indexing progress --- .changeset/media-usage-activation-ui.md | 6 + .../content/docs/deployment/cloudflare.mdx | 11 +- docs/src/content/docs/deployment/nodejs.mdx | 11 +- .../src/content/docs/guides/media-library.mdx | 14 ++ docs/src/content/docs/reference/rest-api.mdx | 13 ++ .../admin/src/components/MediaLibrary.tsx | 33 +++- .../settings/MediaUsageSettings.tsx | 173 ++++++++++++------ .../src/lib/api/media-usage-activation.ts | 38 +++- .../tests/components/MediaLibrary.test.tsx | 49 +++++ .../settings/MediaUsageSettings.test.tsx | 70 ++++++- .../lib/media-usage-activation-api.test.ts | 27 +++ .../src/database/repositories/media-usage.ts | 5 +- .../media-usage-read-repository.test.ts | 10 + 13 files changed, 386 insertions(+), 74 deletions(-) create mode 100644 .changeset/media-usage-activation-ui.md diff --git a/.changeset/media-usage-activation-ui.md b/.changeset/media-usage-activation-ui.md new file mode 100644 index 0000000000..13d8d8171e --- /dev/null +++ b/.changeset/media-usage-activation-ui.md @@ -0,0 +1,6 @@ +--- +"emdash": minor +"@emdash-cms/admin": minor +--- + +Adds guided Media Usage setup and historical indexing progress for administrators. diff --git a/docs/src/content/docs/deployment/cloudflare.mdx b/docs/src/content/docs/deployment/cloudflare.mdx index 7b745d83ca..75987a190f 100644 --- a/docs/src/content/docs/deployment/cloudflare.mdx +++ b/docs/src/content/docs/deployment/cloudflare.mdx @@ -110,10 +110,13 @@ To use different schedules, set the corresponding `generalCron` or `mediaUsageCr ### Enable automatic media usage indexing -Keep `mediaUsageCron` running while you enable automatic media usage indexing. Pause all application -and direct database writes, follow [Enable automatic media usage -indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the -endpoint returns `active`. Existing content is indexed in the background. +Keep `mediaUsageCron` running, then pause all application and direct database writes. Open +**Settings → Media Usage**, select **Enable Media Usage**, confirm the safety requirements, and use +**Continue setup** until setup finishes. Resume writers only after activation is active. The page +then shows how many content types have finished indexing existing content. + +The [REST procedure](/reference/rest-api/#enable-automatic-media-usage-indexing) remains available +for API operators. ## Deploy diff --git a/docs/src/content/docs/deployment/nodejs.mdx b/docs/src/content/docs/deployment/nodejs.mdx index 89b8196815..4d3cd9a2dc 100644 --- a/docs/src/content/docs/deployment/nodejs.mdx +++ b/docs/src/content/docs/deployment/nodejs.mdx @@ -63,10 +63,13 @@ scheduled tasks pause. ### Enable automatic media usage indexing -Keep at least one Node process running while you enable automatic media usage indexing. Pause all -application and direct database writes, follow [Enable automatic media usage -indexing](/reference/rest-api/#enable-automatic-media-usage-indexing), then resume writes when the -endpoint returns `active`. Existing content is indexed in the background. +Keep at least one Node process running, then pause all application and direct database writes. Open +**Settings → Media Usage**, select **Enable Media Usage**, confirm the safety requirements, and use +**Continue setup** until setup finishes. Resume writers only after activation is active. The page +then shows how many content types have finished indexing existing content. + +The [REST procedure](/reference/rest-api/#enable-automatic-media-usage-indexing) remains available +for API operators. ## Production Storage diff --git a/docs/src/content/docs/guides/media-library.mdx b/docs/src/content/docs/guides/media-library.mdx index d5f5147a76..d006b9e1d8 100644 --- a/docs/src/content/docs/guides/media-library.mdx +++ b/docs/src/content/docs/guides/media-library.mdx @@ -14,6 +14,20 @@ Open the media library from the admin sidebar by clicking **Media**. The library EmDash media library showing image grid with upload button +## Used in + +Open a local media item's details to see known references from EmDash-managed content fields. +Already-found references remain visible while historical content is still indexing. + +Administrators see a Media Library notice until automatic indexing is active. Open **Settings → +Media Usage**, enable the feature, and continue its bounded setup steps. Once active, the Settings +page shows how many content types have finished indexing existing content. + + + ## Uploading Files ### From the Media Library diff --git a/docs/src/content/docs/reference/rest-api.mdx b/docs/src/content/docs/reference/rest-api.mdx index 607e52587f..cb23b146f6 100644 --- a/docs/src/content/docs/reference/rest-api.mdx +++ b/docs/src/content/docs/reference/rest-api.mdx @@ -371,6 +371,9 @@ paused while EmDash prepares each collection so that no changes are missed. Both endpoints require `schema:manage`. Bearer tokens also require the `admin` scope. +Administrators can run the same procedure from **Settings → Media Usage**. The page sends one POST +per deliberate setup action; it cannot stop external writers or verify scheduled maintenance. + #### Check the current state ```http @@ -429,6 +432,16 @@ again. Do not edit EmDash's internal database tables. When the state is `active`, EmDash tracks changes to media references in content. Existing content may still be indexing in the background. +#### Check historical indexing progress + +```http +GET /_emdash/api/admin/media-usage/progress +``` + +After activation is active, this returns `indexing`, `ready`, or `needs_attention` together with the +number of ready and total current content types. It does not inspect content rows or return work-item +details. The endpoint requires `schema:manage`; bearer tokens also require the `admin` scope. + ### List Media Usage Work ```http diff --git a/packages/admin/src/components/MediaLibrary.tsx b/packages/admin/src/components/MediaLibrary.tsx index 2e976fdc29..22292fd395 100644 --- a/packages/admin/src/components/MediaLibrary.tsx +++ b/packages/admin/src/components/MediaLibrary.tsx @@ -1,4 +1,4 @@ -import { Button, Input, Loader, Select, Tabs } from "@cloudflare/kumo"; +import { Banner, Button, Input, Loader, Select, Tabs } from "@cloudflare/kumo"; import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { @@ -22,6 +22,11 @@ import { fetchProviderMedia, uploadToProvider, } from "../lib/api"; +import { useCurrentUser } from "../lib/api/current-user.js"; +import { + MEDIA_USAGE_ACTIVATION_QUERY_KEY, + fetchMediaUsageActivationStatus, +} from "../lib/api/media-usage-activation.js"; import { useDebouncedValue } from "../lib/hooks.js"; import { providerItemToMediaItem, @@ -33,6 +38,7 @@ import { } from "../lib/media-utils"; import { cn } from "../lib/utils"; import { MediaDetailPanel } from "./MediaDetailPanel"; +import { RouterLinkButton } from "./RouterLinkButton.js"; /** Maps a coarse type-filter choice to the media list's `mimeType` filter. */ function mimeForTypeFilter(value: string): string | string[] | undefined { @@ -80,6 +86,17 @@ export function MediaLibrary({ onLocalMimeFilterChange, }: MediaLibraryProps) { const { t } = useLingui(); + const isAdmin = (useCurrentUser().data?.role ?? 0) >= 50; + const activationQuery = useQuery({ + queryKey: MEDIA_USAGE_ACTIVATION_QUERY_KEY, + queryFn: fetchMediaUsageActivationStatus, + enabled: isAdmin, + retry: false, + staleTime: 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + const setupStatus = isAdmin && !activationQuery.isError ? activationQuery.data : undefined; const [viewMode, setViewMode] = React.useState<"grid" | "list">("grid"); const [detailItem, setDetailItem] = React.useState(null); const [isDetailOpen, setIsDetailOpen] = React.useState(false); @@ -368,6 +385,20 @@ export function MediaLibrary({ )} + {activeProvider === "local" && setupStatus && setupStatus.state !== "active" ? ( + + {setupStatus.state === "activating" ? t`Continue setup` : t`Enable Media Usage`} + + } + /> + ) : null} {/* Provider tabs (only when an external provider is configured) */} {providerTabs.length > 1 && ( diff --git a/packages/admin/src/components/settings/MediaUsageSettings.tsx b/packages/admin/src/components/settings/MediaUsageSettings.tsx index 5f43b7abd7..9ab39d6c75 100644 --- a/packages/admin/src/components/settings/MediaUsageSettings.tsx +++ b/packages/admin/src/components/settings/MediaUsageSettings.tsx @@ -1,4 +1,5 @@ import { Badge, Banner, Button, Checkbox, Loader } from "@cloudflare/kumo"; +import { plural } from "@lingui/core/macro"; import { useLingui } from "@lingui/react/macro"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import * as React from "react"; @@ -9,7 +10,9 @@ import { MediaUsageActivationRequestError, advanceMediaUsageActivation, fetchMediaUsageActivationStatus, + fetchMediaUsageProgress, type MediaUsageActivationStatus, + type MediaUsageProgress, } from "../../lib/api/media-usage-activation.js"; import { ConfirmDialog } from "../ConfirmDialog.js"; import { SettingRow, SettingsFrame, SettingsSection } from "./SettingsLayout.js"; @@ -18,15 +21,7 @@ const ROLE_ADMIN = 50; const EMPTY_CONFIRMATIONS = { maintenance: false, writers: false, irreversible: false }; type Confirmation = keyof typeof EMPTY_CONFIRMATIONS; -type Notice = - | "busy" - | "ownership" - | "ambiguous" - | "unconfirmed" - | "version" - | "validation" - | "denied" - | null; +type Notice = "busy" | "refreshed" | "unconfirmed" | "version" | "validation" | "denied" | null; export function MediaUsageSettings() { const { t } = useLingui(); @@ -36,7 +31,6 @@ export function MediaUsageSettings() { const [confirmations, setConfirmations] = React.useState(EMPTY_CONFIRMATIONS); const [dialogOpen, setDialogOpen] = React.useState(false); const [notice, setNotice] = React.useState(null); - const [liveMessage, setLiveMessage] = React.useState(""); const awayGenerationRef = React.useRef(0); const returnGenerationRef = React.useRef(0); const refreshSequenceRef = React.useRef(0); @@ -53,6 +47,15 @@ export function MediaUsageSettings() { refetchOnWindowFocus: false, refetchOnReconnect: false, }); + const progressQuery = useQuery({ + queryKey: ["media-usage-progress"], + queryFn: fetchMediaUsageProgress, + enabled: isAdmin && activationQuery.data?.state === "active", + retry: false, + refetchInterval: 60_000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); const resetConfirmations = React.useCallback(() => { setConfirmations(EMPTY_CONFIRMATIONS); @@ -66,6 +69,7 @@ export function MediaUsageSettings() { const refreshStatus = React.useCallback( async (reason: "manual" | "ownership" | "ambiguous") => { + if (submittingRef.current) return; const awayGeneration = awayGenerationRef.current; const refreshSequence = ++refreshSequenceRef.current; if (reason === "ownership" || reason === "ambiguous") setNotice("unconfirmed"); @@ -84,18 +88,13 @@ export function MediaUsageSettings() { setNotice((current) => current === "validation" || current === "version" ? current - : reason === "ownership" - ? "ownership" - : reason === "ambiguous" - ? "ambiguous" - : null, + : reason === "manual" + ? null + : "refreshed", ); if (result.data.state !== "active") focusActiveRef.current = false; - setLiveMessage( - result.data.state === "active" ? t`Media Usage is active.` : t`Setup status updated.`, - ); }, - [activationQuery, resetConfirmations, t], + [activationQuery, resetConfirmations], ); React.useEffect(() => { @@ -125,25 +124,22 @@ export function MediaUsageSettings() { }, [isAdmin, refreshStatus, resetConfirmations]); const advanceMutation = useMutation({ - mutationFn: () => advanceMediaUsageActivation({ writersDrained: true, maintenanceReady: true }), + mutationFn: async () => { + await queryClient.cancelQueries({ queryKey: MEDIA_USAGE_ACTIVATION_QUERY_KEY }); + return advanceMediaUsageActivation({ writersDrained: true, maintenanceReady: true }); + }, retry: false, onSuccess: (result) => { queryClient.setQueryData(MEDIA_USAGE_ACTIVATION_QUERY_KEY, result.activation); setNotice(null); setDialogOpen(false); - setLiveMessage( - result.activation.state === "active" - ? t`Media Usage is active.` - : result.processedCollections === 1 - ? t`One content type is ready. Continue setup.` - : t`Setup status updated. Continue setup.`, - ); if (result.activation.state !== "active") focusActiveRef.current = false; if (result.activation.state === "active" || result.activation.lastErrorCode) { resetConfirmations(); } }, onError: (caught) => { + submittingRef.current = false; void handleAdvanceError(caught); }, onSettled: () => { @@ -248,6 +244,20 @@ export function MediaUsageSettings() { : activation.state === "expanded" ? t`Enable Media Usage` : t`Continue setup`; + const liveMessage = active + ? progressQuery.isError + ? t`Indexing progress is unavailable.` + : progressQuery.isPending + ? t`Checking indexing progress.` + : progressQuery.data?.status === "ready" + ? t`Media Usage is ready.` + : progressQuery.data?.status === "needs_attention" + ? t`Media Usage needs attention.` + : t`Existing content is indexing.` + : activation.state === "activating" + ? t`Setup is in progress.` + : t`Media Usage is off.`; + const progressAlert = progressQuery.isError || progressQuery.data?.status === "needs_attention"; const submit = () => { if (submittingRef.current) return; submittingRef.current = true; @@ -264,9 +274,23 @@ export function MediaUsageSettings() { return ( - - - {storedFailure || notice ? ( + void progressQuery.refetch()}> + {t`Try again`} + + ) : undefined + } + > + + {!active && (storedFailure || notice) ? ( @@ -290,11 +314,14 @@ export function MediaUsageSettings() { activation={activation} confirmations={confirmations} pending={advanceMutation.isPending} - onOpenChange={setDialogOpen} + onOpenChange={(open) => { + setDialogOpen(open); + if (!open) setConfirmations(EMPTY_CONFIRMATIONS); + }} onChange={(key, checked) => setConfirmations((current) => ({ ...current, [key]: checked }))} onConfirm={submit} /> - + {liveMessage} @@ -303,14 +330,69 @@ export function MediaUsageSettings() { function StatusRow({ activation, + progress, + progressError, activeHeadingRef, }: { activation: MediaUsageActivationStatus; + progress: MediaUsageProgress | undefined; + progressError: boolean; activeHeadingRef: React.RefObject; }) { const { t } = useLingui(); const active = activation.state === "active"; const settingUp = activation.state === "activating"; + let heading = t`Automatic indexing is off`; + let detail = t`Enable Media Usage to index existing content and keep references up to date.`; + let badge = t`Off`; + let variant: "neutral" | "warning" | "success" | "error" = "neutral"; + if (settingUp) { + heading = t`Setup is in progress`; + detail = t`Keep editing paused until setup is complete.`; + badge = t`Setting up`; + variant = "warning"; + } + if (active) { + if (progressError) { + heading = t`Indexing progress unavailable`; + detail = t`New changes are still tracked automatically.`; + badge = t`Unavailable`; + variant = "error"; + } + heading = progressError + ? heading + : !progress + ? t`Checking indexing progress` + : progress.status === "ready" + ? t`Media Usage is ready` + : progress?.status === "needs_attention" + ? t`Media Usage needs attention` + : t`Indexing existing content`; + detail = progressError + ? detail + : progress + ? plural(progress.totalCollections, { + one: `${progress.readyCollections} of # content type ready`, + other: `${progress.readyCollections} of # content types ready`, + }) + : t`Checking indexing progress…`; + badge = progressError + ? badge + : !progress + ? t`Checking` + : progress.status === "ready" + ? t`Ready` + : progress.status === "needs_attention" + ? t`Needs attention` + : t`Indexing`; + variant = progressError + ? variant + : progress?.status === "ready" + ? "success" + : progress?.status === "needs_attention" + ? "error" + : "warning"; + } return (
@@ -320,25 +402,12 @@ function StatusRow({ tabIndex={active ? -1 : undefined} className="text-sm font-medium leading-5" > - {active - ? t`Media Usage is active` - : settingUp - ? t`Setup is in progress` - : t`Automatic indexing is off`} + {heading} -

- {active - ? t`New changes are tracked automatically. Existing content may still be indexing.` - : settingUp - ? t`Keep editing paused until setup is complete.` - : t`Enable Media Usage to index existing content and keep references up to date.`} -

+

{detail}

- - {active ? t`Active` : settingUp ? t`Setting up` : t`Off`} + + {badge}
@@ -456,11 +525,7 @@ function SetupNotice({ return ( ); diff --git a/packages/admin/src/lib/api/media-usage-activation.ts b/packages/admin/src/lib/api/media-usage-activation.ts index 51d7ca967a..99e3c162ea 100644 --- a/packages/admin/src/lib/api/media-usage-activation.ts +++ b/packages/admin/src/lib/api/media-usage-activation.ts @@ -4,10 +4,8 @@ const ACTIVATION_URL = `${API_BASE}/admin/media-usage/activation`; export const MEDIA_USAGE_ACTIVATION_QUERY_KEY = ["media-usage-activation"] as const; -export type MediaUsageActivationState = "expanded" | "activating" | "active"; - export interface MediaUsageActivationStatus { - state: MediaUsageActivationState; + state: "expanded" | "activating" | "active"; collectionCursor: string | null; attemptCount: number; drainConfirmedAt: string | null; @@ -24,6 +22,12 @@ export interface MediaUsageActivationAdvanceResponse { activation: MediaUsageActivationStatus; } +export interface MediaUsageProgress { + status: "indexing" | "ready" | "needs_attention"; + readyCollections: number; + totalCollections: number; +} + export type MediaUsageActivationErrorKind = | "busy" | "ownership_conflict" @@ -52,6 +56,14 @@ export async function fetchMediaUsageActivationStatus(): Promise { + const response = await activationFetch(`${API_BASE}/admin/media-usage/progress`); + if (!response.ok) throw await parseActivationError(response); + const data = await readSuccessData(response); + if (!isMediaUsageProgress(data)) throw unknownResponse(response.status); + return data; +} + export async function advanceMediaUsageActivation(input: { writersDrained: true; maintenanceReady: true; @@ -159,3 +171,23 @@ function isAdvanceResponse(value: unknown): value is MediaUsageActivationAdvance value.processedCollections <= 1 ); } + +function isMediaUsageProgress(value: unknown): value is MediaUsageProgress { + if ( + !isRecord(value) || + (value.status !== "indexing" && value.status !== "ready" && value.status !== "needs_attention") + ) + return false; + const ready = value.readyCollections; + const total = value.totalCollections; + if ( + typeof ready !== "number" || + !Number.isSafeInteger(ready) || + ready < 0 || + typeof total !== "number" || + !Number.isSafeInteger(total) || + total < ready + ) + return false; + return value.status === "needs_attention" || (value.status === "ready") === (ready === total); +} diff --git a/packages/admin/tests/components/MediaLibrary.test.tsx b/packages/admin/tests/components/MediaLibrary.test.tsx index 555fdd927d..78760fac3b 100644 --- a/packages/admin/tests/components/MediaLibrary.test.tsx +++ b/packages/admin/tests/components/MediaLibrary.test.tsx @@ -14,6 +14,24 @@ import { render } from "../utils/render.tsx"; const UPLOAD_CTA_PATTERN = /Upload images, videos, and documents to keep reusable assets/; const UPLOAD_TO_LIBRARY_PATTERN = /Upload to Library/; const UPLOAD_FILES_PATTERN = /Upload Files/; +const setupMocks = vi.hoisted(() => ({ fetchStatus: vi.fn(), role: 40 })); + +vi.mock("../../src/lib/api/media-usage-activation.js", async () => { + const actual = await vi.importActual< + typeof import("../../src/lib/api/media-usage-activation.js") + >("../../src/lib/api/media-usage-activation.js"); + return { ...actual, fetchMediaUsageActivationStatus: setupMocks.fetchStatus }; +}); + +vi.mock("../../src/lib/api/current-user.js", () => ({ + useCurrentUser: () => ({ data: { id: "user-1", role: setupMocks.role } }), +})); + +vi.mock("../../src/components/RouterLinkButton.js", () => ({ + RouterLinkButton: ({ to, children }: { to: string; children: React.ReactNode }) => ( + {children} + ), +})); vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); @@ -67,6 +85,37 @@ function makeMediaItem(overrides: Partial = {}): MediaItem { describe("MediaLibrary", () => { beforeEach(() => { vi.clearAllMocks(); + setupMocks.role = 40; + setupMocks.fetchStatus.mockResolvedValue({ state: "active" }); + }); + + describe("Media Usage setup discovery", () => { + it("shows an administrator one setup action while activation is off", async () => { + setupMocks.role = 50; + setupMocks.fetchStatus.mockResolvedValue({ state: "expanded" }); + + const screen = await renderLibrary(); + + await expect.element(screen.getByText("Set up Media Usage")).toBeInTheDocument(); + await expect + .element(screen.getByRole("link", { name: "Enable Media Usage" })) + .toBeInTheDocument(); + }); + + it("keeps the library usable when optional setup discovery fails", async () => { + setupMocks.role = 50; + setupMocks.fetchStatus.mockRejectedValue(new Error("status unavailable")); + + const screen = await renderLibrary({ + items: [makeMediaItem({ id: "1", filename: "still-usable.jpg" })], + }); + + await expect.element(screen.getByAltText("still-usable.jpg")).toBeInTheDocument(); + await expect + .element(screen.getByRole("button", { name: UPLOAD_TO_LIBRARY_PATTERN })) + .toBeInTheDocument(); + expect(screen.getByText("Set up Media Usage").query()).toBeNull(); + }); }); describe("rendering items", () => { diff --git a/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx b/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx index 3a26ce4e2a..59d6f7e924 100644 --- a/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx +++ b/packages/admin/tests/components/settings/MediaUsageSettings.test.tsx @@ -7,6 +7,7 @@ import { render } from "../../utils/render.tsx"; const activationMocks = vi.hoisted(() => ({ fetchStatus: vi.fn(), + fetchProgress: vi.fn(), advance: vi.fn(), })); const currentUserMock = vi.hoisted(() => vi.fn()); @@ -18,6 +19,7 @@ vi.mock("../../../src/lib/api/media-usage-activation.js", async () => { return { ...actual, fetchMediaUsageActivationStatus: activationMocks.fetchStatus, + fetchMediaUsageProgress: activationMocks.fetchProgress, advanceMediaUsageActivation: activationMocks.advance, }; }); @@ -98,6 +100,11 @@ describe("MediaUsageSettings", () => { vi.clearAllMocks(); setCurrentUser(50); activationMocks.fetchStatus.mockResolvedValue(status("expanded")); + activationMocks.fetchProgress.mockResolvedValue({ + status: "indexing", + readyCollections: 1, + totalCollections: 2, + }); }); it("denies a direct Editor visit before requesting activation status", async () => { @@ -162,14 +169,31 @@ describe("MediaUsageSettings", () => { await expect.element(pending).toBeDisabled(); pending.element().click(); expect(activationMocks.advance).toHaveBeenCalledOnce(); + window.dispatchEvent(new PageTransitionEvent("pagehide")); + window.dispatchEvent(new PageTransitionEvent("pageshow")); + expect(activationMocks.fetchStatus).toHaveBeenCalledOnce(); resolveAdvance({ outcome: "active", processedCollections: 1, activation: status("active") }); await expect - .element(screen.getByRole("heading", { name: "Media Usage is active" })) + .element(screen.getByRole("heading", { name: "Indexing existing content" })) .toBeInTheDocument(); expect(screen.getByRole("button", { name: /setup/i }).query()).toBeNull(); }); + it("clears safety confirmations when the dialog is cancelled", async () => { + const { screen } = await renderPage(); + await openConfirmation(screen); + await confirmAll(screen); + const cancel = screen.getByRole("button", { name: "Cancel" }); + cancel.element().focus(); + await userEvent.keyboard("{Enter}"); + + await openConfirmation(screen); + for (const checkbox of screen.getByRole("checkbox").all()) { + await expect.element(checkbox).not.toBeChecked(); + } + }); + it("resets confirmations after leaving and returning", async () => { activationMocks.advance.mockResolvedValue({ outcome: "activating", @@ -187,7 +211,7 @@ describe("MediaUsageSettings", () => { await userEvent.click(screen.getByRole("button", { name: "Continue setup" })); await expect.element(screen.getByRole("dialog", { name: "Enable Media Usage" })).toBeVisible(); - for (const checkbox of await screen.getByRole("checkbox").all()) { + for (const checkbox of screen.getByRole("checkbox").all()) { await expect.element(checkbox).not.toBeChecked(); } }); @@ -204,7 +228,7 @@ describe("MediaUsageSettings", () => { await submitConfirmation(screen); await expect - .element(screen.getByRole("heading", { name: "Media Usage is active" })) + .element(screen.getByRole("heading", { name: "Indexing existing content" })) .toBeInTheDocument(); expect(activationMocks.fetchStatus).toHaveBeenCalledTimes(2); expect(activationMocks.advance).toHaveBeenCalledOnce(); @@ -259,15 +283,47 @@ describe("MediaUsageSettings", () => { activationMocks.fetchStatus.mockResolvedValue(status("active")); const { screen } = await renderPage(); - const heading = screen.getByRole("heading", { name: "Media Usage is active" }); + const heading = screen.getByRole("heading", { name: "Indexing existing content" }); await expect.element(heading).toBeInTheDocument(); expect(document.activeElement).not.toBe(heading.element()); }); - it("keeps a busy request resumable through deliberate refresh", async () => { - activationMocks.advance.mockRejectedValue( - new MediaUsageActivationRequestError("busy", 409, "2026-08-16T09:05:00.000Z"), + it.each([ + ["indexing", "Indexing existing content", "1 of 2 content types ready"], + ["ready", "Media Usage is ready", "2 of 2 content types ready"], + ["needs_attention", "Media Usage needs attention", "1 of 2 content types ready"], + ] as const)("shows %s progress after activation", async (progressStatus, heading, summary) => { + activationMocks.fetchStatus.mockResolvedValue(status("active")); + activationMocks.fetchProgress.mockResolvedValue({ + status: progressStatus, + readyCollections: progressStatus === "ready" ? 2 : 1, + totalCollections: 2, + }); + + const { screen } = await renderPage(); + + await expect.element(screen.getByRole("heading", { name: heading })).toBeInTheDocument(); + await expect.element(screen.getByText(summary)).toBeInTheDocument(); + expect(activationMocks.fetchProgress).toHaveBeenCalledOnce(); + }); + + it("keeps active setup visible when progress cannot be loaded", async () => { + activationMocks.fetchStatus.mockResolvedValue(status("active")); + activationMocks.fetchProgress.mockRejectedValue( + new MediaUsageActivationRequestError("read_failure", 500), ); + + const { screen } = await renderPage(); + + await expect + .element(screen.getByRole("heading", { name: "Indexing progress unavailable" })) + .toBeInTheDocument(); + await expect.element(screen.getByRole("button", { name: "Try again" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /setup/i }).query()).toBeNull(); + }); + + it("keeps a busy request resumable through deliberate refresh", async () => { + activationMocks.advance.mockRejectedValue(new MediaUsageActivationRequestError("busy", 409)); const { screen } = await renderPage(); await openConfirmation(screen); await confirmAll(screen); diff --git a/packages/admin/tests/lib/media-usage-activation-api.test.ts b/packages/admin/tests/lib/media-usage-activation-api.test.ts index 10eb34d688..bf7cb990e0 100644 --- a/packages/admin/tests/lib/media-usage-activation-api.test.ts +++ b/packages/admin/tests/lib/media-usage-activation-api.test.ts @@ -4,9 +4,11 @@ import { MediaUsageActivationRequestError, advanceMediaUsageActivation, fetchMediaUsageActivationStatus, + fetchMediaUsageProgress, } from "../../src/lib/api/media-usage-activation.js"; const activationUrl = "/_emdash/api/admin/media-usage/activation"; +const progressUrl = "/_emdash/api/admin/media-usage/progress"; function activationStatus(state: "expanded" | "activating" | "active" = "expanded") { return { @@ -58,6 +60,31 @@ describe("media usage activation admin API", () => { expect(headers.get("X-EmDash-Request")).toBe("1"); }); + it("reads validated aggregate indexing progress", async () => { + const data = { status: "indexing", readyCollections: 1, totalCollections: 2 } as const; + const fetch = vi.spyOn(globalThis, "fetch").mockResolvedValue(success(data)); + + await expect(fetchMediaUsageProgress()).resolves.toEqual(data); + expect(fetch.mock.calls[0]?.[0]).toBe(progressUrl); + }); + + it("rejects contradictory aggregate progress", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue( + success({ status: "ready", readyCollections: 2, totalCollections: 1 }), + ); + + await expect(caught(() => fetchMediaUsageProgress())).resolves.toMatchObject({ + kind: "unknown", + }); + }); + + it("accepts attention when every remaining content type is ready", async () => { + const data = { status: "needs_attention", readyCollections: 1, totalCollections: 1 } as const; + vi.spyOn(globalThis, "fetch").mockResolvedValue(success(data)); + + await expect(fetchMediaUsageProgress()).resolves.toEqual(data); + }); + it("advances once with only the two backend confirmations", async () => { const activation = activationStatus("activating"); const data = { outcome: "activating", processedCollections: 1, activation }; diff --git a/packages/core/src/database/repositories/media-usage.ts b/packages/core/src/database/repositories/media-usage.ts index 4ecf470629..029cb1a95b 100644 --- a/packages/core/src/database/repositories/media-usage.ts +++ b/packages/core/src/database/repositories/media-usage.ts @@ -851,7 +851,10 @@ export class MediaUsageRepository { ) AS activation_active, COUNT(*) AS total_collections, COALESCE(SUM(is_ready), 0) AS ready_collections, - COALESCE(MAX(needs_attention), 0) AS needs_attention + CASE WHEN COALESCE(MAX(needs_attention), 0) = 1 OR EXISTS ( + SELECT 1 FROM _emdash_media_usage_collection_deletions AS deletion + WHERE deletion.state = 'failed' + ) THEN 1 ELSE 0 END AS needs_attention FROM collection_progress `.execute(this.db); const row = result.rows[0]; diff --git a/packages/core/tests/integration/database/media-usage-read-repository.test.ts b/packages/core/tests/integration/database/media-usage-read-repository.test.ts index e16877d406..f0d4ce7fb4 100644 --- a/packages/core/tests/integration/database/media-usage-read-repository.test.ts +++ b/packages/core/tests/integration/database/media-usage-read-repository.test.ts @@ -455,6 +455,16 @@ describeEachDialect("MediaUsageRepository reads", (dialect) => { readyCollections: 1, totalCollections: 1, }); + await ctx.db + .updateTable("_emdash_media_usage_collection_deletions") + .set({ state: "failed" }) + .where("collection_id", "=", "collection-posts") + .execute(); + await expect(progress()).resolves.toEqual({ + status: "needs_attention", + readyCollections: 1, + totalCollections: 1, + }); await ctx.db .updateTable("_emdash_media_usage_index_status") From 91eeaefc81449e9d754d0112b836f8dae14207e8 Mon Sep 17 00:00:00 2001 From: khoinguyenpham04 <137921741+khoinguyenpham04@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:48:05 +0100 Subject: [PATCH 4/4] fix(core): start Node media usage reconciliation --- packages/core/src/emdash-runtime.ts | 19 +++++---- .../media-usage-scheduled-driver.test.ts | 41 +++++++++++++++++++ 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/packages/core/src/emdash-runtime.ts b/packages/core/src/emdash-runtime.ts index 5d02c4386a..ce4dd347cb 100644 --- a/packages/core/src/emdash-runtime.ts +++ b/packages/core/src/emdash-runtime.ts @@ -221,7 +221,7 @@ import { PluginStateRepository } from "./plugins/state.js"; import { syncDeclaredStorageIndexes } from "./plugins/storage-indexes.js"; import { normalizeRegistryConfig } from "./registry/config.js"; import { requestCached } from "./request-cache.js"; -import { getRequestContext } from "./request-context.js"; +import { getRequestContext, runWithContext } from "./request-context.js"; import { publishDueContent, type PublishedRef } from "./scheduled-publish.js"; import { FTSManager } from "./search/fts-manager.js"; import { invalidateSiteSettingsCache } from "./settings/index.js"; @@ -1723,14 +1723,15 @@ export class EmDashRuntime { if (deps.createScheduler) { const scheduler = deps.createScheduler(cronExecutor); cronScheduler = scheduler; - const runMediaUsageMaintenance = async () => { - const runtime = runtimeRef.current; - if (runtime) { - await runtime.runScheduledMediaUsageTasks(); - } else { - await runScheduledMediaUsageLane(db); - } - }; + const runMediaUsageMaintenance = () => + runWithContext({ editMode: false }, async () => { + const runtime = runtimeRef.current; + if (runtime) { + await runtime.runScheduledMediaUsageTasks(); + } else { + await runScheduledMediaUsageLane(db); + } + }); // Run scheduled publishing and system cleanup alongside each tick. // Pass storage so cleanupPendingUploads can delete orphaned files. diff --git a/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts b/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts index e6f124ba0b..f7d6e89322 100644 --- a/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts +++ b/packages/core/tests/integration/runtime/media-usage-scheduled-driver.test.ts @@ -10,6 +10,7 @@ import { MEDIA_USAGE_MAINTENANCE_QUERY_RESERVATIONS, type RuntimeDependencies, } from "../../../src/emdash-runtime.js"; +import { activateMediaUsageCapture } from "../../../src/media/usage/activation.js"; import { installMediaUsageCaptureTriggers } from "../../../src/media/usage/capture-triggers.js"; import type { CronScheduler, SystemCleanupFn } from "../../../src/plugins/scheduler/types.js"; import { createRequestMetrics, runWithContext } from "../../../src/request-context.js"; @@ -58,6 +59,46 @@ describe("media usage scheduled drivers", () => { ).not.toBeNull(); }); + it("starts reconciliation when Node maintenance inherits an expensive request context", async () => { + const scheduler = new CapturingScheduler(); + const metrics = createRequestMetrics(performance.now()); + runtime = await EmDashRuntime.create(createDeps(() => scheduler)); + await runtime.schemaRegistry.createCollection({ + slug: "node_reconciliation", + label: "Node reconciliation", + }); + await runtime.schemaRegistry.createField("node_reconciliation", { + slug: "title", + label: "Title", + type: "string", + }); + metrics.dbCount = MEDIA_USAGE_MAINTENANCE_QUERY_RESERVATIONS.eventCeiling; + + await expect(activateMediaUsageCapture(runtime.db, { writersDrained: true })).resolves.toEqual({ + outcome: "active", + processedCollections: 1, + }); + expect( + await runtime.db + .selectFrom("_emdash_media_usage_reconciliations") + .select("collection_id") + .executeTakeFirst(), + ).toBeUndefined(); + + await runWithContext({ editMode: false, metrics }, async () => { + await scheduler.runMaintenance(); + await scheduler.runMaintenance(); + await scheduler.runMaintenance(); + }); + + expect( + await runtime.db + .selectFrom("_emdash_media_usage_reconciliations") + .select("collection_id") + .executeTakeFirst(), + ).toBeDefined(); + }); + it("drains bounded work through a legacy Node scheduler cleanup callback", async () => { const scheduler = new LegacyCapturingScheduler(); runtime = await EmDashRuntime.create(createDeps(() => scheduler));