forked from
tangled.org/core
Monorepo for Tangled
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}