pgkiln 0.58.0 → 0.59.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.
@@ -1,5 +1,5 @@
1
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
1
2
  import type { FastifyInstance, FastifyReply } from 'fastify';
2
- import { tr } from './i18n.ts';
3
3
  import { owner, type Db } from '../db.ts';
4
4
  import { devDbForWorkspace, workspacesChanged } from '../workspace-db.ts';
5
5
  import { ownerUrl, runtimeUrl } from '../db.ts';
@@ -129,8 +129,9 @@ const NAME = /^\S(.{0,58}\S)?$/;
129
129
 
130
130
  async function adminOnly(s: Session, reply: FastifyReply) {
131
131
  if (await isAdmin(s.username)) return true;
132
- const main = html`<h1 class="u-mb1">Workspaces</h1><div class="alert alert-error" role="alert">Only administrators manage workspaces.</div>`;
133
- await send(reply.code(403), s, shell(s, 'Workspaces', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Workspaces']], main));
132
+ const tx = tr(s);
133
+ const main = html`<h1 class="u-mb1">${tx('wsp.title')}</h1><div class="alert alert-error" role="alert">${tx('wsp.admins_only')}</div>`;
134
+ await send(reply.code(403), s, shell(s, tx('wsp.title'), [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [tx('wsp.title')]], main));
134
135
  return false;
135
136
  }
136
137
 
@@ -146,101 +147,105 @@ async function workspaceAdminOnly(s: Session, reply: FastifyReply, workspaceId:
146
147
  return adminOnly(s, reply);
147
148
  }
148
149
 
149
- const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Workspaces', `${BASE}/workspaces`], ...extra];
150
+ const crumbs = (tx: BuilderTranslate, extra: [string, string?][] = []): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [tx('wsp.title'), `${BASE}/workspaces`], ...extra];
150
151
 
151
- const nameError = (e: unknown) => {
152
+ const nameError = (e: unknown, tx: BuilderTranslate) => {
152
153
  const m = (e as Error).message;
153
- if (m.includes('workspace_name_key')) return 'A workspace with this name exists already.';
154
- if (m.includes('workspace_name_check')) return 'The name is 1 to 60 characters, without spaces at the start or end.';
154
+ if (m.includes('workspace_name_key')) return tx('wsp.err.exists');
155
+ if (m.includes('workspace_name_check')) return tx('wsp.err.name_check');
155
156
  return m;
156
157
  };
157
158
 
158
159
  const ROLE = /^[A-Za-z_][A-Za-z0-9_$]{0,62}$/;
159
160
 
160
161
  /** Why this installation can't isolate a workspace (090), or null. */
161
- export function isolationProblem(): string | null {
162
- if (!secretKeyConfigured()) return 'set PGKILN_SECRET_KEY (at least 32 characters) in the server\'s environment: the workspace\'s database logins get passwords derived from it.';
163
- if (runtimeUrl === ownerUrl) return 'set RUNTIME_DATABASE_URL: applications run on the owner connection, so no workspace can be isolated.';
162
+ export function isolationProblem(tx: BuilderTranslate = translatorFor('en')): string | null {
163
+ if (!secretKeyConfigured()) return tx('wsp.iso.secret');
164
+ if (runtimeUrl === ownerUrl) return tx('wsp.iso.runtime');
164
165
  return null;
165
166
  }
166
167
 
167
168
  /** A role problem from meta.workspace_sync_roles (or a duplicate role) in words. */
168
- const roleError = (e: unknown) => {
169
+ const roleError = (e: unknown, tx: BuilderTranslate) => {
169
170
  const m = (e as Error).message;
170
- if (m.includes('workspace_role_role_name_key')) return 'this role belongs to another workspace already.';
171
- if (m.includes('workspace_role_pkey')) return 'the workspace has this role already.';
171
+ if (m.includes('workspace_role_role_name_key')) return tx('wsp.err.role_taken');
172
+ if (m.includes('workspace_role_pkey')) return tx('wsp.err.role_has');
172
173
  return m;
173
174
  };
174
175
 
175
176
  /** The Isolation region of a workspace's page. */
176
177
  function isolationRegion(s: Session, w: { id: number; name: string; isolated: boolean; run_role: string | null; dev_role: string | null },
177
178
  roles: { role_name: string; exists: boolean; apps: string | null }[]) {
178
- const problem = w.isolated ? null : isolationProblem();
179
+ const tx = tr(s);
180
+ const problem = w.isolated ? null : isolationProblem(tx);
179
181
  const body = w.isolated
180
- ? html`<p class="u-mt0"><span class="tag">${icon('lock')} isolated</span> Its applications run as <code>${w.run_role}</code> and its developers' SQL (SQL Workshop, code checks, the Advisor) as <code>${w.dev_role}</code>. Both reach only the roles below, and see only this workspace's applications, sessions and accounts in pgkiln's own tables. Developers who also belong to a shared workspace keep that workspace's rights there.</p>
181
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Database role</th><th>Used by</th><th></th></tr></thead><tbody>
182
+ ? html`<p class="u-mt0"><span class="tag">${icon('lock')} ${tx('wsp.isolated_tag')}</span> ${tx.rich('wsp.iso.on', { run: html`<code>${w.run_role}</code>`, dev: html`<code>${w.dev_role}</code>` })}</p>
183
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('wsp.db_role')}</th><th>${tx('wsp.used_by')}</th><th></th></tr></thead><tbody>
182
184
  ${roles.length
183
- ? roles.map((r) => html`<tr><td data-label="Database role"><code>${r.role_name}</code>${r.exists ? '' : html` <span class="tag tag-error">missing</span>`}</td>
184
- <td data-label="Used by">${r.apps ?? html`<span class="muted">no application</span>`}</td>
185
+ ? roles.map((r) => html`<tr><td data-label="${tx('wsp.db_role')}"><code>${r.role_name}</code>${r.exists ? '' : html` <span class="tag tag-error">${tx('wsp.missing')}</span>`}</td>
186
+ <td data-label="${tx('wsp.used_by')}">${r.apps ?? html`<span class="muted">${tx('wsp.no_app')}</span>`}</td>
185
187
  <td><form method="post" action="${BASE}/workspaces/${w.id}/roles">${csrf(s)}<input type="hidden" name="role" value="${r.role_name}">
186
- <button class="btn btn-sm" name="action" value="remove">Remove</button></form></td></tr>`)
187
- : html`<tr><td colspan="3" class="empty">No database roles: applications here can't run as a role of their own.</td></tr>`}</tbody></table></div>
188
+ <button class="btn btn-sm" name="action" value="remove">${tx('wsp.remove')}</button></form></td></tr>`)
189
+ : html`<tr><td colspan="3" class="empty">${tx('wsp.no_roles')}</td></tr>`}</tbody></table></div>
188
190
  <form method="post" action="${BASE}/workspaces/${w.id}/roles" class="u-mt1">${csrf(s)}
189
- ${input('role', 'Add a database role', '', { help: 'An existing role (not a superuser, not one that creates roles or bypasses row-level security). Its rights become this workspace\'s; it can\'t belong to another workspace.' })}
190
- <div class="buttons"><button class="btn" name="action" value="add">${icon('plus')} Add role</button></div></form>
191
+ ${input('role', tx('wsp.add_role'), '', { help: tx('wsp.add_role_help') })}
192
+ <div class="buttons"><button class="btn" name="action" value="add">${icon('plus')} ${tx('wsp.add_role_btn')}</button></div></form>
191
193
  <form method="post" action="${BASE}/workspaces/${w.id}/isolation" class="danger-zone">${csrf(s)}<input type="hidden" name="isolated" value="false">
192
- <button class="btn btn-danger" data-confirm="Make ${w.name} shared again? Its applications then run on the shared runtime connection, and its developers' SQL as the owner.">Make shared</button></form>`
193
- : html`<p class="u-mt0">Shared: the applications run on the shared runtime connection and the developers' SQL as the owner, like every shared workspace's.</p>
194
- <p class="muted small">Isolating gives the workspace two database logins of its own and the database roles its applications use now. Application code then reaches only those roles and this workspace's applications; its developers' SQL Workshop runs on the workspace's login instead of the owner. New applications get a new role and schema of their own.</p>
195
- ${problem ? html`<div class="alert alert-error" role="alert">To isolate a workspace, ${problem}</div>` : ''}
194
+ <button class="btn btn-danger" data-confirm="${tx('wsp.make_shared_confirm', { name: w.name })}">${tx('wsp.make_shared')}</button></form>`
195
+ : html`<p class="u-mt0">${tx('wsp.shared_text')}</p>
196
+ <p class="muted small">${tx('wsp.iso_help')}</p>
197
+ ${problem ? html`<div class="alert alert-error" role="alert">${tx('wsp.to_isolate', { problem })}</div>` : ''}
196
198
  <form method="post" action="${BASE}/workspaces/${w.id}/isolation">${csrf(s)}<input type="hidden" name="isolated" value="true">
197
- <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''} data-confirm="Isolate ${w.name}?">${icon('lock')} Isolate this workspace</button></div></form>`;
198
- return region('Isolation', body);
199
+ <div class="buttons"><button class="btn btn-hot"${problem ? raw(' disabled') : ''} data-confirm="${tx('wsp.isolate_confirm', { name: w.name })}">${icon('lock')} ${tx('wsp.isolate')}</button></div></form>`;
200
+ return region(tx('wsp.isolation'), body);
199
201
  }
200
202
 
201
203
  /** (091) Manage service: the workspace message and the environment banner. */
202
204
  function serviceRegion(s: Session, w: { id: number; message: string | null; banner_label: string | null; banner_color: string }) {
203
- return region('Manage service', html`<form method="post" action="${BASE}/workspaces/${w.id}/service">${csrf(s)}
204
- <div class="field"><label class="label" for="f_message">Workspace message</label>
205
+ const tx = tr(s);
206
+ return region(tx('wsp.service'), html`<form method="post" action="${BASE}/workspaces/${w.id}/service">${csrf(s)}
207
+ <div class="field"><label class="label" for="f_message">${tx('wsp.message')}</label>
205
208
  <textarea id="f_message" name="message" rows="3" maxlength="2000">${w.message ?? ''}</textarea>
206
- <small class="help">Shown to this workspace's developers on the App Builder home, e.g. a maintenance window. Plain text.</small></div>
209
+ <small class="help">${tx('wsp.message_help')}</small></div>
207
210
  <div class="form-grid">
208
- ${input('banner_label', 'Environment banner', w.banner_label ?? '', { placeholder: 'DEVELOPMENT', help: 'A short label shown at the top of every builder page while this workspace is current. Empty: no banner.' })}
209
- ${select('banner_color', 'Banner colour', w.banner_color, BANNER_COLORS.map((c): [string, string] => [c, c[0].toUpperCase() + c.slice(1)]))}
211
+ ${input('banner_label', tx('wsp.banner'), w.banner_label ?? '', { placeholder: 'DEVELOPMENT', help: tx('wsp.banner_help') })}
212
+ ${select('banner_color', tx('wsp.banner_colour'), w.banner_color, BANNER_COLORS.map((c): [string, string] => [c, c[0].toUpperCase() + c.slice(1)]))}
210
213
  </div>
211
- <div class="buttons"><button class="btn">Save</button></div></form>`, '', 'service');
214
+ <div class="buttons"><button class="btn">${tx('wsp.save')}</button></div></form>`, '', 'service');
212
215
  }
213
216
 
214
217
  /** (093) Workspace preferences: the SQL Workshop and the applications' REST modules on or off. */
215
218
  function preferencesRegion(s: Session, w: { id: number; sql_workshop: boolean; rest_services: boolean; builder_home: string | null }) {
216
219
  const check = (name: string, on: boolean, label: string, help: string) =>
217
220
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
218
- return region('Workspace preferences', html`<form method="post" action="${BASE}/workspaces/${w.id}/preferences">${csrf(s)}
219
- ${check('sql_workshop', w.sql_workshop, 'Enable the SQL Workshop', 'Off: the SQL Worksheet, the Object Browser, SQL scripts, data loading and unloading, Quick SQL, the query builder, DDL, Compare and AI are refused while this workspace is current, for every developer.')}
220
- ${check('rest_services', w.rest_services, 'Enable RESTful services', 'Off: the REST modules of this workspace\'s applications answer 404 (Not found) at /a/<alias>/rest/…; they can still be edited.')}
221
- ${select('builder_home', 'Builder links open', w.builder_home === 'home' ? 'home' : 'apps', [['apps', 'App Builder'], ['home', 'Workspace home']], 'Where a link to /builder (a bookmark from before the Workspace home) leads in this workspace. Signing in always opens the Workspace home; the App Builder is at /builder/apps.')}
222
- <div class="buttons"><button class="btn">Save preferences</button></div></form>`, '', 'preferences');
221
+ const tx = tr(s);
222
+ return region(tx('wsp.prefs'), html`<form method="post" action="${BASE}/workspaces/${w.id}/preferences">${csrf(s)}
223
+ ${check('sql_workshop', w.sql_workshop, tx('wsp.sql_on'), tx('wsp.sql_on_help'))}
224
+ ${check('rest_services', w.rest_services, tx('wsp.rest_on'), tx('wsp.rest_on_help', { path: '/a/<alias>/rest/…' }))}
225
+ ${select('builder_home', tx('wsp.builder_home'), w.builder_home === 'home' ? 'home' : 'apps', [['apps', tx('wsp.home.apps')], ['home', tx('wsp.home.home')]], tx('wsp.builder_home_help'))}
226
+ <div class="buttons"><button class="btn">${tx('wsp.save_prefs')}</button></div></form>`, '', 'preferences');
223
227
  }
224
228
 
225
229
  /** (093) Extension links: links to other tools in the builder's Extensions menu. */
226
230
  function linksRegion(s: Session, w: { id: number }, links: { id: number; label: string; url: string; description: string | null; position: number }[]) {
227
- return region('Extension links', html`<p class="muted u-mt0">Links to other tools this workspace's developers use (monitoring, a wiki, a ticket system), in the builder's <strong>Extensions</strong> menu while the workspace is current. They open in a new tab.</p>
231
+ const tx = tr(s);
232
+ return region(tx('wsp.links'), html`<p class="muted u-mt0">${tx.rich('wsp.links_help', { menu: html`<strong>${tx('wsp.extensions')}</strong>` })}</p>
228
233
  <div class="table-wrap"><table class="report report-reflow">
229
- <thead><tr><th>Label</th><th>URL</th><th class="num">Order</th><th></th></tr></thead>
234
+ <thead><tr><th>${tx('wsp.label')}</th><th>${tx('wsp.url')}</th><th class="num">${tx('wsp.order')}</th><th></th></tr></thead>
230
235
  <tbody>${links.length
231
- ? links.map((l) => html`<tr><td data-label="Label">${l.label}${l.description ? html` <span class="muted">${l.description}</span>` : ''}</td>
232
- <td data-label="URL" class="u-break"><a href="${l.url}" target="_blank" rel="noopener noreferrer">${l.url}</a></td>
233
- <td class="num" data-label="Order">${l.position}</td>
234
- <td><form method="post" action="${BASE}/workspaces/${w.id}/links/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="Delete the link ${l.label}?">Delete</button></form></td></tr>`)
235
- : html`<tr><td colspan="4" class="empty">No extension links.</td></tr>`}</tbody></table></div>
236
+ ? links.map((l) => html`<tr><td data-label="${tx('wsp.label')}">${l.label}${l.description ? html` <span class="muted">${l.description}</span>` : ''}</td>
237
+ <td data-label="${tx('wsp.url')}" class="u-break"><a href="${l.url}" target="_blank" rel="noopener noreferrer">${l.url}</a></td>
238
+ <td class="num" data-label="${tx('wsp.order')}">${l.position}</td>
239
+ <td><form method="post" action="${BASE}/workspaces/${w.id}/links/${l.id}/delete">${csrf(s)}<button class="btn btn-sm btn-danger" data-confirm="${tx('wsp.delete_link_confirm', { label: l.label })}">${tx('wsp.delete')}</button></form></td></tr>`)
240
+ : html`<tr><td colspan="4" class="empty">${tx('wsp.no_links')}</td></tr>`}</tbody></table></div>
236
241
  <form method="post" action="${BASE}/workspaces/${w.id}/links" class="u-mt1">${csrf(s)}
237
242
  <div class="form-grid">
238
- ${input('label', 'Label', '', { required: true, placeholder: 'Monitoring' })}
239
- ${input('url', 'URL', '', { required: true, type: 'url', placeholder: 'https://grafana.example.com', help: 'http:// or https://' })}
240
- ${input('description', 'Description', '')}
241
- ${input('position', 'Order', '0', { type: 'number' })}
243
+ ${input('label', tx('wsp.label'), '', { required: true, placeholder: tx('wsp.label_ph') })}
244
+ ${input('url', tx('wsp.url'), '', { required: true, type: 'url', placeholder: 'https://grafana.example.com', help: 'http:// or https://' })}
245
+ ${input('description', tx('wsp.description'), '')}
246
+ ${input('position', tx('wsp.order'), '0', { type: 'number' })}
242
247
  </div>
243
- <div class="buttons"><button class="btn">${icon('plus')} Add link</button></div></form>`, '', 'links');
248
+ <div class="buttons"><button class="btn">${icon('plus')} ${tx('wsp.add_link')}</button></div></form>`, '', 'links');
244
249
  }
245
250
 
246
251
  const LINK_LABEL = /^\S(.{0,58}\S)?$/;
@@ -254,13 +259,14 @@ export async function workspaceRoutes(app: FastifyInstance) {
254
259
  const w = s.workspaces?.find((x) => String(x.id) === req.body?.workspace);
255
260
  if (!w) return reply.code(404).send('Not found');
256
261
  s.state.__WS = String(w.id);
257
- flash(s, `Workspace ${w.name}.`);
262
+ flash(s, tr(s)('wsp.switched', { name: w.name }));
258
263
  return back(reply, s, BASE);
259
264
  });
260
265
 
261
266
  app.get(`${BASE}/workspaces`, async (req: Req, reply) => {
262
267
  const s = await developer(req, reply);
263
268
  if (!s || !(await adminOnly(s, reply))) return;
269
+ const tx = tr(s);
264
270
  const rows = (
265
271
  await owner.query(
266
272
  `select w.id, w.name, w.description, w.isolated,
@@ -270,22 +276,22 @@ export async function workspaceRoutes(app: FastifyInstance) {
270
276
  )
271
277
  ).rows;
272
278
  const main = html`
273
- <div class="title-row"><h1>Workspaces</h1></div>
274
- <p class="muted u-mt0">A workspace groups applications and the developers who build them. Developers see and change the applications of their own workspaces; administrators see all of them. A shared workspace is not a security boundary between developers who write SQL: its SQL Workshop and application code run with this installation's rights. An <strong>isolated</strong> workspace is: its applications and SQL run on database logins of its own that reach only its database roles and its own applications.</p>
279
+ <div class="title-row"><h1>${tx('wsp.title')}</h1></div>
280
+ <p class="muted u-mt0">${tx.rich('wsp.intro', { isolated: html`<strong>${tx('wsp.isolated_word')}</strong>` })}</p>
275
281
  <div class="columns wide-left">
276
- ${region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
277
- <thead><tr><th>Workspace</th><th class="num">Applications</th><th>Developers</th></tr></thead>
282
+ ${region(tx('wsp.title'), html`<div class="table-wrap"><table class="report report-reflow">
283
+ <thead><tr><th>${tx('wsp.workspace')}</th><th class="num">${tx('wsp.applications')}</th><th>${tx('wsp.developers')}</th></tr></thead>
278
284
  <tbody>${rows.map((w) => html`<tr>
279
- <td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.isolated ? html` <span class="tag">${icon('lock')} isolated</span>` : ''}${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
280
- <td class="num" data-label="Applications">${w.apps}</td>
281
- <td data-label="Developers">${w.members ?? html`<span class="muted">administrators only</span>`}</td></tr>`)}</tbody></table></div>`)}
282
- ${region('Add workspace', html`<form method="post" action="${BASE}/workspaces">${csrf(s)}
283
- ${input('name', 'Name', '', { required: true, help: 'For example the team or department that builds its applications here.' })}
284
- ${input('description', 'Description', '')}
285
- <p class="muted small">You become its first developer; add others on its page.</p>
286
- <div class="buttons"><button class="btn btn-hot">${icon('plus')} Add workspace</button></div></form>`)}
285
+ <td data-label="${tx('wsp.workspace')}"><a href="${BASE}/workspaces/${w.id}">${w.name}</a>${w.isolated ? html` <span class="tag">${icon('lock')} ${tx('wsp.isolated_tag')}</span>` : ''}${w.description ? html` <span class="muted">${w.description}</span>` : ''}</td>
286
+ <td class="num" data-label="${tx('wsp.applications')}">${w.apps}</td>
287
+ <td data-label="${tx('wsp.developers')}">${w.members ?? html`<span class="muted">${tx('wsp.admins_text')}</span>`}</td></tr>`)}</tbody></table></div>`)}
288
+ ${region(tx('wsp.add'), html`<form method="post" action="${BASE}/workspaces">${csrf(s)}
289
+ ${input('name', tx('wsp.name'), '', { required: true, help: tx('wsp.name_help') })}
290
+ ${input('description', tx('wsp.description'), '')}
291
+ <p class="muted small">${tx('wsp.first_dev')}</p>
292
+ <div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('wsp.add')}</button></div></form>`)}
287
293
  </div>`;
288
- return send(reply, s, shell(s, 'Workspaces', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['Workspaces']], main));
294
+ return send(reply, s, shell(s, tx('wsp.title'), [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [tx('wsp.title')]], main));
289
295
  });
290
296
 
291
297
  app.post(`${BASE}/workspaces`, async (req: Req, reply) => {
@@ -293,7 +299,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
293
299
  if (!s || !(await adminOnly(s, reply))) return;
294
300
  const name = String(req.body?.name ?? '').trim();
295
301
  if (!NAME.test(name)) {
296
- flash(s, 'The name is 1 to 60 characters.', 'error');
302
+ flash(s, tr(s)('wsp.err.name'), 'error');
297
303
  return back(reply, s, `${BASE}/workspaces`);
298
304
  }
299
305
  try {
@@ -303,10 +309,10 @@ export async function workspaceRoutes(app: FastifyInstance) {
303
309
  return w;
304
310
  });
305
311
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `created workspace ${name}` });
306
- flash(s, `Workspace ${name} added.`);
312
+ flash(s, tr(s)('wsp.added', { name }));
307
313
  return back(reply, s, `${BASE}/workspaces/${id}`);
308
314
  } catch (e) {
309
- flash(s, nameError(e), 'error');
315
+ flash(s, nameError(e, tr(s)), 'error');
310
316
  return back(reply, s, `${BASE}/workspaces`);
311
317
  }
312
318
  });
@@ -315,6 +321,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
315
321
  const s = await developer(req, reply);
316
322
  if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
317
323
  const full = await isAdmin(s.username);
324
+ const tx = tr(s);
318
325
  const w = await owner.one('select * from meta.workspace where id = $1', [req.params.id]);
319
326
  if (!w) return reply.code(404).send('Not found');
320
327
  const [devs, apps, others, rolesQ] = await Promise.all([
@@ -337,41 +344,41 @@ export async function workspaceRoutes(app: FastifyInstance) {
337
344
  const main = html`
338
345
  <div class="title-row"><h1>${w.name}</h1></div>
339
346
  <div class="columns wide-left">
340
- ${region('Settings', html`<form method="post" action="${BASE}/workspaces/${w.id}">${csrf(s)}
341
- ${input('name', 'Name', w.name, { required: true })}
342
- ${input('description', 'Description', w.description ?? '')}
343
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
347
+ ${region(tx('wsp.settings'), html`<form method="post" action="${BASE}/workspaces/${w.id}">${csrf(s)}
348
+ ${input('name', tx('wsp.name'), w.name, { required: true })}
349
+ ${input('description', tx('wsp.description'), w.description ?? '')}
350
+ <div class="buttons"><button class="btn btn-hot">${tx('wsp.save')}</button></div></form>
344
351
  ${!full ? '' : w.id === 1
345
- ? html`<p class="muted small">The Default workspace can't be deleted: imported applications and applications made with the command line start here.</p>`
352
+ ? html`<p class="muted small">${tx('wsp.default_no_delete')}</p>`
346
353
  : html`<form method="post" action="${BASE}/workspaces/${w.id}/delete" class="danger-zone">${csrf(s)}
347
- <button class="btn btn-danger" data-confirm="Delete workspace ${w.name}?"${apps.rows.length ? raw(' disabled') : ''}>Delete workspace</button>
348
- ${apps.rows.length ? html`<p class="muted small">Move its applications to another workspace first.</p>` : ''}</form>`}`)}
349
- ${region('Developers', html`<p class="muted u-mt0">Developers of this workspace see and change its applications. Administrators see every workspace.</p>
354
+ <button class="btn btn-danger" data-confirm="${tx('wsp.delete_confirm', { name: w.name })}"${apps.rows.length ? raw(' disabled') : ''}>${tx('wsp.delete_ws')}</button>
355
+ ${apps.rows.length ? html`<p class="muted small">${tx('wsp.move_first')}</p>` : ''}</form>`}`)}
356
+ ${region(tx('wsp.developers'), html`<p class="muted u-mt0">${tx('wsp.devs_help')}</p>
350
357
  <form method="post" action="${BASE}/workspaces/${w.id}/members">${csrf(s)}
351
- <fieldset class="field"><legend class="sr-only">Developers</legend><div class="radio-group">
352
- ${devs.rows.map((d) => html`<label class="check"><input type="checkbox" name="member" value="${d.username}"${d.member ? raw(' checked') : ''}> ${d.username}${d.is_admin ? html` <span class="muted">(administrator)</span>` : ''}</label>`)}
358
+ <fieldset class="field"><legend class="sr-only">${tx('wsp.developers')}</legend><div class="radio-group">
359
+ ${devs.rows.map((d) => html`<label class="check"><input type="checkbox" name="member" value="${d.username}"${d.member ? raw(' checked') : ''}> ${d.username}${d.is_admin ? html` <span class="muted">${tx('wsp.administrator')}</span>` : ''}</label>`)}
353
360
  </div></fieldset>
354
- <fieldset class="field"><legend class="label">Workspace administrators</legend><div class="radio-group">
361
+ <fieldset class="field"><legend class="label">${tx('wsp.ws_admins')}</legend><div class="radio-group">
355
362
  ${devs.rows.filter((d) => d.member && !d.is_admin).map((d) => html`<label class="check"><input type="checkbox" name="admin" value="${d.username}"${d.ws_admin ? raw(' checked') : ''}> ${d.username}</label>`)}
356
- </div><small class="help">They manage this workspace's name and developers. Isolation, database roles, moving applications and deleting the workspace stay with administrators.</small></fieldset>
357
- <div class="buttons"><button class="btn">Save developers</button></div></form>`)}
363
+ </div><small class="help">${tx('wsp.ws_admins_help')}</small></fieldset>
364
+ <div class="buttons"><button class="btn">${tx('wsp.save_devs')}</button></div></form>`)}
358
365
  </div>
359
366
  ${serviceRegion(s, w)}
360
- ${region('Mail and network access', html`<p class="u-mt0">This workspace's own <a href="${BASE}/workspaces/${w.id}/mail">mail service</a> for its applications without one of their own, its <a href="${BASE}/network?ws=${w.id}">network access</a> list (which hosts its applications may call, within the installation's list), and the <a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">mail queue</a>.</p>`, '', 'mail-network')}
367
+ ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
361
368
  <div class="columns">${preferencesRegion(s, w)}${linksRegion(s, w, links)}</div>
362
- ${full ? isolationRegion(s, w, roles.rows) : region('Isolation', html`<p class="u-mt0">${w.isolated ? 'Isolated: its applications and SQL run on database logins of its own.' : 'Shared.'} Administrators change it.</p>`)}
363
- ${!full ? '' : region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
364
- <thead><tr><th>Application</th><th>Move to</th></tr></thead>
369
+ ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
370
+ ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
371
+ <thead><tr><th>${tx('wsp.application')}</th><th>${tx('wsp.move_to')}</th></tr></thead>
365
372
  <tbody>${apps.rows.length
366
- ? apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">${a.id} · /a/${a.alias}</span></td>
367
- <td data-label="Move to">${others.rows.length
373
+ ? apps.rows.map((a) => html`<tr><td data-label="${tx('wsp.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">${a.id} · /a/${a.alias}</span></td>
374
+ <td data-label="${tx('wsp.move_to')}">${others.rows.length
368
375
  ? html`<form method="post" action="${BASE}/workspaces/${w.id}/move" class="search u-mwnone">${csrf(s)}<input type="hidden" name="app" value="${a.id}">
369
- <label class="sr-only" for="move-${a.id}">Move ${a.name} to</label>
376
+ <label class="sr-only" for="move-${a.id}">${tx('wsp.move_label', { app: a.name })}</label>
370
377
  <select id="move-${a.id}" name="to">${others.rows.map((o) => html`<option value="${o.id}">${o.name}</option>`)}</select>
371
- <button class="btn">Move</button></form>`
372
- : html`<span class="muted">no other workspace</span>`}</td></tr>`)
373
- : html`<tr><td colspan="2" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}`;
374
- return send(reply, s, shell(s, w.name, full ? crumbs([[w.name]]) : [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name]], main));
378
+ <button class="btn">${tx('wsp.move')}</button></form>`
379
+ : html`<span class="muted">${tx('wsp.no_other')}</span>`}</td></tr>`)
380
+ : html`<tr><td colspan="2" class="empty">${tx('wsp.no_apps')}</td></tr>`}</tbody></table></div>`)}`;
381
+ return send(reply, s, shell(s, w.name, full ? crumbs(tx, [[w.name]]) : [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name]], main));
375
382
  });
376
383
 
377
384
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)`, async (req: Req, reply) => {
@@ -379,11 +386,11 @@ export async function workspaceRoutes(app: FastifyInstance) {
379
386
  if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
380
387
  const name = String(req.body?.name ?? '').trim();
381
388
  try {
382
- if (!NAME.test(name)) throw new Error('The name is 1 to 60 characters.');
389
+ if (!NAME.test(name)) throw new Error(tr(s)('wsp.err.name'));
383
390
  const r = await owner.query('update meta.workspace set name = $2, description = $3 where id = $1', [req.params.id, name, String(req.body?.description ?? '').trim() || null]);
384
- flash(s, r.rowCount ? 'Workspace saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
391
+ flash(s, tr(s)(r.rowCount ? 'wsp.saved' : 'wsp.not_found'), r.rowCount ? 'ok' : 'error');
385
392
  } catch (e) {
386
- flash(s, nameError(e), 'error');
393
+ flash(s, nameError(e, tr(s)), 'error');
387
394
  }
388
395
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
389
396
  });
@@ -406,7 +413,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
406
413
  );
407
414
  });
408
415
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `developers of workspace ${req.params.id}: ${names.join(', ').slice(0, 1000)}` });
409
- flash(s, 'Developers saved.');
416
+ flash(s, tr(s)('wsp.devs_saved'));
410
417
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
411
418
  });
412
419
 
@@ -436,13 +443,13 @@ export async function workspaceRoutes(app: FastifyInstance) {
436
443
  await c.query('delete from meta.app_group_member where app_id = $1', [appId]);
437
444
  });
438
445
  } catch (e) {
439
- flash(s, `Not moved: ${roleError(e)}`, 'error');
446
+ flash(s, tr(s)('wsp.not_moved', { why: roleError(e, tr(s)) }), 'error');
440
447
  return back(reply, s, `${BASE}/workspaces/${from}`);
441
448
  } finally {
442
449
  await workspacesChanged();
443
450
  }
444
451
  await logActivity({ appId, username: s.username, event: 'workspace', ip: clientIp(req), detail: `moved from workspace ${from} to ${to.id}` });
445
- flash(s, `Application moved to ${to.name}.`);
452
+ flash(s, tr(s)('wsp.moved', { name: to.name }));
446
453
  return back(reply, s, `${BASE}/workspaces/${from}`);
447
454
  });
448
455
 
@@ -451,20 +458,20 @@ export async function workspaceRoutes(app: FastifyInstance) {
451
458
  if (!s || !(await adminOnly(s, reply))) return;
452
459
  const id = Number(req.params.id);
453
460
  if (id === DEFAULT_WORKSPACE) {
454
- flash(s, 'The Default workspace cannot be deleted.', 'error');
461
+ flash(s, tr(s)('wsp.err.default'), 'error');
455
462
  return back(reply, s, `${BASE}/workspaces/${id}`);
456
463
  }
457
464
  if (await owner.one('select 1 as ok from meta.workspace where id = $1 and isolated', [id])) {
458
- flash(s, 'Make the workspace shared (not isolated) before deleting it.', 'error');
465
+ flash(s, tr(s)('wsp.err.isolated'), 'error');
459
466
  return back(reply, s, `${BASE}/workspaces/${id}`);
460
467
  }
461
468
  if (await owner.one('select 1 as ok from meta.workspace_app where workspace_id = $1 limit 1', [id])) {
462
- flash(s, 'Move the applications of this workspace to another workspace first.', 'error');
469
+ flash(s, tr(s)('wsp.err.has_apps'), 'error');
463
470
  return back(reply, s, `${BASE}/workspaces/${id}`);
464
471
  }
465
472
  const r = await owner.one<{ name: string }>('delete from meta.workspace where id = $1 returning name', [id]);
466
473
  if (r) await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `deleted workspace ${r.name}` });
467
- flash(s, r ? `Workspace ${r.name} deleted.` : 'Not found.', r ? 'ok' : 'error');
474
+ flash(s, r ? tr(s)('wsp.deleted', { name: r.name }) : tr(s)('wsp.not_found'), r ? 'ok' : 'error');
468
475
  return back(reply, s, `${BASE}/workspaces`);
469
476
  });
470
477
 
@@ -475,14 +482,14 @@ export async function workspaceRoutes(app: FastifyInstance) {
475
482
  const id = Number(req.params.id);
476
483
  const on = req.body?.isolated === 'true';
477
484
  try {
478
- const problem = on ? isolationProblem() : null;
485
+ const problem = on ? isolationProblem(tr(s)) : null;
479
486
  if (problem) throw new Error(problem);
480
487
  const r = await owner.query('update meta.workspace set isolated = $2 where id = $1', [id, on]);
481
488
  if (!r.rowCount) return reply.code(404).send('Not found');
482
489
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} ${on ? 'isolated' : 'shared'}` });
483
- flash(s, on ? 'The workspace is isolated: its applications and SQL now run on its own database logins.' : 'The workspace is shared again: its applications run on the shared runtime connection.');
490
+ flash(s, tr(s)(on ? 'wsp.isolated_now' : 'wsp.shared_now'));
484
491
  } catch (e) {
485
- flash(s, `${on ? 'Not isolated' : 'Not changed'}: ${roleError(e)}`, 'error');
492
+ flash(s, tr(s)(on ? 'wsp.not_isolated' : 'wsp.not_changed', { why: roleError(e, tr(s)) }), 'error');
486
493
  } finally {
487
494
  await workspacesChanged();
488
495
  }
