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/assistant.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { assistantProblems, type AssistantConfig } from '../runtime/assistant.ts
|
|
|
7
7
|
import type { Session } from '../session.ts';
|
|
8
8
|
import { agentOptions } from './ai-agents.ts';
|
|
9
9
|
import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
|
|
10
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
10
11
|
|
|
11
12
|
// Page designer → an AI assistant region → Settings (service, prompts,
|
|
12
13
|
// context queries, tools, limits), and → a report region → "Ask in your own
|
|
@@ -29,7 +30,7 @@ async function serviceOptions(appId: number, current: unknown) {
|
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
32
|
|
|
32
|
-
const NO_SERVICE = html`<p class="muted"
|
|
33
|
+
const NO_SERVICE = () => html`<p class="muted">${tx.rich('asst.no_service', { link: html`<a href="${BASE}/ai">${tx('asst.workspace_utilities_ai_services')}</a>` })}</p>`;
|
|
33
34
|
|
|
34
35
|
const json = (v: unknown) => (v === undefined ? '' : JSON.stringify(v, null, 2));
|
|
35
36
|
|
|
@@ -40,41 +41,41 @@ export async function assistantSettingsForm(pageId: number, appId: number, r: {
|
|
|
40
41
|
const problems = assistantProblems(cfg);
|
|
41
42
|
const agents = await agentOptions(appId);
|
|
42
43
|
const agent = typeof cfg.agent === 'string' ? cfg.agent.toUpperCase() : '';
|
|
43
|
-
return html`<h3 class="u-mt15"
|
|
44
|
-
<p class="muted u-mt0"
|
|
45
|
-
${allowed.length ? '' : NO_SERVICE}
|
|
44
|
+
return html`<h3 class="u-mt15">${tx('asst.ai_assistant_settings')}</h3>
|
|
45
|
+
<p class="muted u-mt0">${tx('asst.a_chat_with_an_ai')}</p>
|
|
46
|
+
${allowed.length ? '' : NO_SERVICE()}
|
|
46
47
|
${problems.length ? html`<div class="alert alert-error" role="alert">${problems.join(' ')}</div>` : ''}
|
|
47
48
|
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/assistant" class="component-form">${csrf(s)}
|
|
48
|
-
<fieldset class="prop-group"><legend
|
|
49
|
-
<div class="field"><label class="label" for="${id('agent')}"
|
|
50
|
-
<select id="${id('agent')}" name="agent">${opt('', '
|
|
51
|
-
<small class="help"
|
|
52
|
-
<div class="field"><label class="label" for="${id('service')}"
|
|
53
|
-
<select id="${id('service')}" name="service">${opt('', '
|
|
54
|
-
<div class="field"><label class="label" for="${id('max_rounds')}"
|
|
49
|
+
<fieldset class="prop-group"><legend>${tx('asst.assistant')}</legend><div class="form-grid">
|
|
50
|
+
<div class="field"><label class="label" for="${id('agent')}">${tx('asst.ai_agent')}</label>
|
|
51
|
+
<select id="${id('agent')}" name="agent">${opt('', tx('asst.none_the_service_prompt_and'), agent)}${agents.map((n) => opt(n, n, agent))}${agent && !agents.includes(agent) ? opt(agent, `${agent} (does not exist)`, agent) : ''}</select>
|
|
52
|
+
<small class="help">${tx.rich('asst.agent_help', { link: html`<a href="${BASE}/apps/${appId}/ai-agents">${tx('asst.shared_components_ai_agents')}</a>` })}</small></div>
|
|
53
|
+
<div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
|
|
54
|
+
<select id="${id('service')}" name="service">${opt('', tx('asst.choose'), cfg.service ?? '')}${options}</select></div>
|
|
55
|
+
<div class="field"><label class="label" for="${id('max_rounds')}">${tx('asst.tool_rounds_per_question')}</label>
|
|
55
56
|
<input id="${id('max_rounds')}" name="max_rounds" type="number" min="1" max="10" value="${cfg.max_rounds ?? ''}" placeholder="5"></div>
|
|
56
|
-
<div class="field"><label class="label" for="${id('max_turns')}"
|
|
57
|
+
<div class="field"><label class="label" for="${id('max_turns')}">${tx('asst.questions_per_conversation')}</label>
|
|
57
58
|
<input id="${id('max_turns')}" name="max_turns" type="number" min="1" max="100" value="${cfg.max_turns ?? ''}" placeholder="20"></div>
|
|
58
|
-
<div class="field" data-wide><label class="label" for="${id('system')}"
|
|
59
|
+
<div class="field" data-wide><label class="label" for="${id('system')}">${tx('asst.system_prompt')}</label>
|
|
59
60
|
<textarea id="${id('system')}" name="system" rows="4">${cfg.system ?? ''}</textarea>
|
|
60
|
-
<small class="help"
|
|
61
|
-
<div class="field" data-wide><label class="label" for="${id('welcome')}"
|
|
61
|
+
<small class="help">${tx.code('asst.who_the_assistant_is_and')}</small></div>
|
|
62
|
+
<div class="field" data-wide><label class="label" for="${id('welcome')}">${tx('asst.welcome_text')}</label>
|
|
62
63
|
<textarea id="${id('welcome')}" name="welcome" rows="2">${cfg.welcome ?? ''}</textarea></div>
|
|
63
|
-
<div class="field"><label class="label" for="${id('placeholder')}"
|
|
64
|
+
<div class="field"><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
|
|
64
65
|
<input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
|
|
65
|
-
<div class="field"><label class="label" for="${id('error_message')}"
|
|
66
|
-
<input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="
|
|
66
|
+
<div class="field"><label class="label" for="${id('error_message')}">${tx('asst.error_message')}</label>
|
|
67
|
+
<input id="${id('error_message')}" name="error_message" maxlength="300" value="${cfg.error_message ?? ''}" placeholder="${tx('asst.the_service_s_message')}"></div>
|
|
67
68
|
</div>
|
|
68
|
-
<div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}>
|
|
69
|
-
<fieldset class="prop-group"><legend
|
|
70
|
-
<div class="field"><label class="label" for="${id('context')}"
|
|
69
|
+
<div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are')}</label></div></fieldset>
|
|
70
|
+
<fieldset class="prop-group"><legend>${tx('asst.context_and_tools')}</legend>
|
|
71
|
+
<div class="field"><label class="label" for="${id('context')}">${tx('asst.context_queries_json')}</label>
|
|
71
72
|
<textarea id="${id('context')}" name="context" rows="5" class="code" spellcheck="false">${json(cfg.context)}</textarea>
|
|
72
|
-
<small class="help"
|
|
73
|
-
<div class="field"><label class="label" for="${id('tools')}"
|
|
73
|
+
<small class="help">${tx.code('asst.run_before_each_question_their')}</small></div>
|
|
74
|
+
<div class="field"><label class="label" for="${id('tools')}">${tx('asst.tools_json')}</label>
|
|
74
75
|
<textarea id="${id('tools')}" name="tools" rows="10" class="code" spellcheck="false">${json(cfg.tools)}</textarea>
|
|
75
|
-
<small class="help"
|
|
76
|
+
<small class="help">${tx.code('asst.the_model_calls_them_when')}</small></div>
|
|
76
77
|
</fieldset>
|
|
77
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
78
|
+
<div class="buttons"><button class="btn btn-hot">${tx('asst.save_ai_assistant_settings')}</button></div>
|
|
78
79
|
</form>`;
|
|
79
80
|
}
|
|
80
81
|
|
|
@@ -84,9 +85,9 @@ async function appProblems(appId: number, cfg: AssistantConfig) {
|
|
|
84
85
|
const sources = new Set((await owner.query<{ name: string }>('select name from meta.rest_source where app_id = $1', [appId])).rows.map((x) => x.name));
|
|
85
86
|
const schemes = new Set((await owner.query<{ name: string }>('select name from meta.authz_scheme where app_id = $1', [appId])).rows.map((x) => x.name.toUpperCase()));
|
|
86
87
|
for (const t of Array.isArray(cfg.tools) ? cfg.tools : []) {
|
|
87
|
-
if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(
|
|
88
|
+
if (t?.type === 'rest' && typeof t.source === 'string' && !sources.has(t.source.toUpperCase())) out.push(tx('asst.no_rest_source', { tool: t.name, source: t.source.toUpperCase() }));
|
|
88
89
|
if (typeof t?.authz === 'string' && t.authz.trim() && !['MUST_NOT_BE_PUBLIC_USER'].includes(t.authz.replace(/^!/, '').trim().toUpperCase()) && !schemes.has(t.authz.replace(/^!/, '').trim().toUpperCase()))
|
|
89
|
-
out.push(
|
|
90
|
+
out.push(tx('asst.no_authz', { tool: t.name, scheme: t.authz }));
|
|
90
91
|
}
|
|
91
92
|
return out;
|
|
92
93
|
}
|
|
@@ -124,12 +125,12 @@ export async function mergeAssistantSettings(appId: number, config: Config, b: B
|
|
|
124
125
|
try {
|
|
125
126
|
out[k] = JSON.parse(v);
|
|
126
127
|
} catch {
|
|
127
|
-
errors.push(`${k === 'tools' ? '
|
|
128
|
+
errors.push(`${k === 'tools' ? tx('asst.tools') : tx('asst.context_queries')}: not valid JSON (kept as it was).`);
|
|
128
129
|
}
|
|
129
130
|
}
|
|
130
131
|
if (out.agent) {
|
|
131
132
|
for (const k of ['service', 'system', 'tools', 'max_rounds']) delete out[k];
|
|
132
|
-
if (!(await agentOptions(appId)).includes(out.agent)) errors.push(
|
|
133
|
+
if (!(await agentOptions(appId)).includes(out.agent)) errors.push(tx('asst.no_agent', { agent: out.agent }));
|
|
133
134
|
}
|
|
134
135
|
errors.push(...assistantProblems(out), ...(await appProblems(appId, out as AssistantConfig)));
|
|
135
136
|
return { config: out, errors };
|
|
@@ -140,17 +141,17 @@ export async function aiFilterSettingsForm(pageId: number, appId: number, r: { i
|
|
|
140
141
|
const cfg = (r.config?.ai_filter ?? {}) as Config;
|
|
141
142
|
const id = (n: string) => `raf_${r.id}_${n}`;
|
|
142
143
|
const { options } = await serviceOptions(appId, cfg.service);
|
|
143
|
-
return html`<h3 class="u-mt15"
|
|
144
|
-
<p class="muted u-mt0"
|
|
144
|
+
return html`<h3 class="u-mt15">${tx('asst.ask_in_your_own_words')}</h3>
|
|
145
|
+
<p class="muted u-mt0">${tx.rich('asst.filter_intro', { builtin: html`<i>${tx('asst.built_in_phrases')}</i>` })}</p>
|
|
145
146
|
<form method="post" action="${BASE}/pages/${pageId}/region/${r.id}/ai-filter" class="component-form">${csrf(s)}
|
|
146
147
|
<div class="form-grid">
|
|
147
|
-
<div class="field"><label class="label" for="${id('service')}"
|
|
148
|
-
<select id="${id('service')}" name="service">${opt('', '
|
|
149
|
-
<div class="field" data-wide><label class="label" for="${id('placeholder')}"
|
|
148
|
+
<div class="field"><label class="label" for="${id('service')}">${tx('asst.ai_service')}</label>
|
|
149
|
+
<select id="${id('service')}" name="service">${opt('', tx('asst.off'), r.config?.ai_filter ? '-' : '')}${opt('-', tx('asst.built_in_phrases_no_ai'), r.config?.ai_filter && !cfg.service ? '-' : '')}${options}</select></div>
|
|
150
|
+
<div class="field" data-wide><label class="label" for="${id('placeholder')}">${tx('asst.placeholder')}</label>
|
|
150
151
|
<input id="${id('placeholder')}" name="placeholder" maxlength="200" value="${cfg.placeholder ?? ''}"></div>
|
|
151
152
|
</div>
|
|
152
|
-
<div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}>
|
|
153
|
-
<div class="buttons"><button class="btn"
|
|
153
|
+
<div class="field"><label class="check"><input type="checkbox" name="public" value="true"${cfg.public ? raw(' checked') : ''}> ${tx('asst.also_for_users_who_are_2')}</label></div>
|
|
154
|
+
<div class="buttons"><button class="btn">${tx('asst.save')}</button></div>
|
|
154
155
|
</form>`;
|
|
155
156
|
}
|
|
156
157
|
|
|
@@ -170,7 +171,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
|
|
|
170
171
|
const { config, errors } = await mergeAssistantSettings(r.app_id, r.config ?? {}, (req.body ?? {}) as Body);
|
|
171
172
|
await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
|
|
172
173
|
if (errors.length) flash(s, `Saved, but: ${errors.join(' ')}`, 'error');
|
|
173
|
-
else flash(s, '
|
|
174
|
+
else flash(s, tx('asst.settings_saved'));
|
|
174
175
|
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
175
176
|
});
|
|
176
177
|
|
|
@@ -193,7 +194,7 @@ export async function assistantBuilderRoutes(app: FastifyInstance) {
|
|
|
193
194
|
if (errors.length) flash(s, errors.join(' '), 'error');
|
|
194
195
|
else {
|
|
195
196
|
await owner.query('update meta.region set config = $2 where id = $1', [r.id, JSON.stringify(config)]);
|
|
196
|
-
flash(s, '
|
|
197
|
+
flash(s, tx('asst.settings_saved'));
|
|
197
198
|
}
|
|
198
199
|
return back(reply, s, `${BASE}/pages/${pid}?c=region-${rid}`);
|
|
199
200
|
});
|
|
@@ -5,6 +5,7 @@ import { html, type Raw } from '../html.ts';
|
|
|
5
5
|
import { icon } from '../icons.ts';
|
|
6
6
|
import type { Session } from '../session.ts';
|
|
7
7
|
import { back, BASE, csrf, developer, flash, type Req } from './ui.ts';
|
|
8
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
8
9
|
|
|
9
10
|
// Shared Components → Automations: the actions (add, edit, reorder),
|
|
10
11
|
// next run, Run now and the run history with the errors per row, under the
|
|
@@ -23,13 +24,13 @@ export function actionPrefill(automation: string, actions: any[]) {
|
|
|
23
24
|
/** Under an action's form: back to its automation. */
|
|
24
25
|
export function actionExtras(row: any, automations: any[]): Raw {
|
|
25
26
|
const a = automations.find((x) => x.name === row.automation_name);
|
|
26
|
-
return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')}
|
|
27
|
+
return a ? html`<p class="u-mt1"><a class="btn" href="?c=automation-${a.id}">${icon('clock')} ${tx('au.back_to', { name: a.name })}</a></p>` : html``;
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
function rowErrors(errors: RowError[] | null, failed: number | null): Raw {
|
|
30
31
|
if (!errors?.length) return html``;
|
|
31
|
-
return html`<details><summary>${failed ?? errors.length}
|
|
32
|
-
<ul class="small">${errors.map((e) => html`<li
|
|
32
|
+
return html`<details><summary>${tx('au.failed_rows', { n: failed ?? errors.length })}${failed && failed > errors.length ? ` ${tx('au.first', { n: errors.length })}` : ''}</summary>
|
|
33
|
+
<ul class="small">${errors.map((e) => html`<li>${tx.rich('au.row_action', { row: e.row, action: html`<b>${e.action}</b>` })}: ${e.message}${e.values ? html`<br><code>${e.values}</code>` : ''}</li>`)}</ul></details>`;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
36
|
/** Actions, next run, Run now and the last runs of an automation. */
|
|
@@ -43,44 +44,44 @@ export async function automationExtras(appId: number, row: { id: number; name: s
|
|
|
43
44
|
).rows;
|
|
44
45
|
let next: string;
|
|
45
46
|
try {
|
|
46
|
-
next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : '
|
|
47
|
+
next = row.enabled ? when(nextRun(parseCron(row.schedule), row.time_zone, new Date())) : tx('au.not_scheduled');
|
|
47
48
|
} catch (e) {
|
|
48
|
-
next =
|
|
49
|
+
next = tx('au.never', { message: (e as Error).message });
|
|
49
50
|
}
|
|
50
|
-
const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b
|
|
51
|
+
const scheduler = process.env.AUTOMATIONS === 'off' ? html` <b>${tx('au.scheduler_off')}</b>` : '';
|
|
51
52
|
const move = (id: number, dir: 'up' | 'down', label: string, disabled: boolean) =>
|
|
52
53
|
html`<form method="post" action="${BASE}/apps/${appId}/shared/automation_action/${id}/move" class="u-inline">${csrf(s)}<input type="hidden" name="dir" value="${dir}"><button class="link-button" aria-label="${label}"${disabled ? html` disabled` : ''}>${dir === 'up' ? '↑' : '↓'}</button></form>`;
|
|
53
|
-
const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend
|
|
54
|
-
<p class="muted u-mt0"
|
|
54
|
+
const actionsHtml = html`<fieldset class="prop-group u-mt125"><legend>${tx('au.actions')}</legend>
|
|
55
|
+
<p class="muted u-mt0">${tx(!row.query?.trim() ? 'au.order_once' : row.error_handling === 'skip' ? 'au.order_rows_skip' : 'au.order_rows')}</p>
|
|
55
56
|
${actions.length
|
|
56
|
-
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num"
|
|
57
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th class="num">${tx('au.seq')}</th><th>${tx('au.action')}</th><th>${tx('au.condition')}</th><th>${tx('au.order')}</th></tr></thead><tbody>
|
|
57
58
|
${actions.map((x, i) => html`<tr>
|
|
58
|
-
<td class="num" data-label="
|
|
59
|
-
<td data-label="
|
|
60
|
-
<td data-label="
|
|
61
|
-
<td data-label="
|
|
59
|
+
<td class="num" data-label="${tx('au.seq')}">${x.seq}</td>
|
|
60
|
+
<td data-label="${tx('au.action')}"><a href="?c=automation_action-${x.id}">${x.name}</a></td>
|
|
61
|
+
<td data-label="${tx('au.condition')}">${x.condition ? html`<code>${x.condition.length > 80 ? x.condition.slice(0, 80) + '…' : x.condition}</code>` : html`<span class="muted">${tx('au.always')}</span>`}</td>
|
|
62
|
+
<td data-label="${tx('au.order')}">${move(x.id, 'up', tx('au.move_up', { name: x.name }), i === 0)} ${move(x.id, 'down', tx('au.move_down', { name: x.name }), i === actions.length - 1)}</td></tr>`)}
|
|
62
63
|
</tbody></table></div>`
|
|
63
|
-
: html`<p class="muted"
|
|
64
|
-
<p><a class="btn" href="?new=automation_action&automation=${encodeURIComponent(row.name)}">+
|
|
64
|
+
: html`<p class="muted">${tx('au.no_actions')}</p>`}
|
|
65
|
+
<p><a class="btn" href="?new=automation_action&automation=${encodeURIComponent(row.name)}">+ ${tx('au.add_action')}</a></p>
|
|
65
66
|
</fieldset>`;
|
|
66
|
-
return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend
|
|
67
|
-
<p
|
|
68
|
-
<p class="muted"
|
|
67
|
+
return html`${actionsHtml}<fieldset class="prop-group u-mt125"><legend>${tx('au.runs')}</legend>
|
|
68
|
+
<p>${tx.rich('au.next_run', { when: html`<b>${next}</b>` })}${scheduler}</p>
|
|
69
|
+
<p class="muted">${tx.rich('au.from_code', { call: html`<code>select meta.run_automation('${row.name}')</code>` })}</p>
|
|
69
70
|
<form method="post" action="${BASE}/apps/${appId}/shared/automation/${row.id}/run">${csrf(s)}
|
|
70
|
-
<button class="btn">${icon('play')}
|
|
71
|
+
<button class="btn">${icon('play')} ${tx('au.run_now')}</button>
|
|
71
72
|
</form>
|
|
72
73
|
${log.length
|
|
73
|
-
? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th
|
|
74
|
+
? html`<div class="table-wrap u-mt075"><table class="report report-reflow"><thead><tr><th>${tx('au.started')}</th><th>${tx('au.took')}</th><th>${tx('au.by')}</th><th>${tx('au.status')}</th><th class="num">${tx('au.rows')}</th><th class="num">${tx('au.failed')}</th><th>${tx('au.message')}</th></tr></thead><tbody>
|
|
74
75
|
${log.map((l) => html`<tr>
|
|
75
|
-
<td data-label="
|
|
76
|
-
<td data-label="
|
|
77
|
-
<td data-label="
|
|
78
|
-
<td data-label="
|
|
79
|
-
<td class="num" data-label="
|
|
80
|
-
<td class="num" data-label="
|
|
81
|
-
<td data-label="
|
|
76
|
+
<td data-label="${tx('au.started')}">${when(l.started_at)}</td>
|
|
77
|
+
<td data-label="${tx('au.took')}">${l.finished_at ? `${((new Date(l.finished_at).getTime() - new Date(l.started_at).getTime()) / 1000).toFixed(1)} s` : '…'}</td>
|
|
78
|
+
<td data-label="${tx('au.by')}">${l.trigger}${l.run_by ? html` <span class="muted">(${l.run_by})</span>` : ''}</td>
|
|
79
|
+
<td data-label="${tx('au.status')}"><span class="tag${STATUS_TAG[l.status] ?? ''}">${l.status}</span></td>
|
|
80
|
+
<td class="num" data-label="${tx('au.rows')}">${l.rows ?? ''}</td>
|
|
81
|
+
<td class="num" data-label="${tx('au.failed')}">${l.rows_failed || ''}</td>
|
|
82
|
+
<td data-label="${tx('au.message')}">${l.message ?? ''}${rowErrors(l.errors, l.rows_failed)}</td></tr>`)}
|
|
82
83
|
</tbody></table></div>`
|
|
83
|
-
: html`<p class="muted"
|
|
84
|
+
: html`<p class="muted">${tx('au.no_runs')}</p>`}
|
|
84
85
|
</fieldset>`;
|
|
85
86
|
}
|
|
86
87
|
|
|
@@ -93,9 +94,9 @@ export async function automationRoutes(app: FastifyInstance) {
|
|
|
93
94
|
const a = /^\d+$/.test(id) && /^\d+$/.test(cid) ? await owner.one('select id from meta.automation where id = $1 and app_id = $2', [cid, id]) : undefined;
|
|
94
95
|
if (!a) return reply.code(404).send('Not found');
|
|
95
96
|
const r = await runAutomation(a.id, 'manual');
|
|
96
|
-
if (r.status === 'ok') flash(s,
|
|
97
|
-
else if (r.status === 'warning') flash(s,
|
|
98
|
-
else flash(s, r.message ?? '
|
|
97
|
+
if (r.status === 'ok') flash(s, r.rows ? tx('au.ran_rows', { n: r.rows }) : tx('au.ran'));
|
|
98
|
+
else if (r.status === 'warning') flash(s, tx('au.ran_errors', { message: r.message ?? '' }), 'error');
|
|
99
|
+
else flash(s, r.message ?? tx('au.failed_run'), 'error');
|
|
99
100
|
return back(reply, s, target);
|
|
100
101
|
});
|
|
101
102
|
|
|
@@ -4,18 +4,21 @@ import { html, raw } from '../html.ts';
|
|
|
4
4
|
import { clientIp, logActivity } from '../session.ts';
|
|
5
5
|
import { appOr404 } from './forms.ts';
|
|
6
6
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, type Req } from './ui.ts';
|
|
7
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
7
8
|
|
|
8
9
|
// Application → Availability (APEX: Application Availability and Build Status,
|
|
9
10
|
// migration 083): who may use the running application, the message they see
|
|
10
11
|
// otherwise, and whether the builder may change it (Run only). Allowed on a
|
|
11
12
|
// Run only application (src/builder/locks.ts), logged as "availability".
|
|
12
13
|
|
|
13
|
-
const
|
|
14
|
-
['available', '
|
|
15
|
-
['developers', '
|
|
16
|
-
['restricted', '
|
|
17
|
-
['unavailable', '
|
|
14
|
+
const MODE_KEYS: [string, BuilderKey][] = [
|
|
15
|
+
['available', 'avl.mode.available'],
|
|
16
|
+
['developers', 'avl.mode.developers'],
|
|
17
|
+
['restricted', 'avl.mode.restricted'],
|
|
18
|
+
['unavailable', 'avl.mode.unavailable'],
|
|
18
19
|
];
|
|
20
|
+
/** The statuses with their labels in the developer's language. */
|
|
21
|
+
const modes = (): [string, string][] => MODE_KEYS.map(([v, k]) => [v, tx(k)]);
|
|
19
22
|
|
|
20
23
|
export async function availabilityRoutes(app: FastifyInstance) {
|
|
21
24
|
app.get(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
|
|
@@ -23,35 +26,32 @@ export async function availabilityRoutes(app: FastifyInstance) {
|
|
|
23
26
|
if (!s) return;
|
|
24
27
|
const a = await appOr404(req.params.id);
|
|
25
28
|
if (!a) return reply.code(404).send('Not found');
|
|
26
|
-
const body = region('
|
|
27
|
-
<p class="muted u-mt0">
|
|
28
|
-
(they see the developer toolbar). Pages, file downloads, REST modules and the REST API follow this setting; automations,
|
|
29
|
-
workflows, background jobs and queued mail keep running. It belongs to this installation: exports leave it out and an
|
|
30
|
-
import over the application keeps it.</p>
|
|
29
|
+
const body = region(tx('app.head.availability'), html`<form method="post" action="${BASE}/apps/${a.id}/availability">${csrf(s)}
|
|
30
|
+
<p class="muted u-mt0">${tx('avl.intro')}</p>
|
|
31
31
|
<div class="form-grid">
|
|
32
|
-
${select('availability', '
|
|
33
|
-
<div class="field" data-wide><label class="label" for="f_message"
|
|
32
|
+
${select('availability', tx('avl.status'), a.availability, modes())}
|
|
33
|
+
<div class="field" data-wide><label class="label" for="f_message">${tx('avl.message')}</label>
|
|
34
34
|
<textarea id="f_message" name="message" rows="3" maxlength="2000">${a.availability_message ?? ''}</textarea>
|
|
35
|
-
<small class="help"
|
|
36
|
-
<div class="field" data-wide><label class="label" for="f_users"
|
|
35
|
+
<small class="help">${tx('avl.message_help')}</small></div>
|
|
36
|
+
<div class="field" data-wide><label class="label" for="f_users">${tx('avl.users')}</label>
|
|
37
37
|
<textarea id="f_users" name="users" rows="2">${(a.availability_users ?? []).join(', ')}</textarea>
|
|
38
|
-
<small class="help"
|
|
39
|
-
${select('build_status', '
|
|
40
|
-
'
|
|
38
|
+
<small class="help">${tx('avl.users_help')}</small></div>
|
|
39
|
+
${select('build_status', tx('avl.build_status'), a.build_status, [['run_and_build', tx('avl.run_and_build')], ['run_only', tx('avl.run_only')]],
|
|
40
|
+
tx('avl.build_status_help'))}
|
|
41
41
|
</div>
|
|
42
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
42
|
+
<div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div></form>`);
|
|
43
43
|
const main = html`${appHeader(a, 'settings', s)}<div class="ide-body">${body}</div>`;
|
|
44
|
-
return send(reply, s, shell(s,
|
|
44
|
+
return send(reply, s, shell(s, tx('avl.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.availability')]], main));
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
app.post(`${BASE}/apps/:id(^\\d+$)/availability`, async (req: Req, reply) => {
|
|
48
48
|
const s = await developer(req, reply);
|
|
49
49
|
if (!s) return;
|
|
50
50
|
const b = req.body ?? {};
|
|
51
|
-
const mode =
|
|
51
|
+
const mode = MODE_KEYS.some(([v]) => v === b.availability) ? b.availability : 'available';
|
|
52
52
|
const users = [...new Set(String(b.users ?? '').split(/[\s,;]+/).map((u) => u.trim()).filter(Boolean))];
|
|
53
53
|
if (users.some((u) => u.length > 100 || /[:\s]/.test(u))) {
|
|
54
|
-
flash(s, '
|
|
54
|
+
flash(s, tx('avl.users_invalid'), 'error');
|
|
55
55
|
return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
|
|
56
56
|
}
|
|
57
57
|
const build = b.build_status === 'run_only' ? 'run_only' : 'run_and_build';
|
|
@@ -59,7 +59,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
|
|
|
59
59
|
`update meta.app set availability = $2, availability_message = $3, availability_users = $4, build_status = $5, updated_at = now() where id = $1`,
|
|
60
60
|
[req.params.id, mode, String(b.message ?? '').trim().slice(0, 2000) || null, users, build]);
|
|
61
61
|
if (r.rowCount) await logActivity({ appId: Number(req.params.id), username: s.username, event: 'availability', ip: clientIp(req), detail: `${mode}, ${build}` });
|
|
62
|
-
flash(s, r.rowCount ? '
|
|
62
|
+
flash(s, r.rowCount ? tx('avl.saved') : tx('avl.not_found'), r.rowCount ? 'ok' : 'error');
|
|
63
63
|
return back(reply, s, `${BASE}/apps/${req.params.id}/availability`);
|
|
64
64
|
});
|
|
65
65
|
}
|
|
@@ -68,7 +68,7 @@ export async function availabilityRoutes(app: FastifyInstance) {
|
|
|
68
68
|
export const availabilityBadge = (a: { id: number; availability?: string; build_status?: string }) =>
|
|
69
69
|
a.availability && a.availability !== 'available' || a.build_status === 'run_only'
|
|
70
70
|
? html`<a class="tag tag-warning availability-badge" href="${BASE}/apps/${a.id}/availability">${[
|
|
71
|
-
a.availability && a.availability !== 'available' ?
|
|
72
|
-
a.build_status === 'run_only' ? '
|
|
71
|
+
a.availability && a.availability !== 'available' ? modes().find(([v]) => v === a.availability)?.[1] : null,
|
|
72
|
+
a.build_status === 'run_only' ? tx('avl.run_only') : null,
|
|
73
73
|
].filter(Boolean).join(' · ')}</a>`
|
|
74
74
|
: raw('');
|
package/src/builder/backups.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { icon } from '../icons.ts';
|
|
|
6
6
|
import { clientIp, logActivity } from '../session.ts';
|
|
7
7
|
import { appOr404 } from './forms.ts';
|
|
8
8
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
9
|
+
import { scopedTx as tx, type BuilderKey } from './i18n.ts';
|
|
9
10
|
|
|
10
11
|
// Application → Backups (APEX: Application Backups, migration 082): the
|
|
11
12
|
// exports this installation keeps of an application (before every import
|
|
@@ -14,8 +15,8 @@ import { appHeader, back, BASE, csrf, developer, flash, input, region, send, she
|
|
|
14
15
|
// backup, so the version it replaces is backed up as well. Locks of the
|
|
15
16
|
// application are respected (developer() refuses a POST to a locked app).
|
|
16
17
|
|
|
17
|
-
const REASON: Record<string,
|
|
18
|
-
import: '
|
|
18
|
+
const REASON: Record<string, BuilderKey> = {
|
|
19
|
+
import: 'bkp.reason.import', merge: 'bkp.reason.merge', restore: 'bkp.reason.restore', daily: 'bkp.reason.daily', manual: 'bkp.reason.manual',
|
|
19
20
|
};
|
|
20
21
|
const when = (d: string | Date) => new Date(d).toISOString().slice(0, 16).replace('T', ' ');
|
|
21
22
|
const kb = (n: number) => (n < 1024 * 1024 ? `${Math.max(1, Math.round(n / 1024))} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
|
|
@@ -39,35 +40,33 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
39
40
|
const rows = (await owner.query(
|
|
40
41
|
'select id, created_at, created_by, reason, note, size from meta.app_backup where app_id = $1 order by created_at desc, id desc', [a.id])).rows;
|
|
41
42
|
const body = html`
|
|
42
|
-
${region('
|
|
43
|
-
<p class="muted u-mt0"
|
|
44
|
-
|
|
45
|
-
The newest ${a.backups_keep} are kept. Backups are not exported.</p>
|
|
46
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Made (UTC)</th><th>Why</th><th>By</th><th>Note</th><th class="num">Size</th><th>Actions</th></tr></thead><tbody>
|
|
43
|
+
${region(tx('app.head.backups'), html`
|
|
44
|
+
<p class="muted u-mt0">${tx('bkp.intro', { n: a.backups_keep })}</p>
|
|
45
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('bkp.made_utc')}</th><th>${tx('bkp.why')}</th><th>${tx('bkp.by')}</th><th>${tx('bkp.note')}</th><th class="num">${tx('bkp.size')}</th><th>${tx('bkp.actions')}</th></tr></thead><tbody>
|
|
47
46
|
${rows.length ? rows.map((b) => html`<tr>
|
|
48
|
-
<td data-label="
|
|
49
|
-
<td data-label="
|
|
50
|
-
<td data-label="
|
|
51
|
-
<td data-label="
|
|
52
|
-
<td class="num" data-label="
|
|
53
|
-
<td data-label="
|
|
54
|
-
<a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')}
|
|
47
|
+
<td data-label="${tx('bkp.made')}">${when(b.created_at)}</td>
|
|
48
|
+
<td data-label="${tx('bkp.why')}">${REASON[b.reason] ? tx(REASON[b.reason]) : b.reason}</td>
|
|
49
|
+
<td data-label="${tx('bkp.by')}">${b.created_by ?? ''}</td>
|
|
50
|
+
<td data-label="${tx('bkp.note')}" class="u-break">${b.note ?? ''}</td>
|
|
51
|
+
<td class="num" data-label="${tx('bkp.size')}">${kb(b.size)}</td>
|
|
52
|
+
<td data-label="${tx('bkp.actions')}"><div class="buttons">
|
|
53
|
+
<a class="btn btn-sm" href="${BASE}/apps/${a.id}/backups/${b.id}/download">${icon('download')} ${tx('bkp.download')}</a>
|
|
55
54
|
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/restore" class="u-inline">${csrf(s)}
|
|
56
|
-
<button class="btn btn-sm" data-confirm="
|
|
55
|
+
<button class="btn btn-sm" data-confirm="${tx('bkp.restore_confirm', { when: when(b.created_at) })}">${icon('history')} ${tx('bkp.restore')}</button></form>
|
|
57
56
|
<form method="post" action="${BASE}/apps/${a.id}/backups/${b.id}/delete" class="u-inline">${csrf(s)}
|
|
58
|
-
<button class="btn btn-sm btn-danger" data-confirm="
|
|
57
|
+
<button class="btn btn-sm btn-danger" data-confirm="${tx('bkp.delete_confirm')}">${tx('bkp.delete')}</button></form>
|
|
59
58
|
</div></td></tr>`)
|
|
60
|
-
: html`<tr><td colspan="6" class="empty"
|
|
59
|
+
: html`<tr><td colspan="6" class="empty">${tx('bkp.none')}</td></tr>`}</tbody></table></div>`)}
|
|
61
60
|
<div class="columns">
|
|
62
|
-
${region('
|
|
63
|
-
<div class="form-grid">${input('note', '
|
|
64
|
-
<div class="buttons"><button class="btn btn-hot">${icon('save')}
|
|
65
|
-
${region('
|
|
66
|
-
<div class="form-grid">${input('keep', '
|
|
67
|
-
<div class="buttons"><button class="btn"
|
|
61
|
+
${region(tx('bkp.back_up_now'), html`<form method="post" action="${BASE}/apps/${a.id}/backups">${csrf(s)}
|
|
62
|
+
<div class="form-grid">${input('note', tx('bkp.note'), '', { placeholder: tx('bkp.note_placeholder') })}</div>
|
|
63
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('bkp.back_up_now')}</button></div></form>`)}
|
|
64
|
+
${region(tx('bkp.keep'), html`<form method="post" action="${BASE}/apps/${a.id}/backups/settings">${csrf(s)}
|
|
65
|
+
<div class="form-grid">${input('keep', tx('bkp.to_keep'), a.backups_keep, { type: 'number', help: tx('bkp.to_keep_help') })}</div>
|
|
66
|
+
<div class="buttons"><button class="btn">${tx('common.save')}</button></div></form>`)}
|
|
68
67
|
</div>`;
|
|
69
68
|
const main = html`${appHeader(a, 'pages', s)}<div class="ide-body">${body}</div>`;
|
|
70
|
-
return send(reply, s, shell(s,
|
|
69
|
+
return send(reply, s, shell(s, tx('bkp.page_title', { name: a.name }), [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.head.backups')]], main));
|
|
71
70
|
});
|
|
72
71
|
|
|
73
72
|
app.post(`${BASE}/apps/:id(^\\d+$)/backups`, async (req: Req, reply) => {
|
|
@@ -76,7 +75,7 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
76
75
|
const a = await appOr404(req.params.id);
|
|
77
76
|
if (!a) return reply.code(404).send('Not found');
|
|
78
77
|
const r = await owner.one(`select meta.backup_app($1, 'manual', $2, $3) as id`, [a.id, s.username, String(req.body?.note ?? '').trim().slice(0, 500) || null]);
|
|
79
|
-
flash(s, r.id ? '
|
|
78
|
+
flash(s, r.id ? tx('bkp.made_flash') : tx('bkp.none_kept'), r.id ? 'ok' : 'error');
|
|
80
79
|
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
81
80
|
});
|
|
82
81
|
|
|
@@ -84,12 +83,12 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
84
83
|
const s = await developer(req, reply);
|
|
85
84
|
if (!s) return;
|
|
86
85
|
const keep = Number(req.body?.keep);
|
|
87
|
-
if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, '
|
|
86
|
+
if (!Number.isInteger(keep) || keep < 0 || keep > 500) flash(s, tx('bkp.keep_invalid'), 'error');
|
|
88
87
|
else {
|
|
89
88
|
await owner.query('update meta.app set backups_keep = $2, updated_at = now() where id = $1', [req.params.id, keep]);
|
|
90
89
|
// fewer to keep: the oldest go now
|
|
91
90
|
await owner.query(`delete from meta.app_backup where app_id = $1 and id not in (select id from meta.app_backup where app_id = $1 order by created_at desc, id desc limit $2)`, [req.params.id, keep]);
|
|
92
|
-
flash(s, '
|
|
91
|
+
flash(s, tx('bkp.saved'));
|
|
93
92
|
}
|
|
94
93
|
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
95
94
|
});
|
|
@@ -112,14 +111,14 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
112
111
|
try {
|
|
113
112
|
const at = await owner.tx(async (c) => {
|
|
114
113
|
const b = (await c.query('select doc, created_at from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, a.id])).rows[0];
|
|
115
|
-
if (!b) throw new Error('
|
|
114
|
+
if (!b) throw new Error(tx('bkp.not_found'));
|
|
116
115
|
await replaceApp(c, b.doc, a.alias, { backup: { reason: 'restore', user: s.username, note: `before restoring the backup of ${when(b.created_at)}` } });
|
|
117
116
|
return when(b.created_at);
|
|
118
117
|
});
|
|
119
118
|
await logActivity({ appId: a.id, username: s.username, event: 'backup_restored', ip: clientIp(req), detail: `backup ${req.params.bid} of ${at}` });
|
|
120
|
-
flash(s,
|
|
119
|
+
flash(s, tx('bkp.restored', { when: at }));
|
|
121
120
|
} catch (e) {
|
|
122
|
-
flash(s,
|
|
121
|
+
flash(s, tx('bkp.restore_failed', { message: (e as Error).message }), 'error');
|
|
123
122
|
}
|
|
124
123
|
return back(reply, s, `${BASE}/apps/${a.id}/backups`);
|
|
125
124
|
});
|
|
@@ -128,7 +127,7 @@ export async function backupRoutes(app: FastifyInstance) {
|
|
|
128
127
|
const s = await developer(req, reply);
|
|
129
128
|
if (!s) return;
|
|
130
129
|
const r = await owner.query('delete from meta.app_backup where id = $1 and app_id = $2', [req.params.bid, req.params.id]);
|
|
131
|
-
flash(s, r.rowCount ? '
|
|
130
|
+
flash(s, r.rowCount ? tx('bkp.deleted') : tx('bkp.not_found_short'), r.rowCount ? 'ok' : 'error');
|
|
132
131
|
return back(reply, s, `${BASE}/apps/${req.params.id}/backups`);
|
|
133
132
|
});
|
|
134
133
|
}
|