pgkiln 0.84.0 → 0.86.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/db/migrations/155_mail_available.sql +23 -0
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/04-pages-and-regions.md +1 -1
- package/docs/guide/06-processing.md +7 -3
- package/docs/guide/07-dynamic-actions.md +3 -3
- package/docs/guide/09-reference.md +1 -0
- package/docs/guide/12-development.md +10 -1
- package/examples/gallery/README.md +23 -6
- package/examples/gallery/gallery.json +4 -0
- package/examples/gallery/samples/sample-approvals.sql +102 -11
- package/examples/gallery/samples/sample-calendar.sql +40 -5
- package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
- package/examples/gallery/samples/sample-grids.sql +35 -3
- package/examples/gallery/samples/sample-maps.sql +65 -7
- package/examples/gallery/samples/sample-processes.sql +466 -0
- package/examples/gallery/samples/sample-validations.sql +407 -0
- package/examples/gallery/samples/ux-patterns.sql +154 -14
- package/examples/hr/README.md +2 -0
- package/examples/hr/hr_80_notifications.sql +125 -0
- package/examples/hr/hr_81_vector_search_type.sql +33 -0
- package/package.json +1 -1
- package/public/app.css +8 -1
- package/public/app.js +28 -9
- package/src/app.ts +9 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3453 -0
- package/src/builder/i18n/en.ts +3453 -0
- package/src/builder/i18n/es.ts +3453 -0
- package/src/builder/i18n/fr.ts +3453 -0
- package/src/builder/i18n/it.ts +3453 -0
- package/src/builder/i18n/ja.ts +3453 -0
- package/src/builder/i18n/ko.ts +3453 -0
- package/src/builder/i18n/nl.ts +3453 -0
- package/src/builder/i18n/pt.ts +3453 -0
- package/src/builder/i18n/zh-cn.ts +3453 -0
- package/src/builder/i18n/zh-tw.ts +3453 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +27 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/runtime/engine.ts +6 -3
- package/src/runtime/lists.ts +2 -2
- package/src/runtime/push.ts +7 -1
- package/src/runtime/regions.ts +9 -4
package/src/builder/telemetry.ts
CHANGED
|
@@ -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?][]
|
|
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"
|
|
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">
|
|
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, '
|
|
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"
|
|
65
|
-
<p class="muted u-mt0">
|
|
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('
|
|
72
|
-
${check('enabled', '
|
|
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', '
|
|
75
|
-
${input('service_name', '
|
|
76
|
-
${input('sample', '
|
|
77
|
-
${select('user', '
|
|
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, '
|
|
80
|
-
<fieldset class="field"><legend class="label"
|
|
81
|
-
${check('traces', '
|
|
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', '
|
|
84
|
-
${check('builder', '
|
|
85
|
-
${check('browser', '
|
|
86
|
-
${check('prometheus', '
|
|
87
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
88
|
-
<form method="post" action="${BASE}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn"
|
|
89
|
-
${region('
|
|
90
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
91
|
-
<tr><td data-label="
|
|
92
|
-
${workspaces.filter((w) => w.telemetry === 'own').map((w) => html`<tr><td data-label="
|
|
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('
|
|
95
|
-
A scraper sends <code>Authorization: Bearer <token></code>. ${t.prometheusTokenHash ? (t.origin.metrics_token_hash === 'builder' || !process.env.PGKILN_METRICS_TOKEN ? 'A token is set.' : '
|
|
96
|
-
${token ? html`<div class="alert alert-success" role="status"
|
|
97
|
-
<form method="post" action="${BASE}/telemetry/token" class="buttons">${csrf(s)}<button class="btn" name="action" value="new">${t.prometheusTokenHash ? '
|
|
98
|
-
${t.prometheusTokenHash ? html`<button class="btn btn-danger" name="action" value="remove" data-confirm="
|
|
99
|
-
${region('
|
|
100
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
101
|
-
${workspaces.length ? workspaces.map((w) => html`<tr><td data-label="
|
|
102
|
-
<td data-label="
|
|
103
|
-
: html`<tr><td colspan="3" class="empty"
|
|
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 <token></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, '
|
|
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 ?? '
|
|
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 ?
|
|
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, '
|
|
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, '
|
|
151
|
-
html`<h1 class="u-mb1"
|
|
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"
|
|
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('
|
|
170
|
-
<fieldset class="field"><legend class="label"
|
|
171
|
-
${radio('instance', '
|
|
172
|
-
${radio('off', '
|
|
173
|
-
${radio('own', '
|
|
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', '
|
|
176
|
-
${headersField(names.length ? names : w.has_headers ? ['
|
|
177
|
-
${check('values', '
|
|
178
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
179
|
-
${w.telemetry === 'own' ? html`<form method="post" action="${BASE}/workspaces/${w.id}/telemetry/test" class="u-mt1">${csrf(s)}<button class="btn"
|
|
180
|
-
${w.telemetry === 'own' ? region('
|
|
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}`, [['
|
|
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 ?? '
|
|
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) : '
|
|
203
|
-
flash(s, problem ?
|
|
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
|
}
|
package/src/builder/templates.ts
CHANGED
|
@@ -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 : ['
|
|
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
|
|
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
|
|
81
|
-
${attrs.map((a) => html`<tr><td data-label="
|
|
82
|
-
<td data-label="
|
|
83
|
-
<td data-label="
|
|
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"
|
|
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
|
|
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"
|
|
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"
|
|
94
|
-
<label class="check"><input type="checkbox" name="multiple" value="1"${multiple ? raw(' checked') : ''}>
|
|
95
|
-
<div class="buttons"><button class="btn"
|
|
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="
|
|
98
|
+
<div class="tc-preview" aria-label="${tx('tpl.preview')}">${previewHtml(c, sample, multiple)}</div>
|
|
98
99
|
</fieldset>
|
|
99
|
-
<fieldset class="prop-group"><legend
|
|
100
|
-
<p class="muted u-mt0"
|
|
101
|
-
<div class="buttons"><a class="btn" href="${BASE}/apps/${appId}/template-components/${row.id}/export" download
|
|
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
|
|
108
|
-
<p class="muted u-mt0"
|
|
109
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
110
|
-
${BUILTIN_COMPONENTS.map((c) => html`<tr><td data-label="
|
|
111
|
-
<td data-label="
|
|
112
|
-
<button class="btn btn-sm"
|
|
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
|
|
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"
|
|
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"
|
|
119
|
-
<label class="check"><input type="checkbox" name="replace" value="true">
|
|
120
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
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"
|
|
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"
|
|
200
|
-
<p class="muted u-mt0"
|
|
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"
|
|
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
|
|
206
|
-
<div class="field"><label class="label" for="${id('component')}"
|
|
207
|
-
<select id="${id('component')}" name="component">${opt('', '
|
|
208
|
-
<small class="help"
|
|
209
|
-
<div class="field"><label class="label" for="${id('display')}"
|
|
210
|
-
<select id="${id('display')}" name="display">${opt('', '
|
|
211
|
-
${c && !c.wrapper && cfg.display === 'multiple' ? html`<small class="help">${
|
|
212
|
-
<div class="field"><label class="label" for="${id('max_rows')}"
|
|
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')}"
|
|
215
|
-
<input id="${id('empty')}" name="empty" value="${cfg.empty ?? ''}" placeholder="
|
|
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
|
|
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
|
|
221
|
-
<div class="field"><label class="label" for="${id('link_page')}"
|
|
222
|
-
<select id="${id('link_page')}" name="link_page">${opt('', '
|
|
223
|
-
<small class="help"
|
|
224
|
-
<div class="field" data-wide><label class="label" for="${id('link_items')}"
|
|
225
|
-
<input id="${id('link_items')}" name="link_items" value="${linkItemsText(cfg.link?.items)}" placeholder="
|
|
226
|
-
<small class="help"
|
|
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"
|
|
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="
|
|
278
|
-
<td data-label="
|
|
279
|
-
<td data-label="
|
|
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"
|
|
283
|
-
<p class="muted u-mt0"
|
|
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
|
|
288
|
-
: html`<p class="muted"
|
|
289
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
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, '
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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, '
|
|
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, '
|
|
390
|
+
flash(s, tx('tpl.column_templates_saved'));
|
|
390
391
|
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
391
392
|
});
|
|
392
393
|
}
|