pgkiln 0.48.0 → 0.49.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 (48) hide show
  1. package/SECURITY.md +2 -2
  2. package/db/migrations/116_dump_jobs.sql +41 -0
  3. package/db/migrations/117_rest_handlers.sql +63 -0
  4. package/db/migrations/118_change_log.sql +54 -0
  5. package/docs/apex-feature-parity.md +7 -7
  6. package/docs/guide/01-installation.md +4 -0
  7. package/docs/guide/03-builder.md +179 -5
  8. package/docs/guide/08-security.md +11 -0
  9. package/docs/guide/12-development.md +44 -6
  10. package/docs/guide/13-rest-api.md +59 -1
  11. package/docs/guide/15-extensions.md +1 -1
  12. package/examples/hr/hr_62_rest_module.sql +39 -0
  13. package/package.json +1 -1
  14. package/public/app.css +1 -1
  15. package/public/builder-icons.svg +2 -0
  16. package/public/builder.css +420 -173
  17. package/public/builder.js +48 -0
  18. package/public/sql-monitor.js +73 -0
  19. package/src/app.ts +6 -0
  20. package/src/automations.ts +6 -0
  21. package/src/binds.ts +2 -1
  22. package/src/builder/advisor.ts +24 -2
  23. package/src/builder/autorest.ts +3 -2
  24. package/src/builder/changelog.ts +240 -0
  25. package/src/builder/components.ts +1 -1
  26. package/src/builder/datapump.ts +364 -0
  27. package/src/builder/ddl-tools.ts +13 -4
  28. package/src/builder/find-workspace.ts +2 -2
  29. package/src/builder/plan-view.ts +25 -0
  30. package/src/builder/rest-modules.ts +629 -0
  31. package/src/builder/rest.ts +1 -0
  32. package/src/builder/routes.ts +4 -2
  33. package/src/builder/scheduling.ts +495 -0
  34. package/src/builder/sql-monitor.ts +397 -0
  35. package/src/builder/sql-tools.ts +6 -2
  36. package/src/builder/sql-utilities.ts +7 -39
  37. package/src/builder/sql.ts +6 -0
  38. package/src/builder/table-editor.ts +4 -1
  39. package/src/builder/top-sql.ts +24 -6
  40. package/src/builder/ui.ts +6 -4
  41. package/src/builder/worksheet.ts +5 -21
  42. package/src/changelog.ts +109 -0
  43. package/src/cron-schedule.ts +282 -0
  44. package/src/datapump.ts +546 -0
  45. package/src/dumpscript.ts +407 -0
  46. package/src/pgcron.ts +225 -0
  47. package/src/runtime/rest-handlers.ts +75 -0
  48. package/src/runtime/rest.ts +116 -22
