A vibe coded tangled fork which supports pijul.
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 // not sure how to use these. should we vibe-code the admin page with React?
46 r.Post("/api/triggerRepoResync", s.handleRepoResyncTrigger())
47 r.Post("/api/cancelRepoResync", s.handleRepoResyncCancel())
48 r.Post("/api/testNotif", s.handleTestNotif)
49 return r
50}
51
52func funcmap() template.FuncMap {
53 return template.FuncMap{
54 "add": func(a, b int) int { return a + b },
55 "sub": func(a, b int) int { return a - b },
56 "readt": func(ts int64) string {
57 if ts <= 0 {
58 return "n/a"
59 }
60 return time.Unix(ts, 0).Format("2006-01-02 15:04")
61 },
62 "const": func() map[string]any {
63 return map[string]any{
64 "AllRepoStates": models.AllRepoStates,
65 "AllHostStatuses": models.AllHostStatuses,
66 }
67 },
68 }
69}
70
71func (s *AdminServer) handleRepos() http.HandlerFunc {
72 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/repos.html"))
73 return func(w http.ResponseWriter, r *http.Request) {
74 pageNum, _ := strconv.Atoi(r.URL.Query().Get("page"))
75 if pageNum < 1 {
76 pageNum = 1
77 }
78 page := pagination.Page{
79 Offset: (pageNum - 1) * repoPageSize,
80 Limit: repoPageSize,
81 }
82
83 var (
84 did = r.URL.Query().Get("did")
85 knot = r.URL.Query().Get("knot")
86 state = r.URL.Query().Get("state")
87 )
88
89 repos, err := db.ListRepos(r.Context(), s.db, page, did, knot, state)
90 if err != nil {
91 http.Error(w, err.Error(), http.StatusInternalServerError)
92 }
93 counts, err := db.GetRepoCountsByState(r.Context(), s.db)
94 if err != nil {
95 http.Error(w, err.Error(), http.StatusInternalServerError)
96 }
97 err = tpl.ExecuteTemplate(w, "base", map[string]any{
98 "Repos": repos,
99 "RepoCounts": counts,
100 "Page": pageNum,
101 "FilterByDid": did,
102 "FilterByKnot": knot,
103 "FilterByState": models.RepoState(state),
104 })
105 if err != nil {
106 slog.Error("failed to render", "err", err)
107 }
108 }
109}
110
111func (s *AdminServer) handleHosts() http.HandlerFunc {
112 tpl := template.Must(template.New("").Funcs(funcmap()).ParseFS(templateFS, "templates/base.html", "templates/hosts.html"))
113 return func(w http.ResponseWriter, r *http.Request) {
114 var status = r.URL.Query().Get("status")
115
116 hosts, err := db.ListHosts(r.Context(), s.db, models.HostStatus(status))
117 if err != nil {
118 http.Error(w, err.Error(), http.StatusInternalServerError)
119 }
120 err = tpl.ExecuteTemplate(w, "base", map[string]any{
121 "Hosts": hosts,
122 "FilterByStatus": models.HostStatus(status),
123 })
124 if err != nil {
125 slog.Error("failed to render", "err", err)
126 }
127 }
128}
129
130func (s *AdminServer) handleRepoResyncTrigger() http.HandlerFunc {
131 return func(w http.ResponseWriter, r *http.Request) {
132 var repoQuery = r.FormValue("repo")
133
134 repo, err := syntax.ParseATURI(repoQuery)
135 if err != nil || repo.RecordKey() == "" {
136 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
137 return
138 }
139
140 if err := s.resyncer.TriggerResyncJob(r.Context(), repo); err != nil {
141 s.logger.Error("failed to trigger resync job", "err", err)
142 writeNotif(w, http.StatusInternalServerError, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
143 return
144 }
145 writeNotif(w, http.StatusOK, "success")
146 }
147}
148
149func (s *AdminServer) handleRepoResyncCancel() http.HandlerFunc {
150 return func(w http.ResponseWriter, r *http.Request) {
151 var repoQuery = r.FormValue("repo")
152
153 repo, err := syntax.ParseATURI(repoQuery)
154 if err != nil || repo.RecordKey() == "" {
155 writeNotif(w, http.StatusBadRequest, fmt.Sprintf("repo parameter invalid: %s", repoQuery))
156 return
157 }
158
159 s.resyncer.CancelResyncJob(repo)
160 writeNotif(w, http.StatusOK, "success")
161 }
162}
163
164func (s *AdminServer) handleTestNotif(w http.ResponseWriter, r *http.Request) {
165 writeNotif(w, http.StatusOK, "new notifi")
166}
167
168func writeNotif(w http.ResponseWriter, status int, msg string) {
169 w.Header().Set("Content-Type", "text/html")
170 w.WriteHeader(status)
171
172 class := "info"
173 switch {
174 case status >= 500:
175 class = "error"
176 case status >= 400:
177 class = "warn"
178 }
179
180 fmt.Fprintf(w,
181 `<div hx-swap-oob="beforeend:#notifications"><div class="notif %s">%s</div></div>`,
182 class,
183 html.EscapeString(msg),
184 )
185}