Monorepo for Tangled tangled.org
5

Configure Feed

Select the types of activity you want to include in your feed.

at icy/qmlqxq 6.3 kB View raw
1package knotmirror 2 3import ( 4 "database/sql" 5 "embed" 6 "encoding/json" 7 "fmt" 8 "html" 9 "html/template" 10 "log/slog" 11 "net/http" 12 "strconv" 13 "time" 14 15 "github.com/bluesky-social/indigo/atproto/syntax" 16 "github.com/go-chi/chi/v5" 17 "tangled.org/core/appview/pagination" 18 "tangled.org/core/idresolver" 19 "tangled.org/core/knotmirror/db" 20 "tangled.org/core/knotmirror/models" 21 "tangled.org/core/knotmirror/xrpc" 22) 23 24//go:embed templates/*.html 25var templateFS embed.FS 26 27const repoPageSize = 20 28 29type AdminServer struct { 30 db *sql.DB 31 resyncer *Resyncer 32 xrpc *xrpc.Xrpc 33 logger *slog.Logger 34 resolver *idresolver.Resolver 35} 36 37func NewAdminServer(l *slog.Logger, database *sql.DB, resyncer *Resyncer, x *xrpc.Xrpc, resolver *idresolver.Resolver) *AdminServer { 38 return &AdminServer{ 39 db: database, 40 resyncer: resyncer, 41 xrpc: x, 42 logger: l, 43 resolver: resolver, 44 } 45} 46 47func (s *AdminServer) Router() http.Handler { 48 r := chi.NewRouter() 49 r.Get("/", s.handleIndex()) 50 r.Get("/repos", s.handleRepos()) 51 r.Get("/hosts", s.handleHosts()) 52 53 r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger()) 54 r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel()) 55 r.Get("/api/inflight", s.handleInflight()) 56 return r 57} 58 59func (s *AdminServer) handleInflight() http.HandlerFunc { 60 return func(w http.ResponseWriter, r *http.Request) { 61 entries := s.xrpc.Inflight() 62 w.Header().Set("Content-Type", "application/json") 63 _ = json.NewEncoder(w).Encode(entries) 64 } 65} 66 67func funcmap() template.FuncMap { 68 return template.FuncMap{ 69 "add": func(a, b int) int { return a + b }, 70 "sub": func(a, b int) int { return a - b }, 71 "readt": func(ts int64) string { 72 if ts <= 0 { 73 return "n/a" 74 } 75 return time.Unix(ts, 0).Format("2006-01-02 15:04") 76 }, 77 "const": func() map[string]any { 78 return map[string]any{ 79 "AllRepoStates": models.AllRepoStates, 80 "AllHostStatuses": models.AllHostStatuses, 81 } 82 }, 83 } 84} 85 86func (s *AdminServer) handleIndex() http.HandlerFunc { 87 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/index.html")) 88 return func(w http.ResponseWriter, r *http.Request) { 89 err := tpl.ExecuteTemplate(w, "base", nil) 90 if err != nil { 91 slog.Error("failed to render", "err", err) 92 } 93 } 94} 95 96func (s *AdminServer) handleRepos() http.HandlerFunc { 97 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/repos.html")) 98 return func(w http.ResponseWriter, r *http.Request) { 99 pageNum, _ := strconv.Atoi(r.URL.Query().Get("page")) 100 if pageNum < 1 { 101 pageNum = 1 102 } 103 page := pagination.Page{ 104 Offset: (pageNum - 1) * repoPageSize, 105 Limit: repoPageSize, 106 } 107 108 var ( 109 didInput = r.URL.Query().Get("did") 110 knot = r.URL.Query().Get("knot") 111 state = r.URL.Query().Get("state") 112 name = r.URL.Query().Get("name") 113 ) 114 115 did := didInput 116 if didInput != "" { 117 if _, err := syntax.ParseDID(didInput); err != nil { 118 // treat as a handle and resolve to DID 119 handle, herr := syntax.ParseHandle(didInput) 120 if herr != nil { 121 http.Error(w, fmt.Sprintf("invalid DID or handle: %s", didInput), http.StatusBadRequest) 122 return 123 } 124 resolved, rerr := s.resolver.ResolveHandle(r.Context(), handle.Normalize()) 125 if rerr != nil { 126 http.Error(w, fmt.Sprintf("could not resolve handle %q: %s", didInput, rerr), http.StatusBadRequest) 127 return 128 } 129 did = resolved.String() 130 } 131 } 132 133 repos, err := db.ListRepos(r.Context(), s.db, page, did, knot, state, name) 134 if err != nil { 135 http.Error(w, err.Error(), http.StatusInternalServerError) 136 return 137 } 138 counts, err := db.GetRepoCountsByState(r.Context(), s.db) 139 if err != nil { 140 http.Error(w, err.Error(), http.StatusInternalServerError) 141 return 142 } 143 err = tpl.ExecuteTemplate(w, "base", map[string]any{ 144 "Repos": repos, 145 "RepoCounts": counts, 146 "Page": pageNum, 147 "FilterByDid": did, 148 "FilterByKnot": knot, 149 "FilterByState": models.RepoState(state), 150 "FilterByName": name, 151 }) 152 if err != nil { 153 slog.Error("failed to render", "err", err) 154 } 155 } 156} 157 158func (s *AdminServer) handleHosts() http.HandlerFunc { 159 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/hosts.html")) 160 return func(w http.ResponseWriter, r *http.Request) { 161 var status = models.HostStatus(r.URL.Query().Get("status")) 162 if status == "" { 163 status = models.HostStatusActive 164 } 165 166 hosts, err := db.ListHosts(r.Context(), s.db, status) 167 if err != nil { 168 http.Error(w, err.Error(), http.StatusInternalServerError) 169 return 170 } 171 err = tpl.ExecuteTemplate(w, "base", map[string]any{ 172 "Hosts": hosts, 173 "FilterByStatus": models.HostStatus(status), 174 }) 175 if err != nil { 176 slog.Error("failed to render", "err", err) 177 } 178 } 179} 180 181func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc { 182 return func(w http.ResponseWriter, r *http.Request) { 183 var repoQuery = r.FormValue("repo") 184 185 repo, err := syntax.ParseDID(repoQuery) 186 if err != nil { 187 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 188 return 189 } 190 191 if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil { 192 s.logger.Error("failed to trigger resync job", "err", err) 193 writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 194 return 195 } 196 writeNotif(w, http.StatusOK, "success") 197 } 198} 199 200func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc { 201 return func(w http.ResponseWriter, r *http.Request) { 202 var repoQuery = r.FormValue("repo") 203 204 repo, err := syntax.ParseDID(repoQuery) 205 if err != nil { 206 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery)) 207 return 208 } 209 210 s.resyncer.CancelResyncJob(repo) 211 writeNotif(w, http.StatusOK, "success") 212 } 213} 214 215func writeNotif(w http.ResponseWriter, status int, msg string) { 216 w.Header().Set("Content-Type", "text/html") 217 w.WriteHeader(status) 218 219 class := "info" 220 switch { 221 case status >= 500: 222 class = "error" 223 case status >= 400: 224 class = "warn" 225 } 226 227 fmt.Fprintf(w, 228 `<div hx-swap-oob="beforeend:#notifications"><div class="notif %s">%s</div></div>`, 229 class, 230 html.EscapeString(msg), 231 ) 232}