pgkiln 0.70.0 → 0.72.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 (65) hide show
  1. package/db/migrations/137_create_page_types.sql +904 -0
  2. package/db/migrations/138_app_utilities.sql +153 -0
  3. package/db/migrations/139_opentelemetry.sql +36 -0
  4. package/docs/apex-feature-parity.md +9 -7
  5. package/docs/guide/01-installation.md +108 -0
  6. package/docs/guide/03-builder.md +116 -4
  7. package/docs/guide/04-pages-and-regions.md +3 -1
  8. package/docs/guide/05-items.md +1 -1
  9. package/docs/guide/06-processing.md +18 -0
  10. package/docs/guide/09-reference.md +18 -1
  11. package/docs/guide/12-development.md +35 -1
  12. package/docs/guide/16-files.md +1 -1
  13. package/examples/hr/hr_73_configuration_options.sql +17 -0
  14. package/examples/hr/hr_74_app_utilities.sql +22 -0
  15. package/package.json +1 -1
  16. package/public/app.css +9 -0
  17. package/public/app.js +50 -0
  18. package/public/builder.css +48 -0
  19. package/src/ai/service.ts +14 -0
  20. package/src/app.ts +20 -0
  21. package/src/builder/advisor.ts +2 -2
  22. package/src/builder/app-updates.ts +3 -0
  23. package/src/builder/components.ts +3 -1
  24. package/src/builder/create-page.ts +259 -0
  25. package/src/builder/dependencies.ts +311 -0
  26. package/src/builder/features.ts +313 -0
  27. package/src/builder/home.ts +3 -0
  28. package/src/builder/i18n/de.ts +6 -0
  29. package/src/builder/i18n/en.ts +6 -0
  30. package/src/builder/i18n/es.ts +6 -0
  31. package/src/builder/i18n/fr.ts +6 -0
  32. package/src/builder/i18n/it.ts +6 -0
  33. package/src/builder/i18n/ja.ts +6 -0
  34. package/src/builder/i18n/ko.ts +6 -0
  35. package/src/builder/i18n/nl.ts +6 -0
  36. package/src/builder/i18n/pt.ts +6 -0
  37. package/src/builder/i18n/zh-cn.ts +6 -0
  38. package/src/builder/i18n/zh-tw.ts +6 -0
  39. package/src/builder/login-page.ts +17 -14
  40. package/src/builder/newapp.ts +41 -23
  41. package/src/builder/routes.ts +13 -2
  42. package/src/builder/telemetry.ts +206 -0
  43. package/src/builder/ui-defaults.ts +161 -0
  44. package/src/builder/ui.ts +2 -1
  45. package/src/builder/upgrade.ts +154 -0
  46. package/src/builder/utilities.ts +181 -0
  47. package/src/builder/wizards.ts +211 -26
  48. package/src/builder/workspaces.ts +2 -1
  49. package/src/cli/replace.ts +9 -1
  50. package/src/history.ts +155 -0
  51. package/src/instance-checks.ts +11 -4
  52. package/src/layout.ts +2 -1
  53. package/src/mail/mail.ts +14 -0
  54. package/src/runtime/ai-agents.ts +9 -0
  55. package/src/runtime/builtin-components.ts +23 -2
  56. package/src/runtime/items.ts +4 -2
  57. package/src/runtime/routes.ts +3 -0
  58. package/src/session.ts +26 -0
  59. package/src/telemetry/browser.ts +91 -0
  60. package/src/telemetry/config.ts +342 -0
  61. package/src/telemetry/export.ts +484 -0
  62. package/src/telemetry/http.ts +126 -0
  63. package/src/telemetry/trace.ts +456 -0
  64. package/src/webclient.ts +25 -0
  65. package/src/workflow.ts +14 -7
