pgkiln 0.83.0 → 0.85.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 (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. package/src/session.ts +13 -4
@@ -11,6 +11,7 @@ import { destinationStatus, sendTest } from '../telemetry/export.ts';
11
11
  import { isAdmin } from './locks.ts';
12
12
  import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
13
  import { workspaceAdmin } from './workspaces.ts';
14
+ import { scopedTx as tx } from './i18n.ts';
14
15
 
15
16
  // (sprint 139, AP-6; APEX: Workspace Utilities → OpenTelemetry) Workspace
16
17
  // utilities → OpenTelemetry (administrators): the instance's collector, what
@@ -19,16 +20,16 @@ import { workspaceAdmin } from './workspaces.ts';
19
20
  // on /builder/workspaces/:id/telemetry. Headers (an API key) are write-only:
20
21
  // stored encrypted, only their names are shown.
21
22
 
22
- const crumbs: [string, string?][] = [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['OpenTelemetry']];
23
+ const crumbs = (): [string, string?][] => [[tx('tel.app_builder'), BASE], [tx('tel.workspace_utilities'), `${BASE}/utilities`], [tx('tel.opentelemetry')]];
23
24
  const ORIGIN: Record<Origin, string> = { builder: 'set here', environment: 'environment variable', default: 'default' };
24
25
 
25
26
  const check = (name: string, label: string, on: boolean, help?: string) =>
26
27
  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
 
28
- const headersField = (names: string[], help: string) => html`<div class="field"><label class="label" for="f_headers">Headers</label>
29
+ const headersField = (names: string[], help: string) => html`<div class="field"><label class="label" for="f_headers">${tx('tel.headers')}</label>
29
30
  <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
31
  <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
+ ${names.length ? html`<label class="check"><input type="checkbox" name="remove_headers" value="true"> ${tx('tel.remove_the_headers')}</label>` : ''}</div>`;
32
33
 
33
34
  const when = (ms: number | null) => (ms ? new Date(ms).toISOString().replace('T', ' ').slice(0, 19) + ' UTC' : '—');
34
35
 
@@ -40,7 +41,7 @@ function statusCell(key: string): Raw {
40
41
 
41
42
  async function adminOnly(s: Session, reply: FastifyReply) {
42
43
  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
+ await send(reply.code(403), s, shell(s, tx('tel.opentelemetry'), crumbs(), html`<h1 class="u-mb1">${tx('tel.opentelemetry')}</h1><div class="alert alert-error" role="alert">${tx('tel.only_administrators_set_up_opentelemetry')}</div>`));
44
45
  return false;
45
46
  }
46
47
 
@@ -59,50 +60,47 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
59
60
  const workspaces = (await owner.query<{ id: number; name: string; telemetry: string; telemetry_endpoint: string | null; apps: number }>(
60
61
  `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
62
  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 from = (k: string) => html`<span class="muted">(${tx.dyn(`tel.o.${t.origin[k] ?? 'default'}`, ORIGIN[t.origin[k] ?? 'default'])})</span>`;
63
64
  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>
65
+ const main = html`<h1 class="u-mb1">${tx('tel.opentelemetry')}</h1>
66
+ <p class="muted u-mt0">${tx.code('tel.send_traces_logs_and_metrics')}</p>
69
67
  ${t.protocolWarning ? html`<div class="alert alert-warning" role="status">${t.protocolWarning}</div>` : ''}
70
68
  <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).')}
69
+ ${region(tx('tel.collector'), html`<form method="post" action="${BASE}/telemetry">${csrf(s)}
70
+ ${check('enabled', tx('tel.send_to_this_collector'), t.enabled, tx('tel.off_nothing_leaves_the_server'))}
73
71
  <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.')}
72
+ ${input('endpoint', tx('tel.collector_url'), t.origin.otel_endpoint === 'builder' ? t.endpoint : '', { placeholder: t.endpoint || 'http://otel-collector:4318', help: tx.rich('tel.endpoint_help', { value: html`<b>${t.endpoint || tx('tel.none')}</b>`, origin: from('otel_endpoint'), env: html`<code>OTEL_EXPORTER_OTLP_ENDPOINT</code>` }) as unknown as string })}
73
+ ${input('service_name', tx('tel.service_name'), t.origin.otel_service_name === 'builder' ? t.serviceName : '', { placeholder: t.serviceName, help: tx('tel.service_help', { value: t.serviceName }) })}
74
+ ${input('sample', tx('tel.sample_ratio'), t.origin.otel_sample === 'builder' ? String(t.sample) : '', { placeholder: String(t.sample), help: tx('tel.share_of_traces_sent_0') })}
75
+ ${select('user', tx('tel.user'), t.user, [['hashed', tx('tel.hashed_the_same_user_gives')], ['name', tx('tel.user_name')], ['none', tx('tel.not_sent')]], 'enduser.id on the page span and on logs.')}
78
76
  </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)}
