diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 4d81791a..d616409c 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -18,6 +18,25 @@ entry. See `CONTRIBUTING.md` § Releases & changelog. ## [Unreleased] +### Added — admin UI for Knowledge-Graph datasets: upload / schema / delete (#532) + +- New page `web-ui/app/admin/datasets/page.tsx` — the web-ui face of the #430 + CSV-import path, closing the admin-UI gap that #430's triage acceptance + criteria called for and that was deliberately deferred to Phase 14 + (`docs/middleware-agent-handoff.md` §13). Upload a CSV, browse the inferred + schema and a paginated row preview, and delete a dataset behind a two-step + confirm. The upload surfaces the mandatory privacy-scan (`masked / scanned` + cells) and truncation stats returned by the ingest pipeline, so the operator + sees what was masked before it landed in the graph. +- API client added to `web-ui/app/_lib/api.ts` + (`listDatasets` / `getDataset` / `getDatasetRows` / `uploadDataset` / + `deleteDataset`) over the existing `/api/v1/datasets*` REST surface + (cookie-session auth, owner-scoped). No new backend — the routes shipped in + #430 and are tested server-side. +- i18n namespace `adminDatasets` mirrored across `messages/{en,de}.json`; a + card in the `/admin` index under the Knowledge group. Component test in + `web-ui/app/admin/datasets/__tests__/page.test.tsx`. + ### Added — API keys as a first-class authentication method, with per-key scopes (#439) - New workspace package `@omadia/api-key-auth` diff --git a/docs/middleware-agent-handoff.md b/docs/middleware-agent-handoff.md index 21b743c6..3b9f32f7 100644 --- a/docs/middleware-agent-handoff.md +++ b/docs/middleware-agent-handoff.md @@ -1646,18 +1646,6 @@ gekettet, weil `requires` beim Boot enforced wird): docs-RFC (diese PR) omadia-ui-Orchestrator-Consumer. Details + per-PR-Doc-Pflichten in §15 des RFC. -### Phase 14 — Admin-UI für Dataset-Upload/Schema/Delete (#430 Follow-up) - -Der #430-Scope (CSV-Import + `query_dataset`-Tool, siehe §3 und §7) deckt -absichtlich **keine** Admin-UI ab — Upload/Schema-Browse/Delete bleibt -API-only (`POST/GET/DELETE /api/v1/datasets*`, siehe §3). #430's eigene -Triage-Acceptance-Criteria verlangen aber genau diese UI; der Branch -schließt das Issue deshalb NICHT, sondern "addresses" es — ein -Folge-Issue für die Admin-UI-Seite (`web-ui/app/admin/datasets/` o.ä., -Upload-Dropzone + Schema-Tabelle + Zeilen-Preview + Delete-Bestätigung, -Pattern analog zur bestehenden Package-Upload-Seite) ist offen zu -erfassen. - --- ## 14. Commands (vom `middleware/`-Dir aus) diff --git a/middleware/package-lock.json b/middleware/package-lock.json index 7678bcec..1c2f0a27 100644 --- a/middleware/package-lock.json +++ b/middleware/package-lock.json @@ -3974,9 +3974,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -5275,9 +5275,9 @@ "license": "Unlicense" }, "node_modules/fast-uri": { - "version": "3.1.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", - "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "funding": [ { "type": "github", diff --git a/middleware/package.json b/middleware/package.json index 9fdca3bb..14af1ee9 100644 --- a/middleware/package.json +++ b/middleware/package.json @@ -96,8 +96,8 @@ "overrides": { "esbuild": "^0.28.1", "axios": "1.18.1", - "fast-uri": "3.1.4", + "fast-uri": "3.1.5", "postcss": "8.5.23", - "brace-expansion": "5.0.8" + "brace-expansion": "5.0.9" } } diff --git a/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts b/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts index 25e38e09..0d2a9272 100644 --- a/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts +++ b/middleware/packages/harness-knowledge-graph-inmemory/src/inMemoryKnowledgeGraph.ts @@ -2923,15 +2923,25 @@ export class InMemoryKnowledgeGraph implements KnowledgeGraph { async listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise { const limit = Math.max(1, Math.min(opts.limit ?? 50, 200)); + const offset = Math.max(0, opts.offset ?? 0); return [...this.datasets.values()] .filter((d) => d.ownerOmadiaUserId === opts.ownerOmadiaUserId) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, limit) + .slice(offset, offset + limit) .map((d) => this.datasetToSummary(d)); } + async countDatasets(opts: { ownerOmadiaUserId: string }): Promise { + let count = 0; + for (const d of this.datasets.values()) { + if (d.ownerOmadiaUserId === opts.ownerOmadiaUserId) count += 1; + } + return count; + } + async getDataset( datasetId: string, viewerOmadiaUserId: string, diff --git a/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts b/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts index 59e56b7a..612f2d0f 100644 --- a/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts +++ b/middleware/packages/harness-knowledge-graph-neon/src/neonKnowledgeGraph.ts @@ -702,19 +702,31 @@ export class NeonKnowledgeGraph implements KnowledgeGraph { async listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise { const limit = Math.max(1, Math.min(opts.limit ?? 50, 200)); + const offset = Math.max(0, opts.offset ?? 0); const result = await this.pool.query( `SELECT id, name, source_file_name, owner_omadia_user_id, row_count, columns, created_at FROM datasets WHERE tenant_id = $1 AND owner_omadia_user_id = $2 ORDER BY created_at DESC - LIMIT $3`, - [this.tenantId, opts.ownerOmadiaUserId, limit], + LIMIT $3 OFFSET $4`, + [this.tenantId, opts.ownerOmadiaUserId, limit, offset], ); return result.rows.map((r) => this.datasetRowToSummary(r)); } + async countDatasets(opts: { ownerOmadiaUserId: string }): Promise { + const result = await this.pool.query<{ count: string }>( + `SELECT COUNT(*)::text AS count + FROM datasets + WHERE tenant_id = $1 AND owner_omadia_user_id = $2`, + [this.tenantId, opts.ownerOmadiaUserId], + ); + return Number(result.rows[0]?.count ?? 0); + } + async getDataset( datasetId: string, viewerOmadiaUserId: string, diff --git a/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts b/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts index 2fa2bee2..600b4044 100644 --- a/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts +++ b/middleware/packages/harness-orchestrator-extras/src/captureFilteringKnowledgeGraph.ts @@ -616,9 +616,13 @@ export class CaptureFilteringKnowledgeGraph implements KnowledgeGraph { listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise { return this.inner.listDatasets(opts); } + countDatasets(opts: { ownerOmadiaUserId: string }): Promise { + return this.inner.countDatasets(opts); + } getDataset( datasetId: string, viewerOmadiaUserId: string, diff --git a/middleware/packages/harness-orchestrator-extras/src/inconsistencyTriggeringKnowledgeGraph.ts b/middleware/packages/harness-orchestrator-extras/src/inconsistencyTriggeringKnowledgeGraph.ts index 8e406840..a4e53718 100644 --- a/middleware/packages/harness-orchestrator-extras/src/inconsistencyTriggeringKnowledgeGraph.ts +++ b/middleware/packages/harness-orchestrator-extras/src/inconsistencyTriggeringKnowledgeGraph.ts @@ -542,9 +542,13 @@ export class InconsistencyTriggeringKnowledgeGraph implements KnowledgeGraph { listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise { return this.inner.listDatasets(opts); } + countDatasets(opts: { ownerOmadiaUserId: string }): Promise { + return this.inner.countDatasets(opts); + } getDataset( datasetId: string, viewerOmadiaUserId: string, diff --git a/middleware/packages/harness-orchestrator-extras/src/mergeTriggeringKnowledgeGraph.ts b/middleware/packages/harness-orchestrator-extras/src/mergeTriggeringKnowledgeGraph.ts index 57218523..1fc1ab47 100644 --- a/middleware/packages/harness-orchestrator-extras/src/mergeTriggeringKnowledgeGraph.ts +++ b/middleware/packages/harness-orchestrator-extras/src/mergeTriggeringKnowledgeGraph.ts @@ -597,9 +597,13 @@ export class MergeTriggeringKnowledgeGraph implements KnowledgeGraph { listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise { return this.inner.listDatasets(opts); } + countDatasets(opts: { ownerOmadiaUserId: string }): Promise { + return this.inner.countDatasets(opts); + } getDataset( datasetId: string, viewerOmadiaUserId: string, diff --git a/middleware/packages/plugin-api/src/knowledgeGraph.ts b/middleware/packages/plugin-api/src/knowledgeGraph.ts index 1a6e934e..d00d5da7 100644 --- a/middleware/packages/plugin-api/src/knowledgeGraph.ts +++ b/middleware/packages/plugin-api/src/knowledgeGraph.ts @@ -704,11 +704,19 @@ export interface KnowledgeGraph { * KnowledgeGraph boundary, never inside it. */ ingestDataset(input: DatasetIngest): Promise; - /** #430 — list datasets owned by the caller, most-recent first. */ + /** + * #430 — list datasets owned by the caller, most-recent first. + * `limit` clamped to [1, 200] server-side (default 50); `offset` skips + * that many rows for pagination. Pair with {@link countDatasets} to render + * a "showing N of M" hint instead of silently truncating at the cap. + */ listDatasets(opts: { ownerOmadiaUserId: string; limit?: number; + offset?: number; }): Promise; + /** #430 — total datasets owned by the caller, ignoring limit/offset. */ + countDatasets(opts: { ownerOmadiaUserId: string }): Promise; /** * #430 — read one dataset's metadata + inferred schema. Null when * missing or the viewer doesn't own it (ACL mirrors `/api/v1/memory`: diff --git a/middleware/src/routes/datasets.ts b/middleware/src/routes/datasets.ts index 1786d909..5ac03df0 100644 --- a/middleware/src/routes/datasets.ts +++ b/middleware/src/routes/datasets.ts @@ -29,6 +29,12 @@ const RowsQuerySchema = z.object({ offset: z.coerce.number().int().min(0).optional(), }); +// Same shape as RowsQuerySchema — the dataset list is paginated too, so the +// admin UI can page past the 50-row default cap instead of silently hiding +// older datasets (they'd otherwise be un-viewable and un-deletable here). +const ListQuerySchema = RowsQuerySchema; +const DEFAULT_LIST_LIMIT = 50; + function requireSessionUserId(req: Request, res: Response): string | null { const id = req.session?.omadia_user_id; if (!id) { @@ -134,9 +140,19 @@ export function createDatasetsRouter(deps: { graph: KnowledgeGraph }): Router { router.get('/', async (req: Request, res: Response) => { const sessionUserId = requireSessionUserId(req, res); if (!sessionUserId) return; + const parsed = ListQuerySchema.safeParse(req.query); + if (!parsed.success) { + res.status(400).json({ code: 'dataset.invalid_query', issues: parsed.error.issues }); + return; + } + const limit = parsed.data.limit ?? DEFAULT_LIST_LIMIT; + const offset = parsed.data.offset ?? 0; try { - const items = await deps.graph.listDatasets({ ownerOmadiaUserId: sessionUserId }); - res.json({ items }); + const [items, total] = await Promise.all([ + deps.graph.listDatasets({ ownerOmadiaUserId: sessionUserId, limit, offset }), + deps.graph.countDatasets({ ownerOmadiaUserId: sessionUserId }), + ]); + res.json({ items, total, limit, offset }); } catch (err) { const { status, code, message } = mapErrorToHttp(err); res.status(status).json({ code, message }); diff --git a/middleware/test/datasetsRoute.test.ts b/middleware/test/datasetsRoute.test.ts index 98710a65..6362d24a 100644 --- a/middleware/test/datasetsRoute.test.ts +++ b/middleware/test/datasetsRoute.test.ts @@ -103,9 +103,17 @@ describe('POST /api/v1/datasets', () => { const { datasetId } = uploadBody.dataset; const listRes = await fetch(h.baseUrl); - const listBody = (await listRes.json()) as { items: Array<{ id: string; name: string }> }; + const listBody = (await listRes.json()) as { + items: Array<{ id: string; name: string }>; + total: number; + limit: number; + offset: number; + }; assert.equal(listBody.items.length, 1); assert.equal(listBody.items[0]?.name, 'People'); + assert.equal(listBody.total, 1); + assert.equal(listBody.limit, 50); + assert.equal(listBody.offset, 0); const schemaRes = await fetch(`${h.baseUrl}/${datasetId}`); const schemaBody = (await schemaRes.json()) as { columns: Array<{ name: string }> }; @@ -124,6 +132,41 @@ describe('POST /api/v1/datasets', () => { assert.equal(afterDeleteRes.status, 404); }); + it('paginates the list with limit/offset and reports the pre-limit total', async () => { + const paged = await makeHarness('user-1'); + for (const nm of ['A', 'B', 'C']) { + const form = new FormData(); + form.append('file', new Blob([CSV], { type: 'text/csv' }), 'people.csv'); + form.append('name', nm); + const res = await fetch(paged.baseUrl, { method: 'POST', body: form }); + assert.equal(res.status, 201); + } + + const firstPage = (await (await fetch(`${paged.baseUrl}?limit=2&offset=0`)).json()) as { + items: Array<{ name: string }>; + total: number; + limit: number; + offset: number; + }; + assert.equal(firstPage.total, 3); + assert.equal(firstPage.limit, 2); + assert.equal(firstPage.offset, 0); + assert.equal(firstPage.items.length, 2); + + const secondPage = (await (await fetch(`${paged.baseUrl}?limit=2&offset=2`)).json()) as { + items: Array<{ name: string }>; + total: number; + }; + assert.equal(secondPage.items.length, 1); + assert.equal(secondPage.total, 3); + + // A malformed query is a 400, not a silent full-list dump. + const bad = await fetch(`${paged.baseUrl}?limit=-1`); + assert.equal(bad.status, 400); + + await paged.close(); + }); + it('returns a JSON {code, message} body — not an unhandled rejection / Express default page — when importCsvDataset throws', async () => { const throwing = await makeHarness('user-1', new ThrowingIngestKnowledgeGraph()); const form = new FormData(); diff --git a/web-ui/app/_lib/api.ts b/web-ui/app/_lib/api.ts index 96e4bd79..a8921d3e 100644 --- a/web-ui/app/_lib/api.ts +++ b/web-ui/app/_lib/api.ts @@ -4525,3 +4525,115 @@ export async function listWebhookSubscriptionDeliveries( ): Promise<{ deliveries: ConductorWebhookOutboundDelivery[] }> { return getJson(`${WEBHOOKS_BASE}/subscriptions/${encodeURIComponent(id)}/deliveries`); } + +// ----------------------------------------------------------------------------- +// Knowledge-Graph datasets (issue #532 — admin UI for the #430 CSV-import path). +// The full REST surface lives in middleware/src/routes/datasets.ts under +// /api/v1/datasets (cookie-session auth, owner-scoped — cross-owner reads 404). +// ----------------------------------------------------------------------------- + +const DATASETS_BASE = '/v1/datasets'; + +export type DatasetColumnType = 'string' | 'number' | 'boolean' | 'date'; + +export interface DatasetColumnSchema { + name: string; + type: DatasetColumnType; + /** First non-empty value, surfaced as a schema-preview hint. */ + sample?: string; +} + +export interface DatasetSummary { + id: string; + name: string; + sourceFileName: string; + ownerOmadiaUserId: string; + rowCount: number; + columns: DatasetColumnSchema[]; + createdAt: string; +} + +/** 201 body of POST /api/v1/datasets — the multipart CSV upload. */ +export interface DatasetUploadResult { + dataset: { datasetId: string; rowCount: number; graphNodeId: string }; + privacyScan: { scannedCells: number; maskedCells: number }; + truncation: { truncatedCellCount: number; truncatedColumns: string[] }; +} + +/** + * GET /api/v1/datasets/:id/rows. `rows` is populated for a row query (no + * aggregate) — but `KnowledgeGraph.DatasetQueryResult.rows` is optional, so + * mirror that and let callers guard rather than assume it's always present. + */ +export interface DatasetRowsResult { + rows?: Array>; + /** Pre-limit match count, for a "showing X of Y" hint. */ + totalMatched: number; +} + +/** GET /api/v1/datasets — the owner-scoped, paginated dataset list. */ +export interface DatasetListResult { + items: DatasetSummary[]; + /** Total datasets the caller owns, before limit/offset. */ + total: number; + limit: number; + offset: number; +} + +export async function listDatasets( + opts: { limit?: number; offset?: number } = {}, +): Promise { + const params = new URLSearchParams(); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return getJson(`${DATASETS_BASE}${qs ? `?${qs}` : ''}`); +} + +export async function getDataset(id: string): Promise { + return getJson(`${DATASETS_BASE}/${encodeURIComponent(id)}`); +} + +export async function getDatasetRows( + id: string, + opts: { limit?: number; offset?: number } = {}, +): Promise { + const params = new URLSearchParams(); + if (opts.limit !== undefined) params.set('limit', String(opts.limit)); + if (opts.offset !== undefined) params.set('offset', String(opts.offset)); + const qs = params.toString(); + return getJson( + `${DATASETS_BASE}/${encodeURIComponent(id)}/rows${qs ? `?${qs}` : ''}`, + ); +} + +/** + * Uploads a CSV as `multipart/form-data`. Content-Type is NOT set manually — + * the browser generates the boundary (mirrors `uploadPackage`). + */ +export async function uploadDataset( + file: File, + name: string, +): Promise { + const forwarded = await forwardCookieHeader(); + const form = new FormData(); + form.append('file', file, file.name); + if (name.trim().length > 0) form.append('name', name.trim()); + const res = await fetch(botApi(DATASETS_BASE), { + method: 'POST', + body: form, + headers: { accept: 'application/json', ...forwarded }, + credentials: 'include', + cache: 'no-store', + }); + const text = await res.text(); + if (!res.ok) { + maybeNavigateToLogin(res.status); + throw new ApiError(res.status, `POST ${DATASETS_BASE} failed: ${res.status}`, text); + } + return JSON.parse(text) as DatasetUploadResult; +} + +export async function deleteDataset(id: string): Promise { + return deleteRequest(`${DATASETS_BASE}/${encodeURIComponent(id)}`); +} diff --git a/web-ui/app/_lib/test-utils.tsx b/web-ui/app/_lib/test-utils.tsx index 0928dbf3..088c95af 100644 --- a/web-ui/app/_lib/test-utils.tsx +++ b/web-ui/app/_lib/test-utils.tsx @@ -31,7 +31,11 @@ export function renderWithIntl( const { locale = 'en', ...rest } = options; function Wrapper({ children }: { children: ReactNode }): ReactElement { return ( - + {children} ); diff --git a/web-ui/app/admin/datasets/__tests__/page.test.tsx b/web-ui/app/admin/datasets/__tests__/page.test.tsx new file mode 100644 index 00000000..853834b1 --- /dev/null +++ b/web-ui/app/admin/datasets/__tests__/page.test.tsx @@ -0,0 +1,301 @@ +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { renderWithIntl } from '../../../_lib/test-utils'; +// Resolves to the mocked class defined in vi.mock below — `instanceof ApiError` +// in the page's error mapper matches rejections thrown with it. +import { ApiError } from '../../../_lib/api'; +import AdminDatasetsPage from '../page'; + +/** + * Coverage for /admin/datasets (issue #532): + * - lists the owner's datasets (paginated), + * - deletes only after the inline confirm step (two-click guard), and + * reports a delete failure as a delete error (not a load error), + * - opens a detail view with the inferred schema + a paginated row preview, + * - surfaces the mandatory privacy-scan + truncation stats after an upload. + */ + +const { + mockListDatasets, + mockGetDataset, + mockGetDatasetRows, + mockDeleteDataset, + mockUploadDataset, +} = vi.hoisted(() => ({ + mockListDatasets: vi.fn(), + mockGetDataset: vi.fn(), + mockGetDatasetRows: vi.fn(), + mockDeleteDataset: vi.fn(), + mockUploadDataset: vi.fn(), +})); + +vi.mock('../../../_lib/api', () => ({ + ApiError: class ApiError extends Error { + constructor( + public status: number, + message: string, + public body = '', + ) { + super(message); + } + }, + listDatasets: mockListDatasets, + getDataset: mockGetDataset, + getDatasetRows: mockGetDatasetRows, + deleteDataset: mockDeleteDataset, + uploadDataset: mockUploadDataset, +})); + +function peopleDataset() { + return { + id: 'ds-1', + name: 'People', + sourceFileName: 'people.csv', + ownerOmadiaUserId: 'user-1', + rowCount: 60, + columns: [ + { name: 'name', type: 'string' as const, sample: 'Ada' }, + { name: 'age', type: 'number' as const, sample: '36' }, + ], + createdAt: '2026-08-01T10:00:00.000Z', + }; +} + +/** A page-of-25 rows keyed on the requested offset, over a 60-row dataset — so + * the prev/next controls and their offset arithmetic actually render. */ +function rowsPage(offset: number) { + return { + rows: Array.from({ length: Math.min(25, 60 - offset) }, (_, i) => ({ + name: `person-${String(offset + i)}`, + age: offset + i, + })), + totalMatched: 60, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockListDatasets.mockResolvedValue({ + items: [peopleDataset()], + total: 1, + limit: 50, + offset: 0, + }); + mockGetDataset.mockResolvedValue(peopleDataset()); + mockGetDatasetRows.mockImplementation((_id: string, opts?: { offset?: number }) => + Promise.resolve(rowsPage(opts?.offset ?? 0)), + ); + mockDeleteDataset.mockResolvedValue(undefined); +}); + +describe('AdminDatasetsPage', () => { + it('lists datasets from the API', async () => { + renderWithIntl(); + expect(await screen.findByText('People')).toBeInTheDocument(); + expect(mockListDatasets).toHaveBeenCalledWith({ limit: 50, offset: 0 }); + }); + + it('requires a confirm click before deleting', async () => { + const user = userEvent.setup(); + renderWithIntl(); + await screen.findByText('People'); + + await user.click(screen.getByRole('button', { name: /^delete people$/i })); + expect(mockDeleteDataset).not.toHaveBeenCalled(); + + await user.click( + screen.getByRole('button', { name: /confirm delete of people/i }), + ); + await waitFor(() => expect(mockDeleteDataset).toHaveBeenCalledWith('ds-1')); + }); + + it('reports a failed delete as a delete error and keeps the confirm armed', async () => { + const user = userEvent.setup(); + mockDeleteDataset.mockRejectedValue( + new ApiError( + 404, + 'DELETE failed: 404', + JSON.stringify({ code: 'dataset.not_found' }), + ), + ); + renderWithIntl(); + await screen.findByText('People'); + + await user.click(screen.getByRole('button', { name: /^delete people$/i })); + await user.click( + screen.getByRole('button', { name: /confirm delete of people/i }), + ); + + // Rendered via the dedicated delete-error key, not the load-error banner. + expect(await screen.findByText(/delete failed/i)).toBeInTheDocument(); + // Confirm stays armed for a retry. + expect( + screen.getByRole('button', { name: /confirm delete of people/i }), + ).toBeInTheDocument(); + }); + + it('opens the detail view with schema and row preview', async () => { + const user = userEvent.setup(); + renderWithIntl(); + await screen.findByText('People'); + + await user.click(screen.getByRole('button', { name: 'People' })); + + await waitFor(() => expect(mockGetDataset).toHaveBeenCalledWith('ds-1')); + expect(await screen.findByText('person-0')).toBeInTheDocument(); + expect(mockGetDatasetRows).toHaveBeenCalledWith('ds-1', { + limit: 25, + offset: 0, + }); + }); + + it('pages the row preview forward with the right offset', async () => { + const user = userEvent.setup(); + renderWithIntl(); + await screen.findByText('People'); + await user.click(screen.getByRole('button', { name: 'People' })); + await screen.findByText('person-0'); + + // Both the list footer and the row preview have a "Next" — the list one is + // disabled (total 1), so page the enabled (row-preview) control. + const nextButtons = screen.getAllByRole('button', { name: /next/i }); + const rowsNext = nextButtons.find( + (b) => !(b as HTMLButtonElement).disabled, + ); + await user.click(rowsNext as HTMLElement); + + await waitFor(() => + expect(mockGetDatasetRows).toHaveBeenCalledWith('ds-1', { + limit: 25, + offset: 25, + }), + ); + expect(await screen.findByText('person-25')).toBeInTheDocument(); + }); + + it('surfaces the privacy-scan stats after an upload', async () => { + const user = userEvent.setup(); + mockUploadDataset.mockResolvedValue({ + dataset: { datasetId: 'ds-2', rowCount: 5, graphNodeId: 'n-2' }, + privacyScan: { scannedCells: 20, maskedCells: 3 }, + truncation: { truncatedCellCount: 0, truncatedColumns: [] }, + }); + renderWithIntl(); + await screen.findByText('People'); + + const file = new File(['name,age\nAda,36\n'], 'people.csv', { + type: 'text/csv', + }); + const input = screen.getByLabelText(/csv file/i); + await user.upload(input, file); + await user.click(screen.getByRole('button', { name: /^upload$/i })); + + await waitFor(() => + // The name is auto-derived from the file name (nameTouched === false). + expect(mockUploadDataset).toHaveBeenCalledWith(file, 'people'), + ); + expect(await screen.findByText(/3 of 20 cells masked/i)).toBeInTheDocument(); + }); + + it('renders the truncation warning when cells were cut', async () => { + const user = userEvent.setup(); + mockUploadDataset.mockResolvedValue({ + dataset: { datasetId: 'ds-3', rowCount: 5, graphNodeId: 'n-3' }, + privacyScan: { scannedCells: 20, maskedCells: 0 }, + truncation: { truncatedCellCount: 2, truncatedColumns: ['bio'] }, + }); + renderWithIntl(); + await screen.findByText('People'); + + const file = new File(['name,bio\nAda,x\n'], 'people.csv', { + type: 'text/csv', + }); + await user.upload(screen.getByLabelText(/csv file/i), file); + await user.click(screen.getByRole('button', { name: /^upload$/i })); + + expect(await screen.findByText(/truncated in: bio/i)).toBeInTheDocument(); + }); + + it('surfaces a friendly per-code message from a 422 upload error', async () => { + const user = userEvent.setup(); + mockUploadDataset.mockRejectedValue( + new ApiError( + 422, + 'POST /v1/datasets failed: 422', + JSON.stringify({ + code: 'dataset.unsupported_type', + message: 'only CSV is supported', + }), + ), + ); + renderWithIntl(); + await screen.findByText('People'); + + const file = new File(['x,y\n1,2\n'], 'bad.csv', { type: 'text/csv' }); + await user.upload(screen.getByLabelText(/csv file/i), file); + await user.click(screen.getByRole('button', { name: /^upload$/i })); + + await waitFor(() => expect(mockUploadDataset).toHaveBeenCalledOnce()); + // The per-code catalog line, wrapped in the uploadError key. + expect( + await screen.findByText(/only csv files are supported/i), + ).toBeInTheDocument(); + }); + + it('paginates the dataset list and disables Prev on the first page', async () => { + const user = userEvent.setup(); + mockListDatasets.mockResolvedValueOnce({ + items: [peopleDataset()], + total: 60, + limit: 50, + offset: 0, + }); + mockListDatasets.mockResolvedValueOnce({ + items: [{ ...peopleDataset(), id: 'ds-2', name: 'Later' }], + total: 60, + limit: 50, + offset: 50, + }); + renderWithIntl(); + await screen.findByText('People'); + + const footer = screen.getByText(/showing 1–1 of 60/i); + expect(footer).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /prev/i })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: /next/i })); + await waitFor(() => + expect(mockListDatasets).toHaveBeenLastCalledWith({ + limit: 50, + offset: 50, + }), + ); + }); + + it('shows the empty state', async () => { + mockListDatasets.mockResolvedValue({ + items: [], + total: 0, + limit: 50, + offset: 0, + }); + renderWithIntl(); + expect(await screen.findByText(/no datasets yet/i)).toBeInTheDocument(); + }); + + it('closes the detail panel and dismisses a detail error', async () => { + const user = userEvent.setup(); + renderWithIntl(); + await screen.findByText('People'); + await user.click(screen.getByRole('button', { name: 'People' })); + const panel = await screen.findByText('person-0'); + expect(panel).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /close/i })); + await waitFor(() => + expect(screen.queryByText('person-0')).not.toBeInTheDocument(), + ); + }); +}); diff --git a/web-ui/app/admin/datasets/page.tsx b/web-ui/app/admin/datasets/page.tsx new file mode 100644 index 00000000..ec848bb4 --- /dev/null +++ b/web-ui/app/admin/datasets/page.tsx @@ -0,0 +1,681 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import Link from 'next/link'; +import { useFormatter, useTranslations } from 'next-intl'; + +import { Button } from '@/app/_components/ui/Button'; +import { + ApiError, + deleteDataset, + getDataset, + getDatasetRows, + listDatasets, + uploadDataset, + type DatasetRowsResult, + type DatasetSummary, + type DatasetUploadResult, +} from '../../_lib/api'; + +/** + * Admin → Knowledge · Datasets (issue #532). + * + * The web-ui face of the #430 CSV-import path: upload a CSV, browse the + * inferred schema + a row preview, and delete. The REST surface + * (`/bot-api/v1/datasets*`, cookie-session auth, owner-scoped) already exists + * and is tested server-side (middleware/test/datasetsRoute.test.ts); this page + * wires routing, an API client, and i18n. + * + * Scope note: the list is owner-scoped — it shows only datasets the current + * user owns, NOT every dataset in the instance. CSVs other users imported from + * chat attachments are stored under their own owner id and are not shown here + * (see the intro copy and the /admin index card). + * + * The upload surfaces the mandatory privacy-scan and truncation stats returned + * by the ingest pipeline — the whole point of #430 is that CSVs are scanned for + * PII before they land in the graph, so the operator sees what was masked. + */ + +const ROWS_PAGE_SIZE = 25; +const LIST_PAGE_SIZE = 50; + +// Match the admin table idiom (app/admin/users, app/admin/mcp): a bordered +// `overflow-x-auto` wrapper, a faint `bg-card/40` head, `px-4 py-3` cells and +// subtle `border-t` row separators. (A global `tbody tr:hover` fill from +// globals.css still applies — these utility classes just add nothing on top.) +const TABLE_WRAP = + 'overflow-x-auto rounded-lg border border-[color:var(--border)]'; +const TH_CLS = + 'px-4 py-3 text-left text-[11px] font-medium uppercase tracking-[0.16em] text-[color:var(--fg-muted)]'; +const TD_CLS = 'px-4 py-3 text-sm align-top'; +const ROW_CLS = 'border-t border-[color:var(--border)]/50'; +// Neutral type pill for the schema table — the inline-span badge idiom shared +// across admin pages (users status, duplicates status). +const TYPE_BADGE = + 'inline-flex items-center rounded-full bg-[color:var(--border)]/40 px-2 py-0.5 font-mono text-[10px] uppercase tracking-[0.16em] text-[color:var(--fg-muted)]'; +// Pagination controls need a ≥24px hit target (WCAG 2.5.8); the `-mx-2` keeps +// the visual position while the padding grows the target. +const PAGER_BTN = + '-mx-2 px-2 py-1 text-xs hover:text-[color:var(--fg-strong)] disabled:opacity-40'; + +// The known middleware error codes we render as friendly, per-code copy; every +// other code falls back to the generic `errorByCode` line. +const KNOWN_ERROR_CODES = new Set([ + 'limit_file_size', + 'unsupported_type', + 'import_failed', + 'not_found', +]); + +/** + * The middleware answers every dataset error as a structured `{ code, message }` + * JSON body (never an HTML error page). Return the `dataset.`-stripped code so + * the caller can map it to a catalog key; null when the error isn't an ApiError + * (e.g. a network `TypeError`) or carries no code. + */ +function datasetErrorCode(err: unknown): string | null { + if (!(err instanceof ApiError)) return null; + try { + const parsed = JSON.parse(err.body) as { code?: string }; + if (typeof parsed.code !== 'string') return null; + return parsed.code.replace(/^dataset\./, ''); + } catch { + return null; + } +} + +export default function AdminDatasetsPage(): React.ReactElement { + const t = useTranslations('adminDatasets'); + const format = useFormatter(); + const fileInputRef = useRef(null); + const detailRef = useRef(null); + // Monotonic generation counter: every detail fetch captures the current value + // and bails after its await if a newer fetch has since started, so a slow + // response can never overwrite a fresher dataset's rows (schema/row mismatch). + const detailSeq = useRef(0); + + const [items, setItems] = useState(null); + const [total, setTotal] = useState(0); + const [listOffset, setListOffset] = useState(0); + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(true); + + const [file, setFile] = useState(null); + const [name, setName] = useState(''); + const [nameTouched, setNameTouched] = useState(false); + const [uploading, setUploading] = useState(false); + const [uploadError, setUploadError] = useState(null); + const [uploadResult, setUploadResult] = useState( + null, + ); + + const [selected, setSelected] = useState(null); + const [rows, setRows] = useState(null); + const [rowsOffset, setRowsOffset] = useState(0); + const [rowsLoading, setRowsLoading] = useState(false); + const [openingId, setOpeningId] = useState(null); + const [detailError, setDetailError] = useState(null); + + const [confirmDelete, setConfirmDelete] = useState(null); + const [deleting, setDeleting] = useState(null); + const [deleteError, setDeleteError] = useState(null); + + /** Turn an error into a user-facing string via its structured code. */ + const errorMessage = useCallback( + (err: unknown): string => { + const code = datasetErrorCode(err); + if (code !== null) { + return KNOWN_ERROR_CODES.has(code) + ? t(`errorCode.${code}`) + : t('errorByCode', { code }); + } + return err instanceof Error ? err.message : String(err); + }, + [t], + ); + + const reload = useCallback( + async (offset = 0): Promise => { + setLoading(true); + setLoadError(null); + try { + const res = await listDatasets({ limit: LIST_PAGE_SIZE, offset }); + setItems(res.items); + setTotal(res.total); + setListOffset(res.offset); + } catch (err) { + setLoadError(errorMessage(err)); + setItems(null); + } finally { + setLoading(false); + } + }, + [errorMessage], + ); + + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + void reload(); + }, [reload]); + + // Move the opening panel into view once it has actually mounted — it otherwise + // sits below the upload section and the whole list, off-screen on a long list. + // Scrolling inside openDetail would fire before the panel renders (ref null on + // the first open), so key it on the mount instead. + useEffect(() => { + if (openingId !== null && typeof detailRef.current?.scrollIntoView === 'function') { + detailRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }, [openingId]); + + const onUpload = useCallback(async (): Promise => { + if (file === null) return; + setUploading(true); + setUploadError(null); + setUploadResult(null); + try { + const result = await uploadDataset(file, name); + setUploadResult(result); + setFile(null); + setName(''); + setNameTouched(false); + if (fileInputRef.current !== null) fileInputRef.current.value = ''; + await reload(); + } catch (err) { + setUploadError(errorMessage(err)); + } finally { + setUploading(false); + } + }, [file, name, reload, errorMessage]); + + const openDetail = useCallback( + async (ds: DatasetSummary): Promise => { + const my = ++detailSeq.current; + setDetailError(null); + setRows(null); + setRowsOffset(0); + setSelected(null); + setOpeningId(ds.id); + setRowsLoading(true); + try { + const [detail, firstRows] = await Promise.all([ + getDataset(ds.id), + getDatasetRows(ds.id, { limit: ROWS_PAGE_SIZE, offset: 0 }), + ]); + if (my !== detailSeq.current) return; + setSelected(detail); + setRows(firstRows); + } catch (err) { + if (my !== detailSeq.current) return; + setSelected(null); + setDetailError(errorMessage(err)); + } finally { + if (my === detailSeq.current) { + setRowsLoading(false); + setOpeningId(null); + } + } + }, + [errorMessage], + ); + + const loadRowsPage = useCallback( + async (offset: number): Promise => { + if (selected === null) return; + const my = ++detailSeq.current; + setDetailError(null); + setRowsLoading(true); + try { + const page = await getDatasetRows(selected.id, { + limit: ROWS_PAGE_SIZE, + offset, + }); + if (my !== detailSeq.current) return; + setRows(page); + setRowsOffset(offset); + } catch (err) { + if (my !== detailSeq.current) return; + setDetailError(errorMessage(err)); + } finally { + if (my === detailSeq.current) setRowsLoading(false); + } + }, + [selected, errorMessage], + ); + + const closeDetail = useCallback((): void => { + // Invalidate any in-flight fetch so it can't reopen the panel after close. + detailSeq.current += 1; + setSelected(null); + setRows(null); + setDetailError(null); + setOpeningId(null); + }, []); + + const onDelete = useCallback( + async (id: string): Promise => { + setDeleting(id); + setDeleteError(null); + try { + await deleteDataset(id); + if (selected?.id === id) closeDetail(); + setConfirmDelete(null); + // If we just removed the last row of a non-first page, step back so the + // operator doesn't land on an empty page. + const stepBack = + items !== null && items.length === 1 && listOffset > 0; + await reload(stepBack ? Math.max(0, listOffset - LIST_PAGE_SIZE) : listOffset); + } catch (err) { + // A failed delete is a delete error, not a load error — and reload() so + // the table reflects reality (the row may already be gone). Leave + // confirmDelete armed so a retry doesn't restart the two-click guard. + setDeleteError(errorMessage(err)); + await reload(listOffset); + } finally { + setDeleting(null); + } + }, + [selected, items, listOffset, reload, closeDetail, errorMessage], + ); + + const rowsShown = rows?.rows ?? []; + const listFrom = total === 0 ? 0 : listOffset + 1; + const listTo = listOffset + (items?.length ?? 0); + + return ( +
+
+ + ← /admin + +

