@goodandready/dsh-cron 0.2.3 → 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 +69 -15
- package/docs/README.ru.md +69 -14
- package/docs/README.zh.md +74 -14
- package/docs/design/DESIGN.md +18 -8
- package/docs/plans/0.2.5-ui-block.md +50 -0
- package/lib/channels.js +412 -0
- package/lib/client.js +1134 -30
- package/lib/index.js +311 -38
- package/lib/integrations.js +3 -1
- package/lib/runner.js +185 -28
- package/lib/runtimes.js +174 -0
- package/lib/scheduler.js +66 -60
- package/lib/secrets.js +123 -0
- package/lib/store.js +91 -21
- package/lib/task-transfer.js +226 -0
- package/lib/telegram.js +20 -4
- package/lib/templates.js +88 -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
|
-
'actions.settings': '
|
|
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',
|
|
@@ -94,7 +129,32 @@ window.__ModuleLoader__.load({
|
|
|
94
129
|
'form.namePlaceholder': 'For example: dsh-tags-watch',
|
|
95
130
|
'form.typeLabel': 'Execution type',
|
|
96
131
|
'form.typeLlm': 'LLM (AI agent)',
|
|
97
|
-
'form.typeScript': '
|
|
132
|
+
'form.typeScript': 'Shell command / script',
|
|
133
|
+
'form.typeNode': 'Node.js',
|
|
134
|
+
'form.typePython': 'Python',
|
|
135
|
+
'form.typeHttp': 'HTTP request / webhook',
|
|
136
|
+
'form.typeSsh': 'Remote SSH',
|
|
137
|
+
'form.typeDocker': 'Docker container',
|
|
138
|
+
'form.typeSkill': 'DSH Skill',
|
|
139
|
+
'form.typeWorkflow': 'DSH Workflow',
|
|
140
|
+
'form.envLabel': 'Environment variables (KEY=VALUE per line)',
|
|
141
|
+
'form.envPlaceholder': 'NODE_ENV=production\nTZ=Europe/Berlin',
|
|
142
|
+
'form.envHint': 'Applied to non-LLM runtimes only. Do not store secrets here.',
|
|
143
|
+
'form.cwdLabel': 'Working directory (optional)',
|
|
144
|
+
'form.workspaceLabel': 'Workspace id or path (optional)',
|
|
145
|
+
'form.worktreeLabel': 'Run in an isolated git worktree',
|
|
146
|
+
'form.keepWorktreeLabel': 'Keep the worktree after the run',
|
|
147
|
+
'form.httpMethodLabel': 'HTTP method',
|
|
148
|
+
'form.httpUrlLabel': 'URL',
|
|
149
|
+
'form.httpHeadersLabel': 'Headers (JSON, optional)',
|
|
150
|
+
'form.httpBodyLabel': 'Body (optional)',
|
|
151
|
+
'form.sshProfileLabel': 'dsh-remote-workspace profile id',
|
|
152
|
+
'form.sshProfileHint': 'Preferred: host and credentials stay in the remote-workspace plugin.',
|
|
153
|
+
'form.sshTargetLabel': 'Fallback target (user@host, optional)',
|
|
154
|
+
'form.sshKeyLabel': 'Private key path (fallback, optional)',
|
|
155
|
+
'form.dockerImageLabel': 'Container image',
|
|
156
|
+
'form.skillNameLabel': 'Skill name',
|
|
157
|
+
'form.workflowNameLabel': 'Workflow name',
|
|
98
158
|
'form.scheduleLabel': 'Schedule (cron or interval)',
|
|
99
159
|
'form.schedulePlaceholder': '0 */6 * * * or every 2h or in 30m or at: 2026-09-05T15:00:00Z',
|
|
100
160
|
'form.timeoutLabel': 'Execution timeout (seconds)',
|
|
@@ -131,15 +191,54 @@ window.__ModuleLoader__.load({
|
|
|
131
191
|
'form.notifyTitle': 'Telegram notifications',
|
|
132
192
|
'form.notifyTelegram': 'Send the run report to Telegram',
|
|
133
193
|
'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
|
|
194
|
+
'form.channelsTitle': 'Delivery channels',
|
|
195
|
+
'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
|
|
196
|
+
'form.templateLabel': 'Message template (optional)',
|
|
197
|
+
'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
|
|
198
|
+
'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
|
|
199
|
+
'form.channelTelegram': 'Telegram',
|
|
200
|
+
'form.channelKanban': 'dsh-kanban card',
|
|
201
|
+
'form.channelDiscord': 'Discord',
|
|
202
|
+
'form.channelSlack': 'Slack',
|
|
203
|
+
'form.channelNtfy': 'ntfy',
|
|
204
|
+
'form.channelBark': 'Bark',
|
|
205
|
+
'form.channelPushplus': 'PushPlus',
|
|
206
|
+
'form.channelTts': 'Voice (dsh-tts)',
|
|
207
|
+
'form.channelGitea': 'Gitea issue',
|
|
208
|
+
'settings.deliverySection': 'Delivery channels',
|
|
209
|
+
'settings.secretsSection': 'Credentials (references)',
|
|
210
|
+
'settings.secretsHint': 'Enter the NAME of a DSH credential, not the secret itself. Secrets stay in the credentials store.',
|
|
211
|
+
'settings.telegramSection': 'Telegram',
|
|
212
|
+
'settings.templatesSection': 'Message templates',
|
|
213
|
+
'settings.channelTemplateLabel': 'Template for {channel}',
|
|
214
|
+
'settings.botTokenRefLabel': 'Bot token credential name',
|
|
215
|
+
'settings.botTokenRefPlaceholder': 'e.g. TELEGRAM_BOT_TOKEN',
|
|
216
|
+
'settings.discordLabel': 'Discord webhook URL',
|
|
217
|
+
'settings.slackLabel': 'Slack webhook URL',
|
|
218
|
+
'settings.ntfyUrlLabel': 'ntfy server URL',
|
|
219
|
+
'settings.ntfyTopicLabel': 'ntfy topic',
|
|
220
|
+
'settings.ntfyTokenRefLabel': 'ntfy token credential name',
|
|
221
|
+
'settings.barkServerLabel': 'Bark server URL',
|
|
222
|
+
'settings.barkKeyLabel': 'Bark device key',
|
|
223
|
+
'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
|
|
224
|
+
'settings.pushplusUrlLabel': 'PushPlus endpoint',
|
|
225
|
+
'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
|
|
226
|
+
'settings.giteaBaseUrlLabel': 'Gitea base URL',
|
|
227
|
+
'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
|
|
228
|
+
'settings.giteaTokenRefLabel': 'Gitea token credential name',
|
|
229
|
+
'settings.templateLabel': 'Default message template',
|
|
230
|
+
'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
|
|
231
|
+
'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
|
|
232
|
+
'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
|
|
134
233
|
'form.nextRunLabel': 'Next run: ',
|
|
135
234
|
'form.notScheduled': 'not scheduled',
|
|
136
235
|
'settings.loading': 'Loading settings...',
|
|
137
236
|
'settings.unavailable': 'Settings are unavailable right now.',
|
|
138
237
|
'settings.retry': 'Retry',
|
|
139
238
|
'settings.cardTitle': '⏰ Task scheduler (Cron)',
|
|
140
|
-
'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios and Telegram
|
|
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.',
|
|
141
240
|
'settings.openPanel': 'Open task panel',
|
|
142
|
-
'settings.modalTitle': '
|
|
241
|
+
'settings.modalTitle': 'Notifications & delivery channels',
|
|
143
242
|
'settings.tokenLabel': 'Telegram Bot Token',
|
|
144
243
|
'settings.tokenPlaceholder': '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
|
|
145
244
|
'settings.tokenPlaceholderDefault': 'Using the bot configured in DSH settings (or enter your own)',
|
|
@@ -231,6 +330,156 @@ window.__ModuleLoader__.load({
|
|
|
231
330
|
}
|
|
232
331
|
const ErrorBoundary = createErrorBoundary();
|
|
233
332
|
|
|
333
|
+
const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
|
|
334
|
+
const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
|
|
335
|
+
|
|
336
|
+
// Delivery channels (#26) — must stay in sync with lib/channels.js.
|
|
337
|
+
const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
|
|
338
|
+
const CHANNEL_LABEL_KEYS = {
|
|
339
|
+
telegram: 'form.channelTelegram',
|
|
340
|
+
kanban: 'form.channelKanban',
|
|
341
|
+
discord: 'form.channelDiscord',
|
|
342
|
+
slack: 'form.channelSlack',
|
|
343
|
+
ntfy: 'form.channelNtfy',
|
|
344
|
+
bark: 'form.channelBark',
|
|
345
|
+
pushplus: 'form.channelPushplus',
|
|
346
|
+
tts: 'form.channelTts',
|
|
347
|
+
gitea: 'form.channelGitea',
|
|
348
|
+
};
|
|
349
|
+
|
|
350
|
+
// Non-secret delivery settings shown in the settings panel. Secrets are
|
|
351
|
+
// referenced by credential name only (#51) — never typed as values.
|
|
352
|
+
const DELIVERY_SETTING_FIELDS = [
|
|
353
|
+
{ key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
|
|
354
|
+
{ key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
|
|
355
|
+
{ key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
|
|
356
|
+
{ key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
|
|
357
|
+
{ key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
|
|
358
|
+
{ key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
|
|
359
|
+
{ key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
|
|
360
|
+
{ key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
|
|
361
|
+
{ key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
|
|
362
|
+
{ key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
|
|
363
|
+
{ key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
|
|
364
|
+
{ key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
|
|
365
|
+
{ key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
|
|
366
|
+
{ key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
|
|
367
|
+
{ key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
|
|
368
|
+
];
|
|
369
|
+
const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
|
|
370
|
+
// The global template plus the per-channel override map (server model).
|
|
371
|
+
const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
|
|
372
|
+
|
|
373
|
+
/** Collect every delivery key the settings endpoints may return. */
|
|
374
|
+
function pickDeliverySettings(source) {
|
|
375
|
+
const out = {};
|
|
376
|
+
for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
|
|
377
|
+
if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
|
|
378
|
+
}
|
|
379
|
+
if (out.channelTemplates && typeof out.channelTemplates === 'object') {
|
|
380
|
+
out.channelTemplates = Object.assign({}, out.channelTemplates);
|
|
381
|
+
}
|
|
382
|
+
return out;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* Shared delivery form used by both the task panel modal and the plugin
|
|
387
|
+
* settings card, so a new channel is added in exactly one place.
|
|
388
|
+
* Sections collapse via the canonical aria-expanded head contract.
|
|
389
|
+
*/
|
|
390
|
+
function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
|
|
391
|
+
const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
|
|
392
|
+
const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
|
|
393
|
+
React.createElement('label', null, T(f.labelKey)),
|
|
394
|
+
React.createElement('input', f.numeric ? {
|
|
395
|
+
type: 'number',
|
|
396
|
+
min: f.min !== undefined ? f.min : 1000,
|
|
397
|
+
step: 1000,
|
|
398
|
+
value: val(f.key),
|
|
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)),
|
|
406
|
+
} : {
|
|
407
|
+
type: 'text',
|
|
408
|
+
value: val(f.key),
|
|
409
|
+
placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
|
|
410
|
+
onChange: (e) => onChange(f.key, e.target.value)
|
|
411
|
+
})
|
|
412
|
+
);
|
|
413
|
+
const section = (key, titleKey, children) => {
|
|
414
|
+
const open = Boolean(sections[key]);
|
|
415
|
+
return React.createElement('div', { className: 'dsh-cron-section', key },
|
|
416
|
+
React.createElement('button', {
|
|
417
|
+
type: 'button',
|
|
418
|
+
className: 'dsh-cron-section-head',
|
|
419
|
+
'aria-expanded': open ? 'true' : 'false',
|
|
420
|
+
onClick: () => onToggleSection(key)
|
|
421
|
+
},
|
|
422
|
+
React.createElement('span', null, T(titleKey)),
|
|
423
|
+
React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
|
|
424
|
+
),
|
|
425
|
+
open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
|
|
426
|
+
);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
return [
|
|
430
|
+
section('secrets', 'settings.secretsSection', [
|
|
431
|
+
React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
|
|
432
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
|
|
433
|
+
]),
|
|
434
|
+
section('channels', 'settings.deliverySection', [
|
|
435
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
|
|
436
|
+
...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
|
|
437
|
+
),
|
|
438
|
+
]),
|
|
439
|
+
section('templates', 'settings.templatesSection', [
|
|
440
|
+
React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
|
|
441
|
+
React.createElement('label', null, T('settings.templateLabel')),
|
|
442
|
+
React.createElement('input', {
|
|
443
|
+
type: 'text',
|
|
444
|
+
value: val('template'),
|
|
445
|
+
onChange: (e) => onChange('template', e.target.value)
|
|
446
|
+
}),
|
|
447
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
|
|
448
|
+
),
|
|
449
|
+
React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
|
|
450
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
|
|
451
|
+
React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
|
|
452
|
+
React.createElement('input', {
|
|
453
|
+
type: 'text',
|
|
454
|
+
value: (values.channelTemplates && values.channelTemplates[id]) || '',
|
|
455
|
+
onChange: (e) => onChannelTemplate(id, e.target.value)
|
|
456
|
+
})
|
|
457
|
+
))
|
|
458
|
+
),
|
|
459
|
+
]),
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** Parse a KEY=VALUE textarea into the task env object (#38). */
|
|
464
|
+
function parseEnvText(text) {
|
|
465
|
+
const env = {};
|
|
466
|
+
for (const rawLine of String(text || '').split('\n')) {
|
|
467
|
+
const line = rawLine.trim();
|
|
468
|
+
if (!line || line.startsWith('#')) continue;
|
|
469
|
+
const idx = line.indexOf('=');
|
|
470
|
+
if (idx <= 0) continue;
|
|
471
|
+
const key = line.slice(0, idx).trim();
|
|
472
|
+
const value = line.slice(idx + 1).trim();
|
|
473
|
+
if (key) env[key] = value;
|
|
474
|
+
}
|
|
475
|
+
return Object.keys(env).length ? env : undefined;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function formatEnvText(env) {
|
|
479
|
+
if (!env || typeof env !== 'object') return '';
|
|
480
|
+
return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
|
|
481
|
+
}
|
|
482
|
+
|
|
234
483
|
// Status colors: core semantic state tokens first (same set dsh-clinebot
|
|
235
484
|
// uses), plugin fallbacks as a single point of change (#105).
|
|
236
485
|
const STYLES = `
|
|
@@ -288,6 +537,12 @@ window.__ModuleLoader__.load({
|
|
|
288
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; }
|
|
289
538
|
.dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
|
|
290
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; }
|
|
291
546
|
.dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
292
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; }
|
|
293
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)); }
|
|
@@ -316,6 +571,12 @@ window.__ModuleLoader__.load({
|
|
|
316
571
|
|
|
317
572
|
.dsh-cron-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
|
318
573
|
.dsh-cron-modal { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 16px; width: 100%; max-width: 540px; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); box-sizing: border-box; }
|
|
574
|
+
.dsh-cron-modal-scroll { max-width: 620px; max-height: 88vh; overflow-y: auto; }
|
|
575
|
+
.dsh-cron-section-head { display: flex; align-items: center; justify-content: space-between; width: 100%; background: none; border: none; padding: 0; margin-bottom: 10px; font: inherit; font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; }
|
|
576
|
+
.dsh-cron-section-head:hover { color: var(--dsw-alias-label-primary, #eee); }
|
|
577
|
+
.dsh-cron-section-chevron { transition: transform 0.15s ease; display: inline-block; }
|
|
578
|
+
.dsh-cron-section-chevron[data-open="false"] { transform: rotate(-90deg); }
|
|
579
|
+
.dsh-cron-section-body { padding-bottom: 4px; }
|
|
319
580
|
.dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
|
|
320
581
|
.dsh-cron-form-group { margin-bottom: 16px; }
|
|
321
582
|
.dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
|
|
@@ -373,12 +634,63 @@ window.__ModuleLoader__.load({
|
|
|
373
634
|
.dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
|
|
374
635
|
.dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
|
|
375
636
|
.dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
|
|
637
|
+
.dsh-cron-channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px 12px; }
|
|
638
|
+
.dsh-cron-section { border-top: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-top: 16px; padding-top: 12px; }
|
|
639
|
+
.dsh-cron-section-title { font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 10px; }
|
|
640
|
+
.dsh-cron-secret-row { display: flex; align-items: center; gap: 8px; }
|
|
641
|
+
.dsh-cron-secret-row input { flex: 1; }
|
|
642
|
+
.dsh-cron-badge-ref { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-tertiary, #888); white-space: nowrap; }
|
|
376
643
|
.dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
|
|
377
644
|
.dsh-cron-status-banner[data-type="success"] { background: var(--dsh-cron-success-bg); border: 1px solid var(--dsh-cron-success); color: var(--dsh-cron-success); }
|
|
378
645
|
.dsh-cron-status-banner[data-type="error"] { background: var(--dsh-cron-danger-bg); border: 1px solid var(--dsh-cron-danger); color: var(--dsh-cron-danger); }
|
|
379
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); }
|
|
380
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); } }
|
|
381
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
|
+
}
|
|
382
694
|
`;
|
|
383
695
|
|
|
384
696
|
function ensureStyles() {
|
|
@@ -475,12 +787,25 @@ window.__ModuleLoader__.load({
|
|
|
475
787
|
const t = props.t || translate;
|
|
476
788
|
const [tab, setTab] = React.useState('all');
|
|
477
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);
|
|
478
796
|
const [tasks, setTasks] = React.useState([]);
|
|
479
797
|
const [recs, setRecs] = React.useState([]);
|
|
480
798
|
const [loading, setLoading] = React.useState(false);
|
|
481
799
|
const [fetchError, setFetchError] = React.useState(null);
|
|
482
800
|
const [dropdownOpen, setDropdownOpen] = React.useState(false);
|
|
483
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
|
+
|
|
484
809
|
// Manual / Edit modal state
|
|
485
810
|
const [manualModalOpen, setManualModalOpen] = React.useState(false);
|
|
486
811
|
const [formId, setFormId] = React.useState(null);
|
|
@@ -496,6 +821,8 @@ window.__ModuleLoader__.load({
|
|
|
496
821
|
const [taskHistory, setTaskHistory] = React.useState([]);
|
|
497
822
|
const [historyLoading, setHistoryLoading] = React.useState(false);
|
|
498
823
|
const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
|
|
824
|
+
const [formChannels, setFormChannels] = React.useState([]);
|
|
825
|
+
const [formTemplate, setFormTemplate] = React.useState('');
|
|
499
826
|
const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
|
|
500
827
|
const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
|
|
501
828
|
const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
|
|
@@ -504,6 +831,23 @@ window.__ModuleLoader__.load({
|
|
|
504
831
|
const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
|
|
505
832
|
const [formMaxRetries, setFormMaxRetries] = React.useState(0);
|
|
506
833
|
const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
|
|
834
|
+
const [formEnv, setFormEnv] = React.useState('');
|
|
835
|
+
const [formCwd, setFormCwd] = React.useState('');
|
|
836
|
+
const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
|
|
837
|
+
const [formWorktree, setFormWorktree] = React.useState(false);
|
|
838
|
+
const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
|
|
839
|
+
const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
|
|
840
|
+
const [formHttpUrl, setFormHttpUrl] = React.useState('');
|
|
841
|
+
const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
|
|
842
|
+
const [formHttpBody, setFormHttpBody] = React.useState('');
|
|
843
|
+
const [formSshProfileId, setFormSshProfileId] = React.useState('');
|
|
844
|
+
const [formSshTarget, setFormSshTarget] = React.useState('');
|
|
845
|
+
const [formSshKeyPath, setFormSshKeyPath] = React.useState('');
|
|
846
|
+
const [formDockerImage, setFormDockerImage] = React.useState('');
|
|
847
|
+
const [formNodePath, setFormNodePath] = React.useState('');
|
|
848
|
+
const [formPythonPath, setFormPythonPath] = React.useState('');
|
|
849
|
+
const [formSkillName, setFormSkillName] = React.useState('');
|
|
850
|
+
const [formWorkflowName, setFormWorkflowName] = React.useState('');
|
|
507
851
|
const [stats, setStats] = React.useState(null);
|
|
508
852
|
|
|
509
853
|
// Live execution indicator (#35): a light 1s tick only while some task
|
|
@@ -527,6 +871,8 @@ window.__ModuleLoader__.load({
|
|
|
527
871
|
const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
|
|
528
872
|
const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
|
|
529
873
|
const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
|
|
874
|
+
const [settingsDelivery, setSettingsDelivery] = React.useState({});
|
|
875
|
+
const [settingsSections, setSettingsSections] = React.useState({ channels: true });
|
|
530
876
|
const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
|
|
531
877
|
const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
|
|
532
878
|
const [testStatus, setTestStatus] = React.useState(null);
|
|
@@ -556,6 +902,49 @@ window.__ModuleLoader__.load({
|
|
|
556
902
|
return () => window.removeEventListener('keydown', onKey);
|
|
557
903
|
}, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
|
|
558
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
|
+
|
|
559
948
|
const fetchTasks = async () => {
|
|
560
949
|
setLoading(true);
|
|
561
950
|
setFetchError(null);
|
|
@@ -600,8 +989,7 @@ window.__ModuleLoader__.load({
|
|
|
600
989
|
|
|
601
990
|
React.useEffect(() => {
|
|
602
991
|
fetchTasks();
|
|
603
|
-
const pollInterval = setInterval(() => {
|
|
604
|
-
// Background polling without resetting loading spinner
|
|
992
|
+
const pollInterval = setInterval(() => { // Background polling without resetting loading spinner
|
|
605
993
|
fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query))
|
|
606
994
|
.then(res => res.json())
|
|
607
995
|
.then(data => {
|
|
@@ -634,13 +1022,128 @@ window.__ModuleLoader__.load({
|
|
|
634
1022
|
}
|
|
635
1023
|
};
|
|
636
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
|
+
|
|
637
1141
|
const handleDelete = async (id) => {
|
|
638
1142
|
if (!confirm(T_KEY(t, 'confirm.delete'))) return;
|
|
639
1143
|
try {
|
|
640
1144
|
await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
|
|
641
1145
|
fetchTasks();
|
|
642
|
-
} catch (err) {}
|
|
643
|
-
};
|
|
1146
|
+
} catch (err) {} };
|
|
644
1147
|
|
|
645
1148
|
const openSettingsModal = async () => {
|
|
646
1149
|
setTestStatus(null);
|
|
@@ -654,6 +1157,7 @@ window.__ModuleLoader__.load({
|
|
|
654
1157
|
setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
|
|
655
1158
|
setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
|
|
656
1159
|
setSettingsKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
|
|
1160
|
+
setSettingsDelivery(pickDeliverySettings(data.settings));
|
|
657
1161
|
setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
|
|
658
1162
|
setSettingsHasCustomBotToken(Boolean(data.settings.hasCustomBotToken));
|
|
659
1163
|
}
|
|
@@ -666,13 +1170,13 @@ window.__ModuleLoader__.load({
|
|
|
666
1170
|
const res = await fetch('/dsh-cron/settings', {
|
|
667
1171
|
method: 'POST',
|
|
668
1172
|
headers: { 'Content-Type': 'application/json' },
|
|
669
|
-
body: JSON.stringify({
|
|
1173
|
+
body: JSON.stringify(Object.assign({
|
|
670
1174
|
botToken: settingsBotToken,
|
|
671
1175
|
chatId: settingsChatId,
|
|
672
1176
|
notifyTelegram: settingsNotifyGlobal,
|
|
673
1177
|
onlyOnFailure: settingsOnlyOnFailureGlobal,
|
|
674
1178
|
kanbanBaseUrl: settingsKanbanUrl,
|
|
675
|
-
})
|
|
1179
|
+
}, settingsDelivery))
|
|
676
1180
|
});
|
|
677
1181
|
const data = await res.json();
|
|
678
1182
|
if (data && data.ok) {
|
|
@@ -685,8 +1189,35 @@ window.__ModuleLoader__.load({
|
|
|
685
1189
|
}
|
|
686
1190
|
};
|
|
687
1191
|
|
|
688
|
-
|
|
689
|
-
|
|
1192
|
+
/**
|
|
1193
|
+
* Update one delivery setting without dropping the rest of the object.
|
|
1194
|
+
* `undefined` removes the key so the server default applies again; an
|
|
1195
|
+
* empty string is kept so a text field can be cleared on purpose.
|
|
1196
|
+
*/
|
|
1197
|
+
const updateDelivery = (key, value) => {
|
|
1198
|
+
setSettingsDelivery((prev) => {
|
|
1199
|
+
const next = Object.assign({}, prev);
|
|
1200
|
+
if (value === undefined) delete next[key];
|
|
1201
|
+
else next[key] = value;
|
|
1202
|
+
return next;
|
|
1203
|
+
});
|
|
1204
|
+
};
|
|
1205
|
+
|
|
1206
|
+
/** Per-channel template overrides live in a nested map on the server. */
|
|
1207
|
+
const updateChannelTemplate = (channelId, value) => {
|
|
1208
|
+
setSettingsDelivery((prev) => {
|
|
1209
|
+
const templates = Object.assign({}, prev.channelTemplates || {});
|
|
1210
|
+
if (value && value.trim()) templates[channelId] = value;
|
|
1211
|
+
else delete templates[channelId];
|
|
1212
|
+
return Object.assign({}, prev, { channelTemplates: templates });
|
|
1213
|
+
});
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
const toggleDeliverySection = (key) => {
|
|
1217
|
+
setSettingsSections((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
|
|
1218
|
+
};
|
|
1219
|
+
|
|
1220
|
+
const handleTestTelegram = async () => { setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
|
|
690
1221
|
try {
|
|
691
1222
|
const res = await fetch('/dsh-cron/telegram/test', {
|
|
692
1223
|
method: 'POST',
|
|
@@ -714,6 +1245,8 @@ window.__ModuleLoader__.load({
|
|
|
714
1245
|
setFormType('llm');
|
|
715
1246
|
setFormDelivery('isolated');
|
|
716
1247
|
setFormNotifyTelegram(false);
|
|
1248
|
+
setFormChannels([]);
|
|
1249
|
+
setFormTemplate('');
|
|
717
1250
|
setFormOnlyOnFailure(false);
|
|
718
1251
|
setFormTimeoutSeconds(1800);
|
|
719
1252
|
setFormOverlapPolicy('skip');
|
|
@@ -722,6 +1255,23 @@ window.__ModuleLoader__.load({
|
|
|
722
1255
|
setFormMisfirePolicy('skip');
|
|
723
1256
|
setFormMaxRetries(0);
|
|
724
1257
|
setFormPermissionPreset('default');
|
|
1258
|
+
setFormEnv('');
|
|
1259
|
+
setFormCwd('');
|
|
1260
|
+
setFormWorkspaceId('');
|
|
1261
|
+
setFormWorktree(false);
|
|
1262
|
+
setFormKeepWorktree(false);
|
|
1263
|
+
setFormHttpMethod('GET');
|
|
1264
|
+
setFormHttpUrl('');
|
|
1265
|
+
setFormHttpHeaders('');
|
|
1266
|
+
setFormHttpBody('');
|
|
1267
|
+
setFormSshProfileId('');
|
|
1268
|
+
setFormSshTarget('');
|
|
1269
|
+
setFormSshKeyPath('');
|
|
1270
|
+
setFormDockerImage('');
|
|
1271
|
+
setFormNodePath('');
|
|
1272
|
+
setFormPythonPath('');
|
|
1273
|
+
setFormSkillName('');
|
|
1274
|
+
setFormWorkflowName('');
|
|
725
1275
|
if (preset) {
|
|
726
1276
|
setFormTitle(preset.title || '');
|
|
727
1277
|
setFormSchedule(preset.schedule || '');
|
|
@@ -768,6 +1318,8 @@ window.__ModuleLoader__.load({
|
|
|
768
1318
|
setFormModel(task.model || '');
|
|
769
1319
|
setFormDelivery(task.delivery || 'isolated');
|
|
770
1320
|
setFormNotifyTelegram(Boolean(task.notifyTelegram));
|
|
1321
|
+
setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
|
|
1322
|
+
setFormTemplate(task.template || '');
|
|
771
1323
|
setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
|
|
772
1324
|
setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
|
|
773
1325
|
setFormOverlapPolicy(task.overlapPolicy || 'skip');
|
|
@@ -776,6 +1328,23 @@ window.__ModuleLoader__.load({
|
|
|
776
1328
|
setFormMisfirePolicy(task.misfirePolicy || 'skip');
|
|
777
1329
|
setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
|
|
778
1330
|
setFormPermissionPreset(task.permissionPreset || 'default');
|
|
1331
|
+
setFormEnv(formatEnvText(task.env));
|
|
1332
|
+
setFormCwd(task.cwd || '');
|
|
1333
|
+
setFormWorkspaceId(task.workspaceId || '');
|
|
1334
|
+
setFormWorktree(Boolean(task.worktree));
|
|
1335
|
+
setFormKeepWorktree(Boolean(task.keepWorktree));
|
|
1336
|
+
setFormHttpMethod(task.httpMethod || 'GET');
|
|
1337
|
+
setFormHttpUrl(task.httpUrl || '');
|
|
1338
|
+
setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
|
|
1339
|
+
setFormHttpBody(task.httpBody || '');
|
|
1340
|
+
setFormSshProfileId(task.sshProfileId || '');
|
|
1341
|
+
setFormSshTarget(task.sshTarget || '');
|
|
1342
|
+
setFormSshKeyPath(task.sshKeyPath || '');
|
|
1343
|
+
setFormDockerImage(task.dockerImage || '');
|
|
1344
|
+
setFormNodePath(task.nodePath || '');
|
|
1345
|
+
setFormPythonPath(task.pythonPath || '');
|
|
1346
|
+
setFormSkillName(task.skillName || '');
|
|
1347
|
+
setFormWorkflowName(task.workflowName || '');
|
|
779
1348
|
if (task.provider) loadModels(task.provider);
|
|
780
1349
|
else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
|
|
781
1350
|
loadTaskHistory(task.id);
|
|
@@ -792,17 +1361,20 @@ window.__ModuleLoader__.load({
|
|
|
792
1361
|
const handleSaveManual = async (e) => {
|
|
793
1362
|
e.preventDefault();
|
|
794
1363
|
try {
|
|
1364
|
+
const isAgentType = AGENT_FORM_TYPES.includes(formType);
|
|
795
1365
|
const payload = {
|
|
796
1366
|
id: formId || undefined,
|
|
797
1367
|
status: selectedTaskMeta ? selectedTaskMeta.status : 'active',
|
|
798
1368
|
title: formTitle,
|
|
799
1369
|
schedule: formSchedule,
|
|
800
|
-
prompt: formPrompt,
|
|
1370
|
+
prompt: formType === 'http' ? (formPrompt.trim() || formHttpUrl.trim()) : formPrompt,
|
|
801
1371
|
type: formType,
|
|
802
1372
|
delivery: formDelivery || 'isolated',
|
|
803
|
-
provider:
|
|
804
|
-
model:
|
|
1373
|
+
provider: isAgentType ? (formProvider || undefined) : undefined,
|
|
1374
|
+
model: isAgentType ? (formModel || undefined) : undefined,
|
|
805
1375
|
notifyTelegram: formNotifyTelegram,
|
|
1376
|
+
channels: formChannels,
|
|
1377
|
+
template: formTemplate.trim(),
|
|
806
1378
|
onlyOnFailure: formOnlyOnFailure,
|
|
807
1379
|
timeoutSeconds: Number(formTimeoutSeconds) || 1800,
|
|
808
1380
|
overlapPolicy: formOverlapPolicy,
|
|
@@ -811,11 +1383,28 @@ window.__ModuleLoader__.load({
|
|
|
811
1383
|
maxRetries: Number(formMaxRetries) || 0,
|
|
812
1384
|
permissionPreset: formPermissionPreset,
|
|
813
1385
|
kanbanMode: formKanbanMode,
|
|
1386
|
+
env: parseEnvText(formEnv),
|
|
1387
|
+
cwd: formCwd.trim(),
|
|
1388
|
+
workspaceId: formWorkspaceId.trim(),
|
|
1389
|
+
worktree: isAgentType ? Boolean(formWorktree) : false,
|
|
1390
|
+
keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
|
|
1391
|
+
httpMethod: formHttpMethod,
|
|
1392
|
+
httpUrl: formHttpUrl.trim(),
|
|
1393
|
+
httpHeaders: formHttpHeaders.trim() || undefined,
|
|
1394
|
+
httpBody: formHttpBody,
|
|
1395
|
+
sshProfileId: formSshProfileId.trim(),
|
|
1396
|
+
sshTarget: formSshTarget.trim(),
|
|
1397
|
+
sshKeyPath: formSshKeyPath.trim(),
|
|
1398
|
+
dockerImage: formDockerImage.trim(),
|
|
1399
|
+
nodePath: formNodePath.trim(),
|
|
1400
|
+
pythonPath: formPythonPath.trim(),
|
|
1401
|
+
skillName: formSkillName.trim(),
|
|
1402
|
+
workflowName: formWorkflowName.trim(),
|
|
814
1403
|
};
|
|
815
|
-
// Cross-origin hardening:
|
|
816
|
-
// header, which
|
|
1404
|
+
// Cross-origin hardening: every code-executing type needs the explicit
|
|
1405
|
+
// confirm header, which forged cross-site posts cannot attach (#86)
|
|
817
1406
|
const headers = { 'Content-Type': 'application/json' };
|
|
818
|
-
if (formType
|
|
1407
|
+
if (CODE_FORM_TYPES.includes(formType)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
|
|
819
1408
|
const res = await fetch('/dsh-cron/tasks', {
|
|
820
1409
|
method: 'POST',
|
|
821
1410
|
headers,
|
|
@@ -905,7 +1494,31 @@ window.__ModuleLoader__.load({
|
|
|
905
1494
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: openDshModal }, T_KEY(t, 'actions.createWithDsh')),
|
|
906
1495
|
React.createElement('button', { className: 'dsh-cron-dropdown-item', onClick: () => openManualModal(null) }, T_KEY(t, 'actions.setupManually'))
|
|
907
1496
|
)
|
|
908
|
-
)
|
|
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
|
+
})
|
|
909
1522
|
)
|
|
910
1523
|
),
|
|
911
1524
|
React.createElement('div', { className: 'dsh-cron-search-bar' },
|
|
@@ -951,6 +1564,50 @@ window.__ModuleLoader__.load({
|
|
|
951
1564
|
: T_KEY(t, 'tabs.completed'))
|
|
952
1565
|
)
|
|
953
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
|
+
),
|
|
954
1611
|
fetchError ? React.createElement('div', {
|
|
955
1612
|
className: 'dsh-cron-status-banner',
|
|
956
1613
|
'data-type': 'error',
|
|
@@ -964,15 +1621,38 @@ window.__ModuleLoader__.load({
|
|
|
964
1621
|
onClick: () => fetchTasks()
|
|
965
1622
|
}, T_KEY(t, 'banner.retry'))
|
|
966
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,
|
|
967
1636
|
React.createElement('div', { className: 'dsh-cron-task-list' },
|
|
968
1637
|
loading && tasks.length === 0
|
|
969
1638
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #888)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.loading'))
|
|
970
1639
|
: tasks.length === 0
|
|
971
1640
|
? React.createElement('div', { style: { color: 'var(--dsw-alias-label-tertiary, #777)', padding: '16px 0', fontSize: '14px' } }, T_KEY(t, 'list.empty'))
|
|
972
|
-
:
|
|
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) =>
|
|
973
1652
|
React.createElement('div', {
|
|
974
1653
|
key: task.id,
|
|
975
1654
|
className: 'dsh-cron-task-item',
|
|
1655
|
+
'data-task-id': task.id,
|
|
976
1656
|
role: 'button',
|
|
977
1657
|
tabIndex: 0,
|
|
978
1658
|
'aria-label': task.title,
|
|
@@ -1035,6 +1715,13 @@ window.__ModuleLoader__.load({
|
|
|
1035
1715
|
'aria-label': T_KEY(t, 'actions.edit'),
|
|
1036
1716
|
onClick: () => openEditModal(task)
|
|
1037
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))),
|
|
1038
1725
|
React.createElement('button', {
|
|
1039
1726
|
type: 'button',
|
|
1040
1727
|
className: 'dsh-cron-icon-btn dsh-cron-danger',
|
|
@@ -1176,7 +1863,14 @@ window.__ModuleLoader__.load({
|
|
|
1176
1863
|
onChange: (e) => setFormType(e.target.value)
|
|
1177
1864
|
},
|
|
1178
1865
|
React.createElement('option', { value: 'llm' }, T_KEY(t, 'form.typeLlm')),
|
|
1179
|
-
React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript'))
|
|
1866
|
+
React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript')),
|
|
1867
|
+
React.createElement('option', { value: 'node' }, T_KEY(t, 'form.typeNode')),
|
|
1868
|
+
React.createElement('option', { value: 'python' }, T_KEY(t, 'form.typePython')),
|
|
1869
|
+
React.createElement('option', { value: 'http' }, T_KEY(t, 'form.typeHttp')),
|
|
1870
|
+
React.createElement('option', { value: 'ssh' }, T_KEY(t, 'form.typeSsh')),
|
|
1871
|
+
React.createElement('option', { value: 'docker' }, T_KEY(t, 'form.typeDocker')),
|
|
1872
|
+
React.createElement('option', { value: 'skill' }, T_KEY(t, 'form.typeSkill')),
|
|
1873
|
+
React.createElement('option', { value: 'workflow' }, T_KEY(t, 'form.typeWorkflow'))
|
|
1180
1874
|
)
|
|
1181
1875
|
),
|
|
1182
1876
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
@@ -1268,7 +1962,7 @@ window.__ModuleLoader__.load({
|
|
|
1268
1962
|
React.createElement('option', { value: 'always' }, T_KEY(t, 'form.kanbanAlways'))
|
|
1269
1963
|
)
|
|
1270
1964
|
),
|
|
1271
|
-
formType
|
|
1965
|
+
AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1272
1966
|
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1273
1967
|
React.createElement('label', null, T_KEY(t, 'form.providerLabel')),
|
|
1274
1968
|
React.createElement('select', {
|
|
@@ -1290,14 +1984,165 @@ window.__ModuleLoader__.load({
|
|
|
1290
1984
|
)
|
|
1291
1985
|
)
|
|
1292
1986
|
),
|
|
1293
|
-
React.createElement('div', { className: 'dsh-cron-form-
|
|
1294
|
-
React.createElement('
|
|
1987
|
+
formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
1988
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1989
|
+
React.createElement('label', null, T_KEY(t, 'form.httpMethodLabel')),
|
|
1990
|
+
React.createElement('select', {
|
|
1991
|
+
value: formHttpMethod,
|
|
1992
|
+
onChange: (e) => setFormHttpMethod(e.target.value)
|
|
1993
|
+
},
|
|
1994
|
+
['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map((m) => React.createElement('option', { key: m, value: m }, m))
|
|
1995
|
+
)
|
|
1996
|
+
),
|
|
1997
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
1998
|
+
React.createElement('label', null, T_KEY(t, 'form.httpUrlLabel')),
|
|
1999
|
+
React.createElement('input', {
|
|
2000
|
+
required: true,
|
|
2001
|
+
value: formHttpUrl,
|
|
2002
|
+
placeholder: 'https://example.test/webhook',
|
|
2003
|
+
onChange: (e) => setFormHttpUrl(e.target.value)
|
|
2004
|
+
})
|
|
2005
|
+
)
|
|
2006
|
+
),
|
|
2007
|
+
formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
2008
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2009
|
+
React.createElement('label', null, T_KEY(t, 'form.httpHeadersLabel')),
|
|
2010
|
+
React.createElement('input', {
|
|
2011
|
+
value: formHttpHeaders,
|
|
2012
|
+
placeholder: '{"Authorization":"Bearer ..."}',
|
|
2013
|
+
onChange: (e) => setFormHttpHeaders(e.target.value)
|
|
2014
|
+
})
|
|
2015
|
+
),
|
|
2016
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2017
|
+
React.createElement('label', null, T_KEY(t, 'form.httpBodyLabel')),
|
|
2018
|
+
React.createElement('input', {
|
|
2019
|
+
value: formHttpBody,
|
|
2020
|
+
onChange: (e) => setFormHttpBody(e.target.value)
|
|
2021
|
+
})
|
|
2022
|
+
)
|
|
2023
|
+
),
|
|
2024
|
+
formType === 'ssh' && React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
2025
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2026
|
+
React.createElement('label', null, T_KEY(t, 'form.sshProfileLabel')),
|
|
2027
|
+
React.createElement('input', {
|
|
2028
|
+
value: formSshProfileId,
|
|
2029
|
+
placeholder: 'prod-web',
|
|
2030
|
+
onChange: (e) => setFormSshProfileId(e.target.value)
|
|
2031
|
+
}),
|
|
2032
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.sshProfileHint'))
|
|
2033
|
+
),
|
|
2034
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2035
|
+
React.createElement('label', null, T_KEY(t, 'form.sshTargetLabel')),
|
|
2036
|
+
React.createElement('input', {
|
|
2037
|
+
value: formSshTarget,
|
|
2038
|
+
placeholder: 'root@10.0.0.5',
|
|
2039
|
+
onChange: (e) => setFormSshTarget(e.target.value)
|
|
2040
|
+
}),
|
|
2041
|
+
React.createElement('input', {
|
|
2042
|
+
style: { marginTop: '6px' },
|
|
2043
|
+
value: formSshKeyPath,
|
|
2044
|
+
placeholder: '~/.ssh/id_ed25519',
|
|
2045
|
+
onChange: (e) => setFormSshKeyPath(e.target.value)
|
|
2046
|
+
})
|
|
2047
|
+
)
|
|
2048
|
+
),
|
|
2049
|
+
formType === 'docker' && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2050
|
+
React.createElement('label', null, T_KEY(t, 'form.dockerImageLabel')),
|
|
2051
|
+
React.createElement('input', {
|
|
2052
|
+
required: true,
|
|
2053
|
+
value: formDockerImage,
|
|
2054
|
+
placeholder: 'alpine:3.20',
|
|
2055
|
+
onChange: (e) => setFormDockerImage(e.target.value)
|
|
2056
|
+
})
|
|
2057
|
+
),
|
|
2058
|
+
formType === 'skill' && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2059
|
+
React.createElement('label', null, T_KEY(t, 'form.skillNameLabel')),
|
|
2060
|
+
React.createElement('input', {
|
|
2061
|
+
value: formSkillName,
|
|
2062
|
+
placeholder: 'gitea',
|
|
2063
|
+
onChange: (e) => setFormSkillName(e.target.value)
|
|
2064
|
+
})
|
|
2065
|
+
),
|
|
2066
|
+
formType === 'workflow' && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2067
|
+
React.createElement('label', null, T_KEY(t, 'form.workflowNameLabel')),
|
|
2068
|
+
React.createElement('input', {
|
|
2069
|
+
value: formWorkflowName,
|
|
2070
|
+
placeholder: 'nightly-release',
|
|
2071
|
+
onChange: (e) => setFormWorkflowName(e.target.value)
|
|
2072
|
+
})
|
|
2073
|
+
),
|
|
2074
|
+
(formType === 'node' || formType === 'python') && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2075
|
+
React.createElement('label', null, formType === 'node' ? 'Node.js binary (optional)' : 'Python interpreter (optional)'),
|
|
2076
|
+
React.createElement('input', {
|
|
2077
|
+
value: formType === 'node' ? formNodePath : formPythonPath,
|
|
2078
|
+
placeholder: formType === 'node' ? 'auto' : '.venv/bin/python',
|
|
2079
|
+
onChange: (e) => (formType === 'node' ? setFormNodePath(e.target.value) : setFormPythonPath(e.target.value))
|
|
2080
|
+
})
|
|
2081
|
+
),
|
|
2082
|
+
!AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2083
|
+
React.createElement('label', null, T_KEY(t, 'form.envLabel')),
|
|
2084
|
+
React.createElement('textarea', {
|
|
2085
|
+
rows: 3,
|
|
2086
|
+
style: { fontFamily: 'monospace', fontSize: '12.5px' },
|
|
2087
|
+
value: formEnv,
|
|
2088
|
+
placeholder: T_KEY(t, 'form.envPlaceholder'),
|
|
2089
|
+
onChange: (e) => setFormEnv(e.target.value)
|
|
2090
|
+
}),
|
|
2091
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.envHint'))
|
|
2092
|
+
),
|
|
2093
|
+
React.createElement('div', { className: 'dsh-cron-form-row' },
|
|
2094
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2095
|
+
React.createElement('label', null, T_KEY(t, 'form.cwdLabel')),
|
|
2096
|
+
React.createElement('input', {
|
|
2097
|
+
value: formCwd,
|
|
2098
|
+
placeholder: '/srv/app',
|
|
2099
|
+
onChange: (e) => setFormCwd(e.target.value)
|
|
2100
|
+
})
|
|
2101
|
+
),
|
|
2102
|
+
React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2103
|
+
React.createElement('label', null, T_KEY(t, 'form.workspaceLabel')),
|
|
2104
|
+
React.createElement('input', {
|
|
2105
|
+
value: formWorkspaceId,
|
|
2106
|
+
onChange: (e) => setFormWorkspaceId(e.target.value)
|
|
2107
|
+
})
|
|
2108
|
+
)
|
|
2109
|
+
),
|
|
2110
|
+
AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2111
|
+
React.createElement('label', { className: 'dsh-cron-checkbox-row' },
|
|
2112
|
+
React.createElement('input', {
|
|
2113
|
+
type: 'checkbox',
|
|
2114
|
+
checked: formWorktree,
|
|
2115
|
+
onChange: (e) => setFormWorktree(e.target.checked)
|
|
2116
|
+
}),
|
|
2117
|
+
React.createElement('span', null, T_KEY(t, 'form.worktreeLabel'))
|
|
2118
|
+
),
|
|
2119
|
+
formWorktree && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
|
|
2120
|
+
React.createElement('input', {
|
|
2121
|
+
type: 'checkbox',
|
|
2122
|
+
checked: formKeepWorktree,
|
|
2123
|
+
onChange: (e) => setFormKeepWorktree(e.target.checked)
|
|
2124
|
+
}),
|
|
2125
|
+
React.createElement('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
|
|
2126
|
+
)
|
|
2127
|
+
),
|
|
2128
|
+
formType !== 'http' && React.createElement('div', { className: 'dsh-cron-form-group' },
|
|
2129
|
+
React.createElement('label', null, AGENT_FORM_TYPES.includes(formType) ? T_KEY(t, 'form.promptLabelLlm') : T_KEY(t, 'form.promptLabelScript')),
|
|
1295
2130
|
React.createElement('textarea', {
|
|
1296
2131
|
required: true,
|
|
1297
2132
|
rows: 9,
|
|
1298
2133
|
style: { fontFamily: 'monospace', fontSize: '12.5px', lineHeight: '1.45' },
|
|
1299
2134
|
value: formPrompt,
|
|
1300
|
-
placeholder:
|
|
2135
|
+
placeholder: AGENT_FORM_TYPES.includes(formType)
|
|
2136
|
+
? T_KEY(t, 'form.promptPlaceholderLlm')
|
|
2137
|
+
: formType === 'docker'
|
|
2138
|
+
? 'python -V'
|
|
2139
|
+
: formType === 'ssh'
|
|
2140
|
+
? 'uname -a'
|
|
2141
|
+
: formType === 'node'
|
|
2142
|
+
? 'console.log("hello")'
|
|
2143
|
+
: formType === 'python'
|
|
2144
|
+
? 'print("hello")'
|
|
2145
|
+
: T_KEY(t, 'form.promptPlaceholderScript'),
|
|
1301
2146
|
onChange: (e) => setFormPrompt(e.target.value)
|
|
1302
2147
|
})
|
|
1303
2148
|
),
|
|
@@ -1322,13 +2167,43 @@ window.__ModuleLoader__.load({
|
|
|
1322
2167
|
}),
|
|
1323
2168
|
React.createElement('span', null, T_KEY(t, 'form.notifyTelegram'))
|
|
1324
2169
|
),
|
|
1325
|
-
formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
|
|
2170
|
+
(formNotifyTelegram || formChannels.length > 0) && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
|
|
1326
2171
|
React.createElement('input', {
|
|
1327
2172
|
type: 'checkbox',
|
|
1328
2173
|
checked: formOnlyOnFailure,
|
|
1329
2174
|
onChange: (e) => setFormOnlyOnFailure(e.target.checked)
|
|
1330
2175
|
}),
|
|
1331
2176
|
React.createElement('span', null, T_KEY(t, 'form.notifyOnlyFailure'))
|
|
2177
|
+
),
|
|
2178
|
+
React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2, #2e2e2e)' } },
|
|
2179
|
+
React.createElement('div', { style: { fontWeight: 500, fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary, #aaa)', marginBottom: '8px' } }, T_KEY(t, 'form.channelsTitle')),
|
|
2180
|
+
React.createElement('div', { className: 'dsh-cron-channel-grid' },
|
|
2181
|
+
...DELIVERY_CHANNELS.map((id) => React.createElement('label', { key: id, className: 'dsh-cron-checkbox-row' },
|
|
2182
|
+
React.createElement('input', {
|
|
2183
|
+
type: 'checkbox',
|
|
2184
|
+
checked: formChannels.indexOf(id) >= 0,
|
|
2185
|
+
onChange: (e) => {
|
|
2186
|
+
setFormChannels((prev) => {
|
|
2187
|
+
const set = new Set(prev);
|
|
2188
|
+
if (e.target.checked) set.add(id); else set.delete(id);
|
|
2189
|
+
return DELIVERY_CHANNELS.filter((c) => set.has(c));
|
|
2190
|
+
});
|
|
2191
|
+
}
|
|
2192
|
+
}),
|
|
2193
|
+
React.createElement('span', null, T_KEY(t, CHANNEL_LABEL_KEYS[id]))
|
|
2194
|
+
))
|
|
2195
|
+
),
|
|
2196
|
+
React.createElement('div', { className: 'dsh-cron-hint', style: { marginTop: '6px' } }, T_KEY(t, 'form.channelsHint'))
|
|
2197
|
+
),
|
|
2198
|
+
React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
|
|
2199
|
+
React.createElement('label', null, T_KEY(t, 'form.templateLabel')),
|
|
2200
|
+
React.createElement('input', {
|
|
2201
|
+
type: 'text',
|
|
2202
|
+
value: formTemplate,
|
|
2203
|
+
placeholder: T_KEY(t, 'form.templatePlaceholder'),
|
|
2204
|
+
onChange: (e) => setFormTemplate(e.target.value)
|
|
2205
|
+
}),
|
|
2206
|
+
React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
|
|
1332
2207
|
)
|
|
1333
2208
|
),
|
|
1334
2209
|
selectedTaskMeta && React.createElement('div', {
|
|
@@ -1376,7 +2251,7 @@ window.__ModuleLoader__.load({
|
|
|
1376
2251
|
|
|
1377
2252
|
// 2. Settings modal
|
|
1378
2253
|
settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
|
|
1379
|
-
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
2254
|
+
React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
1380
2255
|
React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
|
|
1381
2256
|
React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
|
|
1382
2257
|
React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: 'var(--dsh-cron-info)' }, ...iconHtml(ICON_TELEGRAM) }),
|
|
@@ -1425,6 +2300,14 @@ window.__ModuleLoader__.load({
|
|
|
1425
2300
|
React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
|
|
1426
2301
|
)
|
|
1427
2302
|
),
|
|
2303
|
+
...renderDeliverySettings({
|
|
2304
|
+
T: (key, vars) => T_KEY(t, key, vars),
|
|
2305
|
+
values: settingsDelivery,
|
|
2306
|
+
onChange: updateDelivery,
|
|
2307
|
+
onChannelTemplate: updateChannelTemplate,
|
|
2308
|
+
sections: settingsSections,
|
|
2309
|
+
onToggleSection: toggleDeliverySection,
|
|
2310
|
+
}),
|
|
1428
2311
|
React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
|
|
1429
2312
|
React.createElement('button', {
|
|
1430
2313
|
type: 'button',
|
|
@@ -1453,7 +2336,50 @@ window.__ModuleLoader__.load({
|
|
|
1453
2336
|
)
|
|
1454
2337
|
),
|
|
1455
2338
|
|
|
1456
|
-
// 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
|
|
1457
2383
|
dshModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => !dshLoading && setDshModalOpen(false) },
|
|
1458
2384
|
React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
|
|
1459
2385
|
React.createElement('div', { className: 'dsh-cron-modal-title' }, T_KEY(t, 'dsh.modalTitle')),
|
|
@@ -1510,6 +2436,8 @@ window.__ModuleLoader__.load({
|
|
|
1510
2436
|
const [notify, setNotify] = React.useState(false);
|
|
1511
2437
|
const [onlyFailure, setOnlyFailure] = React.useState(false);
|
|
1512
2438
|
const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
|
|
2439
|
+
const [delivery, setDelivery] = React.useState({});
|
|
2440
|
+
const [sections, setSections] = React.useState({ channels: true });
|
|
1513
2441
|
const [hasDefault, setHasDefault] = React.useState(false);
|
|
1514
2442
|
const [saved, setSaved] = React.useState(false);
|
|
1515
2443
|
const [testInfo, setTestInfo] = React.useState(null);
|
|
@@ -1534,6 +2462,7 @@ window.__ModuleLoader__.load({
|
|
|
1534
2462
|
setNotify(Boolean(data.settings.notifyTelegram));
|
|
1535
2463
|
setOnlyFailure(Boolean(data.settings.onlyOnFailure));
|
|
1536
2464
|
setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
|
|
2465
|
+
setDelivery(pickDeliverySettings(data.settings));
|
|
1537
2466
|
setHasDefault(Boolean(data.settings.hasDefaultCredentials));
|
|
1538
2467
|
setLoadState('ready');
|
|
1539
2468
|
} else {
|
|
@@ -1550,13 +2479,13 @@ window.__ModuleLoader__.load({
|
|
|
1550
2479
|
const res = await fetch('/dsh-cron/settings', {
|
|
1551
2480
|
method: 'POST',
|
|
1552
2481
|
headers: { 'Content-Type': 'application/json' },
|
|
1553
|
-
body: JSON.stringify({
|
|
2482
|
+
body: JSON.stringify(Object.assign({
|
|
1554
2483
|
botToken: token,
|
|
1555
2484
|
chatId,
|
|
1556
2485
|
notifyTelegram: notify,
|
|
1557
2486
|
onlyOnFailure: onlyFailure,
|
|
1558
2487
|
kanbanBaseUrl: kanbanUrl
|
|
1559
|
-
})
|
|
2488
|
+
}, delivery))
|
|
1560
2489
|
});
|
|
1561
2490
|
const data = await res.json();
|
|
1562
2491
|
if (data && data.ok) {
|
|
@@ -1686,6 +2615,24 @@ window.__ModuleLoader__.load({
|
|
|
1686
2615
|
React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
|
|
1687
2616
|
)
|
|
1688
2617
|
),
|
|
2618
|
+
...renderDeliverySettings({
|
|
2619
|
+
T: (key, vars) => T_KEY(t, key, vars),
|
|
2620
|
+
values: delivery,
|
|
2621
|
+
onChange: (key, value) => setDelivery((prev) => {
|
|
2622
|
+
const next = Object.assign({}, prev);
|
|
2623
|
+
if (value === undefined) delete next[key];
|
|
2624
|
+
else next[key] = value;
|
|
2625
|
+
return next;
|
|
2626
|
+
}),
|
|
2627
|
+
onChannelTemplate: (channelId, value) => setDelivery((prev) => {
|
|
2628
|
+
const templates = Object.assign({}, prev.channelTemplates || {});
|
|
2629
|
+
if (value && value.trim()) templates[channelId] = value;
|
|
2630
|
+
else delete templates[channelId];
|
|
2631
|
+
return Object.assign({}, prev, { channelTemplates: templates });
|
|
2632
|
+
}),
|
|
2633
|
+
sections,
|
|
2634
|
+
onToggleSection: (key) => setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
|
|
2635
|
+
}),
|
|
1689
2636
|
testInfo && testInfo.message && React.createElement('div', {
|
|
1690
2637
|
className: 'dsh-cron-status-banner',
|
|
1691
2638
|
'data-type': testInfo.success ? 'success' : 'error',
|
|
@@ -1751,6 +2698,162 @@ window.__ModuleLoader__.load({
|
|
|
1751
2698
|
return entry;
|
|
1752
2699
|
}
|
|
1753
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
|
+
|
|
1754
2857
|
function placeEntry(target, entry) {
|
|
1755
2858
|
let anchor = target;
|
|
1756
2859
|
let next = target.nextElementSibling;
|
|
@@ -1933,6 +3036,7 @@ window.__ModuleLoader__.load({
|
|
|
1933
3036
|
ctx.effect(() => {
|
|
1934
3037
|
const off = [
|
|
1935
3038
|
mountSidebarEntry(toggle, translate('sidebar.label')),
|
|
3039
|
+
mountSidebarJobs(toggle, translate('sidebar.jobsTitle'), (taskId) => { pendingTaskHighlight = taskId; }),
|
|
1936
3040
|
mountCronScreen(ctx, toggle)
|
|
1937
3041
|
];
|
|
1938
3042
|
return () => { for (const dispose of off) dispose(); };
|