## Slice A — explicit dark/light toggle projax now ships with two palettes and a 1y cookie to remember the choice. Dark is the new default; ☀ button in the header nav flips to light and writes projax_theme=light. Server reads the cookie via themeFromRequest(r) and injects Theme + ThemeColor into every template via the centralised render(w, r, …) path, so first paint never flashes the wrong theme. Inline JS in layout.tmpl handles the toggle without a server roundtrip. Every panel colour now lives in a CSS variable under :root[data-theme=dark|light]; the only hardcoded hex values left are inside those two :root blocks. A future palette tweak is one edit, not 30 selectors. Graph node colours, kind-badges, highlights and warn/ok/bad all have parallel dark/light values picked for contrast. Standalone SVG download bakes the light palette inline because the downloaded asset has no parent :root providing vars — m's existing snapshots stay print-friendly regardless of his current cookie. Login page keeps its embedded dark CSS — it's the gateway, intentionally always dark. Tests: TestThemeDefaultIsDark, TestThemeCookieRoundTrips, TestThemeCookieUnknownFallsBackToDark, TestThemeTogglePagesShareSameTheme, TestThemeToggleScriptPresent, TestThemeColorMetaHelper. Full suite green. ## Slice B — file-upload permanently out of scope (m, 2026-05-17) docs/design.md moves "File uploads / in-projax storage" from the §3c parked list to a permanent "Out of scope (decided 2026-05-17)" clause with the rationale: PER is the cross-reference index, not the file system. docs/standards/per.md gains the same explicit clause so future shifts working from the PER standard see the constraint where they look. Memory note filed so future workers don't re-propose multipart uploads, attachments tables, or documents buckets. ## docs/design.md §13 Theming Documents the toggle approach, cookie semantics, palette table, the standalone-SVG carve-out, the login-page exception, and the 4b out-of-scope (prefers-color-scheme detection, per-page overrides, transitions on swap).
233 lines
7.0 KiB
Go
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, 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
|
|
}
|