@cronvello/sdk 0.1.3 → 0.2.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/CHANGELOG.md +53 -0
- package/README.md +135 -2
- package/dist/cli.cjs +1106 -10
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1104 -12
- package/dist/cli.js.map +1 -1
- package/dist/dev.cjs +1186 -0
- package/dist/dev.cjs.map +1 -0
- package/dist/dev.d.cts +318 -0
- package/dist/dev.d.ts +318 -0
- package/dist/dev.js +1173 -0
- package/dist/dev.js.map +1 -0
- package/dist/index.cjs +645 -6
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +644 -7
- package/dist/index.js.map +1 -1
- package/docs/dashboard.png +0 -0
- package/package.json +12 -1
package/dist/cli.cjs
CHANGED
|
@@ -1,12 +1,887 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
var http = require('http');
|
|
4
5
|
var module$1 = require('module');
|
|
5
6
|
var url = require('url');
|
|
6
7
|
var path = require('path');
|
|
7
8
|
var fs = require('fs');
|
|
8
9
|
|
|
9
10
|
var _documentCurrentScript = typeof document !== 'undefined' ? document.currentScript : null;
|
|
11
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
12
|
+
|
|
13
|
+
var http__default = /*#__PURE__*/_interopDefault(http);
|
|
14
|
+
|
|
15
|
+
var __defProp = Object.defineProperty;
|
|
16
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
17
|
+
var __esm = (fn, res) => function __init() {
|
|
18
|
+
return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
|
|
19
|
+
};
|
|
20
|
+
var __export = (target, all) => {
|
|
21
|
+
for (var name in all)
|
|
22
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// src/internal/cron-schedule.ts
|
|
26
|
+
function parseCron(expr) {
|
|
27
|
+
if (typeof expr !== "string" || !expr.trim()) throw new Error("cron expression is empty");
|
|
28
|
+
let trimmed = expr.trim();
|
|
29
|
+
if (trimmed.startsWith("@")) {
|
|
30
|
+
const macro = trimmed.toLowerCase();
|
|
31
|
+
if (macro === "@reboot") {
|
|
32
|
+
return {
|
|
33
|
+
seconds: /* @__PURE__ */ new Set([0]),
|
|
34
|
+
minutes: /* @__PURE__ */ new Set(),
|
|
35
|
+
hours: /* @__PURE__ */ new Set(),
|
|
36
|
+
daysOfMonth: /* @__PURE__ */ new Set(),
|
|
37
|
+
months: /* @__PURE__ */ new Set(),
|
|
38
|
+
daysOfWeek: /* @__PURE__ */ new Set(),
|
|
39
|
+
domRestricted: false,
|
|
40
|
+
dowRestricted: false,
|
|
41
|
+
reboot: true
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
const expanded = MACRO_EXPANSIONS[macro];
|
|
45
|
+
if (!expanded) throw new Error(`unknown cron macro "${trimmed}" (try @daily, @hourly, \u2026)`);
|
|
46
|
+
trimmed = expanded;
|
|
47
|
+
}
|
|
48
|
+
const parts = trimmed.split(/\s+/);
|
|
49
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
50
|
+
throw new Error(`expected 5 fields (min hour dom month dow) or 6 with seconds, got ${parts.length}: "${trimmed}"`);
|
|
51
|
+
}
|
|
52
|
+
const hasSeconds = parts.length === 6;
|
|
53
|
+
const [secRaw, minRaw, hourRaw, domRaw, monthRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
|
|
54
|
+
return {
|
|
55
|
+
seconds: expandField(secRaw, SECOND_SPEC),
|
|
56
|
+
minutes: expandField(minRaw, MINUTE_SPEC),
|
|
57
|
+
hours: expandField(hourRaw, HOUR_SPEC),
|
|
58
|
+
daysOfMonth: expandField(domRaw, DOM_SPEC),
|
|
59
|
+
months: expandField(monthRaw, MONTH_SPEC),
|
|
60
|
+
daysOfWeek: expandField(dowRaw, DOW_SPEC),
|
|
61
|
+
domRestricted: isRestricted(domRaw),
|
|
62
|
+
dowRestricted: isRestricted(dowRaw),
|
|
63
|
+
reboot: false
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function isRestricted(field) {
|
|
67
|
+
const f = field.trim();
|
|
68
|
+
return f !== "*" && f !== "?";
|
|
69
|
+
}
|
|
70
|
+
function expandField(raw, spec) {
|
|
71
|
+
const set = /* @__PURE__ */ new Set();
|
|
72
|
+
for (const term of raw.split(",")) {
|
|
73
|
+
if (term === "") throw new Error(`empty term in ${spec.label} field "${raw}"`);
|
|
74
|
+
let base = term;
|
|
75
|
+
let step = 1;
|
|
76
|
+
const slash = term.indexOf("/");
|
|
77
|
+
if (slash >= 0) {
|
|
78
|
+
base = term.slice(0, slash);
|
|
79
|
+
const stepStr = term.slice(slash + 1);
|
|
80
|
+
if (!/^\d+$/.test(stepStr) || Number(stepStr) === 0) {
|
|
81
|
+
throw new Error(`invalid step "${stepStr}" in ${spec.label} field "${raw}"`);
|
|
82
|
+
}
|
|
83
|
+
step = Number(stepStr);
|
|
84
|
+
}
|
|
85
|
+
let lo;
|
|
86
|
+
let hi;
|
|
87
|
+
if (base === "*" || base === "?") {
|
|
88
|
+
lo = spec.min;
|
|
89
|
+
hi = spec.max;
|
|
90
|
+
} else {
|
|
91
|
+
const dash = base.indexOf("-");
|
|
92
|
+
if (dash > 0) {
|
|
93
|
+
lo = resolveValue(base.slice(0, dash), spec);
|
|
94
|
+
hi = resolveValue(base.slice(dash + 1), spec);
|
|
95
|
+
} else {
|
|
96
|
+
lo = resolveValue(base, spec);
|
|
97
|
+
hi = step > 1 ? spec.max : lo;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (lo === null || hi === null) {
|
|
101
|
+
throw new Error(`value out of range in ${spec.label} field "${raw}" (${spec.min}-${spec.max})`);
|
|
102
|
+
}
|
|
103
|
+
if (lo > hi) throw new Error(`range start ${lo} is greater than end ${hi} in ${spec.label} field "${raw}"`);
|
|
104
|
+
for (let v = lo; v <= hi; v += step) set.add(spec.fold ? spec.fold(v) : v);
|
|
105
|
+
}
|
|
106
|
+
return set;
|
|
107
|
+
}
|
|
108
|
+
function resolveValue(token, spec) {
|
|
109
|
+
const t = token.trim();
|
|
110
|
+
if (/^\d+$/.test(t)) {
|
|
111
|
+
const n = Number(t);
|
|
112
|
+
return n >= spec.min && n <= spec.max ? n : null;
|
|
113
|
+
}
|
|
114
|
+
if (spec.names) {
|
|
115
|
+
const idx = spec.names.indexOf(t.toLowerCase());
|
|
116
|
+
if (idx >= 0) return idx + spec.nameOffset;
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
function nextOccurrence(expr, opts = {}) {
|
|
121
|
+
const parsed = typeof expr === "string" ? parseCron(expr) : expr;
|
|
122
|
+
if (parsed.reboot) throw new Error("@reboot has no scheduled next occurrence (the engine fires it once at start)");
|
|
123
|
+
const tz = opts.timeZone ?? "UTC";
|
|
124
|
+
const fromMs = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
125
|
+
const cursor = Math.floor(fromMs / 1e3) * 1e3 + 1e3;
|
|
126
|
+
const p = getZonedParts(cursor, tz);
|
|
127
|
+
const startYear = p.y;
|
|
128
|
+
const hourArr = sorted(parsed.hours);
|
|
129
|
+
const minuteArr = sorted(parsed.minutes);
|
|
130
|
+
const secondArr = sorted(parsed.seconds);
|
|
131
|
+
let guard = 0;
|
|
132
|
+
while (guard++ < 1e6) {
|
|
133
|
+
if (p.y > startYear + SEARCH_HORIZON_YEARS) return null;
|
|
134
|
+
if (!parsed.months.has(p.mo)) {
|
|
135
|
+
bumpMonth(p);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (p.d > daysInMonth(p.y, p.mo)) {
|
|
139
|
+
bumpMonth(p);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (!dayMatches(parsed, p.y, p.mo, p.d)) {
|
|
143
|
+
bumpDay(p);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const nh = firstAtLeast(hourArr, p.h);
|
|
147
|
+
if (nh === null) {
|
|
148
|
+
bumpDay(p);
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
if (nh !== p.h) {
|
|
152
|
+
p.h = nh;
|
|
153
|
+
p.mi = 0;
|
|
154
|
+
p.s = 0;
|
|
155
|
+
}
|
|
156
|
+
const nmi = firstAtLeast(minuteArr, p.mi);
|
|
157
|
+
if (nmi === null) {
|
|
158
|
+
p.h += 1;
|
|
159
|
+
p.mi = 0;
|
|
160
|
+
p.s = 0;
|
|
161
|
+
continue;
|
|
162
|
+
}
|
|
163
|
+
if (nmi !== p.mi) {
|
|
164
|
+
p.mi = nmi;
|
|
165
|
+
p.s = 0;
|
|
166
|
+
}
|
|
167
|
+
const ns = firstAtLeast(secondArr, p.s);
|
|
168
|
+
if (ns === null) {
|
|
169
|
+
p.mi += 1;
|
|
170
|
+
p.s = 0;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
p.s = ns;
|
|
174
|
+
const epoch = zonedWallToEpoch(p, tz);
|
|
175
|
+
if (epoch <= fromMs) {
|
|
176
|
+
p.s += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
return new Date(epoch);
|
|
180
|
+
}
|
|
181
|
+
throw new Error(`nextOccurrence: search exceeded its iteration bound for "${typeof expr === "string" ? expr : "(parsed)"}"`);
|
|
182
|
+
}
|
|
183
|
+
function dayMatches(parsed, y, mo, d) {
|
|
184
|
+
const domOk = parsed.daysOfMonth.has(d);
|
|
185
|
+
const dowOk = parsed.daysOfWeek.has(weekdayOf(y, mo, d));
|
|
186
|
+
if (parsed.domRestricted && parsed.dowRestricted) return domOk || dowOk;
|
|
187
|
+
if (parsed.domRestricted) return domOk;
|
|
188
|
+
if (parsed.dowRestricted) return dowOk;
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
function bumpMonth(p) {
|
|
192
|
+
p.mo += 1;
|
|
193
|
+
if (p.mo > 12) {
|
|
194
|
+
p.mo = 1;
|
|
195
|
+
p.y += 1;
|
|
196
|
+
}
|
|
197
|
+
p.d = 1;
|
|
198
|
+
p.h = 0;
|
|
199
|
+
p.mi = 0;
|
|
200
|
+
p.s = 0;
|
|
201
|
+
}
|
|
202
|
+
function bumpDay(p) {
|
|
203
|
+
p.d += 1;
|
|
204
|
+
p.h = 0;
|
|
205
|
+
p.mi = 0;
|
|
206
|
+
p.s = 0;
|
|
207
|
+
}
|
|
208
|
+
function sorted(set) {
|
|
209
|
+
return [...set].sort((a, b) => a - b);
|
|
210
|
+
}
|
|
211
|
+
function firstAtLeast(arr, v) {
|
|
212
|
+
for (const x of arr) if (x >= v) return x;
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
function daysInMonth(year, month) {
|
|
216
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
217
|
+
}
|
|
218
|
+
function weekdayOf(year, month, day) {
|
|
219
|
+
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
220
|
+
}
|
|
221
|
+
function formatterFor(timeZone) {
|
|
222
|
+
let fmt = FORMATTER_CACHE.get(timeZone);
|
|
223
|
+
if (!fmt) {
|
|
224
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
225
|
+
timeZone,
|
|
226
|
+
year: "numeric",
|
|
227
|
+
month: "2-digit",
|
|
228
|
+
day: "2-digit",
|
|
229
|
+
hour: "2-digit",
|
|
230
|
+
minute: "2-digit",
|
|
231
|
+
second: "2-digit",
|
|
232
|
+
hour12: false
|
|
233
|
+
});
|
|
234
|
+
FORMATTER_CACHE.set(timeZone, fmt);
|
|
235
|
+
}
|
|
236
|
+
return fmt;
|
|
237
|
+
}
|
|
238
|
+
function getZonedParts(epochMs, timeZone) {
|
|
239
|
+
const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
|
|
240
|
+
const m = {};
|
|
241
|
+
for (const part of parts) if (part.type !== "literal") m[part.type] = part.value;
|
|
242
|
+
let h = Number(m["hour"]);
|
|
243
|
+
if (h === 24) h = 0;
|
|
244
|
+
return { y: Number(m["year"]), mo: Number(m["month"]), d: Number(m["day"]), h, mi: Number(m["minute"]), s: Number(m["second"]) };
|
|
245
|
+
}
|
|
246
|
+
function offsetAt(epochMs, timeZone) {
|
|
247
|
+
const p = getZonedParts(epochMs, timeZone);
|
|
248
|
+
const asNaive = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
|
|
249
|
+
return asNaive - epochMs;
|
|
250
|
+
}
|
|
251
|
+
function zonedWallToEpoch(p, timeZone) {
|
|
252
|
+
const asUTC = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
|
|
253
|
+
const o1 = offsetAt(asUTC, timeZone);
|
|
254
|
+
let epoch = asUTC - o1;
|
|
255
|
+
const o2 = offsetAt(epoch, timeZone);
|
|
256
|
+
if (o2 !== o1) epoch = asUTC - o2;
|
|
257
|
+
return epoch;
|
|
258
|
+
}
|
|
259
|
+
function previewSchedule(expr, opts = {}) {
|
|
260
|
+
const parsed = parseCron(expr);
|
|
261
|
+
const timeZone = opts.timeZone ?? localTimeZone();
|
|
262
|
+
const count = Math.min(Math.max(Math.trunc(opts.count ?? 5), 1), 100);
|
|
263
|
+
const out2 = [];
|
|
264
|
+
let from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
265
|
+
for (let i = 0; i < count; i++) {
|
|
266
|
+
const next = nextOccurrence(parsed, { from, timeZone });
|
|
267
|
+
if (!next) break;
|
|
268
|
+
out2.push(next);
|
|
269
|
+
from = next.getTime();
|
|
270
|
+
}
|
|
271
|
+
return out2;
|
|
272
|
+
}
|
|
273
|
+
function upcomingFires(jobs, opts = {}) {
|
|
274
|
+
const from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
275
|
+
const withinMs = opts.withinMs ?? 60 * 60 * 1e3;
|
|
276
|
+
const maxPerJob = Math.max(1, opts.maxPerJob ?? 50);
|
|
277
|
+
const horizon = from + withinMs;
|
|
278
|
+
const fires = [];
|
|
279
|
+
for (const job of jobs) {
|
|
280
|
+
let parsed;
|
|
281
|
+
try {
|
|
282
|
+
parsed = parseCron(job.schedule);
|
|
283
|
+
} catch {
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (parsed.reboot) continue;
|
|
287
|
+
let cursor = from;
|
|
288
|
+
for (let i = 0; i < maxPerJob; i++) {
|
|
289
|
+
const next = nextOccurrence(parsed, { from: cursor, timeZone: job.timeZone });
|
|
290
|
+
if (!next || next.getTime() > horizon) break;
|
|
291
|
+
fires.push({ key: job.key, time: next, schedule: job.schedule, timeZone: job.timeZone });
|
|
292
|
+
cursor = next.getTime();
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
fires.sort((a, b) => a.time.getTime() - b.time.getTime());
|
|
296
|
+
return fires;
|
|
297
|
+
}
|
|
298
|
+
function localTimeZone() {
|
|
299
|
+
try {
|
|
300
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
301
|
+
} catch {
|
|
302
|
+
return "UTC";
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
var MACRO_EXPANSIONS, MONTH_NAMES, DOW_NAMES, SECOND_SPEC, MINUTE_SPEC, HOUR_SPEC, DOM_SPEC, MONTH_SPEC, DOW_SPEC, SEARCH_HORIZON_YEARS, FORMATTER_CACHE;
|
|
306
|
+
var init_cron_schedule = __esm({
|
|
307
|
+
"src/internal/cron-schedule.ts"() {
|
|
308
|
+
MACRO_EXPANSIONS = {
|
|
309
|
+
"@yearly": "0 0 1 1 *",
|
|
310
|
+
"@annually": "0 0 1 1 *",
|
|
311
|
+
"@monthly": "0 0 1 * *",
|
|
312
|
+
"@weekly": "0 0 * * 0",
|
|
313
|
+
"@daily": "0 0 * * *",
|
|
314
|
+
"@midnight": "0 0 * * *",
|
|
315
|
+
"@hourly": "0 * * * *"
|
|
316
|
+
};
|
|
317
|
+
MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
318
|
+
DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
319
|
+
SECOND_SPEC = { min: 0, max: 59, nameOffset: 0, label: "second" };
|
|
320
|
+
MINUTE_SPEC = { min: 0, max: 59, nameOffset: 0, label: "minute" };
|
|
321
|
+
HOUR_SPEC = { min: 0, max: 23, nameOffset: 0, label: "hour" };
|
|
322
|
+
DOM_SPEC = { min: 1, max: 31, nameOffset: 0, label: "day-of-month" };
|
|
323
|
+
MONTH_SPEC = { min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1, label: "month" };
|
|
324
|
+
DOW_SPEC = { min: 0, max: 7, names: DOW_NAMES, nameOffset: 0, fold: (v) => v % 7, label: "day-of-week" };
|
|
325
|
+
SEARCH_HORIZON_YEARS = 5;
|
|
326
|
+
FORMATTER_CACHE = /* @__PURE__ */ new Map();
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// src/dev/dashboard-assets.ts
|
|
331
|
+
var DASHBOARD_HTML;
|
|
332
|
+
var init_dashboard_assets = __esm({
|
|
333
|
+
"src/dev/dashboard-assets.ts"() {
|
|
334
|
+
DASHBOARD_HTML = `<!doctype html>
|
|
335
|
+
<html lang="en">
|
|
336
|
+
<head>
|
|
337
|
+
<meta charset="utf-8" />
|
|
338
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
339
|
+
<meta name="robots" content="noindex" />
|
|
340
|
+
<title>Cronvello \u2014 local dashboard</title>
|
|
341
|
+
<style>
|
|
342
|
+
:root {
|
|
343
|
+
--indigo: #4F46E5;
|
|
344
|
+
--indigo-soft: #6366F1;
|
|
345
|
+
--teal: #14B8A6;
|
|
346
|
+
--green: #10B981;
|
|
347
|
+
--amber: #F59E0B;
|
|
348
|
+
--red: #EF4444;
|
|
349
|
+
--bg: #f8fafc;
|
|
350
|
+
--panel: #ffffff;
|
|
351
|
+
--panel-2: #f1f5f9;
|
|
352
|
+
--border: #e2e8f0;
|
|
353
|
+
--text: #0f172a;
|
|
354
|
+
--muted: #64748b;
|
|
355
|
+
--shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
356
|
+
}
|
|
357
|
+
[data-theme="dark"] {
|
|
358
|
+
--bg: #0b1120;
|
|
359
|
+
--panel: #111827;
|
|
360
|
+
--panel-2: #0f172a;
|
|
361
|
+
--border: #1f2937;
|
|
362
|
+
--text: #e5e7eb;
|
|
363
|
+
--muted: #94a3b8;
|
|
364
|
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
365
|
+
}
|
|
366
|
+
/* Dark-mode-first: follow the OS preference unless the user has explicitly chosen light. */
|
|
367
|
+
@media (prefers-color-scheme: dark) {
|
|
368
|
+
:root:not([data-theme="light"]) {
|
|
369
|
+
--bg: #0b1120;
|
|
370
|
+
--panel: #111827;
|
|
371
|
+
--panel-2: #0f172a;
|
|
372
|
+
--border: #1f2937;
|
|
373
|
+
--text: #e5e7eb;
|
|
374
|
+
--muted: #94a3b8;
|
|
375
|
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
* { box-sizing: border-box; }
|
|
379
|
+
html, body { margin: 0; padding: 0; }
|
|
380
|
+
body {
|
|
381
|
+
background: var(--bg);
|
|
382
|
+
color: var(--text);
|
|
383
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
|
|
384
|
+
font-size: 14px;
|
|
385
|
+
line-height: 1.5;
|
|
386
|
+
}
|
|
387
|
+
code, .mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
|
388
|
+
header {
|
|
389
|
+
display: flex; align-items: center; gap: 12px;
|
|
390
|
+
padding: 14px 20px;
|
|
391
|
+
border-bottom: 1px solid var(--border);
|
|
392
|
+
background: var(--panel);
|
|
393
|
+
position: sticky; top: 0; z-index: 10;
|
|
394
|
+
}
|
|
395
|
+
.logo {
|
|
396
|
+
width: 28px; height: 28px; border-radius: 8px;
|
|
397
|
+
display: grid; place-items: center;
|
|
398
|
+
background: linear-gradient(135deg, var(--indigo), var(--teal));
|
|
399
|
+
color: #fff; font-size: 17px; font-weight: 700;
|
|
400
|
+
}
|
|
401
|
+
.brand { font-weight: 700; letter-spacing: -0.01em; }
|
|
402
|
+
.brand small { color: var(--muted); font-weight: 500; margin-left: 6px; }
|
|
403
|
+
.spacer { flex: 1; }
|
|
404
|
+
.pill {
|
|
405
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
406
|
+
padding: 4px 10px; border-radius: 999px;
|
|
407
|
+
font-size: 12px; font-weight: 600;
|
|
408
|
+
border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
|
|
409
|
+
}
|
|
410
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
411
|
+
.pill.live .dot { background: var(--green); box-shadow: 0 0 0 0 rgba(16,185,129,0.5); animation: pulse 1.8s infinite; }
|
|
412
|
+
.pill.down .dot { background: var(--red); }
|
|
413
|
+
@keyframes pulse { 0% { box-shadow: 0 0 0 0 rgba(16,185,129,0.5); } 70% { box-shadow: 0 0 0 6px rgba(16,185,129,0); } 100% { box-shadow: 0 0 0 0 rgba(16,185,129,0); } }
|
|
414
|
+
button {
|
|
415
|
+
font: inherit; cursor: pointer; color: var(--text);
|
|
416
|
+
background: var(--panel-2); border: 1px solid var(--border);
|
|
417
|
+
border-radius: 8px; padding: 6px 12px;
|
|
418
|
+
}
|
|
419
|
+
button:hover { border-color: var(--indigo-soft); }
|
|
420
|
+
button.primary { background: var(--indigo); color: #fff; border-color: var(--indigo); }
|
|
421
|
+
button.primary:hover { background: var(--indigo-soft); }
|
|
422
|
+
.layout { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; padding: 16px 20px; align-items: start; }
|
|
423
|
+
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
|
424
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
|
|
425
|
+
.card > h2 { margin: 0; padding: 12px 16px; font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); border-bottom: 1px solid var(--border); }
|
|
426
|
+
table { width: 100%; border-collapse: collapse; }
|
|
427
|
+
th, td { text-align: left; padding: 10px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
428
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; }
|
|
429
|
+
tr:last-child td { border-bottom: none; }
|
|
430
|
+
tbody tr { cursor: pointer; }
|
|
431
|
+
tbody tr:hover { background: var(--panel-2); }
|
|
432
|
+
.jobkey { font-weight: 600; }
|
|
433
|
+
.jobdesc { color: var(--muted); font-size: 12px; }
|
|
434
|
+
.count { font-variant-numeric: tabular-nums; }
|
|
435
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
|
436
|
+
.badge.success { background: rgba(16,185,129,0.15); color: var(--green); }
|
|
437
|
+
.badge.error, .badge.timed_out { background: rgba(239,68,68,0.15); color: var(--red); }
|
|
438
|
+
.badge.skipped { background: rgba(100,116,139,0.18); color: var(--muted); }
|
|
439
|
+
.badge.running { background: rgba(79,70,229,0.15); color: var(--indigo-soft); }
|
|
440
|
+
.badge.none { background: var(--panel-2); color: var(--muted); }
|
|
441
|
+
.feed { max-height: 70vh; overflow-y: auto; }
|
|
442
|
+
.ev { display: flex; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
|
443
|
+
.ev .glyph { width: 16px; text-align: center; flex: none; }
|
|
444
|
+
.ev .body { flex: 1; min-width: 0; }
|
|
445
|
+
.ev .when { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
446
|
+
.ev.success .glyph { color: var(--green); }
|
|
447
|
+
.ev.error .glyph, .ev.timeout .glyph { color: var(--red); }
|
|
448
|
+
.ev.retry .glyph { color: var(--amber); }
|
|
449
|
+
.ev.fire .glyph { color: var(--indigo-soft); }
|
|
450
|
+
.ev.skipped .glyph, .ev.scheduled .glyph, .ev.engine .glyph { color: var(--muted); }
|
|
451
|
+
.empty { padding: 24px 16px; color: var(--muted); text-align: center; }
|
|
452
|
+
footer { padding: 12px 20px 28px; color: var(--muted); font-size: 12px; }
|
|
453
|
+
.overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: none; align-items: stretch; justify-content: flex-end; z-index: 30; }
|
|
454
|
+
.overlay.open { display: flex; }
|
|
455
|
+
.drawer { width: min(520px, 92vw); background: var(--panel); border-left: 1px solid var(--border); height: 100%; overflow-y: auto; box-shadow: var(--shadow); }
|
|
456
|
+
.drawer header { background: var(--panel); }
|
|
457
|
+
.drawer .pad { padding: 16px; }
|
|
458
|
+
.kv { display: flex; gap: 8px; margin: 4px 0; }
|
|
459
|
+
.kv span:first-child { color: var(--muted); min-width: 90px; }
|
|
460
|
+
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 18px 0 8px; }
|
|
461
|
+
ul.plain { list-style: none; margin: 0; padding: 0; }
|
|
462
|
+
ul.plain li { padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 8px; }
|
|
463
|
+
</style>
|
|
464
|
+
</head>
|
|
465
|
+
<body>
|
|
466
|
+
<header>
|
|
467
|
+
<div class="logo">↻</div>
|
|
468
|
+
<div class="brand">Cronvello <small>local dashboard</small></div>
|
|
469
|
+
<div class="spacer"></div>
|
|
470
|
+
<span id="conn" class="pill"><span class="dot"></span><span id="conn-text">connecting…</span></span>
|
|
471
|
+
<button id="theme-btn" title="Toggle theme">☼</button>
|
|
472
|
+
</header>
|
|
473
|
+
|
|
474
|
+
<div class="layout">
|
|
475
|
+
<section class="card">
|
|
476
|
+
<h2>Jobs — next fire & last status</h2>
|
|
477
|
+
<table>
|
|
478
|
+
<thead><tr><th>Job</th><th>Schedule</th><th>Timezone</th><th>Next fire</th><th>Last</th></tr></thead>
|
|
479
|
+
<tbody id="jobs"><tr><td colspan="5" class="empty">Loading jobs…</td></tr></tbody>
|
|
480
|
+
</table>
|
|
481
|
+
</section>
|
|
482
|
+
|
|
483
|
+
<section class="card">
|
|
484
|
+
<h2>Live run feed</h2>
|
|
485
|
+
<div id="feed" class="feed"><div class="empty">Waiting for runs…</div></div>
|
|
486
|
+
</section>
|
|
487
|
+
</div>
|
|
488
|
+
|
|
489
|
+
<footer>Local · no account · no cloud · no external network. The engine on this machine is the only source of truth.</footer>
|
|
490
|
+
|
|
491
|
+
<div id="overlay" class="overlay">
|
|
492
|
+
<div class="drawer">
|
|
493
|
+
<header>
|
|
494
|
+
<div class="brand" id="drawer-title">Job</div>
|
|
495
|
+
<div class="spacer"></div>
|
|
496
|
+
<button id="drawer-close">Close</button>
|
|
497
|
+
</header>
|
|
498
|
+
<div class="pad" id="drawer-body"></div>
|
|
499
|
+
</div>
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<script>
|
|
503
|
+
(function () {
|
|
504
|
+
"use strict";
|
|
505
|
+
var MAX_FEED = 200;
|
|
506
|
+
var jobsByKey = {};
|
|
507
|
+
|
|
508
|
+
function el(id) { return document.getElementById(id); }
|
|
509
|
+
function esc(s) {
|
|
510
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
511
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
512
|
+
});
|
|
513
|
+
}
|
|
514
|
+
function pad(n) { return n < 10 ? "0" + n : "" + n; }
|
|
515
|
+
function clockTime(ms) {
|
|
516
|
+
var d = new Date(ms);
|
|
517
|
+
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
|
518
|
+
}
|
|
519
|
+
function fmtDuration(ms) {
|
|
520
|
+
if (ms == null) return "";
|
|
521
|
+
if (ms < 1000) return ms + "ms";
|
|
522
|
+
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
|
|
523
|
+
}
|
|
524
|
+
function countdown(iso) {
|
|
525
|
+
if (!iso) return "—";
|
|
526
|
+
var diff = new Date(iso).getTime() - Date.now();
|
|
527
|
+
if (diff <= 0) return "due now";
|
|
528
|
+
var s = Math.floor(diff / 1000);
|
|
529
|
+
var d = Math.floor(s / 86400); s -= d * 86400;
|
|
530
|
+
var h = Math.floor(s / 3600); s -= h * 3600;
|
|
531
|
+
var m = Math.floor(s / 60); s -= m * 60;
|
|
532
|
+
var out = "in ";
|
|
533
|
+
if (d) out += d + "d ";
|
|
534
|
+
if (d || h) out += h + "h ";
|
|
535
|
+
if (d || h || m) out += m + "m ";
|
|
536
|
+
out += s + "s";
|
|
537
|
+
return out;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// \u2500\u2500 Theme \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
541
|
+
function applyTheme(t) {
|
|
542
|
+
if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
|
|
543
|
+
else document.documentElement.removeAttribute("data-theme");
|
|
544
|
+
}
|
|
545
|
+
function resolvedTheme() {
|
|
546
|
+
var t = document.documentElement.getAttribute("data-theme");
|
|
547
|
+
if (t) return t;
|
|
548
|
+
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
549
|
+
}
|
|
550
|
+
try { applyTheme(localStorage.getItem("cronvello-theme")); } catch (e) {}
|
|
551
|
+
el("theme-btn").addEventListener("click", function () {
|
|
552
|
+
var next = resolvedTheme() === "dark" ? "light" : "dark";
|
|
553
|
+
applyTheme(next);
|
|
554
|
+
try { localStorage.setItem("cronvello-theme", next); } catch (e) {}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
// \u2500\u2500 Jobs table \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
558
|
+
function statusBadge(s) {
|
|
559
|
+
if (!s) return '<span class="badge none">—</span>';
|
|
560
|
+
return '<span class="badge ' + esc(s) + '">' + esc(s) + "</span>";
|
|
561
|
+
}
|
|
562
|
+
function renderJobs(jobs) {
|
|
563
|
+
jobsByKey = {};
|
|
564
|
+
if (!jobs.length) { el("jobs").innerHTML = '<tr><td colspan="5" class="empty">No jobs registered.</td></tr>'; return; }
|
|
565
|
+
var rows = "";
|
|
566
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
567
|
+
var j = jobs[i];
|
|
568
|
+
jobsByKey[j.key] = j;
|
|
569
|
+
var last = j.running ? "running" : (j.lastStatus || "");
|
|
570
|
+
rows +=
|
|
571
|
+
'<tr data-key="' + esc(j.key) + '">' +
|
|
572
|
+
"<td><div class=\\"jobkey\\">" + esc(j.key) + "</div>" + (j.description ? '<div class="jobdesc">' + esc(j.description) + "</div>" : "") + "</td>" +
|
|
573
|
+
'<td><code>' + esc(j.schedule) + "</code></td>" +
|
|
574
|
+
"<td>" + esc(j.timeZone) + "</td>" +
|
|
575
|
+
'<td class="count" data-next="' + esc(j.nextFire || "") + '">' + countdown(j.nextFire) + "</td>" +
|
|
576
|
+
"<td>" + (j.running ? '<span class="badge running">running</span>' : statusBadge(j.lastStatus)) + "</td>" +
|
|
577
|
+
"</tr>";
|
|
578
|
+
}
|
|
579
|
+
el("jobs").innerHTML = rows;
|
|
580
|
+
var trs = el("jobs").querySelectorAll("tr[data-key]");
|
|
581
|
+
for (var k = 0; k < trs.length; k++) {
|
|
582
|
+
trs[k].addEventListener("click", function () { openDrawer(this.getAttribute("data-key")); });
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
function tickCountdowns() {
|
|
586
|
+
var cells = el("jobs").querySelectorAll("td[data-next]");
|
|
587
|
+
for (var i = 0; i < cells.length; i++) {
|
|
588
|
+
var iso = cells[i].getAttribute("data-next");
|
|
589
|
+
cells[i].innerHTML = iso ? countdown(iso) : "—";
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
function loadJobs() {
|
|
594
|
+
return fetch("/api/jobs").then(function (r) { return r.json(); }).then(function (jobs) {
|
|
595
|
+
// Fold in each job's last run so the table can show a status without a per-row fetch.
|
|
596
|
+
return fetch("/api/runs").then(function (r) { return r.json(); }).then(function (runs) {
|
|
597
|
+
var lastByKey = {};
|
|
598
|
+
for (var i = 0; i < runs.length; i++) { if (!lastByKey[runs[i].key]) lastByKey[runs[i].key] = runs[i]; }
|
|
599
|
+
for (var j = 0; j < jobs.length; j++) {
|
|
600
|
+
var lr = lastByKey[jobs[j].key];
|
|
601
|
+
jobs[j].lastStatus = lr ? lr.status : "";
|
|
602
|
+
}
|
|
603
|
+
renderJobs(jobs);
|
|
604
|
+
});
|
|
605
|
+
}).catch(function () {});
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// \u2500\u2500 Live feed \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
609
|
+
var glyphs = { fire: "▷", success: "✓", error: "✗", timeout: "⏳", retry: "↻", skipped: "⊢", scheduled: "🕑", "engine-start": "⚡", "engine-stop": "■" };
|
|
610
|
+
function describe(ev) {
|
|
611
|
+
switch (ev.type) {
|
|
612
|
+
case "fire": return "<strong>" + esc(ev.key) + "</strong> fired" + (ev.attempt > 1 ? " (attempt " + ev.attempt + ")" : "");
|
|
613
|
+
case "success": return "<strong>" + esc(ev.key) + "</strong> succeeded in " + fmtDuration(ev.durationMs) + (ev.attempts > 1 ? " after " + ev.attempts + " attempts" : "");
|
|
614
|
+
case "error": return "<strong>" + esc(ev.key) + "</strong> errored: " + esc(ev.error) + (ev.willRetry ? " \u2014 will retry" : "");
|
|
615
|
+
case "timeout": return "<strong>" + esc(ev.key) + "</strong> timed out after " + fmtDuration(ev.durationMs) + (ev.willRetry ? " \u2014 will retry" : "");
|
|
616
|
+
case "retry": return "<strong>" + esc(ev.key) + "</strong> retrying (attempt " + ev.attempt + ") in " + fmtDuration(ev.delayMs);
|
|
617
|
+
case "skipped": return "<strong>" + esc(ev.key) + "</strong> skipped (overlap)";
|
|
618
|
+
case "scheduled": return "<strong>" + esc(ev.key) + "</strong> scheduled";
|
|
619
|
+
case "engine-start": return "Engine started \u2014 " + ev.jobs + " job(s)";
|
|
620
|
+
case "engine-stop": return "Engine stopped";
|
|
621
|
+
default: return esc(ev.type);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function feedClass(type) {
|
|
625
|
+
if (type === "engine-start" || type === "engine-stop" || type === "scheduled") return "engine";
|
|
626
|
+
return type;
|
|
627
|
+
}
|
|
628
|
+
function pushEvent(ev) {
|
|
629
|
+
var feed = el("feed");
|
|
630
|
+
if (feed.firstChild && feed.firstChild.className === "empty") feed.innerHTML = "";
|
|
631
|
+
var div = document.createElement("div");
|
|
632
|
+
div.className = "ev " + feedClass(ev.type);
|
|
633
|
+
div.innerHTML =
|
|
634
|
+
'<div class="glyph">' + (glyphs[ev.type] || "•") + "</div>" +
|
|
635
|
+
'<div class="body">' + describe(ev) + "</div>" +
|
|
636
|
+
'<div class="when">' + clockTime(ev.at || Date.now()) + "</div>";
|
|
637
|
+
feed.insertBefore(div, feed.firstChild);
|
|
638
|
+
while (feed.childNodes.length > MAX_FEED) feed.removeChild(feed.lastChild);
|
|
639
|
+
|
|
640
|
+
// A terminal event changes a job's last status / running flag \u2014 refresh the table.
|
|
641
|
+
if (ev.type === "success" || ev.type === "error" || ev.type === "timeout" || ev.type === "skipped" || ev.type === "scheduled") {
|
|
642
|
+
loadJobs();
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function setConn(state) {
|
|
647
|
+
var pill = el("conn"), text = el("conn-text");
|
|
648
|
+
pill.className = "pill " + state;
|
|
649
|
+
text.textContent = state === "live" ? "live" : (state === "down" ? "disconnected" : "connecting\u2026");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function connect() {
|
|
653
|
+
setConn("connecting");
|
|
654
|
+
var src = new EventSource("/api/events");
|
|
655
|
+
src.onopen = function () { setConn("live"); };
|
|
656
|
+
src.onmessage = function (m) {
|
|
657
|
+
try { pushEvent(JSON.parse(m.data)); } catch (e) {}
|
|
658
|
+
};
|
|
659
|
+
src.onerror = function () { setConn("down"); };
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// \u2500\u2500 Job drawer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
663
|
+
function openDrawer(key) {
|
|
664
|
+
var job = jobsByKey[key];
|
|
665
|
+
if (!job) return;
|
|
666
|
+
el("drawer-title").textContent = key;
|
|
667
|
+
el("drawer-body").innerHTML = '<div class="empty">Loading…</div>';
|
|
668
|
+
el("overlay").classList.add("open");
|
|
669
|
+
|
|
670
|
+
Promise.all([
|
|
671
|
+
fetch("/api/preview/" + encodeURIComponent(key) + "?n=5").then(function (r) { return r.json(); }).catch(function () { return []; }),
|
|
672
|
+
fetch("/api/runs/" + encodeURIComponent(key)).then(function (r) { return r.json(); }).catch(function () { return []; })
|
|
673
|
+
]).then(function (res) {
|
|
674
|
+
var fires = res[0] || [], runs = res[1] || [];
|
|
675
|
+
var html =
|
|
676
|
+
'<div class="kv"><span>Schedule</span><code>' + esc(job.schedule) + "</code></div>" +
|
|
677
|
+
'<div class="kv"><span>Timezone</span><span>' + esc(job.timeZone) + "</span></div>" +
|
|
678
|
+
(job.description ? '<div class="kv"><span>Description</span><span>' + esc(job.description) + "</span></div>" : "") +
|
|
679
|
+
'<div style="margin-top:14px"><button class="primary" id="run-now">Run now</button></div>' +
|
|
680
|
+
'<div class="section-title">Next fire times</div>';
|
|
681
|
+
if (fires.length) {
|
|
682
|
+
html += '<ul class="plain">';
|
|
683
|
+
for (var i = 0; i < fires.length; i++) html += "<li><span>" + esc(new Date(fires[i]).toLocaleString()) + "</span><span class=\\"count\\">" + countdown(fires[i]) + "</span></li>";
|
|
684
|
+
html += "</ul>";
|
|
685
|
+
} else { html += '<div class="empty">No upcoming fires.</div>'; }
|
|
686
|
+
|
|
687
|
+
html += '<div class="section-title">Recent runs</div>';
|
|
688
|
+
if (runs.length) {
|
|
689
|
+
html += '<ul class="plain">';
|
|
690
|
+
for (var j = 0; j < runs.length && j < 20; j++) {
|
|
691
|
+
var run = runs[j];
|
|
692
|
+
html += "<li><span>" + statusBadge(run.status) + " <span class=\\"when\\">" + esc(new Date(run.startedAt).toLocaleString()) + "</span></span><span>" + fmtDuration(run.durationMs) + "</span></li>";
|
|
693
|
+
}
|
|
694
|
+
html += "</ul>";
|
|
695
|
+
} else { html += '<div class="empty">No runs yet.</div>'; }
|
|
696
|
+
|
|
697
|
+
el("drawer-body").innerHTML = html;
|
|
698
|
+
el("run-now").addEventListener("click", function () { runNow(key, this); });
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
function closeDrawer() { el("overlay").classList.remove("open"); }
|
|
702
|
+
el("drawer-close").addEventListener("click", closeDrawer);
|
|
703
|
+
el("overlay").addEventListener("click", function (e) { if (e.target === el("overlay")) closeDrawer(); });
|
|
704
|
+
|
|
705
|
+
function runNow(key, btn) {
|
|
706
|
+
btn.disabled = true; btn.textContent = "Running\u2026";
|
|
707
|
+
fetch("/api/trigger/" + encodeURIComponent(key), { method: "POST", headers: { "content-type": "application/json" } })
|
|
708
|
+
.then(function (r) { return r.json(); })
|
|
709
|
+
.then(function () { btn.textContent = "Ran \u2713"; setTimeout(function () { openDrawer(key); }, 600); loadJobs(); })
|
|
710
|
+
.catch(function () { btn.disabled = false; btn.textContent = "Run now"; });
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
// \u2500\u2500 Boot \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
714
|
+
loadJobs();
|
|
715
|
+
connect();
|
|
716
|
+
setInterval(tickCountdowns, 1000);
|
|
717
|
+
setInterval(loadJobs, 15000);
|
|
718
|
+
})();
|
|
719
|
+
</script>
|
|
720
|
+
</body>
|
|
721
|
+
</html>`;
|
|
722
|
+
}
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// src/dev/dashboard.ts
|
|
726
|
+
var dashboard_exports = {};
|
|
727
|
+
__export(dashboard_exports, {
|
|
728
|
+
startDashboard: () => startDashboard
|
|
729
|
+
});
|
|
730
|
+
function startDashboard(engine, options = {}) {
|
|
731
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
732
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
733
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
734
|
+
const server = http__default.default.createServer((req, res) => handleRequest(engine, sseClients, req, res));
|
|
735
|
+
return new Promise((resolve2, reject) => {
|
|
736
|
+
const onError = (err) => {
|
|
737
|
+
server.removeListener("error", onError);
|
|
738
|
+
if (err.code === "EADDRINUSE") {
|
|
739
|
+
reject(new Error(`Cronvello dashboard: port ${port} on ${host} is already in use \u2014 pass a different --port.`));
|
|
740
|
+
} else {
|
|
741
|
+
reject(err);
|
|
742
|
+
}
|
|
743
|
+
};
|
|
744
|
+
server.once("error", onError);
|
|
745
|
+
server.listen(port, host, () => {
|
|
746
|
+
server.removeListener("error", onError);
|
|
747
|
+
const actualPort = server.address().port;
|
|
748
|
+
const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
749
|
+
const url = `http://${shown}:${actualPort}`;
|
|
750
|
+
let closed = false;
|
|
751
|
+
const close = () => {
|
|
752
|
+
if (closed) return Promise.resolve();
|
|
753
|
+
closed = true;
|
|
754
|
+
for (const client of sseClients) client.end();
|
|
755
|
+
sseClients.clear();
|
|
756
|
+
return new Promise((res2) => {
|
|
757
|
+
server.closeAllConnections?.();
|
|
758
|
+
server.close(() => res2());
|
|
759
|
+
});
|
|
760
|
+
};
|
|
761
|
+
resolve2({ url, port: actualPort, close });
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
}
|
|
765
|
+
function handleRequest(engine, sseClients, req, res) {
|
|
766
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
767
|
+
const path = url.pathname;
|
|
768
|
+
const method = req.method ?? "GET";
|
|
769
|
+
if (method === "GET" && path === "/") {
|
|
770
|
+
return sendHtml(res, DASHBOARD_HTML);
|
|
771
|
+
}
|
|
772
|
+
if (method === "GET" && path === "/api/events") {
|
|
773
|
+
return openEventStream(engine, sseClients, req, res);
|
|
774
|
+
}
|
|
775
|
+
if (method === "GET" && path === "/api/health") {
|
|
776
|
+
return sendJson(res, 200, { ok: true, jobs: engine.jobs().length, activeRuns: engine.activeRuns });
|
|
777
|
+
}
|
|
778
|
+
if (method === "GET" && path === "/api/runs.ndjson") {
|
|
779
|
+
res.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" });
|
|
780
|
+
const body = engine.toNdjson();
|
|
781
|
+
return void res.end(body.length ? body + "\n" : "");
|
|
782
|
+
}
|
|
783
|
+
if (method === "GET" && path === "/api/runs") {
|
|
784
|
+
const key = url.searchParams.get("key");
|
|
785
|
+
return sendJson(res, 200, key ? engine.runsFor(key) : engine.runs());
|
|
786
|
+
}
|
|
787
|
+
if (method === "GET" && path.startsWith("/api/runs/")) {
|
|
788
|
+
return sendJson(res, 200, engine.runsFor(decodeKey(path, "/api/runs/")));
|
|
789
|
+
}
|
|
790
|
+
if (method === "GET" && path.startsWith("/api/preview/")) {
|
|
791
|
+
const key = decodeKey(path, "/api/preview/");
|
|
792
|
+
const job = engine.snapshot().find((j) => j.key === key);
|
|
793
|
+
if (!job) return sendJson(res, 404, { error: `unknown job '${key}'` });
|
|
794
|
+
const n = clampCount(url.searchParams.get("n"));
|
|
795
|
+
try {
|
|
796
|
+
const fires = previewSchedule(job.schedule, { timeZone: job.timeZone, count: n });
|
|
797
|
+
return sendJson(res, 200, fires.map((d) => d.toISOString()));
|
|
798
|
+
} catch {
|
|
799
|
+
return sendJson(res, 200, []);
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
if (method === "GET" && path === "/api/jobs") {
|
|
803
|
+
const jobs = engine.snapshot().map((j) => ({
|
|
804
|
+
key: j.key,
|
|
805
|
+
schedule: j.schedule,
|
|
806
|
+
timeZone: j.timeZone,
|
|
807
|
+
description: j.description,
|
|
808
|
+
nextFire: j.nextFire ? j.nextFire.toISOString() : null,
|
|
809
|
+
running: j.running
|
|
810
|
+
}));
|
|
811
|
+
return sendJson(res, 200, jobs);
|
|
812
|
+
}
|
|
813
|
+
if (method === "POST" && path.startsWith("/api/trigger/")) {
|
|
814
|
+
if (!isSameOrigin(req)) return sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
815
|
+
const key = decodeKey(path, "/api/trigger/");
|
|
816
|
+
return void engine.trigger(key).then((record) => sendJson(res, 200, record)).catch((err) => {
|
|
817
|
+
const status = /unknown job/.test(err.message) ? 404 : 400;
|
|
818
|
+
sendJson(res, status, { error: err.message });
|
|
819
|
+
});
|
|
820
|
+
}
|
|
821
|
+
sendJson(res, 404, { error: "not found" });
|
|
822
|
+
}
|
|
823
|
+
function openEventStream(engine, sseClients, req, res) {
|
|
824
|
+
res.writeHead(200, {
|
|
825
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
826
|
+
"cache-control": "no-store",
|
|
827
|
+
connection: "keep-alive",
|
|
828
|
+
"x-accel-buffering": "no"
|
|
829
|
+
});
|
|
830
|
+
res.write(": connected\n\n");
|
|
831
|
+
sseClients.add(res);
|
|
832
|
+
const unsubscribe = engine.subscribe((event) => {
|
|
833
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
834
|
+
|
|
835
|
+
`);
|
|
836
|
+
});
|
|
837
|
+
const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
838
|
+
if (typeof heartbeat.unref === "function") heartbeat.unref();
|
|
839
|
+
const cleanup = () => {
|
|
840
|
+
clearInterval(heartbeat);
|
|
841
|
+
unsubscribe();
|
|
842
|
+
sseClients.delete(res);
|
|
843
|
+
};
|
|
844
|
+
res.on("close", cleanup);
|
|
845
|
+
req.on("close", cleanup);
|
|
846
|
+
}
|
|
847
|
+
function sendJson(res, status, body) {
|
|
848
|
+
const payload = JSON.stringify(body);
|
|
849
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
850
|
+
res.end(payload);
|
|
851
|
+
}
|
|
852
|
+
function sendHtml(res, html) {
|
|
853
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
854
|
+
res.end(html);
|
|
855
|
+
}
|
|
856
|
+
function decodeKey(path, prefix) {
|
|
857
|
+
return decodeURIComponent(path.slice(prefix.length));
|
|
858
|
+
}
|
|
859
|
+
function clampCount(raw) {
|
|
860
|
+
const n = raw ? Number.parseInt(raw, 10) : 5;
|
|
861
|
+
if (!Number.isFinite(n) || n < 1) return 5;
|
|
862
|
+
return Math.min(n, MAX_PREVIEW);
|
|
863
|
+
}
|
|
864
|
+
function isSameOrigin(req) {
|
|
865
|
+
const origin = req.headers.origin;
|
|
866
|
+
if (!origin) return true;
|
|
867
|
+
try {
|
|
868
|
+
return new URL(origin).host === req.headers.host;
|
|
869
|
+
} catch {
|
|
870
|
+
return false;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
var DEFAULT_PORT, DEFAULT_HOST, HEARTBEAT_MS, MAX_PREVIEW;
|
|
874
|
+
var init_dashboard = __esm({
|
|
875
|
+
"src/dev/dashboard.ts"() {
|
|
876
|
+
init_cron_schedule();
|
|
877
|
+
init_dashboard_assets();
|
|
878
|
+
DEFAULT_PORT = 4747;
|
|
879
|
+
DEFAULT_HOST = "127.0.0.1";
|
|
880
|
+
HEARTBEAT_MS = 15e3;
|
|
881
|
+
MAX_PREVIEW = 50;
|
|
882
|
+
}
|
|
883
|
+
});
|
|
884
|
+
|
|
10
885
|
// src/internal/errors.ts
|
|
11
886
|
var CronvelloError = class extends Error {
|
|
12
887
|
constructor(message) {
|
|
@@ -406,8 +1281,8 @@ function formatSyncResult(result, options = {}) {
|
|
|
406
1281
|
`${paint("Cronvello", "cyan")} ${verb} ${paint(`"${result.jobName}"`, "bold")} ${paint(`(${result.jobId})`, "gray")}`
|
|
407
1282
|
);
|
|
408
1283
|
const order = ["created", "updated", "deleted", "skipped", "unchanged"];
|
|
409
|
-
const
|
|
410
|
-
for (const c2 of
|
|
1284
|
+
const sorted2 = [...result.changes].sort((a, b) => order.indexOf(a.action) - order.indexOf(b.action));
|
|
1285
|
+
for (const c2 of sorted2) {
|
|
411
1286
|
const g = GLYPH[c2.action];
|
|
412
1287
|
const detail = c2.changedFields && c2.changedFields.length ? paint(` (${c2.changedFields.join(", ")})`, "gray") : c2.reason ? paint(` (${c2.reason})`, "gray") : "";
|
|
413
1288
|
lines.push(` ${paint(g.sign, g.color)} ${paint(c2.action.padEnd(9), g.color)} ${c2.key}${detail}`);
|
|
@@ -432,6 +1307,9 @@ function generateDispatchSecret(bytes = 32) {
|
|
|
432
1307
|
return Array.from(buf).map((b) => b.toString(16).padStart(2, "0")).join("");
|
|
433
1308
|
}
|
|
434
1309
|
|
|
1310
|
+
// src/cli/index.ts
|
|
1311
|
+
init_cron_schedule();
|
|
1312
|
+
|
|
435
1313
|
// src/cli/ui.ts
|
|
436
1314
|
var E = String.fromCharCode(27);
|
|
437
1315
|
var ENV = typeof process !== "undefined" && process.env ? process.env : {};
|
|
@@ -516,6 +1394,8 @@ var VERSION = (() => {
|
|
|
516
1394
|
return "0.0.0";
|
|
517
1395
|
}
|
|
518
1396
|
})();
|
|
1397
|
+
var VALUE_FLAGS = ["limit", "status", "job", "type", "runType", "tz", "window", "count", "n", "port", "host"];
|
|
1398
|
+
var SHORT_VALUE_FLAGS = ["n", "c"];
|
|
519
1399
|
function parseArgs(argv) {
|
|
520
1400
|
const positionals = [];
|
|
521
1401
|
const flags = {};
|
|
@@ -524,14 +1404,21 @@ function parseArgs(argv) {
|
|
|
524
1404
|
if (a.startsWith("--")) {
|
|
525
1405
|
const key = a.slice(2);
|
|
526
1406
|
const next = argv[i + 1];
|
|
527
|
-
if (next !== void 0 && !next.startsWith("-") &&
|
|
1407
|
+
if (next !== void 0 && !next.startsWith("-") && VALUE_FLAGS.includes(key)) {
|
|
528
1408
|
flags[key] = next;
|
|
529
1409
|
i++;
|
|
530
1410
|
} else {
|
|
531
1411
|
flags[key] = true;
|
|
532
1412
|
}
|
|
533
1413
|
} else if (a.startsWith("-") && a.length > 1) {
|
|
534
|
-
|
|
1414
|
+
const key = a.slice(1);
|
|
1415
|
+
const next = argv[i + 1];
|
|
1416
|
+
if (next !== void 0 && !next.startsWith("-") && SHORT_VALUE_FLAGS.includes(key)) {
|
|
1417
|
+
flags[key] = next;
|
|
1418
|
+
i++;
|
|
1419
|
+
} else {
|
|
1420
|
+
flags[key] = true;
|
|
1421
|
+
}
|
|
535
1422
|
} else {
|
|
536
1423
|
positionals.push(a);
|
|
537
1424
|
}
|
|
@@ -708,10 +1595,81 @@ async function cmdSync(positionals, flags, useJson) {
|
|
|
708
1595
|
if (useJson) return json(result);
|
|
709
1596
|
out(formatSyncResult(result, { color: true, dryRun }));
|
|
710
1597
|
}
|
|
711
|
-
async function
|
|
1598
|
+
async function cmdDevEngine(positionals, flags, useJson) {
|
|
1599
|
+
const app = await loadApp(positionals[0]);
|
|
1600
|
+
const dryRun = !!flags["dry-run"] || !!flags["dry"];
|
|
1601
|
+
if (dryRun) {
|
|
1602
|
+
const engine2 = app.dev({ autoStart: false, installSignalHandlers: false });
|
|
1603
|
+
const windowMs = parseDurationFlag(flags["window"], 60 * 60 * 1e3);
|
|
1604
|
+
const maxPerJob = 50;
|
|
1605
|
+
const fires = upcomingFires(
|
|
1606
|
+
engine2.jobs().map((j) => ({ key: j.key, schedule: j.schedule, timeZone: j.timeZone })),
|
|
1607
|
+
{ withinMs: windowMs, maxPerJob }
|
|
1608
|
+
);
|
|
1609
|
+
const cappedJobs = [...new Set(fires.map((f) => f.key))].filter(
|
|
1610
|
+
(key) => fires.filter((f) => f.key === key).length >= maxPerJob
|
|
1611
|
+
);
|
|
1612
|
+
if (useJson) {
|
|
1613
|
+
return json({
|
|
1614
|
+
window: { ms: windowMs },
|
|
1615
|
+
capped: cappedJobs,
|
|
1616
|
+
fires: fires.map((f) => ({ key: f.key, time: f.time.toISOString(), schedule: f.schedule, timeZone: f.timeZone }))
|
|
1617
|
+
});
|
|
1618
|
+
}
|
|
1619
|
+
header(`dev \xB7 dry-run \xB7 next ${formatDuration(windowMs)}`);
|
|
1620
|
+
out(renderUpcoming(fires));
|
|
1621
|
+
out();
|
|
1622
|
+
out(c.gray(fires.length ? `${fires.length} fire(s) would run \u2014 no handlers were executed` : "nothing scheduled in this window"));
|
|
1623
|
+
if (cappedJobs.length) {
|
|
1624
|
+
out(c.yellow(`\u25B2 output capped at ${maxPerJob} per job (${cappedJobs.join(", ")}) \u2014 high-frequency schedule, not the full window`));
|
|
1625
|
+
}
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
const engine = app.dev({ autoStart: false, installSignalHandlers: false, onEvent: (e) => renderEvent(e, useJson) });
|
|
1629
|
+
engine.start();
|
|
1630
|
+
let dashboardUrl;
|
|
1631
|
+
if (flags["dashboard"]) {
|
|
1632
|
+
const { startDashboard: startDashboard2 } = await Promise.resolve().then(() => (init_dashboard(), dashboard_exports));
|
|
1633
|
+
const port = typeof flags["port"] === "string" ? Number.parseInt(flags["port"], 10) : void 0;
|
|
1634
|
+
const host = typeof flags["host"] === "string" ? flags["host"] : void 0;
|
|
1635
|
+
try {
|
|
1636
|
+
const dash = await startDashboard2(engine, { port, host });
|
|
1637
|
+
dashboardUrl = dash.url;
|
|
1638
|
+
engine.onStop(() => dash.close());
|
|
1639
|
+
} catch (err) {
|
|
1640
|
+
await engine.stop();
|
|
1641
|
+
throw err;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
if (!useJson) {
|
|
1645
|
+
header(`dev \xB7 local engine ${c.gray("\xB7 no account, no cloud")}`);
|
|
1646
|
+
out(renderSchedule(engine.snapshot()));
|
|
1647
|
+
out();
|
|
1648
|
+
if (dashboardUrl) out(`${sym.ok} dashboard \u2192 ${c.cyan(dashboardUrl)}`);
|
|
1649
|
+
out(c.gray(`watching ${engine.jobs().length} job(s) \u2014 press Ctrl-C to stop`));
|
|
1650
|
+
out();
|
|
1651
|
+
}
|
|
1652
|
+
await new Promise((resolve2) => {
|
|
1653
|
+
const onSignal = () => {
|
|
1654
|
+
process.removeListener("SIGINT", onSignal);
|
|
1655
|
+
process.removeListener("SIGTERM", onSignal);
|
|
1656
|
+
void engine.stop().then(resolve2);
|
|
1657
|
+
};
|
|
1658
|
+
process.once("SIGINT", onSignal);
|
|
1659
|
+
process.once("SIGTERM", onSignal);
|
|
1660
|
+
});
|
|
1661
|
+
if (!useJson) {
|
|
1662
|
+
out();
|
|
1663
|
+
const runs = engine.runs();
|
|
1664
|
+
const ok = runs.filter((r) => r.status === "success").length;
|
|
1665
|
+
const bad = runs.filter((r) => r.status === "error" || r.status === "timed_out").length;
|
|
1666
|
+
out(c.gray(`stopped \u2014 ${runs.length} run(s) this session (${ok} ok${bad ? `, ${c.red(`${bad} failed`)}` : ""})`));
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
async function cmdTrigger(positionals, useJson) {
|
|
712
1670
|
const jobKey = positionals[positionals.length - 1];
|
|
713
1671
|
const configPath = positionals.length >= 2 ? positionals[0] : void 0;
|
|
714
|
-
if (!jobKey) throw new CronvelloConfigError("Usage: cronvello
|
|
1672
|
+
if (!jobKey) throw new CronvelloConfigError("Usage: cronvello trigger [configPath] <jobKey>");
|
|
715
1673
|
const app = await loadApp(configPath);
|
|
716
1674
|
if (!app.keys().includes(jobKey)) {
|
|
717
1675
|
throw new CronvelloConfigError(`Unknown job "${jobKey}". Known: ${app.keys().join(", ") || "(none)"}`);
|
|
@@ -726,6 +1684,123 @@ async function cmdDev(positionals, useJson) {
|
|
|
726
1684
|
out(JSON.stringify(result, null, 2).split("\n").map((l) => " " + l).join("\n"));
|
|
727
1685
|
}
|
|
728
1686
|
}
|
|
1687
|
+
function cmdPreview(positionals, flags, useJson) {
|
|
1688
|
+
const expr = positionals[0];
|
|
1689
|
+
if (!expr) throw new CronvelloConfigError('Usage: cronvello preview "<cron>" [--tz <IANA>] [-n 5]');
|
|
1690
|
+
const tz = typeof flags["tz"] === "string" ? flags["tz"] : localTimeZone();
|
|
1691
|
+
const count = clampLimit(flags["n"] ?? flags["count"] ?? flags["limit"]);
|
|
1692
|
+
let times;
|
|
1693
|
+
try {
|
|
1694
|
+
times = previewSchedule(expr, { timeZone: tz, count });
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
throw new CronvelloConfigError(err instanceof Error ? err.message : String(err));
|
|
1697
|
+
}
|
|
1698
|
+
if (useJson) return json({ schedule: expr, timeZone: tz, next: times.map((t) => t.toISOString()) });
|
|
1699
|
+
header(`preview ${c.cyan(expr)} ${c.gray(`\xB7 ${tz}`)}`);
|
|
1700
|
+
if (!times.length) {
|
|
1701
|
+
out(c.gray("no upcoming fire times (does this expression ever match?)"));
|
|
1702
|
+
return;
|
|
1703
|
+
}
|
|
1704
|
+
const now = Date.now();
|
|
1705
|
+
out(
|
|
1706
|
+
table(
|
|
1707
|
+
[{ header: "#", align: "right" }, { header: "LOCAL TIME" }, { header: "WHEN" }, { header: "UTC" }],
|
|
1708
|
+
times.map((t, i) => [
|
|
1709
|
+
c.gray(String(i + 1)),
|
|
1710
|
+
c.bold(formatInZone(t, tz)),
|
|
1711
|
+
relativeTime(t.toISOString(), now),
|
|
1712
|
+
c.gray(t.toISOString())
|
|
1713
|
+
])
|
|
1714
|
+
)
|
|
1715
|
+
);
|
|
1716
|
+
}
|
|
1717
|
+
function renderSchedule(snapshot) {
|
|
1718
|
+
const now = Date.now();
|
|
1719
|
+
return table(
|
|
1720
|
+
[{ header: "JOB" }, { header: "SCHEDULE" }, { header: "TZ" }, { header: "NEXT RUN" }, { header: "WHEN" }],
|
|
1721
|
+
snapshot.map((s) => [
|
|
1722
|
+
c.bold(s.key),
|
|
1723
|
+
c.cyan(s.schedule),
|
|
1724
|
+
c.gray(s.timeZone),
|
|
1725
|
+
s.nextFire ? formatInZone(s.nextFire, s.timeZone) : c.gray("\u2014"),
|
|
1726
|
+
s.nextFire ? relativeTime(s.nextFire.toISOString(), now) : c.gray("\u2014")
|
|
1727
|
+
])
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
function renderUpcoming(fires) {
|
|
1731
|
+
const now = Date.now();
|
|
1732
|
+
return table(
|
|
1733
|
+
[{ header: "WHEN" }, { header: "LOCAL TIME" }, { header: "JOB" }, { header: "SCHEDULE" }],
|
|
1734
|
+
fires.map((f) => [
|
|
1735
|
+
relativeTime(f.time.toISOString(), now),
|
|
1736
|
+
c.bold(formatInZone(f.time, f.timeZone)),
|
|
1737
|
+
c.cyan(f.key),
|
|
1738
|
+
c.gray(f.schedule)
|
|
1739
|
+
])
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
function renderEvent(event, useJson) {
|
|
1743
|
+
if (useJson) return out(JSON.stringify(event));
|
|
1744
|
+
const ts = c.gray(clockStamp(Date.now()));
|
|
1745
|
+
switch (event.type) {
|
|
1746
|
+
case "fire":
|
|
1747
|
+
out(`${ts} ${sym.arrow} ${c.bold(event.key)} ${c.gray(event.attempt > 1 ? `(attempt ${event.attempt})` : "fired")}`);
|
|
1748
|
+
break;
|
|
1749
|
+
case "success":
|
|
1750
|
+
out(`${ts} ${sym.ok} ${c.bold(event.key)} ${c.gray(`${event.durationMs}ms${event.attempts > 1 ? ` \xB7 ${event.attempts} attempts` : ""}`)}`);
|
|
1751
|
+
break;
|
|
1752
|
+
case "error":
|
|
1753
|
+
out(`${ts} ${sym.fail} ${c.bold(event.key)} ${c.red(event.error)} ${c.gray(event.willRetry ? "\xB7 will retry" : "\xB7 gave up")}`);
|
|
1754
|
+
break;
|
|
1755
|
+
case "timeout":
|
|
1756
|
+
out(`${ts} ${sym.fail} ${c.bold(event.key)} ${c.red("timed out")} ${c.gray(`after ${event.durationMs}ms${event.willRetry ? " \xB7 will retry" : " \xB7 gave up"}`)}`);
|
|
1757
|
+
break;
|
|
1758
|
+
case "retry":
|
|
1759
|
+
out(`${ts} ${sym.warn} ${c.bold(event.key)} ${c.gray(`retrying in ${event.delayMs}ms (attempt ${event.attempt})`)}`);
|
|
1760
|
+
break;
|
|
1761
|
+
case "skipped":
|
|
1762
|
+
out(`${ts} ${sym.warn} ${c.bold(event.key)} ${c.gray("skipped \u2014 previous run still in flight")}`);
|
|
1763
|
+
break;
|
|
1764
|
+
}
|
|
1765
|
+
}
|
|
1766
|
+
function formatInZone(date, timeZone) {
|
|
1767
|
+
try {
|
|
1768
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
1769
|
+
timeZone,
|
|
1770
|
+
year: "numeric",
|
|
1771
|
+
month: "2-digit",
|
|
1772
|
+
day: "2-digit",
|
|
1773
|
+
hour: "2-digit",
|
|
1774
|
+
minute: "2-digit",
|
|
1775
|
+
second: "2-digit",
|
|
1776
|
+
hour12: false
|
|
1777
|
+
}).formatToParts(date);
|
|
1778
|
+
const m = {};
|
|
1779
|
+
for (const p of parts) if (p.type !== "literal") m[p.type] = p.value;
|
|
1780
|
+
const hour = m["hour"] === "24" ? "00" : m["hour"];
|
|
1781
|
+
return `${m["year"]}-${m["month"]}-${m["day"]} ${hour}:${m["minute"]}:${m["second"]}`;
|
|
1782
|
+
} catch {
|
|
1783
|
+
return date.toISOString();
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
function clockStamp(nowMs) {
|
|
1787
|
+
return formatInZone(new Date(nowMs), localTimeZone()).slice(11);
|
|
1788
|
+
}
|
|
1789
|
+
function parseDurationFlag(value, fallback) {
|
|
1790
|
+
if (typeof value !== "string") return fallback;
|
|
1791
|
+
const m = /^(\d+)\s*(s|m|h|d)?$/i.exec(value.trim());
|
|
1792
|
+
if (!m) return fallback;
|
|
1793
|
+
const n = Number(m[1]);
|
|
1794
|
+
const unit = (m[2] ?? "m").toLowerCase();
|
|
1795
|
+
const mult = unit === "s" ? 1e3 : unit === "h" ? 36e5 : unit === "d" ? 864e5 : 6e4;
|
|
1796
|
+
return n * mult;
|
|
1797
|
+
}
|
|
1798
|
+
function formatDuration(ms) {
|
|
1799
|
+
if (ms % 864e5 === 0) return `${ms / 864e5}d`;
|
|
1800
|
+
if (ms % 36e5 === 0) return `${ms / 36e5}h`;
|
|
1801
|
+
if (ms % 6e4 === 0) return `${ms / 6e4}m`;
|
|
1802
|
+
return `${Math.round(ms / 1e3)}s`;
|
|
1803
|
+
}
|
|
729
1804
|
function cmdSecret(useJson) {
|
|
730
1805
|
const secret = generateDispatchSecret();
|
|
731
1806
|
if (useJson) return json({ dispatchSecret: secret });
|
|
@@ -787,7 +1862,10 @@ function printHelp() {
|
|
|
787
1862
|
["run <taskId>", "trigger a task now (or: run <jobName> <taskName>)"],
|
|
788
1863
|
["status", "health overview + recent failures"],
|
|
789
1864
|
["sync [path] [--dry]", "reconcile a code registry (loads your module)"],
|
|
790
|
-
["dev [path]
|
|
1865
|
+
["dev [path] [--dry-run]", "start the local engine \u2014 run jobs locally, no account"],
|
|
1866
|
+
["dev --dashboard [--port n]", "\u2026plus a local web dashboard (127.0.0.1, live runs + run-now)"],
|
|
1867
|
+
['preview "<cron>" [-n 5]', "print the next N fire times (--tz <IANA>)"],
|
|
1868
|
+
["trigger [path] <jobKey>", "run a single job handler once, locally"],
|
|
791
1869
|
["secret", "generate a strong dispatch secret"]
|
|
792
1870
|
];
|
|
793
1871
|
for (const [name, desc] of cmds) out(` ${c.cyan(name.padEnd(30))} ${c.gray(desc)}`);
|
|
@@ -844,7 +1922,13 @@ async function run(argv) {
|
|
|
844
1922
|
await cmdSync(args.positionals, args.flags, useJson);
|
|
845
1923
|
break;
|
|
846
1924
|
case "dev":
|
|
847
|
-
await
|
|
1925
|
+
await cmdDevEngine(args.positionals, args.flags, useJson);
|
|
1926
|
+
break;
|
|
1927
|
+
case "preview":
|
|
1928
|
+
cmdPreview(args.positionals, args.flags, useJson);
|
|
1929
|
+
break;
|
|
1930
|
+
case "trigger":
|
|
1931
|
+
await cmdTrigger(args.positionals, useJson);
|
|
848
1932
|
break;
|
|
849
1933
|
case "secret":
|
|
850
1934
|
cmdSecret(useJson);
|
|
@@ -865,8 +1949,20 @@ async function run(argv) {
|
|
|
865
1949
|
return 1;
|
|
866
1950
|
}
|
|
867
1951
|
}
|
|
868
|
-
|
|
869
|
-
|
|
1952
|
+
function isMainModule() {
|
|
1953
|
+
try {
|
|
1954
|
+
const argv1 = process.argv?.[1];
|
|
1955
|
+
if (!argv1) return false;
|
|
1956
|
+
return fs.realpathSync(argv1) === fs.realpathSync(url.fileURLToPath((typeof document === 'undefined' ? require('u' + 'rl').pathToFileURL(__filename).href : (_documentCurrentScript && _documentCurrentScript.tagName.toUpperCase() === 'SCRIPT' && _documentCurrentScript.src || new URL('cli.cjs', document.baseURI).href))));
|
|
1957
|
+
} catch {
|
|
1958
|
+
return false;
|
|
1959
|
+
}
|
|
1960
|
+
}
|
|
1961
|
+
if (isMainModule()) {
|
|
1962
|
+
process.stdout.on("error", (err) => {
|
|
1963
|
+
if (err.code === "EPIPE") process.exit(0);
|
|
1964
|
+
throw err;
|
|
1965
|
+
});
|
|
870
1966
|
run(process.argv.slice(2)).then((code) => {
|
|
871
1967
|
process.exitCode = code;
|
|
872
1968
|
});
|