@goodandready/dsh-cron 0.3.24 → 0.3.26

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.
Files changed (84) hide show
  1. package/lib/api-actions.js +1 -317
  2. package/lib/api-collection.js +1 -250
  3. package/lib/api-helpers.js +1 -100
  4. package/lib/api-import-export.js +1 -155
  5. package/lib/api-item.js +1 -190
  6. package/lib/api-task-webhook.js +1 -247
  7. package/lib/api-webhook.js +1 -183
  8. package/lib/api.js +1 -178
  9. package/lib/artifacts.js +1 -200
  10. package/lib/chain-checkpoints.js +1 -223
  11. package/lib/client-src/00-open.js +62 -0
  12. package/lib/client-src/10-locales.js +484 -0
  13. package/lib/client-src/20-styles.js +64 -0
  14. package/lib/client-src/30-utils.js +248 -0
  15. package/lib/client-src/40-delivery-settings.js +96 -0
  16. package/lib/client-src/45-dag-graph-view.js +287 -0
  17. package/lib/client-src/46-live-logs-modal.js +108 -0
  18. package/lib/client-src/47-analytics-digest-modal.js +166 -0
  19. package/lib/client-src/48-timeline-heatmap-view.js +176 -0
  20. package/lib/client-src/50-cron-screen-state.js +447 -0
  21. package/lib/client-src/52-cron-screen-actions.js +330 -0
  22. package/lib/client-src/53-cron-screen-modal-actions.js +640 -0
  23. package/lib/client-src/54-cron-screen-analytics-actions.js +68 -0
  24. package/lib/client-src/55-cron-screen-view.js +378 -0
  25. package/lib/client-src/60-modal-task-form-head.js +580 -0
  26. package/lib/client-src/62-modal-task-form-tail.js +466 -0
  27. package/lib/client-src/65-modal-dialogs.js +350 -0
  28. package/lib/client-src/70-settings-card.js +420 -0
  29. package/lib/client-src/80-sidebar-jobs.js +200 -0
  30. package/lib/client-src/90-close.js +200 -0
  31. package/lib/client.js +48 -3
  32. package/lib/commands.js +200 -0
  33. package/lib/durable-journal.js +1 -188
  34. package/lib/external-api.js +1 -74
  35. package/lib/index.js +17 -0
  36. package/lib/log-streamer.js +10 -2
  37. package/lib/metrics.js +1 -125
  38. package/lib/routes/api-actions.js +317 -0
  39. package/lib/routes/api-collection.js +250 -0
  40. package/lib/routes/api-helpers.js +100 -0
  41. package/lib/routes/api-import-export.js +155 -0
  42. package/lib/routes/api-item.js +191 -0
  43. package/lib/routes/api-task-webhook.js +247 -0
  44. package/lib/routes/api-webhook.js +183 -0
  45. package/lib/routes/api.js +178 -0
  46. package/lib/routes/external-api.js +74 -0
  47. package/lib/routes/index.js +11 -0
  48. package/lib/routes/metrics.js +125 -0
  49. package/lib/routes/routes.js +549 -0
  50. package/lib/routes.js +1 -549
  51. package/lib/runner-external.js +1 -352
  52. package/lib/runner-worktree.js +1 -90
  53. package/lib/runner.js +1 -881
  54. package/lib/runtimes/index.js +4 -0
  55. package/lib/runtimes/runner-external.js +352 -0
  56. package/lib/runtimes/runner-worktree.js +90 -0
  57. package/lib/runtimes/runner.js +881 -0
  58. package/lib/runtimes/runtimes.js +198 -0
  59. package/lib/runtimes.js +1 -198
  60. package/lib/scheduler/index.js +6 -0
  61. package/lib/scheduler/scheduler-assists.js +142 -0
  62. package/lib/scheduler/scheduler-completion.js +417 -0
  63. package/lib/scheduler/scheduler-execution.js +360 -0
  64. package/lib/scheduler/scheduler-lifecycle.js +157 -0
  65. package/lib/scheduler/scheduler-preflight.js +195 -0
  66. package/lib/scheduler/scheduler.js +751 -0
  67. package/lib/scheduler-assists.js +1 -142
  68. package/lib/scheduler-completion.js +1 -417
  69. package/lib/scheduler-execution.js +1 -360
  70. package/lib/scheduler-lifecycle.js +1 -157
  71. package/lib/scheduler-preflight.js +1 -195
  72. package/lib/scheduler.js +1 -751
  73. package/lib/store/artifacts.js +200 -0
  74. package/lib/store/chain-checkpoints.js +223 -0
  75. package/lib/store/durable-journal.js +188 -0
  76. package/lib/store/index.js +6 -0
  77. package/lib/store/store.js +1179 -0
  78. package/lib/store/task-schema.js +180 -0
  79. package/lib/store/task-transfer.js +310 -0
  80. package/lib/store.js +2 -1179
  81. package/lib/task-schema.js +1 -180
  82. package/lib/task-transfer.js +1 -310
  83. package/lib/tui-settings.js +157 -0
  84. package/package.json +6 -3
