Monorepo for Tangled
at master 182 lines 4.7 kB view raw
1package knotmirror 2 3import ( 4 "database/sql" 5 "embed" 6 "fmt" 7 "html" 8 "html/template" 9 "log/slog" 10 "net/http" 11 "strconv" 12 "time" 13 14 "github.com/bluesky-social/indigo/atproto/syntax" 15 "github.com/go-chi/chi/v5" 16 "tangled.org/core/appview/pagination" 17 "tangled.org/core/knotmirror/db" 18 "tangled.org/core/knotmirror/models" 19) 20 21//go:embed templates/*.html 22var templateFS embed.FS 23 24const repoPageSize = 20 25 26type AdminServer struct { 27 db *sql.DB 28 resyncer *Resyncer 29 logger *slog.Logger 30} 31 32func NewAdminServer(l *slog.Logger, database *sql.DB, resyncer *Resyncer) *AdminServer { 33 return &AdminServer{ 34 db: database, 35 resyncer: resyncer, 36 logger: l, 37 } 38} 39 40func (s *AdminServer) Router() http.Handler { 41 r := chi.NewRouter() 42 r.Get("/repos", s.handleRepos()) 43 r.Get("/hosts", s.handleHosts()) 44 45 r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger()) 46 r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel()) 47 return r 48} 49 50func funcmap() template.FuncMap { 51 return template.FuncMap{ 52 "add": func(a, b int) int { return a + b }, 53 "sub": func(a, b int) int { return a - b }, 54 "readt": func(ts int64) string { 55 if ts <= 0 { 56 return "n/a" 57 } 58 return time.Unix(ts, 0).Format("2006-01-02 15:04") 59 }, 60 "const": func() map[string]any { 61 return map[string]any{ 62 "AllRepoStates": models.AllRepoStates, 63 "AllHostStatuses": models.AllHostStatuses, 64 } 65 }, 66 } 67} 68 69func (s *AdminServer) handleRepos() http.HandlerFunc { 70 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/repos.html")) 71 return func(w http.ResponseWriter, r *http.Request) { 72 pageNum, _ := strconv.Atoi(r.URL.Query().Get("page")) 73 if pageNum < 1 { 74 pageNum = 1 75 } 76 page := pagination.Page{ 77 Offset: (pageNum - 1) * repoPageSize, 78 Limit: repoPageSize, 79 } 80 81 var ( 82 did = r.URL.Query().Get("did") 83 knot = r.URL.Query().Get("knot") 84 state = r.URL.Query().Get("state") 85 ) 86 87 repos, err := db.ListRepos(r.Context(), s.db, page, did, knot, state) 88 if err != nil { 89 http.Error(w, err.Error(), http.StatusInternalServerError) 90 } 91 counts, err := db.GetRepoCountsByState(r.Context(), s.db) 92 if err != nil { 93 http.Error(w, err.Error(), http.StatusInternalServerError) 94 } 95 err = tpl.ExecuteTemplate(w, "base", map[string]any{ 96 "Repos": repos, 97 "RepoCounts": counts, 98 "Page": pageNum, 99 "FilterByDid": did, 100 "FilterByKnot": knot, 101 "FilterByState": models.RepoState(state), 102 }) 103 if err != nil { 104 slog.Error("failed to render", "err", err) 105 } 106 } 107} 108 109func (s *AdminServer) handleHosts() http.HandlerFunc { 110 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/hosts.html")) 111 return func(w http.ResponseWriter, r *http.Request) { 112 var status = models.HostStatus(r.URL.Query().Get("status")) 113 if status == "" { 114 status = models.HostStatusActive 115 } 116 117 hosts, err := db.ListHosts(r.Context(), s.db, status) 118 if err != nil { 119 http.Error(w, err.Error(), http.StatusInternalServerError) 120 } 121 err = tpl.ExecuteTemplate(w, "base", map[string]any{ 122 "Hosts": hosts, 123 "FilterByStatus": models.HostStatus(status), 124 }) 125 if err != nil { 126 slog.Error("failed to render", "err", err) 127 } 128 } 129} 130 131func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc { 132 return func(w http.ResponseWriter, r *http.Request) { 133 var repoQuery = r.FormValue("repo") 134 135 repo, err := syntax.ParseATURI(repoQuery) 136 if err != nil || repo.RecordKey() == "" { 137 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 138 return 139 } 140 141 if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil { 142 s.logger.Error("failed to trigger resync job", "err", err) 143 writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 144 return 145 } 146 writeNotif(w, http.StatusOK, "success") 147 } 148} 149 150func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc { 151 return func(w http.ResponseWriter, r *http.Request) { 152 var repoQuery = r.FormValue("repo") 153 154 repo, err := syntax.ParseATURI(repoQuery) 155 if err != nil || repo.RecordKey() == "" { 156 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 157 return 158 } 159 160 s.resyncer.CancelResyncJob(repo) 161 writeNotif(w, http.StatusOK, "success") 162 } 163} 164 165func writeNotif(w http.ResponseWriter, status int, msg string) { 166 w.Header().Set("Content-Type", "text/html") 167 w.WriteHeader(status) 168 169 class := "info" 170 switch { 171 case status >= 500: 172 class = "error" 173 case status >= 400: 174 class = "warn" 175 } 176 177 fmt.Fprintf(w, 178 `<div hx-swap-oob="beforeend:#notifications"><div class="notif %s">%s</div></div>`, 179 class, 180 html.EscapeString(msg), 181 ) 182}