77
+ ${headersField(t.headerNames, tx('tel.sent_with_every_request_to'))}
78
+ <fieldset class="field"><legend class="label">${tx('tel.send')}</legend><div class="radio-group">
79
+ ${check('traces', tx('tel.traces'), t.traces)}${check('logs', tx('tel.logs_errors_refused_access_and'), t.logs)}${check('metrics', `Metrics, every ${Math.round(t.intervalMs / 1000)} seconds`, t.metrics)}
82
80
  </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>`)}
81
+ ${check('values', tx('tel.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.')}
82
+ ${check('builder', tx('tel.trace_builder_requests_too'), t.builder, tx('tel.applications_only_by_default'))}
83
+ ${check('browser', tx('tel.browser_spans'), t.browser, tx('tel.pages_report_their_load_time'))}
84
+ ${check('prometheus', tx('tel.prometheus_endpoint_metrics'), t.prometheus, tx('tel.for_administrators_signed_in_to'))}
85
+ <div class="buttons"><button class="btn btn-hot">${tx('tel.save')}</button></div></form>
86
+ <form method="post" action="${BASE}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">${tx('tel.send_a_test_span')}</button></form>`)}
87
+ ${region(tx('tel.status'), html`<p class="muted u-mt0">${tx('tel.since_this_server_started_each')}</p>
88
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('tel.collector')}</th><th>${tx('tel.sending')}</th></tr></thead><tbody>
89
+ <tr><td data-label="${tx('tel.collector')}">This instance's${t.enabled ? '' : html` <span class="muted">${tx('tel.off')}</span>`}</td><td data-label="${tx('tel.sending')}">${statusCell('instance')}</td></tr>
90
+ ${workspaces.filter((w) => w.telemetry === 'own').map((w) => html`<tr><td data-label="${tx('tel.collector')}">Workspace ${w.name}</td><td data-label="${tx('tel.sending')}">${statusCell(`ws:${w.id}`)}</td></tr>`)}
93
91
  </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>`)}
92
+ ${region(tx('tel.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.` : tx('tel.off_switch_it_on_above')}
93
+ 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.' : tx('tel.the_token_is_pgkiln_metrics')) : tx('tel.no_token_is_set')}</p>
94
+ ${token ? html`<div class="alert alert-success" role="status">${tx.rich('tel.new_token', { token: html`<code class="cell-break">${token}</code>` })}</div>` : ''}
95
+ <form method="post" action="${BASE}/telemetry/token" class="buttons">${csrf(s)}<button class="btn" name="action" value="new">${t.prometheusTokenHash ? tx('tel.replace_the_token') : tx('tel.make_a_token')}</button>
96
+ ${t.prometheusTokenHash ? html`<button class="btn btn-danger" name="action" value="remove" data-confirm="${tx('tel.remove_the_metrics_token_scrapers')}">${tx('tel.remove_the_token')}</button>` : ''}</form>`)}
97
+ ${region(tx('tel.workspaces'), html`<p class="muted u-mt0">${tx('tel.a_workspace_s_administrators_may')}</p>
98
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('tel.workspace')}</th><th>${tx('tel.applications')}</th><th>${tx('tel.sends')}</th></tr></thead><tbody>
99
+ ${workspaces.length ? workspaces.map((w) => html`<tr><td data-label="${tx('tel.workspace')}"><a href="${BASE}/workspaces/${w.id}/telemetry">${w.name}</a></td><td data-label="${tx('tel.applications')}" class="num">${w.apps}</td>
100
+ <td data-label="${tx('tel.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>`)
101
+ : html`<tr><td colspan="3" class="empty">${tx('tel.no_workspaces')}</td></tr>`}</tbody></table></div>`)}
104
102
  </div>`;
105
- return send(reply, s, shell(s, 'OpenTelemetry', crumbs, main));
103
+ return send(reply, s, shell(s, tx('tel.opentelemetry'), crumbs(), main));
106
104
  });
107
105
 
108
106
  app.post(`${BASE}/telemetry`, async (req: Req, reply) => {
@@ -114,7 +112,7 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
114
112
  await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req),
115
113
  detail: `OpenTelemetry: ${t.enabled ? `on, ${t.endpoint}` : 'off'}, sample ${t.sample}, user ${t.user}, values ${t.values}, browser ${t.browser}, /metrics ${t.prometheus}` });
116
114
  }
117
- flash(s, problem ?? 'OpenTelemetry settings saved.', problem ? 'error' : 'ok');
115
+ flash(s, problem ?? tx('tel.opentelemetry_settings_saved'), problem ? 'error' : 'ok');
118
116
  return back(reply, s, `${BASE}/telemetry`);
119
117
  });
120
118
 
@@ -122,7 +120,7 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
122
120
  const s = await developer(req, reply);
123
121
  if (!s || !(await adminOnly(s, reply))) return;
124
122
  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');
123
+ flash(s, problem ? tx('tel.test_failed', { problem }) : tx('tel.the_collector_took_the_test'), problem ? 'error' : 'ok');
126
124
  return back(reply, s, `${BASE}/telemetry`);
127
125
  });
128
126
 
