package web import ( "context" "net/http" "sync" "time" ) // adminHealth is one upstream's reachability snapshot for the system panel. // `OK` reflects the last probe; `URL` is what was probed (BaseURL of the // underlying client, not the full /api/v1 path). type adminHealth struct { Name string URL string OK bool } // adminCard is a tile on the admin index. Count is a lazily-computed int that // the handler resolves before rendering; -1 means "unknown / not applicable". type adminCard struct { Title string Href string Description string Count int CountLabel string // e.g. "orphans" / "calendars" / "items" Disabled bool // true → grey out + skip the link (integration off) DisabledMsg string } // adminSystemPanel bundles the bottom-of-page system info. type adminSystemPanel struct { Version string // build-time -ldflags injection; "" when unset MigrationLast string // most recent applied migration filename Healths []adminHealth MCPEnabled bool } // adminCacheTTL is how long admin-health probes are cached. The system panel // hammers DAV / Gitea / Supabase once per render — caching for 30 s keeps the // cost negligible even if m refreshes hard. const adminCacheTTL = 30 * time.Second // adminHealthCache holds the last health probe + when it was built. type adminHealthCache struct { mu sync.Mutex at time.Time hs []adminHealth } func newAdminHealthCache() *adminHealthCache { return &adminHealthCache{} } func (c *adminHealthCache) get(now time.Time) ([]adminHealth, bool) { c.mu.Lock() defer c.mu.Unlock() if c.hs == nil || now.Sub(c.at) > adminCacheTTL { return nil, false } return c.hs, true } func (c *adminHealthCache) set(hs []adminHealth, now time.Time) { c.mu.Lock() defer c.mu.Unlock() c.hs = hs c.at = now } // handleAdminIndex renders the admin landing page: three cards (Classify, // CalDAV, Bulk) with live counts + a system panel (version, migration, // upstream health, MCP status). func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) { ctx := r.Context() // Counts: orphans + total items run against the local DB (cheap). orphanCount, _ := s.orphanCount(ctx) itemCount, _ := s.itemCount(ctx) classify := adminCard{ Title: "Classify orphans", Href: "/admin/classify", Description: "Mai-managed items that landed at root and need a parent. Pick one for each.", Count: orphanCount, CountLabel: "orphans", } caldav := adminCard{ Title: "CalDAV calendars", Href: "/admin/caldav", Description: "Link/unlink CalDAV lists to projax items. The Tasks + Events cards on /dashboard fan out across these.", } if s.CalDAV == nil { caldav.Disabled = true caldav.DisabledMsg = "DAV_URL not configured" } else { // Count calendars; per-render fetch is bounded by the existing client // timeout (~5s) and shares the integration cache the detail page already // uses. If the upstream is down the card surfaces "?" rather than 0. if n, err := s.calendarCount(ctx); err == nil { caldav.Count = n caldav.CountLabel = "calendars" } else { caldav.Count = -1 caldav.CountLabel = "calendars (probe failed)" } } bulk := adminCard{ Title: "Bulk edit", Href: "/admin/bulk", Description: "Multi-row tag/management/status apply across the filtered set. One transaction, undoable item-by-item.", Count: itemCount, CountLabel: "items in scope", } cards := []adminCard{classify, caldav, bulk} panel := adminSystemPanel{ Version: s.Version, MigrationLast: s.migrationLast(ctx), Healths: s.gatherHealth(ctx), MCPEnabled: s.MCP != nil, } s.render(w, r, "admin", map[string]any{ "Title": "admin", "Cards": cards, "Panel": panel, }) } // orphanCount reports how many mai-managed items live at root and need // classifying — same predicate as MaiOrphans, just a count. func (s *Server) orphanCount(ctx context.Context) (int, error) { var n int err := s.Store.Pool.QueryRow(ctx, ` select count(*) from projax.items where deleted_at is null and cardinality(parent_ids) = 0 and 'mai' = any(management)`).Scan(&n) return n, err } // itemCount reports the total number of live, non-archived items. func (s *Server) itemCount(ctx context.Context) (int, error) { var n int err := s.Store.Pool.QueryRow(ctx, ` select count(*) from projax.items where deleted_at is null and archived = false`).Scan(&n) return n, err } // calendarCount fetches the calendar list from dav.msbls.de and returns the // total. Surfaces only the count — discovery itself happens on /admin/caldav. func (s *Server) calendarCount(ctx context.Context) (int, error) { cals, err := s.CalDAV.Client.ListCalendars(ctx) if err != nil { return 0, err } return len(cals), nil } // migrationLast reads the most-recent row from projax.schema_migrations so // the system panel can show "0013_orphan_item_links_cleanup.sql · 2026-05-15". // Returns "" if the table doesn't exist (fresh DB) or on error. func (s *Server) migrationLast(ctx context.Context) string { var name string err := s.Store.Pool.QueryRow(ctx, `select name from projax.schema_migrations order by applied_at desc limit 1`).Scan(&name) if err != nil { return "" } return name } // gatherHealth probes each configured upstream in parallel with a 1 s // timeout. Results are cached for adminCacheTTL so /admin renders stay // cheap even if m hammers refresh. func (s *Server) gatherHealth(ctx context.Context) []adminHealth { now := time.Now() if cached, ok := s.adminHealth.get(now); ok { return cached } type target struct { name string url string } targets := []target{} if s.CalDAV != nil { targets = append(targets, target{"CalDAV", s.CalDAV.Client.BaseURL}) } if s.Gitea != nil { targets = append(targets, target{"Gitea", s.Gitea.Client.BaseURL}) } if s.Auth != nil { targets = append(targets, target{"Supabase", s.Auth.SupabaseURL}) } if len(targets) == 0 { return nil } out := make([]adminHealth, len(targets)) var wg sync.WaitGroup probeCtx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond) defer cancel() client := &http.Client{Timeout: time.Second} for i, t := range targets { wg.Add(1) go func(i int, t target) { defer wg.Done() out[i] = adminHealth{Name: t.name, URL: t.url, OK: probeURL(probeCtx, client, t.url)} }(i, t) } wg.Wait() s.adminHealth.set(out, now) return out } // probeURL issues a quick GET against the given URL and reports whether the // upstream returned ANY HTTP status. Connection-level failures (DNS, // refused, TLS) count as down. Non-2xx but valid HTTP counts as UP — the // admin panel cares about reachability, not authorisation. CalDAV returns // 401 unauth on a bare GET, which is "alive". Same for Gitea / Supabase. func probeURL(ctx context.Context, client *http.Client, u string) bool { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil) if err != nil { return false } resp, err := client.Do(req) if err != nil { return false } _ = resp.Body.Close() return true }