pgkiln 0.50.0 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/119_workspace_home.sql +30 -0
  3. package/db/migrations/120_datasets.sql +87 -0
  4. package/docs/apex-feature-parity.md +5 -4
  5. package/docs/guide/01-installation.md +2 -0
  6. package/docs/guide/03-builder.md +91 -5
  7. package/docs/guide/04-pages-and-regions.md +25 -1
  8. package/docs/guide/12-development.md +26 -3
  9. package/examples/hr/hr_63_datasets.sql +29 -0
  10. package/package.json +1 -1
  11. package/public/app.css +6 -0
  12. package/public/builder.css +34 -0
  13. package/src/app.ts +5 -1
  14. package/src/builder/datasets.ts +472 -0
  15. package/src/builder/home.ts +12 -7
  16. package/src/builder/instance.ts +2 -0
  17. package/src/builder/reporter.ts +52 -3
  18. package/src/builder/routes.ts +3 -3
  19. package/src/builder/subscriptions.ts +1 -0
  20. package/src/builder/ui.ts +7 -5
  21. package/src/builder/workspace-home.ts +283 -0
  22. package/src/builder/workspaces.ts +7 -4
  23. package/src/datasets.ts +367 -0
  24. package/src/i18n/ar.ts +16 -0
  25. package/src/i18n/cs.ts +16 -0
  26. package/src/i18n/da.ts +16 -0
  27. package/src/i18n/de.ts +16 -0
  28. package/src/i18n/el.ts +16 -0
  29. package/src/i18n/es.ts +16 -0
  30. package/src/i18n/fi.ts +16 -0
  31. package/src/i18n/fr.ts +16 -0
  32. package/src/i18n/he.ts +16 -0
  33. package/src/i18n/it.ts +16 -0
  34. package/src/i18n/ja.ts +16 -0
  35. package/src/i18n/ko.ts +16 -0
  36. package/src/i18n/nb.ts +16 -0
  37. package/src/i18n/pl.ts +16 -0
  38. package/src/i18n/pt.ts +16 -0
  39. package/src/i18n/ru.ts +16 -0
  40. package/src/i18n/sv.ts +16 -0
  41. package/src/i18n/tr.ts +16 -0
  42. package/src/i18n/uk.ts +16 -0
  43. package/src/i18n/zh.ts +16 -0
  44. package/src/i18n.ts +32 -0
  45. package/src/instance.ts +1 -0
  46. package/src/release-check.ts +139 -0
  47. package/src/reporting-app.ts +101 -0
  48. package/src/runtime/data-reporter.ts +199 -32
  49. package/src/session.ts +2 -0
  50. package/src/subscriptions.ts +2 -1
  51. package/src/workspace-export.ts +24 -4
@@ -1359,3 +1359,37 @@ pre.wrap-pre { white-space: pre-wrap; overflow-wrap: anywhere; }
1359
1359
  .sm-toolbar:not([hidden]) { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; margin: 0.25rem 0 0.75rem; }
1360
1360
  .sm-toolbar select { width: auto; }
1361
1361
  .sm-toolbar [aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); }
1362
+
1363
+ /* (sprint 109, WS-1) the Workspace home (/builder/home) */
1364
+ .wh-title { margin: 0 0 14px; font-size: var(--fs-xl); overflow-wrap: anywhere; }
1365
+ .wh-summary h2, .wh-top-head h2 { margin: 0 0 10px; }
1366
+ .wh-stats a.stat { color: inherit; text-decoration: none; }
1367
+ .wh-stats a.stat:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
1368
+ .wh-stats a.stat span { color: var(--accent); }
1369
+ .wh-top-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; margin-top: 8px; }
1370
+ .wh-top-head h2 { margin: 0; }
1371
+ .wh-timeframe { display: inline-flex; }
1372
+ .wh-top { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 260px), 1fr)); gap: 16px; margin-top: 10px; }
1373
+ .wh-top > .region { margin: 0; min-width: 0; }
1374
+ .wh-message { margin-bottom: 16px; }
1375
+ .wh-message-text { margin: 0 0 8px; white-space: pre-line; overflow-wrap: anywhere; }
1376
+ .wh-edit > summary { list-style: none; display: inline-flex; align-items: center; gap: 6px; cursor: pointer; }
1377
+ .wh-edit > summary::-webkit-details-marker { display: none; }
1378
+ .wh-edit[open] > summary { margin-bottom: 10px; }
1379
+ .wh-release { display: flex; gap: 0.6rem; align-items: flex-start; }
1380
+ .wh-release p { margin: 0.2rem 0 0; overflow-wrap: anywhere; }
1381
+ .wh-release-form { margin-top: 10px; }
1382
+ @media (max-width: 640px) {
1383
+ .wh-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
1384
+ .wh-stats .stat { padding: 0.6rem 0.75rem; }
1385
+ .wh-stats .stat b { font-size: 1.2rem; }
1386
+ }
1387
+
1388
+ /* (sprint 110) App Builder → Data Reporter: datasets */
1389
+ .dataset-checks { display: flex; flex-direction: column; gap: 0.35rem; margin: 0.25rem 0 0.5rem; overflow-wrap: anywhere; }
1390
+ .dataset-filter { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.4rem; }
1391
+ .dataset-filter > select, .dataset-filter > input { flex: 1 1 10rem; min-width: 0; }
1392
+ .dataset-columns input { min-width: 0; width: 100%; }
1393
+ .dataset-page pre.code { white-space: pre-wrap; overflow-wrap: anywhere; }
1394
+ .dataset-schema { flex-wrap: wrap; }
1395
+ .dataset-schema > select { flex: 1 1 10rem; min-width: 0; }
package/src/app.ts CHANGED
@@ -52,7 +52,9 @@ import { blueprintRoutes } from './builder/blueprints.ts';
52
52
  import { accountRoutes } from './runtime/account.ts';
53
53
  import { taskRoutes } from './runtime/tasks.ts';
54
54
  import { dataReporterRoutes } from './runtime/data-reporter.ts';
55
+ import { datasetRoutes } from './builder/datasets.ts';
55
56
  import { reporterBuilderRoutes } from './builder/reporter.ts';
57
+ import { workspaceHomeRoutes } from './builder/workspace-home.ts';
56
58
  import { formDesignerRoutes } from './runtime/form-designer.ts';
57
59
  import { formDesignerBuilderRoutes } from './builder/form-designer.ts';
58
60
  import { builderErrorPages, builderServerError } from './builder/errors.ts';