@@ -0,0 +1,420 @@
1
+ function CronSettingsCard(props) {
2
+ const { ctx, toggle } = props;
3
+ const t = props.t || translate;
4
+ const [token, setToken] = React.useState('');
5
+ const [chatId, setChatId] = React.useState('');
6
+ const [notify, setNotify] = React.useState(false);
7
+ const [onlyFailure, setOnlyFailure] = React.useState(false);
8
+ const [llmActionsEnabled, setLlmActionsEnabled] = React.useState(false);
9
+ const [enableDynamicCredentials, setEnableDynamicCredentials] = React.useState(false);
10
+ const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
11
+ const [delivery, setDelivery] = React.useState({});
12
+ const [sections, setSections] = React.useState({ channels: true });
13
+ const [hasDefault, setHasDefault] = React.useState(false);
14
+ const [saved, setSaved] = React.useState(false);
15
+ const [testInfo, setTestInfo] = React.useState(null);
16
+ // Collapsed by default, head is the toggle (canonical card contract, #100)
17
+ const [cardOpen, setCardOpen] = React.useState(false);
18
+ const [updateState, setUpdateState] = React.useState({
19
+ checking: false,
20
+ updating: false,
21
+ currentVersion: '',
22
+ latestVersion: undefined,
23
+ updateAvailable: false,
24
+ notice: null,
25
+ error: null
26
+ });
27
+
28
+ const checkUpdates = async () => {
29
+ setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
30
+ try {
31
+ const { res, data } = await fetchJsonWithTimeout('/api/dsh-cron/update');
32
+ if (data && data.currentVersion) {
33
+ setUpdateState(prev => ({
34
+ ...prev,
35
+ checking: false,
36
+ currentVersion: data.currentVersion,
37
+ latestVersion: data.latestVersion,
38
+ updateAvailable: Boolean(data.updateAvailable),
39
+ error: data.latestCheckFailed ? 'Registry check failed' : null
40
+ }));
41
+ } else {
42
+ setUpdateState(prev => ({ ...prev, checking: false, error: data.error || 'Check failed' }));
43
+ }
44
+ } catch (err) {
45
+ setUpdateState(prev => ({ ...prev, checking: false, error: err.message }));
46
+ }
47
+ };
48
+
49
+ const runUpdate = async () => {
50
+ setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
51
+ try {
52
+ const { res, data } = await fetchJsonWithTimeout('/api/dsh-cron/update', {
53
+ method: 'POST',
54
+ headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
55
+ });
56
+ if (data && data.updated) {
57
+ setUpdateState(prev => ({
58
+ ...prev,
59
+ updating: false,
60
+ updateAvailable: false,
61
+ currentVersion: data.updatedVersion || data.latestVersion,
62
+ notice: T_KEY(t, 'updater.success', { version: data.updatedVersion || data.latestVersion })
63
+ }));
64
+ } else {
65
+ setUpdateState(prev => ({
66
+ ...prev,
67
+ updating: false,
68
+ error: data.error || data.message || 'Update failed'
69
+ }));
70
+ }
71
+ } catch (err) {
72
+ setUpdateState(prev => ({ ...prev, updating: false, error: err.message }));
73
+ }
74
+ };
75
+
76
+ // Settings snapshot status (#102): never render phantom inputs before
77
+ // the snapshot arrives; offer a retry when it is unavailable.
78
+ const [loadState, setLoadState] = React.useState('loading');
79
+ const [reloadTick, setReloadTick] = React.useState(0);
80
+
81
+ React.useEffect(() => {
82
+ let active = true;
83
+ setLoadState('loading');
84
+ fetchJsonWithTimeout('/dsh-cron/settings')
85
+ .then(({ data }) => {
86
+ if (!active) return;
87
+ if (data && data.ok && data.settings) {
88
+ setToken(data.settings.botToken || '');
89
+ setChatId(data.settings.chatId || '');
90
+ setNotify(Boolean(data.settings.notifyTelegram));
91
+ setOnlyFailure(Boolean(data.settings.onlyOnFailure));
92
+ setLlmActionsEnabled(Boolean(data.settings.llmActionsEnabled));
93
+ setEnableDynamicCredentials(Boolean(data.settings.enableDynamicCredentials));
94
+ setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
95
+ setDelivery(pickDeliverySettings(data.settings));
96
+ setHasDefault(Boolean(data.settings.hasDefaultCredentials));
97
+ if (data.settings.version) {
98
+ setUpdateState(prev => ({ ...prev, currentVersion: data.settings.version }));
99
+ }
100
+ setLoadState('ready');
101
+ } else {
102
+ setLoadState('error');
103
+ }
104
+ })
105
+ .catch(() => { if (active) setLoadState('error'); });
106
+ return () => { active = false; };
107
+ }, [reloadTick]);
108
+
109
+ const saveSettings = async (e) => {
110
+ if (e) e.preventDefault();
111
+ try {
112
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/settings', {
113
+ method: 'POST',
114
+ headers: { 'Content-Type': 'application/json' },
115
+ body: JSON.stringify(Object.assign({
116
+ botToken: token,
117
+ chatId,
118
+ notifyTelegram: notify,
119
+ onlyOnFailure: onlyFailure,
120
+ llmActionsEnabled: llmActionsEnabled,
121
+ enableDynamicCredentials: enableDynamicCredentials,
122
+ kanbanBaseUrl: kanbanUrl
123
+ }, delivery))
124
+ });
125
+ if (data && data.ok) {
126
+ setSaved(true);
127
+ setTimeout(() => setSaved(false), 2500);
128
+ }
129
+ } catch (err) {
130
+ console.debug('[dsh-cron] save settings failed', err);
131
+ }
132
+ };
133
+
134
+ const testTelegram = async () => {
135
+ setTestInfo({ loading: true, message: T_KEY(t, 'settings.sending') });
136
+ try {
137
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/telegram/test', {
138
+ method: 'POST',
139
+ headers: { 'Content-Type': 'application/json' },
140
+ body: JSON.stringify({ botToken: token, chatId })
141
+ });
142
+ if (data && data.ok) {
143
+ setTestInfo({ success: true, message: T_KEY(t, 'settings.testOk') });
144
+ } else {
145
+ setTestInfo({ success: false, message: T_KEY(t, 'settings.testFail', { error: (data && data.error) || '' }) });
146
+ }
147
+ } catch (err) {
148
+ setTestInfo({ success: false, message: T_KEY(t, 'settings.testFail', { error: err.message }) });
149
+ }
150
+ };
151
+
152
+ const testKanban = async () => {
153
+ setTestInfo({ loading: true, message: T_KEY(t, 'settings.checkingKanban') });
154
+ try {
155
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/kanban/test', {
156
+ method: 'POST',
157
+ headers: { 'Content-Type': 'application/json' },
158
+ body: JSON.stringify({ kanbanBaseUrl: kanbanUrl })
159
+ });
160
+ if (data && data.ok) {
161
+ setTestInfo({ success: true, message: T_KEY(t, 'settings.kanbanOk') });
162
+ } else {
163
+ setTestInfo({ success: false, message: T_KEY(t, 'settings.kanbanFail', { error: (data && data.error) || '' }) });
164
+ }
165
+ } catch (err) {
166
+ setTestInfo({ success: false, message: T_KEY(t, 'settings.kanbanFail', { error: err.message }) });
167
+ }
168
+ };
169
+
170
+ if (loadState !== 'ready') {
171
+ return React.createElement('div', { className: 'dsh-cron-card' },
172
+ React.createElement('div', { className: 'dsh-cron-card-title' }, T_KEY(t, 'settings.cardTitle')),
173
+ React.createElement('div', {
174
+ className: 'dsh-cron-status-banner',
175
+ 'data-type': loadState === 'error' ? 'error' : 'info',
176
+ style: { marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px' }
177
+ },
178
+ React.createElement('span', null, loadState === 'error' ? T_KEY(t, 'settings.unavailable') : T_KEY(t, 'settings.loading')),
179
+ loadState === 'error' && React.createElement('button', {
180
+ type: 'button',
181
+ className: 'dsh-cron-btn-secondary',
182
+ onClick: () => setReloadTick(x => x + 1)
183
+ }, T_KEY(t, 'settings.retry'))
184
+ )
185
+ );
186
+ }
187
+
188
+ // Row seat (plugins.row.config): the host page draws title/icon/crumb and the
189
+ // padding, so the summary is a one-liner and the page drops our card chrome.
190
+ if (props && props.view === 'summary') {
191
+ return React.createElement('span', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc'));
192
+ }
193
+ const page = !!(props && props.view === 'page');
194
+ return React.createElement('div', { className: page ? 'dsh-cron-page' : 'dsh-cron-card' },
195
+ React.createElement('button', {
196
+ type: 'button',
197
+ className: 'dsh-cron-card-head-btn',
198
+ style: page ? { display: 'none' } : undefined,
199
+ 'aria-expanded': (page || cardOpen) ? 'true' : 'false',
200
+ onClick: () => setCardOpen(!cardOpen)
201
+ },
202
+ React.createElement('span', { className: 'dsh-cron-card-title' }, T_KEY(t, 'settings.cardTitle')),
203
+ React.createElement('span', { className: 'dsh-cron-chev' + (cardOpen ? ' dsh-cron-chev-open' : '') }, chevronNode())
204
+ ),
205
+ (page || cardOpen) && React.createElement('div', { className: 'dsh-cron-card-body' },
206
+ React.createElement('div', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc')),
207
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' } },
208
+ React.createElement('button', {
209
+ type: 'button',
210
+ className: 'dsh-cron-btn-primary',
211
+ onClick: () => { if (toggle) toggle.set(true); }
212
+ }, T_KEY(t, 'settings.openPanel'))
213
+ ),
214
+ React.createElement('form', { onSubmit: saveSettings, style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
215
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
216
+ React.createElement('label', null, T_KEY(t, 'settings.tokenLabel')),
217
+ React.createElement('input', {
218
+ type: 'password',
219
+ value: token,
220
+ placeholder: hasDefault ? T_KEY(t, 'settings.tokenPlaceholderDefault') : T_KEY(t, 'settings.tokenPlaceholder'),
221
+ onChange: (e) => setToken(e.target.value)
222
+ }),
223
+ hasDefault && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'settings.defaultHint'))
224
+ ),
225
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
226
+ React.createElement('label', null, T_KEY(t, 'settings.chatIdLabel')),
227
+ React.createElement('input', {
228
+ value: chatId,
229
+ placeholder: T_KEY(t, 'settings.chatIdPlaceholder'),
230
+ onChange: (e) => setChatId(e.target.value)
231
+ })
232
+ ),
233
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
234
+ React.createElement('label', null, T_KEY(t, 'settings.kanbanUrlLabel')),
235
+ React.createElement('input', {
236
+ value: kanbanUrl,
237
+ placeholder: 'http://127.0.0.1:3000',
238
+ onChange: (e) => setKanbanUrl(e.target.value)
239
+ })
240
+ ),
241
+ React.createElement('div', { style: { background: 'var(--dsw-alias-bg-layer-3, color-mix(in srgb, var(--dsw-alias-label-primary) 2%, transparent))', border: '1px solid var(--dsw-alias-border-l2)', borderRadius: '6px', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: '8px' } },
242
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
243
+ React.createElement('input', {
244
+ type: 'checkbox',
245
+ checked: notify,
246
+ onChange: (e) => setNotify(e.target.checked)
247
+ }),
248
+ React.createElement('span', null, T_KEY(t, 'settings.globalNotify'))
249
+ ),
250
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
251
+ React.createElement('input', {
252
+ type: 'checkbox',
253
+ checked: onlyFailure,
254
+ onChange: (e) => setOnlyFailure(e.target.checked)
255
+ }),
256
+ React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
257
+ ),
258
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
259
+ React.createElement('input', {
260
+ type: 'checkbox',
261
+ checked: llmActionsEnabled,
262
+ onChange: (e) => setLlmActionsEnabled(e.target.checked)
263
+ }),
264
+ React.createElement('span', null, 'Enable Structured LLM Actions (trigger tasks, notify, create issues)')
265
+ ),
266
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
267
+ React.createElement('input', {
268
+ type: 'checkbox',
269
+ checked: enableDynamicCredentials,
270
+ onChange: (e) => setEnableDynamicCredentials(e.target.checked)
271
+ }),
272
+ React.createElement('span', null, T_KEY(t, 'settings.dynamicCredentials'))
273
+ )
274
+ ),
275
+ ...renderDeliverySettings({
276
+ T: (key, vars) => T_KEY(t, key, vars),
277
+ values: delivery,
278
+ onChange: (key, value) => setDelivery((prev) => {
279
+ const next = Object.assign({}, prev);
280
+ if (value === undefined) delete next[key];
281
+ else next[key] = value;
282
+ return next;
283
+ }),
284
+ onChannelTemplate: (channelId, value) => setDelivery((prev) => {
285
+ const templates = Object.assign({}, prev.channelTemplates || {});
286
+ if (value && value.trim()) templates[channelId] = value;
287
+ else delete templates[channelId];
288
+ return Object.assign({}, prev, { channelTemplates: templates });
289
+ }),
290
+ sections,
291
+ onToggleSection: (key) => setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
292
+ }),
293
+ testInfo && testInfo.message && React.createElement('div', {
294
+ className: 'dsh-cron-status-banner',
295
+ 'data-type': testInfo.success ? 'success' : 'error',
296
+ style: { margin: 0, padding: '8px 12px' }
297
+ }, testInfo.message),
298
+ React.createElement('div', {
299
+ className: 'dsh-cron-form-group',
300
+ style: {
301
+ background: 'var(--dsw-alias-bg-layer-2, color-mix(in srgb, var(--dsw-alias-label-primary) 3%, transparent))',
302
+ border: '1px solid var(--dsw-alias-border-l2, color-mix(in srgb, var(--dsw-alias-label-primary) 8%, transparent))',
303
+ borderRadius: '8px',
304
+ padding: '14px',
305
+ marginBottom: '16px'
306
+ }
307
+ },
308
+ React.createElement('div', {
309
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }
310
+ },
311
+ React.createElement('div', { style: { fontWeight: '600', fontSize: '14px' } }, `🚀 ${T_KEY(t, 'updater.title')}`),
312
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
313
+ React.createElement('button', {
314
+ type: 'button',
315
+ className: 'dsh-cron-btn-secondary',
316
+ style: { padding: '4px 10px', fontSize: '12px' },
317
+ disabled: updateState.checking || updateState.updating,
318
+ onClick: checkUpdates
319
+ }, updateState.checking ? T_KEY(t, 'updater.checking') : T_KEY(t, 'updater.btnCheck')),
320
+ updateState.updateAvailable ? React.createElement('button', {
321
+ type: 'button',
322
+ className: 'dsh-cron-btn-primary',
323
+ style: { padding: '4px 10px', fontSize: '12px' },
324
+ disabled: updateState.updating,
325
+ onClick: runUpdate
326
+ }, updateState.updating ? T_KEY(t, 'updater.updating') : T_KEY(t, 'updater.btnUpdate', { version: updateState.latestVersion })) : null
327
+ )
328
+ ),
329
+ React.createElement('div', {
330
+ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #999)', marginBottom: '8px' }
331
+ }, T_KEY(t, 'updater.desc')),
332
+ React.createElement('div', {
333
+ style: { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '13px' }
334
+ },
335
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #aaa)' } },
336
+ updateState.currentVersion ? T_KEY(t, 'updater.current', { version: updateState.currentVersion }) : ''
337
+ ),
338
+ updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-info' }, T_KEY(t, 'updater.checking')) :
339
+ updateState.updateAvailable ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-warn' }, T_KEY(t, 'updater.available', { version: updateState.latestVersion })) :
340
+ updateState.currentVersion && !updateState.error && !updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-ok' }, `✓ ${T_KEY(t, 'updater.upToDate')}`) : null
341
+ ),
342
+ updateState.notice ? React.createElement('div', {
343
+ style: { marginTop: '8px', color: 'var(--dsw-alias-success)', fontSize: '12px' }
344
+ }, updateState.notice) : null,
345
+ updateState.error ? React.createElement('div', {
346
+ style: { marginTop: '8px', color: 'var(--dsw-alias-error)', fontSize: '12px' }
347
+ }, updateState.error) : null
348
+ ),
349
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
350
+ React.createElement('button', {
351
+ type: 'submit',
352
+ className: 'dsh-cron-btn-primary',
353
+ style: { minWidth: '140px' }
354
+ }, saved ? T_KEY(t, 'settings.saved') : T_KEY(t, 'settings.save')),
355
+ React.createElement('button', {
356
+ type: 'button',
357
+ className: 'dsh-cron-btn-secondary',
358
+ onClick: testTelegram,
359
+ disabled: testInfo?.loading
360
+ }, T_KEY(t, 'settings.testTelegram')),
361
+ React.createElement('button', {
362
+ type: 'button',
363
+ className: 'dsh-cron-btn-secondary',
364
+ onClick: testKanban,
365
+ disabled: testInfo?.loading
366
+ }, T_KEY(t, 'settings.testKanban'))
367
+ )
368
+ )
369
+ )
370
+ );
371
+ }
372
+
373
+ function nativeButton() {
374
+ const nodes = document.querySelectorAll('button[class*="newSession"]');
375
+ for (const node of nodes) {
376
+ if (String(node.className).indexOf('newSessionLabel') === -1) return node;
377
+ }
378
+ return undefined;
379
+ }
380
+
381
+ function createEntry(target, toggle, label) {
382
+ const entry = target.cloneNode(true);
383
+ entry.className = String(target.className) + ' dsh-cron-entry-clone';
384
+ entry.setAttribute(ENTRY_ATTR, '');
385
+ entry.setAttribute('data-dsh-plugin', 'dsh-cron');
386
+ entry.setAttribute('data-dsh-part', 'sidebar-entry');
387
+ entry.setAttribute('aria-label', label);
388
+ entry.removeAttribute('id');
389
+ entry.innerHTML = '';
390
+
391
+ const icon = document.createElement('span');
392
+ icon.className = 'dsh-cron-entry-icon';
393
+ icon.innerHTML = ICON_TIMER;
394
+ entry.appendChild(icon);
395
+
396
+ const text = document.createElement('span');
397
+ text.className = 'dsh-cron-entry-label';
398
+ text.textContent = label;
399
+ entry.appendChild(text);
400
+
401
+ entry.addEventListener('click', (event) => {
402
+ event.preventDefault();
403
+ event.stopPropagation();
404
+ toggle.toggle();
405
+ });
406
+ return entry;
407
+ }
408
+
409
+ /**
410
+ * Collapsible list of active jobs under the sidebar entry (#34).
411
+ *
412
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
413
+ * Collapsed by default, state persisted, capped at a few rows with an
414
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
415
+ * off screen. Clicking a row opens the panel and highlights that task.
416
+ */
417
+ const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
418
+ const SIDEBAR_JOBS_MAX_ROWS = 5;
419
+
420
+ /** Sidebar job row: title plus next run time or the live running state. */
@@ -0,0 +1,200 @@
1
+ function buildSidebarJobRow(state, task) {
2
+ const row = document.createElement('button');
3
+ row.type = 'button';
4
+ row.className = 'dsh-cron-sidebar-job';
5
+ // A distinct attribute: the panel uses data-task-id, and the highlight
6
+ // lookup must never pick a sidebar row instead of a task card.
7
+ row.setAttribute('data-sidebar-task-id', task.id);
8
+ row.setAttribute('aria-label', task.title || task.id);
9
+
10
+ const title = document.createElement('span');
11
+ title.className = 'dsh-cron-sidebar-job-title';
12
+ title.textContent = task.title || task.id;
13
+ row.appendChild(title);
14
+
15
+ const meta = document.createElement('span');
16
+ meta.className = 'dsh-cron-sidebar-job-meta';
17
+ if (task.running) meta.textContent = T('list.runningNow');
18
+ else if (task.nextRunAt) meta.textContent = new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
19
+ else meta.textContent = task.scheduleText || task.schedule || '';
20
+ row.appendChild(meta);
21
+
22
+ if (task.running) row.classList.add('dsh-cron-pulse');
23
+ row.addEventListener('click', (event) => {
24
+ event.preventDefault();
25
+ event.stopPropagation();
26
+ state.openTaskSignal(task.id);
27
+ if (!state.toggle.isOpen()) state.toggle.toggle();
28
+ });
29
+ return row;
30
+ }
31
+
32
+ function renderSidebarJobs(state, tasks) {
33
+ const listEl = state.listEl;
34
+ if (!listEl) return;
35
+ listEl.innerHTML = '';
36
+ if (!tasks.length) {
37
+ const empty = document.createElement('div');
38
+ empty.className = 'dsh-cron-sidebar-empty';
39
+ empty.textContent = T('sidebar.noActiveJobs');
40
+ listEl.appendChild(empty);
41
+ return;
42
+ }
43
+ for (const task of tasks.slice(0, SIDEBAR_JOBS_MAX_ROWS)) listEl.appendChild(buildSidebarJobRow(state, task));
44
+ if (tasks.length > SIDEBAR_JOBS_MAX_ROWS) {
45
+ const more = document.createElement('div');
46
+ more.className = 'dsh-cron-sidebar-more';
47
+ more.textContent = T('sidebar.moreJobs', { count: tasks.length - SIDEBAR_JOBS_MAX_ROWS });
48
+ listEl.appendChild(more);
49
+ }
50
+ }
51
+
52
+ async function loadSidebarJobs(state) {
53
+ try {
54
+ const { res, data } = await fetchJsonWithTimeout('/dsh-cron/tasks?status=active');
55
+ if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
56
+ } catch (err) {
57
+ console.debug('[dsh-cron] load sidebar jobs failed', err);
58
+ }
59
+ }
60
+
61
+ function applySidebarJobsOpen(state) {
62
+ if (!state.root) return;
63
+ state.root.dataset.open = state.open ? 'true' : 'false';
64
+ if (state.headEl) state.headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
65
+ if (state.open) loadSidebarJobs(state);
66
+ }
67
+
68
+ function buildSidebarJobsRoot(state, label) {
69
+ const root = document.createElement('div');
70
+ root.className = 'dsh-cron-sidebar-jobs';
71
+ root.setAttribute('data-dsh-plugin', 'dsh-cron');
72
+ root.setAttribute('data-dsh-part', 'sidebar-jobs');
73
+
74
+ const headEl = document.createElement('button');
75
+ headEl.type = 'button';
76
+ headEl.className = 'dsh-cron-sidebar-jobs-head';
77
+ headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
78
+ headEl.textContent = label;
79
+ headEl.addEventListener('click', (event) => {
80
+ event.preventDefault();
81
+ event.stopPropagation();
82
+ state.open = !state.open;
83
+ try { window.localStorage.setItem(SIDEBAR_JOBS_KEY, state.open ? '1' : '0'); } catch (err) { /* localStorage write failed */ }
84
+ applySidebarJobsOpen(state);
85
+ });
86
+ root.appendChild(headEl);
87
+
88
+ const listEl = document.createElement('div');
89
+ listEl.className = 'dsh-cron-sidebar-jobs-list';
90
+ root.appendChild(listEl);
91
+
92
+ state.root = root;
93
+ state.headEl = headEl;
94
+ state.listEl = listEl;
95
+ return root;
96
+ }
97
+
98
+ /** Keep the section mounted under the sidebar entry across re-renders. */
99
+ function placeSidebarJobs(state, label) {
100
+ if (state.root !== undefined && state.root.isConnected) return;
101
+ const entry = document.querySelector('[' + ENTRY_ATTR + ']');
102
+ if (entry === null || entry.parentElement === null) return;
103
+ if (state.root === undefined) {
104
+ entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
105
+ } else {
106
+ entry.insertAdjacentElement('afterend', state.root);
107
+ // Re-query the refs: the previous nodes may belong to a detached tree.
108
+ state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
109
+ state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
110
+ state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
111
+ }
112
+ applySidebarJobsOpen(state);
113
+ }
114
+
115
+ /**
116
+ * Collapsible list of active jobs under the sidebar entry (#34).
117
+ *
118
+ * Vanilla DOM like the entry itself: the sidebar is not a React root here.
119
+ * Collapsed by default, state persisted, capped at a few rows with an
120
+ * "and N more" line so a busy schedule cannot push the rest of the sidebar
121
+ * off screen. Clicking a row opens the panel and highlights that task.
122
+ */
123
+ function mountSidebarJobs(toggle, label, openTaskSignal) {
124
+ if (typeof document === 'undefined') return () => {};
125
+ const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
126
+ try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (err) { /* localStorage read failed */ }
127
+
128
+ let rafPending = false;
129
+ const schedulePlace = () => {
130
+ if (state.root !== undefined && state.root.isConnected) return;
131
+ if (rafPending) return;
132
+ rafPending = true;
133
+ const scheduleFn = typeof requestAnimationFrame === 'function' ? requestAnimationFrame : (cb) => setTimeout(cb, 16);
134
+ scheduleFn(() => {
135
+ rafPending = false;
136
+ placeSidebarJobs(state, label);
137
+ });
138
+ };
139
+
140
+ const observer = new MutationObserver(schedulePlace);
141
+ observer.observe(document.body, { childList: true, subtree: true });
142
+ placeSidebarJobs(state, label);
143
+ loadSidebarJobs(state);
144
+ const fetchTimer = setInterval(() => {
145
+ let isOpen = false;
146
+ try { isOpen = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (err) { /* localStorage poll failed */ }
147
+ if (isOpen) loadSidebarJobs(state);
148
+ }, 30000);
149
+
150
+ return () => {
151
+ observer.disconnect();
152
+ clearInterval(fetchTimer);
153
+ if (state.root !== undefined && state.root.isConnected) state.root.remove();
154
+ state.root = undefined;
155
+ };
156
+ }
157
+
158
+ function placeEntry(target, entry) {
159
+ let anchor = target;
160
+ let next = target.nextElementSibling;
161
+ while (next !== null && next.tagName === 'BUTTON'
162
+ && String(next.className).indexOf('sidebar-entry') >= 0) {
163
+ anchor = next;
164
+ next = next.nextElementSibling;
165
+ }
166
+ anchor.insertAdjacentElement('afterend', entry);
167
+ }
168
+
169
+ function mountSidebarEntry(toggle, label) {
170
+ if (typeof document === 'undefined') return () => {};
171
+ let entry;
172
+ const tryPlace = () => {
173
+ if (entry !== undefined && entry.isConnected) return;
174
+ const target = nativeButton();
175
+ if (target === undefined || target.parentElement === null) return;
176
+ if (target.parentElement.querySelector('[' + ENTRY_ATTR + ']') !== null) return;
177
+ entry = createEntry(target, toggle, label);
178
+ placeEntry(target, entry);
179
+ syncActive();
180
+ };
181
+
182
+ const syncActive = () => {
183
+ if (entry === undefined) return;
184
+ if (toggle.isOpen()) entry.dataset.active = 'true';
185
+ else delete entry.dataset.active;
186
+ };
187
+
188
+ const observer = new MutationObserver(tryPlace);
189
+ observer.observe(document.body, { childList: true, subtree: true });
190
+ const unsubscribe = toggle.subscribe(syncActive);
191
+ tryPlace();
192
+
193
+ return () => {
194
+ observer.disconnect();
195
+ unsubscribe();
196
+ if (entry !== undefined) entry.remove();
197
+ entry = undefined;
198
+ };
199
+ }
200
+