thatcher 1.0.64 → 1.0.66
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/package.json +1 -1
- package/src/lib/config-generator-engine.js +18 -0
- package/src/lib/rate-limiter.js +37 -0
- package/src/lib/scheduler-engine.js +69 -0
- package/src/server/server.js +167 -0
- package/src/ui/dashboard-renderer.js +7 -7
- package/src/ui/page-handler-admin.js +9 -0
- package/src/ui/scheduled-job-renderer.js +92 -0
package/package.json
CHANGED
|
@@ -79,12 +79,30 @@ const ENTITY_TEMPLATE_ENTITY_DEFAULT = {
|
|
|
79
79
|
},
|
|
80
80
|
};
|
|
81
81
|
|
|
82
|
+
const SCHEDULED_JOB_ENTITY_DEFAULT = {
|
|
83
|
+
label: 'Scheduled Job',
|
|
84
|
+
label_plural: 'Scheduled Jobs',
|
|
85
|
+
system_entity: true,
|
|
86
|
+
fields: {
|
|
87
|
+
name: { type: 'text', required: true, label: 'Name' },
|
|
88
|
+
entity: { type: 'text', required: true, label: 'Entity' },
|
|
89
|
+
action: { type: 'json', required: true, label: 'Action' },
|
|
90
|
+
filter: { type: 'json', label: 'Filter' },
|
|
91
|
+
interval_minutes: { type: 'int', required: true, label: 'Interval (minutes)' },
|
|
92
|
+
last_run_at: { type: 'int', label: 'Last Run At' },
|
|
93
|
+
next_run_at: { type: 'int', label: 'Next Run At' },
|
|
94
|
+
enabled: { type: 'bool', default: true, label: 'Enabled' },
|
|
95
|
+
owner_id: { type: 'ref', ref: 'user', required: true, label: 'Owner' },
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
|
|
82
99
|
function withWebhookDefaults(masterConfig) {
|
|
83
100
|
const entities = { ...(masterConfig.entities || {}) };
|
|
84
101
|
let changed = false;
|
|
85
102
|
if (!entities.webhook) { entities.webhook = WEBHOOK_ENTITY_DEFAULT; changed = true; }
|
|
86
103
|
if (!entities.webhook_delivery) { entities.webhook_delivery = WEBHOOK_DELIVERY_ENTITY_DEFAULT; changed = true; }
|
|
87
104
|
if (!entities.entity_template) { entities.entity_template = ENTITY_TEMPLATE_ENTITY_DEFAULT; changed = true; }
|
|
105
|
+
if (!entities.scheduled_job) { entities.scheduled_job = SCHEDULED_JOB_ENTITY_DEFAULT; changed = true; }
|
|
88
106
|
return changed ? { ...masterConfig, entities } : masterConfig;
|
|
89
107
|
}
|
|
90
108
|
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Fixed-window per-key rate limiter, in-memory. Keyed by authenticated user id
|
|
2
|
+
// when available, otherwise by remote IP, so one anonymous IP hammering the
|
|
3
|
+
// API can't exhaust a budget shared with real users behind the same NAT --
|
|
4
|
+
// each authenticated user gets their own independent window.
|
|
5
|
+
const WINDOW_MS = 60 * 1000;
|
|
6
|
+
const DEFAULT_LIMIT = 300;
|
|
7
|
+
const buckets = new Map();
|
|
8
|
+
|
|
9
|
+
let sweepHandle = null;
|
|
10
|
+
function ensureSweep() {
|
|
11
|
+
if (sweepHandle) return;
|
|
12
|
+
sweepHandle = setInterval(() => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [key, bucket] of buckets) {
|
|
15
|
+
if (now - bucket.windowStart > WINDOW_MS) buckets.delete(key);
|
|
16
|
+
}
|
|
17
|
+
}, WINDOW_MS);
|
|
18
|
+
if (sweepHandle.unref) sweepHandle.unref();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function checkRateLimit(key, limit = DEFAULT_LIMIT) {
|
|
22
|
+
ensureSweep();
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
let bucket = buckets.get(key);
|
|
25
|
+
if (!bucket || now - bucket.windowStart >= WINDOW_MS) {
|
|
26
|
+
bucket = { windowStart: now, count: 0 };
|
|
27
|
+
buckets.set(key, bucket);
|
|
28
|
+
}
|
|
29
|
+
bucket.count += 1;
|
|
30
|
+
const remaining = Math.max(0, limit - bucket.count);
|
|
31
|
+
const resetMs = bucket.windowStart + WINDOW_MS - now;
|
|
32
|
+
return { allowed: bucket.count <= limit, remaining, resetMs, limit };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function resetRateLimiter() {
|
|
36
|
+
buckets.clear();
|
|
37
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { list, update, getBy } from './busybase/store.js';
|
|
2
|
+
import { runBulkOperation } from './bulk-operations.js';
|
|
3
|
+
import { getConfigEngineSync } from './config-generator-engine.js';
|
|
4
|
+
import { now } from './id-helpers.js';
|
|
5
|
+
import { createLogger } from './logger.js';
|
|
6
|
+
|
|
7
|
+
const log = createLogger('[Scheduler]');
|
|
8
|
+
const CHECK_INTERVAL_MS = 60 * 1000;
|
|
9
|
+
let intervalHandle = null;
|
|
10
|
+
|
|
11
|
+
export async function runDueJobs() {
|
|
12
|
+
const nowTs = now();
|
|
13
|
+
const dueJobs = await list('scheduled_job', { enabled: true });
|
|
14
|
+
const results = [];
|
|
15
|
+
for (const job of dueJobs) {
|
|
16
|
+
if (!job.enabled) continue;
|
|
17
|
+
if (job.next_run_at && job.next_run_at > nowTs) continue;
|
|
18
|
+
results.push(await runOneJob(job, nowTs));
|
|
19
|
+
}
|
|
20
|
+
return results;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function runOneJob(job, nowTs) {
|
|
24
|
+
try {
|
|
25
|
+
const owner = await getBy('users', 'id', job.owner_id);
|
|
26
|
+
if (!owner) throw new Error(`Owner ${job.owner_id} not found`);
|
|
27
|
+
|
|
28
|
+
const configEngine = getConfigEngineSync();
|
|
29
|
+
const spec = configEngine.generateEntitySpec(job.entity);
|
|
30
|
+
|
|
31
|
+
const filter = job.filter ? (typeof job.filter === 'string' ? JSON.parse(job.filter) : job.filter) : {};
|
|
32
|
+
const action = typeof job.action === 'string' ? JSON.parse(job.action) : job.action;
|
|
33
|
+
|
|
34
|
+
// list() is org/row-access scoped by passing {user: owner} -- the same
|
|
35
|
+
// scoping crud-handlers.js's read path enforces for an interactive
|
|
36
|
+
// request, so a job cannot reach records outside its owner's org just
|
|
37
|
+
// because it runs unattended.
|
|
38
|
+
const targets = await list(job.entity, filter, { user: owner });
|
|
39
|
+
const ids = targets.map(r => r.id);
|
|
40
|
+
|
|
41
|
+
let result = { ok: true, total: 0, succeeded: 0, failed: 0, results: [] };
|
|
42
|
+
if (ids.length) {
|
|
43
|
+
result = await runBulkOperation(job.entity, spec, ids, action, owner);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const nextRunAt = nowTs + job.interval_minutes * 60;
|
|
47
|
+
await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt });
|
|
48
|
+
log.info(`Job "${job.name}" ran: ${result.succeeded}/${result.total} succeeded`);
|
|
49
|
+
return { job_id: job.id, ok: true, ...result };
|
|
50
|
+
} catch (err) {
|
|
51
|
+
log.error(`Job "${job.name}" failed: ${err.message}`);
|
|
52
|
+
const nextRunAt = nowTs + job.interval_minutes * 60;
|
|
53
|
+
await update('scheduled_job', job.id, { last_run_at: nowTs, next_run_at: nextRunAt }).catch(() => {});
|
|
54
|
+
return { job_id: job.id, ok: false, error: err.message };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function startScheduler() {
|
|
59
|
+
if (intervalHandle) return intervalHandle;
|
|
60
|
+
intervalHandle = setInterval(() => {
|
|
61
|
+
runDueJobs().catch(e => log.error(e.message));
|
|
62
|
+
}, CHECK_INTERVAL_MS);
|
|
63
|
+
if (intervalHandle.unref) intervalHandle.unref();
|
|
64
|
+
return intervalHandle;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function stopScheduler() {
|
|
68
|
+
if (intervalHandle) { clearInterval(intervalHandle); intervalHandle = null; }
|
|
69
|
+
}
|
package/src/server/server.js
CHANGED
|
@@ -73,6 +73,8 @@ export function createServer(options) {
|
|
|
73
73
|
});
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
import('../lib/scheduler-engine.js').then(({ startScheduler }) => startScheduler()).catch(e => log.error(e.message));
|
|
77
|
+
|
|
76
78
|
const server = http.createServer(async (req, res) => {
|
|
77
79
|
globalThis.__debug__.activeRequests.count++;
|
|
78
80
|
|
|
@@ -91,6 +93,20 @@ export function createServer(options) {
|
|
|
91
93
|
// API routes
|
|
92
94
|
if (pathname.startsWith('/api/')) {
|
|
93
95
|
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
96
|
+
|
|
97
|
+
const { checkRateLimit } = await import('../lib/rate-limiter.js');
|
|
98
|
+
const rateUser = await resolveRequestUser(req);
|
|
99
|
+
const rateKey = rateUser ? `user:${rateUser.id}` : `ip:${req.socket?.remoteAddress || 'unknown'}`;
|
|
100
|
+
const rate = checkRateLimit(rateKey);
|
|
101
|
+
res.setHeader('X-RateLimit-Limit', String(rate.limit));
|
|
102
|
+
res.setHeader('X-RateLimit-Remaining', String(rate.remaining));
|
|
103
|
+
res.setHeader('X-RateLimit-Reset', String(Math.ceil(rate.resetMs / 1000)));
|
|
104
|
+
if (!rate.allowed) {
|
|
105
|
+
res.setHeader('Retry-After', String(Math.ceil(rate.resetMs / 1000)));
|
|
106
|
+
res.writeHead(429);
|
|
107
|
+
res.end(JSON.stringify({ error: 'Rate limit exceeded, try again later' }));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
94
110
|
const parts = pathname.slice(5).split('/').filter(Boolean); // remove /api/
|
|
95
111
|
|
|
96
112
|
if (parts.length === 0) {
|
|
@@ -151,6 +167,18 @@ export function createServer(options) {
|
|
|
151
167
|
return await handleFileUpload(req, res, thatcher, configEngine);
|
|
152
168
|
}
|
|
153
169
|
|
|
170
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id === 'create' && !action) {
|
|
171
|
+
return await handleCreateScheduledJob(req, res, thatcher, configEngine);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id && action === 'update') {
|
|
175
|
+
return await handleUpdateScheduledJob(req, res, id, thatcher, configEngine);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (req.method === 'POST' && entity === 'scheduled_job' && id && action === 'delete') {
|
|
179
|
+
return await handleDeleteScheduledJob(req, res, id, thatcher, configEngine);
|
|
180
|
+
}
|
|
181
|
+
|
|
154
182
|
// Check if user has custom route for this
|
|
155
183
|
const userRoutePath = path.join(process.cwd(), 'app/api', ...parts, 'route.js');
|
|
156
184
|
const routeExists = await fileExists(userRoutePath);
|
|
@@ -541,6 +569,145 @@ async function handleDeleteEntityTemplate(req, res, templateId, thatcher, config
|
|
|
541
569
|
}
|
|
542
570
|
}
|
|
543
571
|
|
|
572
|
+
async function handleCreateScheduledJob(req, res, thatcher, configEngineArg) {
|
|
573
|
+
const user = await requireAuthedPartner(req, res);
|
|
574
|
+
if (!user) return;
|
|
575
|
+
|
|
576
|
+
let configEngine = configEngineArg || thatcher?.configEngine || globalThis.__thatcherConfigEngine;
|
|
577
|
+
if (!configEngine) {
|
|
578
|
+
const { getConfigEngineSync } = await import('../lib/config-generator-engine.js');
|
|
579
|
+
configEngine = getConfigEngineSync();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
let body;
|
|
583
|
+
try {
|
|
584
|
+
body = await readBody(req);
|
|
585
|
+
} catch (e) {
|
|
586
|
+
res.writeHead(400);
|
|
587
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
const { name, entity, action, filter, interval_minutes } = body || {};
|
|
591
|
+
if (!entity || typeof entity !== 'string') {
|
|
592
|
+
res.writeHead(400);
|
|
593
|
+
res.end(JSON.stringify({ error: 'entity required' }));
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
try {
|
|
597
|
+
configEngine.generateEntitySpec(entity);
|
|
598
|
+
} catch {
|
|
599
|
+
res.writeHead(400);
|
|
600
|
+
res.end(JSON.stringify({ error: `Unknown entity "${entity}"` }));
|
|
601
|
+
return;
|
|
602
|
+
}
|
|
603
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
604
|
+
res.writeHead(400);
|
|
605
|
+
res.end(JSON.stringify({ error: 'name required' }));
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (!action || typeof action !== 'object' || typeof action.type !== 'string') {
|
|
609
|
+
res.writeHead(400);
|
|
610
|
+
res.end(JSON.stringify({ error: 'action.type required' }));
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const intervalMinutes = Number(interval_minutes);
|
|
614
|
+
if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
|
|
615
|
+
res.writeHead(400);
|
|
616
|
+
res.end(JSON.stringify({ error: 'interval_minutes must be a positive number' }));
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
try {
|
|
621
|
+
const { create } = await import('../lib/busybase/store.js');
|
|
622
|
+
const { now } = await import('../lib/id-helpers.js');
|
|
623
|
+
const nowTs = now();
|
|
624
|
+
const record = await create('scheduled_job', {
|
|
625
|
+
name: name.trim(),
|
|
626
|
+
entity,
|
|
627
|
+
action,
|
|
628
|
+
filter: filter || {},
|
|
629
|
+
interval_minutes: intervalMinutes,
|
|
630
|
+
last_run_at: null,
|
|
631
|
+
next_run_at: nowTs,
|
|
632
|
+
enabled: true,
|
|
633
|
+
owner_id: user.id,
|
|
634
|
+
}, user);
|
|
635
|
+
res.writeHead(201, { 'Content-Type': 'application/json' });
|
|
636
|
+
res.end(JSON.stringify({ ok: true, id: record.id }));
|
|
637
|
+
} catch (err) {
|
|
638
|
+
apiLog.error(err.message);
|
|
639
|
+
res.writeHead(500);
|
|
640
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function handleUpdateScheduledJob(req, res, jobId, thatcher, configEngineArg) {
|
|
645
|
+
const user = await requireAuthedPartner(req, res);
|
|
646
|
+
if (!user) return;
|
|
647
|
+
|
|
648
|
+
let body;
|
|
649
|
+
try {
|
|
650
|
+
body = await readBody(req);
|
|
651
|
+
} catch (e) {
|
|
652
|
+
res.writeHead(400);
|
|
653
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
try {
|
|
658
|
+
const { update, get } = await import('../lib/busybase/store.js');
|
|
659
|
+
const existing = await get('scheduled_job', jobId);
|
|
660
|
+
if (!existing) {
|
|
661
|
+
res.writeHead(404);
|
|
662
|
+
res.end(JSON.stringify({ error: 'Job not found' }));
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
const patch = {};
|
|
666
|
+
if (typeof body?.enabled === 'boolean') patch.enabled = body.enabled;
|
|
667
|
+
if (typeof body?.name === 'string' && body.name.trim()) patch.name = body.name.trim();
|
|
668
|
+
if (body?.interval_minutes !== undefined) {
|
|
669
|
+
const intervalMinutes = Number(body.interval_minutes);
|
|
670
|
+
if (!Number.isFinite(intervalMinutes) || intervalMinutes <= 0) {
|
|
671
|
+
res.writeHead(400);
|
|
672
|
+
res.end(JSON.stringify({ error: 'interval_minutes must be a positive number' }));
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
patch.interval_minutes = intervalMinutes;
|
|
676
|
+
}
|
|
677
|
+
if (body?.filter !== undefined) patch.filter = body.filter;
|
|
678
|
+
if (body?.action !== undefined) patch.action = body.action;
|
|
679
|
+
const record = await update('scheduled_job', jobId, patch);
|
|
680
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
681
|
+
res.end(JSON.stringify({ ok: true, data: record }));
|
|
682
|
+
} catch (err) {
|
|
683
|
+
apiLog.error(err.message);
|
|
684
|
+
res.writeHead(500);
|
|
685
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function handleDeleteScheduledJob(req, res, jobId, thatcher, configEngineArg) {
|
|
690
|
+
const user = await requireAuthedPartner(req, res);
|
|
691
|
+
if (!user) return;
|
|
692
|
+
|
|
693
|
+
try {
|
|
694
|
+
const { remove, get } = await import('../lib/busybase/store.js');
|
|
695
|
+
const existing = await get('scheduled_job', jobId);
|
|
696
|
+
if (!existing) {
|
|
697
|
+
res.writeHead(404);
|
|
698
|
+
res.end(JSON.stringify({ error: 'Job not found' }));
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
await remove('scheduled_job', jobId);
|
|
702
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
703
|
+
res.end(JSON.stringify({ ok: true }));
|
|
704
|
+
} catch (err) {
|
|
705
|
+
apiLog.error(err.message);
|
|
706
|
+
res.writeHead(500);
|
|
707
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
544
711
|
async function handleCreateWebhook(req, res, thatcher, configEngineArg) {
|
|
545
712
|
const user = await requireAuthedPartner(req, res);
|
|
546
713
|
if (!user) return;
|
|
@@ -89,12 +89,12 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
89
89
|
const { summary = {}, recentActivity = [] } = auditData;
|
|
90
90
|
const actRows = recentActivity.slice(0, 20).map(a =>
|
|
91
91
|
`<tr data-row>
|
|
92
|
-
<td data-col="time">${new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA')}</td>
|
|
93
|
-
<td data-col="action"><span class="pill pill-info">${a.action||'-'}</span></td>
|
|
94
|
-
<td data-col="entity">${a.entity_type||'-'}</td>
|
|
95
|
-
<td data-col="id" style="font-size:12px">${a.entity_id||'-'}</td>
|
|
96
|
-
<td data-col="user">${a.user_name||a.user_id||'-'}</td>
|
|
97
|
-
<td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${a.reason||'-'}</td>
|
|
92
|
+
<td data-col="time">${esc(new Date((a.timestamp||a.created_at)*1000).toLocaleString('en-ZA'))}</td>
|
|
93
|
+
<td data-col="action"><span class="pill pill-info">${esc(a.action||'-')}</span></td>
|
|
94
|
+
<td data-col="entity">${esc(a.entity_type||'-')}</td>
|
|
95
|
+
<td data-col="id" style="font-size:12px">${esc(a.entity_id||'-')}</td>
|
|
96
|
+
<td data-col="user">${esc(a.user_name||a.user_id||'-')}</td>
|
|
97
|
+
<td data-col="reason" style="font-size:12px;color:var(--color-text-muted)">${esc(a.reason||'-')}</td>
|
|
98
98
|
</tr>`
|
|
99
99
|
).join('') || emptyRow(6, 'No audit records found');
|
|
100
100
|
|
|
@@ -124,7 +124,7 @@ export function renderAuditDashboard(user, auditData = {}) {
|
|
|
124
124
|
export function renderSystemHealth(user, healthData = {}) {
|
|
125
125
|
const { database = {}, server: srv = {}, entities = {} } = healthData;
|
|
126
126
|
const entRows = Object.entries(entities).map(([n, c]) =>
|
|
127
|
-
`<tr data-row><td data-col="entity">${n}</td><td data-col="count" style="text-align:right">${c}</td></tr>`
|
|
127
|
+
`<tr data-row><td data-col="entity">${esc(n)}</td><td data-col="count" style="text-align:right">${esc(String(c))}</td></tr>`
|
|
128
128
|
).join('') || emptyRow(2, 'No data');
|
|
129
129
|
|
|
130
130
|
const statsHtml = `<div class="stats-row">${[
|
|
@@ -10,6 +10,7 @@ import { renderWorkflowList, renderWorkflowEditor } from '@/ui/workflow-builder-
|
|
|
10
10
|
import { renderRolesList, renderTemplateList, renderPermissionMatrix } from '@/ui/rbac-renderer.js';
|
|
11
11
|
import { renderWebhookList, renderWebhookDetail } from '@/ui/webhook-renderer.js';
|
|
12
12
|
import { renderTemplateList as renderEntityTemplateList } from '@/ui/template-renderer.js';
|
|
13
|
+
import { renderScheduledJobList } from '@/ui/scheduled-job-renderer.js';
|
|
13
14
|
import { isPartner, isManager } from '@/ui/permissions-ui.js';
|
|
14
15
|
import { getSystemConfig, getSettingsCounts, getAuditData, getSystemHealth, renderBuildLogsContent } from '@/ui/page-handler-helpers.js';
|
|
15
16
|
import { fileURLToPath } from 'url';
|
|
@@ -191,5 +192,13 @@ export async function handleAdminPage(normalized, segments, user) {
|
|
|
191
192
|
}
|
|
192
193
|
return renderEntityTemplateList(user, entityNames, templatesByEntity);
|
|
193
194
|
}
|
|
195
|
+
if (normalized === '/admin/scheduled-jobs') {
|
|
196
|
+
if (!isPartner(user)) return renderAccessDenied(user, 'admin', 'view');
|
|
197
|
+
const { getConfigEngineSync } = await import('@/lib/config-generator-engine.js');
|
|
198
|
+
const engine = getConfigEngineSync();
|
|
199
|
+
const entityNames = engine.getAllEntities().filter(e => e !== 'scheduled_job' && e !== 'entity_template' && e !== 'webhook' && e !== 'webhook_delivery' && e !== 'user_organization');
|
|
200
|
+
let jobs = []; try { jobs = await list('scheduled_job', {}); } catch {}
|
|
201
|
+
return renderScheduledJobList(user, jobs, entityNames);
|
|
202
|
+
}
|
|
194
203
|
return null;
|
|
195
204
|
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { page } from '@/ui/layout.js';
|
|
2
|
+
import { esc } from '@/ui/render-helpers.js';
|
|
3
|
+
|
|
4
|
+
function fmtTs(ts) {
|
|
5
|
+
if (!ts) return '-';
|
|
6
|
+
return new Date(ts * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderScheduledJobList(user, jobs, entityNames) {
|
|
10
|
+
const rows = jobs.map(j => {
|
|
11
|
+
const statusPill = j.enabled ? `<span class="pill pill-success">Enabled</span>` : `<span class="pill pill-neutral">Disabled</span>`;
|
|
12
|
+
const toggleLabel = j.enabled ? 'Disable' : 'Enable';
|
|
13
|
+
return `<tr>
|
|
14
|
+
<td>${esc(j.name)}</td>
|
|
15
|
+
<td>${esc(j.entity)}</td>
|
|
16
|
+
<td>${esc(String(j.interval_minutes))} min</td>
|
|
17
|
+
<td>${statusPill}</td>
|
|
18
|
+
<td>${esc(fmtTs(j.last_run_at))}</td>
|
|
19
|
+
<td>${esc(fmtTs(j.next_run_at))}</td>
|
|
20
|
+
<td style="display:flex;gap:8px">
|
|
21
|
+
<button type="button" class="btn-ghost-clean" data-action="toggleJob" data-args='["${esc(j.id)}", ${j.enabled ? 'false' : 'true'}]'>${toggleLabel}</button>
|
|
22
|
+
<button type="button" class="btn-ghost-clean" data-action="deleteJob" data-args='["${esc(j.id)}"]'>Delete</button>
|
|
23
|
+
</td>
|
|
24
|
+
</tr>`;
|
|
25
|
+
}).join('') || '<tr><td colspan="7">No scheduled jobs</td></tr>';
|
|
26
|
+
|
|
27
|
+
const entityOpts = entityNames.map(e => `<option value="${esc(e)}">${esc(e)}</option>`).join('');
|
|
28
|
+
|
|
29
|
+
const content = `<div class="page-header"><h1 class="page-title">Scheduled Jobs</h1></div>
|
|
30
|
+
<div class="card-clean" style="margin-bottom:16px"><div class="card-clean-body">
|
|
31
|
+
<table class="data-table"><thead><tr><th>Name</th><th>Entity</th><th>Interval</th><th>Status</th><th>Last Run</th><th>Next Run</th><th></th></tr></thead><tbody>${rows}</tbody></table>
|
|
32
|
+
</div></div>
|
|
33
|
+
<div class="card-clean"><div class="card-clean-body">
|
|
34
|
+
<h3 style="margin-bottom:8px">New Scheduled Job</h3>
|
|
35
|
+
<div style="display:flex;flex-direction:column;gap:8px;max-width:480px">
|
|
36
|
+
<input type="text" id="new-job-name" placeholder="Job name" class="form-input">
|
|
37
|
+
<select id="new-job-entity" class="form-input"><option value="">Select entity...</option>${entityOpts}</select>
|
|
38
|
+
<select id="new-job-action-type" class="form-input">
|
|
39
|
+
<option value="delete">Delete matching records</option>
|
|
40
|
+
<option value="set_field">Set field on matching records</option>
|
|
41
|
+
</select>
|
|
42
|
+
<input type="text" id="new-job-field" placeholder="Field name (for set_field)" class="form-input">
|
|
43
|
+
<input type="text" id="new-job-value" placeholder="Value (for set_field)" class="form-input">
|
|
44
|
+
<input type="text" id="new-job-filter" placeholder='Filter JSON, e.g. {"status":"pending"}' class="form-input">
|
|
45
|
+
<input type="number" id="new-job-interval" placeholder="Interval (minutes)" class="form-input" min="1">
|
|
46
|
+
<button type="button" class="btn-primary-clean" data-action="createJob">Create Job</button>
|
|
47
|
+
</div>
|
|
48
|
+
</div></div>
|
|
49
|
+
<span id="job-status" style="font-size:13px"></span>`;
|
|
50
|
+
|
|
51
|
+
const script = `(function(){
|
|
52
|
+
window.createJob=function(){
|
|
53
|
+
var status=document.getElementById('job-status');
|
|
54
|
+
var name=(document.getElementById('new-job-name').value||'').trim();
|
|
55
|
+
var entity=document.getElementById('new-job-entity').value;
|
|
56
|
+
var actionType=document.getElementById('new-job-action-type').value;
|
|
57
|
+
var field=(document.getElementById('new-job-field').value||'').trim();
|
|
58
|
+
var value=document.getElementById('new-job-value').value;
|
|
59
|
+
var filterRaw=(document.getElementById('new-job-filter').value||'').trim();
|
|
60
|
+
var interval=Number(document.getElementById('new-job-interval').value);
|
|
61
|
+
if(!name){status.textContent='Name required';return}
|
|
62
|
+
if(!entity){status.textContent='Entity required';return}
|
|
63
|
+
if(!interval||interval<=0){status.textContent='Interval must be a positive number';return}
|
|
64
|
+
var filter={};
|
|
65
|
+
if(filterRaw){try{filter=JSON.parse(filterRaw)}catch(e){status.textContent='Filter must be valid JSON';return}}
|
|
66
|
+
var action=actionType==='set_field'?{type:'set_field',field:field,value:value}:{type:'delete'};
|
|
67
|
+
status.textContent='Creating...';
|
|
68
|
+
fetch('/api/scheduled_job/create',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:name,entity:entity,action:action,filter:filter,interval_minutes:interval})})
|
|
69
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
70
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'create failed')}})
|
|
71
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
72
|
+
};
|
|
73
|
+
window.toggleJob=function(id,enabled){
|
|
74
|
+
var status=document.getElementById('job-status');
|
|
75
|
+
status.textContent='Updating...';
|
|
76
|
+
fetch('/api/scheduled_job/'+id+'/update',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({enabled:enabled})})
|
|
77
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
78
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'update failed')}})
|
|
79
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
80
|
+
};
|
|
81
|
+
window.deleteJob=function(id){
|
|
82
|
+
var status=document.getElementById('job-status');
|
|
83
|
+
status.textContent='Deleting...';
|
|
84
|
+
fetch('/api/scheduled_job/'+id+'/delete',{method:'POST'})
|
|
85
|
+
.then(function(r){return r.json().then(function(d){return {ok:r.ok,d:d}})})
|
|
86
|
+
.then(function(res){if(res.ok){location.reload()}else{status.textContent='Error: '+(res.d.error||'delete failed')}})
|
|
87
|
+
.catch(function(err){status.textContent='Error: '+err.message});
|
|
88
|
+
};
|
|
89
|
+
})();`;
|
|
90
|
+
|
|
91
|
+
return page(user, 'Scheduled Jobs | Thatcher', [{ href: '/admin/settings', label: 'Settings' }, { label: 'Scheduled Jobs' }], content, [script]);
|
|
92
|
+
}
|