@goodandready/dsh-cron 0.2.4 → 0.2.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/README.md +27 -21
- package/docs/README.ru.md +31 -20
- package/docs/README.zh.md +28 -21
- package/docs/design/DESIGN.md +13 -7
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/docs/plans/0.2.6-economy-block.md +61 -0
- package/lib/api.js +399 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +727 -44
- package/lib/failure-inspector.js +107 -0
- package/lib/http-utils.js +24 -0
- package/lib/index.js +171 -359
- package/lib/llm-ask.js +141 -0
- package/lib/recipes.js +238 -0
- package/lib/runner.js +210 -171
- package/lib/scheduler.js +226 -80
- package/lib/silent-rule.js +100 -0
- package/lib/store.js +16 -5
- package/lib/task-patch.js +81 -0
- package/lib/task-transfer.js +230 -0
- package/lib/templates.js +4 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -15,6 +15,9 @@ window.__ModuleLoader__.load({
|
|
|
15
15
|
const ACTIVATE_EVENT = 'dsh-panel-activate';
|
|
16
16
|
const ACTIVE_ATTR = 'data-dsh-cron-active';
|
|
17
17
|
const ENTRY_ATTR = 'data-dsh-cron-entry';
|
|
18
|
+
// Set by the sidebar job list (#34) so the panel can highlight the task the
|
|
19
|
+
// user clicked; cleared once the panel has scrolled to it.
|
|
20
|
+
let pendingTaskHighlight = null;
|
|
18
21
|
const VIEW_ATTR = 'data-dsh-cron-view';
|
|
19
22
|
const COLUMN_SELECTOR = '[data-pane=conversation], [class*=centerCol]';
|
|
20
23
|
const SESSION_ROW_SELECTOR = '[class*="sessionRow"], [class*="projectRow"], [class*="searchResultRow"], [class*="newSession"]';
|
|
@@ -32,6 +35,10 @@ window.__ModuleLoader__.load({
|
|
|
32
35
|
const ICON_ACTIVITY = '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"></polyline></svg>';
|
|
33
36
|
const ICON_EDIT = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path></svg>';
|
|
34
37
|
const ICON_SEND = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>';
|
|
38
|
+
const ICON_COPY = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path></svg>';
|
|
39
|
+
const ICON_TUNE = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="4" y1="21" x2="4" y2="14"></line><line x1="4" y1="10" x2="4" y2="3"></line><line x1="12" y1="21" x2="12" y2="12"></line><line x1="12" y1="8" x2="12" y2="3"></line><line x1="20" y1="21" x2="20" y2="16"></line><line x1="20" y1="12" x2="20" y2="3"></line><line x1="1" y1="14" x2="7" y2="14"></line><line x1="9" y1="8" x2="15" y2="8"></line><line x1="17" y1="16" x2="23" y2="16"></line></svg>';
|
|
40
|
+
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg>';
|
|
41
|
+
const ICON_UPLOAD = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="17 8 12 3 7 8"></polyline><line x1="12" y1="3" x2="12" y2="15"></line></svg>';
|
|
35
42
|
const ICON_TELEGRAM = '<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm4.64 6.8-1.7 8.01c-.13.58-.47.72-.95.45l-2.6-1.92-1.25 1.21c-.14.14-.26.26-.53.26l.19-2.64 4.81-4.35c.21-.19-.05-.29-.32-.11L8.34 13.5 5.78 12.7c-.56-.17-.57-.56.12-.83l10-3.86c.46-.17.87.11.74.79z"/></svg>';
|
|
36
43
|
|
|
37
44
|
// Locale dictionary: English is the canonical source language (Issue #87).
|
|
@@ -40,6 +47,9 @@ window.__ModuleLoader__.load({
|
|
|
40
47
|
const STRINGS = {
|
|
41
48
|
en: {
|
|
42
49
|
'sidebar.label': 'Scheduled tasks',
|
|
50
|
+
'sidebar.jobsTitle': 'Active jobs',
|
|
51
|
+
'sidebar.noActiveJobs': 'No active jobs',
|
|
52
|
+
'sidebar.moreJobs': 'and {count} more',
|
|
43
53
|
'chip.title': 'Scheduled tasks (Cron)',
|
|
44
54
|
'panel.back': 'Back to chat',
|
|
45
55
|
'panel.title': 'Scheduled tasks',
|
|
@@ -50,11 +60,39 @@ window.__ModuleLoader__.load({
|
|
|
50
60
|
'actions.runNow': 'Run now',
|
|
51
61
|
'actions.runNowShort': 'Run',
|
|
52
62
|
'actions.edit': 'Edit / Details',
|
|
63
|
+
'actions.duplicate': 'Duplicate task',
|
|
64
|
+
'actions.tuneWithDsh': 'Tune with DSH',
|
|
65
|
+
'tune.startFailed': 'Could not start the tuning dialogue: {error}',
|
|
53
66
|
'actions.delete': 'Delete task',
|
|
67
|
+
'duplicate.done': 'Copy created and paused: review its schedule before resuming it.',
|
|
68
|
+
'duplicate.oneShotHint': 'The copy is one-shot and paused — its original time has likely passed, so set a new time before resuming.',
|
|
69
|
+
'duplicate.failed': 'Could not duplicate the task: {error}',
|
|
70
|
+
'transfer.exportTitle': 'Export tasks',
|
|
71
|
+
'transfer.importTitle': 'Import tasks',
|
|
72
|
+
'transfer.exported': 'Exported {count} task(s).',
|
|
73
|
+
'transfer.exportFailed': 'Export failed: {error}',
|
|
74
|
+
'transfer.importTitleModal': 'Import tasks',
|
|
75
|
+
'transfer.importHint': 'Exported task configuration only: no run history, no counters. Secrets are not included — channels reference credentials by name, so make sure those credential names exist on the target harness.',
|
|
76
|
+
'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
|
|
77
|
+
'transfer.strategyReplace': 'Replace tasks with the same id',
|
|
78
|
+
'transfer.strategySkip': 'Skip tasks whose id already exists',
|
|
79
|
+
'transfer.summary': 'This file contains {total} task(s): {add} new, {replace} matching an existing id, {skip} already skipped.',
|
|
80
|
+
'transfer.confirm': 'Import',
|
|
81
|
+
'transfer.imported': 'Imported {count} task(s).',
|
|
82
|
+
'transfer.importFailed': 'Import failed: {error}',
|
|
83
|
+
'transfer.badFile': 'The selected file is not a valid JSON export: {error}',
|
|
54
84
|
'actions.pause': 'Pause',
|
|
55
85
|
'actions.resume': 'Resume',
|
|
56
86
|
'actions.settings': 'Notifications & channels',
|
|
57
87
|
'search.placeholder': 'Search scheduled tasks',
|
|
88
|
+
'filters.title': 'Filters',
|
|
89
|
+
'filters.type': 'Type',
|
|
90
|
+
'filters.model': 'Model',
|
|
91
|
+
'filters.channel': 'Channel',
|
|
92
|
+
'filters.any': 'Any',
|
|
93
|
+
'filters.reset': 'Reset filters',
|
|
94
|
+
'filters.empty': 'No task matches the current filters.',
|
|
95
|
+
'filters.showing': 'Showing {shown} of {total}',
|
|
58
96
|
'stats.activeTasks': 'Active tasks',
|
|
59
97
|
'stats.totalRuns': 'Total runs',
|
|
60
98
|
'stats.totalTokens': 'Tokens spent',
|
|
@@ -72,6 +110,7 @@ window.__ModuleLoader__.load({
|
|
|
72
110
|
'list.oneShot': 'One-shot',
|
|
73
111
|
'list.next': ' · next: {time}',
|
|
74
112
|
'recs.title': 'Recommended tasks',
|
|
113
|
+
'recs.hint': 'Ready-to-use recipes. Clicking one opens the form with its fields filled in — nothing is created until you save.',
|
|
75
114
|
'modal.editTitle': 'Edit task',
|
|
76
115
|
'modal.newTitle': 'New scheduled task',
|
|
77
116
|
'modal.statusActive': '● Active',
|
|
@@ -135,6 +174,18 @@ window.__ModuleLoader__.load({
|
|
|
135
174
|
'form.providerDefault': 'Default (DSH)',
|
|
136
175
|
'form.modelLabel': 'LLM model',
|
|
137
176
|
'form.modelDefault': 'Default',
|
|
177
|
+
'form.silentRuleLabel': 'Silent rule (optional)',
|
|
178
|
+
'form.silentRulePlaceholder': 'Stay silent when no filesystem is above 80%',
|
|
179
|
+
'form.silentRuleHint': 'For tasks with output: a cheap model judges the run against this rule and the report is skipped when it says to stay silent. If the model is unavailable or unsure, the report is delivered.',
|
|
180
|
+
'history.silentSkip': 'stayed silent:',
|
|
181
|
+
'history.diagnosis': 'Diagnosis',
|
|
182
|
+
'history.applySuggestion': 'Open the edit form with this suggestion',
|
|
183
|
+
'form.inspectOnFailureLabel': 'Diagnose failures with a model',
|
|
184
|
+
'form.inspectOnFailureHint': 'After a failed run a model reads the prompt, the error and the output, and the diagnosis with a proposed prompt change is stored with the run. Nothing is applied automatically.',
|
|
185
|
+
'settings.inspectorModelLabel': 'Model for failure diagnosis',
|
|
186
|
+
'form.fallbackModelLabel': 'Fallback model (optional)',
|
|
187
|
+
'form.fallbackModelPlaceholder': 'e.g. deepseek-reasoner',
|
|
188
|
+
'form.fallbackModelHint': 'If the run fails on the model above, it is retried once on this model. Both attempts are counted in the cost.',
|
|
138
189
|
'form.promptLabelScript': 'Shell command or script',
|
|
139
190
|
'form.promptLabelLlm': 'Instruction / Prompt for the agent',
|
|
140
191
|
'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
|
|
@@ -160,7 +211,7 @@ window.__ModuleLoader__.load({
|
|
|
160
211
|
'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
|
|
161
212
|
'form.templateLabel': 'Message template (optional)',
|
|
162
213
|
'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
|
|
163
|
-
'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
|
|
214
|
+
'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost} {model}. Empty = built-in text.',
|
|
164
215
|
'form.channelTelegram': 'Telegram',
|
|
165
216
|
'form.channelKanban': 'dsh-kanban card',
|
|
166
217
|
'form.channelDiscord': 'Discord',
|
|
@@ -168,7 +219,6 @@ window.__ModuleLoader__.load({
|
|
|
168
219
|
'form.channelNtfy': 'ntfy',
|
|
169
220
|
'form.channelBark': 'Bark',
|
|
170
221
|
'form.channelPushplus': 'PushPlus',
|
|
171
|
-
'form.channelEmail': 'Email',
|
|
172
222
|
'form.channelTts': 'Voice (dsh-tts)',
|
|
173
223
|
'form.channelGitea': 'Gitea issue',
|
|
174
224
|
'settings.deliverySection': 'Delivery channels',
|
|
@@ -188,13 +238,6 @@ window.__ModuleLoader__.load({
|
|
|
188
238
|
'settings.barkKeyLabel': 'Bark device key',
|
|
189
239
|
'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
|
|
190
240
|
'settings.pushplusUrlLabel': 'PushPlus endpoint',
|
|
191
|
-
'settings.smtpHostLabel': 'SMTP host',
|
|
192
|
-
'settings.smtpPortLabel': 'SMTP port',
|
|
193
|
-
'settings.smtpUserLabel': 'SMTP user',
|
|
194
|
-
'settings.smtpPasswordRefLabel': 'SMTP password credential name',
|
|
195
|
-
'settings.smtpFromLabel': 'From address',
|
|
196
|
-
'settings.smtpToLabel': 'Recipients (comma separated)',
|
|
197
|
-
'settings.smtpSecureLabel': 'SMTP over TLS (implicit)',
|
|
198
241
|
'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
|
|
199
242
|
'settings.giteaBaseUrlLabel': 'Gitea base URL',
|
|
200
243
|
'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
|
|
@@ -202,6 +245,9 @@ window.__ModuleLoader__.load({
|
|
|
202
245
|
'settings.templateLabel': 'Default message template',
|
|
203
246
|
'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
|
|
204
247
|
'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
|
|
248
|
+
'settings.automationSection': 'Automation models',
|
|
249
|
+
'settings.automationHint': 'Models used for the small helper calls (rule evaluation, failure analysis). Leave empty to use the task model.',
|
|
250
|
+
'settings.silentRuleModelLabel': 'Model for silent rules',
|
|
205
251
|
'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
|
|
206
252
|
'form.nextRunLabel': 'Next run: ',
|
|
207
253
|
'form.notScheduled': 'not scheduled',
|
|
@@ -209,7 +255,7 @@ window.__ModuleLoader__.load({
|
|
|
209
255
|
'settings.unavailable': 'Settings are unavailable right now.',
|
|
210
256
|
'settings.retry': 'Retry',
|
|
211
257
|
'settings.cardTitle': '⏰ Task scheduler (Cron)',
|
|
212
|
-
'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus,
|
|
258
|
+
'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus, voice and Gitea.',
|
|
213
259
|
'settings.openPanel': 'Open task panel',
|
|
214
260
|
'settings.modalTitle': 'Notifications & delivery channels',
|
|
215
261
|
'settings.tokenLabel': 'Telegram Bot Token',
|
|
@@ -307,7 +353,7 @@ window.__ModuleLoader__.load({
|
|
|
307
353
|
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
308
354
|
|
|
309
355
|
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
310
|
-
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', '
|
|
356
|
+
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
311
357
|
const CHANNEL_LABEL_KEYS = {
|
|
312
358
|
telegram: 'form.channelTelegram',
|
|
313
359
|
kanban: 'form.channelKanban',
|
|
@@ -316,7 +362,6 @@ window.__ModuleLoader__.load({
|
|
|
316
362
|
ntfy: 'form.channelNtfy',
|
|
317
363
|
bark: 'form.channelBark',
|
|
318
364
|
pushplus: 'form.channelPushplus',
|
|
319
|
-
email: 'form.channelEmail',
|
|
320
365
|
tts: 'form.channelTts',
|
|
321
366
|
gitea: 'form.channelGitea',
|
|
322
367
|
};
|
|
@@ -327,7 +372,6 @@ window.__ModuleLoader__.load({
|
|
|
327
372
|
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
328
373
|
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
329
374
|
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
330
|
-
{ key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
|
|
331
375
|
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
332
376
|
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
333
377
|
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
@@ -336,20 +380,14 @@ window.__ModuleLoader__.load({
|
|
|
336
380
|
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
337
381
|
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
338
382
|
{ key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
|
|
339
|
-
{ key: 'smtpHost', labelKey: 'settings.smtpHostLabel', group: 'channels' },
|
|
340
|
-
{ key: 'smtpPort', labelKey: 'settings.smtpPortLabel', group: 'channels' },
|
|
341
|
-
{ key: 'smtpUser', labelKey: 'settings.smtpUserLabel', group: 'channels' },
|
|
342
|
-
{ key: 'smtpFrom', labelKey: 'settings.smtpFromLabel', group: 'channels' },
|
|
343
|
-
{ key: 'smtpTo', labelKey: 'settings.smtpToLabel', group: 'channels' },
|
|
344
383
|
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
345
384
|
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
346
385
|
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
347
|
-
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
|
|
386
|
+
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
387
|
+
{ key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
|
|
388
|
+
{ key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
|
|
348
389
|
];
|
|
349
|
-
const
|
|
350
|
-
{ key: 'smtpSecure', labelKey: 'settings.smtpSecureLabel' },
|
|
351
|
-
];
|
|
352
|
-
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key).concat(DELIVERY_TOGGLE_FIELDS.map((f) => f.key));
|
|
390
|
+
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
353
391
|
// The global template plus the per-channel override map (server model).
|
|
354
392
|
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
355
393
|
|
|
@@ -376,11 +414,16 @@ window.__ModuleLoader__.load({
|
|
|
376
414
|
React.createElement('label', null, T(f.labelKey)),
|
|
377
415
|
React.createElement('input', f.numeric ? {
|
|
378
416
|
type: 'number',
|
|
379
|
-
min: 1000,
|
|
417
|
+
min: f.min !== undefined ? f.min : 1000,
|
|
380
418
|
step: 1000,
|
|
381
419
|
value: val(f.key),
|
|
382
|
-
placeholder:
|
|
383
|
-
|
|
420
|
+
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
421
|
+
// #115: clearing the field must send the explicit default. Deleting the
|
|
422
|
+
// key would leave the previously stored value in place (the settings
|
|
423
|
+
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
424
|
+
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
425
|
+
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
426
|
+
: Number(e.target.value)),
|
|
384
427
|
} : {
|
|
385
428
|
type: 'text',
|
|
386
429
|
value: val(f.key),
|
|
@@ -413,14 +456,12 @@ window.__ModuleLoader__.load({
|
|
|
413
456
|
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
414
457
|
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
415
458
|
),
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
React.createElement('span', null, T(f.labelKey))
|
|
423
|
-
)),
|
|
459
|
+
]),
|
|
460
|
+
section('automation', 'settings.automationSection', [
|
|
461
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
|
|
462
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
463
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
|
|
464
|
+
),
|
|
424
465
|
]),
|
|
425
466
|
section('templates', 'settings.templatesSection', [
|
|
426
467
|
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
@@ -523,6 +564,12 @@ window.__ModuleLoader__.load({
|
|
|
523
564
|
.dsh-cron-search-input { width: 100%; height: 36px; background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 0 12px 0 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 13px; outline: none; box-sizing: border-box; }
|
|
524
565
|
.dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
|
|
525
566
|
.dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
|
|
567
|
+
.dsh-cron-filters { margin-top: 8px; }
|
|
568
|
+
.dsh-cron-filter-toggle { background: none; border: 1px solid var(--dsw-alias-border-l2, #333); color: var(--dsw-alias-label-secondary, #aaa); border-radius: 8px; padding: 4px 10px; font-size: 12px; cursor: pointer; }
|
|
569
|
+
.dsh-cron-filter-toggle:hover { color: var(--dsw-alias-label-primary, #eee); border-color: var(--dsh-cron-info); }
|
|
570
|
+
.dsh-cron-filter-panel { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 10px; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--dsw-alias-border-l2, #2e2e2e); border-radius: 8px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.02)); }
|
|
571
|
+
.dsh-cron-filter-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); }
|
|
572
|
+
.dsh-cron-filter-field select { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); color: var(--dsw-alias-label-primary, inherit); border-radius: 6px; padding: 5px 8px; font-size: 12.5px; }
|
|
526
573
|
.dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
527
574
|
.dsh-cron-tab { appearance: none; background: transparent; border: 1px solid var(--dsw-alias-border-l2, #383838); padding: 4px 12px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 12px; font-weight: 500; cursor: pointer; }
|
|
528
575
|
.dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); }
|
|
@@ -626,6 +673,51 @@ window.__ModuleLoader__.load({
|
|
|
626
673
|
.dsh-cron-status-banner[data-type="info"] { background: var(--dsh-cron-info-bg); border: 1px solid var(--dsh-cron-info); color: var(--dsh-cron-info); }
|
|
627
674
|
@keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } 70% { box-shadow: 0 0 0 9px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } }
|
|
628
675
|
.dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
676
|
+
|
|
677
|
+
/* #34: sidebar job list */
|
|
678
|
+
.dsh-cron-sidebar-jobs { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 6px 0; }
|
|
679
|
+
.dsh-cron-sidebar-jobs-head { display: flex; align-items: center; justify-content: space-between; width: 100%; background: none; border: none; padding: 4px 8px; font: inherit; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; border-radius: 6px; text-align: left; }
|
|
680
|
+
.dsh-cron-sidebar-jobs-head:hover { color: var(--dsw-alias-label-primary, #eee); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.04)); }
|
|
681
|
+
.dsh-cron-sidebar-jobs-head::after { content: '▾'; font-size: 10px; transition: transform 0.15s ease; }
|
|
682
|
+
.dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-head::after { transform: rotate(-90deg); }
|
|
683
|
+
.dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-list { display: none; }
|
|
684
|
+
.dsh-cron-sidebar-jobs-list { display: flex; flex-direction: column; gap: 2px; padding-left: 10px; }
|
|
685
|
+
.dsh-cron-sidebar-job { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; width: 100%; background: none; border: none; border-left: 2px solid transparent; padding: 3px 8px; font: inherit; cursor: pointer; border-radius: 0 6px 6px 0; text-align: left; }
|
|
686
|
+
.dsh-cron-sidebar-job:hover { background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.04)); border-left-color: var(--dsh-cron-info); }
|
|
687
|
+
.dsh-cron-sidebar-job-title { font-size: 12px; color: var(--dsw-alias-label-secondary, #ccc); max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
688
|
+
.dsh-cron-sidebar-job-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); }
|
|
689
|
+
.dsh-cron-sidebar-empty, .dsh-cron-sidebar-more { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); padding: 2px 8px; }
|
|
690
|
+
.dsh-cron-task-highlight { border-color: var(--dsh-cron-info) !important; box-shadow: 0 0 0 2px var(--dsh-cron-info-bg); }
|
|
691
|
+
|
|
692
|
+
/* #39: narrow screens. Only layout changes — no information is hidden. */
|
|
693
|
+
@media (max-width: 900px) {
|
|
694
|
+
.dsh-cron-container { padding: 8px 12px 24px; gap: 14px; }
|
|
695
|
+
.dsh-cron-form-row { grid-template-columns: 1fr; }
|
|
696
|
+
.dsh-cron-channel-grid { grid-template-columns: 1fr; }
|
|
697
|
+
.dsh-cron-filter-panel { grid-template-columns: 1fr; }
|
|
698
|
+
.dsh-cron-modal { padding: 16px; border-radius: 12px; }
|
|
699
|
+
.dsh-cron-modal-scroll { max-height: 92vh; }
|
|
700
|
+
.dsh-cron-task-item { flex-wrap: wrap; gap: 10px; }
|
|
701
|
+
.dsh-cron-task-actions { margin-left: auto; }
|
|
702
|
+
.dsh-cron-history-list { max-height: 320px; }
|
|
703
|
+
}
|
|
704
|
+
@media (max-width: 640px) {
|
|
705
|
+
.dsh-cron-container { padding: 6px 8px 20px; }
|
|
706
|
+
.dsh-cron-top-bar { flex-wrap: wrap; gap: 8px; }
|
|
707
|
+
.dsh-cron-tabs { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
|
|
708
|
+
.dsh-cron-tab { white-space: nowrap; }
|
|
709
|
+
.dsh-cron-stats-bar { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
|
|
710
|
+
.dsh-cron-task-item { align-items: flex-start; padding: 12px; }
|
|
711
|
+
.dsh-cron-task-title { flex-wrap: wrap; row-gap: 4px; }
|
|
712
|
+
.dsh-cron-modal { padding: 14px; }
|
|
713
|
+
.dsh-cron-modal-foot { flex-direction: column-reverse; }
|
|
714
|
+
.dsh-cron-modal-foot > button { width: 100%; }
|
|
715
|
+
.dsh-cron-filter-panel { padding: 8px 10px; }
|
|
716
|
+
.dsh-cron-card { padding: 14px; }
|
|
717
|
+
/* 16px keeps iOS Safari from zooming the page when a field is focused. */
|
|
718
|
+
.dsh-cron-modal input, .dsh-cron-modal select, .dsh-cron-modal textarea,
|
|
719
|
+
.dsh-cron-card input, .dsh-cron-search-input, .dsh-cron-filter-field select { font-size: 16px; }
|
|
720
|
+
}
|
|
629
721
|
`;
|
|
630
722
|
|
|
631
723
|
function ensureStyles() {
|
|
@@ -722,12 +814,25 @@ window.__ModuleLoader__.load({
|
|
|
722
814
|
const t = props.t || translate;
|
|
723
815
|
const [tab, setTab] = React.useState('all');
|
|
724
816
|
const [query, setQuery] = React.useState('');
|
|
817
|
+
// Facet filters (#40): applied client-side over the already-loaded list so
|
|
818
|
+
// toggling them is instant and does not touch the polling request.
|
|
819
|
+
const [filterType, setFilterType] = React.useState('');
|
|
820
|
+
const [filterModel, setFilterModel] = React.useState('');
|
|
821
|
+
const [filterChannel, setFilterChannel] = React.useState('');
|
|
822
|
+
const [filtersOpen, setFiltersOpen] = React.useState(false);
|
|
725
823
|
const [tasks, setTasks] = React.useState([]);
|
|
726
824
|
const [recs, setRecs] = React.useState([]);
|
|
727
825
|
const [loading, setLoading] = React.useState(false);
|
|
728
826
|
const [fetchError, setFetchError] = React.useState(null);
|
|
729
827
|
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
|
730
828
|
|
|
829
|
+
// Export/import state (#42)
|
|
830
|
+
const [transferBusy, setTransferBusy] = React.useState(false);
|
|
831
|
+
const [importDoc, setImportDoc] = React.useState(null);
|
|
832
|
+
const [importSummary, setImportSummary] = React.useState(null);
|
|
833
|
+
const [importStrategy, setImportStrategy] = React.useState('skip');
|
|
834
|
+
const importFileRef = React.useRef(null);
|
|
835
|
+
|
|
731
836
|
// Manual / Edit modal state
|
|
732
837
|
const [manualModalOpen, setManualModalOpen] = React.useState(false);
|
|
733
838
|
const [formId, setFormId] = React.useState(null);
|
|
@@ -737,6 +842,9 @@ window.__ModuleLoader__.load({
|
|
|
737
842
|
const [formPrompt, setFormPrompt] = React.useState('');
|
|
738
843
|
const [formProvider, setFormProvider] = React.useState('');
|
|
739
844
|
const [formModel, setFormModel] = React.useState('');
|
|
845
|
+
const [formFallbackModel, setFormFallbackModel] = React.useState('');
|
|
846
|
+
const [formSilentRule, setFormSilentRule] = React.useState('');
|
|
847
|
+
const [formInspectOnFailure, setFormInspectOnFailure] = React.useState(false);
|
|
740
848
|
const [formDelivery, setFormDelivery] = React.useState('isolated');
|
|
741
849
|
const [selectedTaskMeta, setSelectedTaskMeta] = React.useState(null);
|
|
742
850
|
const [modalTab, setModalTab] = React.useState('params');
|
|
@@ -824,6 +932,49 @@ window.__ModuleLoader__.load({
|
|
|
824
932
|
return () => window.removeEventListener('keydown', onKey);
|
|
825
933
|
}, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
|
|
826
934
|
|
|
935
|
+
/** #40: facet filtering happens client-side over the loaded list. */
|
|
936
|
+
const visibleTasks = React.useMemo(() => { let list = tasks;
|
|
937
|
+
if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
|
|
938
|
+
if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
|
|
939
|
+
if (filterChannel) {
|
|
940
|
+
list = list.filter((x) => {
|
|
941
|
+
const channels = Array.isArray(x.channels) && x.channels.length
|
|
942
|
+
? x.channels
|
|
943
|
+
: [x.notifyTelegram ? 'telegram' : null, (x.kanbanMode || 'none') !== 'none' ? 'kanban' : null].filter(Boolean);
|
|
944
|
+
return channels.includes(filterChannel);
|
|
945
|
+
});
|
|
946
|
+
}
|
|
947
|
+
return list;
|
|
948
|
+
}, [tasks, filterType, filterModel, filterChannel]);
|
|
949
|
+
|
|
950
|
+
const filterTypeOptions = React.useMemo(
|
|
951
|
+
() => Array.from(new Set(tasks.map((x) => x.type || 'llm'))).sort(),
|
|
952
|
+
[tasks]
|
|
953
|
+
);
|
|
954
|
+
|
|
955
|
+
const filterModelOptions = React.useMemo(
|
|
956
|
+
() => Array.from(new Set(tasks.map((x) => String(x.model || '').split('/').pop()).filter(Boolean))).sort(),
|
|
957
|
+
[tasks]
|
|
958
|
+
);
|
|
959
|
+
|
|
960
|
+
const resetFilters = () => {
|
|
961
|
+
setFilterType('');
|
|
962
|
+
setFilterModel('');
|
|
963
|
+
setFilterChannel('');
|
|
964
|
+
};
|
|
965
|
+
|
|
966
|
+
/** #34: scroll to and flash the task the sidebar list asked for. */
|
|
967
|
+
React.useEffect(() => {
|
|
968
|
+
if (!pendingTaskHighlight || tasks.length === 0) return;
|
|
969
|
+
const id = pendingTaskHighlight;
|
|
970
|
+
pendingTaskHighlight = null;
|
|
971
|
+
const node = document.querySelector('[data-task-id="' + id + '"]');
|
|
972
|
+
if (!node) return;
|
|
973
|
+
if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' });
|
|
974
|
+
node.classList.add('dsh-cron-task-highlight');
|
|
975
|
+
setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (_) {} }, 2500);
|
|
976
|
+
}, [tasks]);
|
|
977
|
+
|
|
827
978
|
const fetchTasks = async () => {
|
|
828
979
|
setLoading(true);
|
|
829
980
|
setFetchError(null);
|
|
@@ -868,8 +1019,7 @@ window.__ModuleLoader__.load({
|
|
|
868
1019
|
|
|
869
1020
|
React.useEffect(() => {
|
|
870
1021
|
fetchTasks();
|
|
871
|
-
const pollInterval = setInterval(() => {
|
|
872
|
-
// Background polling without resetting loading spinner
|
|
1022
|
+
const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
|
|
873
1023
|
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
|
|
874
1024
|
.then(res => res.json())
|
|
875
1025
|
.then(data => {
|
|
@@ -902,13 +1052,162 @@ window.__ModuleLoader__.load({
|
|
|
902
1052
|
}
|
|
903
1053
|
};
|
|
904
1054
|
|
|
1055
|
+
/** #41: duplicate a task; the server copies config, resets run state (#41). */
|
|
1056
|
+
const handleDuplicate = async (task) => {
|
|
1057
|
+
try {
|
|
1058
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
|
|
1059
|
+
const data = await res.json();
|
|
1060
|
+
if (!data.ok) {
|
|
1061
|
+
alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
await fetchTasks();
|
|
1065
|
+
// A one-shot copy keeps its original schedule string, so warn instead
|
|
1066
|
+
// of letting the user resume a task pointing at a past moment.
|
|
1067
|
+
alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
|
|
1073
|
+
/** A task id must not shadow a collection route (#42 review finding). */
|
|
1074
|
+
const RESERVED_TASK_IDS = ['export', 'import'];
|
|
1075
|
+
|
|
1076
|
+
/** Import needs the script-confirm header when the file has code tasks. */
|
|
1077
|
+
const importHeaders = (document_) => {
|
|
1078
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1079
|
+
const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
|
|
1080
|
+
if (tasks.some((x) => CODE_FORM_TYPES.includes(x && x.type))) headers[SCRIPT_CONFIRM_HEADER] = 'script';
|
|
1081
|
+
return headers;
|
|
1082
|
+
};
|
|
1083
|
+
|
|
1084
|
+
/** #42: download the task configuration as a JSON file. */
|
|
1085
|
+
const handleExport = async () => {
|
|
1086
|
+
setTransferBusy(true);
|
|
1087
|
+
try {
|
|
1088
|
+
const res = await fetch('/dsh-cron/tasks/export');
|
|
1089
|
+
const data = await res.json();
|
|
1090
|
+
if (!data.ok) {
|
|
1091
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
|
|
1092
|
+
return;
|
|
1093
|
+
}
|
|
1094
|
+
const blob = new Blob([JSON.stringify(data.document, null, 2)], { type: 'application/json' });
|
|
1095
|
+
const url = URL.createObjectURL(blob);
|
|
1096
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
1097
|
+
const anchor = document.createElement('a');
|
|
1098
|
+
anchor.href = url;
|
|
1099
|
+
anchor.download = `dsh-cron-tasks-${stamp}.json`;
|
|
1100
|
+
document.body.appendChild(anchor);
|
|
1101
|
+
anchor.click();
|
|
1102
|
+
document.body.removeChild(anchor);
|
|
1103
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1104
|
+
alert(T_KEY(t, 'transfer.exported', { count: data.count }));
|
|
1105
|
+
} catch (err) {
|
|
1106
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: err.message }));
|
|
1107
|
+
} finally {
|
|
1108
|
+
setTransferBusy(false);
|
|
1109
|
+
}
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
/** #42: read the picked file, validate it server-side and show the plan. */
|
|
1113
|
+
const handleImportFile = async (e) => {
|
|
1114
|
+
const file = e.target.files && e.target.files[0];
|
|
1115
|
+
e.target.value = '';
|
|
1116
|
+
if (!file) return;
|
|
1117
|
+
setTransferBusy(true);
|
|
1118
|
+
try {
|
|
1119
|
+
const text = await file.text();
|
|
1120
|
+
let document_;
|
|
1121
|
+
try {
|
|
1122
|
+
document_ = JSON.parse(text);
|
|
1123
|
+
} catch (parseErr) {
|
|
1124
|
+
alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
const res = await fetch('/dsh-cron/tasks/import', {
|
|
1128
|
+
method: 'POST',
|
|
1129
|
+
headers: importHeaders(document_),
|
|
1130
|
+
body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
|
|
1131
|
+
});
|
|
1132
|
+
const data = await res.json();
|
|
1133
|
+
if (!data.ok) {
|
|
1134
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
setImportDoc(document_);
|
|
1138
|
+
setImportSummary(data.summary);
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
1141
|
+
} finally {
|
|
1142
|
+
setTransferBusy(false);
|
|
1143
|
+
}
|
|
1144
|
+
};
|
|
1145
|
+
|
|
1146
|
+
const handleImportConfirm = async () => {
|
|
1147
|
+
if (!importDoc) return;
|
|
1148
|
+
setTransferBusy(true);
|
|
1149
|
+
try {
|
|
1150
|
+
const res = await fetch('/dsh-cron/tasks/import', {
|
|
1151
|
+
method: 'POST',
|
|
1152
|
+
headers: importHeaders(importDoc),
|
|
1153
|
+
body: JSON.stringify({ document: importDoc, strategy: importStrategy })
|
|
1154
|
+
});
|
|
1155
|
+
const data = await res.json();
|
|
1156
|
+
if (!data.ok) {
|
|
1157
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
setImportDoc(null);
|
|
1161
|
+
setImportSummary(null);
|
|
1162
|
+
await fetchTasks();
|
|
1163
|
+
alert(T_KEY(t, 'transfer.imported', { count: data.imported }));
|
|
1164
|
+
} catch (err) {
|
|
1165
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
1166
|
+
} finally {
|
|
1167
|
+
setTransferBusy(false);
|
|
1168
|
+
}
|
|
1169
|
+
};
|
|
1170
|
+
|
|
1171
|
+
/** #49: tune an existing task in a dialogue instead of editing fields. */
|
|
1172
|
+
const handleTuneWithDsh = async (task) => {
|
|
1173
|
+
const lines = [
|
|
1174
|
+
`Tune the existing scheduled task "${task.title}" (id: ${task.id}).`,
|
|
1175
|
+
'Its current configuration is:',
|
|
1176
|
+
`- type: ${task.type || 'llm'}`,
|
|
1177
|
+
`- schedule: ${task.scheduleText || task.schedule} (${task.schedule})`,
|
|
1178
|
+
`- status: ${task.status}`,
|
|
1179
|
+
task.model ? `- model: ${task.model}${task.fallbackModel ? `, fallback: ${task.fallbackModel}` : ''}` : null,
|
|
1180
|
+
Array.isArray(task.channels) && task.channels.length ? `- channels: ${task.channels.join(', ')}` : null,
|
|
1181
|
+
`- prompt: ${String(task.prompt || '').slice(0, 400)}`,
|
|
1182
|
+
'',
|
|
1183
|
+
'Ask what should change, then apply it with the cron_update_task tool (read the current state again with cron_get_task if you need the full configuration).',
|
|
1184
|
+
'If the change makes the task execute code (script/node/python/ssh/docker), confirm it with me explicitly before applying it.',
|
|
1185
|
+
].filter(Boolean);
|
|
1186
|
+
try {
|
|
1187
|
+
const res = await fetch('/dsh-cron/chat/start', {
|
|
1188
|
+
method: 'POST',
|
|
1189
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1190
|
+
body: JSON.stringify({ prompt: lines.join(String.fromCharCode(10)) })
|
|
1191
|
+
});
|
|
1192
|
+
const data = await res.json();
|
|
1193
|
+
if (!data.ok || !data.sessionId) {
|
|
1194
|
+
alert(T_KEY(t, 'tune.startFailed', { error: (data && data.error) || '' }));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (onClose) onClose();
|
|
1198
|
+
else toggle.set(false);
|
|
1199
|
+
await openSession(ctx, data.sessionId);
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
alert(T_KEY(t, 'tune.startFailed', { error: err.message }));
|
|
1202
|
+
}
|
|
1203
|
+
};
|
|
1204
|
+
|
|
905
1205
|
const handleDelete = async (id) => {
|
|
906
1206
|
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
907
1207
|
try {
|
|
908
1208
|
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
909
1209
|
fetchTasks();
|
|
910
|
-
} catch (err) {}
|
|
911
|
-
};
|
|
1210
|
+
} catch (err) {} };
|
|
912
1211
|
|
|
913
1212
|
const openSettingsModal = async () => {
|
|
914
1213
|
setTestStatus(null);
|
|
@@ -1037,10 +1336,21 @@ window.__ModuleLoader__.load({
|
|
|
1037
1336
|
setFormPythonPath('');
|
|
1038
1337
|
setFormSkillName('');
|
|
1039
1338
|
setFormWorkflowName('');
|
|
1339
|
+
// Reset before the preset branch, so a recipe keeps what it sets.
|
|
1340
|
+
setFormFallbackModel('');
|
|
1341
|
+
setFormSilentRule('');
|
|
1342
|
+
setFormInspectOnFailure(false);
|
|
1040
1343
|
if (preset) {
|
|
1041
1344
|
setFormTitle(preset.title || '');
|
|
1042
1345
|
setFormSchedule(preset.schedule || '');
|
|
1043
1346
|
setFormPrompt(preset.prompt || '');
|
|
1347
|
+
// #48: a recipe preset also carries its runtime and delivery shape.
|
|
1348
|
+
if (preset.type) setFormType(preset.type);
|
|
1349
|
+
if (Array.isArray(preset.channels) && preset.channels.length) setFormChannels(preset.channels.filter((c) => DELIVERY_CHANNELS.includes(c)));
|
|
1350
|
+
if (preset.silentRule !== undefined) setFormSilentRule(preset.silentRule || '');
|
|
1351
|
+
if (preset.fallbackModel !== undefined) setFormFallbackModel(preset.fallbackModel || '');
|
|
1352
|
+
if (preset.inspectOnFailure !== undefined) setFormInspectOnFailure(Boolean(preset.inspectOnFailure));
|
|
1353
|
+
setFormOnlyOnFailure(Boolean(preset.onlyOnFailure));
|
|
1044
1354
|
setFormProvider(defaultModelInfo?.provider || '');
|
|
1045
1355
|
setFormModel(defaultModelInfo?.model || '');
|
|
1046
1356
|
if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
|
|
@@ -1070,7 +1380,7 @@ window.__ModuleLoader__.load({
|
|
|
1070
1380
|
}
|
|
1071
1381
|
};
|
|
1072
1382
|
|
|
1073
|
-
const openEditModal = (task) => {
|
|
1383
|
+
const openEditModal = (task, overrides = {}) => {
|
|
1074
1384
|
setDropdownOpen(false);
|
|
1075
1385
|
setFormId(task.id);
|
|
1076
1386
|
setSelectedTaskMeta(task);
|
|
@@ -1078,9 +1388,12 @@ window.__ModuleLoader__.load({
|
|
|
1078
1388
|
setFormTitle(task.title || '');
|
|
1079
1389
|
setFormSchedule(task.schedule || '');
|
|
1080
1390
|
setFormType(task.type || 'llm');
|
|
1081
|
-
setFormPrompt(task.prompt || '');
|
|
1391
|
+
setFormPrompt(overrides.prompt !== undefined ? overrides.prompt : (task.prompt || ''));
|
|
1082
1392
|
setFormProvider(task.provider || '');
|
|
1083
1393
|
setFormModel(task.model || '');
|
|
1394
|
+
setFormFallbackModel(task.fallbackModel || '');
|
|
1395
|
+
setFormSilentRule(task.silentRule || '');
|
|
1396
|
+
setFormInspectOnFailure(Boolean(task.inspectOnFailure));
|
|
1084
1397
|
setFormDelivery(task.delivery || 'isolated');
|
|
1085
1398
|
setFormNotifyTelegram(Boolean(task.notifyTelegram));
|
|
1086
1399
|
setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
|
|
@@ -1137,6 +1450,9 @@ window.__ModuleLoader__.load({
|
|
|
1137
1450
|
delivery: formDelivery || 'isolated',
|
|
1138
1451
|
provider: isAgentType ? (formProvider || undefined) : undefined,
|
|
1139
1452
|
model: isAgentType ? (formModel || undefined) : undefined,
|
|
1453
|
+
fallbackModel: isAgentType ? (formFallbackModel.trim() || undefined) : undefined,
|
|
1454
|
+
silentRule: isAgentType ? undefined : formSilentRule.trim(),
|
|
1455
|
+
inspectOnFailure: isAgentType ? Boolean(formInspectOnFailure) : false,
|
|
1140
1456
|
notifyTelegram: formNotifyTelegram,
|
|
1141
1457
|
channels: formChannels,
|
|
1142
1458
|
template: formTemplate.trim(),
|
|
@@ -1259,7 +1575,31 @@ window.__ModuleLoader__.load({
|
|
|
1259
1575
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
|
|
1260
1576
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
|
|
1261
1577
|
)
|
|
1262
|
-
)
|
|
1578
|
+
),
|
|
1579
|
+
// #42: export/import as a small pair of icon buttons.
|
|
1580
|
+
React.createElement('button', {
|
|
1581
|
+
type: 'button',
|
|
1582
|
+
className: 'dsh-cron-icon-btn',
|
|
1583
|
+
title: T_KEY(t, 'transfer.exportTitle'),
|
|
1584
|
+
'aria-label': T_KEY(t, 'transfer.exportTitle'),
|
|
1585
|
+
disabled: transferBusy,
|
|
1586
|
+
onClick: handleExport
|
|
1587
|
+
}, React.createElement('span', svgProps(ICON_DOWNLOAD))),
|
|
1588
|
+
React.createElement('button', {
|
|
1589
|
+
type: 'button',
|
|
1590
|
+
className: 'dsh-cron-icon-btn',
|
|
1591
|
+
title: T_KEY(t, 'transfer.importTitle'),
|
|
1592
|
+
'aria-label': T_KEY(t, 'transfer.importTitle'),
|
|
1593
|
+
disabled: transferBusy,
|
|
1594
|
+
onClick: () => { if (importFileRef.current) importFileRef.current.click(); }
|
|
1595
|
+
}, React.createElement('span', svgProps(ICON_UPLOAD))),
|
|
1596
|
+
React.createElement('input', {
|
|
1597
|
+
type: 'file',
|
|
1598
|
+
accept: '.json,application/json',
|
|
1599
|
+
ref: importFileRef,
|
|
1600
|
+
style: { display: 'none' },
|
|
1601
|
+
onChange: handleImportFile
|
|
1602
|
+
})
|
|
1263
1603
|
)
|
|
1264
1604
|
),
|
|
1265
1605
|
React.createElement('div', { className: 'dsh-cron-search-bar' },
|
|
@@ -1305,6 +1645,50 @@ window.__ModuleLoader__.load({
|
|
|
1305
1645
|
: T_KEY(t, 'tabs.completed'))
|
|
1306
1646
|
)
|
|
1307
1647
|
),
|
|
1648
|
+
// #40: facet filters over the loaded list.
|
|
1649
|
+
React.createElement('div', { className: 'dsh-cron-filters' },
|
|
1650
|
+
React.createElement('button', {
|
|
1651
|
+
type: 'button',
|
|
1652
|
+
className: 'dsh-cron-filter-toggle',
|
|
1653
|
+
'aria-expanded': filtersOpen ? 'true' : 'false',
|
|
1654
|
+
onClick: () => setFiltersOpen(!filtersOpen)
|
|
1655
|
+
},
|
|
1656
|
+
T_KEY(t, 'filters.title'),
|
|
1657
|
+
(filterType || filterModel || filterChannel) ? ' •' : ''
|
|
1658
|
+
),
|
|
1659
|
+
filtersOpen && React.createElement('div', { className: 'dsh-cron-filter-panel' },
|
|
1660
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1661
|
+
React.createElement('span', null, T_KEY(t, 'filters.type')),
|
|
1662
|
+
React.createElement('select', {
|
|
1663
|
+
value: filterType,
|
|
1664
|
+
onChange: (e) => setFilterType(e.target.value)
|
|
1665
|
+
},
|
|
1666
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1667
|
+
...filterTypeOptions.map((value) => React.createElement('option', { key: value, value }, value))
|
|
1668
|
+
)
|
|
1669
|
+
),
|
|
1670
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1671
|
+
React.createElement('span', null, T_KEY(t, 'filters.model')),
|
|
1672
|
+
React.createElement('select', {
|
|
1673
|
+
value: filterModel,
|
|
1674
|
+
onChange: (e) => setFilterModel(e.target.value)
|
|
1675
|
+
},
|
|
1676
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1677
|
+
...filterModelOptions.map((value) => React.createElement('option', { key: value, value }, value))
|
|
1678
|
+
)
|
|
1679
|
+
),
|
|
1680
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1681
|
+
React.createElement('span', null, T_KEY(t, 'filters.channel')),
|
|
1682
|
+
React.createElement('select', {
|
|
1683
|
+
value: filterChannel,
|
|
1684
|
+
onChange: (e) => setFilterChannel(e.target.value)
|
|
1685
|
+
},
|
|
1686
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1687
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('option', { key: id, value: id }, T_KEY(t, CHANNEL_LABEL_KEYS[id])))
|
|
1688
|
+
)
|
|
1689
|
+
)
|
|
1690
|
+
)
|
|
1691
|
+
),
|
|
1308
1692
|
fetchError ? React.createElement('div', {
|
|
1309
1693
|
className: 'dsh-cron-status-banner',
|
|
1310
1694
|
'data-type': 'error',
|
|
@@ -1318,15 +1702,38 @@ window.__ModuleLoader__.load({
|
|
|
1318
1702
|
onClick: () => fetchTasks()
|
|
1319
1703
|
}, T_KEY(t, 'banner.retry'))
|
|
1320
1704
|
) : null,
|
|
1705
|
+
(filterType || filterModel || filterChannel) ? React.createElement('div', {
|
|
1706
|
+
className: 'dsh-cron-filter-summary',
|
|
1707
|
+
style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, #888)', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' }
|
|
1708
|
+
},
|
|
1709
|
+
React.createElement('span', null, T_KEY(t, 'filters.showing', { shown: visibleTasks.length, total: tasks.length })),
|
|
1710
|
+
React.createElement('button', {
|
|
1711
|
+
type: 'button',
|
|
1712
|
+
className: 'dsh-cron-btn-secondary',
|
|
1713
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
1714
|
+
onClick: resetFilters
|
|
1715
|
+
}, T_KEY(t, 'filters.reset'))
|
|
1716
|
+
) : null,
|
|
1321
1717
|
React.createElement('div', { className: 'dsh-cron-task-list' },
|
|
1322
1718
|
loading && tasks.length === 0
|
|
1323
1719
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
|
|
1324
1720
|
: tasks.length === 0
|
|
1325
1721
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.empty'))
|
|
1326
|
-
:
|
|
1722
|
+
: visibleTasks.length === 0
|
|
1723
|
+
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px', display: 'flex', gap: '10px', alignItems: 'center' } },
|
|
1724
|
+
React.createElement('span', null, T_KEY(t, 'filters.empty')),
|
|
1725
|
+
React.createElement('button', {
|
|
1726
|
+
type: 'button',
|
|
1727
|
+
className: 'dsh-cron-btn-secondary',
|
|
1728
|
+
style: { padding: '2px 8px', fontSize: '12px' },
|
|
1729
|
+
onClick: resetFilters
|
|
1730
|
+
}, T_KEY(t, 'filters.reset'))
|
|
1731
|
+
)
|
|
1732
|
+
: visibleTasks.map((task) =>
|
|
1327
1733
|
React.createElement('div', {
|
|
1328
1734
|
key: task.id,
|
|
1329
1735
|
className: 'dsh-cron-task-item',
|
|
1736
|
+
'data-task-id': task.id,
|
|
1330
1737
|
role: 'button',
|
|
1331
1738
|
tabIndex: 0,
|
|
1332
1739
|
'aria-label': task.title,
|
|
@@ -1389,6 +1796,20 @@ window.__ModuleLoader__.load({
|
|
|
1389
1796
|
'aria-label': T_KEY(t, 'actions.edit'),
|
|
1390
1797
|
onClick: () => openEditModal(task)
|
|
1391
1798
|
}, React.createElement('span', svgProps(ICON_EDIT))),
|
|
1799
|
+
React.createElement('button', {
|
|
1800
|
+
type: 'button',
|
|
1801
|
+
className: 'dsh-cron-icon-btn',
|
|
1802
|
+
title: T_KEY(t, 'actions.tuneWithDsh'),
|
|
1803
|
+
'aria-label': T_KEY(t, 'actions.tuneWithDsh'),
|
|
1804
|
+
onClick: () => handleTuneWithDsh(task)
|
|
1805
|
+
}, React.createElement('span', svgProps(ICON_TUNE))),
|
|
1806
|
+
React.createElement('button', {
|
|
1807
|
+
type: 'button',
|
|
1808
|
+
className: 'dsh-cron-icon-btn',
|
|
1809
|
+
title: T_KEY(t, 'actions.duplicate'),
|
|
1810
|
+
'aria-label': T_KEY(t, 'actions.duplicate'),
|
|
1811
|
+
onClick: () => handleDuplicate(task)
|
|
1812
|
+
}, React.createElement('span', svgProps(ICON_COPY))),
|
|
1392
1813
|
React.createElement('button', {
|
|
1393
1814
|
type: 'button',
|
|
1394
1815
|
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
@@ -1401,6 +1822,7 @@ window.__ModuleLoader__.load({
|
|
|
1401
1822
|
)
|
|
1402
1823
|
),
|
|
1403
1824
|
React.createElement('div', { className: 'dsh-cron-recs-title' }, T_KEY(t, 'recs.title')),
|
|
1825
|
+
React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '8px' } }, T_KEY(t, 'recs.hint')),
|
|
1404
1826
|
React.createElement('div', { className: 'dsh-cron-recs-list' },
|
|
1405
1827
|
recs.map((rec) =>
|
|
1406
1828
|
React.createElement('div', {
|
|
@@ -1487,6 +1909,35 @@ window.__ModuleLoader__.load({
|
|
|
1487
1909
|
React.createElement('span', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', fontSize: '11.5px' } }, (run.durationMs || 0) + ' ms')
|
|
1488
1910
|
)
|
|
1489
1911
|
),
|
|
1912
|
+
// #43: a diagnosed failure shows what went wrong and
|
|
1913
|
+
// offers to prefill the prompt with the suggestion.
|
|
1914
|
+
run.diagnosis && React.createElement('div', {
|
|
1915
|
+
className: 'dsh-cron-status-banner',
|
|
1916
|
+
'data-type': 'info',
|
|
1917
|
+
style: { marginTop: '8px', display: 'flex', flexDirection: 'column', gap: '6px' }
|
|
1918
|
+
},
|
|
1919
|
+
React.createElement('div', null,
|
|
1920
|
+
React.createElement('strong', null, T_KEY(t, 'history.diagnosis')),
|
|
1921
|
+
run.confidence ? ` (${run.confidence})` : ''
|
|
1922
|
+
),
|
|
1923
|
+
React.createElement('div', null, run.diagnosis),
|
|
1924
|
+
run.suggestion && React.createElement('div', null,
|
|
1925
|
+
React.createElement('button', {
|
|
1926
|
+
type: 'button',
|
|
1927
|
+
className: 'dsh-cron-btn-secondary',
|
|
1928
|
+
style: { padding: '2px 8px', fontSize: '12px' },
|
|
1929
|
+
onClick: () => openEditModal(selectedTaskMeta, { prompt: run.suggestion })
|
|
1930
|
+
}, T_KEY(t, 'history.applySuggestion'))
|
|
1931
|
+
)
|
|
1932
|
+
),
|
|
1933
|
+
// #44: a run suppressed by its silent rule says why.
|
|
1934
|
+
run.silentSkip && React.createElement('div', {
|
|
1935
|
+
className: 'dsh-cron-hint',
|
|
1936
|
+
style: { display: 'flex', gap: '6px', alignItems: 'center' }
|
|
1937
|
+
},
|
|
1938
|
+
React.createElement('span', { className: 'dsh-cron-badge-ref' }, T_KEY(t, 'history.silentSkip')),
|
|
1939
|
+
React.createElement('span', null, run.silentReason || '')
|
|
1940
|
+
),
|
|
1490
1941
|
run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
|
|
1491
1942
|
run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
|
|
1492
1943
|
run.sessionId && React.createElement('button', {
|
|
@@ -1649,7 +2100,28 @@ window.__ModuleLoader__.load({
|
|
|
1649
2100
|
React.createElement('option', { value: '' }, T_KEY(t, 'form.modelDefault')),
|
|
1650
2101
|
models.map((m) => React.createElement('option', { key: m.id, value: m.id }, m.name || m.id))
|
|
1651
2102
|
)
|
|
1652
|
-
)
|
|
2103
|
+
),
|
|
2104
|
+
// #45: one retry on a stronger model when the cheap one fails.
|
|
2105
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2106
|
+
React.createElement('label', null, T_KEY(t, 'form.fallbackModelLabel')),
|
|
2107
|
+
React.createElement('input', {
|
|
2108
|
+
type: 'text',
|
|
2109
|
+
value: formFallbackModel,
|
|
2110
|
+
placeholder: T_KEY(t, 'form.fallbackModelPlaceholder'),
|
|
2111
|
+
onChange: (e) => setFormFallbackModel(e.target.value)
|
|
2112
|
+
}),
|
|
2113
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.fallbackModelHint'))
|
|
2114
|
+
),
|
|
2115
|
+
// #43: diagnose failures with a model.
|
|
2116
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginTop: '4px' } },
|
|
2117
|
+
React.createElement('input', {
|
|
2118
|
+
type: 'checkbox',
|
|
2119
|
+
checked: formInspectOnFailure,
|
|
2120
|
+
onChange: (e) => setFormInspectOnFailure(e.target.checked)
|
|
2121
|
+
}),
|
|
2122
|
+
React.createElement('span', null, T_KEY(t, 'form.inspectOnFailureLabel'))
|
|
2123
|
+
),
|
|
2124
|
+
formInspectOnFailure && React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.inspectOnFailureHint'))
|
|
1653
2125
|
),
|
|
1654
2126
|
formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1655
2127
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
@@ -1871,6 +2343,17 @@ window.__ModuleLoader__.load({
|
|
|
1871
2343
|
onChange: (e) => setFormTemplate(e.target.value)
|
|
1872
2344
|
}),
|
|
1873
2345
|
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
|
|
2346
|
+
),
|
|
2347
|
+
// #44: output-producing runtimes may stay silent by rule.
|
|
2348
|
+
!AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
|
|
2349
|
+
React.createElement('label', null, T_KEY(t, 'form.silentRuleLabel')),
|
|
2350
|
+
React.createElement('input', {
|
|
2351
|
+
type: 'text',
|
|
2352
|
+
value: formSilentRule,
|
|
2353
|
+
placeholder: T_KEY(t, 'form.silentRulePlaceholder'),
|
|
2354
|
+
onChange: (e) => setFormSilentRule(e.target.value)
|
|
2355
|
+
}),
|
|
2356
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.silentRuleHint'))
|
|
1874
2357
|
)
|
|
1875
2358
|
),
|
|
1876
2359
|
selectedTaskMeta && React.createElement('div', {
|
|
@@ -2003,7 +2486,50 @@ window.__ModuleLoader__.load({
|
|
|
2003
2486
|
)
|
|
2004
2487
|
),
|
|
2005
2488
|
|
|
2006
|
-
// 3.
|
|
2489
|
+
// 3. Import modal (#42) — summary first, then an explicit strategy.
|
|
2490
|
+
importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
|
|
2491
|
+
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
2492
|
+
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
|
|
2493
|
+
React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '10px' } }, T_KEY(t, 'transfer.importHint')),
|
|
2494
|
+
importSummary && React.createElement('div', null,
|
|
2495
|
+
React.createElement('div', { style: { fontSize: '13px', marginBottom: '12px' } },
|
|
2496
|
+
T_KEY(t, 'transfer.summary', {
|
|
2497
|
+
total: (importSummary.add || 0) + (importSummary.replace || 0) + (importSummary.skip || 0),
|
|
2498
|
+
add: importSummary.add || 0,
|
|
2499
|
+
replace: importSummary.replace || 0,
|
|
2500
|
+
skip: importSummary.skip || 0
|
|
2501
|
+
})
|
|
2502
|
+
),
|
|
2503
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '14px' } },
|
|
2504
|
+
...['add', 'replace', 'skip'].map((mode) => React.createElement('label', { key: mode, className: 'dsh-cron-checkbox-row' },
|
|
2505
|
+
React.createElement('input', {
|
|
2506
|
+
type: 'radio',
|
|
2507
|
+
name: 'dsh-cron-import-strategy',
|
|
2508
|
+
checked: importStrategy === mode,
|
|
2509
|
+
onChange: () => setImportStrategy(mode)
|
|
2510
|
+
}),
|
|
2511
|
+
React.createElement('span', null, T_KEY(t, mode === 'add' ? 'transfer.strategyAdd' : mode === 'replace' ? 'transfer.strategyReplace' : 'transfer.strategySkip'))
|
|
2512
|
+
))
|
|
2513
|
+
)
|
|
2514
|
+
),
|
|
2515
|
+
React.createElement('div', { className: 'dsh-cron-modal-foot' },
|
|
2516
|
+
React.createElement('button', {
|
|
2517
|
+
type: 'button',
|
|
2518
|
+
className: 'dsh-cron-btn-secondary',
|
|
2519
|
+
disabled: transferBusy,
|
|
2520
|
+
onClick: () => setImportDoc(null)
|
|
2521
|
+
}, T_KEY(t, 'modal.cancel')),
|
|
2522
|
+
React.createElement('button', {
|
|
2523
|
+
type: 'button',
|
|
2524
|
+
className: 'dsh-cron-btn-primary',
|
|
2525
|
+
disabled: transferBusy || !importSummary,
|
|
2526
|
+
onClick: handleImportConfirm
|
|
2527
|
+
}, T_KEY(t, 'transfer.confirm'))
|
|
2528
|
+
)
|
|
2529
|
+
)
|
|
2530
|
+
),
|
|
2531
|
+
|
|
2532
|
+
// 4. "Create with DSH" modal
|
|
2007
2533
|
dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
|
|
2008
2534
|
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
2009
2535
|
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
|
|
@@ -2322,6 +2848,162 @@ window.__ModuleLoader__.load({
|
|
|
2322
2848
|
return entry;
|
|
2323
2849
|
}
|
|
2324
2850
|
|
|
2851
|
+
/**
|
|
2852
|
+
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
2853
|
+
*
|
|
2854
|
+
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
2855
|
+
* Collapsed by default, state persisted, capped at a few rows with an
|
|
2856
|
+
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
2857
|
+
* off screen. Clicking a row opens the panel and highlights that task.
|
|
2858
|
+
*/
|
|
2859
|
+
const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
|
|
2860
|
+
const SIDEBAR_JOBS_MAX_ROWS = 5;
|
|
2861
|
+
|
|
2862
|
+
/** Sidebar job row: title plus next run time or the live running state. */
|
|
2863
|
+
function buildSidebarJobRow(state, task) {
|
|
2864
|
+
const row = document.createElement('button');
|
|
2865
|
+
row.type = 'button';
|
|
2866
|
+
row.className = 'dsh-cron-sidebar-job';
|
|
2867
|
+
// A distinct attribute: the panel uses data-task-id, and the highlight
|
|
2868
|
+
// lookup must never pick a sidebar row instead of a task card.
|
|
2869
|
+
row.setAttribute('data-sidebar-task-id', task.id);
|
|
2870
|
+
row.setAttribute('aria-label', task.title || task.id);
|
|
2871
|
+
|
|
2872
|
+
const title = document.createElement('span');
|
|
2873
|
+
title.className = 'dsh-cron-sidebar-job-title';
|
|
2874
|
+
title.textContent = task.title || task.id;
|
|
2875
|
+
row.appendChild(title);
|
|
2876
|
+
|
|
2877
|
+
const meta = document.createElement('span');
|
|
2878
|
+
meta.className = 'dsh-cron-sidebar-job-meta';
|
|
2879
|
+
if (task.running) meta.textContent = T('list.runningNow');
|
|
2880
|
+
else if (task.nextRunAt) meta.textContent = new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2881
|
+
else meta.textContent = task.scheduleText || task.schedule || '';
|
|
2882
|
+
row.appendChild(meta);
|
|
2883
|
+
|
|
2884
|
+
if (task.running) row.classList.add('dsh-cron-pulse');
|
|
2885
|
+
row.addEventListener('click', (event) => {
|
|
2886
|
+
event.preventDefault();
|
|
2887
|
+
event.stopPropagation();
|
|
2888
|
+
state.openTaskSignal(task.id);
|
|
2889
|
+
if (!state.toggle.isOpen()) state.toggle.toggle();
|
|
2890
|
+
});
|
|
2891
|
+
return row;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function renderSidebarJobs(state, tasks) {
|
|
2895
|
+
const listEl = state.listEl;
|
|
2896
|
+
if (!listEl) return;
|
|
2897
|
+
listEl.innerHTML = '';
|
|
2898
|
+
if (!tasks.length) {
|
|
2899
|
+
const empty = document.createElement('div');
|
|
2900
|
+
empty.className = 'dsh-cron-sidebar-empty';
|
|
2901
|
+
empty.textContent = T('sidebar.noActiveJobs');
|
|
2902
|
+
listEl.appendChild(empty);
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
for (const task of tasks.slice(0, SIDEBAR_JOBS_MAX_ROWS)) listEl.appendChild(buildSidebarJobRow(state, task));
|
|
2906
|
+
if (tasks.length > SIDEBAR_JOBS_MAX_ROWS) {
|
|
2907
|
+
const more = document.createElement('div');
|
|
2908
|
+
more.className = 'dsh-cron-sidebar-more';
|
|
2909
|
+
more.textContent = T('sidebar.moreJobs', { count: tasks.length - SIDEBAR_JOBS_MAX_ROWS });
|
|
2910
|
+
listEl.appendChild(more);
|
|
2911
|
+
}
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
async function loadSidebarJobs(state) {
|
|
2915
|
+
try {
|
|
2916
|
+
const res = await fetch('/dsh-cron/tasks?status=active');
|
|
2917
|
+
const data = await res.json();
|
|
2918
|
+
if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
|
|
2919
|
+
} catch (_) {}
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
function applySidebarJobsOpen(state) {
|
|
2923
|
+
if (!state.root) return;
|
|
2924
|
+
state.root.dataset.open = state.open ? 'true' : 'false';
|
|
2925
|
+
if (state.headEl) state.headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
|
|
2926
|
+
if (state.open) loadSidebarJobs(state);
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
function buildSidebarJobsRoot(state, label) {
|
|
2930
|
+
const root = document.createElement('div');
|
|
2931
|
+
root.className = 'dsh-cron-sidebar-jobs';
|
|
2932
|
+
root.setAttribute('data-dsh-plugin', 'dsh-cron');
|
|
2933
|
+
root.setAttribute('data-dsh-part', 'sidebar-jobs');
|
|
2934
|
+
|
|
2935
|
+
const headEl = document.createElement('button');
|
|
2936
|
+
headEl.type = 'button';
|
|
2937
|
+
headEl.className = 'dsh-cron-sidebar-jobs-head';
|
|
2938
|
+
headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
|
|
2939
|
+
headEl.textContent = label;
|
|
2940
|
+
headEl.addEventListener('click', (event) => {
|
|
2941
|
+
event.preventDefault();
|
|
2942
|
+
event.stopPropagation();
|
|
2943
|
+
state.open = !state.open;
|
|
2944
|
+
try { window.localStorage.setItem(SIDEBAR_JOBS_KEY, state.open ? '1' : '0'); } catch (_) {}
|
|
2945
|
+
applySidebarJobsOpen(state);
|
|
2946
|
+
});
|
|
2947
|
+
root.appendChild(headEl);
|
|
2948
|
+
|
|
2949
|
+
const listEl = document.createElement('div');
|
|
2950
|
+
listEl.className = 'dsh-cron-sidebar-jobs-list';
|
|
2951
|
+
root.appendChild(listEl);
|
|
2952
|
+
|
|
2953
|
+
state.root = root;
|
|
2954
|
+
state.headEl = headEl;
|
|
2955
|
+
state.listEl = listEl;
|
|
2956
|
+
return root;
|
|
2957
|
+
}
|
|
2958
|
+
|
|
2959
|
+
/** Keep the section mounted under the sidebar entry across re-renders. */
|
|
2960
|
+
function placeSidebarJobs(state, label) {
|
|
2961
|
+
if (state.root !== undefined && state.root.isConnected) return;
|
|
2962
|
+
const entry = document.querySelector('[' + ENTRY_ATTR + ']');
|
|
2963
|
+
if (entry === null || entry.parentElement === null) return;
|
|
2964
|
+
if (state.root === undefined) {
|
|
2965
|
+
entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
|
|
2966
|
+
} else {
|
|
2967
|
+
entry.insertAdjacentElement('afterend', state.root);
|
|
2968
|
+
// Re-query the refs: the previous nodes may belong to a detached tree.
|
|
2969
|
+
state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
|
|
2970
|
+
state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
|
|
2971
|
+
state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
|
|
2972
|
+
}
|
|
2973
|
+
applySidebarJobsOpen(state);
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
/**
|
|
2977
|
+
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
2978
|
+
*
|
|
2979
|
+
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
2980
|
+
* Collapsed by default, state persisted, capped at a few rows with an
|
|
2981
|
+
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
2982
|
+
* off screen. Clicking a row opens the panel and highlights that task.
|
|
2983
|
+
*/
|
|
2984
|
+
function mountSidebarJobs(toggle, label, openTaskSignal) {
|
|
2985
|
+
if (typeof document === 'undefined') return () => {};
|
|
2986
|
+
const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
|
|
2987
|
+
try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
|
|
2988
|
+
|
|
2989
|
+
const observer = new MutationObserver(() => placeSidebarJobs(state, label));
|
|
2990
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
2991
|
+
placeSidebarJobs(state, label);
|
|
2992
|
+
loadSidebarJobs(state);
|
|
2993
|
+
const fetchTimer = setInterval(() => {
|
|
2994
|
+
let isOpen = false;
|
|
2995
|
+
try { isOpen = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
|
|
2996
|
+
if (isOpen) loadSidebarJobs(state);
|
|
2997
|
+
}, 30000);
|
|
2998
|
+
|
|
2999
|
+
return () => {
|
|
3000
|
+
observer.disconnect();
|
|
3001
|
+
clearInterval(fetchTimer);
|
|
3002
|
+
if (state.root !== undefined && state.root.isConnected) state.root.remove();
|
|
3003
|
+
state.root = undefined;
|
|
3004
|
+
};
|
|
3005
|
+
}
|
|
3006
|
+
|
|
2325
3007
|
function placeEntry(target, entry) {
|
|
2326
3008
|
let anchor = target;
|
|
2327
3009
|
let next = target.nextElementSibling;
|
|
@@ -2504,6 +3186,7 @@ window.__ModuleLoader__.load({
|
|
|
2504
3186
|
ctx.effect(() => {
|
|
2505
3187
|
const off = [
|
|
2506
3188
|
mountSidebarEntry(toggle, translate('sidebar.label')),
|
|
3189
|
+
mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
|
|
2507
3190
|
mountCronScreen(ctx, toggle)
|
|
2508
3191
|
];
|
|
2509
3192
|
return () => { for (const dispose of off) dispose(); };
|