feat: complete catalog fleet management workflow
This commit is contained in:
+7
-1
@@ -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 });
|
||||
|
||||
@@ -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}`))
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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" }), {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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([])),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user