@@ -0,0 +1,181 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { unifiedDiff } from '../cli/diff.ts';
3
+ import { owner } from '../db.ts';
4
+ import { appChecksum, ensureSnapshot, pageChecksums, shortSum } from '../history.ts';
5
+ import { html, raw, type Raw } from '../html.ts';
6
+ import { icon, ICONS, LUCIDE_VERSION, lucideNames, searchIcons } from '../icons.ts';
7
+ import { appOfPath } from './locks.ts';
8
+ import { appHeader, BASE, designerUrl, developer, region, select, send, shell, type Req } from './ui.ts';
9
+
10
+ // (sprint 138, AP-5) App Builder → an application's Utilities (APEX: Application
11
+ // Utilities): one page for what an application's maintenance needs, the tools
12
+ // that existed elsewhere (Advisor, Search, Debug messages, Backups, Activity,
13
+ // Working copies, Export) and the new ones: Database object dependencies
14
+ // (dependencies.ts), Change history (below; recorded by src/history.ts), Upgrade
15
+ // application (upgrade.ts), UI defaults (ui-defaults.ts), the application and
16
+ // page checksums (history.ts) and the icon browser (/builder/icons).
17
+
18
+ const appOf = async (id: string) => (/^\d{1,9}$/.test(id) ? owner.one('select * from meta.app where id = $1', [id]) : undefined);
19
+ const when = (t: string | null | undefined) => (t ? String(t).slice(0, 16).replace('T', ' ') : '');
20
+
21
+ /** A unified diff with its added and removed lines marked (escaped). */
22
+ export function diffHtml(path: string, a: string | null | undefined, b: string | null | undefined, names: [string, string]): Raw {
23
+ const text = unifiedDiff(path, a === null || a === undefined ? undefined : Buffer.from(a), b === null || b === undefined ? undefined : Buffer.from(b), names);
24
+ return html`<pre class="code-block diff">${text.replace(/\n$/, '').split('\n').map((l) =>
25
+ l.startsWith('+') && !l.startsWith('+++') ? html`<span class="diff-add">${l}</span>\n` : l.startsWith('-') && !l.startsWith('---') ? html`<span class="diff-del">${l}</span>\n` : html`${l}\n`)}</pre>`;
26
+ }
27
+
28
+ /** The files of one change as diffs (before → after). */
29
+ function changeDiff(before: Record<string, string> | null, after: Record<string, string> | null): Raw {
30
+ const paths = [...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])].sort();
31
+ return html`${paths.map((p) => (before?.[p] === after?.[p] ? '' : diffHtml(p, before?.[p], after?.[p], ['before', 'after'])))}`;
32
+ }
33
+
34
+ /** The component of a history row in the builder (a page component opens the page designer). */
35
+ const componentUrl = (appId: number, page: number | null) => (page !== null && page !== undefined && page >= 0 ? designerUrl(appId, page) : `${BASE}/apps/${appId}/shared`);
36
+
37
+ /** (registered on the root instance, like app-updates.ts) a first snapshot before an application's first recorded change */
38
+ export function historyHooks(app: FastifyInstance) {
39
+ const snapshotted = new Set<number>();
40
+ app.addHook('preHandler', async (req) => {
41
+ if (req.method !== 'POST' || !req.url.startsWith(`${BASE}/`)) return;
42
+ const target = await appOfPath(req.url).catch(() => null);
43
+ if (!target || snapshotted.has(target.appId)) return;
44
+ await ensureSnapshot(target.appId).catch((err) => req.log.warn({ err }, 'app snapshot not taken'));
45
+ snapshotted.add(target.appId);
46
+ });
47
+ }
48
+
49
+ export async function utilityRoutes(app: FastifyInstance) {
50
+ // ---------------------------------------------------------------- the Utilities page
51
+ app.get(`${BASE}/apps/:id/utilities`, async (req: Req, reply) => {
52
+ const s = await developer(req, reply);
53
+ if (!s) return;
54
+ const a = await appOf(req.params.id);
55
+ if (!a) return reply.code(404).send('Not found');
56
+ const doc = (await owner.one('select meta.export_app($1) as d', [a.alias])).d;
57
+ const sum = appChecksum(doc);
58
+ const pageSums = pageChecksums(doc);
59
+ const counts = await owner.one(
60
+ `select (select count(*) from meta.page where app_id = $1)::int as pages,
61
+ (select count(*) from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1)::int as regions,
62
+ (select count(*) from meta.item i join meta.page p on p.id = i.page_id where p.app_id = $1)::int as items,
63
+ (select count(*) from meta.process x join meta.page p on p.id = x.page_id where p.app_id = $1)::int as processes,
64
+ (select count(*) from meta.lov where app_id = $1)::int as lovs,
65
+ (select count(*) from meta.component_change where app_id = $1 and changed_at > now() - interval '7 days')::int as week`,
66
+ [a.id],
67
+ );
68
+ const pages = (await owner.query<{ page_no: number; name: string }>('select page_no, name from meta.page where app_id = $1 order by page_no', [a.id])).rows;
69
+ const recent = (await owner.query<{ page_no: number; changed_at: string; changed_by: string | null; n: number }>(
70
+ `select distinct on (page_no) page_no, changed_at::text, changed_by, count(*) over (partition by page_no)::int as n
71
+ from meta.component_change where app_id = $1 and page_no is not null order by page_no, changed_at desc`, [a.id])).rows
72
+ .sort((x, y) => (x.changed_at < y.changed_at ? 1 : -1)).slice(0, 10);
73
+ const pageName = new Map(pages.map((p) => [p.page_no, p.name]));
74
+ const card = (href: string, ic: string, title: string, text: string) =>
75
+ html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
76
+ const u = `${BASE}/apps/${a.id}`;
77
+ const main = html`${appHeader(a, 'utilities', s)}
78
+ <div class="ab-tiles ab-tiles-wrap util-tiles">
79
+ ${card(`${u}/advisor`, 'check', 'Advisor', 'Plans every SQL statement as the application\'s role and checks every reference.')}
80
+ ${card(`${u}/utilities/dependencies`, 'database', 'Database object dependencies', 'The tables, views, sequences and functions the application uses, missing ones and missing grants.')}
81
+ ${card(`${u}/utilities/history`, 'history', 'Change history', 'Who changed which component and when, with the differences.')}
82
+ ${card(`${u}/utilities/upgrade`, 'refresh', 'Upgrade application', 'Components that can use a newer default of this pgkiln version.')}
83
+ ${card(`${BASE}/ui-defaults`, 'sliders-horizontal', 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
84
+ ${card(`${u}/search`, 'search', 'Search', 'Names, SQL, conditions, settings and help texts of every component.')}
85
+ ${card(`${u}/debug`, 'list', 'Debug messages', 'Timed steps of each request and messages from meta.debug().')}
86
+ ${card(`${u}/activity`, 'activity', 'Activity', 'Page views, errors, sign-ins and the application\'s logs.')}
87
+ ${card(`${u}/backups`, 'archive', 'Backups', 'Back up now, download and restore earlier versions.')}
88
+ ${card(`${u}/working-copies`, 'layers', 'Working copies', 'Change a copy, compare it and merge it back.')}
89
+ ${card(`${u}/export`, 'download', 'Export', `The application as a file (checksum ${shortSum(sum)}).`)}
90
+ ${card(`${BASE}/icons`, 'image', 'Icons', 'Browse the icons the icon picker offers.')}
91
+ </div>
92
+ ${region('Application dashboard', html`<dl class="util-stats">
93
+ <div><dt>Pages</dt><dd>${counts.pages}</dd></div><div><dt>Regions</dt><dd>${counts.regions}</dd></div>
94
+ <div><dt>Items</dt><dd>${counts.items}</dd></div><div><dt>Processes</dt><dd>${counts.processes}</dd></div>
95
+ <div><dt>Lists of values</dt><dd>${counts.lovs}</dd></div><div><dt>Changes in 7 days</dt><dd>${counts.week}</dd></div>
96
+ </dl>`)}
97
+ ${region('Recently updated pages', recent.length
98
+ ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Last change</th><th scope="col">By</th><th scope="col">Changes</th></tr></thead><tbody>
99
+ ${recent.map((r) => html`<tr><td data-label="Page"><a href="${designerUrl(a.id, r.page_no)}">${r.page_no}${pageName.has(r.page_no) ? `: ${pageName.get(r.page_no)}` : ''}</a></td><td data-label="Last change">${when(r.changed_at)}</td>
100
+ <td data-label="By">${r.changed_by ?? 'command line'}</td><td data-label="Changes"><a href="${u}/utilities/history?page=${r.page_no}">${r.n}</a></td></tr>`)}</tbody></table></div>`
101
+ : html`<p class="muted u-mt0">No recorded changes yet: the change history starts with the first change after the upgrade to this version.</p>`)}
102
+ ${region('Application checksum', html`<p class="u-mt0">The checksum of the application's definition (its export without the alias): the same definition gives the same checksum on any installation, so two installations, or an application and its working copy, compare at a glance.</p>
103
+ <p class="util-sum"><code>${sum}</code></p>
104
+ <details><summary>Page checksums (${pageSums.size})</summary><div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Page</th><th scope="col">Checksum</th></tr></thead><tbody>
105
+ ${[...pageSums].map(([p, h]) => html`<tr><td data-label="Page">${p}${pageName.has(p) ? `: ${pageName.get(p)}` : ''}</td><td data-label="Checksum"><code>${shortSum(h)}</code></td></tr>`)}</tbody></table></div></details>`, '', 'checksum')}`;
106
+ return send(reply, s, shell(s, `Utilities · ${a.name}`, [['App Builder', BASE], [a.name, u], ['Utilities']], main));
107
+ });
108
+
109
+ // ---------------------------------------------------------------- change history
110
+ app.get(`${BASE}/apps/:id/utilities/history`, async (req: Req, reply) => {
111
+ const s = await developer(req, reply);
112
+ if (!s) return;
113
+ const a = await appOf(req.params.id);
114
+ if (!a) return reply.code(404).send('Not found');
115
+ const q = req.query ?? {};
116
+ const dev = typeof q.dev === 'string' ? q.dev.slice(0, 200) : '';
117
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
118
+ const from = typeof q.from === 'string' && DATE.test(q.from) ? q.from : '';
119
+ const to = typeof q.to === 'string' && DATE.test(q.to) ? q.to : '';
120
+ const page = typeof q.page === 'string' && /^-?\d{1,9}$/.test(q.page) ? Number(q.page) : null;
121
+ const text = typeof q.q === 'string' ? q.q.trim().slice(0, 100) : '';
122
+ const shown = typeof q.c === 'string' && /^\d{1,18}$/.test(q.c) ? q.c : '';
123
+ const devs = (await owner.query<{ d: string | null }>('select distinct changed_by as d from meta.component_change where app_id = $1 order by 1 nulls last', [a.id])).rows.map((r) => r.d);
124
+ const params: unknown[] = [a.id];
125
+ const where = ['app_id = $1'];
126
+ const add = (sql: string, v: unknown) => {
127
+ params.push(v);
128
+ where.push(sql.replace('?', `$${params.length}`));
129
+ };
130
+ if (dev === '(command line)') where.push('changed_by is null');
131
+ else if (dev) add('changed_by = ?', dev);
132
+ if (from) add(`changed_at >= ?::date`, from);
133
+ if (to) add(`changed_at < ?::date + 1`, to);
134
+ if (page !== null) add('page_no = ?', page);
135
+ if (text) add(`(label ilike '%' || ? || '%' escape '\\')`, text.replace(/[\\%_]/g, (m) => `\\${m}`));
136
+ const rows = (await owner.query(
137
+ `select id::text, component, label, page_no, op, changed_by, source, changed_at::text from meta.component_change
138
+ where ${where.join(' and ')} order by changed_at desc, id desc limit 500`, params)).rows;
139
+ const detail = shown ? await owner.one('select id::text, label, page_no, op, before, after, changed_by, source, changed_at::text from meta.component_change where app_id = $1 and id = $2', [a.id, shown]) : null;
140
+ const keep = new URLSearchParams(Object.entries({ dev, from, to, page: page === null ? '' : String(page), q: text }).filter(([, v]) => v));
141
+ const link = (id: string) => `?${new URLSearchParams([...keep, ['c', id]])}#change`;
142
+ const OPS: Record<string, string> = { added: 'tag-info', changed: 'tag-warning', deleted: 'tag-error' };
143
+ const main = html`${appHeader(a, 'utilities', s)}
144
+ ${region('Change history', html`<p class="muted u-mt0">Every change made in the builder (and every import, restore or merge) is compared with the application before it: each component that was added, changed or deleted is listed here with who and when. Changes from the command line or the MCP server have no developer.</p>
145
+ <form method="get" class="util-filters">
146
+ ${select('dev', 'Developer', dev, [['', 'All'], ...devs.map((d): [string, string] => (d === null ? ['(command line)', 'Command line / import'] : [d, d]))])}
147
+ <div class="field"><label class="label" for="f_from">From</label><input id="f_from" name="from" type="date" value="${from}"></div>
148
+ <div class="field"><label class="label" for="f_to">To</label><input id="f_to" name="to" type="date" value="${to}"></div>
149
+ <div class="field"><label class="label" for="f_page">Page</label><input id="f_page" name="page" type="number" value="${page ?? ''}"></div>
150
+ <div class="field"><label class="label" for="f_q">Component</label><input id="f_q" name="q" type="search" value="${text}" placeholder="e.g. P3_ID"></div>
151
+ <div class="buttons"><button class="btn btn-sm">${icon('filter')} Show</button></div></form>
152
+ ${rows.length
153
+ ? html`<div class="table-wrap"><table class="table util-history report-reflow"><thead><tr><th scope="col">When</th><th scope="col">Developer</th><th scope="col">Component</th><th scope="col">Change</th><th scope="col">Differences</th></tr></thead><tbody>
154
+ ${rows.map((r) => html`<tr${r.id === shown ? raw(' aria-current="true"') : ''}><td data-label="When">${when(r.changed_at)}</td><td data-label="Developer">${r.changed_by ?? html`<span class="muted">${r.source === 'import' ? 'import' : 'command line'}</span>`}</td>
155
+ <td data-label="Component"><a href="${componentUrl(a.id, r.page_no)}">${r.label}</a></td>
156
+ <td data-label="Change"><span class="tag ${OPS[r.op] ?? ''}">${r.op}</span>${r.source !== 'builder' ? html` <span class="tag">${r.source}</span>` : ''}</td>
157
+ <td data-label="Differences"><a class="btn btn-sm" href="${link(r.id)}">Show</a></td></tr>`)}</tbody></table></div>`
158
+ : html`<p class="muted">No changes${where.length > 1 ? ' with these filters' : ' recorded yet'}.</p>`}`)}
159
+ ${detail ? region(`Differences: ${detail.label}`, html`<p class="muted u-mt0">${detail.op} by ${detail.changed_by ?? 'the command line'}, ${when(detail.changed_at)}. Lines with <code>-</code> are before the change, lines with <code>+</code> after it.</p>${changeDiff(detail.before, detail.after)}`, '', 'change') : ''}`;
160
+ return send(reply, s, shell(s, `Change history · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Change history']], main));
161
+ });
162
+
163
+ // ---------------------------------------------------------------- icons
164
+ app.get(`${BASE}/icons`, async (req: Req, reply) => {
165
+ const s = await developer(req, reply);
166
+ if (!s) return;
167
+ const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 60) : '';
168
+ const needle = q.toLowerCase().replace(/^fa-/, '');
169
+ const own = (ICONS as readonly string[]).filter((n) => !needle || n.includes(needle));
170
+ const lucide = needle ? searchIcons(q, 300) : [];
171
+ const tile = (n: string) => html`<li><span class="icon-tile">${icon(n, 'icon icon-2x')}<code>${n}</code></span></li>`;
172
+ const main = html`<h1 class="u-mb1">Icons</h1>
173
+ ${region('Icon browser', html`<p class="muted u-mt0">The icons of the icon picker: pgkiln's own set and ${String(lucideNames().size)} Lucide icons (${LUCIDE_VERSION}, ISC licence). Use the name in an icon field; modifiers such as <code>lg</code>, <code>spin</code> or <code>success</code> may follow it (e.g. <code>truck lg success</code>), and Font APEX names work where Lucide has the same icon (<code>fa-users</code>).</p>
174
+ <form method="get" role="search" class="util-filters"><div class="field"><label class="label" for="f_icon_q">Search</label><input id="f_icon_q" name="q" type="search" value="${q}" placeholder="e.g. truck, money, arrow"></div>
175
+ <div class="buttons"><button class="btn btn-sm">${icon('search')} Search</button></div></form>
176
+ <h3>pgkiln (${own.length})</h3><ul class="util-icons">${own.map(tile)}</ul>
177
+ ${needle ? html`<h3>Lucide (${lucide.length}${lucide.length === 300 ? '+' : ''})</h3>${lucide.length ? html`<ul class="util-icons">${lucide.map(tile)}</ul>` : html`<p class="muted">No Lucide icon matches.</p>`}`
178
+ : html`<p class="muted">Search to see the matching Lucide icons.</p>`}`)}`;
179
+ return send(reply, s, shell(s, 'Icons', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Icons']], main));
180
+ });
181
+ }
@@ -3,6 +3,7 @@ import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { ICONS } from '../icons.ts';
5
5
  import { appOr404 } from './forms.ts';
6
+ import { addDashboard } from './appsheets.ts';
6
7
  import { appHeader, back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
7
8
 
8
9
  // Create page wizards (APEX: Create Page). Step 1, on the app's Pages tab,
@@ -23,7 +24,32 @@ export const WIZARD_KINDS: [string, string, string][] = [
23
24
  ['facets', 'Faceted search', 'A report with a filter panel: values with counts, ranges and a search field.'],
24
25
  ['master_detail', 'Master detail', 'A grid of the table and, below it, an editable grid of the selected row\'s details.'],
25
26
  ];
26
- const KINDS = new Set(WIZARD_KINDS.map((k) => k[0]));
27
+ /**
28
+ * (sprint 137, AP-3) The page types that reuse other regions (APEX: Create a Page → Component), made by the same
29
+ * meta.generate_page (migration 137), and the dashboard (addDashboard). WIZARD_KINDS above stay the types the
30
+ * AI path and blueprints propose.
31
+ */
32
+ export const MORE_KINDS: [string, string, string][] = [
33
+ ['report', 'Interactive report', 'An interactive report of the table on its own page, optionally with a modal form page.'],
34
+ ['smart_filters', 'Smart filters', 'An interactive report with one search field above it that suggests filters (chips) from the columns.'],
35
+ ['search', 'Search page', 'A search region over the application\'s search configurations, or a new one on a table.'],
36
+ ['data_load', 'Data loading', 'A data load definition for the table and a page where users upload a CSV, Excel, JSON or XML file into it.'],
37
+ ['tree', 'Tree', 'The rows as a tree: an id, a parent (from a foreign key to the table itself) and a label.'],
38
+ ['timeline', 'Timeline', 'Rows as events on a vertical line: when, who, a title and a text (template component Timeline).'],
39
+ ['media_list', 'Media list', 'Rows with initials, a title, a description and a badge (template component Media list).'],
40
+ ['content_row', 'Content row', 'Rows with initials, a title and a description, and a secondary text and a badge on the right (template component Content row).'],
41
+ ['comments', 'Comments', 'Rows as a conversation: who wrote what and when (template component Comments).'],
42
+ ['dashboard', 'Dashboard', 'Charts of the application\'s tables, chosen from the data like a new application\'s dashboard.'],
43
+ ['wizard', 'Wizard', 'A form over several steps, one page per step with a progress list, Previous and Next; Finish adds the row.'],
44
+ ['tasks', 'Unified task list', 'The approval and action tasks of the signed-in user (a task list region).'],
45
+ ['workflows', 'Workflow console', 'The workflows the user started or administers, with their state and history.'],
46
+ ];
47
+ /** All the page types step 2 knows: [key, label, description]. */
48
+ export const PAGE_KINDS: [string, string, string][] = [...WIZARD_KINDS, ...MORE_KINDS];
49
+ const KINDS = new Set(PAGE_KINDS.map((k) => k[0]));
50
+ /** Page types that need no table (search takes one optionally, for a new search configuration). */
51
+ export const NO_TABLE = new Set(['search', 'tasks', 'workflows', 'dashboard']);
52
+ const TASK_CONTEXTS: [string, string][] = [['my', 'My tasks (to act on or claim)'], ['initiated', 'Tasks I requested'], ['admin', 'Tasks I administer']];
27
53
  const CHART_KINDS = ['bar', 'column', 'line', 'area', 'donut', 'pie', 'funnel'];
28
54
  const FUNCTIONS: [string, string][] = [['count', 'Count'], ['sum', 'Sum'], ['avg', 'Average'], ['min', 'Minimum'], ['max', 'Maximum']];
29
55
 
@@ -110,6 +136,55 @@ export function wizardOptions(kind: string, b: Record<string, unknown>) {
110
136
  o.search = b.search === 'true';
111
137
  o.form_page = formPage();
112
138
  break;
139
+ // (137) the page types of migration 137
140
+ case 'report':
141
+ o.form_page = formPage();
142
+ break;
143
+ case 'smart_filters':
144
+ o.facets = list(b.facets);
145
+ o.form_page = formPage();
146
+ break;
147
+ case 'tree':
148
+ for (const k of ['id_column', 'parent_column', 'label_column']) o[k] = str(k) || null;
149
+ o.expanded = /^\d{1,2}$/.test(str('expanded')) ? Number(str('expanded')) : 1;
150
+ o.form_page = formPage();
151
+ break;
152
+ case 'timeline':
153
+ for (const k of ['title', 'when', 'who', 'body']) o[k] = str(k) || null;
154
+ o.form_page = formPage();
155
+ break;
156
+ case 'media_list':
157
+ case 'content_row':
158
+ for (const k of ['title', 'description', 'badge', ...(kind === 'content_row' ? ['misc'] : [])]) o[k] = str(k) || null;
159
+ o.form_page = formPage();
160
+ break;
161
+ case 'comments':
162
+ for (const k of ['user', 'date', 'comment']) o[k] = str(k) || null;
163
+ break;
164
+ case 'data_load':
165
+ o.definition = str('definition').toUpperCase() || null;
166
+ o.mode = ['append', 'merge', 'replace'].includes(str('mode')) ? str('mode') : 'append';
167
+ o.skip_errors = b.skip_errors === 'true';
168
+ break;
169
+ case 'wizard':
170
+ o.columns = list(b.columns);
171
+ o.steps = /^\d{1,2}$/.test(str('steps')) ? Number(str('steps')) : 2;
172
+ o.return_page = pageNo(b.return_page);
173
+ break;
174
+ case 'search':
175
+ o.configs = list(b.configs).map((c) => c.toUpperCase());
176
+ o.form_page = formPage();
177
+ break;
178
+ case 'tasks':
179
+ o.context = TASK_CONTEXTS.some(([c]) => c === str('context')) ? str('context') : 'my';
180
+ o.completed = b.completed === 'true';
181
+ break;
182
+ case 'workflows':
183
+ o.context = str('context') === 'admin' ? 'admin' : 'initiator';
184
+ break;
185
+ case 'dashboard':
186
+ o.tables = list(b.tables);
187
+ break;
113
188
  case 'master_detail': {
114
189
  const d = str('detail');
115
190
  const at = d.lastIndexOf('|');
@@ -130,30 +205,31 @@ export async function wizardRoutes(app: FastifyInstance) {
130
205
  if (!a) return reply.code(404).send('Not found');
131
206
  const kind = String(req.query.kind ?? '');
132
207
  const table = String(req.query.table ?? '');
133
- const rel = KINDS.has(kind) && table
208
+ const rel = KINDS.has(kind) && table && kind !== 'dashboard'
134
209
  ? await owner.one<{ t: string; relkind: string }>(
135
210
  `select meta.wizard_qname(c.oid) as t, c.relkind::text from pg_class c join pg_namespace n on n.oid = c.relnamespace
136
211
  where c.oid = to_regclass($1) and c.relkind in ('r', 'p', 'v', 'm') and n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta')`,
137
212
  [table],
138
213
  )
139
214
  : null;
140
- if (!rel) {
215
+ if (!rel && !(NO_TABLE.has(kind) && !table)) {
141
216
  flash(s, KINDS.has(kind) ? 'Choose a table or view.' : 'Choose a page type.', 'error');
142
- return back(reply, s, `${BASE}/apps/${a.id}`);
217
+ // (137) back to the type's table step on the Create page grid
218
+ return back(reply, s, KINDS.has(kind) ? `${BASE}/apps/${a.id}/create-page?type=${encodeURIComponent(kind)}` : `${BASE}/apps/${a.id}/create-page`);
143
219
  }
144
220
  const [cols, defaults] = await Promise.all([
145
- owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows),
146
- owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel.t]).then((r) => r?.d ?? {}),
221
+ rel ? owner.query<Col>('select * from meta.wizard_catalog($1::regclass)', [rel.t]).then((r) => r.rows) : Promise.resolve([] as Col[]),
222
+ owner.one<{ d: Record<string, any> }>('select meta.wizard_defaults($1, $2::regclass) as d', [kind, rel?.t ?? null]).then((r) => r?.d ?? {}),
147
223
  ]);
