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.
- package/docs/apex-feature-parity.md +1 -1
- package/docs/guide/03-builder.md +3 -2
- package/package.json +1 -1
- package/src/builder/i18n/de.ts +328 -0
- package/src/builder/i18n/en.ts +328 -0
- package/src/builder/i18n/es.ts +328 -0
- package/src/builder/i18n/fr.ts +328 -0
- package/src/builder/i18n/nl.ts +328 -0
- package/src/builder/i18n/pt.ts +328 -0
- package/src/builder/routes.ts +31 -30
- package/src/builder/shared.ts +2 -2
- package/src/builder/users.ts +123 -118
- package/src/builder/workspaces.ts +143 -135
|
@@ -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
|
|
133
|
-
|
|
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`], ['
|
|
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 '
|
|
154
|
-
if (m.includes('workspace_name_check')) return '
|
|
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
|
|
163
|
-
if (runtimeUrl === ownerUrl) return '
|
|
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 '
|
|
171
|
-
if (m.includes('workspace_role_pkey')) return '
|
|
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
|
|
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')}
|
|
181
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
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="
|
|
184
|
-
<td data-label="
|
|
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"
|
|
187
|
-
: html`<tr><td colspan="3" class="empty"
|
|
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', '
|
|
190
|
-
<div class="buttons"><button class="btn" name="action" value="add">${icon('plus')}
|
|
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="
|
|
193
|
-
: html`<p class="u-mt0"
|
|
194
|
-
<p class="muted small"
|
|
195
|
-
${problem ? html`<div class="alert alert-error" role="alert"
|
|
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="
|
|
198
|
-
return region('
|
|
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
|
-
|
|
204
|
-
|
|
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"
|
|
209
|
+
<small class="help">${tx('wsp.message_help')}</small></div>
|
|
207
210
|
<div class="form-grid">
|
|
208
|
-
${input('banner_label', '
|
|
209
|
-
${select('banner_color', '
|
|
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"
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
${check('
|
|
221
|
-
${
|
|
222
|
-
|
|
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
|
-
|
|
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
|
|
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="
|
|
232
|
-
<td data-label="
|
|
233
|
-
<td class="num" data-label="
|
|
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="
|
|
235
|
-
: html`<tr><td colspan="4" class="empty"
|
|
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', '
|
|
239
|
-
${input('url', '
|
|
240
|
-
${input('description', '
|
|
241
|
-
${input('position', '
|
|
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')}
|
|
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,
|
|
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
|
|
274
|
-
<p class="muted u-mt0"
|
|
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('
|
|
277
|
-
<thead><tr><th
|
|
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="
|
|
280
|
-
<td class="num" data-label="
|
|
281
|
-
<td data-label="
|
|
282
|
-
${region('
|
|
283
|
-
${input('name', '
|
|
284
|
-
${input('description', '
|
|
285
|
-
<p class="muted small"
|
|
286
|
-
<div class="buttons"><button class="btn btn-hot">${icon('plus')}
|
|
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, '
|
|
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, '
|
|
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,
|
|
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('
|
|
341
|
-
${input('name', '
|
|
342
|
-
${input('description', '
|
|
343
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
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"
|
|
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="
|
|
348
|
-
${apps.rows.length ? html`<p class="muted small"
|
|
349
|
-
${region('
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
357
|
-
<div class="buttons"><button class="btn"
|
|
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('
|
|
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('
|
|
363
|
-
${!full ? '' : region('
|
|
364
|
-
<thead><tr><th
|
|
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="
|
|
367
|
-
<td data-label="
|
|
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}"
|
|
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"
|
|
372
|
-
: html`<span class="muted"
|
|
373
|
-
: html`<tr><td colspan="2" class="empty"
|
|
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('
|
|
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 ? '
|
|
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, '
|
|
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,
|
|
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,
|
|
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, '
|
|
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,
|
|
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, '
|
|
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 ?
|
|
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 ? '
|
|
490
|
+
flash(s, tr(s)(on ? 'wsp.isolated_now' : 'wsp.shared_now'));
|
|
484
491
|
} catch (e) {
|
|
485
|
-
flash(s,
|
|
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(
|
|
501
|
-
if (!remove && !(await owner.one('select 1 as ok from pg_roles where rolname = $1', [role]))) throw new Error(
|
|
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(
|
|
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 ?
|
|
518
|
+
flash(s, tr(s)(remove ? 'wsp.role_removed' : 'wsp.role_added', { role }));
|
|
512
519
|
} catch (e) {
|
|
513
|
-
flash(s,
|
|
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('
|
|
529
|
-
if (label && !/^\S(.{0,38}\S)?$/.test(label)) throw new Error('
|
|
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, '
|
|
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, '
|
|
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('
|
|
565
|
-
if (!LINK_URL.test(url) || url.length > 2000 || !URL.canParse(url)) throw new Error('
|
|
566
|
-
if (description.length > 500) throw new Error('
|
|
567
|
-
if (!Number.isInteger(position) || Math.abs(position) > 100000) throw new Error('
|
|
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,
|
|
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' ?
|
|
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 ? '
|
|
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
|
|
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
|
-
${[['
|
|
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('
|
|
630
|
-
<thead><tr><th
|
|
631
|
-
<tbody>${apps.rows.length ? apps.rows.map((a) => html`<tr><td data-label="
|
|
632
|
-
<td class="num" data-label="
|
|
633
|
-
<td class="num" data-label="
|
|
634
|
-
: html`<tr><td colspan="7" class="empty"
|
|
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('
|
|
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
|
|
639
|
-
<tbody>${schemas.rows.length ? schemas.rows.map((r) => html`<tr><td data-label="
|
|
640
|
-
<td class="num" data-label="
|
|
641
|
-
: html`<tr><td colspan="4" class="empty"
|
|
642
|
-
return send(reply, s, shell(s,
|
|
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
|
}
|