pgkiln 0.47.0 → 0.48.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/109_builder_pins.sql +19 -0
- package/db/migrations/110_sql_charts.sql +23 -0
- package/db/migrations/111_sql_diagram.sql +24 -0
- package/db/migrations/113_rest_objects.sql +49 -0
- package/docs/apex-feature-parity.md +6 -6
- package/docs/guide/03-builder.md +88 -2
- package/docs/guide/08-security.md +18 -0
- package/docs/guide/12-development.md +26 -0
- package/docs/guide/13-rest-api.md +74 -9
- package/package.json +1 -1
- package/public/builder.css +22 -0
- package/public/data-modeler.css +56 -0
- package/public/data-modeler.js +367 -0
- package/public/palette.js +133 -0
- package/public/worksheet.css +49 -0
- package/public/worksheet.js +4 -0
- package/src/app.ts +4 -0
- package/src/builder/autorest.ts +362 -0
- package/src/builder/data-modeler.ts +382 -0
- package/src/builder/db-users.ts +472 -0
- package/src/builder/launchpad.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +7 -1
- package/src/builder/sql-charts.ts +203 -0
- package/src/builder/sql-tools.ts +49 -0
- package/src/builder/sql.ts +14 -9
- package/src/builder/ui.ts +21 -15
- package/src/builder/worksheet.ts +30 -7
- package/src/builder/workspaces.ts +4 -2
- package/src/cli/replace.ts +3 -1
- package/src/datamodel.ts +324 -0
- package/src/dbroles.ts +215 -0
- package/src/runtime/autorest.ts +336 -0
- package/src/runtime/rest.ts +4 -4
- package/src/session.ts +2 -0
|
@@ -32,6 +32,7 @@ export function objectMenu(schema: string, o: MenuObj): Raw {
|
|
|
32
32
|
${table ? item(`${BASE}/sql/objects/table?${new URLSearchParams({ schema, template: o.ref })}`, 'copy', 'Use as template') : ''}
|
|
33
33
|
${table ? item(`${BASE}/sql/load`, 'upload', 'Load data') : ''}
|
|
34
34
|
${table ? html`${item(q('index'), 'list', 'Add index')}${item(q('trigger'), 'bolt', 'Add trigger')}${item(q('policy'), 'shield', 'Add policy')}` : ''}
|
|
35
|
+
${RELATIONS.has(o.type) ? item(`${BASE}/sql/rest/object?${new URLSearchParams({ schema, name: o.name })}`, 'globe', 'REST › Enable / Disable') : ''}
|
|
35
36
|
${item(q('ddl'), 'code', 'Quick DDL to the worksheet')}
|
|
36
37
|
${item(q('download'), 'download', 'Quick DDL as a file')}
|
|
37
38
|
${item(q('drop'), 'trash', 'Drop…')}
|
|
@@ -6,6 +6,7 @@ import { readSchema } from '../ddl.ts';
|
|
|
6
6
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
7
7
|
import { currentWorkspace, inWorkspaceSql, sessionSchema, workshopDb } from './workspaces.ts';
|
|
8
8
|
import { objectMenu } from './object-actions.ts';
|
|
9
|
+
import { isPinned, pinButton } from './launchpad.ts';
|
|
9
10
|
import { owner } from '../db.ts';
|
|
10
11
|
import type { Session } from '../session.ts';
|
|
11
12
|
|
|
@@ -142,7 +143,12 @@ export async function objectBrowserRoutes(app: FastifyInstance) {
|
|
|
142
143
|
|
|
143
144
|
let detail: Raw;
|
|
144
145
|
if (!schema) detail = html`<p class="muted">No schema you may use.</p>`;
|
|
145
|
-
else if (selected)
|
|
146
|
+
else if (selected) {
|
|
147
|
+
// (sprint 96) pin the object to the SQL Workshop's home
|
|
148
|
+
const href = `${BASE}/sql/objects?${new URLSearchParams({ schema, k: selected.type, o: selected.ref })}`;
|
|
149
|
+
const pin = pinButton(s, { href, label: selected.name, icon: OB_TYPES.find(([t]) => t === selected.type)![2] }, await isPinned(s, href), href);
|
|
150
|
+
detail = html`<div class="ob-pin">${pin}</div>${await objectDetail(db, s, schema, selected, String(q.tab ?? ''), Number(q.p) || 1, link)}`;
|
|
151
|
+
}
|
|
146
152
|
else
|
|
147
153
|
detail = region(`Create database objects in ${schema}`, html`<div class="ob-tiles">
|
|
148
154
|
<a class="create-source" href="${BASE}/sql/objects/table?${new URLSearchParams({ schema })}">${icon('table')}<span><b>Table (form)</b><small class="muted">Columns, keys, indexes and security in a form</small></span></a>${[
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import pg from 'pg';
|
|
3
|
+
import { PageCss } from '../css.ts';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
6
|
+
import { icon } from '../icons.ts';
|
|
7
|
+
import { isNumeric } from '../runtime/report.ts';
|
|
8
|
+
import { renderChartBody, type ChartKind } from '../runtime/charts.ts';
|
|
9
|
+
import type { Session } from '../session.ts';
|
|
10
|
+
import { unloadStatement } from '../unload.ts';
|
|
11
|
+
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
12
|
+
import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 97, Database Actions DA-6: Create Chart and Charts) A query drawn as a chart with pgkiln's own
|
|
15
|
+
// server-rendered charts (src/runtime/charts.ts): the worksheet's Chart tab draws the last query, guessing the label
|
|
16
|
+
// column (the first text or date column) and the value columns (the numbers); saved charts (meta.sql_chart,
|
|
17
|
+
// migration 110) are listed under SQL Workshop → Charts, the ones on the dashboard drawn there. A saved chart's
|
|
18
|
+
// SELECT runs read-only, with a statement timeout and at most 500 rows, as the workshop's connection.
|
|
19
|
+
|
|
20
|
+
export const SQL_CHART_KINDS: [ChartKind, string][] = [['bar', 'Bar'], ['column', 'Column'], ['stacked', 'Stacked bar'], ['line', 'Line'], ['area', 'Area'], ['donut', 'Donut'], ['pie', 'Pie'], ['scatter', 'Scatter']];
|
|
21
|
+
const MAX_ROWS = 500;
|
|
22
|
+
|
|
23
|
+
export interface ChartSpec { kind: ChartKind; label: string; values: string[] }
|
|
24
|
+
|
|
25
|
+
/** The columns to draw: the ones asked for when they exist, else a label (first non-number) and the numbers. */
|
|
26
|
+
export function chartSpec(columns: string[], numeric: boolean[], asked: Partial<ChartSpec> = {}): ChartSpec | null {
|
|
27
|
+
const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
|
|
28
|
+
const label = asked.label && columns.includes(asked.label) ? asked.label : (columns.find((_, i) => !numeric[i]) ?? columns[0]);
|
|
29
|
+
const wanted = (asked.values ?? []).filter((v) => columns.includes(v) && v !== label);
|
|
30
|
+
const values = wanted.length ? wanted : columns.filter((c, i) => numeric[i] && c !== label);
|
|
31
|
+
return label && values.length ? { kind, label, values } : null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A chart of rows (text values from the worksheet, or values from a query), with the rules for the page's <style>. */
|
|
35
|
+
export function drawChart(title: string, columns: string[], rows: unknown[][], spec: ChartSpec): { body: Raw; css: string } {
|
|
36
|
+
const css = new PageCss();
|
|
37
|
+
const li = columns.indexOf(spec.label);
|
|
38
|
+
const vi = spec.values.map((v) => columns.indexOf(v));
|
|
39
|
+
const data = rows.map((r) => [r[li] === null || r[li] === undefined ? '' : String(r[li]), ...vi.map((i) => (r[i] === null || r[i] === undefined || r[i] === '' ? null : Number(r[i])))]);
|
|
40
|
+
const body = renderChartBody(spec.kind, title, data, [{ name: spec.label }, ...spec.values.map((name) => ({ name }))], css);
|
|
41
|
+
return { body, css: css.text };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The controls of a chart (type, label column, value columns), named chart_*. */
|
|
45
|
+
export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch'): Raw {
|
|
46
|
+
return html`<div class="ch-controls">
|
|
47
|
+
<label><span>Type</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${label}</option>`)}</select></label>
|
|
48
|
+
<label><span>Labels</span><select name="chart_label" id="${idPrefix}-label">${columns.map((c) => html`<option value="${c}"${c === spec.label ? raw(' selected') : ''}>${c}</option>`)}</select></label>
|
|
49
|
+
<fieldset><legend>Values</legend>${columns.map((c) => html`<label class="check"><input type="checkbox" name="chart_values" value="${c}"${spec.values.includes(c) ? raw(' checked') : ''}> ${c}</label>`)}</fieldset>
|
|
50
|
+
</div>`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const list = (v: unknown) => (Array.isArray(v) ? v : v === undefined || v === null || v === '' ? [] : [v]).map(String);
|
|
54
|
+
export const askedSpec = (b: Body): Partial<ChartSpec> => ({ kind: b.chart_kind as ChartKind, label: typeof b.chart_label === 'string' ? b.chart_label : undefined, values: list(b.chart_values) });
|
|
55
|
+
|
|
56
|
+
interface SavedChart { id: number; name: string; sql: string; kind: ChartKind; label_col: string; value_cols: string[]; on_dashboard: boolean; created_by: string | null; updated_at: string }
|
|
57
|
+
|
|
58
|
+
/** A saved chart's SELECT, run read-only: columns, which are numbers, and the rows. */
|
|
59
|
+
async function runChart(s: Session, sql: string) {
|
|
60
|
+
const db = await workshopDb(s);
|
|
61
|
+
const schema = await sessionSchema(s, db);
|
|
62
|
+
const c = await db.pool.connect();
|
|
63
|
+
try {
|
|
64
|
+
await c.query('begin transaction read only');
|
|
65
|
+
await c.query(`select set_config('statement_timeout', '10s', true)`);
|
|
66
|
+
if (schema) await c.query(`select set_config('search_path', $1, true)`, [`${pg.escapeIdentifier(schema)}, public`]);
|
|
67
|
+
const res = await c.query({ text: `select * from (${unloadStatement(sql)}\n) chart_rows limit ${MAX_ROWS}`, rowMode: 'array' });
|
|
68
|
+
return { columns: res.fields.map((f) => f.name), numeric: res.fields.map((f) => isNumeric(f.dataTypeID)), rows: res.rows as unknown[][] };
|
|
69
|
+
} finally {
|
|
70
|
+
await c.query('rollback').catch(() => {});
|
|
71
|
+
c.release(true);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function chartCard(s: Session, ch: SavedChart, full = false): Promise<{ body: Raw; css: string }> {
|
|
76
|
+
try {
|
|
77
|
+
const r = await runChart(s, ch.sql);
|
|
78
|
+
const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
|
|
79
|
+
if (!spec) return { body: html`<p class="muted">The query has no number column to draw.</p>`, css: '' };
|
|
80
|
+
const d = drawChart(ch.name, r.columns, r.rows, spec);
|
|
81
|
+
return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length} row${r.rows.length === 1 ? '' : 's'}${r.rows.length === MAX_ROWS ? ` (the first ${MAX_ROWS})` : ''}.</p>` : ''}`, css: d.css };
|
|
82
|
+
} catch (e) {
|
|
83
|
+
return { body: html`<p class="alert alert-error">${(e as Error).message}</p>`, css: '' };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const style = (nonce: string, css: string) => (css ? html`<style nonce="${nonce}">${raw(css.replace(/</g, ''))}</style>` : '');
|
|
88
|
+
|
|
89
|
+
export async function sqlChartRoutes(app: FastifyInstance) {
|
|
90
|
+
// the list and the dashboard
|
|
91
|
+
app.get(`${BASE}/sql/charts`, async (req: Req, reply: FastifyReply) => {
|
|
92
|
+
const s = await developer(req, reply);
|
|
93
|
+
if (!s) return;
|
|
94
|
+
const charts = (await owner.query<SavedChart>('select * from meta.sql_chart where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
95
|
+
let css = '';
|
|
96
|
+
const cards: Raw[] = [];
|
|
97
|
+
for (const ch of charts.filter((c) => c.on_dashboard)) {
|
|
98
|
+
const card = await chartCard(s, ch);
|
|
99
|
+
css += `\n${card.css}`;
|
|
100
|
+
cards.push(html`<section class="ch-card"><h2><a href="${BASE}/sql/charts/${ch.id}">${ch.name}</a></h2>${card.body}</section>`);
|
|
101
|
+
}
|
|
102
|
+
const main = html`<h1 class="u-mb1">Charts</h1>${workshopTabs('charts')}${style(req.cspNonce, css)}
|
|
103
|
+
<p class="muted">Queries drawn as charts. Make one from a query in the <a href="${BASE}/sql/worksheet">SQL Worksheet</a> (its Chart tab, Ctrl+F11), then save it.</p>
|
|
104
|
+
${cards.length ? html`<div class="ch-dashboard">${cards}</div>` : ''}
|
|
105
|
+
${region('All charts', charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Chart</th><th>Type</th><th>On the dashboard</th><th>Changed</th></tr></thead><tbody>
|
|
106
|
+
${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${SQL_CHART_KINDS.find(([k]) => k === c.kind)?.[1] ?? c.kind}</td><td>${c.on_dashboard ? 'Yes' : 'No'}</td>
|
|
107
|
+
<td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">by ${c.created_by}</span>` : ''}</td></tr>`)}</tbody></table></div>`
|
|
108
|
+
: html`<p class="muted">No charts yet.</p>`)}`;
|
|
109
|
+
return send(reply, s, shell(s, 'Charts', [['SQL Workshop', `${BASE}/sql`], ['Charts']], main, 'sql'));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// one chart: drawn, with its settings
|
|
113
|
+
app.get(`${BASE}/sql/charts/:id`, async (req: Req, reply: FastifyReply) => {
|
|
114
|
+
const s = await developer(req, reply);
|
|
115
|
+
if (!s) return;
|
|
116
|
+
const ch = /^\d{1,9}$/.test(String(req.params.id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [req.params.id, currentWorkspace(s)]) : undefined;
|
|
117
|
+
if (!ch) return reply.code(404).send('Not found');
|
|
118
|
+
const card = await chartCard(s, ch, true);
|
|
119
|
+
let controls: Raw | '' = '';
|
|
120
|
+
try {
|
|
121
|
+
const r = await runChart(s, ch.sql);
|
|
122
|
+
const spec = chartSpec(r.columns, r.numeric, { kind: ch.kind, label: ch.label_col, values: ch.value_cols });
|
|
123
|
+
if (spec) controls = chartControls(r.columns, spec, 'sch');
|
|
124
|
+
} catch {
|
|
125
|
+
controls = '';
|
|
126
|
+
}
|
|
127
|
+
const main = html`<h1 class="u-mb1">${ch.name}</h1>${workshopTabs('charts')}${style(req.cspNonce, card.css)}
|
|
128
|
+
<div class="ch-view">${card.body}</div>
|
|
129
|
+
<form method="post" action="${BASE}/sql/charts/${ch.id}" class="ch-edit">${csrf(s)}
|
|
130
|
+
<div class="field"><label class="label" for="ch-name">Name</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
|
|
131
|
+
${controls}
|
|
132
|
+
<label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> On the Charts dashboard</label>
|
|
133
|
+
<details class="u-mt1"><summary>The query</summary><pre class="source">${ch.sql}</pre></details>
|
|
134
|
+
<div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} Save</button>
|
|
135
|
+
<button class="btn" name="action" value="worksheet">${icon('code')} Open in the worksheet</button>
|
|
136
|
+
<button class="btn btn-danger" name="action" value="delete" data-confirm="Delete the chart ${ch.name}?">${icon('trash')} Delete</button></div>
|
|
137
|
+
</form>`;
|
|
138
|
+
return send(reply, s, shell(s, ch.name, [['SQL Workshop', `${BASE}/sql`], ['Charts', `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// save a chart from the worksheet (a new one), or change, open or delete a saved one
|
|
142
|
+
const save = async (req: Req, reply: FastifyReply) => {
|
|
143
|
+
const s = await developer(req, reply);
|
|
144
|
+
if (!s) return;
|
|
145
|
+
const b = req.body ?? {};
|
|
146
|
+
const ws = currentWorkspace(s);
|
|
147
|
+
const id = req.params?.id;
|
|
148
|
+
const name = String(b.name ?? b.chart_name ?? '').trim();
|
|
149
|
+
const asked = askedSpec(b);
|
|
150
|
+
const kind = SQL_CHART_KINDS.some(([k]) => k === asked.kind) ? asked.kind! : 'bar';
|
|
151
|
+
if (id !== undefined) {
|
|
152
|
+
const ch = /^\d{1,9}$/.test(String(id)) ? await owner.one<SavedChart>('select * from meta.sql_chart where id = $1 and workspace_id = $2', [id, ws]) : undefined;
|
|
153
|
+
if (!ch) return reply.code(404).send('Not found');
|
|
154
|
+
if (b.action === 'delete') {
|
|
155
|
+
await owner.query('delete from meta.sql_chart where id = $1', [ch.id]);
|
|
156
|
+
flash(s, `Deleted the chart ${ch.name}.`);
|
|
157
|
+
return back(reply, s, `${BASE}/sql/charts`);
|
|
158
|
+
}
|
|
159
|
+
if (b.action === 'worksheet') {
|
|
160
|
+
s.state.__SQLDRAFT = ch.sql;
|
|
161
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
162
|
+
}
|
|
163
|
+
if (!name || name.length > 120) {
|
|
164
|
+
flash(s, 'A chart needs a name (at most 120 characters).', 'error');
|
|
165
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
await owner.query(`update meta.sql_chart set name = $2, kind = $3, label_col = $4, value_cols = $5, on_dashboard = $6, updated_at = now() where id = $1`,
|
|
169
|
+
[ch.id, name, kind, asked.label ?? ch.label_col, asked.values?.length ? asked.values : ch.value_cols, b.on_dashboard === 'on']);
|
|
170
|
+
} catch (e) {
|
|
171
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
172
|
+
flash(s, `A chart named “${name}” exists already.`, 'error');
|
|
173
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
174
|
+
}
|
|
175
|
+
flash(s, `Saved the chart ${name}.`);
|
|
176
|
+
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
177
|
+
}
|
|
178
|
+
let sql = '';
|
|
179
|
+
try {
|
|
180
|
+
sql = unloadStatement(String(b.chart_sql ?? ''));
|
|
181
|
+
} catch {
|
|
182
|
+
flash(s, 'Only a chart of one SELECT can be saved.', 'error');
|
|
183
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
184
|
+
}
|
|
185
|
+
if (!name || name.length > 120) {
|
|
186
|
+
flash(s, 'Name the chart to save it (at most 120 characters).', 'error');
|
|
187
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
const r = await owner.one<{ id: number }>(
|
|
191
|
+
`insert into meta.sql_chart (workspace_id, name, sql, kind, label_col, value_cols, created_by) values ($1, $2, $3, $4, $5, $6, $7) returning id`,
|
|
192
|
+
[ws, name, sql, kind, asked.label ?? '', asked.values ?? [], s.username]);
|
|
193
|
+
flash(s, `Saved the chart ${name}: it is on the Charts dashboard.`);
|
|
194
|
+
return back(reply, s, `${BASE}/sql/charts/${r!.id}`);
|
|
195
|
+
} catch (e) {
|
|
196
|
+
if ((e as { code?: string }).code !== '23505') throw e;
|
|
197
|
+
flash(s, `A chart named “${name}” exists already.`, 'error');
|
|
198
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
app.post(`${BASE}/sql/charts`, save);
|
|
202
|
+
app.post(`${BASE}/sql/charts/:id`, save);
|
|
203
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// (sprint 96, Database Actions DA-15) The SQL Workshop's tools in one list: the home page's Launchpad tabs
|
|
2
|
+
// (Development, Administration, Monitoring), the More menu's headings and the builder's search (Ctrl+K) read it.
|
|
3
|
+
// `tab` is the SQL Workshop tab a tool's own pages mark (workshopTabs in ui.ts); tools without one are pages
|
|
4
|
+
// elsewhere in the builder.
|
|
5
|
+
|
|
6
|
+
export type ToolGroup = 'development' | 'administration' | 'monitoring';
|
|
7
|
+
export interface SqlTool {
|
|
8
|
+
key: string;
|
|
9
|
+
href: string;
|
|
10
|
+
icon: string;
|
|
11
|
+
label: string;
|
|
12
|
+
description: string;
|
|
13
|
+
group: ToolGroup;
|
|
14
|
+
/** the SQL Workshop tab its pages mark */
|
|
15
|
+
tab?: string;
|
|
16
|
+
/** one of the main tabs (not in More) */
|
|
17
|
+
main?: boolean;
|
|
18
|
+
/** administrators only */
|
|
19
|
+
admin?: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const TOOL_GROUPS: [ToolGroup, string][] = [['development', 'Development'], ['administration', 'Administration'], ['monitoring', 'Monitoring']];
|
|
23
|
+
|
|
24
|
+
export const SQL_TOOLS: SqlTool[] = [
|
|
25
|
+
{ key: 'worksheet', href: '/builder/sql/worksheet', icon: 'code', label: 'SQL Worksheet', description: 'Run statements and scripts with a navigator, plans, results and history.', group: 'development', tab: 'sql', main: true },
|
|
26
|
+
{ key: 'objects', href: '/builder/sql/objects', icon: 'database', label: 'Object Browser', description: 'Tables, views and functions with their columns, data, keys, policies and DDL.', group: 'development', tab: 'objects', main: true },
|
|
27
|
+
{ key: 'scripts', href: '/builder/sql/scripts', icon: 'file', label: 'SQL Scripts', description: 'Saved scripts of the workspace: upload, run, download, run history.', group: 'development', tab: 'scripts', main: true },
|
|
28
|
+
{ key: 'table', href: '/builder/sql/objects/table', icon: 'table', label: 'Create Table', description: 'A new table in a form: columns, keys, indexes and security.', group: 'development' },
|
|
29
|
+
{ key: 'charts', href: '/builder/sql/charts', icon: 'chart', label: 'Charts', description: 'Queries drawn as charts, and a dashboard of them.', group: 'development', tab: 'charts' },
|
|
30
|
+
{ key: 'quicksql', href: '/builder/sql/quick', icon: 'bolt', label: 'Quick SQL', description: 'A data model in shorthand, turned into DDL.', group: 'development', tab: 'quicksql' },
|
|
31
|
+
{ key: 'query', href: '/builder/sql/query', icon: 'filter', label: 'Query Builder', description: 'A query from tables and their joins, drawn.', group: 'development', tab: 'query' },
|
|
32
|
+
{ key: 'load', href: '/builder/sql/load', icon: 'upload', label: 'Load Data', description: 'CSV, Excel, JSON or XML into a new or existing table.', group: 'development', tab: 'load' },
|
|
33
|
+
{ key: 'unload', href: '/builder/sql/unload', icon: 'download', label: 'Unload Data', description: 'A table, view or query as CSV, JSON, Excel, XML or SQL inserts.', group: 'development', tab: 'unload' },
|
|
34
|
+
{ key: 'sample', href: '/builder/sql/sample-data', icon: 'layers', label: 'Sample Data', description: 'Rows that look real, for development.', group: 'development', tab: 'sample' },
|
|
35
|
+
{ key: 'ddl', href: '/builder/sql/ddl', icon: 'file', label: 'Generate DDL', description: 'A schema\'s objects as one script.', group: 'development', tab: 'ddl' },
|
|
36
|
+
{ key: 'compare', href: '/builder/sql/compare', icon: 'layers', label: 'Schema Comparison', description: 'The differences between two schemas, and the SQL to align them.', group: 'development', tab: 'compare' },
|
|
37
|
+
{ key: 'modeler', href: '/builder/sql/modeler', icon: 'org', label: 'Data Modeler', description: 'Relational diagrams of tables and their foreign keys; DDL and a data dictionary from them.', group: 'development', tab: 'modeler' },
|
|
38
|
+
{ key: 'rest', href: '/builder/sql/rest', icon: 'globe', label: 'RESTful Services', description: 'Tables and views as REST endpoints (AutoREST): URLs, examples, OpenAPI.', group: 'development', tab: 'rest' },
|
|
39
|
+
{ key: 'ai', href: '/builder/sql/ai', icon: 'bolt', label: 'AI', description: 'SQL from a question, explanations and table descriptions.', group: 'development', tab: 'ai' },
|
|
40
|
+
{ key: 'utilities', href: '/builder/sql/utilities', icon: 'wrench', label: 'Utilities', description: 'Object reports, About Database and the other utilities.', group: 'administration', tab: 'utilities', main: true },
|
|
41
|
+
{ key: 'backups', href: '/builder/backups', icon: 'archive', label: 'Manage Backups', description: 'Backups of the workspace\'s applications.', group: 'administration' },
|
|
42
|
+
{ key: 'workspaces', href: '/builder/workspaces', icon: 'layers', label: 'Workspaces', description: 'Workspaces, their developers and database roles.', group: 'administration', admin: true },
|
|
43
|
+
{ key: 'dbusers', href: '/builder/db-users', icon: 'key', label: 'Database Users', description: 'PostgreSQL roles: sign-in, expiry, memberships and schema privileges.', group: 'administration', admin: true },
|
|
44
|
+
{ key: 'developers', href: '/builder/developers', icon: 'users', label: 'Developers', description: 'Builder accounts and their workspaces.', group: 'administration' },
|
|
45
|
+
{ key: 'monitor', href: '/builder/sql/utilities/monitor', icon: 'activity', label: 'Database Monitor', description: 'Sessions, what they run and what blocks them.', group: 'monitoring', admin: true },
|
|
46
|
+
{ key: 'about', href: '/builder/sql/utilities/about', icon: 'info', label: 'About Database', description: 'The server: version, settings, extensions and sizes.', group: 'monitoring' },
|
|
47
|
+
{ key: 'reports', href: '/builder/sql/utilities/reports', icon: 'list', label: 'Object Reports', description: 'Tables without keys, unindexed foreign keys, row level security gaps and more.', group: 'monitoring' },
|
|
48
|
+
{ key: 'activity', href: '/builder/dashboard', icon: 'chart', label: 'Monitor Activity', description: 'Page views, errors and slow pages of the applications.', group: 'monitoring' },
|
|
49
|
+
];
|
package/src/builder/sql.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { clientIp, logActivity, type Session } from '../session.ts';
|
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { scriptRoutes } from './scripts.ts';
|
|
10
10
|
import { worksheetRoutes } from './worksheet.ts';
|
|
11
|
+
import { launchpad, launchpadRoutes } from './launchpad.ts';
|
|
12
|
+
import { sqlChartRoutes } from './sql-charts.ts';
|
|
13
|
+
import { isAdmin } from './locks.ts';
|
|
11
14
|
import { quickSqlRoutes } from './quicksql.ts';
|
|
12
15
|
import { queryBuilderRoutes } from './querybuilder.ts';
|
|
13
16
|
import { unloadRoutes } from './unload.ts';
|
|
@@ -16,6 +19,8 @@ import { sqlUtilityRoutes } from './sql-utilities.ts';
|
|
|
16
19
|
import { createTemplate, objectBrowserRoutes } from './object-browser.ts';
|
|
17
20
|
import { objectActionRoutes } from './object-actions.ts';
|
|
18
21
|
import { tableEditorRoutes } from './table-editor.ts';
|
|
22
|
+
import { dataModelerRoutes } from './data-modeler.ts';
|
|
23
|
+
import { autoRestBuilderRoutes } from './autorest.ts';
|
|
19
24
|
import { currentWorkspace, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
20
25
|
|
|
21
26
|
// SQL Workshop: SQL commands and the object browser (owner connection; an isolated
|
|
@@ -54,21 +59,13 @@ async function workshopHome(req: Req, reply: FastifyReply) {
|
|
|
54
59
|
[currentWorkspace(s)],
|
|
55
60
|
),
|
|
56
61
|
]);
|
|
57
|
-
const tile = (href: string, ic: string, title: string, text: string) =>
|
|
58
|
-
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>`;
|
|
59
62
|
const list = (title: string | Raw, rows: Raw[], empty: string) =>
|
|
60
63
|
html`<section class="sw-recent"><h2>${title}</h2>${rows.length ? html`<ul>${rows}</ul>` : html`<p class="muted">${empty}</p>`}</section>`;
|
|
61
64
|
const main = html`<div class="ab-home">
|
|
62
65
|
<div class="ab-main">
|
|
63
66
|
<h1 class="sr-only">SQL Workshop</h1>
|
|
64
67
|
${workshopTabs('home')}
|
|
65
|
-
|
|
66
|
-
${tile('/sql/worksheet', 'code', 'SQL Worksheet', 'Run statements and scripts, with a navigator, plans and history.')}
|
|
67
|
-
${tile('/sql/objects', 'search', 'Object Browser', 'Browse tables, views and functions, their policies, grants and data.')}
|
|
68
|
-
${tile('/sql/scripts', 'file', 'SQL Scripts', 'Create, upload and run stored SQL scripts.')}
|
|
69
|
-
${tile('/sql/utilities', 'wrench', 'Utilities', 'Load and unload data, sample data, DDL, schema comparison, object reports and the database.')}
|
|
70
|
-
${tile('/sql/quick', 'bolt', 'Quick SQL', 'Write a data model in shorthand and get the DDL.')}
|
|
71
|
-
</div>
|
|
68
|
+
${await launchpad(s, await isAdmin(s.username))}
|
|
72
69
|
<div class="sw-recents">
|
|
73
70
|
${list('Recently created tables', tables.rows.map((t) => html`<li><a href="${BASE}/sql/objects?o=${encodeURIComponent(t.qname)}">${t.qname}</a><span class="muted">~${Number(t.rows).toLocaleString('en')} rows</span></li>`), 'No tables you can read.')}
|
|
74
71
|
${list(html`<a href="${BASE}/sql/worksheet?tab=history" title="Open SQL History">Recent SQL commands</a>`, commands.rows.map((c) => html`<li><a href="${BASE}/sql/worksheet?recent=${c.id}" title="${c.detail.slice(0, 500)}">${c.detail.replace(/\s+/g, ' ').slice(0, 60)}</a><span class="muted">${ago(c.at)}</span></li>`), 'You haven\'t run SQL commands yet.')}
|
|
@@ -105,6 +102,10 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
105
102
|
// Generate DDL and Schema Comparison (sprint 56)
|
|
106
103
|
await app.register(ddlToolRoutes);
|
|
107
104
|
await app.register(sqlUtilityRoutes);
|
|
105
|
+
// (sprint 97) Charts: saved charts and their dashboard
|
|
106
|
+
await app.register(sqlChartRoutes);
|
|
107
|
+
// (sprint 96) the Launchpad's pins and the builder's search (Ctrl+K)
|
|
108
|
+
await app.register(launchpadRoutes);
|
|
108
109
|
// (sprint 91) the SQL Worksheet (was SQL Commands; /sql/commands and a post to /sql are the same page)
|
|
109
110
|
await app.register(worksheetRoutes);
|
|
110
111
|
app.get(`${BASE}/sql`, workshopHome);
|
|
@@ -128,4 +129,8 @@ export async function sqlRoutes(app: FastifyInstance) {
|
|
|
128
129
|
await app.register(objectActionRoutes);
|
|
129
130
|
// (sprint 95) Table Properties: create, edit or copy a table in a form
|
|
130
131
|
await app.register(tableEditorRoutes);
|
|
132
|
+
// (sprint 98) the Data Modeler: relational diagrams, their DDL and data dictionary
|
|
133
|
+
await app.register(dataModelerRoutes);
|
|
134
|
+
// (sprint 100) RESTful Services: AutoREST (autorest.ts)
|
|
135
|
+
await app.register(autoRestBuilderRoutes);
|
|
131
136
|
}
|
package/src/builder/ui.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
8
8
|
import { documentShell } from '../layout.ts';
|
|
9
9
|
import { getSession, saveState, takeFlash, type Session } from '../session.ts';
|
|
10
|
+
import { SQL_TOOLS, TOOL_GROUPS } from './sql-tools.ts';
|
|
10
11
|
import { ensureDevbar } from '../runtime/devbar.ts';
|
|
11
12
|
|
|
12
13
|
// Shared building blocks of the builder UI.
|
|
@@ -51,8 +52,8 @@ export type Section = 'apps' | 'users' | 'sql' | 'developers' | 'gallery';
|
|
|
51
52
|
// Builder-only stylesheets and scripts, loaded on every builder page after
|
|
52
53
|
// app.css / app.js (the runtime never loads them). Add new builder assets
|
|
53
54
|
// here, and only here.
|
|
54
|
-
export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css'];
|
|
55
|
-
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js'];
|
|
55
|
+
export const BUILDER_STYLES = ['/static/builder.css', '/static/code-editor.css', '/static/worksheet.css', '/static/data-modeler.css'];
|
|
56
|
+
export const BUILDER_SCRIPTS = ['/static/builder.js', '/static/code-editor.js', '/static/worksheet.js', '/static/palette.js', '/static/data-modeler.js'];
|
|
56
57
|
export const builderHead = () =>
|
|
57
58
|
html`${BUILDER_STYLES.map((href) => html`<link rel="stylesheet" href="${href}">\n`)}${BUILDER_SCRIPTS.map((src) => html`<script src="${src}" defer></script>\n`)}`;
|
|
58
59
|
|
|
@@ -139,6 +140,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
139
140
|
<a href="${BASE}/users">${icon('user')} Manage users and groups</a>
|
|
140
141
|
<a href="${BASE}/developers">${icon('users')} Developers</a>
|
|
141
142
|
<a href="${BASE}/dashboard">${icon('activity')} Monitor activity</a>
|
|
143
|
+
${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} Database users</a>` : ''}
|
|
142
144
|
<a href="${BASE}/developers">${icon('key')} Change my password</a>
|
|
143
145
|
</div>
|
|
144
146
|
</div></details></li>
|
|
@@ -170,6 +172,9 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
|
|
|
170
172
|
${back ? html`<a class="ide-back" href="${back[1]}" title="Back to ${back[0]}">${bicon('back')}<span class="sr-only">Back to ${back[0]}</span></a>` : ''}
|
|
171
173
|
<nav class="ide-crumbs" aria-label="Breadcrumb"><ol>${crumbs.map(([label, href], i) =>
|
|
172
174
|
html`<li>${href && i < crumbs.length - 1 ? html`<a href="${href}">${label}</a>` : html`<span aria-current="page">${label}</span>`}</li>`)}</ol></nav>
|
|
175
|
+
<form method="get" action="${BASE}/palette" class="ide-search" role="search" data-palette>
|
|
176
|
+
<label class="sr-only" for="ide-search-q">Search the builder</label>
|
|
177
|
+
<input id="ide-search-q" name="q" type="search" placeholder="Search (Ctrl+K)" autocomplete="off" aria-keyshortcuts="Control+K"></form>
|
|
173
178
|
<div class="ide-tools">${opts.toolbar ?? ''}</div>
|
|
174
179
|
</header>
|
|
175
180
|
${s.workspace?.banner_label ? html`<div class="ide-banner ide-banner-${BANNER_COLORS.includes(s.workspace.banner_color ?? '') ? s.workspace.banner_color : 'blue'}" role="note" aria-label="Environment">${s.workspace.banner_label}</div>` : ''}
|
|
@@ -247,22 +252,23 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
|
|
|
247
252
|
};
|
|
248
253
|
|
|
249
254
|
/** Tabs of the SQL Workshop. */
|
|
250
|
-
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities';
|
|
255
|
+
export type WorkshopTab = 'home' | 'sql' | 'scripts' | 'quicksql' | 'query' | 'objects' | 'load' | 'unload' | 'sample' | 'ai' | 'ddl' | 'compare' | 'utilities' | 'charts' | 'modeler' | 'rest';
|
|
251
256
|
export const workshopTabs = (active: WorkshopTab) => {
|
|
252
|
-
const tab = (key:
|
|
253
|
-
html`<a class="ide-tab" href="${
|
|
254
|
-
// (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
['compare', '/sql/compare', 'layers', 'Compare'], ['ai', '/sql/ai', 'bolt', 'AI'],
|
|
259
|
-
];
|
|
260
|
-
const open = more.find(([k]) => k === active);
|
|
257
|
+
const tab = (key: string, href: string, ic: string, label: string) =>
|
|
258
|
+
html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${icon(ic)}<span>${label}</span></a>`;
|
|
259
|
+
// (sprint 75) the main tools as tabs, the others in a More menu (the open one is a tab too), so the strip never
|
|
260
|
+
// scrolls; (sprint 96) the More menu under the Launchpad's headings, from SQL_TOOLS
|
|
261
|
+
const main = SQL_TOOLS.filter((t) => t.main);
|
|
262
|
+
const open = SQL_TOOLS.find((t) => !t.main && t.tab === active);
|
|
261
263
|
return html`<nav class="ide-tabs sw-tabs u-mb1" aria-label="SQL Workshop">
|
|
262
|
-
${tab('home',
|
|
263
|
-
${open ? tab(
|
|
264
|
+
${tab('home', `${BASE}/sql`, 'home', 'Home')}${main.map((t) => tab(t.tab!, t.href, t.icon, t.label))}
|
|
265
|
+
${open ? tab(open.tab!, open.href, open.icon, open.label) : ''}
|
|
264
266
|
<details class="menu sw-more"><summary class="ide-tab">${icon('chevron-down')}<span>More</span></summary>
|
|
265
|
-
<div class="menu-panel align-right"
|
|
267
|
+
<div class="menu-panel align-right">${TOOL_GROUPS.map(([g, heading]) => {
|
|
268
|
+
// administrators' tools are on the Launchpad and the Utilities page, not in every developer's menu
|
|
269
|
+
const list = SQL_TOOLS.filter((t) => t.group === g && !t.main && !t.admin);
|
|
270
|
+
return list.length ? html`<div class="menu-section menu-links"><span class="small muted">${heading}</span>${list.map((t) => html`<a href="${t.href}"${t.tab && t.tab === active ? raw(' aria-current="page"') : ''}>${icon(t.icon)} ${t.label}</a>`)}</div>` : '';
|
|
271
|
+
})}</div></details></nav>`;
|
|
266
272
|
};
|
|
267
273
|
|
|
268
274
|
/** Number of app tile colours (.app-color-0 … -7 in app.css). */
|
package/src/builder/worksheet.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { back, bicon, BASE, csrf, developer, flash, send, shell, workshopTabs, t
|
|
|
9
9
|
import { clearCompletions } from './code-editor.ts';
|
|
10
10
|
import { createTemplate, listObjects, OB_TYPES, pickSchema, SYSTEM_SCHEMAS, type Obj } from './object-browser.ts';
|
|
11
11
|
import { objectMenu } from './object-actions.ts';
|
|
12
|
+
import { askedSpec, chartControls, chartSpec, drawChart } from './sql-charts.ts';
|
|
12
13
|
import { currentWorkspace, inWorkspaceSql, sessionSchema, usableSchemas, workshopDb, workshopRunsAs } from './workspaces.ts';
|
|
13
14
|
import { applySubstitutions, explainStatement, pickStatements, runStatements, substitutionNames, textTable, type RunMode, type WorksheetRun } from '../sqlworksheet.ts';
|
|
14
15
|
import { applyBinds, bindNames } from '../binds.ts';
|
|
@@ -30,7 +31,7 @@ import { unloadStatement } from '../unload.ts';
|
|
|
30
31
|
const NAV_TYPES: [string, string][] = [['all', 'All Objects'], ...OB_TYPES.map(([t, label]) => [t, label] as [string, string]), ['policy', 'Policies']];
|
|
31
32
|
const MODES: RunMode[] = ['statement', 'script', 'explain', 'autotrace'];
|
|
32
33
|
const TABS = [
|
|
33
|
-
['result', 'Query Result'], ['output', 'Script Output'], ['notices', 'Notices'], ['explain', 'Explain Plan'], ['autotrace', 'Autotrace'], ['history', 'SQL History'],
|
|
34
|
+
['result', 'Query Result'], ['chart', 'Chart'], ['output', 'Script Output'], ['notices', 'Notices'], ['explain', 'Explain Plan'], ['autotrace', 'Autotrace'], ['history', 'SQL History'],
|
|
34
35
|
] as const;
|
|
35
36
|
type Tab = (typeof TABS)[number][0];
|
|
36
37
|
|
|
@@ -150,7 +151,7 @@ async function navigatorTree(db: Awaited<ReturnType<typeof workshopDb>>, schema:
|
|
|
150
151
|
}
|
|
151
152
|
|
|
152
153
|
/** The output panes of a run (empty: the page as opened). */
|
|
153
|
-
function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
|
|
154
|
+
function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: HistoryRow[], tabAsked: string, chart: Raw | null): { panes: Record<Tab, Raw>; active: Tab; status: Raw } {
|
|
154
155
|
const results = run?.results ?? [];
|
|
155
156
|
const ran = results.filter((r) => r.status !== 'not_run');
|
|
156
157
|
const query = [...ran].reverse().find((r) => r.columns);
|
|
@@ -175,6 +176,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
|
|
|
175
176
|
<p class="muted small ws-info">${rowsLabel(query)} · ${query.ms} ms${query.more ? html` · showing the first ${query.data!.length}` : ''} · line ${query.line}</p>
|
|
176
177
|
<span class="ws-spacer"></span>
|
|
177
178
|
${query.more ? html`<button class="btn btn-sm" name="mode" value="statement_all" title="Run the statement again and show up to ${ALL_ROWS.toLocaleString('en')} rows">All rows</button>` : ''}
|
|
179
|
+
<button class="btn btn-sm" name="mode" value="chart" title="Create Chart (Ctrl+F11)">${icon('chart')}<span>Chart</span></button>
|
|
178
180
|
<button type="button" class="btn btn-sm" data-ws-copy hidden>${icon('copy')}<span>Copy</span></button>
|
|
179
181
|
${download}
|
|
180
182
|
</div>
|
|
@@ -220,7 +222,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
|
|
|
220
222
|
</li>`)}</ul>`
|
|
221
223
|
: html`<p class="muted">Your statements show here after you run them.</p>`;
|
|
222
224
|
|
|
223
|
-
const active: Tab = !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
|
|
225
|
+
const active: Tab = chart && run ? 'chart' : !run ? (TABS.find(([k]) => k === tabAsked)?.[0] ?? 'result') : mode === 'explain' ? 'explain' : mode === 'autotrace' ? 'autotrace' : mode === 'script' || !query || errors.length ? 'output' : 'result';
|
|
224
226
|
const time = new Date().toTimeString().slice(0, 8);
|
|
225
227
|
const last = !run ? 'Ready.' : errors.length
|
|
226
228
|
? `Error${errors[0].errorLine ? ` at line ${errors[0].errorLine}` : ''}: ${errors[0].error}`
|
|
@@ -231,7 +233,7 @@ function outputPanes(run: WorksheetRun | null, mode: RunMode | null, history: Hi
|
|
|
231
233
|
<span class="ws-count ${warnings ? 'is-warn' : ''}" title="Warnings">${icon('alert')} ${warnings}</span>
|
|
232
234
|
<span class="ws-busy" hidden>${icon('refresh')} Running…</span>
|
|
233
235
|
<span class="ws-last">${last}</span><span class="muted ws-time">${run ? time : ''}</span>`;
|
|
234
|
-
return { panes: { result, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
|
|
236
|
+
return { panes: { result, chart: chart ?? html`<p class="muted">Create Chart (Ctrl+F11) draws the last query: a label column and number columns.</p>`, output: log, notices, explain: planOf('explain', 'Explain Plan (F10) shows how PostgreSQL would run the statement at the cursor; nothing runs.'), autotrace: planOf('autotrace', 'Autotrace (F6) runs the statement at the cursor with EXPLAIN ANALYZE and rolls it back: actual rows, time and buffers per step.'), history: hist }, active, status };
|
|
235
237
|
}
|
|
236
238
|
|
|
237
239
|
/** The applications whose database role a run can take (the developer's workspace). */
|
|
@@ -347,6 +349,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
347
349
|
const continueOnError = body.continue_on_error === 'on';
|
|
348
350
|
// (sprint 93, DA-9) bind variables (:NAME) and substitution variables (&NAME.) asked for before a run
|
|
349
351
|
let askValues: { binds: string[]; subs: string[]; mode: string } | null = null;
|
|
352
|
+
let chartAsked = false;
|
|
350
353
|
const remembered: Record<string, string> = (() => {
|
|
351
354
|
try {
|
|
352
355
|
const v = JSON.parse(s.state.__WSBINDS ?? '{}');
|
|
@@ -358,6 +361,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
358
361
|
if (isPost && text.trim() && !['nav', 'save', 'save_as', 'rename', 'download'].includes(String(body.mode ?? ''))) {
|
|
359
362
|
const m: RunMode = MODES.find((x) => x === body.mode) ?? 'statement';
|
|
360
363
|
const allRows = body.mode === 'statement_all';
|
|
364
|
+
chartAsked = body.mode === 'chart';
|
|
361
365
|
mode = m;
|
|
362
366
|
const sel = /^\d{1,9}$/.test(String(body.sel_start ?? '')) && /^\d{1,9}$/.test(String(body.sel_end ?? '')) ? { start: Number(body.sel_start), end: Number(body.sel_end) } : undefined;
|
|
363
367
|
const picked = pickStatements(text, m, sel);
|
|
@@ -410,8 +414,25 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
410
414
|
const history = (await owner.query<HistoryRow>(
|
|
411
415
|
`select id, sql, status, rows, ms, error, runs, at from meta.sql_history where username = $1 and workspace_id = $2 order by id desc limit $3`,
|
|
412
416
|
[s.username, currentWorkspace(s), HISTORY_SHOWN])).rows;
|
|
413
|
-
const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''));
|
|
414
417
|
const lastQuery = [...(run?.results ?? [])].reverse().find((r) => r.status === 'ok' && r.columns);
|
|
418
|
+
// (sprint 97, DA-6) Create Chart: the last query drawn, its settings and Save
|
|
419
|
+
let chartPane: Raw | null = null;
|
|
420
|
+
let chartCss = '';
|
|
421
|
+
if (chartAsked && lastQuery?.columns) {
|
|
422
|
+
const spec = chartSpec(lastQuery.columns, lastQuery.numeric ?? [], askedSpec(body));
|
|
423
|
+
if (!spec) chartPane = html`<p class="muted">Nothing to draw: the query needs a number column next to its labels.</p>`;
|
|
424
|
+
else {
|
|
425
|
+
const d = drawChart('', lastQuery.columns, lastQuery.data ?? [], spec);
|
|
426
|
+
chartCss = d.css;
|
|
427
|
+
chartPane = html`${chartControls(lastQuery.columns, spec)}
|
|
428
|
+
<div class="ch-actions"><button class="btn btn-sm" name="mode" value="chart">${icon('refresh')} Draw again</button>
|
|
429
|
+
<input type="hidden" name="chart_sql" value="${lastQuery.sql}">
|
|
430
|
+
<label class="sr-only" for="ws-chart-name">Chart name</label><input id="ws-chart-name" name="chart_name" placeholder="Chart name" maxlength="120">
|
|
431
|
+
<button class="btn btn-sm btn-hot" formaction="${BASE}/sql/charts">${icon('save')} Save chart</button></div>
|
|
432
|
+
<div class="ch-view">${d.body}</div>`;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
const { panes, active, status } = outputPanes(run, mode, history, String(req.query?.tab ?? ''), chartPane);
|
|
415
436
|
const downloadable = (() => {
|
|
416
437
|
try {
|
|
417
438
|
return lastQuery ? unloadStatement(lastQuery.sql) : null;
|
|
@@ -433,7 +454,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
433
454
|
|
|
434
455
|
const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
|
|
435
456
|
html`<button class="btn btn-sm${hot ? ' btn-hot' : ''}" name="mode" value="${m}" title="${label} (${key})" data-ws-key="${key}">${icon(ic)}<span>${label}</span></button>`;
|
|
436
|
-
const main = html`<div class="ws-page">
|
|
457
|
+
const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
|
|
437
458
|
<h1 class="sr-only">SQL Worksheet</h1>
|
|
438
459
|
${workshopTabs('sql')}
|
|
439
460
|
<form method="post" action="${BASE}/sql/worksheet" class="ws" id="ws" data-ran="${ran ? `${ran.start},${ran.end}` : ''}" data-error-line="${firstError?.errorLine ?? ''}"
|
|
@@ -479,7 +500,9 @@ async function worksheet(req: Req, reply: FastifyReply) {
|
|
|
479
500
|
${script ? html`<button class="btn btn-sm" name="mode" value="save_as">Save as new</button><button class="btn btn-sm" name="mode" value="rename">Rename</button>` : ''}
|
|
480
501
|
<button class="btn btn-sm" name="mode" value="download">${icon('download')} Download</button>
|
|
481
502
|
</div>
|
|
482
|
-
<div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a
|
|
503
|
+
<div class="ws-file-actions"><a class="btn btn-sm" href="${BASE}/sql/worksheet?new=1" data-ws-new>${icon('plus')} New</a><a class="btn btn-sm" href="#ws-files" data-ws-open>Open…</a>
|
|
504
|
+
${script ? html`<input type="hidden" name="pin_href" value="${BASE}/sql/worksheet?script=${script.id}"><input type="hidden" name="pin_label" value="${script.name}"><input type="hidden" name="back" value="${BASE}/sql/worksheet?script=${script.id}">
|
|
505
|
+
<button class="btn btn-sm" formaction="${BASE}/pins" title="Pin this worksheet to the SQL Workshop home">${icon('pin')} Pin</button>` : ''}</div>
|
|
483
506
|
</div></div></details>
|
|
484
507
|
${toolBtn('statement', 'play', 'Run Statement', 'Ctrl+Enter', true)}
|
|
485
508
|
${toolBtn('script', 'file', 'Run Script', 'F5')}
|
|
@@ -28,12 +28,13 @@ export const DEFAULT_WORKSPACE = 1;
|
|
|
28
28
|
/** The workspaces a developer may use (administrators: all), and the current one, on the session object. */
|
|
29
29
|
export async function loadWorkspaces(s: Session) {
|
|
30
30
|
const rows = (
|
|
31
|
-
await owner.query<WorkspaceRef>(
|
|
31
|
+
await owner.query<WorkspaceRef & { instance_admin?: boolean }>(
|
|
32
32
|
`select w.id, w.name, w.message, w.banner_label, w.banner_color, w.isolated, w.sql_workshop, w.rest_services,
|
|
33
33
|
coalesce((select json_agg(json_build_object('label', l.label, 'url', l.url, 'description', l.description) order by l.position, lower(l.label))
|
|
34
34
|
from meta.workspace_link l where l.workspace_id = w.id), '[]') as links,
|
|
35
35
|
exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
|
|
36
|
-
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin
|
|
36
|
+
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1 and m.is_admin) as admin,
|
|
37
|
+
exists (select 1 from meta.developer d where d.username = $1 and d.is_admin) as instance_admin
|
|
37
38
|
from meta.workspace w
|
|
38
39
|
where exists (select 1 from meta.developer d where d.username = $1 and d.is_admin)
|
|
39
40
|
or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $1)
|
|
@@ -42,6 +43,7 @@ export async function loadWorkspaces(s: Session) {
|
|
|
42
43
|
)
|
|
43
44
|
).rows;
|
|
44
45
|
s.workspaces = rows;
|
|
46
|
+
s.instanceAdmin = !!rows[0]?.instance_admin;
|
|
45
47
|
const wanted = Number(s.state.__WS);
|
|
46
48
|
s.workspace = rows.find((w) => w.id === wanted) ?? rows[0] ?? null;
|
|
47
49
|
}
|
package/src/cli/replace.ts
CHANGED
|
@@ -72,7 +72,9 @@ export const KEPT = ['app_access', 'api_client', 'session', 'sso_pending', 'save
|
|
|
72
72
|
// (081) the mail service an application sends through, and its queued and sent mail
|
|
73
73
|
'app_mail_service', 'mail_queue',
|
|
74
74
|
// (086) users' preferences
|
|
75
|
-
'user_preference'
|
|
75
|
+
'user_preference',
|
|
76
|
+
// (113) AutoREST: the tables and views the application publishes (SQL Workshop › RESTful Services)
|
|
77
|
+
'rest_object'];
|
|
76
78
|
/** Children of pages (replaced with their page). */
|
|
77
79
|
const PAGE_CHILDREN = ['region', 'item', 'button', 'dynamic_action', 'validation', 'process', 'computation', 'branch'];
|
|
78
80
|
/** References into replaced tables from kept data, repointed below: "table.column". */
|