@@ -497,20 +504,20 @@ export async function workspaceRoutes(app: FastifyInstance) {
497
504
  const role = String(req.body?.role ?? '').trim();
498
505
  const remove = req.body?.action === 'remove';
499
506
  try {
500
- if (!ROLE.test(role)) throw new Error('A role name is a letter or _ followed by letters, digits, _ or $ (at most 63).');
501
- if (!remove && !(await owner.one('select 1 as ok from pg_roles where rolname = $1', [role]))) throw new Error(`There is no database role ${role}.`);
507
+ if (!ROLE.test(role)) throw new Error(tr(s)('wsp.err.role_name'));
508
+ if (!remove && !(await owner.one('select 1 as ok from pg_roles where rolname = $1', [role]))) throw new Error(tr(s)('wsp.err.no_role', { role }));
502
509
  if (remove) {
503
510
  const used = await owner.one<{ aliases: string }>(
504
511
  `select string_agg(alias, ', ') as aliases from meta.app a where a.db_role = $2 and meta.app_workspace(a.id) = $1 having count(*) > 0`,
505
512
  [id, role],
506
513
  );
507
- if (used) throw new Error(`${used.aliases} run${used.aliases.includes(',') ? '' : 's'} as ${role}: give ${used.aliases.includes(',') ? 'them' : 'it'} another role first.`);
514
+ if (used) throw new Error(tr(s)(used.aliases.includes(',') ? 'wsp.err.role_used' : 'wsp.err.role_used_one', { apps: used.aliases, role }));
508
515
  await owner.query('delete from meta.workspace_role where workspace_id = $1 and role_name = $2', [id, role]);
509
516
  } else await owner.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2)', [id, role]);
510
517
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id}: ${remove ? 'removed' : 'added'} role ${role}` });
511
- flash(s, remove ? `Role ${role} removed.` : `Role ${role} added.`);
518
+ flash(s, tr(s)(remove ? 'wsp.role_removed' : 'wsp.role_added', { role }));
512
519
  } catch (e) {
513
- flash(s, `Not saved: ${roleError(e)}`, 'error');
520
+ flash(s, tr(s)('wsp.not_saved', { why: roleError(e, tr(s)) }), 'error');
514
521
  } finally {
515
522
  await workspacesChanged();
516
523
  }
@@ -525,12 +532,12 @@ export async function workspaceRoutes(app: FastifyInstance) {
525
532
  const label = String(req.body?.banner_label ?? '').trim();
526
533
  const color = BANNER_COLORS.includes(String(req.body?.banner_color)) ? String(req.body?.banner_color) : 'blue';
527
534
  try {
528
- if (message.length > 2000) throw new Error('The message is at most 2000 characters.');
529
- if (label && !/^\S(.{0,38}\S)?$/.test(label)) throw new Error('The banner label is at most 40 characters.');
535
+ if (message.length > 2000) throw new Error(tr(s)('wsp.err.message'));
536
+ if (label && !/^\S(.{0,38}\S)?$/.test(label)) throw new Error(tr(s)('wsp.err.banner'));
530
537
  const r = await owner.query('update meta.workspace set message = $2, banner_label = $3, banner_color = $4 where id = $1', [req.params.id, message || null, label || null, color]);
531
538
  if (!r.rowCount) return reply.code(404).send('Not found');
532
539
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${req.params.id}: message and banner` });
533
- flash(s, 'Message and banner saved.');
540
+ flash(s, tr(s)('wsp.service_saved'));
534
541
  } catch (e) {
535
542
  flash(s, (e as Error).message, 'error');
536
543
  }
