pgkiln 0.87.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/public/sw.js CHANGED
@@ -4,6 +4,8 @@
4
4
  // (offline, the cached copy of another version)
5
5
  // pages (navigations): network first; offline, the cached copy (only when the app keeps pages) or the offline page
6
6
  // signing in or out empties the page cache, so the next person on the device doesn't see them
7
+ // (168) with prefetchHours: the pages app.js hands over (the navigation's) are fetched one by one and kept, again
8
+ // when the copies are older than prefetchHours (a stamp in the page cache, so a sign-in starts over)
7
9
  // push notifications (migration 074): shown as they arrive, a click opens their page of the app;
8
10
  // signing out turns them off on this device
9
11
  /* global PGKILN */
@@ -77,10 +79,58 @@ self.addEventListener('message', (event) => {
77
79
  caches
78
80
  .open(PAGES)
79
81
  .then((c) => c.keys())
80
- .then((keys) => event.source.postMessage({ type: 'pgkiln:pages', pages: keys.map((k) => k.url) }));
82
+ .then((keys) => event.source.postMessage({ type: 'pgkiln:pages', pages: keys.map((k) => k.url).filter((u) => new URL(u).pathname !== STAMP) }));
81
83
  }
84
+ if (event.data && event.data.type === 'pgkiln:prefetch' && Array.isArray(event.data.urls)) event.waitUntil(prefetchPages(event.data.urls));
82
85
  });
83
86
 
87
+ // ------------------------------------------------------------------ every page offline (168, PGKILN.prefetchHours)
88
+ // The server answers a request with X-Pgkiln-Prefetch read-only, keeps no session state, and refuses (409) pages
89
+ // that run processes when they open; only 200 pages are kept. One page at a time, a short pause between them;
90
+ // the stamp is written when the list is done, so a list cut short by the network is tried again next time.
91
+ const STAMP = `${PGKILN.base}/__prefetched`;
92
+ const MAX_PREFETCH = 60;
93
+ let prefetching = false;
94
+ async function prefetchPages(urls) {
95
+ if (!PGKILN.offlinePages || !PGKILN.prefetchHours || prefetching) return;
96
+ prefetching = true;
97
+ try {
98
+ const cache = await caches.open(PAGES);
99
+ const stamp = await cache.match(STAMP);
100
+ if (stamp && Date.now() - Number(await stamp.text()) < PGKILN.prefetchHours * 3600 * 1000) return;
101
+ const seen = new Set();
102
+ let kept = 0;
103
+ for (const u of urls) {
104
+ if (seen.size >= MAX_PREFETCH) break;
105
+ let url;
106
+ try {
107
+ url = new URL(u, self.location.origin);
108
+ } catch {
109
+ continue;
110
+ }
111
+ url.hash = '';
112
+ if (url.origin !== self.location.origin || !url.pathname.startsWith(`${PGKILN.base}/`) || signInOrOut(url) || seen.has(url.href)) continue;
113
+ seen.add(url.href);
114
+ let res;
115
+ try {
116
+ res = await fetch(url.href, { credentials: 'same-origin', redirect: 'manual', headers: { 'X-Pgkiln-Prefetch': '1' } });
117
+ } catch {
118
+ return; // offline: again on the next page
119
+ }
120
+ if (res.status === 200 && (res.headers.get('content-type') || '').startsWith('text/html')) {
121
+ await cache.put(new Request(url.href), res);
122
+ kept++;
123
+ }
124
+ await new Promise((r) => setTimeout(r, 150));
125
+ }
126
+ if (!seen.size) return;
127
+ await cache.put(STAMP, new Response(String(Date.now())));
128
+ for (const c of await self.clients.matchAll({ type: 'window' })) c.postMessage({ type: 'pgkiln:prefetched', kept });
129
+ } finally {
130
+ prefetching = false;
131
+ }
132
+ }
133
+
84
134
  // ------------------------------------------------------------------ offline form queue (PGKILN.offlineSubmit)
85
135
  // A form posted while the network is down is kept on the device (IndexedDB, files included) and the
86
136
  // browser shows the page again with ?queued=1. It is sent later (on "online", when a page of the app
@@ -3,7 +3,7 @@
3
3
  screens: navigator | editor over output tabs, filling the window, with a status bar at the bottom. */
4
4
  .ws-page { display: flex; flex-direction: column; padding: 8px 10px 0; background: var(--bg); }
5
5
  .ws-page > .sw-tabs { margin-bottom: 6px; }
6
- .ws { --ws-nav: 280px; --ws-out: 42vh; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; background: var(--surface);
6
+ .ws { --ws-nav: 280px; --ws-out: 45%; display: grid; grid-template-columns: minmax(0, 1fr); gap: 0; background: var(--surface);
7
7
  border: 1px solid var(--border); border-radius: var(--radius); min-width: 0; }
8
8
  .ws-default { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); border: 0; padding: 0; }
9
9
  .ws-nav { display: flex; flex-direction: column; min-width: 0; border-bottom: 1px solid var(--border); background: var(--surface-2); }
@@ -32,7 +32,7 @@
32
32
  .ws-obj-actions { display: flex; flex-wrap: wrap; gap: 4px; padding: 4px 10px 8px 34px; }
33
33
  .ws-obj-actions .btn { font-size: 11px; padding: 1px 6px; }
34
34
 
35
- .ws-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; }
35
+ .ws-main { display: flex; flex-direction: column; min-width: 0; min-height: 0; container-type: inline-size; }
36
36
  .ws-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 6px 8px; border-bottom: 1px solid var(--border); }
37
37
  .ws-toolbar .btn { display: inline-flex; align-items: center; gap: 4px; }
38
38
  .ws-toolbar .icon { width: 15px; height: 15px; }
