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