Files
projax/web/admin.go
mAi c486a8b028 feat(phase 3o admin-index): /admin landing + system panel + nav consolidation
The three admin pages (classify, caldav, bulk) had no shared entry point —
m navigated around and couldn't find them. /admin is now their index:

- 3 cards, each linking to the underlying tool, with live counts
  (orphan count via projax.items_unified predicate; calendar count via
  ListCalendars; item count via projax.items where deleted_at IS NULL
  AND archived = false)
- CalDAV card auto-disables when DAV_URL isn't configured
- System panel: version (build-time ldflags hook), last migration
  (projax.schema_migrations top row), MCP status (token present
  yes/no — token itself never displayed), upstream health (DAV +
  Gitea + Supabase, parallel-probed with 1s HTTP timeout each,
  cached 30s)

web/admin.go houses the handler + cache + probeURL helper + count
queries. Templates/admin.tmpl renders the cards + system grid.
admin_test.go covers /admin render + nav-link presence on every
chrome-bearing route.

Nav consolidation: the three separate admin links in layout.tmpl
collapse to one /admin entry. Pre-existing TestTreeRenders updated
to assert the new shape.

Probe-URL caveat: probeURL counts any HTTP response as "alive" (incl.
4xx) — the admin panel measures reachability, not authorisation. CalDAV
returns 401 on bare GET; Gitea returns 200 at the root; Supabase same.
All show green when alive.
2026-05-16 02:26:07 +02:00

233 lines
7.0 KiB
Go

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, "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
}