pgkiln 0.82.0 → 0.83.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/153_component_events.sql +21 -0
- package/docs/apex-feature-parity.md +3 -3
- package/docs/guide/03-builder.md +8 -1
- package/docs/guide/04-pages-and-regions.md +2 -1
- package/docs/guide/07-dynamic-actions.md +27 -0
- package/docs/guide/11-from-apex.md +2 -1
- package/docs/guide/12-development.md +9 -1
- package/package.json +1 -1
- package/public/app.css +2 -0
- package/public/app.js +85 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/components.ts +5 -4
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1627 -0
- package/src/builder/i18n/en.ts +1627 -0
- package/src/builder/i18n/es.ts +1627 -0
- package/src/builder/i18n/fr.ts +1627 -0
- package/src/builder/i18n/it.ts +1627 -0
- package/src/builder/i18n/ja.ts +1627 -0
- package/src/builder/i18n/ko.ts +1627 -0
- package/src/builder/i18n/nl.ts +1627 -0
- package/src/builder/i18n/pt.ts +1627 -0
- package/src/builder/i18n/zh-cn.ts +1627 -0
- package/src/builder/i18n/zh-tw.ts +1627 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cron-schedule.ts +47 -17
- package/src/metadata.ts +3 -1
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/maps.ts +7 -4
- package/src/runtime/render.ts +6 -1
- package/src/runtime/routes.ts +4 -0
- package/src/runtime/tree.ts +4 -3
|
@@ -4,7 +4,7 @@ import { html, raw, type Raw } from '../html.ts';
|
|
|
4
4
|
import { icon } from '../icons.ts';
|
|
5
5
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
6
6
|
import {
|
|
7
|
-
cronTimeZone, describeSchedule, formatRun, joinSchedule, nextRuns, parseSchedule, PRESETS, ScheduleError, splitSchedule, type CronFields,
|
|
7
|
+
cronTimeZone, describeSchedule, EN_WORDS_TEXT, formatRun, joinSchedule, nextRuns, parseSchedule, PRESETS, ScheduleError, splitSchedule, type CronFields,
|
|
8
8
|
} from '../cron-schedule.ts';
|
|
9
9
|
import {
|
|
10
10
|
activeSql, applySql, checkJob, countPurge, cronState, jobSql, listJobs, listRuns, oneJob, purgeSql, reviewDigest, RUN_STATUSES, runNow, unscheduleSql,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from '../pgcron.ts';
|
|
13
13
|
import { isAdmin } from './locks.ts';
|
|
14
14
|
import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
15
|
+
import { tr, translatorFor, type BuilderKey, type BuilderTranslate } from './i18n.ts';
|
|
15
16
|
|
|
16
17
|
// (sprint 101, Database Actions DA-12) SQL Workshop › Scheduling: pg_cron's jobs (cron.job) with their schedule in
|
|
17
18
|
// words and next runs, the last run and recent failures; create, edit, disable, unschedule and run now; the run
|
|
@@ -21,40 +22,42 @@ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs,
|
|
|
21
22
|
// catalog and SQL: src/pgcron.ts; the builder's live preview: public/builder.js (data-cron-*).
|
|
22
23
|
|
|
23
24
|
const URL_BASE = `${BASE}/sql/scheduling`;
|
|
24
|
-
const TITLE = 'Scheduling';
|
|
25
25
|
const jobUrl = (id: string) => `${URL_BASE}/job/${id}`;
|
|
26
|
-
const crumbs = (...more: [string, string?][]): [string, string?][] => [['
|
|
26
|
+
const crumbs = (tx: BuilderTranslate, ...more: [string, string?][]): [string, string?][] => [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.scheduling'), more.length ? URL_BASE : undefined], ...more];
|
|
27
27
|
|
|
28
28
|
const one = (v: unknown) => (Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''));
|
|
29
29
|
const chk = (on: boolean) => (on ? raw(' checked') : '');
|
|
30
30
|
const sel = (on: boolean) => (on ? raw(' selected') : '');
|
|
31
|
-
const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`;
|
|
32
31
|
const errorList = (errors: string[]) =>
|
|
33
32
|
errors.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : '';
|
|
33
|
+
/** (sprint 162) the schedule's phrases in the builder's language (cron.* keys, English in src/cron-schedule.ts) */
|
|
34
|
+
const cronWords = (tx: BuilderTranslate) => (k: string) => tx.dyn(k, EN_WORDS_TEXT[k] ?? k);
|
|
35
|
+
const jobName = (tx: BuilderTranslate, j: { jobname: string | null; jobid: string }) => j.jobname ?? tx('cr.job_n', { id: j.jobid });
|
|
34
36
|
|
|
35
37
|
/** An administrator's session, or null (a 403 page sent). */
|
|
36
38
|
async function admin(req: Req, reply: FastifyReply) {
|
|
37
39
|
const s = await developer(req, reply);
|
|
38
40
|
if (!s) return null;
|
|
39
41
|
if (await isAdmin(s.username)) return s;
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
+
const tx = tr(s);
|
|
43
|
+
const main = html`<h1 class="u-mb1">${tx('sql.tool.scheduling')}</h1><div class="alert alert-error" role="alert">${tx('cr.admins_only')}</div>`;
|
|
44
|
+
await send(reply.code(403), s, shell(s, tx('sql.tool.scheduling'), crumbs(tx), main, 'sql'));
|
|
42
45
|
return null;
|
|
43
46
|
}
|
|
44
47
|
|
|
45
|
-
const head = (s: Session, title: string, actions: Raw | '' = '') => html`<h1 class="u-mb1"
|
|
48
|
+
const head = (s: Session, title: string, actions: Raw | '' = '') => html`<h1 class="u-mb1">${tr(s)('rail.sql_workshop')}</h1>${workshopTabs('scheduling', s)}
|
|
46
49
|
<div class="ide-head-title u-mb1"><div><h2 class="u-m0 u-break">${title}</h2></div>${actions ? html`<div class="buttons ide-head-actions">${actions}</div>` : ''}</div>`;
|
|
47
50
|
|
|
48
51
|
// ------------------------------------------------------------------ schedule preview
|
|
49
52
|
|
|
50
53
|
/** A schedule in words and its next five runs, or why it doesn't parse. */
|
|
51
|
-
export function schedulePreview(text: string, timeZone: string): Raw {
|
|
54
|
+
export function schedulePreview(text: string, timeZone: string, tx: BuilderTranslate = translatorFor('en')): Raw {
|
|
52
55
|
try {
|
|
53
56
|
const sc = parseSchedule(text);
|
|
54
57
|
const runs = nextRuns(sc, new Date(), 5, cronTimeZone(timeZone));
|
|
55
|
-
return html`<p class="u-m0"><strong>${describeSchedule(sc)}</strong></p>
|
|
56
|
-
${runs.length ? html`<p class="muted small u-m0"
|
|
57
|
-
: html`<p class="muted small"
|
|
58
|
+
return html`<p class="u-m0"><strong>${describeSchedule(sc, cronWords(tx), tx.lang)}</strong></p>
|
|
59
|
+
${runs.length ? html`<p class="muted small u-m0">${tx('cr.next_runs', { tz: timeZone })}</p><ol class="cron-runs">${runs.map((d) => html`<li><time>${formatRun(d, cronTimeZone(timeZone), sc.kind === 'seconds')}</time></li>`)}</ol>`
|
|
60
|
+
: html`<p class="muted small">${tx('cr.no_times')}</p>`}`;
|
|
58
61
|
} catch (e) {
|
|
59
62
|
if (!(e instanceof ScheduleError)) throw e;
|
|
60
63
|
return html`<p class="u-m0 cron-error" role="alert">${e.message}</p>`;
|
|
@@ -62,52 +65,54 @@ export function schedulePreview(text: string, timeZone: string): Raw {
|
|
|
62
65
|
}
|
|
63
66
|
|
|
64
67
|
/** The next run of a job, for the list. */
|
|
65
|
-
function nextRun(job: Job, timeZone: string): Raw | string {
|
|
66
|
-
if (!job.active) return html`<span class="muted"
|
|
68
|
+
function nextRun(tx: BuilderTranslate, job: Job, timeZone: string): Raw | string {
|
|
69
|
+
if (!job.active) return html`<span class="muted">${tx('cr.inactive')}</span>`;
|
|
67
70
|
try {
|
|
68
71
|
const sc = parseSchedule(job.schedule);
|
|
69
72
|
const [d] = nextRuns(sc, new Date(), 1, cronTimeZone(timeZone));
|
|
70
|
-
return d ? formatRun(d, cronTimeZone(timeZone), sc.kind === 'seconds') : html`<span class="muted"
|
|
73
|
+
return d ? formatRun(d, cronTimeZone(timeZone), sc.kind === 'seconds') : html`<span class="muted">${tx('cr.at_start')}</span>`;
|
|
71
74
|
} catch {
|
|
72
75
|
return html`<span class="muted">?</span>`;
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
const words = (schedule: string) => {
|
|
79
|
+
const words = (tx: BuilderTranslate, schedule: string) => {
|
|
77
80
|
try {
|
|
78
|
-
return describeSchedule(parseSchedule(schedule));
|
|
81
|
+
return describeSchedule(parseSchedule(schedule), cronWords(tx), tx.lang);
|
|
79
82
|
} catch (e) {
|
|
80
|
-
return e instanceof ScheduleError ? e.message : 'unreadable';
|
|
83
|
+
return e instanceof ScheduleError ? e.message : tx('cr.unreadable');
|
|
81
84
|
}
|
|
82
85
|
};
|
|
83
86
|
|
|
84
|
-
const statusTag = (status: string | null) => status === null ? html`<span class="muted"
|
|
85
|
-
: html`<span class="tag ${status === 'succeeded' ? 'tag-ok' : status === 'failed' ? 'tag-error' : 'tag-info'}">${status}</span>`;
|
|
87
|
+
const statusTag = (tx: BuilderTranslate, status: string | null) => status === null ? html`<span class="muted">${tx('cr.never_ran')}</span>`
|
|
88
|
+
: html`<span class="tag ${status === 'succeeded' ? 'tag-ok' : status === 'failed' ? 'tag-error' : 'tag-info'}">${tx.dyn(`cr.status.${status}`, status)}</span>`;
|
|
86
89
|
const secs = (n: number | null) => (n === null ? '' : n >= 100 ? `${n.toFixed(0)} s` : `${n.toFixed(n >= 1 ? 1 : 3)} s`);
|
|
87
90
|
|
|
88
91
|
// ------------------------------------------------------------------ without pg_cron
|
|
89
92
|
|
|
90
|
-
function installNote(st: Exclude<CronState, { ok: true }>): Raw {
|
|
93
|
+
function installNote(tx: BuilderTranslate, st: Exclude<CronState, { ok: true }>): Raw {
|
|
94
|
+
// element content needs only & < > escaped (quotes stay readable: shared_preload_libraries = 'pg_cron')
|
|
95
|
+
const c = (x: string) => raw(`<code>${x.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')}</code>`);
|
|
91
96
|
const steps = html`<ol class="u-m0">
|
|
92
|
-
<li
|
|
93
|
-
<li
|
|
94
|
-
<li
|
|
97
|
+
<li>${tx.rich('cr.step1', { apt: c('apt install postgresql-17-cron') })}</li>
|
|
98
|
+
<li>${tx.rich('cr.step2', { conf: c('postgresql.conf'), alter: c('alter system set …'), preload: c("shared_preload_libraries = 'pg_cron'"), db: c(`cron.database_name = '${st.database}'`), tz: c('cron.timezone') })}</li>
|
|
99
|
+
<li>${tx.rich('cr.step3', { create: c('create extension pg_cron;') })}</li>
|
|
95
100
|
</ol>`;
|
|
96
101
|
const why = {
|
|
97
|
-
not_available: html
|
|
98
|
-
not_loaded:
|
|
99
|
-
other_database:
|
|
100
|
-
not_created: html
|
|
101
|
-
no_access:
|
|
102
|
-
old:
|
|
102
|
+
not_available: html`${tx('cr.why.not_available')}`,
|
|
103
|
+
not_loaded: tx.rich('cr.why.not_loaded', { preload: c('shared_preload_libraries') }),
|
|
104
|
+
other_database: tx.rich('cr.why.other_database', { other: c(st.cronDatabase ?? 'postgres'), setting: c('cron.database_name'), db: c(st.database), set: c(`cron.database_name = '${st.database}'`), fn: c('cron.schedule_in_database') }),
|
|
105
|
+
not_created: html`${tx('cr.why.not_created')}`,
|
|
106
|
+
no_access: tx.rich('cr.why.no_access', { message: st.message ?? '', grant: c('grant usage on schema cron to …') }),
|
|
107
|
+
old: tx.rich('cr.why.old', { version: st.message ?? '?', fn1: c('cron.schedule_in_database'), fn2: c('cron.alter_job'), update: c('alter extension pg_cron update;') }),
|
|
103
108
|
}[st.reason];
|
|
104
109
|
return html`<div class="alert alert-info" role="status"><p class="u-mt0"><strong>${why}</strong></p>
|
|
105
|
-
${st.reason === 'no_access' || st.reason === 'old' ? '' : html`<p
|
|
106
|
-
<p class="u-m0"
|
|
110
|
+
${st.reason === 'no_access' || st.reason === 'old' ? '' : html`<p>${tx('cr.to_schedule')}</p>${steps}`}
|
|
111
|
+
<p class="u-m0">${tx.rich('cr.own_scheduler', { automations: html`<strong>${tx('cr.automations_path')}</strong>`, jobs: html`<strong>${tx('cr.process_jobs_lower')}</strong>` })}</p></div>`;
|
|
107
112
|
}
|
|
108
113
|
|
|
109
114
|
/** pgkiln's own scheduled work: automations and recent process jobs, read-only. */
|
|
110
|
-
async function pgkilnWork(): Promise<Raw> {
|
|
115
|
+
async function pgkilnWork(tx: BuilderTranslate): Promise<Raw> {
|
|
111
116
|
const automations = (await owner.query<{ app_id: number; alias: string; id: number; name: string; enabled: boolean; schedule: string; time_zone: string; next_run: string | null; last_run: string | null; last_status: string | null }>(
|
|
112
117
|
`select a.id as app_id, a.alias, x.id, x.name, x.enabled, x.schedule, x.time_zone,
|
|
113
118
|
to_char(x.next_run_at at time zone x.time_zone, 'YYYY-MM-DD HH24:MI') as next_run,
|
|
@@ -117,55 +122,59 @@ async function pgkilnWork(): Promise<Raw> {
|
|
|
117
122
|
`select j.id::text, a.id as app_id, a.alias, j.name, j.state, j.app_user, to_char(j.queued_at, 'YYYY-MM-DD HH24:MI:SS') as queued,
|
|
118
123
|
to_char(j.ended_at, 'YYYY-MM-DD HH24:MI:SS') as ended, j.error
|
|
119
124
|
from meta.process_job j join meta.app a on a.id = j.app_id order by j.id desc limit 20`)).rows;
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
const L = { app: tx('sm.col.application'), automation: tx('cr.col.automation'), schedule: tx('cr.col.schedule'), next: tx('cr.col.next_run'), last: tx('cr.col.last_run'), job: tx('dp.col.job'), state: tx('sm.col.state'), by: tx('cr.col.started_by'), queued: tx('cr.col.queued'), ended: tx('cr.col.ended') };
|
|
126
|
+
return html`${region(tx('cr.automations'), html`<p class="muted u-mt0">${tx('cr.automations_intro')}</p>
|
|
127
|
+
<div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>${L.app}</th><th>${L.automation}</th><th>${L.schedule}</th><th>${L.next}</th><th>${L.last}</th></tr></thead>
|
|
122
128
|
<tbody>${automations.length ? automations.map((x) => html`<tr>
|
|
123
|
-
<td data-label="
|
|
124
|
-
<td data-label="
|
|
125
|
-
<td data-label="
|
|
126
|
-
<td data-label="
|
|
127
|
-
<td data-label="
|
|
128
|
-
: html`<tr><td colspan="5" class="empty"
|
|
129
|
-
${region('
|
|
130
|
-
<div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th
|
|
129
|
+
<td data-label="${L.app}"><a href="${BASE}/apps/${x.app_id}">${x.alias}</a></td>
|
|
130
|
+
<td data-label="${L.automation}" class="u-break"><a href="${BASE}/apps/${x.app_id}/shared?c=automation-${x.id}">${x.name}</a>${x.enabled ? '' : html` <span class="tag">${tx('cr.disabled')}</span>`}</td>
|
|
131
|
+
<td data-label="${L.schedule}"><code>${x.schedule}</code><br><span class="muted small">${words(tx, x.schedule)} (${x.time_zone})</span></td>
|
|
132
|
+
<td data-label="${L.next}">${x.enabled ? x.next_run ?? html`<span class="muted">${tx('cr.soon')}</span>` : html`<span class="muted">${tx('cr.disabled')}</span>`}</td>
|
|
133
|
+
<td data-label="${L.last}">${x.last_run ?? html`<span class="muted">${tx('scr.never')}</span>`}${x.last_status ? html` <span class="tag ${x.last_status === 'ok' ? 'tag-ok' : x.last_status === 'error' ? 'tag-error' : 'tag-warning'}">${x.last_status}</span>` : ''}</td></tr>`)
|
|
134
|
+
: html`<tr><td colspan="5" class="empty">${tx('cr.no_automations')}</td></tr>`}</tbody></table></div>`)}
|
|
135
|
+
${region(tx('cr.process_jobs'), html`<p class="muted u-mt0">${tx('cr.process_jobs_intro')}</p>
|
|
136
|
+
<div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>${L.app}</th><th>${L.job}</th><th>${L.state}</th><th>${L.by}</th><th>${L.queued}</th><th>${L.ended}</th></tr></thead>
|
|
131
137
|
<tbody>${jobs.length ? jobs.map((x) => html`<tr>
|
|
132
|
-
<td data-label="
|
|
133
|
-
<td data-label="
|
|
134
|
-
<td data-label="
|
|
135
|
-
<td data-label="
|
|
136
|
-
: html`<tr><td colspan="6" class="empty"
|
|
138
|
+
<td data-label="${L.app}"><a href="${BASE}/apps/${x.app_id}">${x.alias}</a></td>
|
|
139
|
+
<td data-label="${L.job}" class="u-break">${x.name}${x.error ? html`<br><span class="muted small">${x.error.slice(0, 300)}</span>` : ''}</td>
|
|
140
|
+
<td data-label="${L.state}"><span class="tag ${x.state === 'completed' ? 'tag-ok' : x.state === 'failed' ? 'tag-error' : 'tag-info'}">${x.state}</span></td>
|
|
141
|
+
<td data-label="${L.by}">${x.app_user}</td><td data-label="${L.queued}">${x.queued}</td><td data-label="${L.ended}">${x.ended ?? ''}</td></tr>`)
|
|
142
|
+
: html`<tr><td colspan="6" class="empty">${tx('cr.no_process_jobs')}</td></tr>`}</tbody></table></div>`)}`;
|
|
137
143
|
}
|
|
138
144
|
|
|
139
145
|
// ------------------------------------------------------------------ list
|
|
140
146
|
|
|
141
147
|
async function listPage(req: Req, reply: FastifyReply, s: Session) {
|
|
148
|
+
const tx = tr(s);
|
|
149
|
+
const title = tx('sql.tool.scheduling');
|
|
142
150
|
const st = await cronState();
|
|
143
151
|
if (!st.ok) {
|
|
144
|
-
const main = html`${head(s,
|
|
145
|
-
return send(reply, s, shell(s,
|
|
152
|
+
const main = html`${head(s, title)}${installNote(tx, st)}${await pgkilnWork(tx)}`;
|
|
153
|
+
return send(reply, s, shell(s, title, crumbs(tx), main, 'sql'));
|
|
146
154
|
}
|
|
147
155
|
const jobs = await listJobs(st.timeZone);
|
|
148
156
|
const q = String(req.query.q ?? '').trim().toLowerCase().slice(0, 100);
|
|
149
157
|
const shown = jobs.filter((j) => !q || `${j.jobname ?? ''} ${j.command} ${j.database} ${j.username}`.toLowerCase().includes(q));
|
|
150
158
|
const failing = jobs.filter((j) => j.last_status === 'failed').length;
|
|
151
|
-
const
|
|
159
|
+
const L = { job: tx('dp.col.job'), schedule: tx('cr.col.schedule'), next: tx('cr.col.next_run'), last: tx('cr.col.last_run'), db: tx('su.database'), as: tx('cr.col.run_as') };
|
|
160
|
+
const main = html`${head(s, title, html`<a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} ${tx('cr.create')}</a><a class="btn" href="${URL_BASE}/history">${icon('list')} ${tx('cr.history')}</a>`)}
|
|
152
161
|
<div class="stat-grid u-mb1 dbu-stats">
|
|
153
|
-
<div class="stat"><b>${String(jobs.length)}</b><span
|
|
154
|
-
<div class="stat"><b>${String(jobs.filter((j) => j.active).length)}</b><span
|
|
155
|
-
<a class="stat" href="${URL_BASE}/history?status=failed"><b>${String(failing)}</b><span
|
|
162
|
+
<div class="stat"><b>${String(jobs.length)}</b><span>${tx('cr.jobs')}</span></div>
|
|
163
|
+
<div class="stat"><b>${String(jobs.filter((j) => j.active).length)}</b><span>${tx('sm.active')}</span></div>
|
|
164
|
+
<a class="stat" href="${URL_BASE}/history?status=failed"><b>${String(failing)}</b><span>${tx('cr.failed_last')}</span></a>
|
|
156
165
|
</div>
|
|
157
|
-
${region('
|
|
158
|
-
<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search"><label class="sr-only" for="cron-q"
|
|
159
|
-
<div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th
|
|
166
|
+
${region(tx('cr.pg_cron_jobs'), html`<p class="muted u-mt0">${tx.rich('cr.list_intro', { version: st.version, tz: html`<code>${st.timeZone}</code>`, setting: html`<code>cron.timezone</code>` })}${st.seesAll ? '' : html` ${tx.rich('cr.own_only', { user: html`<code>${st.user}</code>` })}`}</p>
|
|
167
|
+
<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search"><label class="sr-only" for="cron-q">${tx('cr.search')}</label><input id="cron-q" name="q" type="search" value="${q}" placeholder="${tx('cr.search')}" autocomplete="off"> <button class="btn btn-sm">${icon('search')} ${tx('common.search')}</button></form>
|
|
168
|
+
<div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>${L.job}</th><th>${L.schedule}</th><th>${L.next}</th><th>${L.last}</th><th>${L.db}</th><th>${L.as}</th></tr></thead>
|
|
160
169
|
<tbody>${shown.length ? shown.map((j) => html`<tr>
|
|
161
|
-
<td data-label="
|
|
162
|
-
<td data-label="
|
|
163
|
-
<td data-label="
|
|
164
|
-
<td data-label="
|
|
165
|
-
<td data-label="
|
|
166
|
-
: html`<tr><td colspan="6" class="empty">${jobs.length ? '
|
|
167
|
-
<details class="u-mt1"><summary
|
|
168
|
-
return send(reply, s, shell(s,
|
|
170
|
+
<td data-label="${L.job}" class="u-break"><a href="${jobUrl(j.jobid)}"><strong>${jobName(tx, j)}</strong></a>${j.active ? '' : html` <span class="tag">${tx('cr.inactive')}</span>`}<br><code class="small cron-cmd">${j.command.replace(/\s+/g, ' ').slice(0, 120)}</code></td>
|
|
171
|
+
<td data-label="${L.schedule}"><code>${j.schedule}</code><br><span class="muted small">${words(tx, j.schedule)}</span></td>
|
|
172
|
+
<td data-label="${L.next}">${nextRun(tx, j, st.timeZone)}</td>
|
|
173
|
+
<td data-label="${L.last}">${statusTag(tx, j.last_status)}${j.last_start ? html` <span class="small">${j.last_start}</span>` : ''}${j.failures > 1 ? html`<br><span class="small cron-error">${tx('cr.failures', { n: j.failures })}</span>` : ''}</td>
|
|
174
|
+
<td data-label="${L.db}">${j.database}</td><td data-label="${L.as}">${j.username}</td></tr>`)
|
|
175
|
+
: html`<tr><td colspan="6" class="empty">${jobs.length ? tx('cr.no_match') : tx.rich('cr.none_yet', { create: html`<a href="${URL_BASE}/new">${tx('cr.create_one')}</a>` })}</td></tr>`}</tbody></table></div>`)}
|
|
176
|
+
<details class="u-mt1"><summary>${tx('cr.own_work')}</summary>${await pgkilnWork(tx)}</details>`;
|
|
177
|
+
return send(reply, s, shell(s, title, crumbs(tx), main, 'sql'));
|
|
169
178
|
}
|
|
170
179
|
|
|
171
180
|
// ------------------------------------------------------------------ create / edit
|
|
@@ -191,41 +200,42 @@ function specFromBody(b: Body): JobSpec {
|
|
|
191
200
|
};
|
|
192
201
|
}
|
|
193
202
|
|
|
194
|
-
const FIELD_LABELS: [keyof CronFields,
|
|
195
|
-
['minute', '
|
|
203
|
+
const FIELD_LABELS: [keyof CronFields, BuilderKey, BuilderKey][] = [
|
|
204
|
+
['minute', 'cr.f.minute', 'cr.f.minute_help'], ['hour', 'cr.f.hour', 'cr.f.hour_help'], ['day', 'cr.f.day', 'cr.f.day_help'], ['month', 'cr.f.month', 'cr.f.month_help'], ['weekday', 'cr.f.weekday', 'cr.f.weekday_help'],
|
|
196
205
|
];
|
|
197
206
|
|
|
198
207
|
function jobForm(s: Session, spec: JobSpec, ctx: FormContext, o: { jobid: string | null; errors: string[]; fields?: CronFields | null }) {
|
|
208
|
+
const tx = tr(s);
|
|
199
209
|
const fields = o.fields ?? splitSchedule(spec.schedule) ?? { minute: '', hour: '', day: '', month: '', weekday: '' };
|
|
200
210
|
return html`${errorList(o.errors)}
|
|
201
211
|
<form method="post" action="${URL_BASE}/save" class="cron-form" data-cron-form>${csrf(s)}
|
|
202
212
|
<input type="hidden" name="jobid" value="${o.jobid ?? ''}">
|
|
203
|
-
${region('
|
|
204
|
-
<div class="field"><label class="label" for="f_name"
|
|
205
|
-
<small class="help"
|
|
206
|
-
<div class="field"><label class="label" for="f_database"
|
|
207
|
-
<small class="help"
|
|
208
|
-
<div class="field"><label class="label" for="f_username"
|
|
209
|
-
<small class="help"
|
|
210
|
-
<div class="field"><span class="label"
|
|
211
|
-
<small class="help"
|
|
212
|
-
<div class="field cron-wide"><label class="label" for="f_command"
|
|
213
|
+
${region(tx('cr.job'), html`<div class="form-grid">
|
|
214
|
+
<div class="field"><label class="label" for="f_name">${tx('scr.name')}</label><input id="f_name" name="name" value="${spec.name}" required maxlength="100" autocomplete="off" placeholder="${tx('cr.name_placeholder')}">
|
|
215
|
+
<small class="help">${tx('cr.name_help')}</small></div>
|
|
216
|
+
<div class="field"><label class="label" for="f_database">${tx('su.database')}</label><select id="f_database" name="database">${ctx.databases.map((d) => html`<option${sel(d === spec.database)}>${d}</option>`)}</select>
|
|
217
|
+
<small class="help">${tx('cr.database_help')}</small></div>
|
|
218
|
+
<div class="field"><label class="label" for="f_username">${tx('cr.col.run_as')}</label><select id="f_username" name="username">${ctx.users.map((u) => html`<option${sel(u === spec.username)}>${u}</option>`)}</select>
|
|
219
|
+
<small class="help">${tx('cr.run_as_help')}</small></div>
|
|
220
|
+
<div class="field"><span class="label">${tx('sm.col.state')}</span><label class="check"><input type="checkbox" name="active" value="1"${chk(spec.active)}> ${tx('sm.active')}</label>
|
|
221
|
+
<small class="help">${tx('cr.active_help')}</small></div>
|
|
222
|
+
<div class="field cron-wide"><label class="label" for="f_command">${tx('cr.command')}</label>
|
|
213
223
|
<textarea id="f_command" name="command" class="code sql-editor" rows="8" spellcheck="false" data-code="plpgsql" required>${spec.command}</textarea>
|
|
214
|
-
<small class="help"
|
|
224
|
+
<small class="help">${tx.code('cr.command_help')}</small></div>
|
|
215
225
|
</div>`)}
|
|
216
|
-
${region('
|
|
217
|
-
<div class="field"><label class="label" for="f_schedule"
|
|
218
|
-
<small class="help"
|
|
219
|
-
<div class="field"><label class="label" for="f_preset"
|
|
220
|
-
<button class="btn btn-sm" name="mode" value="preset" formnovalidate data-cron-nojs
|
|
226
|
+
${region(tx('cr.col.schedule'), html`<div class="form-grid">
|
|
227
|
+
<div class="field"><label class="label" for="f_schedule">${tx('cr.col.schedule')}</label><input id="f_schedule" name="schedule" value="${spec.schedule}" required maxlength="200" spellcheck="false" autocomplete="off" placeholder="0 2 * * *" data-cron-schedule aria-describedby="cron-preview">
|
|
228
|
+
<small class="help">${tx.code('cr.schedule_help')}</small></div>
|
|
229
|
+
<div class="field"><label class="label" for="f_preset">${tx('cr.presets')}</label><div class="cron-inline"><select id="f_preset" name="preset" data-cron-preset><option value="">${tx('cr.choose')}</option>${PRESETS.map(([l, v], i) => html`<option value="${v}">${tx.dyn(`cr.preset.p${i + 1}`, l)} (${v})</option>`)}</select>
|
|
230
|
+
<button class="btn btn-sm" name="mode" value="preset" formnovalidate data-cron-nojs>${tx('cr.use')}</button></div></div>
|
|
221
231
|
</div>
|
|
222
|
-
<fieldset class="cron-fields"><legend class="label"
|
|
223
|
-
<div class="cron-field-grid">${FIELD_LABELS.map(([k, l, h]) => html`<div class="field"><label class="label" for="f_cf_${k}">${l}</label><input id="f_cf_${k}" name="cf_${k}" value="${fields[k]}" placeholder="*" maxlength="60" spellcheck="false" autocomplete="off" data-cron-field="${k}"><small class="help">${h}</small></div>`)}</div>
|
|
224
|
-
<button class="btn btn-sm" name="mode" value="build" formnovalidate data-cron-nojs
|
|
232
|
+
<fieldset class="cron-fields"><legend class="label">${tx('cr.builder')}</legend>
|
|
233
|
+
<div class="cron-field-grid">${FIELD_LABELS.map(([k, l, h]) => html`<div class="field"><label class="label" for="f_cf_${k}">${tx(l)}</label><input id="f_cf_${k}" name="cf_${k}" value="${fields[k]}" placeholder="*" maxlength="60" spellcheck="false" autocomplete="off" data-cron-field="${k}"><small class="help">${tx(h)}</small></div>`)}</div>
|
|
234
|
+
<button class="btn btn-sm" name="mode" value="build" formnovalidate data-cron-nojs>${tx('cr.use_fields')}</button>
|
|
225
235
|
</fieldset>
|
|
226
|
-
<div class="cron-preview" id="cron-preview" aria-live="polite" data-cron-preview="${URL_BASE}/preview">${schedulePreview(spec.schedule || '0 2 * * *', ctx.timeZone)}</div>
|
|
227
|
-
<button class="btn btn-sm" name="mode" value="preview" formnovalidate data-cron-nojs
|
|
228
|
-
<div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')}
|
|
236
|
+
<div class="cron-preview" id="cron-preview" aria-live="polite" data-cron-preview="${URL_BASE}/preview">${schedulePreview(spec.schedule || '0 2 * * *', ctx.timeZone, tx)}</div>
|
|
237
|
+
<button class="btn btn-sm" name="mode" value="preview" formnovalidate data-cron-nojs>${tx('sd.preview')}</button>`)}
|
|
238
|
+
<div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} ${tx('cr.review')}</button><a class="btn" href="${o.jobid ? jobUrl(o.jobid) : URL_BASE}">${tx('sm.cancel')}</a></div>
|
|
229
239
|
</form>`;
|
|
230
240
|
}
|
|
231
241
|
|
|
@@ -236,25 +246,28 @@ const hiddenSpec = (spec: JobSpec, jobid: string | null) => html`<input type="hi
|
|
|
236
246
|
|
|
237
247
|
/** The review step of any change: the SQL, Apply (with the digest), Back. */
|
|
238
248
|
function reviewRegion(s: Session, stmts: string[], action: string, hidden: Raw, o: { back?: Raw | ''; apply?: string; danger?: boolean; extra?: Raw | '' } = {}) {
|
|
249
|
+
const tx = tr(s);
|
|
239
250
|
return html`${region('SQL', stmts.length
|
|
240
|
-
? html`<p class="muted u-mt0"
|
|
241
|
-
: html`<p class="muted u-mt0"
|
|
251
|
+
? html`<p class="muted u-mt0">${tx('cr.review_intro')}</p><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="${tx('qs.generated')}">${stmts.join('\n\n')}</pre>`
|
|
252
|
+
: html`<p class="muted u-mt0">${tx('cr.nothing')}</p>`)}
|
|
242
253
|
<form method="post" action="${action}" class="cron-form">${csrf(s)}${hidden}<input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">${o.extra ?? ''}
|
|
243
|
-
<div class="buttons">${stmts.length ? html`<button class="btn ${o.danger ? 'btn-danger' : 'btn-hot'}" name="mode" value="apply">${icon(o.danger ? 'trash' : 'check')} ${o.apply ?? '
|
|
254
|
+
<div class="buttons">${stmts.length ? html`<button class="btn ${o.danger ? 'btn-danger' : 'btn-hot'}" name="mode" value="apply">${icon(o.danger ? 'trash' : 'check')} ${o.apply ?? tx('qb.apply')}</button>` : ''}${o.back ?? ''}</div></form>`;
|
|
244
255
|
}
|
|
245
256
|
|
|
246
|
-
const failedBox = (message: string) => html`<div class="alert alert-error" role="alert"><p class="u-mt0"
|
|
257
|
+
const failedBox = (tx: BuilderTranslate, message: string) => html`<div class="alert alert-error" role="alert"><p class="u-mt0">${tx.rich('cr.failed_box', { nothing: html`<strong>${tx('cr.nothing_changed')}</strong>` })}</p><p class="u-m0">${message}</p></div>`;
|
|
247
258
|
|
|
248
259
|
async function editPage(reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job | null, o: { spec?: JobSpec; errors?: string[]; fields?: CronFields | null } = {}) {
|
|
260
|
+
const tx = tr(s);
|
|
249
261
|
const ctx = await formContext(st);
|
|
250
262
|
const spec = o.spec ?? (job ? specOf(job) : { name: '', schedule: '0 2 * * *', command: '', database: st.database, username: st.user, active: true });
|
|
251
|
-
const title = job ?
|
|
263
|
+
const title = job ? tx('cr.edit_title', { name: jobName(tx, job) }) : tx('cr.create');
|
|
252
264
|
const main = html`${head(s, title)}${jobForm(s, spec, ctx, { jobid: job?.jobid ?? null, errors: o.errors ?? [], fields: o.fields })}`;
|
|
253
|
-
const trail: [string, string?][] = job ? [[
|
|
254
|
-
return send(reply.code(o.errors?.length ? 422 : 200), s, shell(s, title, crumbs(...trail), main, 'sql'));
|
|
265
|
+
const trail: [string, string?][] = job ? [[jobName(tx, job), jobUrl(job.jobid)], [tx('cr.edit')]] : [[tx('cr.create_crumb')]];
|
|
266
|
+
return send(reply.code(o.errors?.length ? 422 : 200), s, shell(s, title, crumbs(tx, ...trail), main, 'sql'));
|
|
255
267
|
}
|
|
256
268
|
|
|
257
269
|
async function save(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
|
|
270
|
+
const tx = tr(s);
|
|
258
271
|
const b = req.body ?? {};
|
|
259
272
|
const jobid = one(b.jobid);
|
|
260
273
|
const job = jobid ? await oneJob(st.timeZone, jobid) : null;
|
|
@@ -272,120 +285,128 @@ async function save(req: Req, reply: FastifyReply, s: Session, st: Extract<CronS
|
|
|
272
285
|
const errors = checkJob(spec, ctx);
|
|
273
286
|
if (!errors.length) {
|
|
274
287
|
const clash = await owner.one<{ jobid: string }>('select jobid::text from cron.job where jobname = $1 and username = $2', [spec.name, spec.username]);
|
|
275
|
-
if (clash && clash.jobid !== job?.jobid) errors.push(
|
|
288
|
+
if (clash && clash.jobid !== job?.jobid) errors.push(tx('cr.err.clash', { user: spec.username, name: spec.name }));
|
|
276
289
|
}
|
|
277
290
|
if (errors.length) return editPage(reply, s, st, job, { spec, errors });
|
|
278
291
|
const stmts = jobSql(job ? { ...specOf(job), jobid: job.jobid } : null, spec);
|
|
279
|
-
const title = job ?
|
|
292
|
+
const title = job ? tx('cr.change_title', { name: job.jobname ?? tx('cr.job_n_lower', { id: job.jobid }) }) : tx('cr.create_title', { name: spec.name });
|
|
280
293
|
const page = (output: Raw | '' = '') => {
|
|
281
294
|
const main = html`${head(s, title)}${output}
|
|
282
|
-
${region('
|
|
283
|
-
${reviewRegion(s, stmts, `${URL_BASE}/save`, hiddenSpec(spec, job?.jobid ?? null), { back: html`<button class="btn" name="mode" value="edit" formnovalidate
|
|
284
|
-
const trail: [string, string?][] = job ? [[
|
|
285
|
-
return send(reply.code(output ? 422 : 200), s, shell(s, title, crumbs(...trail), main, 'sql'));
|
|
295
|
+
${region(tx('cr.col.schedule'), schedulePreview(spec.schedule, st.timeZone, tx))}
|
|
296
|
+
${reviewRegion(s, stmts, `${URL_BASE}/save`, hiddenSpec(spec, job?.jobid ?? null), { back: html`<button class="btn" name="mode" value="edit" formnovalidate>${tx('cr.back_form')}</button>` })}`;
|
|
297
|
+
const trail: [string, string?][] = job ? [[jobName(tx, job), jobUrl(job.jobid)], [tx('cr.review_crumb')]] : [[tx('cr.create_crumb'), `${URL_BASE}/new`], [tx('cr.review_crumb')]];
|
|
298
|
+
return send(reply.code(output ? 422 : 200), s, shell(s, title, crumbs(tx, ...trail), main, 'sql'));
|
|
286
299
|
};
|
|
287
300
|
if (mode !== 'apply') return page();
|
|
288
|
-
if (one(b.reviewed) !== reviewDigest(stmts)) return page(errorList(['
|
|
289
|
-
if (!stmts.length) return page(errorList(['
|
|
301
|
+
if (one(b.reviewed) !== reviewDigest(stmts)) return page(errorList([tx('cr.err.changed_form')]));
|
|
302
|
+
if (!stmts.length) return page(errorList([tx('cr.nothing')]));
|
|
290
303
|
const failed = await applySql(stmts);
|
|
291
304
|
await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}`.slice(0, 4000) });
|
|
292
|
-
if (failed) return page(failedBox(failed));
|
|
305
|
+
if (failed) return page(failedBox(tx, failed));
|
|
293
306
|
const created = job ? null : await owner.one<{ jobid: string }>('select jobid::text from cron.job where jobname = $1 and username = $2', [spec.name, spec.username]);
|
|
294
|
-
flash(s,
|
|
307
|
+
flash(s, job ? tx('cr.changed', { name: spec.name }) : tx('cr.created', { name: spec.name }));
|
|
295
308
|
return back(reply, s, job ? jobUrl(job.jobid) : created ? jobUrl(created.jobid) : URL_BASE);
|
|
296
309
|
}
|
|
297
310
|
|
|
298
311
|
// ------------------------------------------------------------------ one job
|
|
299
312
|
|
|
300
|
-
function runsTable(runs: Awaited<ReturnType<typeof listRuns>>, withJob: boolean) {
|
|
301
|
-
|
|
313
|
+
function runsTable(tx: BuilderTranslate, runs: Awaited<ReturnType<typeof listRuns>>, withJob: boolean) {
|
|
314
|
+
const L = { run: tx('cr.col.run'), job: tx('dp.col.job'), status: tx('sm.col.state'), started: tx('scr.col.started'), duration: tx('cr.col.duration'), message: tx('cr.col.message') };
|
|
315
|
+
return html`<div class="table-wrap"><table class="report report-reflow cron-runs-table"><thead><tr><th class="num">${L.run}</th>${withJob ? html`<th>${L.job}</th>` : ''}<th>${L.status}</th><th>${L.started}</th><th class="num">${L.duration}</th><th>${L.message}</th></tr></thead>
|
|
302
316
|
<tbody>${runs.length ? runs.map((r) => html`<tr>
|
|
303
|
-
<td class="num" data-label="
|
|
304
|
-
${withJob ? html`<td data-label="
|
|
305
|
-
<td data-label="
|
|
306
|
-
<td class="num" data-label="
|
|
307
|
-
<td data-label="
|
|
308
|
-
: html`<tr><td colspan="${withJob ? 6 : 5}" class="empty"
|
|
317
|
+
<td class="num" data-label="${L.run}">${r.runid}</td>
|
|
318
|
+
${withJob ? html`<td data-label="${L.job}" class="u-break">${r.jobid ? html`<a href="${jobUrl(r.jobid)}">${r.jobname ?? tx('cr.job_n', { id: r.jobid })}</a>` : ''}</td>` : ''}
|
|
319
|
+
<td data-label="${L.status}">${statusTag(tx, r.status)}</td><td data-label="${L.started}">${r.start ?? ''}</td>
|
|
320
|
+
<td class="num" data-label="${L.duration}">${secs(r.secs)}</td>
|
|
321
|
+
<td data-label="${L.message}" class="u-break"><code class="small">${(r.return_message ?? '').slice(0, 500)}</code></td></tr>`)
|
|
322
|
+
: html`<tr><td colspan="${withJob ? 6 : 5}" class="empty">${tx('cr.no_runs')}</td></tr>`}</tbody></table></div>`;
|
|
309
323
|
}
|
|
310
324
|
|
|
311
325
|
async function jobPage(reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job, output: Raw | '' = '') {
|
|
326
|
+
const tx = tr(s);
|
|
312
327
|
const runs = await listRuns(st.timeZone, { job: job.jobid, limit: 20 });
|
|
313
|
-
const name =
|
|
314
|
-
const main = html`${head(s, name, html`<a class="btn btn-hot" href="${jobUrl(job.jobid)}/edit">${icon('edit')}
|
|
315
|
-
<form method="post" action="${jobUrl(job.jobid)}/run" class="u-inline">${csrf(s)}<button class="btn" data-confirm="
|
|
316
|
-
<form method="post" action="${jobUrl(job.jobid)}/active" class="u-inline">${csrf(s)}<input type="hidden" name="active" value="${job.active ? '' : '1'}"><button class="btn">${job.active ? '
|
|
317
|
-
<a class="btn btn-danger" href="${jobUrl(job.jobid)}/unschedule">${icon('trash')}
|
|
328
|
+
const name = jobName(tx, job);
|
|
329
|
+
const main = html`${head(s, name, html`<a class="btn btn-hot" href="${jobUrl(job.jobid)}/edit">${icon('edit')} ${tx('cr.edit')}</a>
|
|
330
|
+
<form method="post" action="${jobUrl(job.jobid)}/run" class="u-inline">${csrf(s)}<button class="btn" data-confirm="${tx('cr.run_confirm', { name, user: job.username, db: job.database })}">${icon('play')} ${tx('cr.run_now')}</button></form>
|
|
331
|
+
<form method="post" action="${jobUrl(job.jobid)}/active" class="u-inline">${csrf(s)}<input type="hidden" name="active" value="${job.active ? '' : '1'}"><button class="btn">${job.active ? tx('cr.disable') : tx('cr.enable')}</button></form>
|
|
332
|
+
<a class="btn btn-danger" href="${jobUrl(job.jobid)}/unschedule">${icon('trash')} ${tx('cr.unschedule_dots')}</a>`)}
|
|
318
333
|
${output}
|
|
319
|
-
${region('
|
|
320
|
-
<dt
|
|
321
|
-
<dt
|
|
322
|
-
<dt
|
|
323
|
-
</dl><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="
|
|
324
|
-
${region('
|
|
325
|
-
${region('
|
|
326
|
-
return send(reply, s, shell(s, name, crumbs([name]), main, 'sql'));
|
|
334
|
+
${region(tx('cr.job'), html`<dl class="dbu-facts">
|
|
335
|
+
<dt>${tx('sm.col.state')}</dt><dd>${job.active ? tx('sm.active') : html`<span class="tag">${tx('cr.inactive')}</span>`}</dd>
|
|
336
|
+
<dt>${tx('cr.col.schedule')}</dt><dd><code>${job.schedule}</code></dd>
|
|
337
|
+
<dt>${tx('su.database')}</dt><dd>${job.database}</dd><dt>${tx('cr.col.run_as')}</dt><dd>${job.username}</dd><dt>${tx('cr.job_id')}</dt><dd>${job.jobid}</dd>
|
|
338
|
+
</dl><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="${tx('cr.command')}">${job.command}</pre>`)}
|
|
339
|
+
${region(tx('cr.col.schedule'), schedulePreview(job.schedule, st.timeZone, tx))}
|
|
340
|
+
${region(tx('cr.recent_runs'), html`${runsTable(tx, runs, false)}<p class="small"><a href="${URL_BASE}/history?job=${job.jobid}">${tx('cr.all_runs')}</a> · <a href="${URL_BASE}/history/purge?job=${job.jobid}">${tx('cr.purge_its')}</a></p>`)}`;
|
|
341
|
+
return send(reply, s, shell(s, name, crumbs(tx, [name]), main, 'sql'));
|
|
327
342
|
}
|
|
328
343
|
|
|
329
344
|
async function setActive(req: Req, reply: FastifyReply, s: Session, job: Job) {
|
|
345
|
+
const tx = tr(s);
|
|
330
346
|
const active = !!one(req.body?.active);
|
|
331
347
|
const stmts = activeSql(job.jobid, active);
|
|
332
348
|
const failed = await applySql(stmts);
|
|
333
349
|
await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}` });
|
|
334
|
-
if (failed) flash(s,
|
|
335
|
-
else flash(s,
|
|
350
|
+
if (failed) flash(s, tx(active ? 'cr.enable_failed' : 'cr.disable_failed', { error: failed }), 'error');
|
|
351
|
+
else flash(s, tx(active ? 'cr.enabled' : 'cr.disabled_flash', { name: job.jobname ?? tx('cr.job_n_lower', { id: job.jobid }), sql: stmts[0] }));
|
|
336
352
|
return back(reply, s, jobUrl(job.jobid));
|
|
337
353
|
}
|
|
338
354
|
|
|
339
355
|
async function unschedulePage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job) {
|
|
356
|
+
const tx = tr(s);
|
|
340
357
|
const stmts = unscheduleSql(job.jobid);
|
|
341
|
-
const name =
|
|
358
|
+
const name = jobName(tx, job);
|
|
342
359
|
let output: Raw | '' = '';
|
|
343
360
|
if (req.method === 'POST') {
|
|
344
|
-
if (one(req.body?.reviewed) !== reviewDigest(stmts)) output = errorList(['
|
|
361
|
+
if (one(req.body?.reviewed) !== reviewDigest(stmts)) output = errorList([tx('cr.err.changed')]);
|
|
345
362
|
else {
|
|
346
363
|
const failed = await applySql(stmts);
|
|
347
364
|
await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}` });
|
|
348
365
|
if (!failed) {
|
|
349
|
-
flash(s,
|
|
366
|
+
flash(s, tx('cr.unscheduled', { name }));
|
|
350
367
|
return back(reply, s, URL_BASE);
|
|
351
368
|
}
|
|
352
|
-
output = failedBox(failed);
|
|
369
|
+
output = failedBox(tx, failed);
|
|
353
370
|
}
|
|
354
371
|
}
|
|
355
|
-
const
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
372
|
+
const title = tx('cr.unschedule_title', { name });
|
|
373
|
+
const main = html`${head(s, title)}${output}
|
|
374
|
+
<div class="alert alert-info" role="status">${tx('cr.unschedule_note')}</div>
|
|
375
|
+
${reviewRegion(s, stmts, `${jobUrl(job.jobid)}/unschedule`, html``, { danger: true, apply: tx('cr.unschedule'), back: html`<a class="btn" href="${jobUrl(job.jobid)}">${tx('sm.cancel')}</a>` })}`;
|
|
376
|
+
return send(reply.code(output ? 422 : 200), s, shell(s, title, crumbs(tx, [name, jobUrl(job.jobid)], [tx('cr.unschedule')]), main, 'sql'));
|
|
359
377
|
}
|
|
360
378
|
|
|
361
379
|
async function runJobNow(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job) {
|
|
380
|
+
const tx = tr(s);
|
|
362
381
|
const r = await runNow(job);
|
|
363
382
|
await logActivity({ username: s.username, event: 'cron_run_now', ip: clientIp(req), elapsedMs: Math.round(r.ms), detail: `job ${job.jobid} ${job.jobname ?? ''}: ${r.ok ? 'ok' : 'failed'}: ${r.message}`.slice(0, 4000) });
|
|
364
|
-
const out = html`<div class="alert ${r.ok ? 'alert-success' : 'alert-error'}" role="${r.ok ? 'status' : 'alert'}"><p class="u-mt0"
|
|
383
|
+
const out = html`<div class="alert ${r.ok ? 'alert-success' : 'alert-error'}" role="${r.ok ? 'status' : 'alert'}"><p class="u-mt0">${tx.rich('cr.ran', { what: html`<strong>${r.ok ? tx('cr.ran_ok') : tx('cr.ran_failed')}</strong>`, secs: secs(r.ms / 1000), user: job.username, db: job.database, message: html`<code>${r.message}</code>` })}</p>
|
|
365
384
|
${r.notices.length ? html`<pre class="source small">${r.notices.join('\n')}</pre>` : ''}
|
|
366
|
-
<p class="small u-m0"
|
|
385
|
+
<p class="small u-m0">${tx('cr.run_now_note')}</p></div>`;
|
|
367
386
|
return jobPage(reply.code(r.ok ? 200 : 422), s, st, job, out);
|
|
368
387
|
}
|
|
369
388
|
|
|
370
389
|
// ------------------------------------------------------------------ history
|
|
371
390
|
|
|
372
391
|
async function historyPage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
|
|
392
|
+
const tx = tr(s);
|
|
373
393
|
const f = { job: String(req.query.job ?? ''), status: String(req.query.status ?? ''), q: String(req.query.q ?? '').trim().slice(0, 100) };
|
|
374
394
|
const [jobs, runs] = await Promise.all([listJobs(st.timeZone), listRuns(st.timeZone, { ...f, limit: 200 })]);
|
|
375
|
-
const main = html`${head(s, '
|
|
376
|
-
${region('
|
|
395
|
+
const main = html`${head(s, tx('cr.history'), html`<a class="btn" href="${URL_BASE}/history/purge">${icon('trash')} ${tx('cr.purge_dots')}</a>`)}
|
|
396
|
+
${region(tx('cr.runs'), html`<p class="muted u-mt0">${tx.rich('cr.history_intro', { table: html`<code>cron.job_run_details</code>`, tz: html`<code>${st.timeZone}</code>` })}</p>
|
|
377
397
|
<form method="get" action="${URL_BASE}/history" class="dbu-search u-mb1" role="search">
|
|
378
|
-
<label class="sr-only" for="h-job"
|
|
379
|
-
<label class="sr-only" for="h-status"
|
|
380
|
-
<label class="sr-only" for="h-q"
|
|
381
|
-
<button class="btn btn-sm">${icon('filter')}
|
|
382
|
-
${runsTable(runs, true)}`)}`;
|
|
383
|
-
return send(reply, s, shell(s, '
|
|
398
|
+
<label class="sr-only" for="h-job">${tx('dp.col.job')}</label><select id="h-job" name="job"><option value="">${tx('cr.all_jobs')}</option>${jobs.map((j) => html`<option value="${j.jobid}"${sel(j.jobid === f.job)}>${jobName(tx, j)}</option>`)}</select>
|
|
399
|
+
<label class="sr-only" for="h-status">${tx('sm.col.state')}</label><select id="h-status" name="status"><option value="">${tx('cr.any_status')}</option>${RUN_STATUSES.map((x) => html`<option value="${x}"${sel(x === f.status)}>${tx.dyn(`cr.status.${x}`, x)}</option>`)}</select>
|
|
400
|
+
<label class="sr-only" for="h-q">${tx('cr.search_messages_label')}</label><input id="h-q" name="q" type="search" value="${f.q}" placeholder="${tx('cr.search_messages')}" autocomplete="off">
|
|
401
|
+
<button class="btn btn-sm">${icon('filter')} ${tx('cr.filter')}</button></form>
|
|
402
|
+
${runsTable(tx, runs, true)}`)}`;
|
|
403
|
+
return send(reply, s, shell(s, tx('cr.history'), crumbs(tx, [tx('cr.history')]), main, 'sql'));
|
|
384
404
|
}
|
|
385
405
|
|
|
386
|
-
const PURGE_DAYS: [string,
|
|
406
|
+
const PURGE_DAYS: [string, BuilderKey][] = [['7', 'cr.older_7'], ['30', 'cr.older_30'], ['90', 'cr.older_90'], ['all', 'cr.purge_all']];
|
|
387
407
|
|
|
388
408
|
async function purgePage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
|
|
409
|
+
const tx = tr(s);
|
|
389
410
|
const src = req.method === 'POST' ? (req.body ?? {}) : req.query;
|
|
390
411
|
const dayKey = PURGE_DAYS.some(([k]) => k === one(src.days)) ? one(src.days) : '30';
|
|
391
412
|
const days = dayKey === 'all' ? null : Number(dayKey);
|
|
@@ -395,27 +416,27 @@ async function purgePage(req: Req, reply: FastifyReply, s: Session, st: Extract<
|
|
|
395
416
|
const stmts = purgeSql(days, job?.jobid ?? null);
|
|
396
417
|
let output: Raw | '' = '';
|
|
397
418
|
if (req.method === 'POST') {
|
|
398
|
-
if (one(src.reviewed) !== reviewDigest(stmts)) output = errorList(['
|
|
419
|
+
if (one(src.reviewed) !== reviewDigest(stmts)) output = errorList([tx('cr.err.changed')]);
|
|
399
420
|
else {
|
|
400
421
|
const failed = await applySql(stmts);
|
|
401
422
|
await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}` });
|
|
402
423
|
if (!failed) {
|
|
403
|
-
flash(s, '
|
|
424
|
+
flash(s, tx('cr.purged'));
|
|
404
425
|
return back(reply, s, `${URL_BASE}/history${job ? `?job=${job.jobid}` : ''}`);
|
|
405
426
|
}
|
|
406
|
-
output = failedBox(failed);
|
|
427
|
+
output = failedBox(tx, failed);
|
|
407
428
|
}
|
|
408
429
|
}
|
|
409
430
|
const n = await countPurge(days, job?.jobid ?? null);
|
|
410
|
-
const main = html`${head(s, '
|
|
411
|
-
${region('
|
|
412
|
-
<label for="p-days"
|
|
413
|
-
<select id="p-days" name="days">${PURGE_DAYS.map(([k, l]) => html`<option value="${k}"${sel(k === dayKey)}>${l}</option>`)}</select>
|
|
414
|
-
<button class="btn btn-sm">${icon('eye')}
|
|
415
|
-
<p class="u-m0">${
|
|
431
|
+
const main = html`${head(s, tx('cr.purge_title'))}${output}
|
|
432
|
+
${region(tx('cr.which_runs'), html`<form method="get" action="${URL_BASE}/history/purge" class="dbu-search">${job ? html`<input type="hidden" name="job" value="${job.jobid}">` : ''}
|
|
433
|
+
<label for="p-days">${job ? tx.rich('cr.runs_of', { name: html`<strong>${job.jobname ?? tx('cr.job_n_lower', { id: job.jobid })}</strong>` }) : tx('cr.runs')}</label>
|
|
434
|
+
<select id="p-days" name="days">${PURGE_DAYS.map(([k, l]) => html`<option value="${k}"${sel(k === dayKey)}>${tx(l)}</option>`)}</select>
|
|
435
|
+
<button class="btn btn-sm">${icon('eye')} ${tx('cr.review')}</button></form>
|
|
436
|
+
<p class="u-m0">${tx(n === 1 ? 'cr.would_delete_one' : 'cr.would_delete', { n })}</p>`)}
|
|
416
437
|
${reviewRegion(s, stmts, `${URL_BASE}/history/purge`, html`<input type="hidden" name="days" value="${dayKey}">${job ? html`<input type="hidden" name="job" value="${job.jobid}">` : ''}`,
|
|
417
|
-
{ danger: true, apply: '
|
|
418
|
-
return send(reply.code(output ? 422 : 200), s, shell(s, '
|
|
438
|
+
{ danger: true, apply: tx('cr.purge'), back: html`<a class="btn" href="${URL_BASE}/history">${tx('sm.cancel')}</a>` })}`;
|
|
439
|
+
return send(reply.code(output ? 422 : 200), s, shell(s, tx('cr.purge_title'), crumbs(tx, [tx('cr.history'), `${URL_BASE}/history`], [tx('cr.purge')]), main, 'sql'));
|
|
419
440
|
}
|
|
420
441
|
|
|
421
442
|
// ------------------------------------------------------------------ routes
|
|
@@ -452,7 +473,7 @@ export async function schedulingRoutes(app: FastifyInstance) {
|
|
|
452
473
|
const s = await admin(req, reply);
|
|
453
474
|
if (!s) return;
|
|
454
475
|
const st = await cronState();
|
|
455
|
-
return reply.type('text/html').send(String(schedulePreview(String(req.query.schedule ?? '').slice(0, 300), st.ok ? st.timeZone : 'GMT')));
|
|
476
|
+
return reply.type('text/html').send(String(schedulePreview(String(req.query.schedule ?? '').slice(0, 300), st.ok ? st.timeZone : 'GMT', tr(s))));
|
|
456
477
|
});
|
|
457
478
|
app.get(`${URL_BASE}/new`, async (req: Req, reply) => {
|
|
458
479
|
const r = await ready(req, reply);
|