feat: Admin is a left rail with how many wait on you in each queue, and the Overview opens with a verdict, the queues that need you and since when, then the clear ones on one line (M503 #5605)
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m21s
release / go (push) Successful in 1m37s
release / integration (push) Successful in 5m10s
release / android (push) Successful in 5m16s
release / Build signed APK (releases and dev) (push) Successful in 5m27s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 24s
release / Verify release artifacts (tag releases only) (push) Skipped
- GET /api/admin/summary: every admin queue (requests, reported tracks, playback errors, duplicates, suspect sources, missing files) with its count and the age of its oldest item, in one query. - The admin layout drops the 11-tab strip for SectionRail, as Settings and the Collection have, with each queue's count as its badge; the counts refresh on each admin navigation and every 30s. - The Overview leads with "Nothing needs you." or "N things need you.", lists each waiting queue with "oldest 3d ago" and a link to it, counts a failed library scan as a problem, and names the empty queues on one line. The request and quarantine previews and the stat tiles are gone; those queues are worked on their own pages. A failed summary says so with Try again instead of reading as all-clear. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// adminQueueView is one queue that waits on a person: how many, and since
|
||||
// when the oldest of them has waited (null when the queue is empty).
|
||||
type adminQueueView struct {
|
||||
Count int64 `json:"count"`
|
||||
Since *string `json:"since"`
|
||||
}
|
||||
|
||||
// adminSummaryView is what the admin rail and the Overview's verdict read
|
||||
// (#5605): every queue an admin acts on, in one response, so each admin page
|
||||
// pays one cheap read for its rail.
|
||||
type adminSummaryView struct {
|
||||
Requests adminQueueView `json:"requests"`
|
||||
Quarantined adminQueueView `json:"quarantined"`
|
||||
PlaybackErrors adminQueueView `json:"playback_errors"`
|
||||
Duplicates adminQueueView `json:"duplicates"`
|
||||
Suspect adminQueueView `json:"suspect"`
|
||||
Missing adminQueueView `json:"missing"`
|
||||
}
|
||||
|
||||
func queueView(count int64, since pgtype.Timestamptz) adminQueueView {
|
||||
v := adminQueueView{Count: count}
|
||||
if count > 0 && since.Valid {
|
||||
s := formatTimestamp(since)
|
||||
v.Since = &s
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func adminSummaryFrom(row dbq.AdminSummaryRow) adminSummaryView {
|
||||
return adminSummaryView{
|
||||
Requests: queueView(row.Requests, row.RequestsSince),
|
||||
Quarantined: queueView(row.Quarantined, row.QuarantinedSince),
|
||||
PlaybackErrors: queueView(row.PlaybackErrors, row.PlaybackErrorsSince),
|
||||
Duplicates: queueView(row.Duplicates, row.DuplicatesSince),
|
||||
Suspect: queueView(row.Suspect, row.SuspectSince),
|
||||
Missing: queueView(row.Missing, row.MissingSince),
|
||||
}
|
||||
}
|
||||
|
||||
// handleGetAdminSummary implements GET /api/admin/summary.
|
||||
func (h *handlers) handleGetAdminSummary(w http.ResponseWriter, r *http.Request) {
|
||||
row, err := dbq.New(h.pool).AdminSummary(r.Context())
|
||||
if err != nil {
|
||||
h.logger.Error("admin: summary", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, adminSummaryFrom(row))
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func getAdminSummary(t *testing.T, h *handlers, user dbq.User) (int, adminSummaryView) {
|
||||
t.Helper()
|
||||
r := chi.NewRouter()
|
||||
r.Route("/api/admin", func(admin chi.Router) {
|
||||
admin.Use(auth.RequireAdmin())
|
||||
admin.Get("/summary", h.handleGetAdminSummary)
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, withUser(httptest.NewRequest(http.MethodGet, "/api/admin/summary", nil), user))
|
||||
var out adminSummaryView
|
||||
if w.Code == http.StatusOK {
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return w.Code, out
|
||||
}
|
||||
|
||||
// The summary counts each queue an admin acts on and says since when the
|
||||
// oldest item has waited; an empty queue has no since (#5605).
|
||||
func TestAdminSummary_CountsTheQueuesAndSinceWhen(t *testing.T) {
|
||||
h, _ := testHandlersWithClientFn(t)
|
||||
admin := seedUser(t, h.pool, "admin", "pw", true)
|
||||
listener := seedUser(t, h.pool, "listener", "pw", false)
|
||||
ctx := context.Background()
|
||||
|
||||
code, empty := getAdminSummary(t, h, admin)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status = %d", code)
|
||||
}
|
||||
if empty.Requests.Count != 0 || empty.Requests.Since != nil || empty.Missing.Count != 0 {
|
||||
t.Errorf("empty summary = %+v", empty)
|
||||
}
|
||||
|
||||
if _, err := dbq.New(h.pool).CreateRequest(ctx, dbq.CreateRequestParams{
|
||||
UserID: listener.ID, Kind: dbq.RequestKindArtist, ArtistMbid: "a0000000-0000-4000-8000-000000000001",
|
||||
ArtistName: "Annadale",
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, got := getAdminSummary(t, h, admin)
|
||||
if got.Requests.Count != 1 || got.Requests.Since == nil {
|
||||
t.Errorf("requests = %+v, want 1 with a since", got.Requests)
|
||||
}
|
||||
|
||||
if code, _ := getAdminSummary(t, h, listener); code != http.StatusForbidden {
|
||||
t.Errorf("a listener got %d, want 403", code)
|
||||
}
|
||||
}
|
||||
@@ -244,6 +244,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
|
||||
admin.Get("/lidarr/quality-profiles", h.handleListQualityProfiles)
|
||||
admin.Get("/lidarr/metadata-profiles", h.handleListMetadataProfiles)
|
||||
admin.Get("/lidarr/root-folders", h.handleListRootFolders)
|
||||
// The queues that wait on an admin, for the rail and the Overview (#5605).
|
||||
admin.Get("/summary", h.handleGetAdminSummary)
|
||||
admin.Get("/requests", h.handleListAdminRequests)
|
||||
admin.Post("/requests/{id}/approve", h.handleApproveRequest)
|
||||
admin.Post("/requests/{id}/reject", h.handleRejectRequest)
|
||||
|
||||
Reference in New Issue
Block a user