Skip to content

Config UI: visualize Messaging#25768

Closed
Maschga wants to merge 123 commits intoevcc-io:masterfrom
Maschga:config-ui/messaging
Closed

Config UI: visualize Messaging#25768
Maschga wants to merge 123 commits intoevcc-io:masterfrom
Maschga:config-ui/messaging

Conversation

@Maschga
Copy link
Collaborator

@Maschga Maschga commented Dec 2, 2025

🖼 visualize messaging section
🧩 migration: split email-uri into host-, port-, user-, password-, from- and to-keys
🧩 migration: split ntfy-uri into host- and topics-keys
🧩 migration: add disable-key to each predefined event
🔕 add toggle to enable or disable specific events
📋 add default title- and message-values to each not-predefined event in the browser's language
💪 use TypeScript

TODO:

  • test each service:
    • Pushover
    • Telegram
    • Email
    • Shout
    • Ntfy
    • Custom

Config UI card:
grafik

Events:
grafik

Add messenger:
grafik

Pushover:
grafik

Telegram:
grafik

Email:
grafik

Shout:
grafik

Ntfy:
grafik

Custom:
grafik

\cc @naltatis

@andig andig added javascript Pull requests that update Javascript code ux User experience/ interface enhancement New feature or request labels Dec 2, 2025
@github-actions github-actions bot added the stale Outdated and ready to close label Dec 9, 2025
@github-actions github-actions bot removed the stale Outdated and ready to close label Dec 10, 2025
@naltatis
Copy link
Member

Hi @Maschga, here a few considerations regarding the layout of this modal. We have a lot of different things to show here.

  • less tabs: on mobile screens the tabs are wrapping which becomes confusing. therefore I'd recommend we organize this in two tabs: Events, Services. Similar to the existing data structure. Maybe adding a number-indicator for better overview of the enabled services.
  • events: I've attached a screenshot/mock illustrating how enabling/disabling of specific events could look like. The switch + disable/grayout pattern is already used similarly on the "Issues" page. Technically it's a little tricky since a disabled status does not exist in our data structure yet. We could work around it but this would essentially mean, that the users will lose custom message text once they disable and save them. That's why I'd suggest extending the event data structure and add an optional (non-breaking) disabled attribute. All other options (forcing the user to delete title/msg, storing disabled texts in local-storage) are messy.
  • services: as said initially, I'd combine the services into one tab. This functionality did not work on my machine. maybe because I updated the branch before. not sure. My expected for would be: User can click on a "add service" button, select the desired service type (mail, telegram, ...). This will add a service-block with form. User can delete this with a delete button. This is very close to our data structure. For the "service selection" we could use a dropdown button.

This looks very promising. Thanks for your work so far and sorry for my delayed response.

Bildschirmfoto 2025-12-10 um 15 44 39

@Maschga Maschga added the backlog Things to do later label Dec 10, 2025
@Maschga Maschga marked this pull request as ready for review January 11, 2026 09:49
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 6 issues, and left some high level feedback:

  • In maskedTransformer.Transformer the slice/array branch uses for i := range n where n is an int; this won’t compile in Go and should be rewritten as a standard counted loop (e.g. for i := 0; i < n; i++ { ... }).
  • The NewEmailFromConfig helper builds the SMTP URI with a to query parameter, while the migration logic in configureMessengers expects toAddresses; using different parameter names will break round-trips and should be aligned.
  • In configureMessengers, any error returned from migrateYamlToJsonByData(keys.Messaging, data) is ignored; consider handling or propagating it so that failed migrations don’t silently proceed to settings.Json with partially written data.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `maskedTransformer.Transformer` the slice/array branch uses `for i := range n` where `n` is an `int`; this won’t compile in Go and should be rewritten as a standard counted loop (e.g. `for i := 0; i < n; i++ { ... }`).