@@ -138,7 +136,7 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
138
136
  } else if (action === 'remove') {
139
137
  await setMetricsToken(null);
140
138
  await logActivity({ username: s.username, event: 'instance_settings', ip: clientIp(req), detail: 'metrics token removed' });
141
- flash(s, 'The metrics token is removed.');
139
+ flash(s, tx('tel.the_metrics_token_is_removed'));
142
140
  }
143
141
  return back(reply, s, `${BASE}/telemetry`);
144
142
  });
@@ -147,8 +145,8 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
147
145
 
148
146
  const wsAllowed = async (s: Session, reply: FastifyReply, id: string) => {
149
147
  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'));
148
+ await send(reply.code(403), s, shell(s, tx('tel.opentelemetry'), [[tx('tel.administration')], [tx('tel.opentelemetry')]],
149
+ html`<h1 class="u-mb1">${tx('tel.opentelemetry')}</h1><div class="alert alert-error" role="alert">${tx('tel.only_the_workspace_s_administrators')}</div>`, 'users'));
152
150
  return false;
153
151
  };
154
152
 
@@ -163,23 +161,22 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
163
161
  const names = Object.keys(workspaceTelemetry(w.id)?.headers ?? {});
164
162
  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
163
  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>
164
+ <p class="muted u-mt0">${tx.rich('tel.intro', { state: t.enabled ? tx('tel.the_instance_sends_them_to') : tx('tel.the_instance_sends_nothing_at'), network: html`<a href="${BASE}/network?ws=${w.id}">${tx('tel.network_access')}</a>` })}</p>
168
165
  <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', '')}
166
+ ${region(tx('tel.setting'), html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry">${csrf(s)}
167
+ <fieldset class="field"><legend class="label">${tx('tel.the_applications_send')}</legend><div class="radio-group">
168
+ ${radio('instance', tx('tel.as_the_instance_does'), '(default)')}
169
+ ${radio('off', tx('tel.nothing'), 'not even to the instance\'s collector')}
170
+ ${radio('own', tx('tel.also_to_our_own_collector'), '')}
174
171
  </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>`) : ''}
172
+ <div class="form-grid">${input('endpoint', tx('tel.own_collector_url'), w.telemetry_endpoint ?? '', { placeholder: tx('tel.https_otlp_example_com'), help: tx('tel.the_otlp_http_base_address') })}</div>
173
+ ${headersField(names.length ? names : w.has_headers ? [tx('tel.unreadable')] : [], tx('tel.sent_with_every_request_to_2'))}
174
+ ${check('values', tx('tel.include_values_for_the_own'), w.telemetry_values, 'SQL statements with their literals, bind values and query strings.')}
175
+ <div class="buttons"><button class="btn btn-hot">${tx('tel.save')}</button></div></form>
176
+ ${w.telemetry === 'own' ? html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn">${tx('tel.send_a_test_span')}</button></form>` : ''}`)}
177
+ ${w.telemetry === 'own' ? region(tx('tel.status'), html`<p class="u-mt0">${statusCell(`ws:${w.id}`)}</p>`) : ''}
181
178
  </div>`;
182
- return send(reply, s, shell(s, `OpenTelemetry · ${w.name}`, [['Administration'], [w.name, `${BASE}/workspaces/${w.id}`], ['OpenTelemetry']], main, 'users'));
179
+ return send(reply, s, shell(s, `OpenTelemetry · ${w.name}`, [[tx('tel.administration')], [w.name, `${BASE}/workspaces/${w.id}`], [tx('tel.opentelemetry')]], main, 'users'));
183
180
  });
184
181
 
185
182
  app.post(`${BASE}/workspaces/:id(^\\d{1,9}$)/telemetry`, async (req: Req, reply) => {
@@ -191,7 +188,7 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
191
188
  const problem = await saveWorkspaceTelemetry(id, f);
192
189
  if (!problem)
193
190
  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');
191
+ flash(s, problem ?? tx('tel.saved'), problem ? 'error' : 'ok');
195
192
  return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
196
193
  });
197
194
 
@@ -199,8 +196,8 @@ export async function telemetryBuilderRoutes(app: FastifyInstance) {
199
196
  const s = await developer(req, reply);
200
197
  if (!s || !(await wsAllowed(s, reply, req.params.id))) return;
201
198
  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');
199
+ const problem = workspaceTelemetry(id)?.mode === 'own' ? await sendTest(id) : tx('tel.the_workspace_has_no_collector');
200
+ flash(s, problem ? tx('tel.test_failed', { problem }) : tx('tel.the_collector_took_the_test'), problem ? 'error' : 'ok');
204
201
  return back(reply, s, `${BASE}/workspaces/${id}/telemetry`);
205
202
  });
206
203
  }
@@ -10,6 +10,7 @@ import { BUILTIN_COMPONENTS, builtinComponents } from '../runtime/builtin-compon
10
10
  import type { Session } from '../session.ts';
11
11
  import { linkItemsText, parseLinkItems, reportColumns } from './report-settings.ts';
12
12
  import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
