- TestHandler: POST /api/test/cable, GET /api/test/events, GET /api/test/recent - POST /api/test/cable: DisallowUnknownFields (T-05-03), creates NetBox cable, auto-prints with 1s rate limit (T-05-05), prepends to 20-item ring buffer - GET /api/test/events: SSE, 30s keepalive, exits on context cancel (T-05-04) - GET /api/test/recent: thread-safe ring buffer, returns [] when empty - AttachStream() wires StreamingTesterDriver channel to SSE broadcaster - Router: three /api/test/* routes added to NewRouter signature - main.go: constructs TestHandler, wires USB Manager event loop stub - All handler tests pass race-clean (7 test cases)
292 lines
8.5 KiB
Go
292 lines
8.5 KiB
Go
package handlers
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
|
|
"git.georgsen.dk/hwlab/internal/labels"
|
|
"git.georgsen.dk/hwlab/internal/printer"
|
|
"git.georgsen.dk/hwlab/internal/tester"
|
|
)
|
|
|
|
// TestNetBoxClient is the subset of netbox.Client used by TestHandler.
|
|
// Using an interface allows test injection without depending on the concrete type.
|
|
type TestNetBoxClient interface {
|
|
CreateCable(ctx context.Context, label, assetTag, testDataJSON string) (int64, error)
|
|
}
|
|
|
|
// TestPrinter is the narrow print interface for TestHandler.
|
|
// Allows nil injection (auto-print skipped when nil).
|
|
type TestPrinter interface {
|
|
Print(bitmap []byte, width, height int) error
|
|
}
|
|
|
|
// TestHandler handles the three cable-test endpoints:
|
|
//
|
|
// POST /api/test/cable — receive TestResult, persist to NetBox, auto-print
|
|
// GET /api/test/events — SSE stream of LiveReading from attached StreamingTesterDriver
|
|
// GET /api/test/recent — last ≤20 TestResult entries from in-memory ring buffer
|
|
//
|
|
// T-05-04: StreamEvents exits on r.Context().Done() — no goroutine leak.
|
|
// T-05-05: rate limit on print calls (1/second) inherited via printCooldown.
|
|
type TestHandler struct {
|
|
nb TestNetBoxClient
|
|
printer TestPrinter // may be nil — auto-print skipped
|
|
|
|
// ring buffer (cap ringCap, thread-safe)
|
|
mu sync.Mutex
|
|
recent []tester.TestResult
|
|
|
|
// live readings channel; set by AttachStream
|
|
liveCh chan tester.LiveReading
|
|
|
|
// rate limiting (T-05-05 — DoS/label-waste mitigation)
|
|
printMu sync.Mutex
|
|
lastPrintTime time.Time
|
|
printCooldown time.Duration
|
|
}
|
|
|
|
const ringCap = 20
|
|
|
|
// NewTestHandler constructs a TestHandler. p may be nil (auto-print disabled).
|
|
func NewTestHandler(nb TestNetBoxClient, p TestPrinter) *TestHandler {
|
|
return &TestHandler{
|
|
nb: nb,
|
|
printer: p,
|
|
recent: make([]tester.TestResult, 0, ringCap),
|
|
liveCh: make(chan tester.LiveReading, 64),
|
|
printCooldown: time.Second,
|
|
}
|
|
}
|
|
|
|
// AttachStream wires a StreamingTesterDriver's live readings channel to the
|
|
// SSE broadcaster. Safe to call from any goroutine.
|
|
func (h *TestHandler) AttachStream(ch <-chan tester.LiveReading) {
|
|
go func() {
|
|
for reading := range ch {
|
|
select {
|
|
case h.liveCh <- reading:
|
|
default:
|
|
// Drop if buffer full — non-blocking, live readings are best-effort.
|
|
}
|
|
}
|
|
}()
|
|
}
|
|
|
|
// cableTestRequest is the JSON body for POST /api/test/cable.
|
|
// T-05-03: unknown fields are disallowed (DisallowUnknownFields below).
|
|
type cableTestRequest struct {
|
|
CableType int `json:"cable_type"`
|
|
USBVersion string `json:"usb_version"`
|
|
DPVersion string `json:"dp_version"`
|
|
HDMIVersion string `json:"hdmi_version"`
|
|
SpeedGbps float64 `json:"speed_gbps"`
|
|
MaxWatts int `json:"max_watts"`
|
|
PinContinuity bool `json:"pin_continuity"`
|
|
HasEMarker bool `json:"has_emarker"`
|
|
ResistanceOhm float64 `json:"resistance_ohm"`
|
|
HWID string `json:"hw_id"`
|
|
}
|
|
|
|
// cableTestResponse is the JSON body returned by POST /api/test/cable on 201.
|
|
type cableTestResponse struct {
|
|
HWID string `json:"hw_id"`
|
|
NetBoxID int64 `json:"netbox_id"`
|
|
PrintSkipped bool `json:"print_skipped"`
|
|
}
|
|
|
|
// SubmitCableTest handles POST /api/test/cable.
|
|
//
|
|
// Flow:
|
|
// 1. Decode JSON body (DisallowUnknownFields — T-05-03)
|
|
// 2. Marshal TestResult to JSON for test_data custom field
|
|
// 3. Derive cable label from CableType + version strings
|
|
// 4. CreateCable in NetBox
|
|
// 5. Auto-print cable label (non-fatal; rate-limited — T-05-05)
|
|
// 6. Prepend to ring buffer (cap 20)
|
|
// 7. Return 201 cableTestResponse
|
|
func (h *TestHandler) SubmitCableTest(w http.ResponseWriter, r *http.Request) {
|
|
var req cableTestRequest
|
|
dec := json.NewDecoder(r.Body)
|
|
dec.DisallowUnknownFields()
|
|
if err := dec.Decode(&req); err != nil {
|
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Build TestResult from request
|
|
result := tester.TestResult{
|
|
CableType: tester.CableType(req.CableType),
|
|
USBVersion: req.USBVersion,
|
|
DPVersion: req.DPVersion,
|
|
HDMIVersion: req.HDMIVersion,
|
|
SpeedGbps: req.SpeedGbps,
|
|
MaxWatts: req.MaxWatts,
|
|
PinContinuity: req.PinContinuity,
|
|
HasEMarker: req.HasEMarker,
|
|
ResistanceOhm: req.ResistanceOhm,
|
|
}
|
|
|
|
// Marshal TestResult as test_data JSON for NetBox custom field
|
|
testDataJSON, err := json.Marshal(result)
|
|
if err != nil {
|
|
log.Printf("test: marshal TestResult: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
|
|
return
|
|
}
|
|
|
|
// Derive cable label
|
|
cableLabel := deriveCableLabel(result)
|
|
|
|
// Create NetBox cable record
|
|
netboxID, err := h.nb.CreateCable(r.Context(), cableLabel, req.HWID, string(testDataJSON))
|
|
if err != nil {
|
|
log.Printf("test: CreateCable: %v", err)
|
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "NetBox error: " + err.Error()})
|
|
return
|
|
}
|
|
|
|
// Auto-print cable label (non-fatal — T-05-05 rate limit applies)
|
|
printSkipped := true
|
|
if h.printer != nil && req.HWID != "" {
|
|
// Rate limit: reject if last print was less than printCooldown ago (T-05-05)
|
|
h.printMu.Lock()
|
|
now := time.Now()
|
|
rateLimitOK := h.lastPrintTime.IsZero() || now.Sub(h.lastPrintTime) >= h.printCooldown
|
|
if rateLimitOK {
|
|
h.lastPrintTime = now
|
|
}
|
|
h.printMu.Unlock()
|
|
|
|
if rateLimitOK {
|
|
labelData := labels.CableLabelData{
|
|
HWID: req.HWID,
|
|
Name: cableLabel,
|
|
USBVersion: req.USBVersion,
|
|
MaxSpeedGbps: req.SpeedGbps,
|
|
MaxWatts: req.MaxWatts,
|
|
TestDate: time.Now().Format("2006-01-02"),
|
|
}
|
|
img, renderErr := labels.RenderCable(labelData)
|
|
if renderErr == nil {
|
|
bm, bw, bh := printer.ImageToRawBitmap(img)
|
|
if printErr := h.printer.Print(bm, bw, bh); printErr == nil {
|
|
printSkipped = false
|
|
} else {
|
|
log.Printf("test: auto-print error: %v", printErr)
|
|
}
|
|
} else {
|
|
log.Printf("test: cable label render error: %v", renderErr)
|
|
}
|
|
} else {
|
|
log.Printf("test: auto-print rate limited — skipping")
|
|
}
|
|
}
|
|
|
|
// Prepend to ring buffer (most recent first, cap 20)
|
|
h.mu.Lock()
|
|
h.recent = append([]tester.TestResult{result}, h.recent...)
|
|
if len(h.recent) > ringCap {
|
|
h.recent = h.recent[:ringCap]
|
|
}
|
|
h.mu.Unlock()
|
|
|
|
writeJSON(w, http.StatusCreated, cableTestResponse{
|
|
HWID: req.HWID,
|
|
NetBoxID: netboxID,
|
|
PrintSkipped: printSkipped,
|
|
})
|
|
}
|
|
|
|
// StreamEvents handles GET /api/test/events — SSE stream of LiveReading values.
|
|
//
|
|
// T-05-04: exits on r.Context().Done() — goroutine-leak-safe.
|
|
// Sends a 30-second keepalive comment to prevent proxy timeout.
|
|
func (h *TestHandler) StreamEvents(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "streaming not supported", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
fmt.Fprintf(w, ": connected\n\n")
|
|
flusher.Flush()
|
|
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
// T-05-04: client disconnected — return cleanly, no goroutine leak.
|
|
return
|
|
|
|
case reading, ok := <-h.liveCh:
|
|
if !ok {
|
|
return
|
|
}
|
|
data, err := json.Marshal(reading)
|
|
if err != nil {
|
|
fmt.Fprintf(w, ": marshal error\n\n")
|
|
flusher.Flush()
|
|
continue
|
|
}
|
|
fmt.Fprintf(w, "data: %s\n\n", data)
|
|
flusher.Flush()
|
|
|
|
case <-ticker.C:
|
|
fmt.Fprintf(w, ": keepalive\n\n")
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
|
|
// RecentTests handles GET /api/test/recent — returns last ≤20 TestResult entries.
|
|
// Returns an empty JSON array when no tests have been submitted.
|
|
func (h *TestHandler) RecentTests(w http.ResponseWriter, r *http.Request) {
|
|
h.mu.Lock()
|
|
results := make([]tester.TestResult, len(h.recent))
|
|
copy(results, h.recent)
|
|
h.mu.Unlock()
|
|
|
|
writeJSON(w, http.StatusOK, results)
|
|
}
|
|
|
|
// deriveCableLabel builds a human-readable cable label from a TestResult.
|
|
func deriveCableLabel(r tester.TestResult) string {
|
|
switch r.CableType {
|
|
case tester.CableTypeUSB:
|
|
version := r.USBVersion
|
|
if version == "" {
|
|
version = "USB"
|
|
}
|
|
if r.SpeedGbps > 0 {
|
|
return fmt.Sprintf("%s / %.0f Gbps Cable", version, r.SpeedGbps)
|
|
}
|
|
return version + " Cable"
|
|
case tester.CableTypeDP:
|
|
version := r.DPVersion
|
|
if version == "" {
|
|
version = "DP"
|
|
}
|
|
return "DisplayPort " + version + " Cable"
|
|
case tester.CableTypeHDMI:
|
|
version := r.HDMIVersion
|
|
if version == "" {
|
|
version = "HDMI"
|
|
}
|
|
return "HDMI " + version + " Cable"
|
|
default:
|
|
return "Cable"
|
|
}
|
|
}
|