@@ -99,12 +99,16 @@
99
99
  .js .ide-full .ws-editor .ce { flex: 1 1 auto; min-height: 0; grid-template-rows: minmax(0, 1fr) auto; border: 0; border-radius: 0; }
100
100
  .js .ide-full .ws-editor .ce-main { min-height: 0; }
101
101
  .js .ide-full .ws-editor .ce textarea.ce-input { height: 100%; min-height: 0; resize: none; }
102
- .js .ide-full .ws-output { flex: 0 0 var(--ws-out); }
102
+ .js .ide-full .ws-output { flex: 0 1 var(--ws-out); }
103
+ /* (UX-1) the editor keeps at least ten lines; an empty output is only its tab strip until a run */
104
+ .js .ide-full .ws-editor { min-height: 13.5rem; }
105
+ .js .ide-full .ws-output[data-empty] { flex: 0 0 auto; }
106
+ .js .ide-full .ws-output[data-empty] .ws-pane { display: none; }
103
107
  .js .ide-full .ws-pane { flex: 1 1 auto; }
104
108
  .js .ide-full .ws-nav { overflow: hidden; }
105
109
  }
106
110
  @media (max-width: 899px) {
107
- .ws-toolbar .btn span:not(.sr-only) { display: none; }
111
+ .ws-toolbar .btn span:not(.sr-only):not(.ws-dots) { display: none; }
108
112
  .ws-toolbar .btn-hot span:not(.sr-only) { display: inline; }
109
113
  }
110
114
  /* fullscreen: only the editor and its toolbar */
@@ -299,3 +303,34 @@ details.menu.obj-menu > summary::before, details.menu.obj-menu > summary::after
299
303
  .ws-txn-badge[data-state="open"] { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--border)); background: color-mix(in srgb, var(--warn) 10%, transparent); font-weight: 600; }
300
304
  .ws-txn-badge[data-state="aborted"] { color: var(--danger); border-color: var(--danger); background: var(--danger-soft); font-weight: 600; }
301
305
  .ws-txn-alert { margin: 6px 8px; }
306
+
307
+ /* (UX-1) one toolbar row: the run buttons, Suggest / Check, Commit mode, fullscreen and a ⋯ menu with the rest */
308
+ /* it fits on one row; an open transaction (badge, Commit, Rollback) may take a second one */
309
+ .ws-toolbar > * { flex: none; }
310
+ /* the commit mode's options name it (Autocommit / Transaction): no label beside the select */
311
+ .ws-toolbar .ws-txn .ws-runas > span { display: none; }
312
+ @container (max-width: 950px) {
313
+ .ws-toolbar .btn:not(.btn-hot) > span:not(.sr-only):not(.ws-dots) { display: none; }
314
+ }
315
+ @container (max-width: 640px) {
316
+ .ws-toolbar > * { flex: 0 1 auto; min-width: 0; }
317
+ .ws-toolbar .ws-txn { flex-wrap: wrap; }
318
+ }
319
+ .ws-more .menu-panel { min-width: 18rem; max-height: calc(100dvh - 190px); overflow: auto; }
320
+ /* with script the worksheet tabs name the file: the file menu is its icon */
321
+ .js .ws-toolbar .ws-file-name { display: none; }
322
+ .ws-more-tools { display: flex; flex-direction: column; gap: 2px; }
323
+ .ws-menu-item { display: flex; align-items: center; gap: 8px; width: 100%; padding: 6px 8px; border: 0; border-radius: 6px; background: none;
324
+ color: var(--text); font: inherit; text-align: left; text-decoration: none; cursor: pointer; }
325
+ .ws-menu-item:hover, .ws-menu-item:focus-visible { background: var(--surface-2); }
326
+ .ws-menu-item .icon { width: 15px; height: 15px; }
327
+ .ws-menu-item kbd { margin-left: auto; font-size: 11.5px; color: var(--muted); }
328
+ .ws-more-tools .ws-check { padding: 6px 8px; }
329
+ .ws-more-head { display: block; margin-bottom: 4px; }
330
+ /* the code editor's hint bar under the editor: only while it has something to say (Check's result) */
331
+ .ws-editor .ce-bar:not(:has(.ce-msg:not(:empty))) { display: none; }
332
+ .ws-editor .ce-bar :is(.ce-hint, .ce-suggest, .ce-check) { display: none; }
333
+ /* (UX-1) who the statements run as: a chip at the right of the status bar */
334
+ .ws-status-run { display: flex; align-items: center; gap: 12px; min-width: 0; flex: 1 1 auto; }
335
+ .ws-runas-chip { flex: none; display: inline-flex; align-items: center; gap: 6px; }
336
+ .ws-runas-chip select { padding: 1px 22px 1px 6px; min-height: 0; font-size: 12.5px; }
@@ -104,11 +104,15 @@
104
104
  out.dataset.active = key;
105
105
  };
106
106
  show(out.dataset.active || 'result');
107
+ // (UX-1) before a run the output is only its tab strip; choosing a tab opens it
107
108
  out.querySelector('[role=tablist]').addEventListener('click', (e) => {
108
109
  const t = e.target.closest('[role=tab]');
109
- if (t) show(t.dataset.wsTab);
110
+ if (!t) return;
111
+ delete out.dataset.empty;
112
+ show(t.dataset.wsTab);
110
113
  });
