pgkiln 0.88.0 → 0.89.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/src/builder/ui.ts CHANGED
@@ -50,7 +50,24 @@ export async function send(reply: FastifyReply, s: Session, body: string) {
50
50
  return reply.type('text/html').send(body);
51
51
  }
52
52
 
53
- export type Section = 'home' | 'apps' | 'users' | 'sql' | 'developers' | 'gallery' | 'reporter' | 'team';
53
+ export type Section = 'home' | 'apps' | 'users' | 'sql' | 'developers' | 'gallery' | 'reporter' | 'team' | 'admin';
54
+
55
+ /**
56
+ * (UX-3) The rail's current section, from the request's path: one rule for every builder page, so a page doesn't have
57
+ * to name it (and can't name the wrong one). Administration: the workspace utilities and what they lead to.
58
+ */
59
+ const ADMIN_PATHS = /^\/builder\/(utilities|users|developers|workspaces?|workspace-(?:import|export|files|credentials)|instance|installation|mail|mail-queue|network|dashboard|telemetry|ui-defaults|icons|vector-providers|ai|sso|backups|db-users|remote-servers|remote-databases|rest-catalogs|app-groups|cross-app)(?:[/?#]|$)/;
60
+ export function sectionOf(path: string | undefined, fallback: Section): Section {
61
+ if (!path) return fallback;
62
+ if (/^\/builder\/home(?:[/?#]|$)/.test(path)) return 'home';
63
+ if (/^\/builder\/sql(?:[/?#]|$)/.test(path)) return 'sql';
64
+ if (/^\/builder\/(gallery)(?:[/?#]|$)/.test(path)) return 'gallery';
65
+ if (/^\/builder\/reporter(?:[/?#]|$)/.test(path)) return 'reporter';
66
+ if (/^\/builder\/team(?:[/?#]|$)/.test(path)) return 'team';
67
+ if (ADMIN_PATHS.test(path)) return 'admin';
68
+ if (/^\/builder\/(apps|pages|create|import|blueprints|working-copies)(?:[/?#]|$)/.test(path)) return 'apps';
69
+ return fallback;
70
+ }
54
71
 
55
72
  // ------------------------------------------------------------------ builder head
56
73
  // Builder-only stylesheets and scripts, loaded on every builder page after
@@ -100,14 +117,15 @@ const initials = (name: string) => name.replace(/[^\p{L}\p{N}]+/gu, ' ').trim().
100
117
  * Administration, Help, the developer's menu), an environment banner (091), a compact toolbar with
101
118
  * back button and breadcrumb, then the page itself.
102
119
  */
103
- export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, section: Section = 'apps', opts: ShellOpts = {}) {
120
+ export function shell(s: Session, title: string, crumbs: [string, string?][], main: Raw, pageSection: Section = 'apps', opts: ShellOpts = {}) {
121
+ // (UX-3) the rail's current entry follows the request's path; a page's own choice only where the path says nothing
122
+ const section = sectionOf(s.path, pageSection);
123
+ const railLabels = s.state.__RAIL === 'labels';
104
124
  const ok = takeFlash(s, '__FLASH');
105
125
  const err = takeFlash(s, '__ERROR');
106
126
  const theme = builderTheme(s);
107
127
  // (sprint 121) the developer's builder language (i18n.ts); known English breadcrumb labels are translated here
108
128
  const tx = tr(s);
109
- // the application these pages belong to (for the Search entry), from the breadcrumb
110
- const appId = crumbs.map(([, href]) => /^\/builder\/apps\/(\d+)$/.exec(href ?? '')?.[1]).find(Boolean);
111
129
  const rail = (key: Section | 'search' | 'help', href: string, ic: string, label: string, extra: Raw | '' = '') =>
112
130
  html`<li><a href="${href}" class="rail-link" title="${label}"${section === key ? raw(' aria-current="page"') : ''}${extra}>${bicon(ic)}<span class="rail-label">${label}</span></a></li>`;
113
131
  // (sprint 74) the default-password warning: on the Workspace home (sprint 109), the App Builder home and the Developers page only, until
@@ -131,40 +149,16 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
131
149
  ${rail('gallery', `${BASE}/gallery`, 'package', tx('rail.gallery'))}
132
150
  ${rail('reporter', `${BASE}/reporter`, 'pie-chart', tx('rail.reporter'))}
133
151
  ${rail('team', `${BASE}/team`, 'kanban', tx('rail.team'))}
134
- ${appId ? rail('search', `${BASE}/apps/${appId}/search`, 'search', tx('rail.search_app')) : ''}
152
+ ${rail('admin', `${BASE}/utilities`, 'settings', tx('rail.admin'))}
135
153
  </ul>
136
154
  <ul class="rail-list rail-bottom">
137
- <li><details class="menu rail-user rail-admin">
138
- <summary class="rail-link${section === 'users' || section === 'developers' ? ' rail-open' : ''}" title="${tx('rail.admin')}"${section === 'users' || section === 'developers' ? raw(' aria-current="page"') : ''}>${icon('settings')}<span class="rail-label">${tx('rail.admin')}</span></summary>
139
- <div class="menu-panel">
140
- <div class="menu-section menu-links">
141
- <strong>${tx('rail.admin')}</strong>
142
- <a href="${BASE}/utilities">${icon('grid')} ${tx('admin.utilities')}</a>
143
- </div>
144
- ${s.workspace?.admin ? html`<div class="menu-section menu-links">
145
- <span class="small muted">${tx('admin.manage_service', { name: s.workspace.name })}</span>
146
- <a href="${BASE}/workspaces/${s.workspace.id}#service">${icon('bell')} ${tx('admin.ws_message')}</a>
147
- <a href="${BASE}/workspaces/${s.workspace.id}#preferences">${icon('sliders-horizontal')} ${tx('admin.ws_prefs')}</a>
148
- <a href="${BASE}/workspaces/${s.workspace.id}#links">${icon('external-link')} ${tx('admin.ext_links')}</a>
149
- <a href="${BASE}/workspaces/${s.workspace.id}/utilization">${icon('chart')} ${tx('admin.utilization')}</a>
150
- <a href="${BASE}/mail-queue">${icon('inbox')} ${tx('admin.mail_queue')}</a>
151
- <a href="${BASE}/network">${icon('globe')} ${tx('admin.network')}</a>
152
- <a href="${BASE}/workspaces/${s.workspace.id}">${icon('layers')} ${tx('admin.ws_devs')}</a>
153
- </div>` : ''}
154
- <div class="menu-section menu-links">
155
- <a href="${BASE}/users">${icon('user')} ${tx('admin.users')}</a>
156
- <a href="${BASE}/developers">${icon('users')} ${tx('admin.developers')}</a>
157
- <a href="${BASE}/dashboard">${icon('activity')} ${tx('admin.monitor')}</a>
158
- ${s.instanceAdmin ? html`<a href="${BASE}/db-users">${icon('key')} ${tx('admin.db_users')}</a>` : ''}
159
- <a href="${BASE}/developers">${icon('key')} ${tx('admin.change_my_password')}</a>
160
- </div>
161
- </div></details></li>
162
155
  ${s.workspace?.links?.length ? html`<li><details class="menu rail-user rail-admin rail-extensions">
163
156
  <summary class="rail-link" title="${tx('rail.extensions')}">${icon('puzzle')}<span class="rail-label">${tx('rail.extensions')}</span></summary>
164
157
  <div class="menu-panel"><div class="menu-section menu-links"><strong>${tx('rail.extensions')}</strong>
165
158
  ${s.workspace.links.map((l) => html`<a href="${l.url}" target="_blank" rel="noopener noreferrer"${l.description ? html` title="${l.description}"` : ''}>${icon('external-link')} ${l.label}</a>`)}
166
159
  </div></div></details></li>` : ''}
167
160
  ${rail('help', 'https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md', 'help', tx('rail.help'), raw(' target="_blank" rel="noopener"'))}
161
+ <li><form method="post" action="${BASE}/rail">${csrf(s)}<button class="rail-link rail-toggle" name="labels" value="${railLabels ? '' : 'labels'}" title="${tx(railLabels ? 'rail.labels_hide' : 'rail.labels_show')}" aria-pressed="${railLabels ? 'true' : 'false'}">${bicon(railLabels ? 'left' : 'right')}<span class="rail-label">${tx(railLabels ? 'rail.labels_hide' : 'rail.labels_show')}</span></button></form></li>
168
162
  <li><details class="menu rail-user">
169
163
  <summary class="rail-avatar${weak ? ' has-dot' : ''}" title="${weak ? tx('user.weak_title', { user: s.username }) : s.username}"><span aria-hidden="true">${initials(s.username ?? '')}</span><span class="sr-only">${tx(weak ? 'user.account_weak' : 'user.account', { user: s.username })}</span></summary>
170
164
  <div class="menu-panel">
@@ -213,7 +207,7 @@ export function shell(s: Session, title: string, crumbs: [string, string?][], ma
213
207
  </footer>
214
208
  </div>
215
209
  <div id="builder-texts" hidden data-texts="${JSON.stringify(clientTexts(tx.lang))}"></div>`,
216
- `builder-body ide${opts.full ? ' ide-full' : ''}`,
210
+ `builder-body ide${opts.full ? ' ide-full' : ''}${railLabels ? ' rail-labels' : ''}`,
217
211
  {},
218
212
  builderHead(),
219
213
  { theme, lang: tx.lang },
@@ -225,6 +219,23 @@ export const BANNER_COLORS = ['red', 'orange', 'yellow', 'green', 'teal', 'blue'
225
219
 
226
220
  export const csrf = (s: Session) => html`<input type="hidden" name="__csrf" value="${s.csrf_token}">`;
227
221
 
222
+ /**
223
+ * (UX-5) A builder page's header: its one heading, its one primary action (accent), a few secondary actions and a ⋯ menu
224
+ * for the rest. Every builder page should have exactly one H1 and one primary button.
225
+ */
226
+ export function pageHeader(title: string | Raw, o: { sub?: Raw | string; primary?: [href: string, label: string, ic?: string]; secondary?: [href: string, label: string, ic?: string][]; more?: [href: string, label: string, ic?: string][]; moreLabel?: string } = {}) {
227
+ const link = ([href, label, ic]: [string, string, string?], cls: string) => html`<a class="${cls}" href="${href}">${ic ? icon(ic) : ''}<span>${label}</span></a>`;
228
+ return html`<div class="page-head">
229
+ <div class="page-head-title"><h1>${title}</h1>${o.sub ? html`<p class="page-head-sub muted">${o.sub}</p>` : ''}</div>
230
+ <div class="page-head-actions">
231
+ ${(o.secondary ?? []).map((a) => link(a, 'btn'))}
232
+ ${o.more?.length ? html`<details class="menu page-head-more"><summary class="btn btn-icon" title="${o.moreLabel ?? ''}"><span aria-hidden="true">⋯</span><span class="sr-only">${o.moreLabel ?? ''}</span></summary>
233
+ <div class="menu-panel align-right"><div class="menu-section menu-links">${o.more.map(([href, label, ic]) => html`<a href="${href}">${ic ? icon(ic) : ''} ${label}</a>`)}</div></div></details>` : ''}
234
+ ${o.primary ? link(o.primary, 'btn btn-hot') : ''}
235
+ </div>
236
+ </div>`;
237
+ }
238
+
228
239
  export function input(name: string, label: string, value: unknown, opts: { type?: string; help?: string; required?: boolean; placeholder?: string; auto?: string } = {}) {
229
240
  return html`<div class="field"><label class="label" for="f_${name}">${label}</label>
230
241
  <input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value ?? ''}"${opts.required ? raw(' required') : ''} placeholder="${opts.placeholder}"${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -243,7 +254,7 @@ export const region = (title: string, body: Raw | Raw[], extra: Raw | '' = '', i
243
254
  html`<section class="region region-standard"${id ? raw(` id="${id.replace(/[^a-z0-9-]/g, '')}"`) : ''}><header class="region-header"><h2>${title}</h2>${extra}</header><div class="region-body">${body}</div></section>`;
244
255
 
245
256
  /** Title and tabs of an application's builder pages (Pages, Shared Components, …), in the session's builder language. */
246
- export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>) => {
257
+ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'activity' | 'api' | 'search' | 'advisor' | 'utilities', s?: Pick<Session, 'state'>, primary?: [href: string, label: string, ic: string]) => {
247
258
  const tx = tr(s);
248
259
  const tab = (key: typeof active, href: string, ic: string, label: string) =>
249
260
  html`<a class="ide-tab" href="${href}"${active === key ? raw(' aria-current="page"') : ''}>${bicon(ic)}<span>${label}</span></a>`;
@@ -256,7 +267,9 @@ export const appHeader = (a: any, active: 'pages' | 'shared' | 'settings' | 'act
256
267
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups">${icon('history')} ${tx('app.head.backups')}</a>
257
268
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/availability">${icon('power')} ${tx('app.head.availability')}</a>
258
269
  <a class="btn btn-sm" href="${BASE}/apps/${a.id}/export">${icon('download')} ${tx('app.head.export')}</a>
259
- <a class="btn btn-sm btn-run" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('common.run')}</a>
270
+ <!-- (UX-5) one primary action: the page's own (Create page on the Pages tab), else Run -->
271
+ <a class="btn btn-sm${primary ? '' : ' btn-run'}" href="/a/${a.alias}" target="_blank" rel="noopener">${icon('play')} ${tx('common.run')}</a>
272
+ ${primary ? html`<a class="btn btn-sm btn-hot" href="${primary[0]}">${icon(primary[2])} ${primary[1]}</a>` : ''}
260
273
  </div></div>
261
274
  <nav class="ide-tabs" aria-label="${tx('app.head.tabs')}">
262
275
  ${tab('pages', `${BASE}/apps/${a.id}`, 'pages', tx('app.tab.pages'))}
@@ -323,6 +336,7 @@ export async function developer(req: Req, reply: FastifyReply) {
323
336
  // (sprint 121) a session from before the developer's language was kept in it: the stored choice (language.ts)
324
337
  if (s.state.__BLANG === undefined) await loadLanguage(s);
325
338
  setRequestTranslator(tr(s)); // (sprint 164) scopedTx in this request's handler and helpers
339
+ s.path = req.url; // (UX-3) the rail's section
326
340
  // (sprint 126, SEC-2) the two-factor policy: a banner during the grace period, then only the Security page until they enrol
327
341
  delete s.state.__MFA_DUE;
328
342
  const admin = await isAdmin(s.username);
@@ -561,6 +561,7 @@ async function worksheet(req: Req, reply: FastifyReply) {
561
561
 
562
562
  const toolBtn = (m: RunMode, ic: string, label: string, key: string, hot = false) =>
563
563
  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>`;
564
+ const runsAsText = `${tx('ws.runs_as', { who: String(runsAs) })}${searchSchema ? `; ${tx('ws.search_path_plain', { schema: searchSchema })}` : ''}`;
564
565
  const main = html`${chartCss ? html`<style nonce="${req.cspNonce}">${raw(chartCss.replace(/</g, ''))}</style>` : ''}<div class="ws-page">
565
566
  <h1 class="sr-only">${tx('ws.title')}</h1>
566
567
  ${workshopTabs('sql', s)}
@@ -615,20 +616,9 @@ async function worksheet(req: Req, reply: FastifyReply) {
615
616
  ${toolBtn('script', 'file', tx('ws.run_script'), 'F5')}
616
617
  ${toolBtn('explain', 'route', tx('ws.tab.explain'), 'F10')}
617
618
  ${toolBtn('autotrace', 'activity', tx('ws.tab.autotrace'), 'F6')}
618
- <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> ${tx('ws.continue')}</label>
619
+ <button type="button" class="btn btn-sm btn-icon" data-ws-suggest title="${tx('ws.suggest_title')}" hidden>${icon('list')}<span class="sr-only">${tx('ws.suggest')}</span></button>
620
+ <button type="button" class="btn btn-sm btn-icon" data-ws-check title="${tx('ws.check_title')}" hidden>${icon('check-circle')}<span class="sr-only">${tx('ws.check')}</span></button>
619
621
  <span class="ws-spacer"></span>
620
- <button type="button" class="btn btn-sm btn-icon" data-ws-format title="${tx('ws.format_title')}" hidden>${icon('sliders')}<span class="sr-only">${tx('ws.format')}</span></button>
621
- <button type="button" class="btn btn-sm btn-icon" data-ws-case title="${tx('ws.case_title')}" hidden><span aria-hidden="true" class="ws-aa">Aa</span><span class="sr-only">${tx('ws.case')}</span></button>
622
- <button type="button" class="btn btn-sm btn-icon" data-ws-find title="${tx('ws.find_title')}" hidden>${icon('search')}<span class="sr-only">${tx('ws.find')}</span></button>
623
- <a class="btn btn-sm btn-icon" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="${tx('ws.load_title')}">${icon('upload')}<span class="sr-only">${tx('ws.load')}</span></a>
624
- <details class="menu ws-keys"><summary class="btn btn-sm btn-icon" title="${tx('ws.keys')}">?<span class="sr-only">${tx('ws.keys')}</span></summary>
625
- <div class="menu-panel align-right"><div class="menu-section"><dl class="ws-keylist">
626
- <dt>Ctrl+Enter</dt><dd>${tx('ws.run_statement')}</dd><dt>F5</dt><dd>${tx('ws.run_script')}</dd><dt>F10</dt><dd>${tx('ws.tab.explain')}</dd><dt>F6</dt><dd>${tx('ws.tab.autotrace')}</dd>
627
- <dt>Ctrl+S</dt><dd>${tx('ws.key.save')}</dd><dt>Ctrl+F7</dt><dd>${tx('ws.format')}</dd><dt>Ctrl+B</dt><dd>${tx('ws.case')}</dd><dt>Ctrl+F / Ctrl+H</dt><dd>${tx('ws.key.find')}</dd>
628
- <dt>Ctrl+G</dt><dd>${tx('ws.key.goto')}</dd><dt>Ctrl+/</dt><dd>${tx('ws.key.comment')}</dd><dt>Ctrl+Space</dt><dd>${tx('ws.key.suggest')}</dd><dt>F1</dt><dd>${tx('ws.key.help')}</dd><dt>Escape</dt><dd>${tx('ws.key.fullscreen')}</dd>
629
- </dl>
630
- <label class="ws-kwcase" hidden>${tx('ws.kwcase')} <select data-ws-kwcase><option value="lower">${tx('ws.lower')}</option><option value="upper">${tx('ws.upper')}</option></select></label>
631
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>${tx('ws.help')}</a></div></div></details>
632
622
  <span class="ws-txn" data-ws-txn data-state="${tState}" data-statements="${txn?.statements ?? 0}" data-started="${txn?.startedMs ?? ''}" data-last="${txn?.lastMs ?? ''}" data-idle="${txnIdleSeconds()}" data-note="${tNote ?? ''}" data-now="${Date.now()}" hidden>
633
623
  <label class="ws-runas"><span>${tx('ws.commit_label')}</span><select name="txn" data-ws-txn-mode title="${tx('ws.txn.mode_title')}">
634
624
  <option value="">${tx('ws.autocommit')}</option><option value="on"${txnOn ? raw(' selected') : ''}>${tx('ws.transaction')}</option></select></label>
@@ -636,17 +626,25 @@ async function worksheet(req: Req, reply: FastifyReply) {
636
626
  <button class="btn btn-sm" name="mode" value="commit" data-ws-txn-commit title="${tx('ws.txn.commit_title')}"${txn ? '' : raw(' disabled')}>${icon('check')}<span>${tx('ws.commit_label')}</span></button>
637
627
  <button class="btn btn-sm" name="mode" value="rollback" data-ws-txn-rollback title="${tx('ws.txn.rollback_title')}"${txn ? '' : raw(' disabled')}>${icon('undo')}<span>${tx('ws.rollback')}</span></button>
638
628
  </span>
639
- <label class="ws-runas"><span>${tx('ws.run_as')}</span>
640
- <select name="as_app" title="${tx('ws.run_as_title')}">
641
- <option value="">${db === owner ? tx('ws.owner_conn') : tx('ws.ws_login')}</option>
642
- ${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
643
- </select></label>
644
- <button type="button" class="btn btn-sm btn-icon" data-ws-clear title="${tx('ws.clear_title')}" hidden>${icon('trash')}<span class="sr-only">${tx('ws.clear')}</span></button>
645
629
  <button type="button" class="btn btn-sm btn-icon" data-ws-fullscreen title="${tx('ws.fullscreen_title')}" aria-pressed="false" hidden>${bicon('expand')}<span class="sr-only">${tx('ws.fullscreen')}</span></button>
630
+ <!-- (UX-1) the rest of the tools in one menu, so the toolbar is one row and the editor keeps its height -->
631
+ <details class="menu ws-more"><summary class="btn btn-sm btn-icon" title="${tx('ws.more')}"><span aria-hidden="true" class="ws-dots">⋯</span><span class="sr-only">${tx('ws.more')}</span></summary>
632
+ <div class="menu-panel align-right"><div class="menu-section ws-more-tools">
633
+ <label class="ws-check"><input type="checkbox" name="continue_on_error"${continueOnError ? raw(' checked') : ''}> ${tx('ws.continue')}</label>
634
+ <button type="button" class="ws-menu-item" data-ws-format title="${tx('ws.format_title')}" hidden>${icon('sliders')} ${tx('ws.format')} <kbd>Ctrl+F7</kbd></button>
635
+ <button type="button" class="ws-menu-item" data-ws-case title="${tx('ws.case_title')}" hidden><span aria-hidden="true" class="ws-aa">Aa</span> ${tx('ws.case')} <kbd>Ctrl+B</kbd></button>
636
+ <button type="button" class="ws-menu-item" data-ws-find title="${tx('ws.find_title')}" hidden>${icon('search')} ${tx('ws.find')} <kbd>Ctrl+F</kbd></button>
637
+ <a class="ws-menu-item" href="${BASE}/sql/load?schema=${encodeURIComponent(navSchema)}" title="${tx('ws.load_title')}">${icon('upload')} ${tx('ws.load')}</a>
638
+ <button type="button" class="ws-menu-item" data-ws-clear title="${tx('ws.clear_title')}" hidden>${icon('trash')} ${tx('ws.clear')}</button>
639
+ </div>
640
+ <div class="menu-section"><strong class="ws-more-head">${tx('ws.keys')}</strong><dl class="ws-keylist">
641
+ <dt>Ctrl+Enter</dt><dd>${tx('ws.run_statement')}</dd><dt>F5</dt><dd>${tx('ws.run_script')}</dd><dt>F10</dt><dd>${tx('ws.tab.explain')}</dd><dt>F6</dt><dd>${tx('ws.tab.autotrace')}</dd>
642
+ <dt>Ctrl+S</dt><dd>${tx('ws.key.save')}</dd><dt>Ctrl+F7</dt><dd>${tx('ws.format')}</dd><dt>Ctrl+B</dt><dd>${tx('ws.case')}</dd><dt>Ctrl+F / Ctrl+H</dt><dd>${tx('ws.key.find')}</dd>
643
+ <dt>Ctrl+G</dt><dd>${tx('ws.key.goto')}</dd><dt>Ctrl+/</dt><dd>${tx('ws.key.comment')}</dd><dt>Ctrl+Space</dt><dd>${tx('ws.key.suggest')}</dd><dt>Tab</dt><dd>${tx('ws.key.indent')}</dd><dt>F1</dt><dd>${tx('ws.key.help')}</dd><dt>Escape</dt><dd>${tx('ws.key.fullscreen')}</dd>
644
+ </dl>
645
+ <label class="ws-kwcase" hidden>${tx('ws.kwcase')} <select data-ws-kwcase><option value="lower">${tx('ws.lower')}</option><option value="upper">${tx('ws.upper')}</option></select></label>
646
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#sql-workshop" target="_blank" rel="noopener" data-ws-help>${tx('ws.help')}</a></div></div></details>
646
647
  </div>
647
- <p class="ws-runs-as muted small">${tx('ws.runs_as', { who: runsAs })}${searchSchema ? html`; ${tx.rich('ws.search_path', { schema: html`<code>${searchSchema}</code>`, change: html`<a href="${BASE}/sql">${tx('ws.change')}</a>` })}` : ''}. <span data-ws-commit-note>${txnOn
648
- ? tx('ws.commit.txn', { idle: idleLabel(tx) })
649
- : tx('ws.commit.auto')}</span></p>
650
648
  ${fileError ? html`<p class="alert alert-error ws-file-error" role="alert">${fileError}</p>` : ''}
651
649
  ${txnError ? html`<p class="alert alert-error ws-txn-alert" role="alert">${txnError}</p>` : txnInfo ? html`<p class="alert alert-info ws-txn-alert" role="status">${txnInfo}</p>` : ''}
652
650
  <div class="ws-wtabs" role="group" aria-label="${tx('ws.worksheets')}" hidden></div>
@@ -666,12 +664,23 @@ async function worksheet(req: Req, reply: FastifyReply) {
666
664
  <textarea id="ws-sql" name="sql" aria-label="SQL" class="code sql-editor" rows="14" spellcheck="false" data-code="plpgsql">${text || (isPost ? '' : 'select current_user, version();')}</textarea>
667
665
  </div>
668
666
  <div class="ws-split ws-split-h" role="separator" aria-orientation="horizontal" aria-label="${tx('ws.resize_out')}" tabindex="0" data-ws-split="out" hidden></div>
669
- <div class="ws-output" data-active="${active}">
667
+ <div class="ws-output" data-active="${active}"${!run && !req.query?.tab ? raw(' data-empty') : ''}>
670
668
  <div class="ws-tabs" role="tablist" aria-label="${tx('ws.output')}">${TABS.map(([k]) => html`<button type="button" role="tab" id="ws-tab-${k}" aria-controls="ws-pane-${k}" aria-selected="${k === active ? 'true' : 'false'}" data-ws-tab="${k}">${tx(`ws.tab.${k}`)}</button>`)}</div>
671
669
  ${TABS.map(([k]) => html`<section class="ws-pane" id="ws-pane-${k}" role="tabpanel" aria-labelledby="ws-tab-${k}" data-pane="${k}"><h2 class="ws-pane-title">${tx(`ws.tab.${k}`)}</h2>${panes[k]}</section>`)}
672
670
  </div>
673
671
  </section>
674
- <footer class="ws-status" role="status" aria-live="polite">${status}</footer>
672
+ <footer class="ws-status">
673
+ <span class="ws-status-run" role="status" aria-live="polite">${status}</span>
674
+ <!-- (UX-1) who the statements run as: a chip in the status bar; the explanation is its description -->
675
+ <label class="ws-runas ws-runas-chip" title="${runsAsText}"><span>${tx('ws.run_as')}</span>
676
+ <select name="as_app" aria-describedby="ws-runs-as">
677
+ <option value="">${db === owner ? tx('ws.owner_conn') : tx('ws.ws_login')}</option>
678
+ ${apps.map((a) => html`<option value="${a.id}"${asApp?.id === a.id ? raw(' selected') : ''}>${a.name} (${a.db_role})</option>`)}
679
+ </select></label>
680
+ <span class="sr-only" id="ws-runs-as">${tx('ws.runs_as', { who: runsAs })}${searchSchema ? html`; ${tx('ws.search_path_plain', { schema: searchSchema })}` : ''}. <span data-ws-commit-note>${txnOn
681
+ ? tx('ws.commit.txn', { idle: idleLabel(tx) })
682
+ : tx('ws.commit.auto')}</span></span>
683
+ </footer>
675
684
  </form>
676
685
  ${downloadable ? html`<form method="post" action="${BASE}/sql/unload" id="ws-dl" class="ws-dl-form">${csrf(s)}<input type="hidden" name="source" value="query"><input type="hidden" name="query" value="${downloadable}"><input type="hidden" name="header" value="1"><input type="hidden" name="bom" value="1"></form>` : ''}
677
686
  </div>`;
@@ -121,6 +121,22 @@ async function systemMessage(s: Session, admin: boolean) {
121
121
  return region(tx('wh.system'), html`${checks}${body}${admins}`, '', 'system-message');
122
122
  }
123
123
 
124
+ /** (UX-13) What an administrator must see first: lockout risks and a newer pgkiln, above the tools (details: System message). */
125
+ async function attention(s: Session, admin: boolean) {
126
+ if (!admin) return '';
127
+ const tx = tr(s);
128
+ const lockout = await attentionCount();
129
+ const check = await releaseCheckEnabled();
130
+ const r = check.on ? cachedRelease()?.release : null;
131
+ const newer = r && newerVersion(r.version, VERSION) ? r : null;
132
+ const items = [
133
+ lockout ? html`<li>${icon('alert')} ${tx('wh.lockout', { count: lockout })} <a href="${BASE}/instance#lockout-checks">${tx('wh.lockout_open')}</a></li>` : '',
134
+ newer ? html`<li>${icon('bell')} ${tx('wh.available', { version: newer.version })} <a href="#system-message">${tx('wh.release_notes')}</a></li>` : '',
135
+ ].filter(Boolean);
136
+ return items.length ? html`<section class="alert alert-warning wh-attention" role="status" aria-labelledby="wh-attention-h">
137
+ <h2 id="wh-attention-h">${tx('wh.attention')}</h2><ul>${items}</ul></section>` : '';
138
+ }
139
+
124
140
  /** (Instance settings page) the release check: on, off, or as the environment says. */
125
141
  export async function releaseCheckRegion(s: Session) {
126
142
  const check = await releaseCheckEnabled();
@@ -194,6 +210,7 @@ export async function workspaceHomeRoutes(app: FastifyInstance) {
194
210
  const main = html`<div class="ab-home wh-home">
195
211
  <div class="ab-main">
196
212
  <h1 class="wh-title">${s.workspace ? tx('wh.title_ws', { name: s.workspace.name }) : tx('wh.title')}</h1>
213
+ ${await attention(s, admin)}
197
214
  <nav class="ab-tiles ab-tiles-wrap wh-tools" aria-label="${tx('wh.tools')}">
198
215
  ${card(`${BASE}/apps`, 'grid', tx('rail.app_builder'), tx('wh.card.apps'))}
199
216
  ${sql ? card(`${BASE}/sql`, 'database', tx('rail.sql_workshop'), tx('wh.card.sql')) : ''}
package/src/session.ts CHANGED
@@ -36,6 +36,8 @@ export interface Session {
36
36
  /** Builder only: the developer's workspaces and the current one (src/builder/workspaces.ts). */
37
37
  workspaces?: BuilderWorkspace[];
38
38
  workspace?: BuilderWorkspace | null;
39
+ /** Builder only (UX-3): this request's path, so the rail marks the right section */
40
+ path?: string;
39
41
  /** Builder only (sprint 99): an administrator of the installation (the Administration menu's Database users) */
40
42
  instanceAdmin?: boolean;
41
43
  /** Builder only (079): sha256 of the developer toolbar token (src/runtime/devbar.ts). */