@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,447 @@
1
+ function CronScreen(props) {
2
+ const { ctx, toggle, onClose } = props;
3
+ const t = props.t || translate;
4
+ const [isOpen, setIsOpen] = React.useState(toggle && typeof toggle.isOpen === 'function' ? toggle.isOpen() : true);
5
+ React.useEffect(() => {
6
+ if (!toggle || typeof toggle.subscribe !== 'function') return;
7
+ return toggle.subscribe((val) => setIsOpen(val));
8
+ }, [toggle]);
9
+ if (!isOpen) return null;
10
+ const [tab, setTab] = React.useState('all');
11
+ const [query, setQuery] = React.useState('');
12
+ // Facet filters (#40): applied client-side over the already-loaded list so
13
+ // toggling them is instant and does not touch the polling request.
14
+ const [filterType, setFilterType] = React.useState('');
15
+ const [filterModel, setFilterModel] = React.useState('');
16
+ const [filterChannel, setFilterChannel] = React.useState('');
17
+ const [filtersOpen, setFiltersOpen] = React.useState(false);
18
+ const [tasks, setTasks] = React.useState([]);
19
+ const [recs, setRecs] = React.useState([]);
20
+ const [loading, setLoading] = React.useState(false);
21
+ const [fetchError, setFetchError] = React.useState(null);
22
+ const [dropdownOpen, setDropdownOpen] = React.useState(false);
23
+
24
+ // Export/import state (#42)
25
+ const [transferBusy, setTransferBusy] = React.useState(false);
26
+ const [importDoc, setImportDoc] = React.useState(null);
27
+ const [importSummary, setImportSummary] = React.useState(null);
28
+ const [importStrategy, setImportStrategy] = React.useState('skip');
29
+ const importFileRef = React.useRef(null);
30
+
31
+ // Manual / Edit modal state
32
+ const [manualModalOpen, setManualModalOpen] = React.useState(false);
33
+ const [formId, setFormId] = React.useState(null);
34
+ const [formTitle, setFormTitle] = React.useState('');
35
+ const [formSchedule, setFormSchedule] = React.useState('');
36
+ const [formType, setFormType] = React.useState('llm');
37
+ const [formPrompt, setFormPrompt] = React.useState('');
38
+ const [formProvider, setFormProvider] = React.useState('');
39
+ const [formModel, setFormModel] = React.useState('');
40
+ const [formFallbackModel, setFormFallbackModel] = React.useState('');
41
+ const [formSilentRule, setFormSilentRule] = React.useState('');
42
+ const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
43
+ const [formRequireApproval, setFormRequireApproval] = React.useState(false);
44
+ const [formDelivery, setFormDelivery] = React.useState('isolated');
45
+ const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
46
+ const [modalTab, setModalTab] = React.useState('params');
47
+ const [taskHistory, setTaskHistory] = React.useState([]);
48
+ const [historyLoading, setHistoryLoading] = React.useState(false);
49
+ const [retryingDeliveryId, setRetryingDeliveryId] = React.useState(null);
50
+ const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
51
+ const [formChannels, setFormChannels] = React.useState([]);
52
+ const [formTemplate, setFormTemplate] = React.useState('');
53
+ const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
54
+ const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
55
+ const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
56
+ const [formKanbanMode, setFormKanbanMode] = React.useState('none');
57
+ const [formOnSuccess, setFormOnSuccess] = React.useState('');
58
+ const [formOnFailure, setFormOnFailure] = React.useState('');
59
+ const [formDependsOn, setFormDependsOn] = React.useState('');
60
+ const [formJoinPolicy, setFormJoinPolicy] = React.useState('all_success');
61
+ const [formElseTask, setFormElseTask] = React.useState('');
62
+ const [formAutoHeal, setFormAutoHeal] = React.useState(false);
63
+ // Adaptive polling state (#329)
64
+ const [formAdaptiveEnabled, setFormAdaptiveEnabled] = React.useState(false);
65
+ const [formAdaptiveMinInterval, setFormAdaptiveMinInterval] = React.useState('5m');
66
+ const [formAdaptiveMaxInterval, setFormAdaptiveMaxInterval] = React.useState('2h');
67
+ const [formAdaptiveBackoffFactor, setFormAdaptiveBackoffFactor] = React.useState('1.5');
68
+ const [formAdaptiveIdleThreshold, setFormAdaptiveIdleThreshold] = React.useState('3');
69
+
70
+ // Live Logs state (#336)
71
+ const [liveLogsModalOpen, setLiveLogsModalOpen] = React.useState(false);
72
+ const [liveLogsTaskId, setLiveLogsTaskId] = React.useState('');
73
+ const [liveLogsTaskTitle, setLiveLogsTaskTitle] = React.useState('');
74
+ const [liveLogsLines, setLiveLogsLines] = React.useState([]);
75
+ const [liveLogsStatus, setLiveLogsStatus] = React.useState('idle');
76
+ const [liveLogsCompleted, setLiveLogsCompleted] = React.useState(false);
77
+ const [liveLogsAutoScroll, setLiveLogsAutoScroll] = React.useState(true);
78
+ const [chainGraphModalOpen, setChainGraphModalOpen] = React.useState(false);
79
+ const [chainGraphData, setChainGraphData] = React.useState(null);
80
+ const [chainGraphLoading, setChainGraphLoading] = React.useState(false);
81
+ const [chainGraphRunId, setChainGraphRunId] = React.useState(null);
82
+ // Analytics Digest state (#330, GH #8)
83
+ const [digestOpen, setDigestOpen] = React.useState(false);
84
+ const [digestData, setDigestData] = React.useState(null);
85
+ const [digestMarkdown, setDigestMarkdown] = React.useState('');
86
+ const [digestLoading, setDigestLoading] = React.useState(false);
87
+ const [periodDays, setPeriodDays] = React.useState(7);
88
+ const [sendStatus, setSendStatus] = React.useState(null);
89
+
90
+ // Timeline & Cost Heatmap state (#332, #333, GH #8)
91
+ const [timelineOpen, setTimelineOpen] = React.useState(false);
92
+ const [timelineData, setTimelineData] = React.useState(null);
93
+ const [heatmapData, setHeatmapData] = React.useState(null);
94
+ const [timelineLoading, setTimelineLoading] = React.useState(false);
95
+ const [timelineTab, setTimelineTab] = React.useState('timeline');
96
+
97
+ const [formHeartbeatIntervalSeconds, setFormHeartbeatIntervalSeconds] = React.useState(0);
98
+ const [formGracePeriodSeconds, setFormGracePeriodSeconds] = React.useState(300);
99
+ const [formPreflightType, setFormPreflightType] = React.useState('none');
100
+ const [formPreflightTarget, setFormPreflightTarget] = React.useState('');
101
+ const [formPriority, setFormPriority] = React.useState(5);
102
+ const [formConcurrencyGroup, setFormConcurrencyGroup] = React.useState('default');
103
+ const [formSelfHealingCommand, setFormSelfHealingCommand] = React.useState('');
104
+ const [formAutoDiagnose, setFormAutoDiagnose] = React.useState(false);
105
+ const [schedulePreviewRuns, setSchedulePreviewRuns] = React.useState(null);
106
+ const [dryRunResult, setDryRunResult] = React.useState(null);
107
+ const [archiveModalOpen, setArchiveModalOpen] = React.useState(false);
108
+ const [archiveTask, setArchiveTask] = React.useState(null);
109
+ const [archiveRuns, setArchiveRuns] = React.useState([]);
110
+ const [archiveLoading, setArchiveLoading] = React.useState(false);
111
+ const [archiveOffset, setArchiveOffset] = React.useState(0);
112
+ const [archiveTotal, setArchiveTotal] = React.useState(0);
113
+ const [formTimezone, setFormTimezone] = React.useState('');
114
+ const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
115
+ const [formMaxRetries, setFormMaxRetries] = React.useState(0);
116
+ const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
117
+ const [formAgentPreset, setFormAgentPreset] = React.useState('');
118
+ const [formSessionTitle, setFormSessionTitle] = React.useState('');
119
+ const [formTargetSessionId, setFormTargetSessionId] = React.useState('');
120
+ const [formTargetSessionReset, setFormTargetSessionReset] = React.useState('never');
121
+ const [formEnv, setFormEnv] = React.useState('');
122
+ const [formCwd, setFormCwd] = React.useState('');
123
+ const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
124
+ const [formWorktree, setFormWorktree] = React.useState(false);
125
+ const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
126
+ const [formCaptureGitDiff, setFormCaptureGitDiff] = React.useState(false);
127
+ const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
128
+ const [formHttpUrl, setFormHttpUrl] = React.useState('');
129
+ const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
130
+ const [formHttpBody, setFormHttpBody] = React.useState('');
131
+ const [formSshProfileId, setFormSshProfileId] = React.useState('');
132
+ const [formSshTarget, setFormSshTarget] = React.useState('');
133
+ const [formSshKeyPath, setFormSshKeyPath] = React.useState('');
134
+ const [formDockerImage, setFormDockerImage] = React.useState('');
135
+ const [formNodePath, setFormNodePath] = React.useState('');
136
+ const [formPythonPath, setFormPythonPath] = React.useState('');
137
+ const [formSkillName, setFormSkillName] = React.useState('');
138
+ const [formWorkflowName, setFormWorkflowName] = React.useState('');
139
+ const [stats, setStats] = React.useState(null);
140
+
141
+ const initialFormSnapshotRef = React.useRef(null);
142
+
143
+ const getFormSnapshot = () => JSON.stringify({
144
+ title: String(formTitle || '').trim(),
145
+ schedule: String(formSchedule || '').trim(),
146
+ type: String(formType || 'llm'),
147
+ prompt: String(formPrompt || '').trim(),
148
+ provider: String(formProvider || ''),
149
+ model: String(formModel || ''),
150
+ fallbackModel: String(formFallbackModel || ''),
151
+ silentRule: String(formSilentRule || ''),
152
+ inspectOnFailure: Boolean(formInspectOnFailure),
153
+ requireApproval: Boolean(formRequireApproval),
154
+ delivery: String(formDelivery || 'isolated'),
155
+ notifyTelegram: Boolean(formNotifyTelegram),
156
+ channels: Array.isArray(formChannels) ? [...formChannels].sort() : [],
157
+ template: String(formTemplate || '').trim(),
158
+ onlyOnFailure: Boolean(formOnlyOnFailure),
159
+ timeoutSeconds: Number(formTimeoutSeconds) || 1800,
160
+ overlapPolicy: String(formOverlapPolicy || 'skip'),
161
+ kanbanMode: String(formKanbanMode || 'none'),
162
+ onSuccess: String(formOnSuccess || '').trim(),
163
+ onFailure: String(formOnFailure || '').trim(),
164
+ heartbeatIntervalSeconds: Number(formHeartbeatIntervalSeconds) || 0,
165
+ gracePeriodSeconds: Number(formGracePeriodSeconds) || 300,
166
+ preflightType: String(formPreflightType || 'none'),
167
+ preflightTarget: String(formPreflightTarget || '').trim(),
168
+ priority: Number(formPriority) || 5,
169
+ concurrencyGroup: String(formConcurrencyGroup || 'default').trim(),
170
+ selfHealingCommand: String(formSelfHealingCommand || '').trim(),
171
+ autoDiagnose: Boolean(formAutoDiagnose),
172
+ timezone: String(formTimezone || '').trim(),
173
+ misfirePolicy: String(formMisfirePolicy || 'skip'),
174
+ maxRetries: Number(formMaxRetries) || 0,
175
+ permissionPreset: String(formPermissionPreset || 'default'),
176
+ agentPreset: String(formAgentPreset || '').trim(),
177
+ targetSessionId: String(formTargetSessionId || '').trim(),
178
+ targetSessionReset: String(formTargetSessionReset || 'never'),
179
+ env: String(formEnv || '').trim(),
180
+ cwd: String(formCwd || '').trim(),
181
+ workspaceId: String(formWorkspaceId || '').trim(),
182
+ worktree: Boolean(formWorktree),
183
+ keepWorktree: Boolean(formKeepWorktree),
184
+ captureGitDiff: Boolean(formCaptureGitDiff),
185
+ httpMethod: String(formHttpMethod || 'GET'),
186
+ httpUrl: String(formHttpUrl || '').trim(),
187
+ httpHttpHeaders: String(formHttpHeaders || '').trim(),
188
+ httpBody: String(formHttpBody || '').trim(),
189
+ sshProfileId: String(formSshProfileId || '').trim(),
190
+ sshTarget: String(formSshTarget || '').trim(),
191
+ sshKeyPath: String(formSshKeyPath || '').trim(),
192
+ dockerImage: String(formDockerImage || '').trim(),
193
+ nodePath: String(formNodePath || '').trim(),
194
+ pythonPath: String(formPythonPath || '').trim(),
195
+ skillName: String(formSkillName || '').trim(),
196
+ workflowName: String(formWorkflowName || '').trim(),
197
+ });
198
+
199
+ const isFormDirty = () => {
200
+ if (!manualModalOpen) return false;
201
+ if (initialFormSnapshotRef.current !== null) {
202
+ return getFormSnapshot() !== initialFormSnapshotRef.current;
203
+ }
204
+ return Boolean(formTitle.trim() || formPrompt.trim());
205
+ };
206
+
207
+ const closeManualModalWithConfirm = () => {
208
+ if (isFormDirty()) {
209
+ if (!confirm(T_KEY(t, 'modal.confirmDiscard') || 'Discard unsaved changes?')) {
210
+ return;
211
+ }
212
+ }
213
+ initialFormSnapshotRef.current = null;
214
+ setManualModalOpen(false);
215
+ };
216
+
217
+ // Live execution indicator (#35): a light 1s tick only while some task
218
+ // is actually running.
219
+ const tasksRef = React.useRef([]);
220
+ const [nowTick, setNowTick] = React.useState(0);
221
+ React.useEffect(() => {
222
+ tasksRef.current = tasks;
223
+ }, [tasks]);
224
+ React.useEffect(() => {
225
+ const tick = setInterval(() => {
226
+ if (tasksRef.current.some((t2) => t2.running)) setNowTick((v) => v + 1);
227
+ }, 1000);
228
+ return () => clearInterval(tick);
229
+ }, []);
230
+
231
+ // Settings modal state
232
+ const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
233
+ const [settingsBotToken, setSettingsBotToken] = React.useState('');
234
+ const [settingsChatId, setSettingsChatId] = React.useState('');
235
+ const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
236
+ const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
237
+ const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
238
+ const [settingsDelivery, setSettingsDelivery] = React.useState({});
239
+ const [settingsSections, setSettingsSections] = React.useState({ channels: true });
240
+ const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
241
+ const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
242
+ const [testStatus, setTestStatus] = React.useState(null);
243
+
244
+ // DSH dialog state
245
+ const [dshModalOpen, setDshModalOpen] = React.useState(false);
246
+ const [dshPrompt, setDshPrompt] = React.useState('');
247
+ const [dshLoading, setDshLoading] = React.useState(false);
248
+
249
+ // Models list state
250
+ const [providers, setProviders] = React.useState([]);
251
+ const [models, setModels] = React.useState([]);
252
+ const [defaultModelInfo, setDefaultModelInfo] = React.useState(null);
253
+
254
+ React.useEffect(() => {
255
+ const onKey = (e) => {
256
+ if (e.key === 'Escape') {
257
+ if (dropdownOpen) { setDropdownOpen(false); return; }
258
+ if (manualModalOpen) { closeManualModalWithConfirm(); return; }
259
+ if (settingsModalOpen) { setSettingsModalOpen(false); return; }
260
+ if (dshModalOpen && !dshLoading) { setDshModalOpen(false); return; }
261
+ if (onClose) onClose();
262
+ else toggle.set(false);
263
+ }
264
+ };
265
+ window.addEventListener('keydown', onKey);
266
+ return () => window.removeEventListener('keydown', onKey);
267
+ }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
268
+
269
+ /** #40: facet filtering happens client-side over the loaded list. */
270
+ const visibleTasks = React.useMemo(() => { let list = tasks;
271
+ if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
272
+ if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
273
+ if (filterChannel) {
274
+ list = list.filter((x) => {
275
+ const channels = Array.isArray(x.channels) && x.channels.length
276
+ ? x.channels
277
+ : [x.notifyTelegram ? 'telegram' : null, (x.kanbanMode || 'none') !== 'none' ? 'kanban' : null].filter(Boolean);
278
+ return channels.includes(filterChannel);
279
+ });
280
+ }
281
+ return list;
282
+ }, [tasks, filterType, filterModel, filterChannel]);
283
+
284
+ const filterTypeOptions = React.useMemo(
285
+ () => Array.from(new Set(tasks.map((x) => x.type || 'llm'))).sort(),
286
+ [tasks]
287
+ );
288
+
289
+ const filterModelOptions = React.useMemo(
290
+ () => Array.from(new Set(tasks.map((x) => String(x.model || '').split('/').pop()).filter(Boolean))).sort(),
291
+ [tasks]
292
+ );
293
+
294
+ const resetFilters = () => {
295
+ setFilterType('');
296
+ setFilterModel('');
297
+ setFilterChannel('');
298
+ };
299
+
300
+ /** #34: scroll to and flash the task the sidebar list asked for. */
301
+ React.useEffect(() => {
302
+ if (!pendingTaskHighlight || tasks.length === 0) return;
303
+ const id = pendingTaskHighlight;
304
+ pendingTaskHighlight = null;
305
+ const node = document.querySelector('[data-task-id="' + id + '"]');
306
+ if (!node) return;
307
+ if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' });
308
+ node.classList.add('dsh-cron-task-highlight');
309
+ setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (err) { /* node unmounted */ } }, 2500);
310
+ }, [tasks]);
311
+
312
+ const fetchGenRef = React.useRef(0);
313
+
314
+ const fetchTasks = async () => {
315
+ const gen = ++fetchGenRef.current;
316
+ setLoading(true);
317
+ setFetchError(null);
318
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
319
+ const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
320
+ try {
321
+ const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), {
322
+ signal: controller ? controller.signal : undefined,
323
+ });
324
+ const data = await res.json();
325
+ if (gen !== fetchGenRef.current) return;
326
+ if (data && data.ok) {
327
+ setTasks(data.tasks || []);
328
+ setRecs(data.recommendations || []);
329
+ setStats(data.stats || null);
330
+ } else {
331
+ setFetchError(data && data.error ? data.error : T_KEY(t, 'list.loadErrorPrefix'));
332
+ }
333
+ } catch (err) {
334
+ if (gen !== fetchGenRef.current) return;
335
+ console.error('[dsh-cron] fetch tasks error:', err);
336
+ setFetchError(err.message || T_KEY(t, 'list.loadErrorPrefix'));
337
+ } finally {
338
+ if (timer) clearTimeout(timer);
339
+ if (gen === fetchGenRef.current) {
340
+ setLoading(false);
341
+ }
342
+ }
343
+ };
344
+
345
+ const loadModels = async (providerName) => {
346
+ const controller = (typeof AbortController !== 'undefined') ? new AbortController() : null;
347
+ const timer = controller ? setTimeout(() => controller.abort(), 15000) : null;
348
+ try {
349
+ const url = providerName ? '/dsh-cron/models?provider=' + encodeURIComponent(providerName) : '/dsh-cron/models';
350
+ const res = await fetch(url, { signal: controller ? controller.signal : undefined });
351
+ const data = await res.json();
352
+ if (data && data.ok) {
353
+ if (!providerName) {
354
+ setProviders(data.providers || []);
355
+ setDefaultModelInfo(data.defaultModel || data.current || null);
356
+ if (data.defaultModel?.models) {
357
+ setModels(data.defaultModel.models);
358
+ }
359
+ } else {
360
+ setModels(data.models || []);
361
+ }
362
+ }
363
+ } catch (err) {
364
+ console.error('[dsh-cron] load models error:', err);
365
+ } finally {
366
+ if (timer) clearTimeout(timer);
367
+ }
368
+ };
369
+
370
+ const lastEtagRef = React.useRef('');
371
+
372
+ React.useEffect(() => {
373
+ fetchTasks();
374
+
375
+ let timerId = null;
376
+ let isDestroyed = false;
377
+ let pollController = null;
378
+
379
+ const doPoll = () => {
380
+ if (isDestroyed) return;
381
+ const pollGen = ++fetchGenRef.current;
382
+ const headers = {};
383
+ if (lastEtagRef.current) headers['If-None-Match'] = lastEtagRef.current;
384
+
385
+ if (pollController) pollController.abort();
386
+ pollController = (typeof AbortController !== 'undefined') ? new AbortController() : null;
387
+
388
+ const fetchOpts = { headers };
389
+ if (pollController) fetchOpts.signal = pollController.signal;
390
+
391
+ const pollTimer = pollController ? setTimeout(() => { try { pollController.abort(); } catch (abortErr) { void abortErr; } }, 15000) : null;
392
+ fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query), fetchOpts)
393
+ .then(res => {
394
+ if (isDestroyed || pollGen !== fetchGenRef.current) return null;
395
+ if (res.status === 304) {
396
+ // Not modified: skip parsing JSON
397
+ return null;
398
+ }
399
+ const etag = res.headers.get('ETag');
400
+ if (etag) lastEtagRef.current = etag;
401
+ return res.json();
402
+ })
403
+ .then(data => {
404
+ if (isDestroyed || pollGen !== fetchGenRef.current) return;
405
+ if (data && data.ok) {
406
+ setTasks(data.tasks || []);
407
+ setRecs(data.recommendations || []);
408
+ setStats(data.stats || null);
409
+ }
410
+ })
411
+ .catch(err => {
412
+ if (err && err.name === 'AbortError') return;
413
+ })
414
+ .finally(() => {
415
+ if (pollTimer) clearTimeout(pollTimer);
416
+ if (isDestroyed) return;
417
+ // Adaptive interval: 30s when tab is hidden, 8s when active (#134)
418
+ const intervalMs = (typeof document !== 'undefined' && document.hidden) ? 30000 : 8000;
419
+ timerId = setTimeout(doPoll, intervalMs);
420
+ });
421
+ };
422
+
423
+ // Trigger immediate fetch when visibility returns
424
+ const onVisibilityChange = () => {
425
+ if (typeof document !== 'undefined' && !document.hidden) {
426
+ if (timerId) clearTimeout(timerId);
427
+ doPoll();
428
+ }
429
+ };
430
+
431
+ if (typeof document !== 'undefined') {
432
+ document.addEventListener('visibilitychange', onVisibilityChange);
433
+ }
434
+
435
+ timerId = setTimeout(doPoll, 8000);
436
+
437
+ return () => {
438
+ isDestroyed = true;
439
+ if (timerId) clearTimeout(timerId);
440
+ if (pollController) pollController.abort();
441
+ if (typeof document !== 'undefined') {
442
+ document.removeEventListener('visibilitychange', onVisibilityChange);
443
+ }
444
+ };
445
+ }, [tab, query]);
446
+ React.useEffect(() => { loadModels(); }, []);
447
+