@@ -18,6 +18,7 @@ export function restExtras(a: { id: number; alias: string }, row: { name: string
18
18
  <td data-label="Type">${h.type}</td><td data-label="Access">${h.auth === 'public' ? 'public' : h.roles?.length ? `token, role ${h.roles.join(' or ')}` : 'token'}</td></tr>`)}</tbody></table></div>
19
19
  ${example ? html`<p class="muted small">Try it with a token from <a href="/builder/apps/${a.id}/api">REST API</a>:</p>
20
20
  <pre class="source">curl ${base}/${example.path.replace(/:([a-z_]+)/gi, '1')} -H "Authorization: Bearer $TOKEN"</pre>` : ''}
21
+ <p class="muted small">Edit its templates and handlers, and try them, in <a href="/builder/sql/rest/modules">SQL Workshop › RESTful Services › Modules</a>.</p>
21
22
  <p class="wrap-anywhere">OpenAPI description: <a href="${base}/openapi.json" target="_blank" rel="noopener"><code>${base}/openapi.json</code></a></p>
22
23
  </fieldset>`;
23
24
  }
@@ -13,7 +13,7 @@ import { documentShell } from '../layout.ts';
13
13
  import { passwordProblem } from '../accounts.ts';
14
14
  import { clientIp, createSession, destroySession, getSession, loginThrottled, logActivity, saveState, takeFlash } from '../session.ts';
15
15
  import { loginSettingsFields, saveLoginSettings } from './login-page.ts';
16
- import { APP_COLORS, appHeader, back, BASE, designerUrl, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
16
+ import { APP_COLORS, appHeader, back, BASE, brandMark, designerUrl, builderHead, csrf, developer, flash, input, region, select, send, shell, THEME_COOKIE, validTheme, type Req } from './ui.ts';
17
17
  import { appOr404 } from './forms.ts';
18
18
  import { docToFiles, filesToZip } from '../appfiles.ts';
19
19
  import { homeRoutes, rememberApp } from './home.ts';
@@ -61,7 +61,7 @@ export async function builderRoutes(app: FastifyInstance) {
61
61
  'Sign in · Builder',
62
62
  html`<main class="signin">
63
63
  <section class="signin-hero" aria-label="pgkiln">
64
- <span class="signin-logo" aria-hidden="true">pg</span>
64
+ <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
65
65
  <h1 class="signin-title">pgkiln</h1>
66
66
  <p class="signin-tagline">Build data applications on PostgreSQL, in SQL you can trust</p>
67
67
  <p class="signin-foot">Open source under the Apache-2.0 license</p>
@@ -360,6 +360,8 @@ export async function builderRoutes(app: FastifyInstance) {
360
360
  const a = await appOr404(req.params.id);
361
361
  if (!a) return reply.code(404).send('Not found');
362
362
  const r = await owner.one('select meta.export_app($1) as doc', [a.alias]);
363
+ // (sprint 106) the change log marks the change sets made before an application's latest export
364
+ await logActivity({ appId: a.id, username: s.username, event: 'app_export', ip: clientIp(req), detail: String(req.query?.format ?? 'json') });
363
365
  // ?format=dir|text: one file per component, as `pgkiln export --format dir|text` writes it (docs/guide/18-cli.md)
364
366
  if (req.query?.format === 'dir' || req.query?.format === 'text')
365
367
  return reply
@@ -0,0 +1,495 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify';
2
+ import { owner } from '../db.ts';
3
+ import { html, raw, type Raw } from '../html.ts';
4
+ import { icon } from '../icons.ts';
5
+ import { clientIp, logActivity, type Session } from '../session.ts';
6
+ import {
7
+ cronTimeZone, describeSchedule, formatRun, joinSchedule, nextRuns, parseSchedule, PRESETS, ScheduleError, splitSchedule, type CronFields,
8
+ } from '../cron-schedule.ts';
9
+ import {
10
+ activeSql, applySql, checkJob, countPurge, cronState, jobSql, listJobs, listRuns, oneJob, purgeSql, reviewDigest, RUN_STATUSES, runNow, unscheduleSql,
11
+ type CronState, type Job, type JobSpec,
12
+ } from '../pgcron.ts';
13
+ import { isAdmin } from './locks.ts';
14
+ import { back, BASE, csrf, developer, flash, region, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
15
+
16
+ // (sprint 101, Database Actions DA-12) SQL Workshop › Scheduling: pg_cron's jobs (cron.job) with their schedule in
17
+ // words and next runs, the last run and recent failures; create, edit, disable, unschedule and run now; the run
18
+ // history (cron.job_run_details) with filters and purge. Every change is shown as SQL first and applied in one
19
+ // transaction on the owner connection. Without pg_cron: how to install it, and pgkiln's own scheduled work
20
+ // (automations and background process jobs), read-only. Administrators only. Schedules: src/cron-schedule.ts;
21
+ // catalog and SQL: src/pgcron.ts; the builder's live preview: public/builder.js (data-cron-*).
22
+
23
+ const URL_BASE = `${BASE}/sql/scheduling`;
24
+ const TITLE = 'Scheduling';
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];
27
+
28
+ const one = (v: unknown) => (Array.isArray(v) ? String(v[0] ?? '') : String(v ?? ''));
29
+ const chk = (on: boolean) => (on ? raw(' checked') : '');
30
+ const sel = (on: boolean) => (on ? raw(' selected') : '');
31
+ const plural = (n: number, w: string) => `${n} ${w}${n === 1 ? '' : 's'}`;
32
+ const errorList = (errors: string[]) =>
33
+ errors.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : '';
34
+
35
+ /** An administrator's session, or null (a 403 page sent). */
36
+ async function admin(req: Req, reply: FastifyReply) {
37
+ const s = await developer(req, reply);
38
+ if (!s) return null;
39
+ 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
+ return null;
43
+ }
44
+
45
+ const head = (s: Session, title: string, actions: Raw | '' = '') => html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('scheduling')}
46
+ <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
+
48
+ // ------------------------------------------------------------------ schedule preview
49
+
50
+ /** A schedule in words and its next five runs, or why it doesn't parse. */
51
+ export function schedulePreview(text: string, timeZone: string): Raw {
52
+ try {
53
+ const sc = parseSchedule(text);
54
+ 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
+ } catch (e) {
59
+ if (!(e instanceof ScheduleError)) throw e;
60
+ return html`<p class="u-m0 cron-error" role="alert">${e.message}</p>`;
61
+ }
62
+ }
63
+
64
+ /** 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>`;
67
+ try {
68
+ const sc = parseSchedule(job.schedule);
69
+ 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>`;
71
+ } catch {
72
+ return html`<span class="muted">?</span>`;
73
+ }
74
+ }
75
+
76
+ const words = (schedule: string) => {
77
+ try {
78
+ return describeSchedule(parseSchedule(schedule));
79
+ } catch (e) {
80
+ return e instanceof ScheduleError ? e.message : 'unreadable';
81
+ }
82
+ };
83
+
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>`;
86
+ const secs = (n: number | null) => (n === null ? '' : n >= 100 ? `${n.toFixed(0)} s` : `${n.toFixed(n >= 1 ? 1 : 3)} s`);
87
+
88
+ // ------------------------------------------------------------------ without pg_cron
89
+
90
+ function installNote(st: Exclude<CronState, { ok: true }>): Raw {
91
+ 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>
95
+ </ol>`;
96
+ 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>`,
103
+ }[st.reason];
104
+ 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>`;
107
+ }
108
+
109
+ /** pgkiln's own scheduled work: automations and recent process jobs, read-only. */
110
+ async function pgkilnWork(): Promise<Raw> {
111
+ 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
+ `select a.id as app_id, a.alias, x.id, x.name, x.enabled, x.schedule, x.time_zone,
113
+ to_char(x.next_run_at at time zone x.time_zone, 'YYYY-MM-DD HH24:MI') as next_run,
114
+ to_char(x.last_run_at at time zone x.time_zone, 'YYYY-MM-DD HH24:MI') as last_run, x.last_status
115
+ from meta.automation x join meta.app a on a.id = x.app_id order by lower(a.alias), lower(x.name) limit 500`)).rows;
116
+ const jobs = (await owner.query<{ id: string; app_id: number; alias: string; name: string; state: string; app_user: string; queued: string; ended: string | null; error: string | null }>(
117
+ `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
+ to_char(j.ended_at, 'YYYY-MM-DD HH24:MI:SS') as ended, j.error
119
+ 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>
122
+ <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>
131
+ <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>`)}`;
137
+ }
138
+
139
+ // ------------------------------------------------------------------ list
140
+
141
+ async function listPage(req: Req, reply: FastifyReply, s: Session) {
142
+ const st = await cronState();
143
+ 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'));
146
+ }
147
+ const jobs = await listJobs(st.timeZone);
148
+ const q = String(req.query.q ?? '').trim().toLowerCase().slice(0, 100);
149
+ const shown = jobs.filter((j) => !q || `${j.jobname ?? ''} ${j.command} ${j.database} ${j.username}`.toLowerCase().includes(q));
150
+ 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>`)}
152
+ <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>
156
+ </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>
160
+ <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'));
169
+ }
170
+
171
+ // ------------------------------------------------------------------ create / edit
172
+
173
+ interface FormContext { databases: string[]; users: string[]; timeZone: string; user: string; database: string }
174
+
175
+ async function formContext(st: Extract<CronState, { ok: true }>): Promise<FormContext> {
176
+ const databases = (await owner.query<{ n: string }>(`select datname as n from pg_database where datallowconn and not datistemplate order by datname <> current_database(), datname`)).rows.map((r) => r.n);
177
+ const users = (await owner.query<{ n: string }>(`select rolname as n from pg_roles where rolcanlogin and rolname !~ '^pg_' order by rolname <> current_user, lower(rolname)`)).rows.map((r) => r.n);
178
+ return { databases, users, timeZone: st.timeZone, user: st.user, database: st.database };
179
+ }
180
+
181
+ const specOf = (j: Job): JobSpec => ({ name: j.jobname ?? '', schedule: j.schedule, command: j.command, database: j.database, username: j.username, active: j.active });
182
+
183
+ function specFromBody(b: Body): JobSpec {
184
+ return {
185
+ name: one(b.name).trim(),
186
+ schedule: one(b.schedule).trim().replace(/\s+/g, ' '),
187
+ command: one(b.command).replace(/\r\n/g, '\n'),
188
+ database: one(b.database),
189
+ username: one(b.username),
190
+ active: !!b.active,
191
+ };
192
+ }
193
+
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'],
196
+ ];
197
+
198
+ function jobForm(s: Session, spec: JobSpec, ctx: FormContext, o: { jobid: string | null; errors: string[]; fields?: CronFields | null }) {
199
+ const fields = o.fields ?? splitSchedule(spec.schedule) ?? { minute: '', hour: '', day: '', month: '', weekday: '' };
200
+ return html`${errorList(o.errors)}
201
+ <form method="post" action="${URL_BASE}/save" class="cron-form" data-cron-form>${csrf(s)}
202
+ <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
+ <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>
215
+ </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>
221
+ </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>
225
+ </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>
229
+ </form>`;
230
+ }
231
+
232
+ const hiddenSpec = (spec: JobSpec, jobid: string | null) => html`<input type="hidden" name="jobid" value="${jobid ?? ''}">
233
+ <input type="hidden" name="name" value="${spec.name}"><input type="hidden" name="schedule" value="${spec.schedule}">
234
+ <input type="hidden" name="command" value="${spec.command}"><input type="hidden" name="database" value="${spec.database}">
235
+ <input type="hidden" name="username" value="${spec.username}">${spec.active ? html`<input type="hidden" name="active" value="1">` : ''}`;
236
+
237
+ /** The review step of any change: the SQL, Apply (with the digest), Back. */
238
+ function reviewRegion(s: Session, stmts: string[], action: string, hidden: Raw, o: { back?: Raw | ''; apply?: string; danger?: boolean; extra?: Raw | '' } = {}) {
239
+ 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" aria-label="Generated SQL">${stmts.join('\n\n')}</pre>`
241
+ : html`<p class="muted u-mt0">Nothing to change.</p>`)}
242
+ <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>`;
244
+ }
245
+
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>`;
247
+
248
+ async function editPage(reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job | null, o: { spec?: JobSpec; errors?: string[]; fields?: CronFields | null } = {}) {
249
+ const ctx = await formContext(st);
250
+ 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';
252
+ 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'));
255
+ }
256
+
257
+ async function save(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
258
+ const b = req.body ?? {};
259
+ const jobid = one(b.jobid);
260
+ const job = jobid ? await oneJob(st.timeZone, jobid) : null;
261
+ if (jobid && !job) return reply.code(404).send('Not found');
262
+ const spec = specFromBody(b);
263
+ const mode = one(b.mode);
264
+ const fields: CronFields = { minute: one(b.cf_minute), hour: one(b.cf_hour), day: one(b.cf_day), month: one(b.cf_month), weekday: one(b.cf_weekday) };
265
+ // the no-JavaScript builder: a preset or the five fields become the schedule, the form comes back
266
+ if (mode === 'preset' || mode === 'build' || mode === 'preview' || mode === 'edit') {
267
+ if (mode === 'preset' && PRESETS.some(([, v]) => v === one(b.preset))) spec.schedule = one(b.preset);
268
+ if (mode === 'build') spec.schedule = joinSchedule(fields).slice(0, 200);
269
+ return editPage(reply, s, st, job, { spec, fields: mode === 'build' ? fields : null });
270
+ }
271
+ const ctx = await formContext(st);
272
+ const errors = checkJob(spec, ctx);
273
+ if (!errors.length) {
274
+ 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.`);
276
+ }
277
+ if (errors.length) return editPage(reply, s, st, job, { spec, errors });
278
+ 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}`;
280
+ const page = (output: Raw | '' = '') => {
281
+ 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'));
286
+ };
287
+ 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.']));
290
+ const failed = await applySql(stmts);
291
+ 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));
293
+ 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}.`);
295
+ return back(reply, s, job ? jobUrl(job.jobid) : created ? jobUrl(created.jobid) : URL_BASE);
296
+ }
297
+
298
+ // ------------------------------------------------------------------ one job
299
+
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>
302
+ <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>`;
309
+ }
310
+
311
+ async function jobPage(reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job, output: Raw | '' = '') {
312
+ 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>`)}
318
+ ${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" 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'));
327
+ }
328
+
329
+ async function setActive(req: Req, reply: FastifyReply, s: Session, job: Job) {
330
+ const active = !!one(req.body?.active);
331
+ const stmts = activeSql(job.jobid, active);
332
+ const failed = await applySql(stmts);
333
+ 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]}`);
336
+ return back(reply, s, jobUrl(job.jobid));
337
+ }
338
+
339
+ async function unschedulePage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job) {
340
+ const stmts = unscheduleSql(job.jobid);
341
+ const name = job.jobname ?? `Job ${job.jobid}`;
342
+ let output: Raw | '' = '';
343
+ if (req.method === 'POST') {
344
+ if (one(req.body?.reviewed) !== reviewDigest(stmts)) output = errorList(['The statements changed since you reviewed them: review them again.']);
345
+ else {
346
+ const failed = await applySql(stmts);
347
+ await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}` });
348
+ if (!failed) {
349
+ flash(s, `Unscheduled ${name}. Its run history stays until you purge it.`);
350
+ return back(reply, s, URL_BASE);
351
+ }
352
+ output = failedBox(failed);
353
+ }
354
+ }
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'));
359
+ }
360
+
361
+ async function runJobNow(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>, job: Job) {
362
+ const r = await runNow(job);
363
+ 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>
365
+ ${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>`;
367
+ return jobPage(reply.code(r.ok ? 200 : 422), s, st, job, out);
368
+ }
369
+
370
+ // ------------------------------------------------------------------ history
371
+
372
+ async function historyPage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
373
+ const f = { job: String(req.query.job ?? ''), status: String(req.query.status ?? ''), q: String(req.query.q ?? '').trim().slice(0, 100) };
374
+ 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>
377
+ <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'));
384
+ }
385
+
386
+ const PURGE_DAYS: [string, string][] = [['7', 'older than 7 days'], ['30', 'older than 30 days'], ['90', 'older than 90 days'], ['all', 'all']];
387
+
388
+ async function purgePage(req: Req, reply: FastifyReply, s: Session, st: Extract<CronState, { ok: true }>) {
389
+ const src = req.method === 'POST' ? (req.body ?? {}) : req.query;
390
+ const dayKey = PURGE_DAYS.some(([k]) => k === one(src.days)) ? one(src.days) : '30';
391
+ const days = dayKey === 'all' ? null : Number(dayKey);
392
+ const jobArg = one(src.job);
393
+ const job = jobArg ? await oneJob(st.timeZone, jobArg) : null;
394
+ if (jobArg && !job) return reply.code(404).send('Not found');
395
+ const stmts = purgeSql(days, job?.jobid ?? null);
396
+ let output: Raw | '' = '';
397
+ if (req.method === 'POST') {
398
+ if (one(src.reviewed) !== reviewDigest(stmts)) output = errorList(['The statements changed since you reviewed them: review them again.']);
399
+ else {
400
+ const failed = await applySql(stmts);
401
+ await logActivity({ username: s.username, event: 'cron_job', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${stmts.join('\n')}` });
402
+ if (!failed) {
403
+ flash(s, 'Purged the run history.');
404
+ return back(reply, s, `${URL_BASE}/history${job ? `?job=${job.jobid}` : ''}`);
405
+ }
406
+ output = failedBox(failed);
407
+ }
408
+ }
409
+ 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>`)}
416
+ ${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'));
419
+ }
420
+
421
+ // ------------------------------------------------------------------ routes
422
+
423
+ export async function schedulingRoutes(app: FastifyInstance) {
424
+ /** an administrator with pg_cron usable, else the list page (which explains) */
425
+ const ready = async (req: Req, reply: FastifyReply) => {
426
+ const s = await admin(req, reply);
427
+ if (!s) return null;
428
+ const st = await cronState();
429
+ if (!st.ok) {
430
+ reply.code(409);
431
+ await listPage(req, reply, s);
432
+ return null;
433
+ }
434
+ return { s, st };
435
+ };
436
+ const withJob = async (req: Req, reply: FastifyReply) => {
437
+ const r = await ready(req, reply);
438
+ if (!r) return null;
439
+ const job = await oneJob(r.st.timeZone, req.params.id);
440
+ if (!job) {
441
+ reply.code(404).send('Not found');
442
+ return null;
443
+ }
444
+ return { ...r, job };
445
+ };
446
+
447
+ app.get(URL_BASE, async (req: Req, reply) => {
448
+ const s = await admin(req, reply);
449
+ if (s) return listPage(req, reply, s);
450
+ });
451
+ app.get(`${URL_BASE}/preview`, async (req: Req, reply) => {
452
+ const s = await admin(req, reply);
453
+ if (!s) return;
454
+ 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')));
456
+ });
457
+ app.get(`${URL_BASE}/new`, async (req: Req, reply) => {
458
+ const r = await ready(req, reply);
459
+ if (r) return editPage(reply, r.s, r.st, null);
460
+ });
461
+ app.post(`${URL_BASE}/save`, async (req: Req, reply) => {
462
+ const r = await ready(req, reply);
463
+ if (r) return save(req, reply, r.s, r.st);
464
+ });
465
+ app.get(`${URL_BASE}/history`, async (req: Req, reply) => {
466
+ const r = await ready(req, reply);
467
+ if (r) return historyPage(req, reply, r.s, r.st);
468
+ });
469
+ for (const method of ['GET', 'POST'] as const) {
470
+ app.route({ method, url: `${URL_BASE}/history/purge`, handler: async (req: Req, reply) => {
471
+ const r = await ready(req, reply);
472
+ if (r) return purgePage(req, reply, r.s, r.st);
473
+ } });
474
+ app.route({ method, url: `${URL_BASE}/job/:id/unschedule`, handler: async (req: Req, reply) => {
475
+ const r = await withJob(req, reply);
476
+ if (r) return unschedulePage(req, reply, r.s, r.st, r.job);
477
+ } });
478
+ }
479
+ app.get(`${URL_BASE}/job/:id`, async (req: Req, reply) => {
480
+ const r = await withJob(req, reply);
481
+ if (r) return jobPage(reply, r.s, r.st, r.job);
482
+ });
483
+ app.get(`${URL_BASE}/job/:id/edit`, async (req: Req, reply) => {
484
+ const r = await withJob(req, reply);
485
+ if (r) return editPage(reply, r.s, r.st, r.job);
486
+ });
487
+ app.post(`${URL_BASE}/job/:id/active`, async (req: Req, reply) => {
488
+ const r = await withJob(req, reply);
489
+ if (r) return setActive(req, reply, r.s, r.job);
490
+ });
491
+ app.post(`${URL_BASE}/job/:id/run`, async (req: Req, reply) => {
492
+ const r = await withJob(req, reply);
493
+ if (r) return runJobNow(req, reply, r.s, r.st, r.job);
494
+ });
495
+ }