package server import ( "bytes" _ "embed" "fmt" "html/template" "log" "net/http" "sort" "strconv" "strings" "time" "github.com/david/knox/internal/db" ) type Server struct { kdb *db.KnoxDB mux *http.ServeMux tmpl *template.Template } func New(kdb *db.KnoxDB) *Server { s := &Server{kdb: kdb, mux: http.NewServeMux()} s.tmpl = template.Must(template.New("knox").Funcs(template.FuncMap{ "shortFP": shortFP, "trunc": truncStr, "fpURL": fpURL, "humanTime": humanTime, "displayTime": displayTime, }).Parse(layout)) s.mux.HandleFunc("GET /", s.dashboard) s.mux.HandleFunc("GET /search", s.search) s.mux.HandleFunc("GET /threads", s.threadList) s.mux.HandleFunc("GET /threads/{id}", s.threadDetail) s.mux.HandleFunc("GET /entries/{fp}", s.entryDetail) s.mux.HandleFunc("GET /stats", s.stats) return s } func (s *Server) Serve(addr string) error { log.Printf("[knox] web UI at http://%s", addr) srv := &http.Server{Addr: addr, Handler: s.mux, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second} return srv.ListenAndServe() } // ─── Dashboard ─────────────────────────────────────────────── func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { gitea, _ := s.kdb.RecentEntriesBySource("gitea", 3) obsidian, _ := s.kdb.RecentEntriesBySource("obsidian", 3) skills, _ := s.kdb.RecentEntriesBySource("skills-catalog", 2) session, _ := s.kdb.RecentEntriesBySource("opencode-session", 3) recent, _ := s.kdb.RecentEntries(8) goldenID, err := s.kdb.GoldenThreadID() if err != nil { http.Error(w, "failed to load golden thread", http.StatusInternalServerError) return } var golden *db.Thread if goldenID > 0 { golden, _ = s.kdb.GetThread(goldenID) } s.render(w, "dashboard", map[string]any{ "gitea": gitea, "obsidian": obsidian, "skills": skills, "session": session, "recent": recent, "golden": golden, }) } // ─── Search ────────────────────────────────────────────────── func (s *Server) search(w http.ResponseWriter, r *http.Request) { q := r.URL.Query().Get("q") if q == "" { w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Write([]byte("
Enter a query above.
")) return } results, err := s.kdb.Search(q, 30) if err != nil { http.Error(w, "search failed", http.StatusInternalServerError) return } s.render(w, "search_results", map[string]any{ "query": q, "results": results, }) } // ─── Thread List ───────────────────────────────────────────── func (s *Server) threadList(w http.ResponseWriter, r *http.Request) { status := r.URL.Query().Get("status") threads, err := s.kdb.ListThreads(status) if err != nil { http.Error(w, "failed to load threads", http.StatusInternalServerError) return } s.render(w, "thread_list", map[string]any{ "title": "Threads", "threads": threads, "status": status, }) } // ─── Thread Detail ─────────────────────────────────────────── func (s *Server) threadDetail(w http.ResponseWriter, r *http.Request) { id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) if err != nil { http.Error(w, "invalid thread id", http.StatusBadRequest) return } t, err := s.kdb.GetThread(id) if err != nil { http.Error(w, "failed to load thread", http.StatusInternalServerError) return } if t == nil { s.render(w, "not_found", map[string]any{"title": "Thread Not Found"}, http.StatusNotFound) return } obs, err := s.kdb.ThreadObservations(id) if err != nil { http.Error(w, "failed to load observations", http.StatusInternalServerError) return } notes, err := s.kdb.ThreadNotes(id) if err != nil { http.Error(w, "failed to load notes", http.StatusInternalServerError) return } provenance, err := s.kdb.ThreadProvenance(id) if err != nil { http.Error(w, "failed to load provenance", http.StatusInternalServerError) return } goldenID, _ := s.kdb.GoldenThreadID() s.render(w, "thread_detail", map[string]any{ "title": fmt.Sprintf("Thread #%d", id), "thread": t, "obs": obs, "notes": notes, "provenance": provenance, "isGolden": goldenID == id, }) } // ─── Entry Detail ──────────────────────────────────────────── func (s *Server) entryDetail(w http.ResponseWriter, r *http.Request) { fp := r.PathValue("fp") entry, err := s.kdb.FindEntry(fp) if err != nil { http.Error(w, "failed to load entry", http.StatusInternalServerError) return } if entry == nil { // Try fingerprint prefix match (the UI displays 8-char prefixes) entries, err := s.kdb.FindEntryByPrefix(fp, 2) if err != nil { http.Error(w, "failed to load entry", http.StatusInternalServerError) return } if len(entries) == 1 { entry = &entries[0] } else { s.render(w, "not_found", map[string]any{"title": "Entry Not Found"}, http.StatusNotFound) return } } obs, err := s.kdb.ObservationsByFingerprint(entry.Fingerprint, 20) if err != nil { http.Error(w, "failed to load observations", http.StatusInternalServerError) return } s.render(w, "entry_detail", map[string]any{ "title": "Entry " + shortFP(entry.Fingerprint), "entry": entry, "obs": obs, }) } // ─── Stats Fragment ────────────────────────────────────────── type statItem struct { Label string Value any } func statItems(stats map[string]any) []statItem { keys := make([]string, 0, len(stats)) for k := range stats { keys = append(keys, k) } sort.Strings(keys) items := make([]statItem, 0, len(keys)) for _, k := range keys { items = append(items, statItem{ Label: strings.ReplaceAll(k, "_", " "), Value: stats[k], }) } return items } func (s *Server) stats(w http.ResponseWriter, r *http.Request) { stats, err := s.kdb.Stats() if err != nil { http.Error(w, "failed to load stats", http.StatusInternalServerError) return } s.render(w, "stats", map[string]any{"stats": statItems(stats)}) } // ─── Render ────────────────────────────────────────────────── func (s *Server) render(w http.ResponseWriter, name string, data map[string]any, status ...int) { code := http.StatusOK if len(status) > 0 { code = status[0] } var buf bytes.Buffer if err := s.tmpl.ExecuteTemplate(&buf, name, data); err != nil { log.Printf("[knox] template error: %v", err) http.Error(w, "template error", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") w.WriteHeader(code) buf.WriteTo(w) } func shortFP(fp string) string { if len(fp) > 8 { return fp[:8] } return fp } // fpURL routes synthetic thread fingerprints to thread pages. func fpURL(fp string) string { if id, ok := strings.CutPrefix(fp, "thread:"); ok { return "/threads/" + id } return "/entries/" + fp } func truncStr(s string, n int) string { runes := []rune(s) if len(runes) <= n { return s } return string(runes[:n]) + "..." } func displayTime(e db.Entry) string { if e.CreatedAt != "" { return humanTime(e.CreatedAt) } return humanTime(e.LastSeen) } func humanTime(s string) string { formats := []string{time.RFC3339, "2006-01-02 15:04:05"} for _, f := range formats { t, err := time.Parse(f, s) if err == nil { return t.Local().Format("Jan 2, 2006 · 15:04") } } return s } //go:embed layout.html var layout string