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,206 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { newToken } from '../security.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import {
7
+ instanceTelemetry, refreshTelemetry, saveInstanceTelemetry, saveWorkspaceTelemetry, setMetricsToken, workspaceTelemetry,
8
+ type InstanceForm, type Origin, type WorkspaceForm,
9
+ } from '../telemetry/config.ts';
10
+ import { destinationStatus, sendTest } from '../telemetry/export.ts';
11
+ import { isAdmin } from './locks.ts';
12
+ import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
+ import { workspaceAdmin } from './workspaces.ts';
14
+
15
+ // (sprint 139, AP-6; APEX: Workspace Utilities → OpenTelemetry) Workspace
16
+ // utilities → OpenTelemetry (administrators): the instance's collector, what
17
+ // it receives, GET /metrics, and each workspace's choice; a workspace's
18
+ // administrators switch its applications off or add a collector of their own
19
+ // on /builder/workspaces/:id/telemetry. Headers (an API key) are write-only:
20
+ // stored encrypted, only their names are shown.
21
+
22
+ const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['OpenTelemetry']];
23
+ const ORIGIN: Record<Origin, string> = { builder: 'set here', environment: 'environment variable', default: 'default' };
24
+
25
+ const check = (name: string, label: string, on: boolean, help?: string) =>
26
+ html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
27
+
28
+ const headersField = (names: string[], help: string) => html`<div class="field"><label class="label" for="f_headers">Headers</label>
29
+ <textarea id="f_headers" name="headers" rows="2" maxlength="4000" spellcheck="false" autocomplete="off" placeholder="${names.length ? `${names.join(', ')} set: type new headers to replace them` : 'e.g. x-honeycomb-team=…, Authorization=Bearer …'}"></textarea>
30
+ <small class="help">${help}${names.length ? html` Set now: <b>${names.join(', ')}</b> (values hidden).` : ''}</small>
31
+ ${names.length ? html`<label class="check"><input type="checkbox" name="remove_headers" value="true"> Remove the headers</label>` : ''}</div>`;
32
+
33
+ const when = (ms: number | null) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
34
+
35
+ function statusCell(key: string): Raw {
36
+ const st = destinationStatus(key);
37
+ return html`${st.sent} sent${st.dropped ? html`, <span class="otel-problem">${st.dropped} dropped</span>` : ''} · last success ${when(st.lastOkAt)}
38
+ ${st.lastError ? html`<br><span class="otel-problem">Last problem (${when(st.lastErrorAt)}): ${st.lastError}</span>` : ''}`;
39
+ }
40
+
41
+ async function adminOnly(s: Session, reply: FastifyReply) {
42
+ if (await isAdmin(s.username)) return true;
43
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', crumbs, html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only administrators set up OpenTelemetry for the installation.</div>`));
44
+ return false;
45
+ }
46
+
47
+ export async function telemetryBuilderRoutes(app: FastifyInstance) {
48
+ app.get(`${BASE}/telemetry`, async (req: Req, reply) => {
49
+ const s = await developer(req, reply);
50
+ if (!s || !(await adminOnly(s, reply))) return;
51
+ await refreshTelemetry(true);
52
+ const t = instanceTelemetry();
53
+ const token = typeof s.state.__METRICS_TOKEN === 'string' ? s.state.__METRICS_TOKEN : null;
54
+ if (token) {
55
+ delete s.state.__METRICS_TOKEN;
56
+ const { saveState } = await import('../session.ts');
57
+ await saveState(s);
58
+ }
59
+ const workspaces = (await owner.query<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; apps: number }>(
60
+ `select w.id, w.name, w.telemetry, w.telemetry_endpoint, (select count(*) from meta.app a where meta.app_workspace(a.id) = w.id)::int as apps
61
+ from meta.workspace w order by w.name`)).rows;
62
+ const from = (k: string) => html`<span class="muted">(${ORIGIN[t.origin[k] ?? 'default']})</span>`;
63
+ const pub = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
64
+ const main = html`<h1 class="u-mb1">OpenTelemetry</h1>
65
+ <p class="muted u-mt0">Send traces, logs and metrics of the applications to your own monitoring (Grafana, Jaeger, Datadog, Honeycomb, OCI APM,
66
+ or anything that speaks OTLP) through an OpenTelemetry Collector. A trace per page request, with a span for each SQL statement, REST data source call,
67
+ AI request and tool call; mail and workflow steps as traces of their own. pgkiln sends OTLP over HTTP as JSON. The standard <code>OTEL_*</code>
68
+ environment variables work too; a value set here wins. Chapter 1 of the guide (Monitoring with OpenTelemetry) explains every choice.</p>
69
+ ${t.protocolWarning ? html`<div class="alert alert-warning" role="status">${t.protocolWarning}</div>` : ''}
70
+ <div class="columns wide-left">
71
+ ${region('Collector', html`<form method="post" action="${BASE}/telemetry">${csrf(s)}
72
+ ${check('enabled', 'Send to this collector', t.enabled, 'Off: nothing leaves the server (workspaces with a collector of their own still send to it).')}
73
+ <div class="form-grid">
74
+ ${input('endpoint', 'Collector URL', t.origin.otel_endpoint === 'builder' ? t.endpoint : '', { placeholder: t.endpoint || 'http://otel-collector:4318', help: html`The OTLP/HTTP base address; /v1/traces, /v1/logs and /v1/metrics are added. In effect: <b>${t.endpoint || 'none'}</b> ${from('otel_endpoint')} (<code>OTEL_EXPORTER_OTLP_ENDPOINT</code>).` as unknown as string })}
75
+ ${input('service_name', 'Service name', t.origin.otel_service_name === 'builder' ? t.serviceName : '', { placeholder: t.serviceName, help: `service.name of everything sent (OTEL_SERVICE_NAME). In effect: ${t.serviceName}.` })}
76
+ ${input('sample', 'Sample ratio', t.origin.otel_sample === 'builder' ? String(t.sample) : '', { placeholder: String(t.sample), help: 'Share of traces sent, 0 to 1 (OTEL_TRACES_SAMPLER_ARG). A trace with an error is always sent. A caller\'s traceparent decides for its trace.' })}
77
+ ${select('user', 'User', t.user, [['hashed', 'Hashed (the same user gives the same value, the name is not sent)'], ['name', 'User name'], ['none', 'Not sent']], 'enduser.id on the page span and on logs.')}
78
+ </div>
79
+ ${headersField(t.headerNames, 'Sent with every request to the collector, name=value, one per line or comma separated (OTEL_EXPORTER_OTLP_HEADERS). Stored encrypted.')}
80
+ <fieldset class="field"><legend class="label">Send</legend><div class="radio-group">
81
+ ${check('traces', 'Traces', t.traces)}${check('logs', 'Logs: errors, refused access and failed sign-ins', t.logs)}${check('metrics', `Metrics, every ${Math.round(t.intervalMs / 1000)} seconds`, t.metrics)}
82
+ </div></fieldset>
83
+ ${check('values', 'Include values', t.values, 'SQL statements with their literals, bind values, query strings and AI tool arguments. Off: literals become ?, values are left out and error messages have quoted values removed.')}
84
+ ${check('builder', 'Trace builder requests too', t.builder, 'Applications only by default.')}
85
+ ${check('browser', 'Browser spans', t.browser, 'Pages report their load time and Ajax calls to pgkiln, which adds them to the page\'s trace. The browser never sees the collector or its headers.')}
86
+ ${check('prometheus', 'Prometheus endpoint /metrics', t.prometheus, 'For administrators signed in to the builder, or a scraper with the metrics token below (PGKILN_METRICS, PGKILN_METRICS_TOKEN).')}
87
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
88
+ <form method="post" action="${BASE}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>`)}
89
+ ${region('Status', html`<p class="muted u-mt0">Since this server started. Each server of the installation sends its own.</p>
90
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Collector</th><th>Sending</th></tr></thead><tbody>
91
+ <tr><td data-label="Collector">This instance's${t.enabled ? '' : html` <span class="muted">(off)</span>`}</td><td data-label="Sending">${statusCell('instance')}</td></tr>
92
+ ${workspaces.filter((w) => w.telemetry === 'own').map((w) => html`<tr><td data-label="Collector">Workspace ${w.name}</td><td data-label="Sending">${statusCell(`ws:${w.id}`)}</td></tr>`)}
93
+ </tbody></table></div>`)}
94
+ ${region('Prometheus', html`<p class="muted u-mt0">${t.prometheus ? html`<code>GET ${pub || ''}/metrics</code> answers with the counters, durations and queue lengths in the Prometheus text format.` : 'Off: switch it on above.'}
95
+ A scraper sends <code>Authorization: Bearer &lt;token&gt;</code>. ${t.prometheusTokenHash ? (t.origin.metrics_token_hash === 'builder' || !process.env.PGKILN_METRICS_TOKEN ? 'A token is set.' : 'The token is PGKILN_METRICS_TOKEN.') : 'No token is set.'}</p>
96
+ ${token ? html`<div class="alert alert-success" role="status">The new token (shown once): <code class="cell-break">${token}</code></div>` : ''}
97
+ <form method="post" action="${BASE}/telemetry/token" class="buttons">${csrf(s)}<button class="btn" name="action" value="new">${t.prometheusTokenHash ? 'Replace the token' : 'Make a token'}</button>
98
+ ${t.prometheusTokenHash ? html`<button class="btn btn-danger" name="action" value="remove" data-confirm="Remove the metrics token? Scrapers that use it get 401.">Remove the token</button>` : ''}</form>`)}
99
+ ${region('Workspaces', html`<p class="muted u-mt0">A workspace's administrators may switch its applications off, or send them to a collector of their own as well.</p>
100
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Workspace</th><th>Applications</th><th>Sends</th></tr></thead><tbody>
101
+ ${workspaces.length ? workspaces.map((w) => html`<tr><td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}/telemetry">${w.name}</a></td><td data-label="Applications" class="num">${w.apps}</td>
102
+ <td data-label="Sends" class="cell-break">${w.telemetry === 'off' ? 'nothing' : w.telemetry === 'own' ? html`to the instance's collector and ${w.telemetry_endpoint}` : 'to the instance\'s collector'}</td></tr>`)
103
+ : html`<tr><td colspan="3" class="empty">No workspaces.</td></tr>`}</tbody></table></div>`)}
104
+ </div>`;
105
+ return send(reply, s, shell(s, 'OpenTelemetry', crumbs, main));
106
+ });
107
+
108
+ app.post(`${BASE}/telemetry`, async (req: Req, reply) => {
109
+ const s = await developer(req, reply);
110
+ if (!s || !(await adminOnly(s, reply))) return;
111
+ const problem = await saveInstanceTelemetry((req.body ?? {}) as InstanceForm);
112
+ if (!problem) {
113
+ const t = instanceTelemetry();
114
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req),
115
+ detail: `OpenTelemetry: ${t.enabled ? `on, ${t.endpoint}` : 'off'}, sample ${t.sample}, user ${t.user}, values ${t.values}, browser ${t.browser}, /metrics ${t.prometheus}` });
116
+ }
117
+ flash(s, problem ?? 'OpenTelemetry settings saved.', problem ? 'error' : 'ok');
118
+ return back(reply, s, `${BASE}/telemetry`);
119
+ });
120
+
121
+ app.post(`${BASE}/telemetry/test`, async (req: Req, reply) => {
122
+ const s = await developer(req, reply);
123
+ if (!s || !(await adminOnly(s, reply))) return;
124
+ const problem = await sendTest(null);
125
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
126
+ return back(reply, s, `${BASE}/telemetry`);
127
+ });
128
+
129
+ app.post(`${BASE}/telemetry/token`, async (req: Req, reply) => {
130
+ const s = await developer(req, reply);
131
+ if (!s || !(await adminOnly(s, reply))) return;
132
+ const action = (req.body as { action?: string } | undefined)?.action;
133
+ if (action === 'new') {
134
+ const token = `pgkm_${newToken()}`;
135
+ await setMetricsToken(token);
136
+ s.state.__METRICS_TOKEN = token;
137
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token made' });
138
+ } else if (action === 'remove') {
139
+ await setMetricsToken(null);
140
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token removed' });
141
+ flash(s, 'The metrics token is removed.');
142
+ }
143
+ return back(reply, s, `${BASE}/telemetry`);
144
+ });
145
+
146
+ // ---------------------------------------------------------------- a workspace's own
147
+
148
+ const wsAllowed = async (s: Session, reply: FastifyReply, id: string) => {
149
+ if (await workspaceAdmin(s.username, id)) return true;
150
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', [['Administration'], ['OpenTelemetry']],
151
+ html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only the workspace's administrators change its OpenTelemetry setting.</div>`, 'users'));
152
+ return false;
153
+ };
154
+
155
+ app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
156
+ const s = await developer(req, reply);
157
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
158
+ const w = await owner.one<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; telemetry_values: boolean; has_headers: boolean }>(
159
+ 'select id, name, telemetry, telemetry_endpoint, telemetry_values, telemetry_headers_enc is not null as has_headers from meta.workspace where id = $1', [req.params.id]);
160
+ if (!w) return reply.code(404).send('Not found');
161
+ await refreshTelemetry(true);
162
+ const t = instanceTelemetry();
163
+ const names = Object.keys(workspaceTelemetry(w.id)?.headers ?? {});
164
+ const radio = (v: string, label: string, help: string) => html`<label class="check"><input type="radio" name="mode" value="${v}"${w.telemetry === v ? raw(' checked') : ''}> ${label} <span class="muted small">${help}</span></label>`;
165
+ const main = html`<div class="title-row"><h1>OpenTelemetry · ${w.name}</h1></div>
166
+ <p class="muted u-mt0">Traces and logs of this workspace's applications. ${t.enabled ? 'The instance sends them to its collector.' : 'The instance sends nothing at the moment.'}
167
+ A collector of the workspace's own is reached through the installation's allow-list and this workspace's <a href="${BASE}/network?ws=${w.id}">network access</a> list.</p>
168
+ <div class="columns wide-left">
169
+ ${region('Setting', html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry">${csrf(s)}
170
+ <fieldset class="field"><legend class="label">The applications send</legend><div class="radio-group">
171
+ ${radio('instance', 'As the instance does', '(default)')}
172
+ ${radio('off', 'Nothing', 'not even to the instance\'s collector')}
173
+ ${radio('own', 'Also to our own collector', '')}
174
+ </div></fieldset>
175
+ <div class="form-grid">${input('endpoint', 'Own collector URL', w.telemetry_endpoint ?? '', { placeholder: 'https://otlp.example.com', help: 'The OTLP/HTTP base address; /v1/traces and /v1/logs are added.' })}</div>
176
+ ${headersField(names.length ? names : w.has_headers ? ['(unreadable)'] : [], 'Sent with every request to the own collector (an API key). Stored encrypted.')}
177
+ ${check('values', 'Include values for the own collector', w.telemetry_values, 'SQL statements with their literals, bind values and query strings.')}
178
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
179
+ ${w.telemetry === 'own' ? html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>` : ''}`)}
180
+ ${w.telemetry === 'own' ? region('Status', html`<p class="u-mt0">${statusCell(`ws:${w.id}`)}</p>`) : ''}
181
+ </div>`;
182
+ return send(reply, s, shell(s, `OpenTelemetry · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['OpenTelemetry']], main, 'users'));
183
+ });
184
+
185
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
186
+ const s = await developer(req, reply);
187
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
188
+ const id = Number(req.params.id);
189
+ if (!(await owner.one('select 1 from meta.workspace where id = $1', [id]))) return reply.code(404).send('Not found');
190
+ const f = (req.body ?? {}) as WorkspaceForm;
191
+ const problem = await saveWorkspaceTelemetry(id, f);
192
+ if (!problem)
193
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} OpenTelemetry: ${f.mode}${f.mode === 'own' ? ` ${f.endpoint}` : ''}` });
194
+ flash(s, problem ?? 'Saved.', problem ? 'error' : 'ok');
195
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
196
+ });
197
+
198
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry/test`, async (req: Req, reply) => {
199
+ const s = await developer(req, reply);
200
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
201
+ const id = Number(req.params.id);
202
+ const problem = workspaceTelemetry(id)?.mode === 'own' ? await sendTest(id) : 'The workspace has no collector of its own.';
203
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
204
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
205
+ });
206
+ }
@@ -0,0 +1,161 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
6
+ import { usableSchemas, workshopDb } from './workspaces.ts';
7
+
8
+ // (sprint 138, AP-5) UI defaults (APEX: Attribute Dictionary / Table UI
9
+ // Defaults): per table column a label, format mask, help text, alignment and
10
+ // list of values, in meta.ui_default (migration 138). The page wizards and the
11
+ // Create Application wizard take the label from meta.wizard_column_label (a UI
12
+ // default wins); a form item a wizard makes also gets the mask, help and list
13
+ // of values (the meta.item trigger of migration 138). "Seed from comments" fills
14
+ // the columns without a UI default from their COMMENT ON: a short comment (60
15
+ // characters at most) as the label, a longer one as the help text.
16
+ //
17
+ // The tables are those the workspace's SQL Workshop connection can see
18
+ // (workshopDb: an isolated workspace's own schemas); a table or column name
19
+ // from a request is only used after it was found in that catalog.
20
+
21
+ export const ALIGNMENTS = ['', 'start', 'center', 'end'] as const;
22
+ /** Format masks: numbers (999G990D00, FML…) and dates (DD.MM.YYYY HH24:MI); the database checks the same. */
23
+ export const MASK = /^[A-Za-z0-9GDLSCFM.,:/ -]{1,60}$/;
24
+ const MAX_COLUMNS = 400;
25
+
26
+ interface Column {
27
+ name: string;
28
+ type: string;
29
+ comment: string | null;
30
+ wizard: string;
31
+ d: { label: string | null; format_mask: string | null; help: string | null; alignment: string | null; lov: string | null; updated_by: string | null; updated_at: string | null } | null;
32
+ }
33
+
34
+ /** A table the session's catalog can see: [schema, table], or null. */
35
+ async function findTable(s: Parameters<typeof workshopDb>[0], schema: unknown, table: unknown) {
36
+ if (typeof schema !== 'string' || typeof table !== 'string' || !schema || !table) return null;
37
+ const db = await workshopDb(s);
38
+ if (!(await usableSchemas(db)).includes(schema)) return null;
39
+ const r = await db.one<{ oid: number }>(
40
+ `select c.oid::int as oid from pg_class c join pg_namespace n on n.oid = c.relnamespace
41
+ where n.nspname = $1 and c.relname = $2 and c.relkind in ('r', 'p', 'v', 'm', 'f')`, [schema, table]);
42
+ return r ? { schema, table, oid: r.oid } : null;
43
+ }
44
+
45
+ async function columnsOf(oid: number, schema: string, table: string): Promise<Column[]> {
46
+ const cols = (await owner.query(
47
+ `select a.attname as name, format_type(a.atttypid, a.atttypmod) as type, col_description(a.attrelid, a.attnum) as comment,
48
+ meta.wizard_column_label(a.attrelid::regclass, a.attname) as wizard
49
+ from pg_attribute a where a.attrelid = $1 and a.attnum > 0 and not a.attisdropped order by a.attnum limit ${MAX_COLUMNS}`, [oid])).rows;
50
+ const defaults = new Map((await owner.query(
51
+ `select column_name, label, format_mask, help, alignment, lov, updated_by, updated_at::text from meta.ui_default where schema_name = $1 and table_name = $2`,
52
+ [schema, table])).rows.map((r) => [r.column_name, r]));
53
+ return cols.map((c) => ({ ...c, d: defaults.get(c.name) ?? null }));
54
+ }
55
+
56
+ const trim = (v: unknown, max: number) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
57
+
58
+ export async function uiDefaultRoutes(app: FastifyInstance) {
59
+ app.get(`${BASE}/ui-defaults`, async (req: Req, reply) => {
60
+ const s = await developer(req, reply);
61
+ if (!s) return;
62
+ const db = await workshopDb(s);
63
+ const schemas = await usableSchemas(db);
64
+ const q = req.query ?? {};
65
+ const schema = typeof q.schema === 'string' && schemas.includes(q.schema) ? q.schema : '';
66
+ const tables = schema
67
+ ? (await db.query<{ name: string; n: number }>(
68
+ `select c.relname as name, (select count(*) from meta.ui_default d where d.schema_name = $1 and d.table_name = c.relname)::int as n
69
+ from pg_class c join pg_namespace n on n.oid = c.relnamespace where n.nspname = $1 and c.relkind in ('r', 'p', 'v', 'm', 'f') and not c.relispartition order by 1`,
70
+ [schema])).rows
71
+ : [];
72
+ const t = schema ? await findTable(s, schema, q.table) : null;
73
+ const counts = (await owner.query<{ schema_name: string; n: number }>('select schema_name, count(*)::int as n from meta.ui_default group by 1')).rows;
74
+ const pick = html`<form method="get" class="util-filters">
75
+ <div class="field"><label class="label" for="f_schema">Schema</label><select id="f_schema" name="schema"><option value="">Choose a schema</option>
76
+ ${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}${counts.find((c) => c.schema_name === n) ? ` (${counts.find((c) => c.schema_name === n)!.n})` : ''}</option>`)}</select></div>
77
+ ${schema ? html`<div class="field"><label class="label" for="f_table">Table or view</label><select id="f_table" name="table"><option value="">Choose a table</option>
78
+ ${tables.map((x) => html`<option value="${x.name}"${x.name === t?.table ? raw(' selected') : ''}>${x.name}${x.n ? ` (${x.n})` : ''}</option>`)}</select></div>` : ''}
79
+ <div class="buttons"><button class="btn btn-sm">${icon('table')} Show</button></div></form>`;
80
+ let body = html`${pick}`;
81
+ if (t) {
82
+ const cols = await columnsOf(t.oid, t.schema, t.table);
83
+ body = html`${pick}
84
+ <form method="post" action="${BASE}/ui-defaults">${csrf(s)}<input type="hidden" name="schema" value="${t.schema}"><input type="hidden" name="table" value="${t.table}">
85
+ <div class="table-wrap"><table class="table ui-defaults report-reflow"><thead><tr><th scope="col">Column</th><th scope="col">Label</th><th scope="col">Format mask</th><th scope="col">Alignment</th><th scope="col">Help</th><th scope="col">List of values</th></tr></thead><tbody>
86
+ ${cols.map((c, i) => html`<tr>
87
+ <td data-label="Column"><code>${c.name}</code><br><span class="muted small">${c.type}</span><input type="hidden" name="c.${i}.name" value="${c.name}"></td>
88
+ <td data-label="Label"><input id="f_l${i}" aria-label="Label of ${c.name}" name="c.${i}.label" value="${c.d?.label ?? ''}" maxlength="200" placeholder="${c.wizard}"></td>
89
+ <td data-label="Format mask"><input id="f_m${i}" aria-label="Format mask of ${c.name}" name="c.${i}.format_mask" value="${c.d?.format_mask ?? ''}" maxlength="60" placeholder="${/numeric|integer|bigint|smallint|real|double/.test(c.type) ? '999G990D00' : /date|time/.test(c.type) ? 'DD.MM.YYYY' : ''}"></td>
90
+ <td data-label="Alignment"><select id="f_a${i}" aria-label="Alignment of ${c.name}" name="c.${i}.alignment">${ALIGNMENTS.map((x) => html`<option value="${x}"${(c.d?.alignment ?? '') === x ? raw(' selected') : ''}>${x || '–'}</option>`)}</select></td>
91
+ <td data-label="Help"><textarea id="f_h${i}" aria-label="Help of ${c.name}" name="c.${i}.help" rows="1" maxlength="4000">${c.d?.help ?? ''}</textarea></td>
92
+ <td data-label="List of values"><input id="f_v${i}" aria-label="List of values of ${c.name}" name="c.${i}.lov" value="${c.d?.lov ?? ''}" maxlength="4000" placeholder="LOV:NAME"></td>
93
+ </tr>`)}</tbody></table></div>
94
+ <div class="buttons"><button class="btn btn-hot" name="mode" value="save">${icon('save')} Save</button>
95
+ <button class="btn" name="mode" value="seed">${icon('edit')} Seed from comments</button></div>
96
+ <p class="muted small">An empty row has no UI default. The placeholder of a label shows what the wizards use without one. Lists of values: <code>LOV:NAME</code> (a shared list of values of the application), <code>STATIC:Yes;Y,No;N</code> or a query (<code>select name d, id r from sales.status</code>).</p>
97
+ </form>`;
98
+ }
99
+ const main = html`<h1 class="u-mb1">UI defaults</h1>
100
+ ${region('UI defaults per column', html`<p class="muted u-mt0">Label, format mask, help text, alignment and list of values per table column (APEX: Attribute Dictionary). The page wizards and the Create Application wizard use the label for every generated report heading and form item, and a generated form item also gets the format mask, help and list of values. They apply to pages generated after the change, in every application.</p>${body}`)}`;
101
+ return send(reply, s, shell(s, 'UI defaults', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['UI defaults']], main));
102
+ });
103
+
104
+ app.post(`${BASE}/ui-defaults`, async (req: Req, reply) => {
105
+ const s = await developer(req, reply);
106
+ if (!s) return;
107
+ const b: Body = req.body ?? {};
108
+ const t = await findTable(s, b.schema, b.table);
109
+ if (!t) return reply.code(404).send('Not found');
110
+ const to = `${BASE}/ui-defaults?${new URLSearchParams({ schema: t.schema, table: t.table })}`;
111
+ const cols = await columnsOf(t.oid, t.schema, t.table);
112
+ const known = new Map(cols.map((c) => [c.name, c]));
113
+ try {
114
+ if (b.mode === 'seed') {
115
+ // columns without a UI default: a short comment is the label, a longer one the help text
116
+ let n = 0;
117
+ for (const c of cols) {
118
+ if (c.d || !c.comment?.trim()) continue;
119
+ const text = c.comment.trim();
120
+ const short = text.length <= 60 && !text.includes('\n');
121
+ await owner.query(
122
+ `insert into meta.ui_default (schema_name, table_name, column_name, label, help, updated_by) values ($1, $2, $3, $4, $5, $6) on conflict do nothing`,
123
+ [t.schema, t.table, c.name, short ? text : null, short ? null : text.slice(0, 4000), s.username]);
124
+ n++;
125
+ }
126
+ flash(s, n ? `UI defaults of ${n} column${n === 1 ? '' : 's'} made from their comments.` : 'No column without a UI default has a comment.');
127
+ return back(reply, s, to);
128
+ }
129
+ const rows: { name: string; label: string | null; mask: string | null; help: string | null; align: string | null; lov: string | null }[] = [];
130
+ for (let i = 0; i < MAX_COLUMNS; i++) {
131
+ const name = b[`c.${i}.name`];
132
+ if (typeof name !== 'string' || !known.has(name)) continue;
133
+ const mask = trim(b[`c.${i}.format_mask`], 60);
134
+ if (mask && !MASK.test(mask)) throw new Error(`Format mask of ${name}: letters, digits and . , : / - only (e.g. 999G990D00 or DD.MM.YYYY).`);
135
+ const align = trim(b[`c.${i}.alignment`], 10);
136
+ if (align && !(ALIGNMENTS as readonly string[]).includes(align)) throw new Error(`Alignment of ${name}: start, center or end.`);
137
+ rows.push({ name, label: trim(b[`c.${i}.label`], 200), mask, help: trim(b[`c.${i}.help`], 4000), align, lov: trim(b[`c.${i}.lov`], 4000) });
138
+ }
139
+ await owner.tx(async (c) => {
140
+ for (const r of rows) {
141
+ const before = known.get(r.name)!.d;
142
+ if (!r.label && !r.mask && !r.help && !r.align && !r.lov) {
143
+ if (before) await c.query('delete from meta.ui_default where schema_name = $1 and table_name = $2 and column_name = $3', [t.schema, t.table, r.name]);
144
+ continue;
145
+ }
146
+ if (before && before.label === r.label && before.format_mask === r.mask && before.help === r.help && before.alignment === r.align && before.lov === r.lov) continue;
147
+ await c.query(
148
+ `insert into meta.ui_default (schema_name, table_name, column_name, label, format_mask, help, alignment, lov, updated_by)
149
+ values ($1, $2, $3, $4, $5, $6, $7, $8, $9)
150
+ on conflict (schema_name, table_name, column_name) do update set label = excluded.label, format_mask = excluded.format_mask, help = excluded.help,
151
+ alignment = excluded.alignment, lov = excluded.lov, updated_by = excluded.updated_by, updated_at = now()`,
152
+ [t.schema, t.table, r.name, r.label, r.mask, r.help, r.align, r.lov, s.username]);
153
+ }
154
+ });
155
+ flash(s, `UI defaults of ${t.schema}.${t.table} saved.`);
156
+ } catch (e) {
157
+ flash(s, (e as Error).message, 'error');
158
+ }
159
+ return back(reply, s, to);
160
+ });
161
+ }
package/src/builder/ui.ts CHANGED
@@ -243,7 +243,7 @@ export const region = (title: string, body: Raw | Raw[], extra: Raw | '' = '', i
243
243
  html`<section class="region region-standard"${id ? raw(` id="${id.replace(/[^a-z0-9-]/g, '')}"`) : ''}><header class="region-header"><h2>${title}</h2>${extra}</header><div class="region-body">${body}</div></section>`;
244
244
 
245
245
  /** Title and tabs of an application's builder pages (Pages, Shared Components, …), in the session's builder language. */
246
- export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor', s?: Pick<Session, 'state'>) => {
246
+ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>) => {
247
247
  const tx = tr(s);
248
248
  const tab = (key: typeof active, href: string, ic: string, label: string) =>
249
249
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${bicon(ic)}<span>${label}</span></a>`;
@@ -266,6 +266,7 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
266
266
  ${tab('settings', `${BASE}/apps/${a.id}/settings`, 'settings', tx('app.tab.settings'))}
267
267
  ${tab('search', `${BASE}/apps/${a.id}/search`, 'search', tx('common.search'))}
268
268
  ${tab('advisor', `${BASE}/apps/${a.id}/advisor`, 'check', tx('app.tab.advisor'))}
269
+ ${tab('utilities', `${BASE}/apps/${a.id}/utilities`, 'wrench', tx('app.tab.utilities'))}
269
270
  </nav>
270
271
  </div>`;
271
272
  };