@@ -548,7 +555,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
548
555
  const r = await owner.query('update meta.workspace set sql_workshop = $2, rest_services = $3, builder_home = $4 where id = $1', [req.params.id, sql, rest, home]);
549
556
  if (!r.rowCount) return reply.code(404).send('Not found');
550
557
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${req.params.id}: SQL Workshop ${sql ? 'on' : 'off'}, RESTful services ${rest ? 'on' : 'off'}, /builder opens ${home ?? 'apps'}` });
551
- flash(s, 'Preferences saved.');
558
+ flash(s, tr(s)('wsp.prefs_saved'));
552
559
  return back(reply, s, `${BASE}/workspaces/${req.params.id}`);
553
560
  });
554
561
 
@@ -561,16 +568,16 @@ export async function workspaceRoutes(app: FastifyInstance) {
561
568
  const description = String(req.body?.description ?? '').trim();
562
569
  const position = Number(req.body?.position || 0);
563
570
  try {
564
- if (!LINK_LABEL.test(label)) throw new Error('The label is 1 to 60 characters.');
565
- if (!LINK_URL.test(url) || url.length > 2000 || !URL.canParse(url)) throw new Error('The URL starts with http:// or https://.');
566
- if (description.length > 500) throw new Error('The description is at most 500 characters.');
567
- if (!Number.isInteger(position) || Math.abs(position) > 100000) throw new Error('The order is a whole number.');
571
+ if (!LINK_LABEL.test(label)) throw new Error(tr(s)('wsp.err.label'));
572
+ if (!LINK_URL.test(url) || url.length > 2000 || !URL.canParse(url)) throw new Error(tr(s)('wsp.err.url'));
573
+ if (description.length > 500) throw new Error(tr(s)('wsp.err.description'));
574
+ if (!Number.isInteger(position) || Math.abs(position) > 100000) throw new Error(tr(s)('wsp.err.order'));
568
575
  await owner.query('insert into meta.workspace_link (workspace_id, label, url, description, position) values ($1, $2, $3, $4, $5)', [req.params.id, label, url, description || null, position]);
569
576
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${req.params.id}: extension link ${label}` });
570
- flash(s, `Link ${label} added.`);
577
+ flash(s, tr(s)('wsp.link_added', { label }));
571
578
  } catch (e) {
572
579
  const err = e as { code?: string; message: string };
573
- flash(s, err.code === '23505' ? `There is a link ${label} already.` : err.code === '23503' ? 'Not found.' : err.code ? 'The link was not saved.' : err.message, 'error');
580
+ flash(s, err.code === '23505' ? tr(s)('wsp.link_exists', { label }) : err.code === '23503' ? tr(s)('wsp.not_found') : err.code ? tr(s)('wsp.link_not_saved') : err.message, 'error');
574
581
  }
