pgkiln 0.82.0 → 0.84.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. package/src/session.ts +13 -4
@@ -9,6 +9,7 @@ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs,
9
9
  import { isAdmin } from './locks.ts';
10
10
  import { planView } from './plan-view.ts';
11
11
  import { statementsNote, statementsState } from './top-sql.ts';
12
+ import { tr, type BuilderKey, type BuilderTranslate } from './i18n.ts';
12
13
 
13
14
  // (sprint 102, Database Actions DA-14) SQL Workshop › SQL Monitor, for administrators: Database Actions' Real Time
14
15
  // SQL Monitor and Performance Hub on PostgreSQL's statistics views.
@@ -37,7 +38,7 @@ async function readOnly<T>(fn: (q: (sql: string, params?: unknown[]) => Promise<
37
38
  });
38
39
  }
39
40
 
40
- const crumbs = (...more: [string, string?][]): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], ['SQL Monitor', more.length ? URL_BASE : undefined], ...more];
41
+ const crumbs = (tx: BuilderTranslate, ...more: [string, string?][]): [string, string?][] => [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.monitor'), more.length ? URL_BASE : undefined], ...more];
41
42
  const num = (v: unknown) => (v == null ? '' : Number(v).toLocaleString('en-US', { maximumFractionDigits: 2 }));
42
43
  const QUERYID_RE = /^-?\d{1,20}$/;
43
44
 
@@ -45,10 +46,10 @@ const QUERYID_RE = /^-?\d{1,20}$/;
45
46
  const bar = (value: number, max: number) =>
46
47
  html`<svg class="sm-bar" viewBox="0 0 100 8" preserveAspectRatio="none" aria-hidden="true" focusable="false"><rect class="sm-bar-track" width="100" height="8"></rect><rect class="sm-bar-fill" width="${max > 0 ? Math.max(1, Math.round((value / max) * 100)) : 0}" height="8"></rect></svg>`;
47
48
 
48
- const viewTabs = (active: 'sessions' | 'top') =>
49
- html`<nav class="seg u-mb1" aria-label="SQL Monitor views">
50
- <a href="${URL_BASE}"${active === 'sessions' ? raw(' aria-current="page"') : ''}>Sessions</a>
51
- <a href="${URL_BASE}/top"${active === 'top' ? raw(' aria-current="page"') : ''}>Top SQL</a></nav>`;
49
+ const viewTabs = (tx: BuilderTranslate, active: 'sessions' | 'top') =>
50
+ html`<nav class="seg u-mb1" aria-label="${tx('sm.views')}">
51
+ <a href="${URL_BASE}"${active === 'sessions' ? raw(' aria-current="page"') : ''}>${tx('sm.sessions')}</a>
52
+ <a href="${URL_BASE}/top"${active === 'top' ? raw(' aria-current="page"') : ''}>${tx('sql.tool.topsql')}</a></nav>`;
52
53
 
53
54
  // ------------------------------------------------------------------ sessions
54
55
 
@@ -104,57 +105,58 @@ async function liveData(showIdle: boolean) {
104
105
 
105
106
  /** The part of the Sessions view that public/sql-monitor.js reloads in place. */
106
107
  async function livePart(s: Session, showIdle: boolean): Promise<Raw> {
108
+ const tx = tr(s);
107
109
  const d = await liveData(showIdle);
108
110
  const t = d.totals as Record<string, number>;
109
111
  const backTo = `${URL_BASE}${showIdle ? '?idle=1' : ''}`;
110
112
  const action = (pid: unknown, kind: 'cancel' | 'terminate') => html`<form method="post" action="${URL_BASE}/session" class="sm-action">${csrf(s)}
111
113
  <input type="hidden" name="pid" value="${String(pid)}"><input type="hidden" name="action" value="${kind}"><input type="hidden" name="idle" value="${showIdle ? '1' : ''}">
112
- <button class="btn btn-sm${kind === 'terminate' ? ' btn-danger' : ''}" data-confirm="${kind === 'cancel' ? `Cancel the statement session ${pid} is running?` : `Terminate session ${pid}? Its open transaction is rolled back and the client is disconnected.`}"
113
- aria-label="${kind === 'cancel' ? 'Cancel the statement of' : 'Terminate'} session ${String(pid)}">${kind === 'cancel' ? 'Cancel' : 'Terminate'}</button></form>`;
114
+ <button class="btn btn-sm${kind === 'terminate' ? ' btn-danger' : ''}" data-confirm="${kind === 'cancel' ? tx('sm.cancel_confirm', { pid: String(pid) }) : tx('sm.terminate_confirm', { pid: String(pid) })}"
115
+ aria-label="${kind === 'cancel' ? tx('sm.cancel_label', { pid: String(pid) }) : tx('sm.terminate_label', { pid: String(pid) })}">${kind === 'cancel' ? tx('sm.cancel') : tx('sm.terminate')}</button></form>`;
114
116
  return html`<div class="stat-grid u-mb1">
115
- ${([['Sessions', `${t.sessions} / ${t.max}`], ['Active', t.active], ['Idle in a transaction', t.idle_tx], ['Blocked', t.blocked], ['Longest running (s)', t.longest]] as [string, unknown][])
117
+ ${([[tx('sm.sessions'), `${t.sessions} / ${t.max}`], [tx('sm.active'), t.active], [tx('sm.idle_tx'), t.idle_tx], [tx('sm.blocked'), t.blocked], [tx('sm.longest'), t.longest]] as [string, unknown][])
116
118
  .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
117
119
  </div>
118
- ${region('Sessions', html`<p class="muted u-mt0">The client sessions of this database (pg_stat_activity)${showIdle ? '' : ', without idle ones'}, active ones first.
119
- <a href="${showIdle ? URL_BASE : `${URL_BASE}?idle=1`}">${showIdle ? 'Hide idle sessions' : 'Show idle sessions'}</a>.
120
- A query's text is shown only for roles whose statistics the owner may read (<code>pg_read_all_stats</code>).</p>
120
+ ${region(tx('sm.sessions'), html`<p class="muted u-mt0">${showIdle ? tx('sm.sessions_intro_all') : tx('sm.sessions_intro')}
121
+ <a href="${showIdle ? URL_BASE : `${URL_BASE}?idle=1`}">${showIdle ? tx('sm.hide_idle') : tx('sm.show_idle')}</a>.
122
+ ${tx.rich('sm.query_text_note', { role: html`<code>pg_read_all_stats</code>` })}</p>
121
123
  <div class="table-wrap"><table class="report report-reflow sm-sessions">
122
- <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><th>Actions</th></tr></thead>
124
+ <thead><tr><th class="num">PID</th><th>${tx('sm.col.user')}</th><th>${tx('sm.col.application')}</th><th>${tx('sm.col.state')}</th><th>${tx('sm.col.wait')}</th><th class="num">${tx('sm.col.running')}</th><th>${tx('sm.col.blocked_by')}</th><th>${tx('sm.col.query')}</th><th>${tx('sm.col.actions')}</th></tr></thead>
123
125
  <tbody>${d.sessions.length ? d.sessions.map((x) => html`<tr${(x.blocked_by as number[]).length ? raw(' class="is-blocked"') : ''}>
124
126
  <td class="num" data-label="PID">${String(x.pid)}</td>
125
- <td data-label="User">${String(x.user ?? '')}</td>
126
- <td data-label="Application" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
127
- <td data-label="State">${x.idle_tx_s != null ? html`<span class="tag tag-warning">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
128
- <td data-label="Wait">${x.wait_type ? `${x.wait_type}: ${x.wait ?? ''}` : ''}</td>
129
- <td class="num" data-label="Running">${x.running_s != null ? String(x.running_s) : ''}</td>
130
- <td data-label="Blocked by">${(x.blocked_by as number[]).length ? html`<span class="tag tag-warning">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
131
- <td data-label="Query" class="u-break">${x.query ? x.queryid && x.queryid !== '0'
132
- ? html`<a href="${URL_BASE}/statements/${String(x.queryid)}" title="Statistics and plan of this statement"><code>${String(x.query).slice(0, 500)}</code></a>`
127
+ <td data-label="${tx('sm.col.user')}">${String(x.user ?? '')}</td>
128
+ <td data-label="${tx('sm.col.application')}" class="u-break">${String(x.app ?? '')}${x.client ? html` <span class="muted">${String(x.client)}</span>` : ''}</td>
129
+ <td data-label="${tx('sm.col.state')}">${x.idle_tx_s != null ? html`<span class="tag tag-warning">${String(x.state)} ${String(x.idle_tx_s)} s</span>` : String(x.state ?? '')}</td>
130
+ <td data-label="${tx('sm.col.wait')}">${x.wait_type ? `${x.wait_type}: ${x.wait ?? ''}` : ''}</td>
131
+ <td class="num" data-label="${tx('sm.col.running')}">${x.running_s != null ? String(x.running_s) : ''}</td>
132
+ <td data-label="${tx('sm.col.blocked_by')}">${(x.blocked_by as number[]).length ? html`<span class="tag tag-warning">${(x.blocked_by as number[]).join(', ')}</span>` : ''}</td>
133
+ <td data-label="${tx('sm.col.query')}" class="u-break">${x.query ? x.queryid && x.queryid !== '0'
134
+ ? html`<a href="${URL_BASE}/statements/${String(x.queryid)}" title="${tx('sm.statement_link')}"><code>${String(x.query).slice(0, 500)}</code></a>`
133
135
  : html`<code>${String(x.query).slice(0, 500)}</code>` : ''}</td>
134
- <td data-label="Actions"><div class="sm-actions">${x.state === 'active' ? action(x.pid, 'cancel') : ''}${action(x.pid, 'terminate')}</div></td></tr>`)
135
- : html`<tr><td colspan="9" class="empty">No ${showIdle ? 'other' : 'busy'} sessions.</td></tr>`}</tbody></table></div>`)}
136
- ${region('Wait events', html`<p class="muted u-mt0">What the active sessions of the server wait for now (<em>CPU: running</em> when they don't wait).</p>
137
- ${d.waits.length ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Type</th><th>Event</th><th class="num">Sessions</th></tr></thead>
138
- <tbody>${d.waits.map((w) => html`<tr><td data-label="Type">${String(w.type)}</td><td data-label="Event">${String(w.event)}</td><td class="num" data-label="Sessions">${String(w.sessions)}</td></tr>`)}</tbody></table></div>`
139
- : html`<p class="muted">No other active sessions.</p>`}`)}
140
- ${d.progress.length ? region('In progress', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">PID</th><th>Command</th><th>Object</th><th>Phase</th><th>Done</th></tr></thead>
141
- <tbody>${d.progress.map((p) => html`<tr><td class="num" data-label="PID">${String(p.pid)}</td><td data-label="Command">${String(p.command)}</td>
142
- <td data-label="Object" class="u-break">${String(p.relation ?? '')}</td><td data-label="Phase">${String(p.phase ?? '')}</td>
143
- <td data-label="Done">${p.pct != null ? html`<progress max="100" value="${String(p.pct)}" aria-label="${String(p.command)} ${String(p.pid)}"></progress> ${String(p.pct)}%` : ''}</td></tr>`)}</tbody></table></div>`) : ''}
144
- <p class="muted small sm-updated" data-sm-updated>Updated ${new Date().toISOString().slice(11, 19)} UTC · <a href="${backTo}">Refresh</a></p>`;
136
+ <td data-label="${tx('sm.col.actions')}"><div class="sm-actions">${x.state === 'active' ? action(x.pid, 'cancel') : ''}${action(x.pid, 'terminate')}</div></td></tr>`)
137
+ : html`<tr><td colspan="9" class="empty">${showIdle ? tx('sm.no_other') : tx('sm.no_busy')}</td></tr>`}</tbody></table></div>`)}
138
+ ${region(tx('sm.waits'), html`<p class="muted u-mt0">${tx.rich('sm.waits_intro', { cpu: html`<em>${tx('sm.cpu_running')}</em>` })}</p>
139
+ ${d.waits.length ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sm.col.type')}</th><th>${tx('sm.col.event')}</th><th class="num">${tx('sm.sessions')}</th></tr></thead>
140
+ <tbody>${d.waits.map((w) => html`<tr><td data-label="${tx('sm.col.type')}">${String(w.type)}</td><td data-label="${tx('sm.col.event')}">${String(w.event)}</td><td class="num" data-label="${tx('sm.sessions')}">${String(w.sessions)}</td></tr>`)}</tbody></table></div>`
141
+ : html`<p class="muted">${tx('sm.no_other_active')}</p>`}`)}
142
+ ${d.progress.length ? region(tx('sm.in_progress'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">PID</th><th>${tx('sm.col.command')}</th><th>${tx('sm.col.object')}</th><th>${tx('sm.col.phase')}</th><th>${tx('sm.col.done')}</th></tr></thead>
143
+ <tbody>${d.progress.map((p) => html`<tr><td class="num" data-label="PID">${String(p.pid)}</td><td data-label="${tx('sm.col.command')}">${String(p.command)}</td>
144
+ <td data-label="${tx('sm.col.object')}" class="u-break">${String(p.relation ?? '')}</td><td data-label="${tx('sm.col.phase')}">${String(p.phase ?? '')}</td>
145
+ <td data-label="${tx('sm.col.done')}">${p.pct != null ? html`<progress max="100" value="${String(p.pct)}" aria-label="${String(p.command)} ${String(p.pid)}"></progress> ${String(p.pct)}%` : ''}</td></tr>`)}</tbody></table></div>`) : ''}
146
+ <p class="muted small sm-updated" data-sm-updated>${tx('sm.updated', { time: new Date().toISOString().slice(11, 19) })} · <a href="${backTo}">${tx('sm.refresh')}</a></p>`;
145
147
  }
146
148
 
147
149
  // ------------------------------------------------------------------ Top SQL
148
150
 
149
- const SORTS: Record<string, { col: string; label: string; asc?: boolean }> = {
150
- role: { col: 'r.rolname', label: 'Role', asc: true },
151
- calls: { col: 's.calls', label: 'Calls' },
152
- total: { col: 's.total_exec_time', label: 'Total ms' },
153
- mean: { col: 's.mean_exec_time', label: 'Mean ms' },
154
- rows: { col: 's.rows', label: 'Rows' },
155
- hit: { col: 's.shared_blks_hit', label: 'Blocks hit' },
156
- read: { col: 's.shared_blks_read', label: 'Blocks read' },
157
- query: { col: 's.query', label: 'Statement', asc: true },
151
+ const SORTS: Record<string, { col: string; label: BuilderKey; asc?: boolean }> = {
152
+ role: { col: 'r.rolname', label: 'sm.col.role', asc: true },
153
+ calls: { col: 's.calls', label: 'sm.col.calls' },
154
+ total: { col: 's.total_exec_time', label: 'sm.col.total_ms' },
155
+ mean: { col: 's.mean_exec_time', label: 'sm.col.mean_ms' },
156
+ rows: { col: 's.rows', label: 'sm.col.rows' },
157
+ hit: { col: 's.shared_blks_hit', label: 'sm.col.hit' },
158
+ read: { col: 's.shared_blks_read', label: 'sm.col.read' },
159
+ query: { col: 's.query', label: 'sm.col.statement', asc: true },
158
160
  };
159
161
 
160
162
  export interface TopRow { queryid: string | null; role: string | null; calls: number; total_ms: number; mean_ms: number; rows: number; hit: number; read: number; query: string }
@@ -176,7 +178,7 @@ export async function topStatements(sort: string, desc: boolean, filter: string,
176
178
 
177
179
  const EXPLAINABLE = /^(select|with|insert|update|delete|merge|values|table)\b/i;
178
180
 
179
- export type MonitorPlan = { ok: true; plan: Plan; text: string; generic: boolean } | { ok: false; error: string };
181
+ export type MonitorPlan = { ok: true; plan: Plan; text: string; generic: boolean } | { ok: false; error: string; key?: BuilderKey };
180
182
 
181
183
  /**
182
184
  * EXPLAIN (never ANALYZE) of a statement someone else ran: one statement only, of a kind that has a plan, in a
@@ -185,14 +187,14 @@ export type MonitorPlan = { ok: true; plan: Plan; text: string; generic: boolean
185
187
  */
186
188
  export async function explainForMonitor(sql: string, schema: string | null): Promise<MonitorPlan> {
187
189
  const stmts = splitScript(sql);
188
- if (stmts.length !== 1 || stmts[0].psql) return { ok: false, error: 'Only one SQL statement can be explained.' };
190
+ if (stmts.length !== 1 || stmts[0].psql) return { ok: false, error: 'Only one SQL statement can be explained.', key: 'sm.err.one' };
189
191
  const text = stmts[0].sql;
190
- if (!EXPLAINABLE.test(text)) return { ok: false, error: 'Only SELECT, INSERT, UPDATE, DELETE, MERGE and VALUES statements have a plan.' };
192
+ if (!EXPLAINABLE.test(text)) return { ok: false, error: 'Only SELECT, INSERT, UPDATE, DELETE, MERGE and VALUES statements have a plan.', key: 'sm.err.kind' };
191
193
  const generic = /\$\d+/.test(text);
192
194
  const c = await owner.pool.connect();
193
195
  try {
194
196
  if (generic && Number((await c.query(`select current_setting('server_version_num') as v`)).rows[0].v) < 160000)
195
- return { ok: false, error: 'The statement has parameters ($1, …): PostgreSQL 16 or later can explain it with a generic plan. Open it in the worksheet and fill in values.' };
197
+ return { ok: false, error: 'The statement has parameters ($1, …): PostgreSQL 16 or later can explain it with a generic plan. Open it in the worksheet and fill in values.', key: 'sm.err.generic' };
196
198
  await c.query('begin transaction read only');
197
199
  await c.query(`set local statement_timeout = '5s'`);
198
200
  await c.query(`set local lock_timeout = '1s'`);
@@ -200,7 +202,7 @@ export async function explainForMonitor(sql: string, schema: string | null): Pro
200
202
  // the extended protocol (Parse/Bind): the server accepts one statement only, whatever the text hides; a generic
201
203
  // plan's parameters are bound to nulls, which it doesn't look at
202
204
  const params = Math.max(0, ...[...text.matchAll(/\$(\d+)/g)].map((m) => Number(m[1])));
203
- if (params > 100) return { ok: false, error: 'The statement has too many parameters to explain.' };
205
+ if (params > 100) return { ok: false, error: 'The statement has too many parameters to explain.', key: 'sm.err.params' };
204
206
  const out = await c.query({ text: `explain (format json, verbose, costs${generic ? ', generic_plan' : ''}) ${text}`, values: Array(params).fill(null), queryMode: 'extended' } as never);
205
207
  const json = (out as { rows: Row[] }).rows[0]?.['QUERY PLAN'];
206
208
  const plan = parsePlan(typeof json === 'string' ? JSON.parse(json) : json, false);
@@ -246,14 +248,15 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
246
248
  const showIdle = req.query?.idle === '1';
247
249
  const live = await livePart(s, showIdle);
248
250
  if (req.query?.fragment === '1') return reply.header('cache-control', 'no-store').type('text/html').send(String(live));
249
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor', s)}${viewTabs('sessions')}
251
+ const tx = tr(s);
252
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('monitor', s)}${viewTabs(tx, 'sessions')}
250
253
  <div class="sm-toolbar" data-sm-controls hidden>
251
- <label for="sm-interval">Refresh every</label>
252
- <select id="sm-interval" data-sm-interval>${REFRESH_SECONDS.map((n) => html`<option value="${n}">${n} seconds</option>`)}</select>
253
- <button type="button" class="btn btn-sm" data-sm-pause aria-pressed="false">Pause</button>
254
+ <label for="sm-interval">${tx('sm.refresh_every')}</label>
255
+ <select id="sm-interval" data-sm-interval>${REFRESH_SECONDS.map((n) => html`<option value="${n}">${tx('sm.seconds', { n })}</option>`)}</select>
256
+ <button type="button" class="btn btn-sm" data-sm-pause aria-pressed="false">${tx('sm.pause')}</button>
254
257
  </div>
255
258
  <div class="sm-live" data-sm-live="${URL_BASE}?${showIdle ? 'idle=1&' : ''}fragment=1" aria-live="off">${live}</div>`;
256
- return send(reply, s, shell(s, 'SQL Monitor', crumbs(), main, 'sql'));
259
+ return send(reply, s, shell(s, tx('sql.tool.monitor'), crumbs(tx), main, 'sql'));
257
260
  });
258
261
 
259
262
  app.post(`${URL_BASE}/session`, async (req: Req, reply) => {
@@ -265,15 +268,16 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
265
268
  // only a client session of this database, and not the connection doing it
266
269
  const target = await owner.one<{ pid: number }>(
267
270
  `select pid from pg_stat_activity where pid = $1 and datname = current_database() and backend_type = 'client backend' and pid <> pg_backend_pid()`, [Number(b.pid)]);
271
+ const tx = tr(s);
268
272
  if (!target) {
269
- flash(s, `Session ${b.pid} isn't a client session of this database (any more).`, 'error');
273
+ flash(s, tx('sm.not_client', { pid: String(b.pid) }), 'error');
270
274
  return back(reply, s, to);
271
275
  }
272
276
  try {
273
277
  const fn = b.action === 'cancel' ? 'pg_cancel_backend' : 'pg_terminate_backend';
274
278
  const r = await owner.one<{ ok: boolean }>(`select ${fn}($1) as ok`, [target.pid]);
275
- if (r?.ok) flash(s, b.action === 'cancel' ? `Asked session ${target.pid} to cancel its statement.` : `Terminated session ${target.pid}.`);
276
- else flash(s, `Session ${target.pid} could not be signalled.`, 'error');
279
+ if (r?.ok) flash(s, b.action === 'cancel' ? tx('sm.cancelled', { pid: target.pid }) : tx('sm.terminated', { pid: target.pid }));
280
+ else flash(s, tx('sm.not_signalled', { pid: target.pid }), 'error');
277
281
  } catch (e) {
278
282
  flash(s, (e as Error).message, 'error');
279
283
  }
@@ -287,9 +291,10 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
287
291
  const sort = SORTS[q.sort ?? ''] ? q.sort! : 'total';
288
292
  const desc = q.dir ? q.dir !== 'asc' : !SORTS[sort].asc;
289
293
  const filter = String(q.q ?? '').slice(0, 200);
294
+ const tx = tr(s);
290
295
  const state = await statementsState();
291
296
  let body: Raw;
292
- if (!state.ok) body = statementsNote(state.reason, state.message);
297
+ if (!state.ok) body = statementsNote(tx, state.reason, state.message);
293
298
  else {
294
299
  const [rows, info] = await Promise.all([
295
300
  topStatements(sort, desc, filter),
@@ -301,32 +306,32 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
301
306
  const on = key === sort;
302
307
  const nextDesc = on ? !desc : !SORTS[key].asc;
303
308
  const params = new URLSearchParams({ sort: key, dir: nextDesc ? 'desc' : 'asc', ...(filter ? { q: filter } : {}) });
304
- return html`<a href="${URL_BASE}/top?${params}" title="Sort by ${SORTS[key].label.toLowerCase()}">${SORTS[key].label}${on ? (desc ? ' ▼' : ' ▲') : ''}</a>`;
309
+ return html`<a href="${URL_BASE}/top?${params}" title="${tx('sm.sort_by', { column: tx(SORTS[key].label) })}">${tx(SORTS[key].label)}${on ? (desc ? ' ▼' : ' ▲') : ''}</a>`;
305
310
  };
306
311
  const th = (key: string, numeric = true) => html`<th${numeric ? raw(' class="num"') : ''}${key === sort ? raw(` aria-sort="${desc ? 'descending' : 'ascending'}"`) : ''}>${link(key)}</th>`;
307
312
  const i = info[0] as Row | undefined;
308
313
  body = html`<form method="get" action="${URL_BASE}/top" class="sm-filter u-mb1" role="search">
309
314
  <input type="hidden" name="sort" value="${sort}"><input type="hidden" name="dir" value="${desc ? 'desc' : 'asc'}">
310
- <label class="sr-only" for="sm-q">Statement contains</label><input id="sm-q" name="q" type="search" value="${filter}" maxlength="200" placeholder="Statement contains…">
311
- <button class="btn btn-sm">${icon('search')} Search</button></form>
315
+ <label class="sr-only" for="sm-q">${tx('sm.contains')}</label><input id="sm-q" name="q" type="search" value="${filter}" maxlength="200" placeholder="${tx('sm.contains')}…">
316
+ <button class="btn btn-sm">${icon('search')} ${tx('common.search')}</button></form>
312
317
  <div class="table-wrap"><table class="report report-reflow sm-top"><thead><tr>${th('role', false)}${th('calls')}${th('total')}${th('mean')}${th('rows')}${th('hit')}${th('read')}${th('query', false)}</tr></thead>
313
318
  <tbody>${rows.length ? rows.map((r) => html`<tr>
314
- <td data-label="Role">${r.role ?? ''}</td>
315
- <td class="num" data-label="Calls">${num(r.calls)}</td>
316
- <td class="num sm-barcell" data-label="Total ms">${bar(r.total_ms, maxTotal)}<span>${num(r.total_ms)}</span></td>
317
- <td class="num sm-barcell" data-label="Mean ms">${bar(r.mean_ms, maxMean)}<span>${num(r.mean_ms)}</span></td>
318
- <td class="num" data-label="Rows">${num(r.rows)}</td>
319
- <td class="num" data-label="Blocks hit">${num(r.hit)}</td>
320
- <td class="num" data-label="Blocks read">${num(r.read)}</td>
321
- <td data-label="Statement" class="u-break">${r.queryid ? html`<a href="${URL_BASE}/statements/${r.queryid}"><code class="sql-text">${r.query}</code></a>` : html`<code class="sql-text">${r.query}</code>`}</td></tr>`)
322
- : html`<tr><td colspan="8" class="empty">${filter ? 'No statements match.' : 'No statements recorded yet.'}</td></tr>`}</tbody></table></div>
323
- <p class="muted small">${i?.reset ? `Statistics since ${String(i.reset)}. ` : ''}At most 100 statements are shown; literals and binds show as <code>$1</code>, <code>$2</code>….</p>
319
+ <td data-label="${tx('sm.col.role')}">${r.role ?? ''}</td>
320
+ <td class="num" data-label="${tx('sm.col.calls')}">${num(r.calls)}</td>
321
+ <td class="num sm-barcell" data-label="${tx('sm.col.total_ms')}">${bar(r.total_ms, maxTotal)}<span>${num(r.total_ms)}</span></td>
322
+ <td class="num sm-barcell" data-label="${tx('sm.col.mean_ms')}">${bar(r.mean_ms, maxMean)}<span>${num(r.mean_ms)}</span></td>
323
+ <td class="num" data-label="${tx('sm.col.rows')}">${num(r.rows)}</td>
324
+ <td class="num" data-label="${tx('sm.col.hit')}">${num(r.hit)}</td>
325
+ <td class="num" data-label="${tx('sm.col.read')}">${num(r.read)}</td>
326
+ <td data-label="${tx('sm.col.statement')}" class="u-break">${r.queryid ? html`<a href="${URL_BASE}/statements/${r.queryid}"><code class="sql-text">${r.query}</code></a>` : html`<code class="sql-text">${r.query}</code>`}</td></tr>`)
327
+ : html`<tr><td colspan="8" class="empty">${filter ? tx('sm.no_match') : tx('sm.none_yet')}</td></tr>`}</tbody></table></div>
328
+ <p class="muted small">${i?.reset ? `${tx('sm.since', { time: String(i.reset) })} ` : ''}${tx.rich('sm.at_most', { p1: html`<code>$1</code>`, p2: html`<code>$2</code>` })}</p>
324
329
  <form method="post" action="${URL_BASE}/reset">${csrf(s)}
325
- <button class="btn" data-confirm="Clear the statement statistics of the whole server?">${icon('history')} Reset statistics</button></form>`;
330
+ <button class="btn" data-confirm="${tx('sm.reset_confirm')}">${icon('history')} ${tx('sm.reset')}</button></form>`;
326
331
  }
327
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('monitor', s)}${viewTabs('top')}
328
- ${region('Top SQL', html`<p class="muted u-mt0">The statements of this database from <code>pg_stat_statements</code>, every role. Click a statement for its plan.</p>${body}`)}`;
329
- return send(reply, s, shell(s, 'Top SQL', crumbs(['Top SQL']), main, 'sql'));
332
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('monitor', s)}${viewTabs(tx, 'top')}
333
+ ${region(tx('sql.tool.topsql'), html`<p class="muted u-mt0">${tx.rich('sm.top_intro', { ext: html`<code>pg_stat_statements</code>` })}</p>${body}`)}`;
334
+ return send(reply, s, shell(s, tx('sql.tool.topsql'), crumbs(tx, [tx('sql.tool.topsql')]), main, 'sql'));
330
335
  });
331
336
 
332
337
  app.post(`${URL_BASE}/reset`, async (req: Req, reply) => {
@@ -334,7 +339,7 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
334
339
  if (!s) return;
335
340
  try {
336
341
  await owner.query('select pg_stat_statements_reset()');
337
- flash(s, 'Statement statistics reset.');
342
+ flash(s, tr(s)('sm.reset_done'));
338
343
  } catch (e) {
339
344
  flash(s, (e as Error).message, 'error');
340
345
  }
@@ -346,10 +351,11 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
346
351
  if (!s) return;
347
352
  const queryid = String(req.params.queryid);
348
353
  if (!QUERYID_RE.test(queryid)) return reply.code(404).send('Not found');
354
+ const tx = tr(s);
349
355
  const state = await statementsState();
350
356
  if (!state.ok) {
351
- const main = html`<h1 class="u-mb1">Statement</h1>${statementsNote(state.reason, state.message)}`;
352
- return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
357
+ const main = html`<h1 class="u-mb1">${tx('sm.statement')}</h1>${statementsNote(tx, state.reason, state.message)}`;
358
+ return send(reply, s, shell(s, tx('sm.statement'), crumbs(tx, [tx('sm.statement')]), main, 'sql'));
353
359
  }
354
360
  let rows: Row[];
355
361
  try {
@@ -366,22 +372,22 @@ export async function sqlMonitorRoutes(app: FastifyInstance) {
366
372
  const calls = sum('calls');
367
373
  const total = sum('total_ms');
368
374
  const tile = (label: string, v: string) => html`<div class="stat"><b>${v}</b><span>${label}</span></div>`;
369
- const main = html`<h1 class="u-mb1">Statement</h1>
370
- <div class="stat-grid u-mb1">${tile('Calls', num(calls))}${tile('Total ms', num(total))}${tile('Mean ms', num(calls ? total / calls : 0))}${tile('Rows', num(sum('rows')))}
371
- ${tile('Blocks hit', num(sum('hit')))}${tile('Blocks read', num(sum('read')))}${tile('Temp blocks written', num(sum('temp_written')))}</div>
372
- ${region('Text', html`<pre class="source u-break sm-sql" data-code-view="sql" tabindex="0" aria-label="SQL text">${sql}</pre>
373
- <div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} Open in worksheet</button></form>
374
- <a class="btn" href="${URL_BASE}/top">Back to Top SQL</a></div>`)}
375
- ${region('By role', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Role</th><th class="num">Calls</th><th class="num">Total ms</th><th class="num">Mean ms</th><th class="num">Std dev ms</th><th class="num">Min ms</th><th class="num">Max ms</th><th class="num">Rows</th><th class="num">Hit</th><th class="num">Read</th><th class="num">Temp read</th><th class="num">Temp written</th></tr></thead>
376
- <tbody>${rows.map((r) => html`<tr><td data-label="Role">${String(r.role ?? '')}${r.toplevel === false ? html` <span class="tag">nested</span>` : ''}</td>
377
- ${(['calls', 'total_ms', 'mean_ms', 'stddev_ms', 'min_ms', 'max_ms', 'rows', 'hit', 'read', 'temp_read', 'temp_written'] as const).map((k) =>
378
- html`<td class="num" data-label="${k.replace('_ms', ' ms').replace('_', ' ')}">${num(r[k])}</td>`)}</tr>`)}</tbody></table></div>`)}
379
- ${region('Explain Plan', html`<p class="muted u-mt0">EXPLAIN without running the statement${/\$\d+/.test(sql) ? ': its parameters ($1, …) get a generic plan' : ''}. Unqualified names are looked up in the schema you choose first.</p>
375
+ const BY_ROLE: Record<string, BuilderKey> = { calls: 'sm.col.calls', total_ms: 'sm.col.total_ms', mean_ms: 'sm.col.mean_ms', stddev_ms: 'sm.col.stddev_ms', min_ms: 'sm.col.min_ms', max_ms: 'sm.col.max_ms', rows: 'sm.col.rows', hit: 'sm.col.hit_short', read: 'sm.col.read_short', temp_read: 'sm.col.temp_read', temp_written: 'sm.col.temp_written' };
376
+ const main = html`<h1 class="u-mb1">${tx('sm.statement')}</h1>
377
+ <div class="stat-grid u-mb1">${tile(tx('sm.col.calls'), num(calls))}${tile(tx('sm.col.total_ms'), num(total))}${tile(tx('sm.col.mean_ms'), num(calls ? total / calls : 0))}${tile(tx('sm.col.rows'), num(sum('rows')))}
378
+ ${tile(tx('sm.col.hit'), num(sum('hit')))}${tile(tx('sm.col.read'), num(sum('read')))}${tile(tx('sm.temp_blocks_written'), num(sum('temp_written')))}</div>
379
+ ${region(tx('sm.text'), html`<pre class="source u-break sm-sql" data-code-view="sql" tabindex="0" aria-label="${tx('sm.sql_text')}">${sql}</pre>
380
+ <div class="buttons"><form method="post" action="${URL_BASE}/statements/${queryid}/worksheet">${csrf(s)}<button class="btn">${icon('code')} ${tx('sm.open_worksheet')}</button></form>
381
+ <a class="btn" href="${URL_BASE}/top">${tx('sm.back_top')}</a></div>`)}
382
+ ${region(tx('sm.by_role'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sm.col.role')}</th>${Object.values(BY_ROLE).map((k) => html`<th class="num">${tx(k)}</th>`)}</tr></thead>
383
+ <tbody>${rows.map((r) => html`<tr><td data-label="${tx('sm.col.role')}">${String(r.role ?? '')}${r.toplevel === false ? html` <span class="tag">${tx('sm.nested')}</span>` : ''}</td>
384
+ ${Object.entries(BY_ROLE).map(([k, label]) => html`<td class="num" data-label="${tx(label)}">${num(r[k])}</td>`)}</tr>`)}</tbody></table></div>`)}
385
+ ${region(tx('sm.explain_plan'), html`<p class="muted u-mt0">${/\$\d+/.test(sql) ? tx('sm.explain_intro_params') : tx('sm.explain_intro')}</p>
380
386
  <form method="get" action="${URL_BASE}/statements/${queryid}" class="sm-filter u-mb1"><input type="hidden" name="explain" value="1">
381
- <label for="sm-schema">Schema</label><select id="sm-schema" name="schema"><option value="">(the search path)</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
382
- <button class="btn">${icon('play')} Explain</button></form>
383
- ${explained ? explained.ok ? planView(explained.plan, explained.text) : html`<div class="alert alert-error" role="alert">${explained.error}</div>` : ''}`)}`;
384
- return send(reply, s, shell(s, 'Statement', crumbs(['Statement']), main, 'sql'));
387
+ <label for="sm-schema">${tx('sm.schema')}</label><select id="sm-schema" name="schema"><option value="">${tx('sm.search_path')}</option>${schemas.map((n) => html`<option value="${n}"${n === schema ? raw(' selected') : ''}>${n}</option>`)}</select>
388
+ <button class="btn">${icon('play')} ${tx('sm.explain')}</button></form>
389
+ ${explained ? explained.ok ? planView(explained.plan, explained.text) : html`<div class="alert alert-error" role="alert">${explained.key ? tx(explained.key) : explained.error}</div>` : ''}`)}`;
390
+ return send(reply, s, shell(s, tx('sm.statement'), crumbs(tx, [tx('sm.statement')]), main, 'sql'));
385
391
  });
386
392
 
387
393
  app.post(`${URL_BASE}/statements/:queryid/worksheet`, async (req: Req, reply) => {
@@ -5,6 +5,8 @@ import { icon } from '../icons.ts';
5
5
  import { BASE, developer, region, send, shell, workshopTabs, type Req } from './ui.ts';
6
6
  import { isAdmin } from './locks.ts';
7
7
  import { workshopDb, workshopRunsAs } from './workspaces.ts';
8
+ import { tr, type BuilderTranslate } from './i18n.ts';
9
+ import { SQL_TOOLS, toolDescription, toolLabel } from './sql-tools.ts';
8
10
 
9
11
  // SQL Workshop → Utilities (sprint 62; APEX: SQL Workshop → Utilities): one
10
12
  // page with the workshop's tools (Load and Unload Data, Sample Data, DDL,
@@ -134,30 +136,30 @@ async function readOnly<T>(db: Db, fn: (q: (sql: string, params?: unknown[]) =>
134
136
  });
135
137
  }
136
138
 
137
- const crumbs = (last: string): [string, string?][] => [['SQL Workshop', `${BASE}/sql`], ['Utilities', `${BASE}/sql/utilities`], [last]];
139
+ const crumbs = (tx: BuilderTranslate, last: string): [string, string?][] => [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.utilities'), `${BASE}/sql/utilities`], [last]];
140
+ /** (sprint 162) an Object Report's title, text and column labels in the builder's language (su.r.<key>.*, su.col.<column>) */
141
+ const reportTitle = (tx: BuilderTranslate, key: string, r: Report) => tx.dyn(`su.r.${key.replace(/-/g, '_')}`, r.title);
142
+ const reportAbout = (tx: BuilderTranslate, key: string, r: Report) => tx.dyn(`su.r.${key.replace(/-/g, '_')}.about`, r.about);
143
+ const colLabel = (tx: BuilderTranslate, col: string, label: string) => tx.dyn(`su.col.${col}`, label);
138
144
 
139
145
  export async function sqlUtilityRoutes(app: FastifyInstance) {
140
146
  app.get(`${BASE}/sql/utilities`, async (req: Req, reply) => {
141
147
  const s = await developer(req, reply);
142
148
  if (!s) return;
143
149
  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', s)}
150
+ const tx = tr(s);
151
+ // (sprint 162) the tools' names and descriptions from the SQL Workshop's tool list, in the builder's language
152
+ const tile = (key: string, ic: string) => {
153
+ const t = SQL_TOOLS.find((x) => x.key === key)!;
154
+ if (t.admin && !admin) return '';
155
+ return html`<a class="ab-tile sw-tile" href="${t.href}"><span class="ab-tile-icon sw-tile-icon">${icon(ic)}</span><strong>${toolLabel(tx, t)}</strong><span>${toolDescription(tx, t)}</span></a>`;
156
+ };
157
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('utilities', s)}
147
158
  <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/data-pump', 'archive', 'Schema Export / Import', 'A schema to a file with pg_dump, a file into a schema with pg_restore (administrators).') : ''}
157
- ${admin ? tile('/sql/scheduling', 'clock', 'Scheduling', 'pg_cron jobs: SQL on a schedule, next runs and run history (administrators).') : ''}
158
- ${admin ? tile('/sql/monitor', 'activity', 'SQL Monitor', 'Sessions, running and blocked queries, wait events and Top SQL (administrators).') : ''}
159
+ ${tile('load', 'upload')}${tile('unload', 'download')}${tile('sample', 'layers')}${tile('query', 'filter')}${tile('ddl', 'file')}
160
+ ${tile('compare', 'layers')}${tile('reports', 'list')}${tile('about', 'help')}${tile('datapump', 'archive')}${tile('scheduling', 'clock')}${tile('monitor', 'activity')}
159
161
  </div>`;
160
- return send(reply, s, shell(s, 'SQL Workshop utilities', [['SQL Workshop', `${BASE}/sql`], ['Utilities']], main, 'sql'));
162
+ return send(reply, s, shell(s, tx('su.title'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.utilities')]], main, 'sql'));
161
163
  });
162
164
 
163
165
  app.get(`${BASE}/sql/utilities/reports`, async (req: Req, reply) => {
@@ -174,20 +176,23 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
174
176
  } catch (e) {
175
177
  error = (e as Error).message;
176
178
  }
177
- const nav = html`<nav aria-label="Object reports"><ul class="ab-links ab-tasks">${Object.entries(OBJECT_REPORTS).map(([k, x]) =>
178
- html`<li><a href="${BASE}/sql/utilities/reports?r=${k}"${k === key ? raw(' aria-current="page"') : ''}>${x.title}${icon('chevron')}</a></li>`)}</ul></nav>`;
179
+ const tx = tr(s);
180
+ const reports = tx('sql.tool.reports');
181
+ const nav = html`<nav aria-label="${reports}"><ul class="ab-links ab-tasks">${Object.entries(OBJECT_REPORTS).map(([k, x]) =>
182
+ html`<li><a href="${BASE}/sql/utilities/reports?r=${k}"${k === key ? raw(' aria-current="page"') : ''}>${reportTitle(tx, k, x)}${icon('chevron')}</a></li>`)}</ul></nav>`;
179
183
  const table = html`<div class="table-wrap"><table class="report report-reflow">
180
- <thead><tr><th>Object</th>${r.columns.map(([, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${l}</th>`)}</tr></thead>
184
+ <thead><tr><th>${tx('sm.col.object')}</th>${r.columns.map(([k, l, c]) => html`<th${c ? raw(` class="${c}"`) : ''}>${colLabel(tx, k, l)}</th>`)}</tr></thead>
181
185
  <tbody>${rows.length
182
- ? 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>
183
- ${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>`)
184
- : html`<tr><td colspan="${r.columns.length + 1}" class="empty">${error ? 'The report failed.' : 'Nothing found.'}</td></tr>`}</tbody></table></div>`;
185
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities', s)}
186
+ ? rows.map((row) => html`<tr><td data-label="${tx('sm.col.object')}" class="u-break"><a href="${BASE}/sql/objects?o=${encodeURIComponent(String(row.object))}">${String(row.object)}</a></td>
187
+ ${r.columns.map(([k, l, c]) => html`<td data-label="${colLabel(tx, k, l)}"${c ? raw(` class="${c}"`) : raw(' class="u-break"')}>${k === 'suggestion' ? html`<code>${String(row[k] ?? '')}</code>` : String(row[k] ?? '')}</td>`)}</tr>`)
188
+ : html`<tr><td colspan="${r.columns.length + 1}" class="empty">${error ? tx('su.failed') : tx('su.nothing')}</td></tr>`}</tbody></table></div>`;
189
+ const title = reportTitle(tx, key, r);
190
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('utilities', s)}
186
191
  <div class="ab-home"><div class="ab-main">
187
- ${region(r.title, html`<p class="muted u-mt0">${r.about} Over the schemas ${workshopRunsAs(db)} may use.</p>
192
+ ${region(title, html`<p class="muted u-mt0">${reportAbout(tx, key, r)} ${tx('su.over', { role: workshopRunsAs(db) })}</p>
188
193
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}${table}`)}
189
- </div><aside class="ab-side" aria-label="Object reports"><section><h2>Object Reports</h2>${nav}</section></aside></div>`;
190
- return send(reply, s, shell(s, r.title, crumbs('Object Reports'), main, 'sql'));
194
+ </div><aside class="ab-side" aria-label="${reports}"><section><h2>${reports}</h2>${nav}</section></aside></div>`;
195
+ return send(reply, s, shell(s, title, crumbs(tx, reports), main, 'sql'));
191
196
  });
192
197
 
193
198
  app.get(`${BASE}/sql/utilities/about`, async (req: Req, reply) => {
@@ -214,26 +219,28 @@ export async function sqlUtilityRoutes(app: FastifyInstance) {
214
219
  where ${USABLE} group by 1, 2 order by 1`),
215
220
  }));
216
221
  const sv = info.server as Record<string, string>;
217
- const main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('utilities', s)}
222
+ const tx = tr(s);
223
+ const L = { schema: tx('sm.schema'), owner: tx('su.col.owner'), tables: tx('su.tables'), size: tx('scr.col.size'), setting: tx('su.setting'), value: tx('su.value'), ext: tx('su.extension'), version: tx('su.version') };
224
+ const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('utilities', s)}
218
225
  <div class="stat-grid u-mb1">
219
- ${[['PostgreSQL', sv.server_version], ['Database', sv.database], ['Size', sv.size], ['Running since', new Date(sv.started).toISOString().slice(0, 16).replace('T', ' ') + ' UTC']]
226
+ ${[['PostgreSQL', sv.server_version], [tx('su.database'), sv.database], [L.size, sv.size], [tx('su.running_since'), new Date(sv.started).toISOString().slice(0, 16).replace('T', ' ') + ' UTC']]
220
227
  .map(([l, v]) => html`<div class="stat"><b class="u-break">${v}</b><span>${l}</span></div>`)}
221
228
  </div>
222
- <p class="muted">${sv.version}. The SQL Workshop runs as <code>${sv.user}</code> (signed in as <code>${sv.login}</code>).</p>
229
+ <p class="muted">${sv.version}. ${tx.rich('su.runs_as', { user: html`<code>${sv.user}</code>`, login: html`<code>${sv.login}</code>` })}</p>
223
230
  <div class="columns">
224
- ${region('Settings', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Setting</th><th>Value</th></tr></thead>
225
- <tbody>${info.settings.map((x) => html`<tr><td data-label="Setting" title="${String(x.short_desc ?? '')}"><code>${String(x.name)}</code></td>
226
- <td data-label="Value" class="u-break">${String(x.setting ?? '')}${x.unit ? ` ${x.unit}` : ''}</td></tr>`)}</tbody></table></div>`)}
227
- ${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>
228
- <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>
229
- <td data-label="Version">${String(x.version)}</td><td data-label="Schema">${String(x.schema)}</td></tr>`)}</tbody></table></div>`)}
231
+ ${region(tx('su.settings'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${L.setting}</th><th>${L.value}</th></tr></thead>
232
+ <tbody>${info.settings.map((x) => html`<tr><td data-label="${L.setting}" title="${String(x.short_desc ?? '')}"><code>${String(x.name)}</code></td>
233
+ <td data-label="${L.value}" class="u-break">${String(x.setting ?? '')}${x.unit ? ` ${x.unit}` : ''}</td></tr>`)}</tbody></table></div>`)}
234
+ ${region(tx('su.extensions'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${L.ext}</th><th>${L.version}</th><th>${L.schema}</th></tr></thead>
235
+ <tbody>${info.extensions.map((x) => html`<tr><td data-label="${L.ext}" class="u-break"><strong>${String(x.name)}</strong>${x.comment ? html` <span class="muted">${String(x.comment)}</span>` : ''}</td>
236
+ <td data-label="${L.version}">${String(x.version)}</td><td data-label="${L.schema}">${String(x.schema)}</td></tr>`)}</tbody></table></div>`)}
230
237
  </div>
231
238
  <div class="u-spacer"></div>
232
- ${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>
233
- <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>
234
- <td class="num" data-label="Tables">${String(x.tables)}</td><td class="num" data-label="Size">${String(x.size)}</td></tr>`)
235
- : html`<tr><td colspan="4" class="empty">No schemas.</td></tr>`}</tbody></table></div>`)}`;
236
- return send(reply, s, shell(s, 'About Database', crumbs('About Database'), main, 'sql'));
239
+ ${region(tx('cl.schemas'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${L.schema}</th><th>${L.owner}</th><th class="num">${L.tables}</th><th class="num">${L.size}</th></tr></thead>
240
+ <tbody>${info.schemas.length ? info.schemas.map((x) => html`<tr><td data-label="${L.schema}">${String(x.schema)}</td><td data-label="${L.owner}"><code>${String(x.owner)}</code></td>
241
+ <td class="num" data-label="${L.tables}">${String(x.tables)}</td><td class="num" data-label="${L.size}">${String(x.size)}</td></tr>`)
242
+ : html`<tr><td colspan="4" class="empty">${tx('su.no_schemas')}</td></tr>`}</tbody></table></div>`)}`;
243
+ return send(reply, s, shell(s, tx('sql.tool.about'), crumbs(tx, tx('sql.tool.about')), main, 'sql'));
237
244
  });
238
245
 
239
246
  // (sprint 102) the Database Monitor became SQL Workshop › SQL Monitor (sql-monitor.ts)