package handlers import ( "database/sql" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "strings" "time" _ "modernc.org/sqlite" ) // ExternalDataHandler aggregates data from quiXzoom, LandveX, ledger, and CRM. type ExternalDataHandler struct { DB *sql.DB AMOSBaseURL string } // UnifiedDashboardResponse is the enriched dashboard with external systems. type UnifiedDashboardResponse struct { // Core system UsersCount int `json:"users_count"` ModulesHealthy int `json:"modules_healthy"` ModulesTotal int `json:"modules_total"` RecentAudit []AuditEntry `json:"recent_audit"` SystemMetrics SystemMetricsData `json:"system_metrics"` // quiXzoom Quixzoom QuixzoomData `json:"quixzoom"` // LandveX Landvex LandvexData `json:"landvex"` // Economy / Ledger Economy EconomyData `json:"economy"` // CRM CRM CRMData `json:"crm"` // Alerts Alerts []Alert `json:"alerts"` } type SystemMetricsData struct { CPU CPUMetrics `json:"cpu"` RAM RAMMetrics `json:"ram"` Disk DiskMetrics `json:"disk"` } type QuixzoomData struct { TotalMissions int `json:"total_missions"` ActiveMissions int `json:"active_missions"` TotalSubmissions int `json:"total_submissions"` Contributors int `json:"contributors"` PendingPayouts int `json:"pending_payouts"` TotalPayoutsSEK int `json:"total_payouts_sek"` } type LandvexData struct { IntelligenceReports int `json:"intelligence_reports"` UrbanDataPoints int `json:"urban_data_points"` ActiveCustomers int `json:"active_customers"` } type EconomyData struct { TotalAccounts int `json:"total_accounts"` TotalVouchers int `json:"total_vouchers"` TrialBalance float64 `json:"trial_balance"` PendingInvoices int `json:"pending_invoices"` CashSEK float64 `json:"cash_sek"` CashEUR float64 `json:"cash_eur"` CashUSD float64 `json:"cash_usd"` } type CRMData struct { TotalCustomers int `json:"total_customers"` ActiveLeads int `json:"active_leads"` DealsWon int `json:"deals_won"` DealsLost int `json:"deals_lost"` PipelineValueSEK int `json:"pipeline_value_sek"` } type Alert struct { Level string `json:"level"` // "critical", "warning", "info" Message string `json:"message"` Deadline time.Time `json:"deadline,omitempty"` AmountSEK float64 `json:"amount_sek,omitempty"` } // UnifiedDashboardHandler returns a comprehensive dashboard with all systems. func UnifiedDashboardHandler(db *sql.DB, amosBaseURL string) http.HandlerFunc { h := &ExternalDataHandler{DB: db, AMOSBaseURL: amosBaseURL} return func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } resp, err := h.buildDashboard(r) if err != nil { // Return partial data with error indication resp = &UnifiedDashboardResponse{ Alerts: []Alert{{ Level: "warning", Message: fmt.Sprintf("Partial data: %v", err), }}, } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(resp) } } func (h *ExternalDataHandler) buildDashboard(r *http.Request) (*UnifiedDashboardResponse, error) { resp := &UnifiedDashboardResponse{} // 1. Core metrics if err := h.loadCoreMetrics(r, resp); err != nil { return resp, fmt.Errorf("core metrics: %w", err) } // 2. quiXzoom data from SQLite if err := h.loadQuixzoomData(resp); err != nil { resp.Alerts = append(resp.Alerts, Alert{ Level: "warning", Message: fmt.Sprintf("quiXzoom data unavailable: %v", err), }) } // 3. LandveX data if err := h.loadLandvexData(resp); err != nil { resp.Alerts = append(resp.Alerts, Alert{ Level: "warning", Message: fmt.Sprintf("LandveX data unavailable: %v", err), }) } // 4. Economy / Ledger if err := h.loadEconomyData(resp); err != nil { resp.Alerts = append(resp.Alerts, Alert{ Level: "warning", Message: fmt.Sprintf("Economy data unavailable: %v", err), }) } // 5. CRM if err := h.loadCRMData(resp); err != nil { resp.Alerts = append(resp.Alerts, Alert{ Level: "warning", Message: fmt.Sprintf("CRM data unavailable: %v", err), }) } // 6. Hardcoded alerts (from MEMORY.md) resp.Alerts = append(resp.Alerts, Alert{ Level: "critical", Message: "Momsdeklaration deadline", Deadline: time.Date(2026, 7, 26, 0, 0, 0, 0, time.UTC), AmountSEK: 442000, }, Alert{ Level: "critical", Message: "Leon kravbrev deadline", Deadline: time.Date(2026, 7, 13, 0, 0, 0, 0, time.UTC), AmountSEK: 197922, }, ) return resp, nil } func (h *ExternalDataHandler) loadCoreMetrics(r *http.Request, resp *UnifiedDashboardResponse) error { var usersCount int if err := h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users`).Scan(&usersCount); err != nil { return err } var modulesTotal, modulesHealthy int if err := h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM modules`).Scan(&modulesTotal); err != nil { return err } if err := h.DB.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM modules WHERE status = 'running'`).Scan(&modulesHealthy); err != nil { return err } rows, err := h.DB.QueryContext(r.Context(), ` SELECT id, user_id, action, resource, ip, timestamp, details FROM audit_logs ORDER BY timestamp DESC LIMIT 5 `) if err != nil { return err } defer rows.Close() audit := make([]AuditEntry, 0, 5) for rows.Next() { var a AuditEntry if err := rows.Scan(&a.ID, &a.UserID, &a.Action, &a.Resource, &a.IP, &a.Timestamp, &a.Details); err != nil { continue } audit = append(audit, a) } cpu, _ := readCPU() ram, _ := readRAM() disk, _ := readDisk("/") resp.UsersCount = usersCount resp.ModulesHealthy = modulesHealthy resp.ModulesTotal = modulesTotal resp.RecentAudit = audit resp.SystemMetrics = SystemMetricsData{CPU: cpu, RAM: ram, Disk: disk} return nil } func (h *ExternalDataHandler) loadQuixzoomData(resp *UnifiedDashboardResponse) error { // Try to read from quiXzoom SQLite database dbPath := "/opt/amos/data/quixzoom-missions.db" qzDB, err := sql.Open("sqlite", dbPath) if err != nil { return err } defer qzDB.Close() var totalMissions, activeMissions, totalSubmissions, contributors, pendingPayouts int qzDB.QueryRow(`SELECT COUNT(*) FROM missions`).Scan(&totalMissions) qzDB.QueryRow(`SELECT COUNT(*) FROM missions WHERE status = 'active'`).Scan(&activeMissions) qzDB.QueryRow(`SELECT COUNT(*) FROM submissions`).Scan(&totalSubmissions) qzDB.QueryRow(`SELECT COUNT(DISTINCT zoomer_id) FROM submissions`).Scan(&contributors) qzDB.QueryRow(`SELECT COUNT(*) FROM payouts WHERE status = 'pending'`).Scan(&pendingPayouts) resp.Quixzoom = QuixzoomData{ TotalMissions: totalMissions, ActiveMissions: activeMissions, TotalSubmissions: totalSubmissions, Contributors: contributors, PendingPayouts: pendingPayouts, } return nil } func (h *ExternalDataHandler) loadLandvexData(resp *UnifiedDashboardResponse) error { // Read from landvex intelligence files landvexPath := "/opt/amos/public/landvex" files := 0 if entries, err := os.ReadDir(landvexPath); err == nil { for _, entry := range entries { if !entry.IsDir() { files++ } } } // Count intelligence reports (JSON files) reports := 0 _ = filepath.Walk(landvexPath, func(path string, info os.FileInfo, err error) error { if err == nil && !info.IsDir() && strings.HasSuffix(path, ".json") { reports++ } return nil }) resp.Landvex = LandvexData{ IntelligenceReports: reports, UrbanDataPoints: files, ActiveCustomers: 1, // Trygg Bil } return nil } func (h *ExternalDataHandler) loadEconomyData(resp *UnifiedDashboardResponse) error { // Try to connect to aamos-ledger on port 3250 ledgerURL := os.Getenv("LEDGER_URL") if ledgerURL == "" { ledgerURL = "http://localhost:3250" } // Try to fetch from ledger API client := &http.Client{Timeout: 5 * time.Second} ledgerResp, err := client.Get(ledgerURL + "/api/v1/vouchers/count") if err == nil && ledgerResp.StatusCode == 200 { defer ledgerResp.Body.Close() var result struct { Count int `json:"count"` } if err := json.NewDecoder(ledgerResp.Body).Decode(&result); err == nil { resp.Economy.TotalVouchers = result.Count } } // Also try to get accounts count ledgerResp2, err := client.Get(ledgerURL + "/api/v1/accounts/count") if err == nil && ledgerResp2.StatusCode == 200 { defer ledgerResp2.Body.Close() var result struct { Count int `json:"count"` } if err := json.NewDecoder(ledgerResp2.Body).Decode(&result); err == nil { resp.Economy.TotalAccounts = result.Count } } // Fallback to local SQLite if ledger API fails if resp.Economy.TotalVouchers == 0 { dbPath := "/opt/amos/data/ledger.db" ledgerDB, err := sql.Open("sqlite", dbPath) if err == nil { defer ledgerDB.Close() ledgerDB.QueryRow(`SELECT COUNT(*) FROM vouchers`).Scan(&resp.Economy.TotalVouchers) ledgerDB.QueryRow(`SELECT COUNT(*) FROM accounts`).Scan(&resp.Economy.TotalAccounts) } } // Hardcoded cash position from MEMORY.md (2026-06-21) resp.Economy.CashSEK = 276504 resp.Economy.CashEUR = 179.70 resp.Economy.CashUSD = 14.70 return nil } func (h *ExternalDataHandler) loadCRMData(resp *UnifiedDashboardResponse) error { // Try to read from CRM database or API // For now, placeholder with reasonable defaults resp.CRM = CRMData{ TotalCustomers: 1, // Trygg Bil ActiveLeads: 0, DealsWon: 1, DealsLost: 0, PipelineValueSEK: 0, } return nil } type ServiceStatus struct { Name string `json:"name"` Status string `json:"status"` Type string `json:"type"` Uptime string `json:"uptime"` LastCheck string `json:"last_check"` } // ServiceHealthHandler returns health status for all Docker containers and systemd services. func ServiceHealthHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } var services []ServiceStatus // Get Docker containers cmd := exec.Command("docker", "ps", "--format", "{{.Names}}|{{.Status}}|{{.Image}}") out, err := cmd.Output() if err == nil { for _, line := range strings.Split(string(out), "\n") { parts := strings.Split(line, "|") if len(parts) >= 2 { status := "running" if strings.Contains(parts[1], "unhealthy") { status = "degraded" } services = append(services, ServiceStatus{ Name: parts[0], Status: status, Type: "docker", Uptime: parts[1], LastCheck: time.Now().UTC().Format(time.RFC3339), }) } } } // Get systemd services (aamos-*) cmd = exec.Command("systemctl", "list-units", "--type=service", "--state=running", "--no-pager", "--no-legend") out, err = cmd.Output() if err == nil { for _, line := range strings.Split(string(out), "\n") { if strings.Contains(line, "aamos-") || strings.Contains(line, "landvex") || strings.Contains(line, "quixzoom") { fields := strings.Fields(line) if len(fields) >= 2 { services = append(services, ServiceStatus{ Name: fields[0], Status: "running", Type: "systemd", Uptime: strings.Join(fields[2:], " "), LastCheck: time.Now().UTC().Format(time.RFC3339), }) } } } } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]interface{}{ "services": services, "total": len(services), "healthy": countHealthy(services), }) } func countHealthy(services []ServiceStatus) int { count := 0 for _, s := range services { if s.Status == "running" { count++ } } return count }