pgkiln 0.38.0 → 0.40.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/SECURITY.md +3 -0
- package/db/migrations/093_workspace_preferences.sql +34 -0
- package/db/migrations/094_task_vacation_expiry.sql +182 -0
- package/docs/apex-feature-parity.md +8 -8
- package/docs/guide/03-builder.md +62 -2
- package/docs/guide/04-pages-and-regions.md +27 -5
- package/docs/guide/06-processing.md +27 -1
- package/docs/guide/12-development.md +15 -6
- package/docs/guide/13-rest-api.md +3 -1
- package/examples/hr/hr_56_more_charts.sql +57 -0
- package/examples/hr/hr_57_task_policies.sql +18 -0
- package/examples/hr/hr_58_facet_charts.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +30 -0
- package/public/sw.js +2 -1
- package/src/app.ts +2 -0
- package/src/automations.ts +6 -0
- package/src/blueprint-from-app.ts +240 -0
- package/src/builder/blueprints.ts +31 -1
- package/src/builder/components.ts +7 -0
- package/src/builder/home.ts +3 -2
- package/src/builder/region-settings.ts +13 -5
- package/src/builder/sql-utilities.ts +278 -0
- package/src/builder/sql.ts +3 -1
- package/src/builder/ui.ts +16 -3
- package/src/builder/workspace-backups.ts +78 -0
- package/src/builder/workspaces.ts +83 -1
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +25 -0
- package/src/i18n/cs.ts +25 -0
- package/src/i18n/da.ts +25 -0
- package/src/i18n/de.ts +25 -0
- package/src/i18n/el.ts +25 -0
- package/src/i18n/es.ts +25 -0
- package/src/i18n/fi.ts +25 -0
- package/src/i18n/fr.ts +25 -0
- package/src/i18n/he.ts +25 -0
- package/src/i18n/it.ts +25 -0
- package/src/i18n/ja.ts +25 -0
- package/src/i18n/ko.ts +25 -0
- package/src/i18n/nb.ts +25 -0
- package/src/i18n/pl.ts +25 -0
- package/src/i18n/pt.ts +25 -0
- package/src/i18n/ru.ts +25 -0
- package/src/i18n/sv.ts +25 -0
- package/src/i18n/tr.ts +25 -0
- package/src/i18n/uk.ts +25 -0
- package/src/i18n/zh.ts +25 -0
- package/src/i18n.ts +50 -0
- package/src/runtime/charts.ts +154 -5
- package/src/runtime/facet-state.ts +3 -0
- package/src/runtime/facets.ts +25 -0
- package/src/runtime/rest.ts +8 -2
- package/src/runtime/tasks.ts +62 -4
- package/src/session.ts +5 -0
- package/src/workflow.ts +6 -4
package/src/builder/home.ts
CHANGED
|
@@ -296,13 +296,14 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
296
296
|
${card(`${BASE}/users/directories`, icon('org'), 'LDAP directories', 'Sign in with directory accounts.')}
|
|
297
297
|
${card(`${BASE}/users#account-settings`, icon('shield'), 'Password policy', 'Length, complexity and lifetime of passwords.')}
|
|
298
298
|
${card(`${BASE}/developers`, icon('users'), 'Developers', 'Who may use this builder.')}
|
|
299
|
-
${card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
|
|
299
|
+
${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
|
|
300
|
+
${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
|
|
300
301
|
${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
|
|
301
302
|
${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
|
|
302
303
|
${(await isAdmin(s.username))
|
|
303
304
|
? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).')
|
|
304
305
|
: s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
|
|
305
|
-
? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name and
|
|
306
|
+
? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name, developers, preferences and extension links (workspace administrators).')
|
|
306
307
|
: ''}
|
|
307
308
|
${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude and OpenAI for the applications: models, keys, limits and usage (administrators).') : ''}
|
|
308
309
|
${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
|
|
@@ -55,6 +55,9 @@ const CHART_LABELS: Record<string, string> = {
|
|
|
55
55
|
gantt: 'Gantt (task, start and end columns)',
|
|
56
56
|
pyramid: 'Pyramid (one series; two series back to back)',
|
|
57
57
|
polar: 'Polar area (one sector per row)',
|
|
58
|
+
range: 'Range (low and high columns: floating columns)',
|
|
59
|
+
boxplot: 'Box plot (one value per row, or min, Q1, median, Q3 and max)',
|
|
60
|
+
stock: 'Stock (open, high, low and close columns: candlesticks)',
|
|
58
61
|
};
|
|
59
62
|
const GAUGE_KEYS = ['min', 'max', 'warning', 'critical'] as const;
|
|
60
63
|
const CARD_COLUMNS = ['title', 'subtitle', 'body', 'badge', 'icon'];
|
|
@@ -283,10 +286,10 @@ export const rangesText = (ranges: unknown) =>
|
|
|
283
286
|
.map((r: any) => `${r?.from ?? ''}..${r?.to ?? ''}${r?.label ? ` = ${r.label}` : ''}`)
|
|
284
287
|
.join('; ');
|
|
285
288
|
|
|
286
|
-
const FACET_FORM_KEYS = ['label', 'limit', 'type', 'ranges', 'custom', 'exclude'];
|
|
289
|
+
const FACET_FORM_KEYS = ['label', 'limit', 'type', 'ranges', 'custom', 'exclude', 'chart'];
|
|
287
290
|
|
|
288
291
|
/** The facets table of a faceted search or smart filters form (and the report it filters). */
|
|
289
|
-
function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
|
|
292
|
+
function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed, charts = true) {
|
|
290
293
|
const set = setter(out);
|
|
291
294
|
const report = Number(b.report);
|
|
292
295
|
const columns = a.reports.get(report);
|
|
@@ -303,6 +306,7 @@ function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
|
|
|
303
306
|
ranges: parseRanges(b[`ranges_${i}`]),
|
|
304
307
|
custom: b[`custom_${i}`] === 'true',
|
|
305
308
|
exclude: b[`exclude_${i}`] === 'true',
|
|
309
|
+
chart: charts && (b[`chart_${i}`] === 'bar' || b[`chart_${i}`] === 'pie') ? b[`chart_${i}`] : null,
|
|
306
310
|
seq: Number(b[`seq_${i}`]) || (i + 1) * 10,
|
|
307
311
|
}))
|
|
308
312
|
.filter((f) => f.on && columns.includes(f.column))
|
|
@@ -320,6 +324,8 @@ function mergeFacetList(out: Config, config: Config, b: Body, a: Allowed) {
|
|
|
320
324
|
...(f.type === 'range' && f.ranges.length ? { ranges: f.ranges } : {}),
|
|
321
325
|
// a range facet without ranges has from/to fields unless switched off; with ranges, only when switched on
|
|
322
326
|
...(f.type === 'range' && f.custom !== !f.ranges.length ? { custom: f.custom } : {}),
|
|
327
|
+
// (sprint 65) a chart of the counts (faceted search only)
|
|
328
|
+
...(f.chart ? { chart: f.chart } : {}),
|
|
323
329
|
};
|
|
324
330
|
});
|
|
325
331
|
set('facets', facets.length ? facets : undefined);
|
|
@@ -335,7 +341,8 @@ export function mergeFacetsSettings(config: Config, b: Body, a: Allowed): Config
|
|
|
335
341
|
|
|
336
342
|
export function mergeSmartFiltersSettings(config: Config, b: Body, a: Allowed): Config {
|
|
337
343
|
const out = { ...config };
|
|
338
|
-
|
|
344
|
+
// (sprint 65) facet charts belong to faceted search
|
|
345
|
+
if (!mergeFacetList(out, config, b, a, false)) return out;
|
|
339
346
|
const set = setter(out);
|
|
340
347
|
const n = Number(b.suggestions);
|
|
341
348
|
set('suggestions', b.suggestions !== undefined && b.suggestions !== '' && Number.isInteger(n) && n >= 0 && n <= 10 && n !== 3 ? n : undefined);
|
|
@@ -618,6 +625,7 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
|
|
|
618
625
|
<td data-label="Exclude"><input type="checkbox" name="exclude_${i}" value="true"${f?.exclude === true ? raw(' checked') : ''} aria-label="Users may exclude values of ${n}"></td>
|
|
619
626
|
<td data-label="Ranges"><input name="ranges_${i}" value="${rangesText(f?.ranges)}" placeholder="..1000; 1000..3000 = Middle; 3000.." aria-label="Ranges of ${n}"></td>
|
|
620
627
|
<td data-label="From/to"><input type="checkbox" name="custom_${i}" value="true"${custom ? raw(' checked') : ''} aria-label="Users may type a range for ${n}"></td>
|
|
628
|
+
${smart ? '' : html`<td data-label="Chart"><select name="chart_${i}" aria-label="Chart of the facet on ${n}">${opt('', 'None', f?.chart)}${opt('bar', 'Bar', f?.chart)}${opt('pie', 'Pie', f?.chart)}</select></td>`}
|
|
621
629
|
<td data-label="Order"><input name="seq_${i}" type="number" value="${(i + 1) * 10}" aria-label="Order of ${n}" class="u-mw6"></td>
|
|
622
630
|
</tr>`;
|
|
623
631
|
});
|
|
@@ -637,8 +645,8 @@ async function facetsFields(r: RegionRow, pageId: number, appId: number, id: (n:
|
|
|
637
645
|
${target
|
|
638
646
|
? html`<fieldset class="prop-group"><legend>Facets</legend>
|
|
639
647
|
${all.length
|
|
640
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Facet</th><th>Label</th><th>Type</th><th>Values shown</th><th>Exclude</th><th>Ranges</th><th>From/to</th
|
|
641
|
-
<small class="help">Checkboxes list the most frequent values (Exclude lets users filter them out instead). Ranges and From/to need a number or date column: ranges are <code>from..to = label</code> separated by <code>;</code>, an open end left empty (<code>..1000</code>, <code>2020-01-01..</code>); a range includes its start, not its end. A star rating offers "4 stars and up" and so on (up to 5; <code>"max"</code> in the JSON changes it)
|
|
648
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Column</th><th>Facet</th><th>Label</th><th>Type</th><th>Values shown</th><th>Exclude</th><th>Ranges</th><th>From/to</th>${smart ? '' : html`<th>Chart</th>`}<th>Order</th></tr></thead><tbody>${rows}</tbody></table></div>
|
|
649
|
+
<small class="help">Checkboxes list the most frequent values (Exclude lets users filter them out instead). Ranges and From/to need a number or date column: ranges are <code>from..to = label</code> separated by <code>;</code>, an open end left empty (<code>..1000</code>, <code>2020-01-01..</code>); a range includes its start, not its end. A star rating offers "4 stars and up" and so on (up to 5; <code>"max"</code> in the JSON changes it).${smart ? '' : ' Chart: a bar or pie chart of the counts under the facet (collapsed); a bar or slice filters on its value.'}</small>`
|
|
642
650
|
: html`<p class="muted">The report has no columns.</p>`}
|
|
643
651
|
</fieldset>`
|
|
644
652
|
: ''}`;
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner, type Db } from '../db.ts';
|
|
3
|
+
import { html, raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
6
|
+
import { isAdmin } from './locks.ts';
|
|
7
|
+
import { workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
8
|
+
|
|
9
|
+
// SQL Workshop → Utilities (sprint 62; APEX: SQL Workshop → Utilities): one
|
|
10
|
+
// page with the workshop's tools (Load and Unload Data, Sample Data, DDL,
|
|
11
|
+
// Compare, Query Builder) and three of its own:
|
|
12
|
+
//
|
|
13
|
+
// - Object Reports: fixed catalogue queries (tables without a primary key,
|
|
14
|
+
// foreign keys without an index, RLS without policies, …) over the schemas
|
|
15
|
+
// the SQL Workshop's connection may use (workshopDb: an isolated
|
|
16
|
+
// workspace's developers see only their own roles' schemas);
|
|
17
|
+
// - About Database: version, a few settings, extensions, sizes;
|
|
18
|
+
// - Database Monitor (administrators): sessions and blocked queries of this
|
|
19
|
+
// database (pg_stat_activity), read only.
|
|
20
|
+
//
|
|
21
|
+
// Every query runs read only with a statement timeout.
|
|
22
|
+
|
|
23
|
+
/** Schemas of the report queries: usable by the connection, not pgkiln's or the system's (alias n). */
|
|
24
|
+
const USABLE = `n.nspname !~ '^pg_' and n.nspname not in ('information_schema', 'meta') and has_schema_privilege(n.oid, 'USAGE')`;
|
|
25
|
+
|
|
26
|
+
interface Report {
|
|
27
|
+
title: string;
|
|
28
|
+
about: string;
|
|
29
|
+
columns: [key: string, label: string, cls?: 'num'][];
|
|
30
|
+
sql: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Object Reports. Each query returns `object` (schema.name, linked to the Object Browser) first. */
|
|
34
|
+
export const OBJECT_REPORTS: Record<string, Report> = {
|
|
35
|
+
'no-primary-key': {
|
|
36
|
+
title: 'Tables without a primary key',
|
|
37
|
+
about: 'Forms, interactive grids and REST write-back need a key to find a row again.',
|
|
38
|
+
columns: [['rows', 'Rows (estimate)', 'num']],
|
|
39
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object, greatest(c.reltuples, 0)::bigint as rows
|
|
40
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
41
|
+
where c.relkind in ('r', 'p') and not c.relispartition and ${USABLE}
|
|
42
|
+
and not exists (select 1 from pg_constraint k where k.conrelid = c.oid and k.contype = 'p')
|
|
43
|
+
order by 1`,
|
|
44
|
+
},
|
|
45
|
+
'unindexed-foreign-keys': {
|
|
46
|
+
title: 'Foreign keys without an index',
|
|
47
|
+
about: 'Deleting or updating a parent row scans the child table, and joins from the parent are slower. An index on the foreign key columns (in this order) avoids it.',
|
|
48
|
+
columns: [['constraint', 'Foreign key'], ['columns', 'Columns'], ['parent', 'References'], ['suggestion', 'Index']],
|
|
49
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object, k.conname as constraint,
|
|
50
|
+
(select string_agg(quote_ident(a.attname), ', ' order by u.i) from unnest(k.conkey) with ordinality u(attnum, i)
|
|
51
|
+
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.attnum) as columns,
|
|
52
|
+
k.confrelid::regclass::text as parent,
|
|
53
|
+
format('create index on %I.%I (%s)', n.nspname, c.relname,
|
|
54
|
+
(select string_agg(quote_ident(a.attname), ', ' order by u.i) from unnest(k.conkey) with ordinality u(attnum, i)
|
|
55
|
+
join pg_attribute a on a.attrelid = k.conrelid and a.attnum = u.attnum)) as suggestion
|
|
56
|
+
from pg_constraint k join pg_class c on c.oid = k.conrelid join pg_namespace n on n.oid = c.relnamespace
|
|
57
|
+
where k.contype = 'f' and ${USABLE}
|
|
58
|
+
and not exists (select 1 from pg_index i where i.indrelid = k.conrelid
|
|
59
|
+
and (i.indkey::int2[])[0:cardinality(k.conkey) - 1] @> k.conkey
|
|
60
|
+
and (i.indkey::int2[])[0:cardinality(k.conkey) - 1] <@ k.conkey)
|
|
61
|
+
order by 1, 2`,
|
|
62
|
+
},
|
|
63
|
+
'rls-without-policies': {
|
|
64
|
+
title: 'Row-level security without policies',
|
|
65
|
+
about: 'Row-level security is enabled but the table has no policy: every role except its owner (and roles that bypass RLS) sees no rows and can change none.',
|
|
66
|
+
columns: [['forced', 'Forced for the owner']],
|
|
67
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object, case when c.relforcerowsecurity then 'yes' else 'no' end as forced
|
|
68
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
69
|
+
where c.relkind in ('r', 'p') and c.relrowsecurity and ${USABLE}
|
|
70
|
+
and not exists (select 1 from pg_policy p where p.polrelid = c.oid)
|
|
71
|
+
order by 1`,
|
|
72
|
+
},
|
|
73
|
+
'no-row-level-security': {
|
|
74
|
+
title: 'Tables without row-level security',
|
|
75
|
+
about: 'Every role with a grant on these tables sees and changes all their rows. Fine for lookup tables; enable row-level security where rows belong to users or tenants.',
|
|
76
|
+
columns: [['owner', 'Owner'], ['grants', 'Granted to']],
|
|
77
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object, c.relowner::regrole::text as owner,
|
|
78
|
+
(select string_agg(distinct case when g.grantee = 0 then 'PUBLIC' else g.grantee::regrole::text end, ', ')
|
|
79
|
+
from aclexplode(c.relacl) g where g.grantee <> c.relowner) as grants
|
|
80
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
81
|
+
where c.relkind in ('r', 'p') and not c.relispartition and not c.relrowsecurity and ${USABLE}
|
|
82
|
+
order by 1`,
|
|
83
|
+
},
|
|
84
|
+
'public-grants': {
|
|
85
|
+
title: 'Privileges granted to PUBLIC',
|
|
86
|
+
about: 'Every database role, including ones made later, has these privileges.',
|
|
87
|
+
columns: [['kind', 'Kind'], ['privileges', 'Privileges']],
|
|
88
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object,
|
|
89
|
+
case c.relkind when 'r' then 'table' when 'p' then 'table' when 'v' then 'view' when 'm' then 'materialized view' when 'S' then 'sequence' when 'f' then 'foreign table' else c.relkind::text end as kind,
|
|
90
|
+
string_agg(g.privilege_type, ', ' order by g.privilege_type) as privileges
|
|
91
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace, aclexplode(c.relacl) g
|
|
92
|
+
where g.grantee = 0 and ${USABLE}
|
|
93
|
+
group by 1, 2 order by 1`,
|
|
94
|
+
},
|
|
95
|
+
'unused-indexes': {
|
|
96
|
+
title: 'Unused indexes',
|
|
97
|
+
about: 'Indexes not used by a query since the statistics were last reset (not primary keys or unique ones). They slow down writes and take space; check on every server (replicas count their own) before dropping one.',
|
|
98
|
+
columns: [['index', 'Index'], ['size', 'Size', 'num']],
|
|
99
|
+
sql: `select format('%I.%I', s.schemaname, s.relname) as object, s.indexrelname as index, pg_size_pretty(pg_relation_size(s.indexrelid)) as size
|
|
100
|
+
from pg_stat_user_indexes s join pg_index i on i.indexrelid = s.indexrelid join pg_namespace n on n.nspname = s.schemaname
|
|
101
|
+
where s.idx_scan = 0 and not i.indisunique and not i.indisprimary and ${USABLE}
|
|
102
|
+
order by pg_relation_size(s.indexrelid) desc, 1`,
|
|
103
|
+
},
|
|
104
|
+
'largest-tables': {
|
|
105
|
+
title: 'Largest tables',
|
|
106
|
+
about: 'The 100 largest tables with their indexes and TOAST data.',
|
|
107
|
+
columns: [['rows', 'Rows (estimate)', 'num'], ['table_size', 'Table', 'num'], ['index_size', 'Indexes', 'num'], ['total', 'Total', 'num']],
|
|
108
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object, greatest(c.reltuples, 0)::bigint as rows,
|
|
109
|
+
pg_size_pretty(pg_table_size(c.oid)) as table_size, pg_size_pretty(pg_indexes_size(c.oid)) as index_size,
|
|
110
|
+
pg_size_pretty(pg_total_relation_size(c.oid)) as total
|
|
111
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
112
|
+
where c.relkind in ('r', 'p', 'm') and ${USABLE}
|
|
113
|
+
order by pg_total_relation_size(c.oid) desc limit 100`,
|
|
114
|
+
},
|
|
115
|
+
'no-comments': {
|
|
116
|
+
title: 'Tables without a comment',
|
|
117
|
+
about: 'Comments (COMMENT ON) document a data model; the App Builder and its AI read them. SQL Workshop → AI → Describe tables drafts them.',
|
|
118
|
+
columns: [['columns', 'Columns', 'num'], ['commented', 'With a comment', 'num']],
|
|
119
|
+
sql: `select format('%I.%I', n.nspname, c.relname) as object,
|
|
120
|
+
(select count(*) from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped)::int as columns,
|
|
121
|
+
(select count(*) from pg_attribute a where a.attrelid = c.oid and a.attnum > 0 and not a.attisdropped and col_description(c.oid, a.attnum) is not null)::int as commented
|
|
122
|
+
from pg_class c join pg_namespace n on n.oid = c.relnamespace
|
|
123
|
+
where c.relkind in ('r', 'p', 'v', 'm') and not c.relispartition and obj_description(c.oid, 'pg_class') is null and ${USABLE}
|
|
124
|
+
order by 1`,
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** Run read-only queries with a statement timeout. */
|
|
129
|
+
async function readOnly<T>(db: Db, fn: (q: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>) => Promise<T>): Promise<T> {
|
|
130
|
+
return db.tx(async (c) => {
|
|
131
|
+
await c.query('set transaction read only');
|
|
132
|
+
await c.query(`set local statement_timeout = '15s'`);
|
|
133
|
+
return fn(async (sql, params) => (await c.query(sql, params)).rows);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const crumbs = (last: string): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], ['Utilities', `${BASE}/sql/utilities`], [last]];
|
|
138
|
+
|
|
139
|
+
export async function sqlUtilityRoutes(app: FastifyInstance) {
|
|
140
|
+
app.get(`${BASE}/sql/utilities`, async (req: Req, reply) => {
|
|
141
|
+
const s = await developer(req, reply);
|
|
142
|
+
if (!s) return;
|
|
143
|
+
const admin = await isAdmin(s.username);
|
|
144
|
+
const tile = (href: string, ic: string, title: string, text: string) =>
|
|
145
|
+
html`<a class="ab-tile sw-tile" href="${BASE}${href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`;
|
|
146
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
|
|
147
|
+
<div class="ab-tiles ab-tiles-wrap">
|
|
148
|
+
${tile('/sql/load', 'upload', 'Data Workshop: Load Data', 'Load CSV, Excel, JSON or XML into a new or existing table, with saved definitions.')}
|
|
149
|
+
${tile('/sql/unload', 'download', 'Data Workshop: Unload Data', 'Download a table or a query as CSV, JSON, XML, Excel or SQL inserts.')}
|
|
150
|
+
${tile('/sql/sample-data', 'layers', 'Sample Data', 'Generate realistic rows for your tables.')}
|
|
151
|
+
${tile('/sql/query', 'filter', 'Query Builder', 'Build a query by drawing joins between tables.')}
|
|
152
|
+
${tile('/sql/ddl', 'file', 'Generate DDL', 'The SQL that creates a schema\'s objects.')}
|
|
153
|
+
${tile('/sql/compare', 'layers', 'Schema Comparison', 'The differences between two schemas, and the SQL to align them.')}
|
|
154
|
+
${tile('/sql/utilities/reports', 'list', 'Object Reports', 'Tables without a primary key, foreign keys without an index, row-level security, grants, sizes.')}
|
|
155
|
+
${tile('/sql/utilities/about', 'help', 'About Database', 'The PostgreSQL version, settings, extensions and sizes.')}
|
|
156
|
+
${admin ? tile('/sql/utilities/monitor', 'activity', 'Database Monitor', 'Sessions, running and blocked queries of this database (administrators).') : ''}
|
|
157
|
+
</div>`;
|
|
158
|
+
return send(reply, s, shell(s, 'SQL Workshop utilities', [['SQL Workshop', `${BASE}/sql`], ['Utilities']], main, 'sql'));
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
app.get(`${BASE}/sql/utilities/reports`, async (req: Req, reply) => {
|
|
162
|
+
const s = await developer(req, reply);
|
|
163
|
+
if (!s) return;
|
|
164
|
+
const db = await workshopDb(s);
|
|
165
|
+
const key = String((req.query as Record<string, unknown>)?.r ?? 'no-primary-key');
|
|
166
|
+
const r = Object.hasOwn(OBJECT_REPORTS, key) ? OBJECT_REPORTS[key] : null;
|
|
167
|
+
if (!r) return reply.code(404).send('Not found');
|
|
168
|
+
let rows: Record<string, unknown>[] = [];
|
|
169
|
+
let error = '';
|
|
170
|
+
try {
|
|
171
|
+
rows = await readOnly(db, (q) => q(r.sql));
|
|
172
|
+
} catch (e) {
|
|
173
|
+
error = (e as Error).message;
|
|
174
|
+
}
|
|
175
|
+
const nav = html`<nav aria-label="Object reports"><ul class="ab-links ab-tasks">${Object.entries(OBJECT_REPORTS).map(([k, x]) =>
|
|
176
|
+
html`<li><a href="${BASE}/sql/utilities/reports?r=${k}"${k === key ? raw(' aria-current="page"') : ''}>${x.title}${icon('chevron')}</a></li>`)}</ul></nav>`;
|
|
177
|
+
const table = html`<div class="table-wrap"><table class="report report-reflow">
|
|
178
|
+
<thead><tr><th>Object</th>${r.columns.map(([, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${l}</th>`)}</tr></thead>
|
|
179
|
+
<tbody>${rows.length
|
|
180
|
+
? rows.map((row) => html`<tr><td data-label="Object" class="u-break"><a href="${BASE}/sql/objects?o=${encodeURIComponent(String(row.object))}">${String(row.object)}</a></td>
|
|
181
|
+
${r.columns.map(([k, l, c]) => html`<td data-label="${l}"${c ? raw(` class="${c}"`) : raw(' class="u-break"')}>${k === 'suggestion' ? html`<code>${String(row[k] ?? '')}</code>` : String(row[k] ?? '')}</td>`)}</tr>`)
|
|
182
|
+
: html`<tr><td colspan="${r.columns.length + 1}" class="empty">${error ? 'The report failed.' : 'Nothing found.'}</td></tr>`}</tbody></table></div>`;
|
|
183
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
|
|
184
|
+
<div class="ab-home"><div class="ab-main">
|
|
185
|
+
${region(r.title, html`<p class="muted u-mt0">${r.about} Over the schemas ${workshopRunsAs(db)} may use.</p>
|
|
186
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}${table}`)}
|
|
187
|
+
</div><aside class="ab-side" aria-label="Object reports"><section><h2>Object Reports</h2>${nav}</section></aside></div>`;
|
|
188
|
+
return send(reply, s, shell(s, r.title, crumbs('Object Reports'), main, 'sql'));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
app.get(`${BASE}/sql/utilities/about`, async (req: Req, reply) => {
|
|
192
|
+
const s = await developer(req, reply);
|
|
193
|
+
if (!s) return;
|
|
194
|
+
const db = await workshopDb(s);
|
|
195
|
+
const info = await readOnly(db, async (q) => ({
|
|
196
|
+
server: (await q(`select version() as version, current_database() as database, current_user as "user", session_user as login,
|
|
197
|
+
pg_size_pretty(pg_database_size(current_database())) as size,
|
|
198
|
+
(select setting from pg_settings where name = 'server_version') as server_version,
|
|
199
|
+
pg_postmaster_start_time() as started`))[0],
|
|
200
|
+
settings: await q(`select name, setting, unit, short_desc from pg_settings
|
|
201
|
+
where name in ('server_encoding', 'lc_collate', 'TimeZone', 'max_connections', 'shared_buffers', 'work_mem',
|
|
202
|
+
'maintenance_work_mem', 'effective_cache_size', 'statement_timeout', 'default_transaction_isolation',
|
|
203
|
+
'row_security', 'shared_preload_libraries', 'wal_level', 'max_wal_size')
|
|
204
|
+
order by lower(name)`),
|
|
205
|
+
extensions: await q(`select e.extname as name, e.extversion as version, n.nspname as schema, a.comment
|
|
206
|
+
from pg_extension e join pg_namespace n on n.oid = e.extnamespace
|
|
207
|
+
left join pg_available_extensions a on a.name = e.extname order by 1`),
|
|
208
|
+
schemas: await q(`select n.nspname as schema, n.nspowner::regrole::text as owner,
|
|
209
|
+
count(c.oid) filter (where c.relkind in ('r', 'p'))::int as tables,
|
|
210
|
+
pg_size_pretty(coalesce(sum(pg_total_relation_size(c.oid)) filter (where c.relkind in ('r', 'p', 'm')), 0)::bigint) as size
|
|
211
|
+
from pg_namespace n left join pg_class c on c.relnamespace = n.oid
|
|
212
|
+
where ${USABLE} group by 1, 2 order by 1`),
|
|
213
|
+
}));
|
|
214
|
+
const sv = info.server as Record<string, string>;
|
|
215
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
|
|
216
|
+
<div class="stat-grid u-mb1">
|
|
217
|
+
${[['PostgreSQL', sv.server_version], ['Database', sv.database], ['Size', sv.size], ['Running since', new Date(sv.started).toISOString().slice(0, 16).replace('T', ' ') + ' UTC']]
|
|
218
|
+
.map(([l, v]) => html`<div class="stat"><b class="u-break">${v}</b><span>${l}</span></div>`)}
|
|
219
|
+
</div>
|
|
220
|
+
<p class="muted">${sv.version}. The SQL Workshop runs as <code>${sv.user}</code> (signed in as <code>${sv.login}</code>).</p>
|
|
221
|
+
<div class="columns">
|
|
222
|
+
${region('Settings', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Setting</th><th>Value</th></tr></thead>
|
|
223
|
+
<tbody>${info.settings.map((x) => html`<tr><td data-label="Setting" title="${String(x.short_desc ?? '')}"><code>${String(x.name)}</code></td>
|
|
224
|
+
<td data-label="Value" class="u-break">${String(x.setting ?? '')}${x.unit ? ` ${x.unit}` : ''}</td></tr>`)}</tbody></table></div>`)}
|
|
225
|
+
${region('Extensions', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Extension</th><th>Version</th><th>Schema</th></tr></thead>
|
|
226
|
+
<tbody>${info.extensions.map((x) => html`<tr><td data-label="Extension" class="u-break"><strong>${String(x.name)}</strong>${x.comment ? html` <span class="muted">${String(x.comment)}</span>` : ''}</td>
|
|
227
|
+
<td data-label="Version">${String(x.version)}</td><td data-label="Schema">${String(x.schema)}</td></tr>`)}</tbody></table></div>`)}
|
|
228
|
+
</div>
|
|
229
|
+
<div class="u-spacer"></div>
|
|
230
|
+
${region('Schemas', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Schema</th><th>Owner</th><th class="num">Tables</th><th class="num">Size</th></tr></thead>
|
|
231
|
+
<tbody>${info.schemas.length ? info.schemas.map((x) => html`<tr><td data-label="Schema">${String(x.schema)}</td><td data-label="Owner"><code>${String(x.owner)}</code></td>
|
|
232
|
+
<td class="num" data-label="Tables">${String(x.tables)}</td><td class="num" data-label="Size">${String(x.size)}</td></tr>`)
|
|
233
|
+
: html`<tr><td colspan="4" class="empty">No schemas.</td></tr>`}</tbody></table></div>`)}`;
|
|
234
|
+
return send(reply, s, shell(s, 'About Database', crumbs('About Database'), main, 'sql'));
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
app.get(`${BASE}/sql/utilities/monitor`, async (req: Req, reply) => {
|
|
238
|
+
const s = await developer(req, reply);
|
|
239
|
+
if (!s) return;
|
|
240
|
+
if (!(await isAdmin(s.username))) return reply.code(403).send('Administrators only.');
|
|
241
|
+
const data = await readOnly(owner, async (q) => ({
|
|
242
|
+
sessions: await q(`select a.pid, a.usename as "user", a.application_name as app, a.client_addr::text as client, a.state,
|
|
243
|
+
a.wait_event_type || coalesce(': ' || a.wait_event, '') as wait,
|
|
244
|
+
to_char(a.backend_start, 'YYYY-MM-DD HH24:MI') as since,
|
|
245
|
+
case when a.state = 'active' then round(extract(epoch from now() - a.query_start)::numeric, 1) end as running_s,
|
|
246
|
+
case when a.state like 'idle in transaction%' then round(extract(epoch from now() - a.state_change)::numeric, 1) end as idle_tx_s,
|
|
247
|
+
pg_blocking_pids(a.pid) as blocked_by, left(a.query, 500) as query
|
|
248
|
+
from pg_stat_activity a
|
|
249
|
+
where a.datname = current_database() and a.backend_type = 'client backend' and a.pid <> pg_backend_pid()
|
|
250
|
+
order by a.state = 'active' desc, a.query_start nulls last limit 200`),
|
|
251
|
+
totals: (await q(`select count(*)::int as sessions, count(*) filter (where state = 'active')::int as active,
|
|
252
|
+
count(*) filter (where state like 'idle in transaction%')::int as idle_tx,
|
|
253
|
+
count(*) filter (where cardinality(pg_blocking_pids(pid)) > 0)::int as blocked,
|
|
254
|
+
(select setting::int from pg_settings where name = 'max_connections') as max
|
|
255
|
+
from pg_stat_activity where datname = current_database() and backend_type = 'client backend'`))[0],
|
|
256
|
+
}));
|
|
257
|
+
const t = data.totals as Record<string, number>;
|
|
258
|
+
const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities')}
|
|
259
|
+
<div class="stat-grid u-mb1">
|
|
260
|
+
${[['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked]]
|
|
261
|
+
.map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
|
|
262
|
+
</div>
|
|
263
|
+
${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity), active ones first. A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>). Reload the page to refresh it.</p>
|
|
264
|
+
<div class="table-wrap"><table class="report report-reflow">
|
|
265
|
+
<thead><tr><th class="num">PID</th><th>User</th><th>Application</th><th>State</th><th>Wait</th><th class="num">Running (s)</th><th>Blocked by</th><th>Query</th></tr></thead>
|
|
266
|
+
<tbody>${data.sessions.length ? data.sessions.map((x) => html`<tr>
|
|
267
|
+
<td class="num" data-label="PID">${String(x.pid)}</td>
|
|
268
|
+
<td data-label="User">${String(x.user ?? '')}</td>
|
|
269
|
+
<td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
|
|
270
|
+
<td data-label="State">${x.idle_tx_s != null ? html`<span class="tag">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
|
|
271
|
+
<td data-label="Wait">${String(x.wait ?? '')}</td>
|
|
272
|
+
<td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
|
|
273
|
+
<td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
|
|
274
|
+
<td data-label="Query" class="u-break"><code>${String(x.query ?? '')}</code></td></tr>`)
|
|
275
|
+
: html`<tr><td colspan="8" class="empty">No other sessions.</td></tr>`}</tbody></table></div>`)}`;
|
|
276
|
+
return send(reply, s, shell(s, 'Database Monitor', crumbs('Database Monitor'), main, 'sql'));
|
|
277
|
+
});
|
|
278
|
+
}
|
package/src/builder/sql.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { quickSqlRoutes } from './quicksql.ts';
|
|
|
11
11
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
12
12
|
import { unloadRoutes } from './unload.ts';
|
|
13
13
|
import { ddlToolRoutes } from './ddl-tools.ts';
|
|
14
|
+
import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
14
15
|
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
15
16
|
|
|
16
17
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
@@ -61,7 +62,7 @@ async function workshopHome(req: Req, reply: FastifyReply) {
|
|
|
61
62
|
${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
|
|
62
63
|
${tile('/sql/commands', 'code', 'SQL Commands', 'Run SQL statements against your schemas.')}
|
|
63
64
|
${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
|
|
64
|
-
${tile('/sql/
|
|
65
|
+
${tile('/sql/utilities', 'wrench', 'Utilities', 'Load and unload data, sample data, DDL, schema comparison, object reports and the database.')}
|
|
65
66
|
${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
|
|
66
67
|
</div>
|
|
67
68
|
<div class="sw-recents">
|
|
@@ -99,6 +100,7 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
99
100
|
await app.register(unloadRoutes);
|
|
100
101
|
// Generate DDL and Schema Comparison (sprint 56)
|
|
101
102
|
await app.register(ddlToolRoutes);
|
|
103
|
+
await app.register(sqlUtilityRoutes);
|
|
102
104
|
// ---------------------------------------------------------------- SQL workshop
|
|
103
105
|
const resultTable = (res: pg.QueryResult<any[]>, limit = 500) => {
|
|
104
106
|
const rows = (res.rows ?? []).slice(0, limit);
|
package/src/builder/ui.ts
CHANGED
|
@@ -109,7 +109,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
109
109
|
<a class="rail-logo" href="${BASE}" title="pgkiln Builder"><span aria-hidden="true">pg</span><span class="sr-only">pgkiln Builder home</span></a>
|
|
110
110
|
<ul class="rail-list">
|
|
111
111
|
${rail('apps', BASE, 'builder', 'App Builder')}
|
|
112
|
-
${rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
|
|
112
|
+
${s.workspace?.sql_workshop === false ? '' : rail('sql', `${BASE}/sql`, 'database', 'SQL Workshop')}
|
|
113
113
|
${rail('gallery', `${BASE}/gallery`, 'package', 'App Gallery')}
|
|
114
114
|
${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', 'Search this application') : ''}
|
|
115
115
|
</ul>
|
|
@@ -124,6 +124,8 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
124
124
|
${s.workspace?.admin ? html`<div class="menu-section menu-links">
|
|
125
125
|
<span class="small muted">Manage service: ${s.workspace.name}</span>
|
|
126
126
|
<a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} Workspace message and environment banner</a>
|
|
127
|
+
<a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} Workspace preferences</a>
|
|
128
|
+
<a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} Extension links</a>
|
|
127
129
|
<a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} Workspace utilization</a>
|
|
128
130
|
<a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} Workspace and developers</a>
|
|
129
131
|
</div>` : ''}
|
|
@@ -134,6 +136,11 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
134
136
|
<a href="${BASE}/developers">${icon('key')} Change my password</a>
|
|
135
137
|
</div>
|
|
136
138
|
</div></details></li>
|
|
139
|
+
${s.workspace?.links?.length ? html`<li><details class="menu rail-user rail-admin rail-extensions">
|
|
140
|
+
<summary class="rail-link" title="Extensions">${icon('puzzle')}<span class="rail-label">Extensions</span></summary>
|
|
141
|
+
<div class="menu-panel"><div class="menu-section menu-links"><strong>Extensions</strong>
|
|
142
|
+
${s.workspace.links.map((l) => html`<a href="${l.url}" target="_blank" rel="noopener noreferrer"${l.description ? html` title="${l.description}"` : ''}>${icon('external-link')} ${l.label}</a>`)}
|
|
143
|
+
</div></div></details></li>` : ''}
|
|
137
144
|
${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', 'Help', raw(' target="_blank" rel="noopener"'))}
|
|
138
145
|
<li><details class="menu rail-user">
|
|
139
146
|
<summary class="rail-avatar" title="${s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">Account: ${s.username}</span></summary>
|
|
@@ -232,13 +239,13 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
232
239
|
};
|
|
233
240
|
|
|
234
241
|
/** Tabs of the SQL Workshop. */
|
|
235
|
-
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare';
|
|
242
|
+
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities';
|
|
236
243
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
237
244
|
const tab = (key: WorkshopTab, href: string, ic: string, label: string) =>
|
|
238
245
|
html`<a class="ide-tab" href="${BASE}${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
239
246
|
return html`<nav class="ide-tabs u-mb1" aria-label="SQL Workshop">
|
|
240
247
|
${tab('home', '/sql', 'home', 'Home')}${tab('sql', '/sql/commands', 'code', 'SQL Commands')}${tab('scripts', '/sql/scripts', 'file', 'SQL Scripts')}${tab('quicksql', '/sql/quick', 'bolt', 'Quick SQL')}
|
|
241
|
-
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}</nav>`;
|
|
248
|
+
${tab('query', '/sql/query', 'filter', 'Query Builder')}${tab('objects', '/sql/objects', 'database', 'Object Browser')}${tab('load', '/sql/load', 'upload', 'Load Data')}${tab('unload', '/sql/unload', 'download', 'Unload Data')}${tab('sample', '/sql/sample-data', 'layers', 'Sample Data')}${tab('ddl', '/sql/ddl', 'file', 'DDL')}${tab('compare', '/sql/compare', 'layers', 'Compare')}${tab('ai', '/sql/ai', 'bolt', 'AI')}${tab('utilities', '/sql/utilities', 'wrench', 'Utilities')}</nav>`;
|
|
242
249
|
};
|
|
243
250
|
|
|
244
251
|
/** Number of app tile colours (.app-color-0 … -7 in app.css). */
|
|
@@ -278,6 +285,12 @@ export async function developer(req: Req, reply: FastifyReply) {
|
|
|
278
285
|
reply.code(404).send('Not found');
|
|
279
286
|
return null;
|
|
280
287
|
}
|
|
288
|
+
// (093) a workspace preference may turn the SQL Workshop off
|
|
289
|
+
if (s.workspace?.sql_workshop === false && /^\/builder\/sql(?:[/?]|$)/.test(req.url)) {
|
|
290
|
+
await send(reply.code(403), s, shell(s, 'SQL Workshop', [['App Builder', BASE], ['SQL Workshop']],
|
|
291
|
+
html`<h1>SQL Workshop</h1><div class="alert alert-info" role="status">The SQL Workshop is turned off for the workspace ${s.workspace.name}. Its administrators can turn it on in the workspace preferences${s.workspaces && s.workspaces.length > 1 ? '; or switch to another workspace' : ''}.</div>`, 'sql'));
|
|
292
|
+
return null;
|
|
293
|
+
}
|
|
281
294
|
// a page or application locked by another developer can't be changed (locks.ts)
|
|
282
295
|
if (req.method === 'POST') {
|
|
283
296
|
const hit = await blockingLock(s.username, req.url);
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { FastifyInstance } from 'fastify';
|
|
2
|
+
import { owner } from '../db.ts';
|
|
3
|
+
import { html } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { clientIp, logActivity } from '../session.ts';
|
|
6
|
+
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { currentWorkspace } from './workspaces.ts';
|
|
8
|
+
|
|
9
|
+
// Workspace utilities → Manage backups (APEX: Workspace Utilities → Manage
|
|
10
|
+
// Backups): the backups (082) of every application of the current workspace on
|
|
11
|
+
// one page, and a backup of all of them at once. Downloading, restoring and
|
|
12
|
+
// deleting a backup stay on the application's own Backups page.
|
|
13
|
+
|
|
14
|
+
const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
15
|
+
const size = (n: number) => (n < 1024 * 1024 ? `${Math.max(n ? 1 : 0, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
16
|
+
|
|
17
|
+
export async function workspaceBackupRoutes(app: FastifyInstance) {
|
|
18
|
+
app.get(`${BASE}/backups`, async (req: Req, reply) => {
|
|
19
|
+
const s = await developer(req, reply);
|
|
20
|
+
if (!s) return;
|
|
21
|
+
const rows = (await owner.query(
|
|
22
|
+
`select a.id, a.name, a.alias, a.backups_keep, a.updated_at,
|
|
23
|
+
count(b.id)::int as backups, coalesce(sum(b.size), 0)::bigint as bytes, max(b.created_at) as newest,
|
|
24
|
+
(max(b.created_at) is null or a.updated_at > max(b.created_at)) as changed
|
|
25
|
+
from meta.app a left join meta.app_backup b on b.app_id = a.id
|
|
26
|
+
where meta.app_workspace(a.id) = $1
|
|
27
|
+
group by a.id order by lower(a.name), a.id`, [currentWorkspace(s)])).rows;
|
|
28
|
+
const total = rows.reduce((t, r) => t + Number(r.bytes), 0);
|
|
29
|
+
const main = html`<div class="title-row"><h1>Manage backups</h1></div>
|
|
30
|
+
<p class="muted u-mt0">The backups of the applications of ${s.workspace?.name ?? 'this workspace'}: one before every import that replaces an application, a merge or a restore, one a day when it changed, and the ones made by hand. Open an application's backups to download, restore or delete one.</p>
|
|
31
|
+
<div class="stat-grid u-mb1">
|
|
32
|
+
${[['Applications', rows.length], ['Backups', rows.reduce((t, r) => t + r.backups, 0)], ['Storage', size(total)], ['Changed since their newest backup', rows.filter((r) => r.changed && r.backups_keep > 0).length]]
|
|
33
|
+
.map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
|
|
34
|
+
</div>
|
|
35
|
+
${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
|
|
36
|
+
<thead><tr><th>Application</th><th class="num">Backups</th><th class="num">Kept</th><th>Newest (UTC)</th><th class="num">Size</th><th></th></tr></thead>
|
|
37
|
+
<tbody>${rows.length ? rows.map((a) => html`<tr>
|
|
38
|
+
<td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
|
|
39
|
+
<td class="num" data-label="Backups">${a.backups}</td>
|
|
40
|
+
<td class="num" data-label="Kept">${a.backups_keep === 0 ? html`<span class="tag">none</span>` : a.backups_keep}</td>
|
|
41
|
+
<td data-label="Newest">${a.newest ? when(a.newest) : html`<span class="muted">never</span>`}${a.changed && a.backups_keep > 0 && a.newest ? html` <span class="tag">changed since</span>` : ''}</td>
|
|
42
|
+
<td class="num" data-label="Size">${size(Number(a.bytes))}</td>
|
|
43
|
+
<td><a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} Backups</a></td></tr>`)
|
|
44
|
+
: html`<tr><td colspan="6" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}
|
|
45
|
+
${rows.length ? region('Back up all applications', html`<form method="post" action="${BASE}/backups">${csrf(s)}
|
|
46
|
+
<p class="muted u-mt0">Makes a backup of every application of this workspace that keeps backups.</p>
|
|
47
|
+
<div class="form-grid">${input('note', 'Note', '', { placeholder: 'e.g. before the upgrade' })}
|
|
48
|
+
<div class="field"><label class="check"><input type="checkbox" name="changed" value="true" checked> Only applications changed since their newest backup</label></div></div>
|
|
49
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} Back up now</button></div></form>`) : ''}`;
|
|
50
|
+
return send(reply, s, shell(s, 'Manage backups', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Manage backups']], main));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
app.post(`${BASE}/backups`, async (req: Req, reply) => {
|
|
54
|
+
const s = await developer(req, reply);
|
|
55
|
+
if (!s) return;
|
|
56
|
+
const note = String(req.body?.note ?? '').trim().slice(0, 500) || null;
|
|
57
|
+
const onlyChanged = req.body?.changed === 'true';
|
|
58
|
+
const apps = (await owner.query<{ id: number }>(
|
|
59
|
+
`select a.id from meta.app a
|
|
60
|
+
where meta.app_workspace(a.id) = $1 and a.backups_keep > 0
|
|
61
|
+
and (not $2 or not exists (select 1 from meta.app_backup b where b.app_id = a.id and b.created_at >= a.updated_at))
|
|
62
|
+
order by a.id`, [currentWorkspace(s), onlyChanged])).rows;
|
|
63
|
+
let made = 0;
|
|
64
|
+
const failed: number[] = [];
|
|
65
|
+
for (const a of apps) {
|
|
66
|
+
try {
|
|
67
|
+
if ((await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, note])).id) made++;
|
|
68
|
+
} catch (e) {
|
|
69
|
+
failed.push(a.id);
|
|
70
|
+
console.error(`backup of application ${a.id}:`, (e as Error).message);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
await logActivity({ appId: null, username: s.username, event: 'backup', ip: clientIp(req), detail: `workspace ${currentWorkspace(s)}: ${made} backup(s)` });
|
|
74
|
+
if (failed.length) flash(s, `${made} backup(s) made; not backed up: application${failed.length > 1 ? 's' : ''} ${failed.join(', ')}.`, 'error');
|
|
75
|
+
else flash(s, made ? `${made} backup${made > 1 ? 's' : ''} made.` : 'Nothing to back up: every application is unchanged since its newest backup, or keeps none.');
|
|
76
|
+
return back(reply, s, `${BASE}/backups`);
|
|
77
|
+
});
|
|
78
|
+
}
|