pgkiln 0.71.0 → 0.73.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 (77) hide show
  1. package/db/migrations/139_opentelemetry.sql +36 -0
  2. package/db/migrations/140_item_region_types.sql +27 -0
  3. package/docs/apex-feature-parity.md +86 -9
  4. package/docs/guide/01-installation.md +108 -0
  5. package/docs/guide/03-builder.md +9 -0
  6. package/docs/guide/04-pages-and-regions.md +41 -1
  7. package/docs/guide/05-items.md +43 -3
  8. package/docs/guide/09-reference.md +3 -0
  9. package/docs/guide/11-from-apex.md +3 -0
  10. package/docs/guide/12-development.md +23 -2
  11. package/examples/hr/hr_75_item_region_types.sql +81 -0
  12. package/package.json +1 -1
  13. package/public/app.css +48 -0
  14. package/public/app.js +427 -1
  15. package/public/builder.css +3 -0
  16. package/src/ai/service.ts +14 -0
  17. package/src/app.ts +10 -0
  18. package/src/builder/advisor.ts +5 -1
  19. package/src/builder/arrange.ts +7 -0
  20. package/src/builder/components.ts +5 -5
  21. package/src/builder/designer.ts +8 -2
  22. package/src/builder/home.ts +1 -0
  23. package/src/builder/i18n/de.ts +8 -0
  24. package/src/builder/i18n/en.ts +8 -0
  25. package/src/builder/i18n/es.ts +8 -0
  26. package/src/builder/i18n/fr.ts +8 -0
  27. package/src/builder/i18n/it.ts +8 -0
  28. package/src/builder/i18n/ja.ts +8 -0
  29. package/src/builder/i18n/ko.ts +8 -0
  30. package/src/builder/i18n/nl.ts +8 -0
  31. package/src/builder/i18n/pt.ts +8 -0
  32. package/src/builder/i18n/zh-cn.ts +8 -0
  33. package/src/builder/i18n/zh-tw.ts +8 -0
  34. package/src/builder/region-help.ts +8 -2
  35. package/src/builder/telemetry.ts +206 -0
  36. package/src/builder/workspaces.ts +2 -1
  37. package/src/i18n/ar.ts +13 -0
  38. package/src/i18n/cs.ts +13 -0
  39. package/src/i18n/da.ts +13 -0
  40. package/src/i18n/de.ts +13 -0
  41. package/src/i18n/el.ts +13 -0
  42. package/src/i18n/es.ts +13 -0
  43. package/src/i18n/fi.ts +13 -0
  44. package/src/i18n/fr.ts +13 -0
  45. package/src/i18n/he.ts +13 -0
  46. package/src/i18n/it.ts +13 -0
  47. package/src/i18n/ja.ts +13 -0
  48. package/src/i18n/ko.ts +13 -0
  49. package/src/i18n/nb.ts +13 -0
  50. package/src/i18n/pl.ts +13 -0
  51. package/src/i18n/pt.ts +13 -0
  52. package/src/i18n/ru.ts +13 -0
  53. package/src/i18n/sv.ts +13 -0
  54. package/src/i18n/tr.ts +13 -0
  55. package/src/i18n/uk.ts +13 -0
  56. package/src/i18n/zh.ts +13 -0
  57. package/src/i18n.ts +26 -0
  58. package/src/instance-checks.ts +11 -4
  59. package/src/layout.ts +2 -1
  60. package/src/mail/mail.ts +14 -0
  61. package/src/metadata.ts +5 -2
  62. package/src/runtime/ai-agents.ts +9 -0
  63. package/src/runtime/items.ts +32 -3
  64. package/src/runtime/page-regions.ts +50 -0
  65. package/src/runtime/region-cache.ts +1 -1
  66. package/src/runtime/regions.ts +11 -0
  67. package/src/runtime/render.ts +3 -17
  68. package/src/runtime/report.ts +55 -3
  69. package/src/runtime/routes.ts +3 -0
  70. package/src/session.ts +26 -0
  71. package/src/telemetry/browser.ts +91 -0
  72. package/src/telemetry/config.ts +342 -0
  73. package/src/telemetry/export.ts +484 -0
  74. package/src/telemetry/http.ts +126 -0
  75. package/src/telemetry/trace.ts +456 -0
  76. package/src/webclient.ts +25 -0
  77. package/src/workflow.ts +14 -7
