package api import ( "bufio" "math" "net/http" "os" "runtime" "strconv" "strings" "syscall" "time" "archivmail/internal/audit" "archivmail/internal/storage" "archivmail/pkg/mailparser" ) // ── Mail Timeseries handler ─────────────────────────────────────────────── // handleMailTimeseries returns daily mail counts for the last 30 days. // GET /api/admin/stats/timeseries?days=30 func (s *Server) handleMailTimeseries(w http.ResponseWriter, r *http.Request) { days := 30 sess := sessionFromCtx(r.Context()) tenantID := tenantFromCtx(r.Context()) // domain_admin sees only own tenant; superadmin sees all var tid *int64 if sess.TenantID != nil { tid = tenantID } points, err := s.store.MailTimeseries(r.Context(), days, tid) if err != nil { writeError(w, http.StatusInternalServerError, "timeseries query failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "days": days, "points": points, }) } // ── Audit Log handler ───────────────────────────────────────────────────── func (s *Server) handleAuditLog(w http.ResponseWriter, r *http.Request) { pageStr := r.URL.Query().Get("page") pageSizeStr := r.URL.Query().Get("page_size") username := r.URL.Query().Get("username") eventType := r.URL.Query().Get("event_type") page, _ := strconv.Atoi(pageStr) pageSize, _ := strconv.Atoi(pageSizeStr) if pageSize <= 0 { pageSize = 50 } entries, total, err := s.audlog.Query(audit.QueryFilter{ Username: username, EventType: eventType, PageSize: pageSize, Page: page, }) if err != nil { writeError(w, http.StatusInternalServerError, "audit query failed") return } writeJSON(w, http.StatusOK, map[string]interface{}{ "total": total, "entries": entries, }) } // ── System stats handler ───────────────────────────────────────────────── type diskStat struct { Mount string `json:"mount"` TotalBytes uint64 `json:"total_bytes"` UsedBytes uint64 `json:"used_bytes"` FreeBytes uint64 `json:"free_bytes"` UsedPct float64 `json:"used_pct"` FSType string `json:"fstype"` } type mailInfo struct { ID string `json:"id"` Date string `json:"date"` From string `json:"from"` Subject string `json:"subject"` } var excludedFSTypes = map[string]bool{ "tmpfs": true, "proc": true, "sysfs": true, "devtmpfs": true, "cgroup": true, "cgroup2": true, "overlay": true, "squashfs": true, "debugfs": true, "tracefs": true, "securityfs": true, "pstore": true, "efivarfs": true, "bpf": true, "hugetlbfs": true, "mqueue": true, "ramfs": true, "devpts": true, "fusectl": true, "configfs": true, "autofs": true, "nsfs": true, "rpc_pipefs": true, "fuse.lxcfs": true, "fuse": true, } func (s *Server) handleSystemStats(w http.ResponseWriter, r *http.Request) { // CPU: /proc/loadavg cpuResp := map[string]interface{}{"load1": 0.0, "load5": 0.0, "load15": 0.0, "num_cpu": runtime.NumCPU()} if data, err := os.ReadFile("/proc/loadavg"); err == nil { parts := strings.Fields(string(data)) if len(parts) >= 3 { l1, _ := strconv.ParseFloat(parts[0], 64) l5, _ := strconv.ParseFloat(parts[1], 64) l15, _ := strconv.ParseFloat(parts[2], 64) cpuResp = map[string]interface{}{"load1": l1, "load5": l5, "load15": l15, "num_cpu": runtime.NumCPU()} } } // RAM: /proc/meminfo ramResp := map[string]interface{}{"total_bytes": uint64(0), "used_bytes": uint64(0), "free_bytes": uint64(0), "used_pct": 0.0} if data, err := os.ReadFile("/proc/meminfo"); err == nil { kv := parseMeminfo(string(data)) total := kv["MemTotal"] * 1024 available := kv["MemAvailable"] * 1024 used := total - available var usedPct float64 if total > 0 { usedPct = math.Round(float64(used)/float64(total)*1000) / 10 } ramResp = map[string]interface{}{ "total_bytes": total, "used_bytes": used, "free_bytes": available, "used_pct": usedPct, } } // Disks: /proc/mounts + syscall.Statfs var disks []diskStat seenMounts := map[string]bool{} // deduplicate by mountpoint seenDevices := map[string]bool{} // deduplicate by device (catches ZFS bind-mounts) if data, err := os.ReadFile("/proc/mounts"); err == nil { scanner := bufio.NewScanner(strings.NewReader(string(data))) for scanner.Scan() { fields := strings.Fields(scanner.Text()) if len(fields) < 3 { continue } device := fields[0] mount := fields[1] fstype := fields[2] if excludedFSTypes[fstype] || seenMounts[mount] || seenDevices[device] { continue } seenMounts[mount] = true var stat syscall.Statfs_t if err := syscall.Statfs(mount, &stat); err != nil { continue } total := stat.Blocks * uint64(stat.Bsize) free := stat.Bavail * uint64(stat.Bsize) used := total - free if total == 0 { continue // skip pseudo-mounts with no storage (e.g. lxcfs overlays) } seenDevices[device] = true var usedPct float64 if total > 0 { usedPct = math.Round(float64(used)/float64(total)*1000) / 10 } disks = append(disks, diskStat{ Mount: mount, TotalBytes: total, UsedBytes: used, FreeBytes: free, UsedPct: usedPct, FSType: fstype, }) } } if disks == nil { disks = []diskStat{} } // Uptime: /proc/uptime uptimeResp := map[string]interface{}{"seconds": 0.0} if data, err := os.ReadFile("/proc/uptime"); err == nil { parts := strings.Fields(string(data)) if len(parts) >= 1 { if secs, err := strconv.ParseFloat(parts[0], 64); err == nil { uptimeResp["seconds"] = secs } } } // Archive: first & last mail archiveResp := map[string]interface{}{"first_mail": nil, "last_mail": nil} first, last, err := s.store.FirstAndLastMail() if err == nil { if first != nil { archiveResp["first_mail"] = mailRefToInfo(s.store, first) } if last != nil { archiveResp["last_mail"] = mailRefToInfo(s.store, last) } } // Mail activity (60min / 24h / 7d / 30d) activity, _ := s.store.MailActivityStats(r.Context()) // Storage estimate — use free bytes of the first non-root disk (or root) var freeDiskBytes uint64 for _, d := range disks { freeDiskBytes = d.FreeBytes break } estimate, _ := s.store.StorageEstimateStats(r.Context(), freeDiskBytes) writeJSON(w, http.StatusOK, map[string]interface{}{ "cpu": cpuResp, "ram": ramResp, "disks": disks, "uptime": uptimeResp, "archive": archiveResp, "activity": activity, "estimate": estimate, }) } func parseMeminfo(content string) map[string]uint64 { result := make(map[string]uint64) scanner := bufio.NewScanner(strings.NewReader(content)) for scanner.Scan() { k, v, ok := strings.Cut(scanner.Text(), ":") if !ok { continue } fields := strings.Fields(strings.TrimSpace(v)) if len(fields) == 0 { continue } val, err := strconv.ParseUint(fields[0], 10, 64) if err == nil { result[k] = val } } return result } func mailRefToInfo(store *storage.Store, ref *storage.MailRef) *mailInfo { dateStr := ref.ModTime.UTC().Format(time.RFC3339) raw, err := store.Load(ref.ID) if err != nil { return &mailInfo{ID: ref.ID, Date: dateStr} } pm, err := mailparser.Parse(raw) if err != nil { return &mailInfo{ID: ref.ID, Date: dateStr} } if !pm.Date.IsZero() { dateStr = pm.Date.UTC().Format(time.RFC3339) } return &mailInfo{ ID: ref.ID, Date: dateStr, From: pm.From, Subject: pm.Subject, } }