feat: the admin Overview shows who is listening, downloads, the library and its disk, and the server's version, uptime and recent errors; a download waiting on you, a client not answering or a full disk count in its verdict (M504 #5627 #5628 #5629 #5630)
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 17s
release / web (push) Successful in 1m35s
release / go (push) Successful in 2m0s
release / integration (push) Successful in 5m40s
release / android (push) Successful in 6m15s
release / Build signed APK (releases and dev) (push) Successful in 6m30s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 25s
release / Verify release artifacts (tag releases only) (push) Skipped
The version moves to internal/buildinfo, so /rest/ping reports the build instead of "dev". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/buildinfo"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/logging"
|
||||
)
|
||||
|
||||
// The admin Overview's status sections (M504): after the verdict, who is
|
||||
// listening, what is downloading, what the library holds and how much room
|
||||
// it has, and how the server itself is doing. One read for the page, polled
|
||||
// while it is open.
|
||||
|
||||
type listeningView struct {
|
||||
Username string `json:"username"`
|
||||
TrackID string `json:"track_id"`
|
||||
Title string `json:"title"`
|
||||
ArtistName string `json:"artist_name"`
|
||||
AlbumID string `json:"album_id"`
|
||||
AlbumTitle string `json:"album_title"`
|
||||
DurationMs int32 `json:"duration_ms"`
|
||||
StartedAt string `json:"started_at"`
|
||||
Client string `json:"client"`
|
||||
DeviceClass string `json:"device_class"`
|
||||
}
|
||||
|
||||
type listeningStatusView struct {
|
||||
Now []listeningView `json:"now"`
|
||||
// The last day: how many plays, by how many people, the latest when.
|
||||
PlaysDay int64 `json:"plays_day"`
|
||||
ListenersDay int64 `json:"listeners_day"`
|
||||
LastPlayAt *string `json:"last_play_at"`
|
||||
}
|
||||
|
||||
type downloadsStatusView struct {
|
||||
Running int64 `json:"running"`
|
||||
NeedsYou int64 `json:"needs_you"`
|
||||
NeedsYouSince *string `json:"needs_you_since"`
|
||||
ImportedDay int64 `json:"imported_day"`
|
||||
GivenUpDay int64 `json:"given_up_day"`
|
||||
// Live is the downloads not yet ended, the ones needing a person first.
|
||||
Live []downloadView `json:"live"`
|
||||
ClientsDown []clientDownView `json:"clients_down"`
|
||||
}
|
||||
|
||||
// diskView is the filesystem one library folder, or the data folder, sits
|
||||
// on.
|
||||
type diskView struct {
|
||||
Path string `json:"path"`
|
||||
// Data marks Minstrel's own data folder (covers, fingerprints), beside
|
||||
// the library's.
|
||||
Data bool `json:"data,omitempty"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
FreeBytes uint64 `json:"free_bytes"`
|
||||
// Error says why the folder could not be read, which is itself the
|
||||
// news: a drive that did not mount.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type libraryStatusView struct {
|
||||
Tracks int64 `json:"tracks"`
|
||||
Albums int64 `json:"albums"`
|
||||
Artists int64 `json:"artists"`
|
||||
Bytes int64 `json:"bytes"`
|
||||
Disks []diskView `json:"disks"`
|
||||
}
|
||||
|
||||
type serverErrorView struct {
|
||||
At string `json:"at"`
|
||||
Message string `json:"message"`
|
||||
Attrs string `json:"attrs"`
|
||||
}
|
||||
|
||||
type serverStatusView struct {
|
||||
Version string `json:"version"`
|
||||
Channel string `json:"channel"`
|
||||
StartedAt string `json:"started_at"`
|
||||
// Migration is the database schema's version, and Dirty whether its
|
||||
// last migration stopped part way.
|
||||
Migration int64 `json:"migration"`
|
||||
Dirty bool `json:"dirty"`
|
||||
// Errors is the newest few errors logged since the start, newest first;
|
||||
// ErrorCount is all of them.
|
||||
Errors []serverErrorView `json:"errors"`
|
||||
ErrorCount int64 `json:"error_count"`
|
||||
}
|
||||
|
||||
type adminStatusView struct {
|
||||
Listening listeningStatusView `json:"listening"`
|
||||
Downloads downloadsStatusView `json:"downloads"`
|
||||
Library libraryStatusView `json:"library"`
|
||||
Server serverStatusView `json:"server"`
|
||||
}
|
||||
|
||||
// overviewLiveDownloads is how many running downloads the Overview lists;
|
||||
// Activity has the rest.
|
||||
const overviewLiveDownloads = 5
|
||||
|
||||
// overviewErrors is how many recent errors the Overview lists.
|
||||
const overviewErrors = 10
|
||||
|
||||
// handleGetAdminStatus implements GET /api/admin/status.
|
||||
func (h *handlers) handleGetAdminStatus(w http.ResponseWriter, r *http.Request) {
|
||||
q := dbq.New(h.pool)
|
||||
ctx := r.Context()
|
||||
var out adminStatusView
|
||||
|
||||
now, err := q.ListListeningNow(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("admin status: listening", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out.Listening.Now = make([]listeningView, 0, len(now))
|
||||
for _, p := range now {
|
||||
out.Listening.Now = append(out.Listening.Now, listeningView{
|
||||
Username: p.Username, TrackID: uuidToString(p.TrackID), Title: p.TrackTitle,
|
||||
ArtistName: p.ArtistName, AlbumID: uuidToString(p.AlbumID), AlbumTitle: p.AlbumTitle,
|
||||
DurationMs: p.DurationMs, StartedAt: formatTimestamp(p.StartedAt),
|
||||
Client: deref(p.ClientID), DeviceClass: deref(p.DeviceClass),
|
||||
})
|
||||
}
|
||||
day, err := q.PlaysLastDay(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("admin status: plays", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out.Listening.PlaysDay, out.Listening.ListenersDay = day.Plays, day.Listeners
|
||||
if day.LastAt.Valid {
|
||||
at := formatTimestamp(day.LastAt)
|
||||
out.Listening.LastPlayAt = &at
|
||||
}
|
||||
|
||||
if err := h.downloadsStatus(r, q, &out.Downloads); err != nil {
|
||||
h.logger.Error("admin status: downloads", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
|
||||
lib, err := q.LibraryStatus(ctx)
|
||||
if err != nil {
|
||||
h.logger.Error("admin status: library", "err", err)
|
||||
writeAdminJSONErr(w, http.StatusInternalServerError, "server_error")
|
||||
return
|
||||
}
|
||||
out.Library = libraryStatusView{Tracks: lib.Tracks, Albums: lib.Albums, Artists: lib.Artists,
|
||||
Bytes: lib.Bytes, Disks: []diskView{}}
|
||||
if h.importer != nil {
|
||||
for _, root := range h.importer.Roots {
|
||||
out.Library.Disks = append(out.Library.Disks, diskOf(root))
|
||||
}
|
||||
}
|
||||
if h.dataDir != "" {
|
||||
d := diskOf(h.dataDir)
|
||||
d.Data = true
|
||||
out.Library.Disks = append(out.Library.Disks, d)
|
||||
}
|
||||
|
||||
out.Server = serverStatus(logging.Errors)
|
||||
// golang-migrate's own table, outside sqlc's schema.
|
||||
if err := h.pool.QueryRow(ctx, `SELECT version, dirty FROM schema_migrations LIMIT 1`).
|
||||
Scan(&out.Server.Migration, &out.Server.Dirty); err != nil {
|
||||
h.logger.Warn("admin status: migration version", "err", err)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *handlers) downloadsStatus(r *http.Request, q *dbq.Queries, out *downloadsStatusView) error {
|
||||
st, err := q.DownloadsStatus(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*out = downloadsStatusView{Running: st.Running, NeedsYou: st.NeedsYou,
|
||||
ImportedDay: st.ImportedDay, GivenUpDay: st.GivenUpDay,
|
||||
Live: []downloadView{}, ClientsDown: []clientDownView{}}
|
||||
if st.NeedsYou > 0 && st.NeedsYouSince.Valid {
|
||||
since := formatTimestamp(st.NeedsYouSince)
|
||||
out.NeedsYouSince = &since
|
||||
}
|
||||
// ListGrabs puts the ones needing a person first, then the running ones.
|
||||
rows, err := q.ListGrabs(r.Context(), dbq.ListGrabsParams{MaxRows: overviewLiveDownloads})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range rows {
|
||||
if !g.EndedAt.Valid {
|
||||
out.Live = append(out.Live, downloadFrom(g))
|
||||
}
|
||||
}
|
||||
down, err := q.ListClientsDown(r.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, c := range down {
|
||||
out.ClientsDown = append(out.ClientsDown, clientDownView{Client: c.Client, Detail: c.Detail,
|
||||
Since: formatTimestamp(c.FirstSeen)})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// diskOf reads the size and free space of the filesystem path is on.
|
||||
func diskOf(path string) diskView {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return diskView{Path: path, Error: err.Error()}
|
||||
}
|
||||
bsize := uint64(st.Bsize)
|
||||
return diskView{Path: path, TotalBytes: st.Blocks * bsize, FreeBytes: st.Bavail * bsize}
|
||||
}
|
||||
|
||||
func serverStatus(ring *logging.ErrorRing) serverStatusView {
|
||||
kept, total := ring.Snapshot()
|
||||
v := serverStatusView{Version: buildinfo.Version, Channel: buildinfo.Channel,
|
||||
StartedAt: buildinfo.Started.UTC().Format(time.RFC3339), ErrorCount: total,
|
||||
Errors: make([]serverErrorView, 0, min(len(kept), overviewErrors))}
|
||||
for i, e := range kept {
|
||||
if i == overviewErrors {
|
||||
break
|
||||
}
|
||||
v.Errors = append(v.Errors, serverErrorView{At: e.At.UTC().Format(time.RFC3339),
|
||||
Message: e.Message, Attrs: e.Attrs})
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user