feat: add Web UI server with basic-auth and artist dashboard
This commit is contained in:
@@ -119,10 +119,10 @@ name collisions.)
|
|||||||
- [x] run tests - must pass before task 7
|
- [x] run tests - must pass before task 7
|
||||||
|
|
||||||
### Task 7: Web UI — server + auth + dashboard
|
### Task 7: Web UI — server + auth + dashboard
|
||||||
- [ ] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
|
- [x] create `internal/web` with `Server` (net/http), `//go:embed` templates, basic-auth middleware using `ServerConfig.Username/Password`
|
||||||
- [ ] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local)
|
- [x] dashboard handler: list monitored artists with missing-release counts (join scanner result / external vs local)
|
||||||
- [ ] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered
|
- [x] write tests: unauthenticated request → 401; authenticated → 200 with expected artist rendered
|
||||||
- [ ] run tests - must pass before task 8
|
- [x] run tests - must pass before task 8
|
||||||
|
|
||||||
### Task 8: Web UI — artist detail + archive + ignore actions
|
### Task 8: Web UI — artist detail + archive + ignore actions
|
||||||
- [ ] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons
|
- [ ] artist page: local albums (Subsonic) + found missing (MB cache) + ignore buttons
|
||||||
|
|||||||
55
internal/web/handlers.go
Normal file
55
internal/web/handlers.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
|
"naviwatcher/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed templates/*.html
|
||||||
|
var templates embed.FS
|
||||||
|
|
||||||
|
// dashboardTmpl is parsed once at package init from the embedded templates.
|
||||||
|
var dashboardTmpl = template.Must(template.ParseFS(templates, "templates/dashboard.html"))
|
||||||
|
|
||||||
|
// handleDashboard renders the artist dashboard: monitored artists with their
|
||||||
|
// missing-release counts.
|
||||||
|
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only serve the index at "/" (and not e.g. "/favicon.ico" fallthroughs).
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
threshold := s.defaultThreshold()
|
||||||
|
data, err := s.buildDashboardData(r.Context(), threshold)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to build dashboard: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
if err := dashboardTmpl.Execute(w, data); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("template render error: %v", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultThreshold returns the fuzzy threshold to use when scanning for the
|
||||||
|
// dashboard. It is currently fixed at the engine default; later wiring can
|
||||||
|
// thread the configured threshold through the Server if desired.
|
||||||
|
func (s *Server) defaultThreshold() float64 {
|
||||||
|
return 0 // 0 → scanner.DefaultThreshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServerWithConfig is a convenience constructor that accepts the full
|
||||||
|
// *config.Config (mirroring how the app constructs other components). It
|
||||||
|
// forwards the server sub-config and derives uiBaseURL from host/port.
|
||||||
|
func NewServerWithConfig(cfg *config.Config, db *database.DB) *Server {
|
||||||
|
// Build a best-effort external base URL from the server config.
|
||||||
|
base := fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port)
|
||||||
|
return NewServer(&cfg.Server, db, base)
|
||||||
|
}
|
||||||
172
internal/web/server.go
Normal file
172
internal/web/server.go
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// Package web implements the NaviWatcher HTTP dashboard: a net/http server with
|
||||||
|
// embedded templates, basic-auth protection, and a dashboard that lists
|
||||||
|
// monitored artists with their missing-release counts.
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
|
"naviwatcher/internal/database"
|
||||||
|
"naviwatcher/internal/scanner"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Server is the NaviWatcher web dashboard HTTP server.
|
||||||
|
type Server struct {
|
||||||
|
cfg *config.ServerConfig
|
||||||
|
db *database.DB
|
||||||
|
mux *http.ServeMux
|
||||||
|
|
||||||
|
// uiBaseURL is the externally reachable base URL of the dashboard (scheme +
|
||||||
|
// host), used to build links in notifications and elsewhere. Optional.
|
||||||
|
uiBaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewServer constructs a dashboard Server bound to the given DB and server
|
||||||
|
// config. uiBaseURL is the externally reachable origin (e.g.
|
||||||
|
// "http://localhost:8080") used when rendering absolute links; pass "" to omit.
|
||||||
|
func NewServer(cfg *config.ServerConfig, db *database.DB, uiBaseURL string) *Server {
|
||||||
|
s := &Server{
|
||||||
|
cfg: cfg,
|
||||||
|
db: db,
|
||||||
|
uiBaseURL: strings.TrimRight(uiBaseURL, "/"),
|
||||||
|
}
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("/", s.handleDashboard)
|
||||||
|
s.mux = mux
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler returns the http.Handler (auth-wrapped mux) for the server. It is
|
||||||
|
// exported so callers can embed the dashboard in a larger handler tree or test
|
||||||
|
// it directly via httptest.
|
||||||
|
func (s *Server) Handler() http.Handler {
|
||||||
|
return s.authMiddleware(s.mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addr returns the listen address ("host:port") for this server.
|
||||||
|
func (s *Server) Addr() string {
|
||||||
|
return fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins listening and serving until the context is cancelled, then shuts
|
||||||
|
// down gracefully. It returns any unrecoverable serve error (a clean shutdown
|
||||||
|
// due to ctx cancellation returns nil).
|
||||||
|
func (s *Server) Start(ctx context.Context) error {
|
||||||
|
srv := &http.Server{
|
||||||
|
Addr: s.Addr(),
|
||||||
|
Handler: s.Handler(),
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-ctx.Done()
|
||||||
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
|
log.Printf("web server shutdown error: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Web UI listening on %s", s.Addr())
|
||||||
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
|
return fmt.Errorf("web server serve: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authMiddleware enforces HTTP Basic auth per RFC 7617 using a constant-time
|
||||||
|
// comparison of the base64-encoded "user:pass" credential. When either
|
||||||
|
// Username or Password is empty, auth is disabled (useful for local/dev).
|
||||||
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
|
user := s.cfg.Username
|
||||||
|
pass := s.cfg.Password
|
||||||
|
if user == "" || pass == "" {
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
// Precompute the expected Authorization header value once.
|
||||||
|
want := "Basic " + base64.StdEncoding.EncodeToString([]byte(user+":"+pass))
|
||||||
|
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
header := r.Header.Get("Authorization")
|
||||||
|
if header == "" {
|
||||||
|
unauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Constant-time compare of the full header value.
|
||||||
|
if subtle.ConstantTimeCompare([]byte(header), []byte(want)) != 1 {
|
||||||
|
unauthorized(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// unauthorized writes a 401 with a Basic auth challenge.
|
||||||
|
func unauthorized(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="NaviWatcher"`)
|
||||||
|
w.WriteHeader(http.StatusUnauthorized)
|
||||||
|
_, _ = w.Write([]byte("401 Unauthorized\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArtistSummary is the dashboard projection of a single monitored artist and
|
||||||
|
// its missing-release count.
|
||||||
|
type ArtistSummary struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
MBID string
|
||||||
|
MissingCount int
|
||||||
|
Monitored bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardData is the view model passed to the dashboard template.
|
||||||
|
type DashboardData struct {
|
||||||
|
Artists []ArtistSummary
|
||||||
|
// TotalMissing is the sum of all artists' missing counts.
|
||||||
|
TotalMissing int
|
||||||
|
// UIBaseURL is the externally reachable origin, for building links.
|
||||||
|
UIBaseURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDashboardData computes the dashboard view model: every monitored artist
|
||||||
|
// joined with its current missing-release count (from the scanner).
|
||||||
|
func (s *Server) buildDashboardData(ctx context.Context, threshold float64) (*DashboardData, error) {
|
||||||
|
settings, err := database.GetAllArtistSettings(s.db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("load artist settings: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute missing releases once and group by artist.
|
||||||
|
missing, err := scanner.ScanAll(ctx, s.db, threshold)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("scan: %w", err)
|
||||||
|
}
|
||||||
|
missingByArtist := make(map[string]int)
|
||||||
|
for _, m := range missing {
|
||||||
|
missingByArtist[m.ArtistID]++
|
||||||
|
}
|
||||||
|
|
||||||
|
data := &DashboardData{UIBaseURL: s.uiBaseURL}
|
||||||
|
for _, a := range settings {
|
||||||
|
if !a.Monitored {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count := missingByArtist[a.ID]
|
||||||
|
data.Artists = append(data.Artists, ArtistSummary{
|
||||||
|
ID: a.ID,
|
||||||
|
Name: a.Name,
|
||||||
|
MBID: a.MBID,
|
||||||
|
MissingCount: count,
|
||||||
|
Monitored: true,
|
||||||
|
})
|
||||||
|
data.TotalMissing += count
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
180
internal/web/server_test.go
Normal file
180
internal/web/server_test.go
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"naviwatcher/internal/config"
|
||||||
|
"naviwatcher/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedArtist inserts an artist_settings row and returns its ID.
|
||||||
|
func seedArtist(t *testing.T, db *database.DB, id, name, mbid string, monitored bool) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.SaveArtistSettings(db, &database.ArtistSettings{
|
||||||
|
ID: id,
|
||||||
|
Name: name,
|
||||||
|
MBID: mbid,
|
||||||
|
Monitored: monitored,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed artist %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedLocalAlbum inserts a local_albums row.
|
||||||
|
func seedLocalAlbum(t *testing.T, db *database.DB, id, artistID, title string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.SaveLocalAlbum(db, &database.LocalAlbum{ID: id, ArtistID: artistID, Title: title}); err != nil {
|
||||||
|
t.Fatalf("seed local album %s: %v", id, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedExternalRelease inserts an external_releases row (not ignored).
|
||||||
|
func seedExternalRelease(t *testing.T, db *database.DB, rgid, artistID, title string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := database.SaveExternalRelease(db, &database.ExternalRelease{
|
||||||
|
RGID: rgid,
|
||||||
|
ArtistID: artistID,
|
||||||
|
Title: title,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("seed external release %s: %v", rgid, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newServer(t *testing.T, user, pass string) (*Server, *database.DB) {
|
||||||
|
t.Helper()
|
||||||
|
db, err := database.New(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
cfg := &config.ServerConfig{Host: "0.0.0.0", Port: 8080, Username: user, Password: pass}
|
||||||
|
s := NewServer(cfg, db, "http://ui.example")
|
||||||
|
return s, db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_Authenticated200(t *testing.T) {
|
||||||
|
s, db := newServer(t, "admin", "secret")
|
||||||
|
seedArtist(t, db, "a1", "Radiohead", "mbid-1", true)
|
||||||
|
seedLocalAlbum(t, db, "l1", "a1", "OK Computer")
|
||||||
|
// One missing release (no local album matches "Kid A").
|
||||||
|
seedExternalRelease(t, db, "r1", "a1", "Kid A")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if !strings.Contains(body, "Radiohead") {
|
||||||
|
t.Errorf("expected artist name in body, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "1") {
|
||||||
|
t.Errorf("expected missing count rendered, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "mbid-1") {
|
||||||
|
t.Errorf("expected MBID rendered, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_Unauthenticated401(t *testing.T) {
|
||||||
|
s, _ := newServer(t, "admin", "secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Header().Get("WWW-Authenticate"), "Basic") {
|
||||||
|
t.Errorf("expected Basic auth challenge, got headers: %v", rec.Header())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_WrongPassword401(t *testing.T) {
|
||||||
|
s, _ := newServer(t, "admin", "secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("admin", "wrong")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("expected 401 for wrong password, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_NoAuthWhenDisabled(t *testing.T) {
|
||||||
|
// When username or password is empty, auth is bypassed.
|
||||||
|
s, db := newServer(t, "", "")
|
||||||
|
seedArtist(t, db, "a1", "Boards of Canada", "", true)
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200 when auth disabled, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "Boards of Canada") {
|
||||||
|
t.Errorf("expected artist rendered, got:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_SkipsUnmonitored(t *testing.T) {
|
||||||
|
s, db := newServer(t, "admin", "secret")
|
||||||
|
seedArtist(t, db, "mon", "Monitored Artist", "", true)
|
||||||
|
seedArtist(t, db, "unmon", "Unmonitored Artist", "", false)
|
||||||
|
seedExternalRelease(t, db, "r1", "unmon", "Should not appear")
|
||||||
|
seedExternalRelease(t, db, "r2", "mon", "Missing here")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
body := rec.Body.String()
|
||||||
|
if strings.Contains(body, "Unmonitored Artist") {
|
||||||
|
t.Errorf("unmonitored artist should not appear, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Monitored Artist") {
|
||||||
|
t.Errorf("monitored artist should appear, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_EmptyState(t *testing.T) {
|
||||||
|
s, _ := newServer(t, "admin", "secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||||
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), "No monitored artists") {
|
||||||
|
t.Errorf("expected empty-state message, got:\n%s", rec.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDashboard_NotFoundForOtherPaths(t *testing.T) {
|
||||||
|
s, _ := newServer(t, "admin", "secret")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
|
||||||
|
req.SetBasicAuth("admin", "secret")
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("expected 404 for non-root path, got %d", rec.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
49
internal/web/templates/dashboard.html
Normal file
49
internal/web/templates/dashboard.html
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>NaviWatcher — Dashboard</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; margin: 2rem; color: #1a1a1a; }
|
||||||
|
h1 { margin-bottom: 0.25rem; }
|
||||||
|
.sub { color: #666; margin-bottom: 1.5rem; }
|
||||||
|
table { border-collapse: collapse; width: 100%; }
|
||||||
|
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #e2e2e2; }
|
||||||
|
th { background: #f6f6f6; }
|
||||||
|
.count { font-weight: bold; }
|
||||||
|
.count-zero { color: #2e7d32; }
|
||||||
|
.count-missing { color: #c62828; }
|
||||||
|
.empty { color: #888; font-style: italic; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>NaviWatcher</h1>
|
||||||
|
<div class="sub">Monitored artists: {{ len .Artists }} · Missing releases: {{ .TotalMissing }}</div>
|
||||||
|
|
||||||
|
{{ if .Artists }}
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Artist</th>
|
||||||
|
<th>Missing</th>
|
||||||
|
<th>MusicBrainz</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{ range .Artists }}
|
||||||
|
<tr>
|
||||||
|
<td>{{ .Name }}</td>
|
||||||
|
<td class="count {{ if eq .MissingCount 0 }}count-zero{{ else }}count-missing{{ end }}">
|
||||||
|
{{ .MissingCount }}
|
||||||
|
</td>
|
||||||
|
<td>{{ if .MBID }}{{ .MBID }}{{ else }}<span class="empty">—</span>{{ end }}</td>
|
||||||
|
</tr>
|
||||||
|
{{ end }}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{{ else }}
|
||||||
|
<p class="empty">No monitored artists yet. Run a sync to populate the dashboard.</p>
|
||||||
|
{{ end }}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user