tempest-express-sdk 0.29.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -8673,6 +8673,7 @@ var TaskManager = class {
8673
8673
  broker;
8674
8674
  queue;
8675
8675
  handlers = /* @__PURE__ */ new Map();
8676
+ metadata = /* @__PURE__ */ new Map();
8676
8677
  unsubscribe = null;
8677
8678
  /**
8678
8679
  * @param options - Broker and queue name.
@@ -8686,9 +8687,32 @@ var TaskManager = class {
8686
8687
  *
8687
8688
  * @param name - The task name.
8688
8689
  * @param handler - The handler invoked with the task payload.
8690
+ * @param options - Description and declared schedule, surfaced by
8691
+ * {@link TaskManager.inventory} and by the admin panel's tasks page.
8689
8692
  */
8690
- register(name, handler) {
8693
+ register(name, handler, options = {}) {
8691
8694
  this.handlers.set(name, handler);
8695
+ this.metadata.set(name, options);
8696
+ }
8697
+ /**
8698
+ * Return what this process would run, ordered by name.
8699
+ *
8700
+ * This is the **declared** side of background work — the handlers this
8701
+ * process knows about — not queue state. A broker's pending depth is not
8702
+ * something the manager can see, and a screen that implied otherwise would
8703
+ * be worse than one that says nothing.
8704
+ *
8705
+ * @returns One entry per registered task.
8706
+ */
8707
+ inventory() {
8708
+ return [...this.handlers.keys()].sort((left, right) => left.localeCompare(right)).map((name) => {
8709
+ const meta = this.metadata.get(name) ?? {};
8710
+ return {
8711
+ name,
8712
+ description: meta.description ?? null,
8713
+ schedule: meta.schedule ?? null
8714
+ };
8715
+ });
8692
8716
  }
8693
8717
  /**
8694
8718
  * Enqueue a task by name.
@@ -8722,6 +8746,180 @@ var TaskManager = class {
8722
8746
  this.unsubscribe = null;
8723
8747
  }
8724
8748
  };
8749
+ var JobStatus = {
8750
+ /** Written, not started. */
8751
+ QUEUED: "queued",
8752
+ /** A worker picked it up. */
8753
+ RUNNING: "running",
8754
+ /** Finished cleanly. */
8755
+ SUCCEEDED: "succeeded",
8756
+ /** Finished with an error. */
8757
+ FAILED: "failed",
8758
+ /** An operator stopped it before it finished. */
8759
+ CANCELLED: "cancelled"
8760
+ };
8761
+ var TERMINAL = [
8762
+ JobStatus.SUCCEEDED,
8763
+ JobStatus.FAILED,
8764
+ JobStatus.CANCELLED
8765
+ ];
8766
+ var BaseJobModel = class extends BaseModel {
8767
+ /** The task name this run belongs to. */
8768
+ name = tempestDbJs.column.varchar(128).notNull();
8769
+ /** Lifecycle state — a {@link JobStatus} value. */
8770
+ status = tempestDbJs.column.varchar(16).notNull().default(JobStatus.QUEUED);
8771
+ /** The payload the run was started with. */
8772
+ payload = tempestDbJs.column.json();
8773
+ /** Whatever the run produced, for an operator to read afterwards. */
8774
+ result = tempestDbJs.column.json();
8775
+ /** The failure message, when the run failed. */
8776
+ error = tempestDbJs.column.text();
8777
+ /** How many times the run has been attempted. */
8778
+ attempts = tempestDbJs.column.integer().notNull().default(0);
8779
+ /** When a worker picked it up. */
8780
+ startedAt = tempestDbJs.column.datetime();
8781
+ /** When it reached a terminal state. */
8782
+ finishedAt = tempestDbJs.column.datetime();
8783
+ };
8784
+ var JobStore = class {
8785
+ /**
8786
+ * @param model - The concrete {@link BaseJobModel} subclass.
8787
+ * @param session - The session rows are written on.
8788
+ */
8789
+ constructor(model, session) {
8790
+ this.model = model;
8791
+ this.repository = new tempestDbJs.BaseRepository(model, session);
8792
+ }
8793
+ model;
8794
+ repository;
8795
+ /**
8796
+ * Record a job about to run.
8797
+ *
8798
+ * @param name - The task name.
8799
+ * @param payload - The payload the run was started with.
8800
+ * @returns The created row.
8801
+ */
8802
+ async enqueue(name, payload = {}) {
8803
+ return await this.repository.create({
8804
+ name,
8805
+ status: JobStatus.QUEUED,
8806
+ payload,
8807
+ result: null,
8808
+ error: null,
8809
+ attempts: 0,
8810
+ startedAt: null,
8811
+ finishedAt: null
8812
+ });
8813
+ }
8814
+ /**
8815
+ * Mark a job as picked up, counting the attempt.
8816
+ *
8817
+ * @param id - The job id.
8818
+ * @param attempt - Which attempt this is. Default `1`.
8819
+ * @returns How many rows changed.
8820
+ */
8821
+ async start(id, attempt = 1) {
8822
+ return await this.repository.update(
8823
+ { id },
8824
+ {
8825
+ status: JobStatus.RUNNING,
8826
+ startedAt: /* @__PURE__ */ new Date(),
8827
+ attempts: attempt
8828
+ }
8829
+ );
8830
+ }
8831
+ /**
8832
+ * Mark a job as finished cleanly.
8833
+ *
8834
+ * @param id - The job id.
8835
+ * @param result - Whatever the run produced.
8836
+ * @returns How many rows changed.
8837
+ */
8838
+ async succeed(id, result = {}) {
8839
+ return await this.repository.update(
8840
+ { id },
8841
+ {
8842
+ status: JobStatus.SUCCEEDED,
8843
+ result,
8844
+ error: null,
8845
+ finishedAt: /* @__PURE__ */ new Date()
8846
+ }
8847
+ );
8848
+ }
8849
+ /**
8850
+ * Mark a job as failed.
8851
+ *
8852
+ * @param id - The job id.
8853
+ * @param error - The failure, as an `Error` or a message.
8854
+ * @returns How many rows changed.
8855
+ */
8856
+ async fail(id, error) {
8857
+ return await this.repository.update(
8858
+ { id },
8859
+ {
8860
+ status: JobStatus.FAILED,
8861
+ error: error instanceof Error ? error.message : String(error),
8862
+ finishedAt: /* @__PURE__ */ new Date()
8863
+ }
8864
+ );
8865
+ }
8866
+ /**
8867
+ * Ask a job to stop.
8868
+ *
8869
+ * A job already in a terminal state is left alone and reported as not
8870
+ * cancelled, so an operator clicking cancel on a run that just finished sees
8871
+ * the truth rather than a row rewritten under them.
8872
+ *
8873
+ * @param id - The job id.
8874
+ * @returns Whether the row moved to `cancelled`.
8875
+ */
8876
+ async cancel(id) {
8877
+ const row = await this.repository.getByIdOrNull(id);
8878
+ if (row === null || TERMINAL.includes(String(row.status))) return false;
8879
+ const changed = await this.repository.update(
8880
+ { id },
8881
+ {
8882
+ status: JobStatus.CANCELLED,
8883
+ finishedAt: /* @__PURE__ */ new Date()
8884
+ }
8885
+ );
8886
+ return changed > 0;
8887
+ }
8888
+ /**
8889
+ * Read one job row.
8890
+ *
8891
+ * @param id - The job id.
8892
+ * @returns The row, or `null`.
8893
+ */
8894
+ async get(id) {
8895
+ return await this.repository.getByIdOrNull(id);
8896
+ }
8897
+ /**
8898
+ * Read a page of jobs, newest first.
8899
+ *
8900
+ * @param filter - Page, size and optional `name` / `status` filters.
8901
+ * @returns The page plus its metadata.
8902
+ */
8903
+ async list(filter = {}) {
8904
+ const filters = {};
8905
+ if (filter.name !== void 0 && filter.name !== "") filters.name = filter.name;
8906
+ if (filter.status !== void 0) filters.status = filter.status;
8907
+ const result = await this.repository.paginate({
8908
+ page: filter.page ?? 1,
8909
+ pageSize: filter.pageSize ?? 25,
8910
+ orderBy: "createdAt",
8911
+ ascending: false,
8912
+ ...Object.keys(filters).length === 0 ? {} : { filters }
8913
+ });
8914
+ return {
8915
+ items: result.items,
8916
+ total: result.total,
8917
+ page: result.page,
8918
+ pageSize: result.pageSize,
8919
+ pages: result.pages
8920
+ };
8921
+ }
8922
+ };
8725
8923
 
8726
8924
  // src/flags/backends.ts
8727
8925
  function coerceFlag(value) {
@@ -9794,8 +9992,8 @@ function humanizeField(name) {
9794
9992
  function adminColumns(model) {
9795
9993
  return tempestDbJs.columnsOf(model);
9796
9994
  }
9797
- function widgetForColumn(column6) {
9798
- const { kind, meta } = column6.type;
9995
+ function widgetForColumn(column7) {
9996
+ const { kind, meta } = column7.type;
9799
9997
  const plain = (widget) => ({
9800
9998
  widget,
9801
9999
  step: null,
@@ -9839,11 +10037,11 @@ function widgetForColumn(column6) {
9839
10037
  return plain("text");
9840
10038
  }
9841
10039
  }
9842
- function isColumnOptional(column6) {
9843
- return !column6.flags.notNull || column6.flags.hasDefault || column6.flags.primaryKey;
10040
+ function isColumnOptional(column7) {
10041
+ return !column7.flags.notNull || column7.flags.hasDefault || column7.flags.primaryKey;
9844
10042
  }
9845
- function filterForColumn(column6) {
9846
- const { kind, meta } = column6.type;
10043
+ function filterForColumn(column7) {
10044
+ const { kind, meta } = column7.type;
9847
10045
  if (kind === "boolean") {
9848
10046
  return {
9849
10047
  kind: "select",
@@ -9867,11 +10065,11 @@ function filterForColumn(column6) {
9867
10065
  }
9868
10066
  return { kind: "text", options: [] };
9869
10067
  }
9870
- function foreignKeyTable(column6) {
9871
- return column6.reference?.table ?? null;
10068
+ function foreignKeyTable(column7) {
10069
+ return column7.reference?.table ?? null;
9872
10070
  }
9873
- function isSearchableColumn(column6) {
9874
- const { kind } = column6.type;
10071
+ function isSearchableColumn(column7) {
10072
+ const { kind } = column7.type;
9875
10073
  return kind === "varchar" || kind === "text" || kind === "char";
9876
10074
  }
9877
10075
 
@@ -10153,8 +10351,8 @@ function formatFieldValue(widget, value) {
10153
10351
  }
10154
10352
  return String(value);
10155
10353
  }
10156
- function literalDefault(column6) {
10157
- const fallback = column6.defaultValue;
10354
+ function literalDefault(column7) {
10355
+ const fallback = column7.defaultValue;
10158
10356
  if (fallback === null || fallback.kind !== "literal") return void 0;
10159
10357
  return fallback.value;
10160
10358
  }
@@ -10167,19 +10365,19 @@ function buildFormFields(admin, options = {}) {
10167
10365
  const autocompleteLabels = options.autocompleteLabels ?? {};
10168
10366
  const uploads = new Set(admin.uploadFields);
10169
10367
  return admin.editableFieldNames().flatMap((name) => {
10170
- const column6 = columns[name];
10171
- if (column6 === void 0) return [];
10368
+ const column7 = columns[name];
10369
+ if (column7 === void 0) return [];
10172
10370
  const related = foreignKeys[name];
10173
10371
  const autocompleteUrl = autocompleteUrls[name];
10174
- const spec = uploads.has(name) ? { widget: "file", step: null, options: [] } : autocompleteUrl !== void 0 ? { widget: "autocomplete", step: null, options: [] } : related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
10175
- const raw = name in values ? values[name] : literalDefault(column6);
10372
+ const spec = uploads.has(name) ? { widget: "file", step: null, options: [] } : autocompleteUrl !== void 0 ? { widget: "autocomplete", step: null, options: [] } : related === void 0 ? widgetForColumn(column7) : { widget: "select", step: null, options: related };
10373
+ const raw = name in values ? values[name] : literalDefault(column7);
10176
10374
  return [
10177
10375
  {
10178
10376
  name,
10179
10377
  label: humanizeField(name),
10180
10378
  widget: spec.widget,
10181
10379
  value: spec.widget === "checkbox" ? "" : formatFieldValue(spec.widget, raw),
10182
- required: !isColumnOptional(column6),
10380
+ required: !isColumnOptional(column7),
10183
10381
  checked: spec.widget === "checkbox" && toBoolean(raw),
10184
10382
  step: spec.step,
10185
10383
  options: spec.options,
@@ -10196,8 +10394,8 @@ function toBoolean(value) {
10196
10394
  if (typeof value !== "string") return false;
10197
10395
  return ["true", "on", "yes", "1"].includes(value.trim().toLowerCase());
10198
10396
  }
10199
- function coerceValue(column6, widget, raw) {
10200
- const { kind, meta } = column6.type;
10397
+ function coerceValue(column7, widget, raw) {
10398
+ const { kind, meta } = column7.type;
10201
10399
  switch (widget) {
10202
10400
  case "number": {
10203
10401
  const parsed = Number(raw);
@@ -10241,10 +10439,10 @@ function parseFormBody(admin, body, options = {}) {
10241
10439
  const only = options.only === void 0 ? null : new Set(options.only);
10242
10440
  for (const name of admin.editableFieldNames()) {
10243
10441
  if (only !== null && !only.has(name)) continue;
10244
- const column6 = columns[name];
10245
- if (column6 === void 0) continue;
10442
+ const column7 = columns[name];
10443
+ if (column7 === void 0) continue;
10246
10444
  if (uploads.has(name)) continue;
10247
- const { widget } = widgetForColumn(column6);
10445
+ const { widget } = widgetForColumn(column7);
10248
10446
  if (widget === "checkbox") {
10249
10447
  data[name] = toBoolean(body[name]);
10250
10448
  continue;
@@ -10252,16 +10450,16 @@ function parseFormBody(admin, body, options = {}) {
10252
10450
  const submitted = body[name];
10253
10451
  const raw = typeof submitted === "string" ? submitted.trim() : "";
10254
10452
  if (raw === "") {
10255
- if (!isColumnOptional(column6)) {
10453
+ if (!isColumnOptional(column7)) {
10256
10454
  errors[name] = "This field is required.";
10257
10455
  continue;
10258
10456
  }
10259
- if (column6.flags.hasDefault && !(name in body)) continue;
10457
+ if (column7.flags.hasDefault && !(name in body)) continue;
10260
10458
  data[name] = null;
10261
10459
  continue;
10262
10460
  }
10263
10461
  try {
10264
- data[name] = coerceValue(column6, widget, raw);
10462
+ data[name] = coerceValue(column7, widget, raw);
10265
10463
  } catch (error) {
10266
10464
  errors[name] = error instanceof Error ? error.message : "Invalid value.";
10267
10465
  }
@@ -10279,9 +10477,9 @@ function foreignKeyFields(admin) {
10279
10477
  const columns = adminColumns(admin.model);
10280
10478
  const out = {};
10281
10479
  for (const name of admin.editableFieldNames()) {
10282
- const column6 = columns[name];
10283
- if (column6 === void 0) continue;
10284
- const table = foreignKeyTable(column6);
10480
+ const column7 = columns[name];
10481
+ if (column7 === void 0) continue;
10482
+ const table = foreignKeyTable(column7);
10285
10483
  if (table !== null) out[name] = table;
10286
10484
  }
10287
10485
  return out;
@@ -10667,7 +10865,14 @@ var AdminSite = class {
10667
10865
  };
10668
10866
 
10669
10867
  // src/admin/styles.ts
10670
- var ADMIN_CSS = `:root {
10868
+ var ADMIN_CSS_EXTRA = `
10869
+ .tempest-log-badge--succeeded { background: #dcfce7; color: #166534; }
10870
+ .tempest-log-badge--failed { background: #fee2e2; color: #991b1b; }
10871
+ .tempest-log-badge--running { background: #dbeafe; color: #1e40af; }
10872
+ .tempest-log-badge--queued { background: #f1f5f9; color: #475569; }
10873
+ .tempest-log-badge--cancelled { background: #fef3c7; color: #92400e; }
10874
+ `;
10875
+ var ADMIN_CSS_BASE = `:root {
10671
10876
  --tempest-bg: #0f172a;
10672
10877
  --tempest-bg-soft: #1e293b;
10673
10878
  --tempest-bg-row: #f8fafc;
@@ -12226,6 +12431,7 @@ a.tempest-admin-list__new:hover {
12226
12431
  }
12227
12432
  }
12228
12433
  `;
12434
+ var ADMIN_CSS = `${ADMIN_CSS_BASE}${ADMIN_CSS_EXTRA}`;
12229
12435
 
12230
12436
  // src/admin/theme.ts
12231
12437
  var FORBIDDEN_CHARS = ["<", ">", "{", "}", '"'];
@@ -12486,11 +12692,11 @@ function renderFilter(filter) {
12486
12692
  function renderListPage(context, view) {
12487
12693
  const bulk = view.bulkActions.length > 0 && context.session !== null;
12488
12694
  const checkColumn = bulk ? 1 : 0;
12489
- const headers = view.columns.map((column6) => {
12490
- const state = view.sort[column6];
12491
- if (state === void 0) return `<th>${escapeHtml(column6)}</th>`;
12695
+ const headers = view.columns.map((column7) => {
12696
+ const state = view.sort[column7];
12697
+ if (state === void 0) return `<th>${escapeHtml(column7)}</th>`;
12492
12698
  const arrow = state.active ? state.ascending ? "\u25B2" : "\u25BC" : "\u2195";
12493
- return `<th><a class="tempest-sort${state.active ? " tempest-sort--active" : ""}" href="${escapeHtml(state.url)}"><span>${escapeHtml(column6)}</span><span class="tempest-sort__arrow" aria-hidden="true">${arrow}</span></a></th>`;
12699
+ return `<th><a class="tempest-sort${state.active ? " tempest-sort--active" : ""}" href="${escapeHtml(state.url)}"><span>${escapeHtml(column7)}</span><span class="tempest-sort__arrow" aria-hidden="true">${arrow}</span></a></th>`;
12494
12700
  }).join("");
12495
12701
  const rows = view.rows.length > 0 ? view.rows.map((row) => {
12496
12702
  const check = bulk ? `<td class="tempest-admin-list__check"><input type="checkbox" name="ids" value="${escapeHtml(row.identity)}" data-row-check aria-label="Select row"></td>` : "";
@@ -12631,7 +12837,7 @@ function renderInline(inline, csrfToken, error) {
12631
12837
  if (inline.rows.length === 0) {
12632
12838
  return `<section class="tempest-admin-inline">${heading}<p><em>No related records.</em></p></section>`;
12633
12839
  }
12634
- const head2 = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}<th></th></tr>`;
12840
+ const head2 = `<tr>${inline.columns.map((column7) => `<th>${escapeHtml(column7)}</th>`).join("")}<th></th></tr>`;
12635
12841
  const body = inline.rows.map(
12636
12842
  (row) => `<tr>${row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}<td>${row.url === null ? "" : `<a href="${escapeHtml(row.url)}">View</a>`}</td></tr>`
12637
12843
  ).join("");
@@ -12646,7 +12852,7 @@ function renderInline(inline, csrfToken, error) {
12646
12852
  ${more}
12647
12853
  </section>`;
12648
12854
  }
12649
- const head = `<tr>${inline.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12855
+ const head = `<tr>${inline.columns.map((column7) => `<th>${escapeHtml(column7)}</th>`).join("")}${inline.canDelete ? "<th>Delete</th>" : ""}</tr>`;
12650
12856
  const renderRow = (row, isNew) => `<tr${isNew ? ' class="tempest-admin-inline__new"' : ""}>${row.fields.map((field) => `<td>${renderInlineCell(field)}</td>`).join("")}${inline.canDelete ? `<td class="tempest-admin-inline__del">${isNew ? "" : `<input type="checkbox" name="row.${escapeHtml(row.key)}.__delete" value="true">`}</td>` : ""}</tr>`;
12651
12857
  const rows = inline.rows.map((row) => renderRow(row, false)).join("");
12652
12858
  const blank = inline.newRow === null ? "" : renderRow(inline.newRow, true);
@@ -12817,12 +13023,86 @@ function renderLogsPage(context, view) {
12817
13023
  </section>`;
12818
13024
  return renderLayout(context, `Logs \xB7 ${context.site.title}`, body);
12819
13025
  }
13026
+ function renderTasksPage(context, view) {
13027
+ const declared = view.inventory === null ? "" : `<section class="tempest-admin-tasks__declared">
13028
+ <h2>Declared</h2>
13029
+ ${view.inventory.length === 0 ? "<p><em>No tasks registered in this process.</em></p>" : `<div class="tempest-admin-table-wrap"><table class="tempest-admin-list__table">
13030
+ <thead><tr><th>Task</th><th>Schedule</th><th>Description</th></tr></thead>
13031
+ <tbody>${view.inventory.map(
13032
+ (entry) => `<tr><td>${escapeHtml(entry.name)}</td><td>${escapeHtml(entry.schedule)}</td><td>${escapeHtml(entry.description)}</td></tr>`
13033
+ ).join("")}</tbody>
13034
+ </table></div>`}
13035
+ </section>`;
13036
+ const runs = view.runs === null ? "" : `<section class="tempest-admin-tasks__runs">
13037
+ <h2>Runs</h2>
13038
+ <div class="tempest-admin-list__toolbar">
13039
+ <form method="get" class="tempest-admin-list__filters">
13040
+ <label><span>Status</span><select name="status">${view.runs.statuses.map(
13041
+ (status) => `<option value="${escapeHtml(status.value)}"${status.selected ? " selected" : ""}>${escapeHtml(status.label)}</option>`
13042
+ ).join("")}</select></label>
13043
+ <input type="search" name="task" value="${escapeHtml(view.runs.nameQuery)}" placeholder="Task name\u2026" aria-label="Task name">
13044
+ <button type="submit">Apply</button>
13045
+ </form>
13046
+ </div>
13047
+ <div class="tempest-admin-table-wrap"><table class="tempest-admin-list__table">
13048
+ <thead><tr><th>Task</th><th>Status</th><th>Started</th><th>Finished</th><th>Attempts</th><th></th></tr></thead>
13049
+ <tbody>${view.runs.rows.length === 0 ? '<tr><td colspan="6">No runs recorded yet.</td></tr>' : view.runs.rows.map(
13050
+ (row) => `<tr>
13051
+ <td>${escapeHtml(row.name)}</td>
13052
+ <td><span class="tempest-log-badge tempest-log-badge--${escapeHtml(row.status)}">${escapeHtml(row.status)}</span></td>
13053
+ <td>${escapeHtml(row.startedAt)}</td>
13054
+ <td>${escapeHtml(row.finishedAt)}</td>
13055
+ <td>${escapeHtml(row.attempts)}</td>
13056
+ <td><a href="${escapeHtml(row.url)}">View</a></td>
13057
+ </tr>`
13058
+ ).join("")}</tbody>
13059
+ </table></div>
13060
+ ${view.runs.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
13061
+ ${view.runs.prevUrl !== null ? `<a href="${escapeHtml(view.runs.prevUrl)}">\u2190 Prev</a>` : ""}
13062
+ <span>Page ${escapeHtml(view.runs.page)} of ${escapeHtml(view.runs.pages)}</span>
13063
+ ${view.runs.nextUrl !== null ? `<a href="${escapeHtml(view.runs.nextUrl)}">Next \u2192</a>` : ""}
13064
+ </nav>` : ""}
13065
+ </section>`;
13066
+ const body = `<section class="tempest-admin-tasks">
13067
+ <header class="tempest-admin-list__header">
13068
+ <h1>Tasks</h1>
13069
+ <p>What this process declares, and what its workers recorded.</p>
13070
+ </header>
13071
+ ${declared}
13072
+ ${runs}
13073
+ <p class="tempest-admin-form__hint">Live queue depth is not shown: a broker does not expose it, and a number that looked like one would be worse than none.</p>
13074
+ </section>`;
13075
+ return renderLayout(context, `Tasks \xB7 ${context.site.title}`, body);
13076
+ }
13077
+ function renderTaskDetailPage(context, view) {
13078
+ if (context.session === null) throw new Error("The task detail requires a session");
13079
+ const block = (label, content) => content === null ? "" : `<h2>${escapeHtml(label)}</h2><pre class="tempest-admin-detail__json">${escapeHtml(content)}</pre>`;
13080
+ const body = `<section class="tempest-admin-detail">
13081
+ <header class="tempest-admin-detail__header">
13082
+ <h1>${escapeHtml(view.name)} \xB7 <span class="tempest-log-badge tempest-log-badge--${escapeHtml(view.status)}">${escapeHtml(view.status)}</span></h1>
13083
+ <div class="tempest-admin-detail__actions">
13084
+ <a href="${escapeHtml(view.backUrl)}">\u2190 Back to tasks</a>
13085
+ ${view.cancelUrl !== null ? `<form method="post" action="${escapeHtml(view.cancelUrl)}" class="tempest-admin-detail__delete" onsubmit="return confirm('Ask this run to stop?');">
13086
+ <input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
13087
+ <button type="submit" class="tempest-admin-btn--danger">Cancel</button>
13088
+ </form>` : ""}
13089
+ </div>
13090
+ </header>
13091
+ <dl class="tempest-admin-detail__fields">${view.fields.map(
13092
+ (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
13093
+ ).join("")}</dl>
13094
+ ${view.error === null ? "" : `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>`}
13095
+ ${block("Payload", view.payload)}
13096
+ ${block("Result", view.result)}
13097
+ </section>`;
13098
+ return renderLayout(context, `${view.name} \xB7 ${context.site.title}`, body);
13099
+ }
12820
13100
  function renderSqlPage(context, view) {
12821
13101
  if (context.session === null) throw new Error("The SQL console requires a session");
12822
13102
  const result = view.rowCount === null ? "" : `<p class="tempest-admin-form__hint">${escapeHtml(view.rowCount)} row${view.rowCount === 1 ? "" : "s"}${view.durationMs === null ? "" : ` in ${escapeHtml(view.durationMs)} ms`}${view.truncated ? " (truncated by the row cap)" : ""}.</p>
12823
13103
  <div class="tempest-admin-table-wrap">
12824
13104
  <table class="tempest-admin-list__table">
12825
- <thead><tr>${view.columns.map((column6) => `<th>${escapeHtml(column6)}</th>`).join("")}</tr></thead>
13105
+ <thead><tr>${view.columns.map((column7) => `<th>${escapeHtml(column7)}</th>`).join("")}</tr></thead>
12826
13106
  <tbody>${view.rows.map(
12827
13107
  (row) => `<tr>${row.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("")}</tr>`
12828
13108
  ).join("")}</tbody>
@@ -12992,6 +13272,15 @@ function makeLogsRouter(options) {
12992
13272
  return router;
12993
13273
  }
12994
13274
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
13275
+ var JOB_STATUSES = [
13276
+ "queued",
13277
+ "running",
13278
+ "succeeded",
13279
+ "failed",
13280
+ "cancelled"
13281
+ ];
13282
+ var TERMINAL_JOB_STATUSES = ["succeeded", "failed", "cancelled"];
13283
+ var TASK_PAGE_SIZE = 25;
12995
13284
  var LOG_SOURCES = ["all", "debug", "info", "warning", "error", "500"];
12996
13285
  var LOG_PAGE_SIZE = 50;
12997
13286
  var LOG_EXPORT_MAX = 500;
@@ -13033,6 +13322,9 @@ function makeAdminRouter(site, options) {
13033
13322
  if (options.sqlConsole !== void 0) {
13034
13323
  systemNav.push({ label: "SQL console", url: `${prefix}/sql` });
13035
13324
  }
13325
+ if (options.tasks !== void 0) {
13326
+ systemNav.push({ label: "Tasks", url: `${prefix}/tasks` });
13327
+ }
13036
13328
  const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
13037
13329
  const sessions = new AdminSessionStore({
13038
13330
  secret: options.secretKey,
@@ -13437,7 +13729,7 @@ function makeAdminRouter(site, options) {
13437
13729
  error: null,
13438
13730
  columns,
13439
13731
  rows: window.map(
13440
- (row) => columns.map((column6) => formatCellValue(row[column6]))
13732
+ (row) => columns.map((column7) => formatCellValue(row[column7]))
13441
13733
  ),
13442
13734
  rowCount: rows.length,
13443
13735
  truncated: rows.length > window.length,
@@ -13446,6 +13738,115 @@ function makeAdminRouter(site, options) {
13446
13738
  })
13447
13739
  );
13448
13740
  }
13741
+ if (options.tasks !== void 0) {
13742
+ const tasks = options.tasks;
13743
+ router.get(
13744
+ `${prefix}/tasks`,
13745
+ guarded(async (req, res) => {
13746
+ const state = await authenticate(req, res);
13747
+ if (state === null) return;
13748
+ const inventory = tasks.manager === void 0 ? null : tasks.manager.inventory().map((entry) => ({
13749
+ name: entry.name,
13750
+ description: entry.description ?? "",
13751
+ schedule: entry.schedule ?? "\u2014"
13752
+ }));
13753
+ let runs = null;
13754
+ if (tasks.jobs !== void 0) {
13755
+ const store = tasks.jobs(state.dbSession);
13756
+ const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
13757
+ const status = queryString(req.query.status);
13758
+ const name = queryString(req.query.task);
13759
+ const result = await store.list({
13760
+ page: page2,
13761
+ pageSize: TASK_PAGE_SIZE,
13762
+ ...name === "" ? {} : { name },
13763
+ ...JOB_STATUSES.includes(status) ? { status } : {}
13764
+ });
13765
+ const pageUrl = (target) => `?${buildQuery({ status, task: name, page: target })}`;
13766
+ runs = {
13767
+ rows: result.items.map((row) => ({
13768
+ id: String(row.id),
13769
+ name: String(row.name ?? ""),
13770
+ status: String(row.status ?? ""),
13771
+ startedAt: formatCellValue(row.startedAt),
13772
+ finishedAt: formatCellValue(row.finishedAt),
13773
+ attempts: formatCellValue(row.attempts),
13774
+ url: `${prefix}/tasks/${String(row.id)}`
13775
+ })),
13776
+ total: result.total,
13777
+ page: result.page,
13778
+ pages: result.pages,
13779
+ prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
13780
+ nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
13781
+ statuses: ["", ...JOB_STATUSES].map((value) => ({
13782
+ value,
13783
+ label: value === "" ? "\u2014 any \u2014" : humanizeField(value),
13784
+ selected: value === status
13785
+ })),
13786
+ nameQuery: name
13787
+ };
13788
+ }
13789
+ html(
13790
+ res,
13791
+ renderTasksPage(context(req, state.session, state.visible), {
13792
+ inventory,
13793
+ runs
13794
+ })
13795
+ );
13796
+ })
13797
+ );
13798
+ if (tasks.jobs !== void 0) {
13799
+ const jobs = tasks.jobs;
13800
+ router.get(
13801
+ `${prefix}/tasks/:id`,
13802
+ guarded(async (req, res) => {
13803
+ const state = await authenticate(req, res);
13804
+ if (state === null) return;
13805
+ const row = await jobs(state.dbSession).get(String(req.params.id));
13806
+ if (row === null) {
13807
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
13808
+ return;
13809
+ }
13810
+ const status = String(row.status ?? "");
13811
+ html(
13812
+ res,
13813
+ renderTaskDetailPage(context(req, state.session, state.visible), {
13814
+ id: String(row.id),
13815
+ name: String(row.name ?? ""),
13816
+ status,
13817
+ fields: [
13818
+ { label: "Created At", value: formatCellValue(row.createdAt) },
13819
+ { label: "Started At", value: formatCellValue(row.startedAt) },
13820
+ { label: "Finished At", value: formatCellValue(row.finishedAt) },
13821
+ { label: "Attempts", value: formatCellValue(row.attempts) }
13822
+ ],
13823
+ payload: jsonBlock(row.payload),
13824
+ result: jsonBlock(row.result),
13825
+ error: typeof row.error === "string" && row.error !== "" ? row.error : null,
13826
+ backUrl: `${prefix}/tasks`,
13827
+ cancelUrl: TERMINAL_JOB_STATUSES.includes(status) ? null : `${prefix}/tasks/${String(row.id)}/cancel`
13828
+ })
13829
+ );
13830
+ })
13831
+ );
13832
+ router.post(
13833
+ `${prefix}/tasks/:id/cancel`,
13834
+ guarded(async (req, res) => {
13835
+ const state = await authenticate(req, res);
13836
+ if (state === null) return;
13837
+ if (!checkCsrf(req, res, state)) return;
13838
+ const id = String(req.params.id);
13839
+ const cancelled = await jobs(state.dbSession).cancel(id);
13840
+ res.redirect(
13841
+ `${prefix}/tasks/${id}?${buildQuery({
13842
+ flash: cancelled ? "The run was asked to stop." : "That run had already finished.",
13843
+ level: cancelled ? "success" : "warning"
13844
+ })}`
13845
+ );
13846
+ })
13847
+ );
13848
+ }
13849
+ }
13449
13850
  router.get(
13450
13851
  `${prefix}/m/:slug`,
13451
13852
  guarded(async (req, res) => {
@@ -13756,8 +14157,8 @@ function makeAdminRouter(site, options) {
13756
14157
  res.status(404).json({ options: [] });
13757
14158
  return;
13758
14159
  }
13759
- const column6 = adminColumns(admin.model)[field];
13760
- const table = column6 === void 0 ? null : foreignKeyTable(column6);
14160
+ const column7 = adminColumns(admin.model)[field];
14161
+ const table = column7 === void 0 ? null : foreignKeyTable(column7);
13761
14162
  const referenced = table === null ? null : site.get(table);
13762
14163
  if (referenced === null) {
13763
14164
  res.json({ options: [] });
@@ -14066,7 +14467,7 @@ function makeAdminRouter(site, options) {
14066
14467
  formAction: "",
14067
14468
  rows: visible.map((child) => ({
14068
14469
  key: String(child[childAdmin?.identityField ?? "id"]),
14069
- cells: columns.map((column6) => formatCellValue(child[column6])),
14470
+ cells: columns.map((column7) => formatCellValue(child[column7])),
14070
14471
  fields: [],
14071
14472
  url: childAdmin === null ? null : `${prefix}/m/${inline.slug}/${String(child[childAdmin.identityField])}`
14072
14473
  })),
@@ -14198,8 +14599,8 @@ function makeAdminRouter(site, options) {
14198
14599
  for (const field of admin.uploadFields) {
14199
14600
  const file = files.find((candidate) => candidate.field === field);
14200
14601
  if (file === void 0) {
14201
- const column6 = columns[field];
14202
- if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
14602
+ const column7 = columns[field];
14603
+ if (creating && column7 !== void 0 && !isColumnOptional(column7)) {
14203
14604
  errors[field] = "This field is required.";
14204
14605
  }
14205
14606
  continue;
@@ -14231,14 +14632,14 @@ function makeAdminRouter(site, options) {
14231
14632
  });
14232
14633
  const displayed = admin.listDisplayNames();
14233
14634
  const sort = {};
14234
- for (const column6 of displayed) {
14235
- if (!(column6 in columns)) continue;
14236
- const active = (query.sortColumn ?? admin.orderKey) === column6;
14635
+ for (const column7 of displayed) {
14636
+ if (!(column7 in columns)) continue;
14637
+ const active = (query.sortColumn ?? admin.orderKey) === column7;
14237
14638
  const nextAscending = active ? !query.ascending : true;
14238
- sort[column6] = {
14639
+ sort[column7] = {
14239
14640
  url: `?${buildQuery({
14240
14641
  ...query.baseQuery,
14241
- sort: column6,
14642
+ sort: column7,
14242
14643
  dir: nextAscending ? "asc" : "desc"
14243
14644
  })}`,
14244
14645
  active,
@@ -14259,7 +14660,7 @@ function makeAdminRouter(site, options) {
14259
14660
  const identity = String(row[admin.identityField]);
14260
14661
  return {
14261
14662
  identity,
14262
- cells: displayed.map((column6) => formatCellValue(row[column6])),
14663
+ cells: displayed.map((column7) => formatCellValue(row[column7])),
14263
14664
  url: `${prefix}/m/${admin.slug()}/${identity}`
14264
14665
  };
14265
14666
  }),
@@ -14305,9 +14706,9 @@ function makeAdminRouter(site, options) {
14305
14706
  }
14306
14707
  const filterViews = [];
14307
14708
  for (const field of admin.listFilter) {
14308
- const column6 = columns[field];
14309
- if (column6 === void 0) continue;
14310
- const spec = filterForColumn(column6);
14709
+ const column7 = columns[field];
14710
+ if (column7 === void 0) continue;
14711
+ const spec = filterForColumn(column7);
14311
14712
  if (spec.kind === "daterange") {
14312
14713
  const from = queryString(req.query[`filter_${field}_from`]);
14313
14714
  const to = queryString(req.query[`filter_${field}_to`]);
@@ -14326,12 +14727,12 @@ function makeAdminRouter(site, options) {
14326
14727
  });
14327
14728
  continue;
14328
14729
  }
14329
- const related = await relatedOptions(column6, dbSession);
14730
+ const related = await relatedOptions(column7, dbSession);
14330
14731
  const options2 = related ?? spec.options;
14331
14732
  const value = queryString(req.query[`filter_${field}`]);
14332
14733
  if (value !== "") {
14333
14734
  conditions.push({
14334
- [field]: column6.type.kind === "boolean" ? value === "true" : value
14735
+ [field]: column7.type.kind === "boolean" ? value === "true" : value
14335
14736
  });
14336
14737
  }
14337
14738
  filterViews.push({
@@ -14349,8 +14750,8 @@ function makeAdminRouter(site, options) {
14349
14750
  });
14350
14751
  }
14351
14752
  const searchable = admin.searchFields.filter((field) => {
14352
- const column6 = columns[field];
14353
- return column6 !== void 0 && isSearchableColumn(column6);
14753
+ const column7 = columns[field];
14754
+ return column7 !== void 0 && isSearchableColumn(column7);
14354
14755
  });
14355
14756
  if (search !== "" && searchable.length > 0) {
14356
14757
  conditions.push(
@@ -14383,8 +14784,8 @@ function makeAdminRouter(site, options) {
14383
14784
  lens: lens?.slug ?? ""
14384
14785
  };
14385
14786
  }
14386
- async function relatedOptions(column6, dbSession) {
14387
- const table = foreignKeyTable(column6);
14787
+ async function relatedOptions(column7, dbSession) {
14788
+ const table = foreignKeyTable(column7);
14388
14789
  if (table === null) return null;
14389
14790
  const referenced = site.get(table);
14390
14791
  if (referenced === null) return null;
@@ -14399,9 +14800,9 @@ function makeAdminRouter(site, options) {
14399
14800
  const options2 = {};
14400
14801
  for (const field of Object.keys(foreignKeyFields(admin))) {
14401
14802
  if (admin.autocompleteFields.includes(field)) continue;
14402
- const column6 = columns[field];
14403
- if (column6 === void 0) continue;
14404
- const related = await relatedOptions(column6, dbSession);
14803
+ const column7 = columns[field];
14804
+ if (column7 === void 0) continue;
14805
+ const related = await relatedOptions(column7, dbSession);
14405
14806
  if (related !== null) options2[field] = related;
14406
14807
  }
14407
14808
  return options2;
@@ -14420,8 +14821,8 @@ function makeAdminRouter(site, options) {
14420
14821
  for (const field of admin.autocompleteFields) {
14421
14822
  const value = row[field];
14422
14823
  if (value === null || value === void 0 || value === "") continue;
14423
- const column6 = columns[field];
14424
- const table = column6 === void 0 ? null : foreignKeyTable(column6);
14824
+ const column7 = columns[field];
14825
+ const table = column7 === void 0 ? null : foreignKeyTable(column7);
14425
14826
  const referenced = table === null ? null : site.get(table);
14426
14827
  if (referenced === null) continue;
14427
14828
  const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
@@ -14460,6 +14861,11 @@ function inlineFieldNames(childAdmin, inline) {
14460
14861
  function inlineFields(childAdmin, names, key, values, errors) {
14461
14862
  return buildFormFields(childAdmin, { values, errors }).filter((field) => names.includes(field.name)).map((field) => ({ ...field, name: `row.${key}.${field.name}` }));
14462
14863
  }
14864
+ function jsonBlock(value) {
14865
+ if (value === null || value === void 0) return null;
14866
+ if (typeof value === "object" && Object.keys(value).length === 0) return null;
14867
+ return JSON.stringify(value, null, 2);
14868
+ }
14463
14869
  async function audit(hook, entry) {
14464
14870
  if (hook === void 0) return;
14465
14871
  try {
@@ -14623,7 +15029,7 @@ function csvField(value) {
14623
15029
  function toCsv(columns, rows) {
14624
15030
  const lines = [columns.map(csvField).join(",")];
14625
15031
  for (const row of rows) {
14626
- lines.push(columns.map((column6) => csvField(exportValue(row[column6]))).join(","));
15032
+ lines.push(columns.map((column7) => csvField(exportValue(row[column7]))).join(","));
14627
15033
  }
14628
15034
  return `${lines.join("\r\n")}\r
14629
15035
  `;
@@ -14631,7 +15037,7 @@ function toCsv(columns, rows) {
14631
15037
  function toJson(columns, rows) {
14632
15038
  return JSON.stringify(
14633
15039
  rows.map(
14634
- (row) => Object.fromEntries(columns.map((column6) => [column6, exportValue(row[column6])]))
15040
+ (row) => Object.fromEntries(columns.map((column7) => [column7, exportValue(row[column7])]))
14635
15041
  ),
14636
15042
  null,
14637
15043
  2
@@ -16588,7 +16994,7 @@ async function withTestDatabase(models, fn) {
16588
16994
  }
16589
16995
 
16590
16996
  // src/version.ts
16591
- var VERSION = "0.29.0";
16997
+ var VERSION = "0.30.0";
16592
16998
 
16593
16999
  Object.defineProperty(exports, "OpenAPIRegistry", {
16594
17000
  enumerable: true,
@@ -16754,6 +17160,7 @@ exports.AttemptThrottle = AttemptThrottle;
16754
17160
  exports.AuditAction = AuditAction;
16755
17161
  exports.BaseAuditLogModel = BaseAuditLogModel;
16756
17162
  exports.BaseController = BaseController;
17163
+ exports.BaseJobModel = BaseJobModel;
16757
17164
  exports.BaseModel = BaseModel;
16758
17165
  exports.BaseOAuthClient = BaseOAuthClient;
16759
17166
  exports.BaseOutboxModel = BaseOutboxModel;
@@ -16789,6 +17196,8 @@ exports.IDEMPOTENCY_HEADER = IDEMPOTENCY_HEADER;
16789
17196
  exports.InvalidTokenException = InvalidTokenException;
16790
17197
  exports.JSONLogger = JSONLogger;
16791
17198
  exports.JWTUtils = JWTUtils;
17199
+ exports.JobStatus = JobStatus;
17200
+ exports.JobStore = JobStore;
16792
17201
  exports.LEVEL_LOG_FILES = LEVEL_LOG_FILES;
16793
17202
  exports.LocalUploadStorage = LocalUploadStorage;
16794
17203
  exports.MemoryBroker = MemoryBroker;
@@ -17017,6 +17426,8 @@ exports.renderLogsPage = renderLogsPage;
17017
17426
  exports.renderMfaPage = renderMfaPage;
17018
17427
  exports.renderPasswordResetFormPage = renderPasswordResetFormPage;
17019
17428
  exports.renderSqlPage = renderSqlPage;
17429
+ exports.renderTaskDetailPage = renderTaskDetailPage;
17430
+ exports.renderTasksPage = renderTasksPage;
17020
17431
  exports.requestIdMiddleware = requestIdMiddleware;
17021
17432
  exports.requestTracingMiddleware = requestTracingMiddleware;
17022
17433
  exports.requireRoles = requireRoles;