148
224
  const nextPage = await freePage(a.id, Math.max(0, ...(await owner.query('select page_no from meta.page where app_id = $1', [a.id])).rows.map((p) => p.page_no)) + 1);
149
225
  const pk = cols.find((c) => c.is_pk)?.column_name ?? null;
150
226
  const role = a.db_role as string | null;
151
- const access = role
227
+ const access = role && rel
152
228
  ? await owner.one<{ s: boolean; i: boolean; u: boolean; d: boolean }>(
153
229
  `select has_table_privilege($1, $2::regclass, 'select') as s, has_table_privilege($1, $2::regclass, 'insert') as i,
154
230
  has_table_privilege($1, $2::regclass, 'update') as u, has_table_privilege($1, $2::regclass, 'delete') as d
155
231
  where exists (select 1 from pg_roles where rolname = $1)`,
156
- [role, rel.t],
232
+ [role, rel!.t],
157
233
  )
158
234
  : null;
159
235
 
@@ -171,6 +247,8 @@ export async function wizardRoutes(app: FastifyInstance) {
171
247
  )}</div></fieldset>`;
172
248
  const formPage = (help: string) => input('form_page', 'Form page', '', { type: 'number', placeholder: `e.g. ${nextPage + 1} (empty: no form)`, help });
173
249
  const needsPk = ['report_form', 'grid', 'form', 'master_detail'].includes(kind);
250
+ const allCols = (c: Col) => c.kind !== 'binary';
251
+ const dates = (c: Col) => c.kind === 'date' || c.kind === 'timestamp';
174
252
 
175
253
  let fields: Raw | string = '';
176
254
  switch (kind) {
@@ -228,43 +306,134 @@ export async function wizardRoutes(app: FastifyInstance) {
228
306
  ${check('search', 'A search field above the facets', defaults.search !== false)}
229
307
  ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
230
308
  break;
309
+ // (137) the page types of migration 137
310
+ case 'report':
311
+ fields = pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : '';
312
+ break;
313
+ case 'smart_filters':
314
+ fields = html`
315
+ ${colChecks('facets', 'Filters (suggested in the search field)', defaults.facets ?? [], (c) => notBinary(c) && c.kind !== 'point' && c.kind !== 'other', (c) => (c.fk_table ? `: values of ${c.fk_table}.${c.fk_display ?? c.column_name}` : ['number', 'date', 'timestamp'].includes(c.kind) ? ': ranges' : ': values'))}
316
+ ${pk ? html`<div class="form-grid">${formPage('A modal form page, linked from each row, and a Create button.')}</div>` : ''}`;
317
+ break;
318
+ case 'tree':
319
+ fields = html`<div class="form-grid">
320
+ ${select('id_column', 'Node id', defaults.id_column ?? '', colOpts(allCols, false), 'What identifies a node: usually the primary key.')}
321
+ ${select('parent_column', 'Parent', defaults.parent_column ?? '', colOpts(allCols), 'The column with the parent node\'s id; proposed from a foreign key to the table itself. Rows whose parent is not in the table are the top level.')}
322
+ ${select('label_column', 'Label', defaults.label_column ?? '', colOpts(notBinary, false))}
323
+ ${input('expanded', 'Levels open at first', defaults.expanded ?? 1, { type: 'number' })}
324
+ ${pk ? formPage('A modal form page, opened by clicking a node.') : ''}
325
+ </div>`;
326
+ break;
327
+ case 'timeline':
328
+ fields = html`<div class="form-grid">
329
+ ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false))}
330
+ ${select('when', 'When', defaults.when ?? '', colOpts(dates), 'The events come newest first.')}
331
+ ${select('who', 'Who', defaults.who ?? '', colOpts(notBinary))}
332
+ ${select('body', 'Text', defaults.body ?? '', colOpts(notBinary))}
333
+ ${pk ? formPage('A modal form page, linked from each title.') : ''}
334
+ </div>`;
335
+ break;
336
+ case 'media_list':
337
+ case 'content_row':
338
+ fields = html`<div class="form-grid">
339
+ ${select('title', 'Title', defaults.title ?? '', colOpts(notBinary, false), 'Its first two letters are the initials.')}
340
+ ${select('description', 'Description', defaults.description ?? '', colOpts(notBinary))}
341
+ ${kind === 'content_row' ? select('misc', 'Secondary text (on the right)', defaults.misc ?? '', colOpts(notBinary)) : ''}
342
+ ${select('badge', 'Badge', defaults.badge ?? '', colOpts(notBinary))}
343
+ ${pk ? formPage('A modal form page, linked from each title.') : ''}
344
+ </div>`;
345
+ break;
346
+ case 'comments':
347
+ fields = html`<div class="form-grid">
348
+ ${select('user', 'Who wrote it', defaults.user ?? '', colOpts(notBinary))}
349
+ ${select('date', 'When', defaults.date ?? '', colOpts(dates), 'The newest first.')}
350
+ ${select('comment', 'Comment', defaults.comment ?? '', colOpts(notBinary, false))}
351
+ </div>`;
352
+ break;
353
+ case 'data_load': {
354
+ const defs = (await owner.query<{ name: string; table_name: string }>('select name, table_name from meta.data_load_def where app_id = $1 order by name', [a.id])).rows;
355
+ fields = html`<div class="form-grid">
356
+ ${input('definition', 'Data load definition', defaults.definition ?? '', { required: true, help: `Upper case, e.g. ORDERS_LOAD. A new definition for ${rel?.t ?? 'the table'}; an existing one of that name is used as it is${defs.length ? ` (this application has ${defs.map((d) => d.name).join(', ')})` : ''}.` })}
357
+ ${select('mode', 'Mode', defaults.mode ?? 'append', [['append', 'Append: add the rows'], ['merge', 'Merge: update rows with the same key, add the others'], ['replace', 'Replace: delete all rows first']])}
358
+ </div>
359
+ ${check('skip_errors', 'Skip rows with errors', !!defaults.skip_errors, 'Load the good rows and list the others; otherwise nothing is loaded when a row fails.')}`;
360
+ break;
361
+ }
362
+ case 'wizard':
363
+ fields = html`<div class="form-grid">
364
+ ${input('steps', 'Steps', defaults.steps ?? 2, { type: 'number', help: 'One page per step, numbered from the page number up; the columns are shared out in order.' })}
365
+ ${input('return_page', 'After Finish or Cancel, go to page', defaults.return_page ?? '', { type: 'number', placeholder: a.home_page ? `empty: the home page (${a.home_page})` : 'empty: this page' })}
366
+ </div>
367
+ ${colChecks('columns', 'Columns', defaults.columns ?? [], (c) => !c.is_pk && !c.generated && c.kind !== 'binary' && c.kind !== 'geometry', (c) => (c.not_null && !c.has_default ? ', required: always included' : c.fk_table ? `, a select list of ${c.fk_table}` : ''))}`;
368
+ break;
369
+ case 'search': {
370
+ const configs = (await owner.query<{ name: string; label: string; source: string }>('select name, label, source from meta.search_config where app_id = $1 order by seq, name', [a.id])).rows;
371
+ const tables = await wizardTables(role);
372
+ fields = html`
373
+ ${configs.length
374
+ ? html`<fieldset class="prop-group"><legend>Search configurations</legend><div class="qb-pick">${configs.map((c) => html`<label class="check"><input type="checkbox" name="configs" value="${c.name}" checked> ${c.label} <span class="muted small">${c.name}, ${c.source.slice(0, 60)}</span></label>`)}</div></fieldset>`
375
+ : html`<p class="muted">This application has no search configurations yet (<a href="${BASE}/apps/${a.id}/shared?type=search_config">Shared Components → Search configurations</a>): choose a table to search below.</p>`}
376
+ <div class="form-grid">
377
+ ${select('table', 'And a new configuration on', rel?.t ?? '', [['', '- none -'], ...tables.filter((t) => t.access).map((t): [string, string] => [t.t, t.t])], 'Searches the table\'s text columns and shows its name (or first text) column as the result\'s title.')}
378
+ ${formPage('With a table: a modal form page that each result links to.')}
379
+ </div>`;
380
+ break;
381
+ }
382
+ case 'tasks':
383
+ fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'my', TASK_CONTEXTS)}</div>
384
+ ${check('completed', 'Also completed tasks', !!defaults.completed)}
385
+ <p class="muted small">Tasks come from task definitions (Shared Components → Task definitions) and approvals started in processes or workflows.</p>`;
386
+ break;
387
+ case 'workflows':
388
+ fields = html`<div class="form-grid">${select('context', 'Show', defaults.context ?? 'initiator', [['initiator', 'The workflows the user started'], ['admin', 'The workflows the user administers']])}</div>`;
389
+ break;
390
+ case 'dashboard': {
391
+ const tables = (await wizardTables(role)).filter((t) => t.access);
392
+ fields = tables.length
393
+ ? html`<fieldset class="prop-group"><legend>Tables</legend><div class="qb-pick">${tables.map((t, n) => html`<label class="check"><input type="checkbox" name="tables" value="${t.t}"${n < 12 ? raw(' checked') : ''}> ${t.t}</label>`)}</div></fieldset>
394
+ <p class="muted small">A chart per table (at most six) of the rows per parent row or per repeating value, read from the data; tables with row level security are left out.</p>`
395
+ : html`<p class="alert alert-error" role="status">The application's database role can't read any table.</p>`;
396
+ break;
397
+ }
231
398
  case 'master_detail': {
399
+ const t = rel!.t;
232
400
  const details = (
233
401
  await owner.query<{ t: string; col: string }>(
234
402
  `select meta.wizard_qname(con.conrelid) as t, a.attname::text as col
235
403
  from pg_constraint con join pg_attribute a on a.attrelid = con.conrelid and a.attnum = con.conkey[1]
236
404
  where con.confrelid = $1::regclass and con.contype = 'f' and con.conrelid <> con.confrelid and array_length(con.conkey, 1) = 1
237
405
  order by 1, 2`,
238
- [rel.t],
406
+ [t],
239
407
  )
240
408
  ).rows;
241
409
  fields = details.length
242
- ? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${rel.t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
243
- : html`<p class="alert alert-error" role="status">No table has a foreign key to ${rel.t}: a master-detail page needs one.</p>`;
410
+ ? select('detail', 'Detail table', defaults.detail ? `${defaults.detail}|${defaults.detail_column}` : '', details.map((d): [string, string] => [`${d.t}|${d.col}`, `${d.t} (${d.col} → ${t})`]), 'Tables with a foreign key to this table. Selecting a master row shows its details; new detail rows get the master\'s key.')
411
+ : html`<p class="alert alert-error" role="status">No table has a foreign key to ${t}: a master-detail page needs one.</p>`;
244
412
  break;
245
413
  }
246
414
  }
