feat: complete catalog fleet management workflow

This commit is contained in:
Codex
2026-08-31 15:58:43 +02:00
parent 866aee4249
commit 9c697dc062
56 changed files with 6692 additions and 611 deletions
+7 -1
View File
@@ -57,6 +57,7 @@ import { catalogDescriptionConsolidationRoutes } from "./routes/catalog-descript
import { PythonModelCompleter, type ModelCompleter } from "./catalog/model-completer.js";
import { DescriptionGenerationWorker } from "./catalog/description-generation-worker.js";
import { SensitiveDataSuggester } from "./catalog/sensitive-data-suggester.js";
import { SensitiveDataSuggestionRunner } from "./catalog/sensitive-data-suggestion-runner.js";
import {
PostgresDescriptionSourceSampler,
type DescriptionSourceSampler,
@@ -185,6 +186,10 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
metadataGenerationModels,
modelCompleter,
);
const sensitiveDataSuggestionRunner = new SensitiveDataSuggestionRunner(
catalogRepository,
sensitiveDataSuggester,
);
const catalogService = deps?.catalogService ?? new CatalogService(
catalogRepository,
workspaceRegistry,
@@ -207,6 +212,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
);
app.addHook("onReady", async () => { await catalogSyncWorker.initialize(); });
app.addHook("onReady", async () => { await descriptionGenerationWorker.initialize(); });
app.addHook("onReady", async () => { await sensitiveDataSuggestionRunner.initialize(); });
if (!deps?.catalogRepository && catalogRepository.close) {
app.addHook("onClose", async () => { await catalogRepository.close?.(); });
}
@@ -444,7 +450,7 @@ export function buildApp(config: AppConfig, deps?: BuildAppDeps): FastifyInstanc
catalogDescriptionGenerationRoutes(app, {
repository: catalogRepository,
worker: descriptionGenerationWorker,
sensitiveDataSuggester,
sensitiveDataSuggestionRunner,
});
settingsRoutes(app, { cfg: config, listModels, getSettings });
piManagementRoutes(app, { service: piManagement });
+149
View File
@@ -1,4 +1,9 @@
import { randomUUID } from "node:crypto";
import {
createCatalogMetrics,
hasCatalogDescription,
latestCatalogTimestamp,
} from "./metrics.js";
import {
CatalogConflictError,
CatalogConnectorError,
@@ -8,6 +13,7 @@ import {
type CatalogDescriptionTarget,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogMetadataDeleteCounts,
type CatalogMetrics,
type CatalogRelationship,
type CatalogSchemaDiff,
type CatalogSyncCounts,
@@ -26,6 +32,10 @@ import {
type DescriptionGenerationRun,
type DescriptionGenerationRunUpdate,
type DescriptionGenerationScope,
type SensitiveDataSuggestionEvent,
type SensitiveDataSuggestionRun,
type SensitiveDataSuggestionRunUpdate,
type SensitiveDataSuggestionScope,
type TableSyncRepositoryResult,
type WorkspaceDatabase,
} from "./types.js";
@@ -42,6 +52,8 @@ export class MemoryCatalogRepository implements CatalogRepository {
private readonly relationships = new Map<string, CatalogRelationship>();
private readonly descriptionGenerationRuns = new Map<string, DescriptionGenerationRun>();
private readonly descriptionGenerationEvents = new Map<string, DescriptionGenerationEvent[]>();
private readonly sensitiveDataSuggestionRuns = new Map<string, SensitiveDataSuggestionRun>();
private readonly sensitiveDataSuggestionEvents = new Map<string, SensitiveDataSuggestionEvent[]>();
private readonly syncRuns = new Map<string, CatalogSyncRun>();
private readonly syncEvents = new Map<string, CatalogSyncEvent[]>();
@@ -56,6 +68,34 @@ export class MemoryCatalogRepository implements CatalogRepository {
const value = [...this.records.values()].find((record) => record.workspaceId === workspaceId);
return value ? clone(value) : undefined;
}
async getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined> {
if (databaseId !== undefined && !this.records.has(databaseId)) return undefined;
const selectedDatabaseIds = new Set(
databaseId === undefined ? this.records.keys() : [databaseId],
);
const tables = [...this.tables.values()]
.filter((table) => selectedDatabaseIds.has(table.databaseId));
const tableIds = new Set(tables.map((table) => table.id));
const columns = [...this.columns.values()]
.filter((column) => tableIds.has(column.tableId));
const relationships = [...this.relationships.values()]
.filter((relationship) => selectedDatabaseIds.has(relationship.databaseId));
return createCatalogMetrics(databaseId, {
tables: tables.length,
columns: columns.length,
sensitiveColumns: columns.filter((column) => column.sensitive).length,
relationships: relationships.length,
describedTables: tables.filter(hasCatalogDescription).length,
describedColumns: columns.filter(hasCatalogDescription).length,
}, latestCatalogTimestamp([
...[...selectedDatabaseIds].map((id) => this.records.get(id)?.schemaSyncedAt),
...tables.map((table) => table.updatedAt),
...columns.map((column) => column.updatedAt),
...relationships.map((relationship) => relationship.updatedAt),
]));
}
async create(input: DatabaseConfigurationInput): Promise<WorkspaceDatabase> {
if ([...this.records.values()].some((record) => record.workspaceId === input.workspaceId)) {
throw new CatalogConflictError("Workspace database already exists");
@@ -136,6 +176,11 @@ export class MemoryCatalogRepository implements CatalogRepository {
this.descriptionGenerationRuns.delete(runId);
this.descriptionGenerationEvents.delete(runId);
}
for (const [runId, run] of this.sensitiveDataSuggestionRuns) {
if (run.databaseId !== id) continue;
this.sensitiveDataSuggestionRuns.delete(runId);
this.sensitiveDataSuggestionEvents.delete(runId);
}
return this.records.delete(id);
}
async listTables(databaseId: string): Promise<CatalogTable[]> {
@@ -378,6 +423,110 @@ export class MemoryCatalogRepository implements CatalogRepository {
.map((event) => structuredClone(event));
}
async createSensitiveDataSuggestionRun(
databaseId: string,
scope: SensitiveDataSuggestionScope,
modelId: string,
): Promise<SensitiveDataSuggestionRun> {
const now = new Date().toISOString();
const run: SensitiveDataSuggestionRun = {
id: randomUUID(),
databaseId,
scope,
modelId,
status: "running",
total: 0,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
createdAt: now,
startedAt: now,
updatedAt: now,
finishedAt: null,
errorSummary: null,
};
this.sensitiveDataSuggestionRuns.set(run.id, run);
return structuredClone(run);
}
async getSensitiveDataSuggestionRun(
runId: string,
): Promise<SensitiveDataSuggestionRun | undefined> {
const run = this.sensitiveDataSuggestionRuns.get(runId);
return run ? structuredClone(run) : undefined;
}
async listSensitiveDataSuggestionRuns(limit = 50): Promise<SensitiveDataSuggestionRun[]> {
return [...this.sensitiveDataSuggestionRuns.values()]
.sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id))
.slice(0, limit)
.map((run) => structuredClone(run));
}
async interruptActiveSensitiveDataSuggestionRuns(
errorSummary: string,
): Promise<SensitiveDataSuggestionRun[]> {
const interrupted: SensitiveDataSuggestionRun[] = [];
for (const run of this.sensitiveDataSuggestionRuns.values()) {
if (run.status !== "running") continue;
const now = new Date().toISOString();
const updated: SensitiveDataSuggestionRun = {
...run,
status: "interrupted",
updatedAt: now,
finishedAt: now,
errorSummary,
};
this.sensitiveDataSuggestionRuns.set(run.id, updated);
interrupted.push(structuredClone(updated));
}
return interrupted;
}
async updateSensitiveDataSuggestionRun(
runId: string,
update: SensitiveDataSuggestionRunUpdate,
): Promise<SensitiveDataSuggestionRun | undefined> {
const current = this.sensitiveDataSuggestionRuns.get(runId);
if (!current) return undefined;
const updated = {
...current,
...structuredClone(update),
updatedAt: new Date().toISOString(),
};
this.sensitiveDataSuggestionRuns.set(runId, updated);
return structuredClone(updated);
}
async appendSensitiveDataSuggestionEvent(
runId: string,
level: SensitiveDataSuggestionEvent["level"],
message: string,
): Promise<SensitiveDataSuggestionEvent> {
if (!this.sensitiveDataSuggestionRuns.has(runId)) {
throw new CatalogConflictError("Sensitive Data Suggestion Run does not exist");
}
const events = this.sensitiveDataSuggestionEvents.get(runId) ?? [];
const event: SensitiveDataSuggestionEvent = {
runId,
sequence: events.length + 1,
level,
message,
createdAt: new Date().toISOString(),
};
events.push(event);
this.sensitiveDataSuggestionEvents.set(runId, events);
return structuredClone(event);
}
async listSensitiveDataSuggestionEvents(
runId: string,
afterSequence = 0,
): Promise<SensitiveDataSuggestionEvent[]> {
return (this.sensitiveDataSuggestionEvents.get(runId) ?? [])
.filter((event) => event.sequence > afterSequence)
.map((event) => structuredClone(event));
}
async listRelationships(databaseId: string): Promise<CatalogRelationship[]> {
return [...this.relationships.values()].filter((relationship) => relationship.databaseId === databaseId)
.sort((a, b) => `${a.sourceTableName}.${a.constraintName}`.localeCompare(`${b.sourceTableName}.${b.constraintName}`))
+53
View File
@@ -0,0 +1,53 @@
import type { CatalogMetrics } from "./types.js";
export interface CatalogMetricCounts {
tables: number;
columns: number;
sensitiveColumns: number;
relationships: number;
describedTables: number;
describedColumns: number;
}
export function hasCatalogDescription(target: {
description: string | null;
generatedDescription: string | null;
}): boolean {
return Boolean(target.description?.trim() || target.generatedDescription?.trim());
}
export function latestCatalogTimestamp(
values: readonly (string | null | undefined)[],
): string | null {
let latest: number | undefined;
for (const value of values) {
if (!value) continue;
const timestamp = Date.parse(value);
if (!Number.isFinite(timestamp)) continue;
latest = latest === undefined ? timestamp : Math.max(latest, timestamp);
}
return latest === undefined ? null : new Date(latest).toISOString();
}
export function createCatalogMetrics(
databaseId: string | undefined,
counts: CatalogMetricCounts,
updatedAt: string | null,
): CatalogMetrics {
const descriptionTargets = counts.tables + counts.columns;
const describedTargets = counts.describedTables + counts.describedColumns;
return {
scope: databaseId === undefined ? "global" : "database",
databaseId: databaseId ?? null,
tables: counts.tables,
columns: counts.columns,
sensitiveColumns: counts.sensitiveColumns,
relationships: counts.relationships,
descriptionTargets,
describedTargets,
descriptionCoverage: descriptionTargets === 0
? 0
: Math.round((describedTargets / descriptionTargets) * 100),
updatedAt,
};
}
+2
View File
@@ -9,6 +9,7 @@ import * as catalogSchemaSyncMigration from "./migrations/003_catalog_schema_syn
import * as catalogRuntimeSequencePrivilegesMigration from "./migrations/004_catalog_runtime_sequence_privileges.js";
import * as descriptionGenerationRunsMigration from "./migrations/005_description_generation_runs.js";
import * as sensitiveDataFlagMigration from "./migrations/006_sensitive_data_flag.js";
import * as sensitiveDataSuggestionRunsMigration from "./migrations/007_sensitive_data_suggestion_runs.js";
const connectionString = process.env.THT_CATALOG_MIGRATOR_DATABASE_URL;
const host = process.env.THT_CATALOG_DB_HOST;
@@ -40,6 +41,7 @@ const provider: MigrationProvider = {
"004_catalog_runtime_sequence_privileges": catalogRuntimeSequencePrivilegesMigration,
"005_description_generation_runs": descriptionGenerationRunsMigration,
"006_sensitive_data_flag": sensitiveDataFlagMigration,
"007_sensitive_data_suggestion_runs": sensitiveDataSuggestionRunsMigration,
};
},
};
@@ -0,0 +1,73 @@
import { type Kysely, sql } from "kysely";
import type { CatalogDatabase } from "../repository.js";
export async function up(db: Kysely<CatalogDatabase>): Promise<void> {
await db.schema.createTable("sensitive_data_suggestion_runs")
.addColumn("id", "uuid", (column) => column.primaryKey())
.addColumn("database_id", "uuid", (column) => column.notNull()
.references("workspace_databases.id").onDelete("cascade"))
.addColumn("scope", "text", (column) => column.notNull())
.addColumn("model_id", "text", (column) => column.notNull())
.addColumn("status", "text", (column) => column.notNull())
.addColumn("total", "integer", (column) => column.notNull().defaultTo(0))
.addColumn("suggested_sensitive", "integer", (column) => column.notNull().defaultTo(0))
.addColumn("suggested_non_sensitive", "integer", (column) => column.notNull().defaultTo(0))
.addColumn("created_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
.addColumn("started_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
.addColumn("updated_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
.addColumn("finished_at", "timestamptz")
.addColumn("error_summary", "text")
.addCheckConstraint(
"sensitive_data_suggestion_runs_scope_check",
sql`scope in ('selected_columns', 'selected_tables', 'all')`,
)
.addCheckConstraint(
"sensitive_data_suggestion_runs_model_id_check",
sql`model_id ~ '^[a-z][a-z0-9._-]{0,63}$'`,
)
.addCheckConstraint(
"sensitive_data_suggestion_runs_status_check",
sql`status in ('running', 'completed', 'failed', 'interrupted')`,
)
.addCheckConstraint(
"sensitive_data_suggestion_runs_counters_check",
sql`total >= 0
and suggested_sensitive >= 0
and suggested_non_sensitive >= 0
and suggested_sensitive + suggested_non_sensitive <= total`,
)
.addCheckConstraint(
"sensitive_data_suggestion_runs_error_summary_check",
sql`error_summary is null or char_length(error_summary) between 1 and 2000`,
)
.execute();
await db.schema.createIndex("sensitive_data_suggestion_runs_database_created_idx")
.on("sensitive_data_suggestion_runs")
.columns(["database_id", "created_at"])
.execute();
await db.schema.createTable("sensitive_data_suggestion_events")
.addColumn("run_id", "uuid", (column) => column.notNull()
.references("sensitive_data_suggestion_runs.id").onDelete("cascade"))
.addColumn("sequence", "integer", (column) => column.notNull())
.addColumn("level", "text", (column) => column.notNull())
.addColumn("message", "text", (column) => column.notNull())
.addColumn("created_at", "timestamptz", (column) => column.notNull().defaultTo(sql`now()`))
.addPrimaryKeyConstraint("sensitive_data_suggestion_events_pkey", ["run_id", "sequence"])
.addCheckConstraint("sensitive_data_suggestion_events_sequence_check", sql`sequence > 0`)
.addCheckConstraint(
"sensitive_data_suggestion_events_level_check",
sql`level in ('info', 'warning', 'error')`,
)
.addCheckConstraint(
"sensitive_data_suggestion_events_message_check",
sql`char_length(message) between 1 and 2000`,
)
.execute();
}
export async function down(db: Kysely<CatalogDatabase>): Promise<void> {
await db.schema.dropTable("sensitive_data_suggestion_events").execute();
await db.schema.dropTable("sensitive_data_suggestion_runs").execute();
}
+255
View File
@@ -11,6 +11,7 @@ import {
type Transaction,
} from "kysely";
import { Pool } from "pg";
import { createCatalogMetrics } from "./metrics.js";
import {
CatalogConflictError,
CatalogConnectorError,
@@ -21,6 +22,7 @@ import {
type CatalogDescriptionTarget,
type CatalogDatabaseMetadataDeleteTarget,
type CatalogMetadataDeleteCounts,
type CatalogMetrics,
type CatalogRelationship,
type CatalogSchemaDiff,
type CatalogSyncCounts,
@@ -40,6 +42,10 @@ import {
type DescriptionGenerationScope,
type ObservedCatalogTable,
type ObservedSchemaSnapshot,
type SensitiveDataSuggestionEvent,
type SensitiveDataSuggestionRun,
type SensitiveDataSuggestionRunUpdate,
type SensitiveDataSuggestionScope,
type TableSyncRepositoryResult,
type WorkspaceDatabase,
} from "./types.js";
@@ -166,6 +172,30 @@ interface DescriptionGenerationEventTable {
createdAt: Timestamp;
}
interface SensitiveDataSuggestionRunTable {
id: string;
databaseId: string;
scope: SensitiveDataSuggestionScope;
modelId: string;
status: SensitiveDataSuggestionRun["status"];
total: number;
suggestedSensitive: number;
suggestedNonSensitive: number;
createdAt: Timestamp;
startedAt: Timestamp;
updatedAt: Timestamp;
finishedAt: Timestamp | null;
errorSummary: string | null;
}
interface SensitiveDataSuggestionEventTable {
runId: string;
sequence: number;
level: SensitiveDataSuggestionEvent["level"];
message: string;
createdAt: Timestamp;
}
interface CatalogSyncRunTable {
id: string;
databaseId: string;
@@ -213,6 +243,8 @@ export interface CatalogDatabase {
catalogRelationshipColumns: CatalogRelationshipColumnTable;
descriptionGenerationRuns: DescriptionGenerationRunTable;
descriptionGenerationEvents: DescriptionGenerationEventTable;
sensitiveDataSuggestionRuns: SensitiveDataSuggestionRunTable;
sensitiveDataSuggestionEvents: SensitiveDataSuggestionEventTable;
catalogSyncRuns: CatalogSyncRunTable;
catalogSyncEvents: CatalogSyncEventTable;
}
@@ -220,6 +252,17 @@ export interface CatalogDatabase {
type DbOrTransaction = Kysely<CatalogDatabase> | Transaction<CatalogDatabase>;
type JoinedRow = Selectable<WorkspaceDatabaseTable> & Selectable<DatabaseBindingTable>;
interface CatalogMetricsRow {
databaseCount: number;
tables: number;
columns: number;
sensitiveColumns: number;
relationships: number;
describedTables: number;
describedColumns: number;
updatedAt: Date | string | null;
}
function present<T>(value: T | null): T | undefined {
return value === null ? undefined : value;
}
@@ -338,6 +381,25 @@ function serializeDescriptionGenerationEvent(
return { ...row, createdAt: new Date(row.createdAt).toISOString() };
}
function serializeSensitiveDataSuggestionRun(
row: Selectable<SensitiveDataSuggestionRunTable>,
): SensitiveDataSuggestionRun {
const stamp = (value: Date | string | null) => value === null ? null : new Date(value).toISOString();
return {
...row,
createdAt: new Date(row.createdAt).toISOString(),
startedAt: new Date(row.startedAt).toISOString(),
updatedAt: new Date(row.updatedAt).toISOString(),
finishedAt: stamp(row.finishedAt),
};
}
function serializeSensitiveDataSuggestionEvent(
row: Selectable<SensitiveDataSuggestionEventTable>,
): SensitiveDataSuggestionEvent {
return { ...row, createdAt: new Date(row.createdAt).toISOString() };
}
function bindingValues(databaseId: string, binding: DatabaseBinding) {
return {
databaseId,
@@ -388,6 +450,83 @@ export class KyselyCatalogRepository implements CatalogRepository {
return id ? await this.get(id.id) : undefined;
}
async getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined> {
const result = await sql<CatalogMetricsRow>`
WITH requested_database AS (
SELECT ${databaseId ?? null}::uuid AS id
),
selected_databases AS (
SELECT workspace_databases.id, workspace_databases.schema_synced_at
FROM workspace_databases
CROSS JOIN requested_database
WHERE requested_database.id IS NULL
OR workspace_databases.id = requested_database.id
),
table_metrics AS (
SELECT
count(*)::int AS tables,
count(*) FILTER (
WHERE nullif(btrim(catalog_tables.description), '') IS NOT NULL
OR nullif(btrim(catalog_tables.generated_description), '') IS NOT NULL
)::int AS "describedTables",
max(catalog_tables.updated_at) AS updated_at
FROM catalog_tables
INNER JOIN selected_databases
ON selected_databases.id = catalog_tables.database_id
),
column_metrics AS (
SELECT
count(*)::int AS columns,
count(*) FILTER (WHERE catalog_columns.sensitive)::int AS "sensitiveColumns",
count(*) FILTER (
WHERE nullif(btrim(catalog_columns.description), '') IS NOT NULL
OR nullif(btrim(catalog_columns.generated_description), '') IS NOT NULL
)::int AS "describedColumns",
max(catalog_columns.updated_at) AS updated_at
FROM catalog_columns
INNER JOIN catalog_tables ON catalog_tables.id = catalog_columns.table_id
INNER JOIN selected_databases
ON selected_databases.id = catalog_tables.database_id
),
relationship_metrics AS (
SELECT
count(*)::int AS relationships,
max(catalog_relationships.updated_at) AS updated_at
FROM catalog_relationships
INNER JOIN selected_databases
ON selected_databases.id = catalog_relationships.database_id
)
SELECT
(SELECT count(*)::int FROM selected_databases) AS "databaseCount",
table_metrics.tables,
column_metrics.columns,
column_metrics."sensitiveColumns",
relationship_metrics.relationships,
table_metrics."describedTables",
column_metrics."describedColumns",
greatest(
(SELECT max(schema_synced_at) FROM selected_databases),
table_metrics.updated_at,
column_metrics.updated_at,
relationship_metrics.updated_at
) AS "updatedAt"
FROM table_metrics
CROSS JOIN column_metrics
CROSS JOIN relationship_metrics
`.execute(this.db);
const row = result.rows[0];
if (!row || (databaseId !== undefined && Number(row.databaseCount) === 0)) return undefined;
return createCatalogMetrics(databaseId, {
tables: Number(row.tables),
columns: Number(row.columns),
sensitiveColumns: Number(row.sensitiveColumns),
relationships: Number(row.relationships),
describedTables: Number(row.describedTables),
describedColumns: Number(row.describedColumns),
}, row.updatedAt === null ? null : new Date(row.updatedAt).toISOString());
}
async create(input: DatabaseConfigurationInput): Promise<WorkspaceDatabase> {
try {
return await this.db.transaction().execute(async (trx) => {
@@ -775,6 +914,114 @@ export class KyselyCatalogRepository implements CatalogRepository {
return rows.map(serializeDescriptionGenerationEvent);
}
async createSensitiveDataSuggestionRun(
databaseId: string,
scope: SensitiveDataSuggestionScope,
modelId: string,
): Promise<SensitiveDataSuggestionRun> {
const row = await this.db.insertInto("sensitiveDataSuggestionRuns").values({
id: randomUUID(),
databaseId,
scope,
modelId,
status: "running",
total: 0,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
finishedAt: null,
errorSummary: null,
}).returningAll().executeTakeFirstOrThrow();
return serializeSensitiveDataSuggestionRun(row);
}
async getSensitiveDataSuggestionRun(
runId: string,
): Promise<SensitiveDataSuggestionRun | undefined> {
const row = await this.db.selectFrom("sensitiveDataSuggestionRuns")
.selectAll()
.where("id", "=", runId)
.executeTakeFirst();
return row ? serializeSensitiveDataSuggestionRun(row) : undefined;
}
async listSensitiveDataSuggestionRuns(limit = 50): Promise<SensitiveDataSuggestionRun[]> {
const rows = await this.db.selectFrom("sensitiveDataSuggestionRuns")
.selectAll()
.orderBy("createdAt", "desc")
.orderBy("id", "desc")
.limit(limit)
.execute();
return rows.map(serializeSensitiveDataSuggestionRun);
}
async interruptActiveSensitiveDataSuggestionRuns(
errorSummary: string,
): Promise<SensitiveDataSuggestionRun[]> {
const rows = await this.db.updateTable("sensitiveDataSuggestionRuns")
.set({
status: "interrupted",
finishedAt: sql`now()`,
updatedAt: sql`now()`,
errorSummary,
})
.where("status", "=", "running")
.returningAll()
.execute();
return rows.map(serializeSensitiveDataSuggestionRun);
}
async updateSensitiveDataSuggestionRun(
runId: string,
update: SensitiveDataSuggestionRunUpdate,
): Promise<SensitiveDataSuggestionRun | undefined> {
const values: any = { ...update, updatedAt: sql`now()` };
const row = await this.db.updateTable("sensitiveDataSuggestionRuns")
.set(values)
.where("id", "=", runId)
.returningAll()
.executeTakeFirst();
return row ? serializeSensitiveDataSuggestionRun(row) : undefined;
}
async appendSensitiveDataSuggestionEvent(
runId: string,
level: SensitiveDataSuggestionEvent["level"],
message: string,
): Promise<SensitiveDataSuggestionEvent> {
return await this.db.transaction().execute(async (trx) => {
const run = await trx.selectFrom("sensitiveDataSuggestionRuns")
.select("id")
.where("id", "=", runId)
.forUpdate()
.executeTakeFirst();
if (!run) throw new CatalogConflictError("Sensitive Data Suggestion Run does not exist");
const current = await trx.selectFrom("sensitiveDataSuggestionEvents")
.select(sql<number>`coalesce(max(sequence), 0)::int`.as("sequence"))
.where("runId", "=", runId)
.executeTakeFirst();
const row = await trx.insertInto("sensitiveDataSuggestionEvents").values({
runId,
sequence: Number(current?.sequence ?? 0) + 1,
level,
message,
}).returningAll().executeTakeFirstOrThrow();
return serializeSensitiveDataSuggestionEvent(row);
});
}
async listSensitiveDataSuggestionEvents(
runId: string,
afterSequence = 0,
): Promise<SensitiveDataSuggestionEvent[]> {
const rows = await this.db.selectFrom("sensitiveDataSuggestionEvents")
.selectAll()
.where("runId", "=", runId)
.where("sequence", ">", afterSequence)
.orderBy("sequence")
.execute();
return rows.map(serializeSensitiveDataSuggestionEvent);
}
async listRelationships(databaseId: string): Promise<CatalogRelationship[]> {
const rows = await this.db.selectFrom("catalogRelationships as relationship")
.innerJoin("catalogTables as sourceTable", "sourceTable.id", "relationship.sourceTableId")
@@ -1345,6 +1592,7 @@ export class UnavailableCatalogRepository implements CatalogRepository {
async list(): Promise<WorkspaceDatabase[]> { return this.fail(); }
async get(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
async getByWorkspace(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
async getCatalogMetrics(): Promise<CatalogMetrics | undefined> { return this.fail(); }
async create(): Promise<WorkspaceDatabase> { return this.fail(); }
async update(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
async recordTest(): Promise<WorkspaceDatabase | undefined> { return this.fail(); }
@@ -1366,6 +1614,13 @@ export class UnavailableCatalogRepository implements CatalogRepository {
async updateDescriptionGenerationRun(): Promise<DescriptionGenerationRun | undefined> { return this.fail(); }
async appendDescriptionGenerationEvent(): Promise<DescriptionGenerationEvent> { return this.fail(); }
async listDescriptionGenerationEvents(): Promise<DescriptionGenerationEvent[]> { return this.fail(); }
async createSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun> { return this.fail(); }
async getSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun | undefined> { return this.fail(); }
async listSensitiveDataSuggestionRuns(): Promise<SensitiveDataSuggestionRun[]> { return this.fail(); }
async interruptActiveSensitiveDataSuggestionRuns(): Promise<SensitiveDataSuggestionRun[]> { return this.fail(); }
async updateSensitiveDataSuggestionRun(): Promise<SensitiveDataSuggestionRun | undefined> { return this.fail(); }
async appendSensitiveDataSuggestionEvent(): Promise<SensitiveDataSuggestionEvent> { return this.fail(); }
async listSensitiveDataSuggestionEvents(): Promise<SensitiveDataSuggestionEvent[]> { return this.fail(); }
async listRelationships(): Promise<CatalogRelationship[]> { return this.fail(); }
async deleteDatabaseMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
async deleteTableMetadata(): Promise<CatalogMetadataDeleteCounts | undefined> { return this.fail(); }
@@ -1,7 +1,14 @@
import { z } from "zod";
import type { MetadataGenerationModels } from "./metadata-generation-models.js";
import type { ModelCompleter, ModelCompletionMessage } from "./model-completer.js";
import type { CatalogColumn, CatalogRepository, CatalogTable } from "./types.js";
import type {
CatalogColumn,
CatalogRepository,
CatalogTable,
SensitiveDataSuggestionScope,
} from "./types.js";
export type { SensitiveDataSuggestionScope } from "./types.js";
// The helper accepts at most 64 KiB per message. Keep the same safety margin used by
// Description Generation so UTF-8 structural metadata never reaches that hard limit.
@@ -16,8 +23,6 @@ const responseSchema = z.object({
}).strict()),
}).strict();
export type SensitiveDataSuggestionScope = "all" | "selected_tables" | "selected_columns";
interface StructuralColumn {
columnId: string;
tableId: string;
@@ -195,10 +200,12 @@ export class SensitiveDataSuggester {
scope: SensitiveDataSuggestionScope,
targetIds: readonly string[],
signal: AbortSignal,
onPrepared?: (total: number) => void | Promise<void>,
): Promise<readonly SensitiveDataSuggestion[]> {
const database = await this.repository.get(databaseId);
if (!database) throw new SensitiveDataSuggestionTargetNotFoundError("database");
const columns = await this.selectColumns(databaseId, scope, targetIds);
await onPrepared?.(columns.length);
const model = this.models.resolve(modelId);
const suggestions: SensitiveDataSuggestion[] = [];
@@ -0,0 +1,110 @@
import type {
SensitiveDataSuggestion,
} from "./sensitive-data-suggester.js";
import {
SensitiveDataSuggester,
SensitiveDataSuggestionTargetNotFoundError,
} from "./sensitive-data-suggester.js";
import type {
CatalogRepository,
SensitiveDataSuggestionRun,
SensitiveDataSuggestionScope,
} from "./types.js";
const interruptedMessage = "Sensitive-field suggestion generation was interrupted by backend restart.";
const failedMessage = "Sensitive-field suggestion generation failed.";
export interface SensitiveDataSuggestionRunResult {
suggestions: readonly SensitiveDataSuggestion[];
run: SensitiveDataSuggestionRun;
}
export class SensitiveDataSuggestionRunner {
constructor(
private readonly repository: CatalogRepository,
private readonly suggester: SensitiveDataSuggester,
) {}
async initialize(): Promise<void> {
if (!(await this.repository.available())) return;
const interrupted = await this.repository.interruptActiveSensitiveDataSuggestionRuns(
interruptedMessage,
);
for (const run of interrupted) {
await this.repository.appendSensitiveDataSuggestionEvent(
run.id,
"warning",
interruptedMessage,
);
}
}
async run(
databaseId: string,
modelId: string,
scope: SensitiveDataSuggestionScope,
targetIds: readonly string[],
signal: AbortSignal,
): Promise<SensitiveDataSuggestionRunResult> {
if (!(await this.repository.get(databaseId))) {
throw new SensitiveDataSuggestionTargetNotFoundError("database");
}
const started = await this.repository.createSensitiveDataSuggestionRun(
databaseId,
scope,
modelId,
);
try {
await this.repository.appendSensitiveDataSuggestionEvent(
started.id,
"info",
"Sensitive-field suggestion generation started.",
);
const suggestions = await this.suggester.suggest(
databaseId,
modelId,
scope,
targetIds,
signal,
async (total) => {
const prepared = await this.repository.updateSensitiveDataSuggestionRun(started.id, {
total,
});
if (!prepared) throw new Error("Sensitive Data Suggestion Run disappeared");
},
);
const suggestedSensitive = suggestions.filter((suggestion) => suggestion.sensitive).length;
const suggestedNonSensitive = suggestions.length - suggestedSensitive;
await this.repository.appendSensitiveDataSuggestionEvent(
started.id,
"info",
`Sensitive-field suggestion generation completed for ${suggestions.length} column${
suggestions.length === 1 ? "" : "s"
}.`,
);
const completed = await this.repository.updateSensitiveDataSuggestionRun(started.id, {
status: "completed",
total: suggestions.length,
suggestedSensitive,
suggestedNonSensitive,
finishedAt: new Date().toISOString(),
errorSummary: null,
});
if (!completed) throw new Error("Sensitive Data Suggestion Run disappeared");
return { suggestions, run: completed };
} catch (error) {
await this.repository.updateSensitiveDataSuggestionRun(started.id, {
status: "failed",
finishedAt: new Date().toISOString(),
errorSummary: failedMessage,
}).catch(() => undefined);
await this.repository.appendSensitiveDataSuggestionEvent(
started.id,
"error",
failedMessage,
).catch(() => undefined);
throw error;
}
}
}
+73
View File
@@ -38,6 +38,19 @@ export interface WorkspaceDatabase {
schemaSyncedAt?: string;
}
export interface CatalogMetrics {
scope: "global" | "database";
databaseId: string | null;
tables: number;
columns: number;
sensitiveColumns: number;
relationships: number;
descriptionTargets: number;
describedTargets: number;
descriptionCoverage: number;
updatedAt: string | null;
}
export interface DatabaseConfigurationInput {
workspaceId: string;
engine: "postgres";
@@ -201,6 +214,42 @@ export interface DescriptionGenerationEvent {
createdAt: string;
}
export type SensitiveDataSuggestionScope = "all" | "selected_tables" | "selected_columns";
export type SensitiveDataSuggestionStatus = "running" | "completed" | "failed" | "interrupted";
export interface SensitiveDataSuggestionRun {
id: string;
databaseId: string;
scope: SensitiveDataSuggestionScope;
modelId: string;
status: SensitiveDataSuggestionStatus;
total: number;
suggestedSensitive: number;
suggestedNonSensitive: number;
createdAt: string;
startedAt: string;
updatedAt: string;
finishedAt: string | null;
errorSummary: string | null;
}
export interface SensitiveDataSuggestionRunUpdate {
status?: SensitiveDataSuggestionStatus;
total?: number;
suggestedSensitive?: number;
suggestedNonSensitive?: number;
finishedAt?: string | null;
errorSummary?: string | null;
}
export interface SensitiveDataSuggestionEvent {
runId: string;
sequence: number;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
}
export interface ObservedRelationshipColumn {
position: number;
sourceColumnName: string;
@@ -321,6 +370,7 @@ export interface CatalogRepository {
list(): Promise<WorkspaceDatabase[]>;
get(id: string): Promise<WorkspaceDatabase | undefined>;
getByWorkspace(workspaceId: string): Promise<WorkspaceDatabase | undefined>;
getCatalogMetrics(databaseId?: string): Promise<CatalogMetrics | undefined>;
create(input: DatabaseConfigurationInput): Promise<WorkspaceDatabase>;
update(id: string, expectedVersion: number, input: DatabaseConfigurationInput): Promise<WorkspaceDatabase | undefined>;
recordTest(id: string, expectedVersion: number, result: DatabaseTestResult): Promise<WorkspaceDatabase | undefined>;
@@ -383,6 +433,29 @@ export interface CatalogRepository {
runId: string,
afterSequence?: number,
): Promise<DescriptionGenerationEvent[]>;
createSensitiveDataSuggestionRun(
databaseId: string,
scope: SensitiveDataSuggestionScope,
modelId: string,
): Promise<SensitiveDataSuggestionRun>;
getSensitiveDataSuggestionRun(runId: string): Promise<SensitiveDataSuggestionRun | undefined>;
listSensitiveDataSuggestionRuns(limit?: number): Promise<SensitiveDataSuggestionRun[]>;
interruptActiveSensitiveDataSuggestionRuns(
errorSummary: string,
): Promise<SensitiveDataSuggestionRun[]>;
updateSensitiveDataSuggestionRun(
runId: string,
update: SensitiveDataSuggestionRunUpdate,
): Promise<SensitiveDataSuggestionRun | undefined>;
appendSensitiveDataSuggestionEvent(
runId: string,
level: SensitiveDataSuggestionEvent["level"],
message: string,
): Promise<SensitiveDataSuggestionEvent>;
listSensitiveDataSuggestionEvents(
runId: string,
afterSequence?: number,
): Promise<SensitiveDataSuggestionEvent[]>;
listRelationships(databaseId: string): Promise<CatalogRelationship[]>;
deleteDatabaseMetadata(
databaseIds: readonly string[],
+16
View File
@@ -66,6 +66,7 @@ const secretsSchema = z.object({
values: z.partialRecord(z.enum(secretNames), z.string().min(1).max(65_536)).refine((values) => Object.keys(values).length > 0),
}).strict();
const versionQuery = z.object({ version: z.coerce.number().int().positive() });
const metricsQuery = z.object({ databaseId: z.uuid().optional() }).strict();
function safeError(reply: FastifyReply, error: unknown) {
if (error instanceof CatalogUnavailableError) {
@@ -114,6 +115,21 @@ export function catalogDatabaseRoutes(
return { available: await deps.repository.available() };
});
app.get("/catalog/metrics", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const { databaseId } = metricsQuery.parse(request.query);
const metrics = await deps.repository.getCatalogMetrics(databaseId);
if (!metrics) {
return reply.code(404).send({
code: "database_not_found",
message: "Database configuration was not found.",
});
}
return metrics;
} catch (error) { return safeError(reply, error); }
});
app.get("/catalog/databases", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
@@ -13,13 +13,13 @@ import {
import { MetadataGenerationModelUnavailableError } from "../catalog/metadata-generation-models.js";
import { ModelCompletionProviderError } from "../catalog/model-completer.js";
import {
SensitiveDataSuggester,
SensitiveDataSuggestionDuplicateTargetIdsError,
SensitiveDataSuggestionInvalidResponseError,
SensitiveDataSuggestionNoEligibleColumnsError,
SensitiveDataSuggestionPayloadTooLargeError,
SensitiveDataSuggestionTargetNotFoundError,
} from "../catalog/sensitive-data-suggester.js";
import type { SensitiveDataSuggestionRunner } from "../catalog/sensitive-data-suggestion-runner.js";
import {
CatalogOperationInProgressError,
CatalogUnavailableError,
@@ -27,6 +27,8 @@ import {
type CatalogRepository,
type DescriptionGenerationEvent,
type DescriptionGenerationRun,
type SensitiveDataSuggestionEvent,
type SensitiveDataSuggestionRun,
} from "../catalog/types.js";
const idSchema = z.uuid();
@@ -107,6 +109,34 @@ function publicRun(run: DescriptionGenerationRun) {
};
}
function publicSensitiveDataSuggestionEvent(event: SensitiveDataSuggestionEvent) {
return {
runId: event.runId,
sequence: event.sequence,
level: event.level,
message: event.message,
createdAt: event.createdAt,
};
}
function publicSensitiveDataSuggestionRun(run: SensitiveDataSuggestionRun) {
return {
id: run.id,
databaseId: run.databaseId,
scope: run.scope,
modelId: run.modelId,
status: run.status,
total: run.total,
suggestedSensitive: run.suggestedSensitive,
suggestedNonSensitive: run.suggestedNonSensitive,
createdAt: run.createdAt,
startedAt: run.startedAt,
updatedAt: run.updatedAt,
finishedAt: run.finishedAt,
errorSummary: run.errorSummary,
};
}
function safeError(reply: FastifyReply, error: unknown) {
if (error instanceof CatalogUnavailableError) {
return reply.code(503).send({
@@ -260,12 +290,31 @@ function safeSuggestionError(reply: FastifyReply, error: unknown) {
});
}
function safeSuggestionHistoryError(reply: FastifyReply, error: unknown) {
if (error instanceof CatalogUnavailableError) {
return reply.code(503).send({
code: "catalog_unavailable",
message: "Sensitive Data Suggestion history is unavailable because the database catalog is unavailable.",
});
}
if (error instanceof z.ZodError) {
return reply.code(400).send({
code: "sensitive_data_suggestion_history_request_invalid",
message: "Sensitive Data Suggestion history parameters are invalid.",
});
}
return reply.code(500).send({
code: "sensitive_data_suggestion_history_failed",
message: "Sensitive Data Suggestion history could not be loaded.",
});
}
export function catalogDescriptionGenerationRoutes(
app: FastifyInstance,
deps: {
repository: CatalogRepository;
worker: DescriptionGenerationWorker;
sensitiveDataSuggester: SensitiveDataSuggester;
sensitiveDataSuggestionRunner: SensitiveDataSuggestionRunner;
},
): void {
app.post("/catalog/databases/:databaseId/sensitive-data-suggestions", async (request, reply) => {
@@ -273,19 +322,66 @@ export function catalogDescriptionGenerationRoutes(
try {
const databaseId = idSchema.parse((request.params as { databaseId?: unknown }).databaseId);
const input = suggestionSchema.parse(request.body);
const suggestions = await deps.sensitiveDataSuggester.suggest(
const result = await deps.sensitiveDataSuggestionRunner.run(
databaseId,
input.modelId,
input.scope,
"targetIds" in input ? input.targetIds : [],
new AbortController().signal,
);
return { suggestions };
return {
suggestions: result.suggestions,
run: publicSensitiveDataSuggestionRun(result.run),
};
} catch (error) {
return safeSuggestionError(reply, error);
}
});
app.get("/catalog/sensitive-data-suggestion-runs", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const { limit } = historyQuerySchema.parse(request.query);
return (await deps.repository.listSensitiveDataSuggestionRuns(limit))
.map(publicSensitiveDataSuggestionRun);
} catch (error) {
return safeSuggestionHistoryError(reply, error);
}
});
app.get("/catalog/sensitive-data-suggestion-runs/:runId", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const runId = idSchema.parse((request.params as { runId?: unknown }).runId);
const run = await deps.repository.getSensitiveDataSuggestionRun(runId);
if (!run) return reply.code(404).send({
code: "sensitive_data_suggestion_run_not_found",
message: "Sensitive Data Suggestion Run was not found.",
});
return publicSensitiveDataSuggestionRun(run);
} catch (error) {
return safeSuggestionHistoryError(reply, error);
}
});
app.get("/catalog/sensitive-data-suggestion-runs/:runId/events-list", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
const runId = idSchema.parse((request.params as { runId?: unknown }).runId);
const { after } = eventQuerySchema.parse(request.query);
if (!(await deps.repository.getSensitiveDataSuggestionRun(runId))) {
return reply.code(404).send({
code: "sensitive_data_suggestion_run_not_found",
message: "Sensitive Data Suggestion Run was not found.",
});
}
return (await deps.repository.listSensitiveDataSuggestionEvents(runId, after))
.map(publicSensitiveDataSuggestionEvent);
} catch (error) {
return safeSuggestionHistoryError(reply, error);
}
});
app.post("/catalog/databases/:databaseId/description-generation-runs", async (request, reply) => {
if (!manage(request, reply)) return reply;
try {
+168 -2
View File
@@ -5,6 +5,7 @@ import { afterEach, expect, test, vi } from "vitest";
import { buildApp } from "../src/app.js";
import { loadConfig } from "../src/config.js";
import { MemoryCatalogRepository } from "../src/catalog/memory-repository.js";
import type { ObservedSchemaSnapshot } from "../src/catalog/types.js";
import { WorkspaceSecretStore } from "../src/workspaces/secret-store.js";
import type { WorkspaceRegistry, WorkspaceRevision } from "../src/workspaces/registry.js";
import type { WorkspaceDescriptor } from "../src/workspaces/schema.js";
@@ -27,7 +28,7 @@ const workspace: WorkspaceDescriptor = {
};
const revision: WorkspaceRevision = { id: "psd-clinical", commit: "a".repeat(40), blob: "b".repeat(40), snapshotPath: "/tmp/psd.yaml" };
function setup() {
function setup(environment: Record<string, string> = {}) {
const secretRoot = mkdtempSync(join(tmpdir(), "catalog-secret-"));
const runtimeRoot = mkdtempSync(join(tmpdir(), "catalog-secret-runtime-"));
roots.push(secretRoot, runtimeRoot);
@@ -38,7 +39,11 @@ function setup() {
listCatalog: vi.fn(async () => [{ id: "psd-clinical", name: "Policlinico San Donato", configurationState: "ready", revision }]),
read: vi.fn(async () => ({ workspace, revision })),
} as unknown as WorkspaceRegistry;
const app = buildApp(loadConfig({ THT_HARNESS_DIR: "/missing", NODE_ENV: "test" }), {
const app = buildApp(loadConfig({
THT_HARNESS_DIR: "/missing",
NODE_ENV: "test",
...environment,
}), {
thtRunner: {} as never,
workspaceRegistry: registry,
workspaceSecretStore: secretStore,
@@ -56,6 +61,31 @@ const direct = {
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
};
const fleetSnapshot: ObservedSchemaSnapshot = {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [
{ name: "patients", sourceComment: "Clinical patients" },
{ name: "visits", sourceComment: null },
],
columns: [
{ tableName: "patients", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "patients", name: "name", ordinalPosition: 2, dataType: "text", isNullable: true, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
{ tableName: "visits", name: "id", ordinalPosition: 1, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: 1, sourceComment: null },
{ tableName: "visits", name: "patient_id", ordinalPosition: 2, dataType: "bigint", isNullable: false, defaultExpression: null, primaryKeyPosition: null, sourceComment: null },
],
relationships: [{
constraintName: "visits_patient_id_fkey",
sourceTableName: "visits",
targetTableName: "patients",
updateRule: "NO ACTION",
deleteRule: "CASCADE",
deferrable: false,
initiallyDeferred: false,
columns: [{ position: 1, sourceColumnName: "patient_id", targetColumnName: "id" }],
}],
};
test("lists every YAML workspace and creates its one database configuration", async () => {
const { app } = setup();
const initial = await app.inject({ method: "GET", url: "/catalog/databases" });
@@ -120,3 +150,139 @@ test("uses optimistic versions, keeps secrets write-only, and hard-deletes only
expect(secretStore.has("psd-clinical", "catalog.dwh.password")).toBe(false);
expect((await app.inject({ method: "GET", url: "/catalog/databases" })).json()).toMatchObject([{ configured: false }]);
});
test("returns exact global and per-database fleet metrics", async () => {
const { app, repository } = setup();
const database = await repository.create(direct);
await repository.applySchemaSync(database.id, database.version, "all", [], fleetSnapshot);
const patients = (await repository.listTables(database.id))
.find((table) => table.name === "patients")!;
await repository.updateTableDescription(
database.id,
patients.id,
patients.version,
"Curated patients",
);
const patientName = (await repository.listColumns(database.id, patients.id))
.find((column) => column.name === "name")!;
await repository.updateColumnMetadata(
database.id,
patients.id,
patientName.id,
patientName.version,
null,
"Generated patient name",
true,
);
const archive = await repository.create({
workspaceId: "removed-workspace",
engine: "postgres",
databaseName: "archive",
schema: "public",
binding: {
transport: "postgres_direct",
host: "archive.internal",
port: 5432,
username: "reader",
},
});
await repository.applySchemaSync(archive.id, archive.version, "all", [], {
schemaVersion: 1,
capabilities: { tables: "available", columns: "available", relationships: "available" },
tables: [{ name: "events", sourceComment: null }],
columns: [{
tableName: "events",
name: "id",
ordinalPosition: 1,
dataType: "bigint",
isNullable: false,
defaultExpression: null,
primaryKeyPosition: 1,
sourceComment: null,
}],
relationships: [],
});
const events = (await repository.listTables(archive.id))[0]!;
await repository.updateTableMetadata(
archive.id,
events.id,
events.version,
null,
"Generated archive events",
);
const scoped = await app.inject({
method: "GET",
url: `/catalog/metrics?databaseId=${database.id}`,
});
expect(scoped.statusCode).toBe(200);
expect(scoped.json()).toEqual({
scope: "database",
databaseId: database.id,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 1,
descriptionTargets: 6,
describedTargets: 2,
descriptionCoverage: 33,
updatedAt: expect.any(String),
});
const global = await app.inject({ method: "GET", url: "/catalog/metrics" });
expect(global.statusCode).toBe(200);
expect(global.json()).toEqual({
scope: "global",
databaseId: null,
tables: 3,
columns: 5,
sensitiveColumns: 1,
relationships: 1,
descriptionTargets: 8,
describedTargets: 3,
descriptionCoverage: 38,
updatedAt: expect.any(String),
});
expect(Number.isNaN(Date.parse(global.json().updatedAt))).toBe(false);
});
test("validates fleet metric scope and requires database.manage", async () => {
const { app } = setup();
const unknown = await app.inject({
method: "GET",
url: "/catalog/metrics?databaseId=99999999-9999-4999-8999-999999999999",
});
expect(unknown.statusCode).toBe(404);
expect(unknown.json()).toEqual({
code: "database_not_found",
message: "Database configuration was not found.",
});
const invalid = await app.inject({
method: "GET",
url: "/catalog/metrics?databaseId=not-a-uuid",
});
expect(invalid.statusCode).toBe(400);
expect(invalid.json()).toEqual({
code: "database_invalid",
message: "Database configuration is invalid.",
});
const { app: restrictedApp } = setup({ AUTH_MODE: "upstream" });
const forbidden = await restrictedApp.inject({
method: "GET",
url: "/catalog/metrics",
headers: {
"x-thoth-principal-issuer": "portal",
"x-thoth-principal-subject": "catalog-reader",
"x-thoth-is-admin": "0",
},
});
expect(forbidden.statusCode).toBe(403);
expect(forbidden.json()).toEqual({
code: "auth_forbidden",
error: "This operation is not permitted",
});
});
@@ -160,7 +160,18 @@ test("suggests sensitive flags from structural metadata without persisting them"
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({
const responseBody = response.json();
expect(responseBody).toMatchObject({
run: {
databaseId: database.id,
scope: "all",
modelId: configuredModel.id,
status: "completed",
total: 1,
suggestedSensitive: 1,
suggestedNonSensitive: 0,
errorSummary: null,
},
suggestions: [{
columnId: column.id,
tableId: table.id,
@@ -173,6 +184,43 @@ test("suggests sensitive flags from structural metadata without persisting them"
});
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
expect(responseBody.run).not.toHaveProperty("suggestions");
const history = await app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs?limit=1",
});
expect(history.statusCode).toBe(200);
expect(history.json()).toEqual([responseBody.run]);
expect(history.body).not.toContain(column.id);
const detail = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${responseBody.run.id}`,
});
expect(detail.statusCode).toBe(200);
expect(detail.json()).toEqual(responseBody.run);
const events = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${responseBody.run.id}/events-list`,
});
expect(events.statusCode).toBe(200);
expect(events.body).not.toContain(column.id);
expect(events.json()).toMatchObject([
{
runId: responseBody.run.id,
sequence: 1,
level: "info",
message: "Sensitive-field suggestion generation started.",
},
{
runId: responseBody.run.id,
sequence: 2,
level: "info",
message: "Sensitive-field suggestion generation completed for 1 column.",
},
]);
const request = modelCompleter.complete.mock.calls[0]![0] as ModelCompletionRequest;
const prompt = request.messages.map((message) => message.content).join("\n");
@@ -505,6 +553,42 @@ test("explains a sensitive-data suggestion provider failure without exposing pro
expect(response.body).not.toContain("model completion failed");
expect(await repository.getColumn(database.id, column.tableId, column.id))
.toMatchObject({ sensitive: false });
const history = await app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs",
});
expect(history.statusCode).toBe(200);
const [failedRun] = history.json();
expect(failedRun).toMatchObject({
databaseId: database.id,
status: "failed",
total: 1,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
errorSummary: "Sensitive-field suggestion generation failed.",
});
const events = await app.inject({
method: "GET",
url: `/catalog/sensitive-data-suggestion-runs/${failedRun.id}/events-list`,
});
expect(events.statusCode).toBe(200);
expect(events.json()).toMatchObject([
{
runId: failedRun.id,
sequence: 1,
level: "info",
message: "Sensitive-field suggestion generation started.",
},
{
runId: failedRun.id,
sequence: 2,
level: "error",
message: "Sensitive-field suggestion generation failed.",
},
]);
expect(events.body).not.toContain("model completion failed");
} finally {
await app.close();
}
@@ -2676,10 +2760,25 @@ test("requires database.manage for every Description Generation route", async ()
url: "/catalog/description-generation-runs/99999999-9999-4999-8999-999999999999/events",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs/99999999-9999-4999-8999-999999999999",
headers,
}),
app.inject({
method: "GET",
url: "/catalog/sensitive-data-suggestion-runs/99999999-9999-4999-8999-999999999999/events-list",
headers,
}),
]);
expect(responses.map((response) => response.statusCode)).toEqual([
403, 403, 403, 403, 403, 403, 403,
403, 403, 403, 403, 403, 403, 403, 403, 403, 403,
]);
expect(modelCompleter.complete).not.toHaveBeenCalled();
} finally {
@@ -12,6 +12,7 @@ import { up as upTables } from "../src/catalog/migrations/002_catalog_tables.js"
import { up as upSchemaSync } from "../src/catalog/migrations/003_catalog_schema_sync.js";
import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_description_generation_runs.js";
import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js";
import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js";
import { KyselyCatalogRepository, type CatalogDatabase } from "../src/catalog/repository.js";
import { loadConfig } from "../src/config.js";
import type { WorkspaceRegistry } from "../src/workspaces/registry.js";
@@ -46,6 +47,7 @@ test.skipIf(!dockerAvailable)("Fastify persists Description Generation success a
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upDescriptionGeneration(db);
await upSensitiveSuggestionRuns(db);
const repository = new KyselyCatalogRepository(db);
const database = await repository.create({
workspaceId: "psd-clinical",
@@ -11,6 +11,7 @@ import { up as upSchemaSync } from "../src/catalog/migrations/003_catalog_schema
import { up as upRuntimeSequencePrivileges } from "../src/catalog/migrations/004_catalog_runtime_sequence_privileges.js";
import { up as upDescriptionGeneration } from "../src/catalog/migrations/005_description_generation_runs.js";
import { up as upSensitiveDataFlag } from "../src/catalog/migrations/006_sensitive_data_flag.js";
import { up as upSensitiveSuggestionRuns } from "../src/catalog/migrations/007_sensitive_data_suggestion_runs.js";
const dockerAvailable = spawnSync("docker", ["info"], { stdio: "ignore" }).status === 0;
@@ -98,6 +99,33 @@ test.skipIf(!dockerAvailable)("PostgreSQL migration enforces one database per wo
patientName.generatedDescription,
true,
)).toMatchObject({ sensitive: true });
expect(await repository.getCatalogMetrics(created.id)).toEqual({
scope: "database",
databaseId: created.id,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 0,
descriptionTargets: 6,
describedTargets: 1,
descriptionCoverage: 17,
updatedAt: expect.any(String),
});
expect(await repository.getCatalogMetrics()).toEqual({
scope: "global",
databaseId: null,
tables: 2,
columns: 4,
sensitiveColumns: 1,
relationships: 0,
descriptionTargets: 6,
describedTargets: 1,
descriptionCoverage: 17,
updatedAt: expect.any(String),
});
expect(await repository.getCatalogMetrics(
"99999999-9999-4999-8999-999999999999",
)).toBeUndefined();
const refreshedColumnsSnapshot: ObservedSchemaSnapshot = {
...fullColumnsSnapshot,
schemaVersion: 2,
@@ -340,7 +368,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository atomically consolidates sel
}
}, 60_000);
test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive Description Generation Runs and ordered events", async () => {
test.skipIf(!dockerAvailable)("PostgreSQL repository persists description and sensitive suggestion run histories", async () => {
const container = await new PostgreSqlContainer("postgres:17.6-bookworm").start();
const db = new Kysely<CatalogDatabase>({
dialect: new PostgresDialect({ pool: new Pool({ connectionString: container.getConnectionUri() }) }),
@@ -352,6 +380,7 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive
await upSchemaSync(db);
await upSensitiveDataFlag(db);
await upDescriptionGeneration(db);
await upSensitiveSuggestionRuns(db);
const repository = new KyselyCatalogRepository(db);
const firstDatabase = await repository.create({
workspaceId: "generation-one",
@@ -519,6 +548,63 @@ test.skipIf(!dockerAvailable)("PostgreSQL repository persists globally exclusive
}),
]);
expect(await repository.getActiveDescriptionGenerationRun()).toBeUndefined();
const suggestionRun = await repository.createSensitiveDataSuggestionRun(
firstDatabase.id,
"selected_columns",
"openai-mini",
);
expect(suggestionRun).toMatchObject({
databaseId: firstDatabase.id,
status: "running",
total: 0,
suggestedSensitive: 0,
suggestedNonSensitive: 0,
startedAt: expect.any(String),
});
await repository.appendSensitiveDataSuggestionEvent(
suggestionRun.id,
"info",
"Sensitive-field suggestion generation started.",
);
await repository.appendSensitiveDataSuggestionEvent(
suggestionRun.id,
"info",
"Sensitive-field suggestion generation completed for 2 columns.",
);
expect(await repository.updateSensitiveDataSuggestionRun(suggestionRun.id, {
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
finishedAt: new Date().toISOString(),
})).toMatchObject({
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
});
expect(await repository.listSensitiveDataSuggestionEvents(suggestionRun.id, 1)).toEqual([
expect.objectContaining({ sequence: 2, level: "info" }),
]);
expect((await repository.listSensitiveDataSuggestionRuns(1))[0]).toMatchObject({
id: suggestionRun.id,
});
const interruptedSuggestionRun = await repository.createSensitiveDataSuggestionRun(
secondDatabase.id,
"all",
"openai-mini",
);
expect(await repository.interruptActiveSensitiveDataSuggestionRuns(
"Sensitive-field suggestion generation was interrupted by backend restart.",
)).toEqual([
expect.objectContaining({
id: interruptedSuggestionRun.id,
status: "interrupted",
finishedAt: expect.any(String),
}),
]);
} finally {
await db.destroy();
await container.stop();
@@ -0,0 +1,563 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import { createAuthenticationStack } from "./fixtures/auth-stack.mjs";
test.describe.configure({ mode: "serial" });
test.use({ viewport: { width: 1910, height: 911 } });
let stack: Awaited<ReturnType<typeof createAuthenticationStack>>;
const databaseId = "11111111-1111-4111-8111-111111111111";
const database = {
id: databaseId,
workspaceId: "psd-clinical",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
configured: true,
engine: "postgres",
databaseName: "warehouse",
schema: "datawarehouse",
version: 3,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
binding: {
transport: "postgres_direct",
host: "db.internal",
port: 5432,
username: "reader",
},
connectionStatus: "reachable",
testedVersion: 3,
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
};
const table = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
databaseId,
name: "patients",
sourceComment: "Patients imported from the clinical source",
description: null,
generatedDescription: null,
version: 1,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
};
function metrics(requestedDatabaseId: string | null) {
return {
scope: requestedDatabaseId ? "database" : "global",
databaseId: requestedDatabaseId,
tables: 1,
columns: 0,
sensitiveColumns: 0,
relationships: 0,
descriptionTargets: 1,
describedTargets: 0,
descriptionCoverage: 0,
updatedAt: "2026-08-31T09:00:00Z",
};
}
function json(route: Route, body: unknown, status = 200) {
return route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
async function signInAsAdmin(page: Page) {
const account = stack.localAccount("admin");
await page.getByLabel("Username").fill(account.username);
await page.locator('input[name="password"]').fill(account.password);
await page.getByRole("button", { name: "Sign in", exact: true }).click();
await expect(page.getByTestId("app-shell")).toBeVisible({ timeout: 30_000 });
}
async function expectContextPanelGeometry(page: Page, accessibleName: string) {
const manager = page
.getByTestId("conversation-column")
.getByRole("main", { name: "Database management" });
const panel = manager.getByRole("dialog", { name: accessibleName });
const sessionRail = page.getByRole("complementary", { name: "Session navigation" });
await expect(panel).toBeVisible();
expect(
await panel.evaluate((element) => element.hasAttribute("data-catalog-panel")),
).toBe(true);
await expect(
panel.locator(':scope > [data-catalog-panel-region="header"]'),
).toHaveCount(1);
await expect(
panel.locator(':scope > [data-catalog-panel-region="body"]'),
).toHaveCount(1);
const [managerBox, panelBox, sessionRailBox] = await Promise.all([
manager.boundingBox(),
panel.boundingBox(),
sessionRail.boundingBox(),
]);
expect(managerBox).not.toBeNull();
expect(panelBox).not.toBeNull();
expect(sessionRailBox).not.toBeNull();
if (!managerBox || !panelBox || !sessionRailBox) return;
const managerCenter = managerBox.x + managerBox.width / 2;
const panelCenter = panelBox.x + panelBox.width / 2;
expect(Math.abs(panelCenter - managerCenter)).toBeLessThanOrEqual(1);
expect(Math.abs(panelBox.width / managerBox.width - 0.6)).toBeLessThanOrEqual(0.005);
expect(panelBox.x).toBeGreaterThanOrEqual(managerBox.x - 1);
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(managerBox.x + managerBox.width + 1);
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(sessionRailBox.x + 1);
expect(panelBox.y).toBeGreaterThanOrEqual(managerBox.y);
expect(panelBox.y + panelBox.height).toBeLessThanOrEqual(managerBox.y + managerBox.height + 1);
}
async function expectContextPanelHeaderMatchesNewSession(
page: Page,
accessibleName: string,
) {
const manager = page
.getByTestId("conversation-column")
.getByRole("main", { name: "Database management" });
const panel = manager.getByRole("dialog", { name: accessibleName });
const header = panel.locator(':scope > [data-catalog-panel-region="header"]');
const newSession = page
.getByRole("complementary", { name: "Session navigation" })
.getByRole("button", { name: "New session", exact: true });
await expect(header).toHaveCount(1);
await expect(newSession).toBeVisible();
const [headerStyle, actionStyle] = await Promise.all([
header.evaluate((element) => ({
backgroundColor: getComputedStyle(element).backgroundColor,
backgroundImage: getComputedStyle(element).backgroundImage,
})),
newSession.evaluate((element) => ({
backgroundColor: getComputedStyle(element).backgroundColor,
})),
]);
expect(headerStyle.backgroundImage).toBe("none");
expect(headerStyle.backgroundColor).not.toBe("rgba(0, 0, 0, 0)");
expect(headerStyle.backgroundColor).toBe(actionStyle.backgroundColor);
}
async function expectWorkAreaPanelGeometry(page: Page, accessibleName: string) {
const workArea = page.getByTestId("conversation-column");
const panel = workArea.getByRole("dialog", { name: accessibleName });
const sessionRail = page.getByRole("complementary", { name: "Session navigation" });
await expect(panel).toBeVisible();
await expect(panel).toHaveAttribute("aria-modal", "false");
await expect(panel).toHaveAttribute("data-work-area-panel", "");
await expect(panel.locator(':scope > [data-work-area-panel-region="header"]')).toHaveCount(1);
await expect(panel.locator(':scope > [data-work-area-panel-region="body"]')).toHaveCount(1);
const [workAreaBox, panelBox, sessionRailBox] = await Promise.all([
workArea.boundingBox(),
panel.boundingBox(),
sessionRail.boundingBox(),
]);
expect(workAreaBox).not.toBeNull();
expect(panelBox).not.toBeNull();
expect(sessionRailBox).not.toBeNull();
if (!workAreaBox || !panelBox || !sessionRailBox) return;
expect(Math.abs(
panelBox.x + panelBox.width / 2 - (workAreaBox.x + workAreaBox.width / 2),
)).toBeLessThanOrEqual(1);
expect(Math.abs(panelBox.width / workAreaBox.width - 0.6)).toBeLessThanOrEqual(0.005);
expect(panelBox.x).toBeGreaterThanOrEqual(workAreaBox.x - 1);
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(workAreaBox.x + workAreaBox.width + 1);
expect(panelBox.x + panelBox.width).toBeLessThanOrEqual(sessionRailBox.x + 1);
expect(panelBox.y).toBeGreaterThanOrEqual(workAreaBox.y);
expect(panelBox.y + panelBox.height).toBeLessThanOrEqual(workAreaBox.y + workAreaBox.height + 1);
}
async function expectWorkAreaPanelHeaderMatchesNewSession(
page: Page,
accessibleName: string,
) {
const panel = page
.getByTestId("conversation-column")
.getByRole("dialog", { name: accessibleName });
const header = panel.locator(':scope > [data-work-area-panel-region="header"]');
const newSession = page
.getByRole("complementary", { name: "Session navigation" })
.getByRole("button", { name: "New session", exact: true });
const [headerStyle, actionStyle] = await Promise.all([
header.evaluate((element) => ({
backgroundColor: getComputedStyle(element).backgroundColor,
backgroundImage: getComputedStyle(element).backgroundImage,
})),
newSession.evaluate((element) => ({
backgroundColor: getComputedStyle(element).backgroundColor,
})),
]);
expect(headerStyle.backgroundImage).toBe("none");
expect(headerStyle.backgroundColor).toBe(actionStyle.backgroundColor);
}
test.beforeAll(async () => {
stack = await createAuthenticationStack();
await stack.useLocalMode();
});
test.afterAll(async () => {
await stack?.close();
});
test("context panels stay inside the manager and the Tables grid sits in a sidebar-colored frame", async ({ page }) => {
const unexpectedCatalogRequests: string[] = [];
const responses: Record<string, unknown> = {
"GET /api/catalog/databases": [database],
"GET /api/catalog/metadata-generation/models": {
models: [],
default: null,
},
"GET /api/catalog/description-generation-runs?limit=50": [],
"GET /api/catalog/sensitive-data-suggestion-runs?limit=50": [],
[`GET /api/catalog/databases/${databaseId}/tables`]: [table],
};
await page.route("**/api/catalog/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const key = `${request.method()} ${url.pathname}${url.search}`;
if (request.method() === "GET" && url.pathname === "/api/catalog/metrics") {
const requestedId = url.searchParams.get("databaseId");
if (requestedId === null || requestedId === databaseId) {
await json(route, metrics(requestedId));
return;
}
}
if (Object.hasOwn(responses, key)) {
await json(route, responses[key]);
return;
}
unexpectedCatalogRequests.push(key);
await json(route, { code: "unexpected_catalog_request" }, 500);
});
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
await page
.getByRole("button", { name: "Database management", exact: true })
.click();
await expect(
page.getByRole("complementary", { name: "Session navigation" }),
).toBeVisible();
await expect(
page.getByTestId("conversation-column").getByRole("main", {
name: "Database management",
}),
).toBeVisible();
await expect(
page.getByRole("button", { name: "Back to workspace", exact: true }),
).toHaveCount(0);
await page
.getByRole("button", { name: "Edit Policlinico San Donato", exact: true })
.click();
await expectContextPanelGeometry(page, "Edit database");
await expectContextPanelHeaderMatchesNewSession(page, "Edit database");
await page
.getByRole("dialog", { name: "Edit database" })
.getByRole("button", { name: "Close database panel" })
.click();
await page
.getByRole("button", { name: "Description history", exact: true })
.click();
await expectContextPanelGeometry(page, "Description generation");
await expectContextPanelHeaderMatchesNewSession(page, "Description generation");
await page
.getByRole("dialog", { name: "Description generation" })
.getByRole("button", { name: /close/i })
.click();
await page.setViewportSize({ width: 1280, height: 800 });
await page
.getByRole("button", { name: "Edit Policlinico San Donato", exact: true })
.click();
await expectContextPanelGeometry(page, "Edit database");
await page
.getByRole("dialog", { name: "Edit database" })
.getByRole("button", { name: "Close database panel" })
.click();
await page.setViewportSize({ width: 1910, height: 911 });
await page
.getByRole("button", {
name: "View tables for Policlinico San Donato",
exact: true,
})
.click();
await expect(
page.getByRole("region", {
name: "Tables for Policlinico San Donato",
}),
).toBeVisible();
const applicationBar = page.locator(
'main[aria-label="Database management"] .thot-fleet-ledger__header',
);
await expect(applicationBar).toHaveCount(1);
await expect(
applicationBar.getByRole("heading", { name: "Database management" }),
).toBeVisible();
await expect(
applicationBar.getByRole("region", { name: "Catalog status" }),
).toBeVisible();
await expect(
applicationBar.getByRole("group", { name: "Database management actions" }),
).toBeVisible();
await expect(applicationBar).not.toContainText("Thoth catalog · Fleet ledger");
await expect(applicationBar).not.toContainText("Inspect physical metadata");
const applicationBarLayout = await applicationBar.evaluate((element) => {
const title = element.querySelector(".thot-fleet-ledger__heading-copy");
const status = element.querySelector(".thot-fleet-ledger__header-status");
const actions = element.querySelector(".thot-fleet-ledger__header-actions");
if (!title || !status || !actions) {
throw new Error("Application bar regions are missing");
}
const centerY = (node: Element) => {
const rect = node.getBoundingClientRect();
return rect.top + rect.height / 2;
};
return {
backgroundImage: getComputedStyle(element).backgroundImage,
height: element.getBoundingClientRect().height,
centers: [centerY(title), centerY(status), centerY(actions)],
};
});
expect(applicationBarLayout.backgroundImage).toBe("none");
expect(applicationBarLayout.height).toBeLessThanOrEqual(64);
expect(
Math.max(...applicationBarLayout.centers) - Math.min(...applicationBarLayout.centers),
).toBeLessThanOrEqual(1);
await expect(page.getByRole("row", { name: /patients/ })).toBeVisible();
await page
.getByRole("button", { name: "Edit description for patients", exact: true })
.click();
await expectContextPanelGeometry(page, "Review table description");
await expectContextPanelHeaderMatchesNewSession(page, "Review table description");
const tableEditor = page.getByRole("dialog", { name: "Review table description" });
const tableEditorFooter = tableEditor.locator(
':scope > [data-catalog-panel-region="footer"]',
);
await expect(tableEditorFooter).toHaveCount(1);
await expect(
tableEditorFooter.getByRole("button", { name: "Cancel", exact: true }),
).toBeVisible();
await expect(
tableEditorFooter.getByRole("button", { name: "Save metadata", exact: true }),
).toBeVisible();
await tableEditor
.getByRole("button", { name: "Close table metadata", exact: true })
.click();
const gridRoot = page.locator(
'main[aria-label="Database management"] .thot-fleet-ledger-grid .ag-root-wrapper',
);
await expect(gridRoot).toHaveCount(1);
await expect(gridRoot).toBeVisible();
await expect.poll(
() => gridRoot.evaluate((element) => {
const manager = element.closest('main[aria-label="Database management"]');
if (!manager) throw new Error("Database manager is missing");
const managerBottom = manager.getBoundingClientRect().bottom;
const gridBottom = element.getBoundingClientRect().bottom;
return Math.max(
Math.abs(window.innerHeight - managerBottom),
Math.abs(managerBottom - gridBottom - 30),
);
}),
{ message: "AG Grid must end 30px above the manager bottom" },
).toBeLessThanOrEqual(1);
const gapColors = await gridRoot.evaluate((element) => {
const workspace = element.closest(".thot-fleet-ledger__workspace");
const tableSurface = element.closest(".thot-fleet-ledger__content");
const sidebar = document.querySelector('aside[aria-label="Session navigation"]');
if (!workspace || !tableSurface || !sidebar) {
throw new Error("Application layout is missing");
}
return {
gap: getComputedStyle(workspace).backgroundColor,
table: getComputedStyle(tableSurface).backgroundColor,
sidebar: getComputedStyle(sidebar).backgroundColor,
};
});
expect(gapColors.gap).not.toBe("rgba(0, 0, 0, 0)");
expect(gapColors.gap).toBe(gapColors.sidebar);
expect(gapColors.gap).not.toBe(gapColors.table);
const workspace = page.locator(
'main[aria-label="Database management"] .thot-fleet-ledger__workspace',
);
const tableSurface = page.locator(
'main[aria-label="Database management"] .thot-fleet-ledger__content',
);
const sessionNavigation = page.getByRole("complementary", {
name: "Session navigation",
});
await expect.poll(
async () => {
const [workspaceBox, tableBox, gridBox, sidebarBox] = await Promise.all([
workspace.boundingBox(),
tableSurface.boundingBox(),
gridRoot.boundingBox(),
sessionNavigation.boundingBox(),
]);
if (!workspaceBox || !tableBox || !gridBox || !sidebarBox) {
throw new Error("Database frame geometry is missing");
}
return Math.max(
Math.abs(tableBox.x - workspaceBox.x - 20),
Math.abs(tableBox.x + tableBox.width - sidebarBox.x),
Math.abs(tableBox.y + tableBox.height - gridBox.y - gridBox.height),
);
},
{
message:
"The table must have a 20px left frame and its right border must stop at the footer",
},
).toBeLessThanOrEqual(1);
const frameBorders = await tableSurface.evaluate((element) => {
const sidebar = document.querySelector('aside[aria-label="Session navigation"]');
if (!sidebar) throw new Error("Session navigation is missing");
return {
tableEnd: Number.parseFloat(getComputedStyle(element).borderInlineEndWidth),
sidebarStart: Number.parseFloat(
getComputedStyle(sidebar).borderInlineStartWidth,
),
};
});
expect(frameBorders.tableEnd).toBe(1);
expect(frameBorders.sidebarStart).toBe(0);
await expect(page.locator(".thot-session-navigation__boundary")).toHaveCount(0);
expect(unexpectedCatalogRequests).toEqual([]);
});
test("Workspace and Pi management share the centered work-area panel without covering the session rail", async ({ page }) => {
await page.route("**/api/workspace-registry/status", (route) => json(route, {
branch: "main",
head: "a".repeat(40),
ahead: 0,
behind: 0,
degraded: false,
repository: {
host: "git.example.test",
repository: "analytics/thoth-workspaces",
transport: "ssh",
},
}));
await page.route("**/api/workspaces", (route) => json(route, []));
await page.route("**/api/pi-management/status", (route) => json(route, {
version: "0.80.3",
ready: true,
credentials: "present",
config: { provider: "zai", model: "glm-5.2", reasoning: "medium" },
checkedAt: "2026-08-31T09:00:00Z",
}));
await page.route("**/api/pi-management/options", (route) => json(route, {
providers: ["zai"],
models: [{ provider: "zai", id: "glm-5.2" }],
reasoning: ["low", "medium", "high"],
checkedAt: "2026-08-31T09:00:00Z",
}));
await page.goto(stack.publicUrl);
await signInAsAdmin(page);
for (const viewport of [
{ width: 1910, height: 911 },
{ width: 1280, height: 800 },
]) {
await page.setViewportSize(viewport);
const workspaceTrigger = page.getByRole("button", {
name: "Workspace management",
exact: true,
});
await workspaceTrigger.click();
await expectWorkAreaPanelGeometry(page, "Workspace management");
await expectWorkAreaPanelHeaderMatchesNewSession(page, "Workspace management");
const piTrigger = page.getByRole("button", { name: "Pi management", exact: true });
await piTrigger.click();
await expect(
page.getByRole("dialog", { name: "Workspace management" }),
).toHaveCount(0);
await expectWorkAreaPanelGeometry(page, "Pi management");
await expectWorkAreaPanelHeaderMatchesNewSession(page, "Pi management");
await page
.getByRole("dialog", { name: "Pi management" })
.getByRole("button", { name: "Close Pi management" })
.click();
await expect(piTrigger).toBeFocused();
}
await page.setViewportSize({ width: 720, height: 800 });
await page.getByRole("button", { name: "Workspace management", exact: true }).click();
const compactWorkArea = page.getByTestId("conversation-column");
const compactPanel = compactWorkArea.getByRole("dialog", { name: "Workspace management" });
const [compactWorkAreaBox, compactPanelBox] = await Promise.all([
compactWorkArea.boundingBox(),
compactPanel.boundingBox(),
]);
expect(compactWorkAreaBox).not.toBeNull();
expect(compactPanelBox).not.toBeNull();
if (compactWorkAreaBox && compactPanelBox) {
expect(Math.abs(compactPanelBox.width - compactWorkAreaBox.width + 16)).toBeLessThanOrEqual(1);
expect(Math.abs(
compactPanelBox.x + compactPanelBox.width / 2
- (compactWorkAreaBox.x + compactWorkAreaBox.width / 2),
)).toBeLessThanOrEqual(1);
}
const compactNavigation = compactPanel.getByRole("navigation", { name: "Workspaces" });
const compactContent = compactPanel.getByRole("region", {
name: "Workspace configuration",
});
const [compactNavigationBox, compactContentBox] = await Promise.all([
compactNavigation.boundingBox(),
compactContent.boundingBox(),
]);
expect(compactNavigationBox).not.toBeNull();
expect(compactContentBox).not.toBeNull();
if (compactNavigationBox && compactContentBox) {
expect(compactContentBox.y).toBeGreaterThanOrEqual(
compactNavigationBox.y + compactNavigationBox.height - 1,
);
}
});
@@ -0,0 +1,60 @@
import { http, HttpResponse } from "msw";
import { expect, test } from "vitest";
import { server } from "../test/msw";
import { getCatalogMetrics, type CatalogMetrics } from "./catalog-databases";
const databaseId = "d4baf0f5-b9c3-4dc5-aad2-2f5676a36e64";
const globalMetrics: CatalogMetrics = {
scope: "global",
databaseId: null,
tables: 163,
columns: 2_275,
sensitiveColumns: 5,
relationships: 21,
descriptionTargets: 2_438,
describedTargets: 1_829,
descriptionCoverage: 75.02,
updatedAt: "2026-08-31T08:45:00.000Z",
};
test("loads global catalog metrics without a database filter", async () => {
let requestedUrl: URL | undefined;
server.use(http.get("/api/catalog/metrics", ({ request }) => {
requestedUrl = new URL(request.url);
return HttpResponse.json(globalMetrics);
}));
await expect(getCatalogMetrics()).resolves.toEqual(globalMetrics);
expect(requestedUrl?.pathname).toBe("/api/catalog/metrics");
expect(requestedUrl?.searchParams.has("databaseId")).toBe(false);
});
test("loads metrics scoped to the requested database", async () => {
const databaseMetrics: CatalogMetrics = {
...globalMetrics,
scope: "database",
databaseId,
tables: 42,
columns: 508,
sensitiveColumns: 3,
relationships: 8,
descriptionTargets: 550,
describedTargets: 413,
descriptionCoverage: 75.09,
};
let requestedDatabaseId: string | null = null;
server.use(http.get("/api/catalog/metrics", ({ request }) => {
requestedDatabaseId = new URL(request.url).searchParams.get("databaseId");
return HttpResponse.json(databaseMetrics);
}));
await expect(getCatalogMetrics(databaseId)).resolves.toEqual(databaseMetrics);
expect(requestedDatabaseId).toBe(databaseId);
});
test("propagates an unsuccessful catalog metrics response", async () => {
server.use(http.get("/api/catalog/metrics", () => new HttpResponse(null, { status: 503 })));
await expect(getCatalogMetrics()).rejects.toMatchObject({ status: 503 });
});
+61
View File
@@ -52,6 +52,19 @@ export interface CatalogDatabase {
secrets: Record<CatalogSecretName, boolean>;
}
export interface CatalogMetrics {
scope: "global" | "database";
databaseId: string | null;
tables: number;
columns: number;
sensitiveColumns: number;
relationships: number;
descriptionTargets: number;
describedTargets: number;
descriptionCoverage: number;
updatedAt: string | null;
}
export interface DatabaseConfiguration {
workspaceId: string;
engine: "postgres";
@@ -109,6 +122,7 @@ export interface SensitiveDataSuggestion {
export interface SensitiveDataSuggestions {
suggestions: SensitiveDataSuggestion[];
run: SensitiveDataSuggestionRun;
}
export type SensitiveDataSuggestionRequest =
@@ -116,6 +130,32 @@ export type SensitiveDataSuggestionRequest =
| { scope: "selected_tables"; targetIds: string[] }
| { scope: "selected_columns"; targetIds: string[] };
export type SensitiveDataSuggestionStatus = "running" | "completed" | "failed" | "interrupted";
export interface SensitiveDataSuggestionRun {
id: string;
databaseId: string;
scope: SensitiveDataSuggestionRequest["scope"];
modelId: string;
status: SensitiveDataSuggestionStatus;
total: number;
suggestedSensitive: number;
suggestedNonSensitive: number;
createdAt: string;
startedAt: string | null;
updatedAt: string;
finishedAt: string | null;
errorSummary: string | null;
}
export interface SensitiveDataSuggestionEvent {
runId: string;
sequence: number;
level: "info" | "warning" | "error";
message: string;
createdAt: string;
}
export interface CatalogRelationshipColumn {
position: number;
sourceColumnId: string;
@@ -245,6 +285,12 @@ export interface DescriptionGenerationEvent {
export const listCatalogDatabases = () => apiFetch<CatalogDatabase[]>("/catalog/databases");
export const getCatalogMetrics = (databaseId?: string) => apiFetch<CatalogMetrics>(
databaseId
? `/catalog/metrics?databaseId=${encodeURIComponent(databaseId)}`
: "/catalog/metrics",
);
export const listMetadataGenerationModels = () =>
apiFetch<MetadataGenerationModels>("/catalog/metadata-generation/models");
@@ -388,6 +434,21 @@ export const suggestSensitiveFields = (
{ method: "POST", body: JSON.stringify({ modelId, ...selection }) },
);
export const getSensitiveDataSuggestionRun = (runId: string) =>
apiFetch<SensitiveDataSuggestionRun>(
`/catalog/sensitive-data-suggestion-runs/${encodeURIComponent(runId)}`,
);
export const listSensitiveDataSuggestionRuns = (limit = 50) =>
apiFetch<SensitiveDataSuggestionRun[]>(
`/catalog/sensitive-data-suggestion-runs?limit=${encodeURIComponent(String(limit))}`,
);
export const listSensitiveDataSuggestionEvents = (runId: string, after = 0) =>
apiFetch<SensitiveDataSuggestionEvent[]>(
`/catalog/sensitive-data-suggestion-runs/${encodeURIComponent(runId)}/events-list?after=${after}`,
);
export const listCatalogRelationships = (databaseId: string) =>
apiFetch<CatalogRelationship[]>(`/catalog/databases/${encodeURIComponent(databaseId)}/relationships`);
@@ -3,9 +3,13 @@ import { server } from "../test/msw";
import {
cancelDescriptionGenerationRun,
descriptionGenerationEventsUrl,
getSensitiveDataSuggestionRun,
listDescriptionGenerationRuns,
listSensitiveDataSuggestionEvents,
listSensitiveDataSuggestionRuns,
unlockDescriptionGenerationRun,
type DescriptionGenerationRun,
type SensitiveDataSuggestionRun,
} from "./catalog-databases";
const historicalRun: DescriptionGenerationRun = {
@@ -27,6 +31,22 @@ const historicalRun: DescriptionGenerationRun = {
errorSummary: null,
};
const sensitiveSuggestionRun: SensitiveDataSuggestionRun = {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "selected_columns",
modelId: "local-qwen",
status: "completed",
total: 4,
suggestedSensitive: 2,
suggestedNonSensitive: 2,
createdAt: "2026-08-28T09:00:00Z",
startedAt: "2026-08-28T09:00:00Z",
updatedAt: "2026-08-28T09:00:01Z",
finishedAt: "2026-08-28T09:00:01Z",
errorSummary: null,
};
test("lists newest-first persisted description-generation history with a bounded limit", async () => {
let requestedLimit: string | null = null;
server.use(http.get("/api/catalog/description-generation-runs", ({ request }) => {
@@ -74,3 +94,34 @@ test("builds the same-origin description-generation SSE replay URL from an event
`/api/catalog/description-generation-runs/${historicalRun.id}/events?after=17`,
);
});
test("lists, reads, and replays persisted sensitive-suggestion history", async () => {
let requestedLimit: string | null = null;
let requestedAfter: string | null = null;
const event = {
runId: sensitiveSuggestionRun.id,
sequence: 3,
level: "info" as const,
message: "Classified 4 columns",
createdAt: "2026-08-28T09:00:01Z",
};
server.use(
http.get("/api/catalog/sensitive-data-suggestion-runs", ({ request }) => {
requestedLimit = new URL(request.url).searchParams.get("limit");
return HttpResponse.json([sensitiveSuggestionRun]);
}),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", () => (
HttpResponse.json(sensitiveSuggestionRun)
)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", ({ request }) => {
requestedAfter = new URL(request.url).searchParams.get("after");
return HttpResponse.json([event]);
}),
);
await expect(listSensitiveDataSuggestionRuns(50)).resolves.toEqual([sensitiveSuggestionRun]);
await expect(getSensitiveDataSuggestionRun(sensitiveSuggestionRun.id)).resolves.toEqual(sensitiveSuggestionRun);
await expect(listSensitiveDataSuggestionEvents(sensitiveSuggestionRun.id, 2)).resolves.toEqual([event]);
expect(requestedLimit).toBe("50");
expect(requestedAfter).toBe("2");
});
+3
View File
@@ -149,6 +149,9 @@ test.each([
["catalog_table_not_found", "One or more selected catalog tables were not found."],
["sensitive_data_suggestion_invalid_response", "The model returned an incomplete or invalid classification. No suggestions were applied."],
["sensitive_data_suggestion_provider_unavailable", "The selected model could not complete the request. No suggestions were applied."],
["sensitive_data_suggestion_history_request_invalid", "Sensitive suggestion history parameters are invalid."],
["sensitive_data_suggestion_history_failed", "Sensitive suggestion history could not be loaded."],
["sensitive_data_suggestion_run_not_found", "The sensitive suggestion run was not found."],
])("maps the catalog error code %s to safe local copy", async (code, message) => {
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(JSON.stringify({ code, message: "provider detail must not be trusted" }), {
+6
View File
@@ -30,6 +30,9 @@ const safeErrorCodes = new Set([
"sensitive_data_suggestion_invalid_response",
"sensitive_data_suggestion_provider_unavailable",
"sensitive_data_suggestion_failed",
"sensitive_data_suggestion_history_request_invalid",
"sensitive_data_suggestion_history_failed",
"sensitive_data_suggestion_run_not_found",
"schema_sync_conflict", "schema_introspection_failed", "schema_request_invalid",
"schema_operation_failed", "sync_run_not_found", "table_stale", "column_stale",
]);
@@ -91,6 +94,9 @@ const localCodeMessages: Record<string, string> = {
sensitive_data_suggestion_invalid_response: "The model returned an incomplete or invalid classification. No suggestions were applied.",
sensitive_data_suggestion_provider_unavailable: "The selected model could not complete the request. No suggestions were applied.",
sensitive_data_suggestion_failed: "Sensitive-field suggestions failed before review. No changes were applied.",
sensitive_data_suggestion_history_request_invalid: "Sensitive suggestion history parameters are invalid.",
sensitive_data_suggestion_history_failed: "Sensitive suggestion history could not be loaded.",
sensitive_data_suggestion_run_not_found: "The sensitive suggestion run was not found.",
schema_sync_conflict: "A schema synchronization is already active or no longer current.",
schema_introspection_failed: "The database schema could not be read safely.",
schema_request_invalid: "The schema request is invalid.",
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { http, HttpResponse } from "msw";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
@@ -6,7 +6,7 @@ import { server } from "../test/msw";
import { FakeEventSource } from "../test/fakeEventSource";
import { useSessionStore } from "../store/sessionStore";
import { clearAuthState, setAuthState } from "../auth/authState";
import { AppShell } from "./AppShell";
import { AppShell, resolveDatabaseManagementPresentation } from "./AppShell";
function renderShell() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -29,6 +29,7 @@ beforeEach(() => {
session: null,
});
localStorage.clear();
window.history.replaceState(null, "", "/");
FakeEventSource.instances = [];
(globalThis as any).EventSource = FakeEventSource;
useSessionStore.getState().resetSession();
@@ -49,33 +50,66 @@ beforeEach(() => {
http.get("/api/workspaces", () => HttpResponse.json([])),
http.get("/api/models", () => HttpResponse.json({ models: [] })),
http.post("/api/runtime/prewarm", () => new HttpResponse(null, { status: 202 })),
http.get("/api/catalog/metrics", () => HttpResponse.json({
scope: "global",
databaseId: null,
tables: 0,
columns: 0,
sensitiveColumns: 0,
relationships: 0,
descriptionTargets: 0,
describedTargets: 0,
descriptionCoverage: 0,
updatedAt: null,
})),
);
});
test("opens the database management surface and returns to the core", async () => {
renderShell();
const composer = screen.getByRole("textbox", { name: /new question/i });
await userEvent.type(composer, "Unsent draft");
const databaseManagement = screen.getByRole("button", { name: "Database management" });
await userEvent.click(databaseManagement);
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
expect(databaseManagement).toHaveAttribute("aria-current", "page");
expect(databaseManagement).toHaveClass("w-full");
expect(databaseManagement).not.toHaveClass("bg-sidebar-accent");
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "New session" }));
await waitFor(() => {
expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument();
});
expect(screen.getByRole("textbox", { name: /new question/i })).toHaveValue("Unsent draft");
test("uses Fleet Ledger by default and gates the legacy fallback to non-production environments", () => {
expect(resolveDatabaseManagementPresentation({
isDevelopment: true,
mode: "development",
search: "",
})).toBe("fleet");
expect(resolveDatabaseManagementPresentation({
isDevelopment: true,
mode: "development",
search: "?db-ui=legacy",
})).toBe("legacy");
expect(resolveDatabaseManagementPresentation({
isDevelopment: false,
mode: "staging",
legacyFlag: "true",
search: "",
})).toBe("legacy");
expect(resolveDatabaseManagementPresentation({
isDevelopment: false,
mode: "production",
legacyFlag: "true",
search: "?db-ui=legacy",
})).toBe("fleet");
});
test("keeps a live core session connected and returns when that session is opened", async () => {
test("replaces the core conversation while keeping the session navigation", async () => {
renderShell();
const conversationColumn = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
const databaseManagement = within(sessionNavigation).getByRole("button", { name: "Database management" });
await userEvent.click(databaseManagement);
const manager = screen.getByRole("main", { name: "Database management" });
expect(manager).toBeVisible();
expect(conversationColumn).toContainElement(manager);
expect(screen.getByRole("complementary", { name: "Session navigation" })).toBe(sessionNavigation);
expect(sessionNavigation).toBeVisible();
expect(within(sessionNavigation).getByRole("button", { name: "New session" })).toBeVisible();
expect(databaseManagement).toHaveAttribute("aria-current", "page");
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: /new question/i })).not.toBeInTheDocument();
});
test("keeps a live core session connected while returning from database management", async () => {
server.use(
http.get("/api/sessions", () => HttpResponse.json([{
id: "s1",
@@ -113,6 +147,7 @@ test("keeps a live core session connected and returns when that session is opene
expect(source.closed).toBe(false);
expect(FakeEventSource.instances).toHaveLength(1);
expect(session).toBeVisible();
await userEvent.click(session);
await waitFor(() => {
@@ -174,14 +209,15 @@ test("does not leave database management without confirming a dirty form", async
await userEvent.clear(schema);
await userEvent.type(schema, "reporting");
await userEvent.click(screen.getByRole("button", { name: "New session" }));
const newSession = screen.getByRole("button", { name: "New session" });
await userEvent.click(newSession);
expect(confirm).toHaveBeenCalledWith("Discard unsaved database changes and leave database management?");
expect(screen.getByRole("main", { name: "Database management" })).toBeVisible();
expect(schema).toHaveValue("reporting");
confirm.mockReturnValue(true);
await userEvent.click(screen.getByRole("button", { name: "New session" }));
await userEvent.click(newSession);
await waitFor(() => expect(screen.queryByRole("main", { name: "Database management" })).not.toBeInTheDocument());
confirm.mockRestore();
});
@@ -166,6 +166,11 @@ test("opens Workspace management from the right sidebar without interrupting the
await userEvent.click(screen.getByRole("button", { name: "Workspace management" }));
expect(await screen.findByRole("heading", { name: "Workspace management" })).toBeVisible();
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
expect(screen.getByTestId("app-shell")).toHaveAttribute("data-activity-layout", "closed");
});
@@ -121,9 +121,14 @@ test("Pi management preserves the open session summary and the model activity ti
expect(await screen.findByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
await user.click(screen.getByRole("button", { name: "Pi management" }));
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
const hiddenSummary = document.querySelector<HTMLElement>('aside[aria-label="Session summary"]');
expect(hiddenSummary).toHaveAttribute("aria-hidden", "true");
expect(hiddenSummary).toHaveTextContent("Attiva uno");
const dialog = screen.getByRole("dialog", { name: "Pi management" });
const workArea = screen.getByTestId("conversation-column");
const sessionNavigation = screen.getByRole("complementary", { name: "Session navigation" });
expect(workArea).toContainElement(dialog);
expect(sessionNavigation).not.toContainElement(dialog);
const preservedSummary = screen.getByRole("complementary", { name: "Session summary" });
expect(preservedSummary).not.toHaveAttribute("aria-hidden", "true");
expect(preservedSummary).toHaveTextContent("Attiva uno");
await user.click(screen.getByRole("button", { name: "Close Pi management" }));
await waitFor(() => {
expect(screen.getByRole("complementary", { name: "Session summary" })).toHaveTextContent("Domanda originale");
+77 -31
View File
@@ -5,6 +5,7 @@ import { CentralStatus } from "./CentralStatus";
import { ModelActivityPanel } from "./ModelActivityPanel";
import { WorkspaceManager } from "./WorkspaceManager";
import { PiManagement } from "./PiManagement";
import { WorkAreaPanelHost } from "./WorkAreaPanel";
import { useActivityPanelResize } from "./useActivityPanelResize";
import { useSessionPanelResize } from "./useSessionPanelResize";
import { NavSessions } from "./NavSessions";
@@ -41,6 +42,35 @@ interface AppShellProps {
}
type ActiveSurface = "core" | "database-management";
type ActiveManagementPanel = "workspace" | "pi" | null;
type DatabaseManagementPresentation = "legacy" | "fleet";
interface DatabaseManagementPresentationEnvironment {
isDevelopment: boolean;
mode: string;
legacyFlag?: string | boolean;
search: string;
}
/**
* Fleet Ledger is the release-safe default. The old surface can only be
* requested explicitly from a development server or a staging build, so an
* accidentally retained flag can never switch the production UI back.
*/
export function resolveDatabaseManagementPresentation({
isDevelopment,
mode,
legacyFlag,
search,
}: DatabaseManagementPresentationEnvironment): DatabaseManagementPresentation {
const legacyAllowed = isDevelopment || mode.trim().toLowerCase() === "staging";
if (!legacyAllowed) return "fleet";
const legacyRequestedByQuery = new URLSearchParams(search).get("db-ui")?.toLowerCase() === "legacy";
const legacyRequestedByEnvironment = String(legacyFlag ?? "").trim().toLowerCase() === "true";
return legacyRequestedByQuery || legacyRequestedByEnvironment ? "legacy" : "fleet";
}
export function AppShell({ canLogout }: AppShellProps) {
const authenticatedUser = useAuthUser();
@@ -123,12 +153,17 @@ export function AppShell({ canLogout }: AppShellProps) {
const queryClient = useQueryClient();
const [showActivity, setShowActivity] = useState(false);
const [activeSurface, setActiveSurface] = useState<ActiveSurface>("core");
const databaseManagementPresentation = resolveDatabaseManagementPresentation({
isDevelopment: import.meta.env.DEV,
mode: import.meta.env.MODE,
legacyFlag: import.meta.env.VITE_DB_MANAGEMENT_LEGACY,
search: window.location.search,
});
const databaseNavigationRef = useRef({ dirty: false, busy: false });
const updateDatabaseNavigationState = useCallback((state: { dirty: boolean; busy: boolean }) => {
databaseNavigationRef.current = state;
}, []);
const [workspaceManagerOpen, setWorkspaceManagerOpen] = useState(false);
const [piManagementOpen, setPiManagementOpen] = useState(false);
const [activeManagementPanel, setActiveManagementPanel] = useState<ActiveManagementPanel>(null);
const [activeOpen, setActiveOpen] = useState(true);
const [archiveOpen, setArchiveOpen] = useState(false);
const [renameTarget, setRenameTarget] = useState<SessionSummary | null>(null);
@@ -218,6 +253,7 @@ export function AppShell({ canLogout }: AppShellProps) {
const s = sessions.find((x) => x.id === id);
if (!s) return;
setActiveSurface("core");
setActiveManagementPanel(null);
// A session with a live Pi runtime opens straight into its live view: doResume
// reconnects to the already-active runtime and replays its pending gate, so an
// in-progress session never shows an empty screen that reads as "stopped". Cold or
@@ -239,6 +275,7 @@ export function AppShell({ canLogout }: AppShellProps) {
async function doResume(id: string, databaseExitApproved = false) {
if (!databaseExitApproved && !canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
const guard = captureAuthOperation({ sessionId: id, disposalEpoch: operationEpochRef.current });
if (!guard) return;
const token = ++resumeInvocationRef.current;
@@ -522,6 +559,7 @@ export function AppShell({ canLogout }: AppShellProps) {
function startNewSession() {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel(null);
invalidateResumeIntent();
newSessionOperationRef.current = null;
resetSession();
@@ -623,7 +661,7 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
)}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setWorkspaceManagerOpen(true)} hidden={activeSurface !== "core"} />}
{showActivity && <ModelActivityPanel desktopSplit={desktopSplit} onClose={() => setShowActivity(false)} onOpenWorkspaceManager={() => setActiveManagementPanel("workspace")} hidden={activeSurface !== "core"} />}
{activeSurface === "core" && showActivity && desktopSplit && (
<div className="relative w-0 shrink-0">
<div
@@ -638,12 +676,13 @@ export function AppShell({ canLogout }: AppShellProps) {
)}
{/* Conversation column */}
<div data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
<WorkAreaPanelHost data-testid="conversation-column" className="flex min-w-0 flex-1 flex-col">
{activeSurface === "database-management" && (
<DatabaseManagementPage
canManage={canManageDatabase}
canManageSecrets={canManageWorkspaceSecrets}
onNavigationStateChange={updateDatabaseNavigationState}
presentation={databaseManagementPresentation}
/>
)}
<div
@@ -727,11 +766,23 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</div>
</div>
</div>
<WorkspaceManager
open={activeManagementPanel === "workspace"}
onClose={() => setActiveManagementPanel(null)}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && (
<PiManagement
open={activeManagementPanel === "pi"}
onClose={() => setActiveManagementPanel(null)}
/>
)}
</WorkAreaPanelHost>
{/* Right session rail */}
{!showActivity && (
<aside className="flex w-[15vw] shrink-0 flex-col border-l border-border bg-sidebar">
{(activeSurface === "database-management" || !showActivity) && (
<aside aria-label="Session navigation" className="flex w-[15vw] shrink-0 flex-col bg-sidebar">
<div className="relative px-4 pb-3 pt-5 text-center">
<h1 className="font-heading text-xl font-semibold leading-none tracking-tight text-foreground">
Thoth<span className="text-primary">II</span>
@@ -767,25 +818,28 @@ export function AppShell({ canLogout }: AppShellProps) {
<Button
variant="outline"
size="sm"
className="w-full"
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setWorkspaceManagerOpen(true);
}}
className="w-full"
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setActiveManagementPanel("workspace");
}}
>
Workspace management
</Button>
{canManageDatabase && (
<Button
variant="outline"
size="sm"
className="w-full"
aria-current={activeSurface === "database-management" ? "page" : undefined}
onClick={() => setActiveSurface("database-management")}
>
Database management
</Button>
<Button
variant={activeSurface === "database-management" ? "secondary" : "outline"}
size="sm"
className="w-full"
aria-current={activeSurface === "database-management" ? "page" : undefined}
onClick={() => {
setActiveManagementPanel(null);
setActiveSurface("database-management");
}}
>
Database management
</Button>
)}
{canManagePi && (
<Button
@@ -795,7 +849,7 @@ export function AppShell({ canLogout }: AppShellProps) {
onClick={() => {
if (!canLeaveDatabaseManagement()) return;
setActiveSurface("core");
setPiManagementOpen(true);
setActiveManagementPanel("pi");
}}
>
Pi management
@@ -951,14 +1005,6 @@ export function AppShell({ canLogout }: AppShellProps) {
</div>
</aside>
)}
<WorkspaceManager
open={workspaceManagerOpen}
onClose={() => setWorkspaceManagerOpen(false)}
canManageWorkspace={canManageWorkspace}
canManageSecrets={canManageWorkspaceSecrets}
/>
{canManagePi && <PiManagement open={piManagementOpen} onClose={() => setPiManagementOpen(false)} />}
<Toaster />
<StopConfirmDialog
@@ -9,6 +9,7 @@ import type {
CatalogSyncRun,
CatalogTable,
DescriptionGenerationRun,
SensitiveDataSuggestionRun,
} from "../api/catalog-databases";
import { Toaster } from "../components/ui/sonner";
import { DatabaseManagementPage } from "./DatabaseManagementPage";
@@ -72,11 +73,13 @@ function renderPage({
canManage = true,
canManageSecrets = true,
onNavigationStateChange,
presentation = "legacy",
}: {
rows?: CatalogDatabase[] | (() => CatalogDatabase[]);
canManage?: boolean;
canManageSecrets?: boolean;
onNavigationStateChange?: (state: { dirty: boolean; busy: boolean }) => void;
presentation?: "legacy" | "fleet";
} = {}) {
server.use(http.get("/api/catalog/databases", () => HttpResponse.json(
typeof rows === "function" ? rows() : rows,
@@ -88,6 +91,7 @@ function renderPage({
canManage={canManage}
canManageSecrets={canManageSecrets}
onNavigationStateChange={onNavigationStateChange}
presentation={presentation}
/>
<Toaster duration={Infinity} />
</QueryClientProvider>,
@@ -145,6 +149,27 @@ function makeDescriptionGenerationRun(
};
}
function makeSensitiveDataSuggestionRun(
overrides: Partial<SensitiveDataSuggestionRun> = {},
): SensitiveDataSuggestionRun {
return {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
modelId: "local-qwen",
scope: "selected_columns",
status: "completed",
total: 2,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
createdAt: "2026-08-28T11:00:00Z",
startedAt: "2026-08-28T11:00:00Z",
updatedAt: "2026-08-28T11:00:01Z",
finishedAt: "2026-08-28T11:00:01Z",
errorSummary: null,
...overrides,
};
}
function registerCompletedSyncRun(run: CatalogSyncRun) {
const completed: CatalogSyncRun = {
...run,
@@ -167,6 +192,116 @@ const synchronizationScopes = [
{ scope: "all", label: "Synchronize all" },
] as const;
test("renders Fleet Ledger with real metrics, conceptual row tooltips, and contextual model selection", async () => {
const user = userEvent.setup();
server.use(
http.get("/api/catalog/metrics", () => HttpResponse.json({
scope: "global",
databaseId: null,
tables: 163,
columns: 2_275,
sensitiveColumns: 5,
relationships: 18,
descriptionTargets: 2_438,
describedTargets: 1_828,
descriptionCoverage: 75,
updatedAt: "2026-08-31T09:00:00Z",
})),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
})),
);
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
const pageHeading = await screen.findByRole("heading", { name: "Database management" });
expect(pageHeading).toBeVisible();
const applicationBar = pageHeading.closest("header");
expect(applicationBar).not.toBeNull();
expect(within(applicationBar!).getByRole("region", { name: "Catalog status" })).toBeVisible();
expect(within(applicationBar!).getByRole("group", { name: "Database management actions" })).toBeVisible();
expect(screen.queryByText("Thoth catalog · Fleet ledger")).not.toBeInTheDocument();
expect(screen.queryByText("Inspect physical metadata, curate descriptions and control catalog operations.")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Back to workspace" })).not.toBeInTheDocument();
const summary = screen.getByRole("region", { name: "Fleet summary" });
expect(await within(summary).findByText("2,275")).toBeVisible();
expect(within(summary).getByText("75%")).toBeVisible();
expect(screen.queryByRole("combobox", { name: "Metadata description model" })).not.toBeInTheDocument();
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
const tablesAction = within(databaseRow).getByRole("button", { name: "View tables for Policlinico San Donato" });
const detailsAction = within(databaseRow).getByRole("button", { name: "View Policlinico San Donato" });
expect(tablesAction.parentElement).toHaveAttribute("data-tooltip", "Tables");
expect(tablesAction).not.toHaveAttribute("title");
expect(detailsAction).toHaveAttribute("data-tooltip", "Details");
const manager = screen.getByRole("main", { name: "Database management" });
await user.click(within(databaseRow).getByRole("button", { name: "Edit Policlinico San Donato" }));
const editDrawer = await screen.findByRole("dialog", { name: "Edit database" });
expect(manager).toContainElement(editDrawer);
expect(editDrawer).toHaveAttribute("aria-modal", "false");
await user.click(within(editDrawer).getByRole("button", { name: "Close database panel" }));
const restoredDatabaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(restoredDatabaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
const actionPicker = screen.getByRole("combobox", { name: "Batch action" });
expect(within(actionPicker).getByRole("option", { name: /^Synchronize all(?:,|$)/ })).toBeVisible();
await user.selectOptions(actionPicker, "generate-missing");
expect(await screen.findByRole("combobox", { name: "Metadata description model" })).toHaveValue("local-qwen");
});
test("keeps only the local back action while browsing Fleet Ledger columns", async () => {
const user = userEvent.setup();
server.use(
http.get("/api/catalog/databases/:databaseId/tables", () => HttpResponse.json([patientsTable])),
http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => HttpResponse.json([])),
);
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("button", {
name: "View tables for Policlinico San Donato",
}));
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
await user.click(await screen.findByRole("button", { name: "View columns for patients" }));
expect(await screen.findByRole("button", { name: "Back to tables" })).toBeVisible();
await waitFor(() => {
expect(screen.queryByRole("button", { name: "Back to databases" })).not.toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: "Back to tables" }));
expect(await screen.findByRole("button", { name: "Back to databases" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Back to tables" })).not.toBeInTheDocument();
});
test("reopens database synchronization history when no run is active", async () => {
const user = userEvent.setup();
const completed: CatalogSyncRun = {
...makeSyncRun("all"),
state: "succeeded",
phase: "completed",
startedAt: "2026-08-27T10:00:00Z",
finishedAt: "2026-08-27T10:00:01Z",
};
server.use(
http.get("/api/catalog/databases/:databaseId/sync-runs", () => HttpResponse.json([completed])),
http.get("/api/catalog/sync-runs/:runId", () => HttpResponse.json(completed)),
http.get("/api/catalog/sync-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderPage({ rows: [makeDatabase()], presentation: "fleet" });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("button", { name: "View synchronization history for Policlinico San Donato" }));
const drawer = await screen.findByRole("dialog", { name: "Schema synchronization" });
expect(drawer).toBeVisible();
expect(screen.getByRole("main", { name: "Database management" })).toContainElement(drawer);
expect(await screen.findByRole("heading", { name: "Succeeded" })).toBeVisible();
expect(screen.getByRole("region", { name: "Synchronization history" })).toBeVisible();
});
test("selects the configured metadata-description model by default", async () => {
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [
@@ -181,6 +316,50 @@ test("selects the configured metadata-description model by default", async () =>
await waitFor(() => expect(selector).toHaveValue("local-qwen"));
});
test("keeps both run-history buttons visible beside the metadata-description selector", async () => {
const user = userEvent.setup();
renderPage();
const selector = await screen.findByRole("combobox", { name: "Metadata description model" });
const toolbar = screen.getByRole("group", { name: "Database management controls" });
const metadataControls = screen.getByRole("group", { name: "Metadata description controls" });
const actions = screen.getByRole("group", { name: "Database management actions" });
const descriptionHistoryButton = within(metadataControls).getByRole("button", {
name: "Run descriptions generation history",
});
const suggestionHistoryButton = within(metadataControls).getByRole("button", {
name: "Run sensitive suggestions history",
});
expect(toolbar).toHaveClass("sm:items-end", "sm:justify-between");
expect(toolbar.firstElementChild).toBe(metadataControls);
expect(metadataControls).toContainElement(selector);
expect(descriptionHistoryButton).toBeVisible();
expect(descriptionHistoryButton).toBeEnabled();
expect(descriptionHistoryButton).toHaveTextContent("Run descriptions generation history");
expect(descriptionHistoryButton).toHaveClass("disabled:opacity-70");
expect(suggestionHistoryButton).toBeVisible();
expect(suggestionHistoryButton).toBeEnabled();
expect(suggestionHistoryButton).toHaveTextContent("Run sensitive suggestions history");
expect(toolbar.lastElementChild).toBe(actions);
expect(actions).toHaveClass("sm:justify-end");
expect(actions).toContainElement(screen.getByRole("button", { name: "Refresh" }));
expect(actions).toContainElement(screen.getByRole("button", { name: "Add database" }));
expect(screen.queryByRole("note", {
name: "Metadata generation source data disclosure",
})).not.toBeInTheDocument();
expect(document.getElementById("metadata-generation-source-data-disclosure")).toBeNull();
await user.click(descriptionHistoryButton);
const descriptionDrawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(descriptionDrawer).getByText("No description generation runs yet.")).toBeVisible();
await user.click(suggestionHistoryButton);
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
const suggestionDrawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(suggestionDrawer).getByText("No sensitive suggestion runs yet.")).toBeVisible();
});
test("changes the metadata-description model in page-local state", async () => {
const user = userEvent.setup();
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
@@ -271,38 +450,6 @@ test("renders only the public metadata-generation model contract", async () => {
expect(document.body).not.toHaveTextContent("raw-private-credential");
});
test("discloses bounded transient source samples in database-wide and selected generation views", async () => {
const user = userEvent.setup();
server.use(http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
})), http.get(
"/api/catalog/databases/:databaseId/tables",
() => HttpResponse.json([]),
));
renderPage();
const disclosure = await screen.findByRole("note", {
name: "Metadata generation source data disclosure",
});
expect(within(disclosure).getByText(
"Description generation may send up to five source rows and up to five representative non-null example values to the selected model provider. Values from columns marked sensitive are replaced with plausible synthetic values before the request.",
)).toBeVisible();
expect(within(disclosure).getByText(
"Values from unmarked columns may be sent unchanged. Samples are transient and are not stored in run logs or catalog metadata.",
)).toBeVisible();
expect(within(disclosure).getByText(
"Sensitive-field suggestions use structural metadata only and remain unsaved until you choose Save sensitive fields.",
)).toBeVisible();
await user.click(await screen.findByRole("button", { name: "View Policlinico San Donato" }));
await user.click(screen.getByRole("tab", { name: "Tables" }));
expect(screen.getByRole("note", {
name: "Metadata generation source data disclosure",
})).toBeVisible();
});
test("starts with a full-width list and applies the row action matrix", async () => {
renderPage();
@@ -377,7 +524,7 @@ test("starts Generate Missing for one configured database without confirmation",
scope: "missing",
}));
expect(screen.queryByText(/replace existing generated descriptions/i)).not.toBeInTheDocument();
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
});
test("confirms Generate All replacement, supports cancel, and sends the database-wide scope", async () => {
@@ -419,7 +566,7 @@ test("confirms Generate All replacement, supports cancel, and sends the database
modelId: "local-qwen",
scope: "all",
}));
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
});
test("keeps the database selected and safely explains when no descriptions are eligible", async () => {
@@ -445,7 +592,7 @@ test("keeps the database selected and safely explains when no descriptions are e
"No eligible catalog tables or columns need description generation.",
)).toBeVisible();
expect(screen.getByText("1 selected")).toBeVisible();
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
expect(document.body).not.toHaveTextContent("private provider and catalog detail");
});
@@ -605,7 +752,7 @@ test("disables database-wide generation while a description generation is active
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate Missing" }));
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
@@ -1177,7 +1324,7 @@ test("starts description generation for multiple selected tables", async () => {
scope: "selected_tables",
targetIds: [patientsTable.id, visitsTable.id],
}));
expect(await screen.findByRole("complementary", {
expect(await screen.findByRole("dialog", {
name: "Description generation",
})).toBeVisible();
expect(await screen.findByText("Description generation started for 2 tables")).toBeVisible();
@@ -1216,18 +1363,42 @@ test("observes an active run from another browser and reopens a terminal run fro
renderPage();
const observe = await screen.findByRole("button", {
name: "Observe active description generation",
name: "Run descriptions generation history",
});
expect(observe).toHaveTextContent("Run descriptions generation history");
expect(requestedLimit).toBe("50");
await user.click(observe);
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
const history = within(drawer).getByRole("region", { name: "Description generation history" });
await user.click(within(history).getByRole("button", { name: /Completed.*missing/i }));
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
});
test("keeps the sensitive suggestion history label stable while showing active status separately", async () => {
const runningRun = makeSensitiveDataSuggestionRun({
status: "running",
finishedAt: null,
updatedAt: new Date().toISOString(),
});
server.use(http.get(
"/api/catalog/sensitive-data-suggestion-runs",
() => HttpResponse.json([runningRun]),
));
renderPage();
const historyButton = await screen.findByRole("button", {
name: "Run sensitive suggestions history",
});
expect(historyButton).toHaveTextContent("Run sensitive suggestions history");
await waitFor(() => expect(historyButton).toHaveAttribute(
"title",
"Sensitive suggestion generation is active",
));
expect(historyButton.querySelector("[aria-hidden='true'].bg-primary")).not.toBeNull();
});
test.each([
"completed",
"completed_with_errors",
@@ -1256,7 +1427,7 @@ test.each([
client.setQueryData(unrelatedColumnKey, []);
await user.click(await screen.findByRole("button", {
name: "View description generation history",
name: "Run descriptions generation history",
}));
await waitFor(() => expect(client.getQueryState(tableKey)?.isInvalidated).toBe(true));
@@ -1299,7 +1470,7 @@ test("keeps selected tables when description generation cannot start", async ()
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
expect(screen.getByText("2 selected")).toBeVisible();
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
expect(document.body).not.toHaveTextContent("private provider detail");
});
@@ -1565,7 +1736,7 @@ test("starts one selected column with the configured default model", async () =>
scope: "selected_columns",
targetIds: [patientIdColumn.id],
}));
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(drawer).toBeVisible();
expect(within(drawer).getByRole("heading", { name: "Queued" })).toBeVisible();
expect(await screen.findByText("Description generation started for 1 column")).toBeVisible();
@@ -1606,14 +1777,23 @@ test("shows database sensitive suggestions only for a selection and rejects mult
test("requests database-level sensitive suggestions for the only selected database", async () => {
const user = userEvent.setup();
let suggestionBody: unknown;
let suggestionFinished = false;
let historyCalls = 0;
const run = makeSensitiveDataSuggestionRun({ scope: "all", total: 1 });
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
})),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
historyCalls += 1;
return HttpResponse.json(suggestionFinished ? [run] : []);
}),
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", async ({ request }) => {
suggestionBody = await request.json();
suggestionFinished = true;
return HttpResponse.json({
run,
suggestions: [{
columnId: patientIdColumn.id,
tableId: patientsTable.id,
@@ -1633,7 +1813,37 @@ test("requests database-level sensitive suggestions for the only selected databa
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await waitFor(() => expect(suggestionBody).toEqual({ modelId: "local-qwen", scope: "all" }));
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
});
test("refetches sensitive suggestion history after a failed request", async () => {
const user = userEvent.setup();
let historyCalls = 0;
server.use(
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({
models: [{ id: "local-qwen", label: "Local Qwen" }],
default: "local-qwen",
})),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => {
historyCalls += 1;
return HttpResponse.json([]);
}),
http.post("/api/catalog/databases/:databaseId/sensitive-data-suggestions", () => (
HttpResponse.json({
code: "sensitive_data_suggestion_invalid_response",
message: "The LLM returned an incomplete or invalid classification. No suggestions were applied.",
}, { status: 502 })
)),
);
renderPage({ rows: [makeDatabase()] });
const databaseRow = await screen.findByRole("row", { name: /Policlinico San Donato/ });
await user.click(within(databaseRow).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Suggest sensitive fields" }));
await waitFor(() => expect(historyCalls).toBeGreaterThanOrEqual(2));
expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument();
});
test("requests sensitive suggestions only for selected tables", async () => {
@@ -1687,7 +1897,7 @@ test("requests sensitive suggestions only for selected tables", async () => {
scope: "selected_tables",
targetIds: [visitsTable.id],
}));
expect(await screen.findByRole("complementary", { name: "Sensitive field review" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Sensitive field review" })).toBeVisible();
});
test("reviews AI-sensitive-field suggestions as an editable draft and saves only changed columns", async () => {
@@ -1798,7 +2008,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
scope: "selected_columns",
targetIds: [idColumn.id, nameColumn.id],
}));
const review = await screen.findByRole("complementary", { name: "Sensitive field review" });
const review = await screen.findByRole("dialog", { name: "Sensitive field review" });
expect(within(review).getByRole("checkbox", { name: "Protect patients.id" })).toBeChecked();
expect(within(review).getByRole("checkbox", { name: "Protect patients.name" })).toBeChecked();
expect(patches).toHaveLength(0);
@@ -1814,7 +2024,7 @@ test("reviews AI-sensitive-field suggestions as an editable draft and saves only
},
}]));
expect(await screen.findByText("Saved 1 sensitive flag")).toBeVisible();
await waitFor(() => expect(screen.queryByRole("complementary", { name: "Sensitive field review" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Sensitive field review" })).not.toBeInTheDocument());
await waitFor(() => expect(screen.getByRole("checkbox", { name: "Sensitive data for id" })).toBeChecked());
expect(screen.getByRole("checkbox", { name: "Sensitive data for name" })).not.toBeChecked();
});
@@ -1875,7 +2085,7 @@ test("polls a description run and renders its events in sequence order", async (
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByText(/local-qwen/)).toBeVisible();
expect(within(drawer).getByText(/selected columns/)).toBeVisible();
expect(await within(drawer).findByRole("heading", { name: "Completed" }, { timeout: 4_000 })).toBeVisible();
@@ -1992,7 +2202,7 @@ test("keeps the selected column and shows a safe message when generation cannot
expect(await screen.findByText("A description generation run is already active.")).toBeVisible();
expect(screen.getByText("1 selected")).toBeVisible();
expect(screen.queryByRole("complementary", { name: "Description generation" })).not.toBeInTheDocument();
expect(screen.queryByRole("dialog", { name: "Description generation" })).not.toBeInTheDocument();
expect(document.body).not.toHaveTextContent(/private-provider|sk-private-key/);
});
@@ -2057,7 +2267,7 @@ test("shows a basic failed run without exposing private model or provider fields
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByRole("heading", { name: "Failed" })).toBeVisible();
expect(within(drawer).getByText(/Approved model/)).toBeVisible();
expect(within(drawer).getByText("The model request failed before a description was saved.")).toBeVisible();
@@ -2200,7 +2410,7 @@ test("disables another generation start while the selected-column run is active"
await user.click(within(columnRow!).getByRole("checkbox", { name: /toggle row selection/i }));
await user.click(screen.getByRole("button", { name: "Actions" }));
await user.click(await screen.findByRole("menuitem", { name: "Generate description" }));
expect(await screen.findByRole("complementary", { name: "Description generation" })).toBeVisible();
expect(await screen.findByRole("dialog", { name: "Description generation" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Actions" }));
expect(await screen.findByRole("menuitem", { name: "Generate description" }))
@@ -2495,10 +2705,16 @@ test("opens the durable job drawer and confirms its exact destructive plan", asy
await user.click(screen.getByRole("tab", { name: "Tables" }));
await user.click(await screen.findByRole("button", { name: "Sync tables" }));
const synchronizationDrawer = await screen.findByRole("complementary", {
const synchronizationDrawer = await screen.findByRole("dialog", {
name: "Schema synchronization",
});
expect(synchronizationDrawer).toHaveClass("inset-y-2", "sm:inset-y-4");
expect(synchronizationDrawer).toHaveAttribute("data-catalog-panel");
expect(synchronizationDrawer.querySelector(
':scope > [data-catalog-panel-region="header"]',
)).toBeInTheDocument();
expect(synchronizationDrawer.querySelector(
':scope > [data-catalog-panel-region="body"]',
)).toBeInTheDocument();
expect(within(synchronizationDrawer).getByRole("listitem", {
name: "Confirmation required, in progress",
})).toHaveAttribute("aria-current", "step");
+463 -58
View File
@@ -14,9 +14,11 @@ import {
createCatalogDatabase,
deleteCatalogDatabase,
deleteCatalogDatabaseMetadata,
getCatalogMetrics,
listCatalogDatabases,
listDescriptionGenerationRuns,
listMetadataGenerationModels,
listSensitiveDataSuggestionRuns,
replaceCatalogDatabaseSecrets,
startCatalogSync,
startDescriptionGenerationRun,
@@ -34,15 +36,27 @@ import {
type DescriptionGenerationRun,
type SensitiveDataSuggestion,
type SensitiveDataSuggestionRequest,
type SensitiveDataSuggestionRun,
} from "../api/catalog-databases";
import { DatabaseGrid } from "./database-management/DatabaseGrid";
import { DatabaseForm } from "./database-management/DatabaseForm";
import { databaseFormTitle } from "./database-management/DatabaseForm";
import { DatabaseTables } from "./database-management/DatabaseTables";
import { DatabaseRelationships } from "./database-management/DatabaseRelationships";
import { CatalogSyncDrawer } from "./database-management/CatalogSyncDrawer";
import { MetadataGenerationModelSelector } from "./database-management/MetadataGenerationModelSelector";
import { DescriptionGenerationDrawer } from "./database-management/DescriptionGenerationDrawer";
import { SensitiveDataSuggestionHistoryDrawer } from "./database-management/SensitiveDataSuggestionHistoryDrawer";
import { SensitiveDataReviewDrawer } from "./database-management/SensitiveDataReviewDrawer";
import {
FleetLedgerBreadcrumb,
FleetLedgerContent,
FleetLedgerDrawer,
FleetLedgerHeader,
FleetLedgerKpiStrip,
FleetLedgerRealStatus,
FleetLedgerShell,
} from "./database-management/FleetLedgerShell";
import {
configurationFingerprint,
configurationFromDraft,
@@ -58,6 +72,7 @@ import {
const DATABASE_QUERY_KEY = ["catalog-databases"] as const;
const DESCRIPTION_GENERATION_HISTORY_QUERY_KEY = ["description-generation-runs", 50] as const;
const SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
const DESCRIPTION_GENERATION_ACTIVE_STATUSES = ["queued", "running"] as const;
const SYNC_STARTED_MESSAGES: Record<CatalogSyncScope, string> = {
tables: "Table synchronization started",
@@ -70,6 +85,7 @@ interface Props {
canManage: boolean;
canManageSecrets: boolean;
onNavigationStateChange?: (state: DatabaseNavigationState) => void;
presentation?: "legacy" | "fleet";
}
interface FormSource {
@@ -91,6 +107,7 @@ export function DatabaseManagementPage({
canManage,
canManageSecrets,
onNavigationStateChange,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
const {
@@ -127,6 +144,17 @@ export function DatabaseManagementPage({
const observedActiveDescriptionGenerationRun = descriptionGenerationRuns.find(
(run) => isDescriptionGenerationActive(run),
);
const sensitiveDataSuggestionRunsQuery = useQuery({
queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
queryFn: () => listSensitiveDataSuggestionRuns(50),
enabled: canManage,
retry: false,
refetchInterval: 5_000,
});
const sensitiveDataSuggestionRuns = sensitiveDataSuggestionRunsQuery.data ?? [];
const observedActiveSensitiveDataSuggestionRun = sensitiveDataSuggestionRuns.find(
(run) => run.status === "running",
);
const [screen, setScreen] = useState<DatabaseScreen>({ kind: "list" });
const [selectedMetadataModel, setSelectedMetadataModel] = useState("");
@@ -142,10 +170,14 @@ export function DatabaseManagementPage({
dirty: false,
busy: false,
});
const [tableNestedNavigationActive, setTableNestedNavigationActive] = useState(false);
const [activeSyncRun, setActiveSyncRun] = useState<CatalogSyncRun | null>(null);
const [syncHistoryDatabaseId, setSyncHistoryDatabaseId] = useState<string | null>(null);
const [syncDrawerOpen, setSyncDrawerOpen] = useState(false);
const [activeDescriptionGenerationRun, setActiveDescriptionGenerationRun] = useState<DescriptionGenerationRun | null>(null);
const [descriptionGenerationDrawerOpen, setDescriptionGenerationDrawerOpen] = useState(false);
const [activeSensitiveDataSuggestionRun, setActiveSensitiveDataSuggestionRun] = useState<SensitiveDataSuggestionRun | null>(null);
const [sensitiveDataSuggestionHistoryDrawerOpen, setSensitiveDataSuggestionHistoryDrawerOpen] = useState(false);
const [sensitiveReview, setSensitiveReview] = useState<{
databaseId: string;
scopeLabel: string;
@@ -163,6 +195,20 @@ export function DatabaseManagementPage({
const activeRow = screen.kind === "list"
? undefined
: rows.find((row) => row.workspaceId === screen.workspaceId);
const metricsDatabaseId = (screen.kind === "tables" || screen.kind === "relationships")
? activeRow?.id
: undefined;
const catalogMetricsQuery = useQuery({
queryKey: ["catalog-metrics", metricsDatabaseId ?? "global"],
queryFn: () => getCatalogMetrics(metricsDatabaseId),
enabled: canManage && (metricsDatabaseId === undefined || Boolean(activeRow?.id)),
retry: false,
refetchInterval: presentation === "fleet" ? 15_000 : false,
});
const invalidateCatalogMetrics = useCallback(
() => queryClient.invalidateQueries({ queryKey: ["catalog-metrics"] }),
[queryClient],
);
const availableWorkspaces = useMemo(
() => rows.filter((row) => row.workspaceAvailable && !row.configured),
[rows],
@@ -235,6 +281,7 @@ export function DatabaseManagementPage({
setStaleBannerOpen(true);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setTableNestedNavigationActive(false);
restoreListFocus();
}, [restoreListFocus]);
@@ -298,6 +345,7 @@ export function DatabaseManagementPage({
setStale(false);
setPartialSecretFailure(null);
setTablesNavigationState({ dirty: false, busy: false });
setTableNestedNavigationActive(false);
setScreen({ kind: "tables", workspaceId: row.workspaceId });
}, []);
@@ -590,10 +638,11 @@ export function DatabaseManagementPage({
try {
const result = await refetch();
if (result.error) throw result.error;
await invalidateCatalogMetrics();
} catch (error) {
toast.error(apiErrorMessage(error));
}
}, [isFetching, refetch]);
}, [invalidateCatalogMetrics, isFetching, refetch]);
const updateTrackedSyncRun = useCallback((run: CatalogSyncRun) => {
setActiveSyncRun(run);
@@ -606,6 +655,7 @@ export function DatabaseManagementPage({
const rememberSyncRun = useCallback((run: CatalogSyncRun) => {
updateTrackedSyncRun(run);
setSyncHistoryDatabaseId(run.databaseId);
setSyncDrawerOpen(true);
}, [updateTrackedSyncRun]);
@@ -622,11 +672,20 @@ export function DatabaseManagementPage({
const run = observedActiveDescriptionGenerationRun
?? descriptionGenerationRuns[0]
?? activeDescriptionGenerationRun;
if (!run) return;
setActiveDescriptionGenerationRun(run);
if (run) setActiveDescriptionGenerationRun(run);
setSensitiveDataSuggestionHistoryDrawerOpen(false);
setDescriptionGenerationDrawerOpen(true);
}, [activeDescriptionGenerationRun, descriptionGenerationRuns, observedActiveDescriptionGenerationRun]);
const openSensitiveDataSuggestionHistory = useCallback(() => {
const run = observedActiveSensitiveDataSuggestionRun
?? sensitiveDataSuggestionRuns[0]
?? activeSensitiveDataSuggestionRun;
if (run) setActiveSensitiveDataSuggestionRun(run);
setDescriptionGenerationDrawerOpen(false);
setSensitiveDataSuggestionHistoryDrawerOpen(true);
}, [activeSensitiveDataSuggestionRun, observedActiveSensitiveDataSuggestionRun, sensitiveDataSuggestionRuns]);
const descriptionGenerationTerminated = useCallback(async (run: DescriptionGenerationRun) => {
await Promise.all([
queryClient.invalidateQueries({
@@ -636,15 +695,19 @@ export function DatabaseManagementPage({
queryClient.invalidateQueries({
queryKey: ["catalog-columns", run.databaseId],
}),
invalidateCatalogMetrics(),
]);
}, [queryClient]);
}, [invalidateCatalogMetrics, queryClient]);
const openSync = useCallback((row?: CatalogDatabase) => {
const run = row?.activeSyncRun ?? activeSyncRun;
if (!run) return;
const databaseId = row?.id ?? activeSyncRun?.databaseId ?? activeRow?.id ?? null;
if (!databaseId) return;
const run = row?.activeSyncRun
?? (activeSyncRun?.databaseId === databaseId ? activeSyncRun : null);
setActiveSyncRun(run);
setSyncHistoryDatabaseId(databaseId);
setSyncDrawerOpen(true);
}, [activeSyncRun]);
}, [activeRow?.id, activeSyncRun]);
const testSelected = useCallback(async (selected: CatalogDatabase[]) => {
try {
@@ -708,13 +771,22 @@ export function DatabaseManagementPage({
}
try {
const result = await suggestSensitiveFields(database.id, selectedMetadataModel, selection);
if (result.run) {
setActiveSensitiveDataSuggestionRun(result.run);
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(
SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY,
(current = []) => [result.run, ...current.filter((item) => item.id !== result.run.id)],
);
}
setSensitiveReview({ databaseId: database.id, scopeLabel, suggestions: result.suggestions });
toast.success(`Prepared ${result.suggestions.length} sensitive-field suggestion${result.suggestions.length === 1 ? "" : "s"} for review`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
} finally {
await queryClient.invalidateQueries({ queryKey: SENSITIVE_DATA_SUGGESTION_HISTORY_QUERY_KEY });
}
}, [selectedMetadataModel]);
}, [queryClient, selectedMetadataModel]);
const suggestDatabaseSensitiveFields = useCallback(async (selected: CatalogDatabase[]) => {
if (selected.length !== 1) {
@@ -751,8 +823,13 @@ export function DatabaseManagementPage({
}),
} : null);
const databaseId = sensitiveReview?.databaseId;
if (databaseId) void queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] });
}, [queryClient, sensitiveReview?.databaseId]);
if (databaseId) {
void Promise.all([
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
invalidateCatalogMetrics(),
]);
}
}, [invalidateCatalogMetrics, queryClient, sensitiveReview?.databaseId]);
const deleteSelectedMetadata = useCallback(async (
selected: CatalogDatabase[],
@@ -766,15 +843,18 @@ export function DatabaseManagementPage({
queryClient.invalidateQueries({ queryKey: ["catalog-columns", databaseId] }),
queryClient.invalidateQueries({ queryKey: ["catalog-relationships", databaseId] }),
]));
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
invalidateCatalogMetrics(),
]);
toast.success(target === "tables"
? `Deleted ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships`
: `Deleted ${counts.relationships} catalog relationships`);
? `Cleared ${counts.tables} catalog tables, ${counts.columns} columns, and ${counts.relationships} relationships. The source database was not changed.`
: `Cleared ${counts.relationships} catalog relationships. The source database was not changed.`);
} catch (error) {
toast.error(apiErrorMessage(error));
throw error;
}
}, [queryClient]);
}, [invalidateCatalogMetrics, queryClient]);
const syncDatabase = useCallback(async (scope: CatalogSyncScope) => {
if (!activeRow?.id || !activeRow.configured) return;
@@ -793,13 +873,16 @@ export function DatabaseManagementPage({
queryClient.invalidateQueries({ queryKey: ["catalog-sync-runs", databaseId] }),
]);
}
await queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY });
}, [activeSyncRun, queryClient]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: DATABASE_QUERY_KEY }),
invalidateCatalogMetrics(),
]);
}, [activeSyncRun, invalidateCatalogMetrics, queryClient]);
const formVisible = screen.kind !== "list" && screen.kind !== "tables" && screen.kind !== "relationships" && activeRow && draft;
const tablesVisible = screen.kind === "tables" && activeRow?.configured && activeRow.id;
const relationshipsVisible = screen.kind === "relationships" && activeRow?.configured && activeRow.id;
const currentActiveRun = activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state)
const currentActiveRun = activeSyncRun && activeSyncRun.databaseId === activeRow?.id && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeSyncRun.state)
? activeSyncRun
: activeRow?.activeSyncRun && ["queued", "running", "awaiting_confirmation", "applying"].includes(activeRow.activeSyncRun.state)
? activeRow.activeSyncRun
@@ -811,13 +894,292 @@ export function DatabaseManagementPage({
const descriptionGenerationActive = isDescriptionGenerationActive(
observedActiveDescriptionGenerationRun ?? activeDescriptionGenerationRun,
);
const descriptionGenerationHistoryAvailable = Boolean(
descriptionGenerationRuns.length > 0 || activeDescriptionGenerationRun,
const fleetMetadataModelControl = (
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
);
const metrics = catalogMetricsQuery.data;
const metricsUnavailable = catalogMetricsQuery.isError;
const metricsValue = (value?: number) => (
catalogMetricsQuery.isLoading ? "…" : metricsUnavailable || value === undefined ? "—" : value.toLocaleString("en-US")
);
const latestCatalogUpdate = metrics?.updatedAt
? new Date(metrics.updatedAt).toLocaleString()
: "No synchronized metadata";
const operationLabel = currentActiveRun
? `Synchronization: ${currentActiveRun.phase.replaceAll("_", " ")}`
: descriptionGenerationActive
? "Description generation active"
: observedActiveSensitiveDataSuggestionRun
? "Sensitive analysis active"
: "Idle";
const operationTone = currentActiveRun || descriptionGenerationActive || observedActiveSensitiveDataSuggestionRun
? "warning" as const
: "neutral" as const;
const fleetBreadcrumb = screen.kind === "tables" || screen.kind === "relationships"
? [
{ id: "databases", label: "Databases", onSelect: showList },
{ id: "database", label: activeRow?.workspaceName ?? "Database" },
{ id: screen.kind, label: screen.kind === "tables" ? "Tables" : "Relationships", current: true },
]
: [{ id: "databases", label: "Databases", current: true }];
const catalogDrawers = (
<>
<CatalogSyncDrawer
databaseId={syncHistoryDatabaseId}
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
onClose={() => setSyncDrawerOpen(false)}
onRunChange={rememberSyncRun}
onRunUpdate={updateTrackedSyncRun}
onCatalogChanged={() => void catalogChanged()}
/>
<DescriptionGenerationDrawer
open={descriptionGenerationDrawerOpen}
run={activeDescriptionGenerationRun}
modelLabel={metadataModels.find(
(model) => model.id === activeDescriptionGenerationRun?.modelId,
)?.label ?? activeDescriptionGenerationRun?.modelId ?? ""}
onClose={() => setDescriptionGenerationDrawerOpen(false)}
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitiveDataSuggestionRun}
/>
<SensitiveDataReviewDrawer
open={Boolean(sensitiveReview)}
databaseId={sensitiveReview?.databaseId ?? null}
scopeLabel={sensitiveReview?.scopeLabel ?? ""}
suggestions={sensitiveReview?.suggestions ?? []}
canManage={canManage}
onClose={() => setSensitiveReview(null)}
onSaved={sensitiveColumnsSaved}
/>
</>
);
if (presentation === "fleet") {
const fleetFormMode = formVisible ? screen.kind as DatabaseFormMode : null;
const fleetDrawer = formVisible && fleetFormMode ? (
<FleetLedgerDrawer
open
eyebrow="Catalog configuration"
title={databaseFormTitle(
fleetFormMode,
fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked),
"drawer",
)}
description={`${activeRow.workspaceName} · ${activeRow.workspaceId}`}
onClose={backToList}
closeLabel="Close database panel"
busy={busy}
width="wide"
>
<DatabaseForm
presentation="drawer"
mode={fleetFormMode}
row={activeRow}
availableWorkspaces={availableWorkspaces}
workspaceLocked={fleetFormMode === "add" && Boolean(screen.kind === "add" && screen.workspaceLocked)}
draft={draft}
dirty={dirty}
canManage={canManage}
canManageSecrets={canManageSecrets}
busyAction={busyAction}
stale={stale}
staleBannerOpen={staleBannerOpen}
partialSecretFailure={partialSecretFailure}
headingRef={formHeadingRef}
onBack={backToList}
onWorkspaceChange={changeWorkspace}
onFieldChange={changeField}
onTransportChange={changeTransport}
onBindingChange={changeBinding}
onSecretChange={changeSecret}
onSave={() => void save()}
onTest={() => void testConnection()}
onDelete={() => void remove()}
onReloadLatest={() => void reloadLatest()}
onKeepEditing={() => setStaleBannerOpen(false)}
onRetrySecrets={() => void retrySecrets()}
onOpenTables={(origin) => openTables(activeRow, origin)}
onOpenRelationships={() => openRelationships(activeRow)}
onSync={(scope) => void syncDatabase(scope)}
onOpenSync={() => openSync(activeRow)}
activeSyncRun={currentActiveRun}
/>
</FleetLedgerDrawer>
) : null;
const fleetContent = screen.kind === "tables" && tablesVisible ? (
<DatabaseTables
presentation="fleet"
database={activeRow}
canManage={canManage}
activeRun={currentActiveRun}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenRelationships={() => openRelationships(activeRow)}
onNavigationStateChange={setTablesNavigationState}
onNestedNavigationChange={setTableNestedNavigationActive}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
onCatalogMetricsChanged={invalidateCatalogMetrics}
metadataModelControl={fleetMetadataModelControl}
/>
) : screen.kind === "relationships" && relationshipsVisible ? (
<DatabaseRelationships
presentation="fleet"
database={{ ...activeRow, activeSyncRun: currentActiveRun }}
canManage={canManage}
onBackToDatabases={showList}
onOpenOverview={() => openOverview(activeRow)}
onOpenTables={() => openTables(activeRow)}
onRunStarted={rememberSyncRun}
onOpenSync={() => openSync(activeRow)}
/>
) : isError && rows.length === 0 ? (
<div className="grid h-full place-items-center p-6">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm" role="alert">
<p className="font-semibold text-destructive">The database catalog is unavailable.</p>
<p className="mt-1 leading-5 text-muted-foreground">Verify the catalog service and database migrations, then try again.</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw /> Try again
</Button>
</div>
</div>
) : (
<DatabaseGrid
presentation="fleet"
rows={rows}
isLoading={isLoading}
isFetching={isFetching}
search={search}
canManage={canManage}
searchInputRef={searchInputRef}
onSearchChange={setSearch}
onView={viewRow}
onOpenTables={(row, origin) => openTables(row, origin)}
onEdit={editRow}
onDelete={deleteRow}
onOpenSync={openSync}
onTestSelected={testSelected}
onSyncSelected={syncSelected}
selectedMetadataModel={selectedMetadataModelAvailable ? selectedMetadataModel : null}
descriptionGenerationActive={descriptionGenerationActive}
onGenerateDescriptions={generateDatabaseDescriptions}
onSuggestSensitive={suggestDatabaseSensitiveFields}
onDeleteMetadataSelected={deleteSelectedMetadata}
metadataModelControl={fleetMetadataModelControl}
onRefresh={refreshList}
/>
);
return (
<>
<main aria-label="Database management" className="flex min-h-0 flex-1 overflow-hidden bg-background">
<FleetLedgerShell
header={(
<FleetLedgerHeader
title="Database management"
status={(
<FleetLedgerRealStatus
title="Catalog status"
items={[
{
id: "catalog",
label: "Catalog",
value: isLoading ? "Loading" : isError ? "Unavailable" : "Available",
detail: isError ? "Retry from the current grid" : `${rows.length} workspace${rows.length === 1 ? "" : "s"}`,
tone: isError ? "danger" : isLoading ? "warning" : "success",
},
{ id: "operation", label: "Operations", value: operationLabel, tone: operationTone },
{ id: "updated", label: "Metadata updated", value: latestCatalogUpdate, tone: "info" },
]}
/>
)}
actions={(
<>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Description history" data-fleet-action="secondary" onClick={openDescriptionGenerationHistory}>
<History aria-hidden="true" /> Description history
{observedActiveDescriptionGenerationRun ? <span className="size-1.5 rounded-full bg-warning" aria-hidden="true" /> : null}
</Button>
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Sensitive history" data-fleet-action="secondary" onClick={openSensitiveDataSuggestionHistory}>
<History aria-hidden="true" /> Sensitive history
{observedActiveSensitiveDataSuggestionRun ? <span className="size-1.5 rounded-full bg-warning" aria-hidden="true" /> : null}
</Button>
{(screen.kind === "list" || formVisible) ? (
<Button
type="button"
size="sm"
className="thot-fleet-nav-action thot-fleet-nav-action--below"
data-tooltip="Add database"
data-fleet-action="primary"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus aria-hidden="true" /> Add database
</Button>
) : null}
</>
)}
/>
)}
kpis={(
<FleetLedgerKpiStrip
title={metricsDatabaseId ? "Database summary" : "Fleet summary"}
items={[
{ id: "tables", label: "Tables", value: metricsValue(metrics?.tables) },
{ id: "columns", label: "Columns", value: metricsValue(metrics?.columns) },
{ id: "sensitive", label: "Sensitive", value: metricsValue(metrics?.sensitiveColumns) },
{ id: "relationships", label: "Relationships", value: metricsValue(metrics?.relationships) },
{ id: "coverage", label: "Description coverage", value: metrics ? `${metrics.descriptionCoverage}%` : metricsValue() },
]}
/>
)}
navigation={(
<FleetLedgerBreadcrumb
items={fleetBreadcrumb}
back={screen.kind === "relationships" || (screen.kind === "tables" && !tableNestedNavigationActive)
? { label: "Back to databases", onBack: showList, disabled: navigationBusy }
: undefined}
/>
)}
children={<FleetLedgerContent busy={isLoading || isFetching}>{fleetContent}</FleetLedgerContent>}
drawer={(
<>
{fleetDrawer}
{catalogDrawers}
</>
)}
/>
</main>
</>
);
}
return (
<main aria-label="Database management" className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<header className="flex flex-wrap items-center justify-between gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
<header className="grid gap-4 border-b border-border bg-card px-4 py-4 sm:px-5">
<div>
<p className="thot-label mb-1">Administration</p>
<h1 className="font-heading text-xl font-semibold tracking-tight">Database management</h1>
@@ -825,43 +1187,76 @@ export function DatabaseManagementPage({
One database configuration for each repository workspace.
</p>
</div>
<div className="flex flex-wrap items-end justify-end gap-3">
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
aria-label={observedActiveDescriptionGenerationRun
? "Observe active description generation"
: "View description generation history"}
disabled={!canManage || !descriptionGenerationHistoryAvailable}
title={!descriptionGenerationHistoryAvailable && !descriptionGenerationRunsQuery.isLoading
? "No description generation runs yet"
: undefined}
onClick={openDescriptionGenerationHistory}
<div
role="group"
aria-label="Database management controls"
className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between"
>
<div
role="group"
aria-label="Metadata description controls"
className="flex flex-wrap items-end gap-3"
>
<History />
{observedActiveDescriptionGenerationRun ? "Active run" : "Run history"}
</Button>
<MetadataGenerationModelSelector
canManage={canManage}
data={metadataModelsQuery.data}
isError={metadataModelsQuery.isError}
isLoading={metadataModelsQuery.isLoading}
selectedModel={selectedMetadataModel}
onSelectedModelChange={setSelectedMetadataModel}
/>
<Button
type="button"
variant="outline"
className="whitespace-nowrap disabled:opacity-70"
aria-label="Run descriptions generation history"
disabled={!canManage}
title={observedActiveDescriptionGenerationRun
? "Description generation is active"
: "View description generation history"}
onClick={openDescriptionGenerationHistory}
>
<History />
Run descriptions generation history
{observedActiveDescriptionGenerationRun ? (
<span aria-hidden="true" className="size-1.5 rounded-full bg-primary" />
) : null}
</Button>
<Button
type="button"
variant="outline"
className="whitespace-nowrap disabled:opacity-70"
aria-label="Run sensitive suggestions history"
disabled={!canManage}
title={observedActiveSensitiveDataSuggestionRun
? "Sensitive suggestion generation is active"
: "View sensitive suggestion history"}
onClick={openSensitiveDataSuggestionHistory}
>
<History />
Run sensitive suggestions history
{observedActiveSensitiveDataSuggestionRun ? (
<span aria-hidden="true" className="size-1.5 rounded-full bg-primary" />
) : null}
</Button>
</div>
{screen.kind === "list" ? (
<div className="flex items-center gap-2">
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
<div
role="group"
aria-label="Database management actions"
className="flex flex-wrap items-center gap-2 sm:justify-end"
>
<Button type="button" variant="outline" disabled={isFetching} onClick={() => void refreshList()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
<Button
type="button"
disabled={!canManage || availableWorkspaces.length === 0}
title={availableWorkspaces.length === 0 ? "Every available workspace is already configured" : undefined}
onClick={(event) => addDatabase(event.currentTarget)}
>
<Plus /> Add database
</Button>
</div>
) : null}
</div>
@@ -959,6 +1354,7 @@ export function DatabaseManagementPage({
onOpenSync={() => openSync(activeRow)}
onDescriptionGenerationRunStarted={rememberDescriptionGenerationRun}
onSuggestSensitive={suggestActiveDatabaseSensitiveFields}
onCatalogMetricsChanged={invalidateCatalogMetrics}
/>
) : null}
@@ -975,9 +1371,9 @@ export function DatabaseManagementPage({
) : null}
</div>
<CatalogSyncDrawer
databaseId={activeSyncRun?.databaseId ?? activeRow?.id ?? null}
runId={activeSyncRun?.id ?? null}
open={syncDrawerOpen && Boolean(activeSyncRun)}
databaseId={syncHistoryDatabaseId}
runId={activeSyncRun?.databaseId === syncHistoryDatabaseId ? activeSyncRun.id : null}
open={syncDrawerOpen && Boolean(syncHistoryDatabaseId)}
onClose={() => setSyncDrawerOpen(false)}
onRunChange={rememberSyncRun}
onRunUpdate={updateTrackedSyncRun}
@@ -993,6 +1389,15 @@ export function DatabaseManagementPage({
onRunUpdate={setActiveDescriptionGenerationRun}
onTerminal={(run) => void descriptionGenerationTerminated(run)}
/>
<SensitiveDataSuggestionHistoryDrawer
open={sensitiveDataSuggestionHistoryDrawerOpen}
run={activeSensitiveDataSuggestionRun}
modelLabel={metadataModels.find(
(model) => model.id === activeSensitiveDataSuggestionRun?.modelId,
)?.label ?? activeSensitiveDataSuggestionRun?.modelId ?? ""}
onClose={() => setSensitiveDataSuggestionHistoryDrawerOpen(false)}
onRunUpdate={setActiveSensitiveDataSuggestionRun}
/>
<SensitiveDataReviewDrawer
open={Boolean(sensitiveReview)}
databaseId={sensitiveReview?.databaseId ?? null}
+6 -2
View File
@@ -41,7 +41,11 @@ test("loads Pi version and readiness as an accessible operational rail", async (
expect(screen.getByText("Loading Pi management…")).toBeVisible();
expect(await screen.findByRole("heading", { name: "Pi management" })).toBeVisible();
await screen.findByLabelText("Provider");
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("sm:max-w-[min(94vw,58rem)]");
const dialog = screen.getByRole("dialog", { name: "Pi management" });
expect(dialog).toHaveAttribute("aria-modal", "false");
expect(dialog).toHaveAttribute("data-work-area-panel");
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
expect(screen.getByTestId("pi-readiness-rail")).toHaveTextContent("Runtime ready");
expect(screen.getByRole("status", { name: "Pi readiness" })).toHaveTextContent("Ready");
expect(screen.getByText("Pi 0.80.3")).toBeVisible();
@@ -174,7 +178,7 @@ test("shows a seven-step host-terminal workflow in scrollable platform tabs", as
const tablist = await screen.findByRole("tablist", { name: "Pi host platform" });
const [linuxTab, macosTab, windowsTab] = within(tablist).getAllByRole("tab");
expect([linuxTab, macosTab, windowsTab].map((tab) => tab.textContent)).toEqual(["Linux", "macOS", "Windows"]);
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveClass("max-h-[calc(100vh-6rem)]");
expect(screen.getByRole("dialog", { name: "Pi management" })).toHaveAttribute("data-width", "standard");
expect(screen.getByTestId("pi-platform-instructions-scroll")).toHaveClass("overflow-y-scroll");
expect(screen.queryByRole("tabpanel")).not.toBeInTheDocument();
expect([linuxTab, macosTab, windowsTab].every((tab) => tab.getAttribute("aria-selected") === "false")).toBe(true);
+14 -14
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText, X } from "lucide-react";
import { CheckCircle2, CircleAlert, ClipboardCheck, FlaskConical, LoaderCircle, ScrollText } from "lucide-react";
import {
asPiManagementApiError,
getPiManagementLogs,
@@ -12,7 +12,7 @@ import {
type PiManagementOptions,
} from "../api/pi-management";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { WorkAreaPanel } from "./WorkAreaPanel";
const fieldClass = "h-9 w-full rounded-md border border-input bg-background px-2.5 text-sm shadow-xs outline-none focus-visible:ring-3 focus-visible:ring-ring/25 disabled:cursor-not-allowed disabled:opacity-60";
@@ -416,16 +416,17 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
const unavailable = statusQuery.isError || optionsQuery.isError;
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) onClose(); }}>
<DialogContent showCloseButton={false} className="max-h-[calc(100vh-6rem)] max-w-[min(94vw,58rem)] overflow-y-auto p-0 sm:max-w-[min(94vw,58rem)]">
<DialogHeader className="border-b border-border/70 px-5 pb-4 pt-5 pr-12">
<p className="thot-label">Installation controls</p>
<DialogTitle>Pi management</DialogTitle>
<DialogDescription>Review the bundled runtime, set safe defaults, and test the saved provider configuration.</DialogDescription>
<PiPlatformInstructions />
</DialogHeader>
<Button aria-label="Close Pi management" className="absolute right-2 top-2" size="icon-sm" variant="ghost" onClick={onClose}><X /></Button>
<div className="max-h-[calc(100vh-11rem)] overflow-y-auto px-5 py-5">
<WorkAreaPanel
open={open}
ariaLabel="Pi management"
eyebrow="Installation controls"
title="Pi management"
description="Review the bundled runtime, set safe defaults, and test the saved provider configuration."
onClose={onClose}
closeLabel="Close Pi management"
>
<PiPlatformInstructions />
<div className="mt-5">
{forbidden ? (
<div role="alert" aria-label="Pi management unavailable" className="rounded-md border border-destructive/30 bg-destructive/5 p-4 text-sm">
<p className="font-semibold">Pi management is not permitted</p>
@@ -527,7 +528,6 @@ export function PiManagement({ open, onClose }: { open: boolean; onClose: () =>
</div>
)}
</div>
</DialogContent>
</Dialog>
</WorkAreaPanel>
);
}
+138
View File
@@ -0,0 +1,138 @@
.thot-work-area-panel-host {
position: relative;
min-width: 0;
min-height: 0;
overflow: hidden;
container-name: work-area;
container-type: inline-size;
}
.thot-work-area-panel {
position: absolute;
z-index: 50;
inset-block: 0.75rem;
inset-inline-start: 50%;
inset-inline-end: auto;
display: flex;
width: 60%;
min-width: 0;
flex-direction: column;
overflow: hidden;
isolation: isolate;
border: 1px solid oklch(var(--border));
border-radius: 0.75rem;
color: oklch(var(--card-foreground));
background-color: oklch(var(--background));
background-image: none;
box-shadow:
0 30px 84px oklch(0.12 0.02 20 / 0.3),
0 8px 22px oklch(0.12 0.02 20 / 0.18);
transform: translateX(-50%);
container-name: work-area-panel;
container-type: inline-size;
}
.thot-work-area-panel:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -2px;
}
.thot-work-area-panel__header {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 1rem 0.875rem 1.25rem;
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
color: oklch(var(--primary-foreground));
background-color: oklch(var(--primary));
background-image: none;
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
}
.thot-work-area-panel__heading {
min-width: 0;
}
.thot-work-area-panel__eyebrow {
margin: 0;
color: inherit;
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.35;
text-transform: uppercase;
}
.thot-work-area-panel__title {
margin: 0.25rem 0 0;
color: inherit;
font-family: var(--font-heading);
font-size: 1.35rem;
font-weight: 600;
letter-spacing: -0.025em;
line-height: 1.18;
}
.thot-work-area-panel__description {
max-width: 68ch;
margin: 0.45rem 0 0;
color: inherit;
font-size: 0.875rem;
line-height: 1.55;
}
.thot-work-area-panel__close {
flex: none;
border-color: oklch(var(--primary-foreground) / 0.22);
color: inherit;
background: oklch(var(--primary-foreground) / 0.1);
}
.thot-work-area-panel__close:hover {
color: inherit;
background: oklch(var(--primary-foreground) / 0.18);
}
.thot-work-area-panel__body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 1.25rem;
overscroll-behavior: contain;
}
.thot-work-area-panel__body--empty {
display: flex;
align-items: flex-start;
}
.thot-work-area-panel__body--flush {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.thot-work-area-panel__footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
padding: 0.875rem 1.25rem;
border-top: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.32);
}
.thot-work-area-panel__footer--split {
justify-content: space-between;
}
@container work-area (max-width: 47.999rem) {
.thot-work-area-panel {
inset-block: 0.5rem;
width: calc(100% - 1rem);
}
}
+67
View File
@@ -0,0 +1,67 @@
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { WorkAreaPanel, WorkAreaPanelHost } from "./WorkAreaPanel";
function PanelHarness() {
const [open, setOpen] = useState(false);
return (
<WorkAreaPanelHost data-testid="host">
<button type="button" onClick={() => setOpen(true)}>Open management</button>
<button type="button" onClick={() => setOpen(false)}>Navigate elsewhere</button>
<WorkAreaPanel
open={open}
eyebrow="Controls"
title="Shared management"
description="Shared work-area chrome"
onClose={() => setOpen(false)}
closeLabel="Close shared management"
>
<p>Panel body</p>
</WorkAreaPanel>
</WorkAreaPanelHost>
);
}
test("portals into its nearest work-area host and exposes shared regions", async () => {
const user = userEvent.setup();
render(<PanelHarness />);
await user.click(screen.getByRole("button", { name: "Open management" }));
const panel = screen.getByRole("dialog", { name: "Shared management" });
expect(screen.getByTestId("host")).toContainElement(panel);
expect(panel).toHaveAttribute("aria-modal", "false");
expect(panel).toHaveAttribute("data-work-area-panel");
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
expect(panel.querySelectorAll(':scope > [data-work-area-panel-region="footer"]')).toHaveLength(0);
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
});
test("closes the focused panel with Escape and restores focus to its trigger", async () => {
const user = userEvent.setup();
render(<PanelHarness />);
const trigger = screen.getByRole("button", { name: "Open management" });
await user.click(trigger);
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
await user.keyboard("{Escape}");
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Shared management" })).not.toBeInTheDocument());
await waitFor(() => expect(trigger).toHaveFocus());
});
test("does not steal focus back when an external control closes the modeless panel", async () => {
const user = userEvent.setup();
render(<PanelHarness />);
await user.click(screen.getByRole("button", { name: "Open management" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Close shared management" })).toHaveFocus());
const externalControl = screen.getByRole("button", { name: "Navigate elsewhere" });
await user.click(externalControl);
await waitFor(() => expect(screen.queryByRole("dialog", { name: "Shared management" })).not.toBeInTheDocument());
await waitFor(() => expect(externalControl).toHaveFocus());
});
+239
View File
@@ -0,0 +1,239 @@
import {
createContext,
forwardRef,
useCallback,
useContext,
useId,
useLayoutEffect,
useRef,
useState,
type HTMLAttributes,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import { X } from "lucide-react";
import { Button } from "../components/ui/button";
import "./WorkAreaPanel.css";
function classes(base: string, className?: string) {
return className ? `${base} ${className}` : base;
}
const WorkAreaPanelHostContext = createContext<HTMLElement | null>(null);
interface WorkAreaPanelHostProviderProps {
host: HTMLElement | null;
children: ReactNode;
}
export function WorkAreaPanelHostProvider({
host,
children,
}: WorkAreaPanelHostProviderProps) {
return (
<WorkAreaPanelHostContext.Provider value={host}>
{children}
</WorkAreaPanelHostContext.Provider>
);
}
export interface WorkAreaPanelHostProps extends HTMLAttributes<HTMLDivElement> {}
/** Hosts modeless management panels inside the central application work area. */
export const WorkAreaPanelHost = forwardRef<HTMLDivElement, WorkAreaPanelHostProps>(
function WorkAreaPanelHost({ className, children, ...props }, forwardedRef) {
const [host, setHost] = useState<HTMLDivElement | null>(null);
const assignRef = useCallback((node: HTMLDivElement | null) => {
setHost(node);
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}, [forwardedRef]);
return (
<WorkAreaPanelHostContext.Provider value={host}>
<div
{...props}
ref={assignRef}
className={classes("thot-work-area-panel-host", className)}
>
{children}
</div>
</WorkAreaPanelHostContext.Provider>
);
},
);
/**
* Keeps a modeless work-area panel keyboard-reachable without trapping focus.
* Only a panel containing focus responds to Escape, so stacked panels close one
* at a time and focus returns to the control that opened the panel.
*/
export function useModelessPanel(open: boolean, onClose: () => void, busy = false) {
const panelRef = useRef<HTMLElement | null>(null);
const stateRef = useRef({ busy, onClose });
stateRef.current = { busy, onClose };
useLayoutEffect(() => {
if (!open) return;
const origin = document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const focusTimer = window.setTimeout(() => {
const panel = panelRef.current;
const target = panel?.querySelector<HTMLElement>("[data-work-area-panel-close]") ?? panel;
target?.focus();
}, 0);
const onKeyDown = (event: KeyboardEvent) => {
const panel = panelRef.current;
if (
event.key !== "Escape"
|| event.defaultPrevented
|| stateRef.current.busy
|| !panel?.contains(document.activeElement)
) return;
event.preventDefault();
stateRef.current.onClose();
};
document.addEventListener("keydown", onKeyDown);
return () => {
const activeElement = document.activeElement;
const shouldRestoreFocus = activeElement === document.body
|| Boolean(activeElement && panelRef.current?.contains(activeElement));
window.clearTimeout(focusTimer);
document.removeEventListener("keydown", onKeyDown);
if (shouldRestoreFocus) {
window.setTimeout(() => {
if (origin?.isConnected) origin.focus();
}, 0);
}
};
}, [open]);
return panelRef;
}
export interface WorkAreaPanelProps {
open: boolean;
ariaLabel?: string;
eyebrow?: ReactNode;
title: ReactNode;
description?: ReactNode;
onClose: () => void;
closeLabel?: string;
children: ReactNode;
bodyClassName?: string;
footer?: ReactNode;
footerClassName?: string;
busy?: boolean;
width?: "standard" | "wide";
className?: string;
catalogMarkers?: boolean;
}
/**
* Shared presentational shell for management, history, review and edit panels
* that live inside the application's central work area.
*/
export const WorkAreaPanel = forwardRef<HTMLElement, WorkAreaPanelProps>(
function WorkAreaPanel(
{
open,
ariaLabel,
eyebrow,
title,
description,
onClose,
closeLabel = "Close panel",
children,
bodyClassName,
footer,
footerClassName,
busy = false,
width = "standard",
className,
catalogMarkers = false,
},
forwardedRef,
) {
const panelHost = useContext(WorkAreaPanelHostContext);
const titleId = useId();
const descriptionId = useId();
const panelRef = useModelessPanel(open, onClose, busy);
const assignRef = (node: HTMLElement | null) => {
panelRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
};
if (!open) return null;
const panel = (
<aside
ref={assignRef}
role="dialog"
aria-modal="false"
className={classes("thot-work-area-panel", className)}
data-work-area-panel=""
data-catalog-panel={catalogMarkers ? "" : undefined}
data-width={width}
aria-label={ariaLabel}
aria-labelledby={ariaLabel ? undefined : titleId}
aria-describedby={description ? descriptionId : undefined}
aria-busy={busy}
tabIndex={-1}
>
<header
className="thot-work-area-panel__header"
data-work-area-panel-region="header"
data-catalog-panel-region={catalogMarkers ? "header" : undefined}
>
<div className="thot-work-area-panel__heading">
{eyebrow ? <p className="thot-work-area-panel__eyebrow">{eyebrow}</p> : null}
<h2 id={titleId} className="thot-work-area-panel__title">
{title}
</h2>
{description ? (
<p id={descriptionId} className="thot-work-area-panel__description">
{description}
</p>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="thot-work-area-panel__close"
data-work-area-panel-close
data-drawer-close
onClick={onClose}
disabled={busy}
aria-label={closeLabel}
title={closeLabel}
>
<X aria-hidden="true" />
</Button>
</header>
<div
className={classes("thot-work-area-panel__body", bodyClassName)}
data-work-area-panel-region="body"
data-catalog-panel-region={catalogMarkers ? "body" : undefined}
>
{children}
</div>
{footer ? (
<footer
className={classes("thot-work-area-panel__footer", footerClassName)}
data-work-area-panel-region="footer"
data-catalog-panel-region={catalogMarkers ? "footer" : undefined}
>
{footer}
</footer>
) : null}
</aside>
);
return panelHost ? createPortal(panel, panelHost) : panel;
},
);
+27
View File
@@ -0,0 +1,27 @@
.thot-workspace-manager__layout {
display: grid;
min-height: 0;
flex: 1;
grid-template-columns: 15rem minmax(0, 1fr);
overflow: hidden;
}
.thot-workspace-manager__navigation {
border-inline-end: 1px solid oklch(var(--border));
}
@container work-area-panel (max-width: 45rem) {
.thot-workspace-manager__layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(8rem, 13rem) minmax(0, 1fr);
}
.thot-workspace-manager__navigation {
border-inline-end: 0;
border-block-end: 1px solid oklch(var(--border));
}
.thot-workspace-manager__content {
padding-inline: 1rem;
}
}
+8 -9
View File
@@ -136,17 +136,16 @@ test("defaults workspace mutations and secrets to denied", async () => {
expect(screen.queryByRole("heading", { name: "Runtime secrets" })).not.toBeInTheDocument();
});
test("uses a tall viewport area and keeps the workspace content scrollable", () => {
test("uses the shared modeless work-area panel and keeps workspace content scrollable", () => {
renderManager();
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveClass(
"h-[86vh]",
"w-[94vw]",
"sm:w-[70vw]",
"max-w-[94vw]",
);
expect(screen.getByRole("main")).toHaveClass("overflow-y-auto");
const dialog = screen.getByRole("dialog", { name: "Workspace management" });
expect(dialog).toHaveAttribute("aria-modal", "false");
expect(dialog).toHaveAttribute("data-work-area-panel");
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="header"]')).toHaveLength(1);
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="body"]')).toHaveLength(1);
expect(dialog.querySelectorAll(':scope > [data-work-area-panel-region="footer"]')).toHaveLength(0);
expect(screen.getByRole("region", { name: "Workspace configuration" })).toHaveClass("overflow-y-auto");
});
test("offers a button above the workspace list that returns to Level 1", async () => {
+21 -29
View File
@@ -9,7 +9,6 @@ import {
GitPullRequest,
KeyRound,
Trash2,
X,
} from "lucide-react";
import {
@@ -33,13 +32,9 @@ import {
type AuthOperationGuard,
} from "../auth/authOperation";
import { Button } from "../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { WorkAreaPanel } from "./WorkAreaPanel";
import "./WorkspaceManager.css";
function QueryError({ name, message, retryLabel, onRetry }: {
name: string;
@@ -393,23 +388,18 @@ export function WorkspaceManager({
const hasEnteredSecrets = Object.values(secretValues).some((value) => value.length > 0);
return (
<Dialog open={open} onOpenChange={(nextOpen) => { if (!nextOpen) close(); }}>
<DialogContent
showCloseButton={false}
className="h-[86vh] w-[94vw] max-h-[92vh] max-w-[94vw] grid-rows-[auto_minmax(0,1fr)] gap-0 overflow-hidden p-0 sm:w-[70vw] sm:max-w-[94vw]"
>
<DialogHeader className="border-b border-border/70 px-6 pb-4 pt-5 pr-12">
<DialogTitle>Workspace management</DialogTitle>
<DialogDescription>
Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository.
</DialogDescription>
</DialogHeader>
<button type="button" aria-label="Close workspace management" onClick={close} className="absolute right-3 top-3 grid size-7 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X className="size-4" />
</button>
<div className="grid min-h-0 grid-cols-[15rem_minmax(0,1fr)] overflow-hidden">
<nav aria-label="Workspaces" className="flex min-h-0 flex-col border-r border-border/70 bg-muted/30 p-3">
<WorkAreaPanel
open={open}
ariaLabel="Workspace management"
eyebrow="Workspace configuration"
title="Workspace management"
description="Read, validate, and complete the runtime configuration of workspaces supplied by the installation repository."
onClose={close}
closeLabel="Close workspace management"
bodyClassName="thot-work-area-panel__body--flush"
>
<div className="thot-workspace-manager__layout">
<nav aria-label="Workspaces" className="thot-workspace-manager__navigation flex min-h-0 flex-col bg-muted/30 p-3">
<Button
type="button"
size="sm"
@@ -442,7 +432,10 @@ export function WorkspaceManager({
</div>
</nav>
<main className="min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5">
<section
aria-label="Workspace configuration"
className="thot-workspace-manager__content min-h-0 min-w-0 overflow-y-auto overscroll-contain px-6 py-5"
>
{notice && (
<p role="status" className="mb-4 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-3 py-2 text-sm">
<CheckCircle2 className="size-4 text-primary" />{notice}
@@ -689,9 +682,8 @@ export function WorkspaceManager({
)}
</section>
)}
</main>
</section>
</div>
</DialogContent>
</Dialog>
</WorkAreaPanel>
);
}
@@ -16,6 +16,7 @@ import {
type CatalogSyncPhase,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
databaseId: string | null;
@@ -101,7 +102,7 @@ export function CatalogSyncDrawer({
const runQuery = useQuery({
queryKey: ["catalog-sync-run", runId],
queryFn: () => getCatalogSyncRun(runId!),
enabled: Boolean(runId),
enabled: Boolean(runId && open),
retry: false,
refetchInterval: (query) => terminal(query.state.data) ? false : 1_000,
});
@@ -113,6 +114,12 @@ export function CatalogSyncDrawer({
retry: false,
refetchInterval: run && !terminal(run) ? 2_000 : false,
});
useEffect(() => {
const latestRun = historyQuery.data?.[0];
if (!open || runId || !latestRun) return;
onRunChange(latestRun);
}, [historyQuery.data, onRunChange, open, runId]);
const eventQuery = useQuery({
queryKey: ["catalog-sync-events", runId],
queryFn: () => listCatalogSyncEvents(runId!, lastSequence.current),
@@ -218,25 +225,74 @@ export function CatalogSyncDrawer({
}
};
if (!open) return null;
return (
<aside
aria-label="Schema synchronization"
className="fixed inset-y-2 right-0 z-40 flex w-full max-w-[440px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<p className="thot-label">Schema synchronization</p>
<h2 className="mt-1 font-heading text-xl font-semibold">{run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Loading"}</h2>
{run ? <p className="mt-1 text-sm text-muted-foreground">{run.scope} · {elapsed(run, now)}</p> : null}
const historySection = (
<section className="thot-catalog-drawer__history mt-5" aria-label="Synchronization history">
<h3 className="thot-label mb-2">Recent runs</h3>
{historyQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading recent runs…</p> : null}
{historyQuery.isError ? (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm" role="alert">
<p className="font-semibold text-destructive">Synchronization history could not be loaded.</p>
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={historyQuery.isFetching} onClick={() => void historyQuery.refetch()}>
<RotateCcw className={historyQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry history
</Button>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close synchronization drawer" onClick={onClose}>
<X aria-hidden="true" />
</Button>
</div>
) : null}
{!historyQuery.isLoading && !historyQuery.isError && (historyQuery.data ?? []).length === 0 ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 px-3 py-4 text-sm text-muted-foreground">
No synchronization runs yet.
</div>
) : null}
{(historyQuery.data ?? []).length > 0 ? (
<div className="divide-y divide-border rounded-md border border-border">
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
<button
key={item.id}
type="button"
aria-current={item.id === run?.id ? "true" : undefined}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 aria-[current=true]:bg-primary/8"
onClick={() => onRunChange(item)}
>
<span>{item.scope}</span>
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
</button>
))}
</div>
) : null}
</section>
);
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
if (!open) return null;
const footer = run ? (
<>
{run.state === "awaiting_confirmation" ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
</Button>
) : null}
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
) : null}
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
</Button>
) : null}
</>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Schema synchronization"
eyebrow="Schema synchronization"
title={run ? `${stateLabel(run)[0].toUpperCase()}${stateLabel(run).slice(1)}` : "Synchronization history"}
description={run ? `${run.scope} · ${elapsed(run, now)}` : undefined}
onClose={onClose}
closeLabel="Close synchronization drawer"
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isLoading ? <p className="text-sm text-muted-foreground">Loading synchronization…</p> : null}
{run ? (
<>
<section aria-labelledby="synchronization-steps-heading">
@@ -365,38 +421,22 @@ export function CatalogSyncDrawer({
</div>
</section>
<section className="mt-5" aria-label="Synchronization history">
<h3 className="thot-label mb-2">Recent runs</h3>
<div className="divide-y divide-border rounded-md border border-border">
{(historyQuery.data ?? []).slice(0, 5).map((item) => (
<button key={item.id} type="button" className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50" onClick={() => onRunChange(item)}>
<span>{item.scope}</span>
<span className="text-xs text-muted-foreground">{stateLabel(item)} · {new Date(item.createdAt).toLocaleString()}</span>
</button>
))}
</div>
</section>
{historySection}
</>
) : null}
</div>
{run ? (
<div className="flex justify-end gap-2 border-t border-border px-5 py-4">
{run.state === "awaiting_confirmation" ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("confirm")}>
{action === "confirm" ? <LoaderCircle className="animate-spin" /> : <Check />} Confirm removals
</Button>
) : null}
{["queued", "running", "awaiting_confirmation"].includes(run.state) ? (
<Button type="button" variant="outline" disabled={action !== null} onClick={() => void perform("cancel")}>Cancel</Button>
) : null}
{["failed", "cancelled", "interrupted"].includes(run.state) ? (
<Button type="button" disabled={action !== null} onClick={() => void perform("retry")}>
<RotateCcw className={action === "retry" ? "animate-spin" : ""} /> Retry
</Button>
) : null}
</div>
) : null}
</aside>
) : (
<>
<div className="rounded-lg border border-dashed border-primary/30 bg-primary/5 px-5 py-6 text-sm">
<p className="font-semibold">{runQuery.isError ? "This synchronization run could not be loaded." : historyQuery.isLoading ? "Loading synchronization history…" : "No run selected."}</p>
<p className="mt-1 text-muted-foreground">Choose a recent run to inspect its steps and event log.</p>
{runQuery.isError ? (
<Button type="button" variant="outline" size="sm" className="mt-3" disabled={runQuery.isFetching} onClick={() => void runQuery.refetch()}>
<RotateCcw className={runQuery.isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry run
</Button>
) : null}
</div>
{historySection}
</>
)}
</FleetLedgerDrawer>
);
}
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
@@ -18,6 +18,8 @@ import {
type SensitiveDataSuggestionRequest,
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
databaseId: string;
@@ -29,11 +31,17 @@ interface Props {
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onSync: () => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
bindingReady?: boolean;
catalogOperationActive?: boolean;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface GridContext {
canManage: boolean;
busy: boolean;
presentation: "legacy" | "fleet";
onEdit: (column: CatalogColumn, origin: HTMLButtonElement) => void;
onSensitiveChange: (column: CatalogColumn, sensitive: boolean) => void;
}
@@ -52,9 +60,11 @@ function ActionCell({ data, context }: ICellRendererParams<CatalogColumn, unknow
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}>
<Button type="button" variant="ghost" size="icon-lg" disabled={!context.canManage} aria-label={`Edit metadata for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} onClick={(event) => context.onEdit(data, event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</span>
</div>
);
}
@@ -86,10 +96,15 @@ export function DatabaseColumns({
onNavigationStateChange,
onSync,
onSuggestSensitive,
bindingReady = true,
catalogOperationActive = false,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const queryClient = useQueryClient();
const queryKey = ["catalog-columns", databaseId, table.id] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey,
queryFn: () => listCatalogColumns(databaseId, table.id),
retry: false,
@@ -159,6 +174,7 @@ export function DatabaseColumns({
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Column metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "column_stale") {
@@ -190,6 +206,7 @@ export function DatabaseColumns({
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -261,6 +278,7 @@ export function DatabaseColumns({
(column) => updatedById.get(column.id) ?? column,
));
setSensitiveDrafts({});
void onCatalogMetricsChanged?.();
toast.success("Sensitive fields saved");
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -270,6 +288,114 @@ export function DatabaseColumns({
}
};
type FleetColumnAction =
| "generate-descriptions"
| "consolidate-descriptions"
| "suggest-sensitive"
| "sync-columns"
| "save-sensitive";
const fleetActions: readonly FleetActionOption<FleetColumnAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to generate descriptions."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update metadata."
: selectedIds.length === 0
? "Select at least one column."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one column."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
group: "Synchronization",
scopeLabel: "All columns in this table",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
{
id: "save-sensitive",
label: "Save sensitive-field changes",
group: "Sensitive data",
runLabel: "Save",
disabled: !canManage || changedSensitiveColumns.length === 0 || catalogOperationActive || busy,
disabledReason: !canManage
? "You do not have permission to update sensitive fields."
: changedSensitiveColumns.length === 0
? "No sensitive-field changes are waiting to be saved."
: catalogOperationActive
? "Wait for the active catalog operation to finish."
: busy
? "Another action is running."
: undefined,
},
];
const runFleetAction = async (action: FleetColumnAction) => {
if (action === "generate-descriptions") await generateDescriptions();
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "sync-columns") onSync();
else await saveSensitive();
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
setSelectedIds([]);
};
const columns = useMemo<ColDef<CatalogColumn>[]>(() => [
{ field: "ordinalPosition", headerName: "#", width: 64, maxWidth: 64, filter: "agNumberColumnFilter" },
{ field: "sensitive", headerName: "Sensitive", minWidth: 110, width: 110, sortable: false, filter: false, resizable: false, cellRenderer: SensitiveCell },
@@ -280,16 +406,28 @@ export function DatabaseColumns({
{ field: "sourceComment", headerName: "Source comment", minWidth: 220, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 230, flex: 1.2, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 64, maxWidth: 64, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: ActionCell },
{ colId: "actions", headerName: "Actions", width: 64, maxWidth: 64, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: ActionCell },
], []);
const context = useMemo<GridContext>(() => ({
canManage,
busy,
busy: busy || catalogOperationActive,
presentation,
onEdit: edit,
onSensitiveChange: changeSensitive,
}), [busy, canManage, data]);
}), [busy, canManage, catalogOperationActive, data, presentation]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog columns could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry columns
</Button>
</div>
</div>
) : null;
if (editingId && active) {
if (editingId && active && presentation === "legacy") {
return (
<div className="mx-auto w-full max-w-4xl px-4 py-6 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={closeEditor}>← Back to columns</Button>
@@ -319,66 +457,71 @@ export function DatabaseColumns({
return (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
{selectedIds.length > 0 ? (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button
type="button"
variant="outline"
disabled={!canManage || descriptionGenerationActive || busy}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void suggestSensitive()}
>
<Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
busy={busy}
busyLabel={sensitiveAction === "suggest" ? "Suggesting…" : sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
<Menu.Root>
<Menu.Trigger className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{sensitiveAction === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
</>
)
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
{changedSensitiveColumns.length > 0 ? (
<Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}>
<Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}
</Button>
) : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy}
busyLabel={sensitiveAction === "save" ? "Saving…" : "Running…"}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Column action"
/>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog columns</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search columns" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
{changedSensitiveColumns.length > 0 ? <Button type="button" disabled={!canManage || busy} onClick={() => void saveSensitive()}><Save />{sensitiveAction === "save" ? "Saving…" : "Save sensitive fields"}</Button> : null}
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || busy} onClick={onSync}><RefreshCw />Sync columns</Button>
</>
)
)}
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogColumn>
ref={gridRef}
rowData={displayedColumns}
@@ -396,7 +539,40 @@ export function DatabaseColumns({
overlayNoRowsTemplate="No columns synchronized for this table."
/>
</div>
</div>
</div>}
{presentation === "fleet" && active ? (
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review column description"
description="Physical schema fields are read-only. Review fields can be edited."
onClose={closeEditor}
closeLabel="Close column metadata"
busy={busy}
footer={(
<>
<Button type="button" variant="outline" disabled={busy} onClick={closeEditor}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy || stale} onClick={() => void save()}><Save aria-hidden="true" />{busy ? "Saving…" : "Save metadata"}</Button>
</>
)}
>
<p className="thot-label">{table.name} · column</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{active.name}</h3>
{stale ? (
staleBannerOpen ? <div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm"><p className="font-semibold">Newer column metadata is available.</p><p className="mt-1 text-muted-foreground">Your draft is preserved until you reload.</p><div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div></div>
: <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this metadata can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadColumn()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<label className="grid gap-1.5 text-sm font-semibold">Physical column name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Native type<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.dataType} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Ordinal position<input className="h-9 rounded-md border border-input bg-muted/35 px-3" value={active.ordinalPosition} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Default expression<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={active.defaultExpression ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={active.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Generated description<textarea className="min-h-28 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold sm:col-span-2">Description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy} onChange={(event) => setDescription(event.target.value)} /></label>
</div>
</FleetLedgerDrawer>
) : null}
</div>
);
}
@@ -0,0 +1,161 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import type { CatalogDatabase, CatalogTable } from "../../api/catalog-databases";
import { server } from "../../test/msw";
import { DatabaseColumns } from "./DatabaseColumns";
import { DatabaseRelationships } from "./DatabaseRelationships";
import { DatabaseTables } from "./DatabaseTables";
const database: CatalogDatabase = {
id: "11111111-1111-4111-8111-111111111111",
workspaceId: "psd-clinical",
workspaceName: "Policlinico San Donato",
workspaceAvailable: true,
configured: true,
engine: "postgres",
databaseName: "warehouse",
schema: "datawarehouse",
version: 3,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
binding: { transport: "postgres_direct", host: "db.internal", port: 5432, username: "reader" },
connectionStatus: "reachable",
testedVersion: 3,
secrets: {
password: false,
apiKey: false,
sshPrivateKey: false,
sshPrivateKeyPassphrase: false,
sshKnownHosts: false,
tlsCa: false,
},
};
const table: CatalogTable = {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
databaseId: database.id!,
name: "patients",
sourceComment: "Patients imported from the clinical source",
description: null,
generatedDescription: null,
version: 1,
createdAt: "2026-08-27T08:00:00Z",
updatedAt: "2026-08-27T09:00:00Z",
};
function renderWithQueryClient(node: React.ReactNode) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
return render(<QueryClientProvider client={client}>{node}</QueryClientProvider>);
}
const noop = () => {};
const noopAsync = async () => {};
test("Fleet tables distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/tables", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseTables
presentation="fleet"
database={database}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
onBackToDatabases={noop}
onOpenOverview={noop}
onOpenRelationships={noop}
onNavigationStateChange={noop}
onRunStarted={noop}
onOpenSync={noop}
onDescriptionGenerationRunStarted={noop}
onSuggestSensitive={noopAsync}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog tables could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No catalog tables/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry tables" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog tables could not be loaded.")).not.toBeInTheDocument());
});
test("Fleet columns distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/tables/:tableId/columns", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseColumns
presentation="fleet"
databaseId={database.id!}
table={table}
canManage
selectedMetadataModel={null}
descriptionGenerationActive={false}
onDescriptionGenerationRunStarted={noop}
onNavigationStateChange={noop}
onSync={noop}
onSuggestSensitive={noopAsync}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog columns could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No columns synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry columns" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog columns could not be loaded.")).not.toBeInTheDocument());
});
test("Fleet relationships distinguish a failed query from an empty catalog and retry it", async () => {
const user = userEvent.setup();
let requests = 0;
server.use(http.get("/api/catalog/databases/:databaseId/relationships", () => {
requests += 1;
return requests === 1
? HttpResponse.json({ code: "catalog_unavailable" }, { status: 503 })
: HttpResponse.json([]);
}));
renderWithQueryClient(
<DatabaseRelationships
presentation="fleet"
database={database}
canManage
onBackToDatabases={noop}
onOpenOverview={noop}
onOpenTables={noop}
onRunStarted={noop}
onOpenSync={noop}
/>,
);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Catalog relationships could not be loaded.");
expect(alert).toHaveTextContent("The database catalog is unavailable.");
expect(screen.queryByText(/No physical relationships synchronized/i)).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Retry relationships" }));
await waitFor(() => expect(requests).toBe(2));
await waitFor(() => expect(screen.queryByText("Catalog relationships could not be loaded.")).not.toBeInTheDocument());
});
@@ -2,6 +2,7 @@ import type { RefObject, ReactNode } from "react";
import {
ArrowLeft,
Database,
History,
RefreshCw,
Save,
TestTube2,
@@ -140,12 +141,17 @@ interface DatabaseFormProps {
onSync: (scope: CatalogSyncScope) => void;
onOpenSync: () => void;
activeSyncRun?: CatalogSyncRun;
presentation?: "page" | "drawer";
}
function formTitle(mode: DatabaseFormMode, workspaceLocked: boolean): string {
export function databaseFormTitle(
mode: DatabaseFormMode,
workspaceLocked: boolean,
presentation: "page" | "drawer" = "page",
): string {
if (mode === "add") return workspaceLocked ? "Edit database" : "Add database";
if (mode === "edit") return "Edit database";
if (mode === "delete") return "Delete database";
if (mode === "delete") return presentation === "drawer" ? "Remove configuration" : "Delete database";
return "Database details";
}
@@ -180,8 +186,9 @@ export function DatabaseForm({
onSync,
onOpenSync,
activeSyncRun,
presentation = "page",
}: DatabaseFormProps) {
const title = formTitle(mode, workspaceLocked);
const title = databaseFormTitle(mode, workspaceLocked, presentation);
const readOnly = mode === "view" || mode === "delete";
const editable = mode === "add" || mode === "edit";
const busy = busyAction !== null;
@@ -212,7 +219,7 @@ export function DatabaseForm({
{row.workspaceAvailable
? "The repository workspace remains available and will return to the list as Not configured. "
: "This orphaned catalog row will disappear from the list. "}
Secret references associated with this configuration are removed too.
Secret references associated with this configuration are removed too. The source database is not modified.
</p>
</div>
) : null}
@@ -425,13 +432,50 @@ export function DatabaseForm({
) : null}
{mode === "delete" ? (
<Button type="button" variant="destructive" disabled={!canManage || !row.configured || !row.id || busy || stale} onClick={onDelete}>
<Trash2 /> {busyAction === "delete" ? "Deleting…" : "Delete database"}
<Trash2 /> {busyAction === "delete" ? "Removing…" : presentation === "drawer" ? "Remove configuration" : "Delete database"}
</Button>
) : null}
</div>
</form>
);
if (presentation === "drawer") {
return (
<section aria-label={`${title} form`} className="thot-fleet-drawer-form">
<h2 ref={headingRef} tabIndex={-1} className="sr-only">{title}</h2>
<div className="thot-fleet-drawer-form__context">
<div className="min-w-0">
<p className="thot-label">Workspace</p>
<p className="mt-1 truncate text-sm font-semibold">{row.workspaceName}</p>
<p className="mt-0.5 truncate font-mono text-xs text-muted-foreground">{row.workspaceId}</p>
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<Database className="size-4 text-muted-foreground" aria-hidden="true" />
<span className="rounded-md border border-border bg-muted px-2.5 py-1 text-xs font-semibold text-muted-foreground">
{statusLabel(row)}
</span>
{mode === "view" && row.configured ? (
<Button type="button" variant="outline" size="sm" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{activeSyncRun ? <RefreshCw className="animate-spin" /> : <History />} Sync history
</Button>
) : null}
{mode === "view" ? (
<DatabaseSyncMenu
disabled={!canManage || !bindingReady || busy || Boolean(activeSyncRun)}
disabledReason={!bindingReady ? "Test the current database binding before synchronizing the schema" : undefined}
onSelect={onSync}
/>
) : null}
</div>
</div>
{row.lastTestedAt ? (
<p className="mb-4 text-xs text-muted-foreground">Last tested {new Date(row.lastTestedAt).toLocaleString()}</p>
) : null}
{form}
</section>
);
}
return (
<section aria-label={`${title} form`} className="min-h-0 flex-1 overflow-y-auto bg-background">
<div className="mx-auto w-full max-w-[900px] px-4 py-5 sm:px-6 sm:py-6">
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
import { Menu } from "@base-ui/react/menu";
import { AgGridReact } from "ag-grid-react";
import {
@@ -9,7 +9,7 @@ import {
} from "ag-grid-community";
import "ag-grid-community/styles/ag-grid.css";
import "ag-grid-community/styles/ag-theme-alpine.css";
import { ChevronDown, Eye, Pencil, RefreshCw, Sparkles, Trash2, X } from "lucide-react";
import { ChevronDown, Eye, History, Info, Pencil, RefreshCw, Rows3, Sparkles, Trash2, X } from "lucide-react";
import { Button } from "../../components/ui/button";
import type {
CatalogDatabase,
@@ -19,6 +19,7 @@ import type {
} from "../../api/catalog-databases";
import { statusLabel } from "./model";
import { databaseSyncItemClass, databaseSyncScopes } from "./DatabaseSyncMenu";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
ModuleRegistry.registerModules([AllCommunityModule]);
@@ -31,6 +32,7 @@ interface DatabaseGridProps {
searchInputRef: RefObject<HTMLInputElement>;
onSearchChange: (value: string) => void;
onView: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenTables?: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onEdit: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onDelete: (row: CatalogDatabase, origin: HTMLButtonElement) => void;
onOpenSync: (row: CatalogDatabase) => void;
@@ -47,16 +49,33 @@ interface DatabaseGridProps {
rows: CatalogDatabase[],
target: CatalogDatabaseMetadataDeleteTarget,
) => Promise<void>;
metadataModelControl?: ReactNode;
onRefresh?: () => void | Promise<void>;
presentation?: "legacy" | "fleet";
}
interface DatabaseGridContext {
canManage: boolean;
presentation: "legacy" | "fleet";
onView: DatabaseGridProps["onView"];
onOpenTables?: DatabaseGridProps["onOpenTables"];
onEdit: DatabaseGridProps["onEdit"];
onDelete: DatabaseGridProps["onDelete"];
onOpenSync: DatabaseGridProps["onOpenSync"];
}
type DatabaseFleetAction =
| "test"
| "sync-tables"
| "sync-columns"
| "sync-relationships"
| "sync-all"
| "generate-all"
| "generate-missing"
| "suggest-sensitive"
| "clear-tables"
| "clear-relationships";
function useCompactViewport(): boolean {
const [compact, setCompact] = useState(false);
@@ -100,22 +119,38 @@ function DatabaseActionsCell({
return (
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
{data.activeSyncRun ? (
<Button type="button" variant="ghost" size="icon-lg" title={`View synchronization for ${data.workspaceName}`} aria-label={`View synchronization for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
<RefreshCw className="animate-spin" aria-hidden="true" />
{data.activeSyncRun || (context.presentation === "fleet" && data.configured && data.id) ? (
<Button type="button" variant="ghost" size="icon-lg" className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Synchronization history" : undefined} title={context.presentation === "legacy" ? `View synchronization for ${data.workspaceName}` : undefined} aria-label={`View synchronization history for ${data.workspaceName}`} onClick={() => context.onOpenSync(data)}>
{data.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />}
</Button>
) : null}
{context.presentation === "fleet" && context.onOpenTables ? (
<span className="thot-fleet-icon-action inline-flex" data-tooltip="Tables">
<Button
type="button"
variant="ghost"
size="icon-lg"
aria-label={`View tables for ${data.workspaceName}`}
disabled={!data.configured || !data.id}
onClick={(event) => context.onOpenTables?.(data, event.currentTarget)}
>
<Rows3 aria-hidden="true" />
</Button>
</span>
) : null}
<Button
type="button"
variant="ghost"
size="icon-lg"
title={`View ${data.workspaceName}`}
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Details" : undefined}
title={context.presentation === "legacy" ? `View ${data.workspaceName}` : undefined}
aria-label={`View ${data.workspaceName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Eye aria-hidden="true" />
{context.presentation === "fleet" ? <Info aria-hidden="true" /> : <Eye aria-hidden="true" />}
</Button>
<span title={editDisabled ? "This database configuration cannot be changed" : editLabel}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Edit configuration" : undefined} title={context.presentation === "legacy" ? (editDisabled ? "This database configuration cannot be changed" : editLabel) : undefined}>
<Button
type="button"
variant="ghost"
@@ -129,7 +164,7 @@ function DatabaseActionsCell({
</Button>
{editDisabled ? <span id={editReasonId} className="sr-only">This database configuration cannot be changed.</span> : null}
</span>
<span title={deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`}>
<span className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined} data-tooltip={context.presentation === "fleet" ? "Remove configuration" : undefined} title={context.presentation === "legacy" ? (deleteDisabled ? "Save this database configuration before deleting it" : `Delete ${data.workspaceName}`) : undefined}>
<Button
type="button"
variant="ghost"
@@ -157,6 +192,7 @@ export function DatabaseGrid({
searchInputRef,
onSearchChange,
onView,
onOpenTables,
onEdit,
onDelete,
onOpenSync,
@@ -167,17 +203,21 @@ export function DatabaseGrid({
onGenerateDescriptions,
onSuggestSensitive,
onDeleteMetadataSelected,
metadataModelControl,
onRefresh,
presentation = "legacy",
}: DatabaseGridProps) {
const compact = useCompactViewport();
const gridRef = useRef<AgGridReact<CatalogDatabase>>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const actionSelectRef = useRef<HTMLSelectElement>(null);
const [selectedRows, setSelectedRows] = useState<CatalogDatabase[]>([]);
const [action, setAction] = useState<"test" | "sync" | "generate" | "suggest" | "delete" | null>(null);
const [pendingDelete, setPendingDelete] = useState<CatalogDatabaseMetadataDeleteTarget | null>(null);
const [pendingGenerateAll, setPendingGenerateAll] = useState(false);
const context = useMemo<DatabaseGridContext>(
() => ({ canManage, onView, onEdit, onDelete, onOpenSync }),
[canManage, onView, onEdit, onDelete, onOpenSync],
() => ({ canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync }),
[canManage, presentation, onView, onOpenTables, onEdit, onDelete, onOpenSync],
);
const columnDefs = useMemo<ColDef<CatalogDatabase>[]>(() => {
@@ -230,7 +270,7 @@ export function DatabaseGrid({
flex: 0.9,
valueFormatter: ({ value }) => value ? new Date(String(value)).toLocaleString() : "",
};
const actionsWidth = compact ? 148 : 164;
const actionsWidth = presentation === "fleet" ? 196 : compact ? 148 : 164;
const actions: ColDef<CatalogDatabase> = {
colId: "actions",
headerName: "Actions",
@@ -251,7 +291,7 @@ export function DatabaseGrid({
return compact
? [workspace, status, database, schema, transport, endpoint, updated, actions]
: [workspace, database, schema, transport, endpoint, status, updated, actions];
}, [compact]);
}, [compact, presentation]);
const configuredCount = rows.filter((row) => row.configured).length;
const hiddenSelected = selectedRows.filter((row) => {
@@ -265,8 +305,71 @@ export function DatabaseGrid({
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canSuggestSensitive = canManage
&& selectedRows.length === 1
&& Boolean(selectedMetadataModel)
&& !descriptionGenerationActive
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const canDeleteMetadataSelection = canManage && selectedRows.length > 0
&& selectedRows.every((row) => row.configured && row.id && !row.activeSyncRun);
const fleetActions = useMemo<readonly FleetActionOption<DatabaseFleetAction>[]>(() => {
const permissionReason = !canManage ? "Database management permission is required" : undefined;
const testReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
: undefined);
const syncReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id)
? "Every selected workspace must be configured"
: selectedRows.some((row) => row.connectionStatus !== "reachable" || row.testedVersion !== row.version)
? "Test every current binding before synchronization"
: selectedRows.some((row) => row.activeSyncRun)
? "Wait for active synchronization"
: undefined);
const generationReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the generation flow" : undefined)
?? (descriptionGenerationActive ? "Wait for active description generation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const sensitiveReason = permissionReason
?? (selectedRows.length !== 1 ? "Select exactly one database" : undefined)
?? (!selectedMetadataModel ? "Choose a metadata model in the suggestion flow" : undefined)
?? (descriptionGenerationActive ? "Wait for the active metadata operation" : undefined)
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "The selected database must be configured and idle"
: undefined);
const cleanupReason = permissionReason
?? (selectedRows.some((row) => !row.configured || !row.id || row.activeSyncRun)
? "Every selected database must be configured and idle"
: undefined);
return [
{ id: "test", label: "Test connections", group: "Connection", disabled: !canTestSelection, disabledReason: testReason },
{ id: "sync-tables", label: "Synchronize tables", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-columns", label: "Synchronize all columns", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-relationships", label: "Synchronize relationships", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "sync-all", label: "Synchronize all", group: "Synchronization", disabled: !canSyncSelection, disabledReason: syncReason },
{ id: "generate-missing", label: "Generate missing descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason },
{ id: "generate-all", label: "Generate all descriptions", group: "Descriptions", disabled: !canGenerateDescriptions, disabledReason: generationReason, runLabel: "Review generation" },
{ id: "suggest-sensitive", label: "Suggest sensitive fields", group: "Sensitive data", disabled: !canSuggestSensitive, disabledReason: sensitiveReason, runLabel: "Open review" },
{ id: "clear-tables", label: "Clear catalog tables", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
{ id: "clear-relationships", label: "Clear catalog relationships", group: "Cleanup", disabled: !canDeleteMetadataSelection, disabledReason: cleanupReason, tone: "destructive", runLabel: "Review cleanup" },
];
}, [
canDeleteMetadataSelection,
canGenerateDescriptions,
canManage,
canSuggestSensitive,
canSyncSelection,
canTestSelection,
descriptionGenerationActive,
selectedMetadataModel,
selectedRows,
]);
useEffect(() => {
if (!pendingDelete) return;
const timer = window.setTimeout(() => document.getElementById("database-cleanup-confirm-button")?.focus(), 0);
@@ -279,11 +382,17 @@ export function DatabaseGrid({
}, [pendingGenerateAll]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") actionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const closeGenerateAllConfirmation = () => {
setPendingGenerateAll(false);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") actionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const perform = async (
kind: "test" | "sync" | "generate" | "suggest" | "delete",
@@ -305,9 +414,43 @@ export function DatabaseGrid({
} finally { setAction(null); }
};
const runFleetAction = async (selectedAction: DatabaseFleetAction) => {
if (selectedAction === "generate-all") {
setPendingGenerateAll(true);
return;
}
if (selectedAction === "clear-tables" || selectedAction === "clear-relationships") {
setPendingDelete(selectedAction === "clear-tables" ? "tables" : "relationships");
return;
}
if (selectedAction === "test") {
await perform("test", () => onTestSelected(selectedRows));
return;
}
if (selectedAction.startsWith("sync-")) {
const scope = selectedAction.slice("sync-".length) as CatalogSyncScope;
await perform("sync", () => onSyncSelected(selectedRows, scope));
return;
}
if (selectedAction === "generate-missing") {
await perform("generate", () => onGenerateDescriptions(selectedRows, "missing"));
return;
}
await perform("suggest", () => onSuggestSensitive(selectedRows), false);
};
return (
<section aria-label="Workspace databases" className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<section
aria-label="Workspace databases"
data-presentation={presentation}
className={presentation === "fleet"
? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card"
: "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}
>
<div className={presentation === "fleet"
? "thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"
: "flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2"}
>
{selectedRows.length > 0 ? (
pendingGenerateAll ? (
<div
@@ -352,8 +495,8 @@ export function DatabaseGrid({
<div className="mr-auto min-w-56">
<p id="database-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "tables"
? `Delete all catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
? `${presentation === "fleet" ? "Clear" : "Delete all"} catalog tables for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`
: `${presentation === "fleet" ? "Clear catalog" : "Delete all"} relationships for ${selectedRows.length} database${selectedRows.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "tables"
@@ -369,9 +512,33 @@ export function DatabaseGrid({
disabled={action !== null}
onClick={() => void perform("delete", () => onDeleteMetadataSelected(selectedRows, pendingDelete))}
>
<Trash2 />{pendingDelete === "tables" ? "Delete catalog tables" : "Delete relationships"}
<Trash2 />{pendingDelete === "tables"
? presentation === "fleet" ? "Clear catalog tables" : "Delete catalog tables"
: presentation === "fleet" ? "Clear catalog relationships" : "Delete relationships"}
</Button>
</div>
) : presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedRows.length}
hiddenSelectedCount={hiddenSelected}
busy={action !== null}
busyLabel={action === "suggest" ? "Suggesting…" : "Running…"}
selectRef={actionSelectRef}
onRun={runFleetAction}
renderContext={(selectedAction) => (
metadataModelControl
&& ["generate-all", "generate-missing", "suggest-sensitive"].includes(selectedAction)
? <div className="thot-fleet-metadata-model">{metadataModelControl}</div>
: null
)}
onClear={() => {
gridRef.current?.api.deselectAll();
setSelectedRows([]);
}}
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedRows.length} selected</span>
@@ -449,7 +616,12 @@ export function DatabaseGrid({
placeholder="Search"
value={search}
onChange={(event) => onSearchChange(event.target.value)}
/></>}
/>
{presentation === "fleet" && onRefresh ? (
<Button type="button" variant="outline" size="sm" disabled={isFetching} onClick={() => void onRefresh()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Refresh
</Button>
) : null}</>}
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{configuredCount}/{rows.length}
</span>
@@ -458,7 +630,7 @@ export function DatabaseGrid({
) : null}
</div>
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<div className={`thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full${presentation === "fleet" ? " thot-fleet-ledger-grid" : ""}`}>
<AgGridReact<CatalogDatabase>
ref={gridRef}
rowData={rows}
@@ -1,8 +1,8 @@
import { useMemo, useState } from "react";
import { useMemo, useRef, useState, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef } from "ag-grid-community";
import { ArrowLeft, RefreshCw } from "lucide-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, History, Info, Play, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
@@ -13,6 +13,7 @@ import {
type CatalogRelationship,
type CatalogSyncRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
database: CatalogDatabase;
@@ -22,6 +23,32 @@ interface Props {
onOpenTables: () => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface RelationshipGridContext {
presentation: "legacy" | "fleet";
onView: (relationship: CatalogRelationship, origin: HTMLButtonElement) => void;
}
function RelationshipActionsCell({ data, context }: ICellRendererParams<CatalogRelationship, unknown, RelationshipGridContext>) {
if (!data || !context || context.presentation !== "fleet") return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className="thot-fleet-icon-action"
data-tooltip="Details"
aria-label={`View relationship ${data.constraintName}`}
onClick={(event) => context.onView(data, event.currentTarget)}
>
<Info aria-hidden="true" />
</Button>
</div>
);
}
export function DatabaseRelationships({
@@ -32,16 +59,32 @@ export function DatabaseRelationships({
onOpenTables,
onRunStarted,
onOpenSync,
presentation = "legacy",
}: Props) {
const databaseId = database.id!;
const { data = [], isLoading, isFetching, refetch } = useQuery({
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({
queryKey: ["catalog-relationships", databaseId],
queryFn: () => listCatalogRelationships(databaseId),
retry: false,
});
const [search, setSearch] = useState("");
const [busy, setBusy] = useState(false);
const [selectedAction, setSelectedAction] = useState<"" | "relationships" | "all">("");
const [activeRelationshipId, setActiveRelationshipId] = useState<string | null>(null);
const originRef = useRef<HTMLButtonElement | null>(null);
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const activeRelationship = activeRelationshipId
? data.find((relationship) => relationship.id === activeRelationshipId)
: undefined;
const selectedActionUnavailable = !selectedAction
? null
: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: database.activeSyncRun
? "Wait for the active synchronization to finish."
: null;
const synchronize = async (scope: "relationships" | "all") => {
setBusy(true);
@@ -52,6 +95,22 @@ export function DatabaseRelationships({
} catch (error) { toast.error(apiErrorMessage(error)); } finally { setBusy(false); }
};
const openRelationship = (relationship: CatalogRelationship, origin: HTMLButtonElement) => {
originRef.current = origin;
setActiveRelationshipId(relationship.id);
};
const closeRelationship = () => {
setActiveRelationshipId(null);
window.setTimeout(() => originRef.current?.focus(), 0);
};
const runSelectedAction = async () => {
if (!selectedAction) return;
await synchronize(selectedAction);
setSelectedAction("");
};
const columns = useMemo<ColDef<CatalogRelationship>[]>(() => [
{ field: "constraintName", headerName: "Constraint", minWidth: 220, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceTableName", headerName: "Source table", minWidth: 200, flex: 1 },
@@ -60,11 +119,24 @@ export function DatabaseRelationships({
{ headerName: "Target columns", minWidth: 200, flex: 1, valueGetter: ({ data: row }) => row?.columns.map((pair) => pair.targetColumnName).join(", ") ?? "" },
{ field: "updateRule", headerName: "On update", minWidth: 125, width: 125 },
{ field: "deleteRule", headerName: "On delete", minWidth: 125, width: 125 },
], []);
...(presentation === "fleet" ? [{ colId: "actions", headerName: "Actions", width: 64, minWidth: 64, maxWidth: 64, pinned: "right" as const, lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: RelationshipActionsCell }] : []),
], [presentation]);
const context = useMemo<RelationshipGridContext>(() => ({ presentation, onView: openRelationship }), [data, presentation]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog relationships could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry relationships
</Button>
</div>
</div>
) : null;
return (
<section aria-label={`Relationships for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="px-4 pt-5 sm:px-5">
<section aria-label={`Relationships for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{presentation === "legacy" ? <div className="px-4 pt-5 sm:px-5">
<Button type="button" variant="ghost" className="-ml-2 mb-4" disabled={busy} onClick={onBackToDatabases}><ArrowLeft />Back to databases</Button>
<div className="flex flex-wrap items-start justify-between gap-4 border-b border-border pb-5">
<div>
@@ -82,22 +154,76 @@ export function DatabaseRelationships({
<button type="button" role="tab" aria-selected="false" className="border-b-2 border-transparent px-3 py-2 text-sm font-semibold text-muted-foreground hover:text-foreground" onClick={onOpenTables}>Tables</button>
<button type="button" role="tab" aria-selected="true" className="border-b-2 border-primary px-3 py-2 text-sm font-semibold text-foreground">Relationships</button>
</nav>
</div>
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
</div> : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
<span className="thot-label whitespace-nowrap">Physical relationships</span>
<input className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search relationships" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span className="text-xs tabular-nums text-muted-foreground">{data.length}</span>
<Button type="button" variant="outline" disabled={isFetching || busy} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>
{presentation === "fleet" ? (
<div className="flex flex-wrap items-center gap-2">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{selectedAction === "all" ? "This database" : selectedAction === "relationships" ? "All relationships in this database" : "This database"}</strong>
<span>Action scope</span>
</div>
<label className="sr-only" htmlFor="relationship-action">Relationship action</label>
<select
id="relationship-action"
className="thot-fleet-action-selector__select"
value={selectedAction}
disabled={busy}
onChange={(event) => setSelectedAction(event.target.value as "" | "relationships" | "all")}
>
<option value="">Choose an action…</option>
<optgroup label="Synchronization">
<option value="relationships">Synchronize all relationships in this database</option>
<option value="all">Synchronize the full schema for this database</option>
</optgroup>
</select>
<Button type="button" size="sm" disabled={!selectedAction || Boolean(selectedActionUnavailable) || busy} aria-describedby={selectedActionUnavailable ? "relationship-action-reason" : undefined} onClick={() => void runSelectedAction()}><Play aria-hidden="true" />{busy ? "Running…" : "Run action"}</Button>
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{database.activeSyncRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
{selectedActionUnavailable ? <span id="relationship-action-reason" className="basis-full text-xs text-muted-foreground" role="status">{selectedActionUnavailable}</span> : null}
</div>
) : <Button type="button" disabled={!canManage || !bindingReady || busy || Boolean(database.activeSyncRun)} title={!bindingReady ? "Test the current database binding before synchronizing relationships" : undefined} onClick={() => void synchronize("relationships")}><RefreshCw />Sync relationships</Button>}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing relationships.</div> : null}
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogRelationship> rowData={data} columnDefs={columns} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No physical relationships synchronized for this database." />
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">{presentation === "fleet" ? "Test the current database binding in database details before synchronizing relationships." : "Test the current database binding from Overview before synchronizing relationships."}</div> : null}
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogRelationship> rowData={data} columnDefs={columns} context={context} loading={isLoading} quickFilterText={search} defaultColDef={{ sortable: true, filter: true, resizable: true }} getRowId={({ data: row }) => row.id} rowHeight={44} headerHeight={38} animateRows={false} overlayNoRowsTemplate="No physical relationships synchronized for this database." />
</div>
</div>
</div>}
</div>
{presentation === "fleet" && activeRelationship ? (
<FleetLedgerDrawer
open
eyebrow="Physical relationship"
title="Relationship details"
description="This information is synchronized from the source schema and is read-only."
onClose={closeRelationship}
closeLabel="Close relationship details"
footer={<Button type="button" variant="outline" onClick={closeRelationship}>Close</Button>}
>
<dl className="grid gap-4 text-sm sm:grid-cols-2">
<div className="sm:col-span-2"><dt className="thot-label">Constraint</dt><dd className="mt-1 break-words font-mono text-xs">{activeRelationship.constraintName}</dd></div>
<div><dt className="thot-label">Source table</dt><dd className="mt-1 break-words">{activeRelationship.sourceTableName}</dd></div>
<div><dt className="thot-label">Target table</dt><dd className="mt-1 break-words">{activeRelationship.targetTableName}</dd></div>
<div><dt className="thot-label">On update</dt><dd className="mt-1">{activeRelationship.updateRule ?? "Not specified"}</dd></div>
<div><dt className="thot-label">On delete</dt><dd className="mt-1">{activeRelationship.deleteRule ?? "Not specified"}</dd></div>
<div className="sm:col-span-2">
<dt className="thot-label">Column mapping</dt>
<dd className="mt-2 overflow-hidden rounded-md border border-border">
<table className="w-full text-left text-xs">
<thead className="bg-muted/60 font-mono uppercase tracking-wide text-muted-foreground"><tr><th className="px-3 py-2">Source</th><th className="px-3 py-2">Target</th></tr></thead>
<tbody>{activeRelationship.columns.map((pair) => <tr key={`${pair.sourceColumnName}:${pair.targetColumnName}`} className="border-t border-border"><td className="px-3 py-2 font-mono">{pair.sourceColumnName}</td><td className="px-3 py-2 font-mono">{pair.targetColumnName}</td></tr>)}</tbody>
</table>
</dd>
</div>
</dl>
</FleetLedgerDrawer>
) : null}
</section>
);
}
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { Menu } from "@base-ui/react/menu";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AgGridReact } from "ag-grid-react";
import type { ColDef, ICellRendererParams } from "ag-grid-community";
import { ArrowLeft, ChevronDown, Columns3, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
import { ArrowLeft, ChevronDown, Columns3, History, Pencil, RefreshCw, Save, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
@@ -23,6 +23,8 @@ import {
} from "../../api/catalog-databases";
import type { DatabaseNavigationState } from "./model";
import { DatabaseColumns } from "./DatabaseColumns";
import { FleetActionSelector, type FleetActionOption } from "./FleetActionSelector";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
database: CatalogDatabase;
@@ -34,27 +36,46 @@ interface Props {
onOpenOverview: () => void;
onOpenRelationships: () => void;
onNavigationStateChange: (state: DatabaseNavigationState) => void;
onNestedNavigationChange?: (active: boolean) => void;
onRunStarted: (run: CatalogSyncRun) => void;
onOpenSync: () => void;
onDescriptionGenerationRunStarted: (run: DescriptionGenerationRun) => void;
onSuggestSensitive: (selection: SensitiveDataSuggestionRequest, scopeLabel: string) => Promise<void>;
onCatalogMetricsChanged?: () => void | Promise<void>;
metadataModelControl?: ReactNode;
presentation?: "legacy" | "fleet";
}
interface TableGridContext {
canManage: boolean;
presentation: "legacy" | "fleet";
onOpen: (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => void;
}
function TableActionsCell({ data, context }: ICellRendererParams<CatalogTable, unknown, TableGridContext>) {
if (!data || !context) return null;
return (
<div className="flex h-full items-center justify-end" onClick={(event) => event.stopPropagation()}>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`View columns for ${data.name}`} title={`View columns for ${data.name}`} onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}>
<div className="flex h-full items-center justify-end gap-0.5" onClick={(event) => event.stopPropagation()}>
<Button
type="button"
variant="ghost"
size="icon-lg"
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Columns" : undefined}
aria-label={`View columns for ${data.name}`}
title={context.presentation === "legacy" ? `View columns for ${data.name}` : undefined}
onClick={(event) => context.onOpen(data, "columns", event.currentTarget)}
>
<Columns3 aria-hidden="true" />
</Button>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={`Edit metadata for ${data.name}`} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<span
className={context.presentation === "fleet" ? "thot-fleet-icon-action" : undefined}
data-tooltip={context.presentation === "fleet" ? "Edit metadata" : undefined}
>
<Button type="button" variant="ghost" size="icon-lg" aria-label={`Edit description for ${data.name}`} title={context.presentation === "legacy" ? `Edit metadata for ${data.name}` : undefined} disabled={!context.canManage} onClick={(event) => context.onOpen(data, "overview", event.currentTarget)}>
<Pencil aria-hidden="true" />
</Button>
</Button>
</span>
</div>
);
}
@@ -69,15 +90,19 @@ export function DatabaseTables({
onOpenOverview,
onOpenRelationships,
onNavigationStateChange,
onNestedNavigationChange,
onRunStarted,
onOpenSync,
onDescriptionGenerationRunStarted,
onSuggestSensitive,
onCatalogMetricsChanged,
metadataModelControl,
presentation = "legacy",
}: Props) {
const databaseId = database.id!;
const queryClient = useQueryClient();
const queryKey = ["catalog-tables", databaseId] as const;
const { data = [], isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
const { data = [], error, isError, isLoading, isFetching, refetch } = useQuery({ queryKey, queryFn: () => listCatalogTables(databaseId), retry: false });
const [search, setSearch] = useState("");
const [displayedCount, setDisplayedCount] = useState(0);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
@@ -96,16 +121,20 @@ export function DatabaseTables({
const originRef = useRef<HTMLButtonElement | null>(null);
const headingRef = useRef<HTMLHeadingElement>(null);
const actionsTriggerRef = useRef<HTMLButtonElement>(null);
const fleetActionSelectRef = useRef<HTMLSelectElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
const activeTable = activeTableId ? data.find((table) => table.id === activeTableId) : undefined;
const editorFingerprint = JSON.stringify([description, generatedDescription]);
const editorDirty = Boolean(activeTableId && editorFingerprint !== baseline);
const dirty = tableSection === "columns" ? columnNavigation.dirty : editorDirty;
const navigationBusy = busy !== null || (tableSection === "columns" && columnNavigation.busy);
const nestedNavigationActive = presentation === "fleet" && Boolean(activeTable) && tableSection === "columns";
const bindingReady = database.connectionStatus === "reachable" && database.testedVersion === database.version;
const currentRun = activeRun ?? database.activeSyncRun;
useEffect(() => { onNavigationStateChange({ dirty, busy: navigationBusy }); }, [dirty, navigationBusy, onNavigationStateChange]);
useEffect(() => { onNestedNavigationChange?.(nestedNavigationActive); }, [nestedNavigationActive, onNestedNavigationChange]);
useEffect(() => () => { onNestedNavigationChange?.(false); }, [onNestedNavigationChange]);
useEffect(() => {
if (!activeTableId || !activeTable || editorVersion === activeTable.version || busy) return;
setStale(true);
@@ -118,7 +147,10 @@ export function DatabaseTables({
}, [pendingDelete]);
const closeDeleteConfirmation = () => {
setPendingDelete(null);
window.setTimeout(() => actionsTriggerRef.current?.focus(), 0);
window.setTimeout(() => {
if (presentation === "fleet") fleetActionSelectRef.current?.focus();
else actionsTriggerRef.current?.focus();
}, 0);
};
const openTable = (table: CatalogTable, section: "overview" | "columns", origin: HTMLButtonElement) => {
@@ -161,6 +193,7 @@ export function DatabaseTables({
setBaseline(JSON.stringify([updated.description ?? "", updated.generatedDescription ?? ""]));
setEditorVersion(updated.version);
setStale(false);
void onCatalogMetricsChanged?.();
toast.success("Table metadata saved");
} catch (error) {
if (error instanceof ApiError && error.code === "table_stale") {
@@ -208,9 +241,10 @@ export function DatabaseTables({
setSelectedIds([]);
setPendingDelete(null);
window.setTimeout(() => searchInputRef.current?.focus(), 0);
void onCatalogMetricsChanged?.();
toast.success(target === "columns"
? `Deleted ${counts.columns} catalog columns`
: `Deleted ${counts.relationships} catalog relationships`);
? `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.columns} catalog columns`
: `${presentation === "fleet" ? "Cleared" : "Deleted"} ${counts.relationships} catalog relationships`);
} catch (error) {
toast.error(apiErrorMessage(error));
} finally { setBusy(null); }
@@ -222,6 +256,7 @@ export function DatabaseTables({
await queryClient.invalidateQueries({ queryKey, exact: true });
gridRef.current?.api.deselectAll();
setSelectedIds([]);
void onCatalogMetricsChanged?.();
toast.success(`Copied ${counts.copied} description${counts.copied === 1 ? "" : "s"}; skipped ${counts.skipped}`);
} catch (error) {
toast.error(apiErrorMessage(error));
@@ -258,14 +293,152 @@ export function DatabaseTables({
}
};
type FleetTableAction =
| "generate-descriptions"
| "sync-tables"
| "sync-columns"
| "consolidate-descriptions"
| "suggest-sensitive"
| "clear-columns"
| "clear-relationships";
const fleetActions: readonly FleetActionOption<FleetTableAction>[] = [
{
id: "generate-descriptions",
label: "Generate descriptions",
group: "Descriptions",
runLabel: "Generate",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to generate descriptions."
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
? "Another action is running."
: undefined,
},
{
id: "consolidate-descriptions",
label: "Move generated to Description",
group: "Descriptions",
runLabel: "Move",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to update metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-tables",
label: "Synchronize tables",
group: "Synchronization",
scopeLabel: "All tables in this database",
runLabel: "Synchronize",
disabled: !canManage || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "sync-columns",
label: "Synchronize columns",
group: "Synchronization",
runLabel: "Synchronize",
disabled: !canManage || selectedIds.length === 0 || !bindingReady || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to synchronize the catalog."
: selectedIds.length === 0
? "Select at least one table."
: !bindingReady
? "Test the current database binding first."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "suggest-sensitive",
label: "Suggest sensitive fields",
group: "Sensitive data",
runLabel: "Suggest",
disabled: !canManage || selectedIds.length === 0 || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to review sensitive data."
: selectedIds.length === 0
? "Select at least one table."
: !selectedMetadataModel
? "Choose a metadata model first."
: descriptionGenerationActive || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: busy !== null
? "Another action is running."
: undefined,
},
{
id: "clear-columns",
label: "Clear catalog columns",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
{
id: "clear-relationships",
label: "Clear catalog relationships",
group: "Catalog cleanup",
tone: "destructive",
runLabel: "Review cleanup",
disabled: !canManage || selectedIds.length === 0 || busy !== null || Boolean(currentRun),
disabledReason: !canManage
? "You do not have permission to clear catalog metadata."
: selectedIds.length === 0
? "Select at least one table."
: busy !== null || Boolean(currentRun)
? "Wait for the active catalog operation to finish."
: undefined,
},
];
const runFleetAction = async (action: FleetTableAction) => {
if (action === "generate-descriptions") await generateDescriptions();
else if (action === "sync-tables") await synchronize("tables");
else if (action === "consolidate-descriptions") await consolidateDescriptions();
else if (action === "sync-columns") await synchronize("columns", selectedIds);
else if (action === "suggest-sensitive") await suggestSensitive();
else if (action === "clear-columns") setPendingDelete("columns");
else setPendingDelete("relationships");
};
const clearSelection = () => {
gridRef.current?.api.deselectAll();
setSelectedIds([]);
};
const columns = useMemo<ColDef<CatalogTable>[]>(() => [
{ field: "name", headerName: "Name", minWidth: 250, flex: 1, cellClass: "font-mono text-xs" },
{ field: "sourceComment", headerName: "Source comment", minWidth: 260, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "generatedDescription", headerName: "Generated description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ field: "description", headerName: "Description", minWidth: 280, flex: 1.25, valueFormatter: ({ value }) => value ?? "" },
{ colId: "actions", headerName: "", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", sortable: false, filter: false, resizable: false, cellRenderer: TableActionsCell },
{ colId: "actions", headerName: "Actions", width: 104, minWidth: 104, maxWidth: 104, pinned: "right", lockPinned: true, sortable: false, filter: false, resizable: false, suppressMovable: true, suppressHeaderMenuButton: true, cellRenderer: TableActionsCell },
], []);
const context = useMemo<TableGridContext>(() => ({ canManage, onOpen: openTable }), [canManage, data]);
const context = useMemo<TableGridContext>(() => ({ canManage, presentation, onOpen: openTable }), [canManage, data, presentation]);
const visibleSelected = useMemo(() => {
const term = search.trim().toLocaleLowerCase();
if (!term) return selectedIds.length;
@@ -273,6 +446,17 @@ export function DatabaseTables({
return data.filter((table) => selected.has(table.id) && [table.name, table.sourceComment, table.generatedDescription, table.description]
.some((value) => value?.toLocaleLowerCase().includes(term))).length;
}, [data, search, selectedIds]);
const fleetQueryError = presentation === "fleet" && isError ? (
<div className="grid min-h-[280px] flex-1 place-items-center p-6" role="alert">
<div className="max-w-lg rounded-lg border border-destructive/30 bg-destructive/8 p-5 text-sm">
<p className="font-semibold text-destructive">Catalog tables could not be loaded.</p>
<p className="mt-1 leading-5 text-muted-foreground">{apiErrorMessage(error)}</p>
<Button type="button" variant="outline" className="mt-4" disabled={isFetching} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} aria-hidden="true" /> Retry tables
</Button>
</div>
</div>
) : null;
const databaseHeader = (
<div className="px-4 pt-5 sm:px-5">
@@ -296,6 +480,126 @@ export function DatabaseTables({
</div>
);
if (activeTable && presentation === "fleet") {
if (tableSection === "columns") {
return (
<section aria-label={`Columns for ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border bg-muted/25 px-3 py-2">
<Button
type="button"
variant="outline"
className="thot-fleet-icon-action border-primary/35 bg-primary/8 shadow-sm"
disabled={navigationBusy}
data-tooltip="Tables"
aria-label="Back to tables"
onClick={leaveTable}
>
<ArrowLeft aria-hidden="true" /> Back to tables
</Button>
<div className="min-w-0">
<p className="thot-label">Physical table</p>
<h3 className="truncate font-heading text-lg font-semibold">{activeTable.name}</h3>
</div>
</div>
<DatabaseColumns
databaseId={databaseId}
table={activeTable}
canManage={canManage}
selectedMetadataModel={selectedMetadataModel}
descriptionGenerationActive={descriptionGenerationActive}
onDescriptionGenerationRunStarted={onDescriptionGenerationRunStarted}
onNavigationStateChange={setColumnNavigation}
onSync={() => void synchronize("columns", [activeTable.id])}
onSuggestSensitive={onSuggestSensitive}
bindingReady={bindingReady}
catalogOperationActive={Boolean(currentRun)}
onCatalogMetricsChanged={onCatalogMetricsChanged}
metadataModelControl={metadataModelControl}
presentation="fleet"
/>
</section>
);
}
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="relative flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
<div className="thot-fleet-grid-toolbar flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input
ref={searchInputRef}
className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
aria-label="Search tables"
placeholder="Search"
value={search}
onChange={(event) => setSearch(event.target.value)}
/>
<span className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}>
<RefreshCw className={isFetching ? "animate-spin" : ""} /> Refresh
</Button>
</div>
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid thot-fleet-ledger-grid ag-theme-alpine absolute inset-0 h-full w-full">
<AgGridReact<CatalogTable>
ref={gridRef}
rowData={data}
columnDefs={columns}
context={context}
loading={isLoading}
quickFilterText={search}
defaultColDef={{ sortable: true, filter: true, resizable: true }}
getRowId={({ data: table }) => table.id}
rowSelection={{ mode: "multiRow", selectAll: "filtered", enableClickSelection: false }}
onSelectionChanged={({ api }) => setSelectedIds(api.getSelectedRows().map((table) => table.id))}
onModelUpdated={({ api }) => setDisplayedCount(api.getDisplayedRowCount())}
rowHeight={44}
headerHeight={38}
animateRows={false}
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
/>
</div>
</div>}
<FleetLedgerDrawer
open
eyebrow="Catalog metadata"
title="Review table description"
description="Physical schema fields are read-only. Review fields can be edited."
onClose={leaveTable}
closeLabel="Close table metadata"
busy={busy !== null}
footer={(
<>
<Button type="button" variant="outline" disabled={busy !== null} onClick={leaveTable}>Cancel</Button>
<Button type="button" disabled={!canManage || !editorDirty || busy !== null || stale} onClick={() => void save()}>
<Save aria-hidden="true" />{busy === "save" ? "Saving…" : "Save metadata"}
</Button>
</>
)}
>
<p className="thot-label">Physical table</p>
<h3 className="mt-1 break-words font-heading text-xl font-semibold">{activeTable.name}</h3>
{stale ? (
staleBannerOpen ? (
<div className="mt-4 rounded-md border border-amber-500/50 bg-amber-500/8 p-4 text-sm">
<p className="font-semibold">A newer table description is available.</p>
<p className="mt-1 text-muted-foreground">Your draft is preserved until you reload the current metadata.</p>
<div className="mt-3 flex gap-2"><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button><Button type="button" variant="ghost" onClick={() => setStaleBannerOpen(false)}>Keep editing</Button></div>
</div>
) : <div className="mt-4 flex items-center justify-between gap-3 rounded-md border border-amber-500/40 bg-amber-500/8 px-4 py-3 text-sm"><span>Reload the latest value before this description can be saved.</span><Button type="button" variant="outline" onClick={() => void reloadTable()}>Reload latest</Button></div>
) : null}
<div className="mt-5 grid gap-4">
<label className="grid gap-1.5 text-sm font-semibold">Physical table name<input className="h-9 rounded-md border border-input bg-muted/35 px-3 font-mono text-xs" value={activeTable.name} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Source comment<textarea className="min-h-24 rounded-md border border-input bg-muted/35 px-3 py-2 text-sm" value={activeTable.sourceComment ?? ""} readOnly /></label>
<label className="grid gap-1.5 text-sm font-semibold">Generated description<textarea className="min-h-32 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={generatedDescription} disabled={!canManage || busy !== null} onChange={(event) => setGeneratedDescription(event.target.value)} /></label>
<label className="grid gap-1.5 text-sm font-semibold">Description<textarea className="min-h-36 rounded-md border border-input bg-card px-3 py-2 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" value={description} disabled={!canManage || busy !== null} onChange={(event) => setDescription(event.target.value)} /><span className="font-normal text-muted-foreground">Leave blank to keep the curated description empty.</span></label>
</div>
</FleetLedgerDrawer>
</section>
);
}
if (activeTable) {
return (
<section aria-label={`Table ${activeTable.name}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
@@ -321,6 +625,7 @@ export function DatabaseTables({
onNavigationStateChange={setColumnNavigation}
onSync={() => void synchronize("columns", [activeTable.id])}
onSuggestSensitive={onSuggestSensitive}
onCatalogMetricsChanged={onCatalogMetricsChanged}
/>
) : (
<div className="min-h-0 overflow-y-auto px-4 py-5">
@@ -353,9 +658,9 @@ export function DatabaseTables({
return (
<section aria-label={`Tables for ${database.workspaceName}`} className="flex min-h-0 flex-1 flex-col overflow-hidden bg-background">
{databaseHeader}
<div className="mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5">
<div className="flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2">
{presentation === "legacy" ? databaseHeader : null}
<div className={presentation === "fleet" ? "thot-fleet-grid-surface flex min-h-0 flex-1 flex-col overflow-hidden bg-card" : "mx-3 mb-4 mt-4 flex min-h-0 flex-1 flex-col overflow-hidden rounded-md border border-border bg-card sm:mx-5"}>
<div className={`${presentation === "fleet" ? "thot-fleet-grid-toolbar " : ""}flex min-h-12 flex-wrap items-center gap-3 border-b border-border px-3 py-2`}>
{selectedIds.length > 0 ? (
pendingDelete ? (
<div
@@ -368,14 +673,20 @@ export function DatabaseTables({
>
<div className="mr-auto min-w-56">
<p id="table-cleanup-confirmation" className="text-sm font-semibold">
{pendingDelete === "columns"
{presentation === "fleet"
? `Clear catalog ${pendingDelete} for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: pendingDelete === "columns"
? `Delete all catalog columns for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`
: `Delete all relationships for ${selectedIds.length} table${selectedIds.length === 1 ? "" : "s"}?`}
</p>
<p className="text-xs text-muted-foreground">
{pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
{presentation === "fleet"
? pendingDelete === "columns"
? "Only synchronized catalog columns for these tables will be cleared. The source database is not modified."
: "Only synchronized catalog relationships for these tables will be cleared. The source database is not modified."
: pendingDelete === "columns"
? "Only columns belonging to the selected catalog tables will be removed."
: "All incoming and outgoing relationships for the selected catalog tables will be removed."}
</p>
</div>
<Button type="button" variant="ghost" disabled={busy !== null} onClick={closeDeleteConfirmation}>Cancel</Button>
@@ -386,88 +697,86 @@ export function DatabaseTables({
disabled={busy !== null}
onClick={() => void deleteMetadata(pendingDelete)}
>
<Trash2 />{pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
<Trash2 />{presentation === "fleet" ? "Clear catalog" : pendingDelete === "columns" ? "Delete columns" : "Delete relationships"}
</Button>
</div>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)}
onClick={() => void generateDescriptions()}
>
Generate {selectedIds.length === 1 ? "description" : "descriptions"}
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)}
onClick={() => void synchronize("columns", selectedIds)}
>
Synchronize columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => void consolidateDescriptions()}
>
Move generated description to Description
</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("columns")}
>
Delete all columns
</Menu.Item>
<Menu.Item
className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45"
disabled={!canManage || busy !== null || Boolean(currentRun)}
onClick={() => setPendingDelete("relationships")}
>
Delete all relationships
</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button
type="button"
variant="outline"
disabled={!canManage || descriptionGenerationActive || busy !== null}
title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined}
onClick={() => void suggestSensitive()}
>
<Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}
</Button>
<Button type="button" variant="ghost" onClick={() => { gridRef.current?.api.deselectAll(); setSelectedIds([]); }}><X />Clear</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={selectedIds.length}
hiddenSelectedCount={selectedIds.length - visibleSelected}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
</>
) : (
<>
<span className="text-sm font-semibold">{selectedIds.length} selected</span>
{visibleSelected !== selectedIds.length ? <span className="text-xs text-muted-foreground">{selectedIds.length - visibleSelected} hidden by filter</span> : null}
<Menu.Root>
<Menu.Trigger ref={actionsTriggerRef} className="inline-flex h-8 items-center justify-center gap-2 rounded-md border border-input bg-background px-3 text-sm font-medium hover:bg-muted disabled:pointer-events-none disabled:opacity-50" disabled={busy !== null}>Actions <ChevronDown className="size-4" /></Menu.Trigger>
<Menu.Portal>
<Menu.Positioner side="bottom" align="start" sideOffset={4}>
<Menu.Popup className="z-50 min-w-64 rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-none">
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !selectedMetadataModel || descriptionGenerationActive || busy !== null || Boolean(currentRun)} onClick={() => void generateDescriptions()}>Generate {selectedIds.length === 1 ? "description" : "descriptions"}</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} onClick={() => void synchronize("columns", selectedIds)}>Synchronize columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm outline-none data-[highlighted]:bg-muted data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => void consolidateDescriptions()}>Move generated description to Description</Menu.Item>
<Menu.Separator className="my-1 h-px bg-border" />
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("columns")}>Delete all columns</Menu.Item>
<Menu.Item className="rounded-md px-3 py-2 text-sm text-destructive outline-none data-[highlighted]:bg-destructive/10 data-[disabled]:opacity-45" disabled={!canManage || busy !== null || Boolean(currentRun)} onClick={() => setPendingDelete("relationships")}>Delete all relationships</Menu.Item>
</Menu.Popup>
</Menu.Positioner>
</Menu.Portal>
</Menu.Root>
<Button type="button" variant="outline" disabled={!canManage || descriptionGenerationActive || busy !== null} title={descriptionGenerationActive ? "Wait for the active description generation to finish" : undefined} onClick={() => void suggestSensitive()}><Sparkles />{busy === "suggest" ? "Suggesting…" : "Suggest sensitive fields"}</Button>
<Button type="button" variant="ghost" onClick={clearSelection}><X />Clear</Button>
</>
)
)
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span
aria-live="polite"
className="whitespace-nowrap text-xs tabular-nums text-muted-foreground"
>
{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}
</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
</>
presentation === "fleet" ? (
<>
<FleetActionSelector
actions={fleetActions}
selectedCount={0}
busy={busy !== null}
busyLabel={busy === "generate" ? "Generating…" : busy === "suggest" ? "Suggesting…" : "Running…"}
selectRef={fleetActionSelectRef}
onRun={runFleetAction}
onClear={clearSelection}
renderContext={(action) => action === "generate-descriptions" || action === "suggest-sensitive" ? metadataModelControl : null}
label="Table action"
/>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
</>
) : (
<>
<span className="thot-label whitespace-nowrap">Catalog tables</span>
<input ref={searchInputRef} className="h-8 min-w-40 flex-1 rounded-md border border-input bg-background px-2.5 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15" aria-label="Search tables" placeholder="Search" value={search} onChange={(event) => setSearch(event.target.value)} />
<span aria-live="polite" className="whitespace-nowrap text-xs tabular-nums text-muted-foreground">{search.trim() ? `${displayedCount} of ${data.length}` : displayedCount}</span>
<Button type="button" variant="outline" disabled={isFetching || busy !== null} onClick={() => void refetch()}><RefreshCw className={isFetching ? "animate-spin" : ""} />Refresh</Button>
<Button type="button" disabled={!canManage || !bindingReady || busy !== null || Boolean(currentRun)} title={!bindingReady ? "Test the current database binding before synchronizing tables" : undefined} onClick={() => void synchronize("tables")}><RefreshCw className={busy === "sync" ? "animate-spin" : ""} />Sync tables</Button>
</>
)
)}
{presentation === "fleet" ? (
<Button type="button" size="sm" variant="outline" className="thot-fleet-nav-action thot-fleet-nav-action--below" data-tooltip="Synchronization history" onClick={onOpenSync}>
{currentRun ? <RefreshCw className="animate-spin" aria-hidden="true" /> : <History aria-hidden="true" />} Sync history
</Button>
) : null}
</div>
{!bindingReady ? <div className="border-b border-border bg-muted/35 px-4 py-3 text-sm text-muted-foreground">Test the current database binding from Overview before synchronizing tables.</div> : null}
<div className="relative min-h-[280px] flex-1">
<div className="thot-database-grid ag-theme-alpine absolute inset-0 h-full w-full">
{fleetQueryError ?? <div className="relative min-h-[280px] flex-1">
<div className={`thot-database-grid ${presentation === "fleet" ? "thot-fleet-ledger-grid " : ""}ag-theme-alpine absolute inset-0 h-full w-full`}>
<AgGridReact<CatalogTable>
ref={gridRef}
rowData={data}
@@ -489,7 +798,7 @@ export function DatabaseTables({
overlayNoRowsTemplate="No catalog tables. Test the binding, then sync tables."
/>
</div>
</div>
</div>}
</div>
</section>
);
@@ -172,7 +172,7 @@ test("stops queued or running work without hiding earlier results or events", as
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByText("First description remains saved")).toBeVisible();
await user.click(within(drawer).getByRole("button", { name: "Stop" }));
@@ -243,7 +243,7 @@ test("reopens a terminal run from newest-first persisted history", async () => {
name: /Completed.*selected tables/i,
}));
const drawer = screen.getByRole("complementary", { name: "Description generation" });
const drawer = screen.getByRole("dialog", { name: "Description generation" });
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
});
@@ -269,7 +269,7 @@ test("offers Unlock only for an apparently stale active run and explains the int
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).getByText(
"Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.",
)).toBeVisible();
@@ -286,7 +286,7 @@ test("does not offer Unlock while an active run still appears current", async ()
);
renderDrawer();
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
});
@@ -307,7 +307,7 @@ test("explains when Unlock is refused because a live worker still exists", async
);
renderDrawer({ initialRun: staleRun });
const drawer = await screen.findByRole("complementary", { name: "Description generation" });
const drawer = await screen.findByRole("dialog", { name: "Description generation" });
await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" }));
expect(await screen.findByText(
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { LoaderCircle, LockOpen, Square, X } from "lucide-react";
import { LoaderCircle, LockOpen, Square } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { ApiError, apiErrorMessage } from "../../api/client";
@@ -14,6 +14,7 @@ import {
type DescriptionGenerationRun,
unlockDescriptionGenerationRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
@@ -100,6 +101,11 @@ export function DescriptionGenerationDrawer({
: false,
});
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
const mergeEvents = useCallback((incoming: DescriptionGenerationEvent[]) => {
if (incoming.length === 0) return;
setEvents((current) => {
@@ -256,28 +262,88 @@ export function DescriptionGenerationDrawer({
}
};
if (!open || !run) return null;
if (!open) return null;
if (!run) {
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title="Run history"
onClose={onClose}
closeLabel="Close description generation"
busy={action !== null}
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading run history…</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No description generation runs yet.</p>
<p className="mt-1 text-muted-foreground">
Start a description generation to create the first history entry.
</p>
</div>
)}
</FleetLedgerDrawer>
);
}
const apparentlyStale = isApparentlyStale(run);
const finalError = safeFinalError(run.errorSummary);
return (
<aside
aria-label="Description generation"
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[400px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<p className="thot-label">Description generation</p>
<h2 className="mt-1 font-heading text-xl font-semibold">{statusLabel(run)}</h2>
<p className="mt-1 text-sm text-muted-foreground">
{modelLabel}{modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · {run.scope.replaceAll("_", " ")}
</p>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close description generation" onClick={onClose}>
<X aria-hidden="true" />
const footer = ["queued", "running"].includes(run.status) ? (
<div className="w-full">
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
</Button>
) : null}
<Button
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
</Button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-5">
{runQuery.isError ? (
</div>
) : undefined;
return (
<FleetLedgerDrawer
open
ariaLabel="Description generation"
eyebrow="Description generation"
title={statusLabel(run)}
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
onClose={onClose}
closeLabel="Close description generation"
busy={action !== null}
bodyClassName="thot-catalog-drawer__history-layout"
footer={footer}
>
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
</div>
@@ -336,7 +402,7 @@ export function DescriptionGenerationDrawer({
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
</section>
<section className="mt-5" aria-label="Description generation history">
<section className="thot-catalog-drawer__history mt-5" aria-label="Description generation history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
@@ -371,43 +437,6 @@ export function DescriptionGenerationDrawer({
</div>
)}
</section>
</div>
{["queued", "running"].includes(run.status) ? (
<div className="border-t border-border px-5 py-4">
{apparentlyStale ? (
<p
id="description-generation-unlock-help"
className="mb-3 rounded-md border border-amber-500/35 bg-amber-500/8 px-3 py-2 text-xs leading-5 text-muted-foreground"
>
Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.
</p>
) : null}
<div className="flex flex-wrap justify-end gap-2">
{apparentlyStale ? (
<Button
type="button"
variant="outline"
aria-label="Unlock stale run"
aria-describedby="description-generation-unlock-help"
disabled={action !== null}
onClick={() => void unlock()}
>
{action === "unlock" ? <LoaderCircle className="animate-spin" /> : <LockOpen />}
{action === "unlock" ? "Unlocking…" : "Unlock"}
</Button>
) : null}
<Button
type="button"
variant="destructive"
disabled={action !== null}
onClick={() => void stop()}
>
{action === "stop" ? <LoaderCircle className="animate-spin" /> : <Square />}
{action === "stop" ? "Stopping…" : "Stop"}
</Button>
</div>
</div>
) : null}
</aside>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { vi } from "vitest";
import { FleetActionSelector } from "./FleetActionSelector";
const actions = [
{ id: "sync", label: "Synchronize tables", group: "Synchronization", scopeLabel: "All tables in this database" },
{
id: "generate",
label: "Generate descriptions",
group: "Descriptions",
disabled: true,
disabledReason: "Choose a metadata model first",
},
] as const;
test("keeps unavailable commands discoverable and explains why Run is disabled", async () => {
const user = userEvent.setup();
render(
<FleetActionSelector
actions={actions}
selectedCount={1}
onRun={vi.fn()}
onClear={vi.fn()}
renderContext={(action) => action === "generate" ? <span>Model selection</span> : null}
/>,
);
await user.selectOptions(screen.getByRole("combobox", { name: "Batch action" }), "generate");
expect(screen.getByText("Choose a metadata model first")).toBeVisible();
expect(screen.getByText("Model selection")).toBeVisible();
expect(screen.getByRole("button", { name: "Run action" })).toBeDisabled();
});
test("runs an enabled command explicitly and resets the picker", async () => {
const user = userEvent.setup();
const onRun = vi.fn().mockResolvedValue(undefined);
render(
<FleetActionSelector
actions={actions}
selectedCount={2}
onRun={onRun}
onClear={vi.fn()}
/>,
);
const picker = screen.getByRole("combobox", { name: "Batch action" });
await user.selectOptions(picker, "sync");
expect(screen.getByText("All tables in this database")).toBeVisible();
expect(screen.getByText("Action scope")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Run action" }));
expect(onRun).toHaveBeenCalledWith("sync");
expect(picker).toHaveValue("");
});
@@ -0,0 +1,144 @@
import {
useEffect,
useId,
useMemo,
useState,
type ReactNode,
type RefObject,
} from "react";
import { Play, X } from "lucide-react";
import { Button } from "../../components/ui/button";
export interface FleetActionOption<ActionId extends string = string> {
id: ActionId;
label: string;
group: string;
scopeLabel?: string;
disabled?: boolean;
disabledReason?: string;
tone?: "default" | "destructive";
runLabel?: string;
}
interface FleetActionSelectorProps<ActionId extends string> {
actions: readonly FleetActionOption<ActionId>[];
selectedCount: number;
hiddenSelectedCount?: number;
busy?: boolean;
busyLabel?: string;
selectRef?: RefObject<HTMLSelectElement>;
onRun: (action: ActionId) => void | Promise<void>;
onClear: () => void;
renderContext?: (action: ActionId | "") => ReactNode;
label?: string;
}
/**
* Selection-aware command picker shared by all Fleet Ledger grids. Eligibility
* remains data owned by the calling grid; this module owns only presentation,
* explicit execution and accessible unavailable reasons.
*/
export function FleetActionSelector<ActionId extends string>({
actions,
selectedCount,
hiddenSelectedCount = 0,
busy = false,
busyLabel = "Running…",
selectRef,
onRun,
onClear,
renderContext,
label = "Batch action",
}: FleetActionSelectorProps<ActionId>) {
const [selectedActionId, setSelectedActionId] = useState<ActionId | "">("");
const hintId = useId();
const groups = useMemo(() => {
const order: string[] = [];
const grouped = new Map<string, FleetActionOption<ActionId>[]>();
for (const action of actions) {
if (!grouped.has(action.group)) {
order.push(action.group);
grouped.set(action.group, []);
}
grouped.get(action.group)!.push(action);
}
return order.map((group) => ({ group, actions: grouped.get(group)! }));
}, [actions]);
const selectedAction = actions.find((action) => action.id === selectedActionId);
const unavailableReason = selectedAction?.disabled
? selectedAction.disabledReason ?? "This action is not available for the current selection."
: null;
const actionScope = selectedAction?.scopeLabel;
useEffect(() => {
if (selectedActionId && !actions.some((action) => action.id === selectedActionId)) {
setSelectedActionId("");
}
}, [actions, selectedActionId]);
const run = async () => {
if (!selectedAction || selectedAction.disabled || busy) return;
await onRun(selectedAction.id);
setSelectedActionId("");
};
return (
<div className="thot-fleet-action-selector">
<div className="thot-fleet-action-selector__scope" aria-live="polite">
<strong>{actionScope ?? `${selectedCount} selected`}</strong>
{actionScope ? (
<span>Action scope</span>
) : hiddenSelectedCount > 0 ? (
<span>{hiddenSelectedCount} hidden by filter</span>
) : (
<span>Selected rows</span>
)}
</div>
<label className="sr-only" htmlFor={hintId}>{label}</label>
<select
ref={selectRef}
id={hintId}
className="thot-fleet-action-selector__select"
aria-label={label}
value={selectedActionId}
disabled={busy}
onChange={(event) => setSelectedActionId(event.target.value as ActionId | "")}
>
<option value="">Choose an action…</option>
{groups.map(({ group, actions: groupActions }) => (
<optgroup key={group} label={group}>
{groupActions.map((action) => (
<option key={action.id} value={action.id}>
{action.label}
{action.disabled && action.disabledReason
? `, unavailable: ${action.disabledReason}`
: ""}
</option>
))}
</optgroup>
))}
</select>
{renderContext ? renderContext(selectedActionId) : null}
<Button
type="button"
size="sm"
variant={selectedAction?.tone === "destructive" ? "destructive" : "default"}
disabled={!selectedAction || selectedAction.disabled || busy}
aria-describedby={unavailableReason ? `${hintId}-reason` : undefined}
onClick={() => void run()}
>
<Play aria-hidden="true" />
{busy ? busyLabel : selectedAction?.runLabel ?? "Run action"}
</Button>
<Button type="button" size="sm" variant="ghost" disabled={busy} onClick={onClear}>
<X aria-hidden="true" /> Clear
</Button>
{unavailableReason ? (
<span id={`${hintId}-reason`} className="thot-fleet-action-selector__reason" role="status">
{unavailableReason}
</span>
) : null}
</div>
);
}
@@ -0,0 +1,907 @@
.thot-fleet-ledger {
position: relative;
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
height: 100%;
overflow: hidden;
color: oklch(var(--foreground));
background: oklch(var(--background));
font-family: var(--font-sans);
container-name: work-area fleet-surface;
container-type: inline-size;
}
.thot-fleet-ledger__workspace {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 0;
overflow: hidden;
background-color: oklch(var(--sidebar));
container-name: fleet-workspace;
container-type: inline-size;
}
.thot-fleet-ledger__header {
position: relative;
z-index: 5;
display: grid;
grid-template-areas:
"heading actions"
"status status";
grid-template-columns: minmax(0, 1fr) auto;
gap: 0.75rem 1.5rem;
align-items: center;
padding: 0.625rem 1.25rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.42);
}
.thot-fleet-ledger__heading-copy {
grid-area: heading;
min-width: 0;
}
.thot-fleet-ledger__drawer-heading {
min-width: 0;
}
.thot-fleet-ledger__section-label,
.thot-fleet-ledger__status-label,
.thot-fleet-ledger__kpi-label {
margin: 0;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 700;
letter-spacing: 0.08em;
line-height: 1.35;
text-transform: uppercase;
}
.thot-fleet-ledger__title,
.thot-fleet-ledger__drawer-title {
margin: 0.25rem 0 0;
color: oklch(var(--foreground));
font-family: var(--font-heading);
font-weight: 600;
letter-spacing: -0.025em;
}
.thot-fleet-ledger__title {
margin: 0;
font-family: var(--font-sans);
font-size: 1rem;
font-weight: 700;
letter-spacing: -0.0125em;
line-height: 1.25;
white-space: nowrap;
}
.thot-fleet-ledger__drawer-description {
max-width: 68ch;
margin: 0.45rem 0 0;
color: oklch(var(--muted-foreground));
font-size: 0.875rem;
line-height: 1.55;
}
.thot-fleet-ledger__header-actions {
grid-area: actions;
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
}
.thot-fleet-ledger__header-status {
grid-area: status;
min-width: 0;
}
.thot-fleet-ledger__header-status > .thot-fleet-ledger__status {
display: block;
padding: 0;
border: 0;
background: transparent;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__section-label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem 1.25rem;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-item {
min-width: 0;
}
.thot-fleet-ledger__status {
display: grid;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.42);
}
.thot-fleet-ledger__status-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.thot-fleet-ledger__status-item {
--fleet-status-color: oklch(var(--muted-foreground));
display: flex;
min-width: min(100%, 10rem);
gap: 0.5rem;
align-items: flex-start;
}
.thot-fleet-ledger__status-item[data-tone="success"] {
--fleet-status-color: oklch(var(--success));
}
.thot-fleet-ledger__status-item[data-tone="warning"] {
--fleet-status-color: oklch(var(--warning));
}
.thot-fleet-ledger__status-item[data-tone="danger"] {
--fleet-status-color: oklch(var(--destructive));
}
.thot-fleet-ledger__status-item[data-tone="info"] {
--fleet-status-color: oklch(var(--info));
}
.thot-fleet-ledger__status-mark {
width: 0.45rem;
height: 0.45rem;
margin-top: 0.33rem;
flex: 0 0 auto;
border-radius: 999px;
background: var(--fleet-status-color);
box-shadow: 0 0 0 3px color-mix(in oklch, var(--fleet-status-color) 16%, transparent);
}
.thot-fleet-ledger__status-copy {
display: grid;
grid-template-columns: auto auto;
gap: 0.05rem 0.4rem;
min-width: 0;
}
.thot-fleet-ledger__status-label {
grid-column: 1 / -1;
font-size: 0.625rem;
}
.thot-fleet-ledger__status-value {
overflow: hidden;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.75rem;
font-weight: 700;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__status-detail {
overflow: hidden;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.45;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__kpi-section {
display: grid;
gap: 0.625rem;
padding: 0.75rem 1.25rem 0;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__kpi-strip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 8.5rem), 1fr));
margin: 0;
}
.thot-fleet-ledger__kpi {
min-width: 0;
padding: 0 1rem 0.75rem 0;
}
.thot-fleet-ledger__kpi + .thot-fleet-ledger__kpi {
padding-left: 1rem;
border-left: 1px solid oklch(var(--border));
}
.thot-fleet-ledger__kpi-value,
.thot-fleet-ledger__kpi-detail {
margin: 0;
}
.thot-fleet-ledger__kpi-value {
margin-top: 0.2rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: clamp(1.25rem, 2vw, 1.625rem);
font-variant-numeric: tabular-nums;
font-weight: 700;
letter-spacing: -0.035em;
line-height: 1.2;
}
.thot-fleet-ledger__kpi-detail {
margin-top: 0.15rem;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.4;
}
.thot-fleet-ledger__navigation {
position: relative;
z-index: 4;
display: flex;
min-width: 0;
min-height: 3rem;
gap: 0.5rem;
align-items: center;
padding: 0.5rem 1rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__navigation > button:first-child {
min-height: 2.25rem;
border: 1px solid oklch(var(--primary) / 0.28);
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.08);
box-shadow: 0 1px 2px oklch(var(--primary) / 0.1);
font-weight: 700;
}
.thot-fleet-ledger__navigation > button:first-child:not(:disabled):hover {
border-color: oklch(var(--primary) / 0.48);
color: oklch(var(--primary));
background: oklch(var(--primary) / 0.14);
}
.thot-fleet-ledger__breadcrumb {
min-width: 0;
overflow: visible;
}
.thot-fleet-ledger__breadcrumb-list {
display: flex;
min-width: 0;
margin: 0;
padding: 0;
align-items: center;
list-style: none;
}
.thot-fleet-ledger__breadcrumb-item {
display: flex;
min-width: 0;
align-items: center;
}
.thot-fleet-ledger__breadcrumb-separator {
width: 0.9rem;
height: 0.9rem;
margin-inline: 0.2rem;
flex: 0 0 auto;
color: oklch(var(--muted-foreground) / 0.65);
}
.thot-fleet-ledger__breadcrumb-button {
max-width: 13rem;
overflow: hidden;
padding: 0.2rem 0.25rem;
border: 0;
border-radius: calc(var(--radius) - 0.25rem);
color: oklch(var(--muted-foreground));
background: transparent;
font: inherit;
font-size: 0.8125rem;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
}
.thot-fleet-ledger__breadcrumb-button:hover {
color: oklch(var(--foreground));
background: oklch(var(--muted));
}
.thot-fleet-ledger__breadcrumb-button:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: 2px;
}
.thot-fleet-ledger__breadcrumb-label {
max-width: 18rem;
overflow: hidden;
color: oklch(var(--foreground));
font-size: 0.8125rem;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__navigation-trailing,
.thot-fleet-ledger__command-trailing {
display: flex;
gap: 0.5rem;
align-items: center;
margin-left: auto;
}
.thot-fleet-ledger__command-bar {
display: flex;
flex-wrap: wrap;
min-height: 3.25rem;
gap: 0.625rem;
align-items: center;
padding: 0.625rem 1rem;
border-bottom: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.24);
}
.thot-fleet-ledger__command-picker {
display: flex;
min-width: min(100%, 12rem);
gap: 0.5rem;
align-items: center;
}
.thot-fleet-action-selector {
display: flex;
min-width: 0;
flex: 1;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
}
.thot-fleet-action-selector__scope {
display: grid;
min-width: 7.5rem;
color: oklch(var(--foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
line-height: 1.35;
}
.thot-fleet-action-selector__scope span {
color: oklch(var(--muted-foreground));
font-size: 0.625rem;
}
.thot-fleet-action-selector__select {
width: min(100%, 22rem);
min-width: 13rem;
height: 2rem;
padding: 0 2rem 0 0.625rem;
border: 1px solid oklch(var(--input));
border-radius: var(--radius);
color: oklch(var(--foreground));
background: oklch(var(--background));
font-size: 0.8125rem;
outline: none;
}
.thot-fleet-action-selector__select:focus-visible {
border-color: oklch(var(--primary) / 0.65);
box-shadow: 0 0 0 3px oklch(var(--ring) / 0.15);
}
.thot-fleet-action-selector__select:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.thot-fleet-action-selector__reason {
flex-basis: 100%;
color: oklch(var(--muted-foreground));
font-size: 0.75rem;
line-height: 1.4;
}
.thot-fleet-metadata-model {
min-width: min(100%, 14rem);
flex: 0 1 16rem;
}
.thot-fleet-metadata-model > div {
min-width: 0;
gap: 0.25rem;
}
.thot-fleet-metadata-model label {
gap: 0.25rem;
}
.thot-fleet-metadata-model select {
height: 2rem;
}
.thot-fleet-ledger__selection-summary {
min-width: 0;
overflow: hidden;
color: oklch(var(--muted-foreground));
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.025em;
text-overflow: ellipsis;
white-space: nowrap;
}
.thot-fleet-ledger__content {
position: relative;
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
min-height: 18rem;
margin-block-end: 30px;
margin-inline-start: 20px;
overflow: hidden;
border-inline-end: 1px solid oklch(var(--border));
background: oklch(var(--card));
}
.thot-fleet-ledger__drawer {
position: absolute;
z-index: 50;
inset-block: 0.75rem;
inset-inline-start: 50%;
inset-inline-end: auto;
display: flex;
width: 60%;
min-width: 0;
flex-direction: column;
overflow: hidden;
border: 1px solid oklch(var(--border));
border-radius: 0.75rem;
color: oklch(var(--card-foreground));
background: oklch(var(--card));
box-shadow:
0 28px 72px oklch(0.12 0.02 20 / 0.28),
0 7px 20px oklch(0.12 0.02 20 / 0.16);
transform: translateX(-50%);
}
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
position: absolute;
inset-block: 0.75rem;
inset-inline-start: 50%;
inset-inline-end: auto;
width: 60%;
max-width: none;
min-width: 0;
transform: translateX(-50%);
}
.thot-fleet-drawer-form {
min-height: 0;
}
.thot-fleet-drawer-form__context {
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 1rem;
padding-bottom: 1rem;
border-bottom: 1px solid oklch(var(--border));
}
.thot-catalog-drawer {
isolation: isolate;
background-color: oklch(var(--background));
background-image: none;
box-shadow:
0 30px 84px oklch(0.12 0.02 20 / 0.3),
0 8px 22px oklch(0.12 0.02 20 / 0.18);
}
.thot-catalog-drawer__body--empty {
display: flex;
align-items: flex-start;
}
.thot-catalog-drawer__body--flush {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.thot-catalog-drawer__footer--split {
justify-content: space-between;
}
.thot-fleet-ledger__drawer-header,
.thot-catalog-drawer__header {
color: oklch(var(--primary-foreground));
background-color: oklch(var(--primary));
background-image: none;
border-color: oklch(var(--primary-foreground) / 0.18);
box-shadow: inset 0 -1px 0 oklch(var(--primary-foreground) / 0.18);
}
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-title,
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__drawer-description,
.thot-fleet-ledger__drawer-header .thot-fleet-ledger__eyebrow,
.thot-catalog-drawer__header .thot-label,
.thot-catalog-drawer__header .text-muted-foreground {
color: oklch(var(--primary-foreground));
}
.thot-fleet-ledger__drawer-header button,
.thot-catalog-drawer__header button {
border-color: oklch(var(--primary-foreground) / 0.22);
color: inherit;
background: oklch(var(--primary-foreground) / 0.1);
}
.thot-fleet-ledger__drawer-header button:hover,
.thot-catalog-drawer__header button:hover {
color: inherit;
background: oklch(var(--primary-foreground) / 0.18);
}
.thot-catalog-drawer__history button[aria-current="true"] {
color: oklch(var(--foreground));
background: oklch(var(--primary) / 0.09);
box-shadow: inset 3px 0 0 oklch(var(--primary));
}
.thot-fleet-grid-surface {
position: relative;
}
.thot-fleet-grid-toolbar {
background: oklch(var(--muted) / 0.32);
}
.thot-fleet-ledger-grid {
--ag-header-background-color: oklch(var(--muted) / 0.84);
--ag-odd-row-background-color: oklch(var(--card));
--ag-row-hover-color: oklch(var(--primary) / 0.045);
--ag-selected-row-background-color: oklch(var(--primary) / 0.085);
}
.thot-fleet-ledger-grid .ag-header-cell-text {
font-family: var(--font-mono);
font-size: 0.6875rem;
letter-spacing: 0.055em;
text-transform: uppercase;
}
.thot-fleet-ledger-grid .ag-row {
background: oklch(var(--card));
}
.thot-fleet-ledger-grid .ag-row-even {
background: oklch(var(--muted) / 0.12);
}
.thot-fleet-icon-action {
position: relative;
}
.thot-fleet-nav-action {
position: relative;
}
.thot-fleet-icon-action::after {
position: absolute;
z-index: 80;
inset-inline-end: calc(100% + 5px);
inset-block-start: 50%;
width: max-content;
max-width: 12rem;
padding: 0.3rem 0.45rem;
border: 1px solid oklch(var(--border));
border-radius: calc(var(--radius) - 0.125rem);
color: oklch(var(--popover-foreground));
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translate(2px, -50%);
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
visibility: hidden;
}
.thot-fleet-nav-action::after {
position: absolute;
z-index: 90;
inset-inline-start: 50%;
inset-block-end: calc(100% + 5px);
width: max-content;
max-width: 12rem;
padding: 0.3rem 0.45rem;
border: 1px solid oklch(var(--border));
border-radius: calc(var(--radius) - 0.125rem);
color: oklch(var(--popover-foreground));
background: oklch(var(--popover));
box-shadow: var(--shadow-sm);
content: attr(data-tooltip);
font-size: 0.6875rem;
font-weight: 650;
line-height: 1.2;
opacity: 0;
pointer-events: none;
transform: translate(-50%, 2px);
transition: opacity 60ms cubic-bezier(0.22, 1, 0.36, 1), transform 60ms cubic-bezier(0.22, 1, 0.36, 1);
visibility: hidden;
}
.thot-fleet-nav-action--below::after {
inset-block: calc(100% + 5px) auto;
transform: translate(-50%, -2px);
}
.thot-fleet-icon-action:hover::after,
.thot-fleet-icon-action:focus-visible::after,
.thot-fleet-icon-action:focus-within::after {
opacity: 1;
transform: translate(0, -50%);
visibility: visible;
}
.thot-fleet-nav-action:hover,
.thot-fleet-nav-action:focus-visible {
z-index: 90;
}
.thot-fleet-nav-action:hover::after,
.thot-fleet-nav-action:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
visibility: visible;
}
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-row:has(.thot-fleet-icon-action:focus-within),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-grid-pinned-right-cells:has(.thot-fleet-icon-action:focus-within),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:hover),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-visible),
.thot-fleet-ledger-grid .ag-cell:has(.thot-fleet-icon-action:focus-within) {
z-index: 8;
overflow: visible;
}
.thot-fleet-ledger__drawer:focus-visible {
outline: 2px solid oklch(var(--ring));
outline-offset: -2px;
}
.thot-fleet-ledger__drawer-header {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 1rem 0.875rem 1.25rem;
border-bottom: 1px solid oklch(var(--primary-foreground) / 0.18);
}
.thot-fleet-ledger__drawer-title {
font-size: 1.35rem;
line-height: 1.18;
}
.thot-fleet-ledger__drawer-body {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 1.25rem;
overscroll-behavior: contain;
}
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--empty {
display: flex;
align-items: flex-start;
}
.thot-fleet-ledger__drawer-body.thot-catalog-drawer__body--flush {
display: flex;
flex-direction: column;
overflow: hidden;
padding: 0;
}
.thot-fleet-ledger__drawer-footer {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
justify-content: flex-end;
padding: 0.875rem 1.25rem;
border-top: 1px solid oklch(var(--border));
background: oklch(var(--muted) / 0.32);
}
.thot-fleet-ledger__drawer-footer.thot-catalog-drawer__footer--split {
justify-content: space-between;
}
@media (min-width: 64rem) {
.thot-fleet-ledger__header {
padding-inline: 1.5rem;
}
.thot-fleet-ledger__status {
grid-template-columns: 9rem minmax(0, 1fr);
align-items: start;
padding-inline: 1.5rem;
}
.thot-fleet-ledger__kpi-section {
padding-inline: 1.5rem;
}
.thot-fleet-ledger__navigation,
.thot-fleet-ledger__command-bar {
padding-inline: 1.25rem;
}
}
@container fleet-surface (max-width: 47.999rem) {
.thot-fleet-ledger__drawer,
.thot-fleet-ledger .thot-fleet-ledger__context-panel {
inset-block: 0.5rem;
width: calc(100% - 1rem);
}
}
@container fleet-workspace (min-width: 82rem) {
.thot-fleet-ledger__header {
grid-template-areas: "heading status actions";
grid-template-columns: max-content minmax(0, 1fr) max-content;
row-gap: 0;
}
}
@container fleet-workspace (max-width: 47.999rem) {
.thot-fleet-ledger__header {
grid-template-areas:
"heading"
"actions"
"status";
grid-template-columns: minmax(0, 1fr);
padding-inline: 1rem;
}
.thot-fleet-ledger__header-actions {
justify-content: flex-start;
}
.thot-fleet-ledger__header-status .thot-fleet-ledger__status-list {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 9rem), 1fr));
}
}
@media (min-width: 40rem) {
.thot-catalog-drawer__history-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 13.5rem;
gap: 0 1rem;
align-content: start;
}
.thot-catalog-drawer__history-layout > * {
grid-column: 1;
}
.thot-catalog-drawer__history-layout > .thot-catalog-drawer__history {
position: sticky;
top: 0;
grid-column: 2;
grid-row: 1 / span 40;
align-self: start;
margin-top: 0;
}
}
@media (max-width: 47.999rem) {
.thot-fleet-ledger__workspace {
overflow-y: auto;
overscroll-behavior: contain;
}
.thot-fleet-ledger__content {
flex: 0 0 28rem;
min-height: 28rem;
}
.thot-fleet-ledger__kpi-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.thot-fleet-ledger__kpi:nth-child(odd) {
padding-left: 0;
border-left: 0;
}
.thot-fleet-ledger__kpi:nth-child(n + 3) {
padding-top: 0.75rem;
border-top: 1px solid oklch(var(--border));
}
.thot-fleet-ledger__navigation {
flex-wrap: wrap;
}
.thot-fleet-ledger__breadcrumb {
order: 2;
width: 100%;
}
.thot-fleet-ledger__command-picker,
.thot-fleet-ledger__selection-summary {
width: 100%;
}
.thot-fleet-action-selector__select {
width: 100%;
}
}
@media (max-height: 44rem) and (min-width: 48rem) {
.thot-fleet-ledger__workspace {
overflow-y: auto;
overscroll-behavior: contain;
}
.thot-fleet-ledger__status,
.thot-fleet-ledger__kpi-strip {
padding-block: 0.5rem;
}
.thot-fleet-ledger__content {
flex: 0 0 24rem;
min-height: 24rem;
}
}
@media (prefers-reduced-motion: reduce) {
.thot-fleet-ledger__breadcrumb-button {
transition: none;
}
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
function DrawerHarness() {
const [open, setOpen] = useState(false);
return (
<>
<button type="button" onClick={() => setOpen(true)}>Open details</button>
<FleetLedgerDrawer
open={open}
eyebrow="Catalog configuration"
title="Database details"
description="Inspect and edit the selected database."
onClose={() => setOpen(false)}
footer={<button type="button">Save details</button>}
>
<p>Drawer content</p>
</FleetLedgerDrawer>
</>
);
}
test("renders the shared catalog-panel regions and preserves modeless focus behavior", async () => {
const user = userEvent.setup();
render(<DrawerHarness />);
const trigger = screen.getByRole("button", { name: "Open details" });
await user.click(trigger);
const drawer = await screen.findByRole("dialog", { name: "Database details" });
expect(drawer).toHaveAttribute("aria-modal", "false");
expect(drawer).toHaveAttribute("data-catalog-panel");
const labelledBy = drawer.getAttribute("aria-labelledby");
const describedBy = drawer.getAttribute("aria-describedby");
expect(labelledBy).toBeTruthy();
expect(describedBy).toBeTruthy();
expect(document.getElementById(labelledBy!)).toHaveTextContent("Database details");
expect(document.getElementById(describedBy!)).toHaveTextContent(
"Inspect and edit the selected database.",
);
const directRegions = (name: "header" | "body" | "footer") => Array.from(
drawer.children,
).filter((element): element is HTMLElement => (
element instanceof HTMLElement
&& element.getAttribute("data-catalog-panel-region") === name
));
const header = directRegions("header");
const body = directRegions("body");
const footer = directRegions("footer");
expect(header).toHaveLength(1);
expect(body).toHaveLength(1);
expect(footer).toHaveLength(1);
expect(header[0]!).toContainElement(document.getElementById(labelledBy!));
expect(header[0]!).toContainElement(document.getElementById(describedBy!));
expect(body[0]!).toContainElement(screen.getByText("Drawer content"));
expect(footer[0]!).toContainElement(screen.getByRole("button", { name: "Save details" }));
await waitFor(() => expect(screen.getByRole("button", { name: "Close panel" })).toHaveFocus());
await user.keyboard("{Escape}");
expect(screen.queryByRole("dialog", { name: "Database details" })).not.toBeInTheDocument();
await waitFor(() => expect(trigger).toHaveFocus());
});
@@ -0,0 +1,369 @@
import { forwardRef, useId, useState, type ReactNode } from "react"
import { ArrowLeft, ChevronRight } from "lucide-react"
import { Button } from "../../components/ui/button"
import {
WorkAreaPanel,
WorkAreaPanelHostProvider,
type WorkAreaPanelProps,
} from "../WorkAreaPanel"
import "./FleetLedgerShell.css"
export type FleetLedgerStatusTone =
| "neutral"
| "success"
| "warning"
| "danger"
| "info"
export interface FleetLedgerStatusItem {
id: string
label: ReactNode
value: ReactNode
detail?: ReactNode
tone?: FleetLedgerStatusTone
}
export interface FleetLedgerKpi {
id: string
label: ReactNode
value: ReactNode
detail?: ReactNode
}
export interface FleetLedgerBreadcrumbItem {
id: string
label: ReactNode
current?: boolean
onSelect?: () => void
}
export interface FleetLedgerBackAction {
label: string
onBack: () => void
disabled?: boolean
}
interface WithClassName {
className?: string
}
function classes(base: string, className?: string) {
return className ? `${base} ${className}` : base
}
export interface FleetLedgerShellProps extends WithClassName {
ariaLabel?: string
header: ReactNode
status?: ReactNode
kpis?: ReactNode
navigation?: ReactNode
commands?: ReactNode
children: ReactNode
drawer?: ReactNode
}
/**
* Presentational frame for a single fleet-inventory surface. Data fetching,
* navigation state and mutations stay with the caller.
*/
export function FleetLedgerShell({
ariaLabel = "Database fleet inventory",
header,
status,
kpis,
navigation,
commands,
children,
drawer,
className,
}: FleetLedgerShellProps) {
const [panelHost, setPanelHost] = useState<HTMLElement | null>(null)
return (
<WorkAreaPanelHostProvider host={panelHost}>
<section
ref={setPanelHost}
className={classes("thot-fleet-ledger", className)}
aria-label={ariaLabel}
>
<div className="thot-fleet-ledger__workspace">
{header}
{status}
{kpis}
{navigation}
{commands}
{children}
</div>
{drawer}
</section>
</WorkAreaPanelHostProvider>
)
}
export interface FleetLedgerHeaderProps extends WithClassName {
title: ReactNode
status?: ReactNode
actions?: ReactNode
actionsLabel?: string
}
export function FleetLedgerHeader({
title,
status,
actions,
actionsLabel = "Database management actions",
className,
}: FleetLedgerHeaderProps) {
return (
<header className={classes("thot-fleet-ledger__header", className)}>
<div className="thot-fleet-ledger__heading-copy">
<h1 className="thot-fleet-ledger__title">{title}</h1>
</div>
{status ? <div className="thot-fleet-ledger__header-status">{status}</div> : null}
{actions ? (
<div className="thot-fleet-ledger__header-actions" role="group" aria-label={actionsLabel}>
{actions}
</div>
) : null}
</header>
)
}
export interface FleetLedgerRealStatusProps extends WithClassName {
title?: ReactNode
items: readonly FleetLedgerStatusItem[]
live?: "off" | "polite"
}
export function FleetLedgerRealStatus({
title = "Live catalog status",
items,
live = "polite",
className,
}: FleetLedgerRealStatusProps) {
const titleId = useId()
return (
<section
className={classes("thot-fleet-ledger__status", className)}
aria-labelledby={titleId}
aria-live={live}
aria-atomic="false"
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
</h2>
<ul className="thot-fleet-ledger__status-list">
{items.map((item) => (
<li
key={item.id}
className="thot-fleet-ledger__status-item"
data-tone={item.tone ?? "neutral"}
>
<span className="thot-fleet-ledger__status-mark" aria-hidden="true" />
<span className="thot-fleet-ledger__status-copy">
<span className="thot-fleet-ledger__status-label">{item.label}</span>
<strong className="thot-fleet-ledger__status-value">{item.value}</strong>
{item.detail ? (
<span className="thot-fleet-ledger__status-detail">{item.detail}</span>
) : null}
</span>
</li>
))}
</ul>
</section>
)
}
export interface FleetLedgerKpiStripProps extends WithClassName {
title?: ReactNode
items: readonly FleetLedgerKpi[]
}
export function FleetLedgerKpiStrip({
title = "Fleet summary",
items,
className,
}: FleetLedgerKpiStripProps) {
const titleId = useId()
return (
<section
className={classes("thot-fleet-ledger__kpi-section", className)}
aria-labelledby={titleId}
>
<h2 id={titleId} className="thot-fleet-ledger__section-label">
{title}
</h2>
<dl className="thot-fleet-ledger__kpi-strip">
{items.map((item) => (
<div key={item.id} className="thot-fleet-ledger__kpi">
<dt className="thot-fleet-ledger__kpi-label">{item.label}</dt>
<dd className="thot-fleet-ledger__kpi-value">{item.value}</dd>
{item.detail ? (
<dd className="thot-fleet-ledger__kpi-detail">{item.detail}</dd>
) : null}
</div>
))}
</dl>
</section>
)
}
export interface FleetLedgerBreadcrumbProps extends WithClassName {
items: readonly FleetLedgerBreadcrumbItem[]
back?: FleetLedgerBackAction
trailing?: ReactNode
ariaLabel?: string
}
export function FleetLedgerBreadcrumb({
items,
back,
trailing,
ariaLabel = "Database hierarchy",
className,
}: FleetLedgerBreadcrumbProps) {
return (
<div className={classes("thot-fleet-ledger__navigation", className)}>
{back ? (
<Button
type="button"
variant="ghost"
size="sm"
className="thot-fleet-nav-action"
data-tooltip={back.label.replace(/^Back to\s+/i, "")}
onClick={back.onBack}
disabled={back.disabled}
data-icon="inline-start"
>
<ArrowLeft aria-hidden="true" />
{back.label}
</Button>
) : null}
<nav className="thot-fleet-ledger__breadcrumb" aria-label={ariaLabel}>
<ol className="thot-fleet-ledger__breadcrumb-list">
{items.map((item, index) => {
const isCurrent = item.current ?? index === items.length - 1
return (
<li key={item.id} className="thot-fleet-ledger__breadcrumb-item">
{index > 0 ? (
<ChevronRight
className="thot-fleet-ledger__breadcrumb-separator"
aria-hidden="true"
/>
) : null}
{item.onSelect && !isCurrent ? (
<button
type="button"
className="thot-fleet-ledger__breadcrumb-button thot-fleet-nav-action thot-fleet-nav-action--below"
data-tooltip={item.label}
onClick={item.onSelect}
>
{item.label}
</button>
) : (
<span
className="thot-fleet-ledger__breadcrumb-label"
aria-current={isCurrent ? "page" : undefined}
>
{item.label}
</span>
)}
</li>
)
})}
</ol>
</nav>
{trailing ? <div className="thot-fleet-ledger__navigation-trailing">{trailing}</div> : null}
</div>
)
}
export interface FleetLedgerCommandBarProps extends WithClassName {
picker: ReactNode
selectionSummary?: ReactNode
trailing?: ReactNode
ariaLabel?: string
}
export function FleetLedgerCommandBar({
picker,
selectionSummary,
trailing,
ariaLabel = "Fleet commands",
className,
}: FleetLedgerCommandBarProps) {
return (
<div
className={classes("thot-fleet-ledger__command-bar", className)}
role="group"
aria-label={ariaLabel}
>
<div className="thot-fleet-ledger__command-picker">{picker}</div>
{selectionSummary ? (
<div className="thot-fleet-ledger__selection-summary">{selectionSummary}</div>
) : null}
{trailing ? <div className="thot-fleet-ledger__command-trailing">{trailing}</div> : null}
</div>
)
}
export interface FleetLedgerContentProps extends WithClassName {
children: ReactNode
ariaLabel?: string
busy?: boolean
}
/** Keeps AG Grid or another real-data view as the only primary work surface. */
export function FleetLedgerContent({
children,
ariaLabel = "Fleet inventory results",
busy = false,
className,
}: FleetLedgerContentProps) {
return (
<section
className={classes("thot-fleet-ledger__content", className)}
aria-label={ariaLabel}
aria-busy={busy}
>
{children}
</section>
)
}
export type FleetLedgerDrawerProps = Omit<WorkAreaPanelProps, "catalogMarkers">
/**
* A modeless detail/action drawer. It owns focus entry/return and Escape;
* mutation-specific confirmation and dirty-state policy stay with the caller.
*/
export const FleetLedgerDrawer = forwardRef<HTMLElement, FleetLedgerDrawerProps>(
function FleetLedgerDrawer({ className, bodyClassName, footerClassName, ...props }, ref) {
return (
<WorkAreaPanel
{...props}
ref={ref}
catalogMarkers
className={classes(
"thot-fleet-ledger__drawer thot-fleet-ledger__context-panel thot-catalog-drawer",
className,
)}
bodyClassName={classes(
"thot-fleet-ledger__drawer-body thot-catalog-drawer__body",
bodyClassName,
)}
footerClassName={classes(
"thot-fleet-ledger__drawer-footer thot-catalog-drawer__footer",
footerClassName,
)}
/>
)
},
)
@@ -23,10 +23,6 @@ export function MetadataGenerationModelSelector({
&& models.some((model) => model.id === configuredDefault);
const noUsableModel = !isLoading && !isError && Boolean(data) && !hasUsableDefault;
const unavailable = noUsableModel || (!isLoading && isError);
const describedBy = [
"metadata-generation-source-data-disclosure",
unavailable ? "metadata-generation-model-help" : null,
].filter(Boolean).join(" ");
return (
<div className="grid min-w-56 max-w-lg gap-1.5">
<label className="grid gap-1.5 text-xs font-medium text-foreground">
@@ -34,7 +30,7 @@ export function MetadataGenerationModelSelector({
<select
className="h-9 w-full rounded-md border border-input bg-card px-3 text-sm outline-none transition focus:border-primary/60 focus:ring-3 focus:ring-ring/15 disabled:bg-muted disabled:text-muted-foreground"
aria-label="Metadata description model"
aria-describedby={describedBy}
aria-describedby={unavailable ? "metadata-generation-model-help" : undefined}
value={selectedModel}
disabled={!canManage || isLoading || isError || !hasUsableDefault}
onChange={(event) => onSelectedModelChange(event.target.value)}
@@ -54,26 +50,6 @@ export function MetadataGenerationModelSelector({
Configure a metadata-generation model in application setup to enable description generation.
</p>
) : null}
<div
id="metadata-generation-source-data-disclosure"
role="note"
aria-label="Metadata generation source data disclosure"
className="space-y-1 text-xs font-normal leading-4 text-muted-foreground"
>
<p>
Description generation may send up to five source rows and up to five representative
non-null example values to the selected model provider. Values from columns marked
sensitive are replaced with plausible synthetic values before the request.
</p>
<p>
Values from unmarked columns may be sent unchanged. Samples are transient and are not
stored in run logs or catalog metadata.
</p>
<p>
Sensitive-field suggestions use structural metadata only and remain unsaved until you
choose Save sensitive fields.
</p>
</div>
</div>
);
}
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { Save, X } from "lucide-react";
import { Save } from "lucide-react";
import { toast } from "sonner";
import { Button } from "../../components/ui/button";
import { apiErrorMessage } from "../../api/client";
@@ -8,6 +8,7 @@ import {
type CatalogColumn,
type SensitiveDataSuggestion,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
@@ -87,25 +88,30 @@ export function SensitiveDataReviewDrawer({
if (!open || !databaseId) return null;
return (
<aside
aria-label="Sensitive field review"
className="fixed inset-y-2 right-0 z-50 flex w-full max-w-[480px] flex-col border-l border-border bg-background shadow-2xl sm:inset-y-4"
<FleetLedgerDrawer
open
ariaLabel="Sensitive field review"
eyebrow="Sensitive data"
title="Review suggested flags"
description={`${scopeLabel}. The model proposed values, but only your save changes the catalog.`}
onClose={close}
closeLabel="Close sensitive field review"
busy={saving}
bodyClassName="thot-catalog-drawer__body--flush"
footerClassName="thot-catalog-drawer__footer--split"
footer={(
<>
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
</Button>
</div>
</>
)}
>
<div className="flex items-start justify-between gap-4 border-b border-border px-5 py-4">
<div>
<p className="thot-label">Sensitive data</p>
<h2 className="mt-1 font-heading text-xl font-semibold">Review suggested flags</h2>
<p className="mt-1 text-sm text-muted-foreground">
{scopeLabel}. The model proposed values, but only your save changes the catalog.
</p>
</div>
<Button type="button" variant="ghost" size="icon-lg" aria-label="Close sensitive field review" disabled={saving} onClick={close}>
<X aria-hidden="true" />
</Button>
</div>
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-b border-border px-5 py-4">
<div className="border-b border-border px-5 py-4">
<div className="flex items-center gap-3">
<input
className="h-9 min-w-0 flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:border-primary/60 focus:ring-3 focus:ring-ring/15"
@@ -127,9 +133,9 @@ export function SensitiveDataReviewDrawer({
/>
Show all {suggestions.length} classified columns
</label>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{visible.length === 0 ? (
<div className="rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No proposed changes in this view.</p>
@@ -171,18 +177,7 @@ export function SensitiveDataReviewDrawer({
})}
</ul>
)}
</div>
</div>
<div className="flex items-center justify-between gap-3 border-t border-border px-5 py-4">
<p className="text-xs text-muted-foreground">Unsaved suggestions never change the catalog.</p>
<div className="flex gap-2">
<Button type="button" variant="outline" disabled={saving} onClick={close}>Cancel</Button>
<Button type="button" disabled={!canManage || saving || changed.length === 0} onClick={() => void save()}>
<Save />{saving ? "Saving…" : `Save ${changed.length}`}
</Button>
</div>
</div>
</aside>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1,132 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { http, HttpResponse } from "msw";
import { useState } from "react";
import type { SensitiveDataSuggestionRun } from "../../api/catalog-databases";
import { server } from "../../test/msw";
import { SensitiveDataSuggestionHistoryDrawer } from "./SensitiveDataSuggestionHistoryDrawer";
const runningRun: SensitiveDataSuggestionRun = {
id: "99999999-9999-4999-8999-999999999999",
databaseId: "11111111-1111-4111-8111-111111111111",
scope: "selected_columns",
modelId: "local-qwen",
status: "running",
total: 4,
suggestedSensitive: 1,
suggestedNonSensitive: 1,
createdAt: "2026-08-28T09:00:00Z",
startedAt: "2026-08-28T09:00:00Z",
updatedAt: "2026-08-28T09:00:01Z",
finishedAt: null,
errorSummary: null,
};
function renderDrawer(initialRun: SensitiveDataSuggestionRun | null) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: Infinity } },
});
function Harness() {
const [run, setRun] = useState(initialRun);
return (
<SensitiveDataSuggestionHistoryDrawer
open
run={run}
modelLabel="Local Qwen"
onClose={() => undefined}
onRunUpdate={setRun}
/>
);
}
return render(
<QueryClientProvider client={client}>
<Harness />
</QueryClientProvider>,
);
}
test("shows an explicit empty state before any sensitive suggestion runs exist", async () => {
server.use(http.get(
"/api/catalog/sensitive-data-suggestion-runs",
() => HttpResponse.json([]),
));
renderDrawer(null);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(drawer).getByRole("heading", { name: "Suggestion run history" })).toBeVisible();
expect(await within(drawer).findByText("No sensitive suggestion runs yet.")).toBeVisible();
});
test("shows sensitive suggestion results, safe events, and lets operators inspect an older run", async () => {
const completedRun: SensitiveDataSuggestionRun = {
...runningRun,
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
scope: "all",
status: "completed",
total: 6,
suggestedSensitive: 2,
suggestedNonSensitive: 4,
createdAt: "2026-08-28T08:00:00Z",
startedAt: "2026-08-28T08:00:00Z",
updatedAt: "2026-08-28T08:00:01Z",
finishedAt: "2026-08-28T08:00:01Z",
};
server.use(
http.get("/api/catalog/sensitive-data-suggestion-runs", () => (
HttpResponse.json([runningRun, completedRun])
)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", ({ params }) => (
HttpResponse.json(params.runId === completedRun.id ? completedRun : runningRun)
)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", ({ params }) => (
HttpResponse.json([{
runId: params.runId,
sequence: 1,
level: "info",
message: params.runId === completedRun.id ? "Classified 6 columns" : "Classifying columns",
createdAt: "2026-08-28T09:00:01Z",
}])
)),
);
const user = userEvent.setup();
renderDrawer(runningRun);
const drawer = await screen.findByRole("dialog", { name: "Sensitive suggestion history" });
expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible();
expect(await within(drawer).findByText("Classifying columns")).toBeVisible();
expect(within(drawer).queryByRole("button", { name: "Stop" })).not.toBeInTheDocument();
expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument();
const history = within(drawer).getByRole("region", { name: "Sensitive suggestion run history" });
await user.click(within(history).getByRole("button", { name: /Completed.*all/i }));
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
expect(within(drawer).getByText("2", { selector: "[data-count='suggestedSensitive']" })).toBeVisible();
expect(within(drawer).getByText("4", { selector: "[data-count='suggestedNonSensitive']" })).toBeVisible();
await waitFor(() => expect(within(drawer).getByText("Classified 6 columns")).toBeVisible());
});
test("does not expose unsafe provider details from a final error summary", async () => {
const failedRun: SensitiveDataSuggestionRun = {
...runningRun,
status: "failed",
finishedAt: "2026-08-28T09:00:02Z",
errorSummary: "stack trace at provider.ts:42 private source sample sk-secret",
};
server.use(
http.get("/api/catalog/sensitive-data-suggestion-runs", () => HttpResponse.json([failedRun])),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId", () => HttpResponse.json(failedRun)),
http.get("/api/catalog/sensitive-data-suggestion-runs/:runId/events-list", () => HttpResponse.json([])),
);
renderDrawer(failedRun);
expect(await screen.findByText(
"The run ended before all columns were classified. Review the event log for safe details.",
)).toBeVisible();
expect(screen.queryByText(/sk-secret/i)).not.toBeInTheDocument();
});
@@ -0,0 +1,234 @@
import { useEffect } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiErrorMessage } from "../../api/client";
import {
getSensitiveDataSuggestionRun,
listSensitiveDataSuggestionEvents,
listSensitiveDataSuggestionRuns,
type SensitiveDataSuggestionRun,
} from "../../api/catalog-databases";
import { FleetLedgerDrawer } from "./FleetLedgerShell";
interface Props {
open: boolean;
run: SensitiveDataSuggestionRun | null;
modelLabel: string;
onClose: () => void;
onRunUpdate: (run: SensitiveDataSuggestionRun) => void;
}
const HISTORY_QUERY_KEY = ["sensitive-data-suggestion-runs", 50] as const;
const SAFE_FINAL_ERROR = "The run ended before all columns were classified. Review the event log for safe details.";
function statusLabel(run: SensitiveDataSuggestionRun): string {
const label = run.status.replaceAll("_", " ");
return `${label[0].toUpperCase()}${label.slice(1)}`;
}
function isTerminal(run?: SensitiveDataSuggestionRun): boolean {
return Boolean(run && ["completed", "failed", "interrupted"].includes(run.status));
}
function safeFinalError(summary: string | null): string | null {
const value = summary?.trim();
if (!value) return null;
if (
value.length > 500
|| /[\r\n]/.test(value)
|| /traceback|stack trace|(?:^|\s)at\s+\S+[:(]|prompt|provider\s+payload|source\s+sample|bearer\s+\S+|api[_ -]?key|sk-[a-z0-9_-]+/i.test(value)
) return SAFE_FINAL_ERROR;
return value;
}
function timestamp(value: string | null): string {
return value ? new Date(value).toLocaleString() : "Not available";
}
export function SensitiveDataSuggestionHistoryDrawer({
open,
run: initialRun,
modelLabel,
onClose,
onRunUpdate,
}: Props) {
const queryClient = useQueryClient();
const runId = initialRun?.id ?? null;
const runQuery = useQuery({
queryKey: ["sensitive-data-suggestion-run", runId],
queryFn: () => getSensitiveDataSuggestionRun(runId!),
enabled: open && Boolean(runId),
initialData: initialRun ?? undefined,
retry: false,
refetchInterval: (query) => isTerminal(query.state.data) ? false : 1_000,
});
const run = runQuery.data;
const eventQuery = useQuery({
queryKey: ["sensitive-data-suggestion-events", runId],
queryFn: () => listSensitiveDataSuggestionEvents(runId!),
enabled: open && Boolean(runId),
retry: false,
refetchInterval: run && isTerminal(run) ? false : 1_500,
});
const historyQuery = useQuery({
queryKey: HISTORY_QUERY_KEY,
queryFn: () => listSensitiveDataSuggestionRuns(50),
enabled: open,
retry: false,
refetchInterval: (query) => query.state.data?.some((item) => !isTerminal(item))
? 3_000
: false,
});
useEffect(() => {
if (!open || initialRun || !historyQuery.data?.[0]) return;
onRunUpdate(historyQuery.data[0]);
}, [historyQuery.data, initialRun, onRunUpdate, open]);
useEffect(() => {
if (!run) return;
onRunUpdate(run);
queryClient.setQueryData<SensitiveDataSuggestionRun[]>(HISTORY_QUERY_KEY, (current) => {
if (!current) return [run];
return current.some((item) => item.id === run.id)
? current.map((item) => item.id === run.id ? run : item)
: [run, ...current];
});
}, [onRunUpdate, queryClient, run]);
if (!open) return null;
if (!run) {
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive suggestion history"
eyebrow="Sensitive data"
title="Suggestion run history"
onClose={onClose}
closeLabel="Close sensitive suggestion history"
bodyClassName="thot-catalog-drawer__body--empty"
>
{historyQuery.isError ? (
<div role="alert" className="w-full rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(historyQuery.error)}
</div>
) : historyQuery.isLoading || (historyQuery.data?.length ?? 0) > 0 ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
) : (
<div className="w-full rounded-md border border-border bg-muted/25 px-4 py-5 text-sm">
<p className="font-semibold">No sensitive suggestion runs yet.</p>
<p className="mt-1 text-muted-foreground">
Request sensitive-field suggestions to create the first history entry.
</p>
</div>
)}
</FleetLedgerDrawer>
);
}
const events = eventQuery.data ?? [];
const finalError = safeFinalError(run.errorSummary);
return (
<FleetLedgerDrawer
open
ariaLabel="Sensitive suggestion history"
eyebrow="Sensitive data suggestions"
title={statusLabel(run)}
description={`${modelLabel}${modelLabel !== run.modelId ? ` (${run.modelId})` : ""} · ${run.scope.replaceAll("_", " ")}`}
onClose={onClose}
closeLabel="Close sensitive suggestion history"
bodyClassName="thot-catalog-drawer__history-layout"
>
{runQuery.isError ? (
<div role="alert" className="rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{apiErrorMessage(runQuery.error)}
</div>
) : null}
<section aria-label="Sensitive suggestion counters">
<h3 className="thot-label mb-2">Results</h3>
<div className="grid grid-cols-2 gap-2">
{([
["total", "Total columns"],
["suggestedSensitive", "Sensitive"],
["suggestedNonSensitive", "Not sensitive"],
] as const).map(([name, label]) => (
<div key={name} className="rounded-md border border-border bg-muted/25 px-3 py-2">
<p className="text-[11px] font-semibold uppercase tracking-wide text-muted-foreground">{label}</p>
<p data-count={name} className="mt-1 text-lg font-semibold tabular-nums">{run[name]}</p>
</div>
))}
</div>
</section>
<section className="mt-5" aria-label="Sensitive suggestion timestamps">
<h3 className="thot-label mb-2">Timestamps</h3>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-sm">
<dt className="text-muted-foreground">Created</dt><dd className="text-right">{timestamp(run.createdAt)}</dd>
<dt className="text-muted-foreground">Started</dt><dd className="text-right">{timestamp(run.startedAt)}</dd>
<dt className="text-muted-foreground">Updated</dt><dd className="text-right">{timestamp(run.updatedAt)}</dd>
<dt className="text-muted-foreground">Finished</dt><dd className="text-right">{timestamp(run.finishedAt)}</dd>
</dl>
</section>
{finalError ? (
<div className="mt-5 rounded-md border border-destructive/35 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{finalError}
</div>
) : null}
<section className="mt-5" aria-label="Sensitive suggestion log">
<div className="mb-2 flex items-center justify-between">
<h3 className="thot-label">Events</h3>
<span className="text-xs tabular-nums text-muted-foreground">{events.length}</span>
</div>
<div
role="log"
aria-label="Sensitive suggestion events"
className="max-h-72 overflow-y-auto rounded-md bg-zinc-950 p-3 font-mono text-xs leading-5 text-zinc-200"
>
{events.length === 0 ? <p className="text-zinc-500">Waiting for events…</p> : events.map((event) => (
<p key={event.sequence} className={event.level === "error" ? "text-red-300" : event.level === "warning" ? "text-amber-300" : undefined}>
<span className="mr-2 text-zinc-500">{new Date(event.createdAt).toLocaleTimeString()}</span>{event.message}
</p>
))}
</div>
{eventQuery.isError ? <p role="alert" className="mt-2 text-sm text-destructive">{apiErrorMessage(eventQuery.error)}</p> : null}
</section>
<section className="thot-catalog-drawer__history mt-5" aria-label="Sensitive suggestion run history">
<div className="mb-2 flex items-center justify-between gap-3">
<h3 className="thot-label">Recent runs</h3>
<span className="text-xs tabular-nums text-muted-foreground">
{historyQuery.data?.length ?? 0}
</span>
</div>
{historyQuery.isError ? (
<p role="alert" className="text-sm text-destructive">{apiErrorMessage(historyQuery.error)}</p>
) : historyQuery.isLoading ? (
<p className="text-sm text-muted-foreground">Loading suggestion history…</p>
) : (historyQuery.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">No sensitive suggestion runs yet.</p>
) : (
<div className="max-h-56 divide-y divide-border overflow-y-auto rounded-md border border-border">
{(historyQuery.data ?? []).map((item) => (
<button
key={item.id}
type="button"
aria-label={`${statusLabel(item)} · ${item.scope.replaceAll("_", " ")}`}
aria-current={item.id === run.id ? "true" : undefined}
className="flex w-full items-start justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-3 focus-visible:ring-ring/25"
onClick={() => onRunUpdate(item)}
>
<span className="font-medium">{statusLabel(item)}</span>
<span className="shrink-0 text-right text-xs text-muted-foreground">
{item.suggestedSensitive + item.suggestedNonSensitive}/{item.total}<br />
{new Date(item.createdAt).toLocaleString()}
</span>
</button>
))}
</div>
)}
</section>
</FleetLedgerDrawer>
);
}
@@ -0,0 +1 @@
export { useModelessPanel as useModelessDrawer } from "../WorkAreaPanel";
+6
View File
@@ -1,7 +1,13 @@
import { getCatalogMetrics } from "../api/catalog-databases";
import { checkDwhHealth } from "../api/sessions";
import { emptyCatalogMetricsFixture } from "./msw";
// Catches shared browser test fixtures drifting from the same-origin /api contract and
// attempting real localhost network requests instead of using MSW.
test("the shared MSW health fixture follows the browser same-origin API contract", async () => {
await expect(checkDwhHealth()).resolves.toEqual({ ok: true, detail: "ok" });
});
test("the shared MSW catalog fixture exposes a valid empty metrics response", async () => {
await expect(getCatalogMetrics()).resolves.toEqual(emptyCatalogMetricsFixture);
});
+17
View File
@@ -1,5 +1,20 @@
import { setupServer } from "msw/node";
import { http, HttpResponse } from "msw";
import type { CatalogMetrics } from "../api/catalog-databases";
export const emptyCatalogMetricsFixture = {
scope: "global",
databaseId: null,
tables: 0,
columns: 0,
sensitiveColumns: 0,
relationships: 0,
descriptionTargets: 0,
describedTargets: 0,
descriptionCoverage: 0,
updatedAt: null,
} satisfies CatalogMetrics;
export const server = setupServer(
http.get("/api/auth/config", () => HttpResponse.json({ mode: "local", localLogin: true, oidcLogin: false })),
http.get("/api/me", () => HttpResponse.json({
@@ -11,6 +26,8 @@ export const server = setupServer(
})),
http.get("/api/health/dwh", () => HttpResponse.json({ ok: true, detail: "ok" })),
http.get("/api/catalog/databases", () => HttpResponse.json([])),
http.get("/api/catalog/metrics", () => HttpResponse.json(emptyCatalogMetricsFixture)),
http.get("/api/catalog/metadata-generation/models", () => HttpResponse.json({ models: [], default: null })),
http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([])),
http.get("/api/catalog/sensitive-data-suggestion-runs", () => HttpResponse.json([])),
);