111
114
  out.querySelector('[role=tablist]').addEventListener('keydown', (e) => {
115
+ delete out.dataset.empty;
112
116
  const k = tabs.findIndex((t) => t.getAttribute('aria-selected') === 'true');
113
117
  const next = { ArrowRight: k + 1, ArrowLeft: k - 1, Home: 0, End: tabs.length - 1 }[e.key];
114
118
  if (next === undefined) return;
@@ -552,6 +556,16 @@
552
556
  (k === 'h' ? fr : k === 'g' ? bar.querySelector('[data-ws-goto]') : fq).focus();
553
557
  };
554
558
  form.querySelector('[data-ws-find]').addEventListener('click', () => openFind('f'));
559
+ // (UX-1) Suggest and Check in the toolbar: the code editor's own buttons (its bar below the editor stays hidden
560
+ // until it has a message, such as Check's result)
561
+ const ceButton = (cls) => form.querySelector(`.ws-editor .${cls}`);
562
+ for (const [attr, cls] of [['data-ws-suggest', 'ce-suggest'], ['data-ws-check', 'ce-check']]) {
563
+ const btn = form.querySelector(`[${attr}]`);
564
+ btn?.addEventListener('click', () => ceButton(cls)?.click());
565
+ const sync = () => { if (btn) btn.hidden = !ceButton(cls) || ceButton(cls).hidden; };
566
+ sync();
567
+ window.addEventListener('load', sync);
568
+ }
555
569
  form.querySelector('[data-ws-format]').addEventListener('click', format);
556
570
  form.querySelector('[data-ws-case]').addEventListener('click', convertCase);
557
571
  bar.addEventListener('keydown', (e) => {
@@ -123,7 +123,7 @@ export async function designerRoutes(app: FastifyInstance) {
123
123
  // (sprint 90) a commented-out component: struck through in the tree, Uncomment in its menu
124
124
  const commented = (r: any) => (r.build_option === 'COMMENTED_OUT' ? raw(' data-commented') : '');
125
125
  const leaf = (k: string, r: any, ic: Raw | '', label: string, note = '') =>
126
- html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
126
+ html`<li><a class="pd-node" href="${url(`c=${k}-${r.id}`)}"${cur(`${k}-${r.id}`)} data-kind="${k}" data-id="${r.id}" title="${note ? `${label} (${note})` : label}"${commented(r)}>${ic}<span class="pd-label">${label}</span>${badges(r)}${note ? html`<span class="pd-note">${note}</span>` : ''}</a></li>`;
127
127
  const folder = (label: string, children: Raw[] | Raw, open = true) =>
128
128
  html`<li${open ? '' : raw(' data-collapsed')}><span class="pd-node pd-folder"><span class="pd-label">${label}</span></span><ul>${children}</ul></li>`;
129
129
  const none = (text: string) => html`<li class="pd-empty">${text}</li>`;
@@ -136,7 +136,7 @@ export async function designerRoutes(app: FastifyInstance) {
136
136
  const bySlot = ['region', ...TOP_SLOTS, ...BOTTOM_SLOTS].map((sl) => [sl, buttons.filter((b) => (b.slot ?? 'region') === sl)] as const).filter(([, list]) => list.length);
137
137
  const kids = [...(items.length ? [folder(tx('pd.tree.items'), items.map(itemLeaf))] : []),
138
138
  ...bySlot.map(([sl, list]) => folder(sl === 'region' ? tx('pd.tree.buttons') : tx('pd.tree.buttons_at', { slot: slotLabel(sl) }), list.map(buttonLeaf)))];
139
- return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
139
+ return html`<li><a class="pd-node" href="${url(`c=region-${r.id}`)}"${cur(`region-${r.id}`)} data-kind="region" data-id="${r.id}" data-position="${r.position ?? ''}" data-next="${next?.id ?? ''}" title="${regionTitle(r)} (${r.type})"${commented(r)}>${bicon(regionIcon(r.type))}<span class="pd-label">${regionTitle(r)}</span>${badges(r)}<span class="pd-note">${r.type}</span></a>${kids.length ? html`<ul>${kids}</ul>` : ''}</li>`;
140
140
  };
141
141
  // (sprint 77, APEX: the rendering tree by display position) the page template's positions, and others in use
142
142
  const treeSide = p.page_template === 'left_side_column' ? 'left' : p.page_template === 'right_side_column' ? 'right' : null;
@@ -9,7 +9,7 @@ import { migrate } from '../migrate.ts';
9
9
  import { clientIp, logActivity } from '../session.ts';
10
10
  import { openBlueprint } from './blueprints.ts';
11
11
  import { isAdmin } from './locks.ts';
12
- import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.ts';
12
+ import { back, BASE, csrf, developer, flash, pageHeader, send, shell, type Req } from './ui.ts';
13
13
  import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
14
14
  import { workspacesChanged } from '../workspace-db.ts';
15
15
  import { scopedTx as tx, type BuilderKey } from './i18n.ts';
@@ -104,7 +104,7 @@ export async function galleryRoutes(app: FastifyInstance) {
104
104
  const tabs = html`<nav class="gal-tabs segmented" aria-label="${tx('gal.kinds')}">${TABS.map(([t, l]) =>
105
105
  html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${tx(l)} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
106
106
  const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
107
- html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">${tx('gal.install')}</button></form>`);
107
+ html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-sm">${tx('gal.install')}</button></form>`);
108
108
  const cards =
109
109
  tab === 'samples'
110
110
  ? samples.filter(matches).map((x) => {
@@ -115,13 +115,13 @@ export async function galleryRoutes(app: FastifyInstance) {
115
115
  ? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">${tx('gal.edit')}</a>`
116
116
  : html`<span class="muted small">${tx('gal.other_workspace')}</span>`}`
117
117
  : admin
118
- ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
118
+ ? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-sm" data-confirm="${tx('gal.confirm_install', { title: x.title })}">${tx('gal.install')}</button></form>`
119
119
  : html`<span class="muted small">${tx('gal.admin_installs')}</span>`);
120
120
  })
121
121
  : tab === 'starters' || tab === 'utilities'
122
122
  ? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
123
123
  : custom.filter(matches).map((x) => card('copy', x.name, tx('gal.this_workspace'), x.description ?? tx('gal.boilerplate_desc'),
124
- html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
124
+ html`<a class="btn btn-sm" href="${BASE}/create?boilerplate=${x.id}">${tx('gal.create_from')}</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">${tx('gal.edit')}</a>`));
125
125
  const empty = q && counts[tab]
126
126
  ? html`<p class="muted">${tx.rich('gal.no_match', { q: html`<strong>${q}</strong>` })} <a href="${BASE}/gallery?tab=${tab}">${tx('gal.show_all')}</a></p>`
127
127
  : tab === 'custom'
@@ -129,13 +129,13 @@ export async function galleryRoutes(app: FastifyInstance) {
129
129
  : html`<p class="muted">${tx('gal.empty')}</p>`;
130
130
  const intro = tx(({ samples: 'gal.intro.samples', starters: 'gal.intro.starters', utilities: 'gal.intro.utilities', custom: 'gal.intro.custom' } as const)[tab]);
131
131
  const main = html`<div class="gal">
132
+ ${pageHeader(tx('rail.gallery'))}
132
133
  <div class="gal-bar">
133
134
  <form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">${tx('gal.search')}</label>${icon('search')}
134
135
  <input type="hidden" name="tab" value="${tab}">
135
136
  <input id="gal-q" name="q" type="search" placeholder="${tx('gal.search')}" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
136
137
  ${tabs}
137
138
  </div>
138
- <h1 class="sr-only">${tx('rail.gallery')}</h1>
139
139
  <p class="muted">${intro}</p>
140
140
  ${cards.length ? html`<div class="gal-grid">${cards}</div>` : empty}
141
141
  </div>`;
@@ -2,7 +2,7 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
2
2
  import { owner } from '../db.ts';
3
3
  import { html, raw, type Raw } from '../html.ts';
4
4
  import { icon } from '../icons.ts';
5
- import { APP_COLORS, BASE, csrf, developer, input, region, send, shell, type Req } from './ui.ts';
5
+ import { APP_COLORS, BASE, csrf, developer, input, pageHeader, region, send, shell, type Req } from './ui.ts';
6
6
  import { crossAppLinks } from './cross-app.ts';
7
7
  import type { Session } from '../session.ts';
8
8
  import { isAdmin } from './locks.ts';
@@ -53,16 +53,6 @@ const lockedText = (tx: BuilderTranslate, by: string) => tx('ab.locked_by', { us
53
53
  const appIcon = (a: { id: number; name: string }, cls = '') =>
54
54
  html`<span class="app-icon app-color-${a.id % APP_COLORS}${cls ? ` ${cls}` : ''}" aria-hidden="true">${a.name.slice(0, 1).toUpperCase()}</span>`;
55
55
 
56
- /** The four tiles at the top of the App Builder. */
57
- const tiles = (tx: BuilderTranslate) => html`<nav class="ab-tiles" aria-label="${tx('rail.app_builder')}">
58
- ${([
59
- ['create', 'plus', tx('ab.tile.create'), tx('ab.tile.create_text')],
60
- ['import', 'upload', tx('ab.tile.import'), tx('ab.tile.import_text')],
61
- ['dashboard', 'activity', tx('ab.tile.dashboard'), tx('ab.tile.dashboard_text')],
62
- ['utilities', 'settings', tx('ab.tile.utilities'), tx('ab.tile.utilities_text')],
63
- ] as const).map(([path, ic, title, text]) => html`<a class="ab-tile" href="${BASE}/${path}"><span class="ab-tile-icon">${icon(ic)}</span><strong>${title}</strong><span>${text}</span></a>`)}
64
- </nav>`;
65
-
66
56
  /** The side column: About, Recent, Tasks. */
67
57
  function aside(tx: BuilderTranslate, recent: { id: number; name: string }[], about: string, tasks: [string, string][]) {
68
58
  return html`<aside class="ab-side" aria-label="${tx('ab.aside')}">
@@ -73,10 +63,9 @@ function aside(tx: BuilderTranslate, recent: { id: number; name: string }[], abo
73
63
  </aside>`;
74
64
  }
75
65
 
66
+ // (UX-5) what the header doesn't offer already (Create and Import are there)
76
67
  const TASKS: [BuilderKey, string][] = [
77
- ['ab.task.create', `${BASE}/create`],
78
68
  ['ab.task.create_file', `${BASE}/create/file`],
79
- ['ab.task.import', `${BASE}/import`],
80
69
  ['ab.task.users', `${BASE}/users`],
81
70
  ['ab.task.providers', `${BASE}/users/providers`],
82
71
  ['rail.sql_workshop', `${BASE}/sql`],
@@ -174,9 +163,13 @@ export async function homeRoutes(app: FastifyInstance) {
174
163
  html`<a class="tb-btn" href="${link({ view: v })}" title="${label}"${view === v ? raw(' aria-current="true"') : ''}>${icon(ic)}<span class="sr-only">${label}</span></a>`;
175
164
  const main = html`<div class="ab-home">
176
165
  <div class="ab-main">
177
- <h1 class="sr-only">${tx('rail.app_builder')}</h1>
166
+ ${pageHeader(tx('rail.app_builder'), {
167
+ primary: [`${BASE}/create`, tx('ab.tile.create'), 'plus'],
168
+ secondary: [[`${BASE}/import`, tx('ab.tile.import'), 'upload']],
169
+ more: [[`${BASE}/dashboard`, tx('ab.tile.dashboard'), 'activity'], [`${BASE}/utilities`, tx('rail.admin'), 'settings']],
170
+ moreLabel: tx('ws.more'),
171
+ })}
178
172
  ${s.workspace?.message ? html`<div class="alert alert-info ab-message" role="status">${icon('bell')}<div><strong>${s.workspace.name}</strong><p>${s.workspace.message}</p></div></div>` : ''}
179
- ${tiles(tx)}
180
173
  <div class="ab-toolbar">
181
174
  <form class="ab-search" method="get" action="${BASE}/apps" role="search">
182
175
  <label class="sr-only" for="ab-q">${tx('ab.search_label')}</label>${icon('search')}
@@ -190,13 +183,8 @@ export async function homeRoutes(app: FastifyInstance) {
190
183
  <div class="menu-panel" role="menu">
191
184
  <div class="menu-section"><span class="small muted">${tx('ab.sort_by')}</span>
192
185
  ${(Object.keys(SORTS) as Sort[]).map((k) => html`<a role="menuitem" href="${link({ sort: k })}"${sort === k ? raw(' aria-current="true"') : ''}>${tx(`ab.sort.${k}`)}</a>`)}</div>
193
- <div class="menu-section menu-links">
194
- <a role="menuitem" href="${BASE}/import">${icon('upload')} ${tx('ab.task.import')}</a>
195
- <a role="menuitem" href="${BASE}/dashboard">${icon('activity')} ${tx('ab.ws_dashboard')}</a>
196
- </div>
197
186
  </div></details>
198
187
  <span class="ab-count muted" aria-live="polite">${rows.length === total.n ? tx(total.n === 1 ? 'ab.count_one' : 'ab.count', { n: total.n }) : tx('ab.count_of', { n: rows.length, total: total.n })}</span>
199
- <a class="btn btn-hot ab-create" href="${BASE}/create">${tx('ab.tile.create')}</a>
200
188
  </div>
201
189
  ${list}
202
190
  </div>
@@ -271,49 +259,72 @@ export async function homeRoutes(app: FastifyInstance) {
271
259
  });
272
260
 
273
261
  // ---------------------------------------------------------------- workspace utilities
262
+ // (UX-4) Administration: the workspace utilities in sections, with a filter; the rail's Administration entry (UX-3)
274
263
  app.get(`${BASE}/utilities`, async (req: Req, reply) => {
275
264
  const s = await developer(req, reply);
276
265
  if (!s) return;
277
266
  const tx = tr(s);
278
- const card = (href: string, ic: Raw | '', title: string, text: string | Raw) =>
279
- html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${ic}</span><strong>${title}</strong><span>${text}</span></a>`;
280
- const main = html`<div class="ab-home"><div class="ab-main"><h1 class="u-mb1">${tx('wu.title')}</h1>
281
- <div class="ab-tiles ab-tiles-wrap">
282
- ${card(`${BASE}/users`, icon('user'), tx('wu.users'), tx('wu.users_text'))}
283
- ${card(`${BASE}/app-groups`, icon('folder'), tx('wu.app_groups'), tx('wu.app_groups_text'))}
284
- ${card(`${BASE}/gallery`, icon('package'), tx('wu.gallery'), tx('wu.gallery_text'))}
285
- ${card(`${BASE}/users/providers`, icon('key'), tx('wu.providers'), tx('wu.providers_text'))}
286
- ${card(`${BASE}/users/directories`, icon('org'), tx('wu.ldap'), tx('wu.ldap_text'))}
287
- ${card(`${BASE}/users#account-settings`, icon('shield'), tx('wu.password_policy'), tx('wu.password_policy_text'))}
288
- ${card(`${BASE}/developers`, icon('users'), tx('wu.developers'), tx('wu.developers_text'))}
289
- ${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), tx('wu.sql'), tx('wu.sql_text'))}
290
- ${card(`${BASE}/backups`, icon('archive'), tx('wu.backups'), tx('wu.backups_text'))}
291
- ${card(`${BASE}/workspace-files`, icon('file'), tx('wu.files'), tx('wu.files_text', { tag: '#WORKSPACE_FILES#' }))}
292
- ${card(`${BASE}/remote-servers`, icon('server'), tx('wu.servers'), tx('wu.servers_text'))}
293
- ${(await isAdmin(s.username)) ? card(`${BASE}/remote-databases`, icon('database'), tx('wu.remote_dbs'), tx('wu.remote_dbs_text')) : ''}
294
- ${card(`${BASE}/workspace-credentials`, icon('key'), tx('wu.credentials'), tx('wu.credentials_text'))}
295
- ${card(`${BASE}/rest-catalogs`, icon('globe'), tx('wu.catalogs'), tx('wu.catalogs_text'))}
296
- ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), tx('wu.export'), tx('wu.export_text')) : ''}
297
- ${card(`${BASE}/dashboard`, icon('activity'), tx('wu.dashboard'), tx('wu.dashboard_text'))}
298
- ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), tx('wu.instance'), tx('wu.instance_text')) : ''}
299
- ${(await isAdmin(s.username))
300
- ? card(`${BASE}/workspaces`, icon('layers'), tx('wu.workspaces'), tx('wu.workspaces_text'))
301
- : s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
302
- ? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), tx('wu.workspace', { name: s.workspace.name }), tx('wu.workspace_text'))
303
- : ''}
304
- ${card(`${BASE}/ui-defaults`, icon('sliders-horizontal'), tx('wu.ui_defaults'), tx('wu.ui_defaults_text'))}
305
- ${card(`${BASE}/icons`, icon('image'), tx('wu.icons'), tx('wu.icons_text'))}
306
- ${card(`${BASE}/vector-providers`, icon('search'), tx('wu.vectors'), tx('wu.vectors_text'))}
307
- ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), tx('wu.ai'), tx('wu.ai_text')) : ''}
308
- ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), tx('wu.mail'), tx('wu.mail_text')) : ''}
309
- ${(await isAdmin(s.username)) ? card(`${BASE}/telemetry`, icon('activity'), tx('wu.otel'), tx('wu.otel_text')) : ''}
310
- ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), tx('wu.installation'), tx('wu.installation_text')) : ''}
311
- </div></div>
312
- <aside class="ab-side" aria-label="${tx('wu.side')}">
313
- <section><h2>${tx('wu.about')}</h2><p>${tx('wu.about_text')}</p>
314
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${tx('wu.learn_more')}</a></section>
315
- <section><h2>${tx('wu.cross_app')}</h2><ul class="ab-links ab-tasks">${crossAppLinks()}</ul></section>
316
- </aside></div>`;
317
- return send(reply, s, shell(s, tx('wu.title'), [[tx('rail.app_builder'), BASE], [tx('wu.title')]], main));
267
+ const admin = await isAdmin(s.username);
268
+ const wsAdmin = !!s.workspace?.admin || (!!s.workspace && (await workspaceAdmin(s.username, s.workspace.id)));
269
+ const ws = s.workspace;
270
+ type Tile = [href: string, ic: string, title: string, text: string | Raw] | null;
271
+ const tile = (t: Tile) =>
272
+ t ? html`<li><a class="adm-tile" href="${t[0]}"><span class="adm-tile-head">${icon(t[1])}<strong>${t[2]}</strong></span>${t[3] ? html`<span class="adm-tile-text">${t[3]}</span>` : ''}</a></li>` : '';
273
+ const section = (id: string, title: string, tiles: Tile[]) => {
274
+ const shown = tiles.filter(Boolean);
275
+ return shown.length ? html`<section class="adm-section" aria-labelledby="adm-${id}"><h2 id="adm-${id}">${title}</h2><ul class="adm-tiles">${shown.map(tile)}</ul></section>` : '';
276
+ };
277
+ const main = html`<div class="adm">
278
+ <div class="title-row"><h1>${tx('rail.admin')}</h1>
279
+ <div class="adm-filter" hidden><label class="sr-only" for="adm-q">${tx('wu.filter')}</label>
280
+ <input id="adm-q" type="search" placeholder="${tx('wu.filter')}" autocomplete="off" data-adm-filter></div></div>
281
+ <p class="u-mt0 adm-lede"><a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${tx('wu.learn_more')}</a></p>
282
+ ${section('people', tx('wu.sec.people'), [
283
+ [`${BASE}/users`, 'user', tx('wu.users'), tx('wu.users_text')],
284
+ [`${BASE}/developers`, 'users', tx('wu.developers'), tx('wu.developers_text')],
285
+ [`${BASE}/users/providers`, 'key', tx('wu.providers'), tx('wu.providers_text')],
286
+ [`${BASE}/users/directories`, 'org', tx('wu.ldap'), tx('wu.ldap_text')],
287
+ [`${BASE}/users#account-settings`, 'shield', tx('wu.password_policy'), tx('wu.password_policy_text')],
288
+ ])}
289
+ ${section('connections', tx('wu.sec.connections'), [
290
+ [`${BASE}/remote-servers`, 'server', tx('wu.servers'), tx('wu.servers_text')],
291
+ admin ? [`${BASE}/remote-databases`, 'database', tx('wu.remote_dbs'), tx('wu.remote_dbs_text')] : null,
292
+ [`${BASE}/workspace-credentials`, 'key', tx('wu.credentials'), tx('wu.credentials_text')],
293
+ [`${BASE}/rest-catalogs`, 'globe', tx('wu.catalogs'), tx('wu.catalogs_text')],
294
+ admin ? [`${BASE}/mail`, 'send', tx('wu.mail'), tx('wu.mail_text')] : null,
295
+ wsAdmin ? [`${BASE}/mail-queue`, 'inbox', tx('admin.mail_queue'), ''] : null,
296
+ wsAdmin ? [`${BASE}/network`, 'globe', tx('admin.network'), ''] : null,
297
+ ])}
298
+ ${section('data', tx('wu.sec.data'), [
299
+ admin ? [`${BASE}/ai`, 'bolt', tx('wu.ai'), tx('wu.ai_text')] : null,
300
+ [`${BASE}/vector-providers`, 'search', tx('wu.vectors'), tx('wu.vectors_text')],
301
+ [`${BASE}/ui-defaults`, 'sliders-horizontal', tx('wu.ui_defaults'), tx('wu.ui_defaults_text')],
302
+ [`${BASE}/workspace-files`, 'file', tx('wu.files'), tx('wu.files_text', { tag: '#WORKSPACE_FILES#' })],
303
+ [`${BASE}/icons`, 'image', tx('wu.icons'), tx('wu.icons_text')],
304
+ ])}
305
+ ${section('operations', tx('wu.sec.operations'), [
306
+ [`${BASE}/app-groups`, 'folder', tx('wu.app_groups'), tx('wu.app_groups_text')],
307
+ [`${BASE}/backups`, 'archive', tx('wu.backups'), tx('wu.backups_text')],
308
+ wsAdmin ? [`${BASE}/workspace-export`, 'download', tx('wu.export'), tx('wu.export_text')] : null,
309
+ [`${BASE}/dashboard`, 'activity', tx('wu.dashboard'), tx('wu.dashboard_text')],
310
+ admin ? [`${BASE}/telemetry`, 'activity', tx('wu.otel'), tx('wu.otel_text')] : null,
311
+ ])}
312
+ ${ws && wsAdmin ? section('workspace', tx('wu.sec.workspace', { name: ws.name }), [
313
+ [`${BASE}/workspaces/${ws.id}`, 'layers', tx('admin.ws_devs'), tx('wu.workspace_text')],
314
+ [`${BASE}/workspaces/${ws.id}#service`, 'bell', tx('admin.ws_message'), ''],
315
+ [`${BASE}/workspaces/${ws.id}#preferences`, 'sliders-horizontal', tx('admin.ws_prefs'), ''],
316
+ [`${BASE}/workspaces/${ws.id}#links`, 'external-link', tx('admin.ext_links'), ''],
317
+ [`${BASE}/workspaces/${ws.id}/utilization`, 'chart', tx('admin.utilization'), ''],
318
+ ]) : ''}
319
+ ${admin ? section('instance', tx('wu.sec.instance'), [
320
+ [`${BASE}/instance`, 'settings', tx('wu.instance'), tx('wu.instance_text')],
321
+ [`${BASE}/workspaces`, 'layers', tx('wu.workspaces'), tx('wu.workspaces_text')],
322
+ s.instanceAdmin ? [`${BASE}/db-users`, 'key', tx('admin.db_users'), ''] : null,
323
+ [`${BASE}/installation`, 'history', tx('wu.installation'), tx('wu.installation_text')],
324
+ ]) : ''}
325
+ <section class="adm-section" aria-labelledby="adm-reports"><h2 id="adm-reports">${tx('wu.cross_app')}</h2><ul class="ab-links ab-tasks adm-reports">${crossAppLinks()}</ul></section>
326
+ <p class="adm-none muted" hidden>${tx('wu.filter_none')}</p>
327
+ </div>`;
328
+ return send(reply, s, shell(s, tx('rail.admin'), [[tx('rail.admin')]], main));
318
329
  });
319
330
  }
@@ -7510,4 +7510,32 @@ export const de: BuilderMessages = {
7510
7510
  'pjb.steps': 'Schritte',
7511
7511
  'pjb.message': 'Meldung',
7512
7512
  'pjb.no_runs': 'Noch keine Ausführungen.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Alle Seiten der Navigation nach der Anmeldung offline verfügbar machen',
7515
+ 'pwa.prefetch_help': 'Erfordert „Besuchte Seiten behalten“. Nach einer Anmeldung und auf der ersten auf einem Gerät geöffneten Seite lädt das Gerät nacheinander alle Seiten des Navigationsmenüs und der Navigationsleiste, die der Benutzer öffnen darf. Der Server zeigt sie schreibgeschützt: Seiten, die beim Öffnen Prozesse ausführen, werden ausgelassen und erst gespeichert, wenn der Benutzer sie öffnet.',
7516
+ 'pwa.prefetch_hours': 'Erneut laden nach (Stunden)',
7517
+ 'pwa.prefetch_hours_help': '1 bis 168. Die Seiten werden nach dieser Zeit beim ersten Seitenaufruf erneut geladen, sofern eine Verbindung besteht.',
7518
+ 'pwa.prefetch_hours_range': 'Erneut laden nach: eine ganze Zahl von Stunden zwischen 1 und 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Weitere Aktionen',
7521
+ 'ws.suggest': 'Vorschläge',
7522
+ 'ws.suggest_title': 'Vorschläge an der Cursorposition (Strg+Leertaste)',
7523
+ 'ws.check': 'Prüfen',
7524
+ 'ws.check_title': 'Das SQL planen, ohne es auszuführen, wie der Advisor',
7525
+ 'ws.key.indent': 'Einrücken (Esc, dann Tab: nächstes Feld)',
7526
+ 'ws.search_path_plain': '{schema} zuerst im Suchpfad',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Beschriftungen anzeigen',
7529
+ 'rail.labels_hide': 'Beschriftungen ausblenden',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Hilfsprogramme filtern',
7532
+ 'wu.filter_none': 'Nichts entspricht dem Filter.',
7533
+ 'wu.sec.people': 'Personen und Anmeldung',
7534
+ 'wu.sec.connections': 'Verbindungen',
7535
+ 'wu.sec.data': 'Daten und KI',
7536
+ 'wu.sec.operations': 'Betrieb',
7537
+ 'wu.sec.workspace': 'Arbeitsbereich {name}',
7538
+ 'wu.sec.instance': 'Instanz',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'Erfordert Aufmerksamkeit',
7513
7541
  };
@@ -7514,4 +7514,32 @@ export const en = {
7514
7514
  'pjb.steps': 'Steps',
7515
7515
  'pjb.message': 'Message',
7516
7516
  'pjb.no_runs': 'No runs yet.',
7517
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7518
+ 'pwa.prefetch': 'Make every page of the navigation available offline after sign-in',
7519
+ 'pwa.prefetch_help': 'Needs Keep visited pages. After a sign-in, and on the first page opened on a device, the device fetches every page of the navigation menu and navigation bar the user may open, one at a time. The server shows them read-only: pages that run processes when they open are left out and kept only when the user opens them.',
7520
+ 'pwa.prefetch_hours': 'Fetch them again after (hours)',
7521
+ 'pwa.prefetch_hours_help': '1 to 168. The pages are fetched again on the first page opened after this time, while there is a connection.',
7522
+ 'pwa.prefetch_hours_range': 'Fetch them again after: a whole number of hours from 1 to 168.',
7523
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7524
+ 'ws.more': 'More actions',
7525
+ 'ws.suggest': 'Suggest',
7526
+ 'ws.suggest_title': 'Suggestions at the cursor (Ctrl+Space)',
7527
+ 'ws.check': 'Check',
7528
+ 'ws.check_title': 'Plan the SQL without running it, as the Advisor does',
7529
+ 'ws.key.indent': 'Indent (Esc, then Tab: next field)',
7530
+ 'ws.search_path_plain': '{schema} first in the search path',
7531
+ // ------------------------------------------------------------ Rail labels (UX-3)
7532
+ 'rail.labels_show': 'Show labels',
7533
+ 'rail.labels_hide': 'Hide labels',
7534
+ // ------------------------------------------------------------ Administration page (UX-4)
7535
+ 'wu.filter': 'Filter the utilities',
7536
+ 'wu.filter_none': 'Nothing matches the filter.',
7537
+ 'wu.sec.people': 'People and sign-in',
7538
+ 'wu.sec.connections': 'Connections',
7539
+ 'wu.sec.data': 'Data and AI',
7540
+ 'wu.sec.operations': 'Operations',
7541
+ 'wu.sec.workspace': 'Workspace {name}',
7542
+ 'wu.sec.instance': 'Instance',
7543
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7544
+ 'wh.attention': 'Needs attention',
7517
7545
  } as const;
@@ -7510,4 +7510,32 @@ export const es: BuilderMessages = {
7510
7510
  'pjb.steps': 'Pasos',
7511
7511
  'pjb.message': 'Mensaje',
7512
7512
  'pjb.no_runs': 'Aún no hay ejecuciones.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Hacer que todas las páginas de la navegación estén disponibles sin conexión después de iniciar sesión',
7515
+ 'pwa.prefetch_help': 'Requiere Guardar las páginas visitadas. Después de iniciar sesión, y en la primera página abierta en un dispositivo, el dispositivo obtiene una por una todas las páginas del menú y de la barra de navegación que el usuario puede abrir. El servidor las muestra en solo lectura: las páginas que ejecutan procesos al abrirse se omiten y solo se guardan cuando el usuario las abre.',
7516
+ 'pwa.prefetch_hours': 'Volver a obtenerlas después de (horas)',
7517
+ 'pwa.prefetch_hours_help': 'De 1 a 168. Las páginas se vuelven a obtener en la primera página abierta después de este tiempo, mientras haya conexión.',
7518
+ 'pwa.prefetch_hours_range': 'Volver a obtenerlas después de: un número entero de horas entre 1 y 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Más acciones',
7521
+ 'ws.suggest': 'Sugerir',
7522
+ 'ws.suggest_title': 'Sugerencias en el cursor (Ctrl+Espacio)',
7523
+ 'ws.check': 'Comprobar',
7524
+ 'ws.check_title': 'Planificar el SQL sin ejecutarlo, como el Advisor',
7525
+ 'ws.key.indent': 'Sangrar (Esc y luego Tab: siguiente campo)',
7526
+ 'ws.search_path_plain': '{schema} primero en la ruta de búsqueda',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Mostrar etiquetas',
7529
+ 'rail.labels_hide': 'Ocultar etiquetas',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Filtrar las utilidades',
7532
+ 'wu.filter_none': 'Nada coincide con el filtro.',
7533
+ 'wu.sec.people': 'Personas e inicio de sesión',
7534
+ 'wu.sec.connections': 'Conexiones',
7535
+ 'wu.sec.data': 'Datos e IA',
7536
+ 'wu.sec.operations': 'Operaciones',
7537
+ 'wu.sec.workspace': 'Espacio de trabajo {name}',
7538
+ 'wu.sec.instance': 'Instancia',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'Requiere atención',
7513
7541
  };
@@ -7510,4 +7510,32 @@ export const fr: BuilderMessages = {
7510
7510
  'pjb.steps': 'Étapes',
7511
7511
  'pjb.message': 'Message',
7512
7512
  'pjb.no_runs': 'Aucune exécution pour l’instant.',
7513
+ // ------------------------------------------------------------ PWA: every page offline (sprint 168)
7514
+ 'pwa.prefetch': 'Rendre toutes les pages de la navigation disponibles hors ligne après la connexion',
7515
+ 'pwa.prefetch_help': 'Nécessite Conserver les pages visitées. Après une connexion, et sur la première page ouverte sur un appareil, l’appareil récupère une à une toutes les pages du menu et de la barre de navigation que l’utilisateur peut ouvrir. Le serveur les affiche en lecture seule : les pages qui exécutent des processus à l’ouverture sont ignorées et conservées seulement quand l’utilisateur les ouvre.',
7516
+ 'pwa.prefetch_hours': 'Les récupérer à nouveau après (heures)',
7517
+ 'pwa.prefetch_hours_help': 'De 1 à 168. Les pages sont récupérées à nouveau à la première page ouverte après ce délai, s’il y a une connexion.',
7518
+ 'pwa.prefetch_hours_range': 'Les récupérer à nouveau après : un nombre entier d’heures entre 1 et 168.',
7519
+ // ------------------------------------------------------------ SQL Worksheet: one toolbar row (UX-1)
7520
+ 'ws.more': 'Plus d’actions',
7521
+ 'ws.suggest': 'Suggérer',
7522
+ 'ws.suggest_title': 'Suggestions au curseur (Ctrl+Espace)',
7523
+ 'ws.check': 'Vérifier',
7524
+ 'ws.check_title': 'Planifier le SQL sans l’exécuter, comme l’Advisor',
7525
+ 'ws.key.indent': 'Indenter (Échap puis Tab : champ suivant)',
7526
+ 'ws.search_path_plain': '{schema} en premier dans le chemin de recherche',
7527
+ // ------------------------------------------------------------ Rail labels (UX-3)
7528
+ 'rail.labels_show': 'Afficher les libellés',
7529
+ 'rail.labels_hide': 'Masquer les libellés',
7530
+ // ------------------------------------------------------------ Administration page (UX-4)
7531
+ 'wu.filter': 'Filtrer les utilitaires',
7532
+ 'wu.filter_none': 'Rien ne correspond au filtre.',
7533
+ 'wu.sec.people': 'Personnes et connexion',
7534
+ 'wu.sec.connections': 'Connexions',
7535
+ 'wu.sec.data': 'Données et IA',
7536
+ 'wu.sec.operations': 'Exploitation',
7537
+ 'wu.sec.workspace': 'Espace de travail {name}',
7538
+ 'wu.sec.instance': 'Instance',
7539
+ // ------------------------------------------------------------ Workspace home: needs attention (UX-13)
7540
+ 'wh.attention': 'À traiter',
7513
7541
  };