Monorepo for Tangled
tangled.org
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.ParseATURI(repoQuery)
186 if err != nil || repo.RecordKey() == "" {
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.ParseATURI(repoQuery)
205 if err != nil || repo.RecordKey() == "" {
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}