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