@kwirthmagnify/kwirth-sender-tee 0.1.2 → 0.1.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/README.md +79 -0
- package/build.mjs +61 -0
- package/{front.js → dist/front.js} +4 -1
- package/dist/package.json +7 -0
- package/package.json +25 -7
- package/src/back/index.ts +68 -0
- package/src/front/TeeSenderDialog.tsx +211 -0
- package/src/front/index.tsx +6 -0
- package/tsconfig.json +13 -0
- package/watch.mjs +68 -0
- /package/{back.js → dist/back.js} +0 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# Tee Sender
|
|
2
|
+
|
|
3
|
+
Sender intermedio que recibe un mensaje y lo reenvía en paralelo a múltiples senders de destino configurados, sin hacer ningún envío final él mismo. Funciona como el comando Unix `tee`: lo que entra, sale por todos los canales a la vez.
|
|
4
|
+
|
|
5
|
+
## Cuándo usarlo
|
|
6
|
+
|
|
7
|
+
- Enviar la misma alerta simultáneamente a consola y a fichero.
|
|
8
|
+
- Construir pipelines de notificación: un único punto de entrada que abanica hacia varios destinos.
|
|
9
|
+
- Encadenar tees para topologías más complejas (un tee apuntando a otro tee).
|
|
10
|
+
|
|
11
|
+
## Configuración
|
|
12
|
+
|
|
13
|
+
Cada config tiene un campo `targets`: lista de pares `(senderId, configName)` que identifican el sender y la config de destino.
|
|
14
|
+
|
|
15
|
+
| Campo | Tipo | Requerido | Descripción |
|
|
16
|
+
|-----------|-----------------------------------|-----------|------------------------------------------------|
|
|
17
|
+
| `name` | `string` | Sí | Nombre de esta config |
|
|
18
|
+
| `targets` | `Array<{senderId, configName}>` | Sí | Lista de senders de destino |
|
|
19
|
+
|
|
20
|
+
### Estructura de un target
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"senderId": "console", // id del sender destino (ej: "console", "file", "tee")
|
|
25
|
+
"configName": "mi-console" // nombre de la config ya registrada en ese sender
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Ejemplos
|
|
30
|
+
|
|
31
|
+
### Fan-out a consola y fichero
|
|
32
|
+
|
|
33
|
+
Prerequisito: tener configurados un `ConsoleSender` con config `"alerts-console"` y un `FileSender` con config `"alerts-file"`.
|
|
34
|
+
|
|
35
|
+
```json
|
|
36
|
+
{
|
|
37
|
+
"name": "alerts-tee",
|
|
38
|
+
"targets": [
|
|
39
|
+
{ "senderId": "console", "configName": "alerts-console" },
|
|
40
|
+
{ "senderId": "file", "configName": "alerts-file" }
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Invocar:
|
|
46
|
+
```ts
|
|
47
|
+
senderAccess.send('tee', 'alerts-tee', {
|
|
48
|
+
level: 'error',
|
|
49
|
+
subject: 'Pod crash',
|
|
50
|
+
body: 'nginx-7d4f crashed in namespace prod'
|
|
51
|
+
})
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
El mensaje llega al mismo tiempo a stdout y al fichero de log.
|
|
55
|
+
|
|
56
|
+
### Cadena de tees
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
tee-main
|
|
60
|
+
├─ tee-ops → console + file
|
|
61
|
+
└─ tee-biz → kafka + slack
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Config `tee-main`:
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"name": "tee-main",
|
|
68
|
+
"targets": [
|
|
69
|
+
{ "senderId": "tee", "configName": "tee-ops" },
|
|
70
|
+
{ "senderId": "tee", "configName": "tee-biz" }
|
|
71
|
+
]
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Comportamiento
|
|
76
|
+
|
|
77
|
+
- Los envíos a todos los targets se hacen en **paralelo** (`Promise.all`).
|
|
78
|
+
- Si un target falla, el error se propaga (el `Promise.all` rechaza). Los targets completados antes del fallo **no se revierten**.
|
|
79
|
+
- El `ISenderAccess` se inyecta en `startSender()` — el tee no funciona antes de que el sistema lo inicialice.
|
package/build.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
fs.mkdirSync('dist', { recursive: true })
|
|
24
|
+
|
|
25
|
+
await esbuild.build({
|
|
26
|
+
entryPoints: ['src/back/index.ts'],
|
|
27
|
+
bundle: true,
|
|
28
|
+
format: 'cjs',
|
|
29
|
+
platform: 'node',
|
|
30
|
+
target: 'node20',
|
|
31
|
+
outfile: 'dist/back.js',
|
|
32
|
+
loader: { '.ts': 'ts' },
|
|
33
|
+
minify: false,
|
|
34
|
+
})
|
|
35
|
+
console.log('Built dist/back.js')
|
|
36
|
+
|
|
37
|
+
await esbuild.build({
|
|
38
|
+
entryPoints: ['src/front/index.tsx'],
|
|
39
|
+
bundle: true,
|
|
40
|
+
format: 'iife',
|
|
41
|
+
outfile: 'dist/front.js',
|
|
42
|
+
plugins: [kwirthGlobalsPlugin],
|
|
43
|
+
loader: { '.tsx': 'tsx', '.ts': 'ts' },
|
|
44
|
+
jsx: 'transform',
|
|
45
|
+
jsxFactory: 'React.createElement',
|
|
46
|
+
jsxFragment: 'React.Fragment',
|
|
47
|
+
target: 'es2020',
|
|
48
|
+
minify: false,
|
|
49
|
+
})
|
|
50
|
+
console.log('Built dist/front.js')
|
|
51
|
+
|
|
52
|
+
const meta = JSON.parse(fs.readFileSync('package.json', 'utf-8'))
|
|
53
|
+
const distMeta = {
|
|
54
|
+
id: meta.id,
|
|
55
|
+
name: meta.name,
|
|
56
|
+
displayName: meta.displayName,
|
|
57
|
+
version: meta.version,
|
|
58
|
+
description: meta.description,
|
|
59
|
+
}
|
|
60
|
+
fs.writeFileSync(path.join('dist', 'package.json'), JSON.stringify(distMeta, null, 2))
|
|
61
|
+
console.log('Wrote dist/package.json')
|
|
@@ -80,7 +80,10 @@
|
|
|
80
80
|
const loadConfigs = async () => {
|
|
81
81
|
try {
|
|
82
82
|
const res = await fetch(`${backendUrl}/senders/tee/configs`, authGet(accessString));
|
|
83
|
-
if (res.ok)
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const data = await res.json();
|
|
85
|
+
setConfigs(Array.isArray(data) ? data : data.configs ?? []);
|
|
86
|
+
}
|
|
84
87
|
} catch {
|
|
85
88
|
}
|
|
86
89
|
};
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "tee",
|
|
3
|
+
"name": "@kwirthmagnify/kwirth-sender-tee",
|
|
4
|
+
"displayName": "Tee Sender",
|
|
5
|
+
"version": "0.1.4",
|
|
6
|
+
"description": "Tee sender plugin for Kwirth — fans out an incoming message to multiple downstream senders simultaneously, acting as an intermediate pipeline node"
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,25 @@
|
|
|
1
|
-
{
|
|
2
|
-
"id": "tee",
|
|
3
|
-
"name": "@kwirthmagnify/kwirth-sender-tee",
|
|
4
|
-
"
|
|
5
|
-
"version": "0.1.
|
|
6
|
-
"
|
|
7
|
-
|
|
1
|
+
{
|
|
2
|
+
"id": "tee",
|
|
3
|
+
"name": "@kwirthmagnify/kwirth-sender-tee",
|
|
4
|
+
"publisher": "@kwirthmagnify",
|
|
5
|
+
"version": "0.1.4",
|
|
6
|
+
"displayName": "Tee Sender",
|
|
7
|
+
"description": "Tee sender plugin for Kwirth — fans out an incoming message to multiple downstream senders simultaneously, acting as an intermediate pipeline node",
|
|
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.11"
|
|
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,68 @@
|
|
|
1
|
+
import { ISender, ISenderAccess, ISenderConfig, ISenderFieldDef, ISenderMessage } from '@kwirthmagnify/kwirth-common-back'
|
|
2
|
+
|
|
3
|
+
// ─── Config ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export interface ITeeSenderTarget {
|
|
6
|
+
senderId: string
|
|
7
|
+
configName: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ITeeSenderConfig extends ISenderConfig {
|
|
11
|
+
name: string
|
|
12
|
+
targets: ITeeSenderTarget[] // list of downstream (senderId, configName) pairs to fan out to
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// ─── Sender ────────────────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
export class TeeSender implements ISender {
|
|
18
|
+
readonly id = 'tee'
|
|
19
|
+
private configs = new Map<string, ITeeSenderConfig>()
|
|
20
|
+
private senderAccess: ISenderAccess | undefined
|
|
21
|
+
|
|
22
|
+
addConfig(config: ISenderConfig): void {
|
|
23
|
+
const tc = config as ITeeSenderConfig
|
|
24
|
+
if (!Array.isArray(tc.targets)) tc.targets = []
|
|
25
|
+
this.configs.set(tc.name, tc)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
removeConfig(name: string): void {
|
|
29
|
+
this.configs.delete(name)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
hasConfig(name: string): boolean {
|
|
33
|
+
return this.configs.has(name)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
getConfigNames(): string[] {
|
|
37
|
+
return Array.from(this.configs.keys())
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async send(configName: string, message: ISenderMessage): Promise<void> {
|
|
41
|
+
const config = this.configs.get(configName)
|
|
42
|
+
if (!config) throw new Error(`TeeSender: config '${configName}' not found`)
|
|
43
|
+
if (!this.senderAccess) throw new Error(`TeeSender: senderAccess not initialized`)
|
|
44
|
+
|
|
45
|
+
await Promise.all(
|
|
46
|
+
config.targets.map(target =>
|
|
47
|
+
this.senderAccess!.send(target.senderId, target.configName, message)
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
getConfigSchema(): ISenderFieldDef[] {
|
|
53
|
+
return [
|
|
54
|
+
{ name: 'name', label: 'Name', required: true },
|
|
55
|
+
{ name: 'targets', label: 'Targets (JSON)', type: 'json' },
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async startSender(senders: ISenderAccess): Promise<void> {
|
|
60
|
+
this.senderAccess = senders
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async stopSender(): Promise<void> {
|
|
64
|
+
this.senderAccess = undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export default TeeSender
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react'
|
|
2
|
+
import {
|
|
3
|
+
Box, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle,
|
|
4
|
+
Divider, FormControl, IconButton, InputLabel, List, ListItem, ListItemButton,
|
|
5
|
+
ListItemText, MenuItem, Select, Stack, TextField, Tooltip, Typography
|
|
6
|
+
} from '@mui/material'
|
|
7
|
+
import { Add, ContentCopy, Delete } from '@mui/icons-material'
|
|
8
|
+
|
|
9
|
+
function authGet(token: string): RequestInit {
|
|
10
|
+
return { headers: { Authorization: token ? `Bearer ${token}` : '', 'X-Kwirth-App': 'true' } }
|
|
11
|
+
}
|
|
12
|
+
function authPost(token: string, body: string): RequestInit {
|
|
13
|
+
return { method: 'POST', body, headers: { Authorization: token ? `Bearer ${token}` : '', 'X-Kwirth-App': 'true', 'Content-Type': 'application/json' } }
|
|
14
|
+
}
|
|
15
|
+
function authDelete(token: string): RequestInit {
|
|
16
|
+
return { method: 'DELETE', headers: { Authorization: token ? `Bearer ${token}` : '', 'X-Kwirth-App': 'true' } }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface IProps { onClose: () => void; backendUrl: string; accessString: string }
|
|
20
|
+
interface IConfig { name: string; description?: string; targets: { senderId: string; configName: string }[] }
|
|
21
|
+
interface ISender { id: string; displayName?: string; configNames: string[] }
|
|
22
|
+
|
|
23
|
+
const TeeSenderDialog: React.FC<IProps> = ({ onClose, backendUrl, accessString }) => {
|
|
24
|
+
const [configs, setConfigs] = useState<IConfig[]>([])
|
|
25
|
+
const [senders, setSenders] = useState<ISender[]>([])
|
|
26
|
+
const [selectedName, setSelectedName] = useState<string | undefined>()
|
|
27
|
+
const [originalName, setOriginalName] = useState<string | undefined>()
|
|
28
|
+
const [editName, setEditName] = useState('')
|
|
29
|
+
const [editDescription, setEditDescription] = useState('')
|
|
30
|
+
const [targets, setTargets] = useState<{ senderId: string; configName: string }[]>([])
|
|
31
|
+
const [newSenderId, setNewSenderId] = useState('')
|
|
32
|
+
const [newConfigName, setNewConfigName] = useState('')
|
|
33
|
+
const [saving, setSaving] = useState(false)
|
|
34
|
+
const [deleting, setDeleting] = useState<string | undefined>()
|
|
35
|
+
const [error, setError] = useState<string | undefined>()
|
|
36
|
+
|
|
37
|
+
useEffect(() => { loadConfigs(); loadSenders() }, [])
|
|
38
|
+
|
|
39
|
+
const loadConfigs = async () => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await fetch(`${backendUrl}/senders/tee/configs`, authGet(accessString))
|
|
42
|
+
if (res.ok) { const data = await res.json(); setConfigs(Array.isArray(data) ? data : (data.configs ?? [])) }
|
|
43
|
+
} catch {}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const loadSenders = async () => {
|
|
47
|
+
try {
|
|
48
|
+
const res = await fetch(`${backendUrl}/senders`, authGet(accessString))
|
|
49
|
+
if (res.ok) setSenders((await res.json() as ISender[]).filter(s => s.id !== 'tee'))
|
|
50
|
+
} catch {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const selectConfig = (cfg: IConfig) => {
|
|
54
|
+
setSelectedName(cfg.name); setOriginalName(cfg.name)
|
|
55
|
+
setEditName(cfg.name); setEditDescription(cfg.description ?? '')
|
|
56
|
+
setTargets([...cfg.targets])
|
|
57
|
+
setNewSenderId(''); setNewConfigName(''); setError(undefined)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const newConfig = () => {
|
|
61
|
+
setSelectedName(undefined); setOriginalName(undefined)
|
|
62
|
+
setEditName(''); setEditDescription(''); setTargets([])
|
|
63
|
+
setNewSenderId(''); setNewConfigName(''); setError(undefined)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cloneConfig = () => {
|
|
67
|
+
if (!selectedName) return
|
|
68
|
+
const base = configs.find(c => c.name === selectedName)
|
|
69
|
+
if (!base) return
|
|
70
|
+
setSelectedName(undefined); setOriginalName(undefined)
|
|
71
|
+
setEditName(`${base.name} (copy)`); setEditDescription(base.description ?? '')
|
|
72
|
+
setTargets([...base.targets])
|
|
73
|
+
setNewSenderId(''); setNewConfigName(''); setError(undefined)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const addTarget = () => {
|
|
77
|
+
if (!newSenderId || !newConfigName) return
|
|
78
|
+
if (targets.some(t => t.senderId === newSenderId && t.configName === newConfigName)) return
|
|
79
|
+
setTargets(prev => [...prev, { senderId: newSenderId, configName: newConfigName }])
|
|
80
|
+
setNewSenderId(''); setNewConfigName('')
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const save = async () => {
|
|
84
|
+
const trimmed = editName.trim()
|
|
85
|
+
if (!trimmed) return
|
|
86
|
+
setSaving(true); setError(undefined)
|
|
87
|
+
try {
|
|
88
|
+
const res = await fetch(`${backendUrl}/senders/tee/configs`, authPost(accessString, JSON.stringify({ name: trimmed, ...(editDescription.trim() ? { description: editDescription.trim() } : {}), targets })))
|
|
89
|
+
if (!res.ok) throw new Error((await res.json()).error ?? `HTTP ${res.status}`)
|
|
90
|
+
if (originalName && originalName !== trimmed) {
|
|
91
|
+
await fetch(`${backendUrl}/senders/tee/configs/${encodeURIComponent(originalName)}`, authDelete(accessString))
|
|
92
|
+
}
|
|
93
|
+
setSelectedName(trimmed); setOriginalName(trimmed)
|
|
94
|
+
await loadConfigs()
|
|
95
|
+
} catch (err) { setError(`Save failed: ${err}`) }
|
|
96
|
+
finally { setSaving(false) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const deleteConfig = async (name: string) => {
|
|
100
|
+
setDeleting(name)
|
|
101
|
+
try {
|
|
102
|
+
const res = await fetch(`${backendUrl}/senders/tee/configs/${encodeURIComponent(name)}`, authDelete(accessString))
|
|
103
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
104
|
+
if (selectedName === name) newConfig()
|
|
105
|
+
await loadConfigs()
|
|
106
|
+
} catch (err) { setError(`Delete failed: ${err}`) }
|
|
107
|
+
finally { setDeleting(undefined) }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const newSenderConfigs = senders.find(s => s.id === newSenderId)?.configNames ?? []
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<Dialog open={true} maxWidth={false} sx={{ '& .MuiDialog-paper': { width: '700px', height: '520px' } }}>
|
|
114
|
+
<DialogTitle>Configure: Tee Sender</DialogTitle>
|
|
115
|
+
<DialogContent sx={{ display: 'flex', gap: 2, p: 2, pt: 2, overflow: 'hidden', height: '100%' }}>
|
|
116
|
+
|
|
117
|
+
{/* Left — config list */}
|
|
118
|
+
<Box sx={{ width: 190, display: 'flex', flexDirection: 'column', gap: 1, flexShrink: 0 }}>
|
|
119
|
+
<Typography variant='caption' color='text.secondary' fontWeight='bold'>Configs</Typography>
|
|
120
|
+
<Box sx={{ flex: 1, border: 1, borderColor: 'divider', borderRadius: 1, overflowY: 'auto' }}>
|
|
121
|
+
<List dense disablePadding>
|
|
122
|
+
{configs.map(cfg => (
|
|
123
|
+
<ListItem key={cfg.name} disablePadding secondaryAction={
|
|
124
|
+
<IconButton size='small' color='error' disabled={deleting === cfg.name} onClick={() => deleteConfig(cfg.name)}>
|
|
125
|
+
{deleting === cfg.name ? <CircularProgress size={12} /> : <Delete sx={{ fontSize: 14 }} />}
|
|
126
|
+
</IconButton>
|
|
127
|
+
}>
|
|
128
|
+
<ListItemButton selected={selectedName === cfg.name} onClick={() => selectConfig(cfg)} dense sx={{ pr: 4 }}>
|
|
129
|
+
<ListItemText
|
|
130
|
+
primary={cfg.name}
|
|
131
|
+
secondary={`${cfg.targets.length} target${cfg.targets.length !== 1 ? 's' : ''}`}
|
|
132
|
+
primaryTypographyProps={{ variant: 'body2', noWrap: true }}
|
|
133
|
+
secondaryTypographyProps={{ variant: 'caption' }} />
|
|
134
|
+
</ListItemButton>
|
|
135
|
+
</ListItem>
|
|
136
|
+
))}
|
|
137
|
+
</List>
|
|
138
|
+
</Box>
|
|
139
|
+
<Stack direction='row' spacing={0.5}>
|
|
140
|
+
<Button size='small' startIcon={<Add />} onClick={newConfig} sx={{ flex: 1 }}>New</Button>
|
|
141
|
+
<Button size='small' startIcon={<ContentCopy />} onClick={cloneConfig} disabled={!selectedName} sx={{ flex: 1 }}>Clone</Button>
|
|
142
|
+
</Stack>
|
|
143
|
+
</Box>
|
|
144
|
+
|
|
145
|
+
<Divider orientation='vertical' flexItem />
|
|
146
|
+
|
|
147
|
+
{/* Right — editor */}
|
|
148
|
+
<Box sx={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 1.5, minWidth: 0, pt: 1 }}>
|
|
149
|
+
<Typography variant='caption' color='text.secondary' fontWeight='bold'>
|
|
150
|
+
{selectedName ? `Editing: ${selectedName}` : 'New config'}
|
|
151
|
+
</Typography>
|
|
152
|
+
<TextField size='small' label='Name *' value={editName}
|
|
153
|
+
onChange={e => setEditName(e.target.value)} fullWidth />
|
|
154
|
+
<TextField size='small' label='Description' value={editDescription}
|
|
155
|
+
onChange={e => setEditDescription(e.target.value)} fullWidth multiline maxRows={2} />
|
|
156
|
+
|
|
157
|
+
<Typography variant='caption' color='text.secondary' fontWeight='bold'>Targets</Typography>
|
|
158
|
+
|
|
159
|
+
<Box sx={{ flex: 1, border: 1, borderColor: 'divider', borderRadius: 1, overflowY: 'auto' }}>
|
|
160
|
+
{targets.length === 0
|
|
161
|
+
? <Typography variant='caption' color='text.disabled' sx={{ p: 1, display: 'block' }}>No targets yet.</Typography>
|
|
162
|
+
: targets.map((t, i) => (
|
|
163
|
+
<Box key={i} sx={{ display: 'flex', alignItems: 'center', gap: 1, px: 1, py: 0.5, borderBottom: 1, borderColor: 'divider' }}>
|
|
164
|
+
<Typography variant='body2' sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.senderId}</Typography>
|
|
165
|
+
<Typography variant='body2' color='text.secondary' sx={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{t.configName}</Typography>
|
|
166
|
+
<IconButton size='small' color='error' onClick={() => setTargets(prev => prev.filter((_, idx) => idx !== i))}>
|
|
167
|
+
<Delete sx={{ fontSize: 14 }} />
|
|
168
|
+
</IconButton>
|
|
169
|
+
</Box>
|
|
170
|
+
))
|
|
171
|
+
}
|
|
172
|
+
</Box>
|
|
173
|
+
|
|
174
|
+
<Stack direction='row' spacing={1} alignItems='center'>
|
|
175
|
+
<FormControl size='small' sx={{ flex: 1 }}>
|
|
176
|
+
<InputLabel>Sender</InputLabel>
|
|
177
|
+
<Select label='Sender' value={newSenderId} onChange={e => { setNewSenderId(e.target.value); setNewConfigName('') }}>
|
|
178
|
+
{senders.map(s => <MenuItem key={s.id} value={s.id}>{s.displayName || s.id}</MenuItem>)}
|
|
179
|
+
</Select>
|
|
180
|
+
</FormControl>
|
|
181
|
+
<FormControl size='small' sx={{ flex: 1 }} disabled={!newSenderId}>
|
|
182
|
+
<InputLabel>Config</InputLabel>
|
|
183
|
+
<Select label='Config' value={newConfigName} onChange={e => setNewConfigName(e.target.value)}>
|
|
184
|
+
{newSenderConfigs.map(c => <MenuItem key={c} value={c}>{c}</MenuItem>)}
|
|
185
|
+
</Select>
|
|
186
|
+
</FormControl>
|
|
187
|
+
<Tooltip title='Add target'>
|
|
188
|
+
<span>
|
|
189
|
+
<IconButton size='small' color='primary' disabled={!newSenderId || !newConfigName} onClick={addTarget}>
|
|
190
|
+
<Add />
|
|
191
|
+
</IconButton>
|
|
192
|
+
</span>
|
|
193
|
+
</Tooltip>
|
|
194
|
+
</Stack>
|
|
195
|
+
|
|
196
|
+
<Stack direction='row' justifyContent='flex-end' alignItems='center' spacing={1}>
|
|
197
|
+
{error && <Typography variant='caption' color='error' sx={{ flex: 1 }}>{error}</Typography>}
|
|
198
|
+
<Button size='small' variant='contained' disabled={saving || !editName.trim()} onClick={save}>
|
|
199
|
+
{saving ? <CircularProgress size={14} /> : selectedName ? 'Update' : 'Add'}
|
|
200
|
+
</Button>
|
|
201
|
+
</Stack>
|
|
202
|
+
</Box>
|
|
203
|
+
</DialogContent>
|
|
204
|
+
<DialogActions>
|
|
205
|
+
<Button size='small' onClick={onClose}>Close</Button>
|
|
206
|
+
</DialogActions>
|
|
207
|
+
</Dialog>
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export default TeeSenderDialog
|
package/tsconfig.json
ADDED
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
|