575
582
  return back(reply, s, `${BASE}/workspaces/${req.params.id}#links`);
576
583
  });
@@ -579,7 +586,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
579
586
  const s = await developer(req, reply);
580
587
  if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
581
588
  const r = await owner.query('delete from meta.workspace_link where id = $2 and workspace_id = $1', [req.params.id, req.params.lid]);
582
- flash(s, r.rowCount ? 'Link deleted.' : 'Not found.', r.rowCount ? 'ok' : 'error');
589
+ flash(s, tr(s)(r.rowCount ? 'wsp.link_deleted' : 'wsp.not_found'), r.rowCount ? 'ok' : 'error');
583
590
  return back(reply, s, `${BASE}/workspaces/${req.params.id}#links`);
584
591
  });
585
592
 
@@ -587,6 +594,7 @@ export async function workspaceRoutes(app: FastifyInstance) {
587
594
  app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/utilization`, async (req: Req, reply) => {
588
595
  const s = await developer(req, reply);
589
596
  if (!s || !(await workspaceAdminOnly(s, reply, req.params.id))) return;
597
+ const tx = tr(s);
590
598
  const w = await owner.one<{ id: number; name: string }>('select id, name from meta.workspace where id = $1', [req.params.id]);
591
599
  if (!w) return reply.code(404).send('Not found');
592
600
  const [apps, schemas, totals] = await Promise.all([
@@ -621,24 +629,24 @@ export async function workspaceRoutes(app: FastifyInstance) {
621
629
  ]);
622
630
  const size = (n: number) => (n < 1024 ? `${n} B` : n < 1048576 ? `${(n / 1024).toFixed(1)} KB` : n < 1073741824 ? `${(n / 1048576).toFixed(1)} MB` : `${(n / 1073741824).toFixed(2)} GB`);
623
631
  const sum = (k: string) => apps.rows.reduce((t, r) => t + Number(r[k]), 0);
624
- const main = html`<div class="title-row"><h1>Workspace utilization: ${w.name}</h1></div>
632
+ const main = html`<div class="title-row"><h1>${tx('wsp.util_title', { name: w.name })}</h1></div>
625
633
  <div class="stat-grid u-mb1">
626
- ${[['Applications', apps.rows.length], ['Pages', sum('pages')], ['Developers', totals.developers], ['Page views (30 days)', sum('views')], ['SQL scripts', totals.scripts]]
634
+ ${[[tx('wsp.applications'), apps.rows.length], [tx('wsp.pages'), sum('pages')], [tx('wsp.developers'), totals.developers], [tx('wsp.views30'), sum('views')], [tx('wsp.scripts'), totals.scripts]]
627
635
  .map(([l, v]) => html`<div class="stat"><b>${String(v)}</b><span>${l}</span></div>`)}
628
636
  </div>
629
- ${region('Applications', html`<div class="table-wrap"><table class="report report-reflow">
630
- <thead><tr><th>Application</th><th class="num">Pages</th><th class="num">Regions</th><th class="num">Items</th><th class="num">Page views (30 days)</th><th class="num">Users (30 days)</th><th class="num">Static files</th></tr></thead>
631
- <tbody>${apps.rows.length ? apps.rows.map((a) => html`<tr><td data-label="Application"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
632
- <td class="num" data-label="Pages">${a.pages}</td><td class="num" data-label="Regions">${a.regions}</td><td class="num" data-label="Items">${a.items}</td>
633
- <td class="num" data-label="Page views">${a.views}</td><td class="num" data-label="Users">${a.users}</td><td class="num" data-label="Static files">${size(Number(a.files))}</td></tr>`)
634
- : html`<tr><td colspan="7" class="empty">No applications.</td></tr>`}</tbody></table></div>`)}
637
+ ${region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
638
+ <thead><tr><th>${tx('wsp.application')}</th><th class="num">${tx('wsp.pages')}</th><th class="num">${tx('wsp.regions')}</th><th class="num">${tx('wsp.items')}</th><th class="num">${tx('wsp.views30')}</th><th class="num">${tx('wsp.users30')}</th><th class="num">${tx('wsp.static')}</th></tr></thead>
639
+ <tbody>${apps.rows.length ? apps.rows.map((a) => html`<tr><td data-label="${tx('wsp.application')}"><a href="${BASE}/apps/${a.id}">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
640
+ <td class="num" data-label="${tx('wsp.pages')}">${a.pages}</td><td class="num" data-label="${tx('wsp.regions')}">${a.regions}</td><td class="num" data-label="${tx('wsp.items')}">${a.items}</td>
641
+ <td class="num" data-label="${tx('wsp.views')}">${a.views}</td><td class="num" data-label="${tx('wsp.users')}">${a.users}</td><td class="num" data-label="${tx('wsp.static')}">${size(Number(a.files))}</td></tr>`)
642
+ : html`<tr><td colspan="7" class="empty">${tx('wsp.no_apps')}</td></tr>`}</tbody></table></div>`)}
635
643
  <div class="u-spacer"></div>