+ {t('title')} +

+

+ {t('intro')} +

+
+ + {/* Upload */} +
+

+ {t('upload.heading')} +

+
+
+ { + const next = e.target.files?.[0] ?? null; + setFile(next); + setUploadError(null); + setUploadResult(null); + // Re-derive the name from the file on every change until the + // operator has typed their own, so replacing the file can't + // leave a stale name from the previous one. + if (next !== null && !nameTouched) { + setName(next.name.replace(/\.csv$/i, '')); + } + }} + className="rounded-md text-sm text-[color:var(--fg-strong)] file:mr-3 file:rounded-md file:border file:border-[color:var(--border)] file:bg-[color:var(--card)] file:px-3 file:py-1.5 file:text-sm file:text-[color:var(--fg-strong)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[color:var(--accent)]" + /> + + {t('upload.fileHint')} + +
+ +
+ +
+
+ + {uploadError !== null && ( +
+ {t('uploadError', { message: uploadError })} +
+ )} + + {uploadResult !== null && ( +
+

+ {t('upload.success', { + rows: uploadResult.dataset.rowCount, + })} +

+

+ {t('upload.privacyScan', { + scanned: uploadResult.privacyScan.scannedCells, + masked: uploadResult.privacyScan.maskedCells, + })} +

+ {uploadResult.truncation.truncatedCellCount > 0 && ( +

+ {t('upload.truncation', { + cells: uploadResult.truncation.truncatedCellCount, + columns: + uploadResult.truncation.truncatedColumns.join(', '), + })} +

+ )} +
+ )} +
+ + {/* List */} +
+

+ {t('list.heading')} +

+ + {loading && ( +

{t('loading')}

+ )} + + {loadError !== null && ( +
+ {t('loadError', { message: loadError })} +
+ )} + + {deleteError !== null && ( +
+ {t('list.deleteFailed', { message: deleteError })} +
+ )} + + {items !== null && !loading && items.length === 0 && ( +
+ {t('empty')} +
+ )} + + {items !== null && items.length > 0 && ( + <> +
+ + + + + + + + + + + + {items.map((ds) => ( + + + + + + + + ))} + +
{t('list.colName')}{t('list.colRows')}{t('list.colColumns')}{t('list.colCreated')} + {t('list.colActions')} +
+ + + {format.number(ds.rowCount)} + + {ds.columns.length} + + {format.dateTime(new Date(ds.createdAt), { + dateStyle: 'medium', + })} + + {confirmDelete === ds.id ? ( + + + + + ) : ( + + )} +
+
+
+ + {t('list.showing', { + from: listFrom, + to: listTo, + total, + })} + + + + + +
+ + )} +
+ + {/* Detail: schema + row preview */} + {detailError !== null && ( +
+ {t('detailError', { message: detailError })} +
+ )} + + {(selected !== null || openingId !== null) && ( +
+ {selected === null ? ( +

+ {t('loading')} +

+ ) : ( + <> +
+

+ {t('detail.heading', { name: selected.name })} +

+ +
+ + {/* Schema */} +

+ {t('detail.schemaHeading')} +

+
+ + + + + + + + + + {selected.columns.map((col) => ( + + + + + + ))} + +
{t('detail.colColumn')}{t('detail.colType')}{t('detail.colSample')}
{col.name} + {col.type} + + {col.sample ?? '—'} +
+
+ + {/* Row preview */} +

+ {t('detail.rowsHeading')} +

+ {rows !== null && rowsShown.length > 0 ? ( + <> +
+ + + + {selected.columns.map((col) => ( + + ))} + + + + {rowsShown.map((row, i) => ( + + {selected.columns.map((col) => ( + + ))} + + ))} + +
+ {col.name} +
+ {String(row[col.name] ?? '')} +
+
+
+ + {t('detail.rowsShowing', { + from: rowsOffset + 1, + to: rowsOffset + rowsShown.length, + total: rows.totalMatched, + })} + + + + + +
+ + ) : ( +

+ {rowsLoading ? t('loading') : t('detail.noRows')} +

+ )} + + )} +
+ )} +
+ ); +} diff --git a/web-ui/app/admin/page.tsx b/web-ui/app/admin/page.tsx index ebae6b19..a906672e 100644 --- a/web-ui/app/admin/page.tsx +++ b/web-ui/app/admin/page.tsx @@ -59,6 +59,8 @@ const GROUPS: readonly GroupDef[] = [ { href: '/admin/memory-backend', key: 'memoryBackend' }, // #440 follow-up — live embeddingClient@1 provider switch. { href: '/admin/embedding-provider', key: 'embeddingProvider' }, + // #532 — admin UI for the #430 CSV-dataset ingestion path. + { href: '/admin/datasets', key: 'datasets' }, ], }, { diff --git a/web-ui/i18n/request.ts b/web-ui/i18n/request.ts index 49c3ac17..e5379c22 100644 --- a/web-ui/i18n/request.ts +++ b/web-ui/i18n/request.ts @@ -99,5 +99,9 @@ export default getRequestConfig(async () => { async function loadConfig(locale: Locale) { const messages = (await import(`../messages/${locale}.json`)).default; - return { locale, messages }; + // An explicit zone is required app-wide: without it, next-intl's + // `format.dateTime` raises IntlError ENVIRONMENT_FALLBACK (logged via + // console.error, in production too) on every call. UTC keeps rendered + // dates deterministic instead of drifting with the server/viewer zone. + return { locale, messages, timeZone: 'UTC' }; } diff --git a/web-ui/messages/de.json b/web-ui/messages/de.json index 351c7623..2b2e186e 100644 --- a/web-ui/messages/de.json +++ b/web-ui/messages/de.json @@ -209,6 +209,10 @@ "title": "Embedding-Provider", "description": "Aktiven Embedding-Adapter im laufenden Betrieb wechseln — ohne Neustart. Zeigt Dimensions-Gate, gespeicherten Korpus und was ein Wechsel verwirft." }, + "datasets": { + "title": "Datensätze", + "description": "CSV-Datensätze hochladen, das abgeleitete Schema und eine Zeilen-Vorschau ansehen und löschen. Zeigt die Datensätze, die dir gehören — CSVs, die andere aus dem Chat importiert haben, erscheinen nicht. Jeder Upload wird vor der Ablage im Graph auf PII geprüft." + }, "domains": { "title": "Domains", "description": "Registrierte Plugins nach Domain gruppiert ansehen (read-only)." @@ -3799,6 +3803,65 @@ "forbiddenError": "Nicht berechtigt (403) — das Umschalten des Backends erfordert Admin-Rechte.", "noProviderError": "Kein Memory-Provider registriert (409) — die Auswahl kann nicht gespeichert werden." }, + "adminDatasets": { + "title": "Wissen · Datensätze", + "intro": "CSV hochladen, das abgeleitete Schema und eine Zeilen-Vorschau ansehen und löschen. Jeder Upload wird vor der Ablage im Knowledge-Graph auf PII geprüft. Zeigt die Datensätze, die dir gehören — CSVs, die andere aus Chat-Anhängen importiert haben, erscheinen hier nicht.", + "loading": "lädt…", + "loadError": "Laden fehlgeschlagen: {message}", + "empty": "Noch keine Datensätze — lade eine CSV hoch, um zu starten.", + "errorByCode": "Anfrage fehlgeschlagen ({code}).", + "errorCode": { + "limit_file_size": "Die Datei ist zu groß — das Upload-Limit liegt bei 25 MB.", + "unsupported_type": "Es werden nur CSV-Dateien unterstützt.", + "import_failed": "Die CSV konnte nicht importiert werden — prüfe, ob sie eine Kopfzeile und gültige Zeilen hat.", + "not_found": "Dieser Datensatz existiert nicht mehr — er wurde möglicherweise bereits gelöscht." + }, + "uploadError": "Upload fehlgeschlagen: {message}", + "detailError": "Der Datensatz konnte nicht geöffnet werden: {message}", + "upload": { + "heading": "CSV hochladen", + "fileLabel": "CSV-Datei", + "fileHint": "Nur CSV, bis zu 25 MB.", + "nameLabel": "Name des Datensatzes", + "namePlaceholder": "Standard ist der Dateiname", + "submit": "Hochladen", + "uploading": "lädt hoch…", + "success": "{rows, plural, one {# Zeile} other {# Zeilen}} importiert.", + "privacyScan": "Privacy-Scan: {masked, number} von {scanned, number} Zellen maskiert.", + "truncation": "{cells, plural, one {# Zelle wurde} other {# Zellen wurden}} gekürzt in: {columns}." + }, + "list": { + "heading": "Datensätze", + "colName": "Name", + "colRows": "Zeilen", + "colColumns": "Spalten", + "colCreated": "Erstellt", + "colActions": "Aktionen", + "delete": "Löschen", + "deleteAria": "{name} löschen", + "confirmDelete": "Löschen bestätigen", + "confirmDeleteAria": "Löschen von {name} bestätigen", + "deleting": "löscht…", + "deleteFailed": "Löschen fehlgeschlagen: {message}", + "cancel": "Abbrechen", + "showing": "Zeige {from, number}–{to, number} von {total, number}", + "prev": "← Zurück", + "next": "Weiter →" + }, + "detail": { + "heading": "Datensatz · {name}", + "close": "Schließen", + "schemaHeading": "Schema", + "colColumn": "Spalte", + "colType": "Typ", + "colSample": "Beispiel", + "rowsHeading": "Zeilen-Vorschau", + "rowsShowing": "Zeige {from, number}–{to, number} von {total, number}", + "noRows": "Keine Zeilen.", + "prev": "← Zurück", + "next": "Weiter →" + } + }, "adminEmbeddingProvider": { "title": "Embeddings · Provider", "intro": "Wechselt den aktiven embeddingClient@1-Provider im laufenden Betrieb — ohne Neustart. Die Middleware deaktiviert den aktuellen Adapter, aktiviert den neuen und lässt das Dimensions-Gate des Knowledge-Graph erneut laufen; bei abweichender Vektorbreite baut es die vector(n)-Spalten um. Jedes gespeicherte Embedding wird dabei verworfen und vom Backfill neu berechnet — ein Provider-Call pro Zeile.", diff --git a/web-ui/messages/en.json b/web-ui/messages/en.json index 121b2109..4ed4d11a 100644 --- a/web-ui/messages/en.json +++ b/web-ui/messages/en.json @@ -209,6 +209,10 @@ "title": "Embedding Provider", "description": "Switch the active embedding adapter live — no restart. Shows the dimension gate, the stored corpus, and what a switch would discard." }, + "datasets": { + "title": "Datasets", + "description": "Upload CSV datasets, browse the inferred schema and a row preview, and delete. Lists the datasets you own — CSVs other users imported from chat are not shown. Every upload is privacy-scanned for PII before it lands in the graph." + }, "domains": { "title": "Domains", "description": "Browse registered plugins grouped by domain (read-only)." @@ -3799,6 +3803,65 @@ "forbiddenError": "Not authorized (403) — switching the backend requires admin rights.", "noProviderError": "No memory provider registered (409) — the choice cannot be saved." }, + "adminDatasets": { + "title": "Knowledge · Datasets", + "intro": "Upload a CSV, browse its inferred schema and a row preview, and delete it. Every upload is privacy-scanned for PII before it lands in the knowledge graph. Lists the datasets you own — CSVs other users imported from chat attachments are not shown here.", + "loading": "loading…", + "loadError": "Loading failed: {message}", + "empty": "No datasets yet — upload a CSV to get started.", + "errorByCode": "Request failed ({code}).", + "errorCode": { + "limit_file_size": "The file is too large — the upload limit is 25 MB.", + "unsupported_type": "Only CSV files are supported.", + "import_failed": "The CSV could not be imported — check that it has a header row and valid rows.", + "not_found": "That dataset no longer exists — it may have been deleted already." + }, + "uploadError": "Upload failed: {message}", + "detailError": "Could not open the dataset: {message}", + "upload": { + "heading": "Upload CSV", + "fileLabel": "CSV file", + "fileHint": "CSV only, up to 25 MB.", + "nameLabel": "Dataset name", + "namePlaceholder": "Defaults to the file name", + "submit": "Upload", + "uploading": "uploading…", + "success": "Imported {rows, plural, one {# row} other {# rows}}.", + "privacyScan": "Privacy scan: {masked, number} of {scanned, number} cells masked.", + "truncation": "{cells, plural, one {# cell was} other {# cells were}} truncated in: {columns}." + }, + "list": { + "heading": "Datasets", + "colName": "Name", + "colRows": "Rows", + "colColumns": "Columns", + "colCreated": "Created", + "colActions": "Actions", + "delete": "Delete", + "deleteAria": "Delete {name}", + "confirmDelete": "Confirm delete", + "confirmDeleteAria": "Confirm delete of {name}", + "deleting": "deleting…", + "deleteFailed": "Delete failed: {message}", + "cancel": "Cancel", + "showing": "Showing {from, number}–{to, number} of {total, number}", + "prev": "← Prev", + "next": "Next →" + }, + "detail": { + "heading": "Dataset · {name}", + "close": "Close", + "schemaHeading": "Schema", + "colColumn": "Column", + "colType": "Type", + "colSample": "Sample", + "rowsHeading": "Row preview", + "rowsShowing": "Showing {from, number}–{to, number} of {total, number}", + "noRows": "No rows.", + "prev": "← Prev", + "next": "Next →" + } + }, "adminEmbeddingProvider": { "title": "Embeddings · Provider", "intro": "Switches the active embeddingClient@1 provider live — without a restart. The middleware deactivates the current adapter, activates the target and re-runs the knowledge-graph dimension gate, which rewrites the vector(n) columns when the width changes. Every stored embedding is discarded and re-earned by the backfill sweep, one provider call per row.", diff --git a/web-ui/package-lock.json b/web-ui/package-lock.json index bdc96a8a..83309669 100644 --- a/web-ui/package-lock.json +++ b/web-ui/package-lock.json @@ -4226,9 +4226,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { diff --git a/web-ui/package.json b/web-ui/package.json index 1b42f7e4..c8b537b7 100644 --- a/web-ui/package.json +++ b/web-ui/package.json @@ -72,7 +72,7 @@ "undici": "^7.28.0", "postcss": "8.5.23", "sharp": "0.35.3", - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "minimatch": "10.2.5", "dompurify": "3.4.12" }