Files

284 lines
7.7 KiB
Go

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)
return http.ListenAndServe(addr, s.mux)
}
// ─── 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("<p class='text-gray-400 text-sm'>Enter a query above.</p>"))
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