@goodandready/dsh-cron 0.2.3 → 0.2.4

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/lib/client.js CHANGED
@@ -53,7 +53,7 @@ window.__ModuleLoader__.load({
53
53
  'actions.delete': 'Delete task',
54
54
  'actions.pause': 'Pause',
55
55
  'actions.resume': 'Resume',
56
- 'actions.settings': 'Telegram & Notifications',
56
+ 'actions.settings': 'Notifications & channels',
57
57
  'search.placeholder': 'Search scheduled tasks',
58
58
  'stats.activeTasks': 'Active tasks',
59
59
  'stats.totalRuns': 'Total runs',
@@ -94,7 +94,32 @@ window.__ModuleLoader__.load({
94
94
  'form.namePlaceholder': 'For example: dsh-tags-watch',
95
95
  'form.typeLabel': 'Execution type',
96
96
  'form.typeLlm': 'LLM (AI agent)',
97
- 'form.typeScript': 'NO-LLM (Shell command/script)',
97
+ 'form.typeScript': 'Shell command / script',
98
+ 'form.typeNode': 'Node.js',
99
+ 'form.typePython': 'Python',
100
+ 'form.typeHttp': 'HTTP request / webhook',
101
+ 'form.typeSsh': 'Remote SSH',
102
+ 'form.typeDocker': 'Docker container',
103
+ 'form.typeSkill': 'DSH Skill',
104
+ 'form.typeWorkflow': 'DSH Workflow',
105
+ 'form.envLabel': 'Environment variables (KEY=VALUE per line)',
106
+ 'form.envPlaceholder': 'NODE_ENV=production\nTZ=Europe/Berlin',
107
+ 'form.envHint': 'Applied to non-LLM runtimes only. Do not store secrets here.',
108
+ 'form.cwdLabel': 'Working directory (optional)',
109
+ 'form.workspaceLabel': 'Workspace id or path (optional)',
110
+ 'form.worktreeLabel': 'Run in an isolated git worktree',
111
+ 'form.keepWorktreeLabel': 'Keep the worktree after the run',
112
+ 'form.httpMethodLabel': 'HTTP method',
113
+ 'form.httpUrlLabel': 'URL',
114
+ 'form.httpHeadersLabel': 'Headers (JSON, optional)',
115
+ 'form.httpBodyLabel': 'Body (optional)',
116
+ 'form.sshProfileLabel': 'dsh-remote-workspace profile id',
117
+ 'form.sshProfileHint': 'Preferred: host and credentials stay in the remote-workspace plugin.',
118
+ 'form.sshTargetLabel': 'Fallback target (user@host, optional)',
119
+ 'form.sshKeyLabel': 'Private key path (fallback, optional)',
120
+ 'form.dockerImageLabel': 'Container image',
121
+ 'form.skillNameLabel': 'Skill name',
122
+ 'form.workflowNameLabel': 'Workflow name',
98
123
  'form.scheduleLabel': 'Schedule (cron or interval)',
99
124
  'form.schedulePlaceholder': '0 */6 * * * or every 2h or in 30m or at: 2026-09-05T15:00:00Z',
100
125
  'form.timeoutLabel': 'Execution timeout (seconds)',
@@ -131,15 +156,62 @@ window.__ModuleLoader__.load({
131
156
  'form.notifyTitle': 'Telegram notifications',
132
157
  'form.notifyTelegram': 'Send the run report to Telegram',
133
158
  'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
159
+ 'form.channelsTitle': 'Delivery channels',
160
+ 'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
161
+ 'form.templateLabel': 'Message template (optional)',
162
+ 'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
163
+ 'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
164
+ 'form.channelTelegram': 'Telegram',
165
+ 'form.channelKanban': 'dsh-kanban card',
166
+ 'form.channelDiscord': 'Discord',
167
+ 'form.channelSlack': 'Slack',
168
+ 'form.channelNtfy': 'ntfy',
169
+ 'form.channelBark': 'Bark',
170
+ 'form.channelPushplus': 'PushPlus',
171
+ 'form.channelEmail': 'Email',
172
+ 'form.channelTts': 'Voice (dsh-tts)',
173
+ 'form.channelGitea': 'Gitea issue',
174
+ 'settings.deliverySection': 'Delivery channels',
175
+ 'settings.secretsSection': 'Credentials (references)',
176
+ 'settings.secretsHint': 'Enter the NAME of a DSH credential, not the secret itself. Secrets stay in the credentials store.',
177
+ 'settings.telegramSection': 'Telegram',
178
+ 'settings.templatesSection': 'Message templates',
179
+ 'settings.channelTemplateLabel': 'Template for {channel}',
180
+ 'settings.botTokenRefLabel': 'Bot token credential name',
181
+ 'settings.botTokenRefPlaceholder': 'e.g. TELEGRAM_BOT_TOKEN',
182
+ 'settings.discordLabel': 'Discord webhook URL',
183
+ 'settings.slackLabel': 'Slack webhook URL',
184
+ 'settings.ntfyUrlLabel': 'ntfy server URL',
185
+ 'settings.ntfyTopicLabel': 'ntfy topic',
186
+ 'settings.ntfyTokenRefLabel': 'ntfy token credential name',
187
+ 'settings.barkServerLabel': 'Bark server URL',
188
+ 'settings.barkKeyLabel': 'Bark device key',
189
+ 'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
190
+ '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
+ 'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
199
+ 'settings.giteaBaseUrlLabel': 'Gitea base URL',
200
+ 'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
201
+ 'settings.giteaTokenRefLabel': 'Gitea token credential name',
202
+ 'settings.templateLabel': 'Default message template',
203
+ 'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
204
+ 'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
205
+ 'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
134
206
  'form.nextRunLabel': 'Next run: ',
135
207
  'form.notScheduled': 'not scheduled',
136
208
  'settings.loading': 'Loading settings...',
137
209
  'settings.unavailable': 'Settings are unavailable right now.',
138
210
  'settings.retry': 'Retry',
139
211
  'settings.cardTitle': '⏰ Task scheduler (Cron)',
140
- 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios and Telegram/Kanban integration.',
212
+ 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus, email, voice and Gitea.',
141
213
  'settings.openPanel': 'Open task panel',
142
- 'settings.modalTitle': 'Telegram & notification settings',
214
+ 'settings.modalTitle': 'Notifications & delivery channels',
143
215
  'settings.tokenLabel': 'Telegram Bot Token',
144
216
  'settings.tokenPlaceholder': '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
145
217
  'settings.tokenPlaceholderDefault': 'Using the bot configured in DSH settings (or enter your own)',
@@ -231,6 +303,169 @@ window.__ModuleLoader__.load({
231
303
  }
232
304
  const ErrorBoundary = createErrorBoundary();
233
305
 
306
+ const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
307
+ const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
308
+
309
+ // Delivery channels (#26) — must stay in sync with lib/channels.js.
310
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'email', 'tts', 'gitea'];
311
+ const CHANNEL_LABEL_KEYS = {
312
+ telegram: 'form.channelTelegram',
313
+ kanban: 'form.channelKanban',
314
+ discord: 'form.channelDiscord',
315
+ slack: 'form.channelSlack',
316
+ ntfy: 'form.channelNtfy',
317
+ bark: 'form.channelBark',
318
+ pushplus: 'form.channelPushplus',
319
+ email: 'form.channelEmail',
320
+ tts: 'form.channelTts',
321
+ gitea: 'form.channelGitea',
322
+ };
323
+
324
+ // Non-secret delivery settings shown in the settings panel. Secrets are
325
+ // referenced by credential name only (#51) — never typed as values.
326
+ const DELIVERY_SETTING_FIELDS = [
327
+ { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
328
+ { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
329
+ { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
330
+ { key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
331
+ { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
332
+ { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
333
+ { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
334
+ { key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
335
+ { key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
336
+ { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
337
+ { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
338
+ { 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
+ { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
345
+ { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
346
+ { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
347
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
348
+ ];
349
+ const DELIVERY_TOGGLE_FIELDS = [
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));
353
+ // The global template plus the per-channel override map (server model).
354
+ const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
355
+
356
+ /** Collect every delivery key the settings endpoints may return. */
357
+ function pickDeliverySettings(source) {
358
+ const out = {};
359
+ for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
360
+ if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
361
+ }
362
+ if (out.channelTemplates && typeof out.channelTemplates === 'object') {
363
+ out.channelTemplates = Object.assign({}, out.channelTemplates);
364
+ }
365
+ return out;
366
+ }
367
+
368
+ /**
369
+ * Shared delivery form used by both the task panel modal and the plugin
370
+ * settings card, so a new channel is added in exactly one place.
371
+ * Sections collapse via the canonical aria-expanded head contract.
372
+ */
373
+ function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
374
+ const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
375
+ const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
376
+ React.createElement('label', null, T(f.labelKey)),
377
+ React.createElement('input', f.numeric ? {
378
+ type: 'number',
379
+ min: 1000,
380
+ step: 1000,
381
+ value: val(f.key),
382
+ placeholder: '15000',
383
+ onChange: (e) => onChange(f.key, e.target.value === '' ? undefined : Number(e.target.value)),
384
+ } : {
385
+ type: 'text',
386
+ value: val(f.key),
387
+ placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
388
+ onChange: (e) => onChange(f.key, e.target.value)
389
+ })
390
+ );
391
+ const section = (key, titleKey, children) => {
392
+ const open = Boolean(sections[key]);
393
+ return React.createElement('div', { className: 'dsh-cron-section', key },
394
+ React.createElement('button', {
395
+ type: 'button',
396
+ className: 'dsh-cron-section-head',
397
+ 'aria-expanded': open ? 'true' : 'false',
398
+ onClick: () => onToggleSection(key)
399
+ },
400
+ React.createElement('span', null, T(titleKey)),
401
+ React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
402
+ ),
403
+ open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
404
+ );
405
+ };
406
+
407
+ return [
408
+ section('secrets', 'settings.secretsSection', [
409
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
410
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
411
+ ]),
412
+ section('channels', 'settings.deliverySection', [
413
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
414
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
415
+ ),
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
+ ]),
425
+ section('templates', 'settings.templatesSection', [
426
+ React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
427
+ React.createElement('label', null, T('settings.templateLabel')),
428
+ React.createElement('input', {
429
+ type: 'text',
430
+ value: val('template'),
431
+ onChange: (e) => onChange('template', e.target.value)
432
+ }),
433
+ React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
434
+ ),
435
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
436
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
437
+ React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
438
+ React.createElement('input', {
439
+ type: 'text',
440
+ value: (values.channelTemplates && values.channelTemplates[id]) || '',
441
+ onChange: (e) => onChannelTemplate(id, e.target.value)
442
+ })
443
+ ))
444
+ ),
445
+ ]),
446
+ ];
447
+ }
448
+
449
+ /** Parse a KEY=VALUE textarea into the task env object (#38). */
450
+ function parseEnvText(text) {
451
+ const env = {};
452
+ for (const rawLine of String(text || '').split('\n')) {
453
+ const line = rawLine.trim();
454
+ if (!line || line.startsWith('#')) continue;
455
+ const idx = line.indexOf('=');
456
+ if (idx <= 0) continue;
457
+ const key = line.slice(0, idx).trim();
458
+ const value = line.slice(idx + 1).trim();
459
+ if (key) env[key] = value;
460
+ }
461
+ return Object.keys(env).length ? env : undefined;
462
+ }
463
+
464
+ function formatEnvText(env) {
465
+ if (!env || typeof env !== 'object') return '';
466
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
467
+ }
468
+
234
469
  // Status colors: core semantic state tokens first (same set dsh-clinebot
235
470
  // uses), plugin fallbacks as a single point of change (#105).
236
471
  const STYLES = `
@@ -316,6 +551,12 @@ window.__ModuleLoader__.load({
316
551
 
317
552
  .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
553
  .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; }
554
+ .dsh-cron-modal-scroll { max-width: 620px; max-height: 88vh; overflow-y: auto; }
555
+ .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; }
556
+ .dsh-cron-section-head:hover { color: var(--dsw-alias-label-primary, #eee); }
557
+ .dsh-cron-section-chevron { transition: transform 0.15s ease; display: inline-block; }
558
+ .dsh-cron-section-chevron[data-open="false"] { transform: rotate(-90deg); }
559
+ .dsh-cron-section-body { padding-bottom: 4px; }
319
560
  .dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
320
561
  .dsh-cron-form-group { margin-bottom: 16px; }
321
562
  .dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
@@ -373,6 +614,12 @@ window.__ModuleLoader__.load({
373
614
  .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
615
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
375
616
  .dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
617
+ .dsh-cron-channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px 12px; }
618
+ .dsh-cron-section { border-top: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-top: 16px; padding-top: 12px; }
619
+ .dsh-cron-section-title { font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 10px; }
620
+ .dsh-cron-secret-row { display: flex; align-items: center; gap: 8px; }
621
+ .dsh-cron-secret-row input { flex: 1; }
622
+ .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
623
  .dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
377
624
  .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
625
  .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); }
@@ -496,6 +743,8 @@ window.__ModuleLoader__.load({
496
743
  const [taskHistory, setTaskHistory] = React.useState([]);
497
744
  const [historyLoading, setHistoryLoading] = React.useState(false);
498
745
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
746
+ const [formChannels, setFormChannels] = React.useState([]);
747
+ const [formTemplate, setFormTemplate] = React.useState('');
499
748
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
500
749
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
501
750
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
@@ -504,6 +753,23 @@ window.__ModuleLoader__.load({
504
753
  const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
505
754
  const [formMaxRetries, setFormMaxRetries] = React.useState(0);
506
755
  const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
756
+ const [formEnv, setFormEnv] = React.useState('');
757
+ const [formCwd, setFormCwd] = React.useState('');
758
+ const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
759
+ const [formWorktree, setFormWorktree] = React.useState(false);
760
+ const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
761
+ const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
762
+ const [formHttpUrl, setFormHttpUrl] = React.useState('');
763
+ const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
764
+ const [formHttpBody, setFormHttpBody] = React.useState('');
765
+ const [formSshProfileId, setFormSshProfileId] = React.useState('');
766
+ const [formSshTarget, setFormSshTarget] = React.useState('');
767
+ const [formSshKeyPath, setFormSshKeyPath] = React.useState('');
768
+ const [formDockerImage, setFormDockerImage] = React.useState('');
769
+ const [formNodePath, setFormNodePath] = React.useState('');
770
+ const [formPythonPath, setFormPythonPath] = React.useState('');
771
+ const [formSkillName, setFormSkillName] = React.useState('');
772
+ const [formWorkflowName, setFormWorkflowName] = React.useState('');
507
773
  const [stats, setStats] = React.useState(null);
508
774
 
509
775
  // Live execution indicator (#35): a light 1s tick only while some task
@@ -527,6 +793,8 @@ window.__ModuleLoader__.load({
527
793
  const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
528
794
  const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
529
795
  const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
796
+ const [settingsDelivery, setSettingsDelivery] = React.useState({});
797
+ const [settingsSections, setSettingsSections] = React.useState({ channels: true });
530
798
  const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
531
799
  const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
532
800
  const [testStatus, setTestStatus] = React.useState(null);
@@ -654,6 +922,7 @@ window.__ModuleLoader__.load({
654
922
  setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
655
923
  setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
656
924
  setSettingsKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
925
+ setSettingsDelivery(pickDeliverySettings(data.settings));
657
926
  setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
658
927
  setSettingsHasCustomBotToken(Boolean(data.settings.hasCustomBotToken));
659
928
  }
@@ -666,13 +935,13 @@ window.__ModuleLoader__.load({
666
935
  const res = await fetch('/dsh-cron/settings', {
667
936
  method: 'POST',
668
937
  headers: { 'Content-Type': 'application/json' },
669
- body: JSON.stringify({
938
+ body: JSON.stringify(Object.assign({
670
939
  botToken: settingsBotToken,
671
940
  chatId: settingsChatId,
672
941
  notifyTelegram: settingsNotifyGlobal,
673
942
  onlyOnFailure: settingsOnlyOnFailureGlobal,
674
943
  kanbanBaseUrl: settingsKanbanUrl,
675
- })
944
+ }, settingsDelivery))
676
945
  });
677
946
  const data = await res.json();
678
947
  if (data && data.ok) {
@@ -685,8 +954,35 @@ window.__ModuleLoader__.load({
685
954
  }
686
955
  };
687
956
 
688
- const handleTestTelegram = async () => {
689
- setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
957
+ /**
958
+ * Update one delivery setting without dropping the rest of the object.
959
+ * `undefined` removes the key so the server default applies again; an
960
+ * empty string is kept so a text field can be cleared on purpose.
961
+ */
962
+ const updateDelivery = (key, value) => {
963
+ setSettingsDelivery((prev) => {
964
+ const next = Object.assign({}, prev);
965
+ if (value === undefined) delete next[key];
966
+ else next[key] = value;
967
+ return next;
968
+ });
969
+ };
970
+
971
+ /** Per-channel template overrides live in a nested map on the server. */
972
+ const updateChannelTemplate = (channelId, value) => {
973
+ setSettingsDelivery((prev) => {
974
+ const templates = Object.assign({}, prev.channelTemplates || {});
975
+ if (value && value.trim()) templates[channelId] = value;
976
+ else delete templates[channelId];
977
+ return Object.assign({}, prev, { channelTemplates: templates });
978
+ });
979
+ };
980
+
981
+ const toggleDeliverySection = (key) => {
982
+ setSettingsSections((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
983
+ };
984
+
985
+ const handleTestTelegram = async () => { setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
690
986
  try {
691
987
  const res = await fetch('/dsh-cron/telegram/test', {
692
988
  method: 'POST',
@@ -714,6 +1010,8 @@ window.__ModuleLoader__.load({
714
1010
  setFormType('llm');
715
1011
  setFormDelivery('isolated');
716
1012
  setFormNotifyTelegram(false);
1013
+ setFormChannels([]);
1014
+ setFormTemplate('');
717
1015
  setFormOnlyOnFailure(false);
718
1016
  setFormTimeoutSeconds(1800);
719
1017
  setFormOverlapPolicy('skip');
@@ -722,6 +1020,23 @@ window.__ModuleLoader__.load({
722
1020
  setFormMisfirePolicy('skip');
723
1021
  setFormMaxRetries(0);
724
1022
  setFormPermissionPreset('default');
1023
+ setFormEnv('');
1024
+ setFormCwd('');
1025
+ setFormWorkspaceId('');
1026
+ setFormWorktree(false);
1027
+ setFormKeepWorktree(false);
1028
+ setFormHttpMethod('GET');
1029
+ setFormHttpUrl('');
1030
+ setFormHttpHeaders('');
1031
+ setFormHttpBody('');
1032
+ setFormSshProfileId('');
1033
+ setFormSshTarget('');
1034
+ setFormSshKeyPath('');
1035
+ setFormDockerImage('');
1036
+ setFormNodePath('');
1037
+ setFormPythonPath('');
1038
+ setFormSkillName('');
1039
+ setFormWorkflowName('');
725
1040
  if (preset) {
726
1041
  setFormTitle(preset.title || '');
727
1042
  setFormSchedule(preset.schedule || '');
@@ -768,6 +1083,8 @@ window.__ModuleLoader__.load({
768
1083
  setFormModel(task.model || '');
769
1084
  setFormDelivery(task.delivery || 'isolated');
770
1085
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
1086
+ setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
1087
+ setFormTemplate(task.template || '');
771
1088
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
772
1089
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
773
1090
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
@@ -776,6 +1093,23 @@ window.__ModuleLoader__.load({
776
1093
  setFormMisfirePolicy(task.misfirePolicy || 'skip');
777
1094
  setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
778
1095
  setFormPermissionPreset(task.permissionPreset || 'default');
1096
+ setFormEnv(formatEnvText(task.env));
1097
+ setFormCwd(task.cwd || '');
1098
+ setFormWorkspaceId(task.workspaceId || '');
1099
+ setFormWorktree(Boolean(task.worktree));
1100
+ setFormKeepWorktree(Boolean(task.keepWorktree));
1101
+ setFormHttpMethod(task.httpMethod || 'GET');
1102
+ setFormHttpUrl(task.httpUrl || '');
1103
+ setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
1104
+ setFormHttpBody(task.httpBody || '');
1105
+ setFormSshProfileId(task.sshProfileId || '');
1106
+ setFormSshTarget(task.sshTarget || '');
1107
+ setFormSshKeyPath(task.sshKeyPath || '');
1108
+ setFormDockerImage(task.dockerImage || '');
1109
+ setFormNodePath(task.nodePath || '');
1110
+ setFormPythonPath(task.pythonPath || '');
1111
+ setFormSkillName(task.skillName || '');
1112
+ setFormWorkflowName(task.workflowName || '');
779
1113
  if (task.provider) loadModels(task.provider);
780
1114
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
781
1115
  loadTaskHistory(task.id);
@@ -792,17 +1126,20 @@ window.__ModuleLoader__.load({
792
1126
  const handleSaveManual = async (e) => {
793
1127
  e.preventDefault();
794
1128
  try {
1129
+ const isAgentType = AGENT_FORM_TYPES.includes(formType);
795
1130
  const payload = {
796
1131
  id: formId || undefined,
797
1132
  status: selectedTaskMeta ? selectedTaskMeta.status : 'active',
798
1133
  title: formTitle,
799
1134
  schedule: formSchedule,
800
- prompt: formPrompt,
1135
+ prompt: formType === 'http' ? (formPrompt.trim() || formHttpUrl.trim()) : formPrompt,
801
1136
  type: formType,
802
1137
  delivery: formDelivery || 'isolated',
803
- provider: formType === 'script' ? undefined : (formProvider || undefined),
804
- model: formType === 'script' ? undefined : (formModel || undefined),
1138
+ provider: isAgentType ? (formProvider || undefined) : undefined,
1139
+ model: isAgentType ? (formModel || undefined) : undefined,
805
1140
  notifyTelegram: formNotifyTelegram,
1141
+ channels: formChannels,
1142
+ template: formTemplate.trim(),
806
1143
  onlyOnFailure: formOnlyOnFailure,
807
1144
  timeoutSeconds: Number(formTimeoutSeconds) || 1800,
808
1145
  overlapPolicy: formOverlapPolicy,
@@ -811,11 +1148,28 @@ window.__ModuleLoader__.load({
811
1148
  maxRetries: Number(formMaxRetries) || 0,
812
1149
  permissionPreset: formPermissionPreset,
813
1150
  kanbanMode: formKanbanMode,
1151
+ env: parseEnvText(formEnv),
1152
+ cwd: formCwd.trim(),
1153
+ workspaceId: formWorkspaceId.trim(),
1154
+ worktree: isAgentType ? Boolean(formWorktree) : false,
1155
+ keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
1156
+ httpMethod: formHttpMethod,
1157
+ httpUrl: formHttpUrl.trim(),
1158
+ httpHeaders: formHttpHeaders.trim() || undefined,
1159
+ httpBody: formHttpBody,
1160
+ sshProfileId: formSshProfileId.trim(),
1161
+ sshTarget: formSshTarget.trim(),
1162
+ sshKeyPath: formSshKeyPath.trim(),
1163
+ dockerImage: formDockerImage.trim(),
1164
+ nodePath: formNodePath.trim(),
1165
+ pythonPath: formPythonPath.trim(),
1166
+ skillName: formSkillName.trim(),
1167
+ workflowName: formWorkflowName.trim(),
814
1168
  };
815
- // Cross-origin hardening: script tasks need the explicit confirm
816
- // header, which browsers cannot attach to forged cross-site posts (#86)
1169
+ // Cross-origin hardening: every code-executing type needs the explicit
1170
+ // confirm header, which forged cross-site posts cannot attach (#86)
817
1171
  const headers = { 'Content-Type': 'application/json' };
818
- if (formType === 'script') headers[SCRIPT_CONFIRM_HEADER] = 'script';
1172
+ if (CODE_FORM_TYPES.includes(formType)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
819
1173
  const res = await fetch('/dsh-cron/tasks', {
820
1174
  method: 'POST',
821
1175
  headers,
@@ -1176,7 +1530,14 @@ window.__ModuleLoader__.load({
1176
1530
  onChange: (e) => setFormType(e.target.value)
1177
1531
  },
1178
1532
  React.createElement('option', { value: 'llm' }, T_KEY(t, 'form.typeLlm')),
1179
- React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript'))
1533
+ React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript')),
1534
+ React.createElement('option', { value: 'node' }, T_KEY(t, 'form.typeNode')),
1535
+ React.createElement('option', { value: 'python' }, T_KEY(t, 'form.typePython')),
1536
+ React.createElement('option', { value: 'http' }, T_KEY(t, 'form.typeHttp')),
1537
+ React.createElement('option', { value: 'ssh' }, T_KEY(t, 'form.typeSsh')),
1538
+ React.createElement('option', { value: 'docker' }, T_KEY(t, 'form.typeDocker')),
1539
+ React.createElement('option', { value: 'skill' }, T_KEY(t, 'form.typeSkill')),
1540
+ React.createElement('option', { value: 'workflow' }, T_KEY(t, 'form.typeWorkflow'))
1180
1541
  )
1181
1542
  ),
1182
1543
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -1268,7 +1629,7 @@ window.__ModuleLoader__.load({
1268
1629
  React.createElement('option', { value: 'always' }, T_KEY(t, 'form.kanbanAlways'))
1269
1630
  )
1270
1631
  ),
1271
- formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row' },
1632
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-row' },
1272
1633
  React.createElement('div', { className: 'dsh-cron-form-group' },
1273
1634
  React.createElement('label', null, T_KEY(t, 'form.providerLabel')),
1274
1635
  React.createElement('select', {
@@ -1290,14 +1651,165 @@ window.__ModuleLoader__.load({
1290
1651
  )
1291
1652
  )
1292
1653
  ),
1293
- React.createElement('div', { className: 'dsh-cron-form-group' },
1294
- React.createElement('label', null, formType === 'script' ? T_KEY(t, 'form.promptLabelScript') : T_KEY(t, 'form.promptLabelLlm')),
1654
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1655
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1656
+ React.createElement('label', null, T_KEY(t, 'form.httpMethodLabel')),
1657
+ React.createElement('select', {
1658
+ value: formHttpMethod,
1659
+ onChange: (e) => setFormHttpMethod(e.target.value)
1660
+ },
1661
+ ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map((m) => React.createElement('option', { key: m, value: m }, m))
1662
+ )
1663
+ ),
1664
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1665
+ React.createElement('label', null, T_KEY(t, 'form.httpUrlLabel')),
1666
+ React.createElement('input', {
1667
+ required: true,
1668
+ value: formHttpUrl,
1669
+ placeholder: 'https://example.test/webhook',
1670
+ onChange: (e) => setFormHttpUrl(e.target.value)
1671
+ })
1672
+ )
1673
+ ),
1674
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1675
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1676
+ React.createElement('label', null, T_KEY(t, 'form.httpHeadersLabel')),
1677
+ React.createElement('input', {
1678
+ value: formHttpHeaders,
1679
+ placeholder: '{"Authorization":"Bearer ..."}',
1680
+ onChange: (e) => setFormHttpHeaders(e.target.value)
1681
+ })
1682
+ ),
1683
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1684
+ React.createElement('label', null, T_KEY(t, 'form.httpBodyLabel')),
1685
+ React.createElement('input', {
1686
+ value: formHttpBody,
1687
+ onChange: (e) => setFormHttpBody(e.target.value)
1688
+ })
1689
+ )
1690
+ ),
1691
+ formType === 'ssh' && React.createElement('div', { className: 'dsh-cron-form-row' },
1692
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1693
+ React.createElement('label', null, T_KEY(t, 'form.sshProfileLabel')),
1694
+ React.createElement('input', {
1695
+ value: formSshProfileId,
1696
+ placeholder: 'prod-web',
1697
+ onChange: (e) => setFormSshProfileId(e.target.value)
1698
+ }),
1699
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.sshProfileHint'))
1700
+ ),
1701
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1702
+ React.createElement('label', null, T_KEY(t, 'form.sshTargetLabel')),
1703
+ React.createElement('input', {
1704
+ value: formSshTarget,
1705
+ placeholder: 'root@10.0.0.5',
1706
+ onChange: (e) => setFormSshTarget(e.target.value)
1707
+ }),
1708
+ React.createElement('input', {
1709
+ style: { marginTop: '6px' },
1710
+ value: formSshKeyPath,
1711
+ placeholder: '~/.ssh/id_ed25519',
1712
+ onChange: (e) => setFormSshKeyPath(e.target.value)
1713
+ })
1714
+ )
1715
+ ),
1716
+ formType === 'docker' && React.createElement('div', { className: 'dsh-cron-form-group' },
1717
+ React.createElement('label', null, T_KEY(t, 'form.dockerImageLabel')),
1718
+ React.createElement('input', {
1719
+ required: true,
1720
+ value: formDockerImage,
1721
+ placeholder: 'alpine:3.20',
1722
+ onChange: (e) => setFormDockerImage(e.target.value)
1723
+ })
1724
+ ),
1725
+ formType === 'skill' && React.createElement('div', { className: 'dsh-cron-form-group' },
1726
+ React.createElement('label', null, T_KEY(t, 'form.skillNameLabel')),
1727
+ React.createElement('input', {
1728
+ value: formSkillName,
1729
+ placeholder: 'gitea',
1730
+ onChange: (e) => setFormSkillName(e.target.value)
1731
+ })
1732
+ ),
1733
+ formType === 'workflow' && React.createElement('div', { className: 'dsh-cron-form-group' },
1734
+ React.createElement('label', null, T_KEY(t, 'form.workflowNameLabel')),
1735
+ React.createElement('input', {
1736
+ value: formWorkflowName,
1737
+ placeholder: 'nightly-release',
1738
+ onChange: (e) => setFormWorkflowName(e.target.value)
1739
+ })
1740
+ ),
1741
+ (formType === 'node' || formType === 'python') && React.createElement('div', { className: 'dsh-cron-form-group' },
1742
+ React.createElement('label', null, formType === 'node' ? 'Node.js binary (optional)' : 'Python interpreter (optional)'),
1743
+ React.createElement('input', {
1744
+ value: formType === 'node' ? formNodePath : formPythonPath,
1745
+ placeholder: formType === 'node' ? 'auto' : '.venv/bin/python',
1746
+ onChange: (e) => (formType === 'node' ? setFormNodePath(e.target.value) : setFormPythonPath(e.target.value))
1747
+ })
1748
+ ),
1749
+ !AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
1750
+ React.createElement('label', null, T_KEY(t, 'form.envLabel')),
1751
+ React.createElement('textarea', {
1752
+ rows: 3,
1753
+ style: { fontFamily: 'monospace', fontSize: '12.5px' },
1754
+ value: formEnv,
1755
+ placeholder: T_KEY(t, 'form.envPlaceholder'),
1756
+ onChange: (e) => setFormEnv(e.target.value)
1757
+ }),
1758
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.envHint'))
1759
+ ),
1760
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1761
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1762
+ React.createElement('label', null, T_KEY(t, 'form.cwdLabel')),
1763
+ React.createElement('input', {
1764
+ value: formCwd,
1765
+ placeholder: '/srv/app',
1766
+ onChange: (e) => setFormCwd(e.target.value)
1767
+ })
1768
+ ),
1769
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1770
+ React.createElement('label', null, T_KEY(t, 'form.workspaceLabel')),
1771
+ React.createElement('input', {
1772
+ value: formWorkspaceId,
1773
+ onChange: (e) => setFormWorkspaceId(e.target.value)
1774
+ })
1775
+ )
1776
+ ),
1777
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
1778
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
1779
+ React.createElement('input', {
1780
+ type: 'checkbox',
1781
+ checked: formWorktree,
1782
+ onChange: (e) => setFormWorktree(e.target.checked)
1783
+ }),
1784
+ React.createElement('span', null, T_KEY(t, 'form.worktreeLabel'))
1785
+ ),
1786
+ formWorktree && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1787
+ React.createElement('input', {
1788
+ type: 'checkbox',
1789
+ checked: formKeepWorktree,
1790
+ onChange: (e) => setFormKeepWorktree(e.target.checked)
1791
+ }),
1792
+ React.createElement('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
1793
+ )
1794
+ ),
1795
+ formType !== 'http' && React.createElement('div', { className: 'dsh-cron-form-group' },
1796
+ React.createElement('label', null, AGENT_FORM_TYPES.includes(formType) ? T_KEY(t, 'form.promptLabelLlm') : T_KEY(t, 'form.promptLabelScript')),
1295
1797
  React.createElement('textarea', {
1296
1798
  required: true,
1297
1799
  rows: 9,
1298
1800
  style: { fontFamily: 'monospace', fontSize: '12.5px', lineHeight: '1.45' },
1299
1801
  value: formPrompt,
1300
- placeholder: formType === 'script' ? T_KEY(t, 'form.promptPlaceholderScript') : T_KEY(t, 'form.promptPlaceholderLlm'),
1802
+ placeholder: AGENT_FORM_TYPES.includes(formType)
1803
+ ? T_KEY(t, 'form.promptPlaceholderLlm')
1804
+ : formType === 'docker'
1805
+ ? 'python -V'
1806
+ : formType === 'ssh'
1807
+ ? 'uname -a'
1808
+ : formType === 'node'
1809
+ ? 'console.log("hello")'
1810
+ : formType === 'python'
1811
+ ? 'print("hello")'
1812
+ : T_KEY(t, 'form.promptPlaceholderScript'),
1301
1813
  onChange: (e) => setFormPrompt(e.target.value)
1302
1814
  })
1303
1815
  ),
@@ -1322,13 +1834,43 @@ window.__ModuleLoader__.load({
1322
1834
  }),
1323
1835
  React.createElement('span', null, T_KEY(t, 'form.notifyTelegram'))
1324
1836
  ),
1325
- formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1837
+ (formNotifyTelegram || formChannels.length > 0) && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1326
1838
  React.createElement('input', {
1327
1839
  type: 'checkbox',
1328
1840
  checked: formOnlyOnFailure,
1329
1841
  onChange: (e) => setFormOnlyOnFailure(e.target.checked)
1330
1842
  }),
1331
1843
  React.createElement('span', null, T_KEY(t, 'form.notifyOnlyFailure'))
1844
+ ),
1845
+ React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2, #2e2e2e)' } },
1846
+ React.createElement('div', { style: { fontWeight: 500, fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary, #aaa)', marginBottom: '8px' } }, T_KEY(t, 'form.channelsTitle')),
1847
+ React.createElement('div', { className: 'dsh-cron-channel-grid' },
1848
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('label', { key: id, className: 'dsh-cron-checkbox-row' },
1849
+ React.createElement('input', {
1850
+ type: 'checkbox',
1851
+ checked: formChannels.indexOf(id) >= 0,
1852
+ onChange: (e) => {
1853
+ setFormChannels((prev) => {
1854
+ const set = new Set(prev);
1855
+ if (e.target.checked) set.add(id); else set.delete(id);
1856
+ return DELIVERY_CHANNELS.filter((c) => set.has(c));
1857
+ });
1858
+ }
1859
+ }),
1860
+ React.createElement('span', null, T_KEY(t, CHANNEL_LABEL_KEYS[id]))
1861
+ ))
1862
+ ),
1863
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginTop: '6px' } }, T_KEY(t, 'form.channelsHint'))
1864
+ ),
1865
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
1866
+ React.createElement('label', null, T_KEY(t, 'form.templateLabel')),
1867
+ React.createElement('input', {
1868
+ type: 'text',
1869
+ value: formTemplate,
1870
+ placeholder: T_KEY(t, 'form.templatePlaceholder'),
1871
+ onChange: (e) => setFormTemplate(e.target.value)
1872
+ }),
1873
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
1332
1874
  )
1333
1875
  ),
1334
1876
  selectedTaskMeta && React.createElement('div', {
@@ -1376,7 +1918,7 @@ window.__ModuleLoader__.load({
1376
1918
 
1377
1919
  // 2. Settings modal
1378
1920
  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() },
1921
+ React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
1380
1922
  React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
1381
1923
  React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
1382
1924
  React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: 'var(--dsh-cron-info)' }, ...iconHtml(ICON_TELEGRAM) }),
@@ -1425,6 +1967,14 @@ window.__ModuleLoader__.load({
1425
1967
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1426
1968
  )
1427
1969
  ),
1970
+ ...renderDeliverySettings({
1971
+ T: (key, vars) => T_KEY(t, key, vars),
1972
+ values: settingsDelivery,
1973
+ onChange: updateDelivery,
1974
+ onChannelTemplate: updateChannelTemplate,
1975
+ sections: settingsSections,
1976
+ onToggleSection: toggleDeliverySection,
1977
+ }),
1428
1978
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
1429
1979
  React.createElement('button', {
1430
1980
  type: 'button',
@@ -1510,6 +2060,8 @@ window.__ModuleLoader__.load({
1510
2060
  const [notify, setNotify] = React.useState(false);
1511
2061
  const [onlyFailure, setOnlyFailure] = React.useState(false);
1512
2062
  const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
2063
+ const [delivery, setDelivery] = React.useState({});
2064
+ const [sections, setSections] = React.useState({ channels: true });
1513
2065
  const [hasDefault, setHasDefault] = React.useState(false);
1514
2066
  const [saved, setSaved] = React.useState(false);
1515
2067
  const [testInfo, setTestInfo] = React.useState(null);
@@ -1534,6 +2086,7 @@ window.__ModuleLoader__.load({
1534
2086
  setNotify(Boolean(data.settings.notifyTelegram));
1535
2087
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
1536
2088
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
2089
+ setDelivery(pickDeliverySettings(data.settings));
1537
2090
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
1538
2091
  setLoadState('ready');
1539
2092
  } else {
@@ -1550,13 +2103,13 @@ window.__ModuleLoader__.load({
1550
2103
  const res = await fetch('/dsh-cron/settings', {
1551
2104
  method: 'POST',
1552
2105
  headers: { 'Content-Type': 'application/json' },
1553
- body: JSON.stringify({
2106
+ body: JSON.stringify(Object.assign({
1554
2107
  botToken: token,
1555
2108
  chatId,
1556
2109
  notifyTelegram: notify,
1557
2110
  onlyOnFailure: onlyFailure,
1558
2111
  kanbanBaseUrl: kanbanUrl
1559
- })
2112
+ }, delivery))
1560
2113
  });
1561
2114
  const data = await res.json();
1562
2115
  if (data && data.ok) {
@@ -1686,6 +2239,24 @@ window.__ModuleLoader__.load({
1686
2239
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1687
2240
  )
1688
2241
  ),
2242
+ ...renderDeliverySettings({
2243
+ T: (key, vars) => T_KEY(t, key, vars),
2244
+ values: delivery,
2245
+ onChange: (key, value) => setDelivery((prev) => {
2246
+ const next = Object.assign({}, prev);
2247
+ if (value === undefined) delete next[key];
2248
+ else next[key] = value;
2249
+ return next;
2250
+ }),
2251
+ onChannelTemplate: (channelId, value) => setDelivery((prev) => {
2252
+ const templates = Object.assign({}, prev.channelTemplates || {});
2253
+ if (value && value.trim()) templates[channelId] = value;
2254
+ else delete templates[channelId];
2255
+ return Object.assign({}, prev, { channelTemplates: templates });
2256
+ }),
2257
+ sections,
2258
+ onToggleSection: (key) => setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
2259
+ }),
1689
2260
  testInfo && testInfo.message && React.createElement('div', {
1690
2261
  className: 'dsh-cron-status-banner',
1691
2262
  'data-type': testInfo.success ? 'success' : 'error',