13
+ import { scopedTx as tx } from './i18n.ts';
13
14
 
14
15
  // Template components in the builder:
15
16
  // - Shared Components → Template components: the extras under the
@@ -53,7 +54,7 @@ export function previewHtml(c: TemplateComponent, sample: string, multiple: bool
53
54
  const rows = sampleRows(sample);
54
55
  const lookups = (rows.length ? rows : [new Map<string, string>()]).map((cols, n) => rowLookup(null, attrs, cols, { APEX$ROW_NUM: String(n + 1), LINK: '#' }));
55
56
  const layout = (c.css_classes ?? []).filter((k) => (LAYOUT_CLASSES as readonly string[]).includes(k));
56
- return html`<div class="${['tc-region', ...(layout.length ? layout : ['tc-list'])].join(' ')}">${raw(renderInstances(comp, lookups, rowLookup(null, attrs, new Map()), multiple))}</div>`;
57
+ return html`<div class="${['tc-region', ...(layout.length ? layout : [tx('tpl.tc_list')])].join(' ')}">${raw(renderInstances(comp, lookups, rowLookup(null, attrs, new Map()), multiple))}</div>`;
57
58
  }
58
59
 
59
60
  /** Placeholders a template uses that are neither attributes nor built in: probably columns. */
@@ -75,49 +76,49 @@ export function templateExtras(appId: number, row: any, query: Record<string, st
75
76
  const sample = query.sample ?? cols.map((n) => `${n}=${n.toLowerCase()} 1`).join('\n') + (cols.length ? `\n\n${cols.map((n) => `${n}=${n.toLowerCase()} 2`).join('\n')}` : '');
76
77
  const multiple = query.multiple === '1';
77
78
  const attrs: TcAttribute[] = c.attributes ?? [];
78
- return html`<fieldset class="prop-group u-mt125"><legend>Custom attributes</legend>
79
+ return html`<fieldset class="prop-group u-mt125"><legend>${tx('tpl.custom_attributes')}</legend>
79
80
  ${attrs.length
80
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Name</th><th>Label</th><th>Type</th><th>Default</th></tr></thead><tbody>
81
- ${attrs.map((a) => html`<tr><td data-label="Name"><code>#${a.name}#</code></td><td data-label="Label">${a.label ?? ''}</td>
82
- <td data-label="Type">${a.type ?? 'text'}${a.options?.length ? html` <span class="muted">(${a.options.join(', ')})</span>` : ''}</td>
83
- <td data-label="Default"><code>${a.default ?? ''}</code></td></tr>`)}
81
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('tpl.name')}</th><th>${tx('tpl.label')}</th><th>${tx('tpl.type')}</th><th>${tx('tpl.default')}</th></tr></thead><tbody>
82
+ ${attrs.map((a) => html`<tr><td data-label="${tx('tpl.name')}"><code>#${a.name}#</code></td><td data-label="${tx('tpl.label')}">${a.label ?? ''}</td>
83
+ <td data-label="${tx('tpl.type')}">${a.type ?? 'text'}${a.options?.length ? html` <span class="muted">(${a.options.join(', ')})</span>` : ''}</td>
84
+ <td data-label="${tx('tpl.default')}"><code>${a.default ?? ''}</code></td></tr>`)}
84
85
  </tbody></table></div>`
85
- : html`<p class="muted">None: the template uses the row's columns only.</p>`}
86
+ : html`<p class="muted">${tx('tpl.none_the_template_uses_the')}</p>`}
86
87
  ${cols.length ? html`<p class="muted">Columns the template expects: ${cols.map((n, i) => html`${i ? ', ' : ''}<code>${n.toLowerCase()}</code>`)}.</p>` : ''}
87
88
  </fieldset>
88
- <fieldset class="prop-group"><legend>Preview</legend>
89
+ <fieldset class="prop-group"><legend>${tx('tpl.preview')}</legend>
89
90
  <form method="get" action="${BASE}/apps/${appId}/shared" class="tc-preview-form">
90
91
  <input type="hidden" name="c" value="template_component-${row.id}">
91
- <div class="field" data-wide><label class="label" for="f_tc_sample">Sample rows</label>
92
+ <div class="field" data-wide><label class="label" for="f_tc_sample">${tx('tpl.sample_rows')}</label>
92
93
  <textarea id="f_tc_sample" name="sample" class="code" rows="5" spellcheck="false">${sample}</textarea>
93
- <small class="help">One NAME=value per line; a blank line starts the next row. Attributes take their defaults; #LINK# is "#".</small></div>
94
- <label class="check"><input type="checkbox" name="multiple" value="1"${multiple ? raw(' checked') : ''}> As "multiple" (in the wrapper)</label>
95
- <div class="buttons"><button class="btn">Preview</button></div>
94
+ <small class="help">${tx('tpl.one_name_value_per_line')}</small></div>
95
+ <label class="check"><input type="checkbox" name="multiple" value="1"${multiple ? raw(' checked') : ''}> ${tx('tpl.as_multiple_in_the_wrapper')}</label>
96
+ <div class="buttons"><button class="btn">${tx('tpl.preview')}</button></div>
96
97
  </form>
