A vibe coded tangled fork which supports pijul.
at sl/knotmirror 185 lines 4.9 kB view raw
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}