From 11428659d11cc9653770b328b4edeef435482954 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Fri, 20 Feb 2026 19:26:53 +0100 Subject: [PATCH] hub v0.3.1: Config diff display + pull config Replace broken SHA256 hash comparison with value-based YAML comparison. Add "Show Diff" button showing per-key differences in a color-coded table. Add "Pull Config" to import controller's current config into the Hub. New endpoints: GET /customers/{id}/config-diff, POST /customers/{id}/pull-config. Co-Authored-By: Claude Opus 4.6 --- hub/CHANGELOG.md | 10 + hub/README.md | 4 +- hub/internal/web/configs.go | 414 ++++++++++++++++-- hub/internal/web/server.go | 16 + .../web/templates/customer_unified.html | 86 +++- hub/internal/web/templates/style.css | 5 + 6 files changed, 501 insertions(+), 34 deletions(-) diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index b8e25ed..ddc6844 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,15 @@ # Felhom Hub — Changelog +## v0.3.1 (2026-02-20) + +**Config Diff Display + Pull Config** + +- **Value-based config comparison**: Replaced broken SHA256 hash comparison with semantic YAML comparison. Both configs are parsed into maps, flattened to dot-notation keys, and compared by value. Ignores key ordering, whitespace, comments, and volatile fields (`web.session_secret`). Shows actual diff count on customer page ("⚠ Config mismatch — N differences"). +- **Config diff endpoint** (`GET /customers/{id}/config-diff`): Fetches live YAML from controller via new `GET /api/config` endpoint, generates Hub YAML via `configgen.Generate()`, returns JSON with per-key diffs (key, hub value, controller value, status). Sensitive values (tokens, passwords, secrets) are masked. +- **Pull Config** (`POST /customers/{id}/pull-config`): Reverse of Push Config — imports controller's current config into the Hub. Extracts identity fields (name, domain, email) and override fields (infrastructure tokens, git credentials, monitoring UUIDs). Preserves existing APIKey and RetrievalPassword. +- **Diff display UI**: "Show Diff" button on customer page expands a table showing all key-value differences with color-coded rows (yellow=changed, blue=hub-only, orange=controller-only). +- **Pull Config button**: Added next to existing "Push Config" with confirmation dialog. + ## v0.3.0 (2026-02-20) **Hub Monitoring Takeover — Event System, Dead Man's Switch, Notifications** diff --git a/hub/README.md b/hub/README.md index 4b50f1a..006cbfd 100644 --- a/hub/README.md +++ b/hub/README.md @@ -4,7 +4,7 @@ A lightweight Go service that receives periodic reports and structured events from felhom-controller instances, stores them in SQLite, and provides a web dashboard for fleet monitoring. Also serves as the infrastructure backup store for disaster recovery, event-based dead man's switch monitoring, and notification dispatch. -**Current version: v0.3.0** +**Current version: v0.3.1** --- @@ -139,6 +139,8 @@ Protected by bcrypt password + session cookie (7-day expiry). |--------|-------------| | **Block/Unblock** | Toggle blocked status — blocked customers are hidden from dashboard and notifications are suppressed, but reports are still accepted and stored | | **Push Config** | Generate YAML from Hub config and POST it to the controller's `/api/config/apply` endpoint (requires controller URL from reports) | +| **Pull Config** | Import controller's current config into Hub — fetches live YAML via `GET /api/config`, extracts identity and override fields, updates Hub's stored config | +| **Show Diff** | Compare Hub-generated config with controller's live config — shows per-key differences in a color-coded table (value-based comparison, ignores key ordering and volatile fields) | | **Create Config** | Auto-create a managed config from a manual customer's report data, then redirect to edit form | | **Trigger Update** | Instruct controller to self-update to the latest version | | **Delete** | Remove customer config (customer reappears as manual if reports continue) | diff --git a/hub/internal/web/configs.go b/hub/internal/web/configs.go index f9b5f58..a78496c 100644 --- a/hub/internal/web/configs.go +++ b/hub/internal/web/configs.go @@ -1,8 +1,7 @@ package web import ( - "crypto/sha256" - "encoding/hex" + "encoding/base64" "encoding/json" "fmt" "io" @@ -14,6 +13,7 @@ import ( "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" + "gopkg.in/yaml.v3" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) @@ -182,31 +182,29 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c } } - // Config hash comparison - var controllerConfigHash string - var hubConfigHash string + // Config value comparison (parse both YAMLs, compare actual values) var configSyncStatus string // "in_sync", "mismatch", "unknown" - if customer != nil && cfg != nil { - var rptHash struct { - ConfigHash string `json:"config_hash"` - } - json.Unmarshal([]byte(customer.ReportJSON), &rptHash) - controllerConfigHash = rptHash.ConfigHash - - if controllerConfigHash != "" { - // Generate Hub-side YAML and compute its hash - templateYAML := defaultControllerTemplate - if s.templateFetcher != nil { - templateYAML = s.templateFetcher.Template() - } - if yamlOutput, err := configgen.Generate(templateYAML, cfg); err == nil { - h := sha256.Sum256([]byte(yamlOutput)) - hubConfigHash = hex.EncodeToString(h[:]) - if hubConfigHash == controllerConfigHash { - configSyncStatus = "in_sync" - } else { - configSyncStatus = "mismatch" + var configDiffCount int + if cfg != nil { + infraData, _ := s.store.GetInfraBackup(customerID) + if infraData != nil { + controllerYAML := extractControllerYAML(infraData) + if controllerYAML != "" { + templateYAML := defaultControllerTemplate + if s.templateFetcher != nil { + templateYAML = s.templateFetcher.Template() } + if hubYAML, err := configgen.Generate(templateYAML, cfg); err == nil { + diffs := compareYAMLValues(hubYAML, controllerYAML) + configDiffCount = len(diffs) + if configDiffCount == 0 { + configSyncStatus = "in_sync" + } else { + configSyncStatus = "mismatch" + } + } + } else { + configSyncStatus = "unknown" } } else { configSyncStatus = "unknown" @@ -264,9 +262,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c UpdateAvailable bool ControllerURL string - ConfigSyncStatus string // "in_sync", "mismatch", "unknown" - ControllerConfigHash string - HubConfigHash string + ConfigSyncStatus string // "in_sync", "mismatch", "unknown" + ConfigDiffCount int InfraBackup *store.InfraBackupMeta InfraBackupAge string @@ -301,9 +298,8 @@ func (s *Server) handleCustomerUnified(w http.ResponseWriter, r *http.Request, c UpdateAvailable: updateAvailable, ControllerURL: controllerURL, - ConfigSyncStatus: configSyncStatus, - ControllerConfigHash: controllerConfigHash, - HubConfigHash: hubConfigHash, + ConfigSyncStatus: configSyncStatus, + ConfigDiffCount: configDiffCount, InfraBackup: infraMeta, InfraBackupAge: infraBackupAge, @@ -724,3 +720,359 @@ func buildConfigJSON(r *http.Request) string { data, _ := json.Marshal(overrides) return string(data) } + +// --- Config comparison helpers --- + +// extractControllerYAML decodes the controller.yaml from an infra backup JSON payload. +func extractControllerYAML(infraData []byte) string { + var parsed struct { + ControllerConfigB64 string `json:"controller_config_b64"` + } + if err := json.Unmarshal(infraData, &parsed); err != nil || parsed.ControllerConfigB64 == "" { + return "" + } + data, err := base64.StdEncoding.DecodeString(parsed.ControllerConfigB64) + if err != nil { + return "" + } + return string(data) +} + +// volatileKeys are YAML keys ignored during config comparison (always differ). +var volatileKeys = map[string]bool{ + "web.session_secret": true, +} + +// sensitiveKeyParts are substrings that indicate a value should be masked in diff output. +var sensitiveKeyParts = []string{"token", "password", "secret", "api_key"} + +// flattenYAML recursively flattens a nested map into dot-separated key-value pairs. +func flattenYAML(m map[string]interface{}, prefix string) map[string]string { + result := make(map[string]string) + for k, v := range m { + key := k + if prefix != "" { + key = prefix + "." + k + } + switch val := v.(type) { + case map[string]interface{}: + for fk, fv := range flattenYAML(val, key) { + result[fk] = fv + } + case []interface{}: + for i, item := range val { + itemKey := fmt.Sprintf("%s.%d", key, i) + if sub, ok := item.(map[string]interface{}); ok { + for fk, fv := range flattenYAML(sub, itemKey) { + result[fk] = fv + } + } else { + result[itemKey] = fmt.Sprintf("%v", item) + } + } + default: + result[key] = fmt.Sprintf("%v", v) + } + } + return result +} + +// configDiff represents a single key-value difference between two configs. +type configDiff struct { + Key string `json:"key"` + HubValue string `json:"hub"` + CtrlValue string `json:"controller"` + Status string `json:"status"` // "changed", "hub_only", "controller_only" +} + +// compareYAMLValues parses two YAML strings and returns their value differences. +// Volatile keys (e.g., web.session_secret) are excluded. +func compareYAMLValues(hubYAML, controllerYAML string) []configDiff { + var hubMap, ctrlMap map[string]interface{} + yaml.Unmarshal([]byte(hubYAML), &hubMap) + yaml.Unmarshal([]byte(controllerYAML), &ctrlMap) + + hubFlat := flattenYAML(hubMap, "") + ctrlFlat := flattenYAML(ctrlMap, "") + + var diffs []configDiff + + // Keys in hub but different/missing in controller + for k, hv := range hubFlat { + if volatileKeys[k] { + continue + } + cv, exists := ctrlFlat[k] + if !exists { + if hv != "" && hv != "" { + diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: "(not set)", Status: "hub_only"}) + } + } else if hv != cv { + diffs = append(diffs, configDiff{Key: k, HubValue: hv, CtrlValue: cv, Status: "changed"}) + } + } + + // Keys in controller but missing in hub + for k, cv := range ctrlFlat { + if volatileKeys[k] { + continue + } + if _, exists := hubFlat[k]; !exists { + if cv != "" && cv != "" { + diffs = append(diffs, configDiff{Key: k, HubValue: "(not set)", CtrlValue: cv, Status: "controller_only"}) + } + } + } + + sort.Slice(diffs, func(i, j int) bool { return diffs[i].Key < diffs[j].Key }) + return diffs +} + +// maskSensitive masks a value if the key contains sensitive substrings. +func maskSensitive(key, value string) string { + if value == "" || value == "(not set)" { + return value + } + keyLower := strings.ToLower(key) + for _, part := range sensitiveKeyParts { + if strings.Contains(keyLower, part) { + if len(value) > 8 { + return "***" + value[len(value)-4:] + } + return "***" + } + } + return value +} + +// handleConfigDiff returns a JSON diff between Hub-generated and controller's live config. +func (s *Server) handleConfigDiff(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) + return + } + + // Get controller URL + customer, _ := s.store.GetCustomer(customerID) + controllerURL := "" + if customer != nil { + controllerURL = customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + } + if controllerURL == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) + return + } + + // Fetch live config from controller + fetchURL := controllerURL + "/api/config" + req, err := http.NewRequest("GET", fetchURL, nil) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) + return + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) + return + } + + controllerYAML, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) + return + } + + // Generate Hub YAML + templateYAML := defaultControllerTemplate + if s.templateFetcher != nil { + templateYAML = s.templateFetcher.Template() + } + hubYAML, err := configgen.Generate(templateYAML, cfg) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to generate Hub config"}) + return + } + + // Compare + diffs := compareYAMLValues(hubYAML, string(controllerYAML)) + + // Mask sensitive values + for i := range diffs { + diffs[i].HubValue = maskSensitive(diffs[i].Key, diffs[i].HubValue) + diffs[i].CtrlValue = maskSensitive(diffs[i].Key, diffs[i].CtrlValue) + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "ok": true, + "in_sync": len(diffs) == 0, + "diff_count": len(diffs), + "diffs": diffs, + }) +} + +// handlePullConfig fetches the controller's live config and imports it into the Hub. +func (s *Server) handlePullConfig(w http.ResponseWriter, r *http.Request, customerID string) { + cfg, err := s.store.GetCustomerConfig(customerID) + if err != nil || cfg == nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "No config found"}) + return + } + + // Get controller URL + customer, _ := s.store.GetCustomer(customerID) + controllerURL := "" + if customer != nil { + controllerURL = customer.ControllerURL + if controllerURL == "" { + var rpt struct { + ControllerURL string `json:"controller_url"` + } + json.Unmarshal([]byte(customer.ReportJSON), &rpt) + controllerURL = rpt.ControllerURL + } + } + if controllerURL == "" { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Controller URL not available"}) + return + } + + // Fetch live config from controller + fetchURL := controllerURL + "/api/config" + req, err := http.NewRequest("GET", fetchURL, nil) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to create request"}) + return + } + req.Header.Set("Authorization", "Bearer "+s.apiKey) + + client := &http.Client{Timeout: 15 * time.Second} + resp, err := client.Do(req) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller unreachable: %v", err)}) + return + } + defer resp.Body.Close() + + if resp.StatusCode != 200 { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": fmt.Sprintf("Controller returned HTTP %d", resp.StatusCode)}) + return + } + + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to read controller response"}) + return + } + + // Parse controller's YAML + var parsed map[string]interface{} + if err := yaml.Unmarshal(body, &parsed); err != nil { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to parse controller YAML"}) + return + } + + // Extract identity fields + if customer, ok := parsed["customer"].(map[string]interface{}); ok { + if v, ok := customer["name"].(string); ok && v != "" { + cfg.CustomerName = v + } + if v, ok := customer["domain"].(string); ok && v != "" { + cfg.Domain = v + } + if v, ok := customer["email"].(string); ok && v != "" { + cfg.Email = v + } + } + + // Build config_json from override fields + overrides := make(map[string]interface{}) + + // Infrastructure tokens + if infra, ok := parsed["infrastructure"].(map[string]interface{}); ok { + infraOverrides := make(map[string]interface{}) + if v, ok := infra["cf_tunnel_token"].(string); ok && v != "" { + infraOverrides["cf_tunnel_token"] = v + } + if v, ok := infra["cf_api_token"].(string); ok && v != "" { + infraOverrides["cf_api_token"] = v + } + if len(infraOverrides) > 0 { + overrides["infrastructure"] = infraOverrides + } + } + + // Git credentials + if git, ok := parsed["git"].(map[string]interface{}); ok { + gitOverrides := make(map[string]interface{}) + if v, ok := git["username"].(string); ok && v != "" { + gitOverrides["username"] = v + } + if v, ok := git["token"].(string); ok && v != "" { + gitOverrides["token"] = v + } + if len(gitOverrides) > 0 { + overrides["git"] = gitOverrides + } + } + + // Monitoring UUIDs (legacy but still imported) + if monitoring, ok := parsed["monitoring"].(map[string]interface{}); ok { + if uuids, ok := monitoring["ping_uuids"].(map[string]interface{}); ok { + uuidOverrides := make(map[string]interface{}) + for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} { + if v, ok := uuids[key].(string); ok && v != "" { + uuidOverrides[key] = v + } + } + if len(uuidOverrides) > 0 { + overrides["monitoring"] = map[string]interface{}{"ping_uuids": uuidOverrides} + } + } + } + + configJSON, _ := json.Marshal(overrides) + cfg.ConfigJSON = string(configJSON) + + if err := s.store.SaveCustomerConfig(cfg); err != nil { + s.logger.Printf("[ERROR] Pull config: failed to update config for %s: %v", customerID, err) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": false, "error": "Failed to save config"}) + return + } + + s.logger.Printf("[INFO] Config pulled from controller for %s", customerID) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"ok": true, "message": "Config imported from controller"}) +} diff --git a/hub/internal/web/server.go b/hub/internal/web/server.go index cd65a73..62692a5 100644 --- a/hub/internal/web/server.go +++ b/hub/internal/web/server.go @@ -114,6 +114,22 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { } else { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/pull-config"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/pull-config") + if r.Method == http.MethodPost { + s.handlePullConfig(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } + case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/config-diff"): + customerID := strings.TrimPrefix(path, "/customers/") + customerID = strings.TrimSuffix(customerID, "/config-diff") + if r.Method == http.MethodGet { + s.handleConfigDiff(w, r, customerID) + } else { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + } case strings.HasPrefix(path, "/customers/") && strings.HasSuffix(path, "/create-config"): customerID := strings.TrimPrefix(path, "/customers/") customerID = strings.TrimSuffix(customerID, "/create-config") diff --git a/hub/internal/web/templates/customer_unified.html b/hub/internal/web/templates/customer_unified.html index 485819c..e30a0b6 100644 --- a/hub/internal/web/templates/customer_unified.html +++ b/hub/internal/web/templates/customer_unified.html @@ -375,11 +375,13 @@ Config Sync {{if eq .ConfigSyncStatus "in_sync"}}✓ In sync - {{else if eq .ConfigSyncStatus "mismatch"}}⚠ Config mismatch — Hub config differs from controller - {{else}}Unknown — controller not reporting config hash (update controller) + {{else if eq .ConfigSyncStatus "mismatch"}}⚠ Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}} + + {{else}}Unknown — no infra backup available yet {{end}} + {{end}}
{{if and .ControllerURL .UpdateAvailable}} @@ -395,6 +397,9 @@ + {{end}}
@@ -610,6 +615,83 @@ }); } + function pullConfig(customerID) { + if (!confirm('Import the controller\'s current config into the Hub?\n\nThis updates the Hub\'s stored configuration to match the controller.')) return; + var btn = document.getElementById('btn-pull-config'); + var msg = document.getElementById('action-msg'); + btn.disabled = true; + btn.textContent = 'Pulling...'; + msg.style.display = 'none'; + fetch('/customers/' + customerID + '/pull-config', {method: 'POST'}) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.ok) { + msg.textContent = 'Config imported successfully'; + msg.style.display = 'inline'; + msg.style.color = '#4ade80'; + setTimeout(function() { location.reload(); }, 1500); + } else { + msg.textContent = data.error || 'Failed'; + msg.style.display = 'inline'; + msg.style.color = '#f87171'; + } + btn.disabled = false; + btn.textContent = 'Pull Config'; + }) + .catch(function() { + msg.textContent = 'Connection error'; + msg.style.display = 'inline'; + msg.style.color = '#f87171'; + btn.disabled = false; + btn.textContent = 'Pull Config'; + }); + } + + function showConfigDiff(customerID) { + var container = document.getElementById('config-diff-container'); + if (container.style.display !== 'none') { + container.style.display = 'none'; + return; + } + container.innerHTML = '

Loading diff...

'; + container.style.display = 'block'; + fetch('/customers/' + customerID + '/config-diff') + .then(function(r) { return r.json(); }) + .then(function(data) { + if (!data.ok) { + container.innerHTML = '

' + (data.error || 'Failed to load diff') + '

'; + return; + } + if (data.in_sync) { + container.innerHTML = '

Configs are in sync (no differences found).

'; + return; + } + var html = ''; + html += ''; + data.diffs.forEach(function(d) { + var cls = 'diff-' + d.status; + var statusLabel = d.status === 'changed' ? 'Changed' : d.status === 'hub_only' ? 'Hub only' : 'Controller only'; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + html += ''; + }); + html += '
KeyHub ValueController ValueStatus
' + escHtml(d.key) + '' + escHtml(d.hub) + '' + escHtml(d.controller) + '' + statusLabel + '
'; + container.innerHTML = html; + }) + .catch(function() { + container.innerHTML = '

Failed to fetch diff from controller.

'; + }); + } + + function escHtml(s) { + var div = document.createElement('div'); + div.appendChild(document.createTextNode(s)); + return div.innerHTML; + } + {{if .HasConfig}} // Load YAML preview fetch('/configs/{{.CustomerID}}/preview') diff --git a/hub/internal/web/templates/style.css b/hub/internal/web/templates/style.css index e882852..f345046 100644 --- a/hub/internal/web/templates/style.css +++ b/hub/internal/web/templates/style.css @@ -613,6 +613,11 @@ code { color: #3b82f6; } +/* Config diff table */ +.diff-changed td { color: #f59e0b; } +.diff-hub_only td { color: #3b82f6; } +.diff-controller_only td { color: #fb923c; } + /* Responsive */ @media (max-width: 768px) { .container { padding: 1rem; }