247
415
 
248
- const kindName = WIZARD_KINDS.find((k) => k[0] === kind)![1];
416
+ const kindDef = PAGE_KINDS.find((k) => k[0] === kind)!;
417
+ const kindName = kindDef[1];
249
418
  const main = html`${appHeader(a, 'pages', s)}
250
- ${region(`${kindName} on ${rel.t}`, html`
251
- <p class="muted u-mt0">${WIZARD_KINDS.find((k) => k[0] === kind)![2]} The proposals come from the table's columns, key and foreign keys; change them below. <a href="${BASE}/apps/${a.id}">Back to choose another type or table</a>.</p>
252
- ${needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
253
- ${role && access && !(access.s && (!needsPk || (access.i && access.u && access.d)))
254
- ? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : 'select'}).</p>`
419
+ ${region(rel && kind !== 'search' ? `${kindName} on ${rel.t}` : kindName, html`
420
+ <p class="muted u-mt0">${kindDef[2]} ${rel ? 'The proposals come from the table\'s columns, key and foreign keys; change them below.' : ''} <a href="${BASE}/apps/${a.id}/create-page">Back to choose another type${rel ? ' or table' : ''}</a>.</p>
421
+ ${rel && needsPk && !pk ? html`<p class="alert alert-error" role="status">${rel.t} has no single-column primary key, which this page type needs.</p>` : ''}
422
+ ${rel && role && access && !(access.s && (!(needsPk || kind === 'wizard' || kind === 'data_load') || (access.i && (kind === 'wizard' || (access.u && access.d)))))
423
+ ? html`<p class="alert alert-error" role="status">The application's database role <code>${role}</code> ${access.s ? 'can read but not change' : 'has no access to'} ${rel.t}: grant it the privileges the page needs (${needsPk ? 'select, insert, update, delete' : kind === 'wizard' ? 'select, insert' : kind === 'data_load' ? 'select, insert, update, delete for merge or replace' : 'select'}).</p>`
255
424
  : ''}