97
- <div class="tc-preview" aria-label="Preview">${previewHtml(c, sample, multiple)}</div>
98
+ <div class="tc-preview" aria-label="${tx('tpl.preview')}">${previewHtml(c, sample, multiple)}</div>
98
99
  </fieldset>
99
- <fieldset class="prop-group"><legend>Plug-in</legend>
100
- <p class="muted u-mt0">Share this component with other applications and installations as one JSON file; import it under Shared Components → Template components → Add.</p>
101
- <div class="buttons"><a class="btn" href="${BASE}/apps/${appId}/template-components/${row.id}/export" download>Download plug-in file</a></div>
100
+ <fieldset class="prop-group"><legend>${tx('tpl.plug_in')}</legend>
101
+ <p class="muted u-mt0">${tx('tpl.share_this_component_with_other')}</p>
102
+ <div class="buttons"><a class="btn" href="${BASE}/apps/${appId}/template-components/${row.id}/export" download>${tx('tpl.download_plug_in_file')}</a></div>
102
103
  </fieldset>`;
103
104
  }
104
105
 
105
106
  /** Under "New template component": copy a built-in one, or import a plug-in file. */
106
107
  export function templateImport(appId: number, s: Session) {
107
- return html`<section class="region region-standard u-mt125"><header class="region-header"><h2>Built-in components</h2></header><div class="region-body">
108
- <p class="muted u-mt0">Every application can use these in a Template component region or as a report column template. To change one, copy it into this application: the copy (same static id) takes its place.</p>
109
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Component</th><th>Static id</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
110
- ${BUILTIN_COMPONENTS.map((c) => html`<tr><td data-label="Component"><b>${c.name}</b> <span class="muted">${c.description ?? ''}</span></td><td data-label="Static id"><code>${c.static_id}</code></td>
111
- <td data-label="Actions"><form method="post" action="${BASE}/apps/${appId}/template-components/copy" class="u-inline">${csrf(s)}<input type="hidden" name="static_id" value="${c.static_id}">
112
- <button class="btn btn-sm">Copy into this application</button></form></td></tr>`)}
108
+ return html`<section class="region region-standard u-mt125"><header class="region-header"><h2>${tx('tpl.built_in_components')}</h2></header><div class="region-body">
109
+ <p class="muted u-mt0">${tx('tpl.every_application_can_use_these')}</p>
110
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('tpl.component')}</th><th>${tx('tpl.static_id')}</th><th><span class="sr-only">${tx('tpl.actions')}</span></th></tr></thead><tbody>
111
+ ${BUILTIN_COMPONENTS.map((c) => html`<tr><td data-label="${tx('tpl.component')}"><b>${c.name}</b> <span class="muted">${c.description ?? ''}</span></td><td data-label="${tx('tpl.static_id')}"><code>${c.static_id}</code></td>
112
+ <td data-label="${tx('tpl.actions')}"><form method="post" action="${BASE}/apps/${appId}/template-components/copy" class="u-inline">${csrf(s)}<input type="hidden" name="static_id" value="${c.static_id}">
113
+ <button class="btn btn-sm">${tx('tpl.copy_into_this_application')}</button></form></td></tr>`)}
113
114
  </tbody></table></div></div></section>
114
- <section class="region region-standard u-mt125"><header class="region-header"><h2>Import a plug-in</h2></header><div class="region-body">
115
+ <section class="region region-standard u-mt125"><header class="region-header"><h2>${tx('tpl.import_a_plug_in')}</h2></header><div class="region-body">
115
116
  <form method="post" action="${BASE}/apps/${appId}/template-components/import">${csrf(s)}
116
- <div class="field" data-wide><label class="label" for="f_tc_plugin">Plug-in file (JSON)</label>
117
+ <div class="field" data-wide><label class="label" for="f_tc_plugin">${tx('tpl.plug_in_file_json')}</label>
117
118
  <textarea id="f_tc_plugin" name="plugin" class="code" rows="7" spellcheck="false" required placeholder='{"format": "pgkiln-plugin/1", "type": "template_component", …}'></textarea>
118
- <small class="help">Paste the contents of a .plugin.json file, e.g. one from examples/plugins/. The template is checked before it is saved.</small></div>
119
- <label class="check"><input type="checkbox" name="replace" value="true"> Replace a component with the same static id</label>
120
- <div class="buttons"><button class="btn btn-hot">Import plug-in</button></div>
119
+ <small class="help">${tx('tpl.paste_the_contents_of_a')}</small></div>
120
+ <label class="check"><input type="checkbox" name="replace" value="true"> ${tx('tpl.replace_a_component_with_the')}</label>
121
+ <div class="buttons"><button class="btn btn-hot">${tx('tpl.import_plug_in')}</button></div>
121
122
  </form></div></section>`;
122
123
  }
123
124
 
