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>
230 lines
7.5 KiB
Go
230 lines
7.5 KiB
Go
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
|
|
}
|