Fix Notification Preferences Sync (Controller → Hub)

This commit is contained in:
2026-02-16 20:11:39 +01:00
parent d1032a3a4f
commit 8f5962c47d
+261 -531
View File
@@ -1,597 +1,327 @@
# TASK: Phase 2 — Monitoring Warnings, Dashboard Alerts & Notification System
# TASK: Fix Notification Preferences Sync (Controller → Hub)
**Version target:** 0.7.1
**Repos:** `deploy-felhom-compose` (controller) + `felhom.eu` (notification-relay on k3s)
**Version target:** controller 0.7.2, hub 0.1.5
**Repos:** `deploy-felhom-compose` (controller) + `felhom.eu` (hub)
## Overview
## Problem
Three workstreams in this phase:
1. **Monitoring page warnings** — Show healthcheck ping configuration status, warn about missing UUIDs
2. **Dashboard alert system** — Persistent in-app banners for active issues/warnings
3. **Notification system** — Central email relay on k3s + customer-side preferences UI
When a customer saves notification settings (email + enabled events) on the controller settings page, the preferences are only stored locally in `settings.json`. The hub's `customer_notifications` SQLite table remains empty, so when the controller sends a notification event via `POST /api/v1/notify`, the hub responds with "No email configured for demo-felhom, skipping notification."
The email sending infrastructure works (Resend API key is configured, `sendResendEmail` is proven). The gap is that preferences never reach the hub.
## Solution
Add a preferences sync endpoint to the hub (`POST /api/v1/preferences`) and have the controller call it when the user saves notification settings.
---
## 1. Monitoring Page: Healthcheck Ping Status
## 1. Hub: Add `POST /api/v1/preferences` Endpoint
### 1.1 Problem
### 1.1 Route
The monitoring page shows system metrics but doesn't indicate whether healthcheck pings are actually configured. If a ping UUID is empty or `CHANGEME`, the pinger silently skips it — the customer has no visibility into whether remote monitoring is working.
Add to `hub/internal/api/handler.go` routing:
### 1.2 New section: "Távoli monitoring" (Remote Monitoring)
```go
case r.Method == http.MethodPost && path == "/preferences":
h.handleSavePreferences(w, r)
```
Add a new section to `monitoring.html` **between** "Rendszer áttekintés" and "Rendszer metrikák" (section 1 and 2). This section is server-rendered (not JS/API — ping config is static and known at page load).
### 1.2 Handler: `handleSavePreferences`
Display a table showing each healthcheck ping's configuration status:
```go
func (h *Handler) handleSavePreferences(w http.ResponseWriter, r *http.Request) {
// Same bearer token auth as /report and /notify
if h.apiKey != "" {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
}
| Ellenőrzés | UUID státusz | Gyakoriság |
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "Bad request", http.StatusBadRequest)
return
}
var payload struct {
CustomerID string `json:"customer_id"`
Email string `json:"email"`
EnabledEvents []string `json:"enabled_events"`
}
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
return
}
if err := h.store.SaveNotificationPrefs(payload.CustomerID, payload.Email, payload.EnabledEvents); err != nil {
h.logger.Printf("[ERROR] Failed to save notification prefs for %s: %v", payload.CustomerID, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
h.logger.Printf("[INFO] Notification preferences updated for %s: email=%s, events=%v", payload.CustomerID, payload.Email, payload.EnabledEvents)
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
```
**Note:** `store.SaveNotificationPrefs` already exists and uses `INSERT ... ON CONFLICT DO UPDATE`. No store changes needed.
### 1.3 Edge case: empty email
If the customer clears their email and saves, the controller should still sync — this effectively disables notifications for that customer. The hub accepts empty email and stores it (the notify handler already handles `prefs.Email == ""` gracefully).
---
## 2. Controller: Sync Preferences on Save
### 2.1 Add `SyncPreferences` method to `internal/notify/notifier.go`
```go
// preferencesRequest is the JSON payload sent to the hub preferences endpoint.
type preferencesRequest struct {
CustomerID string `json:"customer_id"`
Email string `json:"email"`
EnabledEvents []string `json:"enabled_events"`
}
// SyncPreferences pushes the current notification preferences to the hub.
// Called after the user saves notification settings on the settings page.
// Returns error for the handler to display to the user.
func (n *Notifier) SyncPreferences(email string, enabledEvents []string) error {
if !n.enabled {
return fmt.Errorf("hub nem konfigurált")
}
payload := preferencesRequest{
CustomerID: n.customerID,
Email: email,
EnabledEvents: enabledEvents,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal: %w", err)
}
url := n.hubURL + "/api/v1/preferences"
req, err := http.NewRequest("POST", url, bytes.NewReader(jsonData))
if err != nil {
return fmt.Errorf("request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+n.apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := n.httpClient.Do(req)
if err != nil {
return fmt.Errorf("hub elérhetetlen: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("hub hiba (%d): %s", resp.StatusCode, string(body))
}
n.logger.Printf("[INFO] Notification preferences synced to hub: email=%s, events=%v", email, enabledEvents)
return nil
}
```
**Note:** This is a synchronous call (not goroutine) because the handler needs to know if it succeeded to show feedback to the user.
### 2.2 Call `SyncPreferences` in the notification settings POST handler
In `internal/web/handlers.go`, the `handleNotificationSettings` handler currently:
1. Parses form data
2. Saves to `settings.json`
3. Redirects with success message
Add step 2.5: sync to hub.
```go
// After saving to settings.json:
if err := s.notifier.SyncPreferences(email, enabledEvents); err != nil {
s.logger.Printf("[WARN] Failed to sync preferences to hub: %v", err)
// Don't block the save — local settings are saved, sync failed
// Show warning instead of error
// Redirect with: "Értesítési beállítások mentve, de a központi szinkronizálás sikertelen: <err>"
}
// If sync succeeded: "Értesítési beállítások mentve."
```
**Important:** Local save always succeeds even if hub sync fails. The user sees a warning but their settings are saved. This prevents a hub outage from blocking local config changes.
### 2.3 Flash message variants
| Scenario | Message | Color |
|---|---|---|
| 💓 Életjel (Heartbeat) | ✅ Beállítva | 5 percenként |
| 🖥️ Rendszer állapot | ✅ Beállítva | 5 percenként |
| 🗄️ Adatbázis mentés | ⚠️ Nincs beállítva | Naponta 02:30 |
| 💾 Biztonsági mentés | ✅ Beállítva | Naponta 03:00 |
| 🔍 Mentés integritás | ⚠️ Nincs beállítva | Hetente (vasárnap) |
| Save + sync OK | "Értesítési beállítások mentve." | Green (success) |
| Save OK, sync failed | "Értesítési beállítások mentve (helyi). A központi szinkronizálás sikertelen: [error]" | Yellow (warning) |
| Save failed | "Hiba az értesítési beállítások mentésekor: [error]" | Red (error) |
**Logic for each row:**
- Read `monitoring.ping_uuids.*` from config
- UUID is "configured" if: non-empty AND doesn't start with `CHANGEME`
- If configured: show `✅ Beállítva` (green text)
- If not configured: show `⚠️ Nincs beállítva` (yellow/orange warning text)
- If monitoring is disabled entirely (`monitoring.enabled = false`): show a single warning banner instead of the table: "A távoli monitoring ki van kapcsolva. Az üzemeltető nem kap értesítést hibák esetén."
### 2.4 Sync on startup (recommended)
**Summary banner above the table:**
- All configured: green banner — "✅ Minden távoli monitoring aktív — az üzemeltető értesítést kap hibák esetén."
- Some missing: yellow banner — "⚠️ Egyes monitoring ellenőrzések nincsenek beállítva. Kérd az üzemeltetőt a konfiguráláshoz."
- Monitoring disabled: red/orange banner — as above
When the controller starts and has notification preferences in `settings.json`, sync them to the hub once. This handles the case where the hub was rebuilt (lost its DB) or the controller was moved to a new hub.
### 1.3 Data flow
Add a new template data struct for the monitoring handler:
In `main.go`, after loading settings and creating the notifier:
```go
type MonitoringPageData struct {
// Existing fields...
SystemInfo *system.Info
ActivePage string
// New: healthcheck ping status
MonitoringEnabled bool
PingStatus []PingStatusItem
AllPingsConfigured bool
}
type PingStatusItem struct {
Label string // Hungarian display name
Icon string // emoji
Configured bool // UUID is valid
Schedule string // "5 percenként" / "Naponta 02:30" etc.
prefs := sett.GetNotificationPrefs()
if prefs.Email != "" && notifier.IsEnabled() {
if err := notifier.SyncPreferences(prefs.Email, prefs.EnabledEvents); err != nil {
logger.Printf("[WARN] Failed to sync notification preferences on startup: %v", err)
}
}
```
Build `PingStatus` slice in the handler from `cfg.Monitoring.PingUUIDs`:
```go
pings := []PingStatusItem{
{Label: "Életjel (Heartbeat)", Icon: "💓", Configured: isConfigured(cfg.Monitoring.PingUUIDs.Heartbeat), Schedule: "5 percenként"},
{Label: "Rendszer állapot", Icon: "🖥️", Configured: isConfigured(cfg.Monitoring.PingUUIDs.SystemHealth), Schedule: "5 percenként"},
{Label: "Adatbázis mentés", Icon: "🗄️", Configured: isConfigured(cfg.Monitoring.PingUUIDs.DBDump), Schedule: "Naponta " + cfg.Backup.DBDumpSchedule},
{Label: "Biztonsági mentés", Icon: "💾", Configured: isConfigured(cfg.Monitoring.PingUUIDs.Backup), Schedule: "Naponta " + cfg.Backup.ResticSchedule},
{Label: "Mentés integritás", Icon: "🔍", Configured: isConfigured(cfg.Monitoring.PingUUIDs.BackupIntegrity), Schedule: "Hetente (vasárnap)"},
}
func isConfigured(uuid string) bool {
return uuid != "" && !strings.HasPrefix(uuid, "CHANGEME")
}
```
### 1.4 CSS
Reuse existing `.settings-row` / `.sysinfo-row` pattern for the table. Add:
- `.ping-status-ok` — green text (same as `.state-text-green`)
- `.ping-status-warn` — orange/yellow text
- `.monitoring-banner` — full-width banner with icon, green/yellow/red variants
Add `IsEnabled() bool` method to Notifier if it doesn't exist (simple getter for `n.enabled`).
---
## 2. Dashboard Alert System
## 3. Hub: Display Customer Notification Settings (Dashboard)
### 2.1 Concept
### 3.1 Customer detail page
Display persistent alert banners at the top of the main content area (below page header, above page content). Alerts are generated from the latest health check results and other events. They show on ALL pages, not just monitoring.
On the existing hub customer detail page (`/customers/{id}`), add a small "Notifications" section showing:
- Email: `nagyfenyvesi.viktor@gmail.com` (or "Not set")
- Enabled events: comma-separated list
- Recent notification log entries (last 10)
### 2.2 Alert sources
The controller already runs health checks every 5 minutes (`RunHealthCheck`). The resulting `HealthReport` contains `Issues` (critical) and `Warnings` (non-critical). Use these directly.
Additionally, generate alerts for:
- Missing healthcheck ping UUIDs (from section 1 above)
- Backup not configured (`backup.enabled = false`)
- Hub reporting not configured when it should be
- Recent backup failures (from backup manager state)
### 2.3 Implementation: Alert Manager
Create `internal/web/alerts.go`:
### 3.2 Store: Add `GetRecentNotifications` method
```go
type Alert struct {
ID string // unique, for dismiss tracking
Level string // "error", "warning", "info"
Message string // Hungarian text
Link string // optional link to relevant page (e.g., "/monitoring", "/backups")
LinkText string // "Részletek" etc.
type NotificationLogEntry struct {
EventType string
Severity string
Message string
Status string // "sent", "skipped", "failed"
ErrorMessage string
CreatedAt time.Time
}
type AlertManager struct {
mu sync.RWMutex
alerts []Alert
logger *log.Logger
func (s *Store) GetRecentNotifications(customerID string, limit int) ([]NotificationLogEntry, error) {
rows, err := s.db.Query(`
SELECT event_type, severity, message, status, error_message, created_at
FROM notification_log
WHERE customer_id = ?
ORDER BY created_at DESC
LIMIT ?`, customerID, limit)
// ... scan rows into []NotificationLogEntry
}
```
**Alert generation** runs after each health check cycle (every 5 min):
### 3.3 Customer detail handler update
In the web handler that renders the customer detail page, load notification prefs and recent log:
```go
func (am *AlertManager) Refresh(healthReport *HealthReport, cfg *config.Config, backupMgr *backup.Manager) {
var alerts []Alert
// From health check issues
for _, issue := range healthReport.Issues {
alerts = append(alerts, Alert{
ID: "health-" + hash(issue), Level: "error",
Message: issue, Link: "/monitoring", LinkText: "Rendszermonitor",
})
}
// From health check warnings
for _, w := range healthReport.Warnings {
alerts = append(alerts, Alert{
ID: "health-" + hash(w), Level: "warning",
Message: w, Link: "/monitoring", LinkText: "Rendszermonitor",
})
}
// Missing ping UUIDs
missingCount := countMissingPings(cfg)
if missingCount > 0 {
alerts = append(alerts, Alert{
ID: "pings-missing", Level: "warning",
Message: fmt.Sprintf("%d monitoring ellenőrzés nincs beállítva", missingCount),
Link: "/monitoring", LinkText: "Rendszermonitor",
})
}
// Backup disabled
if !cfg.Backup.Enabled {
alerts = append(alerts, Alert{
ID: "backup-disabled", Level: "warning",
Message: "A biztonsági mentés nincs bekapcsolva",
Link: "/settings", LinkText: "Beállítások",
})
}
am.mu.Lock()
am.alerts = alerts
am.mu.Unlock()
}
notifPrefs, _ := s.store.GetNotificationPrefs(customerID)
recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10)
// Pass both to template data
```
### 2.4 Template integration
### 3.4 Customer detail template addition
In `layout_start` template (or a new `alerts` partial), render alerts above the page content:
In `hub/internal/web/templates/customer.html`, add after the Health section:
```html
{{if .Alerts}}
<div class="alerts-container">
{{range .Alerts}}
<div class="alert-banner alert-banner-{{.Level}}">
<span class="alert-icon">{{if eq .Level "error"}}🔴{{else if eq .Level "warning"}}🟡{{else}}️{{end}}</span>
<span class="alert-message">{{.Message}}</span>
{{if .Link}}<a href="{{.Link}}" class="alert-link">{{.LinkText}} →</a>{{end}}
<!-- Notifications -->
<section class="card">
<h2>Notifications</h2>
<div class="info-grid">
<div class="info-item">
<span class="label">Email</span>
<span class="value">{{if .NotifPrefs}}{{if .NotifPrefs.Email}}{{.NotifPrefs.Email}}{{else}}Not set{{end}}{{else}}Not configured{{end}}</span>
</div>
{{if .NotifPrefs}}
<div class="info-item">
<span class="label">Events</span>
<span class="value">{{joinStrings .NotifPrefs.EnabledEvents ", "}}</span>
</div>
{{end}}
</div>
{{if .RecentNotifications}}
<h3>Recent (last 10)</h3>
<table class="history-table">
<thead><tr><th>Time</th><th>Event</th><th>Status</th><th>Message</th></tr></thead>
<tbody>
{{range .RecentNotifications}}
<tr>
<td>{{.CreatedAt.Format "Jan 02 15:04"}}</td>
<td>{{.EventType}}</td>
<td><span class="status-badge status-badge-{{.Status}}">{{.Status}}</span></td>
<td>{{.Message}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
{{end}}
</section>
```
**Key decisions:**
- Alerts are NOT dismissible (they reflect real state — they disappear when the issue is resolved)
- Maximum 5 alerts shown, with "+N more" indicator if overflow
- On the monitoring page, skip the "pings-missing" alert since the detailed table is already visible
- Error alerts (red) above warning alerts (yellow)
### 2.5 Passing alerts to templates
Every page handler already passes template data via a struct. Add an `Alerts []Alert` field to each page's data struct (or use a shared base struct). The alert manager is available via the web server struct.
```go
// In each handler:
data.Alerts = s.alertManager.GetAlerts()
```
### 2.6 CSS
```css
.alerts-container { margin-bottom: 1rem; }
.alert-banner {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.alert-banner-error { background: rgba(248, 113, 113, 0.1); border: 1px solid rgba(248, 113, 113, 0.3); color: #f87171; }
.alert-banner-warning { background: rgba(250, 204, 21, 0.1); border: 1px solid rgba(250, 204, 21, 0.3); color: #facc15; }
.alert-banner-info { background: rgba(96, 165, 250, 0.1); border: 1px solid rgba(96, 165, 250, 0.3); color: #60a5fa; }
.alert-link { margin-left: auto; white-space: nowrap; }
```
---
## 3. Notification System
### 3.1 Architecture
```
Customer Node k3s Cluster
┌──────────────────────┐ ┌──────────────────────────────┐
│ felhom-controller │ HTTP POST │ notification-relay │
│ │ ─────────────────>│ (notify.felhom.eu) │
│ Event detected: │ {customer_id, │ │
│ - disk_warning │ event_type, │ 1. Validate API key │
│ - backup_failed │ message, │ 2. Format email │
│ - ... │ severity} │ 3. Send via Resend API │
│ │ │ 4. Return 200/4xx/5xx │
└──────────────────────┘ └──────────────────────────────┘
│ Resend API
┌──────────────┐
│ Customer │
│ email inbox │
└──────────────┘
```
**Why a relay?**
- Resend API key stays on trusted infrastructure (k3s), never on customer hardware
- Central rate limiting and logging of all notifications
- Operator visibility into what notifications were sent
- Customer controllers only need hub URL + API key (already have these for hub reporting)
### 3.2 Notification Relay Service (k3s side)
**Repo:** `felhom.eu` — new directory `notification-relay/` alongside `hub/`
This is a small Go service, similar to `contact-mailer`. Deploy on k3s at `notify.felhom.eu` (or as a path under hub, e.g., `hub.felhom.eu/api/v1/notify`).
**Option A: Standalone service at notify.felhom.eu**
- Separate deployment, its own ingress
- Clean separation of concerns
- More k3s resources
**Option B: Add notify endpoint to the existing hub**
- Hub already runs, has API key auth, knows customer IDs
- Just add a `POST /api/v1/notify` endpoint
- Reuse hub's Resend integration
- Less infrastructure
**Recommendation: Option B** — Add to the hub. The hub already authenticates customers by API key and has all the context needed. Adding a `/api/v1/notify` endpoint is minimal work.
#### Hub notify endpoint
```
POST /api/v1/notify
Authorization: Bearer <customer_api_key>
Content-Type: application/json
{
"customer_id": "demo-felhom",
"event_type": "disk_warning",
"severity": "warning", // "info", "warning", "critical"
"message": "SSD disk usage: 85%",
"details": "Threshold: 80%" // optional
}
```
**Hub processing:**
1. Validate API key (same auth as report push)
2. Look up customer notification preferences (stored in hub's SQLite)
3. If customer has email configured AND event_type is in their enabled events:
- Format email (Hungarian template)
- Send via Resend API (direct HTTP call, same pattern as contact-mailer)
4. Log the notification attempt and result
5. Return 200 (accepted), 400 (bad request), 401 (unauthorized)
**Hub config additions** (hub.yaml secret):
```yaml
RESEND_API_KEY: "re_XZZenCJs..." # Same key as healthchecks/contact-mailer
FROM_EMAIL: "monitoring@felhom.eu"
```
#### Customer notification config (hub-side storage)
The hub stores per-customer notification preferences in its SQLite DB:
```sql
CREATE TABLE customer_notifications (
customer_id TEXT PRIMARY KEY,
email TEXT NOT NULL DEFAULT '', -- customer email address
enabled_events TEXT NOT NULL DEFAULT '[]', -- JSON array of event types
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
**How the customer's email and preferences get there:**
- **Phase 2a (this task):** Operator sets them manually via hub dashboard or SQLite
- **Phase 2b (future):** Controller pushes notification preferences to hub along with reports, hub saves them
For now (2a), the operator configures each customer's email in the hub after setup. This avoids needing the controller to push preferences to the hub yet.
### 3.3 Controller-side: Notification Trigger
Add `internal/notify/notifier.go`:
```go
type Notifier struct {
hubURL string
apiKey string
httpClient *http.Client
logger *log.Logger
enabled bool
prefs *settings.NotificationPrefs // local preferences
}
// Notify sends a notification event to the hub relay.
// Non-blocking: fires and forgets (logs errors but doesn't retry aggressively).
func (n *Notifier) Notify(eventType, severity, message, details string) {
if !n.enabled { return }
if !n.prefs.IsEventEnabled(eventType) { return }
// POST to hub
payload := NotifyRequest{
CustomerID: n.customerID,
EventType: eventType,
Severity: severity,
Message: message,
Details: details,
}
// ... HTTP POST to hubURL + "/api/v1/notify"
}
```
**Integration points** — trigger notifications from:
1. `monitor/healthcheck.go` — after RunHealthCheck, if status changed from ok to warn/fail
2. `backup/backup.go` — after backup failure
3. `backup/dbdump.go` — after DB dump failure
4. `scheduler/scheduler.go` — after integrity check failure
**Important: Don't spam.** Track last notification time per event type. Don't re-notify for the same ongoing issue within 6 hours (configurable). This is handled locally with an in-memory map.
### 3.4 Notification Preferences (settings.json + settings page)
Expand the `NotificationPrefs` struct:
```go
type NotificationPrefs struct {
// Customer email for notifications (sent to hub, hub delivers via Resend)
Email string `json:"email,omitempty"`
// Which events to be notified about
EnabledEvents []string `json:"enabled_events,omitempty"`
// Notification cooldown in hours (don't re-send for same issue within this period)
CooldownHours int `json:"cooldown_hours,omitempty"` // default: 6
}
// Default events if not configured
var DefaultCustomerEvents = []string{
"disk_warning",
"backup_failed",
"update_available",
}
```
### 3.5 Settings page: Notification Preferences UI
Add a **third section** to the existing settings page (below "Jelszó módosítás"):
**Section C: "Értesítések" (Notifications)**
Only shown if hub is enabled (`hub.enabled = true`). If hub is disabled, show info message:
"Az értesítések a központi rendszeren keresztül működnek, ami jelenleg nincs bekapcsolva."
```
┌─────────────────────────────────────────────────────────┐
│ Értesítések │
│ │
│ E-mail cím: [________________________] (text input) │
│ │
│ Az alábbi eseményekről kapjon értesítést: │
│ │
│ [x] Lemez figyelmeztetés (80%+) │
│ [x] Biztonsági mentés sikertelen │
│ [x] Frissítés elérhető │
│ [ ] Biztonsági frissítés │
│ │
│ Értesítési szünet: [6] óra │
│ (Azonos probléma esetén ennyi ideig nem küld újat) │
│ │
│ [Mentés] [Teszt email küldése]│
└─────────────────────────────────────────────────────────┘
```
**Route:**
| Method | Path | Auth? | Handler |
|--------|------|-------|---------|
| POST | `/settings/notifications` | Yes | Save notification preferences |
| POST | `/settings/notifications/test` | Yes | Send test notification via hub relay |
**POST `/settings/notifications` handler:**
1. Parse form: email, enabled_events (checkbox list), cooldown_hours
2. Validate email format (basic regex, allow empty = disable)
3. Save to `settings.json``notifications`
4. Show success flash: "Értesítési beállítások mentve."
**POST `/settings/notifications/test` handler:**
1. Read current notification preferences from settings
2. Send a test notification via the hub relay:
```json
{
"customer_id": "demo-felhom",
"event_type": "test",
"severity": "info",
"message": "Teszt értesítés a Felhom rendszerből",
"details": "Ha ezt az emailt megkapta, az értesítések megfelelően működnek."
}
```
3. Show result: "Teszt email elküldve." or error message
### 3.6 Event Types Reference
| Event type | Severity | Trigger | Hungarian label |
|---|---|---|---|
| `disk_warning` | warning | Disk usage >= warn threshold | Lemez figyelmeztetés |
| `disk_critical` | critical | Disk usage >= crit threshold | Lemez kritikus |
| `backup_failed` | critical | Restic snapshot failed | Biztonsági mentés sikertelen |
| `db_dump_failed` | critical | DB dump failed | Adatbázis mentés sikertelen |
| `update_available` | info | New controller version available | Frissítés elérhető |
| `security_update` | warning | Security update available | Biztonsági frissítés |
| `container_unhealthy` | warning | Protected container not running | Alkalmazás leállt |
| `integrity_failed` | warning | Weekly restic check failed | Mentés integritás hiba |
| `test` | info | Manual test from settings page | Teszt |
Add `joinStrings` template function if not already available.
---
## 4. Implementation Order
### Step 1: Monitoring page — ping status section
- Add `PingStatusItem` struct and builder in monitoring handler
- Add "Távoli monitoring" section to `monitoring.html`
- Add CSS for ping status rows and banner
- **Test:** Check monitoring page shows ✅/⚠️ for each ping UUID
### Step 1: Hub — Add preferences endpoint
- Add `POST /api/v1/preferences` route + handler in `handler.go`
- No store changes needed (`SaveNotificationPrefs` already exists)
- Build hub 0.1.5, deploy to k3s
- **Test:** `curl -X POST https://hub.felhom.eu/api/v1/preferences -H "Authorization: Bearer <key>" -H "Content-Type: application/json" -d '{"customer_id":"demo-felhom","email":"nagyfenyvesi.viktor@gmail.com","enabled_events":["disk_warning","backup_failed","update_available"]}'` → check hub logs + SQLite
### Step 2: Alert manager + dashboard banners
- Create `internal/web/alerts.go` with AlertManager
- Wire AlertManager into health check cycle
- Add alert rendering to `layout_start` template
- Add CSS for alert banners
- **Test:** Set a ping UUID to empty → warning banner appears on all pages. Fix it → banner disappears.
### Step 2: Controller — Sync on settings save
- Add `SyncPreferences` method to `notifier.go`
- Add `IsEnabled()` method to `notifier.go` if missing
- Call from notification settings POST handler in `handlers.go`
- Update flash messages for success/warning/error
- Build controller 0.7.2, deploy
- **Test:** Save notification settings on controller → hub logs "Notification preferences updated" → press "Teszt email küldése" → email arrives in inbox
### Step 3: Hub notification endpoint (felhom.eu repo)
- Add Resend API key to hub's k8s secret
- Add `POST /api/v1/notify` endpoint to hub
- Add `customer_notifications` table to hub's SQLite
- Add email sending via Resend HTTP API (not SMTP — direct API call)
- Add hub admin page or CLI to set customer email/preferences
- **Test:** `curl -X POST hub.felhom.eu/api/v1/notify -H "Authorization: Bearer ..." -d '{"customer_id":"demo-felhom","event_type":"test","severity":"info","message":"Test"}'` → email arrives
### Step 3: Controller — Sync on startup
- Add startup sync in `main.go`
- Rebuild + deploy
- **Test:** Restart controller → hub logs show preference sync on startup
### Step 4: Controller-side notifier
- Create `internal/notify/notifier.go`
- Wire into health check, backup, and DB dump flows
- Add cooldown tracking (in-memory map, not persisted)
- **Test:** Trigger a disk warning → notification sent to hub → email arrives
### Step 5: Notification preferences UI
- Expand `NotificationPrefs` struct in settings.go
- Add "Értesítések" section to settings.html
- Add POST handlers for save and test
- Push email preference to hub when saving (optional — can be deferred)
- **Test:** Set email → save → test → email arrives → change events → save → verify filtering works
### Step 6: Cleanup & version bump
- Update CONTEXT.md
- Bump controller version to 0.7.1
- Bump hub version accordingly
- Build + deploy both → verify on demo-felhom.eu
### Step 4: Hub — Display notification info on customer page
- Add `GetRecentNotifications` to store
- Load notification prefs + recent log in customer detail handler
- Update `customer.html` template
- Add `joinStrings` template function
- Rebuild + deploy hub
- **Test:** Open hub customer detail page → see email, events, notification log
---
## 5. Files to Create / Modify
### Controller repo (`deploy-felhom-compose`):
### Hub (`felhom.eu`):
- `hub/internal/api/handler.go` — Add `handleSavePreferences` handler + route
- `hub/internal/store/store.go` — Add `GetRecentNotifications` method + `NotificationLogEntry` struct
- `hub/internal/web/server.go` — Load notification prefs + log in customer detail handler, add `joinStrings` template func
- `hub/internal/web/templates/customer.html` — Add notification section
**New files:**
- `controller/internal/web/alerts.go` — AlertManager
- `controller/internal/notify/notifier.go` — Hub notification client
**Modified files:**
- `controller/internal/web/server.go` — Add AlertManager, wire into handlers, pass alerts to all templates
- `controller/internal/web/handlers.go` — Monitoring handler: add ping status data. Settings handler: add notification preferences section + POST handlers.
- `controller/internal/web/templates/monitoring.html` — Add "Távoli monitoring" section
- `controller/internal/web/templates/settings.html` — Add "Értesítések" section
- `controller/internal/web/templates/layout.html` — Add alert banner rendering
- `controller/internal/web/templates/style.css` — New styles for alerts and ping status
- `controller/internal/settings/settings.go` — Expand NotificationPrefs struct
- `controller/internal/monitor/healthcheck.go` — After health check, update AlertManager + trigger notifications
- `controller/internal/backup/backup.go` — Trigger notification on backup failure
- `controller/internal/backup/dbdump.go` — Trigger notification on dump failure
- `controller/cmd/controller/main.go` — Initialize Notifier, AlertManager, wire dependencies
### Hub repo (`felhom.eu`):
**Modified files:**
- `hub/internal/api/server.go` (or new `notify.go`) — Add `POST /api/v1/notify` endpoint
- `hub/internal/store/store.go` — Add `customer_notifications` table + queries
- `hub/cmd/hub/main.go` — Add Resend API key config
- `manifests/hub.yaml` — Add `RESEND_API_KEY` to hub secret
### Controller (`deploy-felhom-compose`):
- `controller/internal/notify/notifier.go` — Add `SyncPreferences` method, `preferencesRequest` struct, `IsEnabled()` method
- `controller/internal/web/handlers.go`Call `SyncPreferences` after saving notification settings, update flash messages
- `controller/cmd/controller/main.go` — Add startup preferences sync
---
## 6. Design Decisions & Notes
## 6. Notes
### Why add notify to the hub instead of a new service?
- Hub already authenticates customers, has SQLite, knows customer IDs
- Adding one endpoint is simpler than deploying+maintaining a separate service
- Shared Resend API key, shared k8s secret
- One less DNS record, ingress, deployment to manage
### Deploy order matters
Deploy hub first — the controller needs the `/api/v1/preferences` endpoint to exist. If controller is deployed first, the sync will fail gracefully (warning logged, local save still works). Next save attempt after hub is deployed will succeed.
### Customer email configuration flow (Phase 2a — operator-managed)
For now, the operator sets each customer's email via direct SQLite or a simple hub admin endpoint. The customer can see and change their email in the controller's settings page, but actually syncing this to the hub is deferred — the controller just stores it locally. The operator manually ensures the hub has the right email.
### Hub DB rebuild recovery
The startup sync ensures that if the hub's SQLite is lost (hub redeployment, PVC wipe), the next controller restart will re-populate preferences. This makes the system self-healing.
This is acceptable for the initial small customer base. Phase 2b (future) will add automatic preference sync via the report push.
### Resend API key
The hub already has the Resend API key configured — the "DOWN | TEST" email to admin@felhom.eu (visible in Resend dashboard, 11 days ago) proves this. If the key is missing in hub.yaml, the notify handler already logs "Resend API key not configured." That's a config issue, not a code issue.
### Notification cooldown
The controller tracks in-memory when each event type was last notified. If the same event type fires again within the cooldown period (default 6 hours), the notification is suppressed. This prevents email spam during prolonged issues (e.g., disk stays at 85% for days).
The cooldown resets on controller restart, which is fine — restarting the controller during an active issue should re-trigger a notification.
### Dashboard alerts are state-based, not event-based
Alerts reflect current system state. They're regenerated every 5 minutes from the latest health check. When the issue resolves, the alert disappears. No persistence needed — alerts live in memory only.
### Resend API usage from hub
Use Resend's HTTP API directly (POST to `https://api.resend.com/emails`) rather than SMTP. This avoids SMTP connection management complexity and is more idiomatic for a Go service. The contact-mailer already demonstrates this pattern.
```go
// Example Resend API call
req, _ := http.NewRequest("POST", "https://api.resend.com/emails", bytes.NewReader(payload))
req.Header.Set("Authorization", "Bearer " + resendAPIKey)
req.Header.Set("Content-Type", "application/json")
```
### Email template
Notifications should be simple, text-focused emails in Hungarian:
```
Tárgy: [Felhom] Figyelmeztetés: SSD lemez használat 85%
Kedves Ügyfél!
A Felhom rendszered a következő figyelmeztetést jelezte:
SSD lemez használat: 85% (küszöb: 80%)
Részletek:
- Szerver: demo-felhom.eu
- Időpont: 2026-02-16 14:30
- Szint: Figyelmeztetés
Ha kérdésed van, vedd fel a kapcsolatot az üzemeltetővel.
Üdvözlettel,
Felhom.eu monitoring
```
### Monitoring page vs Settings page — what goes where
- **Rendszermonitor** shows: live ping status table (read-only), system metrics, alerts related to monitoring
- **Beállítások** shows: notification email + event preferences (editable), test button
- No overlap — monitoring shows status, settings allows configuration
### Hub dashboard expansion (future, out of scope)
The notification section on the customer detail page is read-only. Future hub features like editing notification preferences from the hub side, customer management, and controller.yaml generation are out of scope but this task lays the data model and API groundwork.