Files
nmon/internal/api/printers.go
jleung e25143881c Device metrics API, printer fleet, profile presentation schema (v1.0.1 backend)
Profiles gain category/label/of/display/scale_by/sentinels/enum_state and component_attrs;
every pollable non-interface metric is annotated and an orphan test enforces it. New routes
GET /devices/{slug}/metrics, /metrics/graph and /printers; printer supply type/colour read at
discovery into device_components.attrs; printer_supply_empty alert; supply_replaced audit event;
docs/coverage.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-23 05:33:07 +00:00

193 lines
5.7 KiB
Go

package api
import (
"context"
"math"
"net/http"
"sort"
"strings"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/store"
)
const (
supplyTable = "Printer-MIB::prtMarkerSuppliesTable"
// supplyWarnPercent is the level under which a supply reads as low, as the printer_supply_low rule does.
supplyWarnPercent = 10
)
func (s *server) registerPrinters(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "list-printers", Method: http.MethodGet, Path: "/api/v1/printers", Tags: []string{"devices"},
Summary: "Printer fleet: supplies, trays and page counts",
Description: "Every device whose profile is or extends printer, with its supply and paper tray levels from the hot store. " +
"The printer with the lowest supply comes first; printers with no reading come last.",
Errors: []int{http.StatusTooManyRequests, http.StatusServiceUnavailable},
}, s.listPrinters)
}
// printerNames are the metrics the fleet view reads.
var printerNames = []string{
"prtMarkerSuppliesLevel", "prtMarkerSuppliesMaxCapacity", "prtInputCurrentLevel", "prtInputMaxCapacity", "prtMarkerLifeCount",
}
// printerDevices lists the devices whose profile chain includes printer.
func (s *server) printerDevices(ctx context.Context) ([]store.Device, error) {
if s.Profiles == nil {
return nil, nil
}
var out []store.Device
for off := int64(0); ; off += store.MaxLimit {
rows, n, err := s.Store.ListDevices(ctx, store.DeviceFilter{}, store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return nil, err
}
for _, d := range rows {
if res := s.Profiles.Get(d.Profile); res != nil && containsString(res.Chain, "printer") {
out = append(out, d)
}
}
if off+store.MaxLimit >= n {
return out, nil
}
}
}
func containsString(l []string, s string) bool {
for _, x := range l {
if x == s {
return true
}
}
return false
}
type printerEntry struct {
dev store.Device
row PrinterRow
lowest float64 // lowest supply percent; +Inf with no reading
sentinel map[int]string
}
// supplyState: empty is critical, under supplyWarnPercent a warning, a sentinel or no reading unknown or ok by its words.
func supplyState(pct *float64, text *string) string {
switch {
case pct != nil && *pct <= 0:
return "crit"
case pct != nil && *pct < supplyWarnPercent:
return "warn"
case pct != nil:
return "ok"
case text != nil && *text != "unknown":
return "ok"
}
return "unknown"
}
// levels builds the rows of one level/capacity metric pair, lowest first.
func levels(rd readings, level, capacity string, sent map[int]string) []PrinterSupply {
out := []PrinterSupply{}
for comp, lv := range rd[level] {
sup := PrinterSupply{Component: comp}
if words, ok := sent[int(lv.v)]; ok && lv.v == math.Trunc(lv.v) {
sup.Text = &words
} else if c := rd.at(capacity, comp); c != nil && c.v > 0 && lv.v >= 0 {
p := 100 * lv.v / c.v
sup.Percent = &p
}
sup.State = supplyState(sup.Percent, sup.Text)
out = append(out, sup)
}
sort.Slice(out, func(i, j int) bool {
a, b := out[i].Percent, out[j].Percent
switch {
case a != nil && b != nil && *a != *b:
return *a < *b
case (a == nil) != (b == nil):
return a != nil
}
return out[i].Component < out[j].Component
})
return out
}
func (s *server) printerEntry(d store.Device, rd readings) printerEntry {
sent := map[int]string{}
if res := s.Profiles.Get(d.Profile); res != nil {
if m, ok := res.Metric("prtMarkerSuppliesLevel"); ok {
sent = m.Sentinels
}
}
e := printerEntry{dev: d, lowest: math.Inf(1), sentinel: sent}
e.row = PrinterRow{
Device: DeviceRefSite{Slug: d.Slug, Name: d.Name, Site: d.Site},
Supplies: levels(rd, "prtMarkerSuppliesLevel", "prtMarkerSuppliesMaxCapacity", sent),
Trays: levels(rd, "prtInputCurrentLevel", "prtInputMaxCapacity", sent),
}
for _, sup := range e.row.Supplies {
if sup.Percent != nil {
e.lowest = min(e.lowest, *sup.Percent)
}
}
var pages float64
for _, c := range rd["prtMarkerLifeCount"] {
pages += c.v
e.row.PagesTotal = &pages
}
return e
}
func (s *server) listPrinters(ctx context.Context, in *PageParams) (*struct{ Body PrinterList }, error) {
devs, err := s.printerDevices(ctx)
if err != nil {
return nil, s.storeErr(err)
}
var all map[string]readings
if len(devs) > 0 {
slugs := make([]string, len(devs))
for i, d := range devs {
slugs[i] = d.Slug
}
if all, err = s.queryLatest(ctx, `device=~`+quote(regexAlt(slugs)), printerNames); err != nil {
return nil, err
}
}
entries := make([]printerEntry, len(devs))
reach := s.Reachability.Reachability(ctx, devs)
for i, d := range devs {
entries[i] = s.printerEntry(d, all[d.Slug])
entries[i].row.Reachability = reach[d.Slug]
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].lowest != entries[j].lowest {
return entries[i].lowest < entries[j].lowest
}
return strings.ToLower(entries[i].dev.Name) < strings.ToLower(entries[j].dev.Name)
})
lo := min(in.offset(), int64(len(entries)))
hi := min(lo+in.limit(), int64(len(entries)))
out := PrinterList{Items: []PrinterRow{}, Page: in.pageNo(), PerPage: in.limit(), Total: int64(len(entries))}
for _, e := range entries[lo:hi] {
if err := s.addSupplyAttrs(ctx, &e); err != nil {
return nil, s.storeErr(err)
}
out.Items = append(out.Items, e.row)
}
return &struct{ Body PrinterList }{out}, nil
}
// addSupplyAttrs fills each supply's type and color from the discovery attributes of its row.
func (s *server) addSupplyAttrs(ctx context.Context, e *printerEntry) error {
attrs, err := s.componentAttrs(ctx, e.dev.ID)
if err != nil {
return err
}
for i := range e.row.Supplies {
a := attrs[attrKey(supplyTable, e.row.Supplies[i].Component)]
e.row.Supplies[i].Type, e.row.Supplies[i].Color = a["type"], a["color"]
}
return nil
}