checker: add /check route for standalone human-facing web UI

Providers that implement the new CheckerInteractive interface
(RenderForm + ParseForm) get a built-in HTML form on GET /check and
a consolidated result page on POST /check that runs the standard
Collect -> Evaluate -> GetHTMLReport / ExtractMetrics pipeline. This
lets a checker be used directly from a browser outside of happyDomain,
with the checker itself resolving what the host would normally
auto-fill (typically via its own DNS queries).

Also guards NewServer against a nil Definition() so providers that
advertise CheckerDefinitionProvider without a ready definition no
longer panic at registration.
This commit is contained in:
nemunaire 2026-04-23 11:20:29 +07:00
commit 199c7dea3f
4 changed files with 644 additions and 18 deletions

View file

@ -60,15 +60,17 @@ func updateLoadAvg(prev [3]float64, sample float64) [3]float64 {
// It always exposes /health and /collect. If the provider implements
// CheckerDefinitionProvider, it also exposes /definition and /evaluate.
// If the provider implements CheckerHTMLReporter or CheckerMetricsReporter,
// it also exposes /report.
// it also exposes /report. If the provider implements CheckerInteractive,
// it also exposes /check (a human-facing web form).
//
// Security: Server does not perform any authentication or authorization.
// It is intended to be run behind a reverse proxy or in a trusted network
// where access control is handled externally (e.g. by the happyDomain server).
type Server struct {
provider ObservationProvider
definition *CheckerDefinition
mux *http.ServeMux
provider ObservationProvider
definition *CheckerDefinition
interactive CheckerInteractive
mux *http.ServeMux
// startTime is captured in NewServer and used to compute uptime.
startTime time.Time
@ -128,6 +130,12 @@ func NewServer(provider ObservationProvider) *Server {
s.mux.Handle("POST /report", s.TrackWork(http.HandlerFunc(s.handleReport)))
}
if ip, ok := provider.(CheckerInteractive); ok {
s.interactive = ip
s.mux.HandleFunc("GET /check", s.handleCheckForm)
s.mux.Handle("POST /check", s.TrackWork(http.HandlerFunc(s.handleCheckSubmit)))
}
go s.runSampler(ctx)
return s
@ -286,25 +294,17 @@ func (s *Server) handleCollect(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
var req ExternalEvaluateRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
obs := &mapObservationGetter{data: req.Observations}
// evaluateRules runs all definition rules against obs/opts, skipping any rule
// whose name maps to false in enabledRules (nil means run all).
func (s *Server) evaluateRules(ctx context.Context, obs ObservationGetter, opts CheckerOptions, enabledRules map[string]bool) []CheckState {
var states []CheckState
for _, rule := range s.definition.Rules {
if len(req.EnabledRules) > 0 {
if enabled, ok := req.EnabledRules[rule.Name()]; ok && !enabled {
if len(enabledRules) > 0 {
if enabled, ok := enabledRules[rule.Name()]; ok && !enabled {
continue
}
}
ruleStates := rule.Evaluate(r.Context(), obs, req.Options)
ruleStates := rule.Evaluate(ctx, obs, opts)
if len(ruleStates) == 0 {
ruleStates = []CheckState{{
Status: StatusUnknown,
@ -318,7 +318,20 @@ func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
states = append(states, state)
}
}
return states
}
func (s *Server) handleEvaluate(w http.ResponseWriter, r *http.Request) {
var req ExternalEvaluateRequest
if err := json.NewDecoder(io.LimitReader(r.Body, maxRequestBodySize)).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, ExternalEvaluateResponse{
Error: fmt.Sprintf("invalid request body: %v", err),
})
return
}
obs := &mapObservationGetter{data: req.Observations}
states := s.evaluateRules(r.Context(), obs, req.Options, req.EnabledRules)
writeJSON(w, http.StatusOK, ExternalEvaluateResponse{States: states})
}