Initial commit: knox knowledge index
This commit is contained in:
@@ -0,0 +1,299 @@
|
||||
{{define "head"}}<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Knox{{if .title}} · {{.title}}{{end}}</title>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>body{font-family:system-ui,sans-serif}</style>
|
||||
</head>
|
||||
<body class="bg-gray-950 text-gray-100 min-h-screen">
|
||||
<header class="border-b border-gray-800 p-4 flex items-center justify-between">
|
||||
<h1 class="text-xl font-bold text-emerald-400"><a href="/">Knox</a>{{if .title}} <span class="text-gray-400 text-sm font-normal">/ {{.title}}</span>{{end}}</h1>
|
||||
<nav class="flex gap-4 text-sm">
|
||||
<a href="/" class="text-gray-300 hover:text-white">Dashboard</a>
|
||||
<a href="/threads" class="text-gray-300 hover:text-white">Threads</a>
|
||||
</nav>
|
||||
</header>
|
||||
{{end}}
|
||||
|
||||
{{define "foot"}}</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "not_found"}}
|
||||
{{template "head" .}}
|
||||
<main class="max-w-4xl mx-auto p-6 text-center">
|
||||
<div class="text-gray-500 text-lg">Page not found</div>
|
||||
<a href="/" class="mt-6 inline-block text-emerald-400 hover:text-emerald-300 text-sm">Back to Dashboard</a>
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "dashboard"}}
|
||||
{{template "head" .}}
|
||||
<main class="max-w-6xl mx-auto p-6 space-y-6">
|
||||
{{if .golden}}
|
||||
<div class="bg-emerald-900/30 border border-emerald-800 rounded-lg p-4">
|
||||
<div class="text-xs text-emerald-400 uppercase tracking-wider">Golden Thread</div>
|
||||
<div class="text-lg font-semibold mt-1">{{.golden.Title}}</div>
|
||||
<div class="text-sm text-gray-400">{{.golden.Motivation}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="flex gap-4">
|
||||
<input type="text" name="q" placeholder="Search knowledge index..."
|
||||
class="flex-1 bg-gray-900 border border-gray-700 rounded px-4 py-2 text-sm focus:outline-none focus:border-emerald-500"
|
||||
hx-get="/search" hx-trigger="keyup changed delay:300ms" hx-target="#results" hx-indicator="#search-indicator">
|
||||
<button class="bg-emerald-700 hover:bg-emerald-600 px-4 py-2 rounded text-sm font-medium"
|
||||
hx-get="/search" hx-include="[name=q]" hx-target="#results" hx-indicator="#search-indicator">Search</button>
|
||||
</div>
|
||||
<div id="results" class="text-sm text-gray-500"></div>
|
||||
<div id="search-indicator" class="htmx-indicator text-xs text-emerald-400">Searching...</div>
|
||||
|
||||
<div hx-get="/stats" hx-trigger="load, every 30s" hx-swap="innerHTML">
|
||||
<div class="text-gray-500">Loading stats...</div>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Issues</h2>
|
||||
{{if .gitea}}
|
||||
<div class="space-y-1 mb-4">
|
||||
{{range .gitea}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<span class="text-emerald-300">{{trunc .Title 80}}</span>
|
||||
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Notes</h3>
|
||||
{{if .obsidian}}
|
||||
<div class="space-y-1">
|
||||
{{range .obsidian}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<span>{{trunc .Title 50}}</span>
|
||||
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-gray-500 text-xs">No notes indexed.</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Sessions</h3>
|
||||
{{if .session}}
|
||||
<div class="space-y-1">
|
||||
{{range .session}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<span>{{trunc .Title 50}}</span>
|
||||
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-gray-500 text-xs">No sessions tracked.</div>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Skills</h3>
|
||||
{{if .skills}}
|
||||
<div class="space-y-1">
|
||||
{{range .skills}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<span>{{trunc .Title 50}}</span>
|
||||
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="text-gray-500 text-xs">No skills indexed.</div>
|
||||
{{end}}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-2">Feed</h3>
|
||||
<div class="space-y-1">
|
||||
{{range .recent}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<span class="font-mono text-xs text-gray-500">{{shortFP .Fingerprint}}</span>
|
||||
<span class="ml-2">{{trunc .Title 60}}</span>
|
||||
<span class="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.SourceID}}</span>
|
||||
<span class="ml-2 text-xs text-gray-600">{{displayTime .}}</span>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="text-gray-500 text-sm">No entries yet.</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "search_results"}}
|
||||
{{if .results}}
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-500 mb-2">{{len .results}} results for "{{.query}}"</div>
|
||||
{{range .results}}
|
||||
<a href="/entries/{{.Fingerprint}}" class="block bg-gray-900 hover:bg-gray-800 rounded px-3 py-2 text-sm">
|
||||
<div>
|
||||
<span class="font-mono text-xs text-gray-500">{{shortFP .Fingerprint}}</span>
|
||||
<span class="ml-2 font-medium">{{trunc .Title 70}}</span>
|
||||
<span class="ml-2 text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.SourceID}}</span>
|
||||
</div>
|
||||
{{if .Summary}}<div class="text-gray-500 text-xs mt-0.5 ml-12">{{trunc .Summary 120}}</div>{{end}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-gray-500 text-sm">No results for "{{.query}}".</p>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "stats"}}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{{range .stats}}
|
||||
<div class="bg-gray-900 rounded-lg p-3 border border-gray-800">
|
||||
<div class="text-xs text-gray-500 uppercase tracking-wider">{{.Label}}</div>
|
||||
<div class="text-xl font-semibold mt-1">{{.Value}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "thread_list"}}
|
||||
{{template "head" .}}
|
||||
<main class="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<div class="flex gap-2 mb-4">
|
||||
<a href="/threads" class="px-3 py-1 text-sm rounded {{if eq .status ""}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">All</a>
|
||||
<a href="/threads?status=active" class="px-3 py-1 text-sm rounded {{if eq .status "active"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Active</a>
|
||||
<a href="/threads?status=stalled" class="px-3 py-1 text-sm rounded {{if eq .status "stalled"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Stalled</a>
|
||||
<a href="/threads?status=resolved" class="px-3 py-1 text-sm rounded {{if eq .status "resolved"}}bg-emerald-700{{else}}bg-gray-800 hover:bg-gray-700{{end}}">Resolved</a>
|
||||
</div>
|
||||
{{range .threads}}
|
||||
<a href="/threads/{{.ID}}" class="block bg-gray-900 rounded-lg p-4 border border-gray-800 hover:border-gray-600">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-lg font-semibold">{{.Title}}</span>
|
||||
<span class="text-xs px-1.5 py-0.5 rounded {{if eq .Status "active"}}bg-emerald-900 text-emerald-300{{else if eq .Status "resolved"}}bg-blue-900 text-blue-300{{else}}bg-gray-800 text-gray-400{{end}}">{{.Status}}</span>
|
||||
<span class="text-xs text-gray-500">{{.Priority}}</span>
|
||||
</div>
|
||||
{{if .Motivation}}<div class="text-sm text-gray-400 mt-1">{{.Motivation}}</div>{{end}}
|
||||
<div class="text-xs text-gray-600 mt-1">{{.EntryCount}} observations · {{humanTime .UpdatedAt}}</div>
|
||||
</a>
|
||||
{{else}}
|
||||
<div class="text-gray-500 text-sm">No threads found.</div>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "thread_detail"}}
|
||||
{{template "head" .}}
|
||||
<main class="max-w-4xl mx-auto p-6 space-y-6">
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
|
||||
<div class="flex items-center gap-2">
|
||||
<h2 class="text-xl font-bold">{{.thread.Title}}</h2>
|
||||
{{if .isGolden}}<span class="text-xs bg-emerald-900 text-emerald-300 px-2 py-0.5 rounded">GOLDEN</span>{{end}}
|
||||
</div>
|
||||
<div class="flex gap-4 mt-2 text-sm text-gray-400">
|
||||
<span>{{.thread.Status}}</span>
|
||||
<span>{{.thread.Priority}}</span>
|
||||
<span>Created {{humanTime .thread.CreatedAt}}</span>
|
||||
</div>
|
||||
{{if .thread.Motivation}}<div class="mt-3 text-gray-300"><strong>Motivation:</strong> {{.thread.Motivation}}</div>{{end}}
|
||||
{{if ne .thread.Provenance "{}"}}<div class="mt-1 text-xs text-gray-500"><strong>Provenance:</strong> {{.thread.Provenance}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .provenance}}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Provenance Graph</h3>
|
||||
<div class="bg-gray-900 rounded-lg p-3 border border-gray-800 text-sm font-mono">
|
||||
{{range .provenance}}
|
||||
<div class="text-gray-400">└─ <a href="{{fpURL .Fingerprint}}" class="text-emerald-400 hover:text-emerald-300">{{shortFP .Fingerprint}}</a> <span class="text-gray-600">[{{.Relation}}]</span></div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .obs}}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Observations ({{len .obs}})</h3>
|
||||
<div class="space-y-1">
|
||||
{{range .obs}}
|
||||
<div class="bg-gray-900 rounded px-3 py-2 text-sm flex items-center gap-3">
|
||||
<span class="font-mono text-xs text-gray-500">#{{.ID}}</span>
|
||||
<span class="flex-1">{{trunc .Title 50}}</span>
|
||||
<span class="text-xs text-gray-500">{{.SourceID}}</span>
|
||||
{{if gt .Count 1}}<span class="text-xs text-amber-500" title="seen {{.Count}} times">×{{.Count}}</span>{{end}}
|
||||
<span class="text-xs text-gray-600">{{humanTime .CollectedAt}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{if .notes}}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Notes</h3>
|
||||
<div class="space-y-2">
|
||||
{{range .notes}}
|
||||
<div class="bg-gray-900 rounded p-3 border border-gray-800">
|
||||
<div class="text-xs text-gray-500">{{humanTime .CreatedAt}}</div>
|
||||
<div class="text-sm mt-1">{{.Note}}</div>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "entry_detail"}}
|
||||
{{template "head" .}}
|
||||
<main class="max-w-4xl mx-auto p-6 space-y-4">
|
||||
<div class="bg-gray-900 rounded-lg p-4 border border-gray-800">
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div><span class="text-gray-500">Fingerprint</span><br><span class="font-mono text-xs">{{.entry.Fingerprint}}</span></div>
|
||||
<div><span class="text-gray-500">Source</span><br>{{.entry.SourceID}}</div>
|
||||
<div><span class="text-gray-500">Title</span><br><span class="font-medium">{{.entry.Title}}</span></div>
|
||||
<div><span class="text-gray-500">Project</span><br>{{.entry.Project}}</div>
|
||||
<div><span class="text-gray-500">Path</span><br><span class="font-mono text-xs">{{.entry.SourcePath}}</span></div>
|
||||
<div><span class="text-gray-500">Type</span><br>{{.entry.ContentType}}</div>
|
||||
<div><span class="text-gray-500">Signal Time</span><br>{{if .entry.CreatedAt}}{{humanTime .entry.CreatedAt}}{{else}}<span class="text-gray-500">—</span>{{end}}</div>
|
||||
<div><span class="text-gray-500">Ingested</span><br>{{humanTime .entry.LastSeen}}</div>
|
||||
<div><span class="text-gray-500">Ref Count</span><br>{{.entry.RefCount}}</div>
|
||||
<div><span class="text-gray-500">Confidence</span><br>{{printf "%.1f" .entry.LastConfidence}}</div>
|
||||
</div>
|
||||
{{if .entry.Summary}}<div class="mt-4 p-3 bg-gray-800 rounded text-sm">{{.entry.Summary}}</div>{{end}}
|
||||
</div>
|
||||
|
||||
{{if .obs}}
|
||||
<section>
|
||||
<h3 class="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-2">Observation History ({{len .obs}})</h3>
|
||||
<div class="space-y-1">
|
||||
{{range .obs}}
|
||||
<div class="bg-gray-900 rounded px-3 py-2 text-sm flex items-center gap-3">
|
||||
<span class="font-mono text-xs text-gray-500">#{{.ID}}</span>
|
||||
<span class="text-xs text-gray-500">{{humanTime .CollectedAt}}</span>
|
||||
{{if gt .Count 1}}<span class="text-xs text-amber-500" title="seen {{.Count}} times, unchanged">×{{.Count}}</span>{{end}}
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-800 text-gray-400">{{.Trigger}}</span>
|
||||
<span class="text-xs text-gray-500">{{.IngesterVersion}}</span>
|
||||
<span class="text-xs text-gray-500">conf:{{printf "%.1f" .Confidence}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
{{end}}
|
||||
</main>
|
||||
{{template "foot" .}}
|
||||
{{end}}
|
||||
@@ -0,0 +1,283 @@
|
||||
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
|
||||
Reference in New Issue
Block a user