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
193 lines
5.7 KiB
Go
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
|
|
}
|