figranium 0.9.1 → 0.9.6
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/LICENSE +674 -674
- package/README.md +336 -318
- package/agent.js +1 -1
- package/common-utils.js +211 -166
- package/dist/assets/index--OZi5-p_.css +1 -0
- package/dist/assets/index-Bkr74C53.js +15 -0
- package/dist/index.html +26 -26
- package/dist/novnc.html +108 -108
- package/extraction-worker.js +204 -197
- package/headful.js +583 -219
- package/html-utils.js +24 -24
- package/package.json +81 -78
- package/proxy-rotation.js +261 -261
- package/proxy-utils.js +84 -84
- package/public/novnc.html +108 -108
- package/scrape.js +418 -374
- package/server.js +501 -404
- package/src/server/cron-parser.js +316 -0
- package/src/server/routes/schedules.js +171 -0
- package/src/server/scheduler.js +381 -0
- package/url-utils.js +137 -116
- package/user-agent-settings.js +76 -76
- package/dist/assets/index-ALim18cn.css +0 -1
- package/dist/assets/index-D8YbCWRx.js +0 -15
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Zero-dependency cron expression parser, builder, and scheduler utility.
|
|
3
|
+
* Supports standard 5-field cron: minute hour day-of-month month day-of-week
|
|
4
|
+
* Plus presets: @yearly, @monthly, @weekly, @daily, @hourly
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const PRESETS = {
|
|
8
|
+
'@yearly': '0 0 1 1 *',
|
|
9
|
+
'@annually': '0 0 1 1 *',
|
|
10
|
+
'@monthly': '0 0 1 * *',
|
|
11
|
+
'@weekly': '0 0 * * 0',
|
|
12
|
+
'@daily': '0 0 * * *',
|
|
13
|
+
'@midnight': '0 0 * * *',
|
|
14
|
+
'@hourly': '0 * * * *',
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const FIELD_RANGES = [
|
|
18
|
+
{ name: 'minute', min: 0, max: 59 },
|
|
19
|
+
{ name: 'hour', min: 0, max: 23 },
|
|
20
|
+
{ name: 'dayOfMonth', min: 1, max: 31 },
|
|
21
|
+
{ name: 'month', min: 1, max: 12 },
|
|
22
|
+
{ name: 'dayOfWeek', min: 0, max: 7 }, // 0 and 7 both represent Sunday
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Parse a single cron field into a Set of valid integer values.
|
|
27
|
+
*/
|
|
28
|
+
function parseField(field, min, max) {
|
|
29
|
+
const values = new Set();
|
|
30
|
+
|
|
31
|
+
for (const part of field.split(',')) {
|
|
32
|
+
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
|
33
|
+
let base = stepMatch ? stepMatch[1] : part;
|
|
34
|
+
const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
|
|
35
|
+
|
|
36
|
+
if (step < 1) throw new Error(`Invalid step: ${part}`);
|
|
37
|
+
|
|
38
|
+
let rangeStart = min;
|
|
39
|
+
let rangeEnd = max;
|
|
40
|
+
|
|
41
|
+
if (base === '*') {
|
|
42
|
+
// full range
|
|
43
|
+
} else {
|
|
44
|
+
const rangeMatch = base.match(/^(\d+)-(\d+)$/);
|
|
45
|
+
if (rangeMatch) {
|
|
46
|
+
rangeStart = parseInt(rangeMatch[1], 10);
|
|
47
|
+
rangeEnd = parseInt(rangeMatch[2], 10);
|
|
48
|
+
} else {
|
|
49
|
+
const val = parseInt(base, 10);
|
|
50
|
+
if (isNaN(val)) throw new Error(`Invalid cron field value: ${base}`);
|
|
51
|
+
if (!stepMatch) {
|
|
52
|
+
// single value, clamp dayOfWeek 7 → 0
|
|
53
|
+
values.add(val === 7 && max === 7 ? 0 : val);
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
rangeStart = val;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
for (let i = rangeStart; i <= rangeEnd; i += step) {
|
|
61
|
+
values.add(i === 7 && max === 7 ? 0 : i);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return values;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Parse a full cron expression into an object with Sets for each field.
|
|
70
|
+
* @param {string} expression
|
|
71
|
+
* @returns {{ minute: Set<number>, hour: Set<number>, dayOfMonth: Set<number>, month: Set<number>, dayOfWeek: Set<number> }}
|
|
72
|
+
*/
|
|
73
|
+
function parseCron(expression) {
|
|
74
|
+
if (!expression || typeof expression !== 'string') {
|
|
75
|
+
throw new Error('Invalid cron expression');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const expr = expression.trim().toLowerCase();
|
|
79
|
+
const resolved = PRESETS[expr] || expr;
|
|
80
|
+
const parts = resolved.split(/\s+/);
|
|
81
|
+
|
|
82
|
+
if (parts.length !== 5) {
|
|
83
|
+
throw new Error(`Cron expression must have 5 fields, got ${parts.length}: "${expression}"`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const result = {};
|
|
87
|
+
for (let i = 0; i < 5; i++) {
|
|
88
|
+
const { name, min, max } = FIELD_RANGES[i];
|
|
89
|
+
result[name] = parseField(parts[i], min, max);
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Check if a cron expression is valid.
|
|
96
|
+
* @param {string} expression
|
|
97
|
+
* @returns {boolean}
|
|
98
|
+
*/
|
|
99
|
+
function isValidCron(expression) {
|
|
100
|
+
try {
|
|
101
|
+
parseCron(expression);
|
|
102
|
+
return true;
|
|
103
|
+
} catch {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the next run time after `from` that matches the cron expression.
|
|
110
|
+
* @param {string} expression
|
|
111
|
+
* @param {Date} [from]
|
|
112
|
+
* @returns {Date}
|
|
113
|
+
*/
|
|
114
|
+
function getNextRun(expression, from) {
|
|
115
|
+
const fields = parseCron(expression);
|
|
116
|
+
const d = from ? new Date(from) : new Date();
|
|
117
|
+
// Start from the next minute
|
|
118
|
+
d.setSeconds(0, 0);
|
|
119
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
120
|
+
|
|
121
|
+
// Safety: limit iterations to avoid infinite loop (covers ~4 years)
|
|
122
|
+
const maxIterations = 366 * 24 * 60;
|
|
123
|
+
for (let i = 0; i < maxIterations; i++) {
|
|
124
|
+
if (
|
|
125
|
+
fields.month.has(d.getMonth() + 1) &&
|
|
126
|
+
fields.dayOfMonth.has(d.getDate()) &&
|
|
127
|
+
fields.dayOfWeek.has(d.getDay()) &&
|
|
128
|
+
fields.hour.has(d.getHours()) &&
|
|
129
|
+
fields.minute.has(d.getMinutes())
|
|
130
|
+
) {
|
|
131
|
+
return d;
|
|
132
|
+
}
|
|
133
|
+
d.setMinutes(d.getMinutes() + 1);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
throw new Error(`Could not find next run for expression: ${expression}`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Convert a no-code schedule config object into a cron expression.
|
|
141
|
+
* @param {object} config
|
|
142
|
+
* @param {string} config.frequency - 'interval' | 'hourly' | 'daily' | 'weekly' | 'monthly'
|
|
143
|
+
* @param {number} [config.intervalMinutes] - for 'interval' frequency
|
|
144
|
+
* @param {number} [config.hour] - hour (0-23)
|
|
145
|
+
* @param {number} [config.minute] - minute (0-59)
|
|
146
|
+
* @param {number[]} [config.daysOfWeek] - for 'weekly' (0=Sun..6=Sat)
|
|
147
|
+
* @param {number} [config.dayOfMonth] - for 'monthly' (1-31)
|
|
148
|
+
* @returns {string}
|
|
149
|
+
*/
|
|
150
|
+
function scheduleToCron(config) {
|
|
151
|
+
if (!config || !config.frequency) {
|
|
152
|
+
throw new Error('Schedule config must include a frequency');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const min = config.minute ?? 0;
|
|
156
|
+
const hr = config.hour ?? 0;
|
|
157
|
+
|
|
158
|
+
switch (config.frequency) {
|
|
159
|
+
case 'interval': {
|
|
160
|
+
const interval = config.intervalMinutes || 5;
|
|
161
|
+
if (interval <= 0 || interval > 1440) throw new Error('Interval must be 1-1440 minutes');
|
|
162
|
+
|
|
163
|
+
if (interval <= 60) {
|
|
164
|
+
if (60 % interval === 0) {
|
|
165
|
+
return `*/${interval} * * * *`;
|
|
166
|
+
}
|
|
167
|
+
const minutes = [];
|
|
168
|
+
for (let m = 0; m < 60; m += interval) {
|
|
169
|
+
minutes.push(m);
|
|
170
|
+
}
|
|
171
|
+
return `${minutes.join(',')} * * * *`;
|
|
172
|
+
} else {
|
|
173
|
+
// For intervals > 60m, use hours/minutes
|
|
174
|
+
const hrs = Math.floor(interval / 60);
|
|
175
|
+
const mins = interval % 60;
|
|
176
|
+
if (hrs < 24 && 24 % hrs === 0 && mins === 0) {
|
|
177
|
+
return `0 */${hrs} * * *`;
|
|
178
|
+
}
|
|
179
|
+
// Fallback to daily if too complex, or just return hourly with 0 min
|
|
180
|
+
return `0 */${Math.max(1, hrs)} * * *`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
case 'hourly':
|
|
184
|
+
return `${min} * * * *`;
|
|
185
|
+
case 'daily':
|
|
186
|
+
return `${min} ${hr} * * *`;
|
|
187
|
+
case 'weekly': {
|
|
188
|
+
const days = Array.isArray(config.daysOfWeek) && config.daysOfWeek.length > 0
|
|
189
|
+
? config.daysOfWeek.sort().join(',')
|
|
190
|
+
: '*';
|
|
191
|
+
return `${min} ${hr} * * ${days}`;
|
|
192
|
+
}
|
|
193
|
+
case 'monthly': {
|
|
194
|
+
const dom = config.dayOfMonth || 1;
|
|
195
|
+
return `${min} ${hr} ${dom} * *`;
|
|
196
|
+
}
|
|
197
|
+
default:
|
|
198
|
+
throw new Error(`Unknown frequency: ${config.frequency}`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Produce a human-readable description of a cron expression.
|
|
204
|
+
* @param {string} expression
|
|
205
|
+
* @returns {string}
|
|
206
|
+
*/
|
|
207
|
+
function describeCron(expression) {
|
|
208
|
+
if (!expression || typeof expression !== 'string') return '';
|
|
209
|
+
|
|
210
|
+
const expr = expression.trim().toLowerCase();
|
|
211
|
+
if (PRESETS[expr]) {
|
|
212
|
+
const labels = {
|
|
213
|
+
'@yearly': 'Every year on January 1st at midnight',
|
|
214
|
+
'@annually': 'Every year on January 1st at midnight',
|
|
215
|
+
'@monthly': 'First day of every month at midnight',
|
|
216
|
+
'@weekly': 'Every Sunday at midnight',
|
|
217
|
+
'@daily': 'Every day at midnight',
|
|
218
|
+
'@midnight': 'Every day at midnight',
|
|
219
|
+
'@hourly': 'Every hour',
|
|
220
|
+
};
|
|
221
|
+
return labels[expr] || expr;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
try {
|
|
225
|
+
const parts = expr.split(/\s+/);
|
|
226
|
+
if (parts.length !== 5) return expression;
|
|
227
|
+
|
|
228
|
+
const [minPart, hrPart, domPart, monPart, dowPart] = parts;
|
|
229
|
+
|
|
230
|
+
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
231
|
+
const monthNames = ['', 'January', 'February', 'March', 'April', 'May', 'June',
|
|
232
|
+
'July', 'August', 'September', 'October', 'November', 'December'];
|
|
233
|
+
|
|
234
|
+
const formatTime = (h, m) => {
|
|
235
|
+
const hour = parseInt(h, 10);
|
|
236
|
+
const minute = parseInt(m, 10);
|
|
237
|
+
if (isNaN(hour) || isNaN(minute)) return '';
|
|
238
|
+
const ampm = hour >= 12 ? 'PM' : 'AM';
|
|
239
|
+
const h12 = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour;
|
|
240
|
+
return `${h12}:${String(minute).padStart(2, '0')} ${ampm}`;
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Every N minutes
|
|
244
|
+
const stepMatch = minPart.match(/^\*\/(\d+)$/);
|
|
245
|
+
if (stepMatch && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
|
|
246
|
+
const n = parseInt(stepMatch[1], 10);
|
|
247
|
+
if (n === 1) return 'Every minute';
|
|
248
|
+
return `Every ${n} minutes`;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Every minute
|
|
252
|
+
if (minPart === '*' && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
|
|
253
|
+
return 'Every minute';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Comma separated minutes (common for non-divisible intervals)
|
|
257
|
+
if (hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*' && minPart.includes(',')) {
|
|
258
|
+
const mins = minPart.split(',');
|
|
259
|
+
if (mins.every(m => /^\d+$/.test(m))) {
|
|
260
|
+
const diffs = [];
|
|
261
|
+
for (let i = 1; i < mins.length; i++) diffs.push(parseInt(mins[i]) - parseInt(mins[i-1]));
|
|
262
|
+
const uniqueDiffs = new Set(diffs);
|
|
263
|
+
if (uniqueDiffs.size === 1) {
|
|
264
|
+
return `Every ${uniqueDiffs.values().next().value} minutes`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Specific minute, every hour
|
|
270
|
+
if (/^\d+$/.test(minPart) && hrPart === '*' && domPart === '*' && monPart === '*' && dowPart === '*') {
|
|
271
|
+
const m = parseInt(minPart, 10);
|
|
272
|
+
return `Every hour at :${String(m).padStart(2, '0')}`;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Daily at specific time
|
|
276
|
+
if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart === '*') {
|
|
277
|
+
return `Every day at ${formatTime(hrPart, minPart)}`;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Weekly
|
|
281
|
+
if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && domPart === '*' && monPart === '*' && dowPart !== '*') {
|
|
282
|
+
const dows = dowPart.split(',').map(d => {
|
|
283
|
+
const n = parseInt(d, 10);
|
|
284
|
+
return dayNames[n === 7 ? 0 : n] || d;
|
|
285
|
+
});
|
|
286
|
+
if (dows.length === 5 && !dows.includes('Saturday') && !dows.includes('Sunday')) {
|
|
287
|
+
return `Every weekday at ${formatTime(hrPart, minPart)}`;
|
|
288
|
+
}
|
|
289
|
+
if (dows.length === 2 && dows.includes('Saturday') && dows.includes('Sunday')) {
|
|
290
|
+
return `Every weekend at ${formatTime(hrPart, minPart)}`;
|
|
291
|
+
}
|
|
292
|
+
return `Every ${dows.join(', ')} at ${formatTime(hrPart, minPart)}`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Monthly
|
|
296
|
+
if (/^\d+$/.test(minPart) && /^\d+$/.test(hrPart) && /^\d+$/.test(domPart) && monPart === '*' && dowPart === '*') {
|
|
297
|
+
const dom = parseInt(domPart, 10);
|
|
298
|
+
const suffix = dom === 1 ? 'st' : dom === 2 ? 'nd' : dom === 3 ? 'rd' : 'th';
|
|
299
|
+
return `Monthly on the ${dom}${suffix} at ${formatTime(hrPart, minPart)}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Fallback: return a structured description
|
|
303
|
+
return expression;
|
|
304
|
+
} catch {
|
|
305
|
+
return expression;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
parseCron,
|
|
311
|
+
isValidCron,
|
|
312
|
+
getNextRun,
|
|
313
|
+
scheduleToCron,
|
|
314
|
+
describeCron,
|
|
315
|
+
PRESETS,
|
|
316
|
+
};
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const { requireAuth } = require('../middleware');
|
|
3
|
+
const { loadTasks, saveTasks, getTaskById } = require('../storage');
|
|
4
|
+
const { taskMutex } = require('../state');
|
|
5
|
+
const { refreshSchedule, removeSchedule, getSchedulerStatus, resolveCron } = require('../scheduler');
|
|
6
|
+
const { isValidCron, describeCron, getNextRun } = require('../cron-parser');
|
|
7
|
+
|
|
8
|
+
const router = express.Router();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* GET /api/schedules
|
|
12
|
+
* List all tasks that have schedules (enabled or not), with status info.
|
|
13
|
+
*/
|
|
14
|
+
router.get('/', requireAuth, async (req, res) => {
|
|
15
|
+
const tasks = await loadTasks();
|
|
16
|
+
const schedules = tasks
|
|
17
|
+
.filter(t => t.schedule)
|
|
18
|
+
.map(t => ({
|
|
19
|
+
taskId: t.id,
|
|
20
|
+
taskName: t.name,
|
|
21
|
+
mode: t.mode,
|
|
22
|
+
schedule: t.schedule
|
|
23
|
+
}));
|
|
24
|
+
res.json({ schedules });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* POST /api/schedules/:taskId
|
|
29
|
+
* Create or update a schedule on a task.
|
|
30
|
+
* Body: { enabled, frequency?, intervalMinutes?, hour?, minute?, daysOfWeek?, dayOfMonth?, cron? }
|
|
31
|
+
*/
|
|
32
|
+
router.post('/:taskId', requireAuth, async (req, res) => {
|
|
33
|
+
await taskMutex.lock();
|
|
34
|
+
try {
|
|
35
|
+
const tasks = await loadTasks();
|
|
36
|
+
const task = tasks.find(t => t.id === req.params.taskId);
|
|
37
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
38
|
+
|
|
39
|
+
const body = req.body || {};
|
|
40
|
+
const schedule = {
|
|
41
|
+
...(task.schedule || {}),
|
|
42
|
+
...body,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
// If body explicitly provides one mode, clear the other to avoid mode-switching bugs
|
|
46
|
+
if (body.cron && !body.frequency) {
|
|
47
|
+
delete schedule.frequency;
|
|
48
|
+
delete schedule.intervalMinutes;
|
|
49
|
+
delete schedule.hour;
|
|
50
|
+
delete schedule.minute;
|
|
51
|
+
delete schedule.daysOfWeek;
|
|
52
|
+
delete schedule.dayOfMonth;
|
|
53
|
+
} else if (body.frequency && !body.cron) {
|
|
54
|
+
delete schedule.cron;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Handle explicit nulls (JSON doesn't support undefined, so null is common)
|
|
58
|
+
if (body.cron === null) delete schedule.cron;
|
|
59
|
+
if (body.frequency === null) delete schedule.frequency;
|
|
60
|
+
|
|
61
|
+
// Validate the resulting cron
|
|
62
|
+
const cron = resolveCron(schedule);
|
|
63
|
+
if (schedule.enabled && !cron) {
|
|
64
|
+
return res.status(400).json({ error: 'INVALID_SCHEDULE', message: 'Cannot resolve a valid cron expression from the provided schedule config.' });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Compute metadata
|
|
68
|
+
if (cron) {
|
|
69
|
+
schedule.cron = cron;
|
|
70
|
+
try {
|
|
71
|
+
const nextRun = getNextRun(cron);
|
|
72
|
+
schedule.nextRun = nextRun.getTime();
|
|
73
|
+
} catch {
|
|
74
|
+
schedule.nextRun = null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
task.schedule = schedule;
|
|
79
|
+
await saveTasks(tasks);
|
|
80
|
+
|
|
81
|
+
// Notify scheduler
|
|
82
|
+
await refreshSchedule(task.id);
|
|
83
|
+
|
|
84
|
+
res.json({
|
|
85
|
+
schedule: task.schedule,
|
|
86
|
+
description: cron ? describeCron(cron) : null,
|
|
87
|
+
nextRun: cron ? getNextRun(cron).getTime() : null
|
|
88
|
+
});
|
|
89
|
+
} finally {
|
|
90
|
+
taskMutex.unlock();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* DELETE /api/schedules/:taskId
|
|
96
|
+
* Remove/disable schedule from a task.
|
|
97
|
+
*/
|
|
98
|
+
router.delete('/:taskId', requireAuth, async (req, res) => {
|
|
99
|
+
await taskMutex.lock();
|
|
100
|
+
try {
|
|
101
|
+
const tasks = await loadTasks();
|
|
102
|
+
const task = tasks.find(t => t.id === req.params.taskId);
|
|
103
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
104
|
+
|
|
105
|
+
if (task.schedule) {
|
|
106
|
+
task.schedule.enabled = false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await saveTasks(tasks);
|
|
110
|
+
removeSchedule(task.id);
|
|
111
|
+
|
|
112
|
+
res.json({ success: true });
|
|
113
|
+
} finally {
|
|
114
|
+
taskMutex.unlock();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* GET /api/schedules/:taskId/status
|
|
120
|
+
* Get schedule status for a specific task.
|
|
121
|
+
*/
|
|
122
|
+
router.get('/:taskId/status', requireAuth, async (req, res) => {
|
|
123
|
+
await loadTasks();
|
|
124
|
+
const task = getTaskById(req.params.taskId);
|
|
125
|
+
if (!task) return res.status(404).json({ error: 'TASK_NOT_FOUND' });
|
|
126
|
+
|
|
127
|
+
const schedule = task.schedule || {};
|
|
128
|
+
const cron = resolveCron(schedule);
|
|
129
|
+
|
|
130
|
+
res.json({
|
|
131
|
+
schedule,
|
|
132
|
+
cron,
|
|
133
|
+
description: cron ? describeCron(cron) : null,
|
|
134
|
+
isValid: cron ? isValidCron(cron) : false
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* POST /api/schedules/:taskId/describe
|
|
140
|
+
* Validate and describe a schedule config without saving it.
|
|
141
|
+
*/
|
|
142
|
+
router.post('/:taskId/describe', requireAuth, async (req, res) => {
|
|
143
|
+
const body = req.body || {};
|
|
144
|
+
const cron = resolveCron(body);
|
|
145
|
+
|
|
146
|
+
if (!cron) {
|
|
147
|
+
return res.json({ valid: false, description: null, cron: null, nextRun: null });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let nextRun = null;
|
|
151
|
+
try {
|
|
152
|
+
nextRun = getNextRun(cron).getTime();
|
|
153
|
+
} catch { }
|
|
154
|
+
|
|
155
|
+
res.json({
|
|
156
|
+
valid: true,
|
|
157
|
+
cron,
|
|
158
|
+
description: describeCron(cron),
|
|
159
|
+
nextRun
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* GET /api/schedules/status/all
|
|
165
|
+
* Get overall scheduler status.
|
|
166
|
+
*/
|
|
167
|
+
router.get('/status/all', requireAuth, async (_req, res) => {
|
|
168
|
+
res.json(getSchedulerStatus());
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
module.exports = router;
|