256
425
  <form method="post" action="${BASE}/apps/${a.id}/wizard">${csrf(s)}
257
- <input type="hidden" name="kind" value="${kind}"><input type="hidden" name="table" value="${rel.t}">
426
+ <input type="hidden" name="kind" value="${kind}">${rel && kind !== 'search' ? html`<input type="hidden" name="table" value="${rel.t}">` : ''}
258
427
  <div class="form-grid">
259
428
  ${input('report_page', 'Page number', nextPage, { type: 'number', required: true })}
260
429
  ${input('label', 'Name', defaults.label ?? '', { help: 'The page title and the navigation entry.' })}
261
430
  ${select('icon', 'Menu icon', defaults.icon ?? 'table', [...ICONS])}
262
431
  </div>
263
- ${['report_form', 'grid'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
432
+ ${['report_form', 'grid', 'dashboard'].includes(kind) ? '' : check('nav', 'Add a navigation entry', !!defaults.nav)}
264
433
  ${fields}
265
434
  <div class="buttons"><a class="btn" href="${BASE}/apps/${a.id}">Cancel</a><button class="btn btn-hot">Create page</button></div>
266
435
  </form>`)}`;
267
- return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], [`Create ${kindName.toLowerCase()} page`]], main));
436
+ return send(reply, s, shell(s, `Create page · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Create page', `${BASE}/apps/${a.id}/create-page`], [kindName]], main));
268
437
  });
269
438
 
270
439
  app.post(`${BASE}/apps/:id/wizard`, async (req: Req, reply) => {
@@ -272,26 +441,42 @@ export async function wizardRoutes(app: FastifyInstance) {
272
441
  if (!s) return;
273
442
  const b = (req.body ?? {}) as Record<string, unknown>;
274
443
  const kind = KINDS.has(String(b.kind)) ? String(b.kind) : 'report_form';
275
- const table = String(b.table ?? '');
276
- const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}&table=${encodeURIComponent(table)}`;
444
+ const table = kind === 'dashboard' || kind === 'tasks' || kind === 'workflows' ? '' : String(b.table ?? '');
445
+ const retry = `${BASE}/apps/${req.params.id}/wizard?kind=${encodeURIComponent(kind)}${table && kind !== 'search' ? `&table=${encodeURIComponent(table)}` : ''}`;
277
446
  try {
278
447
  const page = pageNo(b.report_page);
279
448
  if (page === null) throw new Error('Enter a page number.');
280
449
  const options = wizardOptions(kind, b);
450
+ if (!table && !NO_TABLE.has(kind)) throw new Error('Choose a table or view.');
281
451
  const id = await owner.tx(async (c) => {
282
- const a = await c.query('select alias from meta.app where id = $1', [req.params.id]);
452
+ const a = await c.query('select alias, db_role from meta.app where id = $1', [req.params.id]);
283
453
  if (!a.rowCount) throw new Error('This application does not exist.');
284
454
  // report and form and grid keep their own icons and need no step 2 (older forms post here directly)
285
455
  if (kind === 'report_form' && options.form_page === null) throw new Error('Enter the form page number.');
286
456
  if (['report_form', 'grid'].includes(kind) && !options.icon) options.icon = kind === 'grid' ? 'grid' : 'table';
287
- return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table, page, JSON.stringify(options)])).rows[0].id as number;
457
+ if (kind === 'dashboard') {
458
+ // (137) only tables the application's role can read
459
+ const readable = new Set((await wizardTables(a.rows[0].db_role)).filter((t) => t.access).map((t) => t.t));
460
+ const tables = (options.tables as string[]).filter((t) => readable.has(t));
461
+ if (!tables.length) throw new Error('Choose at least one table the application can read.');
462
+ const made = await addDashboard(c, a.rows[0].alias, tables, page);
463
+ if (!made) throw new Error('None of these tables has a column worth a chart (a foreign key, or values that repeat).');
464
+ const pid = (await c.query('select id from meta.page where app_id = $1 and page_no = $2', [req.params.id, page])).rows[0].id as number;
465
+ if (options.label) {
466
+ await c.query('update meta.page set name = $2, title = $2 where id = $1', [pid, options.label]);
467
+ await c.query('update meta.nav_entry set label = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.label]);
468
+ }
469
+ if (options.icon) await c.query('update meta.nav_entry set icon = $3 where app_id = $1 and target_page = $2', [req.params.id, page, options.icon]);
470
+ return pid;
471
+ }
472
+ return (await c.query('select meta.generate_page($1, $2, $3::regclass, $4, $5::jsonb) as id', [a.rows[0].alias, kind, table || null, page, JSON.stringify(options)])).rows[0].id as number;
288
473
  });
289
474
  const pages = (await owner.query('select page_no from meta.page where app_id = $1 and id >= $2 order by page_no', [req.params.id, id])).rows.map((r) => r.page_no);
290
- flash(s, `${pages.length > 1 ? `Pages ${pages.join(' and ')}` : `Page ${page}`} created for ${table}. Make sure the app's database role has privileges on it.`);
475
+ flash(s, `${pages.length > 1 ? `Pages ${pages.join(', ')}` : `Page ${page}`} created${table ? ` for ${table}. Make sure the app's database role has privileges on it` : ''}.`);
291
476
  return back(reply, s, `${BASE}/pages/${id}`);
292
477
  } catch (e) {
293
478
  flash(s, (e as Error).message, 'error');
294
- return back(reply, s, KINDS.has(String(b.kind)) && table ? retry : `${BASE}/apps/${req.params.id}`);
479
+ return back(reply, s, KINDS.has(String(b.kind)) && (table || NO_TABLE.has(kind)) ? retry : `${BASE}/apps/${req.params.id}`);
295
480
  }
296
481
  });