@@ -239,6 +241,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
239
241
  await app.register(codeEditorRoutes);
240
242
  await app.register(dataReporterRoutes);
241
243
  await app.register(reporterBuilderRoutes);
244
+ await app.register(datasetRoutes);
242
245
  await app.register(formDesignerRoutes);
243
246
  await app.register(formDesignerBuilderRoutes);
244
247
  await app.register(sampleDataRoutes);
@@ -247,6 +250,7 @@ export async function buildApp(opts: { logger?: boolean } = {}) {
247
250
  await app.register(assistantBuilderRoutes);
248
251
  await app.register(aiBuilderRoutes);
249
252
  await app.register(blueprintRoutes);
250
- app.get('/', async (_req, reply) => reply.redirect('/builder'));
253
+ await app.register(workspaceHomeRoutes);
254
+ app.get('/', async (_req, reply) => reply.redirect('/builder/home'));
251
255
  return app;
252
256
  }
@@ -0,0 +1,472 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import {
3
+ catalogOf, checkDataset, DATASET_LIMITS, DATASET_NAME, datasetFile, datasetOf, datasetSql, proposeDataset, proposeJoin, readDatasetFile,
4
+ readSchemaCatalog, storeDataset, workspaceDatasets, type Dataset, type DatasetRow,
5
+ } from '../datasets.ts';
6
+ import { owner, type Db } from '../db.ts';
7
+ import { html, raw, type Raw } from '../html.ts';
8
+ import { icon } from '../icons.ts';
9
+ import { createReportingApp, MAX_REPORTING_DATASETS, reportingApps } from '../reporting-app.ts';
10
+ import { readMultipart } from '../runtime/files.ts';
11
+ import { heading } from '../runtime/items.ts';
12
+ import { opLabel, OPERATORS } from '../runtime/report.ts';
13
+ import { english } from '../i18n.ts';
14
+ import { logActivity, clientIp, type Session } from '../session.ts';
15
+ import { checkNewApp, createAppError } from './newapp.ts';
16
+ import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
17
+ import { currentWorkspace, usableSchemas, workshopDb } from './workspaces.ts';
18
+
19
+ // (sprint 110, migration 120) App Builder → Data Reporter (/builder/reporter; APEX 26.1: Data Reporter as a
20
+ // workspace tool): the workspace's datasets (tables and views of one schema, joins proposed from foreign keys,
21
+ // the columns business users may see with labels, format masks and descriptions, a row filter), their files
22
+ // (pgkiln-dataset/1: download one or all, import), and reporting applications made from them (two clicks: Create
23
+ // reporting application on a dataset, then Create). Every developer of the workspace manages them; the catalog is
24
+ // read through the SQL Workshop's connection (an isolated workspace's own login), and a dataset is checked
25
+ // against it before it is saved.
26
+
27
+ const root = `${BASE}/reporter`;
28
+ const MAX_FILE_BYTES = 2 * 1024 * 1024;
29
+ const PREVIEW_ROWS = 10;
30
+ const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Data Reporter', root], ...extra];
31
+ const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
32
+
33
+ const page = (s: Session, reply: FastifyReply, title: string, extra: [string, string?][], main: Raw) =>
34
+ send(reply, s, shell(s, title, crumbs(extra), html`<div class="ab-narrow dataset-page">${main}</div>`, 'reporter'));
35
+
36
+ /** A dataset of the current workspace by id (null: none, or another workspace's). */
37
+ async function datasetRow(s: Session, id: string): Promise<DatasetRow | null> {
38
+ if (!/^\d{1,12}$/.test(id)) return null;
39
+ return (await owner.one<DatasetRow>('select * from meta.dataset where id = $1 and workspace_id = $2', [id, currentWorkspace(s)])) ?? null;
40
+ }
41
+
42
+ /** A schema the developer's SQL Workshop connection may use, or null. */
43
+ async function usableSchema(db: Db, schema: string) {
44
+ return schema && (await usableSchemas(db)).includes(schema) ? schema : null;
45
+ }
46
+
47
+ /** "deptno = deptno, x = y" → join column pairs (this table's column = the earlier table's). */
48
+ export function parseOn(text: string) {
49
+ return text
50
+ .split(',')
51
+ .map((p) => p.split('=').map((x) => x.trim()))
52
+ .filter((p) => p.length === 2 && p[0] && p[1])
53
+ .map(([column, ref]) => ({ column, ref }))
54
+ .slice(0, DATASET_LIMITS.joinColumns);
55
+ }
56
+
57
+ /** The dataset of the edit form (loose: checkDataset() does the checking). */
58
+ export function datasetFromForm(d: Dataset, b: Body, cat: Awaited<ReturnType<typeof readSchemaCatalog>>): Record<string, unknown> {
59
+ const tables: any[] = [];
60
+ for (let i = 0; i < DATASET_LIMITS.tables && b[`t${i}_alias`] !== undefined; i++) {
61
+ const old = d.tables.find((t) => t.alias === b[`t${i}_alias`]);
62
+ if (!old || b[`t${i}_remove`] === 'true') continue;
63
+ tables.push(i === 0 || !tables.length ? { alias: old.alias, table: old.table } : {
64
+ alias: old.alias, table: old.table,
65
+ join: { to: String(b[`t${i}_to`] ?? ''), type: b[`t${i}_type`] === 'inner' ? 'inner' : 'left', on: parseOn(String(b[`t${i}_on`] ?? '')) },
66
+ });
67
+ }
68
+ const added = String(b.new_table ?? '');
69
+ if (added && cat.objects.some((o) => o.name === added) && tables.length < DATASET_LIMITS.tables) {
70
+ let n = 1;
71
+ while (tables.some((t) => t.alias === `t${n}`) || d.tables.some((t) => t.alias === `t${n}`)) n++;
72
+ const on = parseOn(String(b.new_on ?? ''));
73
+ const join = tables.length
74
+ ? on.length ? { to: String(b.new_to || tables[0].alias), type: 'left', on } : proposeJoin(added, tables, cat.fks)
75
+ : undefined;
76
+ tables.push({ alias: `t${n}`, table: added, ...(join ? { join } : {}) });
77
+ }
78
+ const columns: any[] = [];
79
+ for (let j = 0; j < 1000 && b[`c${j}_ref`] !== undefined; j++) {
80
+ if (b[`c${j}_on`] !== 'true') continue;
81
+ const [alias, ...rest] = String(b[`c${j}_ref`]).split('.');
82
+ columns.push({ table: alias, column: rest.join('.'), name: b[`c${j}_name`], label: b[`c${j}_label`], format: b[`c${j}_format`], description: b[`c${j}_desc`] });
83
+ }
84
+ // a table added just now: its columns are offered at once (except the join's and binary ones)
85
+ const fresh = tables.find((t) => !d.tables.some((x) => x.alias === t.alias));
86
+ if (fresh) {
87
+ const taken = new Set(columns.map((c) => String(c.name ?? '')));
88
+ const skip = new Set((fresh.join?.on ?? []).map((p: { column: string }) => p.column));
89
+ for (const c of cat.objects.find((o) => o.name === fresh.table)?.columns ?? []) {
90
+ if (c.type === 'bytea' || skip.has(c.name)) continue;
91
+ let name = c.name.toLowerCase().replace(/[^a-z0-9_]+/g, '_').replace(/^[^a-z]+/, '').slice(0, 40) || 'col';
92
+ if (taken.has(name)) name = `${fresh.table}_${name}`.toLowerCase().replace(/[^a-z0-9_]+/g, '_').slice(0, 40);
93
+ taken.add(name);
94
+ columns.push({ table: fresh.alias, column: c.name, name, label: heading(c.name) });
95
+ }
96
+ }
97
+ const filters: any[] = [];
98
+ for (let k = 0; k < DATASET_LIMITS.filters; k++) {
99
+ const ref = String(b[`f${k}_col`] ?? '');
100
+ if (!ref) continue;
101
+ const [alias, ...rest] = ref.split('.');
102
+ filters.push({ table: alias, column: rest.join('.'), op: b[`f${k}_op`], value: b[`f${k}_value`] ?? '' });
103
+ }
104
+ return { name: d.name, label: b.label, description: b.description, schema: d.schema, tables, columns, filters };
105
+ }
106
+
107
+ /** The first rows of a dataset, read only, through the SQL Workshop's connection (a timeout; errors as text). */
108
+ async function preview(db: Db, d: Dataset): Promise<{ fields: string[]; rows: unknown[][] } | { error: string }> {
109
+ if (!d.columns.length) return { error: 'The dataset offers no columns.' };
110
+ try {
111
+ return await db.tx(async (c) => {
112
+ await c.query('set transaction read only');
113
+ await c.query(`set local statement_timeout = 5000`);
114
+ const r = await c.query({ text: `select * from (${datasetSql(d)}) "__q" limit ${PREVIEW_ROWS}`, rowMode: 'array' });
115
+ return { fields: r.fields.map((f) => f.name), rows: r.rows };
116
+ });
117
+ } catch (e) {
118
+ return { error: (e as Error).message };
119
+ }
120
+ }
121
+
122
+ const cellText = (v: unknown) => (v === null || v === undefined ? '' : v instanceof Date ? v.toISOString().slice(0, 19).replace('T', ' ') : typeof v === 'object' ? JSON.stringify(v) : String(v));
123
+
124
+ export async function datasetRoutes(app: FastifyInstance) {
125
+ // ---------------------------------------------------------------- the home: datasets and reporting applications
126
+ app.get(root, async (req: Req, reply) => {
127
+ const s = await developer(req, reply);
128
+ if (!s) return;
129
+ const ws = currentWorkspace(s);
130
+ const sets = await workspaceDatasets(owner, ws);
131
+ const apps = await reportingApps(ws);
132
+ const main = html`<div class="title-row"><h1>Data Reporter</h1>
133
+ <div class="buttons"><a class="btn btn-hot" href="${root}/datasets/new">${icon('plus')} New dataset</a><a class="btn" href="${root}/import">${icon('upload')} Import</a>${sets.length ? html`<a class="btn" href="${root}/export">${icon('download')} Export all</a>` : ''}</div></div>
134
+ <p class="muted u-mt0">Datasets are trusted views of ${s.workspace?.name ?? 'this workspace'}'s data for business users: tables and views of one schema, joined, with the columns they may see, labels, format masks and descriptions, and a row filter. A reporting application on one or more datasets lets its users build, save, publish and share reports; every report runs as the application's database role, so grants and row level security apply.</p>
135
+ ${region('Datasets', html`<div class="table-wrap"><table class="report report-reflow">
136
+ <thead><tr><th scope="col">Dataset</th><th scope="col">Schema</th><th scope="col">Tables and views</th><th scope="col" class="num">Columns</th><th scope="col">Updated (UTC)</th><th scope="col">Actions</th></tr></thead>
137
+ <tbody>${sets.length ? sets.map((d) => html`<tr>
138
+ <td data-label="Dataset"><a href="${root}/datasets/${d.id}">${d.label}</a> <code>${d.name}</code>${d.description ? html`<br><span class="muted small">${d.description.slice(0, 160)}</span>` : ''}</td>
139
+ <td data-label="Schema"><code>${d.schema_name}</code></td>
140
+ <td data-label="Tables and views">${d.tables.map((t) => t.table).join(', ')}</td>
141
+ <td class="num" data-label="Columns">${d.columns.length}</td>
142
+ <td data-label="Updated (UTC)">${when(d.updated_at)}${d.updated_by ? html` <span class="muted">${d.updated_by}</span>` : ''}</td>
143
+ <td data-label=""><a class="btn" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></td>
144
+ </tr>`) : html`<tr><td colspan="6" class="empty">No datasets yet: create one on tables and views of a schema, or import a dataset file.</td></tr>`}</tbody></table></div>`)}
145
+ ${region('Reporting applications', html`<div class="table-wrap"><table class="report report-reflow">
146
+ <thead><tr><th scope="col">Application</th><th scope="col">Datasets</th><th scope="col" class="num">Users</th><th scope="col" class="num">Published reports</th><th scope="col">Actions</th></tr></thead>
147
+ <tbody>${apps.length ? apps.map((a) => html`<tr>
148
+ <td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <code>${a.alias}</code></td>
149
+ <td data-label="Datasets">${a.datasets.join(', ')}</td>
150
+ <td class="num" data-label="Users">${a.users}</td>
151
+ <td class="num" data-label="Published reports">${a.reports}</td>
152
+ <td data-label=""><a class="btn" href="/a/${a.alias}/">${icon('play')} Run</a> <a class="btn" href="${BASE}/apps/${a.id}/shared">${icon('users')} Users and roles</a></td>
153
+ </tr>`) : html`<tr><td colspan="5" class="empty">No reporting applications yet: choose Create reporting application on a dataset.</td></tr>`}</tbody></table></div>
154
+ <p class="muted small">Roles: <b>viewer</b> runs published reports (every user with access), <b>editor</b> also builds, saves, publishes and unpublishes reports, <b>administrator</b> also unpublishes and deletes anyone's. Give accounts access and roles under Users and roles.</p>`)}`;
155
+ return page(s, reply, 'Data Reporter', [], main);
156
+ });
157
+
158
+ // ---------------------------------------------------------------- a new dataset: schema, then tables and views
159
+ app.get(`${root}/datasets/new`, async (req: Req, reply) => {
160
+ const s = await developer(req, reply);
161
+ if (!s) return;
162
+ const db = await workshopDb(s);
163
+ const schemas = await usableSchemas(db);
164
+ const schema = await usableSchema(db, String(req.query.schema ?? ''));
165
+ const cat = schema ? await readSchemaCatalog(db, schema) : null;
166
+ const main = html`<h1>New dataset</h1>
167
+ ${region('1. Schema', html`<form method="get" action="${root}/datasets/new" class="search u-mwnone dataset-schema">
168
+ <label class="label" for="f_schema">Schema</label>
169
+ <select id="f_schema" name="schema">${schemas.map((n) => html`<option${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
170
+ <button class="btn">Show its tables and views</button></form>`)}
171
+ ${cat ? region('2. Tables and views', html`<form method="post" action="${root}/datasets">${csrf(s)}<input type="hidden" name="schema" value="${schema}">
172
+ <div class="form-grid">
173
+ ${input('name', 'Name', '', { required: true, placeholder: 'e.g. sales', help: 'Lower case letters, digits and _ (at most 40): reports and files use it. It can\'t be changed later.' })}
174
+ ${input('label', 'Label', '', { placeholder: 'e.g. Sales', help: 'What business users see.' })}
175
+ </div>
176
+ <fieldset class="prop-group"><legend>Tables and views (the first is the main one)</legend>
177
+ ${cat.objects.length ? html`<div class="dataset-checks">${cat.objects.map((o) => html`<label class="check"><input type="checkbox" name="tables" value="${o.name}"> ${o.name} <span class="muted">${o.kind}, ${o.columns.length} columns</span></label>`)}</div>`
178
+ : html`<p class="muted">The schema has no tables or views you may use.</p>`}
179
+ <small class="help">Joins are proposed from foreign keys; every column is offered at first, labelled from its name. You change all of it on the next page.</small>
180
+ </fieldset>
181
+ <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create dataset</button></div></form>`) : ''}`;
182
+ return page(s, reply, 'New dataset', [['New dataset']], main);
183
+ });
184
+
185
+ app.post(`${root}/datasets`, async (req: Req, reply) => {
186
+ const s = await developer(req, reply);
187
+ if (!s) return;
188
+ const b = req.body ?? {};
189
+ const db = await workshopDb(s);
190
+ const schema = await usableSchema(db, String(b.schema ?? ''));
191
+ const name = String(b.name ?? '').trim().toLowerCase();
192
+ const again = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/datasets/new${schema ? `?schema=${encodeURIComponent(schema)}` : ''}`));
193
+ if (!schema) return again('Choose one of the schemas in the list.');
194
+ if (!DATASET_NAME.test(name)) return again(`"${name}" is not a valid dataset name: lower case letters, digits and _, starting with a letter (at most 40).`);
195
+ const raw = (req.body as unknown as Record<string, unknown>)?.tables;
196
+ const chosen = (Array.isArray(raw) ? raw : raw ? [raw] : []).map(String);
197
+ if (!chosen.length) return again('Choose at least one table or view.');
198
+ const cat = await readSchemaCatalog(db, schema);
199
+ const proposed = proposeDataset(name, String(b.label ?? '').trim(), schema, chosen, cat);
200
+ const { dataset, errors } = checkDataset(proposed.dataset, catalogOf(cat.objects));
201
+ if (errors.length || !dataset.tables.length) return again([...proposed.errors, ...errors].join(' ') || 'The dataset is not valid.');
202
+ const exists = await owner.one('select 1 from meta.dataset where workspace_id = $1 and name = $2', [currentWorkspace(s), name]);
203
+ if (exists) return again(`There is already a dataset ${name}.`);
204
+ const id = await storeDataset(owner, currentWorkspace(s), dataset, s.username, { replace: false });
205
+ logActivity({ appId: null, username: s.username, event: 'dataset_create', ip: clientIp(req), detail: `${name} on ${schema}` });
206
+ flash(s, proposed.errors.length ? `Dataset ${name} created. ${proposed.errors.join(' ')}` : `Dataset ${name} created: check its columns and labels.`, proposed.errors.length ? 'error' : 'ok');
207
+ return back(reply, s, `${root}/datasets/${id}`);
208
+ });
209
+
210
+ // ---------------------------------------------------------------- a dataset
211
+ app.get(`${root}/datasets/:id`, async (req: Req, reply) => {
212
+ const s = await developer(req, reply);
213
+ if (!s) return;
214
+ const row = await datasetRow(s, req.params.id);
215
+ if (!row) return reply.code(404).send('Not found');
216
+ const d = datasetOf(row);
217
+ const db = await workshopDb(s);
218
+ const cat = await readSchemaCatalog(db, d.schema);
219
+ const { errors } = checkDataset({ ...d }, catalogOf(cat.objects));
220
+ const used = (await reportingApps(currentWorkspace(s))).filter((a) => a.datasets.includes(d.name));
221
+ const objCols = (table: string) => cat.objects.find((o) => o.name === table)?.columns ?? [];
222
+ // every column of the dataset's tables: the offered ones first (in their order), then the others
223
+ const rows: { ref: string; alias: string; table: string; column: string; type: string; c?: Dataset['columns'][number] }[] = [];
224
+ for (const c of d.columns) {
225
+ const t = d.tables.find((x) => x.alias === c.table)!;
226
+ rows.push({ ref: `${c.table}.${c.column}`, alias: c.table, table: t.table, column: c.column, type: objCols(t.table).find((x) => x.name === c.column)?.type ?? '', c });
227
+ }
228
+ for (const t of d.tables)
229
+ for (const col of objCols(t.table))
230
+ if (!d.columns.some((c) => c.table === t.alias && c.column === col.name)) rows.push({ ref: `${t.alias}.${col.name}`, alias: t.alias, table: t.table, column: col.name, type: col.type });
231
+ const colOptions = d.tables.flatMap((t) => objCols(t.table).map((c): [string, string] => [`${t.alias}.${c.name}`, `${t.alias} ${t.table}.${c.name}`]));
232
+ const shown = req.query.preview === '1' ? await preview(db, d) : null;
233
+ const opt = (v: string, l: string, cur: string) => html`<option value="${v}"${v === cur ? raw(' selected') : ''}>${l}</option>`;
234
+ const main = html`<div class="title-row"><h1>${d.label} <code>${d.name}</code></h1>
235
+ <div class="buttons"><a class="btn" href="${root}/datasets/${row.id}/export">${icon('download')} Download</a><a class="btn btn-hot" href="${root}/apps/new?dataset=${encodeURIComponent(d.name)}">${icon('plus')} Create reporting application</a></div></div>
236
+ <p class="muted u-mt0">On schema <code>${d.schema}</code>${used.length ? html` · used by ${used.map((a, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}">${a.name}</a>`)}` : ''} · updated ${when(row.updated_at)} (UTC)${row.updated_by ? ` by ${row.updated_by}` : ''}</p>
237
+ ${errors.length ? html`<div class="alert alert-error" role="alert"><b>The dataset doesn't match the database (any more):</b><ul>${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
238
+ <form method="post" action="${root}/datasets/${row.id}">${csrf(s)}
239
+ ${region('Dataset', html`<div class="form-grid">
240
+ ${input('label', 'Label', d.label, { required: true })}
241
+ <div class="field" data-wide><label class="label" for="f_description">Description (shown to users)</label><input id="f_description" name="description" maxlength="${DATASET_LIMITS.description}" value="${d.description ?? ''}"></div>
242
+ </div>`)}
243
+ ${region('Tables and views', html`<div class="table-wrap"><table class="report report-reflow">
244
+ <thead><tr><th scope="col">Alias</th><th scope="col">Table or view</th><th scope="col">Joins to</th><th scope="col">Join</th><th scope="col">On (column = its column)</th><th scope="col">Remove</th></tr></thead><tbody>
245
+ ${d.tables.map((t, i) => html`<tr>
246
+ <td data-label="Alias"><code>${t.alias}</code><input type="hidden" name="t${i}_alias" value="${t.alias}"></td>
247
+ <td data-label="Table or view"><code>${t.table}</code></td>
248
+ ${i === 0 ? html`<td data-label="Joins to" colspan="3"><span class="muted">the main table</span></td>` : html`
249
+ <td data-label="Joins to"><select name="t${i}_to" aria-label="${t.table} joins to">${d.tables.slice(0, i).map((x) => opt(x.alias, `${x.alias} ${x.table}`, t.join?.to ?? ''))}</select></td>
250
+ <td data-label="Join"><select name="t${i}_type" aria-label="Join type of ${t.table}">${opt('left', 'Left (keep rows without a match)', t.join?.type ?? 'left')}${opt('inner', 'Inner (only matching rows)', t.join?.type ?? 'left')}</select></td>
251
+ <td data-label="On"><input name="t${i}_on" value="${t.join?.on.map((p) => `${p.column} = ${p.ref}`).join(', ') ?? ''}" aria-label="Join columns of ${t.table}" placeholder="deptno = deptno"></td>`}
252
+ <td data-label="Remove">${i === 0 ? '' : html`<input type="checkbox" name="t${i}_remove" value="true" aria-label="Remove ${t.table}">`}</td>
253
+ </tr>`)}</tbody></table></div>
254
+ ${d.tables.length < DATASET_LIMITS.tables ? html`<div class="form-grid">
255
+ <div class="field"><label class="label" for="f_new_table">Add a table or view</label><select id="f_new_table" name="new_table"><option value="">- none -</option>${cat.objects.map((o) => html`<option value="${o.name}">${o.name} (${o.kind})</option>`)}</select></div>
256
+ <div class="field"><label class="label" for="f_new_to">Joined to</label><select id="f_new_to" name="new_to">${d.tables.map((x) => opt(x.alias, `${x.alias} ${x.table}`, ''))}</select></div>
257
+ <div class="field"><label class="label" for="f_new_on">On</label><input id="f_new_on" name="new_on" placeholder="from a foreign key"><small class="help">Empty: proposed from a foreign key.</small></div>
258
+ </div>` : ''}`)}
259
+ ${region('Columns', html`<p class="muted u-mt0">Business users see only the offered columns, under their labels. The name is how reports refer to a column: changing it breaks saved reports that use it.</p>
260
+ <div class="table-wrap"><table class="report report-reflow dataset-columns">
261
+ <thead><tr><th scope="col">Offer</th><th scope="col">Column</th><th scope="col">Name</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Description</th></tr></thead><tbody>
262
+ ${rows.map((r, j) => html`<tr>
263
+ <td data-label="Offer"><input type="hidden" name="c${j}_ref" value="${r.ref}"><input type="checkbox" name="c${j}_on" value="true"${r.c ? raw(' checked') : ''} aria-label="Offer ${r.table}.${r.column}"></td>
264
+ <td data-label="Column"><code>${r.alias}.${r.column}</code> <span class="muted">${r.type}</span></td>
265
+ <td data-label="Name"><input name="c${j}_name" maxlength="40" value="${r.c?.name ?? r.column.toLowerCase()}" aria-label="Name of ${r.table}.${r.column}"></td>
266
+ <td data-label="Label"><input name="c${j}_label" maxlength="${DATASET_LIMITS.label}" value="${r.c?.label ?? ''}" placeholder="${heading(r.column)}" aria-label="Label of ${r.table}.${r.column}"></td>
267
+ <td data-label="Format mask"><input name="c${j}_format" maxlength="${DATASET_LIMITS.format}" value="${r.c?.format ?? ''}" placeholder="999G990D00" aria-label="Format mask of ${r.table}.${r.column}"></td>
268
+ <td data-label="Description"><input name="c${j}_desc" maxlength="${DATASET_LIMITS.columnDescription}" value="${r.c?.description ?? ''}" aria-label="Description of ${r.table}.${r.column}"></td>
269
+ </tr>`)}</tbody></table></div>`)}
270
+ ${region('Row filter', html`<p class="muted u-mt0">Only rows that match every condition are in the dataset (on top of the application role's row level security).</p>
271
+ ${Array.from({ length: DATASET_LIMITS.filters }, (_, k) => {
272
+ const f = d.filters[k];
273
+ return html`<div class="dataset-filter">
274
+ <select name="f${k}_col" aria-label="Filter ${k + 1}: column">${opt('', '- none -', '')}${colOptions.map(([v, l]) => opt(v, l, f ? `${f.table}.${f.column}` : ''))}</select>
275
+ <select name="f${k}_op" aria-label="Filter ${k + 1}: operator">${Object.keys(OPERATORS).map((o) => opt(o, opLabel(english, o), f?.op ?? 'eq'))}</select>
276
+ <input name="f${k}_value" maxlength="${DATASET_LIMITS.value}" value="${f?.value ?? ''}" aria-label="Filter ${k + 1}: value" placeholder="value">
277
+ </div>`;
278
+ })}`)}
279
+ <div class="buttons"><button class="btn btn-hot">${icon('save')} Save dataset</button></div>
280
+ </form>
281
+ ${region('Query and preview', html`<p class="muted u-mt0">The query reports run on (as the application's role). The preview runs it read only through your SQL Workshop connection.</p>
282
+ <pre class="code" data-code-view="sql">${datasetSql(d)}</pre>
283
+ <p><a class="btn" href="${root}/datasets/${row.id}?preview=1#preview">${icon('play')} Preview ${PREVIEW_ROWS} rows</a></p>
284
+ ${shown ? 'error' in shown ? html`<div class="alert alert-error" role="alert" id="preview">${shown.error}</div>` : html`<div class="table-wrap" id="preview"><table class="report report-reflow"><thead><tr>${shown.fields.map((f) => html`<th scope="col">${d.columns.find((c) => c.name === f)?.label ?? f}</th>`)}</tr></thead>
285
+ <tbody>${shown.rows.length ? shown.rows.map((r) => html`<tr>${r.map((v, i) => html`<td data-label="${shown.fields[i]}">${cellText(v)}</td>`)}</tr>`) : html`<tr><td colspan="${shown.fields.length}" class="empty">No rows.</td></tr>`}</tbody></table></div>` : ''}`)}
286
+ ${region('Delete', html`<form method="post" action="${root}/datasets/${row.id}/delete">${csrf(s)}
287
+ <p class="muted u-mt0">${used.length ? `Reporting applications using it (${used.map((a) => a.name).join(', ')}) lose this data source: their reports on it stop working.` : 'No reporting application uses it.'}</p>
288
+ <button class="btn btn-danger" data-confirm="Delete the dataset ${d.name}?">${icon('trash')} Delete dataset</button></form>`)}`;
289
+ return page(s, reply, d.label, [[d.label]], main);
290
+ });
291
+
292
+ app.post(`${root}/datasets/:id`, async (req: Req, reply) => {
293
+ const s = await developer(req, reply);
294
+ if (!s) return;
295
+ const row = await datasetRow(s, req.params.id);
296
+ if (!row) return reply.code(404).send('Not found');
297
+ const d = datasetOf(row);
298
+ const db = await workshopDb(s);
299
+ if (!(await usableSchema(db, d.schema))) {
300
+ flash(s, `Your SQL Workshop connection may not use the schema ${d.schema}.`, 'error');
301
+ return back(reply, s, `${root}/datasets/${row.id}`);
302
+ }
303
+ const cat = await readSchemaCatalog(db, d.schema);
304
+ const { dataset, errors } = checkDataset(datasetFromForm(d, req.body ?? {}, cat), catalogOf(cat.objects));
305
+ if (!dataset.tables.length || !dataset.columns.length) {
306
+ flash(s, [...errors, 'A dataset needs a table and at least one offered column: nothing was saved.'].join(' '), 'error');
307
+ return back(reply, s, `${root}/datasets/${row.id}`);
308
+ }
309
+ await storeDataset(owner, row.workspace_id, dataset, s.username);
310
+ logActivity({ appId: null, username: s.username, event: 'dataset_save', ip: clientIp(req), detail: dataset.name });
311
+ flash(s, errors.length ? `Saved, but: ${errors.join(' ')}` : 'Dataset saved.', errors.length ? 'error' : 'ok');
312
+ return back(reply, s, `${root}/datasets/${row.id}`);
313
+ });
314
+
315
+ app.post(`${root}/datasets/:id/delete`, async (req: Req, reply) => {
316
+ const s = await developer(req, reply);
317
+ if (!s) return;
318
+ const row = await datasetRow(s, req.params.id);
319
+ if (!row) return reply.code(404).send('Not found');
320
+ await owner.query('delete from meta.dataset where id = $1', [row.id]);
321
+ logActivity({ appId: null, username: s.username, event: 'dataset_delete', ip: clientIp(req), detail: row.name });
322
+ flash(s, `Dataset ${row.name} deleted.`);
323
+ return back(reply, s, root);
324
+ });
325
+
326
+ // ---------------------------------------------------------------- files
327
+ const download = (reply: FastifyReply, name: string, list: Dataset[]) =>
328
+ reply.type('application/json').header('content-disposition', `attachment; filename="${name}.dataset.json"`).send(datasetFile(list));
329
+
330
+ app.get(`${root}/datasets/:id/export`, async (req: Req, reply) => {
331
+ const s = await developer(req, reply);
332
+ if (!s) return;
333
+ const row = await datasetRow(s, req.params.id);
334
+ if (!row) return reply.code(404).send('Not found');
335
+ return download(reply, row.name, [datasetOf(row)]);
336
+ });
337
+
338
+ app.get(`${root}/export`, async (req: Req, reply) => {
339
+ const s = await developer(req, reply);
340
+ if (!s) return;
341
+ const list = (await workspaceDatasets(owner, currentWorkspace(s))).map(datasetOf);
342
+ return download(reply, 'datasets', list);
343
+ });
344
+
345
+ app.get(`${root}/import`, async (req: Req, reply) => {
346
+ const s = await developer(req, reply);
347
+ if (!s) return;
348
+ const main = html`<h1>Import datasets</h1>
349
+ ${region('A dataset file', html`<form method="post" action="${root}/import" enctype="multipart/form-data">${csrf(s)}
350
+ <p class="muted u-mt0">A file downloaded from a Data Reporter (<code>*.dataset.json</code>, format pgkiln-dataset/1), with one dataset or several. Each is checked against this installation's database: its schema, tables, views and columns must exist here.</p>
351
+ <div class="form-grid">
352
+ <div class="field"><label class="label" for="f_file">File</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
353
+ <div class="field" data-wide><label class="label" for="f_text">Or paste it</label><textarea id="f_text" name="text" class="code" rows="5" spellcheck="false"></textarea></div>
354
+ <div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> Replace datasets with the same name</label></div>
355
+ </div>
356
+ <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('upload')} Import</button></div></form>`)}`;
357
+ return page(s, reply, 'Import datasets', [['Import']], main);
358
+ });
359
+
360
+ app.post(`${root}/import`, async (req: Req, reply) => {
361
+ // a multipart form: the session and CSRF token are checked after reading it
362
+ let text = '';
363
+ let tooLarge = false;
364
+ if (req.isMultipart()) {
365
+ const parsed = await readMultipart(req, MAX_FILE_BYTES / 1024 / 1024);
366
+ req.body = parsed.body as Body;
367
+ const f = parsed.files.get('file');
368
+ if (f?.truncated) tooLarge = true;
369
+ else if (f) text = f.data.toString('utf8');
370
+ }
371
+ const s = await developer(req, reply);
372
+ if (!s) return;
373
+ const fail = (msg: string) => (flash(s, msg, 'error'), back(reply, s, `${root}/import`));
374
+ if (tooLarge) return fail('The file is larger than 2 MB.');
375
+ if (!text.trim()) text = String(req.body?.text ?? '');
376
+ if (!text.trim()) return fail('Choose a file or paste one.');
377
+ let list: any[];
378
+ try {
379
+ list = readDatasetFile(text);
380
+ } catch (e) {
381
+ return fail((e as Error).message);
382
+ }
383
+ const db = await workshopDb(s);
384
+ const schemas = await usableSchemas(db);
385
+ const ws = currentWorkspace(s);
386
+ const replace = req.body?.replace === 'true';
387
+ const lines: string[] = [];
388
+ try {
389
+ await owner.tx(async (c) => {
390
+ const cats = new Map<string, Awaited<ReturnType<typeof readSchemaCatalog>>>();
391
+ for (const loose of list) {
392
+ const schema = typeof loose?.schema === 'string' ? loose.schema : '';
393
+ const name = typeof loose?.name === 'string' ? loose.name : '?';
394
+ if (!schemas.includes(schema)) throw new Error(`Dataset ${name}: the schema "${schema}" doesn't exist here, or your SQL Workshop connection may not use it.`);
395
+ if (!cats.has(schema)) cats.set(schema, await readSchemaCatalog(db, schema));
396
+ const { dataset, errors } = checkDataset(loose, catalogOf(cats.get(schema)!.objects));
397
+ if (errors.length || !dataset.columns.length) throw new Error(`Dataset ${name}: ${errors.join(' ') || 'it offers no columns.'}`);
398
+ const exists = (await c.query('select 1 from meta.dataset where workspace_id = $1 and name = $2', [ws, dataset.name])).rowCount;
399
+ if (exists && !replace) {
400
+ lines.push(`${dataset.name}: kept (same name)`);
401
+ continue;
402
+ }
403
+ await storeDataset(c, ws, dataset, s.username);
404
+ lines.push(`${dataset.name}: ${exists ? 'replaced' : 'added'}`);
405
+ }
406
+ });
407
+ } catch (e) {
408
+ return fail(`Nothing was imported. ${(e as Error).message}`);
409
+ }
410
+ logActivity({ appId: null, username: s.username, event: 'dataset_import', ip: clientIp(req), detail: lines.join('; ') });
411
+ flash(s, `Imported: ${lines.join('; ')}.`);
412
+ return back(reply, s, root);
413
+ });
414
+
415
+ // ---------------------------------------------------------------- create a reporting application
416
+ const appForm = async (s: Session, b: Body & { datasets?: string | string[] }, sets: DatasetRow[], error?: string) => {
417
+ const chosen = new Set(Array.isArray(b.datasets) ? b.datasets : b.datasets ? [b.datasets] : []);
418
+ return html`<h1>Create a reporting application</h1>
419
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
420
+ <form method="post" action="${root}/apps">${csrf(s)}
421
+ ${region('Application', html`<div class="form-grid">
422
+ ${input('name', 'Name', b.name ?? '', { required: true })}
423
+ ${input('alias', 'Alias', b.alias ?? '', { required: true, help: 'Its address: /a/<alias>. Its database role is app_<alias>, which may only read the datasets\' tables and views.' })}
424
+ ${input('admin_user', 'First administrator', b.admin_user ?? s.username ?? '', { required: true, help: 'An account of the user directory (users sign in with their workspace accounts).' })}
425
+ ${input('admin_password', 'Password', '', { type: 'password', auto: 'new-password', help: 'Only for a new account.' })}
426
+ </div>`)}
427
+ ${region('Datasets', html`<div class="dataset-checks">${sets.map((d) => html`<label class="check"><input type="checkbox" name="datasets" value="${d.name}"${chosen.has(d.name) ? raw(' checked') : ''}> ${d.label} <code>${d.name}</code> <span class="muted">${d.schema_name}: ${d.tables.map((t) => t.table).join(', ')}</span></label>`)}</div>
428
+ <small class="help">The first dataset's schema is the application's parsing schema.</small>`)}
429
+ <div class="buttons"><a class="btn" href="${root}">Cancel</a><button class="btn btn-hot">${icon('plus')} Create</button></div></form>`;
430
+ };
431
+
432
+ app.get(`${root}/apps/new`, async (req: Req, reply) => {
433
+ const s = await developer(req, reply);
434
+ if (!s) return;
435
+ const sets = await workspaceDatasets(owner, currentWorkspace(s));
436
+ if (!sets.length) {
437
+ flash(s, 'Create a dataset first.', 'error');
438
+ return back(reply, s, root);
439
+ }
440
+ const first = sets.find((d) => d.name === req.query.dataset) ?? sets[0];
441
+ const base = first.name.replace(/_/g, '-').slice(0, 40);
442
+ return page(s, reply, 'Create reporting application', [['Create reporting application']],
443
+ await appForm(s, { name: `${first.label} reports`, alias: `${base}-reports`, datasets: first.name }, sets));
444
+ });
445
+
446
+ app.post(`${root}/apps`, async (req: Req, reply) => {
447
+ const s = await developer(req, reply);
448
+ if (!s) return;
449
+ const b = (req.body ?? {}) as Body & { datasets?: string | string[] };
450
+ const sets = await workspaceDatasets(owner, currentWorkspace(s));
451
+ const names = Array.isArray(b.datasets) ? b.datasets : b.datasets ? [b.datasets] : [];
452
+ const chosen = names.map((n) => sets.find((d) => d.name === n)).filter((d): d is DatasetRow => !!d);
453
+ const failed = async (msg: string) => {
454
+ reply.code(422);
455
+ return page(s, reply, 'Create reporting application', [['Create reporting application']], await appForm(s, b, sets, msg));
456
+ };
457
+ if (!chosen.length) return failed('Choose at least one dataset.');
458
+ if (chosen.length > MAX_REPORTING_DATASETS) return failed(`Choose at most ${MAX_REPORTING_DATASETS} datasets.`);
459
+ let created: { id: number; existingAccount: boolean };
460
+ let alias = '';
461
+ try {
462
+ const checked = await checkNewApp({ name: b.name, alias: b.alias, schema: chosen[0].schema_name, authentication: 'app_users', admin_user: b.admin_user, admin_password: b.admin_password }, currentWorkspace(s));
463
+ alias = checked.alias;
464
+ created = await owner.tx((c) => createReportingApp(c, checked, chosen));
465
+ } catch (e) {
466
+ return failed(createAppError(e, alias || String(b.alias ?? '')));
467
+ }
468
+ logActivity({ appId: created.id, username: s.username, event: 'app_create', ip: clientIp(req), detail: `reporting application on ${chosen.map((d) => d.name).join(', ')}` });
469
+ flash(s, `Reporting application ${b.name} created: run it at /a/${alias}/, and give accounts access and the roles viewer, editor or administrator under Users and roles.`);
470
+ return back(reply, s, root);
471
+ });
472
+ }
@@ -1,4 +1,4 @@
1
- import type { FastifyInstance } from 'fastify';
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
@@ -90,9 +90,12 @@ async function recentApps(s: Session) {
90
90
 
91
91
  export async function homeRoutes(app: FastifyInstance) {
92
92
  // ---------------------------------------------------------------- App Builder home
93
- app.get(BASE, async (req: Req, reply) => {
93
+ // (sprint 109) at /builder/apps; /builder (links from before the Workspace home) shows it too, or redirects to the
94
+ // Workspace home when the workspace's preferences say so (meta.workspace.builder_home, migration 119)
95
+ const appBuilder = async (req: Req, reply: FastifyReply) => {
94
96
  const s = await developer(req, reply);
95
97
  if (!s) return;
98
+ if (s.workspace?.builder_home === 'home' && req.url === BASE) return reply.redirect(`${BASE}/home`);
96
99
  const q = String(req.query.q ?? '').trim().slice(0, 100);
97
100
  // the view and sort are remembered for the session
98
101
  if (req.query.view === 'grid' || req.query.view === 'report') s.state.__BVIEW = req.query.view;
@@ -124,13 +127,13 @@ export async function homeRoutes(app: FastifyInstance) {
124
127
  if (q) p.set('q', q);
125
128
  if (o.view) p.set('view', o.view);
126
129
  if (o.sort) p.set('sort', o.sort);
127
- return `${BASE}?${p}`;
130
+ return `${BASE}/apps?${p}`;
128
131
  };
129
132
  const th = (key: Sort, label: string, cls = '') =>
130
133
  html`<th scope="col"${cls ? raw(` class="${cls}"`) : ''}${sort === key ? raw(' aria-sort="' + (key === 'pages' || key === 'updated' ? 'descending' : 'ascending') + '"') : ''}><a href="${link({ sort: key })}">${label}${sort === key ? html`<span class="ab-sorted" aria-hidden="true">${key === 'pages' || key === 'updated' ? '↓' : '↑'}</span>` : ''}</a></th>`;
131
134
  const rows = apps.rows;
132
135
  const list = rows.length === 0
133
- ? html`<p class="ab-empty">${total.n ? html`No application ${group ? html`in the group ${group.name}${q ? html` matches “${q}”` : ''}` : html`matches “${q}”`}. <a href="${BASE}?q=&amp;group=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
136
+ ? html`<p class="ab-empty">${total.n ? html`No application ${group ? html`in the group ${group.name}${q ? html` matches “${q}”` : ''}` : html`matches “${q}”`}. <a href="${BASE}/apps?q=&amp;group=">Show all</a>` : html`No applications yet. <a href="${BASE}/create">Create one</a> or <a href="${BASE}/import">import one</a>.`}</p>`
134
137
  : view === 'grid'
135
138
  ? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
136
139
  <a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
@@ -162,12 +165,12 @@ export async function homeRoutes(app: FastifyInstance) {
162
165
  ${s.workspace?.message ? html`<div class="alert alert-info ab-message" role="status">${icon('bell')}<div><strong>${s.workspace.name}</strong><p>${s.workspace.message}</p></div></div>` : ''}
163
166
  ${tiles()}
164
167
  <div class="ab-toolbar">
165
- <form class="ab-search" method="get" action="${BASE}" role="search">
168
+ <form class="ab-search" method="get" action="${BASE}/apps" role="search">
166
169
  <label class="sr-only" for="ab-q">Search applications</label>${icon('search')}
167
170
  <input id="ab-q" name="q" type="search" value="${q}" placeholder="Search" autocomplete="off" data-filter-list=".ab-home [data-filter-row]">
168
171
  </form>
169
172
  <div class="ab-views" role="group" aria-label="View">${viewButton('grid', 'grid', 'Cards')}${viewButton('report', 'list', 'Report')}</div>
170
- ${groups.length ? html`<form method="get" action="${BASE}" class="ab-group"><label class="sr-only" for="ab-group">Application group</label>
173
+ ${groups.length ? html`<form method="get" action="${BASE}/apps" class="ab-group"><label class="sr-only" for="ab-group">Application group</label>
171
174
  <select id="ab-group" name="group" data-autosubmit><option value="">All groups</option>${groups.map((g) => html`<option value="${g.id}"${group?.id === g.id ? raw(' selected') : ''}>${g.name}</option>`)}</select>
172
175
  <noscript><button class="btn btn-sm">Show</button></noscript></form>` : ''}
173
176
  <details class="menu ab-menu"><summary class="btn">Actions ${icon('chevron')}</summary>
@@ -187,7 +190,9 @@ export async function homeRoutes(app: FastifyInstance) {
187
190
  ${aside(recent, 'The App Builder creates, edits and runs the applications of this workspace: pages and regions on your PostgreSQL tables, with sign-in, roles and row level security.', TASKS)}
188
191
  </div>`;
189
192
  return send(reply, s, shell(s, 'App Builder', [['App Builder']], main));
190
- });
193
+ };
194
+ app.get(BASE, appBuilder);
195
+ app.get(`${BASE}/apps`, appBuilder);
191
196
 
192
197
  // ---------------------------------------------------------------- create and import
193
198
  // (sprint 73) GET /create, the Create Application wizard, is in appwizard.ts