@@ -0,0 +1,206 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { newToken } from '../security.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import {
7
+ instanceTelemetry, refreshTelemetry, saveInstanceTelemetry, saveWorkspaceTelemetry, setMetricsToken, workspaceTelemetry,
8
+ type InstanceForm, type Origin, type WorkspaceForm,
9
+ } from '../telemetry/config.ts';
10
+ import { destinationStatus, sendTest } from '../telemetry/export.ts';
11
+ import { isAdmin } from './locks.ts';
12
+ import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
+ import { workspaceAdmin } from './workspaces.ts';
14
+
15
+ // (sprint 139, AP-6; APEX: Workspace Utilities → OpenTelemetry) Workspace
16
+ // utilities → OpenTelemetry (administrators): the instance's collector, what
17
+ // it receives, GET /metrics, and each workspace's choice; a workspace's
18
+ // administrators switch its applications off or add a collector of their own
19
+ // on /builder/workspaces/:id/telemetry. Headers (an API key) are write-only:
20
+ // stored encrypted, only their names are shown.
21
+
22
+ const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['OpenTelemetry']];
23
+ const ORIGIN: Record<Origin, string> = { builder: 'set here', environment: 'environment variable', default: 'default' };
24
+
25
+ const check = (name: string, label: string, on: boolean, help?: string) =>
26
+ 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>`;
27
+
28
+ const headersField = (names: string[], help: string) => html`<div class="field"><label class="label" for="f_headers">Headers</label>
29
+ <textarea id="f_headers" name="headers" rows="2" maxlength="4000" spellcheck="false" autocomplete="off" placeholder="${names.length ? `${names.join(', ')} set: type new headers to replace them` : 'e.g. x-honeycomb-team=…, Authorization=Bearer …'}"></textarea>
30
+ <small class="help">${help}${names.length ? html` Set now: <b>${names.join(', ')}</b> (values hidden).` : ''}</small>
31
+ ${names.length ? html`<label class="check"><input type="checkbox" name="remove_headers" value="true"> Remove the headers</label>` : ''}</div>`;
32
+
33
+ const when = (ms: number | null) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
34
+
35
+ function statusCell(key: string): Raw {
36
+ const st = destinationStatus(key);
37
+ return html`${st.sent} sent${st.dropped ? html`, <span class="otel-problem">${st.dropped} dropped</span>` : ''} · last success ${when(st.lastOkAt)}
38
+ ${st.lastError ? html`<br><span class="otel-problem">Last problem (${when(st.lastErrorAt)}): ${st.lastError}</span>` : ''}`;
39
+ }
40
+
41
+ async function adminOnly(s: Session, reply: FastifyReply) {
42
+ if (await isAdmin(s.username)) return true;
43
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', crumbs, html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only administrators set up OpenTelemetry for the installation.</div>`));
44
+ return false;
45
+ }
46
+
47
+ export async function telemetryBuilderRoutes(app: FastifyInstance) {
48
+ app.get(`${BASE}/telemetry`, async (req: Req, reply) => {
49
+ const s = await developer(req, reply);
50
+ if (!s || !(await adminOnly(s, reply))) return;
51
+ await refreshTelemetry(true);
52
+ const t = instanceTelemetry();
53
+ const token = typeof s.state.__METRICS_TOKEN === 'string' ? s.state.__METRICS_TOKEN : null;
54
+ if (token) {
55
+ delete s.state.__METRICS_TOKEN;
56
+ const { saveState } = await import('../session.ts');
57
+ await saveState(s);
58
+ }
59
+ const workspaces = (await owner.query<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; apps: number }>(
60
+ `select w.id, w.name, w.telemetry, w.telemetry_endpoint, (select count(*) from meta.app a where meta.app_workspace(a.id) = w.id)::int as apps
61
+ from meta.workspace w order by w.name`)).rows;
62
+ const from = (k: string) => html`<span class="muted">(${ORIGIN[t.origin[k] ?? 'default']})</span>`;
63
+ const pub = (process.env.PUBLIC_URL ?? '').replace(/\/+$/, '');
64
+ const main = html`<h1 class="u-mb1">OpenTelemetry</h1>
65
+ <p class="muted u-mt0">Send traces, logs and metrics of the applications to your own monitoring (Grafana, Jaeger, Datadog, Honeycomb, OCI APM,
66
+ or anything that speaks OTLP) through an OpenTelemetry Collector. A trace per page request, with a span for each SQL statement, REST data source call,
67
+ AI request and tool call; mail and workflow steps as traces of their own. pgkiln sends OTLP over HTTP as JSON. The standard <code>OTEL_*</code>
68
+ environment variables work too; a value set here wins. Chapter 1 of the guide (Monitoring with OpenTelemetry) explains every choice.</p>
69
+ ${t.protocolWarning ? html`<div class="alert alert-warning" role="status">${t.protocolWarning}</div>` : ''}
70
+ <div class="columns wide-left">
71
+ ${region('Collector', html`<form method="post" action="${BASE}/telemetry">${csrf(s)}
72
+ ${check('enabled', 'Send to this collector', t.enabled, 'Off: nothing leaves the server (workspaces with a collector of their own still send to it).')}
73
+ <div class="form-grid">
74
+ ${input('endpoint', 'Collector URL', t.origin.otel_endpoint === 'builder' ? t.endpoint : '', { placeholder: t.endpoint || 'http://otel-collector:4318', help: html`The OTLP/HTTP base address; /v1/traces, /v1/logs and /v1/metrics are added. In effect: <b>${t.endpoint || 'none'}</b> ${from('otel_endpoint')} (<code>OTEL_EXPORTER_OTLP_ENDPOINT</code>).` as unknown as string })}
75
+ ${input('service_name', 'Service name', t.origin.otel_service_name === 'builder' ? t.serviceName : '', { placeholder: t.serviceName, help: `service.name of everything sent (OTEL_SERVICE_NAME). In effect: ${t.serviceName}.` })}
76
+ ${input('sample', 'Sample ratio', t.origin.otel_sample === 'builder' ? String(t.sample) : '', { placeholder: String(t.sample), help: 'Share of traces sent, 0 to 1 (OTEL_TRACES_SAMPLER_ARG). A trace with an error is always sent. A caller\'s traceparent decides for its trace.' })}
77
+ ${select('user', 'User', t.user, [['hashed', 'Hashed (the same user gives the same value, the name is not sent)'], ['name', 'User name'], ['none', 'Not sent']], 'enduser.id on the page span and on logs.')}
78
+ </div>
79
+ ${headersField(t.headerNames, 'Sent with every request to the collector, name=value, one per line or comma separated (OTEL_EXPORTER_OTLP_HEADERS). Stored encrypted.')}
80
+ <fieldset class="field"><legend class="label">Send</legend><div class="radio-group">
81
+ ${check('traces', 'Traces', t.traces)}${check('logs', 'Logs: errors, refused access and failed sign-ins', t.logs)}${check('metrics', `Metrics, every ${Math.round(t.intervalMs / 1000)} seconds`, t.metrics)}
82
+ </div></fieldset>
83
+ ${check('values', 'Include values', t.values, 'SQL statements with their literals, bind values, query strings and AI tool arguments. Off: literals become ?, values are left out and error messages have quoted values removed.')}
84
+ ${check('builder', 'Trace builder requests too', t.builder, 'Applications only by default.')}
85
+ ${check('browser', 'Browser spans', t.browser, 'Pages report their load time and Ajax calls to pgkiln, which adds them to the page\'s trace. The browser never sees the collector or its headers.')}
86
+ ${check('prometheus', 'Prometheus endpoint /metrics', t.prometheus, 'For administrators signed in to the builder, or a scraper with the metrics token below (PGKILN_METRICS, PGKILN_METRICS_TOKEN).')}
87
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
88
+ <form method="post" action="${BASE}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>`)}
89
+ ${region('Status', html`<p class="muted u-mt0">Since this server started. Each server of the installation sends its own.</p>
90
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Collector</th><th>Sending</th></tr></thead><tbody>
91
+ <tr><td data-label="Collector">This instance's${t.enabled ? '' : html` <span class="muted">(off)</span>`}</td><td data-label="Sending">${statusCell('instance')}</td></tr>
92
+ ${workspaces.filter((w) => w.telemetry === 'own').map((w) => html`<tr><td data-label="Collector">Workspace ${w.name}</td><td data-label="Sending">${statusCell(`ws:${w.id}`)}</td></tr>`)}
93
+ </tbody></table></div>`)}
94
+ ${region('Prometheus', html`<p class="muted u-mt0">${t.prometheus ? html`<code>GET ${pub || ''}/metrics</code> answers with the counters, durations and queue lengths in the Prometheus text format.` : 'Off: switch it on above.'}
95
+ A scraper sends <code>Authorization: Bearer &lt;token&gt;</code>. ${t.prometheusTokenHash ? (t.origin.metrics_token_hash === 'builder' || !process.env.PGKILN_METRICS_TOKEN ? 'A token is set.' : 'The token is PGKILN_METRICS_TOKEN.') : 'No token is set.'}</p>
96
+ ${token ? html`<div class="alert alert-success" role="status">The new token (shown once): <code class="cell-break">${token}</code></div>` : ''}
97
+ <form method="post" action="${BASE}/telemetry/token" class="buttons">${csrf(s)}<button class="btn" name="action" value="new">${t.prometheusTokenHash ? 'Replace the token' : 'Make a token'}</button>
98
+ ${t.prometheusTokenHash ? html`<button class="btn btn-danger" name="action" value="remove" data-confirm="Remove the metrics token? Scrapers that use it get 401.">Remove the token</button>` : ''}</form>`)}
99
+ ${region('Workspaces', html`<p class="muted u-mt0">A workspace's administrators may switch its applications off, or send them to a collector of their own as well.</p>
100
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Workspace</th><th>Applications</th><th>Sends</th></tr></thead><tbody>
101
+ ${workspaces.length ? workspaces.map((w) => html`<tr><td data-label="Workspace"><a href="${BASE}/workspaces/${w.id}/telemetry">${w.name}</a></td><td data-label="Applications" class="num">${w.apps}</td>
102
+ <td data-label="Sends" class="cell-break">${w.telemetry === 'off' ? 'nothing' : w.telemetry === 'own' ? html`to the instance's collector and ${w.telemetry_endpoint}` : 'to the instance\'s collector'}</td></tr>`)
103
+ : html`<tr><td colspan="3" class="empty">No workspaces.</td></tr>`}</tbody></table></div>`)}
104
+ </div>`;
105
+ return send(reply, s, shell(s, 'OpenTelemetry', crumbs, main));
106
+ });
107
+
108
+ app.post(`${BASE}/telemetry`, async (req: Req, reply) => {
109
+ const s = await developer(req, reply);
110
+ if (!s || !(await adminOnly(s, reply))) return;
111
+ const problem = await saveInstanceTelemetry((req.body ?? {}) as InstanceForm);
112
+ if (!problem) {
113
+ const t = instanceTelemetry();
114
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req),
115
+ detail: `OpenTelemetry: ${t.enabled ? `on, ${t.endpoint}` : 'off'}, sample ${t.sample}, user ${t.user}, values ${t.values}, browser ${t.browser}, /metrics ${t.prometheus}` });
116
+ }
117
+ flash(s, problem ?? 'OpenTelemetry settings saved.', problem ? 'error' : 'ok');
118
+ return back(reply, s, `${BASE}/telemetry`);
119
+ });
120
+
121
+ app.post(`${BASE}/telemetry/test`, async (req: Req, reply) => {
122
+ const s = await developer(req, reply);
123
+ if (!s || !(await adminOnly(s, reply))) return;
124
+ const problem = await sendTest(null);
125
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
126
+ return back(reply, s, `${BASE}/telemetry`);
127
+ });
128
+
129
+ app.post(`${BASE}/telemetry/token`, async (req: Req, reply) => {
130
+ const s = await developer(req, reply);
131
+ if (!s || !(await adminOnly(s, reply))) return;
132
+ const action = (req.body as { action?: string } | undefined)?.action;
133
+ if (action === 'new') {
134
+ const token = `pgkm_${newToken()}`;
135
+ await setMetricsToken(token);
136
+ s.state.__METRICS_TOKEN = token;
137
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token made' });
138
+ } else if (action === 'remove') {
139
+ await setMetricsToken(null);
140
+ await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token removed' });
141
+ flash(s, 'The metrics token is removed.');
142
+ }
143
+ return back(reply, s, `${BASE}/telemetry`);
144
+ });
145
+
146
+ // ---------------------------------------------------------------- a workspace's own
147
+
148
+ const wsAllowed = async (s: Session, reply: FastifyReply, id: string) => {
149
+ if (await workspaceAdmin(s.username, id)) return true;
150
+ await send(reply.code(403), s, shell(s, 'OpenTelemetry', [['Administration'], ['OpenTelemetry']],
151
+ html`<h1 class="u-mb1">OpenTelemetry</h1><div class="alert alert-error" role="alert">Only the workspace's administrators change its OpenTelemetry setting.</div>`, 'users'));
152
+ return false;
153
+ };
154
+
155
+ app.get(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
156
+ const s = await developer(req, reply);
157
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
158
+ const w = await owner.one<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; telemetry_values: boolean; has_headers: boolean }>(
159
+ 'select id, name, telemetry, telemetry_endpoint, telemetry_values, telemetry_headers_enc is not null as has_headers from meta.workspace where id = $1', [req.params.id]);
160
+ if (!w) return reply.code(404).send('Not found');
161
+ await refreshTelemetry(true);
162
+ const t = instanceTelemetry();
163
+ const names = Object.keys(workspaceTelemetry(w.id)?.headers ?? {});
164
+ const radio = (v: string, label: string, help: string) => html`<label class="check"><input type="radio" name="mode" value="${v}"${w.telemetry === v ? raw(' checked') : ''}> ${label} <span class="muted small">${help}</span></label>`;
165
+ const main = html`<div class="title-row"><h1>OpenTelemetry · ${w.name}</h1></div>
166
+ <p class="muted u-mt0">Traces and logs of this workspace's applications. ${t.enabled ? 'The instance sends them to its collector.' : 'The instance sends nothing at the moment.'}
167
+ A collector of the workspace's own is reached through the installation's allow-list and this workspace's <a href="${BASE}/network?ws=${w.id}">network access</a> list.</p>
168
+ <div class="columns wide-left">
169
+ ${region('Setting', html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry">${csrf(s)}
170
+ <fieldset class="field"><legend class="label">The applications send</legend><div class="radio-group">
171
+ ${radio('instance', 'As the instance does', '(default)')}
172
+ ${radio('off', 'Nothing', 'not even to the instance\'s collector')}
173
+ ${radio('own', 'Also to our own collector', '')}
174
+ </div></fieldset>
175
+ <div class="form-grid">${input('endpoint', 'Own collector URL', w.telemetry_endpoint ?? '', { placeholder: 'https://otlp.example.com', help: 'The OTLP/HTTP base address; /v1/traces and /v1/logs are added.' })}</div>
176
+ ${headersField(names.length ? names : w.has_headers ? ['(unreadable)'] : [], 'Sent with every request to the own collector (an API key). Stored encrypted.')}
177
+ ${check('values', 'Include values for the own collector', w.telemetry_values, 'SQL statements with their literals, bind values and query strings.')}
178
+ <div class="buttons"><button class="btn btn-hot">Save</button></div></form>
179
+ ${w.telemetry === 'own' ? html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">Send a test span</button></form>` : ''}`)}
180
+ ${w.telemetry === 'own' ? region('Status', html`<p class="u-mt0">${statusCell(`ws:${w.id}`)}</p>`) : ''}
181
+ </div>`;
182
+ return send(reply, s, shell(s, `OpenTelemetry · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['OpenTelemetry']], main, 'users'));
183
+ });
184
+
185
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
186
+ const s = await developer(req, reply);
187
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
188
+ const id = Number(req.params.id);
189
+ if (!(await owner.one('select 1 from meta.workspace where id = $1', [id]))) return reply.code(404).send('Not found');
190
+ const f = (req.body ?? {}) as WorkspaceForm;
191
+ const problem = await saveWorkspaceTelemetry(id, f);
192
+ if (!problem)
193
+ await logActivity({ username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${id} OpenTelemetry: ${f.mode}${f.mode === 'own' ? ` ${f.endpoint}` : ''}` });
194
+ flash(s, problem ?? 'Saved.', problem ? 'error' : 'ok');
195
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
196
+ });
197
+
198
+ app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry/test`, async (req: Req, reply) => {
199
+ const s = await developer(req, reply);
200
+ if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
201
+ const id = Number(req.params.id);
202
+ const problem = workspaceTelemetry(id)?.mode === 'own' ? await sendTest(id) : 'The workspace has no collector of its own.';
203
+ flash(s, problem ? `The collector did not take the test span: ${problem}` : 'The collector took the test span ("pgkiln test span").', problem ? 'error' : 'ok');
204
+ return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
205
+ });
206
+ }
@@ -385,7 +385,8 @@ export async function workspaceRoutes(app: FastifyInstance) {
385
385
  <div class="buttons"><button class="btn">${tx('wsp.save_devs')}</button></div></form>`)}