@@ -0,0 +1,154 @@
1
+ import type { FastifyInstance } from 'fastify';
2
+ import { stableJson } from '../appfiles.ts';
3
+ import { owner } from '../db.ts';
4
+ import { recordHistory } from '../history.ts';
5
+ import { html, raw } from '../html.ts';
6
+ import { icon } from '../icons.ts';
7
+ import { appHeader, back, BASE, csrf, designerUrl, developer, flash, region, send, shell, type Req } from './ui.ts';
8
+ import { diffHtml } from './utilities.ts';
9
+
10
+ // (sprint 138, AP-5) App Builder → Utilities → Upgrade application (APEX:
11
+ // Upgrade Application): components made before a pgkiln version that brought a
12
+ // better default, each with its reason, a preview of the change and an Upgrade
13
+ // button (or all of a kind at once). The candidates are worked out again on
14
+ // every request: a POST names a candidate's key, and only a candidate that is
15
+ // still found is changed. Each upgrade is recorded in the change history.
16
+ //
17
+ // Rules:
18
+ // - nav_appearance: an application without a navigation appearance gets
19
+ // Pillar Light (sprint 117), the look of new applications.
20
+ // - date_picker: a text item of a form whose column is a date or a timestamp
21
+ // becomes a date / date-time item with pgkiln's date picker (sprint 84).
22
+ // - popup_lov: a select list whose list of values reads a table of more than
23
+ // POPUP_ROWS rows (the planner's estimate) becomes a popup list of values,
24
+ // which searches and pages on the server instead of sending every row.
25
+
26
+ export const POPUP_ROWS = 1000;
27
+
28
+ export interface Candidate {
29
+ key: string;
30
+ rule: Rule;
31
+ label: string;
32
+ page: number | null;
33
+ url: string;
34
+ reason: string;
35
+ before: Record<string, unknown>;
36
+ after: Record<string, unknown>;
37
+ /** the change: a statement and its parameters */
38
+ sql: [string, unknown[]];
39
+ }
40
+
41
+ export type Rule = 'nav_appearance' | 'date_picker' | 'popup_lov';
42
+ export const RULES: Record<Rule, { title: string; since: string }> = {
43
+ nav_appearance: { title: 'Navigation appearance', since: 'Navigation appearances (Pillar Light, Pillar Dark) came with sprint 117.' },
44
+ date_picker: { title: 'Date picker for date columns', since: 'Date and date-time items have had pgkiln\'s date picker since sprint 84.' },
45
+ popup_lov: { title: 'Paged list of values for large tables', since: 'Popup lists of values search and page on the server.' },
46
+ };
47
+
48
+ /** The relations a list of values query reads (FROM / JOIN names). */
49
+ export const lovTables = (sql: string) =>
50
+ [...sql.matchAll(/\b(?:from|join)\s+((?:"[^"]+"|[A-Za-z_][\w$]*)(?:\s*\.\s*(?:"[^"]+"|[A-Za-z_][\w$]*))?)/gi)].map((m) => m[1].replace(/\s+/g, ''));
51
+
52
+ /** Every upgrade candidate of an application. */
53
+ export async function upgradeCandidates(appId: number): Promise<Candidate[]> {
54
+ const out: Candidate[] = [];
55
+ const app = await owner.one('select id, theme from meta.app where id = $1', [appId]);
56
+ if (!app) return out;
57
+ const theme = app.theme && typeof app.theme === 'object' ? app.theme : {};
58
+ if (typeof theme.appearance !== 'string' || !theme.appearance) {
59
+ out.push({
60
+ key: 'nav_appearance:app', rule: 'nav_appearance', label: 'Application settings › Theme', page: null, url: `${BASE}/apps/${appId}/theme`,
61
+ reason: 'The application has no navigation appearance: Pillar Light gives the header strip and the filled current page of new applications.',
62
+ before: { appearance: theme.appearance ?? null }, after: { appearance: 'pillar_light' },
63
+ sql: [`update meta.app set theme = coalesce(theme, '{}'::jsonb) || '{"appearance": "pillar_light"}' where id = $1 and coalesce(theme ->> 'appearance', '') = ''`, [appId]],
64
+ });
65
+ }
66
+ const dates = (await owner.query(
67
+ `select i.id, i.name, i.type, p.page_no, i.source_column, r.table_name, format_type(a.atttypid, a.atttypmod) as coltype,
68
+ a.atttypid = 'date'::regtype as is_date
69
+ from meta.item i join meta.page p on p.id = i.page_id join meta.region r on r.id = i.region_id
70
+ join pg_attribute a on a.attrelid = to_regclass(r.table_name) and a.attname = i.source_column and not a.attisdropped
71
+ where p.app_id = $1 and i.type = 'text' and r.type = 'form' and r.table_name is not null
72
+ and a.atttypid in ('date'::regtype, 'timestamp'::regtype, 'timestamptz'::regtype)
73
+ order by p.page_no, i.seq, i.id`, [appId])).rows;
74
+ for (const r of dates) {
75
+ const to = r.is_date ? 'date' : 'datetime';
76
+ out.push({
77
+ key: `date_picker:${r.id}`, rule: 'date_picker', label: `Page ${r.page_no} › item ${r.name}`, page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
78
+ reason: `${r.table_name}.${r.source_column} is a ${r.coltype} column shown as a text field: a ${to} item has the date picker and reads the date in the application's format.`,
79
+ before: { type: 'text' }, after: { type: to },
80
+ sql: [`update meta.item set type = $2 where id = $1 and type = 'text'`, [r.id, to]],
81
+ });
82
+ }
83
+ const selects = (await owner.query(
84
+ `select i.id, i.name, p.page_no, i.lov from meta.item i join meta.page p on p.id = i.page_id
85
+ where p.app_id = $1 and i.type = 'select' and i.lov is not null and i.lov !~* '^\\s*(STATIC|LOV):'
86
+ order by p.page_no, i.seq, i.id`, [appId])).rows;
87
+ for (const r of selects) {
88
+ const names = lovTables(r.lov);
89
+ if (!names.length) continue;
90
+ const big = (await owner.query<{ name: string; n: number }>(
91
+ `select x as name, c.reltuples::bigint::int as n from unnest($1::text[]) x join pg_class c on c.oid = to_regclass(x) where c.reltuples > $2 order by c.reltuples desc limit 1`,
92
+ [names, POPUP_ROWS]).catch(() => ({ rows: [] }))).rows[0];
93
+ if (!big) continue;
94
+ out.push({
95
+ key: `popup_lov:${r.id}`, rule: 'popup_lov', label: `Page ${r.page_no} › item ${r.name}`, page: r.page_no, url: designerUrl(appId, r.page_no, `c=item-${r.id}`),
96
+ reason: `Its list of values reads ${big.name} (about ${big.n} rows): a select list sends every row with the page; a popup list of values searches and pages on the server.`,
97
+ before: { type: 'select' }, after: { type: 'popup_lov' },
98
+ sql: [`update meta.item set type = 'popup_lov' where id = $1 and type = 'select'`, [r.id]],
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+
104
+ export async function upgradeRoutes(app: FastifyInstance) {
105
+ const appOf = async (id: string) => (/^\d{1,9}$/.test(id) ? owner.one('select * from meta.app where id = $1', [id]) : undefined);
106
+
107
+ app.get(`${BASE}/apps/:id/utilities/upgrade`, async (req: Req, reply) => {
108
+ const s = await developer(req, reply);
109
+ if (!s) return;
110
+ const a = await appOf(req.params.id);
111
+ if (!a) return reply.code(404).send('Not found');
112
+ const all = await upgradeCandidates(a.id);
113
+ const preview = typeof req.query?.preview === 'string' ? all.find((c) => c.key === req.query.preview) : undefined;
114
+ const u = `${BASE}/apps/${a.id}/utilities/upgrade`;
115
+ const sections = (Object.keys(RULES) as Rule[]).map((rule) => {
116
+ const list = all.filter((c) => c.rule === rule);
117
+ return html`<section class="util-upgrade" aria-labelledby="up-${rule}"><h3 id="up-${rule}">${RULES[rule].title} <span class="tag">${list.length}</span></h3>
118
+ <p class="muted small">${RULES[rule].since}</p>
119
+ ${list.length ? html`<div class="table-wrap"><table class="table report-reflow"><thead><tr><th scope="col">Component</th><th scope="col">Why</th><th scope="col">Actions</th></tr></thead><tbody>
120
+ ${list.map((c) => html`<tr${preview?.key === c.key ? raw(' aria-current="true"') : ''}><td data-label="Component"><a href="${c.url}">${c.label}</a></td><td data-label="Why">${c.reason}</td>
121
+ <td data-label="Actions"><div class="buttons"><a class="btn btn-sm" href="${u}?preview=${encodeURIComponent(c.key)}#preview">Preview</a>
122
+ <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${c.key}"><button class="btn btn-sm btn-hot">${icon('refresh')} Upgrade</button></form></div></td></tr>`)}</tbody></table></div>
123
+ ${list.length > 1 ? html`<form method="post" action="${u}" class="u-mt075">${csrf(s)}<input type="hidden" name="rule" value="${rule}"><button class="btn btn-sm">${icon('refresh')} Upgrade all ${list.length}</button></form>` : ''}`
124
+ : html`<p class="muted">Nothing to upgrade.</p>`}</section>`;
125
+ });
126
+ const main = html`${appHeader(a, 'utilities', s)}
127
+ ${region('Upgrade application', html`<p class="muted u-mt0">Components made before a pgkiln version that brought a better default. Preview a change, then upgrade one component or all of a kind; every upgrade is in the change history (and the backups) if you want to go back.</p>${sections}`)}
128
+ ${preview ? region(`Preview: ${preview.label}`, html`<p class="muted u-mt0">${preview.reason}</p>${diffHtml('settings', stableJson(preview.before), stableJson(preview.after), ['now', 'upgraded'])}
129
+ <form method="post" action="${u}">${csrf(s)}<input type="hidden" name="key" value="${preview.key}"><div class="buttons"><button class="btn btn-hot">${icon('refresh')} Upgrade</button></div></form>`, '', 'preview') : ''}`;
130
+ return send(reply, s, shell(s, `Upgrade application · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Utilities', `${BASE}/apps/${a.id}/utilities`], ['Upgrade application']], main));
131
+ });
132
+
133
+ app.post(`${BASE}/apps/:id/utilities/upgrade`, async (req: Req, reply) => {
134
+ const s = await developer(req, reply);
135
+ if (!s) return;
136
+ const a = await appOf(req.params.id);
137
+ if (!a) return reply.code(404).send('Not found');
138
+ const all = await upgradeCandidates(a.id);
139
+ const key = typeof req.body?.key === 'string' ? req.body.key : '';
140
+ const rule = typeof req.body?.rule === 'string' ? req.body.rule : '';
141
+ const chosen = key ? all.filter((c) => c.key === key) : all.filter((c) => c.rule === rule);
142
+ const to = `${BASE}/apps/${a.id}/utilities/upgrade`;
143
+ if (!chosen.length) {
144
+ flash(s, 'Nothing to upgrade: the component was changed or upgraded in the meantime.', 'error');
145
+ return back(reply, s, to);
146
+ }
147
+ await owner.tx(async (c) => {
148
+ for (const x of chosen) await c.query(x.sql[0], x.sql[1]);
149
+ });
150
+ await recordHistory(a.id, s.username, 'upgrade').catch((err) => req.log.warn({ err }, 'change history not recorded'));
151
+ flash(s, chosen.length === 1 ? `${chosen[0].label} upgraded.` : `${chosen.length} components upgraded.`);
152
+ return back(reply, s, to);
153
+ });
154
+ }