pgkiln 0.80.0 → 0.82.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/db/migrations/146_database_model_providers.sql +53 -0
  2. package/db/migrations/147_condition_types.sql +80 -0
  3. package/db/migrations/148_validation_types.sql +44 -0
  4. package/db/migrations/149_dynamic_action_events.sql +45 -0
  5. package/db/migrations/150_process_types.sql +23 -0
  6. package/db/migrations/151_item_types.sql +16 -0
  7. package/db/migrations/152_workflow_diagram_region.sql +10 -0
  8. package/docs/apex-feature-parity.md +12 -10
  9. package/docs/guide/02-concepts.md +3 -3
  10. package/docs/guide/03-builder.md +13 -3
  11. package/docs/guide/04-pages-and-regions.md +17 -3
  12. package/docs/guide/05-items.md +5 -0
  13. package/docs/guide/06-processing.md +104 -17
  14. package/docs/guide/07-dynamic-actions.md +16 -1
  15. package/docs/guide/11-from-apex.md +7 -0
  16. package/examples/hr/hr_79_condition_types.sql +17 -0
  17. package/package.json +1 -1
  18. package/public/app.css +12 -0
  19. package/public/app.js +157 -9
  20. package/src/ai/embeddings.ts +20 -7
  21. package/src/appfiles.ts +4 -4
  22. package/src/builder/advisor.ts +43 -7
  23. package/src/builder/code-editor.ts +14 -10
  24. package/src/builder/components.ts +51 -21
  25. package/src/builder/conditions.ts +64 -0
  26. package/src/builder/db-users.ts +124 -118
  27. package/src/builder/designer.ts +9 -1
  28. package/src/builder/home.ts +42 -40
  29. package/src/builder/i18n/de.ts +464 -0
  30. package/src/builder/i18n/en.ts +464 -0
  31. package/src/builder/i18n/es.ts +464 -0
  32. package/src/builder/i18n/fr.ts +464 -0
  33. package/src/builder/i18n/it.ts +464 -0
  34. package/src/builder/i18n/ja.ts +464 -0
  35. package/src/builder/i18n/ko.ts +464 -0
  36. package/src/builder/i18n/nl.ts +464 -0
  37. package/src/builder/i18n/pt.ts +464 -0
  38. package/src/builder/i18n/zh-cn.ts +464 -0
  39. package/src/builder/i18n/zh-tw.ts +464 -0
  40. package/src/builder/instance.ts +27 -23
  41. package/src/builder/ldap.ts +50 -47
  42. package/src/builder/mail-queue.ts +50 -45
  43. package/src/builder/network-access.ts +46 -43
  44. package/src/builder/region-help.ts +3 -1
  45. package/src/builder/region-settings.ts +30 -5
  46. package/src/builder/vector-column.ts +3 -3
  47. package/src/builder/vector-providers.ts +16 -5
  48. package/src/i18n/ar.ts +5 -0
  49. package/src/i18n/cs.ts +5 -0
  50. package/src/i18n/da.ts +5 -0
  51. package/src/i18n/de.ts +5 -0
  52. package/src/i18n/el.ts +5 -0
  53. package/src/i18n/es.ts +5 -0
  54. package/src/i18n/fi.ts +5 -0
  55. package/src/i18n/fr.ts +5 -0
  56. package/src/i18n/he.ts +5 -0
  57. package/src/i18n/it.ts +5 -0
  58. package/src/i18n/ja.ts +5 -0
  59. package/src/i18n/ko.ts +5 -0
  60. package/src/i18n/nb.ts +5 -0
  61. package/src/i18n/pl.ts +5 -0
  62. package/src/i18n/pt.ts +5 -0
  63. package/src/i18n/ru.ts +5 -0
  64. package/src/i18n/sv.ts +5 -0
  65. package/src/i18n/tr.ts +5 -0
  66. package/src/i18n/uk.ts +5 -0
  67. package/src/i18n/zh.ts +5 -0
  68. package/src/i18n.ts +10 -0
  69. package/src/metadata.ts +72 -9
  70. package/src/runtime/authz.ts +9 -5
  71. package/src/runtime/context.ts +4 -0
  72. package/src/runtime/da-styles.ts +37 -0
  73. package/src/runtime/documents.ts +3 -2
  74. package/src/runtime/engine.ts +95 -13
  75. package/src/runtime/items.ts +33 -0
  76. package/src/runtime/logic.ts +79 -8
  77. package/src/runtime/maps.ts +50 -4
  78. package/src/runtime/process-types.ts +139 -0
  79. package/src/runtime/processes.ts +2 -0
  80. package/src/runtime/regions.ts +4 -0
  81. package/src/runtime/render.ts +13 -2
  82. package/src/runtime/routes.ts +2 -2
  83. package/src/runtime/workflow-diagram.ts +45 -0
  84. package/src/vector.ts +3 -3
  85. package/src/workflow.ts +10 -2
@@ -4,6 +4,7 @@ import { html, raw } from '../html.ts';
4
4
  import { testDirectory, type Directory } from '../ldap.ts';
5
5
  import { administrator, back, BASE, csrf, flash, input, region, send, shell, type Req } from './ui.ts';
6
6
  import { builderSignInRegion } from './builder-sso.ts';
7
+ import { tr, type BuilderTranslate } from './i18n.ts';
7
8
 
8
9
  // Users → LDAP directories: the directories an application's password form
9
10
  // can check (enabled per app under Settings → Sign-in methods). The bind
@@ -12,36 +13,36 @@ import { builderSignInRegion } from './builder-sso.ts';
12
13
  const check = (name: string, label: string, on: boolean, help?: string) =>
13
14
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label>${help ? html`<small class="help">${help}</small>` : ''}</div>`;
14
15
 
15
- function directoryForm(d: Partial<Directory> & { has_password?: boolean }, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) {
16
+ function directoryForm(tx: BuilderTranslate, d: Partial<Directory> & { has_password?: boolean }, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) {
16
17
  return html`<form method="post" action="${action}">${csrfField}
17
- <fieldset class="prop-group"><legend>Server</legend><div class="form-grid">
18
- ${isNew ? input('name', 'Name', '', { required: true, help: 'lowercase, e.g. corp or ad' }) : ''}
19
- ${input('display_name', 'Display name', d.display_name, { required: true })}
20
- ${input('url', 'URL', d.url, { required: true, placeholder: 'ldaps://ldap.example.com or ldap://…:389', help: 'ldaps:// (TLS) or ldap://, with StartTLS below. Plain ldap:// sends passwords unencrypted.' })}
21
- ${input('bind_dn', 'Service account DN', d.bind_dn, { placeholder: 'cn=pgkiln,ou=services,dc=example,dc=org', help: 'Searches users (and groups). Empty: an anonymous search.' })}
22
- ${input('bind_password', 'Service account password', '', { type: 'password', auto: 'new-password', help: d.has_password ? 'A password is stored. Leave empty to keep it.' : '' })}
18
+ <fieldset class="prop-group"><legend>${tx('ldap.server')}</legend><div class="form-grid">
19
+ ${isNew ? input('name', tx('ldap.name'), '', { required: true, help: tx('ldap.name_help') }) : ''}
20
+ ${input('display_name', tx('ldap.display_name'), d.display_name, { required: true })}
21
+ ${input('url', tx('ldap.url'), d.url, { required: true, placeholder: 'ldaps://ldap.example.com or ldap://…:389', help: tx('ldap.url_help') })}
22
+ ${input('bind_dn', tx('ldap.bind_dn'), d.bind_dn, { placeholder: 'cn=pgkiln,ou=services,dc=example,dc=org', help: tx('ldap.bind_dn_help') })}
23
+ ${input('bind_password', tx('ldap.bind_password'), '', { type: 'password', auto: 'new-password', help: d.has_password ? tx('ldap.password_stored') : '' })}
23
24
  </div>