297
482
  }
@@ -385,7 +385,8 @@ export async function workspaceRoutes(app: FastifyInstance) {
385
385
  <div class="buttons"><button class="btn">${tx('wsp.save_devs')}</button></div></form>`)}
386
386
  </div>
387
387
  ${serviceRegion(s, w)}
388
- ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
388
+ ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>
389
+ <p>${tx.rich('wsp.telemetry_text', { link: html`<a href="${BASE}/workspaces/${w.id}/telemetry">${tx('wsp.telemetry')}</a>` })}</p>`, '', 'mail-network')}
389
390
  <div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
390
391
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
391
392
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
@@ -12,6 +12,7 @@
12
12
  import { randomBytes } from 'node:crypto';
13
13
  import type pg from 'pg';
14
14
  import { regionKeys } from '../appfiles.ts';
15
+ import { ensureSnapshot, recordHistory } from '../history.ts';
15
16
 
16
17
  /** Tables of an application that are its definition: replaced. */
17
18
  export const REPLACED = [
@@ -86,7 +87,10 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
86
87
  // (126) outgoing calls the network access lists refused (Administration → Network access; a log, 7 days)
87
88
  'network_refusal',
88
89
  // (136) rows queued for their embeddings (the vector providers travel in meta.app)
89
- 'embedding_queue'];
90
+ 'embedding_queue',
91
+ // (138) the change history and the snapshot it compares with (this installation's record)
92
+ 'app_snapshot', 'component_change',
93
+ ];
90
94
  /** Children of pages (replaced with their page). */
