pgkiln 0.69.0 → 0.71.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.
- package/db/migrations/136_vector_search.sql +252 -0
- package/db/migrations/137_create_page_types.sql +904 -0
- package/db/migrations/138_app_utilities.sql +153 -0
- package/docs/apex-feature-parity.md +10 -8
- package/docs/guide/03-builder.md +176 -4
- package/docs/guide/04-pages-and-regions.md +45 -3
- package/docs/guide/05-items.md +1 -1
- package/docs/guide/06-processing.md +18 -0
- package/docs/guide/09-reference.md +34 -1
- package/docs/guide/12-development.md +53 -3
- package/docs/guide/15-extensions.md +1 -1
- package/docs/guide/16-files.md +1 -1
- package/examples/hr/README.md +1 -0
- package/examples/hr/hr_72_vector_search.sql +158 -0
- package/examples/hr/hr_73_configuration_options.sql +17 -0
- package/examples/hr/hr_74_app_utilities.sql +22 -0
- package/package.json +1 -1
- package/public/app.css +9 -0
- package/public/builder.css +48 -0
- package/src/ai/embeddings.ts +197 -0
- package/src/ai/service.ts +13 -9
- package/src/app.ts +13 -0
- package/src/builder/advisor.ts +43 -3
- package/src/builder/ai-agents.ts +13 -5
- package/src/builder/app-updates.ts +3 -0
- package/src/builder/components.ts +24 -4
- package/src/builder/create-page.ts +259 -0
- package/src/builder/dependencies.ts +311 -0
- package/src/builder/features.ts +313 -0
- package/src/builder/home.ts +3 -0
- package/src/builder/i18n/de.ts +38 -0
- package/src/builder/i18n/en.ts +38 -0
- package/src/builder/i18n/es.ts +38 -0
- package/src/builder/i18n/fr.ts +38 -0
- package/src/builder/i18n/it.ts +38 -0
- package/src/builder/i18n/ja.ts +38 -0
- package/src/builder/i18n/ko.ts +38 -0
- package/src/builder/i18n/nl.ts +38 -0
- package/src/builder/i18n/pt.ts +38 -0
- package/src/builder/i18n/zh-cn.ts +38 -0
- package/src/builder/i18n/zh-tw.ts +38 -0
- package/src/builder/login-page.ts +17 -14
- package/src/builder/newapp.ts +41 -23
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/routes.ts +13 -2
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/table-editor.ts +1 -0
- package/src/builder/ui-defaults.ts +161 -0
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +154 -0
- package/src/builder/utilities.ts +181 -0
- package/src/builder/vector-column.ts +229 -0
- package/src/builder/vector-providers.ts +286 -0
- package/src/builder/wizards.ts +211 -26
- package/src/cli/replace.ts +11 -1
- package/src/history.ts +155 -0
- package/src/runtime/ai-agents.ts +53 -2
- package/src/runtime/builtin-components.ts +23 -2
- package/src/runtime/items.ts +4 -2
- package/src/runtime/search.ts +116 -12
- package/src/server.ts +3 -0
- package/src/vector.ts +288 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|