6de2455917
- Added GLOBAL_MARKETS_TITLE to all translation files - Updated footer with 12 markets (4 active + 8 upcoming) - Translated market section to: zh-cn, zh-tw, ja, ko, th, vi, id, ms, hi - Built and deployed to production - CloudFront invalidation: I3RTMXVFDJXWLG3SYX208OP1CC
420 lines
12 KiB
Go
420 lines
12 KiB
Go
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
|
|
}
|