386
386
  </div>
387
387
  ${serviceRegion(s, w)}
388
- ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>`, '', 'mail-network')}
388
+ ${region(tx('wsp.mail_net'), html`<p class="u-mt0">${tx.rich('wsp.mail_net_text', { mail: html`<a href="${BASE}/workspaces/${w.id}/mail">${tx('wsp.mail_service')}</a>`, network: html`<a href="${BASE}/network?ws=${w.id}">${tx('wsp.network')}</a>`, queue: html`<a href="${BASE}/mail-queue${full ? `?ws=${w.id}` : ''}">${tx('wsp.queue')}</a>` })}</p>
389
+ <p>${tx.rich('wsp.telemetry_text', { link: html`<a href="${BASE}/workspaces/${w.id}/telemetry">${tx('wsp.telemetry')}</a>` })}</p>`, '', 'mail-network')}
389
390
  <div class="columns">${preferencesRegion(s, w, without, unlinked)}${linksRegion(s, w, links)}</div>
390
391
  ${full ? isolationRegion(s, w, roles.rows) : region(tx('wsp.isolation'), html`<p class="u-mt0">${w.isolated ? tx('wsp.iso_short_on') : tx('wsp.iso_short_off')} ${tx('wsp.admins_change')}</p>`)}
391
392
  ${!full ? '' : region(tx('wsp.applications'), html`<div class="table-wrap"><table class="report report-reflow">
