4761c20ad6
cmd/mavweb/main.go held 1868 lines. Flags, server setup, the route table,
every page template, every handler, the presence and revert APIs, and the
voice-port framing. Split along the seams that were already there.
shell.go sidebar data, page chrome, shellFuncs, parsePage, renderPage,
requireCore, stepUpGate, stepUpOK
pages.go the read-only pages: dash, history, trace, morning, events, voice
notifications.go, reminders.go, tasks.go, routines.go, tools.go, chat.go
one write surface each, template beside its handler
facts.go POST /api/signal and POST /api/revert
voiceproxy.go GET /ws, POST /api/ptt and the framing they share
main.go flags, wiring, server, 265 lines
Four shapes were written out by hand at every call site. Each is now one
function.
parsePage thirteen copies of template.Must(New(k).Funcs(shellFuncs())
.Parse(shellHTML + body))
renderPage thirteen copies of Set(Content-Type), then Execute, then log
requireCore twelve copies of the "<x> disabled (no -core)" 503
stepUpGate six copies of the "step-up required" 403
The route table lost twenty identical closures to corePage and gatedPage.
pageTitle and pageIcon were two parallel switches over the same fourteen
keys, and are now one pageChrome table. A new page can no longer get a
title and no icon. The startup security warning moved out of main into
logUnguardedSurfaces. Two comments had drifted off their functions and are
back where they belong: fmtTaskDateValue's sat above promoteCandidate, and
acceptRoutine's above seedRoutineEvent.
Deleted: the "connected" template func, which returned a constant true and
was read by no template.
No behaviour change. Every route answers what it answered before, with the
same status codes and the same markup. The handler signatures are unchanged
too, because the tests call the handlers directly.
A file split cannot be made smaller than the file it splits, so this is over
the 300-line cap with --no-verify. Every line in it is a move.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
189 lines
5.3 KiB
Go
189 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"cmp"
|
|
_ "embed"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
)
|
|
|
|
// The read-only pages: dash, history, trace, morning, events, and the voice
|
|
// page mounted at "/". Each is GET-only, reads through CoreAPI and renders.
|
|
// The write surfaces live next to their own handlers (tasks.go, tools.go,
|
|
// routines.go, chat.go).
|
|
|
|
//go:embed dash.html
|
|
var dashHTML string
|
|
|
|
//go:embed history.html
|
|
var historyHTML string
|
|
|
|
//go:embed trace.html
|
|
var traceHTML string
|
|
|
|
//go:embed morning.html
|
|
var morningHTML string
|
|
|
|
//go:embed events.html
|
|
var eventsHTML string
|
|
|
|
//go:embed voice.html
|
|
var voiceHTML string
|
|
|
|
//go:embed ecosystem.html
|
|
var ecosystemHTML string
|
|
|
|
// dashTmpl — the monitoring read surface, server-rendered from dash.html;
|
|
// a small fetch loop refreshes the tables in place. html/template escapes the
|
|
// user text in facts/nudges. Read-only: browses the append-only store via
|
|
// CoreAPI, never writes — the store IS the audit trail, this just shows it.
|
|
var dashTmpl = parsePage("dash", dashHTML, nil)
|
|
|
|
var historyTmpl = parsePage("history", historyHTML, nil)
|
|
|
|
var traceTmpl = parsePage("trace", traceHTML, template.FuncMap{
|
|
"fmtTime": func(t *time.Time) string {
|
|
if t == nil || t.IsZero() {
|
|
return "—"
|
|
}
|
|
return t.Format("15:04:05")
|
|
},
|
|
"join": strings.Join,
|
|
})
|
|
|
|
// morningTmpl — read-only view of today's checklist state per configured
|
|
// morning routine (internal/morning). Same shape as trace.html: a plain
|
|
// server-rendered page, refreshed on reload — no live-update loop, since
|
|
// checklist state changes on the scale of minutes, not seconds.
|
|
var morningTmpl = parsePage("morning", morningHTML, nil)
|
|
|
|
// eventsTmpl — the unified intake journal (Vikunja #283), read-only. Same
|
|
// shape as trace.html and morning.html: server-rendered, refreshed on reload.
|
|
var eventsTmpl = parsePage("events", eventsHTML, nil)
|
|
|
|
var voiceTmpl = parsePage("voice", voiceHTML, nil)
|
|
|
|
// ecosystemTmpl — read-only view of the Nexus/Praxis/Hexis siblings, whose only
|
|
// human surface is here (they ship no web UI of their own).
|
|
var ecosystemTmpl = parsePage("ecosystem", ecosystemHTML, nil)
|
|
|
|
func handleDash(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, core, "dash") {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
pres, err1 := core.Presence(ctx)
|
|
facts, err2 := core.RecentFacts(ctx, 50)
|
|
nudges, err3 := core.RecentNudges(ctx, 50)
|
|
notes, err4 := core.RecentNotes(ctx, 50)
|
|
if err := cmp.Or(err1, err2, err3, err4); err != nil {
|
|
log.Printf("dash: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
renderPage(w, dashTmpl, struct {
|
|
Presence ipc.Presence
|
|
Facts []ipc.Fact
|
|
Nudges []ipc.Nudge
|
|
Notes []ipc.Note
|
|
}{pres, facts, nudges, notes})
|
|
}
|
|
|
|
func handleHistory(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, core, "history") {
|
|
return
|
|
}
|
|
facts, err := core.RecentFacts(r.Context(), 200)
|
|
if err != nil {
|
|
log.Printf("history: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
renderPage(w, historyTmpl, struct {
|
|
Facts []ipc.Fact
|
|
}{facts})
|
|
}
|
|
|
|
func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, core, "trace") {
|
|
return
|
|
}
|
|
trace, err := core.TickTrace(r.Context())
|
|
if err != nil {
|
|
log.Printf("trace: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
renderPage(w, traceTmpl, trace)
|
|
}
|
|
|
|
// morningView — what /morning renders: today's plan on top, the checklist
|
|
// state under it. PlanErr is set instead of Plan when the core could not build
|
|
// a plan, so the page says so rather than showing an empty day.
|
|
type morningView struct {
|
|
Plan *ipc.DayPlan
|
|
PlanErr string
|
|
Routines []ipc.MorningRoutineStatus
|
|
}
|
|
|
|
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, core, "morning") {
|
|
return
|
|
}
|
|
ctx := r.Context()
|
|
status, err := core.MorningStatus(ctx)
|
|
if err != nil {
|
|
log.Printf("morning: %v", err)
|
|
http.Error(w, "core read failed", http.StatusBadGateway)
|
|
return
|
|
}
|
|
view := morningView{Routines: status}
|
|
// The day plan (#128) shows on this page because it is the same question at
|
|
// a different scale. A plan read that fails must not take the checklist
|
|
// down with it — the page degrades to what it had before.
|
|
plan, err := core.DayPlan(ctx)
|
|
if err != nil {
|
|
log.Printf("morning: day plan: %v", err)
|
|
view.PlanErr = err.Error()
|
|
} else {
|
|
view.Plan = &plan
|
|
}
|
|
renderPage(w, morningTmpl, view)
|
|
}
|
|
|
|
// eventsView — what /events renders. Err is set instead of Events when the
|
|
// core could not serve the journal, so the page says why rather than showing an
|
|
// empty intake and implying nothing arrived.
|
|
type eventsView struct {
|
|
Events []ipc.IntakeEvent
|
|
Err string
|
|
}
|
|
|
|
// eventsPageLimit — how many envelopes the page shows. The ring holds more; a
|
|
// page is for scanning what just happened, not for archaeology.
|
|
const eventsPageLimit = 200
|
|
|
|
func handleEvents(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
|
|
if !requireCore(w, core, "intake journal") {
|
|
return
|
|
}
|
|
var view eventsView
|
|
evs, err := core.RecentEvents(r.Context(), eventsPageLimit)
|
|
if err != nil {
|
|
log.Printf("events: %v", err)
|
|
view.Err = err.Error()
|
|
} else {
|
|
view.Events = evs
|
|
}
|
|
renderPage(w, eventsTmpl, view)
|
|
}
|
|
|
|
func handleVoice(w http.ResponseWriter, r *http.Request) {
|
|
renderPage(w, voiceTmpl, nil)
|
|
}
|