91
95
  const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
92
96
  /** References into replaced tables from kept data, repointed below: "table.column". */
@@ -140,6 +144,9 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
140
144
  const target = (await db.query<{ id: number }>('select id from meta.app where alias = $1 for update', [alias])).rows[0];
141
145
  if (!target) throw new Error(`application ${alias} not found`);
142
146
  const old = target.id;
147
+ // (sprint 138) the change history compares the application before and after (src/history.ts); not on a bare pool
148
+ const inTx = !('totalCount' in db);
149
+ if (inTx) await ensureSnapshot(old, db as pg.ClientBase);
143
150
  const backup = opts.backup === undefined ? { reason: 'import' as const } : opts.backup;
144
151
  if (backup) await db.query('select meta.backup_app($1, $2, $3, $4)', [old, backup.reason, backup.user ?? null, backup.note ?? null]);
145
152
  const tmpAlias = `pgkiln-replace-${randomBytes(6).toString('hex')}`;
@@ -251,5 +258,6 @@ export async function replaceApp(db: Db, doc: unknown, alias: string, opts: Repl
251
258
  [old, neu],
252
259
  );
253
260
  await db.query('delete from meta.app where id = $1', [neu]);
261
+ if (inTx) await recordHistory(old, opts.backup ? (opts.backup.user ?? null) : null, 'import', db as pg.ClientBase).catch(() => {});
254
262
  return old;
255
263
  }