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.
Files changed (61) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/docs/apex-feature-parity.md +3 -3
  3. package/docs/guide/03-builder.md +8 -1
  4. package/docs/guide/04-pages-and-regions.md +2 -1
  5. package/docs/guide/07-dynamic-actions.md +27 -0
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +9 -1
  8. package/package.json +1 -1
  9. package/public/app.css +2 -0
  10. package/public/app.js +85 -4
  11. package/public/builder.css +25 -2
  12. package/public/data-modeler.js +8 -5
  13. package/public/sql-monitor.js +5 -2
  14. package/src/builder/ai-builder.ts +50 -46
  15. package/src/builder/api.ts +49 -49
  16. package/src/builder/autorest.ts +92 -87
  17. package/src/builder/changelog.ts +39 -37
  18. package/src/builder/components.ts +5 -4
  19. package/src/builder/data-modeler.ts +89 -84
  20. package/src/builder/data-security.ts +39 -37
  21. package/src/builder/dataload.ts +69 -65
  22. package/src/builder/datapump.ts +108 -97
  23. package/src/builder/ddl-tools.ts +38 -34
  24. package/src/builder/document-view.ts +11 -11
  25. package/src/builder/i18n/de.ts +1627 -0
  26. package/src/builder/i18n/en.ts +1627 -0
  27. package/src/builder/i18n/es.ts +1627 -0
  28. package/src/builder/i18n/fr.ts +1627 -0
  29. package/src/builder/i18n/it.ts +1627 -0
  30. package/src/builder/i18n/ja.ts +1627 -0
  31. package/src/builder/i18n/ko.ts +1627 -0
  32. package/src/builder/i18n/nl.ts +1627 -0
  33. package/src/builder/i18n/pt.ts +1627 -0
  34. package/src/builder/i18n/zh-cn.ts +1627 -0
  35. package/src/builder/i18n/zh-tw.ts +1627 -0
  36. package/src/builder/i18n.ts +18 -0
  37. package/src/builder/object-actions.ts +18 -16
  38. package/src/builder/object-browser.ts +2 -2
  39. package/src/builder/querybuilder.ts +42 -36
  40. package/src/builder/quicksql.ts +16 -18
  41. package/src/builder/region-settings.ts +201 -199
  42. package/src/builder/report-settings.ts +64 -60
  43. package/src/builder/rest-modules.ts +145 -143
  44. package/src/builder/sampledata.ts +59 -52
  45. package/src/builder/scheduling.ts +180 -159
  46. package/src/builder/scripts.ts +71 -65
  47. package/src/builder/sql-charts.ts +36 -30
  48. package/src/builder/sql-monitor.ts +95 -89
  49. package/src/builder/sql-utilities.ts +47 -40
  50. package/src/builder/top-sql.ts +21 -17
  51. package/src/builder/unload.ts +48 -44
  52. package/src/builder/worksheet.ts +3 -3
  53. package/src/cron-schedule.ts +47 -17
  54. package/src/metadata.ts +3 -1
  55. package/src/runtime/calendar.ts +3 -2
  56. package/src/runtime/context.ts +2 -0
  57. package/src/runtime/grid.ts +1 -0
  58. package/src/runtime/maps.ts +7 -4
  59. package/src/runtime/render.ts +6 -1
  60. package/src/runtime/routes.ts +4 -0
  61. 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?][] => [['SQL Workshop', `${BASE}/sql`], [TITLE, more.length ? URL_BASE : undefined], ...more];
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 main = html`<h1 class="u-mb1">${TITLE}</h1><div class="alert alert-error" role="alert">Only administrators schedule database jobs: pg_cron runs them on the server, as any role.</div>`;
41
- await send(reply.code(403), s, shell(s, TITLE, crumbs(), main, 'sql'));
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">SQL Workshop</h1>${workshopTabs('scheduling', s)}
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">Next runs (${timeZone}):</p><ol class="cron-runs">${runs.map((d) => html`<li><time>${formatRun(d, cronTimeZone(timeZone), sc.kind === 'seconds')}</time></li>`)}</ol>`
57
- : html`<p class="muted small">No scheduled times: it runs when the database server starts.</p>`}`;
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">inactive</span>`;
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">at server start</span>`;
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">never ran</span>`
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')}</code>`);
91
96
  const steps = html`<ol class="u-m0">
92
- <li>Install the pg_cron package on the database server (for example <code>apt install postgresql-17-cron</code>, or your cloud provider's pg_cron option).</li>
93
- <li>In <code>postgresql.conf</code> (or <code>alter system set …</code>): <code>shared_preload_libraries = 'pg_cron'</code> and <code>cron.database_name = '${st.database}'</code>; optionally <code>cron.timezone</code> (GMT unless set). Restart PostgreSQL.</li>
94
- <li>As a superuser, in this database: <code>create extension pg_cron;</code></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`pg_cron isn't installed on this database server.`,
98
- not_loaded: html`pg_cron is installed on the server but not loaded: it isn't in <code>shared_preload_libraries</code>.`,
99
- other_database: html`pg_cron runs in the database <code>${st.cronDatabase ?? 'postgres'}</code> (<code>cron.database_name</code>), not in <code>${st.database}</code>. Set <code>cron.database_name = '${st.database}'</code> and restart, or schedule from that database (jobs can still run here with <code>cron.schedule_in_database</code>).`,
100
- not_created: html`pg_cron is loaded, but the extension isn't created in this database yet.`,
101
- no_access: html`pg_cron is installed, but pgkiln's owner connection can't read its jobs: ${st.message ?? ''}. Grant it access (as a superuser: <code>grant usage on schema cron to …</code>), or connect pgkiln as the role that owns the jobs.`,
102
- old: html`This pg_cron (${st.message ?? '?'}) is too old: pgkiln needs version 1.4 or later (<code>cron.schedule_in_database</code>, <code>cron.alter_job</code>). <code>alter extension pg_cron update;</code>`,
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>To schedule SQL with pg_cron:</p>${steps}`}
106
- <p class="u-m0">pgkiln's own scheduler works without it: an application's <strong>Shared Components › Automations</strong> run SQL on a schedule as the application, and background execution chains run as <strong>process jobs</strong>. They are listed below.</p></div>`;
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
- return html`${region('Automations', html`<p class="muted u-mt0">Scheduled by pgkiln itself, per application (Shared Components › Automations), in each automation's time zone.</p>
121
- <div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>Application</th><th>Automation</th><th>Schedule</th><th>Next run</th><th>Last run</th></tr></thead>
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="Application"><a href="${BASE}/apps/${x.app_id}">${x.alias}</a></td>
124
- <td data-label="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">disabled</span>`}</td>
125
- <td data-label="Schedule"><code>${x.schedule}</code><br><span class="muted small">${words(x.schedule)} (${x.time_zone})</span></td>
126
- <td data-label="Next run">${x.enabled ? x.next_run ?? html`<span class="muted">soon</span>` : html`<span class="muted">disabled</span>`}</td>
127
- <td data-label="Last run">${x.last_run ?? html`<span class="muted">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>`)
128
- : html`<tr><td colspan="5" class="empty">No automations.</td></tr>`}</tbody></table></div>`)}
129
- ${region('Process jobs', html`<p class="muted u-mt0">The latest execution chains that pages started in the background.</p>
130
- <div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>Application</th><th>Job</th><th>State</th><th>Started by</th><th>Queued</th><th>Ended</th></tr></thead>
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="Application"><a href="${BASE}/apps/${x.app_id}">${x.alias}</a></td>
133
- <td data-label="Job" class="u-break">${x.name}${x.error ? html`<br><span class="muted small">${x.error.slice(0, 300)}</span>` : ''}</td>
134
- <td data-label="State"><span class="tag ${x.state === 'completed' ? 'tag-ok' : x.state === 'failed' ? 'tag-error' : 'tag-info'}">${x.state}</span></td>
135
- <td data-label="Started by">${x.app_user}</td><td data-label="Queued">${x.queued}</td><td data-label="Ended">${x.ended ?? ''}</td></tr>`)
136
- : html`<tr><td colspan="6" class="empty">No process jobs.</td></tr>`}</tbody></table></div>`)}`;
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, 'Scheduling')}${installNote(st)}${await pgkilnWork()}`;
145
- return send(reply, s, shell(s, TITLE, crumbs(), main, 'sql'));
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 main = html`${head(s, 'Scheduling', html`<a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} Create job</a><a class="btn" href="${URL_BASE}/history">${icon('list')} Run history</a>`)}
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>Jobs</span></div>
154
- <div class="stat"><b>${String(jobs.filter((j) => j.active).length)}</b><span>Active</span></div>
155
- <a class="stat" href="${URL_BASE}/history?status=failed"><b>${String(failing)}</b><span>Failed on their last run</span></a>
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('pg_cron jobs', html`<p class="muted u-mt0">pg_cron ${st.version} runs these jobs on the database server; times are in its time zone <code>${st.timeZone}</code> (<code>cron.timezone</code>).${st.seesAll ? '' : html` pgkiln's owner connection (<code>${st.user}</code>) sees only its own jobs: pg_cron shows each role its own.`}</p>
158
- <form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search"><label class="sr-only" for="cron-q">Search jobs</label><input id="cron-q" name="q" type="search" value="${q}" placeholder="Search jobs" autocomplete="off"> <button class="btn btn-sm">${icon('search')} Search</button></form>
159
- <div class="table-wrap"><table class="report report-reflow cron-list"><thead><tr><th>Job</th><th>Schedule</th><th>Next run</th><th>Last run</th><th>Database</th><th>Run as</th></tr></thead>
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="Job" class="u-break"><a href="${jobUrl(j.jobid)}"><strong>${j.jobname ?? `Job ${j.jobid}`}</strong></a>${j.active ? '' : html` <span class="tag">inactive</span>`}<br><code class="small cron-cmd">${j.command.replace(/\s+/g, ' ').slice(0, 120)}</code></td>
162
- <td data-label="Schedule"><code>${j.schedule}</code><br><span class="muted small">${words(j.schedule)}</span></td>
163
- <td data-label="Next run">${nextRun(j, st.timeZone)}</td>
164
- <td data-label="Last run">${statusTag(j.last_status)}${j.last_start ? html` <span class="small">${j.last_start}</span>` : ''}${j.failures > 1 ? html`<br><span class="small cron-error">${plural(j.failures, 'failure')} in the last 10 runs</span>` : ''}</td>
165
- <td data-label="Database">${j.database}</td><td data-label="Run as">${j.username}</td></tr>`)
166
- : html`<tr><td colspan="6" class="empty">${jobs.length ? 'No jobs match.' : html`No jobs yet. <a href="${URL_BASE}/new">Create one</a>.`}</td></tr>`}</tbody></table></div>`)}
167
- <details class="u-mt1"><summary>pgkiln's own scheduled work (automations, process jobs)</summary>${await pgkilnWork()}</details>`;
168
- return send(reply, s, shell(s, TITLE, crumbs(), main, 'sql'));
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, string, string][] = [
195
- ['minute', 'Minute', '0-59'], ['hour', 'Hour', '0-23'], ['day', 'Day of month', '1-31, $ = last'], ['month', 'Month', '1-12 or jan-dec'], ['weekday', 'Day of week', '0-6 (0 = Sunday) or sun-sat'],
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('Job', html`<div class="form-grid">
204
- <div class="field"><label class="label" for="f_name">Name</label><input id="f_name" name="name" value="${spec.name}" required maxlength="100" autocomplete="off" placeholder="nightly vacuum">
205
- <small class="help">Unique per role it runs as.</small></div>
206
- <div class="field"><label class="label" for="f_database">Database</label><select id="f_database" name="database">${ctx.databases.map((d) => html`<option${sel(d === spec.database)}>${d}</option>`)}</select>
207
- <small class="help">pg_cron connects to it to run the command.</small></div>
208
- <div class="field"><label class="label" for="f_username">Run as</label><select id="f_username" name="username">${ctx.users.map((u) => html`<option${sel(u === spec.username)}>${u}</option>`)}</select>
209
- <small class="help">The role the command runs as. Only a superuser schedules jobs for another role.</small></div>
210
- <div class="field"><span class="label">State</span><label class="check"><input type="checkbox" name="active" value="1"${chk(spec.active)}> Active</label>
211
- <small class="help">An inactive job keeps its schedule and doesn't run.</small></div>
212
- <div class="field cron-wide"><label class="label" for="f_command">Command</label>
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">SQL run outside a transaction block, as pg_cron runs it: <code>vacuum analyze</code> works; several statements each commit on their own.</small></div>
224
+ <small class="help">${tx.code('cr.command_help')}</small></div>
215
225
  </div>`)}
