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.
- package/db/migrations/146_database_model_providers.sql +53 -0
- package/db/migrations/147_condition_types.sql +80 -0
- package/db/migrations/148_validation_types.sql +44 -0
- package/db/migrations/149_dynamic_action_events.sql +45 -0
- package/db/migrations/150_process_types.sql +23 -0
- package/db/migrations/151_item_types.sql +16 -0
- package/db/migrations/152_workflow_diagram_region.sql +10 -0
- package/docs/apex-feature-parity.md +12 -10
- package/docs/guide/02-concepts.md +3 -3
- package/docs/guide/03-builder.md +13 -3
- package/docs/guide/04-pages-and-regions.md +17 -3
- package/docs/guide/05-items.md +5 -0
- package/docs/guide/06-processing.md +104 -17
- package/docs/guide/07-dynamic-actions.md +16 -1
- package/docs/guide/11-from-apex.md +7 -0
- package/examples/hr/hr_79_condition_types.sql +17 -0
- package/package.json +1 -1
- package/public/app.css +12 -0
- package/public/app.js +157 -9
- package/src/ai/embeddings.ts +20 -7
- package/src/appfiles.ts +4 -4
- package/src/builder/advisor.ts +43 -7
- package/src/builder/code-editor.ts +14 -10
- package/src/builder/components.ts +51 -21
- package/src/builder/conditions.ts +64 -0
- package/src/builder/db-users.ts +124 -118
- package/src/builder/designer.ts +9 -1
- package/src/builder/home.ts +42 -40
- package/src/builder/i18n/de.ts +464 -0
- package/src/builder/i18n/en.ts +464 -0
- package/src/builder/i18n/es.ts +464 -0
- package/src/builder/i18n/fr.ts +464 -0
- package/src/builder/i18n/it.ts +464 -0
- package/src/builder/i18n/ja.ts +464 -0
- package/src/builder/i18n/ko.ts +464 -0
- package/src/builder/i18n/nl.ts +464 -0
- package/src/builder/i18n/pt.ts +464 -0
- package/src/builder/i18n/zh-cn.ts +464 -0
- package/src/builder/i18n/zh-tw.ts +464 -0
- package/src/builder/instance.ts +27 -23
- package/src/builder/ldap.ts +50 -47
- package/src/builder/mail-queue.ts +50 -45
- package/src/builder/network-access.ts +46 -43
- package/src/builder/region-help.ts +3 -1
- package/src/builder/region-settings.ts +30 -5
- package/src/builder/vector-column.ts +3 -3
- package/src/builder/vector-providers.ts +16 -5
- package/src/i18n/ar.ts +5 -0
- package/src/i18n/cs.ts +5 -0
- package/src/i18n/da.ts +5 -0
- package/src/i18n/de.ts +5 -0
- package/src/i18n/el.ts +5 -0
- package/src/i18n/es.ts +5 -0
- package/src/i18n/fi.ts +5 -0
- package/src/i18n/fr.ts +5 -0
- package/src/i18n/he.ts +5 -0
- package/src/i18n/it.ts +5 -0
- package/src/i18n/ja.ts +5 -0
- package/src/i18n/ko.ts +5 -0
- package/src/i18n/nb.ts +5 -0
- package/src/i18n/pl.ts +5 -0
- package/src/i18n/pt.ts +5 -0
- package/src/i18n/ru.ts +5 -0
- package/src/i18n/sv.ts +5 -0
- package/src/i18n/tr.ts +5 -0
- package/src/i18n/uk.ts +5 -0
- package/src/i18n/zh.ts +5 -0
- package/src/i18n.ts +10 -0
- package/src/metadata.ts +72 -9
- package/src/runtime/authz.ts +9 -5
- package/src/runtime/context.ts +4 -0
- package/src/runtime/da-styles.ts +37 -0
- package/src/runtime/documents.ts +3 -2
- package/src/runtime/engine.ts +95 -13
- package/src/runtime/items.ts +33 -0
- package/src/runtime/logic.ts +79 -8
- package/src/runtime/maps.ts +50 -4
- package/src/runtime/process-types.ts +139 -0
- package/src/runtime/processes.ts +2 -0
- package/src/runtime/regions.ts +4 -0
- package/src/runtime/render.ts +13 -2
- package/src/runtime/routes.ts +2 -2
- package/src/runtime/workflow-diagram.ts +45 -0
- package/src/vector.ts +3 -3
- package/src/workflow.ts +10 -2
package/src/builder/ldap.ts
CHANGED
|
@@ -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
|
|
18
|
-
${isNew ? input('name', '
|
|
19
|
-
${input('display_name', '
|
|
20
|
-
${input('url', '
|
|
21
|
-
${input('bind_dn', '
|
|
22
|
-
${input('bind_password', '
|
|
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', '
|
|
25
|
-
${check('tls_verify', '
|
|
26
|
-
${!isNew && d.has_password ? check('remove_password', '
|
|
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
|
|
29
|
-
${input('user_base', '
|
|
30
|
-
${input('user_filter', '
|
|
31
|
-
${input('username_attribute', '
|
|
32
|
-
${input('display_name_attribute', '
|
|
33
|
-
${input('email_attribute', '
|
|
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', '
|
|
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
|
|
38
|
-
${input('group_attribute', '
|
|
39
|
-
${input('group_base', '
|
|
40
|
-
${input('group_filter', '
|
|
41
|
-
${input('group_name_attribute', '
|
|
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', '
|
|
44
|
-
<div class="buttons"><button class="btn btn-hot">${isNew ? '
|
|
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
|
|
67
|
-
<p class="muted u-mt0"
|
|
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('
|
|
70
|
-
<thead><tr><th
|
|
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="
|
|
73
|
-
<td data-label="
|
|
74
|
-
<td data-label="
|
|
75
|
-
<td data-label="
|
|
76
|
-
<td data-label="
|
|
77
|
-
</tr>`) : html`<tr><td colspan="5" class="empty"
|
|
78
|
-
${region('
|
|
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, '
|
|
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, '
|
|
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('
|
|
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="
|
|
113
|
-
${region('
|
|
114
|
-
<p class="muted u-mt0"
|
|
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="
|
|
117
|
-
<button class="btn"
|
|
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, [['
|
|
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, '
|
|
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,
|
|
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, '
|
|
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
|
|
31
|
-
|
|
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', '
|
|
98
|
-
${scope.admin ? select('ws', '
|
|
99
|
-
${select('app', '
|
|
100
|
-
<div class="buttons"><button class="btn"
|
|
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
|
|
109
|
-
<p class="muted u-mt0"
|
|
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
|
|
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`, '
|
|
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('
|
|
118
|
-
<thead><tr><th
|
|
119
|
-
<tbody>${perHour.length ? perHour.map((h) => html`<tr><td data-label="
|
|
120
|
-
<td class="num" data-label="
|
|
121
|
-
: html`<tr><td colspan="5" class="empty"
|
|
122
|
-
${region('
|
|
123
|
-
<thead><tr><th
|
|
124
|
-
<tbody>${slowest.length ? slowest.map((m) => html`<tr><td data-label="
|
|
125
|
-
<td data-label="
|
|
126
|
-
<td data-label="
|
|
127
|
-
<td class="num" data-label="
|
|
128
|
-
: html`<tr><td colspan="5" class="empty"
|
|
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('
|
|
131
|
-
<thead><tr><th
|
|
132
|
-
<tbody>${usage.length ? usage.map((u) => html`<tr><td data-label="
|
|
133
|
-
<td data-label="
|
|
134
|
-
<td class="num" data-label="
|
|
135
|
-
<td class="num" data-label="
|
|
136
|
-
<td class="num" data-label="
|
|
137
|
-
: html`<tr><td colspan="8" class="empty"
|
|
138
|
-
${region('
|
|
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
|
|
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="
|
|
143
|
-
<td data-label="
|
|
144
|
-
<td data-label="
|
|
145
|
-
<td data-label="
|
|
146
|
-
<td data-label="
|
|
147
|
-
<td data-label="
|
|
148
|
-
: html`<tr><td colspan="6" class="empty"
|
|
149
|
-
${failures.length ? html`<div class="buttons">${action('/retry', html`${icon('refresh')}
|
|
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, '
|
|
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 ? '
|
|
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 ? '
|
|
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,
|
|
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
|
|
35
|
-
|
|
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"
|
|
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: '
|
|
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:
|
|
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
|
|
106
|
-
<p class="muted u-mt0"
|
|
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(
|
|
109
|
-
<fieldset class="field"><legend class="label"
|
|
110
|
-
<label class="check"><input type="radio" name="restricted" value="false"${w.network_restricted ? '' : raw(' checked')}>
|
|
111
|
-
<label class="check"><input type="radio" name="restricted" value="true"${w.network_restricted ? raw(' checked') : ''}>
|
|
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"
|
|
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"
|
|
116
|
-
${unreachable.length ? html`<div class="alert alert-warning" role="status"
|
|
117
|
-
${w.network_hosts.some((h) => serversOf(h).length) ? html`<p class="muted small"
|
|
118
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
119
|
-
${region('
|
|
120
|
-
${inst.allowed.includes('*') ? html`<p
|
|
121
|
-
${m.admin ? html`<h3 class="u-mt1"
|
|
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('
|
|
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
|
|
126
|
-
<tbody>${servers.length ? servers.map((x) => html`<tr><td data-label="
|
|
127
|
-
<td data-label="
|
|
128
|
-
<td data-label="
|
|
129
|
-
<td data-label="
|
|
130
|
-
: html`<tr><td colspan="4" class="empty"
|
|
131
|
-
${region('
|
|
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', '
|
|
134
|
-
<div class="buttons"><button class="btn">${icon('search')}
|
|
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"
|
|
137
|
-
${m.admin ? region('
|
|
138
|
-
<thead><tr><th
|
|
139
|
-
<tbody>${all.map((x) => html`<tr><td data-label="
|
|
140
|
-
<td data-label="
|
|
141
|
-
${region('
|
|
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
|
|
144
|
-
<tbody>${refusals.length ? refusals.map((r) => html`<tr><td data-label="
|
|
145
|
-
<td data-label="
|
|
146
|
-
<td data-label="
|
|
147
|
-
<td data-label="
|
|
148
|
-
: html`<tr><td colspan="5" class="empty"
|
|
149
|
-
return send(reply, s, shell(s, '
|
|
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 ?
|
|
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,
|