pgkiln 0.84.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.
- package/docs/apex-feature-parity.md +2 -2
- package/docs/guide/03-builder.md +14 -5
- package/docs/guide/12-development.md +6 -1
- package/package.json +1 -1
- 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/builder/search.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { html, type Raw } from '../html.ts';
|
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { COMPONENTS, type FieldKind } from './components.ts';
|
|
6
6
|
import { appHeader, BASE, designerUrl, developer, region, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
7
8
|
|
|
8
9
|
// App Builder → Search and "Where used": every component of an application
|
|
9
10
|
// (pages, regions, items, buttons, dynamic actions, validations, processes
|
|
@@ -184,8 +185,8 @@ export function targetOf(kind: string, row: any): Target | null {
|
|
|
184
185
|
|
|
185
186
|
function hitList(hits: Hit[], showPage = true) {
|
|
186
187
|
return html`<ul class="hits">${hits.map((h) => html`<li>
|
|
187
|
-
<a href="${h.entry.url}">${icon(h.entry.icon)}<span>${h.entry.kindLabel}: ${h.entry.label}</span></a>
|
|
188
|
-
${showPage && h.entry.pageNo !== null && h.entry.kind !== 'page' ? html`<span class="tag"
|
|
188
|
+
<a href="${h.entry.url}">${icon(h.entry.icon)}<span>${tx.dyn(`comp.${h.entry.kind}`, h.entry.kindLabel)}: ${h.entry.label}</span></a>
|
|
189
|
+
${showPage && h.entry.pageNo !== null && h.entry.kind !== 'page' ? html`<span class="tag">${tx('srch.page_tag', { n: h.entry.pageNo })}</span>` : ''}
|
|
189
190
|
<span class="muted">${h.field}</span>
|
|
190
191
|
<code class="hit-snippet">${h.snippet}</code>
|
|
191
192
|
</li>`)}</ul>`;
|
|
@@ -197,9 +198,9 @@ export async function usedInPanel(appId: number, kind: string, row: any): Promis
|
|
|
197
198
|
if (!target) return '';
|
|
198
199
|
const hits = whereUsed(await appEntries(appId), target, { kind, id: row.id });
|
|
199
200
|
return html`<section class="used-in" aria-labelledby="used-in-${kind}-${row.id}">
|
|
200
|
-
<h3 id="used-in-${kind}-${row.id}"
|
|
201
|
-
${hits.length ? hitList(hits) : html`<p class="muted"
|
|
202
|
-
${target.type === 'item' || target.type === 'page' ? html`<p class="muted small"
|
|
201
|
+
<h3 id="used-in-${kind}-${row.id}">${tx('srch.used_in', { n: hits.length })}</h3>
|
|
202
|
+
${hits.length ? hitList(hits) : html`<p class="muted">${tx(target.type === 'item' ? 'srch.not_referenced_item' : 'srch.not_referenced')}</p>`}
|
|
203
|
+
${target.type === 'item' || target.type === 'page' ? html`<p class="muted small">${tx('srch.found_in')}</p>` : ''}
|
|
203
204
|
</section>`;
|
|
204
205
|
}
|
|
205
206
|
|
|
@@ -214,16 +215,16 @@ export async function searchRoutes(app: FastifyInstance) {
|
|
|
214
215
|
const groups = new Map<string, Hit[]>();
|
|
215
216
|
for (const h of hits) groups.set(h.entry.kindLabel, [...(groups.get(h.entry.kindLabel) ?? []), h]);
|
|
216
217
|
const main = html`${appHeader(a, 'search', s)}
|
|
217
|
-
${region('
|
|
218
|
-
<input type="search" name="q" value="${q}" placeholder="
|
|
219
|
-
<button class="btn btn-hot"
|
|
218
|
+
${region(tx('common.search'), html`<form method="get" action="${BASE}/apps/${a.id}/search" class="search u-mwnone" role="search">
|
|
219
|
+
<input type="search" name="q" value="${q}" placeholder="${tx('srch.placeholder')}" aria-label="${tx('srch.aria')}" autofocus>
|
|
220
|
+
<button class="btn btn-hot">${tx('common.search')}</button></form>
|
|
220
221
|
${q.trim()
|
|
221
222
|
? hits.length
|
|
222
|
-
? html`<p class="muted">${hits.length
|
|
223
|
-
${[...groups].map(([label, list]) => html`<h3>${label} (${list.length})</h3>${hitList(list)}`)}`
|
|
224
|
-
: html`<p class="muted"
|
|
225
|
-
: html`<p class="muted"
|
|
226
|
-
return send(reply, s, shell(s,
|
|
223
|
+
? html`<p class="muted">${tx(hits.length >= 500 ? 'srch.matches_more' : 'srch.matches', { n: hits.length })}</p>
|
|
224
|
+
${[...groups].map(([label, list]) => html`<h3>${tx.dyn(`comp.${list[0].entry.kind}`, label)} (${list.length})</h3>${hitList(list)}`)}`
|
|
225
|
+
: html`<p class="muted">${tx('srch.nothing', { q })}</p>`
|
|
226
|
+
: html`<p class="muted">${tx('srch.intro', { name: a.name })}</p>`}`)}`;
|
|
227
|
+
return send(reply, s, shell(s, tx('srch.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('common.search')]], main));
|
|
227
228
|
});
|
|
228
229
|
}
|
|
229
230
|
|
|
@@ -8,6 +8,7 @@ import type { Session } from '../session.ts';
|
|
|
8
8
|
import { WORKSPACE_FILES } from '../metadata.ts';
|
|
9
9
|
import { appOr404 } from './forms.ts';
|
|
10
10
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
|
|
11
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
11
12
|
|
|
12
13
|
// Shared Components → Static application files (APEX: Static Application
|
|
13
14
|
// Files): upload, write and remove an application's JavaScript, CSS, images
|
|
@@ -56,48 +57,46 @@ async function page(s: Session, a: any, query: Record<string, string | undefined
|
|
|
56
57
|
const everyPage = new Set<string>(a.static_includes ?? []);
|
|
57
58
|
|
|
58
59
|
const editor = editName || creating
|
|
59
|
-
? region(editName ?
|
|
60
|
+
? region(editName ? tx('sf.edit_title', { name: editName }) : tx('sf.new_file'), html`<form method="post" action="${base}/save">${csrf(s)}
|
|
60
61
|
${editName ? html`<input type="hidden" name="original" value="${editName}">` : ''}
|
|
61
|
-
<div class="form-grid">${input('name', '
|
|
62
|
-
<div class="field" data-wide><label class="label" for="f_content"
|
|
62
|
+
<div class="form-grid">${input('name', tx('sf.file_name'), editName ?? '', { required: true, placeholder: 'app.js', help: tx('sf.file_name_help') })}</div>
|
|
63
|
+
<div class="field" data-wide><label class="label" for="f_content">${tx('sf.content')}</label>
|
|
63
64
|
<textarea id="f_content" name="content" class="code" rows="18" spellcheck="false">${content}</textarea>
|
|
64
|
-
<small class="help"
|
|
65
|
-
<div class="buttons"><a class="btn" href="${base}"
|
|
65
|
+
<small class="help">${tx.code('sf.content_help')}</small></div>
|
|
66
|
+
<div class="buttons"><a class="btn" href="${base}">${tx('common.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('common.save')}</button></div>
|
|
66
67
|
</form>`)
|
|
67
68
|
: '';
|
|
68
69
|
|
|
69
70
|
return html`${appHeader(a, 'shared', s)}
|
|
70
71
|
${editor}
|
|
71
|
-
${region('
|
|
72
|
-
<code>/a/${a.alias}/static/<i>name</i></code> and exported with it. Pages load the scripts and stylesheets chosen below
|
|
73
|
-
(and those a page adds in its properties), so code runs from files, never inline. Up to ${size(STATIC_MAX)} per file; no HTML.</p>
|
|
72
|
+
${region(tx('sf.title'), html`<p class="muted u-mt0">${tx.rich('sf.intro', { path: html`<code>/a/${a.alias}/static/<i>name</i></code>`, max: size(STATIC_MAX) })}</p>
|
|
74
73
|
${files.length
|
|
75
|
-
? html`<div class="table-wrap"><table class="report"><caption class="sr-only"
|
|
76
|
-
<thead><tr><th scope="col"
|
|
74
|
+
? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${tx('sf.title')}</caption>
|
|
75
|
+
<thead><tr><th scope="col">${tx('sf.name')}</th><th scope="col">${tx('sf.type')}</th><th scope="col" class="num">${tx('sf.size')}</th><th scope="col">${tx('sf.loaded_by')}</th><th scope="col">${tx('sf.changed')}</th><th scope="col"><span class="sr-only">${tx('sf.actions')}</span></th></tr></thead>
|
|
77
76
|
<tbody>${files.map((f) => html`<tr>
|
|
78
77
|
<td><a href="/a/${a.alias}/static/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
|
|
79
78
|
<td>${f.mime}</td><td class="num">${size(Number(f.bytes))}</td>
|
|
80
|
-
<td>${includable(f.name) ? [everyPage.has(f.name) ? '
|
|
79
|
+
<td>${includable(f.name) ? [everyPage.has(f.name) ? tx('sf.every_page') : '', pagesOf(f.name).length ? tx('sf.pages', { pages: pagesOf(f.name).join(', ') }) : ''].filter(Boolean).join('; ') || html`<span class="muted">-</span>` : ''}</td>
|
|
81
80
|
<td>${f.changed}</td>
|
|
82
|
-
<td class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${base}?edit=${encodeURIComponent(f.name)}"
|
|
81
|
+
<td class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${base}?edit=${encodeURIComponent(f.name)}">${tx('sf.edit')}</a> ` : ''}
|
|
83
82
|
<form method="post" action="${base}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${f.name}">
|
|
84
|
-
<button class="btn btn-small btn-danger" data-confirm="
|
|
83
|
+
<button class="btn btn-small btn-danger" data-confirm="${tx('sf.delete_confirm', { name: f.name })}" aria-label="${tx('sf.delete_name', { name: f.name })}">${icon('trash')}</button></form></td>
|
|
85
84
|
</tr>`)}</tbody></table></div>`
|
|
86
|
-
: html`<p class="muted"
|
|
87
|
-
<div class="buttons u-mt1"><a class="btn" href="${base}?new=1">${icon('plus')}
|
|
85
|
+
: html`<p class="muted">${tx('sf.no_files')}</p>`}
|
|
86
|
+
<div class="buttons u-mt1"><a class="btn" href="${base}?new=1">${icon('plus')} ${tx('sf.new_file')}</a></div>
|
|
88
87
|
<form method="post" action="${base}/upload" enctype="multipart/form-data" class="search u-mwnone u-mt1">${csrf(s)}
|
|
89
|
-
<input type="file" name="file" multiple aria-label="
|
|
90
|
-
<button class="btn">${icon('upload')}
|
|
91
|
-
<small class="help"
|
|
88
|
+
<input type="file" name="file" multiple aria-label="${tx('sf.files_to_upload')}" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
|
|
89
|
+
<button class="btn">${icon('upload')} ${tx('sf.upload')}</button>
|
|
90
|
+
<small class="help">${tx('sf.replaced')}</small>
|
|
92
91
|
</form>`)}
|
|
93
|
-
${region('
|
|
94
|
-
<div class="form-grid">${input('includes', '
|
|
95
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
92
|
+
${region(tx('sf.every_page_loads'), html`<form method="post" action="${base}/includes">${csrf(s)}
|
|
93
|
+
<div class="form-grid">${input('includes', tx('sf.includes'), (a.static_includes ?? []).join(', '), { placeholder: tx('sf.includes_ph'), help: tx('sf.includes_help', { max: MAX_INCLUDES }) })}</div>
|
|
94
|
+
<div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div>
|
|
96
95
|
</form>`)}`;
|
|
97
96
|
}
|
|
98
97
|
|
|
99
98
|
export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
100
|
-
const title = (a: any) => [['
|
|
99
|
+
const title = (a: any) => [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.shared'), `${BASE}/apps/${a.id}/shared`], [tx('sf.title')]] as [string, string?][];
|
|
101
100
|
const find = async (id: string) => (/^\d+$/.test(id) ? appOr404(id) : undefined);
|
|
102
101
|
|
|
103
102
|
app.get(`${BASE}/apps/:id/static-files`, async (req: Req, reply) => {
|
|
@@ -105,7 +104,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
105
104
|
if (!s) return;
|
|
106
105
|
const a = await find(req.params.id);
|
|
107
106
|
if (!a) return reply.code(404).send('Not found');
|
|
108
|
-
return send(reply, s, shell(s, '
|
|
107
|
+
return send(reply, s, shell(s, tx('sf.title'), title(a), await page(s, a, req.query ?? {})));
|
|
109
108
|
});
|
|
110
109
|
|
|
111
110
|
app.post(`${BASE}/apps/:id/static-files/upload`, async (req: Req, reply) => {
|
|
@@ -121,15 +120,15 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
121
120
|
if (!a) return reply.code(404).send('Not found');
|
|
122
121
|
const target = `${BASE}/apps/${a.id}/static-files`;
|
|
123
122
|
if (!uploads.length) {
|
|
124
|
-
flash(s, '
|
|
123
|
+
flash(s, tx('sf.choose_files'), 'error');
|
|
125
124
|
return back(reply, s, target);
|
|
126
125
|
}
|
|
127
126
|
const saved: string[] = [];
|
|
128
127
|
for (const u of uploads) {
|
|
129
128
|
const name = u.filename.split(/[\\/]/).pop()!.trim();
|
|
130
129
|
const type = staticType(name);
|
|
131
|
-
if (u.truncated) flash(s,
|
|
132
|
-
else if (!type) flash(s,
|
|
130
|
+
if (u.truncated) flash(s, tx('sf.too_large_name', { name, max: size(STATIC_MAX) }), 'error');
|
|
131
|
+
else if (!type) flash(s, tx('sf.bad_type', { name, types: Object.keys(STATIC_TYPES).join(', ') }), 'error');
|
|
133
132
|
else {
|
|
134
133
|
await owner.query(
|
|
135
134
|
`insert into meta.static_file (app_id, name, mime, content) values ($1, $2, $3, $4)
|
|
@@ -138,7 +137,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
138
137
|
saved.push(name);
|
|
139
138
|
}
|
|
140
139
|
}
|
|
141
|
-
if (saved.length) flash(s,
|
|
140
|
+
if (saved.length) flash(s, tx('sf.uploaded', { names: saved.join(', ') }));
|
|
142
141
|
return back(reply, s, target);
|
|
143
142
|
});
|
|
144
143
|
|
|
@@ -154,11 +153,11 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
154
153
|
const content = Buffer.from(String(b.content ?? '').replace(/\r\n/g, '\n'), 'utf8');
|
|
155
154
|
const base = `${BASE}/apps/${a.id}/static-files`;
|
|
156
155
|
if (!type || !editable(name)) {
|
|
157
|
-
flash(s, '
|
|
156
|
+
flash(s, tx('sf.bad_text_name'), 'error');
|
|
158
157
|
return back(reply, s, original ? `${base}?edit=${encodeURIComponent(original)}` : `${base}?new=1`);
|
|
159
158
|
}
|
|
160
159
|
if (content.length > STATIC_MAX) {
|
|
161
|
-
flash(s,
|
|
160
|
+
flash(s, tx('sf.too_large', { max: size(STATIC_MAX) }), 'error');
|
|
162
161
|
return back(reply, s, base);
|
|
163
162
|
}
|
|
164
163
|
try {
|
|
@@ -172,7 +171,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
172
171
|
`insert into meta.static_file (app_id, name, mime, content) values ($1, $2, $3, $4)
|
|
173
172
|
on conflict (app_id, name) do update set mime = excluded.mime, content = excluded.content, updated_at = now()`,
|
|
174
173
|
[a.id, name, type, content]);
|
|
175
|
-
flash(s,
|
|
174
|
+
flash(s, tx('sf.saved_name', { name }));
|
|
176
175
|
return back(reply, s, `${base}?edit=${encodeURIComponent(name)}`);
|
|
177
176
|
} catch (e) {
|
|
178
177
|
flash(s, (e as Error).message, 'error');
|
|
@@ -187,7 +186,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
187
186
|
if (!a) return reply.code(404).send('Not found');
|
|
188
187
|
const name = String(req.body?.name ?? '');
|
|
189
188
|
const r = await owner.query('delete from meta.static_file where app_id = $1 and name = $2', [a.id, name]);
|
|
190
|
-
flash(s, r.rowCount ?
|
|
189
|
+
flash(s, r.rowCount ? tx('sf.deleted', { name }) : tx('sf.no_such_file'), r.rowCount ? 'ok' : 'error');
|
|
191
190
|
return back(reply, s, `${BASE}/apps/${a.id}/static-files`);
|
|
192
191
|
});
|
|
193
192
|
|
|
@@ -202,7 +201,7 @@ export async function staticFileBuilderRoutes(app: FastifyInstance) {
|
|
|
202
201
|
`select n from unnest($2::text[]) n
|
|
203
202
|
where not exists (select 1 from meta.static_file f where f.app_id = $1 and f.name = n)
|
|
204
203
|
and not exists (select 1 from meta.workspace_file w where w.workspace_id = meta.app_workspace($1) and $3 || w.name = n)`, [a.id, names, WORKSPACE_FILES])).rows.map((r) => r.n);
|
|
205
|
-
flash(s, missing.length ?
|
|
204
|
+
flash(s, missing.length ? tx('sf.saved_missing', { names: missing.join(', ') }) : tx('sf.saved'));
|
|
206
205
|
return back(reply, s, `${BASE}/apps/${a.id}/static-files`);
|
|
207
206
|
});
|
|
208
207
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
SubscriptionError, subscribeGroup, unsubscribe, type AppType, type Kind,
|
|
9
9
|
} from '../subscriptions.ts';
|
|
10
10
|
import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
|
|
11
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
11
12
|
|
|
12
13
|
// Shared Components → Subscriptions (APEX: subscribed shared components; 26.1
|
|
13
14
|
// theme and library applications). An application subscribes to a component
|
|
@@ -26,7 +27,9 @@ const TYPE_HELP: Record<AppType, string> = {
|
|
|
26
27
|
reporting: 'Made by App Builder → Data Reporter on datasets of the workspace: its users build, publish and share reports.',
|
|
27
28
|
};
|
|
28
29
|
|
|
29
|
-
const
|
|
30
|
+
const kindLabel = (kind: Kind) => tx.dyn(`sub.k.${kind}`, KINDS[kind].label);
|
|
31
|
+
const typeLabel = (t: AppType) => tx.dyn(`st.type.${t}`, APP_TYPE_LABELS[t] ?? t);
|
|
32
|
+
const what = (kind: Kind, name: string) => (kind === 'theme' ? tx('sub.theme') : tx('sub.what', { kind: kindLabel(kind), name }));
|
|
30
33
|
|
|
31
34
|
/** Another developer's application lock (page 0): such applications are not changed by publishing. */
|
|
32
35
|
const lockedByOther = (username: string) => async (appId: number) =>
|
|
@@ -40,16 +43,15 @@ export async function subscriptionNote(appId: number, kind: string, row: any, s:
|
|
|
40
43
|
const subscribers = (await owner.one<{ n: number }>('select count(*)::int as n from meta.subscription where master_app_id = $1 and kind = $2 and name = $3', [appId, kind, name]))!.n;
|
|
41
44
|
if (!sub && !subscribers) return '';
|
|
42
45
|
const hidden = html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
|
|
43
|
-
return html`<section class="used-in"><h3
|
|
46
|
+
return html`<section class="used-in"><h3>${tx('sub.subscription')}</h3>
|
|
44
47
|
${sub
|
|
45
|
-
? html`<p class="u-mt0"
|
|
46
|
-
|
|
47
|
-
<form method="post" action="${BASE}/apps/${appId}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">Refresh from ${sub.master_name}</button></form>`
|
|
48
|
+
? html`<p class="u-mt0">${((m) => (sub.refreshed_at ? tx.rich('sub.subscribed_from_at', { master: m, at: sub.refreshed_at.slice(0, 16) }) : tx.rich('sub.subscribed_from', { master: m })))(html`<a href="${BASE}/apps/${sub.master_app_id}/subscriptions">${sub.master_name}</a>`)}</p>
|
|
49
|
+
<form method="post" action="${BASE}/apps/${appId}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">${tx('sub.refresh_from', { master: sub.master_name })}</button></form>`
|
|
48
50
|
: ''}
|
|
49
51
|
${subscribers
|
|
50
|
-
? html`<p>${subscribers
|
|
51
|
-
<form method="post" action="${BASE}/apps/${appId}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm"
|
|
52
|
-
<a class="btn btn-sm" href="${BASE}/apps/${appId}/subscriptions"
|
|
52
|
+
? html`<p>${subscribers === 1 ? tx('sub.subscribers_1', { kind: kindLabel(kind).toLowerCase() }) : tx('sub.subscribers_n', { n: subscribers, kind: kindLabel(kind).toLowerCase() })}</p>
|
|
53
|
+
<form method="post" action="${BASE}/apps/${appId}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden}<button class="btn btn-sm">${tx('sub.publish_to_subscribers')}</button></form>
|
|
54
|
+
<a class="btn btn-sm" href="${BASE}/apps/${appId}/subscriptions">${tx('sub.subscribers')}</a>`
|
|
53
55
|
: ''}
|
|
54
56
|
</section>`;
|
|
55
57
|
}
|
|
@@ -64,61 +66,59 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
64
66
|
const sync = await Promise.all(subs.map((x) => inSync(x)));
|
|
65
67
|
const subSync = await Promise.all(subscribers.map((x) => inSync(x)));
|
|
66
68
|
const hidden = (kind: string, name: string) => html`<input type="hidden" name="kind" value="${kind}"><input type="hidden" name="name" value="${name}">`;
|
|
67
|
-
const state = (ok: boolean) => (ok ? html`<span class="badge"
|
|
69
|
+
const state = (ok: boolean) => (ok ? html`<span class="badge">${tx('sub.in_sync')}</span>` : html`<span class="badge badge-warn">differs</span>`);
|
|
68
70
|
const groups = new Map<number, typeof offered>();
|
|
69
71
|
for (const o of offered) groups.set(o.master.id, [...(groups.get(o.master.id) ?? []), o]);
|
|
70
72
|
const published = [...new Map(subscribers.map((x) => [`${x.kind}|${x.name}`, x])).values()];
|
|
71
73
|
|
|
72
74
|
const main = html`${appHeader(a, 'shared', s)}<div class="ide-body">
|
|
73
|
-
${region('
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
<p class="muted u-mt0">Components of this application that are copies of a component in a theme or library application.
|
|
77
|
-
Refreshing copies the master's definition again, replacing changes made here.</p>
|
|
75
|
+
${region(tx('sub.application_type'), html`<p class="u-mt0">${tx.rich('sub.type_line', { type: html`<b>${typeLabel(a.app_type as AppType)}</b>`, help: tx.dyn(`sub.th.${a.app_type}`, TYPE_HELP[a.app_type as AppType] ?? ''), settings: html`<a href="${BASE}/apps/${a.id}/settings">${tx('sub.settings')}</a>` })}</p>`)}
|
|
76
|
+
${region(tx('sub.subscriptions'), html`
|
|
77
|
+
<p class="muted u-mt0">${tx('sub.components_of_this_application_that')}</p>
|
|
78
78
|
${subs.length
|
|
79
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th
|
|
79
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('sub.component')}</th><th>${tx('sub.from')}</th><th>${tx('sub.refreshed')}</th><th>${tx('sub.state')}</th><th>${tx('sub.actions')}</th></tr></thead><tbody>
|
|
80
80
|
${subs.map((x, i) => html`<tr><td>${what(x.kind, x.name)}</td><td><a href="${BASE}/apps/${x.master_app_id}/subscriptions">${x.master_name}</a></td>
|
|
81
81
|
<td>${x.refreshed_at ? x.refreshed_at.slice(0, 16) : '—'}</td><td>${state(sync[i])}</td>
|
|
82
|
-
<td><form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm"
|
|
83
|
-
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/unsubscribe" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm" data-confirm="
|
|
82
|
+
<td><form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm">${tx('sub.refresh')}</button></form>
|
|
83
|
+
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/unsubscribe" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}<button class="btn btn-sm" data-confirm="${tx('sub.unsubscribe_the_component_stays_as')}">${tx('sub.unsubscribe')}</button></form></td></tr>`)}
|
|
84
84
|
</tbody></table></div>
|
|
85
|
-
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh">${csrf(s)}<div class="buttons"><button class="btn"
|
|
86
|
-
: html`<p class="muted"
|
|
87
|
-
<h3
|
|
85
|
+
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/refresh">${csrf(s)}<div class="buttons"><button class="btn">${tx('sub.refresh_all')}</button></div></form>`
|
|
86
|
+
: html`<p class="muted">${tx('sub.no_subscriptions')}</p>`}
|
|
87
|
+
<h3>${tx('sub.subscribe')}</h3>
|
|
88
88
|
${offered.length
|
|
89
89
|
? html`<form method="post" action="${BASE}/apps/${a.id}/subscriptions">${csrf(s)}
|
|
90
|
-
<div class="field"><label class="label" for="f_component"
|
|
91
|
-
<select id="f_component" name="component" required><option value=""
|
|
92
|
-
${[...groups.values()].map((list) => html`<optgroup label="${list[0].master.name} (${
|
|
90
|
+
<div class="field"><label class="label" for="f_component">${tx('sub.component')}</label>
|
|
91
|
+
<select id="f_component" name="component" required><option value="">${tx('sub.choose_dash')}</option>
|
|
92
|
+
${[...groups.values()].map((list) => html`<optgroup label="${list[0].master.name} (${typeLabel(list[0].master.app_type)})">
|
|
93
93
|
${list.map((o) => html`<option value="${o.master.id}|${o.kind}|${o.name}">${what(o.kind, o.name)}</option>`)}</optgroup>`)}
|
|
94
94
|
</select>
|
|
95
|
-
<small class="help"
|
|
96
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
97
|
-
: html`<p class="muted"
|
|
95
|
+
<small class="help">${tx('sub.the_component_is_copied_now')}</small></div>
|
|
96
|
+
<div class="buttons"><button class="btn btn-hot">${tx('sub.subscribe')}</button></div></form>`
|
|
97
|
+
: html`<p class="muted">${tx('sub.no_theme_or_library_applications')}</p>`}
|
|
98
98
|
${groupList.length
|
|
99
|
-
? html`<h3
|
|
99
|
+
? html`<h3>${tx('sub.subscribe_to_a_component_group')}</h3>
|
|
100
100
|
<form method="post" action="${BASE}/apps/${a.id}/subscriptions/group">${csrf(s)}
|
|
101
|
-
<div class="field"><label class="label" for="f_group"
|
|
102
|
-
<select id="f_group" name="group" required><option value=""
|
|
101
|
+
<div class="field"><label class="label" for="f_group">${tx('sub.component_group')}</label>
|
|
102
|
+
<select id="f_group" name="group" required><option value="">${tx('sub.choose_dash')}</option>
|
|
103
103
|
${groupList.map((g) => html`<option value="${g.master_id}|${g.name}">${g.master_name}: ${g.name} (${g.members.length})</option>`)}
|
|
104
104
|
</select>
|
|
105
|
-
<small class="help"
|
|
106
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
105
|
+
<small class="help">${tx('sub.every_component_of_the_group')}</small></div>
|
|
106
|
+
<div class="buttons"><button class="btn btn-hot">${tx('sub.subscribe_to_the_group')}</button></div></form>`
|
|
107
107
|
: ''}`)}
|
|
108
108
|
${a.app_type === 'theme' || a.app_type === 'library' || subscribers.length
|
|
109
|
-
? region('
|
|
110
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th
|
|
109
|
+
? region(tx('sub.subscribers_2'), subscribers.length
|
|
110
|
+
? html`<div class="table-wrap"><table class="report"><thead><tr><th>${tx('sub.component')}</th><th>${tx('sub.application')}</th><th>${tx('sub.refreshed')}</th><th>${tx('sub.state')}</th></tr></thead><tbody>
|
|
111
111
|
${subscribers.map((x, i) => html`<tr><td>${what(x.kind, x.name)}</td><td><a href="${BASE}/apps/${x.app_id}/subscriptions">${x.app_name}</a></td>
|
|
112
112
|
<td>${x.refreshed_at ? x.refreshed_at.slice(0, 16) : '—'}</td><td>${state(subSync[i])}</td></tr>`)}
|
|
113
113
|
</tbody></table></div>
|
|
114
|
-
<h3
|
|
115
|
-
<p class="muted u-mt0"
|
|
114
|
+
<h3>${tx('sub.publish')}</h3>
|
|
115
|
+
<p class="muted u-mt0">${tx('sub.publishing_refreshes_every_subscriber_of')}</p>
|
|
116
116
|
<div class="buttons publish-buttons">${published.map((x) => html`<form method="post" action="${BASE}/apps/${a.id}/subscriptions/publish" class="u-inline">${csrf(s)}${hidden(x.kind, x.name)}
|
|
117
117
|
<button class="btn btn-sm">${icon('upload')} ${what(x.kind, x.name)}</button></form>`)}</div>`
|
|
118
|
-
: html`<p class="muted"
|
|
118
|
+
: html`<p class="muted">${tx('sub.no_application_subscribes_to_this')}</p>`)
|
|
119
119
|
: ''}
|
|
120
120
|
</div>`;
|
|
121
|
-
return send(reply, s, shell(s,
|
|
121
|
+
return send(reply, s, shell(s, tx('sub.title', { app: a.name }), [[tx('sub.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('sub.shared_components'), `${BASE}/apps/${a.id}/shared`], [tx('sub.subscriptions')]], main));
|
|
122
122
|
});
|
|
123
123
|
|
|
124
124
|
const target = (id: number) => `${BASE}/apps/${id}/subscriptions`;
|
|
@@ -142,10 +142,10 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
142
142
|
const v = String(req.body?.component ?? '');
|
|
143
143
|
const m = /^(\d{1,9})\|([a-z_]+)\|(.*)$/s.exec(v);
|
|
144
144
|
return handle(req, reply, s, a.id, async () => {
|
|
145
|
-
if (!m) throw new SubscriptionError('
|
|
145
|
+
if (!m) throw new SubscriptionError(tx('sub.choose_component'));
|
|
146
146
|
await subscribe(a.id, Number(m[1]), m[2], m[3], s.username!);
|
|
147
147
|
await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed ${m[2]} ${m[3]} from application ${m[1]}` });
|
|
148
|
-
return
|
|
148
|
+
return tx('sub.subscribed', { what: isKind(m[2]) ? what(m[2], m[3]) : m[2] });
|
|
149
149
|
});
|
|
150
150
|
});
|
|
151
151
|
|
|
@@ -157,11 +157,12 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
157
157
|
if (!a) return reply.code(404).send('Not found');
|
|
158
158
|
const m = /^(\d{1,9})\|(.{1,60})$/s.exec(String(req.body?.group ?? ''));
|
|
159
159
|
return handle(req, reply, s, a.id, async () => {
|
|
160
|
-
if (!m) throw new SubscriptionError('
|
|
161
|
-
if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError('
|
|
160
|
+
if (!m) throw new SubscriptionError(tx('sub.choose_group'));
|
|
161
|
+
if (!(await groupsOffered(a.id)).some((g) => g.master_id === Number(m[1]) && g.name === m[2])) throw new SubscriptionError(tx('sub.choose_group'));
|
|
162
162
|
const { done, skipped } = await subscribeGroup(a.id, Number(m[1]), m[2], s.username!);
|
|
163
163
|
await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `subscribed component group ${m[2]} from application ${m[1]}: ${done.join(', ')}` });
|
|
164
|
-
|
|
164
|
+
const one = done.length === 1;
|
|
165
|
+
return skipped.length ? tx(one ? 'sub.subscribed_group_skipped_1' : 'sub.subscribed_group_skipped', { group: m[2], n: done.length, skipped: skipped.join(', ') }) : tx(one ? 'sub.subscribed_group_1' : 'sub.subscribed_group', { group: m[2], n: done.length });
|
|
165
166
|
});
|
|
166
167
|
});
|
|
167
168
|
|
|
@@ -185,7 +186,7 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
185
186
|
const a = await appRow(req.params.id);
|
|
186
187
|
if (!a) return reply.code(404).send('Not found');
|
|
187
188
|
return handle(req, reply, s, a.id, async () => {
|
|
188
|
-
if (!(await unsubscribe(a.id, String(req.body?.kind ?? ''), String(req.body?.name ?? '')))) throw new SubscriptionError('
|
|
189
|
+
if (!(await unsubscribe(a.id, String(req.body?.kind ?? ''), String(req.body?.name ?? '')))) throw new SubscriptionError(tx('sub.not_subscribed'));
|
|
189
190
|
return 'Unsubscribed: the component stays as it is.';
|
|
190
191
|
});
|
|
191
192
|
});
|
|
@@ -199,8 +200,9 @@ export async function subscriptionRoutes(app: FastifyInstance) {
|
|
|
199
200
|
return handle(req, reply, s, a.id, async () => {
|
|
200
201
|
const r = await publish(a.id, kind, name, s.username!, lockedByOther(s.username!));
|
|
201
202
|
await logActivity({ appId: a.id, username: s.username, event: 'subscription', ip: clientIp(req), detail: `published ${kind} ${name} to ${r.done.length} applications${r.skipped.length ? `, skipped locked ${r.skipped.join(', ')}` : ''}` });
|
|
202
|
-
if (!r.done.length && !r.skipped.length) throw new SubscriptionError('
|
|
203
|
-
|
|
203
|
+
if (!r.done.length && !r.skipped.length) throw new SubscriptionError(tx('sub.no_subscriber'));
|
|
204
|
+
const one = r.done.length === 1;
|
|
205
|
+
return r.skipped.length ? tx(one ? 'sub.published_skipped_1' : 'sub.published_skipped', { n: r.done.length, skipped: r.skipped.length }) : tx(one ? 'sub.published_1' : 'sub.published', { n: r.done.length });
|
|
204
206
|
});
|
|
205
207
|
});
|
|
206
208
|
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { FastifyInstance } from 'fastify';
|
|
2
2
|
import { splitStatements } from '../binds.ts';
|
|
3
3
|
import { appTx, owner, type Client } from '../db.ts';
|
|
4
|
-
import { html, type Raw } from '../html.ts';
|
|
4
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
5
5
|
import { icon } from '../icons.ts';
|
|
6
6
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
7
7
|
import { clearCompletions } from './code-editor.ts';
|
|
8
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
8
9
|
import { appHeader, BASE, csrf, developer, region, send, shell, type Req } from './ui.ts';
|
|
9
10
|
import { appOr404 } from './forms.ts';
|
|
10
11
|
|
|
@@ -58,7 +59,7 @@ export async function runScripts(app: { id: number; alias: string; db_role: stri
|
|
|
58
59
|
const res = await c.query({ text: sql, rowMode: 'array' });
|
|
59
60
|
results.push({
|
|
60
61
|
script: s.name, sql, ok: true,
|
|
61
|
-
message:
|
|
62
|
+
message: res.rowCount !== null && res.rowCount !== undefined ? tx('sup.rows', { command: res.command ?? tx('sup.statement'), n: res.rowCount }) : (res.command ?? tx('sup.statement')),
|
|
62
63
|
...(res.fields?.length ? { fields: res.fields.map((f) => f.name), rows: (res.rows as unknown[][]).slice(0, SHOW_ROWS) } : {}),
|
|
63
64
|
});
|
|
64
65
|
} catch (e) {
|
|
@@ -76,12 +77,18 @@ export async function runScripts(app: { id: number; alias: string; db_role: stri
|
|
|
76
77
|
|
|
77
78
|
const cell = (v: unknown) => (v === null ? html`<span class="null">null</span>` : typeof v === 'object' ? JSON.stringify(v) : String(v));
|
|
78
79
|
|
|
79
|
-
|
|
80
|
+
const KIND_KEYS: Record<Kind, { heading: BuilderKey; run: BuilderKey; confirm: BuilderKey; result: BuilderKey; ran: BuilderKey; failed: BuilderKey }> = {
|
|
81
|
+
install: { heading: 'sup.h_install', run: 'sup.run_install', confirm: 'sup.confirm_install', result: 'sup.result_install', ran: 'sup.ran_install', failed: 'sup.failed_install' },
|
|
82
|
+
upgrade: { heading: 'sup.h_upgrade', run: 'sup.run_upgrade', confirm: 'sup.confirm_upgrade', result: 'sup.result_upgrade', ran: 'sup.ran_upgrade', failed: 'sup.failed_upgrade' },
|
|
83
|
+
deinstall: { heading: 'sup.h_deinstall', run: 'sup.run_deinstall', confirm: 'sup.confirm_deinstall', result: 'sup.result_deinstall', ran: 'sup.ran_deinstall', failed: 'sup.failed_deinstall' },
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export function resultsHtml(kind: Kind, r: RunResult): Raw {
|
|
80
87
|
return html`<div class="alert ${r.ok ? 'alert-success' : 'alert-error'}" role="status">${r.ok
|
|
81
|
-
?
|
|
82
|
-
:
|
|
88
|
+
? tx(KIND_KEYS[kind].ran, { n: r.results.length })
|
|
89
|
+
: tx(KIND_KEYS[kind].failed)}</div>
|
|
83
90
|
<ol class="support-results">${r.results.map((x) => html`<li class="${x.ok ? 'ok' : 'failed'}">
|
|
84
|
-
<div><b>${x.script}</b> <span class="${x.ok ? 'muted' : null}">${x.ok ? x.message :
|
|
91
|
+
<div><b>${x.script}</b> <span class="${x.ok ? 'muted' : null}">${x.ok ? x.message : tx('sup.error', { message: x.message })}</span></div>
|
|
85
92
|
${x.sql ? html`<pre class="source">${x.sql.length > 2000 ? `${x.sql.slice(0, 2000)}…` : x.sql}</pre>` : ''}
|
|
86
93
|
${x.fields ? html`<div class="table-wrap"><table class="report"><thead><tr>${x.fields.map((f) => html`<th>${f}</th>`)}</tr></thead>
|
|
87
94
|
<tbody>${(x.rows ?? []).map((row) => html`<tr>${row.map((v) => html`<td>${cell(v)}</td>`)}</tr>`)}</tbody></table></div>` : ''}
|
|
@@ -95,17 +102,15 @@ async function page(req: Req, appId: string, result?: { kind: Kind; run: RunResu
|
|
|
95
102
|
const imported = req.query?.imported === '1';
|
|
96
103
|
const byKind = (k: Kind) => scripts.filter((x) => x.kind === k);
|
|
97
104
|
return { a, body: (s: Session) => html`${appHeader(a, 'shared', s)}
|
|
98
|
-
${imported && scripts.length ? html`<div class="alert alert-warning" role="status">${icon('alert')}
|
|
99
|
-
${result ? region(
|
|
100
|
-
${region('
|
|
101
|
-
|
|
102
|
-
<a href="${BASE}/apps/${a.id}/shared?new=supporting_script">Add a script</a> under Shared Components.</p>
|
|
103
|
-
${SCRIPT_KINDS.map((k) => html`<h3>${k[0].toUpperCase() + k.slice(1)} scripts (${byKind(k).length})</h3>
|
|
105
|
+
${imported && scripts.length ? html`<div class="alert alert-warning" role="status">${icon('alert')} ${tx.rich('sup.imported', { b: raw('<b>'), b_end: raw('</b>') })}</div>` : ''}
|
|
106
|
+
${result ? region(tx(KIND_KEYS[result.kind].result), resultsHtml(result.kind, result.run)) : ''}
|
|
107
|
+
${region(tx('app.task.supporting'), html`<p class="muted u-mt0">${tx.rich('sup.intro', { role: html`<code>${a.db_role ?? tx('sup.no_role')}</code>`, add: html`<a href="${BASE}/apps/${a.id}/shared?new=supporting_script">${tx('sup.add_script')}</a>` })}</p>
|
|
108
|
+
${SCRIPT_KINDS.map((k) => html`<h3>${tx(KIND_KEYS[k].heading, { n: byKind(k).length })}</h3>
|
|
104
109
|
${byKind(k).length
|
|
105
|
-
? html`${byKind(k).map((x) => html`<details class="support-script"><summary><b>${x.seq}. ${x.name}</b> <a href="${BASE}/apps/${a.id}/shared?c=supporting_script-${x.id}"
|
|
110
|
+
? html`${byKind(k).map((x) => html`<details class="support-script"><summary><b>${x.seq}. ${x.name}</b> <a href="${BASE}/apps/${a.id}/shared?c=supporting_script-${x.id}">${tx('sup.edit')}</a></summary><pre class="source">${x.script}</pre></details>`)}
|
|
106
111
|
<form method="post" action="${BASE}/apps/${a.id}/supporting-objects/run" class="u-mt1">${csrf(s)}<input type="hidden" name="kind" value="${k}">
|
|
107
|
-
<button class="btn${k === 'deinstall' ? ' btn-danger' : ''}" data-confirm="
|
|
108
|
-
: html`<p class="muted"
|
|
112
|
+
<button class="btn${k === 'deinstall' ? ' btn-danger' : ''}" data-confirm="${tx(KIND_KEYS[k].confirm, { app: a.name, role: a.db_role ?? tx('sup.runtime_connection') })}">${icon('play')} ${tx(KIND_KEYS[k].run)}</button></form>`
|
|
113
|
+
: html`<p class="muted">${tx('sup.none')}</p>`}`)}`)}` };
|
|
109
114
|
}
|
|
110
115
|
|
|
111
116
|
export async function supportingRoutes(app: FastifyInstance) {
|
|
@@ -114,7 +119,7 @@ export async function supportingRoutes(app: FastifyInstance) {
|
|
|
114
119
|
if (!s) return;
|
|
115
120
|
const p = await page(req, req.params.id);
|
|
116
121
|
if (!p) return reply.code(404).send('Not found');
|
|
117
|
-
return send(reply, s, shell(s, '
|
|
122
|
+
return send(reply, s, shell(s, tx('app.task.supporting'), [[tx('rail.app_builder'), BASE], [p.a.name, `${BASE}/apps/${p.a.id}`], [tx('app.task.supporting')]], p.body(s)));
|
|
118
123
|
});
|
|
119
124
|
|
|
120
125
|
app.post(`${BASE}/apps/:id/supporting-objects/run`, async (req: Req, reply) => {
|
|
@@ -123,11 +128,11 @@ export async function supportingRoutes(app: FastifyInstance) {
|
|
|
123
128
|
const a = await appOr404(req.params.id);
|
|
124
129
|
if (!a) return reply.code(404).send('Not found');
|
|
125
130
|
const kind = req.body?.kind as Kind;
|
|
126
|
-
if (!(SCRIPT_KINDS as readonly string[]).includes(kind)) return reply.code(400).send('
|
|
131
|
+
if (!(SCRIPT_KINDS as readonly string[]).includes(kind)) return reply.code(400).send(tx('sup.unknown_kind'));
|
|
127
132
|
const run = await runSupportingScripts(a, kind, s.username!);
|
|
128
133
|
clearCompletions(); // the scripts may have changed tables or grants
|
|
129
134
|
await logActivity({ appId: a.id, username: s.username, event: 'supporting_objects', ip: clientIp(req), detail: `builder: ${kind} ${run.ok ? 'ran' : 'failed'} (${run.results.length} statement(s))` });
|
|
130
135
|
const p = (await page(req, req.params.id, { kind, run }))!;
|
|
131
|
-
return send(reply, s, shell(s, '
|
|
136
|
+
return send(reply, s, shell(s, tx('app.task.supporting'), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.task.supporting')]], p.body(s)));
|
|
132
137
|
});
|
|
133
138
|
}
|