- The `NewEmailFromConfig` helper builds the SMTP URI with a `to` query parameter, while the migration logic in `configureMessengers` expects `toAddresses`; using different parameter names will break round-trips and should be aligned.
- In `configureMessengers`, any error returned from `migrateYamlToJsonByData(keys.Messaging, data)` is ignored; consider handling or propagating it so that failed migrations don’t silently proceed to `settings.Json` with partially written data.

## Individual Comments

### Comment 1
<location> `server/http_config_helper.go:373-382` </location>
<code_context>
 		}
 	}

+	// Preserve slices/arrays: in general don't merge them into dst, but try to merge elements when both slices are non-empty
+	if typ.Kind() == reflect.Slice || typ.Kind() == reflect.Array {
+		// If the slice elements are structs, attempt element-wise merge for non-empty slices
+		if typ.Elem().Kind() == reflect.Struct {
+			return func(dst, src reflect.Value) error {
+				if !dst.IsValid() || dst.IsNil() || !src.IsValid() || src.IsNil() {
+					return nil
+				}
+
+				if dst.Len() == 0 || src.Len() == 0 {
+					// Keep dst value, don't merge
+					return nil
+				}
+
+				// Merge element-wise up to the shorter length
+				n := min(src.Len(), dst.Len())
+
+				for i := range n {
+					de := dst.Index(i)
+					se := src.Index(i)
</code_context>

<issue_to_address>
**issue (bug_risk):** Loop over slice length using `range` on an `int` will not compile

`range` only works on iterables (slices, arrays, maps, etc.), not on `int`, so this code will not compile. Use an index-based loop instead, e.g.:

```go
n := min(src.Len(), dst.Len())
for i := 0; i < n; i++ {
    de := dst.Index(i)
    se := src.Index(i)
    ...
}
```
(or iterate over one slice’s indices and guard with `i < n`).
</issue_to_address>

### Comment 2
<location> `cmd/setup.go:921-929` </location>
<code_context>

+		// the key send is stored as string in database for frontend purposes
+		// but backend needs it as yaml map
+		if conf.Type == "custom" {
+			sendStr := props["send"].(string)
+
+			var send map[string]any
+			if err := yaml.Unmarshal([]byte(sendStr), &send); err != nil {
+				return nil, fmt.Errorf("cannot parse YAML: %w", err)
+			}
+
+			props["send"] = send
+		}
+
</code_context>

<issue_to_address>
**issue (bug_risk):** Un-checked type assertion on `props["send"]` can panic if value is not a string

This code assumes `props["send"]` is always a `string`; if it isn’t (or config is malformed), the type assertion will panic. Use the comma-ok form and return an error instead:

```go
sendRaw, ok := props["send"].(string)
if !ok {
    return nil, fmt.Errorf("custom service 'send' must be a string, got %T", props["send"])
}
```

Then unmarshal `sendRaw` as before so failures are handled gracefully instead of crashing.
</issue_to_address>

### Comment 3
<location> `assets/js/components/Config/Messaging/EventItem.vue:23` </location>
<code_context>
+				<label :for="formId('title')">{{ $t("config.messaging.eventTitle") }}</label>
+			</div>
+			<div class="col-md-10">
+				<PropertyField
+					:id="formId('title')"
+					:model-value="title"
</code_context>

<issue_to_address>
**issue (bug_risk):** Using `@change` on `PropertyField` will not fire because the component emits `update:modelValue`

In `EventItem` you're listening for `@change`, but `PropertyField` only emits `update:modelValue` via `v-model`, so `updateTitle`/`updateMessage` are never called. Wire it to the model event instead:

```vue
<PropertyField
  :model-value="title"
  @update:modelValue="updateTitle"
/>
```

(or use `v-model` on `PropertyField`). Apply the same fix for the message field.
</issue_to_address>

### Comment 4
<location> `assets/js/components/Config/Messaging/Services/EmailService.vue:10-9` </location>
<code_context>
+			required
+			@update:model-value="$emit('update:host', $event)"
+	/></MessagingFormRow>
+	<MessagingFormRow :serviceType="serviceType" inputName="port" example="465">
+		<PropertyField
+			id="messagingServiceEmailPort"
+			:model-value="port"
+			type="String"
+			required
+			@update:model-value="$emit('update:port', $event)"
+	/></MessagingFormRow>
+	<MessagingFormRow :serviceType="serviceType" inputName="user" example="john.doe">
+		<PropertyField
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Email port is modeled as `number` in TS but bound via a string `PropertyField`

This creates a type mismatch: the parent (and `MessagingServiceEmail` type) expect `port: number`, but the editor always emits a string. That can lead to subtle runtime issues and ad-hoc casting.

Please either make `port` consistently a string throughout, or update `PropertyField` to use a numeric type and ensure the emitted value is parsed to `number` before propagation.

Suggested implementation:

```
	<MessagingFormRow :serviceType="serviceType" inputName="port" example="465">
		<PropertyField
			id="messagingServiceEmailPort"
			:model-value="port"
			type="Number"
			required
			@update:model-value="$emit('update:port', Number($event))"
	/></MessagingFormRow>

```

This change assumes that:
1. `PropertyField` supports `type="Number"` and still emits the raw input value (likely a string). If `PropertyField` already parses to a number when `type="Number"` is used, the `Number($event)` cast will still be safe but slightly redundant.
2. `MessagingServiceEmail.port` is typed as `number` in TypeScript; if it's `number | null` or another union, you may want to adjust the conversion logic to handle empty strings explicitly (e.g. emit `null` or `undefined` instead of `Number('')`).
If `PropertyField` does not support `type="Number"`, remove that prop and keep the `Number($event)` cast so the emitted value is still a number.
</issue_to_address>

### Comment 5
<location> `assets/js/components/Config/Messaging/Services/TelegramService.vue:26` </location>
<code_context>
+			/>
+		</MessagingFormRow>
+
+		<MessagingFormRow :serviceType="serviceType" inputName="chats" example="-210987654">
+			<PropertyField
+				id="messagingServiceTelegramChats"
+				:model-value="chats"
+				property="chats"
+				type="List"
+				required
+				:rows="4"
+				@update:model-value="$emit('update:chats', $event)"
+			/>
+		</MessagingFormRow>
+	</div>
+</template>
</code_context>

<issue_to_address>
**suggestion (bug_risk):** Telegram chat IDs are typed as `number[]` but edited via a generic list of strings

`chats` is typed as `number[]`, but `PropertyField` with `type="List"` typically produces `string[]`, so `@update:model-value="$emit('update:chats', $event)` will emit strings into a numeric prop.

Either convert the list items to numbers before emitting (and reject/handle invalid values), or change the type consistently to `string[]` across the prop and backend config if numeric IDs aren’t strictly required.

```suggestion
				@update:model-value="$emit('update:chats', $event.map((v) => Number(v)).filter((v) => !Number.isNaN(v)))"
```
</issue_to_address>

### Comment 6
<location> `cmd/setup.go:805` </location>
<code_context>

 // setup messaging
 func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan push.Event, error) {
-	// migrate settings
 	if settings.Exists(keys.Messaging) {
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the messaging migration logic and custom `send` encoding/decoding from `configureMessengers` into dedicated helper functions to keep this function focused on wiring push services at runtime.

You can keep the new behavior but reduce `configureMessengers`’ responsibility by extracting the migration logic and custom-send conversion into small helpers.

### 1. Extract messaging migration out of `configureMessengers`

Move all the YAML→JSON and per-service migration into a dedicated function that returns a fully migrated `globalconfig.Messaging`:

```go
func migrateMessagingSettings() (*globalconfig.Messaging, error) {
	if !settings.Exists(keys.Messaging) {
		return &globalconfig.Messaging{}, nil
	}

	// YAML → in-memory
	if !settings.IsJson(keys.Messaging) {
		var data globalconfig.Messaging
		if err := settings.Yaml(keys.Messaging, new(globalconfig.Messaging), &data); err != nil {
			return nil, err
		}

		// events already created by the user in yaml should be enabled
		for k, v := range data.Events {
			v.Disabled = false
			data.Events[k] = v
		}

		for i := range data.Services {
			s := &data.Services[i]
			switch s.Type {
			case "email":
				if err := migrateEmailService(s); err != nil {
					return nil, err
				}
			case "ntfy":
				if err := migrateNtfyService(s); err != nil {
					return nil, err
				}
			case "custom":
				if err := encodeCustomSend(s); err != nil {
					return nil, err
				}
			}
		}

		if err := migrateYamlToJsonByData(keys.Messaging, data); err != nil {
			return nil, fmt.Errorf("failed to persist migrated messaging setting: %w", err)
		}
	}

	var conf globalconfig.Messaging
	if err := settings.Json(keys.Messaging, &conf); err != nil {
		return nil, fmt.Errorf("failed to read messaging setting: %w", err)
	}

	return &conf, nil
}
```

Then `configureMessengers` only wires messengers:

```go
func configureMessengers(conf *globalconfig.Messaging, vehicles push.Vehicles, valueChan chan<- util.Param, cache *util.ParamCache) (chan push.Event, error) {
	if settings.Exists(keys.Messaging) {
		migrated, err := migrateMessagingSettings()
		if err != nil {
			return nil, err
		}
		*conf = *migrated
	}

	messageChan := make(chan push.Event, 1)
	messageHub, err := push.NewHub(conf.Events, vehicles, cache)
	if err != nil {
		return messageChan, fmt.Errorf("failed configuring push services: %w", err)
	}

	for _, svcConf := range conf.Services {
		props, err := buildMessengerProps(svcConf)
		if err != nil {
			return nil, err
		}

		impl, err := push.NewFromConfig(context.TODO(), svcConf.Type, props)
		if err != nil {
			return messageChan, fmt.Errorf("failed configuring push service %s: %w", svcConf.Type, err)
		}
		messageHub.Add(impl)
	}

	go messageHub.Run(messageChan, valueChan)
	return messageChan, nil
}
```

### 2. Per-service migration helpers

Pull the inline `if s.Type == ...` branches into small helpers:

```go
func migrateEmailService(s *globalconfig.MessagingService) error {
	uri, ok := s.Other["uri"].(string)
	if !ok {
		return fmt.Errorf("failed to migrate email service due to missing uri")
	}

	u, err := url.Parse(uri)
	if err != nil {
		return err
	}

	s.Other["host"] = u.Hostname()
	s.Other["user"] = u.User.Username()
	s.Other["port"] = u.Port()
	s.Other["from"] = u.Query().Get("fromAddress")

	addresses := u.Query()["toAddresses"]
	toAddresses := make([]string, 0, len(addresses))
	for _, v := range addresses {
		for _, a := range strings.Split(v, ",") {
			if a != "" {
				toAddresses = append(toAddresses, a)
			}
		}
	}
	s.Other["to"] = toAddresses

	if pw, ok := u.User.Password(); ok {
		s.Other["password"] = pw
	}

	delete(s.Other, "uri")
	return nil
}

func migrateNtfyService(s *globalconfig.MessagingService) error {
	uri, ok := s.Other["uri"].(string)
	if !ok {
		return fmt.Errorf("failed to migrate ntfy service due to missing uri")
	}

	parsed, err := url.Parse(uri)
	if err != nil {
		return err
	}

	path := strings.TrimPrefix(parsed.Path, "/")
	s.Other["host"] = parsed.Host
	if path == "" {
		s.Other["topics"] = []string{}
	} else {
		s.Other["topics"] = strings.Split(path, ",")
	}

	delete(s.Other, "uri")
	return nil
}
```

### 3. Encapsulate custom `send` encoding/decoding

Replace the inline JSON/YAML manipulation for `custom` with two helpers; this lets you drop the `deepcopy.Copy` from the hot path:

```go
// during migration (persisted representation)
func encodeCustomSend(s *globalconfig.MessagingService) error {
	d, err := json.Marshal(s.Other)
	if err != nil {
		return err
	}

	var other struct {
		Encoding string `json:"encoding"`
		Send     any    `json:"send"`
	}
	if err := json.Unmarshal(d, &other); err != nil {
		return err
	}

	yamlSend, err := yaml.Marshal(other.Send)
	if err != nil {
		return err
	}

	s.Other["encoding"] = other.Encoding
	s.Other["send"] = string(yamlSend)
	return nil
}

// during runtime wiring (convert back to map for backend)
func decodeCustomSend(props map[string]any) error {
	sendStr, ok := props["send"].(string)
	if !ok {
		return nil // or error if you expect it always to be string
	}

	var send map[string]any
	if err := yaml.Unmarshal([]byte(sendStr), &send); err != nil {
		return fmt.Errorf("cannot parse YAML: %w", err)
	}

	props["send"] = send
	return nil
}
```

### 4. Centralize `Other` decoding and remove `deepcopy.Copy`

Create one helper that handles `customDevice` + custom-send decoding and returns a fresh map, so `configureMessengers` doesn’t need to know about `deepcopy` or the persistence-form:

```go
func buildMessengerProps(conf globalconfig.MessagingService) (map[string]any, error) {
	// make a shallow copy of Other to avoid mutating config
	otherCopy := make(map[string]any, len(conf.Other))
	for k, v := range conf.Other {
		otherCopy[k] = v
	}

	props, err := customDevice(otherCopy)
	if err != nil {
		return nil, fmt.Errorf("cannot decode push service '%s': %w", conf.Type, err)
	}

	if conf.Type == "custom" {
		if err := decodeCustomSend(props); err != nil {
			return nil, err
		}
	}

	return props, nil
}
```

This keeps the new migration behavior, but pushes migration/persistence concerns into dedicated helpers and leaves `configureMessengers` focused on runtime wiring.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@naltatis
Copy link
Member

@Maschga In-between review status. Looks really good. I went through the vue files and made them a little more DRY. Also reduced the usage of consts to improve readability. The changed spots are still type-safe.

Really like the e2e tests you've added! 🎉🙌

I still have to look deeper into the go changes.

@andig please also have a look at this. These changes are not trivial because the messaging structure is quite complex (compared to mqtt, infux or other existing json configs). FYI: The messaging services would be a good candidate handle as dedicated devices in the future. This way we could skip custom masking/merging logic and rely on our existing params handling. But that's something for a later PR. Goal here is to get the yaml based config to a ui based config (json).

@andig
Copy link
Member

andig commented Jan 18, 2026

@Maschga this is a lot of custom and specific new code, some of which we may be able to eliminate. What do you think of this approach:

  1. Make messaging services "devices" internally
  2. Add device templates
  3. Add UI config

I could help with the first task. Events would still require new configuration logic.

@Maschga
Copy link
Collaborator Author

Maschga commented Jan 18, 2026

Okay, then let's go straight for the optimal solution.
I definitely need your help with this.
I'm still wondering whether the messaging services should be migrated somehow or whether it should be marked as BC and all messaging services should be added again.

Should I open a new empty PR?

@andig
Copy link
Member

andig commented Jan 18, 2026

Let me start with 1) and the we see what can be rebased or not. I think we can do yaml or ui as with the other devices.

@Maschga
Copy link
Collaborator Author

Maschga commented Feb 9, 2026

Closed in favor of #26946.

@Maschga Maschga closed this Feb 9, 2026
@Maschga Maschga deleted the config-ui/messaging branch February 9, 2026 20:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backlog Things to do later enhancement New feature or request javascript Pull requests that update Javascript code ux User experience/ interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants