Monorepo for Tangled
Something went wrong. Try again.
6.3 kB · 232 lines
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233package knotmirror
import ( "database/sql" "embed" "encoding/json" "fmt" "html" "html/template" "log/slog" "net/http" "strconv" "time"
"github.com/bluesky-social/indigo/atproto/syntax" "github.com/go-chi/chi/v5" "tangled.org/core/appview/pagination" "tangled.org/core/idresolver" "tangled.org/core/knotmirror/db" "tangled.org/core/knotmirror/models" "tangled.org/core/knotmirror/xrpc")
//go:embed templates/*.htmlvar templateFS embed.FS
const repoPageSize = 20
type AdminServer struct { db *sql.DB resyncer *Resyncer xrpc *xrpc.Xrpc logger *slog.Logger resolver *idresolver.Resolver}
func NewAdminServer(l *slog.Logger, database *sql.DB, resyncer *Resyncer, x *xrpc.Xrpc, resolver *idresolver.Resolver) *AdminServer { return &AdminServer{ db: database, resyncer: resyncer, xrpc: x, logger: l, resolver: resolver, }}
func (s *AdminServer) Router() http.Handler { r := chi.NewRouter() r.Get("/", s.handleIndex()) r.Get("/repos", s.handleRepos()) r.Get("/hosts", s.handleHosts())
r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger()) r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel()) r.Get("/api/inflight", s.handleInflight()) return r}
func (s *AdminServer) handleInflight() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { entries := s.xrpc.Inflight() w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(entries) }}
func funcmap() template.FuncMap { return template.FuncMap{ "add": func(a, b int) int { return a + b }, "sub": func(a, b int) int { return a - b }, "readt": func(ts int64) string { if ts <= 0 { return "n/a" } return time.Unix(ts, 0).Format("2006-01-02 15:04") }, "const": func() map[string]any { return map[string]any{ "AllRepoStates": models.AllRepoStates, "AllHostStatuses": models.AllHostStatuses, } }, }}
func (s *AdminServer) handleIndex() http.HandlerFunc { tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/index.html")) return func(w http.ResponseWriter, r *http.Request) { err := tpl.ExecuteTemplate(w, "base", nil) if err != nil { slog.Error("failed to render", "err", err) } }}
func (s *AdminServer) handleRepos() http.HandlerFunc { tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/repos.html")) return func(w http.ResponseWriter, r *http.Request) { pageNum, _ := strconv.Atoi(r.URL.Query().Get("page")) if pageNum < 1 { pageNum = 1 } page := pagination.Page{ Offset: (pageNum - 1) * repoPageSize, Limit: repoPageSize, }
var ( didInput = r.URL.Query().Get("did") knot = r.URL.Query().Get("knot") state = r.URL.Query().Get("state") name = r.URL.Query().Get("name") )
did := didInput if didInput != "" { if _, err := syntax.ParseDID(didInput); err != nil { // treat as a handle and resolve to DID handle, herr := syntax.ParseHandle(didInput) if herr != nil { http.Error(w, fmt.Sprintf("invalid DID or handle: %s", didInput), http.StatusBadRequest) return } resolved, rerr := s.resolver.ResolveHandle(r.Context(), handle.Normalize()) if rerr != nil { http.Error(w, fmt.Sprintf("could not resolve handle %q: %s", didInput, rerr), http.StatusBadRequest) return } did = resolved.String() } }
repos, err := db.ListRepos(r.Context(), s.db, page, did, knot, state, name) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } counts, err := db.GetRepoCountsByState(r.Context(), s.db) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = tpl.ExecuteTemplate(w, "base", map[string]any{ "Repos": repos, "RepoCounts": counts, "Page": pageNum, "FilterByDid": did, "FilterByKnot": knot, "FilterByState": models.RepoState(state), "FilterByName": name, }) if err != nil { slog.Error("failed to render", "err", err) } }}
func (s *AdminServer) handleHosts() http.HandlerFunc { tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/hosts.html")) return func(w http.ResponseWriter, r *http.Request) { var status = models.HostStatus(r.URL.Query().Get("status")) if status == "" { status = models.HostStatusActive }
hosts, err := db.ListHosts(r.Context(), s.db, status) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } err = tpl.ExecuteTemplate(w, "base", map[string]any{ "Hosts": hosts, "FilterByStatus": models.HostStatus(status), }) if err != nil { slog.Error("failed to render", "err", err) } }}
func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var repoQuery = r.FormValue("repo")
repo, err := syntax.ParseDID(repoQuery) if err != nil { writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) return }
if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil { s.logger.Error("failed to trigger resync job", "err", err) writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) return } writeNotif(w, http.StatusOK, "success") }}
func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { var repoQuery = r.FormValue("repo")
repo, err := syntax.ParseDID(repoQuery) if err != nil { writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) return }
s.resyncer.CancelResyncJob(repo) writeNotif(w, http.StatusOK, "success") }}
func writeNotif(w http.ResponseWriter, status int, msg string) { w.Header().Set("Content-Type", "text/html") w.WriteHeader(status)
class := "info" switch { case status >= 500: class = "error" case status >= 400: class = "warn" }
fmt.Fprintf(w, `<div hx-swap-oob="beforeend:#notifications"><div class="notif %s">%s</div></div>`, class, html.EscapeString(msg), )}