pgkiln 0.82.0 → 0.83.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/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
package/src/builder/scripts.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { runScript, splitScript, type ScriptRun, type StatementResult } from '..
|
|
|
8
8
|
import { clearCompletions } from './code-editor.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
10
10
|
import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
11
|
+
import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
|
|
11
12
|
|
|
12
13
|
// SQL Workshop → SQL Scripts: saved scripts (create, edit, upload, download,
|
|
13
14
|
// delete), run with a result per statement (stop at the first error or go on,
|
|
@@ -73,54 +74,55 @@ const when = (t: string | Date) => String(t instanceof Date ? t.toISOString() :
|
|
|
73
74
|
/** A safe download file name for a script name. */
|
|
74
75
|
export const scriptFileName = (name: string) => `${name.replace(/\.sql$/i, '').replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '').slice(0, 100) || 'script'}.sql`;
|
|
75
76
|
|
|
76
|
-
function statusTag(r: Pick<StatementResult, 'status'>) {
|
|
77
|
+
function statusTag(tx: BuilderTranslate, r: Pick<StatementResult, 'status'>) {
|
|
77
78
|
const [cls, label] =
|
|
78
|
-
r.status === 'ok' ? ['tag tag-ok', 'OK'] : r.status === 'error' ? ['tag tag-error', '
|
|
79
|
+
r.status === 'ok' ? ['tag tag-ok', 'OK'] : r.status === 'error' ? ['tag tag-error', tx('scr.error')] : r.status === 'skipped' ? ['tag tag-warning', tx('scr.skipped')] : ['tag', tx('scr.not_run')];
|
|
79
80
|
return html`<span class="${cls}">${label}</span>`;
|
|
80
81
|
}
|
|
81
82
|
|
|
82
|
-
function runSummary(run: { statements: number; succeeded: number; failed: number; rolled_back?: boolean; transactional?: boolean; stop_on_error?: boolean }) {
|
|
83
|
+
function runSummary(tx: BuilderTranslate, run: { statements: number; succeeded: number; failed: number; rolled_back?: boolean; transactional?: boolean; stop_on_error?: boolean }) {
|
|
83
84
|
const ok = run.failed === 0;
|
|
85
|
+
const rest = run.statements - run.succeeded - run.failed;
|
|
84
86
|
return html`<div class="alert ${ok ? 'alert-success' : 'alert-error'}" role="${ok ? 'status' : 'alert'}">
|
|
85
|
-
${
|
|
86
|
-
${run.rolled_back ? html` <strong
|
|
87
|
+
${rest > 0 ? tx('scr.summary_rest', { n: run.statements, ok: run.succeeded, failed: run.failed, rest }) : tx('scr.summary', { n: run.statements, ok: run.succeeded, failed: run.failed })}
|
|
88
|
+
${run.rolled_back ? html` <strong>${tx('scr.rolled_back_note')}</strong>` : ''}</div>`;
|
|
87
89
|
}
|
|
88
90
|
|
|
89
|
-
export function resultsHtml(results: StatementResult[]) {
|
|
90
|
-
if (!results.length) return html`<p class="muted"
|
|
91
|
+
export function resultsHtml(results: StatementResult[], tx: BuilderTranslate = translatorFor('en')) {
|
|
92
|
+
if (!results.length) return html`<p class="muted">${tx('scr.no_statements')}</p>`;
|
|
91
93
|
return html`<ol class="script-results">${results.map(
|
|
92
94
|
(r) => html`<li class="script-result">
|
|
93
|
-
<div class="script-result-head">${statusTag(r)} <strong>#${r.n || '-'}</strong>
|
|
94
|
-
<span class="muted small">${r.line ?
|
|
95
|
-
<details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="
|
|
95
|
+
<div class="script-result-head">${statusTag(tx, r)} <strong>#${r.n || '-'}</strong>
|
|
96
|
+
<span class="muted small">${r.line ? tx('scr.line', { line: r.line }) : ''}${r.status === 'ok' ? ` · ${r.command ?? tx('scr.done')}${r.rows !== null && r.rows !== undefined ? `, ${tx('scr.rows', { n: r.rows })}` : ''}` : ''}${r.status === 'ok' || r.status === 'error' ? ` · ${r.ms} ms` : ''}</span></div>
|
|
97
|
+
<details class="script-sql"><summary><code>${r.sql.length > 120 ? `${r.sql.slice(0, 120).replace(/\s+/g, ' ')}…` : r.sql.replace(/\s+/g, ' ')}</code></summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('sm.statement')}">${r.sql}</pre></details>
|
|
96
98
|
${r.error ? html`<div class="alert ${r.status === 'error' ? 'alert-error' : 'alert-note'}">${r.error}</div>` : ''}
|
|
97
99
|
${r.columns?.length
|
|
98
100
|
? html`<div class="table-wrap"><table class="report"><thead><tr>${r.columns.map((c) => html`<th>${c}</th>`)}</tr></thead>
|
|
99
101
|
<tbody>${(r.sample ?? []).map((row) => html`<tr>${row.map((v) => html`<td>${v === null ? html`<span class="null">null</span>` : v}</td>`)}</tr>`)}</tbody></table></div>
|
|
100
|
-
${r.rows && r.sample && r.rows > r.sample.length ? html`<p class="muted small"
|
|
102
|
+
${r.rows && r.sample && r.rows > r.sample.length ? html`<p class="muted small">${tx('scr.first_rows', { shown: r.sample.length, n: r.rows })}</p>` : ''}`
|
|
101
103
|
: ''}
|
|
102
104
|
</li>`,
|
|
103
105
|
)}</ol>`;
|
|
104
106
|
}
|
|
105
107
|
|
|
106
108
|
/** Run options (radio + checkbox), shared by the script editor and Quick SQL. */
|
|
107
|
-
export const runOptions = (b: Body = {}) => html`<fieldset class="field"><legend class="label"
|
|
108
|
-
<label class="check"><input type="radio" name="on_error" value="stop"${b.on_error !== 'continue' ? raw(' checked') : ''}>
|
|
109
|
-
<label class="check"><input type="radio" name="on_error" value="continue"${b.on_error === 'continue' ? raw(' checked') : ''}>
|
|
110
|
-
<div class="field"><label class="check"><input type="checkbox" name="transaction" value="true"${b.transaction === 'true' ? raw(' checked') : ''}>
|
|
109
|
+
export const runOptions = (b: Body = {}, tx: BuilderTranslate = translatorFor('en')) => html`<fieldset class="field"><legend class="label">${tx('scr.when_fails')}</legend><div class="radio-group">
|
|
110
|
+
<label class="check"><input type="radio" name="on_error" value="stop"${b.on_error !== 'continue' ? raw(' checked') : ''}> ${tx('scr.stop')}</label>
|
|
111
|
+
<label class="check"><input type="radio" name="on_error" value="continue"${b.on_error === 'continue' ? raw(' checked') : ''}> ${tx('scr.continue')}</label></div></fieldset>
|
|
112
|
+
<div class="field"><label class="check"><input type="checkbox" name="transaction" value="true"${b.transaction === 'true' ? raw(' checked') : ''}> ${tx('scr.one_tx')}</label></div>`;
|
|
111
113
|
|
|
112
114
|
export async function scriptRoutes(app: FastifyInstance) {
|
|
113
115
|
const page = (s: Session, reply: FastifyReply, title: string, crumbs: [string, string?][], main: Raw) =>
|
|
114
|
-
send(reply, s, shell(s, title, [['
|
|
116
|
+
send(reply, s, shell(s, title, [[tr(s)('rail.sql_workshop'), `${BASE}/sql`], ...crumbs], html`<h1 class="u-mb1">${tr(s)('rail.sql_workshop')}</h1>${workshopTabs('scripts', s)}${main}`, 'sql'));
|
|
115
117
|
|
|
116
|
-
const historyTable = (runs: any[], withName: boolean) =>
|
|
118
|
+
const historyTable = (tx: BuilderTranslate, runs: any[], withName: boolean) =>
|
|
117
119
|
runs.length
|
|
118
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th
|
|
120
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('scr.col.started')}</th>${withName ? html`<th>${tx('scr.col.script')}</th>` : ''}<th>${tx('scr.col.by')}</th><th class="num">${tx('scr.col.statements')}</th><th class="num">${tx('scr.col.failed')}</th><th class="num">ms</th><th>${tx('scr.col.result')}</th></tr></thead><tbody>
|
|
119
121
|
${runs.map((r) => html`<tr><td><a href="${BASE}/sql/scripts/runs/${r.id}">${when(r.started_at)}</a></td>${withName ? html`<td>${r.script_name}</td>` : ''}<td>${r.run_by}</td>
|
|
120
122
|
<td class="num">${r.statements}</td><td class="num">${r.failed}</td><td class="num">${r.elapsed_ms}</td>
|
|
121
|
-
<td>${r.failed ? html`<span class="tag tag-error">${r.rolled_back ? '
|
|
123
|
+
<td>${r.failed ? html`<span class="tag tag-error">${r.rolled_back ? tx('scr.rolled_back') : tx('scr.errors')}</span>` : html`<span class="tag tag-ok">OK</span>`}</td></tr>`)}
|
|
122
124
|
</tbody></table></div>`
|
|
123
|
-
: html`<p class="muted"
|
|
125
|
+
: html`<p class="muted">${tx('scr.no_runs')}</p>`;
|
|
124
126
|
|
|
125
127
|
// ------------------------------------------------------------ list
|
|
126
128
|
app.get(`${BASE}/sql/scripts`, async (req: Req, reply) => {
|
|
@@ -138,29 +140,30 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
138
140
|
)
|
|
139
141
|
).rows;
|
|
140
142
|
const runs = (await owner.query('select * from meta.sql_script_run where workspace_id = $1 order by id desc limit 20', [currentWorkspace(s)])).rows;
|
|
143
|
+
const tx = tr(s);
|
|
141
144
|
const list = scripts.length
|
|
142
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th
|
|
145
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('scr.col.script')}</th><th>${tx('scr.col.updated')}</th><th class="num">${tx('scr.col.size')}</th><th>${tx('scr.col.last_run')}</th><th>${tx('scr.col.open')}</th></tr></thead><tbody>
|
|
143
146
|
${scripts.map((x) => html`<tr><td><a href="${BASE}/sql/scripts/${x.id}">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
|
|
144
|
-
<td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small"
|
|
145
|
-
<td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error"
|
|
146
|
-
<td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')}
|
|
147
|
+
<td>${when(x.updated_at)}${x.updated_by ? html` <span class="muted small">${tx('scr.by', { user: x.updated_by })}</span>` : ''}</td><td class="num">${bytes(Number(x.size))}</td>
|
|
148
|
+
<td>${x.last_run ? html`${when(x.last_run)} ${x.last_failed ? html`<span class="tag tag-error">${tx('scr.errors')}</span>` : html`<span class="tag tag-ok">OK</span>`}` : html`<span class="muted">${tx('scr.never')}</span>`}</td>
|
|
149
|
+
<td><a class="btn btn-sm" href="${BASE}/sql/worksheet?script=${x.id}">${icon('code')} ${tx('scr.worksheet')}</a> <a class="btn btn-sm" href="${BASE}/sql/scripts/${x.id}/download">${icon('download')} ${tx('scr.download')}</a></td></tr>`)}
|
|
147
150
|
</tbody></table></div>`
|
|
148
|
-
: html`<p class="muted"
|
|
151
|
+
: html`<p class="muted">${tx('scr.none_yet')}</p>`;
|
|
149
152
|
return page(
|
|
150
153
|
s,
|
|
151
154
|
reply,
|
|
152
|
-
'
|
|
153
|
-
[['
|
|
154
|
-
html`<div class="buttons u-mb1"><a class="btn btn-hot" href="${BASE}/sql/scripts/new">${icon('plus')}
|
|
155
|
-
<a class="btn" href="${BASE}/sql/quick">${icon('bolt')}
|
|
156
|
-
${region('
|
|
155
|
+
tx('sql.tool.scripts'),
|
|
156
|
+
[[tx('sql.tool.scripts')]],
|
|
157
|
+
html`<div class="buttons u-mb1"><a class="btn btn-hot" href="${BASE}/sql/scripts/new">${icon('plus')} ${tx('scr.create')}</a>
|
|
158
|
+
<a class="btn" href="${BASE}/sql/quick">${icon('bolt')} ${tx('sql.tool.quicksql')}</a></div>
|
|
159
|
+
${region(tx('scr.scripts'), list, html`<span class="count">${scripts.length}</span>`)}
|
|
157
160
|
<div class="u-spacer"></div>
|
|
158
|
-
${region('
|
|
159
|
-
<div class="form-grid"><div class="field"><label class="label" for="f_script_file"
|
|
161
|
+
${region(tx('scr.upload_title'), html`<form method="post" action="${BASE}/sql/scripts/upload" enctype="multipart/form-data">${csrf(s)}
|
|
162
|
+
<div class="form-grid"><div class="field"><label class="label" for="f_script_file">${tx('scr.file', { mb: SCRIPT_MAX_MB })}</label>
|
|
160
163
|
<input type="file" id="f_script_file" name="file" accept=".sql,.txt,text/plain,application/sql" required></div></div>
|
|
161
|
-
<div class="buttons"><button class="btn">${icon('upload')}
|
|
164
|
+
<div class="buttons"><button class="btn">${icon('upload')} ${tx('scr.upload')}</button></div></form>`)}
|
|
162
165
|
<div class="u-spacer"></div>
|
|
163
|
-
${region('
|
|
166
|
+
${region(tx('scr.recent_runs'), historyTable(tx, runs, true))}`,
|
|
164
167
|
);
|
|
165
168
|
});
|
|
166
169
|
|
|
@@ -170,30 +173,31 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
170
173
|
const action = isNew ? `${BASE}/sql/scripts` : `${BASE}/sql/scripts/${row.id}`;
|
|
171
174
|
const runs = isNew ? [] : (await owner.query('select * from meta.sql_script_run where script_id = $1 order by id desc limit 20', [row.id])).rows;
|
|
172
175
|
const statements = row.content ? splitScript(row.content).length : 0;
|
|
176
|
+
const tx = tr(s);
|
|
173
177
|
const form = html`<form method="post" action="${action}">${csrf(s)}
|
|
174
178
|
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
175
179
|
<div class="form-grid">
|
|
176
|
-
<div class="field"><label class="label" for="f_name"
|
|
177
|
-
<div class="field"><label class="label" for="f_description"
|
|
180
|
+
<div class="field"><label class="label" for="f_name">${tx('scr.name')}</label><input id="f_name" name="name" value="${row.name ?? ''}" required maxlength="200"></div>
|
|
181
|
+
<div class="field"><label class="label" for="f_description">${tx('scr.description')}</label><input id="f_description" name="description" value="${row.description ?? ''}"></div>
|
|
178
182
|
</div>
|
|
179
|
-
<div class="field"><label class="label" for="f_content"
|
|
183
|
+
<div class="field"><label class="label" for="f_content">${tx('scr.col.script')}</label>
|
|
180
184
|
<textarea id="f_content" name="content" class="code sql-editor" rows="18" spellcheck="false" data-code="plpgsql">${row.content ?? ''}</textarea>
|
|
181
|
-
<small class="help"
|
|
182
|
-
${runOptions(req.body ?? {})}
|
|
183
|
-
<div class="buttons"><button class="btn" name="action" value="save">${icon('check')}
|
|
184
|
-
<button class="btn btn-hot" name="action" value="run">${icon('play')}
|
|
185
|
+
<small class="help">${tx('scr.content_help')}${statements ? ` ${tx('scr.statements', { n: statements })}` : ''}</small></div>
|
|
186
|
+
${runOptions(req.body ?? {}, tx)}
|
|
187
|
+
<div class="buttons"><button class="btn" name="action" value="save">${icon('check')} ${tx('scr.save')}</button>
|
|
188
|
+
<button class="btn btn-hot" name="action" value="run">${icon('play')} ${tx('scr.save_run')}</button></div>
|
|
185
189
|
</form>`;
|
|
186
190
|
const extras = isNew
|
|
187
191
|
? ''
|
|
188
|
-
: html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${row.id}/download">${icon('download')}
|
|
189
|
-
<form method="post" action="${BASE}/sql/scripts/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="
|
|
192
|
+
: html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${row.id}/download">${icon('download')} ${tx('scr.download')}</a></div>
|
|
193
|
+
<form method="post" action="${BASE}/sql/scripts/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('scr.delete_confirm')}">${tx('scr.delete')}</button></form>`;
|
|
190
194
|
return page(
|
|
191
195
|
s,
|
|
192
196
|
reply,
|
|
193
|
-
isNew ? '
|
|
194
|
-
[['
|
|
195
|
-
html`${region(isNew ? '
|
|
196
|
-
${isNew ? '' : html`<div class="u-spacer"></div>${region('
|
|
197
|
+
isNew ? tx('scr.new') : row.name,
|
|
198
|
+
[[tx('sql.tool.scripts'), `${BASE}/sql/scripts`], [isNew ? tx('scr.new') : row.name]],
|
|
199
|
+
html`${region(isNew ? tx('scr.new') : tx('scr.script_title', { name: row.name }), html`${form}${extras}`)}
|
|
200
|
+
${isNew ? '' : html`<div class="u-spacer"></div>${region(tx('scr.runs'), historyTable(tx, runs, false))}`}`,
|
|
197
201
|
);
|
|
198
202
|
};
|
|
199
203
|
|
|
@@ -223,11 +227,11 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
223
227
|
const row = { id: id ? Number(id) : undefined, name, description: b.description ?? '', content };
|
|
224
228
|
if (!name || name.length > 200) {
|
|
225
229
|
reply.code(422);
|
|
226
|
-
return editor(req, reply, s, row,
|
|
230
|
+
return editor(req, reply, s, row, tr(s)('scr.err.name'));
|
|
227
231
|
}
|
|
228
232
|
if (Buffer.byteLength(content) > SCRIPT_MAX_MB * 1024 * 1024) {
|
|
229
233
|
reply.code(422);
|
|
230
|
-
return editor(req, reply, s, row,
|
|
234
|
+
return editor(req, reply, s, row, tr(s)('scr.err.size', { mb: SCRIPT_MAX_MB }));
|
|
231
235
|
}
|
|
232
236
|
let scriptId: number;
|
|
233
237
|
try {
|
|
@@ -248,7 +252,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
248
252
|
} catch (e) {
|
|
249
253
|
if ((e as { code?: string }).code === '23505') {
|
|
250
254
|
reply.code(422);
|
|
251
|
-
return editor(req, reply, s, row,
|
|
255
|
+
return editor(req, reply, s, row, tr(s)('scr.err.exists', { name }));
|
|
252
256
|
}
|
|
253
257
|
throw e;
|
|
254
258
|
}
|
|
@@ -262,7 +266,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
262
266
|
});
|
|
263
267
|
return back(reply, s, `${BASE}/sql/scripts/runs/${runId}`);
|
|
264
268
|
}
|
|
265
|
-
flash(s,
|
|
269
|
+
flash(s, tr(s)('scr.saved', { name }));
|
|
266
270
|
return back(reply, s, `${BASE}/sql/scripts/${scriptId}`);
|
|
267
271
|
};
|
|
268
272
|
app.post(`${BASE}/sql/scripts`, save);
|
|
@@ -278,7 +282,7 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
278
282
|
await c.query('delete from meta.sql_script_run where script_id = $1', [req.params.id]);
|
|
279
283
|
return (await c.query('delete from meta.sql_script where id = $1 returning name', [req.params.id])).rows[0];
|
|
280
284
|
});
|
|
281
|
-
if (r) flash(s,
|
|
285
|
+
if (r) flash(s, tr(s)('scr.deleted', { name: r.name }));
|
|
282
286
|
return back(reply, s, `${BASE}/sql/scripts`);
|
|
283
287
|
});
|
|
284
288
|
|
|
@@ -306,20 +310,21 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
306
310
|
flash(s, message, 'error');
|
|
307
311
|
return back(reply, s, `${BASE}/sql/scripts`);
|
|
308
312
|
};
|
|
309
|
-
|
|
310
|
-
if (file
|
|
313
|
+
const tx = tr(s);
|
|
314
|
+
if (!file) return fail(tx('scr.err.choose'));
|
|
315
|
+
if (file.truncated) return fail(tx('scr.err.file_size', { mb: SCRIPT_MAX_MB }));
|
|
311
316
|
let content: string;
|
|
312
317
|
try {
|
|
313
318
|
content = new TextDecoder('utf-8', { fatal: true }).decode(file.data).replace(/^/, '').replace(/\r\n/g, '\n');
|
|
314
319
|
} catch {
|
|
315
|
-
return fail('
|
|
320
|
+
return fail(tx('scr.err.utf8'));
|
|
316
321
|
}
|
|
317
|
-
if (content.includes('\0')) return fail('
|
|
322
|
+
if (content.includes('\0')) return fail(tx('scr.err.text'));
|
|
318
323
|
const base = file.filename.replace(/^.*[\\/]/, '').replace(/\.sql$/i, '').slice(0, 180) || 'script';
|
|
319
324
|
let name = base;
|
|
320
325
|
for (let n = 2; await owner.one('select 1 from meta.sql_script where name = $1 and workspace_id = $2', [name, currentWorkspace(s)]); n++) name = `${base} (${n})`;
|
|
321
326
|
const r = await owner.one(`insert into meta.sql_script (name, content, created_by, updated_by, workspace_id) values ($1, $2, $3, $3, $4) returning id`, [name, content, s.username, currentWorkspace(s)]);
|
|
322
|
-
flash(s,
|
|
327
|
+
flash(s, tx('scr.uploaded', { name }));
|
|
323
328
|
return back(reply, s, `${BASE}/sql/scripts/${r!.id}`);
|
|
324
329
|
});
|
|
325
330
|
|
|
@@ -330,18 +335,19 @@ export async function scriptRoutes(app: FastifyInstance) {
|
|
|
330
335
|
const run = /^\d+$/.test(req.params.run) ? await owner.one('select * from meta.sql_script_run where id = $1 and workspace_id = $2', [req.params.run, currentWorkspace(s)]) : undefined;
|
|
331
336
|
if (!run) return reply.code(404).send('Not found');
|
|
332
337
|
const exists = run.script_id ? await owner.one('select id from meta.sql_script where id = $1', [run.script_id]) : undefined;
|
|
333
|
-
const
|
|
338
|
+
const tx = tr(s);
|
|
339
|
+
const options = `${run.stop_on_error ? tx('scr.opt.stop') : tx('scr.opt.continue')}${run.transactional ? `, ${tx('scr.opt.one_tx')}` : ''}`;
|
|
334
340
|
return page(
|
|
335
341
|
s,
|
|
336
342
|
reply,
|
|
337
|
-
'
|
|
338
|
-
[['
|
|
343
|
+
tx('scr.results_title'),
|
|
344
|
+
[[tx('sql.tool.scripts'), `${BASE}/sql/scripts`], ...(exists ? [[run.script_name, `${BASE}/sql/scripts/${run.script_id}`] as [string, string]] : []), [tx('scr.results')]],
|
|
339
345
|
region(
|
|
340
|
-
|
|
341
|
-
html`<p class="muted"
|
|
342
|
-
${runSummary(run)}
|
|
343
|
-
${resultsHtml(run.results)}
|
|
344
|
-
${exists ? html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${run.script_id}">${icon('edit')}
|
|
346
|
+
tx('scr.results_of', { name: run.script_name }),
|
|
347
|
+
html`<p class="muted">${tx('scr.run_by', { user: run.run_by, time: when(run.started_at), ms: run.elapsed_ms, options })}</p>
|
|
348
|
+
${runSummary(tx, run)}
|
|
349
|
+
${resultsHtml(run.results, tx)}
|
|
350
|
+
${exists ? html`<div class="buttons u-mt1"><a class="btn" href="${BASE}/sql/scripts/${run.script_id}">${icon('edit')} ${tx('scr.edit')}</a></div>` : ''}`,
|
|
345
351
|
),
|
|
346
352
|
);
|
|
347
353
|
});
|
|
@@ -10,6 +10,7 @@ import type { Session } from '../session.ts';
|
|
|
10
10
|
import { unloadStatement } from '../unload.ts';
|
|
11
11
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
12
12
|
import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
13
|
+
import { tr, translatorFor, type BuilderKey, type BuilderTranslate } from './i18n.ts';
|
|
13
14
|
|
|
14
15
|
// (sprint 97, Database Actions DA-6: Create Chart and Charts) A query drawn as a chart with pgkiln's own
|
|
15
16
|
// server-rendered charts (src/runtime/charts.ts): the worksheet's Chart tab draws the last query, guessing the label
|
|
@@ -17,7 +18,7 @@ import { currentWorkspace, sessionSchema, workshopDb } from './workspaces.ts';
|
|
|
17
18
|
// migration 110) are listed under SQL Workshop → Charts, the ones on the dashboard drawn there. A saved chart's
|
|
18
19
|
// SELECT runs read-only, with a statement timeout and at most 500 rows, as the workshop's connection.
|
|
19
20
|
|
|
20
|
-
export const SQL_CHART_KINDS: [ChartKind,
|
|
21
|
+
export const SQL_CHART_KINDS: [ChartKind, BuilderKey][] = [['bar', 'ch.kind.bar'], ['column', 'ch.kind.column'], ['stacked', 'ch.kind.stacked'], ['line', 'ch.kind.line'], ['area', 'ch.kind.area'], ['donut', 'ch.kind.donut'], ['pie', 'ch.kind.pie'], ['scatter', 'ch.kind.scatter']];
|
|
21
22
|
const MAX_ROWS = 500;
|
|
22
23
|
|
|
23
24
|
export interface ChartSpec { kind: ChartKind; label: string; values: string[] }
|
|
@@ -42,11 +43,11 @@ export function drawChart(title: string, columns: string[], rows: unknown[][], s
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** The controls of a chart (type, label column, value columns), named chart_*. */
|
|
45
|
-
export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch'): Raw {
|
|
46
|
+
export function chartControls(columns: string[], spec: ChartSpec, idPrefix = 'ch', tx: BuilderTranslate = translatorFor('en')): Raw {
|
|
46
47
|
return html`<div class="ch-controls">
|
|
47
|
-
<label><span
|
|
48
|
-
<label><span
|
|
49
|
-
<fieldset><legend
|
|
48
|
+
<label><span>${tx('ch.type')}</span><select name="chart_kind" id="${idPrefix}-kind">${SQL_CHART_KINDS.map(([k, label]) => html`<option value="${k}"${k === spec.kind ? raw(' selected') : ''}>${tx(label)}</option>`)}</select></label>
|
|
49
|
+
<label><span>${tx('ch.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>
|
|
50
|
+
<fieldset><legend>${tx('ch.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
51
|
</div>`;
|
|
51
52
|
}
|
|
52
53
|
|
|
@@ -73,12 +74,13 @@ async function runChart(s: Session, sql: string) {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
async function chartCard(s: Session, ch: SavedChart, full = false): Promise<{ body: Raw; css: string }> {
|
|
77
|
+
const tx = tr(s);
|
|
76
78
|
try {
|
|
77
79
|
const r = await runChart(s, ch.sql);
|
|
78
80
|
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"
|
|
81
|
+
if (!spec) return { body: html`<p class="muted">${tx('ch.no_number')}</p>`, css: '' };
|
|
80
82
|
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
|
|
83
|
+
return { body: html`${d.body}${full ? html`<p class="muted small">${r.rows.length === MAX_ROWS ? tx('ch.rows_first', { n: MAX_ROWS }) : r.rows.length === 1 ? tx('ch.row_one') : tx('ch.rows', { n: r.rows.length })}</p>` : ''}`, css: d.css };
|
|
82
84
|
} catch (e) {
|
|
83
85
|
return { body: html`<p class="alert alert-error">${(e as Error).message}</p>`, css: '' };
|
|
84
86
|
}
|
|
@@ -92,6 +94,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
92
94
|
const s = await developer(req, reply);
|
|
93
95
|
if (!s) return;
|
|
94
96
|
const charts = (await owner.query<SavedChart>('select * from meta.sql_chart where workspace_id = $1 order by lower(name)', [currentWorkspace(s)])).rows;
|
|
97
|
+
const tx = tr(s);
|
|
95
98
|
let css = '';
|
|
96
99
|
const cards: Raw[] = [];
|
|
97
100
|
for (const ch of charts.filter((c) => c.on_dashboard)) {
|
|
@@ -99,14 +102,15 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
99
102
|
css += `\n${card.css}`;
|
|
100
103
|
cards.push(html`<section class="ch-card"><h2><a href="${BASE}/sql/charts/${ch.id}">${ch.name}</a></h2>${card.body}</section>`);
|
|
101
104
|
}
|
|
102
|
-
const
|
|
103
|
-
|
|
105
|
+
const kindLabel = (k: string) => { const x = SQL_CHART_KINDS.find(([kk]) => kk === k); return x ? tx(x[1]) : k; };
|
|
106
|
+
const main = html`<h1 class="u-mb1">${tx('sql.tool.charts')}</h1>${workshopTabs('charts', s)}${style(req.cspNonce, css)}
|
|
107
|
+
<p class="muted">${tx.rich('ch.intro', { worksheet: html`<a href="${BASE}/sql/worksheet">${tx('sql.tool.worksheet')}</a>` })}</p>
|
|
104
108
|
${cards.length ? html`<div class="ch-dashboard">${cards}</div>` : ''}
|
|
105
|
-
${region('
|
|
106
|
-
${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${
|
|
107
|
-
<td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small"
|
|
108
|
-
: html`<p class="muted"
|
|
109
|
-
return send(reply, s, shell(s, '
|
|
109
|
+
${region(tx('ch.all'), charts.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('ch.chart')}</th><th>${tx('ch.type')}</th><th>${tx('ch.on_dashboard_col')}</th><th>${tx('ch.changed')}</th></tr></thead><tbody>
|
|
110
|
+
${charts.map((c) => html`<tr><td><a href="${BASE}/sql/charts/${c.id}">${c.name}</a></td><td>${kindLabel(c.kind)}</td><td>${c.on_dashboard ? tx('ch.yes') : tx('ch.no')}</td>
|
|
111
|
+
<td>${new Date(c.updated_at).toISOString().slice(0, 16).replace('T', ' ')}${c.created_by ? html` <span class="muted small">${tx('scr.by', { user: c.created_by })}</span>` : ''}</td></tr>`)}</tbody></table></div>`
|
|
112
|
+
: html`<p class="muted">${tx('ch.none_yet')}</p>`)}`;
|
|
113
|
+
return send(reply, s, shell(s, tx('sql.tool.charts'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.charts')]], main, 'sql'));
|
|
110
114
|
});
|
|
111
115
|
|
|
112
116
|
// one chart: drawn, with its settings
|
|
@@ -116,26 +120,27 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
116
120
|
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
121
|
if (!ch) return reply.code(404).send('Not found');
|
|
118
122
|
const card = await chartCard(s, ch, true);
|
|
123
|
+
const tx = tr(s);
|
|
119
124
|
let controls: Raw | '' = '';
|
|
120
125
|
try {
|
|
121
126
|
const r = await runChart(s, ch.sql);
|
|
122
127
|
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');
|
|
128
|
+
if (spec) controls = chartControls(r.columns, spec, 'sch', tx);
|
|
124
129
|
} catch {
|
|
125
130
|
controls = '';
|
|
126
131
|
}
|
|
127
132
|
const main = html`<h1 class="u-mb1">${ch.name}</h1>${workshopTabs('charts', s)}${style(req.cspNonce, card.css)}
|
|
128
133
|
<div class="ch-view">${card.body}</div>
|
|
129
134
|
<form method="post" action="${BASE}/sql/charts/${ch.id}" class="ch-edit">${csrf(s)}
|
|
130
|
-
<div class="field"><label class="label" for="ch-name"
|
|
135
|
+
<div class="field"><label class="label" for="ch-name">${tx('scr.name')}</label><input id="ch-name" name="name" value="${ch.name}" maxlength="120" required></div>
|
|
131
136
|
${controls}
|
|
132
|
-
<label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}>
|
|
133
|
-
<details class="u-mt1"><summary
|
|
134
|
-
<div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')}
|
|
135
|
-
<button class="btn" name="action" value="worksheet">${icon('code')}
|
|
136
|
-
<button class="btn btn-danger" name="action" value="delete" data-confirm="
|
|
137
|
+
<label class="check"><input type="checkbox" name="on_dashboard"${ch.on_dashboard ? raw(' checked') : ''}> ${tx('ch.on_dashboard')}</label>
|
|
138
|
+
<details class="u-mt1"><summary>${tx('ch.query')}</summary><pre class="source" data-code-view="sql" tabindex="0" aria-label="${tx('ch.query')}">${ch.sql}</pre></details>
|
|
139
|
+
<div class="buttons"><button class="btn btn-hot" name="action" value="save">${icon('save')} ${tx('scr.save')}</button>
|
|
140
|
+
<button class="btn" name="action" value="worksheet">${icon('code')} ${tx('ch.open_worksheet')}</button>
|
|
141
|
+
<button class="btn btn-danger" name="action" value="delete" data-confirm="${tx('ch.delete_confirm', { name: ch.name })}">${icon('trash')} ${tx('scr.delete')}</button></div>
|
|
137
142
|
</form>`;
|
|
138
|
-
return send(reply, s, shell(s, ch.name, [['
|
|
143
|
+
return send(reply, s, shell(s, ch.name, [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.charts'), `${BASE}/sql/charts`], [ch.name]], main, 'sql'));
|
|
139
144
|
});
|
|
140
145
|
|
|
141
146
|
// save a chart from the worksheet (a new one), or change, open or delete a saved one
|
|
@@ -144,6 +149,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
144
149
|
if (!s) return;
|
|
145
150
|
const b = req.body ?? {};
|
|
146
151
|
const ws = currentWorkspace(s);
|
|
152
|
+
const tx = tr(s);
|
|
147
153
|
const id = req.params?.id;
|
|
148
154
|
const name = String(b.name ?? b.chart_name ?? '').trim();
|
|
149
155
|
const asked = askedSpec(b);
|
|
@@ -153,7 +159,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
153
159
|
if (!ch) return reply.code(404).send('Not found');
|
|
154
160
|
if (b.action === 'delete') {
|
|
155
161
|
await owner.query('delete from meta.sql_chart where id = $1', [ch.id]);
|
|
156
|
-
flash(s,
|
|
162
|
+
flash(s, tx('ch.deleted', { name: ch.name }));
|
|
157
163
|
return back(reply, s, `${BASE}/sql/charts`);
|
|
158
164
|
}
|
|
159
165
|
if (b.action === 'worksheet') {
|
|
@@ -161,7 +167,7 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
161
167
|
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
162
168
|
}
|
|
163
169
|
if (!name || name.length > 120) {
|
|
164
|
-
flash(s, '
|
|
170
|
+
flash(s, tx('ch.err.name'), 'error');
|
|
165
171
|
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
166
172
|
}
|
|
167
173
|
try {
|
|
@@ -169,32 +175,32 @@ export async function sqlChartRoutes(app: FastifyInstance) {
|
|
|
169
175
|
[ch.id, name, kind, asked.label ?? ch.label_col, asked.values?.length ? asked.values : ch.value_cols, b.on_dashboard === 'on']);
|
|
170
176
|
} catch (e) {
|
|
171
177
|
if ((e as { code?: string }).code !== '23505') throw e;
|
|
172
|
-
flash(s,
|
|
178
|
+
flash(s, tx('ch.err.exists', { name }), 'error');
|
|
173
179
|
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
174
180
|
}
|
|
175
|
-
flash(s,
|
|
181
|
+
flash(s, tx('ch.saved', { name }));
|
|
176
182
|
return back(reply, s, `${BASE}/sql/charts/${ch.id}`);
|
|
177
183
|
}
|
|
178
184
|
let sql = '';
|
|
179
185
|
try {
|
|
180
186
|
sql = unloadStatement(String(b.chart_sql ?? ''));
|
|
181
187
|
} catch {
|
|
182
|
-
flash(s, '
|
|
188
|
+
flash(s, tx('ch.err.select'), 'error');
|
|
183
189
|
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
184
190
|
}
|
|
185
191
|
if (!name || name.length > 120) {
|
|
186
|
-
flash(s, '
|
|
192
|
+
flash(s, tx('ch.err.name_new'), 'error');
|
|
187
193
|
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
188
194
|
}
|
|
189
195
|
try {
|
|
190
196
|
const r = await owner.one<{ id: number }>(
|
|
191
197
|
`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
198
|
[ws, name, sql, kind, asked.label ?? '', asked.values ?? [], s.username]);
|
|
193
|
-
flash(s,
|
|
199
|
+
flash(s, tx('ch.saved_new', { name }));
|
|
194
200
|
return back(reply, s, `${BASE}/sql/charts/${r!.id}`);
|
|
195
201
|
} catch (e) {
|
|
196
202
|
if ((e as { code?: string }).code !== '23505') throw e;
|
|
197
|
-
flash(s,
|
|
203
|
+
flash(s, tx('ch.err.exists', { name }), 'error');
|
|
198
204
|
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
199
205
|
}
|
|
200
206
|
};
|