@kwirthmagnify/kwirth-sender-timed 0.1.3 → 0.1.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 ADDED
@@ -0,0 +1,84 @@
1
+ # sender-timed
2
+
3
+ Routes or drops messages based on **time-of-day windows** and optional **day-of-week** filters. Rules are evaluated in order — first match wins. If no rule matches, the `defaultAction` applies.
4
+
5
+ ## Use cases
6
+
7
+ - Only forward alerts to on-call during business hours
8
+ - Drop non-critical notifications at night
9
+ - Route differently on weekends vs weekdays
10
+
11
+ ## Config fields
12
+
13
+ | Field | Type | Required | Description |
14
+ |---|---|---|---|
15
+ | `name` | string | ✓ | Config name |
16
+ | `timezone` | string | | IANA timezone (e.g. `Europe/Madrid`). Defaults to server local time. |
17
+ | `rules` | JSON array | | Ordered list of `ITimedSenderRule` (see below) |
18
+ | `defaultAction` | `send`\|`drop` | | Action when no rule matches (default: `drop`) |
19
+ | `defaultSenderId` | string | | Sender to use for default `send` |
20
+ | `defaultConfigName` | string | | Config name for default `send` |
21
+
22
+ ## Rule fields (`ITimedSenderRule`)
23
+
24
+ | Field | Type | Required | Description |
25
+ |---|---|---|---|
26
+ | `from` | `"HH:mm"` | ✓ | Start of window (inclusive, 24h) |
27
+ | `to` | `"HH:mm"` | ✓ | End of window (exclusive, 24h). If `to < from`, the window spans midnight. |
28
+ | `days` | `number[]` | | Days of week: 0=Sun, 1=Mon … 6=Sat. All days if omitted. |
29
+ | `action` | `send`\|`drop` | ✓ | What to do on match |
30
+ | `senderId` | string | | Required when `action === "send"` |
31
+ | `configName` | string | | Required when `action === "send"` |
32
+
33
+ ## Examples
34
+
35
+ ### Business hours only (Mon–Fri 09:00–18:00 → email, otherwise drop)
36
+
37
+ ```json
38
+ {
39
+ "name": "biz-hours",
40
+ "timezone": "Europe/Madrid",
41
+ "rules": [
42
+ {
43
+ "from": "09:00", "to": "18:00",
44
+ "days": [1,2,3,4,5],
45
+ "action": "send",
46
+ "senderId": "email-smtp", "configName": "ops-team"
47
+ }
48
+ ],
49
+ "defaultAction": "drop"
50
+ }
51
+ ```
52
+
53
+ ### Night silence (22:00–08:00 → drop, rest → send)
54
+
55
+ ```json
56
+ {
57
+ "name": "no-nights",
58
+ "rules": [
59
+ { "from": "22:00", "to": "08:00", "action": "drop" }
60
+ ],
61
+ "defaultAction": "send",
62
+ "defaultSenderId": "console", "defaultConfigName": "default"
63
+ }
64
+ ```
65
+
66
+ ### Weekend routing (Sat+Sun → Slack, weekdays → email)
67
+
68
+ ```json
69
+ {
70
+ "name": "weekend-routing",
71
+ "rules": [
72
+ {
73
+ "from": "00:00", "to": "00:00",
74
+ "days": [0, 6],
75
+ "action": "send",
76
+ "senderId": "slack", "configName": "oncall"
77
+ }
78
+ ],
79
+ "defaultAction": "send",
80
+ "defaultSenderId": "email-smtp", "defaultConfigName": "team"
81
+ }
82
+ ```
83
+
84
+ > **Note:** A window where `from === to` (e.g. `"00:00"` to `"00:00"`) matches **all times** of the specified days.
package/build.mjs ADDED
@@ -0,0 +1,65 @@
1
+ import esbuild from 'esbuild'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+
5
+ const kwirthGlobalsPlugin = {
6
+ name: 'kwirth-globals',
7
+ setup(build) {
8
+ const globals = {
9
+ 'react': 'window.__kwirth__.React',
10
+ '@mui/material': 'window.__kwirth__.MUI.material',
11
+ '@mui/icons-material': 'window.__kwirth__.MUI.icons',
12
+ '@kwirthmagnify/kwirth-common': 'window.__kwirth__.kwirthCommon',
13
+ }
14
+ for (const pkg of Object.keys(globals)) {
15
+ build.onResolve({ filter: new RegExp(`^${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }, () => ({
16
+ path: pkg,
17
+ namespace: 'kwirth-globals',
18
+ }))
19
+ }
20
+ build.onLoad({ filter: /.*/, namespace: 'kwirth-globals' }, (args) => ({
21
+ contents: `module.exports = ${globals[args.path]}`,
22
+ loader: 'js',
23
+ }))
24
+ },
25
+ }
26
+
27
+ fs.mkdirSync('dist', { recursive: true })
28
+
29
+ await esbuild.build({
30
+ entryPoints: ['src/back/index.ts'],
31
+ bundle: true,
32
+ format: 'cjs',
33
+ platform: 'node',
34
+ target: 'node20',
35
+ outfile: 'dist/back.js',
36
+ loader: { '.ts': 'ts' },
37
+ minify: false,
38
+ })
39
+ console.log('Built dist/back.js')
40
+
41
+ await esbuild.build({
42
+ entryPoints: ['src/front/index.tsx'],
43
+ bundle: true,
44
+ format: 'iife',
45
+ outfile: 'dist/front.js',
46
+ plugins: [kwirthGlobalsPlugin],
47
+ loader: { '.tsx': 'tsx', '.ts': 'ts' },
48
+ jsx: 'transform',
49
+ jsxFactory: 'React.createElement',
50
+ jsxFragment: 'React.Fragment',
51
+ target: 'es2020',
52
+ minify: false,
53
+ })
54
+ console.log('Built dist/front.js')
55
+
56
+ const meta = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
57
+ const distMeta = {
58
+ id: meta.id,
59
+ name: meta.name,
60
+ displayName: meta.displayName,
61
+ version: meta.version,
62
+ description: meta.description,
63
+ }
64
+ fs.writeFileSync(path.join('dist', 'package.json'), JSON.stringify(distMeta, null, 2))
65
+ console.log('Wrote dist/package.json')
@@ -283,7 +283,7 @@
283
283
  const [originalFormName, setOriginalFormName] = (0, import_react.useState)();
284
284
  const [error, setError] = (0, import_react.useState)();
285
285
  (0, import_react.useEffect)(() => {
286
- fetch(`${backendUrl}/senders/timed/configs`, authGet(accessString)).then((r) => r.json()).then(setConfigs).catch(() => {
286
+ fetch(`${backendUrl}/senders/timed/configs`, authGet(accessString)).then((r) => r.json()).then((data) => setConfigs(Array.isArray(data) ? data : data.configs ?? [])).catch(() => {
287
287
  }).finally(() => setLoading(false));
288
288
  }, []);
289
289
  const saveConfig = async () => {
@@ -0,0 +1,7 @@
1
+ {
2
+ "id": "timed",
3
+ "name": "@kwirthmagnify/kwirth-sender-timed",
4
+ "displayName": "Timed Sender",
5
+ "version": "0.1.5",
6
+ "description": "Timed sender for Kwirth — routes or drops messages based on time-of-day windows and day-of-week rules"
7
+ }
package/package.json CHANGED
@@ -1,7 +1,25 @@
1
- {
2
- "id": "timed",
3
- "name": "@kwirthmagnify/kwirth-sender-timed",
4
- "displayName": "Timed Sender",
5
- "version": "0.1.3",
6
- "description": "Timed sender for Kwirth — routes or drops messages based on time-of-day windows and day-of-week rules"
7
- }
1
+ {
2
+ "id": "timed",
3
+ "name": "@kwirthmagnify/kwirth-sender-timed",
4
+ "publisher": "@kwirthmagnify",
5
+ "version": "0.1.5",
6
+ "displayName": "Timed Sender",
7
+ "description": "Timed sender for Kwirth — routes or drops messages based on time-of-day windows and day-of-week rules",
8
+ "type": "module",
9
+ "scripts": {
10
+ "build": "node build.mjs",
11
+ "watch": "node watch.mjs"
12
+ },
13
+ "dependencies": {
14
+ "@kwirthmagnify/kwirth-common-back": "^0.5.10"
15
+ },
16
+ "devDependencies": {
17
+ "@mui/icons-material": "^7.1.2",
18
+ "@mui/material": "^7.1.2",
19
+ "@types/node": "^20.12.13",
20
+ "@types/react": "^19.2.16",
21
+ "@types/react-dom": "^19.2.3",
22
+ "esbuild": "^0.27.2",
23
+ "typescript": "^5.4.0"
24
+ }
25
+ }
@@ -0,0 +1,123 @@
1
+ import { ISender, ISenderAccess, ISenderConfig, ISenderFieldDef, ISenderMessage } from '@kwirthmagnify/kwirth-common-back'
2
+
3
+ const TIMEZONES = [
4
+ 'UTC',
5
+ // Europe
6
+ 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon',
7
+ 'Europe/Madrid', 'Europe/Paris', 'Europe/Berlin', 'Europe/Rome', 'Europe/Amsterdam',
8
+ 'Europe/Brussels', 'Europe/Vienna', 'Europe/Zurich', 'Europe/Stockholm', 'Europe/Oslo',
9
+ 'Europe/Copenhagen', 'Europe/Helsinki', 'Europe/Warsaw', 'Europe/Prague', 'Europe/Budapest',
10
+ 'Europe/Bucharest', 'Europe/Athens', 'Europe/Istanbul', 'Europe/Kiev', 'Europe/Moscow',
11
+ // Americas
12
+ 'America/New_York', 'America/Toronto', 'America/Montreal',
13
+ 'America/Chicago', 'America/Winnipeg',
14
+ 'America/Denver', 'America/Edmonton',
15
+ 'America/Los_Angeles', 'America/Vancouver',
16
+ 'America/Phoenix', 'America/Anchorage', 'America/Honolulu',
17
+ 'America/Mexico_City', 'America/Bogota', 'America/Lima',
18
+ 'America/Santiago', 'America/Buenos_Aires', 'America/Sao_Paulo',
19
+ 'America/Caracas', 'America/Halifax', 'America/St_Johns',
20
+ // Asia
21
+ 'Asia/Jerusalem', 'Asia/Beirut', 'Asia/Riyadh', 'Asia/Dubai', 'Asia/Tehran',
22
+ 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Colombo', 'Asia/Dhaka',
23
+ 'Asia/Bangkok', 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Kuala_Lumpur',
24
+ 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei',
25
+ 'Asia/Seoul', 'Asia/Tokyo',
26
+ // Africa
27
+ 'Africa/Casablanca', 'Africa/Lagos', 'Africa/Nairobi', 'Africa/Johannesburg', 'Africa/Cairo',
28
+ // Pacific / Australia
29
+ 'Australia/Perth', 'Australia/Adelaide', 'Australia/Darwin',
30
+ 'Australia/Brisbane', 'Australia/Sydney', 'Australia/Melbourne',
31
+ 'Pacific/Auckland', 'Pacific/Fiji', 'Pacific/Honolulu',
32
+ ]
33
+
34
+ // ─── Config ────────────────────────────────────────────────────────────────────
35
+
36
+ export interface ITimedSenderRule {
37
+ from: string
38
+ to: string
39
+ days?: number[]
40
+ action: 'send' | 'drop'
41
+ }
42
+
43
+ export interface ITimedSenderConfig extends ISenderConfig {
44
+ name: string
45
+ rules: ITimedSenderRule[]
46
+ defaultAction?: 'send' | 'drop'
47
+ timezone?: string
48
+ }
49
+
50
+ // ─── Helpers ───────────────────────────────────────────────────────────────────
51
+
52
+ function tzOffset(tz: string): string {
53
+ try {
54
+ const part = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'shortOffset' })
55
+ .formatToParts(new Date()).find(p => p.type === 'timeZoneName')?.value ?? ''
56
+ return part.replace('GMT', 'UTC')
57
+ } catch { return '' }
58
+ }
59
+
60
+ function parseMinutes(hhmm: string): number {
61
+ const [h, m] = hhmm.split(':').map(Number)
62
+ return (h ?? 0) * 60 + (m ?? 0)
63
+ }
64
+
65
+ function currentContext(timezone?: string): { minutes: number; day: number } {
66
+ const now = timezone
67
+ ? new Date(new Date().toLocaleString('en-US', { timeZone: timezone }))
68
+ : new Date()
69
+ return { minutes: now.getHours() * 60 + now.getMinutes(), day: now.getDay() }
70
+ }
71
+
72
+ function matchesWindow(rule: ITimedSenderRule, minutes: number, day: number): boolean {
73
+ if (rule.days && rule.days.length > 0 && !rule.days.includes(day)) return false
74
+ const from = parseMinutes(rule.from)
75
+ const to = parseMinutes(rule.to)
76
+ return from <= to
77
+ ? minutes >= from && minutes < to // normal: 09:00–18:00
78
+ : minutes >= from || minutes < to // overnight: 22:00–06:00
79
+ }
80
+
81
+ // ─── Sender ────────────────────────────────────────────────────────────────────
82
+
83
+ export class TimedSender implements ISender {
84
+ readonly id = 'timed'
85
+ private configs = new Map<string, ITimedSenderConfig>()
86
+
87
+ addConfig(config: ISenderConfig): void {
88
+ const tc = config as ITimedSenderConfig
89
+ if (!Array.isArray(tc.rules)) tc.rules = []
90
+ this.configs.set(tc.name, tc)
91
+ }
92
+
93
+ removeConfig(name: string): void {
94
+ this.configs.delete(name)
95
+ }
96
+
97
+ hasConfig(name: string): boolean {
98
+ return this.configs.has(name)
99
+ }
100
+
101
+ getConfigNames(): string[] {
102
+ return Array.from(this.configs.keys())
103
+ }
104
+
105
+ async send(_configName: string, _message: ISenderMessage): Promise<void> {
106
+ // timed sender is a filter used inside composite pipelines; standalone send is a no-op
107
+ }
108
+
109
+ getConfigSchema(): ISenderFieldDef[] {
110
+ return [
111
+ { name: 'name', label: 'Name', required: true },
112
+ { name: 'timezone', label: 'Timezone', type: 'select', options: TIMEZONES, labels: TIMEZONES.map(tz => { const off = tzOffset(tz); return off ? `${tz} (${off})` : tz }) } as unknown as ISenderFieldDef,
113
+ { name: 'rules', label: 'Rules (JSON)', type: 'json' },
114
+ { name: 'defaultAction', label: 'Default action', type: 'select', options: ['drop', 'send'] },
115
+ ]
116
+ }
117
+
118
+ async startSender(_senders: ISenderAccess): Promise<void> {}
119
+
120
+ async stopSender(): Promise<void> {}
121
+ }
122
+
123
+ export default TimedSender
@@ -0,0 +1,383 @@
1
+ import React, { useEffect, useState } from 'react'
2
+ import {
3
+ Box, Button, Checkbox, Chip, CircularProgress, Dialog, DialogActions, DialogContent,
4
+ DialogTitle, Divider, FormControl, IconButton, InputLabel,
5
+ MenuItem, Select, Stack, TextField, Tooltip, Typography
6
+ } from '@mui/material'
7
+ import { ContentCopy } from '@mui/icons-material'
8
+ import { Add, Delete } from '@mui/icons-material'
9
+
10
+ // ─── Auth helpers ─────────────────────────────────────────────────────────────
11
+
12
+ function authGet(accessString: string): RequestInit {
13
+ return { headers: { Authorization: accessString ? `Bearer ${accessString}` : '', 'X-Kwirth-App': 'true' } }
14
+ }
15
+
16
+ function authDelete(accessString: string): RequestInit {
17
+ return { method: 'DELETE', headers: { Authorization: accessString ? `Bearer ${accessString}` : '', 'X-Kwirth-App': 'true' } }
18
+ }
19
+
20
+ function authPost(accessString: string, body: string): RequestInit {
21
+ return {
22
+ method: 'POST', body,
23
+ headers: { Authorization: accessString ? `Bearer ${accessString}` : '', 'X-Kwirth-App': 'true', 'Content-Type': 'application/json' },
24
+ }
25
+ }
26
+
27
+ // ─── Types ────────────────────────────────────────────────────────────────────
28
+
29
+ interface ITimedRule {
30
+ from: string
31
+ to: string
32
+ days?: number[]
33
+ action: 'send' | 'drop'
34
+ }
35
+
36
+ interface ITimedConfig {
37
+ name: string
38
+ description?: string
39
+ timezone?: string
40
+ rules: ITimedRule[]
41
+ defaultAction?: 'send' | 'drop'
42
+ }
43
+
44
+ interface ITimedConfigDialogProps {
45
+ onClose: () => void
46
+ backendUrl: string
47
+ accessString: string
48
+ }
49
+
50
+ // ─── Constants ────────────────────────────────────────────────────────────────
51
+
52
+ const TIMEZONES = [
53
+ 'UTC',
54
+ 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon',
55
+ 'Europe/Madrid', 'Europe/Paris', 'Europe/Berlin', 'Europe/Rome', 'Europe/Amsterdam',
56
+ 'Europe/Brussels', 'Europe/Vienna', 'Europe/Zurich', 'Europe/Stockholm', 'Europe/Oslo',
57
+ 'Europe/Copenhagen', 'Europe/Helsinki', 'Europe/Warsaw', 'Europe/Prague', 'Europe/Budapest',
58
+ 'Europe/Bucharest', 'Europe/Athens', 'Europe/Istanbul', 'Europe/Kiev', 'Europe/Moscow',
59
+ 'America/New_York', 'America/Toronto', 'America/Montreal',
60
+ 'America/Chicago', 'America/Winnipeg',
61
+ 'America/Denver', 'America/Edmonton',
62
+ 'America/Los_Angeles', 'America/Vancouver',
63
+ 'America/Phoenix', 'America/Anchorage', 'America/Honolulu',
64
+ 'America/Mexico_City', 'America/Bogota', 'America/Lima',
65
+ 'America/Santiago', 'America/Buenos_Aires', 'America/Sao_Paulo',
66
+ 'America/Caracas', 'America/Halifax', 'America/St_Johns',
67
+ 'Asia/Jerusalem', 'Asia/Beirut', 'Asia/Riyadh', 'Asia/Dubai', 'Asia/Tehran',
68
+ 'Asia/Karachi', 'Asia/Kolkata', 'Asia/Colombo', 'Asia/Dhaka',
69
+ 'Asia/Bangkok', 'Asia/Jakarta', 'Asia/Singapore', 'Asia/Kuala_Lumpur',
70
+ 'Asia/Hong_Kong', 'Asia/Shanghai', 'Asia/Taipei',
71
+ 'Asia/Seoul', 'Asia/Tokyo',
72
+ 'Africa/Casablanca', 'Africa/Lagos', 'Africa/Nairobi', 'Africa/Johannesburg', 'Africa/Cairo',
73
+ 'Australia/Perth', 'Australia/Adelaide', 'Australia/Darwin',
74
+ 'Australia/Brisbane', 'Australia/Sydney', 'Australia/Melbourne',
75
+ 'Pacific/Auckland', 'Pacific/Fiji', 'Pacific/Honolulu',
76
+ ]
77
+
78
+ function tzOffset(tz: string): string {
79
+ try {
80
+ const part = new Intl.DateTimeFormat('en', { timeZone: tz, timeZoneName: 'shortOffset' })
81
+ .formatToParts(new Date()).find(p => p.type === 'timeZoneName')?.value ?? ''
82
+ return part.replace('GMT', 'UTC')
83
+ } catch { return '' }
84
+ }
85
+
86
+ const DAY_LABELS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
87
+
88
+ function emptyRule(): ITimedRule {
89
+ return { from: '09:00', to: '18:00', action: 'send' }
90
+ }
91
+
92
+ function emptyConfig(): ITimedConfig {
93
+ return { name: '', rules: [], defaultAction: 'drop' }
94
+ }
95
+
96
+ // ─── Rule editor row ──────────────────────────────────────────────────────────
97
+
98
+ const RuleRow: React.FC<{
99
+ rule: ITimedRule
100
+ onChange: (rule: ITimedRule) => void
101
+ onDelete: () => void
102
+ }> = ({ rule, onChange, onDelete }) => {
103
+ const allDays = [0, 1, 2, 3, 4, 5, 6]
104
+ const selectedDays = rule.days ?? allDays
105
+
106
+ return (
107
+ <Box sx={{ borderBottom: 1, borderColor: 'divider', px: 1, py: 0.75, display: 'flex', alignItems: 'center', gap: 1 }}>
108
+ <Stack direction='row' spacing={1} alignItems='center' sx={{ flex: 1 }}>
109
+ <TextField size='small' label='From' type='time' sx={{ width: 110 }}
110
+ value={rule.from} onChange={e => onChange({ ...rule, from: e.target.value })}
111
+ InputLabelProps={{ shrink: true }} />
112
+ <TextField size='small' label='To' type='time' sx={{ width: 110 }}
113
+ value={rule.to} onChange={e => onChange({ ...rule, to: e.target.value })}
114
+ InputLabelProps={{ shrink: true }} />
115
+ <FormControl size='small' sx={{ minWidth: 110, flexShrink: 0 }}>
116
+ <InputLabel>Action</InputLabel>
117
+ <Select label='Action' value={rule.action}
118
+ onChange={e => onChange({ ...rule, action: e.target.value as 'send' | 'drop' })}>
119
+ <MenuItem value='drop'><Chip label='drop' size='small' color='error' sx={{ fontSize: 10, height: 18 }} /></MenuItem>
120
+ <MenuItem value='send'><Chip label='send' size='small' color='success' sx={{ fontSize: 10, height: 18 }} /></MenuItem>
121
+ </Select>
122
+ </FormControl>
123
+ <FormControl size='small' sx={{ flex: 1, minWidth: 160 }}>
124
+ <InputLabel>Days</InputLabel>
125
+ <Select
126
+ multiple label='Days'
127
+ value={selectedDays}
128
+ onChange={e => {
129
+ const val = e.target.value as number[]
130
+ onChange({ ...rule, days: val.length === 0 || val.length === 7 ? undefined : [...val].sort((a, b) => a - b) })
131
+ }}
132
+ renderValue={sel => {
133
+ const days = sel as number[]
134
+ return days.length === 7 ? 'All days' : days.map(d => DAY_LABELS[d]).join(', ')
135
+ }}
136
+ >
137
+ {DAY_LABELS.map((label, i) => (
138
+ <MenuItem key={i} value={i} sx={{ py: 0.25 }}>
139
+ <Checkbox checked={selectedDays.includes(i)} size='small' sx={{ p: 0.25 }} />
140
+ <Typography variant='body2'>{label}</Typography>
141
+ </MenuItem>
142
+ ))}
143
+ </Select>
144
+ </FormControl>
145
+ </Stack>
146
+ <IconButton size='small' color='error' onClick={onDelete} sx={{ alignSelf: 'center', flexShrink: 0 }}>
147
+ <Delete fontSize='small' />
148
+ </IconButton>
149
+ </Box>
150
+ )
151
+ }
152
+
153
+ // ─── Config form ──────────────────────────────────────────────────────────────
154
+
155
+ const ConfigForm: React.FC<{
156
+ config: ITimedConfig
157
+ onChange: (config: ITimedConfig) => void
158
+ }> = ({ config, onChange }) => {
159
+ const updateRule = (i: number, rule: ITimedRule) => {
160
+ const rules = [...config.rules]
161
+ rules[i] = rule
162
+ onChange({ ...config, rules })
163
+ }
164
+
165
+ const deleteRule = (i: number) => {
166
+ onChange({ ...config, rules: config.rules.filter((_, idx) => idx !== i) })
167
+ }
168
+
169
+ const addRule = () => {
170
+ onChange({ ...config, rules: [...config.rules, emptyRule()] })
171
+ }
172
+
173
+ return (
174
+ <Stack spacing={2}>
175
+ <Stack direction='row' spacing={1} alignItems='center'>
176
+ <TextField
177
+ size='small' label='Name *' value={config.name}
178
+ onChange={e => onChange({ ...config, name: e.target.value })}
179
+ sx={{ flex: 2 }}
180
+ />
181
+ <FormControl size='small' sx={{ flex: 2 }}>
182
+ <InputLabel>Timezone</InputLabel>
183
+ <Select label='Timezone' value={config.timezone ?? ''}
184
+ onChange={e => onChange({ ...config, timezone: e.target.value || undefined })}>
185
+ <MenuItem value=''><em>Server local</em></MenuItem>
186
+ {TIMEZONES.map(tz => {
187
+ const off = tzOffset(tz)
188
+ return <MenuItem key={tz} value={tz}>{tz}{off ? ` (${off})` : ''}</MenuItem>
189
+ })}
190
+ </Select>
191
+ </FormControl>
192
+ <FormControl size='small' sx={{ minWidth: 110, flexShrink: 0 }}>
193
+ <InputLabel>Default</InputLabel>
194
+ <Select label='Default' value={config.defaultAction ?? 'drop'}
195
+ onChange={e => onChange({ ...config, defaultAction: e.target.value as 'send' | 'drop' })}>
196
+ <MenuItem value='drop'><Chip label='drop' size='small' color='error' sx={{ fontSize: 10, height: 18 }} /></MenuItem>
197
+ <MenuItem value='send'><Chip label='send' size='small' color='success' sx={{ fontSize: 10, height: 18 }} /></MenuItem>
198
+ </Select>
199
+ </FormControl>
200
+ </Stack>
201
+
202
+ <TextField size='small' label='Description' value={config.description ?? ''}
203
+ onChange={e => onChange({ ...config, description: e.target.value || undefined })}
204
+ fullWidth multiline maxRows={2} />
205
+
206
+ <Divider><Typography variant='caption'>Rules (evaluated in order — first match wins)</Typography></Divider>
207
+
208
+ {config.rules.length === 0 && (
209
+ <Typography variant='body2' color='text.secondary'>No rules yet. Add one below.</Typography>
210
+ )}
211
+ {config.rules.map((rule, i) => (
212
+ <RuleRow key={i} rule={rule}
213
+ onChange={r => updateRule(i, r)}
214
+ onDelete={() => deleteRule(i)}
215
+ />
216
+ ))}
217
+ <Button size='small' startIcon={<Add />} onClick={addRule} sx={{ alignSelf: 'flex-start' }}>
218
+ Add rule
219
+ </Button>
220
+
221
+ </Stack>
222
+ )
223
+ }
224
+
225
+ // ─── Main dialog ──────────────────────────────────────────────────────────────
226
+
227
+ const TimedConfigDialog: React.FC<ITimedConfigDialogProps> = ({ onClose, backendUrl, accessString }) => {
228
+ const [configs, setConfigs] = useState<ITimedConfig[]>([])
229
+ const [loading, setLoading] = useState(true)
230
+ const [saving, setSaving] = useState(false)
231
+ const [deletingName, setDeletingName] = useState<string | undefined>()
232
+ const [showForm, setShowForm] = useState(false)
233
+ const [formConfig, setFormConfig] = useState<ITimedConfig>(emptyConfig())
234
+ const [originalFormName, setOriginalFormName] = useState<string | undefined>()
235
+ const [error, setError] = useState<string | undefined>()
236
+
237
+ useEffect(() => {
238
+ fetch(`${backendUrl}/senders/timed/configs`, authGet(accessString))
239
+ .then(r => r.json()).then(data => setConfigs(Array.isArray(data) ? data : (data.configs ?? []))).catch(() => {})
240
+ .finally(() => setLoading(false))
241
+ }, [])
242
+
243
+ const saveConfig = async () => {
244
+ const trimmed = formConfig.name.trim()
245
+ if (!trimmed) { setError('Name is required'); return }
246
+ setSaving(true)
247
+ setError(undefined)
248
+ try {
249
+ const payload = { ...formConfig, name: trimmed }
250
+ const res = await fetch(`${backendUrl}/senders/timed/configs`, authPost(accessString, JSON.stringify(payload)))
251
+ if (!res.ok) throw new Error((await res.json()).error ?? `HTTP ${res.status}`)
252
+ if (originalFormName && originalFormName !== trimmed) {
253
+ await fetch(`${backendUrl}/senders/timed/configs/${encodeURIComponent(originalFormName)}`, authDelete(accessString))
254
+ }
255
+ setConfigs(prev => {
256
+ const withoutOld = originalFormName && originalFormName !== trimmed
257
+ ? prev.filter(c => c.name !== originalFormName)
258
+ : prev
259
+ const idx = withoutOld.findIndex(c => c.name === trimmed)
260
+ return idx >= 0 ? withoutOld.map((c, i) => i === idx ? payload : c) : [...withoutOld, payload]
261
+ })
262
+ setOriginalFormName(trimmed)
263
+ setFormConfig(payload)
264
+ } catch (err) {
265
+ setError(`Save failed: ${err}`)
266
+ } finally {
267
+ setSaving(false)
268
+ }
269
+ }
270
+
271
+ const deleteConfig = async (name: string) => {
272
+ setDeletingName(name)
273
+ try {
274
+ const res = await fetch(`${backendUrl}/senders/timed/configs/${encodeURIComponent(name)}`, authDelete(accessString))
275
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
276
+ setConfigs(prev => prev.filter(c => c.name !== name))
277
+ } catch (err) {
278
+ setError(`Delete failed: ${err}`)
279
+ } finally {
280
+ setDeletingName(undefined)
281
+ }
282
+ }
283
+
284
+ const startAdd = () => {
285
+ setFormConfig(emptyConfig())
286
+ setOriginalFormName(undefined)
287
+ setError(undefined)
288
+ setShowForm(true)
289
+ }
290
+
291
+ const startEdit = (config: ITimedConfig) => {
292
+ setFormConfig({ ...config })
293
+ setOriginalFormName(config.name)
294
+ setError(undefined)
295
+ setShowForm(true)
296
+ }
297
+
298
+ const startClone = (config: ITimedConfig) => {
299
+ setFormConfig({ ...config, name: `${config.name} (copy)` })
300
+ setOriginalFormName(undefined)
301
+ setError(undefined)
302
+ setShowForm(true)
303
+ }
304
+
305
+ const configSummary = (c: ITimedConfig) => {
306
+ const parts: string[] = []
307
+ if (c.timezone) parts.push(c.timezone)
308
+ if (c.rules.length > 0) parts.push(`${c.rules.length} rule(s)`)
309
+ parts.push(`default: ${c.defaultAction ?? 'drop'}`)
310
+ return parts.join(' · ')
311
+ }
312
+
313
+ return (
314
+ <Dialog open={true} maxWidth={false} sx={{ '& .MuiDialog-paper': { width: '900px', height: '640px' } }}>
315
+ <DialogTitle>Configure: Timed Sender</DialogTitle>
316
+ <DialogContent sx={{ display: 'flex', gap: 2, p: '16px !important', overflow: 'hidden', height: '100%' }}>
317
+
318
+ {/* Left — config list */}
319
+ <Box sx={{ width: 190, display: 'flex', flexDirection: 'column', gap: 1, flexShrink: 0 }}>
320
+ <Typography variant='caption' color='text.secondary' fontWeight='bold'>Configs</Typography>
321
+ <Box sx={{ flex: 1, border: 1, borderColor: 'divider', borderRadius: 1, overflowY: 'auto' }}>
322
+ {loading
323
+ ? <Box sx={{ p: 1 }}><CircularProgress size={16} /></Box>
324
+ : configs.length === 0
325
+ ? <Typography variant='caption' color='text.disabled' sx={{ p: 1, display: 'block' }}>No configs yet.</Typography>
326
+ : configs.map(cfg => (
327
+ <Box key={cfg.name} sx={{ display: 'flex', alignItems: 'center', px: 1, py: 0.5, borderBottom: 1, borderColor: 'divider', borderLeft: formConfig.name === cfg.name && showForm ? 3 : 0, borderLeftColor: 'primary.main', bgcolor: formConfig.name === cfg.name && showForm ? 'action.selected' : 'transparent' }}>
328
+ <Box sx={{ flex: 1, minWidth: 0, overflow: 'hidden', cursor: 'pointer' }} onClick={() => startEdit(cfg)}>
329
+ <Typography variant='body2' fontWeight='bold' noWrap>{cfg.name}</Typography>
330
+ <Typography variant='caption' color='text.secondary' noWrap display='block'>{configSummary(cfg)}</Typography>
331
+ </Box>
332
+ <Tooltip title='Delete'>
333
+ <span>
334
+ <IconButton size='small' color='error' disabled={deletingName === cfg.name} onClick={() => deleteConfig(cfg.name)}>
335
+ {deletingName === cfg.name ? <CircularProgress size={12} /> : <Delete sx={{ fontSize: 14 }} />}
336
+ </IconButton>
337
+ </span>
338
+ </Tooltip>
339
+ </Box>
340
+ ))
341
+ }
342
+ </Box>
343
+ <Stack direction='row' spacing={0.5}>
344
+ <Button size='small' startIcon={<Add />} onClick={startAdd} sx={{ flex: 1 }}>New</Button>
345
+ <Button size='small' startIcon={<ContentCopy />} onClick={() => { const c = configs.find(x => x.name === formConfig.name && showForm); if (c) startClone(c) }} disabled={!showForm || !originalFormName} sx={{ flex: 1 }}>Clone</Button>
346
+ </Stack>
347
+ </Box>
348
+
349
+ <Divider orientation='vertical' flexItem />
350
+
351
+ {/* Right — editor */}
352
+ <Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1.5, minWidth: 0, overflow: 'hidden' }}>
353
+ {showForm
354
+ ? <>
355
+ <Typography variant='caption' color='text.secondary' fontWeight='bold'>
356
+ {originalFormName ? `Editing: ${originalFormName}` : 'New config'}
357
+ </Typography>
358
+ <Box sx={{ flex: 1, overflowY: 'auto', pt: 1 }}>
359
+ <ConfigForm config={formConfig} onChange={setFormConfig} />
360
+ </Box>
361
+ <Stack direction='row' justifyContent='flex-end' alignItems='center' spacing={1}>
362
+ {error && <Typography variant='caption' color='error' sx={{ flex: 1 }}>{error}</Typography>}
363
+ <Button size='small' onClick={() => { setShowForm(false); setError(undefined) }}>Cancel</Button>
364
+ <Button size='small' variant='contained' disabled={saving || !formConfig.name.trim()} onClick={saveConfig}>
365
+ {saving ? <CircularProgress size={14} /> : originalFormName ? 'Update' : 'Add'}
366
+ </Button>
367
+ </Stack>
368
+ </>
369
+ : <Box sx={{ m: 'auto', color: 'text.disabled' }}>
370
+ <Typography variant='body2'>Select a config to edit or click New.</Typography>
371
+ </Box>
372
+ }
373
+ </Box>
374
+ </DialogContent>
375
+ <DialogActions sx={{ px: 2 }}>
376
+ <Button onClick={onClose}>Close</Button>
377
+ </DialogActions>
378
+ </Dialog>
379
+ )
380
+ }
381
+
382
+ export { TimedConfigDialog }
383
+ export default TimedConfigDialog
@@ -0,0 +1,6 @@
1
+ import TimedConfigDialog from './TimedConfigDialog'
2
+
3
+ declare global { interface Window { __kwirth_senders__: Record<string, any> } }
4
+
5
+ window.__kwirth_senders__ = window.__kwirth_senders__ ?? {}
6
+ window.__kwirth_senders__['timed'] = TimedConfigDialog
package/tsconfig.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "jsx": "react",
7
+ "strict": false,
8
+ "lib": ["ES2020", "DOM"],
9
+ "skipLibCheck": true,
10
+ "esModuleInterop": true
11
+ },
12
+ "include": ["src"]
13
+ }
package/watch.mjs ADDED
@@ -0,0 +1,68 @@
1
+ import esbuild from 'esbuild'
2
+ import fs from 'fs'
3
+ import path from 'path'
4
+
5
+ const kwirthGlobalsPlugin = {
6
+ name: 'kwirth-globals',
7
+ setup(build) {
8
+ const globals = {
9
+ 'react': 'window.__kwirth__.React',
10
+ '@mui/material': 'window.__kwirth__.MUI.material',
11
+ '@mui/icons-material': 'window.__kwirth__.MUI.icons',
12
+ '@kwirthmagnify/kwirth-common': 'window.__kwirth__.kwirthCommon',
13
+ }
14
+ for (const pkg of Object.keys(globals)) {
15
+ build.onResolve({ filter: new RegExp(`^${pkg.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`) }, () => ({ path: pkg, namespace: 'kwirth-globals' }))
16
+ }
17
+ build.onLoad({ filter: /.*/, namespace: 'kwirth-globals' }, (args) => ({
18
+ contents: `module.exports = ${globals[args.path]}`, loader: 'js',
19
+ }))
20
+ },
21
+ }
22
+
23
+ const notifyPlugin = (label) => ({
24
+ name: `notify-${label}`,
25
+ setup(build) {
26
+ build.onEnd(result => {
27
+ const t = new Date().toLocaleTimeString()
28
+ if (result.errors.length) {
29
+ console.error(`[${t}] ${label} FAILED: ${result.errors.map(e => e.text).join('; ')}`)
30
+ } else {
31
+ console.log(`[${t}] ${label} OK`)
32
+ }
33
+ })
34
+ },
35
+ })
36
+
37
+ fs.mkdirSync('dist', { recursive: true })
38
+ const meta = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
39
+ fs.writeFileSync(path.join('dist', 'package.json'), JSON.stringify({ id: meta.id, name: meta.name, displayName: meta.displayName, version: meta.version, description: meta.description }, null, 2))
40
+
41
+ const backCtx = await esbuild.context({
42
+ entryPoints: ['src/back/index.ts'],
43
+ bundle: true,
44
+ format: 'cjs',
45
+ platform: 'node',
46
+ target: 'node20',
47
+ outfile: 'dist/back.js',
48
+ loader: { '.ts': 'ts' },
49
+ minify: false,
50
+ plugins: [notifyPlugin('back')],
51
+ })
52
+
53
+ const frontCtx = await esbuild.context({
54
+ entryPoints: ['src/front/index.tsx'],
55
+ bundle: true,
56
+ format: 'iife',
57
+ outfile: 'dist/front.js',
58
+ plugins: [kwirthGlobalsPlugin, notifyPlugin('front')],
59
+ loader: { '.tsx': 'tsx', '.ts': 'ts' },
60
+ jsx: 'transform',
61
+ jsxFactory: 'React.createElement',
62
+ jsxFragment: 'React.Fragment',
63
+ target: 'es2020',
64
+ minify: false,
65
+ })
66
+
67
+ await Promise.all([backCtx.watch(), frontCtx.watch()])
68
+ console.log('[watch] Watching src/ — back.js and front.js rebuild on every change.')
File without changes