Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
12 changes: 0 additions & 12 deletions docs/middleware-agent-handoff.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
12 changes: 6 additions & 6 deletions middleware/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions middleware/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2923,15 +2923,25 @@ export class InMemoryKnowledgeGraph implements KnowledgeGraph {
async listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
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<number> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -702,19 +702,31 @@ export class NeonKnowledgeGraph implements KnowledgeGraph {
async listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
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<DatasetRow>(
`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<number> {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -616,9 +616,13 @@ export class CaptureFilteringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -542,9 +542,13 @@ export class InconsistencyTriggeringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -597,9 +597,13 @@ export class MergeTriggeringKnowledgeGraph implements KnowledgeGraph {
listDatasets(opts: {
ownerOmadiaUserId: string;
limit?: number;
offset?: number;
}): Promise<DatasetSummary[]> {
return this.inner.listDatasets(opts);
}
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number> {
return this.inner.countDatasets(opts);
}
getDataset(
datasetId: string,
viewerOmadiaUserId: string,
Expand Down
10 changes: 9 additions & 1 deletion middleware/packages/plugin-api/src/knowledgeGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -704,11 +704,19 @@ export interface KnowledgeGraph {
* KnowledgeGraph boundary, never inside it.
*/
ingestDataset(input: DatasetIngest): Promise<DatasetIngestResult>;
/** #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<DatasetSummary[]>;
/** #430 — total datasets owned by the caller, ignoring limit/offset. */
countDatasets(opts: { ownerOmadiaUserId: string }): Promise<number>;
/**
* #430 — read one dataset's metadata + inferred schema. Null when
* missing or the viewer doesn't own it (ACL mirrors `/api/v1/memory`:
Expand Down
20 changes: 18 additions & 2 deletions middleware/src/routes/datasets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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 });
Expand Down
45 changes: 44 additions & 1 deletion middleware/test/datasetsRoute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }> };
Expand All @@ -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();
Expand Down
Loading
Loading