216
- ${region('Schedule', html`<div class="form-grid">
217
- <div class="field"><label class="label" for="f_schedule">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">
218
- <small class="help">Five fields, <code>minute hour day-of-month month day-of-week</code>; <code>n seconds</code> (1 to 59); or <code>@hourly</code>, <code>@daily</code>, <code>@weekly</code>, <code>@monthly</code>, <code>@yearly</code>, <code>@reboot</code>.</small></div>
219
- <div class="field"><label class="label" for="f_preset">Common schedules</label><div class="cron-inline"><select id="f_preset" name="preset" data-cron-preset><option value="">Choose…</option>${PRESETS.map(([l, v]) => html`<option value="${v}">${l} (${v})</option>`)}</select>
220
- <button class="btn btn-sm" name="mode" value="preset" formnovalidate data-cron-nojs>Use</button></div></div>
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">Schedule builder</legend>
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>Use these fields</button>
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>Preview</button>`)}
228
- <div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} Review SQL</button><a class="btn" href="${o.jobid ? jobUrl(o.jobid) : URL_BASE}">Cancel</a></div>
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">These statements run in one transaction on pgkiln's owner connection when you apply them: all of them, or none.</p><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
241
- : html`<p class="muted u-mt0">Nothing to change.</p>`)}
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 ?? 'Apply'}</button>` : ''}${o.back ?? ''}</div></form>`;
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"><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p class="u-m0">${message}</p></div>`;
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 ? `Edit ${job.jobname ?? `job ${job.jobid}`}` : 'Create 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 ? [[job.jobname ?? `Job ${job.jobid}`, jobUrl(job.jobid)], ['Edit']] : [['Create']];
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(`${spec.username} has a job named ${spec.name} already.`);
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 ? `Change ${job.jobname ?? `job ${job.jobid}`}` : `Create job ${spec.name}`;
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('Schedule', schedulePreview(spec.schedule, st.timeZone))}
283
- ${reviewRegion(s, stmts, `${URL_BASE}/save`, hiddenSpec(spec, job?.jobid ?? null), { back: html`<button class="btn" name="mode" value="edit" formnovalidate>Back to the form</button>` })}`;
284
- const trail: [string, string?][] = job ? [[job.jobname ?? `Job ${job.jobid}`, jobUrl(job.jobid)], ['Review']] : [['Create', `${URL_BASE}/new`], ['Review']];
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(['The statements changed since you reviewed them (the job or the form changed): review them again.']));
289
- if (!stmts.length) return page(errorList(['Nothing to change.']));
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, `${job ? 'Changed' : 'Created'} the job ${spec.name}.`);
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
- return html`<div class="table-wrap"><table class="report report-reflow cron-runs-table"><thead><tr><th class="num">Run</th>${withJob ? html`<th>Job</th>` : ''}<th>Status</th><th>Started</th><th class="num">Duration</th><th>Message</th></tr></thead>
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="Run">${r.runid}</td>
304
- ${withJob ? html`<td data-label="Job" class="u-break">${r.jobid ? html`<a href="${jobUrl(r.jobid)}">${r.jobname ?? `Job ${r.jobid}`}</a>` : ''}</td>` : ''}
305
- <td data-label="Status">${statusTag(r.status)}</td><td data-label="Started">${r.start ?? ''}</td>
306
- <td class="num" data-label="Duration">${secs(r.secs)}</td>
307
- <td data-label="Message" class="u-break"><code class="small">${(r.return_message ?? '').slice(0, 500)}</code></td></tr>`)
308
- : html`<tr><td colspan="${withJob ? 6 : 5}" class="empty">No runs.</td></tr>`}</tbody></table></div>`;
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 = job.jobname ?? `Job ${job.jobid}`;
314
- const main = html`${head(s, name, html`<a class="btn btn-hot" href="${jobUrl(job.jobid)}/edit">${icon('edit')} Edit</a>
315
- <form method="post" action="${jobUrl(job.jobid)}/run" class="u-inline">${csrf(s)}<button class="btn" data-confirm="Run ${name} now, as ${job.username} in ${job.database}?">${icon('play')} Run now</button></form>
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 ? 'Disable' : 'Enable'}</button></form>
317
- <a class="btn btn-danger" href="${jobUrl(job.jobid)}/unschedule">${icon('trash')} Unschedule…</a>`)}
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('Job', html`<dl class="dbu-facts">
320
- <dt>State</dt><dd>${job.active ? 'Active' : html`<span class="tag">inactive</span>`}</dd>
321
- <dt>Schedule</dt><dd><code>${job.schedule}</code></dd>
322
- <dt>Database</dt><dd>${job.database}</dd><dt>Run as</dt><dd>${job.username}</dd><dt>Job id</dt><dd>${job.jobid}</dd>
323
- </dl><pre class="source cron-sql" data-code-view="sql" tabindex="0" aria-label="Command">${job.command}</pre>`)}
324
- ${region('Schedule', schedulePreview(job.schedule, st.timeZone))}
325
- ${region('Recent runs', html`${runsTable(runs, false)}<p class="small"><a href="${URL_BASE}/history?job=${job.jobid}">All runs of this job</a> · <a href="${URL_BASE}/history/purge?job=${job.jobid}">Purge its history</a></p>`)}`;
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, `${active ? 'Enabling' : 'Disabling'} failed: ${failed}`, 'error');
335
- else flash(s, `${active ? 'Enabled' : 'Disabled'} ${job.jobname ?? `job ${job.jobid}`}: ${stmts[0]}`);
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 = job.jobname ?? `Job ${job.jobid}`;
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(['The statements changed since you reviewed them: review them again.']);
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, `Unscheduled ${name}. Its run history stays until you purge it.`);
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 main = html`${head(s, `Unschedule ${name}`)}${output}
356
- <div class="alert alert-info" role="status">pg_cron forgets the job; its runs stay in the history until purged.</div>
357
- ${reviewRegion(s, stmts, `${jobUrl(job.jobid)}/unschedule`, html``, { danger: true, apply: 'Unschedule', back: html`<a class="btn" href="${jobUrl(job.jobid)}">Cancel</a>` })}`;
358
- return send(reply.code(output ? 422 : 200), s, shell(s, `Unschedule ${name}`, crumbs([name, jobUrl(job.jobid)], ['Unschedule']), main, 'sql'));
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"><strong>${r.ok ? 'Ran' : 'Failed'}</strong> in ${secs(r.ms / 1000)}, as ${job.username} in ${job.database}: <code>${r.message}</code></p>
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">Run now runs the command from pgkiln, not from pg_cron: it isn't in pg_cron's run history; pgkiln's activity log has it.</p></div>`;
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, 'Run history', html`<a class="btn" href="${URL_BASE}/history/purge">${icon('trash')} Purge…</a>`)}
376
- ${region('Runs', html`<p class="muted u-mt0">pg_cron's <code>cron.job_run_details</code>, newest first (200 at most); times in <code>${st.timeZone}</code>. pg_cron keeps every run until it is purged.</p>
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">Job</label><select id="h-job" name="job"><option value="">All jobs</option>${jobs.map((j) => html`<option value="${j.jobid}"${sel(j.jobid === f.job)}>${j.jobname ?? `Job ${j.jobid}`}</option>`)}</select>
379
- <label class="sr-only" for="h-status">Status</label><select id="h-status" name="status"><option value="">Any status</option>${RUN_STATUSES.map((x) => html`<option${sel(x === f.status)}>${x}</option>`)}</select>
380
- <label class="sr-only" for="h-q">Search messages and commands</label><input id="h-q" name="q" type="search" value="${f.q}" placeholder="Search messages" autocomplete="off">
381
- <button class="btn btn-sm">${icon('filter')} Filter</button></form>
382
- ${runsTable(runs, true)}`)}`;
383
- return send(reply, s, shell(s, 'Run history', crumbs(['Run history']), main, 'sql'));
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, string][] = [['7', 'older than 7 days'], ['30', 'older than 30 days'], ['90', 'older than 90 days'], ['all', 'all']];
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(['The statements changed since you reviewed them: review them again.']);
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, 'Purged the run history.');
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, 'Purge run history')}${output}
411
- ${region('Which runs', html`<form method="get" action="${URL_BASE}/history/purge" class="dbu-search">${job ? html`<input type="hidden" name="job" value="${job.jobid}">` : ''}
412
- <label for="p-days">Runs${job ? html` of <strong>${job.jobname ?? `job ${job.jobid}`}</strong>` : ''}</label>
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')} Review SQL</button></form>
415
- <p class="u-m0">${plural(n, 'run')} would be deleted.</p>`)}
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: 'Purge', back: html`<a class="btn" href="${URL_BASE}/history">Cancel</a>` })}`;
418
- return send(reply.code(output ? 422 : 200), s, shell(s, 'Purge run history', crumbs(['Run history', `${URL_BASE}/history`], ['Purge']), main, 'sql'));
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);