24
- ${check('start_tls', 'StartTLS (for ldap:// URLs)', !!d.start_tls)}
25
- ${check('tls_verify', 'Verify the server certificate', d.tls_verify !== false, 'Turn off only for test servers with self-signed certificates.')}
26
- ${!isNew && d.has_password ? check('remove_password', 'Remove the stored password (anonymous search)', false) : ''}
25
+ ${check('start_tls', tx('ldap.start_tls'), !!d.start_tls)}
26
+ ${check('tls_verify', tx('ldap.tls_verify'), d.tls_verify !== false, tx('ldap.tls_verify_help'))}
27
+ ${!isNew && d.has_password ? check('remove_password', tx('ldap.remove_password'), false) : ''}
27
28
  </fieldset>
28
- <fieldset class="prop-group"><legend>Users</legend><div class="form-grid">
29
- ${input('user_base', 'User search base', d.user_base, { required: true, placeholder: 'ou=people,dc=example,dc=org' })}
30
- ${input('user_filter', 'User filter', d.user_filter ?? '(uid={username})', { help: '{username} is replaced by the escaped username. Active Directory: (sAMAccountName={username})' })}
31
- ${input('username_attribute', 'Username attribute', d.username_attribute ?? 'uid', { help: 'The pgkiln username; AD: sAMAccountName' })}
32
- ${input('display_name_attribute', 'Display name attribute', d.display_name_attribute ?? 'cn')}
33
- ${input('email_attribute', 'E-mail attribute', d.email_attribute ?? 'mail')}
29
+ <fieldset class="prop-group"><legend>${tx('ldap.users')}</legend><div class="form-grid">
30
+ ${input('user_base', tx('ldap.user_base'), d.user_base, { required: true, placeholder: 'ou=people,dc=example,dc=org' })}
31
+ ${input('user_filter', tx('ldap.user_filter'), d.user_filter ?? '(uid={username})', { help: tx('ldap.user_filter_help', { ad: '(sAMAccountName={username})' }) })}
32
+ ${input('username_attribute', tx('ldap.username_attr'), d.username_attribute ?? 'uid', { help: tx('ldap.username_attr_help') })}
33
+ ${input('display_name_attribute', tx('ldap.display_attr'), d.display_name_attribute ?? 'cn')}
34
+ ${input('email_attribute', tx('ldap.email_attr'), d.email_attribute ?? 'mail')}
34
35
  </div>
35
- ${check('auto_create', 'Create accounts automatically on first sign-in', !!d.auto_create, 'Otherwise only people with an existing account (same username) can sign in.')}
36
+ ${check('auto_create', tx('ldap.auto_create'), !!d.auto_create, tx('ldap.auto_create_help'))}
36
37
  </fieldset>
37
- <fieldset class="prop-group"><legend>Groups (mapped to roles under each app's Access control)</legend><div class="form-grid">
38
- ${input('group_attribute', 'Group attribute of the user', d.group_attribute ?? 'memberOf', { help: 'Group DNs; the first value of each is the group name. Empty: not used.' })}
39
- ${input('group_base', 'Group search base', d.group_base, { placeholder: 'ou=groups,dc=example,dc=org', help: 'Optional: search groups as well.' })}
40
- ${input('group_filter', 'Group filter', d.group_filter ?? '(|(member={dn})(uniqueMember={dn}))', { help: '{dn}: the user\'s DN, {username}: the username (both escaped).' })}
41
- ${input('group_name_attribute', 'Group name attribute', d.group_name_attribute ?? 'cn')}
38
+ <fieldset class="prop-group"><legend>${tx('ldap.groups')}</legend><div class="form-grid">
39
+ ${input('group_attribute', tx('ldap.group_attr'), d.group_attribute ?? 'memberOf', { help: tx('ldap.group_attr_help') })}
40
+ ${input('group_base', tx('ldap.group_base'), d.group_base, { placeholder: 'ou=groups,dc=example,dc=org', help: tx('ldap.group_base_help') })}
41
+ ${input('group_filter', tx('ldap.group_filter'), d.group_filter ?? '(|(member={dn})(uniqueMember={dn}))', { help: tx('ldap.group_filter_help') })}
42
+ ${input('group_name_attribute', tx('ldap.group_name_attr'), d.group_name_attribute ?? 'cn')}
42
43
  </div></fieldset>
43
- ${check('enabled', 'Enabled', d.enabled !== false)}
44
- <div class="buttons"><button class="btn btn-hot">${isNew ? 'Add directory' : 'Save'}</button></div>
44
+ ${check('enabled', tx('ldap.enabled'), d.enabled !== false)}
45
+ <div class="buttons"><button class="btn btn-hot">${isNew ? tx('ldap.add') : tx('ldap.save')}</button></div>
45
46
  </form>`;
46
47
  }
47
48
 
@@ -62,22 +63,23 @@ export async function ldapRoutes(app: FastifyInstance) {
62
63
  (select count(*) from meta.ldap_identity i where i.directory_id = d.id)::int as linked,
63
64
  (select string_agg(a.alias, ', ' order by a.alias) from meta.app a where d.name = any(a.ldap_directories)) as apps
64
65
  from meta.ldap_directory d order by display_name`)).rows;
66
+ const tx = tr(s);
65
67
  const main = html`
66
- <div class="title-row"><h1>LDAP directories</h1></div>
67
- <p class="muted u-mt0">OpenLDAP, Active Directory and other LDAP servers. An application's sign-in form checks local passwords first, then the directories enabled under <b>Settings → Sign-in methods</b>. Directory groups map to roles under each application's <b>Access control</b>.</p>
68
+ <div class="title-row"><h1>${tx('ldap.title')}</h1></div>
69
+ <p class="muted u-mt0">${tx.rich('ldap.intro', { where: html`<b>${tx('ldap.where')}</b>`, access: html`<b>${tx('ldap.access')}</b>` })}</p>
68
70
  <div class="columns wide-left">
69
- ${region('Directories', html`<div class="table-wrap"><table class="report report-reflow">
70
- <thead><tr><th>Directory</th><th>URL</th><th>Used by</th><th>Linked accounts</th><th>Status</th></tr></thead>
71
+ ${region(tx('ldap.directories'), html`<div class="table-wrap"><table class="report report-reflow">
72
+ <thead><tr><th>${tx('ldap.col.directory')}</th><th>${tx('ldap.url')}</th><th>${tx('ldap.col.used_by')}</th><th>${tx('ldap.col.linked')}</th><th>${tx('ldap.col.status')}</th></tr></thead>
71
73
  <tbody>${rows.length ? rows.map((r) => html`<tr>
72
- <td data-label="Directory"><a href="${BASE}/users/directories/${r.id}">${r.display_name}</a> <span class="muted">${r.name}</span></td>
73
- <td data-label="URL">${r.url}</td>
74
- <td data-label="Used by">${r.apps ?? html`<span class="muted">no apps</span>`}</td>
75
- <td data-label="Linked accounts">${r.linked}</td>
76
- <td data-label="Status">${r.enabled ? 'enabled' : html`<b>disabled</b>`}${r.auto_create ? ' · auto-create' : ''}</td>
77
- </tr>`) : html`<tr><td colspan="5" class="empty">No LDAP directories yet.</td></tr>`}</tbody></table></div>`)}
78
- ${region('Add directory', directoryForm({}, `${BASE}/users/directories`, csrf(s), true))}
74
+ <td data-label="${tx('ldap.col.directory')}"><a href="${BASE}/users/directories/${r.id}">${r.display_name}</a> <span class="muted">${r.name}</span></td>
75
+ <td data-label="${tx('ldap.url')}">${r.url}</td>
76
+ <td data-label="${tx('ldap.col.used_by')}">${r.apps ?? html`<span class="muted">${tx('ldap.no_apps')}</span>`}</td>
77
+ <td data-label="${tx('ldap.col.linked')}">${r.linked}</td>
78
+ <td data-label="${tx('ldap.col.status')}">${r.enabled ? tx('ldap.status_enabled') : html`<b>${tx('ldap.status_disabled')}</b>`}${r.auto_create ? ` · ${tx('ldap.status_auto')}` : ''}</td>
79
+ </tr>`) : html`<tr><td colspan="5" class="empty">${tx('ldap.none')}</td></tr>`}</tbody></table></div>`)}
80
+ ${region(tx('ldap.add'), directoryForm(tx, {}, `${BASE}/users/directories`, csrf(s), true))}
79
81
  </div>`;
80
- return send(reply, s, shell(s, 'LDAP directories', [['Users', `${BASE}/users`], ['LDAP directories']], main, 'users'));
82
+ return send(reply, s, shell(s, tx('ldap.title'), [[tx('wu.users'), `${BASE}/users`], [tx('ldap.title')]], main, 'users'));
81
83
  });
82
84
 
83
85
  app.post(`${BASE}/users/directories`, async (req: Req, reply) => {
@@ -90,7 +92,7 @@ export async function ldapRoutes(app: FastifyInstance) {
90
92
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18) returning id`,
91
93
  [b.name?.trim().toLowerCase(), ...values(b), b.bind_password || null],
