@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/dev.js
ADDED
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
import http from 'http';
|
|
2
|
+
|
|
3
|
+
// src/internal/cron-schedule.ts
|
|
4
|
+
var MACRO_EXPANSIONS = {
|
|
5
|
+
"@yearly": "0 0 1 1 *",
|
|
6
|
+
"@annually": "0 0 1 1 *",
|
|
7
|
+
"@monthly": "0 0 1 * *",
|
|
8
|
+
"@weekly": "0 0 * * 0",
|
|
9
|
+
"@daily": "0 0 * * *",
|
|
10
|
+
"@midnight": "0 0 * * *",
|
|
11
|
+
"@hourly": "0 * * * *"
|
|
12
|
+
};
|
|
13
|
+
var MONTH_NAMES = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"];
|
|
14
|
+
var DOW_NAMES = ["sun", "mon", "tue", "wed", "thu", "fri", "sat"];
|
|
15
|
+
var SECOND_SPEC = { min: 0, max: 59, nameOffset: 0, label: "second" };
|
|
16
|
+
var MINUTE_SPEC = { min: 0, max: 59, nameOffset: 0, label: "minute" };
|
|
17
|
+
var HOUR_SPEC = { min: 0, max: 23, nameOffset: 0, label: "hour" };
|
|
18
|
+
var DOM_SPEC = { min: 1, max: 31, nameOffset: 0, label: "day-of-month" };
|
|
19
|
+
var MONTH_SPEC = { min: 1, max: 12, names: MONTH_NAMES, nameOffset: 1, label: "month" };
|
|
20
|
+
var DOW_SPEC = { min: 0, max: 7, names: DOW_NAMES, nameOffset: 0, fold: (v) => v % 7, label: "day-of-week" };
|
|
21
|
+
function parseCron(expr) {
|
|
22
|
+
if (typeof expr !== "string" || !expr.trim()) throw new Error("cron expression is empty");
|
|
23
|
+
let trimmed = expr.trim();
|
|
24
|
+
if (trimmed.startsWith("@")) {
|
|
25
|
+
const macro = trimmed.toLowerCase();
|
|
26
|
+
if (macro === "@reboot") {
|
|
27
|
+
return {
|
|
28
|
+
seconds: /* @__PURE__ */ new Set([0]),
|
|
29
|
+
minutes: /* @__PURE__ */ new Set(),
|
|
30
|
+
hours: /* @__PURE__ */ new Set(),
|
|
31
|
+
daysOfMonth: /* @__PURE__ */ new Set(),
|
|
32
|
+
months: /* @__PURE__ */ new Set(),
|
|
33
|
+
daysOfWeek: /* @__PURE__ */ new Set(),
|
|
34
|
+
domRestricted: false,
|
|
35
|
+
dowRestricted: false,
|
|
36
|
+
reboot: true
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const expanded = MACRO_EXPANSIONS[macro];
|
|
40
|
+
if (!expanded) throw new Error(`unknown cron macro "${trimmed}" (try @daily, @hourly, \u2026)`);
|
|
41
|
+
trimmed = expanded;
|
|
42
|
+
}
|
|
43
|
+
const parts = trimmed.split(/\s+/);
|
|
44
|
+
if (parts.length !== 5 && parts.length !== 6) {
|
|
45
|
+
throw new Error(`expected 5 fields (min hour dom month dow) or 6 with seconds, got ${parts.length}: "${trimmed}"`);
|
|
46
|
+
}
|
|
47
|
+
const hasSeconds = parts.length === 6;
|
|
48
|
+
const [secRaw, minRaw, hourRaw, domRaw, monthRaw, dowRaw] = hasSeconds ? parts : ["0", ...parts];
|
|
49
|
+
return {
|
|
50
|
+
seconds: expandField(secRaw, SECOND_SPEC),
|
|
51
|
+
minutes: expandField(minRaw, MINUTE_SPEC),
|
|
52
|
+
hours: expandField(hourRaw, HOUR_SPEC),
|
|
53
|
+
daysOfMonth: expandField(domRaw, DOM_SPEC),
|
|
54
|
+
months: expandField(monthRaw, MONTH_SPEC),
|
|
55
|
+
daysOfWeek: expandField(dowRaw, DOW_SPEC),
|
|
56
|
+
domRestricted: isRestricted(domRaw),
|
|
57
|
+
dowRestricted: isRestricted(dowRaw),
|
|
58
|
+
reboot: false
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
function isRestricted(field) {
|
|
62
|
+
const f = field.trim();
|
|
63
|
+
return f !== "*" && f !== "?";
|
|
64
|
+
}
|
|
65
|
+
function expandField(raw, spec) {
|
|
66
|
+
const set = /* @__PURE__ */ new Set();
|
|
67
|
+
for (const term of raw.split(",")) {
|
|
68
|
+
if (term === "") throw new Error(`empty term in ${spec.label} field "${raw}"`);
|
|
69
|
+
let base = term;
|
|
70
|
+
let step = 1;
|
|
71
|
+
const slash = term.indexOf("/");
|
|
72
|
+
if (slash >= 0) {
|
|
73
|
+
base = term.slice(0, slash);
|
|
74
|
+
const stepStr = term.slice(slash + 1);
|
|
75
|
+
if (!/^\d+$/.test(stepStr) || Number(stepStr) === 0) {
|
|
76
|
+
throw new Error(`invalid step "${stepStr}" in ${spec.label} field "${raw}"`);
|
|
77
|
+
}
|
|
78
|
+
step = Number(stepStr);
|
|
79
|
+
}
|
|
80
|
+
let lo;
|
|
81
|
+
let hi;
|
|
82
|
+
if (base === "*" || base === "?") {
|
|
83
|
+
lo = spec.min;
|
|
84
|
+
hi = spec.max;
|
|
85
|
+
} else {
|
|
86
|
+
const dash = base.indexOf("-");
|
|
87
|
+
if (dash > 0) {
|
|
88
|
+
lo = resolveValue(base.slice(0, dash), spec);
|
|
89
|
+
hi = resolveValue(base.slice(dash + 1), spec);
|
|
90
|
+
} else {
|
|
91
|
+
lo = resolveValue(base, spec);
|
|
92
|
+
hi = step > 1 ? spec.max : lo;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (lo === null || hi === null) {
|
|
96
|
+
throw new Error(`value out of range in ${spec.label} field "${raw}" (${spec.min}-${spec.max})`);
|
|
97
|
+
}
|
|
98
|
+
if (lo > hi) throw new Error(`range start ${lo} is greater than end ${hi} in ${spec.label} field "${raw}"`);
|
|
99
|
+
for (let v = lo; v <= hi; v += step) set.add(spec.fold ? spec.fold(v) : v);
|
|
100
|
+
}
|
|
101
|
+
return set;
|
|
102
|
+
}
|
|
103
|
+
function resolveValue(token, spec) {
|
|
104
|
+
const t = token.trim();
|
|
105
|
+
if (/^\d+$/.test(t)) {
|
|
106
|
+
const n = Number(t);
|
|
107
|
+
return n >= spec.min && n <= spec.max ? n : null;
|
|
108
|
+
}
|
|
109
|
+
if (spec.names) {
|
|
110
|
+
const idx = spec.names.indexOf(t.toLowerCase());
|
|
111
|
+
if (idx >= 0) return idx + spec.nameOffset;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
var SEARCH_HORIZON_YEARS = 5;
|
|
116
|
+
function nextOccurrence(expr, opts = {}) {
|
|
117
|
+
const parsed = typeof expr === "string" ? parseCron(expr) : expr;
|
|
118
|
+
if (parsed.reboot) throw new Error("@reboot has no scheduled next occurrence (the engine fires it once at start)");
|
|
119
|
+
const tz = opts.timeZone ?? "UTC";
|
|
120
|
+
const fromMs = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
121
|
+
const cursor = Math.floor(fromMs / 1e3) * 1e3 + 1e3;
|
|
122
|
+
const p = getZonedParts(cursor, tz);
|
|
123
|
+
const startYear = p.y;
|
|
124
|
+
const hourArr = sorted(parsed.hours);
|
|
125
|
+
const minuteArr = sorted(parsed.minutes);
|
|
126
|
+
const secondArr = sorted(parsed.seconds);
|
|
127
|
+
let guard = 0;
|
|
128
|
+
while (guard++ < 1e6) {
|
|
129
|
+
if (p.y > startYear + SEARCH_HORIZON_YEARS) return null;
|
|
130
|
+
if (!parsed.months.has(p.mo)) {
|
|
131
|
+
bumpMonth(p);
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
if (p.d > daysInMonth(p.y, p.mo)) {
|
|
135
|
+
bumpMonth(p);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (!dayMatches(parsed, p.y, p.mo, p.d)) {
|
|
139
|
+
bumpDay(p);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const nh = firstAtLeast(hourArr, p.h);
|
|
143
|
+
if (nh === null) {
|
|
144
|
+
bumpDay(p);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (nh !== p.h) {
|
|
148
|
+
p.h = nh;
|
|
149
|
+
p.mi = 0;
|
|
150
|
+
p.s = 0;
|
|
151
|
+
}
|
|
152
|
+
const nmi = firstAtLeast(minuteArr, p.mi);
|
|
153
|
+
if (nmi === null) {
|
|
154
|
+
p.h += 1;
|
|
155
|
+
p.mi = 0;
|
|
156
|
+
p.s = 0;
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
if (nmi !== p.mi) {
|
|
160
|
+
p.mi = nmi;
|
|
161
|
+
p.s = 0;
|
|
162
|
+
}
|
|
163
|
+
const ns = firstAtLeast(secondArr, p.s);
|
|
164
|
+
if (ns === null) {
|
|
165
|
+
p.mi += 1;
|
|
166
|
+
p.s = 0;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
p.s = ns;
|
|
170
|
+
const epoch = zonedWallToEpoch(p, tz);
|
|
171
|
+
if (epoch <= fromMs) {
|
|
172
|
+
p.s += 1;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
return new Date(epoch);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`nextOccurrence: search exceeded its iteration bound for "${typeof expr === "string" ? expr : "(parsed)"}"`);
|
|
178
|
+
}
|
|
179
|
+
function dayMatches(parsed, y, mo, d) {
|
|
180
|
+
const domOk = parsed.daysOfMonth.has(d);
|
|
181
|
+
const dowOk = parsed.daysOfWeek.has(weekdayOf(y, mo, d));
|
|
182
|
+
if (parsed.domRestricted && parsed.dowRestricted) return domOk || dowOk;
|
|
183
|
+
if (parsed.domRestricted) return domOk;
|
|
184
|
+
if (parsed.dowRestricted) return dowOk;
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
function bumpMonth(p) {
|
|
188
|
+
p.mo += 1;
|
|
189
|
+
if (p.mo > 12) {
|
|
190
|
+
p.mo = 1;
|
|
191
|
+
p.y += 1;
|
|
192
|
+
}
|
|
193
|
+
p.d = 1;
|
|
194
|
+
p.h = 0;
|
|
195
|
+
p.mi = 0;
|
|
196
|
+
p.s = 0;
|
|
197
|
+
}
|
|
198
|
+
function bumpDay(p) {
|
|
199
|
+
p.d += 1;
|
|
200
|
+
p.h = 0;
|
|
201
|
+
p.mi = 0;
|
|
202
|
+
p.s = 0;
|
|
203
|
+
}
|
|
204
|
+
function sorted(set) {
|
|
205
|
+
return [...set].sort((a, b) => a - b);
|
|
206
|
+
}
|
|
207
|
+
function firstAtLeast(arr, v) {
|
|
208
|
+
for (const x of arr) if (x >= v) return x;
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function daysInMonth(year, month) {
|
|
212
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
213
|
+
}
|
|
214
|
+
function weekdayOf(year, month, day) {
|
|
215
|
+
return new Date(Date.UTC(year, month - 1, day)).getUTCDay();
|
|
216
|
+
}
|
|
217
|
+
var FORMATTER_CACHE = /* @__PURE__ */ new Map();
|
|
218
|
+
function formatterFor(timeZone) {
|
|
219
|
+
let fmt = FORMATTER_CACHE.get(timeZone);
|
|
220
|
+
if (!fmt) {
|
|
221
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
222
|
+
timeZone,
|
|
223
|
+
year: "numeric",
|
|
224
|
+
month: "2-digit",
|
|
225
|
+
day: "2-digit",
|
|
226
|
+
hour: "2-digit",
|
|
227
|
+
minute: "2-digit",
|
|
228
|
+
second: "2-digit",
|
|
229
|
+
hour12: false
|
|
230
|
+
});
|
|
231
|
+
FORMATTER_CACHE.set(timeZone, fmt);
|
|
232
|
+
}
|
|
233
|
+
return fmt;
|
|
234
|
+
}
|
|
235
|
+
function getZonedParts(epochMs, timeZone) {
|
|
236
|
+
const parts = formatterFor(timeZone).formatToParts(new Date(epochMs));
|
|
237
|
+
const m = {};
|
|
238
|
+
for (const part of parts) if (part.type !== "literal") m[part.type] = part.value;
|
|
239
|
+
let h = Number(m["hour"]);
|
|
240
|
+
if (h === 24) h = 0;
|
|
241
|
+
return { y: Number(m["year"]), mo: Number(m["month"]), d: Number(m["day"]), h, mi: Number(m["minute"]), s: Number(m["second"]) };
|
|
242
|
+
}
|
|
243
|
+
function offsetAt(epochMs, timeZone) {
|
|
244
|
+
const p = getZonedParts(epochMs, timeZone);
|
|
245
|
+
const asNaive = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
|
|
246
|
+
return asNaive - epochMs;
|
|
247
|
+
}
|
|
248
|
+
function zonedWallToEpoch(p, timeZone) {
|
|
249
|
+
const asUTC = Date.UTC(p.y, p.mo - 1, p.d, p.h, p.mi, p.s);
|
|
250
|
+
const o1 = offsetAt(asUTC, timeZone);
|
|
251
|
+
let epoch = asUTC - o1;
|
|
252
|
+
const o2 = offsetAt(epoch, timeZone);
|
|
253
|
+
if (o2 !== o1) epoch = asUTC - o2;
|
|
254
|
+
return epoch;
|
|
255
|
+
}
|
|
256
|
+
function previewSchedule(expr, opts = {}) {
|
|
257
|
+
const parsed = parseCron(expr);
|
|
258
|
+
const timeZone = opts.timeZone ?? localTimeZone();
|
|
259
|
+
const count = Math.min(Math.max(Math.trunc(opts.count ?? 5), 1), 100);
|
|
260
|
+
const out = [];
|
|
261
|
+
let from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
262
|
+
for (let i = 0; i < count; i++) {
|
|
263
|
+
const next = nextOccurrence(parsed, { from, timeZone });
|
|
264
|
+
if (!next) break;
|
|
265
|
+
out.push(next);
|
|
266
|
+
from = next.getTime();
|
|
267
|
+
}
|
|
268
|
+
return out;
|
|
269
|
+
}
|
|
270
|
+
function upcomingFires(jobs, opts = {}) {
|
|
271
|
+
const from = opts.from === void 0 ? Date.now() : typeof opts.from === "number" ? opts.from : opts.from.getTime();
|
|
272
|
+
const withinMs = opts.withinMs ?? 60 * 60 * 1e3;
|
|
273
|
+
const maxPerJob = Math.max(1, opts.maxPerJob ?? 50);
|
|
274
|
+
const horizon = from + withinMs;
|
|
275
|
+
const fires = [];
|
|
276
|
+
for (const job of jobs) {
|
|
277
|
+
let parsed;
|
|
278
|
+
try {
|
|
279
|
+
parsed = parseCron(job.schedule);
|
|
280
|
+
} catch {
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
if (parsed.reboot) continue;
|
|
284
|
+
let cursor = from;
|
|
285
|
+
for (let i = 0; i < maxPerJob; i++) {
|
|
286
|
+
const next = nextOccurrence(parsed, { from: cursor, timeZone: job.timeZone });
|
|
287
|
+
if (!next || next.getTime() > horizon) break;
|
|
288
|
+
fires.push({ key: job.key, time: next, schedule: job.schedule, timeZone: job.timeZone });
|
|
289
|
+
cursor = next.getTime();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
fires.sort((a, b) => a.time.getTime() - b.time.getTime());
|
|
293
|
+
return fires;
|
|
294
|
+
}
|
|
295
|
+
function localTimeZone() {
|
|
296
|
+
try {
|
|
297
|
+
return new Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
298
|
+
} catch {
|
|
299
|
+
return "UTC";
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// src/dev/engine.ts
|
|
304
|
+
var DEFAULT_HISTORY_LIMIT = 100;
|
|
305
|
+
var MAX_TIMER_MS = 2 ** 31 - 1;
|
|
306
|
+
function defaultBackoff(attempt) {
|
|
307
|
+
return Math.min(3e4, 500 * 2 ** (attempt - 1));
|
|
308
|
+
}
|
|
309
|
+
var realClock = {
|
|
310
|
+
now: () => Date.now(),
|
|
311
|
+
setTimeout: (fn, ms) => setTimeout(fn, ms),
|
|
312
|
+
clearTimeout: (handle) => clearTimeout(handle)
|
|
313
|
+
};
|
|
314
|
+
var RunTimeoutError = class extends Error {
|
|
315
|
+
constructor(key, ms) {
|
|
316
|
+
super(`job '${key}' timed out after ${ms}ms`);
|
|
317
|
+
this.name = "RunTimeoutError";
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
function createLocalEngine(jobs, runner, options = {}) {
|
|
321
|
+
return new LocalEngine(jobs, runner, options);
|
|
322
|
+
}
|
|
323
|
+
var LocalEngine = class {
|
|
324
|
+
clock;
|
|
325
|
+
/** Every event listener. The constructor's `onEvent` is registered as one of them. */
|
|
326
|
+
listeners = /* @__PURE__ */ new Set();
|
|
327
|
+
/** Run after the engine has drained, e.g. to close a dashboard server. */
|
|
328
|
+
closeHooks = [];
|
|
329
|
+
historyLimit;
|
|
330
|
+
backoff;
|
|
331
|
+
runner;
|
|
332
|
+
states;
|
|
333
|
+
history = [];
|
|
334
|
+
inFlight = /* @__PURE__ */ new Set();
|
|
335
|
+
started = false;
|
|
336
|
+
stopped = false;
|
|
337
|
+
signalCleanup = null;
|
|
338
|
+
constructor(jobs, runner, options = {}) {
|
|
339
|
+
this.clock = options.clock ?? realClock;
|
|
340
|
+
if (options.onEvent) this.listeners.add(options.onEvent);
|
|
341
|
+
this.historyLimit = Math.max(1, options.historyLimit ?? DEFAULT_HISTORY_LIMIT);
|
|
342
|
+
this.backoff = options.backoff ?? defaultBackoff;
|
|
343
|
+
this.runner = runner;
|
|
344
|
+
this.states = jobs.map((job) => ({
|
|
345
|
+
job,
|
|
346
|
+
nextFire: null,
|
|
347
|
+
timer: null,
|
|
348
|
+
running: false,
|
|
349
|
+
isReboot: job.schedule.trim().toLowerCase() === "@reboot"
|
|
350
|
+
}));
|
|
351
|
+
if (options.installSignalHandlers) this.installSignalHandlers();
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Subscribe to every lifecycle event (in addition to the constructor's `onEvent`). Returns an
|
|
355
|
+
* unsubscribe function. Used by the local dashboard to fan events out to many SSE clients without
|
|
356
|
+
* disturbing the scheduler. A listener that throws is isolated — it can't break the loop.
|
|
357
|
+
*/
|
|
358
|
+
subscribe(listener) {
|
|
359
|
+
this.listeners.add(listener);
|
|
360
|
+
return () => {
|
|
361
|
+
this.listeners.delete(listener);
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* Register a hook to run once, after {@link stop} has drained in-flight runs — e.g. to close a
|
|
366
|
+
* dashboard server so `engine.stop()` tears everything down together. Hooks are awaited.
|
|
367
|
+
*/
|
|
368
|
+
onStop(hook) {
|
|
369
|
+
this.closeHooks.push(hook);
|
|
370
|
+
return this;
|
|
371
|
+
}
|
|
372
|
+
/** Deliver an event to every listener, isolating each so one bad listener can't stall the loop. */
|
|
373
|
+
emit(event) {
|
|
374
|
+
for (const listener of this.listeners) {
|
|
375
|
+
try {
|
|
376
|
+
listener(event);
|
|
377
|
+
} catch {
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/** Begin scheduling. Idempotent — a second call is a no-op. */
|
|
382
|
+
start() {
|
|
383
|
+
if (this.started) return this;
|
|
384
|
+
this.started = true;
|
|
385
|
+
this.stopped = false;
|
|
386
|
+
this.emit({ type: "engine-start", jobs: this.states.length, at: this.clock.now() });
|
|
387
|
+
for (const state of this.states) {
|
|
388
|
+
if (state.isReboot) {
|
|
389
|
+
this.launch(state);
|
|
390
|
+
} else {
|
|
391
|
+
this.scheduleNext(state);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return this;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Stop scheduling and wait for in-flight runs to settle. After this resolves no further handlers
|
|
398
|
+
* will start. Safe to call from a signal handler.
|
|
399
|
+
*/
|
|
400
|
+
async stop() {
|
|
401
|
+
if (this.stopped) {
|
|
402
|
+
await Promise.allSettled([...this.inFlight]);
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
this.stopped = true;
|
|
406
|
+
for (const state of this.states) {
|
|
407
|
+
if (state.timer !== null) {
|
|
408
|
+
this.clock.clearTimeout(state.timer);
|
|
409
|
+
state.timer = null;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
this.signalCleanup?.();
|
|
413
|
+
this.signalCleanup = null;
|
|
414
|
+
await Promise.allSettled([...this.inFlight]);
|
|
415
|
+
this.emit({ type: "engine-stop", at: this.clock.now() });
|
|
416
|
+
for (const hook of this.closeHooks.splice(0)) {
|
|
417
|
+
try {
|
|
418
|
+
await hook();
|
|
419
|
+
} catch {
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
/** The run history, newest first (a copy — safe to keep). */
|
|
424
|
+
runs() {
|
|
425
|
+
return [...this.history].reverse();
|
|
426
|
+
}
|
|
427
|
+
/** History entries for one job, newest first. */
|
|
428
|
+
runsFor(key) {
|
|
429
|
+
return this.runs().filter((r) => r.key === key);
|
|
430
|
+
}
|
|
431
|
+
/** Current scheduling state of every job, for the dev table. */
|
|
432
|
+
snapshot() {
|
|
433
|
+
return this.states.map((s) => ({
|
|
434
|
+
key: s.job.key,
|
|
435
|
+
schedule: s.job.schedule,
|
|
436
|
+
timeZone: s.job.timeZone,
|
|
437
|
+
...s.job.description !== void 0 ? { description: s.job.description } : {},
|
|
438
|
+
nextFire: s.nextFire !== null ? new Date(s.nextFire) : null,
|
|
439
|
+
running: s.running
|
|
440
|
+
}));
|
|
441
|
+
}
|
|
442
|
+
/** The jobs this engine manages (read-only view). */
|
|
443
|
+
jobs() {
|
|
444
|
+
return this.states.map((s) => s.job);
|
|
445
|
+
}
|
|
446
|
+
/** Number of runs currently in flight. */
|
|
447
|
+
get activeRuns() {
|
|
448
|
+
return this.inFlight.size;
|
|
449
|
+
}
|
|
450
|
+
// ── Scheduling ─────────────────────────────────────────────────────────────
|
|
451
|
+
scheduleNext(state) {
|
|
452
|
+
if (this.stopped) return;
|
|
453
|
+
const now = this.clock.now();
|
|
454
|
+
let next;
|
|
455
|
+
try {
|
|
456
|
+
next = nextOccurrence(state.job.schedule, { from: now, timeZone: state.job.timeZone });
|
|
457
|
+
} catch {
|
|
458
|
+
state.nextFire = null;
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!next) {
|
|
462
|
+
state.nextFire = null;
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
state.nextFire = next.getTime();
|
|
466
|
+
const delay = Math.max(0, Math.min(state.nextFire - now, MAX_TIMER_MS));
|
|
467
|
+
state.timer = this.clock.setTimeout(() => this.onDue(state), delay);
|
|
468
|
+
this.emit({ type: "scheduled", key: state.job.key, at: state.nextFire });
|
|
469
|
+
}
|
|
470
|
+
onDue(state) {
|
|
471
|
+
state.timer = null;
|
|
472
|
+
if (this.stopped) return;
|
|
473
|
+
const now = this.clock.now();
|
|
474
|
+
if (state.nextFire !== null && now < state.nextFire) {
|
|
475
|
+
const delay = Math.max(0, Math.min(state.nextFire - now, MAX_TIMER_MS));
|
|
476
|
+
state.timer = this.clock.setTimeout(() => this.onDue(state), delay);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (state.running && !state.job.allowConcurrentRuns) {
|
|
480
|
+
this.emit({ type: "skipped", key: state.job.key, reason: "overlap", at: now });
|
|
481
|
+
this.record({
|
|
482
|
+
key: state.job.key,
|
|
483
|
+
source: "local",
|
|
484
|
+
startedAt: now,
|
|
485
|
+
finishedAt: now,
|
|
486
|
+
durationMs: 0,
|
|
487
|
+
status: "skipped",
|
|
488
|
+
attempts: 0
|
|
489
|
+
});
|
|
490
|
+
} else {
|
|
491
|
+
this.launch(state);
|
|
492
|
+
}
|
|
493
|
+
this.scheduleNext(state);
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Run a job once, right now, by key — the local equivalent of "run now" in the cloud. Goes through
|
|
497
|
+
* the exact same execution path as a scheduled fire (overlap protection, timeout, retry/backoff,
|
|
498
|
+
* history + events), so a manual run shows up in the feed just like any other. Resolves with the
|
|
499
|
+
* resulting {@link RunRecord}. Throws for an unknown key or after the engine has stopped.
|
|
500
|
+
*/
|
|
501
|
+
async trigger(key) {
|
|
502
|
+
if (this.stopped) throw new Error("cannot trigger a job on a stopped engine");
|
|
503
|
+
const state = this.states.find((s) => s.job.key === key);
|
|
504
|
+
if (!state) throw new Error(`unknown job '${key}'`);
|
|
505
|
+
if (state.running && !state.job.allowConcurrentRuns) {
|
|
506
|
+
const now = this.clock.now();
|
|
507
|
+
const skipped = { key, source: "local", startedAt: now, finishedAt: now, durationMs: 0, status: "skipped", attempts: 0 };
|
|
508
|
+
this.emit({ type: "skipped", key, reason: "overlap", at: now });
|
|
509
|
+
this.record(skipped);
|
|
510
|
+
return skipped;
|
|
511
|
+
}
|
|
512
|
+
return this.launch(state);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* The run history as newline-delimited JSON (NDJSON), oldest run first — one record per line, the
|
|
516
|
+
* natural shape for piping to a file or another tool. No trailing newline.
|
|
517
|
+
*/
|
|
518
|
+
toNdjson() {
|
|
519
|
+
return this.history.map((r) => JSON.stringify(r)).join("\n");
|
|
520
|
+
}
|
|
521
|
+
/** Start an execution and track it so {@link stop} can await it. Resolves with the run's record. */
|
|
522
|
+
launch(state) {
|
|
523
|
+
const promise = this.execute(state);
|
|
524
|
+
const tracked = promise.finally(() => {
|
|
525
|
+
this.inFlight.delete(tracked);
|
|
526
|
+
});
|
|
527
|
+
this.inFlight.add(tracked);
|
|
528
|
+
return tracked;
|
|
529
|
+
}
|
|
530
|
+
// ── Execution with timeout + retry/backoff ──────────────────────────────────
|
|
531
|
+
async execute(state) {
|
|
532
|
+
const job = state.job;
|
|
533
|
+
state.running = true;
|
|
534
|
+
const startedAt = this.clock.now();
|
|
535
|
+
const maxRetries = Math.max(0, job.maxRetries ?? 0);
|
|
536
|
+
let attempt = 0;
|
|
537
|
+
let status = "error";
|
|
538
|
+
let error;
|
|
539
|
+
let result;
|
|
540
|
+
while (!this.stopped) {
|
|
541
|
+
attempt++;
|
|
542
|
+
this.emit({ type: "fire", key: job.key, attempt, at: this.clock.now() });
|
|
543
|
+
try {
|
|
544
|
+
result = await this.runOnce(job);
|
|
545
|
+
status = "success";
|
|
546
|
+
this.emit({ type: "success", key: job.key, durationMs: this.clock.now() - startedAt, attempts: attempt, result, at: this.clock.now() });
|
|
547
|
+
error = void 0;
|
|
548
|
+
break;
|
|
549
|
+
} catch (err) {
|
|
550
|
+
const timedOut = err instanceof RunTimeoutError;
|
|
551
|
+
status = timedOut ? "timed_out" : "error";
|
|
552
|
+
error = err instanceof Error ? err.message : String(err);
|
|
553
|
+
const willRetry = attempt <= maxRetries && !this.stopped;
|
|
554
|
+
const at = this.clock.now();
|
|
555
|
+
if (timedOut) {
|
|
556
|
+
this.emit({ type: "timeout", key: job.key, durationMs: at - startedAt, attempt, willRetry, at });
|
|
557
|
+
} else {
|
|
558
|
+
this.emit({ type: "error", key: job.key, durationMs: at - startedAt, attempt, willRetry, error, at });
|
|
559
|
+
}
|
|
560
|
+
if (!willRetry) break;
|
|
561
|
+
const delayMs = this.backoff(attempt);
|
|
562
|
+
this.emit({ type: "retry", key: job.key, attempt: attempt + 1, delayMs, at });
|
|
563
|
+
await this.sleep(delayMs);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const finishedAt = this.clock.now();
|
|
567
|
+
const record = {
|
|
568
|
+
key: job.key,
|
|
569
|
+
source: "local",
|
|
570
|
+
startedAt,
|
|
571
|
+
finishedAt,
|
|
572
|
+
durationMs: finishedAt - startedAt,
|
|
573
|
+
status,
|
|
574
|
+
attempts: attempt,
|
|
575
|
+
...error !== void 0 ? { error } : {},
|
|
576
|
+
...status === "success" ? { result } : {}
|
|
577
|
+
};
|
|
578
|
+
this.record(record);
|
|
579
|
+
state.running = false;
|
|
580
|
+
return record;
|
|
581
|
+
}
|
|
582
|
+
/** A single attempt: run the handler, racing it against the per-job timeout. */
|
|
583
|
+
runOnce(job) {
|
|
584
|
+
const controller = new AbortController();
|
|
585
|
+
const handlerPromise = this.runner(job.key, controller.signal);
|
|
586
|
+
const timeoutMs = job.timeoutMs;
|
|
587
|
+
if (!timeoutMs || timeoutMs <= 0) return handlerPromise;
|
|
588
|
+
return new Promise((resolve, reject) => {
|
|
589
|
+
let settled = false;
|
|
590
|
+
const timer = this.clock.setTimeout(() => {
|
|
591
|
+
if (settled) return;
|
|
592
|
+
settled = true;
|
|
593
|
+
controller.abort();
|
|
594
|
+
reject(new RunTimeoutError(job.key, timeoutMs));
|
|
595
|
+
}, timeoutMs);
|
|
596
|
+
handlerPromise.then(
|
|
597
|
+
(value) => {
|
|
598
|
+
if (settled) return;
|
|
599
|
+
settled = true;
|
|
600
|
+
this.clock.clearTimeout(timer);
|
|
601
|
+
resolve(value);
|
|
602
|
+
},
|
|
603
|
+
(err) => {
|
|
604
|
+
if (settled) return;
|
|
605
|
+
settled = true;
|
|
606
|
+
this.clock.clearTimeout(timer);
|
|
607
|
+
reject(err);
|
|
608
|
+
}
|
|
609
|
+
);
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
sleep(ms) {
|
|
613
|
+
return new Promise((resolve) => this.clock.setTimeout(resolve, ms));
|
|
614
|
+
}
|
|
615
|
+
record(record) {
|
|
616
|
+
this.history.push(record);
|
|
617
|
+
if (this.history.length > this.historyLimit) this.history.shift();
|
|
618
|
+
}
|
|
619
|
+
installSignalHandlers() {
|
|
620
|
+
const handler = () => {
|
|
621
|
+
void this.stop().then(() => process.exit(0));
|
|
622
|
+
};
|
|
623
|
+
process.once("SIGINT", handler);
|
|
624
|
+
process.once("SIGTERM", handler);
|
|
625
|
+
this.signalCleanup = () => {
|
|
626
|
+
process.removeListener("SIGINT", handler);
|
|
627
|
+
process.removeListener("SIGTERM", handler);
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
// src/dev/dashboard-assets.ts
|
|
633
|
+
var DASHBOARD_HTML = `<!doctype html>
|
|
634
|
+
<html lang="en">
|
|
635
|
+
<head>
|
|
636
|
+
<meta charset="utf-8" />
|
|
637
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
638
|
+
<meta name="robots" content="noindex" />
|
|
639
|
+
<title>Cronvello \u2014 local dashboard</title>
|
|
640
|
+
<style>
|
|
641
|
+
:root {
|
|
642
|
+
--indigo: #4F46E5;
|
|
643
|
+
--indigo-soft: #6366F1;
|
|
644
|
+
--teal: #14B8A6;
|
|
645
|
+
--green: #10B981;
|
|
646
|
+
--amber: #F59E0B;
|
|
647
|
+
--red: #EF4444;
|
|
648
|
+
--bg: #f8fafc;
|
|
649
|
+
--panel: #ffffff;
|
|
650
|
+
--panel-2: #f1f5f9;
|
|
651
|
+
--border: #e2e8f0;
|
|
652
|
+
--text: #0f172a;
|
|
653
|
+
--muted: #64748b;
|
|
654
|
+
--shadow: 0 1px 3px rgba(15, 23, 42, 0.08), 0 1px 2px rgba(15, 23, 42, 0.04);
|
|
655
|
+
}
|
|
656
|
+
[data-theme="dark"] {
|
|
657
|
+
--bg: #0b1120;
|
|
658
|
+
--panel: #111827;
|
|
659
|
+
--panel-2: #0f172a;
|
|
660
|
+
--border: #1f2937;
|
|
661
|
+
--text: #e5e7eb;
|
|
662
|
+
--muted: #94a3b8;
|
|
663
|
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
664
|
+
}
|
|
665
|
+
/* Dark-mode-first: follow the OS preference unless the user has explicitly chosen light. */
|
|
666
|
+
@media (prefers-color-scheme: dark) {
|
|
667
|
+
:root:not([data-theme="light"]) {
|
|
668
|
+
--bg: #0b1120;
|
|
669
|
+
--panel: #111827;
|
|
670
|
+
--panel-2: #0f172a;
|
|
671
|
+
--border: #1f2937;
|
|
672
|
+
--text: #e5e7eb;
|
|
673
|
+
--muted: #94a3b8;
|
|
674
|
+
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 1px 2px rgba(0, 0, 0, 0.4);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
* { box-sizing: border-box; }
|
|
678
|
+
html, body { margin: 0; padding: 0; }
|
|
679
|
+
body {
|
|
680
|
+
background: var(--bg);
|
|
681
|
+
color: var(--text);
|
|
682
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Inter, Roboto, sans-serif;
|
|
683
|
+
font-size: 14px;
|
|
684
|
+
line-height: 1.5;
|
|
685
|
+
}
|
|
686
|
+
code, .mono { font-family: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace; }
|
|
687
|
+
header {
|
|
688
|
+
display: flex; align-items: center; gap: 12px;
|
|
689
|
+
padding: 14px 20px;
|
|
690
|
+
border-bottom: 1px solid var(--border);
|
|
691
|
+
background: var(--panel);
|
|
692
|
+
position: sticky; top: 0; z-index: 10;
|
|
693
|
+
}
|
|
694
|
+
.logo {
|
|
695
|
+
width: 28px; height: 28px; border-radius: 8px;
|
|
696
|
+
display: grid; place-items: center;
|
|
697
|
+
background: linear-gradient(135deg, var(--indigo), var(--teal));
|
|
698
|
+
color: #fff; font-size: 17px; font-weight: 700;
|
|
699
|
+
}
|
|
700
|
+
.brand { font-weight: 700; letter-spacing: -0.01em; }
|
|
701
|
+
.brand small { color: var(--muted); font-weight: 500; margin-left: 6px; }
|
|
702
|
+
.spacer { flex: 1; }
|
|
703
|
+
.pill {
|
|
704
|
+
display: inline-flex; align-items: center; gap: 6px;
|
|
705
|
+
padding: 4px 10px; border-radius: 999px;
|
|
706
|
+
font-size: 12px; font-weight: 600;
|
|
707
|
+
border: 1px solid var(--border); background: var(--panel-2); color: var(--muted);
|
|
708
|
+
}
|
|
709
|
+
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
|
|
710
|
+
.pill.live .dot { background: var(--green); box-shadow: 0 0 0 0 rgba(16,185,129,0.5); animation: pulse 1.8s infinite; }
|
|
711
|
+
.pill.down .dot { background: var(--red); }
|
|
712
|
+
@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); } }
|
|
713
|
+
button {
|
|
714
|
+
font: inherit; cursor: pointer; color: var(--text);
|
|
715
|
+
background: var(--panel-2); border: 1px solid var(--border);
|
|
716
|
+
border-radius: 8px; padding: 6px 12px;
|
|
717
|
+
}
|
|
718
|
+
button:hover { border-color: var(--indigo-soft); }
|
|
719
|
+
button.primary { background: var(--indigo); color: #fff; border-color: var(--indigo); }
|
|
720
|
+
button.primary:hover { background: var(--indigo-soft); }
|
|
721
|
+
.layout { display: grid; grid-template-columns: 1.4fr 1fr; gap: 16px; padding: 16px 20px; align-items: start; }
|
|
722
|
+
@media (max-width: 860px) { .layout { grid-template-columns: 1fr; } }
|
|
723
|
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: var(--shadow); overflow: hidden; }
|
|
724
|
+
.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); }
|
|
725
|
+
table { width: 100%; border-collapse: collapse; }
|
|
726
|
+
th, td { text-align: left; padding: 10px 16px; border-bottom: 1px solid var(--border); vertical-align: middle; }
|
|
727
|
+
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); font-weight: 600; }
|
|
728
|
+
tr:last-child td { border-bottom: none; }
|
|
729
|
+
tbody tr { cursor: pointer; }
|
|
730
|
+
tbody tr:hover { background: var(--panel-2); }
|
|
731
|
+
.jobkey { font-weight: 600; }
|
|
732
|
+
.jobdesc { color: var(--muted); font-size: 12px; }
|
|
733
|
+
.count { font-variant-numeric: tabular-nums; }
|
|
734
|
+
.badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700; }
|
|
735
|
+
.badge.success { background: rgba(16,185,129,0.15); color: var(--green); }
|
|
736
|
+
.badge.error, .badge.timed_out { background: rgba(239,68,68,0.15); color: var(--red); }
|
|
737
|
+
.badge.skipped { background: rgba(100,116,139,0.18); color: var(--muted); }
|
|
738
|
+
.badge.running { background: rgba(79,70,229,0.15); color: var(--indigo-soft); }
|
|
739
|
+
.badge.none { background: var(--panel-2); color: var(--muted); }
|
|
740
|
+
.feed { max-height: 70vh; overflow-y: auto; }
|
|
741
|
+
.ev { display: flex; gap: 10px; padding: 8px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
|
742
|
+
.ev .glyph { width: 16px; text-align: center; flex: none; }
|
|
743
|
+
.ev .body { flex: 1; min-width: 0; }
|
|
744
|
+
.ev .when { color: var(--muted); font-size: 11px; font-variant-numeric: tabular-nums; }
|
|
745
|
+
.ev.success .glyph { color: var(--green); }
|
|
746
|
+
.ev.error .glyph, .ev.timeout .glyph { color: var(--red); }
|
|
747
|
+
.ev.retry .glyph { color: var(--amber); }
|
|
748
|
+
.ev.fire .glyph { color: var(--indigo-soft); }
|
|
749
|
+
.ev.skipped .glyph, .ev.scheduled .glyph, .ev.engine .glyph { color: var(--muted); }
|
|
750
|
+
.empty { padding: 24px 16px; color: var(--muted); text-align: center; }
|
|
751
|
+
footer { padding: 12px 20px 28px; color: var(--muted); font-size: 12px; }
|
|
752
|
+
.overlay { position: fixed; inset: 0; background: rgba(15,23,42,0.45); display: none; align-items: stretch; justify-content: flex-end; z-index: 30; }
|
|
753
|
+
.overlay.open { display: flex; }
|
|
754
|
+
.drawer { width: min(520px, 92vw); background: var(--panel); border-left: 1px solid var(--border); height: 100%; overflow-y: auto; box-shadow: var(--shadow); }
|
|
755
|
+
.drawer header { background: var(--panel); }
|
|
756
|
+
.drawer .pad { padding: 16px; }
|
|
757
|
+
.kv { display: flex; gap: 8px; margin: 4px 0; }
|
|
758
|
+
.kv span:first-child { color: var(--muted); min-width: 90px; }
|
|
759
|
+
.section-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; color: var(--muted); margin: 18px 0 8px; }
|
|
760
|
+
ul.plain { list-style: none; margin: 0; padding: 0; }
|
|
761
|
+
ul.plain li { padding: 6px 0; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; gap: 8px; }
|
|
762
|
+
</style>
|
|
763
|
+
</head>
|
|
764
|
+
<body>
|
|
765
|
+
<header>
|
|
766
|
+
<div class="logo">↻</div>
|
|
767
|
+
<div class="brand">Cronvello <small>local dashboard</small></div>
|
|
768
|
+
<div class="spacer"></div>
|
|
769
|
+
<span id="conn" class="pill"><span class="dot"></span><span id="conn-text">connecting…</span></span>
|
|
770
|
+
<button id="theme-btn" title="Toggle theme">☼</button>
|
|
771
|
+
</header>
|
|
772
|
+
|
|
773
|
+
<div class="layout">
|
|
774
|
+
<section class="card">
|
|
775
|
+
<h2>Jobs — next fire & last status</h2>
|
|
776
|
+
<table>
|
|
777
|
+
<thead><tr><th>Job</th><th>Schedule</th><th>Timezone</th><th>Next fire</th><th>Last</th></tr></thead>
|
|
778
|
+
<tbody id="jobs"><tr><td colspan="5" class="empty">Loading jobs…</td></tr></tbody>
|
|
779
|
+
</table>
|
|
780
|
+
</section>
|
|
781
|
+
|
|
782
|
+
<section class="card">
|
|
783
|
+
<h2>Live run feed</h2>
|
|
784
|
+
<div id="feed" class="feed"><div class="empty">Waiting for runs…</div></div>
|
|
785
|
+
</section>
|
|
786
|
+
</div>
|
|
787
|
+
|
|
788
|
+
<footer>Local · no account · no cloud · no external network. The engine on this machine is the only source of truth.</footer>
|
|
789
|
+
|
|
790
|
+
<div id="overlay" class="overlay">
|
|
791
|
+
<div class="drawer">
|
|
792
|
+
<header>
|
|
793
|
+
<div class="brand" id="drawer-title">Job</div>
|
|
794
|
+
<div class="spacer"></div>
|
|
795
|
+
<button id="drawer-close">Close</button>
|
|
796
|
+
</header>
|
|
797
|
+
<div class="pad" id="drawer-body"></div>
|
|
798
|
+
</div>
|
|
799
|
+
</div>
|
|
800
|
+
|
|
801
|
+
<script>
|
|
802
|
+
(function () {
|
|
803
|
+
"use strict";
|
|
804
|
+
var MAX_FEED = 200;
|
|
805
|
+
var jobsByKey = {};
|
|
806
|
+
|
|
807
|
+
function el(id) { return document.getElementById(id); }
|
|
808
|
+
function esc(s) {
|
|
809
|
+
return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
|
810
|
+
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
function pad(n) { return n < 10 ? "0" + n : "" + n; }
|
|
814
|
+
function clockTime(ms) {
|
|
815
|
+
var d = new Date(ms);
|
|
816
|
+
return pad(d.getHours()) + ":" + pad(d.getMinutes()) + ":" + pad(d.getSeconds());
|
|
817
|
+
}
|
|
818
|
+
function fmtDuration(ms) {
|
|
819
|
+
if (ms == null) return "";
|
|
820
|
+
if (ms < 1000) return ms + "ms";
|
|
821
|
+
return (ms / 1000).toFixed(ms < 10000 ? 2 : 1) + "s";
|
|
822
|
+
}
|
|
823
|
+
function countdown(iso) {
|
|
824
|
+
if (!iso) return "—";
|
|
825
|
+
var diff = new Date(iso).getTime() - Date.now();
|
|
826
|
+
if (diff <= 0) return "due now";
|
|
827
|
+
var s = Math.floor(diff / 1000);
|
|
828
|
+
var d = Math.floor(s / 86400); s -= d * 86400;
|
|
829
|
+
var h = Math.floor(s / 3600); s -= h * 3600;
|
|
830
|
+
var m = Math.floor(s / 60); s -= m * 60;
|
|
831
|
+
var out = "in ";
|
|
832
|
+
if (d) out += d + "d ";
|
|
833
|
+
if (d || h) out += h + "h ";
|
|
834
|
+
if (d || h || m) out += m + "m ";
|
|
835
|
+
out += s + "s";
|
|
836
|
+
return out;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// \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
|
|
840
|
+
function applyTheme(t) {
|
|
841
|
+
if (t === "dark" || t === "light") document.documentElement.setAttribute("data-theme", t);
|
|
842
|
+
else document.documentElement.removeAttribute("data-theme");
|
|
843
|
+
}
|
|
844
|
+
function resolvedTheme() {
|
|
845
|
+
var t = document.documentElement.getAttribute("data-theme");
|
|
846
|
+
if (t) return t;
|
|
847
|
+
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
848
|
+
}
|
|
849
|
+
try { applyTheme(localStorage.getItem("cronvello-theme")); } catch (e) {}
|
|
850
|
+
el("theme-btn").addEventListener("click", function () {
|
|
851
|
+
var next = resolvedTheme() === "dark" ? "light" : "dark";
|
|
852
|
+
applyTheme(next);
|
|
853
|
+
try { localStorage.setItem("cronvello-theme", next); } catch (e) {}
|
|
854
|
+
});
|
|
855
|
+
|
|
856
|
+
// \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
|
|
857
|
+
function statusBadge(s) {
|
|
858
|
+
if (!s) return '<span class="badge none">—</span>';
|
|
859
|
+
return '<span class="badge ' + esc(s) + '">' + esc(s) + "</span>";
|
|
860
|
+
}
|
|
861
|
+
function renderJobs(jobs) {
|
|
862
|
+
jobsByKey = {};
|
|
863
|
+
if (!jobs.length) { el("jobs").innerHTML = '<tr><td colspan="5" class="empty">No jobs registered.</td></tr>'; return; }
|
|
864
|
+
var rows = "";
|
|
865
|
+
for (var i = 0; i < jobs.length; i++) {
|
|
866
|
+
var j = jobs[i];
|
|
867
|
+
jobsByKey[j.key] = j;
|
|
868
|
+
var last = j.running ? "running" : (j.lastStatus || "");
|
|
869
|
+
rows +=
|
|
870
|
+
'<tr data-key="' + esc(j.key) + '">' +
|
|
871
|
+
"<td><div class=\\"jobkey\\">" + esc(j.key) + "</div>" + (j.description ? '<div class="jobdesc">' + esc(j.description) + "</div>" : "") + "</td>" +
|
|
872
|
+
'<td><code>' + esc(j.schedule) + "</code></td>" +
|
|
873
|
+
"<td>" + esc(j.timeZone) + "</td>" +
|
|
874
|
+
'<td class="count" data-next="' + esc(j.nextFire || "") + '">' + countdown(j.nextFire) + "</td>" +
|
|
875
|
+
"<td>" + (j.running ? '<span class="badge running">running</span>' : statusBadge(j.lastStatus)) + "</td>" +
|
|
876
|
+
"</tr>";
|
|
877
|
+
}
|
|
878
|
+
el("jobs").innerHTML = rows;
|
|
879
|
+
var trs = el("jobs").querySelectorAll("tr[data-key]");
|
|
880
|
+
for (var k = 0; k < trs.length; k++) {
|
|
881
|
+
trs[k].addEventListener("click", function () { openDrawer(this.getAttribute("data-key")); });
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
function tickCountdowns() {
|
|
885
|
+
var cells = el("jobs").querySelectorAll("td[data-next]");
|
|
886
|
+
for (var i = 0; i < cells.length; i++) {
|
|
887
|
+
var iso = cells[i].getAttribute("data-next");
|
|
888
|
+
cells[i].innerHTML = iso ? countdown(iso) : "—";
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function loadJobs() {
|
|
893
|
+
return fetch("/api/jobs").then(function (r) { return r.json(); }).then(function (jobs) {
|
|
894
|
+
// Fold in each job's last run so the table can show a status without a per-row fetch.
|
|
895
|
+
return fetch("/api/runs").then(function (r) { return r.json(); }).then(function (runs) {
|
|
896
|
+
var lastByKey = {};
|
|
897
|
+
for (var i = 0; i < runs.length; i++) { if (!lastByKey[runs[i].key]) lastByKey[runs[i].key] = runs[i]; }
|
|
898
|
+
for (var j = 0; j < jobs.length; j++) {
|
|
899
|
+
var lr = lastByKey[jobs[j].key];
|
|
900
|
+
jobs[j].lastStatus = lr ? lr.status : "";
|
|
901
|
+
}
|
|
902
|
+
renderJobs(jobs);
|
|
903
|
+
});
|
|
904
|
+
}).catch(function () {});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// \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
|
|
908
|
+
var glyphs = { fire: "▷", success: "✓", error: "✗", timeout: "⏳", retry: "↻", skipped: "⊢", scheduled: "🕑", "engine-start": "⚡", "engine-stop": "■" };
|
|
909
|
+
function describe(ev) {
|
|
910
|
+
switch (ev.type) {
|
|
911
|
+
case "fire": return "<strong>" + esc(ev.key) + "</strong> fired" + (ev.attempt > 1 ? " (attempt " + ev.attempt + ")" : "");
|
|
912
|
+
case "success": return "<strong>" + esc(ev.key) + "</strong> succeeded in " + fmtDuration(ev.durationMs) + (ev.attempts > 1 ? " after " + ev.attempts + " attempts" : "");
|
|
913
|
+
case "error": return "<strong>" + esc(ev.key) + "</strong> errored: " + esc(ev.error) + (ev.willRetry ? " \u2014 will retry" : "");
|
|
914
|
+
case "timeout": return "<strong>" + esc(ev.key) + "</strong> timed out after " + fmtDuration(ev.durationMs) + (ev.willRetry ? " \u2014 will retry" : "");
|
|
915
|
+
case "retry": return "<strong>" + esc(ev.key) + "</strong> retrying (attempt " + ev.attempt + ") in " + fmtDuration(ev.delayMs);
|
|
916
|
+
case "skipped": return "<strong>" + esc(ev.key) + "</strong> skipped (overlap)";
|
|
917
|
+
case "scheduled": return "<strong>" + esc(ev.key) + "</strong> scheduled";
|
|
918
|
+
case "engine-start": return "Engine started \u2014 " + ev.jobs + " job(s)";
|
|
919
|
+
case "engine-stop": return "Engine stopped";
|
|
920
|
+
default: return esc(ev.type);
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
function feedClass(type) {
|
|
924
|
+
if (type === "engine-start" || type === "engine-stop" || type === "scheduled") return "engine";
|
|
925
|
+
return type;
|
|
926
|
+
}
|
|
927
|
+
function pushEvent(ev) {
|
|
928
|
+
var feed = el("feed");
|
|
929
|
+
if (feed.firstChild && feed.firstChild.className === "empty") feed.innerHTML = "";
|
|
930
|
+
var div = document.createElement("div");
|
|
931
|
+
div.className = "ev " + feedClass(ev.type);
|
|
932
|
+
div.innerHTML =
|
|
933
|
+
'<div class="glyph">' + (glyphs[ev.type] || "•") + "</div>" +
|
|
934
|
+
'<div class="body">' + describe(ev) + "</div>" +
|
|
935
|
+
'<div class="when">' + clockTime(ev.at || Date.now()) + "</div>";
|
|
936
|
+
feed.insertBefore(div, feed.firstChild);
|
|
937
|
+
while (feed.childNodes.length > MAX_FEED) feed.removeChild(feed.lastChild);
|
|
938
|
+
|
|
939
|
+
// A terminal event changes a job's last status / running flag \u2014 refresh the table.
|
|
940
|
+
if (ev.type === "success" || ev.type === "error" || ev.type === "timeout" || ev.type === "skipped" || ev.type === "scheduled") {
|
|
941
|
+
loadJobs();
|
|
942
|
+
}
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function setConn(state) {
|
|
946
|
+
var pill = el("conn"), text = el("conn-text");
|
|
947
|
+
pill.className = "pill " + state;
|
|
948
|
+
text.textContent = state === "live" ? "live" : (state === "down" ? "disconnected" : "connecting\u2026");
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function connect() {
|
|
952
|
+
setConn("connecting");
|
|
953
|
+
var src = new EventSource("/api/events");
|
|
954
|
+
src.onopen = function () { setConn("live"); };
|
|
955
|
+
src.onmessage = function (m) {
|
|
956
|
+
try { pushEvent(JSON.parse(m.data)); } catch (e) {}
|
|
957
|
+
};
|
|
958
|
+
src.onerror = function () { setConn("down"); };
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// \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
|
|
962
|
+
function openDrawer(key) {
|
|
963
|
+
var job = jobsByKey[key];
|
|
964
|
+
if (!job) return;
|
|
965
|
+
el("drawer-title").textContent = key;
|
|
966
|
+
el("drawer-body").innerHTML = '<div class="empty">Loading…</div>';
|
|
967
|
+
el("overlay").classList.add("open");
|
|
968
|
+
|
|
969
|
+
Promise.all([
|
|
970
|
+
fetch("/api/preview/" + encodeURIComponent(key) + "?n=5").then(function (r) { return r.json(); }).catch(function () { return []; }),
|
|
971
|
+
fetch("/api/runs/" + encodeURIComponent(key)).then(function (r) { return r.json(); }).catch(function () { return []; })
|
|
972
|
+
]).then(function (res) {
|
|
973
|
+
var fires = res[0] || [], runs = res[1] || [];
|
|
974
|
+
var html =
|
|
975
|
+
'<div class="kv"><span>Schedule</span><code>' + esc(job.schedule) + "</code></div>" +
|
|
976
|
+
'<div class="kv"><span>Timezone</span><span>' + esc(job.timeZone) + "</span></div>" +
|
|
977
|
+
(job.description ? '<div class="kv"><span>Description</span><span>' + esc(job.description) + "</span></div>" : "") +
|
|
978
|
+
'<div style="margin-top:14px"><button class="primary" id="run-now">Run now</button></div>' +
|
|
979
|
+
'<div class="section-title">Next fire times</div>';
|
|
980
|
+
if (fires.length) {
|
|
981
|
+
html += '<ul class="plain">';
|
|
982
|
+
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>";
|
|
983
|
+
html += "</ul>";
|
|
984
|
+
} else { html += '<div class="empty">No upcoming fires.</div>'; }
|
|
985
|
+
|
|
986
|
+
html += '<div class="section-title">Recent runs</div>';
|
|
987
|
+
if (runs.length) {
|
|
988
|
+
html += '<ul class="plain">';
|
|
989
|
+
for (var j = 0; j < runs.length && j < 20; j++) {
|
|
990
|
+
var run = runs[j];
|
|
991
|
+
html += "<li><span>" + statusBadge(run.status) + " <span class=\\"when\\">" + esc(new Date(run.startedAt).toLocaleString()) + "</span></span><span>" + fmtDuration(run.durationMs) + "</span></li>";
|
|
992
|
+
}
|
|
993
|
+
html += "</ul>";
|
|
994
|
+
} else { html += '<div class="empty">No runs yet.</div>'; }
|
|
995
|
+
|
|
996
|
+
el("drawer-body").innerHTML = html;
|
|
997
|
+
el("run-now").addEventListener("click", function () { runNow(key, this); });
|
|
998
|
+
});
|
|
999
|
+
}
|
|
1000
|
+
function closeDrawer() { el("overlay").classList.remove("open"); }
|
|
1001
|
+
el("drawer-close").addEventListener("click", closeDrawer);
|
|
1002
|
+
el("overlay").addEventListener("click", function (e) { if (e.target === el("overlay")) closeDrawer(); });
|
|
1003
|
+
|
|
1004
|
+
function runNow(key, btn) {
|
|
1005
|
+
btn.disabled = true; btn.textContent = "Running\u2026";
|
|
1006
|
+
fetch("/api/trigger/" + encodeURIComponent(key), { method: "POST", headers: { "content-type": "application/json" } })
|
|
1007
|
+
.then(function (r) { return r.json(); })
|
|
1008
|
+
.then(function () { btn.textContent = "Ran \u2713"; setTimeout(function () { openDrawer(key); }, 600); loadJobs(); })
|
|
1009
|
+
.catch(function () { btn.disabled = false; btn.textContent = "Run now"; });
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// \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
|
|
1013
|
+
loadJobs();
|
|
1014
|
+
connect();
|
|
1015
|
+
setInterval(tickCountdowns, 1000);
|
|
1016
|
+
setInterval(loadJobs, 15000);
|
|
1017
|
+
})();
|
|
1018
|
+
</script>
|
|
1019
|
+
</body>
|
|
1020
|
+
</html>`;
|
|
1021
|
+
|
|
1022
|
+
// src/dev/dashboard.ts
|
|
1023
|
+
var DEFAULT_PORT = 4747;
|
|
1024
|
+
var DEFAULT_HOST = "127.0.0.1";
|
|
1025
|
+
var HEARTBEAT_MS = 15e3;
|
|
1026
|
+
var MAX_PREVIEW = 50;
|
|
1027
|
+
function startDashboard(engine, options = {}) {
|
|
1028
|
+
const host = options.host ?? DEFAULT_HOST;
|
|
1029
|
+
const port = options.port ?? DEFAULT_PORT;
|
|
1030
|
+
const sseClients = /* @__PURE__ */ new Set();
|
|
1031
|
+
const server = http.createServer((req, res) => handleRequest(engine, sseClients, req, res));
|
|
1032
|
+
return new Promise((resolve, reject) => {
|
|
1033
|
+
const onError = (err) => {
|
|
1034
|
+
server.removeListener("error", onError);
|
|
1035
|
+
if (err.code === "EADDRINUSE") {
|
|
1036
|
+
reject(new Error(`Cronvello dashboard: port ${port} on ${host} is already in use \u2014 pass a different --port.`));
|
|
1037
|
+
} else {
|
|
1038
|
+
reject(err);
|
|
1039
|
+
}
|
|
1040
|
+
};
|
|
1041
|
+
server.once("error", onError);
|
|
1042
|
+
server.listen(port, host, () => {
|
|
1043
|
+
server.removeListener("error", onError);
|
|
1044
|
+
const actualPort = server.address().port;
|
|
1045
|
+
const shown = host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host;
|
|
1046
|
+
const url = `http://${shown}:${actualPort}`;
|
|
1047
|
+
let closed = false;
|
|
1048
|
+
const close = () => {
|
|
1049
|
+
if (closed) return Promise.resolve();
|
|
1050
|
+
closed = true;
|
|
1051
|
+
for (const client of sseClients) client.end();
|
|
1052
|
+
sseClients.clear();
|
|
1053
|
+
return new Promise((res2) => {
|
|
1054
|
+
server.closeAllConnections?.();
|
|
1055
|
+
server.close(() => res2());
|
|
1056
|
+
});
|
|
1057
|
+
};
|
|
1058
|
+
resolve({ url, port: actualPort, close });
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
}
|
|
1062
|
+
function handleRequest(engine, sseClients, req, res) {
|
|
1063
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1064
|
+
const path = url.pathname;
|
|
1065
|
+
const method = req.method ?? "GET";
|
|
1066
|
+
if (method === "GET" && path === "/") {
|
|
1067
|
+
return sendHtml(res, DASHBOARD_HTML);
|
|
1068
|
+
}
|
|
1069
|
+
if (method === "GET" && path === "/api/events") {
|
|
1070
|
+
return openEventStream(engine, sseClients, req, res);
|
|
1071
|
+
}
|
|
1072
|
+
if (method === "GET" && path === "/api/health") {
|
|
1073
|
+
return sendJson(res, 200, { ok: true, jobs: engine.jobs().length, activeRuns: engine.activeRuns });
|
|
1074
|
+
}
|
|
1075
|
+
if (method === "GET" && path === "/api/runs.ndjson") {
|
|
1076
|
+
res.writeHead(200, { "content-type": "application/x-ndjson; charset=utf-8", "cache-control": "no-store" });
|
|
1077
|
+
const body = engine.toNdjson();
|
|
1078
|
+
return void res.end(body.length ? body + "\n" : "");
|
|
1079
|
+
}
|
|
1080
|
+
if (method === "GET" && path === "/api/runs") {
|
|
1081
|
+
const key = url.searchParams.get("key");
|
|
1082
|
+
return sendJson(res, 200, key ? engine.runsFor(key) : engine.runs());
|
|
1083
|
+
}
|
|
1084
|
+
if (method === "GET" && path.startsWith("/api/runs/")) {
|
|
1085
|
+
return sendJson(res, 200, engine.runsFor(decodeKey(path, "/api/runs/")));
|
|
1086
|
+
}
|
|
1087
|
+
if (method === "GET" && path.startsWith("/api/preview/")) {
|
|
1088
|
+
const key = decodeKey(path, "/api/preview/");
|
|
1089
|
+
const job = engine.snapshot().find((j) => j.key === key);
|
|
1090
|
+
if (!job) return sendJson(res, 404, { error: `unknown job '${key}'` });
|
|
1091
|
+
const n = clampCount(url.searchParams.get("n"));
|
|
1092
|
+
try {
|
|
1093
|
+
const fires = previewSchedule(job.schedule, { timeZone: job.timeZone, count: n });
|
|
1094
|
+
return sendJson(res, 200, fires.map((d) => d.toISOString()));
|
|
1095
|
+
} catch {
|
|
1096
|
+
return sendJson(res, 200, []);
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
if (method === "GET" && path === "/api/jobs") {
|
|
1100
|
+
const jobs = engine.snapshot().map((j) => ({
|
|
1101
|
+
key: j.key,
|
|
1102
|
+
schedule: j.schedule,
|
|
1103
|
+
timeZone: j.timeZone,
|
|
1104
|
+
description: j.description,
|
|
1105
|
+
nextFire: j.nextFire ? j.nextFire.toISOString() : null,
|
|
1106
|
+
running: j.running
|
|
1107
|
+
}));
|
|
1108
|
+
return sendJson(res, 200, jobs);
|
|
1109
|
+
}
|
|
1110
|
+
if (method === "POST" && path.startsWith("/api/trigger/")) {
|
|
1111
|
+
if (!isSameOrigin(req)) return sendJson(res, 403, { error: "cross-origin request rejected" });
|
|
1112
|
+
const key = decodeKey(path, "/api/trigger/");
|
|
1113
|
+
return void engine.trigger(key).then((record) => sendJson(res, 200, record)).catch((err) => {
|
|
1114
|
+
const status = /unknown job/.test(err.message) ? 404 : 400;
|
|
1115
|
+
sendJson(res, status, { error: err.message });
|
|
1116
|
+
});
|
|
1117
|
+
}
|
|
1118
|
+
sendJson(res, 404, { error: "not found" });
|
|
1119
|
+
}
|
|
1120
|
+
function openEventStream(engine, sseClients, req, res) {
|
|
1121
|
+
res.writeHead(200, {
|
|
1122
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
1123
|
+
"cache-control": "no-store",
|
|
1124
|
+
connection: "keep-alive",
|
|
1125
|
+
"x-accel-buffering": "no"
|
|
1126
|
+
});
|
|
1127
|
+
res.write(": connected\n\n");
|
|
1128
|
+
sseClients.add(res);
|
|
1129
|
+
const unsubscribe = engine.subscribe((event) => {
|
|
1130
|
+
res.write(`data: ${JSON.stringify(event)}
|
|
1131
|
+
|
|
1132
|
+
`);
|
|
1133
|
+
});
|
|
1134
|
+
const heartbeat = setInterval(() => res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
1135
|
+
if (typeof heartbeat.unref === "function") heartbeat.unref();
|
|
1136
|
+
const cleanup = () => {
|
|
1137
|
+
clearInterval(heartbeat);
|
|
1138
|
+
unsubscribe();
|
|
1139
|
+
sseClients.delete(res);
|
|
1140
|
+
};
|
|
1141
|
+
res.on("close", cleanup);
|
|
1142
|
+
req.on("close", cleanup);
|
|
1143
|
+
}
|
|
1144
|
+
function sendJson(res, status, body) {
|
|
1145
|
+
const payload = JSON.stringify(body);
|
|
1146
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
1147
|
+
res.end(payload);
|
|
1148
|
+
}
|
|
1149
|
+
function sendHtml(res, html) {
|
|
1150
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
1151
|
+
res.end(html);
|
|
1152
|
+
}
|
|
1153
|
+
function decodeKey(path, prefix) {
|
|
1154
|
+
return decodeURIComponent(path.slice(prefix.length));
|
|
1155
|
+
}
|
|
1156
|
+
function clampCount(raw) {
|
|
1157
|
+
const n = raw ? Number.parseInt(raw, 10) : 5;
|
|
1158
|
+
if (!Number.isFinite(n) || n < 1) return 5;
|
|
1159
|
+
return Math.min(n, MAX_PREVIEW);
|
|
1160
|
+
}
|
|
1161
|
+
function isSameOrigin(req) {
|
|
1162
|
+
const origin = req.headers.origin;
|
|
1163
|
+
if (!origin) return true;
|
|
1164
|
+
try {
|
|
1165
|
+
return new URL(origin).host === req.headers.host;
|
|
1166
|
+
} catch {
|
|
1167
|
+
return false;
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
export { LocalEngine, createLocalEngine, localTimeZone, nextOccurrence, parseCron, previewSchedule, startDashboard, upcomingFires };
|
|
1172
|
+
//# sourceMappingURL=dev.js.map
|
|
1173
|
+
//# sourceMappingURL=dev.js.map
|