@goodandready/dsh-cron 0.2.4 → 0.2.5
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 +9 -15
- package/docs/README.ru.md +10 -15
- package/docs/README.zh.md +10 -15
- package/docs/design/DESIGN.md +8 -4
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +51 -61
- package/lib/client.js +573 -40
- package/lib/index.js +159 -80
- package/lib/store.js +5 -5
- package/lib/task-transfer.js +226 -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,9 @@ 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_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>';
|
|
40
|
+
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
41
|
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
42
|
|
|
37
43
|
// Locale dictionary: English is the canonical source language (Issue #87).
|
|
@@ -40,6 +46,9 @@ window.__ModuleLoader__.load({
|
|
|
40
46
|
const STRINGS = {
|
|
41
47
|
en: {
|
|
42
48
|
'sidebar.label': 'Scheduled tasks',
|
|
49
|
+
'sidebar.jobsTitle': 'Active jobs',
|
|
50
|
+
'sidebar.noActiveJobs': 'No active jobs',
|
|
51
|
+
'sidebar.moreJobs': 'and {count} more',
|
|
43
52
|
'chip.title': 'Scheduled tasks (Cron)',
|
|
44
53
|
'panel.back': 'Back to chat',
|
|
45
54
|
'panel.title': 'Scheduled tasks',
|
|
@@ -50,11 +59,37 @@ window.__ModuleLoader__.load({
|
|
|
50
59
|
'actions.runNow': 'Run now',
|
|
51
60
|
'actions.runNowShort': 'Run',
|
|
52
61
|
'actions.edit': 'Edit / Details',
|
|
62
|
+
'actions.duplicate': 'Duplicate task',
|
|
53
63
|
'actions.delete': 'Delete task',
|
|
64
|
+
'duplicate.done': 'Copy created and paused: review its schedule before resuming it.',
|
|
65
|
+
'duplicate.oneShotHint': 'The copy is one-shot and paused — its original time has likely passed, so set a new time before resuming.',
|
|
66
|
+
'duplicate.failed': 'Could not duplicate the task: {error}',
|
|
67
|
+
'transfer.exportTitle': 'Export tasks',
|
|
68
|
+
'transfer.importTitle': 'Import tasks',
|
|
69
|
+
'transfer.exported': 'Exported {count} task(s).',
|
|
70
|
+
'transfer.exportFailed': 'Export failed: {error}',
|
|
71
|
+
'transfer.importTitleModal': 'Import tasks',
|
|
72
|
+
'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.',
|
|
73
|
+
'transfer.strategyAdd': 'Add as new tasks (new ids, existing ones stay untouched)',
|
|
74
|
+
'transfer.strategyReplace': 'Replace tasks with the same id',
|
|
75
|
+
'transfer.strategySkip': 'Skip tasks whose id already exists',
|
|
76
|
+
'transfer.summary': 'This file contains {total} task(s): {add} new, {replace} matching an existing id, {skip} already skipped.',
|
|
77
|
+
'transfer.confirm': 'Import',
|
|
78
|
+
'transfer.imported': 'Imported {count} task(s).',
|
|
79
|
+
'transfer.importFailed': 'Import failed: {error}',
|
|
80
|
+
'transfer.badFile': 'The selected file is not a valid JSON export: {error}',
|
|
54
81
|
'actions.pause': 'Pause',
|
|
55
82
|
'actions.resume': 'Resume',
|
|
56
83
|
'actions.settings': 'Notifications & channels',
|
|
57
84
|
'search.placeholder': 'Search scheduled tasks',
|
|
85
|
+
'filters.title': 'Filters',
|
|
86
|
+
'filters.type': 'Type',
|
|
87
|
+
'filters.model': 'Model',
|
|
88
|
+
'filters.channel': 'Channel',
|
|
89
|
+
'filters.any': 'Any',
|
|
90
|
+
'filters.reset': 'Reset filters',
|
|
91
|
+
'filters.empty': 'No task matches the current filters.',
|
|
92
|
+
'filters.showing': 'Showing {shown} of {total}',
|
|
58
93
|
'stats.activeTasks': 'Active tasks',
|
|
59
94
|
'stats.totalRuns': 'Total runs',
|
|
60
95
|
'stats.totalTokens': 'Tokens spent',
|
|
@@ -168,7 +203,6 @@ window.__ModuleLoader__.load({
|
|
|
168
203
|
'form.channelNtfy': 'ntfy',
|
|
169
204
|
'form.channelBark': 'Bark',
|
|
170
205
|
'form.channelPushplus': 'PushPlus',
|
|
171
|
-
'form.channelEmail': 'Email',
|
|
172
206
|
'form.channelTts': 'Voice (dsh-tts)',
|
|
173
207
|
'form.channelGitea': 'Gitea issue',
|
|
174
208
|
'settings.deliverySection': 'Delivery channels',
|
|
@@ -188,13 +222,6 @@ window.__ModuleLoader__.load({
|
|
|
188
222
|
'settings.barkKeyLabel': 'Bark device key',
|
|
189
223
|
'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
|
|
190
224
|
'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
225
|
'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
|
|
199
226
|
'settings.giteaBaseUrlLabel': 'Gitea base URL',
|
|
200
227
|
'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
|
|
@@ -209,7 +236,7 @@ window.__ModuleLoader__.load({
|
|
|
209
236
|
'settings.unavailable': 'Settings are unavailable right now.',
|
|
210
237
|
'settings.retry': 'Retry',
|
|
211
238
|
'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,
|
|
239
|
+
'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
240
|
'settings.openPanel': 'Open task panel',
|
|
214
241
|
'settings.modalTitle': 'Notifications & delivery channels',
|
|
215
242
|
'settings.tokenLabel': 'Telegram Bot Token',
|
|
@@ -307,7 +334,7 @@ window.__ModuleLoader__.load({
|
|
|
307
334
|
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
308
335
|
|
|
309
336
|
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
310
|
-
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', '
|
|
337
|
+
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
311
338
|
const CHANNEL_LABEL_KEYS = {
|
|
312
339
|
telegram: 'form.channelTelegram',
|
|
313
340
|
kanban: 'form.channelKanban',
|
|
@@ -316,7 +343,6 @@ window.__ModuleLoader__.load({
|
|
|
316
343
|
ntfy: 'form.channelNtfy',
|
|
317
344
|
bark: 'form.channelBark',
|
|
318
345
|
pushplus: 'form.channelPushplus',
|
|
319
|
-
email: 'form.channelEmail',
|
|
320
346
|
tts: 'form.channelTts',
|
|
321
347
|
gitea: 'form.channelGitea',
|
|
322
348
|
};
|
|
@@ -327,7 +353,6 @@ window.__ModuleLoader__.load({
|
|
|
327
353
|
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
328
354
|
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
329
355
|
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
330
|
-
{ key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
|
|
331
356
|
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
332
357
|
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
333
358
|
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
@@ -336,20 +361,12 @@ window.__ModuleLoader__.load({
|
|
|
336
361
|
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
337
362
|
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
338
363
|
{ 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
364
|
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
345
365
|
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
346
366
|
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
347
|
-
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
|
|
367
|
+
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
348
368
|
];
|
|
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));
|
|
369
|
+
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
353
370
|
// The global template plus the per-channel override map (server model).
|
|
354
371
|
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
355
372
|
|
|
@@ -376,11 +393,16 @@ window.__ModuleLoader__.load({
|
|
|
376
393
|
React.createElement('label', null, T(f.labelKey)),
|
|
377
394
|
React.createElement('input', f.numeric ? {
|
|
378
395
|
type: 'number',
|
|
379
|
-
min: 1000,
|
|
396
|
+
min: f.min !== undefined ? f.min : 1000,
|
|
380
397
|
step: 1000,
|
|
381
398
|
value: val(f.key),
|
|
382
|
-
placeholder:
|
|
383
|
-
|
|
399
|
+
placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
|
|
400
|
+
// #115: clearing the field must send the explicit default. Deleting the
|
|
401
|
+
// key would leave the previously stored value in place (the settings
|
|
402
|
+
// scope skips absent keys), so the field would silently keep 1 ms.
|
|
403
|
+
onChange: (e) => onChange(f.key, e.target.value === ''
|
|
404
|
+
? (f.defaultValue !== undefined ? f.defaultValue : 15000)
|
|
405
|
+
: Number(e.target.value)),
|
|
384
406
|
} : {
|
|
385
407
|
type: 'text',
|
|
386
408
|
value: val(f.key),
|
|
@@ -413,14 +435,6 @@ window.__ModuleLoader__.load({
|
|
|
413
435
|
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
414
436
|
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
415
437
|
),
|
|
416
|
-
...DELIVERY_TOGGLE_FIELDS.map((f) => React.createElement('label', { className: 'dsh-cron-checkbox-row', key: f.key, style: { marginBottom: '10px' } },
|
|
417
|
-
React.createElement('input', {
|
|
418
|
-
type: 'checkbox',
|
|
419
|
-
checked: Boolean(values[f.key]),
|
|
420
|
-
onChange: (e) => onChange(f.key, e.target.checked)
|
|
421
|
-
}),
|
|
422
|
-
React.createElement('span', null, T(f.labelKey))
|
|
423
|
-
)),
|
|
424
438
|
]),
|
|
425
439
|
section('templates', 'settings.templatesSection', [
|
|
426
440
|
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
@@ -523,6 +537,12 @@ window.__ModuleLoader__.load({
|
|
|
523
537
|
.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
538
|
.dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
|
|
525
539
|
.dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
|
|
540
|
+
.dsh-cron-filters { margin-top: 8px; }
|
|
541
|
+
.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; }
|
|
542
|
+
.dsh-cron-filter-toggle:hover { color: var(--dsw-alias-label-primary, #eee); border-color: var(--dsh-cron-info); }
|
|
543
|
+
.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)); }
|
|
544
|
+
.dsh-cron-filter-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); }
|
|
545
|
+
.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
546
|
.dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
527
547
|
.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
548
|
.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 +646,51 @@ window.__ModuleLoader__.load({
|
|
|
626
646
|
.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
647
|
@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
648
|
.dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
649
|
+
|
|
650
|
+
/* #34: sidebar job list */
|
|
651
|
+
.dsh-cron-sidebar-jobs { display: flex; flex-direction: column; gap: 2px; margin: 2px 0 6px 0; }
|
|
652
|
+
.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; }
|
|
653
|
+
.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)); }
|
|
654
|
+
.dsh-cron-sidebar-jobs-head::after { content: '▾'; font-size: 10px; transition: transform 0.15s ease; }
|
|
655
|
+
.dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-head::after { transform: rotate(-90deg); }
|
|
656
|
+
.dsh-cron-sidebar-jobs[data-open="false"] .dsh-cron-sidebar-jobs-list { display: none; }
|
|
657
|
+
.dsh-cron-sidebar-jobs-list { display: flex; flex-direction: column; gap: 2px; padding-left: 10px; }
|
|
658
|
+
.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; }
|
|
659
|
+
.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); }
|
|
660
|
+
.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; }
|
|
661
|
+
.dsh-cron-sidebar-job-meta { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); }
|
|
662
|
+
.dsh-cron-sidebar-empty, .dsh-cron-sidebar-more { font-size: 11px; color: var(--dsw-alias-label-tertiary, #777); padding: 2px 8px; }
|
|
663
|
+
.dsh-cron-task-highlight { border-color: var(--dsh-cron-info) !important; box-shadow: 0 0 0 2px var(--dsh-cron-info-bg); }
|
|
664
|
+
|
|
665
|
+
/* #39: narrow screens. Only layout changes — no information is hidden. */
|
|
666
|
+
@media (max-width: 900px) {
|
|
667
|
+
.dsh-cron-container { padding: 8px 12px 24px; gap: 14px; }
|
|
668
|
+
.dsh-cron-form-row { grid-template-columns: 1fr; }
|
|
669
|
+
.dsh-cron-channel-grid { grid-template-columns: 1fr; }
|
|
670
|
+
.dsh-cron-filter-panel { grid-template-columns: 1fr; }
|
|
671
|
+
.dsh-cron-modal { padding: 16px; border-radius: 12px; }
|
|
672
|
+
.dsh-cron-modal-scroll { max-height: 92vh; }
|
|
673
|
+
.dsh-cron-task-item { flex-wrap: wrap; gap: 10px; }
|
|
674
|
+
.dsh-cron-task-actions { margin-left: auto; }
|
|
675
|
+
.dsh-cron-history-list { max-height: 320px; }
|
|
676
|
+
}
|
|
677
|
+
@media (max-width: 640px) {
|
|
678
|
+
.dsh-cron-container { padding: 6px 8px 20px; }
|
|
679
|
+
.dsh-cron-top-bar { flex-wrap: wrap; gap: 8px; }
|
|
680
|
+
.dsh-cron-tabs { flex-wrap: nowrap; overflow-x: auto; padding-bottom: 4px; }
|
|
681
|
+
.dsh-cron-tab { white-space: nowrap; }
|
|
682
|
+
.dsh-cron-stats-bar { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
|
|
683
|
+
.dsh-cron-task-item { align-items: flex-start; padding: 12px; }
|
|
684
|
+
.dsh-cron-task-title { flex-wrap: wrap; row-gap: 4px; }
|
|
685
|
+
.dsh-cron-modal { padding: 14px; }
|
|
686
|
+
.dsh-cron-modal-foot { flex-direction: column-reverse; }
|
|
687
|
+
.dsh-cron-modal-foot > button { width: 100%; }
|
|
688
|
+
.dsh-cron-filter-panel { padding: 8px 10px; }
|
|
689
|
+
.dsh-cron-card { padding: 14px; }
|
|
690
|
+
/* 16px keeps iOS Safari from zooming the page when a field is focused. */
|
|
691
|
+
.dsh-cron-modal input, .dsh-cron-modal select, .dsh-cron-modal textarea,
|
|
692
|
+
.dsh-cron-card input, .dsh-cron-search-input, .dsh-cron-filter-field select { font-size: 16px; }
|
|
693
|
+
}
|
|
629
694
|
`;
|
|
630
695
|
|
|
631
696
|
function ensureStyles() {
|
|
@@ -722,12 +787,25 @@ window.__ModuleLoader__.load({
|
|
|
722
787
|
const t = props.t || translate;
|
|
723
788
|
const [tab, setTab] = React.useState('all');
|
|
724
789
|
const [query, setQuery] = React.useState('');
|
|
790
|
+
// Facet filters (#40): applied client-side over the already-loaded list so
|
|
791
|
+
// toggling them is instant and does not touch the polling request.
|
|
792
|
+
const [filterType, setFilterType] = React.useState('');
|
|
793
|
+
const [filterModel, setFilterModel] = React.useState('');
|
|
794
|
+
const [filterChannel, setFilterChannel] = React.useState('');
|
|
795
|
+
const [filtersOpen, setFiltersOpen] = React.useState(false);
|
|
725
796
|
const [tasks, setTasks] = React.useState([]);
|
|
726
797
|
const [recs, setRecs] = React.useState([]);
|
|
727
798
|
const [loading, setLoading] = React.useState(false);
|
|
728
799
|
const [fetchError, setFetchError] = React.useState(null);
|
|
729
800
|
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
|
730
801
|
|
|
802
|
+
// Export/import state (#42)
|
|
803
|
+
const [transferBusy, setTransferBusy] = React.useState(false);
|
|
804
|
+
const [importDoc, setImportDoc] = React.useState(null);
|
|
805
|
+
const [importSummary, setImportSummary] = React.useState(null);
|
|
806
|
+
const [importStrategy, setImportStrategy] = React.useState('skip');
|
|
807
|
+
const importFileRef = React.useRef(null);
|
|
808
|
+
|
|
731
809
|
// Manual / Edit modal state
|
|
732
810
|
const [manualModalOpen, setManualModalOpen] = React.useState(false);
|
|
733
811
|
const [formId, setFormId] = React.useState(null);
|
|
@@ -824,6 +902,49 @@ window.__ModuleLoader__.load({
|
|
|
824
902
|
return () => window.removeEventListener('keydown', onKey);
|
|
825
903
|
}, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
|
|
826
904
|
|
|
905
|
+
/** #40: facet filtering happens client-side over the loaded list. */
|
|
906
|
+
const visibleTasks = React.useMemo(() => { let list = tasks;
|
|
907
|
+
if (filterType) list = list.filter((x) => (x.type || 'llm') === filterType);
|
|
908
|
+
if (filterModel) list = list.filter((x) => String(x.model || '').split('/').pop() === filterModel);
|
|
909
|
+
if (filterChannel) {
|
|
910
|
+
list = list.filter((x) => {
|
|
911
|
+
const channels = Array.isArray(x.channels) && x.channels.length
|
|
912
|
+
? x.channels
|
|
913
|
+
: [x.notifyTelegram ? 'telegram' : null, (x.kanbanMode || 'none') !== 'none' ? 'kanban' : null].filter(Boolean);
|
|
914
|
+
return channels.includes(filterChannel);
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
return list;
|
|
918
|
+
}, [tasks, filterType, filterModel, filterChannel]);
|
|
919
|
+
|
|
920
|
+
const filterTypeOptions = React.useMemo(
|
|
921
|
+
() => Array.from(new Set(tasks.map((x) => x.type || 'llm'))).sort(),
|
|
922
|
+
[tasks]
|
|
923
|
+
);
|
|
924
|
+
|
|
925
|
+
const filterModelOptions = React.useMemo(
|
|
926
|
+
() => Array.from(new Set(tasks.map((x) => String(x.model || '').split('/').pop()).filter(Boolean))).sort(),
|
|
927
|
+
[tasks]
|
|
928
|
+
);
|
|
929
|
+
|
|
930
|
+
const resetFilters = () => {
|
|
931
|
+
setFilterType('');
|
|
932
|
+
setFilterModel('');
|
|
933
|
+
setFilterChannel('');
|
|
934
|
+
};
|
|
935
|
+
|
|
936
|
+
/** #34: scroll to and flash the task the sidebar list asked for. */
|
|
937
|
+
React.useEffect(() => {
|
|
938
|
+
if (!pendingTaskHighlight || tasks.length === 0) return;
|
|
939
|
+
const id = pendingTaskHighlight;
|
|
940
|
+
pendingTaskHighlight = null;
|
|
941
|
+
const node = document.querySelector('[data-task-id="' + id + '"]');
|
|
942
|
+
if (!node) return;
|
|
943
|
+
if (typeof node.scrollIntoView === 'function') node.scrollIntoView({ block: 'center' });
|
|
944
|
+
node.classList.add('dsh-cron-task-highlight');
|
|
945
|
+
setTimeout(() => { try { node.classList.remove('dsh-cron-task-highlight'); } catch (_) {} }, 2500);
|
|
946
|
+
}, [tasks]);
|
|
947
|
+
|
|
827
948
|
const fetchTasks = async () => {
|
|
828
949
|
setLoading(true);
|
|
829
950
|
setFetchError(null);
|
|
@@ -868,8 +989,7 @@ window.__ModuleLoader__.load({
|
|
|
868
989
|
|
|
869
990
|
React.useEffect(() => {
|
|
870
991
|
fetchTasks();
|
|
871
|
-
const pollInterval = setInterval(() => {
|
|
872
|
-
// Background polling without resetting loading spinner
|
|
992
|
+
const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
|
|
873
993
|
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
|
|
874
994
|
.then(res => res.json())
|
|
875
995
|
.then(data => {
|
|
@@ -902,13 +1022,128 @@ window.__ModuleLoader__.load({
|
|
|
902
1022
|
}
|
|
903
1023
|
};
|
|
904
1024
|
|
|
1025
|
+
/** #41: duplicate a task; the server copies config, resets run state (#41). */
|
|
1026
|
+
const handleDuplicate = async (task) => {
|
|
1027
|
+
try {
|
|
1028
|
+
const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(task.id) + '/duplicate', { method: 'POST' });
|
|
1029
|
+
const data = await res.json();
|
|
1030
|
+
if (!data.ok) {
|
|
1031
|
+
alert(T_KEY(t, 'duplicate.failed', { error: (data && data.error) || '' }));
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
await fetchTasks();
|
|
1035
|
+
// A one-shot copy keeps its original schedule string, so warn instead
|
|
1036
|
+
// of letting the user resume a task pointing at a past moment.
|
|
1037
|
+
alert(task.oneShot ? T_KEY(t, 'duplicate.oneShotHint') : T_KEY(t, 'duplicate.done'));
|
|
1038
|
+
} catch (err) {
|
|
1039
|
+
alert(T_KEY(t, 'duplicate.failed', { error: err.message }));
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
|
|
1043
|
+
/** A task id must not shadow a collection route (#42 review finding). */
|
|
1044
|
+
const RESERVED_TASK_IDS = ['export', 'import'];
|
|
1045
|
+
|
|
1046
|
+
/** Import needs the script-confirm header when the file has code tasks. */
|
|
1047
|
+
const importHeaders = (document_) => {
|
|
1048
|
+
const headers = { 'Content-Type': 'application/json' };
|
|
1049
|
+
const tasks = (document_ && Array.isArray(document_.tasks)) ? document_.tasks : [];
|
|
1050
|
+
if (tasks.some((x) => CODE_FORM_TYPES.includes(x && x.type))) headers[SCRIPT_CONFIRM_HEADER] = 'script';
|
|
1051
|
+
return headers;
|
|
1052
|
+
};
|
|
1053
|
+
|
|
1054
|
+
/** #42: download the task configuration as a JSON file. */
|
|
1055
|
+
const handleExport = async () => {
|
|
1056
|
+
setTransferBusy(true);
|
|
1057
|
+
try {
|
|
1058
|
+
const res = await fetch('/dsh-cron/tasks/export');
|
|
1059
|
+
const data = await res.json();
|
|
1060
|
+
if (!data.ok) {
|
|
1061
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: (data && data.error) || '' }));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1064
|
+
const blob = new Blob([JSON.stringify(data.document, null, 2)], { type: 'application/json' });
|
|
1065
|
+
const url = URL.createObjectURL(blob);
|
|
1066
|
+
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
|
|
1067
|
+
const anchor = document.createElement('a');
|
|
1068
|
+
anchor.href = url;
|
|
1069
|
+
anchor.download = `dsh-cron-tasks-${stamp}.json`;
|
|
1070
|
+
document.body.appendChild(anchor);
|
|
1071
|
+
anchor.click();
|
|
1072
|
+
document.body.removeChild(anchor);
|
|
1073
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
1074
|
+
alert(T_KEY(t, 'transfer.exported', { count: data.count }));
|
|
1075
|
+
} catch (err) {
|
|
1076
|
+
alert(T_KEY(t, 'transfer.exportFailed', { error: err.message }));
|
|
1077
|
+
} finally {
|
|
1078
|
+
setTransferBusy(false);
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
1081
|
+
|
|
1082
|
+
/** #42: read the picked file, validate it server-side and show the plan. */
|
|
1083
|
+
const handleImportFile = async (e) => {
|
|
1084
|
+
const file = e.target.files && e.target.files[0];
|
|
1085
|
+
e.target.value = '';
|
|
1086
|
+
if (!file) return;
|
|
1087
|
+
setTransferBusy(true);
|
|
1088
|
+
try {
|
|
1089
|
+
const text = await file.text();
|
|
1090
|
+
let document_;
|
|
1091
|
+
try {
|
|
1092
|
+
document_ = JSON.parse(text);
|
|
1093
|
+
} catch (parseErr) {
|
|
1094
|
+
alert(T_KEY(t, 'transfer.badFile', { error: parseErr.message }));
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const res = await fetch('/dsh-cron/tasks/import', {
|
|
1098
|
+
method: 'POST',
|
|
1099
|
+
headers: importHeaders(document_),
|
|
1100
|
+
body: JSON.stringify({ document: document_, dryRun: true, strategy: importStrategy })
|
|
1101
|
+
});
|
|
1102
|
+
const data = await res.json();
|
|
1103
|
+
if (!data.ok) {
|
|
1104
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
setImportDoc(document_);
|
|
1108
|
+
setImportSummary(data.summary);
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
1111
|
+
} finally {
|
|
1112
|
+
setTransferBusy(false);
|
|
1113
|
+
}
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const handleImportConfirm = async () => {
|
|
1117
|
+
if (!importDoc) return;
|
|
1118
|
+
setTransferBusy(true);
|
|
1119
|
+
try {
|
|
1120
|
+
const res = await fetch('/dsh-cron/tasks/import', {
|
|
1121
|
+
method: 'POST',
|
|
1122
|
+
headers: importHeaders(importDoc),
|
|
1123
|
+
body: JSON.stringify({ document: importDoc, strategy: importStrategy })
|
|
1124
|
+
});
|
|
1125
|
+
const data = await res.json();
|
|
1126
|
+
if (!data.ok) {
|
|
1127
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: (data && data.error) || '' }));
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
setImportDoc(null);
|
|
1131
|
+
setImportSummary(null);
|
|
1132
|
+
await fetchTasks();
|
|
1133
|
+
alert(T_KEY(t, 'transfer.imported', { count: data.imported }));
|
|
1134
|
+
} catch (err) {
|
|
1135
|
+
alert(T_KEY(t, 'transfer.importFailed', { error: err.message }));
|
|
1136
|
+
} finally {
|
|
1137
|
+
setTransferBusy(false);
|
|
1138
|
+
}
|
|
1139
|
+
};
|
|
1140
|
+
|
|
905
1141
|
const handleDelete = async (id) => {
|
|
906
1142
|
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
907
1143
|
try {
|
|
908
1144
|
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
909
1145
|
fetchTasks();
|
|
910
|
-
} catch (err) {}
|
|
911
|
-
};
|
|
1146
|
+
} catch (err) {} };
|
|
912
1147
|
|
|
913
1148
|
const openSettingsModal = async () => {
|
|
914
1149
|
setTestStatus(null);
|
|
@@ -1259,7 +1494,31 @@ window.__ModuleLoader__.load({
|
|
|
1259
1494
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
|
|
1260
1495
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
|
|
1261
1496
|
)
|
|
1262
|
-
)
|
|
1497
|
+
),
|
|
1498
|
+
// #42: export/import as a small pair of icon buttons.
|
|
1499
|
+
React.createElement('button', {
|
|
1500
|
+
type: 'button',
|
|
1501
|
+
className: 'dsh-cron-icon-btn',
|
|
1502
|
+
title: T_KEY(t, 'transfer.exportTitle'),
|
|
1503
|
+
'aria-label': T_KEY(t, 'transfer.exportTitle'),
|
|
1504
|
+
disabled: transferBusy,
|
|
1505
|
+
onClick: handleExport
|
|
1506
|
+
}, React.createElement('span', svgProps(ICON_DOWNLOAD))),
|
|
1507
|
+
React.createElement('button', {
|
|
1508
|
+
type: 'button',
|
|
1509
|
+
className: 'dsh-cron-icon-btn',
|
|
1510
|
+
title: T_KEY(t, 'transfer.importTitle'),
|
|
1511
|
+
'aria-label': T_KEY(t, 'transfer.importTitle'),
|
|
1512
|
+
disabled: transferBusy,
|
|
1513
|
+
onClick: () => { if (importFileRef.current) importFileRef.current.click(); }
|
|
1514
|
+
}, React.createElement('span', svgProps(ICON_UPLOAD))),
|
|
1515
|
+
React.createElement('input', {
|
|
1516
|
+
type: 'file',
|
|
1517
|
+
accept: '.json,application/json',
|
|
1518
|
+
ref: importFileRef,
|
|
1519
|
+
style: { display: 'none' },
|
|
1520
|
+
onChange: handleImportFile
|
|
1521
|
+
})
|
|
1263
1522
|
)
|
|
1264
1523
|
),
|
|
1265
1524
|
React.createElement('div', { className: 'dsh-cron-search-bar' },
|
|
@@ -1305,6 +1564,50 @@ window.__ModuleLoader__.load({
|
|
|
1305
1564
|
: T_KEY(t, 'tabs.completed'))
|
|
1306
1565
|
)
|
|
1307
1566
|
),
|
|
1567
|
+
// #40: facet filters over the loaded list.
|
|
1568
|
+
React.createElement('div', { className: 'dsh-cron-filters' },
|
|
1569
|
+
React.createElement('button', {
|
|
1570
|
+
type: 'button',
|
|
1571
|
+
className: 'dsh-cron-filter-toggle',
|
|
1572
|
+
'aria-expanded': filtersOpen ? 'true' : 'false',
|
|
1573
|
+
onClick: () => setFiltersOpen(!filtersOpen)
|
|
1574
|
+
},
|
|
1575
|
+
T_KEY(t, 'filters.title'),
|
|
1576
|
+
(filterType || filterModel || filterChannel) ? ' •' : ''
|
|
1577
|
+
),
|
|
1578
|
+
filtersOpen && React.createElement('div', { className: 'dsh-cron-filter-panel' },
|
|
1579
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1580
|
+
React.createElement('span', null, T_KEY(t, 'filters.type')),
|
|
1581
|
+
React.createElement('select', {
|
|
1582
|
+
value: filterType,
|
|
1583
|
+
onChange: (e) => setFilterType(e.target.value)
|
|
1584
|
+
},
|
|
1585
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1586
|
+
...filterTypeOptions.map((value) => React.createElement('option', { key: value, value }, value))
|
|
1587
|
+
)
|
|
1588
|
+
),
|
|
1589
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1590
|
+
React.createElement('span', null, T_KEY(t, 'filters.model')),
|
|
1591
|
+
React.createElement('select', {
|
|
1592
|
+
value: filterModel,
|
|
1593
|
+
onChange: (e) => setFilterModel(e.target.value)
|
|
1594
|
+
},
|
|
1595
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1596
|
+
...filterModelOptions.map((value) => React.createElement('option', { key: value, value }, value))
|
|
1597
|
+
)
|
|
1598
|
+
),
|
|
1599
|
+
React.createElement('label', { className: 'dsh-cron-filter-field' },
|
|
1600
|
+
React.createElement('span', null, T_KEY(t, 'filters.channel')),
|
|
1601
|
+
React.createElement('select', {
|
|
1602
|
+
value: filterChannel,
|
|
1603
|
+
onChange: (e) => setFilterChannel(e.target.value)
|
|
1604
|
+
},
|
|
1605
|
+
React.createElement('option', { value: '' }, T_KEY(t, 'filters.any')),
|
|
1606
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('option', { key: id, value: id }, T_KEY(t, CHANNEL_LABEL_KEYS[id])))
|
|
1607
|
+
)
|
|
1608
|
+
)
|
|
1609
|
+
)
|
|
1610
|
+
),
|
|
1308
1611
|
fetchError ? React.createElement('div', {
|
|
1309
1612
|
className: 'dsh-cron-status-banner',
|
|
1310
1613
|
'data-type': 'error',
|
|
@@ -1318,15 +1621,38 @@ window.__ModuleLoader__.load({
|
|
|
1318
1621
|
onClick: () => fetchTasks()
|
|
1319
1622
|
}, T_KEY(t, 'banner.retry'))
|
|
1320
1623
|
) : null,
|
|
1624
|
+
(filterType || filterModel || filterChannel) ? React.createElement('div', {
|
|
1625
|
+
className: 'dsh-cron-filter-summary',
|
|
1626
|
+
style: { fontSize: '12px', color: 'var(--dsw-alias-label-tertiary, #888)', marginBottom: '8px', display: 'flex', gap: '8px', alignItems: 'center' }
|
|
1627
|
+
},
|
|
1628
|
+
React.createElement('span', null, T_KEY(t, 'filters.showing', { shown: visibleTasks.length, total: tasks.length })),
|
|
1629
|
+
React.createElement('button', {
|
|
1630
|
+
type: 'button',
|
|
1631
|
+
className: 'dsh-cron-btn-secondary',
|
|
1632
|
+
style: { padding: '2px 8px', fontSize: '11px' },
|
|
1633
|
+
onClick: resetFilters
|
|
1634
|
+
}, T_KEY(t, 'filters.reset'))
|
|
1635
|
+
) : null,
|
|
1321
1636
|
React.createElement('div', { className: 'dsh-cron-task-list' },
|
|
1322
1637
|
loading && tasks.length === 0
|
|
1323
1638
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
|
|
1324
1639
|
: tasks.length === 0
|
|
1325
1640
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.empty'))
|
|
1326
|
-
:
|
|
1641
|
+
: visibleTasks.length === 0
|
|
1642
|
+
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px', display: 'flex', gap: '10px', alignItems: 'center' } },
|
|
1643
|
+
React.createElement('span', null, T_KEY(t, 'filters.empty')),
|
|
1644
|
+
React.createElement('button', {
|
|
1645
|
+
type: 'button',
|
|
1646
|
+
className: 'dsh-cron-btn-secondary',
|
|
1647
|
+
style: { padding: '2px 8px', fontSize: '12px' },
|
|
1648
|
+
onClick: resetFilters
|
|
1649
|
+
}, T_KEY(t, 'filters.reset'))
|
|
1650
|
+
)
|
|
1651
|
+
: visibleTasks.map((task) =>
|
|
1327
1652
|
React.createElement('div', {
|
|
1328
1653
|
key: task.id,
|
|
1329
1654
|
className: 'dsh-cron-task-item',
|
|
1655
|
+
'data-task-id': task.id,
|
|
1330
1656
|
role: 'button',
|
|
1331
1657
|
tabIndex: 0,
|
|
1332
1658
|
'aria-label': task.title,
|
|
@@ -1389,6 +1715,13 @@ window.__ModuleLoader__.load({
|
|
|
1389
1715
|
'aria-label': T_KEY(t, 'actions.edit'),
|
|
1390
1716
|
onClick: () => openEditModal(task)
|
|
1391
1717
|
}, React.createElement('span', svgProps(ICON_EDIT))),
|
|
1718
|
+
React.createElement('button', {
|
|
1719
|
+
type: 'button',
|
|
1720
|
+
className: 'dsh-cron-icon-btn',
|
|
1721
|
+
title: T_KEY(t, 'actions.duplicate'),
|
|
1722
|
+
'aria-label': T_KEY(t, 'actions.duplicate'),
|
|
1723
|
+
onClick: () => handleDuplicate(task)
|
|
1724
|
+
}, React.createElement('span', svgProps(ICON_COPY))),
|
|
1392
1725
|
React.createElement('button', {
|
|
1393
1726
|
type: 'button',
|
|
1394
1727
|
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
@@ -2003,7 +2336,50 @@ window.__ModuleLoader__.load({
|
|
|
2003
2336
|
)
|
|
2004
2337
|
),
|
|
2005
2338
|
|
|
2006
|
-
// 3.
|
|
2339
|
+
// 3. Import modal (#42) — summary first, then an explicit strategy.
|
|
2340
|
+
importDoc && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !transferBusy && setImportDoc(null) },
|
|
2341
|
+
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
2342
|
+
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'transfer.importTitleModal')),
|
|
2343
|
+
React.createElement('div', { className: 'dsh-cron-hint', style: { marginBottom: '10px' } }, T_KEY(t, 'transfer.importHint')),
|
|
2344
|
+
importSummary && React.createElement('div', null,
|
|
2345
|
+
React.createElement('div', { style: { fontSize: '13px', marginBottom: '12px' } },
|
|
2346
|
+
T_KEY(t, 'transfer.summary', {
|
|
2347
|
+
total: (importSummary.add || 0) + (importSummary.replace || 0) + (importSummary.skip || 0),
|
|
2348
|
+
add: importSummary.add || 0,
|
|
2349
|
+
replace: importSummary.replace || 0,
|
|
2350
|
+
skip: importSummary.skip || 0
|
|
2351
|
+
})
|
|
2352
|
+
),
|
|
2353
|
+
React.createElement('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px', marginBottom: '14px' } },
|
|
2354
|
+
...['add', 'replace', 'skip'].map((mode) => React.createElement('label', { key: mode, className: 'dsh-cron-checkbox-row' },
|
|
2355
|
+
React.createElement('input', {
|
|
2356
|
+
type: 'radio',
|
|
2357
|
+
name: 'dsh-cron-import-strategy',
|
|
2358
|
+
checked: importStrategy === mode,
|
|
2359
|
+
onChange: () => setImportStrategy(mode)
|
|
2360
|
+
}),
|
|
2361
|
+
React.createElement('span', null, T_KEY(t, mode === 'add' ? 'transfer.strategyAdd' : mode === 'replace' ? 'transfer.strategyReplace' : 'transfer.strategySkip'))
|
|
2362
|
+
))
|
|
2363
|
+
)
|
|
2364
|
+
),
|
|
2365
|
+
React.createElement('div', { className: 'dsh-cron-modal-foot' },
|
|
2366
|
+
React.createElement('button', {
|
|
2367
|
+
type: 'button',
|
|
2368
|
+
className: 'dsh-cron-btn-secondary',
|
|
2369
|
+
disabled: transferBusy,
|
|
2370
|
+
onClick: () => setImportDoc(null)
|
|
2371
|
+
}, T_KEY(t, 'modal.cancel')),
|
|
2372
|
+
React.createElement('button', {
|
|
2373
|
+
type: 'button',
|
|
2374
|
+
className: 'dsh-cron-btn-primary',
|
|
2375
|
+
disabled: transferBusy || !importSummary,
|
|
2376
|
+
onClick: handleImportConfirm
|
|
2377
|
+
}, T_KEY(t, 'transfer.confirm'))
|
|
2378
|
+
)
|
|
2379
|
+
)
|
|
2380
|
+
),
|
|
2381
|
+
|
|
2382
|
+
// 4. "Create with DSH" modal
|
|
2007
2383
|
dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
|
|
2008
2384
|
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
2009
2385
|
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
|
|
@@ -2322,6 +2698,162 @@ window.__ModuleLoader__.load({
|
|
|
2322
2698
|
return entry;
|
|
2323
2699
|
}
|
|
2324
2700
|
|
|
2701
|
+
/**
|
|
2702
|
+
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
2703
|
+
*
|
|
2704
|
+
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
2705
|
+
* Collapsed by default, state persisted, capped at a few rows with an
|
|
2706
|
+
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
2707
|
+
* off screen. Clicking a row opens the panel and highlights that task.
|
|
2708
|
+
*/
|
|
2709
|
+
const SIDEBAR_JOBS_KEY = 'dsh-cron-sidebar-open';
|
|
2710
|
+
const SIDEBAR_JOBS_MAX_ROWS = 5;
|
|
2711
|
+
|
|
2712
|
+
/** Sidebar job row: title plus next run time or the live running state. */
|
|
2713
|
+
function buildSidebarJobRow(state, task) {
|
|
2714
|
+
const row = document.createElement('button');
|
|
2715
|
+
row.type = 'button';
|
|
2716
|
+
row.className = 'dsh-cron-sidebar-job';
|
|
2717
|
+
// A distinct attribute: the panel uses data-task-id, and the highlight
|
|
2718
|
+
// lookup must never pick a sidebar row instead of a task card.
|
|
2719
|
+
row.setAttribute('data-sidebar-task-id', task.id);
|
|
2720
|
+
row.setAttribute('aria-label', task.title || task.id);
|
|
2721
|
+
|
|
2722
|
+
const title = document.createElement('span');
|
|
2723
|
+
title.className = 'dsh-cron-sidebar-job-title';
|
|
2724
|
+
title.textContent = task.title || task.id;
|
|
2725
|
+
row.appendChild(title);
|
|
2726
|
+
|
|
2727
|
+
const meta = document.createElement('span');
|
|
2728
|
+
meta.className = 'dsh-cron-sidebar-job-meta';
|
|
2729
|
+
if (task.running) meta.textContent = T('list.runningNow');
|
|
2730
|
+
else if (task.nextRunAt) meta.textContent = new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
2731
|
+
else meta.textContent = task.scheduleText || task.schedule || '';
|
|
2732
|
+
row.appendChild(meta);
|
|
2733
|
+
|
|
2734
|
+
if (task.running) row.classList.add('dsh-cron-pulse');
|
|
2735
|
+
row.addEventListener('click', (event) => {
|
|
2736
|
+
event.preventDefault();
|
|
2737
|
+
event.stopPropagation();
|
|
2738
|
+
state.openTaskSignal(task.id);
|
|
2739
|
+
if (!state.toggle.isOpen()) state.toggle.toggle();
|
|
2740
|
+
});
|
|
2741
|
+
return row;
|
|
2742
|
+
}
|
|
2743
|
+
|
|
2744
|
+
function renderSidebarJobs(state, tasks) {
|
|
2745
|
+
const listEl = state.listEl;
|
|
2746
|
+
if (!listEl) return;
|
|
2747
|
+
listEl.innerHTML = '';
|
|
2748
|
+
if (!tasks.length) {
|
|
2749
|
+
const empty = document.createElement('div');
|
|
2750
|
+
empty.className = 'dsh-cron-sidebar-empty';
|
|
2751
|
+
empty.textContent = T('sidebar.noActiveJobs');
|
|
2752
|
+
listEl.appendChild(empty);
|
|
2753
|
+
return;
|
|
2754
|
+
}
|
|
2755
|
+
for (const task of tasks.slice(0, SIDEBAR_JOBS_MAX_ROWS)) listEl.appendChild(buildSidebarJobRow(state, task));
|
|
2756
|
+
if (tasks.length > SIDEBAR_JOBS_MAX_ROWS) {
|
|
2757
|
+
const more = document.createElement('div');
|
|
2758
|
+
more.className = 'dsh-cron-sidebar-more';
|
|
2759
|
+
more.textContent = T('sidebar.moreJobs', { count: tasks.length - SIDEBAR_JOBS_MAX_ROWS });
|
|
2760
|
+
listEl.appendChild(more);
|
|
2761
|
+
}
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
async function loadSidebarJobs(state) {
|
|
2765
|
+
try {
|
|
2766
|
+
const res = await fetch('/dsh-cron/tasks?status=active');
|
|
2767
|
+
const data = await res.json();
|
|
2768
|
+
if (data && data.ok) renderSidebarJobs(state, data.tasks || []);
|
|
2769
|
+
} catch (_) {}
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function applySidebarJobsOpen(state) {
|
|
2773
|
+
if (!state.root) return;
|
|
2774
|
+
state.root.dataset.open = state.open ? 'true' : 'false';
|
|
2775
|
+
if (state.headEl) state.headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
|
|
2776
|
+
if (state.open) loadSidebarJobs(state);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function buildSidebarJobsRoot(state, label) {
|
|
2780
|
+
const root = document.createElement('div');
|
|
2781
|
+
root.className = 'dsh-cron-sidebar-jobs';
|
|
2782
|
+
root.setAttribute('data-dsh-plugin', 'dsh-cron');
|
|
2783
|
+
root.setAttribute('data-dsh-part', 'sidebar-jobs');
|
|
2784
|
+
|
|
2785
|
+
const headEl = document.createElement('button');
|
|
2786
|
+
headEl.type = 'button';
|
|
2787
|
+
headEl.className = 'dsh-cron-sidebar-jobs-head';
|
|
2788
|
+
headEl.setAttribute('aria-expanded', state.open ? 'true' : 'false');
|
|
2789
|
+
headEl.textContent = label;
|
|
2790
|
+
headEl.addEventListener('click', (event) => {
|
|
2791
|
+
event.preventDefault();
|
|
2792
|
+
event.stopPropagation();
|
|
2793
|
+
state.open = !state.open;
|
|
2794
|
+
try { window.localStorage.setItem(SIDEBAR_JOBS_KEY, state.open ? '1' : '0'); } catch (_) {}
|
|
2795
|
+
applySidebarJobsOpen(state);
|
|
2796
|
+
});
|
|
2797
|
+
root.appendChild(headEl);
|
|
2798
|
+
|
|
2799
|
+
const listEl = document.createElement('div');
|
|
2800
|
+
listEl.className = 'dsh-cron-sidebar-jobs-list';
|
|
2801
|
+
root.appendChild(listEl);
|
|
2802
|
+
|
|
2803
|
+
state.root = root;
|
|
2804
|
+
state.headEl = headEl;
|
|
2805
|
+
state.listEl = listEl;
|
|
2806
|
+
return root;
|
|
2807
|
+
}
|
|
2808
|
+
|
|
2809
|
+
/** Keep the section mounted under the sidebar entry across re-renders. */
|
|
2810
|
+
function placeSidebarJobs(state, label) {
|
|
2811
|
+
if (state.root !== undefined && state.root.isConnected) return;
|
|
2812
|
+
const entry = document.querySelector('[' + ENTRY_ATTR + ']');
|
|
2813
|
+
if (entry === null || entry.parentElement === null) return;
|
|
2814
|
+
if (state.root === undefined) {
|
|
2815
|
+
entry.insertAdjacentElement('afterend', buildSidebarJobsRoot(state, label));
|
|
2816
|
+
} else {
|
|
2817
|
+
entry.insertAdjacentElement('afterend', state.root);
|
|
2818
|
+
// Re-query the refs: the previous nodes may belong to a detached tree.
|
|
2819
|
+
state.root = document.querySelector('[data-dsh-part="sidebar-jobs"]') || state.root;
|
|
2820
|
+
state.listEl = state.root.querySelector('.dsh-cron-sidebar-jobs-list');
|
|
2821
|
+
state.headEl = state.root.querySelector('.dsh-cron-sidebar-jobs-head');
|
|
2822
|
+
}
|
|
2823
|
+
applySidebarJobsOpen(state);
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
/**
|
|
2827
|
+
* Collapsible list of active jobs under the sidebar entry (#34).
|
|
2828
|
+
*
|
|
2829
|
+
* Vanilla DOM like the entry itself: the sidebar is not a React root here.
|
|
2830
|
+
* Collapsed by default, state persisted, capped at a few rows with an
|
|
2831
|
+
* "and N more" line so a busy schedule cannot push the rest of the sidebar
|
|
2832
|
+
* off screen. Clicking a row opens the panel and highlights that task.
|
|
2833
|
+
*/
|
|
2834
|
+
function mountSidebarJobs(toggle, label, openTaskSignal) {
|
|
2835
|
+
if (typeof document === 'undefined') return () => {};
|
|
2836
|
+
const state = { toggle, openTaskSignal, root: undefined, listEl: undefined, headEl: undefined, open: false };
|
|
2837
|
+
try { state.open = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
|
|
2838
|
+
|
|
2839
|
+
const observer = new MutationObserver(() => placeSidebarJobs(state, label));
|
|
2840
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
2841
|
+
placeSidebarJobs(state, label);
|
|
2842
|
+
loadSidebarJobs(state);
|
|
2843
|
+
const fetchTimer = setInterval(() => {
|
|
2844
|
+
let isOpen = false;
|
|
2845
|
+
try { isOpen = window.localStorage.getItem(SIDEBAR_JOBS_KEY) === '1'; } catch (_) {}
|
|
2846
|
+
if (isOpen) loadSidebarJobs(state);
|
|
2847
|
+
}, 30000);
|
|
2848
|
+
|
|
2849
|
+
return () => {
|
|
2850
|
+
observer.disconnect();
|
|
2851
|
+
clearInterval(fetchTimer);
|
|
2852
|
+
if (state.root !== undefined && state.root.isConnected) state.root.remove();
|
|
2853
|
+
state.root = undefined;
|
|
2854
|
+
};
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2325
2857
|
function placeEntry(target, entry) {
|
|
2326
2858
|
let anchor = target;
|
|
2327
2859
|
let next = target.nextElementSibling;
|
|
@@ -2504,6 +3036,7 @@ window.__ModuleLoader__.load({
|
|
|
2504
3036
|
ctx.effect(() => {
|
|
2505
3037
|
const off = [
|
|
2506
3038
|
mountSidebarEntry(toggle, translate('sidebar.label')),
|
|
3039
|
+
mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
|
|
2507
3040
|
mountCronScreen(ctx, toggle)
|
|
2508
3041
|
];
|
|
2509
3042
|
return () => { for (const dispose of off) dispose(); };
|