92
94
  );
93
- flash(s, 'Directory added. Test the connection, then enable it for an application.');
95
+ flash(s, tr(s)('ldap.added'));
94
96
  return back(reply, s, `${BASE}/users/directories/${r.id}`);
95
97
  } catch (e) {
96
98
  flash(s, (e as Error).message, 'error');
@@ -104,20 +106,21 @@ export async function ldapRoutes(app: FastifyInstance) {
104
106
  const d = /^\d+$/.test(req.params.id) ? await owner.one('select *, bind_password is not null as has_password from meta.ldap_directory where id = $1', [req.params.id]) : undefined;
105
107
  if (!d) return reply.code(404).send('Not found');
106
108
  delete d.bind_password; // never sent to the browser
109
+ const tx = tr(s);
107
110
  const main = html`
108
111
  <div class="title-row"><h1>${d.display_name}</h1></div>
109
112
  <div class="columns wide-left">
110
- ${region('Settings', html`${directoryForm(d, `${BASE}/users/directories/${d.id}`, csrf(s), false)}
113
+ ${region(tx('ldap.settings'), html`${directoryForm(tx, d, `${BASE}/users/directories/${d.id}`, csrf(s), false)}
111
114
  <form method="post" action="${BASE}/users/directories/${d.id}/delete" class="danger-zone">${csrf(s)}
112
- <button class="btn btn-danger" data-confirm="Delete ${d.display_name}? Links to accounts are removed; accounts stay.">Delete directory</button></form>`)}
113
- ${region('Test connection', html`
114
- <p class="muted u-mt0">Connects, binds as the service account (or anonymously) and checks the search base; with a username, looks the user up with the filter. No password is checked.</p>
115
+ <button class="btn btn-danger" data-confirm="${tx('ldap.delete_confirm', { name: d.display_name })}">${tx('ldap.delete')}</button></form>`)}
116
+ ${region(tx('ldap.test'), html`
117
+ <p class="muted u-mt0">${tx('ldap.test_intro')}</p>
115
118
  <form method="post" action="${BASE}/users/directories/${d.id}/test" class="search u-mwnone">${csrf(s)}
116
- <input name="username" placeholder="Username (optional)" aria-label="Username to look up" maxlength="100">
117
- <button class="btn">Test</button></form>`)}
119
+ <input name="username" placeholder="${tx('ldap.test_username')}" aria-label="${tx('ldap.test_username_label')}" maxlength="100">
120
+ <button class="btn">${tx('ldap.test_btn')}</button></form>`)}
118
121
  ${await builderSignInRegion(s, 'directory', d)}
119
122
  </div>`;
120
- return send(reply, s, shell(s, d.display_name, [['Users', `${BASE}/users`], ['LDAP directories', `${BASE}/users/directories`], [d.display_name]], main, 'users'));
123
+ return send(reply, s, shell(s, d.display_name, [[tx('wu.users'), `${BASE}/users`], [tx('ldap.title'), `${BASE}/users/directories`], [d.display_name]], main, 'users'));
121
124
  });
122
125
 
