tracker/main.go
// Command tracker serves the TNSU EMS project overview.
//
// Pages are rendered on the server with html/template. A watcher polls the
// JSON data files; on any change it pushes a Server-Sent Event,
// and the browser re-fetches the server-rendered page and swaps the content.
//
// cd tracker && go run .
package main
import (
"bytes"
"context"
"crypto/sha256"
"embed"
"encoding/hex"
"errors"
"flag"
"html/template"
"io/fs"
"log"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"time"
)
//go:embed templates/*.html
var templateFS embed.FS
//go:embed static
var staticFS embed.FS
// staticVersion is a hash of the embedded static files. Pages link to
// /static/<file>?v=<hash>, so the browser can cache CSS and JS for good and
// still gets the new files after a rebuild.
var staticVersion = func() string {
h := sha256.New()
fs.WalkDir(staticFS, "static", func(p string, d fs.DirEntry, err error) error {
if err == nil && !d.IsDir() {
b, _ := staticFS.ReadFile(p)
h.Write([]byte(p))
h.Write(b)
}
return nil
})
return hex.EncodeToString(h.Sum(nil))[:10]
}()
func assetURL(name string) string { return "/static/" + name + "?v=" + staticVersion }
// cacheStatic lets the browser keep versioned static files; anything else is
// revalidated on every request.
func cacheStatic(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("v") == staticVersion {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
h.ServeHTTP(w, r)
})
}
type route struct {
path, page, title string
}
var routes = []route{
{"/", "overview", "ภาพรวม"},
{"/milestones", "milestones", "Milestone & Tasks"},
{"/timeline", "timeline", "Timeline"},
{"/checkpoints", "checkpoints", "ประวัติ Checkpoint"},
{"/tests", "tests", "ผลตรวจ Test"},
{"/problems", "problems", "Problem solving"},
{"/reviews", "reviews", "Wait for review"},
}
func main() {
addr := flag.String("addr", ":8099", "listen address")
dataDir := flag.String("data", "data", "directory with milestones.json, checkpoints.json, tests.json, problems.json")
poll := flag.Duration("poll", time.Second, "how often to check for changes")
flag.StringVar(&repoRoot, "root", "..", "repository root that /files/ may show (refs open there)")
flag.Parse()
absData, _ := filepath.Abs(*dataDir)
store := NewStore(absData)
if _, err, _ := store.Get(); err != nil {
log.Printf("warning: %v", err)
}
hub := NewHub()
mux := newMux(store, hub)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go Watch(ctx, store, hub, absData, *poll)
// No WriteTimeout: /events is a long-lived stream.
// BaseContext ties request contexts to shutdown so open event streams end.
srv := &http.Server{Addr: *addr, Handler: mux, ReadHeaderTimeout: 10 * time.Second,
BaseContext: func(net.Listener) context.Context { return ctx }}
go func() {
<-ctx.Done()
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shutdown)
}()
log.Printf("tracker on http://localhost%s (data: %s)", *addr, absData)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatal(err)
}
}
// newMux wires every route. Pages are rendered on the server for each request.
func newMux(store *Store, hub *Hub) *http.ServeMux {
pages := map[string]*template.Template{}
for _, r := range routes {
pages[r.page] = template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
"templates/layout.html", "templates/partials.html", "templates/"+r.page+".html"))
}
for _, name := range []string{"milestone", "epic", "task", "notfound"} {
pages[name] = template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
"templates/layout.html", "templates/partials.html", "templates/"+name+".html"))
}
notFound := pages["notfound"]
render := func(w http.ResponseWriter, t *template.Template, p Page, status int) {
var buf bytes.Buffer
if err := t.ExecuteTemplate(&buf, "layout", p); err != nil {
log.Printf("render %s: %v", p.Active, err)
http.Error(w, "render error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
buf.WriteTo(w)
}
mux := http.NewServeMux()
registerUseCases(mux, store)
registerMockups(mux, store)
for _, r := range routes {
pattern := "GET " + r.path
if r.path == "/" {
pattern = "GET /{$}"
}
mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) {
p := buildPage(store, r.page, r.title)
if r.page == "tests" {
p.SetTestView(req.URL.Query().Get("view"))
}
if r.page == "timeline" {
tl := buildTimeline(p.Milestones, req.URL.Query().Get("scale"), time.Now())
p.Timeline = &tl
}
if r.page == "reviews" {
p.SetReviewState(req.URL.Query().Get("state"))
p.Saved = req.URL.Query().Get("saved")
}
render(w, pages[r.page], p, http.StatusOK)
})
}
// Milestone -> epic -> task sub-pages. Unknown ids answer 404.
detail := func(page string, sel func(p *Page, r *http.Request) bool) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
p := buildPage(store, page, "")
if !sel(&p, req) {
render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
return
}
render(w, pages[page], p, http.StatusOK)
}
}
mux.HandleFunc("GET /milestones/{mid}", detail("milestone", func(p *Page, r *http.Request) bool {
return p.SelectMilestone(r.PathValue("mid"))
}))
mux.HandleFunc("GET /milestones/{mid}/{eid}", detail("epic", func(p *Page, r *http.Request) bool {
return p.SelectEpic(r.PathValue("mid"), r.PathValue("eid"))
}))
mux.HandleFunc("GET /tasks/{tid}", detail("task", func(p *Page, r *http.Request) bool {
p.Saved = r.URL.Query().Get("saved")
return p.SelectTask(r.PathValue("tid"))
}))
// Review form. Writes go to milestones.json; the watcher then pushes the
// change to every open page.
mux.HandleFunc("POST /reviews/{tid}", func(w http.ResponseWriter, req *http.Request) {
if !sameOrigin(req) {
http.Error(w, "forbidden: the form must be sent from this tracker", http.StatusForbidden)
return
}
req.Body = http.MaxBytesReader(w, req.Body, 64<<10)
if err := req.ParseForm(); err != nil {
http.Error(w, "bad form", http.StatusBadRequest)
return
}
tid := req.PathValue("tid")
sub := ReviewSubmission{
TaskID: tid,
Outcome: req.FormValue("outcome"),
Decision: strings.TrimSpace(req.FormValue("decision")),
Reviewer: strings.TrimSpace(req.FormValue("reviewer")),
Checked: map[int]bool{},
Now: time.Now(),
}
for _, v := range req.Form["checklist"] {
if i, err := strconv.Atoi(v); err == nil {
sub.Checked[i] = true
}
}
fromTask := req.FormValue("return") == "task"
state, err := store.SubmitReview(sub)
var fe formError
switch {
case err == nil:
target := "/reviews?state=" + url.QueryEscape(state) + "&saved=" + url.QueryEscape(tid) + "#" + url.PathEscape(tid)
if fromTask {
target = taskHref(tid) + "?saved=" + url.QueryEscape(tid)
}
http.Redirect(w, req, target, http.StatusSeeOther)
case errors.Is(err, errTaskNotFound):
render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
case errors.As(err, &fe):
// Show the same page again with the reviewer's input kept.
page := "reviews"
p := buildPage(store, page, "Wait for review")
p.Form = &ReviewForm{TaskID: tid, Outcome: sub.Outcome, Decision: sub.Decision, Reviewer: sub.Reviewer, Checked: sub.Checked, Error: fe.Error()}
if fromTask && p.SelectTask(tid) {
page = "task"
} else if t, ok := p.tasks[tid]; ok && t.Review != nil {
p.SetReviewState(t.Review.StateOrTodo())
} else {
p.SetReviewState(ReviewTodo)
}
render(w, pages[page], p, http.StatusBadRequest)
default:
log.Printf("review %s: %v", tid, err)
http.Error(w, "บันทึกไม่สำเร็จ: "+err.Error(), http.StatusInternalServerError)
}
})
// File viewer for refs: opens repo files in a new tab.
fileTpl := template.Must(template.New("").Funcs(templateFuncs()).ParseFS(templateFS,
"templates/partials.html", "templates/file.html"))
mux.HandleFunc("GET /files/{path...}", filesHandler(fileTpl, func(w http.ResponseWriter) {
render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
}))
mux.Handle("GET /events", hub)
static, _ := fs.Sub(staticFS, "static")
mux.Handle("GET /static/", cacheStatic(http.StripPrefix("/static/", http.FileServerFS(static))))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.Write([]byte("ok")) })
mux.HandleFunc("/", func(w http.ResponseWriter, req *http.Request) {
render(w, notFound, buildPage(store, "", "ไม่พบหน้า"), http.StatusNotFound)
})
return mux
}
// sameOrigin accepts a form post only when its Origin (or, failing that,
// Referer) is this server, so other sites cannot submit reviews through a
// visitor's browser (CSRF).
func sameOrigin(r *http.Request) bool {
check := func(raw string) bool {
u, err := url.Parse(raw)
return err == nil && u.Host == r.Host
}
if o := r.Header.Get("Origin"); o != "" {
return check(o)
}
if ref := r.Header.Get("Referer"); ref != "" {
return check(ref)
}
return false
}