636
- ${region('Schemas', html`<p class="muted u-mt0">The schemas the workspace's database roles may use, with the storage of their tables, indexes and TOAST data.</p>
644
+ ${region(tx('wsp.schemas'), html`<p class="muted u-mt0">${tx('wsp.schemas_help')}</p>
637
645
  <div class="table-wrap"><table class="report report-reflow">
638
- <thead><tr><th>Schema</th><th>Owner</th><th class="num">Tables</th><th class="num">Storage</th></tr></thead>
639
- <tbody>${schemas.rows.length ? schemas.rows.map((r) => html`<tr><td data-label="Schema">${r.schema}</td><td data-label="Owner"><code>${r.owner}</code></td>
640
- <td class="num" data-label="Tables">${r.tables}</td><td class="num" data-label="Storage">${size(Number(r.bytes))}</td></tr>`)
641
- : html`<tr><td colspan="4" class="empty">No schemas: the workspace's applications have no database role of their own.</td></tr>`}</tbody></table></div>`)}`;
642
- return send(reply, s, shell(s, `Utilization: ${w.name}`, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name, `${BASE}/workspaces/${w.id}`], ['Utilization']], main));
646
+ <thead><tr><th>${tx('wsp.schema')}</th><th>${tx('wsp.owner')}</th><th class="num">${tx('wsp.tables')}</th><th class="num">${tx('wsp.storage')}</th></tr></thead>
647
+ <tbody>${schemas.rows.length ? schemas.rows.map((r) => html`<tr><td data-label="${tx('wsp.schema')}">${r.schema}</td><td data-label="${tx('wsp.owner')}"><code>${r.owner}</code></td>
648
+ <td class="num" data-label="${tx('wsp.tables')}">${r.tables}</td><td class="num" data-label="${tx('wsp.storage')}">${size(Number(r.bytes))}</td></tr>`)
649
+ : html`<tr><td colspan="4" class="empty">${tx('wsp.no_schemas')}</td></tr>`}</tbody></table></div>`)}`;
650
+ return send(reply, s, shell(s, tx('wsp.util_crumb', { name: w.name }), [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [w.name, `${BASE}/workspaces/${w.id}`], [tx('wsp.utilization')]], main));
643
651
  });
644
652
  }