package/src/i18n/ar.ts CHANGED
@@ -424,6 +424,19 @@ export const ar: Record<MessageKey, string> = {
424
424
  'lov.loading': "جارٍ تحميل المزيد…",
425
425
  'da.wait': "يرجى الانتظار…",
426
426
  'lov.no_rows': "لم يُعثر على شيء.",
427
+ 'shuttle.available': "المتاحة",
428
+ 'shuttle.chosen': "المحددة",
429
+ 'shuttle.move': "نقل",
430
+ 'shuttle.move_all': "نقل الكل",
431
+ 'shuttle.remove': "إزالة",
432
+ 'shuttle.remove_all': "إزالة الكل",
433
+ 'shuttle.up': "نقل لأعلى",
434
+ 'shuttle.down': "نقل لأسفل",
435
+ 'shuttle.top': "نقل إلى الأعلى",
436
+ 'shuttle.bottom': "نقل إلى الأسفل",
437
+ 'shuttle.filter': "تصفية",
438
+ 'region.breadcrumb': "مسار التنقل",
439
+ 'region.no_help': "لا تتوفر تعليمات لهذه الصفحة.",
427
440
  'search.did_you_mean': "هل تقصد:",
428
441
  'error.lov_value': "{label}: اختر قيمة من القائمة.",
429
442
  'item.yes': "نعم",
package/src/i18n/cs.ts CHANGED
@@ -424,6 +424,19 @@ export const cs: Record<MessageKey, string> = {
424
424
  'lov.loading': "Načítání dalších…",
425
425
  'da.wait': "Čekejte prosím…",
426
426
  'lov.no_rows': "Nic nenalezeno.",
427
+ 'shuttle.available': "Dostupné",
428
+ 'shuttle.chosen': "Vybrané",
429
+ 'shuttle.move': "Přesunout",
430
+ 'shuttle.move_all': "Přesunout vše",
431
+ 'shuttle.remove': "Odebrat",
432
+ 'shuttle.remove_all': "Odebrat vše",
433
+ 'shuttle.up': "Posunout nahoru",
434
+ 'shuttle.down': "Posunout dolů",
435
+ 'shuttle.top': "Přesunout na začátek",
436
+ 'shuttle.bottom': "Přesunout na konec",
437
+ 'shuttle.filter': "Filtrovat",
438
+ 'region.breadcrumb': "Drobečková navigace",
439
+ 'region.no_help': "Pro tuto stránku není k dispozici nápověda.",
427
440
  'search.did_you_mean': "Měli jste na mysli:",
428
441
  'error.lov_value': "{label}: vyberte hodnotu ze seznamu.",
429
442
  'item.yes': "Ano",
package/src/i18n/da.ts CHANGED
@@ -424,6 +424,19 @@ export const da: Record<MessageKey, string> = {
424
424
  'lov.loading': "Indlæser flere…",
425
425
  'da.wait': "Vent venligst…",
426
426
  'lov.no_rows': "Intet fundet.",
427
+ 'shuttle.available': "Tilgængelige",
428
+ 'shuttle.chosen': "Valgte",
429
+ 'shuttle.move': "Flyt",
430
+ 'shuttle.move_all': "Flyt alle",
431
+ 'shuttle.remove': "Fjern",
432
+ 'shuttle.remove_all': "Fjern alle",
433
+ 'shuttle.up': "Flyt op",
434
+ 'shuttle.down': "Flyt ned",
435
+ 'shuttle.top': "Flyt øverst",
436
+ 'shuttle.bottom': "Flyt nederst",
437
+ 'shuttle.filter': "Filtrer",
438
+ 'region.breadcrumb': "Brødkrummesti",
439
+ 'region.no_help': "Der er ingen hjælp til denne side.",
427
440
  'search.did_you_mean': "Mente du:",
428
441
  'error.lov_value': "{label}: vælg en værdi på listen.",
429
442
  'item.yes': "Ja",
package/src/i18n/de.ts CHANGED
@@ -421,6 +421,19 @@ export const de: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Weitere werden geladen…',
422
422
  'da.wait': 'Bitte warten…',
423
423
  'lov.no_rows': 'Nichts gefunden.',
424
+ 'shuttle.available': 'Verfügbar',
425
+ 'shuttle.chosen': 'Ausgewählt',
426
+ 'shuttle.move': 'Verschieben',
427
+ 'shuttle.move_all': 'Alle verschieben',
428
+ 'shuttle.remove': 'Entfernen',
429
+ 'shuttle.remove_all': 'Alle entfernen',
430
+ 'shuttle.up': 'Nach oben',
431
+ 'shuttle.down': 'Nach unten',
432
+ 'shuttle.top': 'An den Anfang',
433
+ 'shuttle.bottom': 'An das Ende',
434
+ 'shuttle.filter': 'Filtern',
435
+ 'region.breadcrumb': 'Navigationspfad',
436
+ 'region.no_help': 'Für diese Seite ist keine Hilfe verfügbar.',
424
437
  'search.did_you_mean': "Meinten Sie:",
425
438
  'error.lov_value': '{label}: Wählen Sie einen Wert aus der Liste.',
426
439
  'item.yes': 'Ja',
package/src/i18n/el.ts CHANGED
@@ -424,6 +424,19 @@ export const el: Record<MessageKey, string> = {
424
424
  'lov.loading': "Φόρτωση περισσότερων…",
425
425
  'da.wait': "Παρακαλώ περιμένετε…",
426
426
  'lov.no_rows': "Δεν βρέθηκε τίποτα.",
427
+ 'shuttle.available': "Διαθέσιμα",
428
+ 'shuttle.chosen': "Επιλεγμένα",
429
+ 'shuttle.move': "Μετακίνηση",
430
+ 'shuttle.move_all': "Μετακίνηση όλων",
431
+ 'shuttle.remove': "Αφαίρεση",
432
+ 'shuttle.remove_all': "Αφαίρεση όλων",
433
+ 'shuttle.up': "Μετακίνηση πάνω",
434
+ 'shuttle.down': "Μετακίνηση κάτω",
435
+ 'shuttle.top': "Μετακίνηση στην αρχή",
436
+ 'shuttle.bottom': "Μετακίνηση στο τέλος",
437
+ 'shuttle.filter': "Φιλτράρισμα",
438
+ 'region.breadcrumb': "Διαδρομή πλοήγησης",
439
+ 'region.no_help': "Δεν υπάρχει βοήθεια για αυτή τη σελίδα.",
427
440
  'search.did_you_mean': "Μήπως εννοούσατε:",
428
441
  'error.lov_value': "{label}: επιλέξτε τιμή από τη λίστα.",
429
442
  'item.yes': "Ναι",
package/src/i18n/es.ts CHANGED
@@ -421,6 +421,19 @@ export const es: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Cargando más…',
422
422
  'da.wait': 'Espere, por favor…',
423
423
  'lov.no_rows': 'No se ha encontrado nada.',
424
+ 'shuttle.available': 'Disponibles',
425
+ 'shuttle.chosen': 'Seleccionados',
426
+ 'shuttle.move': 'Mover',
427
+ 'shuttle.move_all': 'Mover todos',
428
+ 'shuttle.remove': 'Quitar',
429
+ 'shuttle.remove_all': 'Quitar todos',
430
+ 'shuttle.up': 'Subir',
431
+ 'shuttle.down': 'Bajar',
432
+ 'shuttle.top': 'Mover al principio',
433
+ 'shuttle.bottom': 'Mover al final',
434
+ 'shuttle.filter': 'Filtrar',
435
+ 'region.breadcrumb': 'Ruta de navegación',
436
+ 'region.no_help': 'No hay ayuda disponible para esta página.',
424
437
  'search.did_you_mean': "Quizás quiso decir:",
425
438
  'error.lov_value': '{label}: elija un valor de la lista.',
426
439
  'item.yes': 'Sí',
package/src/i18n/fi.ts CHANGED
@@ -424,6 +424,19 @@ export const fi: Record<MessageKey, string> = {
424
424
  'lov.loading': "Ladataan lisää…",
425
425
  'da.wait': "Odota hetki…",
426
426
  'lov.no_rows': "Mitään ei löytynyt.",
427
+ 'shuttle.available': "Käytettävissä",
428
+ 'shuttle.chosen': "Valitut",
429
+ 'shuttle.move': "Siirrä",
430
+ 'shuttle.move_all': "Siirrä kaikki",
431
+ 'shuttle.remove': "Poista",
432
+ 'shuttle.remove_all': "Poista kaikki",
433
+ 'shuttle.up': "Siirrä ylös",
434
+ 'shuttle.down': "Siirrä alas",
435
+ 'shuttle.top': "Siirrä alkuun",
436
+ 'shuttle.bottom': "Siirrä loppuun",
437
+ 'shuttle.filter': "Suodata",
438
+ 'region.breadcrumb': "Murupolku",
439
+ 'region.no_help': "Tälle sivulle ei ole ohjetta.",
427
440
  'search.did_you_mean': "Tarkoititko:",
428
441
  'error.lov_value': "{label}: valitse arvo luettelosta.",
429
442
  'item.yes': "Kyllä",
package/src/i18n/fr.ts CHANGED
@@ -421,6 +421,19 @@ export const fr: Record<MessageKey, string> = {
421
421
  'lov.loading': 'Chargement de la suite…',
422
422
  'da.wait': 'Veuillez patienter…',
423
423
  'lov.no_rows': 'Aucun résultat.',
424
+ 'shuttle.available': 'Disponibles',
425
+ 'shuttle.chosen': 'Sélectionnés',
426
+ 'shuttle.move': 'Déplacer',
427
+ 'shuttle.move_all': 'Tout déplacer',
428
+ 'shuttle.remove': 'Retirer',
429
+ 'shuttle.remove_all': 'Tout retirer',
430
+ 'shuttle.up': 'Monter',
431
+ 'shuttle.down': 'Descendre',
432
+ 'shuttle.top': 'Placer en premier',
433
+ 'shuttle.bottom': 'Placer en dernier',
434
+ 'shuttle.filter': 'Filtrer',
435
+ 'region.breadcrumb': 'Fil d\'Ariane',
436
+ 'region.no_help': 'Aucune aide n\'est disponible pour cette page.',
424
437
  'search.did_you_mean': "Vouliez-vous dire :",
425
438
  'error.lov_value': '{label} : choisissez une valeur dans la liste.',
426
439
  'item.yes': 'Oui',
package/src/i18n/he.ts CHANGED
@@ -424,6 +424,19 @@ export const he: Record<MessageKey, string> = {
424
424
  'lov.loading': "טוען עוד…",
425
425
  'da.wait': "נא להמתין…",
426
426
  'lov.no_rows': "לא נמצא דבר.",
427
+ 'shuttle.available': "זמינים",
428
+ 'shuttle.chosen': "נבחרים",
429
+ 'shuttle.move': "העבר",
430
+ 'shuttle.move_all': "העבר הכול",
431
+ 'shuttle.remove': "הסר",
432
+ 'shuttle.remove_all': "הסר הכול",
433
+ 'shuttle.up': "הזז למעלה",
434
+ 'shuttle.down': "הזז למטה",
435
+ 'shuttle.top': "הזז להתחלה",
436
+ 'shuttle.bottom': "הזז לסוף",
437
+ 'shuttle.filter': "סינון",
438
+ 'region.breadcrumb': "נתיב ניווט",
439
+ 'region.no_help': "אין עזרה זמינה לדף זה.",
427
440
  'search.did_you_mean': "האם התכוונת ל:",
428
441
  'error.lov_value': "{label}: בחרו ערך מהרשימה.",
429
442
  'item.yes': "כן",
package/src/i18n/it.ts CHANGED
@@ -424,6 +424,19 @@ export const it: Record<MessageKey, string> = {
424
424
  'lov.loading': "Caricamento di altri…",
425
425
  'da.wait': "Attendere prego…",
426
426
  'lov.no_rows': "Nessun risultato.",
427
+ 'shuttle.available': "Disponibili",
428
+ 'shuttle.chosen': "Selezionati",
429
+ 'shuttle.move': "Sposta",
430
+ 'shuttle.move_all': "Sposta tutti",
431
+ 'shuttle.remove': "Rimuovi",
432
+ 'shuttle.remove_all': "Rimuovi tutti",
433
+ 'shuttle.up': "Sposta su",
434
+ 'shuttle.down': "Sposta giù",
435
+ 'shuttle.top': "Sposta all'inizio",
436
+ 'shuttle.bottom': "Sposta alla fine",
437
+ 'shuttle.filter': "Filtra",
438
+ 'region.breadcrumb': "Percorso di navigazione",
439
+ 'region.no_help': "Nessuna guida disponibile per questa pagina.",
427
440
  'search.did_you_mean': "Forse cercavi:",
428
441
  'error.lov_value': "{label}: scegli un valore dall'elenco.",
429
442
  'item.yes': "Sì",
package/src/i18n/ja.ts CHANGED
@@ -424,6 +424,19 @@ export const ja: Record<MessageKey, string> = {
424
424
  'lov.loading': "さらに読み込み中…",
425
425
  'da.wait': "お待ちください…",
426
426
  'lov.no_rows': "見つかりません。",
427
+ 'shuttle.available': "使用可能",
428
+ 'shuttle.chosen': "選択済み",
429
+ 'shuttle.move': "移動",
430
+ 'shuttle.move_all': "すべて移動",
431
+ 'shuttle.remove': "削除",
432
+ 'shuttle.remove_all': "すべて削除",
433
+ 'shuttle.up': "上へ移動",
434
+ 'shuttle.down': "下へ移動",
435
+ 'shuttle.top': "先頭へ移動",
436
+ 'shuttle.bottom': "末尾へ移動",
437
+ 'shuttle.filter': "フィルタ",
438
+ 'region.breadcrumb': "パンくずリスト",
439
+ 'region.no_help': "このページのヘルプはありません。",
427
440
  'search.did_you_mean': "もしかして:",
428
441
  'error.lov_value': "{label}: 一覧から値を選んでください。",
429
442
  'item.yes': "はい",
package/src/i18n/ko.ts CHANGED
@@ -424,6 +424,19 @@ export const ko: Record<MessageKey, string> = {
424
424
  'lov.loading': "더 불러오는 중…",
425
425
  'da.wait': "잠시 기다려 주세요…",
426
426
  'lov.no_rows': "찾은 항목이 없습니다.",
427
+ 'shuttle.available': "사용 가능",
428
+ 'shuttle.chosen': "선택됨",
429
+ 'shuttle.move': "이동",
430
+ 'shuttle.move_all': "모두 이동",
431
+ 'shuttle.remove': "제거",
432
+ 'shuttle.remove_all': "모두 제거",
433
+ 'shuttle.up': "위로 이동",
434
+ 'shuttle.down': "아래로 이동",
435
+ 'shuttle.top': "맨 위로 이동",
436
+ 'shuttle.bottom': "맨 아래로 이동",
437
+ 'shuttle.filter': "필터",
438
+ 'region.breadcrumb': "이동 경로",
439
+ 'region.no_help': "이 페이지에 대한 도움말이 없습니다.",
427
440
  'search.did_you_mean': "이것을 찾으셨나요:",
428
441
  'error.lov_value': "{label}: 목록에서 값을 선택하세요.",
429
442
  'item.yes': "예",
package/src/i18n/nb.ts CHANGED
@@ -424,6 +424,19 @@ export const nb: Record<MessageKey, string> = {
424
424
  'lov.loading': "Laster inn flere…",
425
425
  'da.wait': "Vent litt…",
426
426
  'lov.no_rows': "Fant ingenting.",
427
+ 'shuttle.available': "Tilgjengelige",
428
+ 'shuttle.chosen': "Valgte",
429
+ 'shuttle.move': "Flytt",
430
+ 'shuttle.move_all': "Flytt alle",
431
+ 'shuttle.remove': "Fjern",
432
+ 'shuttle.remove_all': "Fjern alle",
433
+ 'shuttle.up': "Flytt opp",
434
+ 'shuttle.down': "Flytt ned",
435
+ 'shuttle.top': "Flytt øverst",
436
+ 'shuttle.bottom': "Flytt nederst",
437
+ 'shuttle.filter': "Filtrer",
438
+ 'region.breadcrumb': "Brødsmulesti",
439
+ 'region.no_help': "Det finnes ingen hjelp for denne siden.",
427
440
  'search.did_you_mean': "Mente du:",
428
441
  'error.lov_value': "{label}: velg en verdi fra listen.",
429
442
  'item.yes': "Ja",
package/src/i18n/pl.ts CHANGED
@@ -424,6 +424,19 @@ export const pl: Record<MessageKey, string> = {
424
424
  'lov.loading': "Wczytywanie kolejnych…",
425
425
  'da.wait': "Proszę czekać…",
426
426
  'lov.no_rows': "Nic nie znaleziono.",
427
+ 'shuttle.available': "Dostępne",
428
+ 'shuttle.chosen': "Wybrane",
429
+ 'shuttle.move': "Przenieś",
430
+ 'shuttle.move_all': "Przenieś wszystkie",
431
+ 'shuttle.remove': "Usuń",
432
+ 'shuttle.remove_all': "Usuń wszystkie",
433
+ 'shuttle.up': "W górę",
434
+ 'shuttle.down': "W dół",
435
+ 'shuttle.top': "Na początek",
436
+ 'shuttle.bottom': "Na koniec",
437
+ 'shuttle.filter': "Filtruj",
438
+ 'region.breadcrumb': "Ścieżka nawigacji",
439
+ 'region.no_help': "Brak pomocy dla tej strony.",
427
440
  'search.did_you_mean': "Czy chodziło o:",
428
441
  'error.lov_value': "{label}: wybierz wartość z listy.",
429
442
  'item.yes': "Tak",
package/src/i18n/pt.ts CHANGED
@@ -424,6 +424,19 @@ export const pt: Record<MessageKey, string> = {
424
424
  'lov.loading': "A carregar mais…",
425
425
  'da.wait': "Aguarde, por favor…",
426
426
  'lov.no_rows': "Nada encontrado.",
427
+ 'shuttle.available': "Disponíveis",
428
+ 'shuttle.chosen': "Selecionados",
429
+ 'shuttle.move': "Mover",
430
+ 'shuttle.move_all': "Mover todos",
431
+ 'shuttle.remove': "Remover",
432
+ 'shuttle.remove_all': "Remover todos",
433
+ 'shuttle.up': "Mover para cima",
434
+ 'shuttle.down': "Mover para baixo",
435
+ 'shuttle.top': "Mover para o início",
436
+ 'shuttle.bottom': "Mover para o fim",
437
+ 'shuttle.filter': "Filtrar",
438
+ 'region.breadcrumb': "Trilha de navegação",
439
+ 'region.no_help': "Não há ajuda disponível para esta página.",
427
440
  'search.did_you_mean': "Queria dizer:",
428
441
  'error.lov_value': "{label}: escolha um valor da lista.",
429
442
  'item.yes': "Sim",
package/src/i18n/ru.ts CHANGED
@@ -424,6 +424,19 @@ export const ru: Record<MessageKey, string> = {
424
424
  'lov.loading': "Загрузка…",
425
425
  'da.wait': "Подождите…",
426
426
  'lov.no_rows': "Ничего не найдено.",
427
+ 'shuttle.available': "Доступные",
428
+ 'shuttle.chosen': "Выбранные",
429
+ 'shuttle.move': "Переместить",
430
+ 'shuttle.move_all': "Переместить все",
431
+ 'shuttle.remove': "Убрать",
432
+ 'shuttle.remove_all': "Убрать все",
433
+ 'shuttle.up': "Вверх",
434
+ 'shuttle.down': "Вниз",
435
+ 'shuttle.top': "В начало",
436
+ 'shuttle.bottom': "В конец",
437
+ 'shuttle.filter': "Фильтр",
438
+ 'region.breadcrumb': "Навигационная цепочка",
439
+ 'region.no_help': "Для этой страницы нет справки.",
427
440
  'search.did_you_mean': "Возможно, вы имели в виду:",
428
441
  'error.lov_value': "{label}: выберите значение из списка.",
429
442
  'item.yes': "Да",
package/src/i18n/sv.ts CHANGED
@@ -424,6 +424,19 @@ export const sv: Record<MessageKey, string> = {
424
424
  'lov.loading': "Läser in fler…",
425
425
  'da.wait': "Vänta…",
426
426
  'lov.no_rows': "Inget hittades.",
427
+ 'shuttle.available': "Tillgängliga",
428
+ 'shuttle.chosen': "Valda",
429
+ 'shuttle.move': "Flytta",
430
+ 'shuttle.move_all': "Flytta alla",
431
+ 'shuttle.remove': "Ta bort",
432
+ 'shuttle.remove_all': "Ta bort alla",
433
+ 'shuttle.up': "Flytta upp",
434
+ 'shuttle.down': "Flytta ned",
435
+ 'shuttle.top': "Flytta först",
436
+ 'shuttle.bottom': "Flytta sist",
437
+ 'shuttle.filter': "Filtrera",
438
+ 'region.breadcrumb': "Brödsmulor",
439
+ 'region.no_help': "Det finns ingen hjälp för den här sidan.",
427
440
  'search.did_you_mean': "Menade du:",
428
441
  'error.lov_value': "{label}: välj ett värde i listan.",
429
442
  'item.yes': "Ja",
package/src/i18n/tr.ts CHANGED
@@ -424,6 +424,19 @@ export const tr: Record<MessageKey, string> = {
424
424
  'lov.loading': "Daha fazlası yükleniyor…",
425
425
  'da.wait': "Lütfen bekleyin…",
426
426
  'lov.no_rows': "Hiçbir şey bulunamadı.",
427
+ 'shuttle.available': "Kullanılabilir",
428
+ 'shuttle.chosen': "Seçilenler",
429
+ 'shuttle.move': "Taşı",
430
+ 'shuttle.move_all': "Tümünü taşı",
431
+ 'shuttle.remove': "Kaldır",
432
+ 'shuttle.remove_all': "Tümünü kaldır",
433
+ 'shuttle.up': "Yukarı taşı",
434
+ 'shuttle.down': "Aşağı taşı",
435
+ 'shuttle.top': "En üste taşı",
436
+ 'shuttle.bottom': "En alta taşı",
437
+ 'shuttle.filter': "Filtrele",
438
+ 'region.breadcrumb': "İçerik haritası",
439
+ 'region.no_help': "Bu sayfa için yardım yok.",
427
440
  'search.did_you_mean': "Bunu mu demek istediniz:",
428
441
  'error.lov_value': "{label}: listeden bir değer seçin.",
429
442
  'item.yes': "Evet",
package/src/i18n/uk.ts CHANGED
@@ -424,6 +424,19 @@ export const uk: Record<MessageKey, string> = {
424
424
  'lov.loading': "Завантаження…",
425
425
  'da.wait': "Зачекайте…",
426
426
  'lov.no_rows': "Нічого не знайдено.",
427
+ 'shuttle.available': "Доступні",
428
+ 'shuttle.chosen': "Вибрані",
429
+ 'shuttle.move': "Перемістити",
430
+ 'shuttle.move_all': "Перемістити всі",
431
+ 'shuttle.remove': "Прибрати",
432
+ 'shuttle.remove_all': "Прибрати всі",
433
+ 'shuttle.up': "Вгору",
434
+ 'shuttle.down': "Вниз",
435
+ 'shuttle.top': "На початок",
436
+ 'shuttle.bottom': "У кінець",
437
+ 'shuttle.filter': "Фільтр",
438
+ 'region.breadcrumb': "Навігаційний ланцюжок",
439
+ 'region.no_help': "Для цієї сторінки немає довідки.",
427
440
  'search.did_you_mean': "Можливо, ви мали на увазі:",
428
441
  'error.lov_value': "{label}: виберіть значення зі списку.",
429
442
  'item.yes': "Так",