Compare commits

..

17 commits

Author SHA1 Message Date
c6534e607d checkers: add ICMP ping checker with RTT and packet loss metrics
Some checks failed
continuous-integration/drone/push Build is failing
Implements a new ping observation provider and check rule that tests
ICMP reachability against abstract.Server services. Collects RTT and
packet loss metrics with configurable warning/critical thresholds.
2026-04-05 11:58:00 +07:00
46901f23d0 checkers: store observations as json.RawMessage with cross-checker reuse
Refactor observation data pipeline to serialize once after collection and
keep json.RawMessage throughout storage and API responses. This eliminates
double-serialization and makes DB round-trips lossless.

Key changes:
- ObservationGetter.Get() adopts json.Unmarshal semantics (dest any)
- ObservationSnapshot.Data uses map[ObservationKey]json.RawMessage
- Add freshness-based observation cache (ObservationCacheStorage) that
  stores lightweight snapshot pointers, enabling cross-checker reuse of
  recent observations without re-collecting
2026-04-05 11:58:00 +07:00
b2de9241fb checkers: add NoOverride field support for checker options
Prevent more specific scopes from overriding option values locked at a
higher scope (e.g. admin). Includes defense-in-depth stripping on
Set/Add operations, merge-time preservation, and frontend filtering.
2026-04-05 11:58:00 +07:00
edabb1a301 checkers: add typed option extraction helpers 2026-04-05 11:58:00 +07:00
9350b71b48 checkers: show worst check status badge on domain list
Add DomainWithCheckStatus model and GetWorstDomainStatuses usecase to
compute the most critical checker status per domain. The GET /domains
endpoint now returns status alongside each domain. The frontend domain
store, list components, and table row display dynamic status badges
with color and icon instead of a hardcoded "OK".

ZoneList is made generic (T extends HappydnsDomain) so the badges
snippet preserves the caller's concrete type without unsafe casts.
2026-04-05 11:58:00 +07:00
e67ee23805 checkers: show children checkers on domain page and hide scheduling for non-domain checkers
Add a separate section on the domain checks page to display zone and
service-level checkers that can be configured but won't produce results
at the domain scope. Hide the scheduling and rules cards when configuring
a non-domain checker from the domain context.
2026-04-05 11:58:00 +07:00
dcf2711cb1 checkers: add frontend metrics chart on execution pages
Add Chart.js-based line chart for checker metrics. The chart appears
on the executions list page (aggregated) and on individual execution
detail pages. Metrics view mode is selectable via the sidebar alongside
HTML report and raw JSON views.
2026-04-05 11:58:00 +07:00
d0d849db72 checkers: add metrics export with Prometheus and JSON formats
Observation providers can now implement CheckerMetricsReporter to
extract time-series metrics from their stored data. The controller
selects Prometheus text or JSON format based on the Accept header.

Routes: user-level (/api/checkers/metrics), domain-level, per-checker,
and per-execution.
2026-04-05 11:58:00 +07:00
410e955e50 checkers: add HTML report rendering for observation providers
Introduce CheckerHTMLReporter interface that observation providers can
implement to render rich HTML documents from their data. The Zonemaster
provider implements it with collapsible accordions and severity badges.

Adds API endpoint GET .../observations/:obsKey/report, frontend stores
for view mode switching (HTML/JSON), and wires the sidebar toggle buttons.
2026-04-05 11:58:00 +07:00
0d8aecc952 checkers: add frontend UI components and routes
Add all checker UI pages and components:
- Checker list, config, schedule, and rules pages
- Execution list, detail, results, and rules pages
- Sidebar components for domain/service checker status
- Run check modal with option overrides and rule selection
- Domain-scoped and service-scoped check routes
- Admin pages for checker configuration and scheduler management
- Header navigation link for checkers section
2026-04-05 11:58:00 +07:00
51ceb796e4 checkers: add frontend API client, stores, and utilities
Add the frontend infrastructure for the checker UI:
- API client with scoped helpers for domain/service-level operations
- Svelte stores for checker state (currentExecution, currentCheckInfo)
- Utility functions for status colors, icons, i18n keys, date formatting
- Shared helpers: withInheritedPlaceholders, downloadBlob, collectAllOptionDocs
- English translations for all checker UI strings
- Zone model and form types extended for checker support
2026-04-05 11:58:00 +07:00
9f53e0fa80 checkers: add API controllers, routes, and app wiring
Wire up the checker system to the HTTP layer:
- API controllers for checker operations, options, plans, and results
- Scoped routes at domain and service level
- Admin controllers for checker config and scheduler management
- App initialization: create usecases, start/stop scheduler
- Zone controller updated to include per-service check status
2026-04-05 11:58:00 +07:00
cfdef2abb0 checkers: add usecases, engine, and scheduler
Implement the checker business logic:
- CheckerOptionsUsecase: scope-based option resolution, validation,
  auto-fill from execution context (domain, zone, service)
- CheckPlanUsecase: CRUD for user scheduling configurations
- CheckStatusUsecase: aggregated status queries, execution history
- CheckerEngine: full execution pipeline (observe, evaluate, aggregate)
- Scheduler: background job executor with auto-discovery, min-heap
  queue, worker pool, and jitter-based scheduling

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 11:58:00 +07:00
e728a43d5b checkers: add storage interfaces and implementations
Add the persistence layer for the checker system:
- Storage interfaces (CheckPlanStorage, CheckerOptionsStorage,
  CheckEvaluationStorage, ExecutionStorage, ObservationSnapshotStorage,
  SchedulerStateStorage) in the usecase/checker package
