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 <noreply@anthropic.com>
This commit is contained in:
@@ -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**
|
||||
|
||||
+3
-1
@@ -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) |
|
||||
|
||||
+383
-31
@@ -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 != "<nil>" {
|
||||
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 != "<nil>" {
|
||||
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"})
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -375,11 +375,13 @@
|
||||
<span class="label">Config Sync</span>
|
||||
<span class="value">
|
||||
{{if eq .ConfigSyncStatus "in_sync"}}<span style="color: #22c55e;">✓ In sync</span>
|
||||
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">⚠ Config mismatch — Hub config differs from controller</span>
|
||||
{{else}}<span style="color: #94a3b8;">Unknown — controller not reporting config hash (update controller)</span>
|
||||
{{else if eq .ConfigSyncStatus "mismatch"}}<span style="color: #f59e0b;">⚠ Config mismatch — {{.ConfigDiffCount}} difference{{if gt .ConfigDiffCount 1}}s{{end}}</span>
|
||||
<button class="btn btn-outline btn-sm" style="margin-left: 0.5em; font-size: 0.8em;" onclick="showConfigDiff('{{.CustomerID}}')">Show Diff</button>
|
||||
{{else}}<span style="color: #94a3b8;">Unknown — no infra backup available yet</span>
|
||||
{{end}}
|
||||
</span>
|
||||
</div>
|
||||
<div id="config-diff-container" style="display: none; margin-top: 0.5rem;"></div>
|
||||
{{end}}
|
||||
<div style="margin-top: 0.75em; display: flex; gap: 0.5rem; flex-wrap: wrap;">
|
||||
{{if and .ControllerURL .UpdateAvailable}}
|
||||
@@ -395,6 +397,9 @@
|
||||
<button class="btn btn-outline btn-sm" id="btn-push-config" onclick="pushConfig('{{.CustomerID}}')">
|
||||
Push Config
|
||||
</button>
|
||||
<button class="btn btn-outline btn-sm" id="btn-pull-config" onclick="pullConfig('{{.CustomerID}}')">
|
||||
Pull Config
|
||||
</button>
|
||||
{{end}}
|
||||
<span id="action-msg" style="margin-left: 0.5em; display: none;"></span>
|
||||
</div>
|
||||
@@ -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 = '<p class="text-muted">Loading diff...</p>';
|
||||
container.style.display = 'block';
|
||||
fetch('/customers/' + customerID + '/config-diff')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.ok) {
|
||||
container.innerHTML = '<p style="color: #f87171;">' + (data.error || 'Failed to load diff') + '</p>';
|
||||
return;
|
||||
}
|
||||
if (data.in_sync) {
|
||||
container.innerHTML = '<p style="color: #22c55e;">Configs are in sync (no differences found).</p>';
|
||||
return;
|
||||
}
|
||||
var html = '<table class="data-table" style="font-size: 0.85em;">';
|
||||
html += '<thead><tr><th>Key</th><th>Hub Value</th><th>Controller Value</th><th>Status</th></tr></thead><tbody>';
|
||||
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 += '<tr class="' + cls + '">';
|
||||
html += '<td style="font-family: monospace; white-space: nowrap;">' + escHtml(d.key) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.hub) + '</td>';
|
||||
html += '<td style="word-break: break-all;">' + escHtml(d.controller) + '</td>';
|
||||
html += '<td>' + statusLabel + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
html += '</tbody></table>';
|
||||
container.innerHTML = html;
|
||||
})
|
||||
.catch(function() {
|
||||
container.innerHTML = '<p style="color: #f87171;">Failed to fetch diff from controller.</p>';
|
||||
});
|
||||
}
|
||||
|
||||
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')
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user