@@ -125,7 +126,7 @@ export function templateImport(appId: number, s: Session) {
125
126
 
126
127
  /** The value fields of a component's custom attributes (names attr_NAME). */
127
128
  function attributeFields(c: TemplateComponent | undefined, values: Record<string, string> | undefined, idp: string): Raw {
128
- if (!c) return html`<p class="muted">Choose a component and save to set its attributes.</p>`;
129
+ if (!c) return html`<p class="muted">${tx('tpl.choose_a_component_and_save')}</p>`;
129
130
  const attrs = c.attributes ?? [];
130
131
  if (!attrs.length) return html`<p class="muted">${c.name} has no custom attributes.</p>`;
131
132
  return html`<div class="form-grid">${attrs.map((a) => {
@@ -196,36 +197,36 @@ export async function templateRegionForm(pageId: number, appId: number, r: { id:
196
197
  const cols = r.source?.trim() ? await reportColumns(appId, await designSql(appId, r)) : null;
197
198
  const expected = c ? columnNames(c) : [];
198
199
  const missing = cols && 'columns' in cols ? expected.filter((e) => !cols.columns.some((x) => x.toUpperCase() === e)) : [];
199
- return html`<h3 class="u-mt15">Template component settings</h3>
200
- <p class="muted u-mt0">These fields write the region's settings JSON above (other keys are kept).</p>
200
+ return html`<h3 class="u-mt15">${tx('tpl.template_component_settings')}</h3>
201
+ <p class="muted u-mt0">${tx('tpl.these_fields_write_the_region')}</p>
201
202
  ${cols && 'error' in cols ? html`<div class="alert alert-error" role="alert">The columns could not be read: ${cols.error}</div>` : ''}
202
- ${cols && 'columns' in cols ? html`<p class="muted">Columns of the query (#NAME# in the template and in attribute values): ${cols.columns.map((x, i) => html`${i ? ', ' : ''}<code>${x}</code>`)}.</p>` : html`<p class="muted">No query: the component shows once, from its attributes.</p>`}
203
+ ${cols && 'columns' in cols ? html`<p class="muted">Columns of the query (#NAME# in the template and in attribute values): ${cols.columns.map((x, i) => html`${i ? ', ' : ''}<code>${x}</code>`)}.</p>` : html`<p class="muted">${tx('tpl.no_query_the_component_shows')}</p>`}
203
204
  ${missing.length ? html`<p class="muted">Note: the template also uses ${missing.map((m, i) => html`${i ? ', ' : ''}<code>#${m}#</code>`)}, which the query doesn't return.</p>` : ''}
204
205
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/template-settings" class="component-form">${csrf(s)}
205
- <fieldset class="prop-group"><legend>Component</legend><div class="form-grid">
206
- <div class="field"><label class="label" for="${id('component')}">Template component</label>
207
- <select id="${id('component')}" name="component">${opt('', '- choose -', cfg.component)}${[...comps.values()].map((x) => opt(x.static_id, `${x.name} (${x.builtin ? 'built in' : x.static_id})`, cfg.component))}</select>
208
- <small class="help">The built-in ones (avatar, badge, comments, media list, metric card, timeline), and the application's own under <a href="${BASE}/apps/${appId}/shared?new=template_component">Shared Components → Template components</a>.</small></div>
209
- <div class="field"><label class="label" for="${id('display')}">Display</label>
210
- <select id="${id('display')}" name="display">${opt('', 'Each row as an instance', cfg.display)}${opt('multiple', 'Multiple: all rows in the wrapper', cfg.display)}</select>
211
- ${c && !c.wrapper && cfg.display === 'multiple' ? html`<small class="help">${c.name} has no wrapper, so the rows show one after another.</small>` : ''}</div>
212
- <div class="field"><label class="label" for="${id('max_rows')}">At most rows</label>
206
+ <fieldset class="prop-group"><legend>${tx('tpl.component')}</legend><div class="form-grid">
207
+ <div class="field"><label class="label" for="${id('component')}">${tx('tpl.template_component')}</label>
208
+ <select id="${id('component')}" name="component">${opt('', tx('tpl.choose'), cfg.component)}${[...comps.values()].map((x) => opt(x.static_id, `${x.name} (${x.builtin ? tx('tpl.built_in') : x.static_id})`, cfg.component))}</select>
209
+ <small class="help">${tx.rich('tpl.builtin_help', { link: html`<a href="${BASE}/apps/${appId}/shared?new=template_component">${tx('tpl.shared_components_template_components')}</a>` })}</small></div>
210
+ <div class="field"><label class="label" for="${id('display')}">${tx('tpl.display')}</label>
211
+ <select id="${id('display')}" name="display">${opt('', tx('tpl.each_row_as_an_instance'), cfg.display)}${opt('multiple', tx('tpl.multiple_all_rows_in_the'), cfg.display)}</select>
212
+ ${c && !c.wrapper && cfg.display === 'multiple' ? html`<small class="help">${tx('tpl.no_wrapper', { name: c.name })}</small>` : ''}</div>
213
+ <div class="field"><label class="label" for="${id('max_rows')}">${tx('tpl.at_most_rows')}</label>
213
214
  <input id="${id('max_rows')}" name="max_rows" type="number" min="1" max="500" value="${cfg.max_rows ?? ''}" placeholder="500"></div>
214
- <div class="field" data-wide><label class="label" for="${id('empty')}">Text when there are no rows</label>
215
- <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="No data found"></div>
215
+ <div class="field" data-wide><label class="label" for="${id('empty')}">${tx('tpl.text_when_there_are_no')}</label>
216
+ <input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="${tx('tpl.no_data_found')}"></div>
216
217
  </div></fieldset>
217
- <fieldset class="prop-group"><legend>Attributes</legend>
218
+ <fieldset class="prop-group"><legend>${tx('tpl.attributes')}</legend>
218
219
  ${attributeFields(c, cfg.attributes, id('attr'))}
219
220
  </fieldset>
220
- <fieldset class="prop-group"><legend>Link (#LINK#)</legend><div class="form-grid">
221
- <div class="field"><label class="label" for="${id('link_page')}">Each instance links to page</label>
222
- <select id="${id('link_page')}" name="link_page">${opt('', '- no link -', cfg.link?.page)}${pages.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, cfg.link?.page))}</select>
223
- <small class="help">The template puts it in href="#LINK#"; item values get a checksum. Empty for users who may not open the page.</small></div>
224
- <div class="field" data-wide><label class="label" for="${id('link_items')}">Set items</label>
225
- <input id="${id('link_items')}" name="link_items" value="${linkItemsText(cfg.link?.items)}" placeholder="P3_ID=#id#">
226
- <small class="help">ITEM=#column#, comma separated.</small></div>
221
+ <fieldset class="prop-group"><legend>${tx('tpl.link_link')}</legend><div class="form-grid">
222
+ <div class="field"><label class="label" for="${id('link_page')}">${tx('tpl.each_instance_links_to_page')}</label>
223
+ <select id="${id('link_page')}" name="link_page">${opt('', tx('tpl.no_link'), cfg.link?.page)}${pages.map((p) => opt(String(p.page_no), `${p.page_no}. ${p.name}`, cfg.link?.page))}</select>
224
+ <small class="help">${tx('tpl.the_template_puts_it_in')}</small></div>
225
+ <div class="field" data-wide><label class="label" for="${id('link_items')}">${tx('tpl.set_items')}</label>
226
+ <input id="${id('link_items')}" name="link_items" value="${linkItemsText(cfg.link?.items)}" placeholder="${tx('tpl.p3_id_id')}">
227
+ <small class="help">${tx('tpl.item_column_comma_separated')}</small></div>
227
228
  </div></fieldset>
228
- <div class="buttons"><button class="btn btn-hot">Save template component settings</button></div>
229
+ <div class="buttons"><button class="btn btn-hot">${tx('tpl.save_template_component_settings')}</button></div>
229
230
  </form>`;
230
231
  }
231
232
 
@@ -274,19 +275,19 @@ export async function columnTemplatesForm(pageId: number, appId: number, r: { id
274
275
  const c = use?.component ? comps.get(use.component) : undefined;
275
276
  const hint = c?.attributes?.length ? `${c.attributes.map((a) => `${a.name}=`).join('\n')}` : 'NAME=value';
276
277
  return html`<tr>
277
- <td data-label="Column"><code>${col}</code>${names.includes(col) ? '' : html` <span class="tag tag-error">not in the query</span>`}<input type="hidden" name="col_${i}" value="${col}"></td>
278
- <td data-label="Template component"><select name="tc_${i}" aria-label="Template component of ${col}">${opt('', '- plain value -', use?.component)}${[...comps.values()].map((x) => opt(x.static_id, x.name, use?.component))}${use?.component && !c ? opt(use.component, `${use.component} (missing!)`, use.component) : ''}</select></td>
279
- <td data-label="Attributes"><textarea name="attrs_${i}" class="code" rows="${Math.max(1, Math.min(4, c?.attributes?.length ?? 1))}" spellcheck="false" aria-label="Attributes of ${col}" placeholder="${hint}">${linesOf(use?.attributes)}</textarea></td>
278
+ <td data-label="${tx('tpl.column')}"><code>${col}</code>${names.includes(col) ? '' : html` <span class="tag tag-error">${tx('tpl.not_in_the_query')}</span>`}<input type="hidden" name="col_${i}" value="${col}"></td>
279
+ <td data-label="${tx('tpl.template_component')}"><select name="tc_${i}" aria-label="${tx('tpl.aria_component', { column: col })}">${opt('', tx('tpl.plain_value'), use?.component)}${[...comps.values()].map((x) => opt(x.static_id, x.name, use?.component))}${use?.component && !c ? opt(use.component, tx('tpl.missing', { name: use.component }), use.component) : ''}</select></td>
280
+ <td data-label="${tx('tpl.attributes')}"><textarea name="attrs_${i}" class="code" rows="${Math.max(1, Math.min(4, c?.attributes?.length ?? 1))}" spellcheck="false" aria-label="${tx('tpl.aria_attrs', { column: col })}" placeholder="${hint}">${linesOf(use?.attributes)}</textarea></td>
280
281
  </tr>`;
281
282
  });
282
- return html`<h3 class="u-mt15">Column templates</h3>
283
- <p class="muted u-mt0">Render a column's cells through a template component. Its template sees every column of the row as #NAME# (also hidden ones), and #LINK# is the report's link. Attributes: NAME=value per line, with #column# and &amp;ITEM. substitutions.</p>
283
+ return html`<h3 class="u-mt15">${tx('tpl.column_templates')}</h3>
284
+ <p class="muted u-mt0">${tx('tpl.render_a_column_s_cells')}</p>
284
285
  <form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/column-templates" class="component-form">${csrf(s)}
285
286
  <input type="hidden" name="n" value="${all.length}">
286
287
  ${all.length
287
- ? html`<div class="table-wrap"><table class="report report-reflow tc-columns"><thead><tr><th>Column</th><th>Template component</th><th>Attributes</th></tr></thead><tbody>${rows}</tbody></table></div>`
288
- : html`<p class="muted">No columns yet.</p>`}
289
- <div class="buttons"><button class="btn btn-hot">Save column templates</button></div>
288
+ ? html`<div class="table-wrap"><table class="report report-reflow tc-columns"><thead><tr><th>${tx('tpl.column')}</th><th>${tx('tpl.template_component')}</th><th>${tx('tpl.attributes')}</th></tr></thead><tbody>${rows}</tbody></table></div>`
289
+ : html`<p class="muted">${tx('tpl.no_columns_yet')}</p>`}
290
+ <div class="buttons"><button class="btn btn-hot">${tx('tpl.save_column_templates')}</button></div>
290
291
  </form>`;
291
292
  }
292
293
 
@@ -318,20 +319,20 @@ export async function templateRoutes(app: FastifyInstance) {
318
319
  try {
319
320
  doc = JSON.parse(req.body?.plugin ?? '');
320
321
  } catch {
321
- flash(s, 'Import failed: the plug-in file is not valid JSON.', 'error');
322
+ flash(s, tx('tpl.import_failed_the_plug_in'), 'error');
322
323
  return back(reply, s, `${BASE}/apps/${id}/shared?new=template_component`);
323
324
  }
324
325
  const c = parsePlugin(doc);
325
326
  if (typeof c === 'string') {
326
- flash(s, `Import failed: ${c}`, 'error');
327
+ flash(s, tx('tpl.import_failed', { error: c }), 'error');
327
328
  return back(reply, s, `${BASE}/apps/${id}/shared?new=template_component`);
328
329
  }
329
330
  try {
330
331
  const r = await owner.one('select meta.import_template_component($1, $2::jsonb, $3) as id', [id, JSON.stringify(pluginDocument(c)), req.body?.replace === 'true']);
331
- flash(s, `Template component ${c.name} imported.`);
332
+ flash(s, tx('tpl.imported', { name: c.name }));
332
333
  return back(reply, s, `${BASE}/apps/${id}/shared?c=template_component-${r.id}`);
333
334
  } catch (e) {
334
- flash(s, `Import failed: ${(e as Error).message}`, 'error');
335
+ flash(s, tx('tpl.import_failed', { error: (e as Error).message }), 'error');
335
336
  return back(reply, s, `${BASE}/apps/${id}/shared?new=template_component`);
336
337
  }
337
338
  });
@@ -349,7 +350,7 @@ export async function templateRoutes(app: FastifyInstance) {
349
350
  flash(s, `${c.name} copied: this application's ${c.static_id} now replaces the built-in one.`);
350
351
  return back(reply, s, `${BASE}/apps/${id}/shared?c=template_component-${r.id}`);
351
352
  } catch (e) {
352
- flash(s, `Copy failed: ${(e as Error).message}`, 'error');
353
+ flash(s, tx('tpl.copy_failed', { error: (e as Error).message }), 'error');
353
354
  return back(reply, s, `${BASE}/apps/${id}/shared?new=template_component`);
354
355
  }
355
356
  });
@@ -371,7 +372,7 @@ export async function templateRoutes(app: FastifyInstance) {
371
372
  components: await appComponents(r.app_id),
372
373
  });
373
374
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
374
- flash(s, 'Template component settings saved.');
375
+ flash(s, tx('tpl.template_component_settings_saved'));
375
376
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
376
377
  });
377
378
 
@@ -386,7 +387,7 @@ export async function templateRoutes(app: FastifyInstance) {
386
387
  const names = 'columns' in cols ? cols.columns : Object.keys(r.config?.column_templates ?? {});
387
388
  const config = mergeColumnTemplates(r.config ?? {}, (req.body ?? {}) as Body, names, await appComponents(r.app_id));
388
389
  await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
389
- flash(s, 'Column templates saved.');
390
+ flash(s, tx('tpl.column_templates_saved'));
390
391
  return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
391
392
  });
392
393
  }