123
126
  app.post(`${BASE}/users/directories/:id`, async (req: Req, reply) => {
@@ -131,7 +134,7 @@ export async function ldapRoutes(app: FastifyInstance) {
131
134
  where id = $1`,
132
135
  [req.params.id, ...values(b), b.bind_password || null, b.remove_password === 'true'],
133
136
  );
134
- flash(s, 'Directory saved.');
137
+ flash(s, tr(s)('ldap.saved'));
135
138
  } catch (e) {
136
139
  flash(s, (e as Error).message, 'error');
137
140
  }
@@ -146,7 +149,7 @@ export async function ldapRoutes(app: FastifyInstance) {
146
149
  try {
147
150
  flash(s, await testDirectory(d, (req.body?.username ?? '').trim().slice(0, 100) || undefined));
148
151
  } catch (e) {
149
- flash(s, `The test failed: ${(e as Error).message}`, 'error');
152
+ flash(s, tr(s)('ldap.test_failed', { error: (e as Error).message }), 'error');
150
153
  }
151
154
  return back(reply, s, `${BASE}/users/directories/${d.id}`);
152
155
  });
@@ -157,7 +160,7 @@ export async function ldapRoutes(app: FastifyInstance) {
157
160
  const d = await owner.one('delete from meta.ldap_directory where id = $1 returning name', [req.params.id]);
158
161
  // and from the applications that used it
159
162
  if (d) await owner.query('update meta.app set ldap_directories = array_remove(ldap_directories, $1) where $1 = any(ldap_directories)', [d.name]);
160
- flash(s, 'Directory deleted.');
163
+ flash(s, tr(s)('ldap.deleted'));
161
164
  return back(reply, s, `${BASE}/users/directories`);
162
165
  });
163
166
  }
@@ -6,6 +6,7 @@ import { clientIp, logActivity, type Session } from '../session.ts';
6
6
  import { isAdmin } from './locks.ts';
7
7
  import { back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
8
8
  import { workspaceAdmin } from './workspaces.ts';
9
+ import { tr } from './i18n.ts';
9
10
 
10
11
  // (sprint 118, WS-9; APEX 26.2 Administration → Mail queue) Delivery health of
11
12
  // the mail queue (meta.mail_queue): queued, sent, failed and retrying mail per
@@ -18,6 +19,8 @@ import { workspaceAdmin } from './workspaces.ts';
18
19
  // attachments are never shown here.
19
20
 
20
21
  export const PERIODS: [string, string][] = [['24', 'Last 24 hours'], ['48', 'Last 48 hours'], ['168', 'Last 7 days']];
22
+ /** (sprint 152) the periods in the developer's language */
23
+ const periods = (tx: ReturnType<typeof tr>): [string, string][] => PERIODS.map(([v]) => [v, tx(`mq.period.${v}` as 'mq.period.24')]);
21
24
 
22
25
  /** The mail a developer may see: every workspace (null) or one; false when none. */
23
26
  export async function queueScope(s: Session): Promise<{ admin: boolean; workspace: number | null } | false> {
@@ -27,8 +30,9 @@ export async function queueScope(s: Session): Promise<{ admin: boolean; workspac
27
30
  }
28
31
 
29
32
  async function refuse(s: Session, reply: FastifyReply) {
30
- const main = html`<h1 class="u-mb1">Mail queue</h1><div class="alert alert-error" role="alert">Only administrators and workspace administrators see the mail queue.</div>`;
31
- await send(reply.code(403), s, shell(s, 'Mail queue', [['Administration'], ['Mail queue']], main, 'users'));
33
+ const tx = tr(s);
34
+ const main = html`<h1 class="u-mb1">${tx('mq.title')}</h1><div class="alert alert-error" role="alert">${tx('mq.refused')}</div>`;
35
+ await send(reply.code(403), s, shell(s, tx('mq.title'), [[tx('rail.admin')], [tx('mq.title')]], main, 'users'));
32
36
  }
33
37
 
34
38
  const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
@@ -42,6 +46,7 @@ export async function mailQueueRoutes(app: FastifyInstance) {
42
46
  if (!s) return;
43
47
  const scope = await queueScope(s);
44
48
  if (!scope) return refuse(s, reply);
49
+ const tx = tr(s);
45
50
  const hours = PERIODS.some(([v]) => v === req.query.period) ? Number(req.query.period) : 24;
46
51
  const ws = scope.admin ? id(req.query.ws) : scope.workspace;
47
52
  const appId = id(req.query.app);
@@ -94,10 +99,10 @@ export async function mailQueueRoutes(app: FastifyInstance) {
94
99
  ]);
95
100
  const wsName = ws ? (scope.admin ? workspaces.find((w) => w.id === ws)?.name : s.workspace?.name) : null;
96
101
  const filters = html`<form method="get" class="filter-row mail-queue-filters">
97
- ${select('period', 'Period', String(hours), PERIODS)}
98
- ${scope.admin ? select('ws', 'Workspace', ws ? String(ws) : '', [['', 'All workspaces'], ...workspaces.map((w): [string, string] => [String(w.id), w.name])]) : ''}
99
- ${select('app', 'Application', appId ? String(appId) : '', [['', 'All applications'], ...apps.map((a): [string, string] => [String(a.id), a.alias])])}
100
- <div class="buttons"><button class="btn">Show</button></div></form>`;
102
+ ${select('period', tx('mq.period'), String(hours), periods(tx))}
103
+ ${scope.admin ? select('ws', tx('mq.workspace'), ws ? String(ws) : '', [['', tx('mq.all_workspaces')], ...workspaces.map((w): [string, string] => [String(w.id), w.name])]) : ''}
104
+ ${select('app', tx('mq.app'), appId ? String(appId) : '', [['', tx('mq.all_apps')], ...apps.map((a): [string, string] => [String(a.id), a.alias])])}
105
+ <div class="buttons"><button class="btn">${tx('common.show')}</button></div></form>`;
101
106
  const total = totals.sent + totals.failed;
102
107
  const health = total ? Math.round((totals.sent / total) * 1000) / 10 : null;
103
108
  const stat = (n: unknown, label: string, cls = '') => html`<div class="stat${cls}"><b>${n ?? '–'}</b><span>${label}</span></div>`;
@@ -105,50 +110,50 @@ export async function mailQueueRoutes(app: FastifyInstance) {
105
110
  const action = (path: string, label: unknown, cls: string, confirm?: string) =>
106
111
  html`<form method="post" action="${BASE}/mail-queue${path}?${keep}" class="inline-form">${csrf(s)}<button class="btn btn-sm${cls}"${confirm ? html` data-confirm="${confirm}"` : ''}>${label}</button></form>`;
107
112
  const main = html`
108
- <div class="title-row"><h1>Mail queue</h1></div>
109
- <p class="muted u-mt0">Delivery of the mail ${wsName ? html`of workspace <b>${wsName}</b>` : 'of every workspace'}: what was queued, sent and failed, which mail is being retried, how long the providers took, and the failures with the provider's answer. Mail is kept 30 days.${scope.admin ? '' : ' You see your workspace\'s applications only.'}</p>
113
+ <div class="title-row"><h1>${tx('mq.title')}</h1></div>
114
+ <p class="muted u-mt0">${wsName ? tx.rich('mq.intro_ws', { workspace: html`<b>${wsName}</b>` }) : tx('mq.intro_all')}${scope.admin ? '' : ` ${tx('mq.only_yours')}`}</p>
110
115
  ${filters}
111
116
  <div class="stat-grid mail-queue-stats">
112
- ${stat(totals.queued, 'queued now')}${stat(totals.retrying, 'retrying now')}${stat(totals.sending, 'being sent')}
113
- ${stat(totals.sent, 'sent')}${stat(totals.failed, 'failed', totals.failed ? ' stat-bad' : '')}
114
- ${stat(health === null ? null : `${health}%`, 'delivered')}${stat(totals.avg_ms === null ? null : `${totals.avg_ms} ms`, 'average provider call')}
117
+ ${stat(totals.queued, tx('mq.stat.queued'))}${stat(totals.retrying, tx('mq.stat.retrying'))}${stat(totals.sending, tx('mq.stat.sending'))}
118
+ ${stat(totals.sent, tx('mq.stat.sent'))}${stat(totals.failed, tx('mq.stat.failed'), totals.failed ? ' stat-bad' : '')}
119
+ ${stat(health === null ? null : `${health}%`, tx('mq.stat.delivered'))}${stat(totals.avg_ms === null ? null : `${totals.avg_ms} ms`, tx('mq.stat.avg'))}
115
120
  </div>
116
121
  <div class="columns">
117
- ${region('Per hour', html`<div class="table-wrap"><table class="report report-reflow">
118
- <thead><tr><th>Hour (UTC)</th><th class="num">Queued</th><th class="num">Sent</th><th class="num">Failed</th><th class="num">Retrying</th></tr></thead>
119
- <tbody>${perHour.length ? perHour.map((h) => html`<tr><td data-label="Hour">${when(h.hour)}</td><td class="num" data-label="Queued">${h.queued}</td>
120
- <td class="num" data-label="Sent">${h.sent}</td><td class="num" data-label="Failed">${h.failed ? html`<b>${h.failed}</b>` : 0}</td><td class="num" data-label="Retrying">${h.retrying}</td></tr>`)
121
- : html`<tr><td colspan="5" class="empty">No mail in this period.</td></tr>`}</tbody></table></div>`)}
122
- ${region('Slowest provider calls', html`<div class="table-wrap"><table class="report report-reflow">
123
- <thead><tr><th>When (UTC)</th><th>Application</th><th>Service</th><th>Status</th><th class="num">Time</th></tr></thead>
124
- <tbody>${slowest.length ? slowest.map((m) => html`<tr><td data-label="When">${when(m.last_attempt_at)}</td><td data-label="Application">${m.alias}</td>
125
- <td data-label="Service" class="u-break">${m.service ?? ''}${m.provider ? html` <span class="muted">${m.provider}</span>` : ''}</td>
126
- <td data-label="Status"><span class="tag${m.status === 'sent' ? ' tag-ok' : m.status === 'error' ? ' tag-error' : ''}">${m.status === 'queued' ? 'retrying' : m.status}</span></td>
127
- <td class="num" data-label="Time">${m.duration_ms} ms</td></tr>`)
128
- : html`<tr><td colspan="5" class="empty">No provider calls in this period.</td></tr>`}</tbody></table></div>`)}
122
+ ${region(tx('mq.per_hour'), html`<div class="table-wrap"><table class="report report-reflow">
123
+ <thead><tr><th>${tx('mq.col.hour')}</th><th class="num">${tx('mq.col.queued')}</th><th class="num">${tx('mq.col.sent')}</th><th class="num">${tx('mq.col.failed')}</th><th class="num">${tx('mq.col.retrying')}</th></tr></thead>
124
+ <tbody>${perHour.length ? perHour.map((h) => html`<tr><td data-label="${tx('mq.col.hour_short')}">${when(h.hour)}</td><td class="num" data-label="${tx('mq.col.queued')}">${h.queued}</td>
125
+ <td class="num" data-label="${tx('mq.col.sent')}">${h.sent}</td><td class="num" data-label="${tx('mq.col.failed')}">${h.failed ? html`<b>${h.failed}</b>` : 0}</td><td class="num" data-label="${tx('mq.col.retrying')}">${h.retrying}</td></tr>`)
126
+ : html`<tr><td colspan="5" class="empty">${tx('mq.none_period')}</td></tr>`}</tbody></table></div>`)}
127
+ ${region(tx('mq.slowest'), html`<div class="table-wrap"><table class="report report-reflow">
128
+ <thead><tr><th>${tx('mq.col.when')}</th><th>${tx('mq.app')}</th><th>${tx('mq.col.service')}</th><th>${tx('mq.col.status')}</th><th class="num">${tx('mq.col.time')}</th></tr></thead>
129
+ <tbody>${slowest.length ? slowest.map((m) => html`<tr><td data-label="${tx('mq.col.when_short')}">${when(m.last_attempt_at)}</td><td data-label="${tx('mq.app')}">${m.alias}</td>
130
+ <td data-label="${tx('mq.col.service')}" class="u-break">${m.service ?? ''}${m.provider ? html` <span class="muted">${m.provider}</span>` : ''}</td>
131
+ <td data-label="${tx('mq.col.status')}"><span class="tag${m.status === 'sent' ? ' tag-ok' : m.status === 'error' ? ' tag-error' : ''}">${m.status === 'queued' ? tx('mq.status.retrying') : ['sent', 'error', 'sending', 'cancelled'].includes(m.status) ? tx(`mq.status.${m.status}` as 'mq.status.sent') : m.status}</span></td>
132
+ <td class="num" data-label="${tx('mq.col.time')}">${m.duration_ms} ms</td></tr>`)
133
+ : html`<tr><td colspan="5" class="empty">${tx('mq.none_calls')}</td></tr>`}</tbody></table></div>`)}
129
134
  </div>
130
- ${region('Per workspace and application', html`<div class="table-wrap"><table class="report report-reflow">
131
- <thead><tr><th>Workspace</th><th>Application</th><th class="num">Sent</th><th class="num">Failed</th><th class="num">Retrying</th><th class="num">Queued</th><th class="num">Average</th><th class="num">Slowest</th></tr></thead>
132
- <tbody>${usage.length ? usage.map((u) => html`<tr><td data-label="Workspace">${u.workspace}</td>
133
- <td data-label="Application"><a href="${BASE}/mail-queue?${new URLSearchParams({ period: String(hours), ...(scope.admin && ws ? { ws: String(ws) } : {}), app: String(u.app_id) }).toString()}">${u.alias}</a></td>
134
- <td class="num" data-label="Sent">${u.sent}</td><td class="num" data-label="Failed">${u.failed ? html`<b>${u.failed}</b>` : 0}</td>
135
- <td class="num" data-label="Retrying">${u.retrying}</td><td class="num" data-label="Queued">${u.queued}</td>
136
- <td class="num" data-label="Average">${u.avg_ms === null ? '–' : `${u.avg_ms} ms`}</td><td class="num" data-label="Slowest">${u.max_ms === null ? '–' : `${u.max_ms} ms`}</td></tr>`)
137
- : html`<tr><td colspan="8" class="empty">No mail in this period.</td></tr>`}</tbody></table></div>`)}
138
- ${region('Failures', html`<p class="muted u-mt0">Mail that will not be tried again: the provider refused it, or it failed ${5} times. <b>Retry</b> queues it again with fresh attempts; <b>Purge</b> deletes it.</p>
135
+ ${region(tx('mq.per_app'), html`<div class="table-wrap"><table class="report report-reflow">
136
+ <thead><tr><th>${tx('mq.workspace')}</th><th>${tx('mq.app')}</th><th class="num">${tx('mq.col.sent')}</th><th class="num">${tx('mq.col.failed')}</th><th class="num">${tx('mq.col.retrying')}</th><th class="num">${tx('mq.col.queued')}</th><th class="num">${tx('mq.col.average')}</th><th class="num">${tx('mq.col.slowest')}</th></tr></thead>
137
+ <tbody>${usage.length ? usage.map((u) => html`<tr><td data-label="${tx('mq.workspace')}">${u.workspace}</td>
138
+ <td data-label="${tx('mq.app')}"><a href="${BASE}/mail-queue?${new URLSearchParams({ period: String(hours), ...(scope.admin && ws ? { ws: String(ws) } : {}), app: String(u.app_id) }).toString()}">${u.alias}</a></td>
139
+ <td class="num" data-label="${tx('mq.col.sent')}">${u.sent}</td><td class="num" data-label="${tx('mq.col.failed')}">${u.failed ? html`<b>${u.failed}</b>` : 0}</td>
140
+ <td class="num" data-label="${tx('mq.col.retrying')}">${u.retrying}</td><td class="num" data-label="${tx('mq.col.queued')}">${u.queued}</td>
141
+ <td class="num" data-label="${tx('mq.col.average')}">${u.avg_ms === null ? '–' : `${u.avg_ms} ms`}</td><td class="num" data-label="${tx('mq.col.slowest')}">${u.max_ms === null ? '–' : `${u.max_ms} ms`}</td></tr>`)
142
+ : html`<tr><td colspan="8" class="empty">${tx('mq.none_period')}</td></tr>`}</tbody></table></div>`)}
143
+ ${region(tx('mq.failures'), html`<p class="muted u-mt0">${tx.rich('mq.failures_intro', { n: 5, retry: html`<b>${tx('mq.retry')}</b>`, purge: html`<b>${tx('mq.purge')}</b>` })}</p>
139
144
  <div class="table-wrap"><table class="report report-reflow mail-failures">
140
- <thead><tr><th>Failed (UTC)</th><th>Application</th><th>To</th><th>Subject</th><th>Provider's answer</th><th>Actions</th></tr></thead>
145
+ <thead><tr><th>${tx('mq.col.failed_at')}</th><th>${tx('mq.app')}</th><th>${tx('mq.col.to')}</th><th>${tx('mq.col.subject')}</th><th>${tx('mq.col.answer')}</th><th>${tx('mq.col.actions')}</th></tr></thead>
141
146
  <tbody>${failures.length ? failures.map((m) => html`<tr>
142
- <td data-label="Failed">${when(m.failed_at)}<br><span class="muted small">queued ${when(m.created_at)} · ${m.attempts} attempt${m.attempts === 1 ? '' : 's'}</span></td>
143
- <td data-label="Application">${m.alias}${scope.admin && !ws ? html`<br><span class="muted small">${m.workspace}</span>` : ''}</td>
144
- <td data-label="To" class="u-break">${[...m.to_addresses, ...m.cc_addresses].join(', ')}${m.bcc_addresses.length ? html` <span class="muted">+${m.bcc_addresses.length} bcc</span>` : ''}</td>
145
- <td data-label="Subject" class="u-break">${m.subject}</td>
146
- <td data-label="Provider's answer" class="u-break">${m.service ? html`<span class="muted small">${m.service}${m.provider ? ` (${m.provider})` : ''}</span><br>` : ''}<span class="mail-answer">${m.message ?? ''}</span></td>
147
- <td data-label="Actions"><div class="buttons u-mt0">${action(`/${m.id}/retry`, 'Retry', '')}${action(`/${m.id}/purge`, 'Purge', ' btn-danger', 'Delete this mail?')}</div></td></tr>`)
148
- : html`<tr><td colspan="6" class="empty">No failed mail.</td></tr>`}</tbody></table></div>
149
- ${failures.length ? html`<div class="buttons">${action('/retry', html`${icon('refresh')} Retry all failed`, '')}${action('/purge', html`${icon('trash')} Purge all failed`, ' btn-danger', 'Delete every failed mail shown here?')}</div>` : ''}`,
147
+ <td data-label="${tx('mq.col.failed')}">${when(m.failed_at)}<br><span class="muted small">${tx('mq.queued_at', { when: when(m.created_at) })} · ${tx(m.attempts === 1 ? 'mq.attempts_one' : 'mq.attempts', { n: m.attempts })}</span></td>
148
+ <td data-label="${tx('mq.app')}">${m.alias}${scope.admin && !ws ? html`<br><span class="muted small">${m.workspace}</span>` : ''}</td>
149
+ <td data-label="${tx('mq.col.to')}" class="u-break">${[...m.to_addresses, ...m.cc_addresses].join(', ')}${m.bcc_addresses.length ? html` <span class="muted">+${m.bcc_addresses.length} bcc</span>` : ''}</td>
150
+ <td data-label="${tx('mq.col.subject')}" class="u-break">${m.subject}</td>
151
+ <td data-label="${tx('mq.col.answer')}" class="u-break">${m.service ? html`<span class="muted small">${m.service}${m.provider ? ` (${m.provider})` : ''}</span><br>` : ''}<span class="mail-answer">${m.message ?? ''}</span></td>
152
+ <td data-label="${tx('mq.col.actions')}"><div class="buttons u-mt0">${action(`/${m.id}/retry`, tx('mq.retry'), '')}${action(`/${m.id}/purge`, tx('mq.purge'), ' btn-danger', tx('mq.purge_confirm'))}</div></td></tr>`)
153
+ : html`<tr><td colspan="6" class="empty">${tx('mq.none_failed')}</td></tr>`}</tbody></table></div>
154
+ ${failures.length ? html`<div class="buttons">${action('/retry', html`${icon('refresh')} ${tx('mq.retry_all')}`, '')}${action('/purge', html`${icon('trash')} ${tx('mq.purge_all')}`, ' btn-danger', tx('mq.purge_all_confirm'))}</div>` : ''}`,
150
155
  '', 'failures')}`;
151
- return send(reply, s, shell(s, 'Mail queue', [['Administration'], ['Mail queue']], main, 'users'));
156
+ return send(reply, s, shell(s, tx('mq.title'), [[tx('rail.admin')], [tx('mq.title')]], main, 'users'));
152
157
  });
153
158
 
154
159
  /** One mail's id when this developer may act on it (its application is in their scope). */
@@ -174,7 +179,7 @@ export async function mailQueueRoutes(app: FastifyInstance) {
174
179
  const r = await owner.query(`update meta.mail_queue set status = 'queued', attempts = 0, not_before = now(), message = null where id = $1 and status in ('error', 'cancelled')`, [m.id]);
175
180
  if (r.rowCount) await owner.query(`select pg_notify('pgkiln_mail', $1)`, [String(m.id)]);
176
181
  await logActivity({ appId: m.app_id, username: s.username, event: 'mail_queue', ip: clientIp(req), detail: `retry mail ${m.id}` });
177
- flash(s, r.rowCount ? 'The mail is queued again.' : 'The mail has changed meanwhile.', r.rowCount ? 'ok' : 'error');
182
+ flash(s, r.rowCount ? tr(s)('mq.requeued') : tr(s)('mq.changed'), r.rowCount ? 'ok' : 'error');
178
183
  return back(reply, s, redirect(req));
179
184
  });
180
185
 
@@ -187,7 +192,7 @@ export async function mailQueueRoutes(app: FastifyInstance) {
187
192
  // mail that is waiting or being sent is cancelled on the application's Mail page, not purged
188
193
  const r = await owner.query(`delete from meta.mail_queue where id = $1 and status in ('error', 'cancelled', 'sent')`, [m.id]);
189
194
  await logActivity({ appId: m.app_id, username: s.username, event: 'mail_queue', ip: clientIp(req), detail: `purged mail ${m.id}` });
190
- flash(s, r.rowCount ? 'The mail is deleted.' : 'Only sent, failed or cancelled mail can be purged.', r.rowCount ? 'ok' : 'error');
195
+ flash(s, r.rowCount ? tr(s)('mq.deleted') : tr(s)('mq.only_done'), r.rowCount ? 'ok' : 'error');
191
196
  return back(reply, s, redirect(req));
192
197
  });
193
198
 
@@ -206,7 +211,7 @@ export async function mailQueueRoutes(app: FastifyInstance) {
206
211
  if (what === 'retry' && r.rowCount) await owner.query(`select pg_notify('pgkiln_mail', '')`);
207
212
  await logActivity({ appId: appId, username: s.username, event: 'mail_queue', ip: clientIp(req),
208
213
  detail: `${what === 'retry' ? 'retried' : 'purged'} ${r.rowCount} failed mail(s)${ws ? ` of workspace ${ws}` : ''}${appId ? ` of application ${appId}` : ''}` });
209
- flash(s, `${r.rowCount} mail${r.rowCount === 1 ? '' : 's'} ${what === 'retry' ? 'queued again' : 'deleted'}.`);
214
+ flash(s, tr(s)(what === 'retry' ? (r.rowCount === 1 ? 'mq.n_requeued_one' : 'mq.n_requeued') : (r.rowCount === 1 ? 'mq.n_deleted_one' : 'mq.n_deleted'), { n: r.rowCount ?? 0 }));
210
215
  return back(reply, s, redirect(req));
211
216
  });
212
217
  }
@@ -10,6 +10,7 @@ import { clearResponseCache } from '../websources.ts';
10
10
  import { isAdmin } from './locks.ts';
11
11
  import { back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
12
12
  import { workspaceAdmin } from './workspaces.ts';
13
+ import { tr } from './i18n.ts';
13
14
 
14
15
  // (sprint 118, WS-9; APEX 26.2 workspace network ACLs) Administration →
15
16
  // Network access: the installation's allow-list (PGKILN_REST_ALLOWED_HOSTS,
@@ -31,12 +32,13 @@ async function manageable(s: Session, wanted: unknown): Promise<{ admin: boolean
31
32
  }
32
33
 
33
34
  async function refuse(s: Session, reply: FastifyReply) {
34
- const main = html`<h1 class="u-mb1">Network access</h1><div class="alert alert-error" role="alert">Only administrators and workspace administrators manage network access.</div>`;
35
- await send(reply.code(403), s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
35
+ const tx = tr(s);
36
+ const main = html`<h1 class="u-mb1">${tx('na.title')}</h1><div class="alert alert-error" role="alert">${tx('na.refused')}</div>`;
37
+ await send(reply.code(403), s, shell(s, tx('na.title'), [[tx('rail.admin')], [tx('na.title')]], main, 'users'));
36
38
  }
37
39
 
38
40
  const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
39
- const entries = (list: string[]) => (list.length ? html`<ul class="host-list">${list.map((h) => html`<li><code>${h}</code></li>`)}</ul>` : html`<p class="muted">None.</p>`);
41
+ const entries = (list: string[], none: string) => (list.length ? html`<ul class="host-list">${list.map((h) => html`<li><code>${h}</code></li>`)}</ul>` : html`<p class="muted">${none}</p>`);
40
42
 
41
43
  export async function networkAccessRoutes(app: FastifyInstance) {
42
44
  app.get(`${BASE}/network`, async (req: Req, reply) => {
@@ -44,6 +46,7 @@ export async function networkAccessRoutes(app: FastifyInstance) {
44
46
  if (!s) return;
45
47
  const m = await manageable(s, req.query.ws);
46
48
  if (!m) return refuse(s, reply);
49
+ const tx = tr(s);
47
50
  const w = await owner.one<{ id: number; name: string; network_restricted: boolean; network_hosts: string[] }>(
48
51
  'select id, name, network_restricted, network_hosts from meta.workspace where id = $1', [m.workspace]);
49
52
  if (!w) return reply.code(404).send('Not found');
@@ -93,60 +96,60 @@ export async function networkAccessRoutes(app: FastifyInstance) {
93
96
  try {
94
97
  url = new URL(tryUrl);
95
98
  } catch {
96
- verdict = { ok: false, text: 'The URL is not valid.' };
99
+ verdict = { ok: false, text: tx('na.url_invalid') };
97
100
  }
98
101
  if (url) {
99
102
  const scope = await scopeForWorkspace(w.id, 'check');
100
103
  const problem = urlProblem(url) ?? scopeProblem(scope, url);
101
- verdict = problem ? { ok: false, text: problem } : { ok: true, text: `${url.host} may be called by this workspace's applications (the address it resolves to is checked when the call is made).` };
104
+ verdict = problem ? { ok: false, text: problem } : { ok: true, text: tx('na.url_ok', { host: url.host }) };
102
105
  }
103
106
  }
104
107
  const main = html`
105
- <div class="title-row"><h1>Network access</h1></div>
106
- <p class="muted u-mt0">Which web services the applications may call: <code>meta.web_request</code>, REST data sources, the Invoke API process and workflow step, web credential token endpoints, object storage and REST source catalogs. The installation's list applies to every workspace; a workspace's own list can only narrow it, never widen it. Private and loopback addresses stay refused unless the installation allows them, whatever a workspace lists.</p>
108
+ <div class="title-row"><h1>${tx('na.title')}</h1></div>
109
+ <p class="muted u-mt0">${tx.rich('na.intro', { web_request: html`<code>meta.web_request</code>` })}</p>
107
110
  <div class="columns wide-left">
108
- ${region(`Workspace ${w.name}`, html`<form method="post" action="${BASE}/network">${csrf(s)}<input type="hidden" name="ws" value="${w.id}">
109
- <fieldset class="field"><legend class="label">Outgoing calls of this workspace's applications</legend><div class="radio-group">
110
- <label class="check"><input type="radio" name="restricted" value="false"${w.network_restricted ? '' : raw(' checked')}> Any host the installation allows</label>
111
- <label class="check"><input type="radio" name="restricted" value="true"${w.network_restricted ? raw(' checked') : ''}> Only the hosts below (and the installation must allow them too)</label>
111
+ ${region(tx('na.workspace', { name: w.name }), html`<form method="post" action="${BASE}/network">${csrf(s)}<input type="hidden" name="ws" value="${w.id}">
112
+ <fieldset class="field"><legend class="label">${tx('na.outgoing')}</legend><div class="radio-group">
113
+ <label class="check"><input type="radio" name="restricted" value="false"${w.network_restricted ? '' : raw(' checked')}> ${tx('na.any_host')}</label>
114
+ <label class="check"><input type="radio" name="restricted" value="true"${w.network_restricted ? raw(' checked') : ''}> ${tx('na.only_hosts')}</label>
112
115
  </div></fieldset>
113
- <div class="field"><label class="label" for="f_hosts">Hosts</label>
116
+ <div class="field"><label class="label" for="f_hosts">${tx('na.hosts')}</label>
114
117
  <textarea id="f_hosts" name="hosts" rows="6" spellcheck="false">${w.network_hosts.join('\n')}</textarea>
115
- <small class="help">One per line: <code>api.example.com</code>, <code>*.example.com</code> for its subdomains, <code>api.example.com:8443</code> for one port only. No scheme or path. Restricted with no hosts: no outgoing calls at all.</small></div>
116
- ${unreachable.length ? html`<div class="alert alert-warning" role="status">Not on the installation's list, so never reachable: ${unreachable.join(', ')}.</div>` : ''}
117
- ${w.network_hosts.some((h) => serversOf(h).length) ? html`<p class="muted small">Remote servers on these hosts: ${w.network_hosts.filter((h) => serversOf(h).length).map((h) => `${h} (${serversOf(h).join(', ')})`).join('; ')}.</p>` : ''}
118
- <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`, '', 'workspace-list')}
119
- ${region('Installation', html`<p class="muted u-mt0">Set in the server's environment by its administrators (<code>PGKILN_REST_ALLOWED_HOSTS</code>).</p>
120
- ${inst.allowed.includes('*') ? html`<p>Any public host (<code>*</code>).</p>` : entries(inst.allowed)}
121
- ${m.admin ? html`<h3 class="u-mt1">Private hosts</h3><p class="muted small">Hosts on private or loopback addresses that may be called (<code>PGKILN_REST_PRIVATE_HOSTS</code>).</p>${entries(inst.private)}` : ''}`)}
118
+ <small class="help">${tx.rich('na.hosts_help', { a: html`<code>api.example.com</code>`, b: html`<code>*.example.com</code>`, c: html`<code>api.example.com:8443</code>` })}</small></div>
119
+ ${unreachable.length ? html`<div class="alert alert-warning" role="status">${tx('na.unreachable', { hosts: unreachable.join(', ') })}</div>` : ''}
120
+ ${w.network_hosts.some((h) => serversOf(h).length) ? html`<p class="muted small">${tx('na.servers_on_hosts', { list: w.network_hosts.filter((h) => serversOf(h).length).map((h) => `${h} (${serversOf(h).join(', ')})`).join('; ') })}</p>` : ''}
121
+ <div class="buttons"><button class="btn btn-hot">${tx('na.save')}</button></div></form>`, '', 'workspace-list')}
122
+ ${region(tx('na.installation'), html`<p class="muted u-mt0">${tx.rich('na.installation_note', { env: html`<code>PGKILN_REST_ALLOWED_HOSTS</code>` })}</p>
123
+ ${inst.allowed.includes('*') ? html`<p>${tx.rich('na.any_public', { star: html`<code>*</code>` })}</p>` : entries(inst.allowed, tx('na.none'))}
124
+ ${m.admin ? html`<h3 class="u-mt1">${tx('na.private')}</h3><p class="muted small">${tx.rich('na.private_note', { env: html`<code>PGKILN_REST_PRIVATE_HOSTS</code>` })}</p>${entries(inst.private, tx('na.none'))}` : ''}`)}
122
125
  </div>
123
- ${region('Remote servers', html`<p class="muted u-mt0">The hosts this workspace's <a href="${BASE}/remote-servers">remote servers</a> stand for now: the REST data sources, catalogs and AI services on a server call this host.</p>
126
+ ${region(tx('na.servers'), html`<p class="muted u-mt0">${tx.rich('na.servers_intro', { link: html`<a href="${BASE}/remote-servers">${tx('na.servers_link')}</a>` })}</p>
124
127
  <div class="table-wrap"><table class="report report-reflow">
125
- <thead><tr><th>Server</th><th>Host</th><th>From</th><th>Would be called</th></tr></thead>
126
- <tbody>${servers.length ? servers.map((x) => html`<tr><td data-label="Server">${x.static_id}</td>
127
- <td data-label="Host" class="u-break">${x.host ? html`<code>${x.host}</code>` : html`<span class="muted">none</span>`}</td>
128
- <td data-label="From">${x.from === 'env' ? x.env_var : x.from === 'stored' ? 'base URL' : ''}</td>
129
- <td data-label="Would be called">${x.problem ? html`<span class="tag tag-error">no</span> <span class="muted small">${x.problem}</span>` : html`<span class="tag tag-ok">yes</span>`}</td></tr>`)
130
- : html`<tr><td colspan="4" class="empty">No remote servers.</td></tr>`}</tbody></table></div>`, '', 'servers')}
131
- ${region('Check a URL', html`<form method="get" action="${BASE}/network" class="search u-mwnone">
128
+ <thead><tr><th>${tx('na.col.server')}</th><th>${tx('na.col.host')}</th><th>${tx('na.col.from')}</th><th>${tx('na.col.called')}</th></tr></thead>
129
+ <tbody>${servers.length ? servers.map((x) => html`<tr><td data-label="${tx('na.col.server')}">${x.static_id}</td>
130
+ <td data-label="${tx('na.col.host')}" class="u-break">${x.host ? html`<code>${x.host}</code>` : html`<span class="muted">${tx('na.host_none')}</span>`}</td>
131
+ <td data-label="${tx('na.col.from')}">${x.from === 'env' ? x.env_var : x.from === 'stored' ? tx('na.base_url') : ''}</td>
132
+ <td data-label="${tx('na.col.called')}">${x.problem ? html`<span class="tag tag-error">${tx('na.no')}</span> <span class="muted small">${x.problem}</span>` : html`<span class="tag tag-ok">${tx('na.yes')}</span>`}</td></tr>`)
133
+ : html`<tr><td colspan="4" class="empty">${tx('na.no_servers')}</td></tr>`}</tbody></table></div>`, '', 'servers')}
134
+ ${region(tx('na.check'), html`<form method="get" action="${BASE}/network" class="search u-mwnone">
132
135
  <input type="hidden" name="ws" value="${w.id}">
133
- ${input('url', 'URL', tryUrl, { type: 'url', placeholder: 'https://api.example.com/v1/items' })}
134
- <div class="buttons"><button class="btn">${icon('search')} Check</button></div></form>
136
+ ${input('url', tx('na.url'), tryUrl, { type: 'url', placeholder: 'https://api.example.com/v1/items' })}
137
+ <div class="buttons"><button class="btn">${icon('search')} ${tx('na.check_btn')}</button></div></form>
135
138
  <div role="status">${verdict ? html`<div class="alert ${verdict.ok ? 'alert-success' : 'alert-error'}">${verdict.text}</div>` : ''}</div>
136
- <p class="muted small">Nothing is called: this applies the lists to the URL as they are now.</p>`)}
137
- ${m.admin ? region('Workspaces', html`<div class="table-wrap"><table class="report report-reflow">
138
- <thead><tr><th>Workspace</th><th>Outgoing calls</th></tr></thead>
139
- <tbody>${all.map((x) => html`<tr><td data-label="Workspace"><a href="${BASE}/network?ws=${x.id}">${x.name}</a></td>
140
- <td data-label="Outgoing calls">${x.network_restricted ? html`<span class="tag">${icon('lock')} ${x.n} host${x.n === 1 ? '' : 's'}</span>` : html`<span class="muted">the installation's list</span>`}</td></tr>`)}</tbody></table></div>`) : ''}
141
- ${region('Refused calls', html`<p class="muted u-mt0">The last 100 calls of this workspace that a list refused (kept 7 days). The application's debug log has them too.</p>
139
+ <p class="muted small">${tx('na.check_note')}</p>`)}
140
+ ${m.admin ? region(tx('na.workspaces'), html`<div class="table-wrap"><table class="report report-reflow">
141
+ <thead><tr><th>${tx('na.col.workspace')}</th><th>${tx('na.col.outgoing')}</th></tr></thead>
142
+ <tbody>${all.map((x) => html`<tr><td data-label="${tx('na.col.workspace')}"><a href="${BASE}/network?ws=${x.id}">${x.name}</a></td>
143
+ <td data-label="${tx('na.col.outgoing')}">${x.network_restricted ? html`<span class="tag">${icon('lock')} ${tx(x.n === 1 ? 'na.n_hosts_one' : 'na.n_hosts', { n: x.n })}</span>` : html`<span class="muted">${tx('na.inst_list')}</span>`}</td></tr>`)}</tbody></table></div>`) : ''}
144
+ ${region(tx('na.refusals'), html`<p class="muted u-mt0">${tx('na.refusals_intro')}</p>
142
145
  <div class="table-wrap"><table class="report report-reflow">
143
- <thead><tr><th>When (UTC)</th><th>Application</th><th>Call</th><th>Host</th><th>Refused by</th></tr></thead>
144
- <tbody>${refusals.length ? refusals.map((r) => html`<tr><td data-label="When">${when(r.at)}</td><td data-label="Application">${r.alias ?? html`<span class="muted">–</span>`}</td>
145
- <td data-label="Call" class="u-break">${r.what}<br><span class="muted small">${r.url ?? ''}</span></td>
146
- <td data-label="Host" class="u-break"><code>${r.host}</code></td>
147
- <td data-label="Refused by">${r.list === 'workspace' ? 'the workspace\'s list' : 'the installation\'s list'}</td></tr>`)
148
- : html`<tr><td colspan="5" class="empty">No refused calls.</td></tr>`}</tbody></table></div>`, '', 'refusals')}`;
149
- return send(reply, s, shell(s, 'Network access', [['Administration'], ['Network access']], main, 'users'));
146
+ <thead><tr><th>${tx('na.col.when')}</th><th>${tx('na.col.app')}</th><th>${tx('na.col.call')}</th><th>${tx('na.col.host')}</th><th>${tx('na.col.refused_by')}</th></tr></thead>
147
+ <tbody>${refusals.length ? refusals.map((r) => html`<tr><td data-label="${tx('na.col.when_short')}">${when(r.at)}</td><td data-label="${tx('na.col.app')}">${r.alias ?? html`<span class="muted">–</span>`}</td>
148
+ <td data-label="${tx('na.col.call')}" class="u-break">${r.what}<br><span class="muted small">${r.url ?? ''}</span></td>
149
+ <td data-label="${tx('na.col.host')}" class="u-break"><code>${r.host}</code></td>
150
+ <td data-label="${tx('na.col.refused_by')}">${r.list === 'workspace' ? tx('na.ws_list') : tx('na.inst_list')}</td></tr>`)
151
+ : html`<tr><td colspan="5" class="empty">${tx('na.no_refusals')}</td></tr>`}</tbody></table></div>`, '', 'refusals')}`;
152
+ return send(reply, s, shell(s, tx('na.title'), [[tx('rail.admin')], [tx('na.title')]], main, 'users'));
150
153
  });
151
154
 
152
155
  app.post(`${BASE}/network`, async (req: Req, reply) => {
@@ -164,7 +167,7 @@ export async function networkAccessRoutes(app: FastifyInstance) {
164
167
  clearResponseCache();
165
168
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
166
169
  detail: `network access of workspace ${m.workspace}: ${restricted ? `only ${hosts.join(', ') || 'no hosts'}` : 'the installation\'s list'}`.slice(0, 1000) });
167
- flash(s, restricted ? `Saved: this workspace's applications call ${hosts.length ? 'only these hosts' : 'no hosts at all'}.` : 'Saved: the installation\'s list applies.');
170
+ flash(s, tr(s)(restricted ? (hosts.length ? 'na.saved_only' : 'na.saved_none') : 'na.saved_inst'));
168
171
  } catch (e) {
169
172
  flash(s, (e as Error).message, 'error');
170
173
  }
@@ -7,6 +7,7 @@
7
7
  const GUIDE = 'https://pgkiln.vargar.eu/docs';
8
8
 
9
9
  export const REGION_CONFIG_HELP: [string[], string][] = [
10
+ [['workflow_diagram'], '{"instance_item": "P9_WORKFLOW_ID"}: the item holding the workflow instance to draw (only instances the user started or administers; sprint 160)'],
10
11
  [['report'], '{"page_size":15,"pagination":"range" (rows X–Y without a total),"keyset":["id"] (with range: Next/Previous seek on these unique columns),"max_rows":10000,"searchable":true,"sortable":true,"interactive":true,"mobile":"reflow"|"scroll","hidden":["col"],"headings":{"col":"Label"},"link":{"column":"id","page":3,"items":{"P3_ID":"#id#"}},"empty":"No rows","pdf":{"layout":"NAME","columns":["col"],"widths":{"col":40},"align":{"col":"right"}}} (pdf widths in mm; layouts under Shared Components → Report layouts)'],
11
12
  [['report', 'classic_report', 'grid', 'cards'], '{"formats":{"sal":"FML999G990D00","hiredate":"DD-MON-YYYY"}} (number or date format masks per column)'],
12
13
  [['chart'], '{"kind":"bar"|"column"|"line"|"area"|"donut","max_rows":1000,"format_mask":"FML999G990"}'],
@@ -59,6 +60,7 @@ const ANCHORS: Record<string, string> = {
59
60
  plugin: 'plug-ins-with-their-own-code',
60
61
  tasks: 'approvals-and-the-task-list',
61
62
  workflows: 'workflows',
63
+ workflow_diagram: 'workflow-diagram-region',
62
64
  login: 'the-login-page',
63
65
  classic_report: 'classic_report-classic-report',
64
66
  breadcrumb: 'breadcrumb-and-help_text',
@@ -73,7 +75,7 @@ export function regionConfigHelp(type: string | null | undefined): { text: strin
73
75
  if (!type) return { text: ALL_REGION_CONFIG_HELP, url: null };
74
76
  const own = REGION_CONFIG_HELP.filter(([types]) => types.includes(type)).map(([, text]) => text);
75
77
  const any = REGION_CONFIG_HELP.filter(([types]) => types.includes('*')).map(([, text]) => `any region: ${text}`);
76
- const chapter = type === 'tasks' || type === 'workflows' ? 'processing' : type === 'login' ? 'security' : 'pages-and-regions';
78
+ const chapter = type === 'tasks' || type === 'workflows' || type === 'workflow_diagram' ? 'processing' : type === 'login' ? 'security' : 'pages-and-regions';
77
79
  return {
78
80
  text: [...own, ...any].join(' · ') || 'No attributes of its own.',
79
81
  url: ANCHORS[type] ? `${GUIDE}/${chapter}#${ANCHORS[type]}` : null,