- KV-based implementations for LevelDB/Oracle NoSQL backends
- In-memory implementation for testing
- Integrate checker storage into the main Storage interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:58:52 +07:00
45a68b4091 checkers: add map-based option validation for checker fields
Add ValidateMapValues() to the forms package for validating
checker option maps against field documentation (required fields,
allowed choices, type checking).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:58:52 +07:00
eb347a4318 checkers: add domain model types and checker core registry
Introduce the foundational types for the checker system:
- CheckTarget, CheckPlan, Execution, CheckEvaluation model types
- CheckerDefinition, CheckerOptions, ObservationSnapshot types
- CheckRule, ObservationProvider, CheckAggregator interfaces
- CheckerEngine interface for orchestrating check pipelines
- Global checker and observation provider registries
- WorstStatusAggregator for combining rule results
- New error types, config option for max concurrency, and
  AutoFill constants for context-driven option resolution

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:58:52 +07:00
13a58d18d3 fix: guard against undefined entry in domainLink helper
Add null checks to prevent runtime errors when the domain index entry
does not exist for the given identifier.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 10:58:52 +07:00
23 changed files with 661 additions and 170 deletions

View file

@ -36,6 +36,9 @@ import (
"git.happydns.org/happyDomain/services/abstract"
)
// ObservationKeyPing is the observation key for ICMP ping data.
const ObservationKeyPing happydns.ObservationKey = "ping"
// PingData holds the collected ping results for all targets.
type PingData struct {
Targets []PingTargetResult `json:"targets"`
@ -57,7 +60,7 @@ type PingTargetResult struct {
type pingProvider struct{}
func (p *pingProvider) Key() happydns.ObservationKey {
return "ping"
return ObservationKeyPing
}
// ipsFromService extracts IP addresses from an auto-filled abstract.Server service.
@ -247,24 +250,15 @@ func (r *pingRule) ValidateOptions(opts happydns.CheckerOptions) error {
}
func (r *pingRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
raw, err := obs.Get(ctx, "ping")
if err != nil {
var data PingData
if err := obs.Get(ctx, ObservationKeyPing, &data); err != nil {
return happydns.CheckState{
Status: happydns.StatusError,
Message: fmt.Sprintf("Failed to collect ping data: %v", err),
Message: fmt.Sprintf("Failed to get ping data: %v", err),
Code: "ping_error",
}
}
data, ok := raw.(*PingData)
if !ok {
return happydns.CheckState{
Status: happydns.StatusError,
Message: "Invalid ping data format",
Code: "ping_format_error",
}
}
warningRTT := checker.GetFloatOption(opts, "warningRTT", 100)
criticalRTT := checker.GetFloatOption(opts, "criticalRTT", 500)
warningPacketLoss := checker.GetFloatOption(opts, "warningPacketLoss", 10)
@ -340,10 +334,11 @@ func init() {
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "privileged",
Type: "boolean",
Label: "Use privileged ICMP (requires CAP_NET_RAW or root)",
Default: false,
Id: "privileged",
Type: "boolean",
Label: "Use privileged ICMP (requires CAP_NET_RAW or root)",
Default: false,
NoOverride: true,
},
},
ServiceOpts: []happydns.CheckerOptionDocumentation{

3
go.mod
View file

@ -23,6 +23,7 @@ require (
github.com/mileusna/useragent v1.3.5
github.com/oracle/nosql-go-sdk v1.4.7
github.com/ovh/go-ovh v1.9.0
github.com/prometheus-community/pro-bing v0.8.0
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.1
@ -76,7 +77,6 @@ require (
github.com/aws/aws-sdk-go-v2/service/sts v1.41.8 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/billputer/go-namecheap v0.0.0-20210108011502-994a912fb7f9 // indirect
@ -180,7 +180,6 @@ require (
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pquerna/otp v1.5.0 // indirect
github.com/prometheus-community/pro-bing v0.8.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect

2
go.sum
View file

@ -107,8 +107,6 @@ github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6 h1:4NNbNM2Iq/k57qEu7WfL67UrbPq1uFWxW4qODCohi+0=
github.com/babolivier/go-doh-client v0.0.0-20201028162107-a76cff4cb8b6/go.mod h1:J29hk+f9lJrblVIfiJOtTFk+OblBawmib4uz/VdKzlg=
github.com/benbjohnson/clock v1.3.5 h1:VvXlSJBzZpA/zum6Sj74hxwYI2DIxRWuNIoXAzHZz5o=
github.com/benbjohnson/clock v1.3.5/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=

View file

@ -22,7 +22,6 @@
package controller
import (
"encoding/json"
"fmt"
"net/http"
"strconv"
@ -235,7 +234,7 @@ func (cc *CheckerController) GetExecutionObservation(c *gin.Context) {
return
}
c.JSON(http.StatusOK, val)
c.Data(http.StatusOK, "application/json; charset=utf-8", val)
}
// GetExecutionResults returns the evaluation (per-rule states) for an execution.
@ -380,13 +379,7 @@ func (cc *CheckerController) GetExecutionHTMLReport(c *gin.Context) {
return
}
raw, err := json.Marshal(val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, json.RawMessage(raw))
htmlContent, supported, err := checkerPkg.GetHTMLReport(obsKey, val)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return

View file

@ -263,8 +263,9 @@ func (app *App) initUsecases() {
app.store,
app.store,
app.store,
app.store,
)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store)
app.usecases.checkerScheduler = checkerUC.NewScheduler(app.usecases.checkerEngine, app.cfg.CheckerMaxConcurrency, app.store, app.store, app.store, app.store)
app.usecases.checkerStatusUC.SetPlannedJobProvider(app.usecases.checkerScheduler)
}

View file

@ -51,37 +51,48 @@ func GetObservationProviders() map[happydns.ObservationKey]happydns.ObservationP
return providerRegistry
}
// ObservationCacheLookup resolves a cached observation for a target+key.
// Returns the raw data and collection time, or an error if not cached.
type ObservationCacheLookup func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error)
// ObservationContext provides lazy-loading, cached, thread-safe access to observation data.
// Collected data is serialized to json.RawMessage immediately after collection.
type ObservationContext struct {
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]any
errors map[happydns.ObservationKey]error
mu sync.RWMutex
target happydns.CheckTarget
opts happydns.CheckerOptions
cache map[happydns.ObservationKey]json.RawMessage
errors map[happydns.ObservationKey]error
mu sync.RWMutex
cacheLookup ObservationCacheLookup // nil = no DB cache
freshness time.Duration // 0 = always collect
}
// NewObservationContext creates a new ObservationContext for the given target and options.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions) *ObservationContext {
// cacheLookup and freshness enable cross-checker observation reuse from stored snapshots.
// Pass nil and 0 to disable DB-based caching.
func NewObservationContext(target happydns.CheckTarget, opts happydns.CheckerOptions, cacheLookup ObservationCacheLookup, freshness time.Duration) *ObservationContext {
return &ObservationContext{
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]any),
errors: make(map[happydns.ObservationKey]error),
target: target,
opts: opts,
cache: make(map[happydns.ObservationKey]json.RawMessage),
errors: make(map[happydns.ObservationKey]error),
cacheLookup: cacheLookup,
freshness: freshness,
}
}
// Get returns the observation data for the given key, collecting it lazily if needed.
// Get collects observation data for the given key (lazily) and unmarshals it into dest.
// Thread-safe: concurrent calls for the same key will only trigger one collection.
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey) (any, error) {
func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationKey, dest any) error {
// Fast path: check cache under read lock.
oc.mu.RLock()
if val, ok := oc.cache[key]; ok {
if raw, ok := oc.cache[key]; ok {
oc.mu.RUnlock()
return val, nil
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
oc.mu.RUnlock()
return nil, err
return err
}
oc.mu.RUnlock()
@ -90,36 +101,53 @@ func (oc *ObservationContext) Get(ctx context.Context, key happydns.ObservationK
defer oc.mu.Unlock()
// Double-check after acquiring write lock.
if val, ok := oc.cache[key]; ok {
return val, nil
if raw, ok := oc.cache[key]; ok {
return json.Unmarshal(raw, dest)
}
if err, ok := oc.errors[key]; ok {
return nil, err
return err
}
// Try DB cache before collecting fresh data.
if oc.cacheLookup != nil && oc.freshness > 0 {
if raw, collectedAt, err := oc.cacheLookup(oc.target, key); err == nil {
if time.Since(collectedAt) < oc.freshness {
oc.cache[key] = raw
return json.Unmarshal(raw, dest)
}
}
}
provider := GetObservationProvider(key)
if provider == nil {
err := fmt.Errorf("no observation provider registered for key %q", key)
oc.errors[key] = err
return nil, err
return err
}
val, err := provider.Collect(ctx, oc.target, oc.opts)
if err != nil {
oc.errors[key] = err
return nil, err
return err
}
oc.cache[key] = val
return val, nil
raw, err := json.Marshal(val)
if err != nil {
err = fmt.Errorf("observation %q: marshal failed: %w", key, err)
oc.errors[key] = err
return err
}
oc.cache[key] = json.RawMessage(raw)
return json.Unmarshal(raw, dest)
}
// Data returns all cached observation data.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]any {
// Data returns all cached observation data as pre-serialized JSON.
func (oc *ObservationContext) Data() map[happydns.ObservationKey]json.RawMessage {
oc.mu.RLock()
defer oc.mu.RUnlock()
data := make(map[happydns.ObservationKey]any, len(oc.cache))
data := make(map[happydns.ObservationKey]json.RawMessage, len(oc.cache))
for k, v := range oc.cache {
data[k] = v
}
@ -181,12 +209,8 @@ func GetMetrics(key happydns.ObservationKey, raw json.RawMessage, collectedAt ti
// GetAllMetrics extracts metrics from all observation keys in a snapshot.
func GetAllMetrics(snap *happydns.ObservationSnapshot) ([]happydns.CheckMetric, error) {
var allMetrics []happydns.CheckMetric
for key, val := range snap.Data {
raw, err := json.Marshal(val)
if err != nil {
continue
}
metrics, supported, err := GetMetrics(key, json.RawMessage(raw), snap.CollectedAt)
for key, raw := range snap.Data {
metrics, supported, err := GetMetrics(key, raw, snap.CollectedAt)
if err != nil || !supported {
continue
}

View file

@ -579,6 +579,32 @@ func (s *InMemoryStorage) ClearSnapshots() error {
return nil
}
// --- ObservationCacheStorage ---
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
}
func (s *InMemoryStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
data, ok := s.data[obsCacheKey(target, key)]
if !ok {
return nil, happydns.ErrNotFound
}
entry := &happydns.ObservationCacheEntry{}
if err := s.DecodeData(data, entry); err != nil {
return nil, err
}
return entry, nil
}
func (s *InMemoryStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
return s.Put(obsCacheKey(target, key), entry)
}
// --- SchedulerStateStorage ---
func (s *InMemoryStorage) GetLastSchedulerRun() (time.Time, error) {

View file

@ -45,6 +45,7 @@ type Storage interface {
checker.CheckerOptionsStorage
checker.CheckEvaluationStorage
checker.ExecutionStorage
checker.ObservationCacheStorage
checker.ObservationSnapshotStorage
checker.SchedulerStateStorage
domain.DomainStorage

View file

@ -0,0 +1,45 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"fmt"
"git.happydns.org/happyDomain/model"
)
func obsCacheKey(target happydns.CheckTarget, key happydns.ObservationKey) string {
return fmt.Sprintf("obscache-%s-%s", target.String(), key)
}
func (s *KVStorage) GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error) {
entry := &happydns.ObservationCacheEntry{}
err := s.db.Get(obsCacheKey(target, key), entry)
if err != nil {
return nil, err
}
return entry, nil
}
func (s *KVStorage) PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error {
return s.db.Put(obsCacheKey(target, key), entry)
}

View file

@ -361,10 +361,10 @@ func (u *CheckStatusUsecase) GetSnapshotByExecution(execID happydns.Identifier,
return nil, err
}
val, ok := snap.Data[obsKey]
raw, ok := snap.Data[obsKey]
if !ok {
return nil, happydns.ErrSnapshotNotFound
}
return json.Marshal(val)
return raw, nil
}

View file

@ -23,6 +23,7 @@ package checker
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
@ -37,6 +38,7 @@ type checkerEngine struct {
evalStore CheckEvaluationStorage
execStore ExecutionStorage
snapStore ObservationSnapshotStorage
cacheStore ObservationCacheStorage
}
// NewCheckerEngine creates a new CheckerEngine implementation.
@ -45,12 +47,14 @@ func NewCheckerEngine(
evalStore CheckEvaluationStorage,
execStore ExecutionStorage,
snapStore ObservationSnapshotStorage,
cacheStore ObservationCacheStorage,
) happydns.CheckerEngine {
return &checkerEngine{
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
optionsUC: optionsUC,
evalStore: evalStore,
execStore: execStore,
snapStore: snapStore,
cacheStore: cacheStore,
}
}
@ -141,8 +145,35 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("resolving options: %w", err)
}
// Build observation cache lookup for cross-checker reuse.
var cacheLookup checkerPkg.ObservationCacheLookup
if e.cacheStore != nil {
cacheLookup = func(target happydns.CheckTarget, key happydns.ObservationKey) (json.RawMessage, time.Time, error) {
entry, err := e.cacheStore.GetCachedObservation(target, key)
if err != nil {
return nil, time.Time{}, err
}
snap, err := e.snapStore.GetSnapshot(entry.SnapshotID)
if err != nil {
return nil, time.Time{}, err
}
raw, ok := snap.Data[key]
if !ok {
return nil, time.Time{}, fmt.Errorf("observation %q not in snapshot", key)
}
return raw, entry.CollectedAt, nil
}
}
var freshness time.Duration
if plan != nil && plan.Interval != nil {
freshness = *plan.Interval
} else if def.Interval != nil {
freshness = def.Interval.Default
}
// Create observation context for lazy data collection.
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts)
obsCtx := checkerPkg.NewObservationContext(target, mergedOpts, cacheLookup, freshness)
// Evaluate all rules, skipping disabled ones.
states := make([]happydns.CheckState, 0, len(def.Rules))
@ -174,6 +205,16 @@ func (e *checkerEngine) runPipeline(ctx context.Context, def *happydns.CheckerDe
return happydns.CheckState{}, nil, fmt.Errorf("creating snapshot: %w", err)
}
// Update observation cache pointers for cross-checker reuse.
if e.cacheStore != nil {
for key := range snap.Data {
_ = e.cacheStore.PutCachedObservation(target, key, &happydns.ObservationCacheEntry{
SnapshotID: snap.Id,
CollectedAt: snap.CollectedAt,
})
}
}
// Persist evaluation.
eval := &happydns.CheckEvaluation{
PlanID: planID,

View file

@ -52,8 +52,8 @@ func (r *testCheckRule) Name() string { return r.name }
func (r *testCheckRule) Description() string { return "test rule: " + r.name }
func (r *testCheckRule) Evaluate(ctx context.Context, obs happydns.ObservationGetter, opts happydns.CheckerOptions) happydns.CheckState {
_, err := obs.Get(ctx, "test_obs")
if err != nil {
var data map[string]any
if err := obs.Get(ctx, "test_obs", &data); err != nil {
return happydns.CheckState{Status: happydns.StatusError, Message: err.Error()}
}
return happydns.CheckState{Status: r.status, Message: r.name + " passed", Code: r.name}
@ -79,7 +79,7 @@ func TestCheckerEngine_RunOK(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -143,7 +143,7 @@ func TestCheckerEngine_RunWarn(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -188,7 +188,7 @@ func TestCheckerEngine_RunPerRuleDisable(t *testing.T) {
})
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
did, _ := happydns.NewRandomIdentifier()
@ -278,7 +278,7 @@ func TestCheckerEngine_RunNotFound(t *testing.T) {
t.Fatalf("NewInMemoryStorage() returned error: %v", err)
}
optionsUC := checkerUC.NewCheckerOptionsUsecase(store, nil)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store)
engine := checkerUC.NewCheckerEngine(optionsUC, store, store, store, store)
uid, _ := happydns.NewRandomIdentifier()
target := happydns.CheckTarget{UserId: &uid}

View file

@ -126,10 +126,24 @@ func (u *CheckerOptionsUsecase) GetCheckerOptions(
return nil, err
}
// Determine which fields are NoOverride.
var noOverrideIds map[string]bool
if def := checkerPkg.FindChecker(checkerName); def != nil {
noOverrideIds = getNoOverrideFieldIds(def)
}
merged := make(happydns.CheckerOptions)
// positionals are returned in order of increasing specificity.
for _, p := range positionals {
maps.Copy(merged, p.Options)
for k, v := range p.Options {
// If the key is NoOverride and already set by a less specific scope, skip it.
if noOverrideIds[k] {
if _, exists := merged[k]; exists {
continue
}
}
merged[k] = v
}
}
return merged, nil
}
@ -153,17 +167,26 @@ func (u *CheckerOptionsUsecase) SetCheckerOptions(
serviceId *happydns.Identifier,
opts happydns.CheckerOptions,
) error {
// Determine which field IDs are auto-filled for this checker.
// Determine which field IDs are auto-filled or NoOverride for this checker.
var autoFillIds map[string]string
var noOverrideScopes map[string]happydns.CheckScopeType
if def := checkerPkg.FindChecker(checkerName); def != nil {
autoFillIds = getAutoFillFieldIds(def)
noOverrideScopes = getNoOverrideFieldScopes(def)
}
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
filtered := make(happydns.CheckerOptions, len(opts))
for k, v := range opts {
if !isEmptyValue(v) && autoFillIds[k] == "" {
filtered[k] = v
if isEmptyValue(v) || autoFillIds[k] != "" {
continue
}
// Defense-in-depth: strip NoOverride fields at scopes below their definition.
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
continue
}
filtered[k] = v
}
return u.store.UpdateCheckerConfiguration(checkerName, userId, domainId, serviceId, filtered)
}
@ -181,7 +204,19 @@ func (u *CheckerOptionsUsecase) AddCheckerOptions(
if err != nil {
return nil, err
}
// Determine NoOverride scopes for defense-in-depth stripping.
var noOverrideScopes map[string]happydns.CheckScopeType
if def := checkerPkg.FindChecker(checkerName); def != nil {
noOverrideScopes = getNoOverrideFieldScopes(def)
}
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
for k, v := range newOpts {
// Defense-in-depth: skip NoOverride fields at scopes below their definition.
if defScope, ok := noOverrideScopes[k]; ok && currentScope > defScope {
continue
}
if isEmptyValue(v) {
delete(existing, k)
} else {
@ -331,6 +366,17 @@ func (u *CheckerOptionsUsecase) SetCheckerOption(
optName string,
value any,
) error {
// Defense-in-depth: reject NoOverride fields at scopes below their definition.
if def := checkerPkg.FindChecker(checkerName); def != nil {
noOverrideScopes := getNoOverrideFieldScopes(def)
if defScope, ok := noOverrideScopes[optName]; ok {
currentScope := scopeFromIdentifiers(userId, domainId, serviceId)
if currentScope > defScope {
return fmt.Errorf("option %q cannot be overridden at this scope level", optName)
}
}
}
existing, err := u.getScopedOptions(checkerName, userId, domainId, serviceId)
if err != nil {
return err
@ -374,6 +420,72 @@ func getAutoFillFieldIds(def *happydns.CheckerDefinition) map[string]string {
return result
}
// collectNoOverrideFromDoc scans all option groups in a CheckerOptionsDocumentation
// and adds any fields with NoOverride set to the result set.
func collectNoOverrideFromDoc(doc happydns.CheckerOptionsDocumentation, result map[string]bool) {
for _, group := range [][]happydns.Field{
doc.AdminOpts,
doc.UserOpts,
doc.DomainOpts,
doc.ServiceOpts,
doc.RunOpts,
} {
for _, f := range group {
if f.NoOverride {
result[f.Id] = true
}
}
}
}
// getNoOverrideFieldIds returns the set of field IDs that have NoOverride set
// for the given checker definition across all option groups and rules.
func getNoOverrideFieldIds(def *happydns.CheckerDefinition) map[string]bool {
result := make(map[string]bool)
collectNoOverrideFromDoc(def.Options, result)
for _, rule := range def.Rules {
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
collectNoOverrideFromDoc(rwo.Options(), result)
}
}
return result
}
// getNoOverrideFieldScopes returns a map from field ID to the scope at which
// the NoOverride field is defined. Used for defense-in-depth stripping.
func getNoOverrideFieldScopes(def *happydns.CheckerDefinition) map[string]happydns.CheckScopeType {
result := make(map[string]happydns.CheckScopeType)
scanGroups := func(doc happydns.CheckerOptionsDocumentation) {
for _, f := range doc.AdminOpts {
if f.NoOverride {
result[f.Id] = happydns.CheckScopeAdmin
}
}
for _, f := range doc.UserOpts {
if f.NoOverride {
result[f.Id] = happydns.CheckScopeUser
}
}
for _, f := range doc.DomainOpts {
if f.NoOverride {
result[f.Id] = happydns.CheckScopeDomain
}
}
for _, f := range doc.ServiceOpts {
if f.NoOverride {
result[f.Id] = happydns.CheckScopeService
}
}
}
scanGroups(def.Options)
for _, rule := range def.Rules {
if rwo, ok := rule.(happydns.CheckRuleWithOptions); ok {
scanGroups(rwo.Options())
}
}
return result
}
// buildAutoFillContext loads domain/zone data from storage and builds a map
// of auto-fill key to resolved value.
func (u *CheckerOptionsUsecase) buildAutoFillContext(
@ -484,6 +596,15 @@ func (u *CheckerOptionsUsecase) BuildMergedCheckerOptionsWithAutoFill(
merged := BuildMergedCheckerOptions(storedOpts, runOpts)
// Restore NoOverride fields from storedOpts so that runOpts cannot override them.
if def := checkerPkg.FindChecker(checkerName); def != nil {
for id := range getNoOverrideFieldIds(def) {
if v, ok := storedOpts[id]; ok {
merged[id] = v
}
}
}
target := happydns.CheckTarget{
UserId: userId,
DomainId: domainId,

View file

@ -1440,3 +1440,215 @@ func TestValidateOptions_SkipsAutoFillFields(t *testing.T) {
t.Fatalf("auto-fill required field should be skipped during validation, got: %v", err)
}
}
// --- NoOverride tests ---
func TestGetCheckerOptions_NoOverridePreservesAdminValue(t *testing.T) {
registerTestChecker("no_override_merge", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "threshold", Type: "number"},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
uid := idPtr()
// Set at admin scope.
store.UpdateCheckerConfiguration("no_override_merge", nil, nil, nil, happydns.CheckerOptions{
"locked": true,
})
// Attempt to override at user scope (should be ignored during merge).
store.UpdateCheckerConfiguration("no_override_merge", uid, nil, nil, happydns.CheckerOptions{
"locked": false,
"threshold": float64(42),
})
merged, err := uc.GetCheckerOptions("no_override_merge", uid, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["locked"] != true {
t.Errorf("expected locked=true (admin value preserved), got %v", merged["locked"])
}
if merged["threshold"] != float64(42) {
t.Errorf("expected threshold=42 (user value applied), got %v", merged["threshold"])
}
}
func TestGetCheckerOptions_NoOverrideAllowsSameScope(t *testing.T) {
registerTestChecker("no_override_same_scope", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
// Only admin scope sets the value — no conflict.
store.UpdateCheckerConfiguration("no_override_same_scope", nil, nil, nil, happydns.CheckerOptions{
"locked": true,
})
merged, err := uc.GetCheckerOptions("no_override_same_scope", nil, nil, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["locked"] != true {
t.Errorf("expected locked=true, got %v", merged["locked"])
}
}
func TestBuildMergedCheckerOptionsWithAutoFill_NoOverrideBlocksRunOpts(t *testing.T) {
registerTestChecker("no_override_runopt", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "threshold", Type: "number"},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
uid := idPtr()
// Admin sets locked=true.
store.UpdateCheckerConfiguration("no_override_runopt", nil, nil, nil, happydns.CheckerOptions{
"locked": true,
})
// User sets threshold.
store.UpdateCheckerConfiguration("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
"threshold": float64(10),
})
// RunOpts tries to override locked.
merged, err := uc.BuildMergedCheckerOptionsWithAutoFill("no_override_runopt", uid, nil, nil, happydns.CheckerOptions{
"locked": false,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if merged["locked"] != true {
t.Errorf("expected locked=true (NoOverride should block runOpts), got %v", merged["locked"])
}
if merged["threshold"] != float64(10) {
t.Errorf("expected threshold=10, got %v", merged["threshold"])
}
}
func TestSetCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
registerTestChecker("no_override_set", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "threshold", Type: "number"},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
uid := idPtr()
// Try to set locked at user scope — should be silently stripped.
err := uc.SetCheckerOptions("no_override_set", uid, nil, nil, happydns.CheckerOptions{
"locked": true,
"threshold": float64(99),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Check what was actually stored.
stored := store.data[posKey("no_override_set", uid, nil, nil)]
if _, ok := stored["locked"]; ok {
t.Error("expected locked to be stripped from user-scope storage")
}
if stored["threshold"] != float64(99) {
t.Errorf("expected threshold=99 to be stored, got %v", stored["threshold"])
}
}
func TestAddCheckerOptions_StripsNoOverrideAtLowerScope(t *testing.T) {
registerTestChecker("no_override_add", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{Id: "threshold", Type: "number"},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
uid := idPtr()
// Pre-populate user scope with threshold.
store.UpdateCheckerConfiguration("no_override_add", uid, nil, nil, happydns.CheckerOptions{
"threshold": float64(50),
})
// Try to add locked at user scope — should be silently skipped.
result, err := uc.AddCheckerOptions("no_override_add", uid, nil, nil, happydns.CheckerOptions{
"locked": true,
"threshold": float64(75),
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if _, ok := result["locked"]; ok {
t.Error("expected locked to be skipped in AddCheckerOptions result")
}
if result["threshold"] != float64(75) {
t.Errorf("expected threshold=75, got %v", result["threshold"])
}
}
func TestSetCheckerOption_RejectsNoOverrideAtLowerScope(t *testing.T) {
registerTestChecker("no_override_set_single", &happydns.CheckerDefinition{
Options: happydns.CheckerOptionsDocumentation{
AdminOpts: []happydns.CheckerOptionDocumentation{
{Id: "locked", Type: "boolean", NoOverride: true},
},
},
})
store := newOptionsStore()
uc := checkerUC.NewCheckerOptionsUsecase(store, nil)
uid := idPtr()
// Setting at admin scope should work.
err := uc.SetCheckerOption("no_override_set_single", nil, nil, nil, "locked", true)
if err != nil {
t.Fatalf("expected SetCheckerOption at admin scope to succeed, got: %v", err)
}
// Setting at user scope should fail.
err = uc.SetCheckerOption("no_override_set_single", uid, nil, nil, "locked", false)
if err == nil {
t.Fatal("expected error when setting NoOverride field at lower scope")
}
if !strings.Contains(err.Error(), "cannot be overridden") {
t.Errorf("unexpected error message: %v", err)
}
}

View file

@ -26,6 +26,7 @@ import (
"context"
"hash/fnv"
"log"
"slices"
"sort"
"sync"
"time"
@ -47,7 +48,7 @@ type SchedulerJob struct {
PlanID *happydns.Identifier `json:"planID" swaggertype:"string"`
Interval time.Duration `json:"interval" swaggertype:"integer"`
NextRun time.Time `json:"nextRun"`
index int `json:"index"` // heap index
index int // heap index
}
// SchedulerQueue is a min-heap of SchedulerJobs sorted by NextRun.
@ -98,6 +99,7 @@ type Scheduler struct {
engine happydns.CheckerEngine
planStore CheckPlanStorage
domainStore DomainLister
zoneStore ZoneGetter
stateStore SchedulerStateStorage
cancel context.CancelFunc
mu sync.RWMutex
@ -107,7 +109,7 @@ type Scheduler struct {
}
// NewScheduler creates a new Scheduler.
func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, stateStore SchedulerStateStorage) *Scheduler {
func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore CheckPlanStorage, domainStore DomainLister, zoneStore ZoneGetter, stateStore SchedulerStateStorage) *Scheduler {
if maxConcurrency <= 0 {
maxConcurrency = 1
}
@ -115,6 +117,7 @@ func NewScheduler(engine happydns.CheckerEngine, maxConcurrency int, planStore C
engine: engine,
planStore: planStore,
domainStore: domainStore,
zoneStore: zoneStore,
stateStore: stateStore,
maxConcurrency: maxConcurrency,
}
@ -319,30 +322,47 @@ func (s *Scheduler) buildQueue() {
}
}
// Collect checkers by scope for efficient iteration.
var domainCheckers, serviceCheckers []struct {
id string
def *happydns.CheckerDefinition
}
for checkerID, def := range checkers {
if def.Availability.ApplyToDomain {
domainCheckers = append(domainCheckers, struct {
id string
def *happydns.CheckerDefinition
}{checkerID, def})
}
if def.Availability.ApplyToService {
serviceCheckers = append(serviceCheckers, struct {
id string
def *happydns.CheckerDefinition
}{checkerID, def})
}
}
// Auto-discovery: enumerate all domains and schedule applicable checkers.
domains := s.loadAllDomains()
for _, domain := range domains {
uid := domain.Owner
did := domain.Id
target := happydns.CheckTarget{UserId: &uid, DomainId: &did}
domainTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did}
for checkerID, def := range checkers {
if !def.Availability.ApplyToDomain {
continue
}
key := checkerID + "|" + target.String()
for _, c := range domainCheckers {
key := c.id + "|" + domainTarget.String()
if disabledSet[key] {
continue
}
plan := planMap[key]
interval := s.effectiveInterval(def, plan)
offset := computeOffset(checkerID, target.String(), interval)
interval := s.effectiveInterval(c.def, plan)
offset := computeOffset(c.id, domainTarget.String(), interval)
nextRun := computeNextRun(interval, offset, lastRun)
job := &SchedulerJob{
CheckerID: checkerID,
Target: target,
CheckerID: c.id,
Target: domainTarget,
Interval: interval,
NextRun: nextRun,
}
@ -351,6 +371,41 @@ func (s *Scheduler) buildQueue() {
}
heap.Push(&s.queue, job)
}
// Service-level discovery: load the latest zone and match services.
if len(serviceCheckers) > 0 {
services := s.loadDomainServices(domain)
for _, svc := range services {
sid := svc.Id
svcTarget := happydns.CheckTarget{UserId: &uid, DomainId: &did, ServiceId: &sid, ServiceType: svc.Type}
for _, c := range serviceCheckers {
if len(c.def.Availability.LimitToServices) > 0 && !slices.Contains(c.def.Availability.LimitToServices, svc.Type) {
continue
}
key := c.id + "|" + svcTarget.String()
if disabledSet[key] {
continue
}
plan := planMap[key]
interval := s.effectiveInterval(c.def, plan)
offset := computeOffset(c.id, svcTarget.String(), interval)
nextRun := computeNextRun(interval, offset, lastRun)
job := &SchedulerJob{
CheckerID: c.id,
Target: svcTarget,
Interval: interval,
NextRun: nextRun,
}
if plan != nil {
job.PlanID = &plan.Id
}
heap.Push(&s.queue, job)
}
}
}
}
}
@ -387,6 +442,25 @@ func (s *Scheduler) loadAllDomains() []*happydns.Domain {
return domains
}
func (s *Scheduler) loadDomainServices(domain *happydns.Domain) []*happydns.ServiceMessage {
if s.zoneStore == nil || len(domain.ZoneHistory) == 0 {
return nil
}
latestZoneID := domain.ZoneHistory[len(domain.ZoneHistory)-1]
zone, err := s.zoneStore.GetZone(latestZoneID)
if err != nil {
log.Printf("Scheduler: failed to load zone %s for domain %s: %v", latestZoneID, domain.DomainName, err)
return nil
}
var services []*happydns.ServiceMessage
for _, svcs := range zone.Services {
services = append(services, svcs...)
}
return services
}
func (s *Scheduler) effectiveInterval(def *happydns.CheckerDefinition, plan *happydns.CheckPlan) time.Duration {
interval := defaultInterval
if def.Interval != nil {
@ -423,9 +497,7 @@ func (s *Scheduler) spreadOverdueJobs() {
}
window := time.Duration(len(overdue)) * minSpacing
if window > maxCatchUpWindow {
window = maxCatchUpWindow
}
window = min(window, maxCatchUpWindow)
for i, job := range overdue {
delay := window * time.Duration(i) / time.Duration(len(overdue))

View file

@ -38,6 +38,11 @@ type DomainLister interface {
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
}
// ZoneGetter is the minimal interface needed by the scheduler to load zones for service discovery.
type ZoneGetter interface {
GetZone(id happydns.Identifier) (*happydns.ZoneMessage, error)
}
// CheckAutoFillStorage provides access to domain, zone and user data
// needed to resolve auto-fill field values at execution time.
type CheckAutoFillStorage interface {
@ -107,3 +112,10 @@ type ObservationSnapshotStorage interface {
DeleteSnapshot(snapID happydns.Identifier) error
ClearSnapshots() error
}
// ObservationCacheStorage provides a lightweight cache mapping (target, observation key)
// to the snapshot that holds the most recent data.
type ObservationCacheStorage interface {
GetCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey) (*happydns.ObservationCacheEntry, error)
PutCachedObservation(target happydns.CheckTarget, key happydns.ObservationKey, entry *happydns.ObservationCacheEntry) error
}

View file

@ -241,7 +241,13 @@ type ObservationSnapshot struct {
Id Identifier `json:"id" swaggertype:"string" binding:"required" readonly:"true"`
Target CheckTarget `json:"target" binding:"required" readonly:"true"`
CollectedAt time.Time `json:"collectedAt" binding:"required" readonly:"true" format:"date-time"`
Data map[ObservationKey]any `json:"data" binding:"required" readonly:"true"`
Data map[ObservationKey]json.RawMessage `json:"data" binding:"required" readonly:"true"`
}
// ObservationCacheEntry is a lightweight pointer to cached observation data in a snapshot.
type ObservationCacheEntry struct {
SnapshotID Identifier `json:"snapshotId"`
CollectedAt time.Time `json:"collectedAt"`
}
// ExecutionStatus represents the lifecycle state of an execution.
@ -311,8 +317,9 @@ type CheckRuleWithOptions interface {
}
// ObservationGetter provides access to observation data (used by CheckRule).
// Get unmarshals observation data into dest (like json.Unmarshal).
type ObservationGetter interface {
Get(ctx context.Context, key ObservationKey) (any, error)
Get(ctx context.Context, key ObservationKey, dest any) error
}
// CheckAggregator combines multiple CheckStates into a single result.

View file

@ -108,6 +108,10 @@ type Field struct {
// AutoFill indicates that this field is automatically filled by the system
// based on execution context (e.g. domain name, zone, service type).
AutoFill string `json:"autoFill,omitempty"`
// NoOverride indicates that once this field is set at a given scope,
// more specific scopes cannot override its value.
NoOverride bool `json:"noOverride,omitempty"`
}
type FormState struct {

View file

@ -75,11 +75,13 @@
onclean,
}: Props = $props();
// Filter out auto-fill fields from editable groups (they are system-provided).
// Filter out auto-fill and noOverride fields from editable groups.
// Auto-fill fields are system-provided; noOverride fields can only be
// changed at the scope where they are defined (typically admin).
let filteredEditableGroups = $derived(
editableGroups.map((g) => ({
...g,
opts: g.opts.filter((opt) => !opt.autoFill),
opts: g.opts.filter((opt) => !opt.autoFill && !opt.noOverride),
})),
);

View file

@ -107,7 +107,7 @@
const ids = new Set<string>();
if (!resolvedStatus) return ids;
const addOpts = (opts: HappydnsCheckerOptionDocumentation[] | undefined) =>
opts?.forEach((o) => o.id && ids.add(o.id));
opts?.forEach((o) => o.id && !o.noOverride && ids.add(o.id));
addOpts(resolvedStatus.options?.runOpts);
addOpts(resolvedStatus.options?.adminOpts);
addOpts(resolvedStatus.options?.userOpts);
@ -204,7 +204,7 @@
{@const runOpts = [
...(status.options?.runOpts || []),
...activeRulesForOpts.flatMap((r: any) => r?.options?.runOpts || []),
]}
].filter((o: any) => !o.noOverride)}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
@ -214,7 +214,7 @@
...(r?.options?.userOpts || []),
...(r?.options?.domainOpts || []),
]),
].filter((o: any) => o.id)}
].filter((o: any) => o.id && !o.noOverride)}
<Form
id="run-check-modal"
onsubmit={(e: Event) => {

View file

@ -506,73 +506,6 @@
"no-group": "Divers",
"title": "Vos groupes"
},
"checkers": {
"children-checkers": {
"title": "Vérifications de zones et services",
"description": "Ces vérifications s'appliquent aux zones ou aux services individuels. Vous pouvez les configurer ici, mais les résultats n'apparaîtront que lors de l'exécution au niveau approprié."
},
"executions": {
"loading": "Chargement des résultats...",
"no-results": "Aucun résultat. Cliquez sur «Exécuter maintenant» pour lancer la vérification.",
"title": "Exécutions ({{count}})",
"run-check-now": "Exécuter maintenant",
"back-to-checks": "Retour aux vérifications",
"delete-all": "Tout supprimer",
"delete-confirm": "Voulez-vous vraiment supprimer ce résultat ?",
"delete-all-confirm": "Voulez-vous vraiment supprimer TOUS les résultats de cette vérification ? Cette action est irréversible.",
"deleted-all": "Tous les résultats ont été supprimés.",
"delete-failed": "Échec de la suppression",
"delete-all-failed": "Échec de la suppression des résultats",
"configure": "Configurer",
"domain-level": "Au niveau du domaine",
"error-loading": "Erreur lors du chargement : {{error}}",
"error-deleting": "Erreur lors de la suppression : {{error}}",
"table": {
"executed-at": "Date d'exécution",
"status": "Statut",
"message": "Message",
"duration": "Durée",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Planifié",
"manual": "Manuel"
},
"pending": {
"queued": "En attente",
"queued-description": "En attente d'exécution…",
"running": "En cours",
"running-description": "Vérification en cours…"
},
"view": "Voir"
},
"run-check": {
"title": "Exécuter la vérification",
"loading-options": "Chargement des options du vérificateur...",
"select-rule": "Règle à vérifier",
"configure-info": "Configurez les options du vérificateur ci-dessous. Les valeurs pré-remplies proviennent des paramètres au niveau du domaine.",
"no-options": "Ce vérificateur n'a pas d'options configurables. Cliquez sur « Exécuter la vérification » pour lancer avec les paramètres par défaut.",
"no-run-options": "Ce vérificateur n'a pas d'options d'exécution. Vous pouvez toujours écraser les paramètres avancés ci-dessous.",
"error-loading-options": "Erreur lors du chargement des options du vérificateur : {{error}}",
"run-button": "Exécuter la vérification",
"triggered-success": "Vérification déclenchée avec succès ! ID d'exécution : {{id}}",
"trigger-failed": "Échec du déclenchement de la vérification : {{error}}",
"advanced-options": "Options avancées",
"rules": "Règles"
},
"result": {
"view-metrics": "Métriques",
"view-html": "Rapport HTML",
"view-json": "JSON brut",
"download-html": "Télécharger HTML",
"download-json": "Télécharger JSON",
"full-report": "Rapport complet",
"metric-name": "Métrique",
"metric-value": "Valeur",
"metric-unit": "Unité"
}
},
"zones": {
"viewer": "Visualiseur de zone",
"viewer-subtitle": "Vos services en un coup d'œil",

View file

@ -110,5 +110,10 @@ export const domains_by_groups = derived(domains, ($domains: Array<HappydnsDomai
});
export function domainLink(dnid: string): string {
return get(domains_idx)[get(domains_idx)[dnid].domain] ? get(domains_idx)[dnid].domain : dnid;
const idx = get(domains_idx);
const entry = idx[dnid];
if (entry && idx[entry.domain]) {
return entry.domain;
}
return dnid;
}

View file

@ -111,7 +111,7 @@ export function collectAllOptionDocs(
...(r.options?.userOpts || []),
...(r.options?.domainOpts || []),
]),
];
].filter((o) => !o.noOverride);
}
export function downloadBlob(content: string, filename: string, mime: string) {