Compare commits

...

16 commits

Author SHA1 Message Date
1bba7d1279 Add a test plugin for Zonemaster
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-19 20:02:18 +07:00
29ae8addf4 Add a test plugin for Matrix Federation 2026-02-19 20:02:18 +07:00
408979a3b4 Write plugin technical documentation 2026-02-19 20:02:18 +07:00
4cccbc6661 Implement auto-fill variables for checker option fields
Add an AutoFill attribute to the Field struct that marks option fields
as automatically resolved by the software based on test context, rather
than requiring user input. Auto-fill always overrides any user-provided
value at execution time.
2026-02-19 20:02:18 +07:00
6fbc2923ee Add admin API and frontend for scheduler management 2026-02-19 20:02:18 +07:00
2d58b317e4 web: Add frontend for domain tests browsing and execution
Add test API client, data models, Svelte store, and pages to list
available tests per domain, view results, and trigger test runs via a
dedicated modal. Also refactor plugins page to use a shared store.
2026-02-19 20:02:18 +07:00
b2616c5c3a Implement tests scheduler 2026-02-19 20:02:18 +07:00
ef6b8ea294 Implement backend model for test results and schedule 2026-02-19 20:02:18 +07:00
f89fc9dc2b Add checker interface: api routes and frontend to manage user checker 2026-02-19 20:02:18 +07:00
017b85ec85 Add check routes to API + refactor check controller 2026-02-19 20:02:18 +07:00
787685a52c web-admin: Implement checkers interface with option editor 2026-02-19 20:02:18 +07:00
f861b38e56 Implement plugin options retrieval 2026-02-19 20:02:18 +07:00
f26cbb3faa Add usescases to handle checkers 2026-02-19 20:02:18 +07:00
1276997e42 Load checks plugins 2026-02-19 20:02:18 +07:00
2dce5a1a79 New custom flag parser: ArrayArgs 2026-02-19 20:02:18 +07:00
b9185136d2 web: Add transition to VoxPeople card and fix URL param 2026-02-19 20:02:18 +07:00
76 changed files with 9327 additions and 36 deletions

61
checks/interface.go Normal file
View file

@ -0,0 +1,61 @@
// 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 checks provides the registry for domain health checkers.
// It allows individual checker implementations to self-register at startup
// via init() functions and exposes functions to retrieve registered checkers.
package checks // import "git.happydns.org/happyDomain/checks"
import (
"fmt"
"log"
"git.happydns.org/happyDomain/model"
)
// checkersList is the ordered list of all registered checks.
var checkersList map[string]happydns.Checker = map[string]happydns.Checker{}
// RegisterChecker declares the existence of the given check. It is intended to
// be called from init() functions in individual check files so that each check
// self-registers at program startup.
//
// If two checks try to register the same environment name the program will
// terminate: name collisions are a configuration error, not a runtime one.
func RegisterChecker(name string, checker happydns.Checker) {
log.Println("Registering new checker:")
checkersList[name] = checker
}
// GetCheckers returns the ordered list of all registered checks.
func GetCheckers() *map[string]happydns.Checker {
return &checkersList
}
// FindChecker returns the check registered under the given environment name,
// or an error if no check with that name exists.
func FindChecker(name string) (happydns.Checker, error) {
c, ok := checkersList[name]
if !ok {
return nil, fmt.Errorf("unable to find check %q", name)
}
return c, nil
}

307
checks/zonemaster.go Normal file
View file

@ -0,0 +1,307 @@
package checks
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
func init() {
RegisterChecker("zonemaster", &ZonemasterCheck{})
}
type ZonemasterCheck struct{}
func (p *ZonemasterCheck) ID() string {
return "zonemaster"
}
func (p *ZonemasterCheck) Name() string {
return "Zonemaster"
}
func (p *ZonemasterCheck) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
}
}
func (p *ZonemasterCheck) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "domainName",
Type: "string",
Label: "Domain name to check",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
{
Id: "profile",
Type: "string",
Label: "Profile",
Placeholder: "default",
Default: "default",
},
},
UserOpts: []happydns.CheckerOptionDocumentation{
{
Id: "language",
Type: "select",
Label: "Result language",
Default: "en",
Choices: []string{
"en", // English
"fr", // French
"de", // German
"es", // Spanish
"sv", // Swedish
"da", // Danish
"fi", // Finnish
"nb", // Norwegian Bokmål
"nl", // Dutch
"pt", // Portuguese
},
},
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "zonemasterAPIURL",
Type: "string",
Label: "Zonemaster API URL",
Placeholder: "https://zonemaster.net/api",
Default: "https://zonemaster.net/api",
},
},
}
}
// JSON-RPC request/response structures
type jsonRPCRequest struct {
Jsonrpc string `json:"jsonrpc"`
Method string `json:"method"`
Params any `json:"params"`
ID int `json:"id"`
}
type jsonRPCResponse struct {
Jsonrpc string `json:"jsonrpc"`
Result json.RawMessage `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
} `json:"error,omitempty"`
ID int `json:"id"`
}
// Zonemaster API structures
type startCheckParams struct {
Domain string `json:"domain"`
Profile string `json:"profile,omitempty"`
IPv4 bool `json:"ipv4,omitempty"`
IPv6 bool `json:"ipv6,omitempty"`
}
type checkProgressParams struct {
CheckID string `json:"check_id"`
}
type getResultsParams struct {
ID string `json:"id"`
Language string `json:"language"`
}
type checkResult struct {
Module string `json:"module"`
Message string `json:"message"`
Level string `json:"level"`
Checkcase string `json:"checkcase,omitempty"`
}
type zonemasterResults struct {
CreatedAt string `json:"created_at"`
HashID string `json:"hash_id"`
Params map[string]any `json:"params"`
Results []checkResult `json:"results"`
CheckcaseDescriptions map[string]string `json:"checkcase_descriptions,omitempty"`
}
func (p *ZonemasterCheck) callJSONRPC(apiURL, method string, params any) (json.RawMessage, error) {
reqBody := jsonRPCRequest{
Jsonrpc: "2.0",
Method: method,
Params: params,
ID: 1,
}
jsonData, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("failed to marshal request: %w", err)
}
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return nil, fmt.Errorf("failed to call API: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var rpcResp jsonRPCResponse
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
return nil, fmt.Errorf("failed to decode response: %w", err)
}
if rpcResp.Error != nil {
return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
}
return rpcResp.Result, nil
}
func (p *ZonemasterCheck) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
// Extract options
domainName, ok := options["domainName"].(string)
if !ok || domainName == "" {
return nil, fmt.Errorf("domainName is required")
}
domainName = strings.TrimSuffix(domainName, ".")
apiURL, ok := options["zonemasterAPIURL"].(string)
if !ok || apiURL == "" {
return nil, fmt.Errorf("zonemasterAPIURL is required")
}
apiURL = strings.TrimSuffix(apiURL, "/")
language := "en"
if lang, ok := options["language"].(string); ok && lang != "" {
language = lang
}
profile := "default"
if prof, ok := options["profile"].(string); ok && prof != "" {
profile = prof
}
// Step 1: Start the check
startParams := startCheckParams{
Domain: domainName,
Profile: profile,
IPv4: true,
IPv6: true,
}
result, err := p.callJSONRPC(apiURL, "start_domain_check", startParams)
if err != nil {
return nil, fmt.Errorf("failed to start check: %w", err)
}
var checkID string
if err := json.Unmarshal(result, &checkID); err != nil {
return nil, fmt.Errorf("failed to parse check ID: %w", err)
}
if checkID == "" {
return nil, fmt.Errorf("received empty check ID")
}
// Step 2: Poll for check completion
progressParams := checkProgressParams{CheckID: checkID}
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
timeout := time.After(10 * time.Minute)
for {
select {
case <-timeout:
return nil, fmt.Errorf("check timeout after 10 minutes (check ID: %s)", checkID)
case <-ticker.C:
result, err := p.callJSONRPC(apiURL, "check_progress", progressParams)
if err != nil {
return nil, fmt.Errorf("failed to check progress: %w", err)
}
var progress float64
if err := json.Unmarshal(result, &progress); err != nil {
return nil, fmt.Errorf("failed to parse progress: %w", err)
}
if progress >= 100 {
goto checkComplete
}
}
}
checkComplete:
// Step 3: Get check results
resultsParams := getResultsParams{
ID: checkID,
Language: language,
}
result, err = p.callJSONRPC(apiURL, "get_check_results", resultsParams)
if err != nil {
return nil, fmt.Errorf("failed to get results: %w", err)
}
var results zonemasterResults
if err := json.Unmarshal(result, &results); err != nil {
return nil, fmt.Errorf("failed to parse results: %w", err)
}
// Analyze results to determine overall status
var (
errorCount int
warningCount int
infoCount int
criticalMsgs []string
)
for _, r := range results.Results {
switch strings.ToUpper(r.Level) {
case "CRITICAL", "ERROR":
errorCount++
if len(criticalMsgs) < 5 { // Keep first 5 critical messages
criticalMsgs = append(criticalMsgs, r.Message)
}
case "WARNING":
warningCount++
case "INFO", "NOTICE":
infoCount++
}
}
// Determine status
var status happydns.CheckResultStatus
var statusLine string
if errorCount > 0 {
status = happydns.CheckResultStatusKO
statusLine = fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
if len(criticalMsgs) > 0 {
statusLine += ": " + strings.Join(criticalMsgs[:min(2, len(criticalMsgs))], "; ")
}
} else if warningCount > 0 {
status = happydns.CheckResultStatusWarn
statusLine = fmt.Sprintf("%d warning(s) found", warningCount)
} else {
status = happydns.CheckResultStatusOK
statusLine = fmt.Sprintf("All checks passed (%d checks)", len(results.Results))
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: results,
}, nil
}

View file

@ -0,0 +1,211 @@
// 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles admin-level operations.
// All methods in this controller work with admin-scoped options (nil user/domain/service IDs).
type CheckerController struct {
*apicontroller.BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: apicontroller.NewBaseCheckerController(checkerService),
}
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cname")
checker, err := uc.BaseCheckerController.GetCheckerService().GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("checker", checker)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
opts, err := uc.BaseCheckerController.GetCheckerService().GetCheckerOptions(cname, nil, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// ListCheckers retrieves all available checks.
//
// @Summary List checkers (admin)
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of checker names to info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check info
// @Schemes
// @Description Retrieves the status information and available options for a specific checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerResponse "Checker status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Router /checks/{cname} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// GetCheckerOptions retrieves all options for a check.
//
// @Summary Get check options (admin)
// @Schemes
// @Description Retrieves all configuration options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Get admin-level options (nil user/domain/service IDs)
uc.GetCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// AddCheckerOptions adds or overwrites specific admin-level options for a check.
//
// @Summary Add checker options
// @Schemes
// @Description Adds or overwrites specific configuration options for a checker without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Add admin-level options (nil user/domain/service IDs)
uc.AddCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// ChangeCheckerOptions replaces all options for a checker.
//
// @Summary Replace checker options (admin)
// @Schemes
// @Description Replaces all configuration options for a check with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
cname := c.Param("cname")
// Replace admin-level options (nil user/domain/service IDs)
uc.ChangeCheckerOptionsWithScope(c, cname, nil, nil, nil)
}
// GetCheckerOption retrieves a specific option value for a checker.
//
// @Summary Get checker option (admin)
// @Schemes
// @Description Retrieves the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a checker.
//
// @Summary Set checker option (admin)
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a checker.
// @Tags checks
// @Accept json
// @Produce json
// @Param cname path string true "Checker name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cname}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
cname := c.Param("cname")
optname := c.Param("optname")
// Set admin-level option (nil user/domain/service IDs)
uc.SetCheckerOptionWithScope(c, cname, optname, nil, nil, nil)
}

View file

@ -0,0 +1,102 @@
// 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// AdminSchedulerController handles admin operations on the test scheduler
type AdminSchedulerController struct {
scheduler happydns.SchedulerUsecase
}
func NewAdminSchedulerController(scheduler happydns.SchedulerUsecase) *AdminSchedulerController {
return &AdminSchedulerController{scheduler: scheduler}
}
// GetSchedulerStatus returns the current scheduler state
//
// @Summary Get scheduler status
// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules
// @Tags scheduler
// @Produce json
// @Success 200 {object} happydns.SchedulerStatus
// @Router /scheduler [get]
func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// EnableScheduler enables the test scheduler at runtime
//
// @Summary Enable scheduler
// @Description Enables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/enable [post]
func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(true); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// DisableScheduler disables the test scheduler at runtime
//
// @Summary Disable scheduler
// @Description Disables the test scheduler at runtime without restarting the server
// @Tags scheduler
// @Success 200 {object} happydns.SchedulerStatus
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/disable [post]
func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) {
if err := ctrl.scheduler.SetEnabled(false); err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
}
// RescheduleUpcoming randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly.
//
// @Summary Reschedule upcoming tests
// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load
// @Tags scheduler
// @Produce json
// @Success 200 {object} map[string]int
// @Failure 500 {object} happydns.ErrorResponse
// @Router /scheduler/reschedule-upcoming [post]
func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
n, err := ctrl.scheduler.RescheduleUpcomingChecks()
if err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

@ -0,0 +1,51 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) {
cc := controller.NewCheckerController(dep.Checker)
apiChecksRoutes := router.Group("/checks")
apiChecksRoutes.GET("", cc.ListCheckers)
apiCheckerRoutes := apiChecksRoutes.Group("/:cname")
apiCheckerRoutes.Use(cc.CheckerHandler)
apiCheckerRoutes.GET("", cc.GetCheckerStatus)
//apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus)
apiCheckerRoutes.GET("/options", cc.GetCheckerOptions)
apiCheckerRoutes.POST("/options", cc.AddCheckerOptions)
apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions)
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname")
apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler)
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption)
apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption)
}

View file

@ -32,6 +32,8 @@ import (
// Dependencies holds all use cases required to register the admin API routes.
type Dependencies struct {
AuthUser happydns.AuthUserUsecase
Checker happydns.CheckerUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
Provider happydns.ProviderUsecase
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
declareBackupRoutes(cfg, apiRoutes, s)
declareDomainRoutes(apiRoutes, dep, s)
declareChecksRoutes(apiRoutes, dep)
declareProviderRoutes(apiRoutes, dep, s)
declareSchedulerRoutes(apiRoutes, dep)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dep, s)
declareUsersRoutes(apiRoutes, dep, s)

View file

@ -0,0 +1,38 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
)
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
schedulerRoute := router.Group("/scheduler")
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
}

View file

@ -0,0 +1,137 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// BaseCheckerController contains shared functionality for check controllers.
// It provides common methods that can be used by both admin and user-scoped controllers.
type BaseCheckerController struct {
checkerService happydns.CheckerUsecase
}
func NewBaseCheckerController(checkerService happydns.CheckerUsecase) *BaseCheckerController {
return &BaseCheckerController{
checkerService,
}
}
// GetCheckerService returns the check service for use by derived controllers.
func (bc *BaseCheckerController) GetCheckerService() happydns.CheckerUsecase {
return bc.checkerService
}
// ListCheckers retrieves all available checks.
func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
checkers, err := bc.checkerService.ListCheckers()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
res := map[string]happydns.CheckerResponse{}
for name, checker := range *checkers {
res[name] = happydns.CheckerResponse{
ID: name,
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
}
}
happydns.ApiResponse(c, res, nil)
}
// GetCheckerStatus retrieves the status and available options for a check.
func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) {
checker := c.MustGet("checker").(happydns.Checker)
c.JSON(http.StatusOK, happydns.CheckerResponse{
ID: checker.ID(),
Name: checker.Name(),
Availability: checker.Availability(),
Options: checker.Options(),
})
}
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
happydns.ApiResponse(c, opts, err)
}
// AddCheckerOptionsWithScope adds or overwrites specific options for a check with the given scope.
func (bc *BaseCheckerController) AddCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// ChangeCheckerOptionsWithScope replaces all options for a check with the given scope.
func (bc *BaseCheckerController) ChangeCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetCheckerOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.checkerService.SetCheckerOptions(cname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// GetCheckerOptionValue retrieves a specific option value from the context.
func (bc *BaseCheckerController) GetCheckerOptionValue(c *gin.Context) {
opt := c.MustGet("option")
happydns.ApiResponse(c, opt, nil)
}
// SetCheckerOptionWithScope sets or updates a specific option value for a check with the given scope.
func (bc *BaseCheckerController) SetCheckerOptionWithScope(c *gin.Context, cname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req any
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
po := happydns.CheckerOptions{}
po[optname] = req
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, po)
happydns.ApiResponse(c, true, err)
}

View file

@ -0,0 +1,211 @@
// 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 controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// CheckerController handles user-scoped check operations for the main API.
// All methods work with options scoped to the authenticated user.
type CheckerController struct {
*BaseCheckerController
}
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
return &CheckerController{
BaseCheckerController: NewBaseCheckerController(checkerService),
}
}
// ListCheckers retrieves all available checks.
//
// @Summary List all checks
// @Schemes
// @Description Returns a list of all available checks with their version information.
// @Tags checks
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of check names to version info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks [get]
func (uc *CheckerController) ListCheckers(c *gin.Context) {
uc.BaseCheckerController.ListCheckers(c)
}
// GetCheckerStatus retrieves the status and available options for a check.
//
// @Summary Get check status
// @Schemes
// @Description Retrieves the status information and available options for a specific check.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Check name"
// @Success 200 {object} happydns.CheckerResponse "Check status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Router /checks/{cid} [get]
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
uc.BaseCheckerController.GetCheckerStatus(c)
}
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
cname := c.Param("cid")
check, err := uc.checkerService.GetChecker(cname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
return
}
c.Set("check", check)
c.Next()
}
// CheckerOptionHandler is a middleware that retrieves a specific check option for the authenticated user and sets it in the context.
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cid")
optname := c.Param("optname")
opts, err := uc.checkerService.GetCheckerOptions(cname, &user.Id, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// GetCheckOptions retrieves all options for a check for the authenticated user.
//
// @Summary Get check options
// @Schemes
// @Description Retrieves all configuration options for a specific check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Check name"
// @Success 200 {object} happydns.CheckerOptions "Check options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cid}/options [get]
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cid")
uc.GetCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
}
// AddCheckerOptions adds or overwrites specific options for a check for the authenticated user.
//
// @Summary Add check options
// @Schemes
// @Description Adds or overwrites specific configuration options for a check for the authenticated user without affecting other options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Check name"
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cid}/options [post]
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cid")
uc.AddCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
}
// ChangeCheckerOptions replaces all options for a check for the authenticated user.
//
// @Summary Replace check options
// @Schemes
// @Description Replaces all configuration options for a check for the authenticated user with the provided options.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Checker name"
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cid}/options [put]
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cid")
uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
}
// GetCheckerOption retrieves a specific option value for a check for the authenticated user.
//
// @Summary Get check option
// @Schemes
// @Description Retrieves the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Check name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cid}/options/{optname} [get]
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
uc.GetCheckerOptionValue(c)
}
// SetCheckerOption sets or updates a specific option value for a check for the authenticated user.
//
// @Summary Set check option
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a check for the authenticated user.
// @Tags checks
// @Accept json
// @Produce json
// @Param cid path string true "Check name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /checks/{cid}/options/{optname} [put]
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
cname := c.Param("cid")
optname := c.Param("optname")
uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil)
}

View file

@ -0,0 +1,525 @@
// 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 controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckResultController handles check result operations
type CheckResultController struct {
scope happydns.CheckScopeType
checkerUC happydns.CheckerUsecase
checkResultUC happydns.CheckResultUsecase
checkerScheduleUC happydns.CheckerScheduleUsecase
checkScheduler happydns.SchedulerUsecase
}
func NewCheckResultController(
scope happydns.CheckScopeType,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkerScheduleUC happydns.CheckerScheduleUsecase,
checkScheduler happydns.SchedulerUsecase,
) *CheckResultController {
return &CheckResultController{
scope: scope,
checkerUC: checkerUC,
checkResultUC: checkResultUC,
checkerScheduleUC: checkerScheduleUC,
checkScheduler: checkScheduler,
}
}
// getTargetFromContext extracts the target ID from context based on scope
func (tc *CheckResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
switch tc.scope {
case happydns.CheckScopeUser:
user := c.MustGet("user").(*happydns.User)
return user.Id, nil
case happydns.CheckScopeDomain:
domain := c.MustGet("domain").(*happydns.Domain)
return domain.Id, nil
case happydns.CheckScopeService:
// Services are stored by ID in context
serviceID := c.MustGet("serviceid").(happydns.Identifier)
return serviceID, nil
default:
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
}
}
// ListAvailableChecks lists all available check plugins for the target scope
//
// @Summary List available checks
// @Description Retrieves all available check plugins for the target scope with their last execution status if enabled
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Success 200 {array} object "List of available checks"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks [get]
func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get all check plugins
plugins, err := tc.checkerUC.ListCheckers()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get schedules for this target
schedules, err := tc.checkerScheduleUC.ListSchedulesByTarget(tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Build schedule map
scheduleMap := make(map[string]*happydns.CheckerSchedule)
for _, sched := range schedules {
scheduleMap[sched.CheckerName] = sched
}
// Build response with last results
var checks []happydns.CheckerStatus
for checkername, check := range *plugins {
// Filter plugins by scope
if tc.scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain {
continue
}
if tc.scope == happydns.CheckScopeService && !check.Availability().ApplyToService {
continue
}
info := happydns.CheckerStatus{
CheckerName: checkername,
Enabled: true, // enabled by default unless explicitly disabled via a schedule
}
// Check if there's a schedule
if sched, ok := scheduleMap[checkername]; ok {
info.Enabled = sched.Enabled
info.Schedule = sched
// Get last result
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkername, tc.scope, targetID, 1)
if err == nil && len(results) > 0 {
info.LastResult = results[0]
}
}
checks = append(checks, info)
}
c.JSON(http.StatusOK, checks)
}
// ListLatestCheckResults retrieves the lacheck check results for a specific plugin
//
// @Summary Get lacheck check results
// @Description Retrieves the 5 most recent check results for a specific plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [get]
func (tc *CheckResultController) ListLatestCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, 5)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// TriggerCheck triggers an on-demand check execution
//
// @Summary Trigger check execution
// @Description Triggers an immediate check execution and returns the execution ID
// @Tags checks
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param body body object false "Optional: Plugin options"
// @Success 202 {object} object{execution_id=string}
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname} [post]
func (tc *CheckResultController) TriggerCheck(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse run options
var options happydns.SetCheckerOptionsRequest
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Merge options with upper levels (user, domain, service)
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.CheckScopeDomain:
domainID = &targetID
case happydns.CheckScopeService:
serviceID = &targetID
}
mergedOptions, err := tc.checkerUC.BuildMergedCheckerOptions(checkName, &user.Id, domainID, serviceID, options.Options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, mergedOptions)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
}
// GetCheckerOptions retrieves plugin options for the target scope
//
// @Summary Get check plugin options
// @Description Retrieves configuration options for a checker at the target scope
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Success 200 {object} happydns.CheckerOptions
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/options [get]
func (tc *CheckResultController) GetCheckerOptions(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.CheckScopeDomain:
domainID = &targetID
case happydns.CheckScopeService:
serviceID = &targetID
}
opts, err := tc.checkerUC.GetStoredCheckerOptionsNoDefault(checkName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// AddCheckerOptions adds or overwrites specific options
//
// @Summary Add check plugin options
// @Description Adds or overwrites specific options for a check plugin at the target scope
// @Tags checks
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param body body happydns.CheckerOptions true "Options to add"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/options [post]
func (tc *CheckResultController) AddCheckerOptions(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.CheckerOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.CheckScopeDomain:
domainID = &targetID
case happydns.CheckScopeService:
serviceID = &targetID
}
err = tc.checkerUC.OverwriteSomeCheckerOptions(checkName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// ChangeCheckerOptions replaces all options
//
// @Summary Replace check plugin options
// @Description Replaces all options for a check plugin at the target scope
// @Tags checks
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param body body happydns.CheckerOptions true "New complete options"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/options [put]
func (tc *CheckResultController) ChangeCheckerOptions(c *gin.Context) {
user := middleware.MyUser(c)
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.CheckerOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.CheckScopeDomain:
domainID = &targetID
case happydns.CheckScopeService:
serviceID = &targetID
}
err = tc.checkerUC.SetCheckerOptions(checkName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// GetCheckExecutionStatus retrieves the status of a check execution
//
// @Summary Get check execution status
// @Description Retrieves the current status of a check execution
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param execution_id path string true "Execution ID"
// @Success 200 {object} happydns.CheckExecution
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/executions/{execution_id} [get]
func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) {
executionIDStr := c.Param("execution_id")
executionID, err := happydns.NewIdentifierFromString(executionIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID"))
return
}
execution, err := tc.checkResultUC.GetCheckExecution(executionID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, execution)
}
// ListCheckResults lists all results for a check plugin
//
// @Summary List check results
// @Description Lists all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param limit query int false "Maximum number of results to return (default: 10)"
// @Success 200 {array} happydns.CheckResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [get]
func (tc *CheckResultController) ListCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse limit parameter
limit := 10
if limitStr := c.Query("limit"); limitStr != "" {
fmt.Sscanf(limitStr, "%d", &limit)
}
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// DropCheckResults deletes all results for a check plugin
//
// @Summary Delete all check results
// @Description Deletes all check results for a specific check plugin and target
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Success 204 "No Content"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results [delete]
func (tc *CheckResultController) DropCheckResults(c *gin.Context) {
checkName := c.Param("cname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetCheckPluginResult retrieves a specific check result
//
// @Summary Get check result
// @Description Retrieves a specific check result by ID
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.CheckResult
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [get]
func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, result)
}
// DropCheckResult deletes a specific check result
//
// @Summary Delete check result
// @Description Deletes a specific check result by ID
// @Tags checks
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param cname path string true "Check plugin name"
// @Param result_id path string true "Result ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [delete]
func (tc *CheckResultController) DropCheckResult(c *gin.Context) {
checkName := c.Param("cname")
resultIDStr := c.Param("result_id")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
return
}
err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,231 @@
// 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 controller
import (
"fmt"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// CheckerScheduleController handles test schedule operations
type CheckerScheduleController struct {
testScheduleUC happydns.CheckerScheduleUsecase
}
func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController {
return &CheckerScheduleController{
testScheduleUC: testScheduleUC,
}
}
// ListCheckerSchedules retrieves schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.CheckerSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) {
user := middleware.MyUser(c)
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}
// CreateCheckerSchedule creates a new test schedule
//
// @Summary Create test schedule
// @Description Creates a new test schedule for the authenticated user
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param body body happydns.CheckerSchedule true "Check schedule to create"
// @Success 201 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [post]
func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Set user ID
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusCreated, schedule)
}
// GetCheckerSchedule retrieves a specific schedule
//
// @Summary Get test schedule
// @Description Retrieves a specific test schedule by ID
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [get]
func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// UpdateCheckerSchedule updates an existing schedule
//
// @Summary Update test schedule
// @Description Updates an existing test schedule
// @Tags test-schedules
// @Accept json
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Param body body happydns.CheckerSchedule true "Updated schedule"
// @Success 200 {object} happydns.CheckerSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [put]
func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
var schedule happydns.CheckerSchedule
if err := c.ShouldBindJSON(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
// Ensure ID matches
schedule.Id = scheduleId
schedule.OwnerId = user.Id
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, schedule)
}
// DeleteCheckerSchedule deletes a schedule
//
// @Summary Delete test schedule
// @Description Deletes a test schedule
// @Tags test-schedules
// @Produce json
// @Param schedule_id path string true "Schedule ID"
// @Success 204 "No Content"
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [delete]
func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) {
user := middleware.MyUser(c)
scheduleIdStr := c.Param("schedule_id")
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
return
}
// Verify ownership
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
middleware.ErrorResponse(c, http.StatusForbidden, err)
return
}
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,49 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
happydns "git.happydns.org/happyDomain/model"
)
func DeclareChecksRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) {
tpc := controller.NewCheckerController(checkerUC)
router.GET("/checks", tpc.ListCheckers)
apiCheckRoutes := router.Group("/checks/:cid")
apiCheckRoutes.Use(tpc.CheckerHandler)
apiCheckRoutes.GET("", tpc.GetCheckerStatus)
apiCheckRoutes.GET("/options", tpc.GetCheckerOptions)
apiCheckRoutes.POST("/options", tpc.AddCheckerOptions)
apiCheckRoutes.PUT("/options", tpc.ChangeCheckerOptions)
apiCheckOptionsRoutes := apiCheckRoutes.Group("/options/:optname")
apiCheckOptionsRoutes.Use(tpc.CheckerOptionHandler)
apiCheckOptionsRoutes.GET("", tpc.GetCheckerOption)
apiCheckOptionsRoutes.PUT("", tpc.SetCheckerOption)
}

View file

@ -0,0 +1,81 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedCheckResultRoutes(
scopedRouter *gin.RouterGroup,
checkerUC happydns.CheckerUsecase,
checkResultUC happydns.CheckResultUsecase,
checkerScheduleUC happydns.CheckerScheduleUsecase,
checkScheduler happydns.SchedulerUsecase,
scope happydns.CheckScopeType,
) {
tc := controller.NewCheckResultController(
scope,
checkerUC,
checkResultUC,
checkerScheduleUC,
checkScheduler,
)
// List all available tests with their status
scopedRouter.GET("/tests", tc.ListAvailableChecks)
// Check-specific routes
apiChecksRoutes := scopedRouter.Group("/tests/:tname")
{
// Get latest results for a test
apiChecksRoutes.GET("", tc.ListLatestCheckResults)
// Trigger an on-demand test
apiChecksRoutes.POST("", tc.TriggerCheck)
// Manage check options at this scope
apiChecksRoutes.GET("/options", tc.GetCheckerOptions)
apiChecksRoutes.POST("/options", tc.AddCheckerOptions)
apiChecksRoutes.PUT("/options", tc.ChangeCheckerOptions)
// Check execution routes
apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id")
{
apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus)
}
// Check results routes
apiChecksRoutes.GET("/results", tc.ListCheckResults)
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
apiCheckResultsRoutes := apiChecksRoutes.Group("/results/:result_id")
{
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
}
}
}

View file

@ -0,0 +1,47 @@
// 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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) {
sc := controller.NewCheckerScheduleController(checkerScheduleUC)
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListCheckerSchedules)
schedulesRoutes.POST("", sc.CreateCheckerSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetCheckerSchedule)
scheduleRoutes.PUT("", sc.UpdateCheckerSchedule)
scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule)
}
}
}

View file

@ -29,7 +29,7 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
dc := controller.NewDomainController(
domainUC,
remoteZoneImporter,
@ -48,8 +48,12 @@ func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecas
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
// Declare test result routes for domain scope
DeclareScopedCheckResultRoutes(apiDomainsRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeDomain)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC)
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler)
}

View file

@ -34,6 +34,10 @@ type Dependencies struct {
Authentication happydns.AuthenticationUsecase
AuthUser happydns.AuthUserUsecase
CaptchaVerifier happydns.CaptchaVerifier
Checker happydns.CheckerUsecase
CheckResult happydns.CheckResultUsecase
CheckerSchedule happydns.CheckerScheduleUsecase
CheckScheduler happydns.SchedulerUsecase
Domain happydns.DomainUsecase
DomainLog happydns.DomainLogUsecase
FailureTracker happydns.FailureTracker
@ -99,10 +103,12 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
apiAuthRoutes.Use(middleware.AuthRequired())
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service)
DeclareChecksRoutes(apiAuthRoutes, dep.Checker)
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service, dep.Checker, dep.CheckResult, dep.CheckerSchedule, dep.CheckScheduler)
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
DeclareRecordRoutes(apiAuthRoutes)
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
}

View file

@ -29,7 +29,7 @@ import (
"git.happydns.org/happyDomain/model"
)
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase) {
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(serviceUC))
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
// Declare test result routes for service scope
DeclareScopedCheckResultRoutes(apiZonesSubdomainServiceIdRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeService)
}

View file

@ -29,7 +29,7 @@ import (
happydns "git.happydns.org/happyDomain/model"
)
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
zc := controller.NewZoneController(
zoneUC,
domainUC,
@ -50,7 +50,7 @@ func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, dom
apiZonesSubdomainRoutes.Use(middleware.SubdomainHandler)
apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain)
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC)
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler)
apiZonesRoutes.POST("/records", zc.AddRecords)
apiZonesRoutes.POST("/records/delete", zc.DeleteRecords)

View file

@ -58,6 +58,8 @@ func NewAdmin(app *App) *Admin {
admin.DeclareRoutes(app.cfg, router, app.store, admin.Dependencies{
AuthUser: app.usecases.authUser,
Checker: app.usecases.checker,
CheckScheduler: app.checkScheduler,
Domain: app.usecases.domain,
Provider: app.usecases.providerAdmin,
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,

View file

@ -38,6 +38,8 @@ import (
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase"
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
checkUC "git.happydns.org/happyDomain/internal/usecase/check"
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
@ -54,6 +56,9 @@ import (
type Usecases struct {
authentication happydns.AuthenticationUsecase
authUser happydns.AuthUserUsecase
checker happydns.CheckerUsecase
checkResult happydns.CheckResultUsecase
checkerSchedule happydns.CheckerScheduleUsecase
domain happydns.DomainUsecase
domainLog happydns.DomainLogUsecase
provider happydns.ProviderUsecase
@ -81,10 +86,10 @@ type App struct {
router *gin.Engine
srv *http.Server
store storage.Storage
checkScheduler happydns.SchedulerUsecase
usecases Usecases
}
func NewApp(cfg *happydns.Options) *App {
app := &App{
cfg: cfg,
@ -94,8 +99,12 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -109,8 +118,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
if err := app.initPlugins(); err != nil {
log.Fatalf("Plugin initialization error: %s", err)
}
app.initUsecases()
app.initCaptcha()
app.initCheckScheduler()
app.setupRouter()
return app
@ -184,6 +197,20 @@ func (app *App) initInsights() {
}
}
func (app *App) initCheckScheduler() {
if app.cfg.DisableScheduler {
// Use a disabled scheduler that returns clear errors
app.checkScheduler = &disabledScheduler{}
return
}
app.checkScheduler = newCheckScheduler(
app.cfg,
app.store,
app.usecases.checker,
)
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
@ -211,6 +238,9 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg)
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
@ -248,6 +278,7 @@ func (app *App) setupRouter() {
Authentication: app.usecases.authentication,
AuthUser: app.usecases.authUser,
CaptchaVerifier: app.captchaVerifier,
Checker: app.usecases.checker,
Domain: app.usecases.domain,
DomainLog: app.usecases.domainLog,
FailureTracker: app.failureTracker,
@ -280,6 +311,8 @@ func (app *App) Start() {
go app.insights.Run()
}
go app.checkScheduler.Run()
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
@ -305,4 +338,6 @@ func (app *App) Stop() {
if app.failureTracker != nil {
app.failureTracker.Close()
}
app.checkScheduler.Close()
}

View file

@ -0,0 +1,668 @@
// 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 app
import (
"container/heap"
"context"
"fmt"
"log"
"runtime"
"sync"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/model"
)
const (
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets
CheckExecutionTimeout = 5 * time.Minute // Max time for a single check
MaxRetries = 3 // Max retry attempts for failed checks
)
// Priority levels for test execution queue
const (
PriorityOnDemand = iota // On-demand tests (highest priority)
PriorityOverdue // Overdue scheduled tests
PriorityScheduled // Regular scheduled tests
)
// checkScheduler manages background test execution
type checkScheduler struct {
cfg *happydns.Options
store storage.Storage
checkerUsecase happydns.CheckerUsecase
resultUsecase *checkresult.CheckResultUsecase
scheduleUsecase *checkresult.CheckScheduleUsecase
stop chan struct{} // closed to stop the main Run loop
stopWorkers chan struct{} // closed to stop all workers simultaneously
runNowChan chan *queueItem // on-demand items routed through the main loop
workAvail chan struct{} // non-blocking signals that queue has new work
queue *priorityQueue
activeExecutions map[string]*activeExecution
workers []*worker
mu sync.RWMutex
wg sync.WaitGroup
runtimeEnabled bool
running bool
}
// activeExecution tracks a running test execution
type activeExecution struct {
execution *happydns.CheckExecution
cancel context.CancelFunc
startTime time.Time
}
// queueItem represents a test execution request in the queue
type queueItem struct {
schedule *happydns.CheckerSchedule
execution *happydns.CheckExecution
priority int
queuedAt time.Time
retries int
}
// --- container/heap implementation for priorityQueue ---
// priorityHeap is the underlying heap, ordered by priority then arrival time.
type priorityHeap []*queueItem
func (h priorityHeap) Len() int { return len(h) }
func (h priorityHeap) Less(i, j int) bool {
if h[i].priority != h[j].priority {
return h[i].priority < h[j].priority
}
return h[i].queuedAt.Before(h[j].queuedAt)
}
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) }
func (h *priorityHeap) Pop() any {
old := *h
n := len(old)
x := old[n-1]
old[n-1] = nil // avoid memory leak
*h = old[:n-1]
return x
}
// priorityQueue is a thread-safe min-heap of queueItems.
type priorityQueue struct {
h priorityHeap
mu sync.Mutex
}
func newPriorityQueue() *priorityQueue {
pq := &priorityQueue{}
heap.Init(&pq.h)
return pq
}
// Push adds an item to the queue.
func (q *priorityQueue) Push(item *queueItem) {
q.mu.Lock()
defer q.mu.Unlock()
heap.Push(&q.h, item)
}
// Pop removes and returns the highest-priority item, or nil if empty.
func (q *priorityQueue) Pop() *queueItem {
q.mu.Lock()
defer q.mu.Unlock()
if q.h.Len() == 0 {
return nil
}
return heap.Pop(&q.h).(*queueItem)
}
// Len returns the queue length.
func (q *priorityQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.h.Len()
}
// worker processes tests from the queue
type worker struct {
id int
scheduler *checkScheduler
}
// disabledScheduler is a no-op implementation used when scheduler is disabled
type disabledScheduler struct{}
func (d *disabledScheduler) Run() {}
func (d *disabledScheduler) Close() {}
// TriggerOnDemandCheck returns an error indicating the scheduler is disabled
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
}
// GetSchedulerStatus returns a status indicating the scheduler is disabled
func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
return happydns.SchedulerStatus{
ConfigEnabled: false,
RuntimeEnabled: false,
Running: false,
}
}
// SetEnabled returns an error since the scheduler is disabled in configuration
func (d *disabledScheduler) SetEnabled(enabled bool) error {
return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime")
}
// RescheduleUpcomingChecks returns an error since the scheduler is disabled
func (d *disabledScheduler) RescheduleUpcomingChecks() (int, error) {
return 0, fmt.Errorf("test scheduler is disabled in configuration")
}
// newCheckScheduler creates a new test scheduler
func newCheckScheduler(
cfg *happydns.Options,
store storage.Storage,
checkerUsecase happydns.CheckerUsecase,
) *checkScheduler {
numWorkers := cfg.TestWorkers
if numWorkers <= 0 {
numWorkers = runtime.NumCPU()
}
scheduler := &checkScheduler{
cfg: cfg,
store: store,
checkerUsecase: checkerUsecase,
resultUsecase: checkresult.NewCheckResultUsecase(store, cfg),
scheduleUsecase: checkresult.NewCheckScheduleUsecase(store, cfg, store, checkerUsecase),
stop: make(chan struct{}),
stopWorkers: make(chan struct{}),
runNowChan: make(chan *queueItem, 100),
workAvail: make(chan struct{}, numWorkers),
queue: newPriorityQueue(),
activeExecutions: make(map[string]*activeExecution),
workers: make([]*worker, numWorkers),
runtimeEnabled: true,
}
for i := 0; i < numWorkers; i++ {
scheduler.workers[i] = &worker{
id: i,
scheduler: scheduler,
}
}
return scheduler
}
// enqueue pushes an item to the priority queue and wakes one idle worker.
func (s *checkScheduler) enqueue(item *queueItem) {
s.queue.Push(item)
select {
case s.workAvail <- struct{}{}:
default:
// All workers are already busy or already notified; they will drain
// the queue on their own after finishing the current item.
}
}
// Close stops the scheduler and waits for all workers to finish.
func (s *checkScheduler) Close() {
log.Println("Stopping test scheduler...")
// Unblock the main Run loop.
close(s.stop)
// Unblock all workers simultaneously.
close(s.stopWorkers)
// Cancel all active test executions.
s.mu.Lock()
for _, exec := range s.activeExecutions {
exec.cancel()
}
s.mu.Unlock()
// Wait for all workers to finish their current item.
s.wg.Wait()
log.Println("Check scheduler stopped")
}
// Run starts the scheduler main loop. It must not be called more than once.
func (s *checkScheduler) Run() {
s.mu.Lock()
s.running = true
s.mu.Unlock()
defer func() {
s.mu.Lock()
s.running = false
s.mu.Unlock()
}()
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
// Reschedule overdue tests before starting workers so that tests missed
// during a server suspend or shutdown are spread into the near future
// instead of all firing at once.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) into the near future\n", n)
}
// Start workers
for _, w := range s.workers {
s.wg.Add(1)
go w.run(&s.wg)
}
// Main scheduling loop
checkTicker := time.NewTicker(SchedulerCheckInterval)
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval)
defer checkTicker.Stop()
defer cleanupTicker.Stop()
defer discoveryTicker.Stop()
// Initial discovery: create default schedules for all existing targets
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
// Initial check
s.checkSchedules()
for {
select {
case <-checkTicker.C:
s.checkSchedules()
case <-cleanupTicker.C:
s.cleanup()
case <-discoveryTicker.C:
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
}
case item := <-s.runNowChan:
s.enqueue(item)
case <-s.stop:
return
}
}
}
// checkSchedules checks for due tests and queues them
func (s *checkScheduler) checkSchedules() {
s.mu.RLock()
enabled := s.runtimeEnabled
s.mu.RUnlock()
if !enabled {
return
}
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
if err != nil {
log.Printf("Error listing due schedules: %v\n", err)
return
}
now := time.Now()
for _, schedule := range dueSchedules {
// Determine priority based on how overdue the test is
priority := PriorityScheduled
if schedule.NextRun.Add(schedule.Interval).Before(now) {
priority = PriorityOverdue
}
// Create execution record
execution := &happydns.CheckExecution{
ScheduleId: &schedule.Id,
CheckerName: schedule.CheckerName,
OwnerId: schedule.OwnerId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.CheckExecutionPending,
StartedAt: time.Now(),
Options: schedule.Options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err)
continue
}
s.enqueue(&queueItem{
schedule: schedule,
execution: execution,
priority: priority,
queuedAt: now,
retries: 0,
})
}
// Mark scheduler run
if err := s.store.CheckSchedulerRun(); err != nil {
log.Printf("Error marking scheduler run: %v\n", err)
}
}
// TriggerOnDemandCheck triggers an immediate test execution.
// It creates the execution record synchronously (so the caller gets an ID back)
// and then routes the item through runNowChan so the main loop controls
// all queue insertions.
func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: 0, // On-demand, no interval
Enabled: true,
Options: options,
}
execution := &happydns.CheckExecution{
ScheduleId: nil,
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Status: happydns.CheckExecutionPending,
StartedAt: time.Now(),
Options: options,
}
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
return happydns.Identifier{}, err
}
item := &queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: time.Now(),
retries: 0,
}
// Route through the main loop when possible; fall back to direct enqueue
// if the channel is full so that the caller never blocks.
select {
case s.runNowChan <- item:
default:
s.enqueue(item)
}
return execution.Id, nil
}
// GetSchedulerStatus returns a snapshot of the current scheduler state
func (s *checkScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
s.mu.RLock()
activeCount := len(s.activeExecutions)
running := s.running
runtimeEnabled := s.runtimeEnabled
s.mu.RUnlock()
nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20)
return happydns.SchedulerStatus{
ConfigEnabled: !s.cfg.DisableScheduler,
RuntimeEnabled: runtimeEnabled,
Running: running,
WorkerCount: len(s.workers),
QueueSize: s.queue.Len(),
ActiveCount: activeCount,
NextSchedules: nextSchedules,
}
}
// SetEnabled enables or disables the scheduler at runtime
func (s *checkScheduler) SetEnabled(enabled bool) error {
s.mu.Lock()
wasEnabled := s.runtimeEnabled
s.runtimeEnabled = enabled
s.mu.Unlock()
if enabled && !wasEnabled {
// Spread out any overdue tests to avoid a thundering herd, then
// immediately enqueue whatever is now due.
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
log.Printf("Warning: failed to reschedule overdue tests on re-enable: %v\n", err)
} else if n > 0 {
log.Printf("Rescheduled %d overdue test(s) after scheduler re-enable\n", n)
}
s.checkSchedules()
}
return nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals, delegating to the schedule usecase.
func (s *checkScheduler) RescheduleUpcomingChecks() (int, error) {
return s.scheduleUsecase.RescheduleUpcomingChecks()
}
// cleanup removes old execution records and expired test results
func (s *checkScheduler) cleanup() {
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// run is the worker's main loop. It drains the queue eagerly and waits for a
// workAvail signal when idle, rather than sleeping on a fixed timer.
func (w *worker) run(wg *sync.WaitGroup) {
defer wg.Done()
log.Printf("Worker %d started\n", w.id)
for {
// Drain: try to grab work before blocking.
if item := w.scheduler.queue.Pop(); item != nil {
w.executeCheck(item)
continue
}
// Queue is empty; wait for new work or a stop signal.
select {
case <-w.scheduler.workAvail:
// Loop back to attempt a Pop.
case <-w.scheduler.stopWorkers:
log.Printf("Worker %d stopped\n", w.id)
return
}
}
}
// executeCheck runs a checker and stores the result
func (w *worker) executeCheck(item *queueItem) {
ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout)
defer cancel()
execution := item.execution
schedule := item.schedule
// Always update schedule NextRun after execution, whether it succeeds or fails.
// This prevents the schedule from being re-queued on the next tick if the test fails.
if item.execution.ScheduleId != nil {
defer func() {
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil {
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
}
}()
}
// Mark execution as running
execution.Status = happydns.CheckExecutionRunning
if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil {
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Track active execution
w.scheduler.mu.Lock()
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
execution: execution,
cancel: cancel,
startTime: time.Now(),
}
w.scheduler.mu.Unlock()
defer func() {
w.scheduler.mu.Lock()
delete(w.scheduler.activeExecutions, execution.Id.String())
w.scheduler.mu.Unlock()
}()
// Get the checker
checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName)
if err != nil {
errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err)
log.Printf("Worker %d: %s\n", w.id, errMsg)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg)
return
}
// For scheduled tests: merge checker defaults < stored (user/domain/service) opts < schedule opts < auto-fill.
// For on-demand tests the caller has already merged all options, so use them directly.
var mergedOptions happydns.CheckerOptions
if item.execution.ScheduleId != nil {
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.CheckScopeDomain:
domainId = &schedule.TargetId
case happydns.CheckScopeService:
serviceId = &schedule.TargetId
}
var mergeErr error
mergedOptions, mergeErr = w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &schedule.OwnerId, domainId, serviceId, schedule.Options)
if mergeErr != nil {
// Non-fatal: fall back to schedule-only options
log.Printf("Worker %d: warning, could not prepare checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr)
mergedOptions = schedule.Options
}
} else {
mergedOptions = schedule.Options
}
// Prepare metadata
meta := make(map[string]string)
meta["target_type"] = schedule.TargetType.String()
meta["target_id"] = schedule.TargetId.String()
// Run the test
startTime := time.Now()
resultChan := make(chan *happydns.CheckResult, 1)
errorChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errorChan <- fmt.Errorf("checker panicked: %v", r)
}
}()
result, err := checker.RunCheck(mergedOptions, meta)
if err != nil {
errorChan <- err
} else {
resultChan <- result
}
}()
// Wait for result or timeout
var checkResult *happydns.CheckResult
var testErr error
select {
case checkResult = <-resultChan:
// Check completed successfully
case testErr = <-errorChan:
// Check returned an error
case <-ctx.Done():
// Timeout
testErr = fmt.Errorf("test execution timeout after %v", CheckExecutionTimeout)
}
duration := time.Since(startTime)
// Store the result
result := &happydns.CheckResult{
CheckerName: schedule.CheckerName,
CheckType: schedule.TargetType,
TargetId: schedule.TargetId,
OwnerId: schedule.OwnerId,
ExecutedAt: time.Now(),
ScheduledCheck: item.execution.ScheduleId != nil,
Options: schedule.Options,
Duration: duration,
}
if testErr != nil {
result.Status = happydns.CheckResultStatusKO
result.StatusLine = "Check execution failed"
result.Error = testErr.Error()
} else if checkResult != nil {
result.Status = checkResult.Status
result.StatusLine = checkResult.StatusLine
result.Report = checkResult.Report
} else {
result.Status = happydns.CheckResultStatusKO
result.StatusLine = "Unknown error"
result.Error = "No result or error returned from check"
}
// Save the result
if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil {
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
return
}
// Complete the execution
if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil {
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
return
}
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration)
}

134
internal/app/plugins.go Normal file
View file

@ -0,0 +1,134 @@
// 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 app
import (
"fmt"
"log"
"os"
"path/filepath"
"plugin"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
// pluginLoader attempts to find and register one specific kind of plugin
// symbol from an already-opened .so file.
//
// It returns (true, nil) when the symbol was found and registration
// succeeded, (true, err) when the symbol was found but something went wrong,
// and (false, nil) when the symbol simply isn't present in that file (which
// is not considered an error — a single .so may implement only a subset of
// the known plugin types).
type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error)
// pluginLoaders is the authoritative list of plugin types that happyDomain
// knows about. To support a new plugin type, add a single entry here.
var pluginLoaders = []pluginLoader{
loadCheckPlugin,
}
// loadCheckPlugin handles the NewTestPlugin symbol.
func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) {
sym, err := p.Lookup("NewCheckPlugin")
if err != nil {
// Symbol not present in this .so — not an error.
return false, nil
}
factory, ok := sym.(func() (string, happydns.Checker, error))
if !ok {
return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym)
}
pluginname, myplugin, err := factory()
if err != nil {
return true, err
}
checks.RegisterChecker(pluginname, myplugin)
log.Printf("Plugin %s loaded", pluginname)
return true, nil
}
// initPlugins scans each directory listed in cfg.PluginsDirectories, loads
// every .so file found as a Go plugin, and registers it in the application's
// PluginManager. All load errors are collected and returned as a joined error
// so that a single bad plugin does not prevent the others from loading.
func (a *App) initPlugins() error {
for _, directory := range a.cfg.PluginsDirectories {
files, err := os.ReadDir(directory)
if err != nil {
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
}
for _, file := range files {
if file.IsDir() {
continue
}
// Only attempt to load shared-object files.
if filepath.Ext(file.Name()) != ".so" {
continue
}
fname := filepath.Join(directory, file.Name())
err = loadPlugin(fname)
if err != nil {
log.Printf("Unable to load plugin %q: %s", fname, err)
}
}
}
return nil
}
// loadPlugin opens the .so file at fname and runs every registered
// pluginLoader against it. A loader that does not find its symbol is silently
// skipped. If no loader recognises any symbol in the file a warning is logged,
// but no error is returned because the file might be a valid plugin for a
// future version of happyDomain. The first loader error that is encountered
// is returned immediately.
func loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
anyFound := false
for _, loader := range pluginLoaders {
found, err := loader(p, fname)
if err != nil {
return err
}
if found {
anyFound = true
}
}
if !anyFound {
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
}
return nil
}

View file

@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
// Define defaults options
opts = &happydns.Options{
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
AdminBind: "./happydomain.sock",
BasePath: "/",
Bind: ":8081",
DefaultNameServer: "127.0.0.1:53",
ExternalURL: *u,
JWTSigningMethod: "HS512",
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
MailSMTPPort: 587,
StorageEngine: "leveldb",
MaxResultsPerCheck: 100,
ResultRetentionDays: 90,
TestWorkers: 2,
}
declareFlags(opts)

View file

@ -25,8 +25,25 @@ import (
"encoding/base64"
"net/mail"
"net/url"
"strings"
)
type ArrayArgs struct {
Slice *[]string
}
func (i *ArrayArgs) String() string {
if i == nil || i.Slice == nil {
return ""
}
return strings.Join(*i.Slice, ",")
}
func (i *ArrayArgs) Set(value string) error {
*i.Slice = append(*i.Slice, value)
return nil
}
type JWTSecretKey struct {
Secret *[]byte
}

View file

@ -40,6 +40,7 @@ type InMemoryStorage struct {
data map[string][]byte // Generic key-value store for KVStorage interface
authUsers map[string]*happydns.UserAuth
authUsersByEmail map[string]happydns.Identifier
checksCfg map[string]*happydns.CheckerOptions
domains map[string]*happydns.Domain
domainLogs map[string]*happydns.DomainLogWithDomainId
domainLogsByDomains map[string][]*happydns.Identifier
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
data: make(map[string][]byte),
authUsers: make(map[string]*happydns.UserAuth),
authUsersByEmail: make(map[string]happydns.Identifier),
checksCfg: make(map[string]*happydns.CheckerOptions),
domains: make(map[string]*happydns.Domain),
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
domainLogsByDomains: make(map[string][]*happydns.Identifier),

View file

@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
import (
"git.happydns.org/happyDomain/internal/usecase/authuser"
"git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/internal/usecase/checkresult"
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
@ -43,8 +45,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
check.CheckerStorage
provider.ProviderStorage
session.SessionStorage
checkresult.CheckResultStorage
user.UserStorage
zone.ZoneStorage

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 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 (
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) {
iter := s.db.Search("chckrcfg-")
return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil
}
func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
u := ""
if user != nil {
u = user.String()
}
d := ""
if domain != nil {
d = domain.String()
}
s := ""
if service != nil {
s = service.String()
}
return strings.Join([]string{cname, u, d, s}, "/")
}
func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) {
tmp := strings.Split(key, "/")
if len(tmp) < 4 {
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
}
cname := tmp[0]
var userid *happydns.Identifier
if len(tmp[1]) > 0 {
u, err := happydns.NewIdentifierFromString(tmp[1])
if err != nil {
return nil, err
}
userid = &u
}
var domainid *happydns.Identifier
if len(tmp[2]) > 0 {
d, err := happydns.NewIdentifierFromString(tmp[2])
if err != nil {
return nil, err
}
domainid = &d
}
var serviceid *happydns.Identifier
if len(tmp[3]) > 0 {
s, err := happydns.NewIdentifierFromString(tmp[3])
if err != nil {
return nil, err
}
serviceid = &s
}
return &happydns.CheckerOptionsPositional{
CheckName: cname,
UserId: userid,
DomainId: domainid,
ServiceId: serviceid,
Options: *opts,
}, nil
}
func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
configs = append(configs, opts)
}
return
}
func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) {
iter := s.db.Search("chckrcfg-" + cname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.CheckerOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
// Match logic:
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
matchUser := (user == nil && opts.UserId == nil) ||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
matchDomain := (domain == nil && opts.DomainId == nil) ||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
matchService := (service == nil && opts.ServiceId == nil) ||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
if matchUser && matchDomain && matchService {
configs = append(configs, opts)
}
}
return
}
func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error {
return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts)
}
func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)))
}
func (s *KVStorage) ClearCheckerConfigurations() error {
iter := s.db.Search("chckrcfg-")
defer iter.Release()
for iter.Next() {
err := s.db.Delete(iter.Key())
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,433 @@
// 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 (
"errors"
"fmt"
"sort"
"strings"
"time"
"git.happydns.org/happyDomain/model"
)
// Check Result storage keys:
// checkresult|{plugin-name}|{target-type}|{target-id}|{result-id}
func makeCheckResultKey(checkName string, targetType happydns.CheckScopeType, targetId, resultId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|%s", checkName, targetType, targetId.String(), resultId.String())
}
func makeCheckResultPrefix(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) string {
return fmt.Sprintf("checkresult|%s|%d|%s|", checkName, targetType, targetId.String())
}
// ListCheckResults retrieves check results for a specific plugin+target combination
func (s *KVStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
prefix := makeCheckResultPrefix(checkName, targetType, targetId)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
results = append(results, &r)
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
func (s *KVStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) {
prefix := fmt.Sprintf("checkresult|%s|", checkName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// ListCheckResultsByUser retrieves all check results for a user
func (s *KVStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
iter := s.db.Search("checkresult|")
defer iter.Release()
var results []*happydns.CheckResult
for iter.Next() {
var r happydns.CheckResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
return nil, err
}
// Filter by user
if r.OwnerId.Equals(userId) {
results = append(results, &r)
}
}
// Sort by ExecutedAt descending (most recent first)
sort.Slice(results, func(i, j int) bool {
return results[i].ExecutedAt.After(results[j].ExecutedAt)
})
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetCheckResult retrieves a specific check result by its ID
func (s *KVStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
var result happydns.CheckResult
err := s.db.Get(key, &result)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckResultNotFound
}
return &result, err
}
// CreateCheckResult stores a new check result
func (s *KVStorage) CreateCheckResult(result *happydns.CheckResult) error {
prefix := makeCheckResultPrefix(result.CheckerName, result.CheckType, result.TargetId)
key, id, err := s.db.FindIdentifierKey(prefix)
if err != nil {
return err
}
result.Id = id
return s.db.Put(key, result)
}
// DeleteCheckResult removes a specific check result
func (s *KVStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
return s.db.Delete(key)
}
// DeleteOldCheckResults removes old check results keeping only the most recent N results
func (s *KVStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error {
results, err := s.ListCheckResults(checkName, targetType, targetId, 0)
if err != nil {
return err
}
// Results are already sorted by ExecutedAt descending
// Delete results beyond keepCount
if len(results) > keepCount {
for _, r := range results[keepCount:] {
if err := s.DeleteCheckResult(checkName, targetType, targetId, r.Id); err != nil {
return err
}
}
}
return nil
}
// Checker Schedule storage keys:
// checkschedule|{schedule-id}
// checkschedule.byuser|{user-id}|{schedule-id}
// checkschedule.bytarget|{target-type}|{target-id}|{schedule-id}
func makeCheckerScheduleKey(scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule|%s", scheduleId.String())
}
func makeCheckerScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.byuser|%s|%s", userId.String(), scheduleId.String())
}
func makeCheckerScheduleTargetIndexKey(targetType happydns.CheckScopeType, targetId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("checkschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
}
// ListEnabledCheckerSchedules retrieves all enabled schedules
func (s *KVStorage) ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) {
iter := s.db.Search("checkschedule|")
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
var sched happydns.CheckerSchedule
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
return nil, err
}
if sched.Enabled {
schedules = append(schedules, &sched)
}
}
return schedules, nil
}
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
func (s *KVStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.byuser|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 3 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
func (s *KVStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
prefix := fmt.Sprintf("checkschedule.bytarget|%d|%s|", targetType, targetId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.CheckerSchedule
for iter.Next() {
// Extract schedule ID from index key
key := string(iter.Key())
parts := strings.Split(key, "|")
if len(parts) < 4 {
continue
}
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
if err != nil {
continue
}
// Get the actual schedule
var sched happydns.CheckerSchedule
schedKey := makeCheckerScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// GetCheckerSchedule retrieves a specific schedule by ID
func (s *KVStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
key := makeCheckerScheduleKey(scheduleId)
var schedule happydns.CheckerSchedule
err := s.db.Get(key, &schedule)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckScheduleNotFound
}
return &schedule, err
}
// CreateCheckerSchedule creates a new check schedule
func (s *KVStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key, id, err := s.db.FindIdentifierKey("checkschedule|")
if err != nil {
return err
}
schedule.Id = id
// Store the schedule
if err := s.db.Put(key, schedule); err != nil {
return err
}
// Create indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
return err
}
return nil
}
// UpdateCheckerSchedule updates an existing schedule
func (s *KVStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
key := makeCheckerScheduleKey(schedule.Id)
return s.db.Put(key, schedule)
}
// DeleteCheckerSchedule removes a schedule and its indexes
func (s *KVStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) error {
// Get the schedule first to know what indexes to delete
schedule, err := s.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
// Delete indexes
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Delete(userIndexKey); err != nil {
return err
}
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Delete(targetIndexKey); err != nil {
return err
}
// Delete the schedule itself
key := makeCheckerScheduleKey(scheduleId)
return s.db.Delete(key)
}
// Check Execution storage keys:
// checkexec|{execution-id}
func makeCheckExecutionKey(executionId happydns.Identifier) string {
return fmt.Sprintf("checkexec|%s", executionId.String())
}
// ListActiveCheckExecutions retrieves all executions that are pending or running
func (s *KVStorage) ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) {
iter := s.db.Search("checkexec|")
defer iter.Release()
var executions []*happydns.CheckExecution
for iter.Next() {
var exec happydns.CheckExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
return nil, err
}
if exec.Status == happydns.CheckExecutionPending || exec.Status == happydns.CheckExecutionRunning {
executions = append(executions, &exec)
}
}
return executions, nil
}
// GetCheckExecution retrieves a specific execution by ID
func (s *KVStorage) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
key := makeCheckExecutionKey(executionId)
var execution happydns.CheckExecution
err := s.db.Get(key, &execution)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrCheckExecutionNotFound
}
return &execution, err
}
// CreateCheckExecution creates a new check execution record
func (s *KVStorage) CreateCheckExecution(execution *happydns.CheckExecution) error {
key, id, err := s.db.FindIdentifierKey("checkexec|")
if err != nil {
return err
}
execution.Id = id
return s.db.Put(key, execution)
}
// UpdateCheckExecution updates an existing execution record
func (s *KVStorage) UpdateCheckExecution(execution *happydns.CheckExecution) error {
key := makeCheckExecutionKey(execution.Id)
return s.db.Put(key, execution)
}
// DeleteCheckExecution removes an execution record
func (s *KVStorage) DeleteCheckExecution(executionId happydns.Identifier) error {
key := makeCheckExecutionKey(executionId)
return s.db.Delete(key)
}
// Scheduler state storage key:
// checkscheduler.lastrun
// CheckerSchedulerRun marks that the scheduler has run at current time
func (s *KVStorage) CheckSchedulerRun() error {
now := time.Now()
return s.db.Put("checkscheduler.lastrun", &now)
}
// LastCheckSchedulerRun retrieves the last time the scheduler ran
func (s *KVStorage) LastCheckSchedulerRun() (*time.Time, error) {
var lastRun time.Time
err := s.db.Get("checkscheduler.lastrun", &lastRun)
if errors.Is(err, happydns.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastRun, nil
}

View file

@ -0,0 +1,62 @@
// 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 check
import (
"git.happydns.org/happyDomain/model"
)
type CheckerStorage interface {
// ListAllCheckConfigurations retrieves the list of known Providers.
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error)
// ListCheckerConfiguration retrieves all providers own by the given User.
ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error)
// GetCheckerConfiguration retrieves the full Provider with the given identifier and owner.
GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
// UpdateCheckerConfiguration updates the fields of the given Provider.
UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error
// DeleteCheckerConfiguration removes the given Provider from the database.
DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
// ClearCheckerConfigurations deletes all Providers present in the database.
ClearCheckerConfigurations() error
}
// CheckAutoFillStorage provides the domain/zone/user lookups needed to
// resolve auto-fill variables for test check options.
type CheckAutoFillStorage interface {
// GetDomain retrieves the Domain with the given identifier.
GetDomain(domainid happydns.Identifier) (*happydns.Domain, error)
// GetUser retrieves the User with the given identifier.
GetUser(userid happydns.Identifier) (*happydns.User, error)
// ListDomains retrieves all Domains associated to the given User.
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
// GetZone retrieves the full Zone (including Services and metadata) for the given identifier.
GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error)
}

View file

@ -0,0 +1,318 @@
// 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 check
import (
"fmt"
"log"
"maps"
"sort"
"git.happydns.org/happyDomain/checks"
"git.happydns.org/happyDomain/model"
)
type checkerUsecase struct {
config *happydns.Options
store CheckerStorage
autoFillStore CheckAutoFillStorage
}
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase {
return &checkerUsecase{
config: cfg,
store: store,
autoFillStore: autoFillStore,
}
}
func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) {
checker, err := checks.FindChecker(cname)
if err != nil {
return nil, fmt.Errorf("unable to find check named %q: %w", cname, err)
}
return checker, nil
}
type ByOptionPosition []*happydns.CheckerOptionsPositional
func (a ByOptionPosition) Len() int { return len(a) }
func (a ByOptionPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByOptionPosition) Less(i, j int) bool {
if a[i].CheckName != a[j].CheckName {
return a[i].CheckName < a[j].CheckName
}
if res := compareIdentifiers(a[i].UserId, a[j].UserId); res != 0 {
return res < 0
}
if res := compareIdentifiers(a[i].DomainId, a[j].DomainId); res != 0 {
return res < 0
}
if res := compareIdentifiers(a[i].ServiceId, a[j].ServiceId); res != 0 {
return res < 0
}
return false
}
func compareIdentifiers(a, b *happydns.Identifier) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
if a.Equals(*b) {
return 0
}
return a.Compare(*b)
}
func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
sort.Sort(ByOptionPosition(configs))
opts := make(happydns.CheckerOptions)
for _, c := range configs {
maps.Copy(opts, c.Options)
}
return &opts, nil
}
func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
return checks.GetCheckers(), nil
}
// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes)
// with auto-fill variables applied, but without checker-defined defaults or run-time overrides.
func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
var opts happydns.CheckerOptions
if stored != nil {
opts = *stored
} else {
opts = make(happydns.CheckerOptions)
}
checker, err := tu.GetChecker(cname)
if err != nil {
return opts, nil
}
return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil
}
// BuildMergedCheckerOptions merges checker options from all sources in priority order:
// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
merged := make(happydns.CheckerOptions)
// 1. Fill checker defaults.
checker, err := tu.GetChecker(cname)
if err != nil {
log.Printf("Warning: unable to get checker %q for default options: %v", cname, err)
} else {
opts := checker.Options()
allOpts := []happydns.CheckerOptionDocumentation{}
allOpts = append(allOpts, opts.RunOpts...)
allOpts = append(allOpts, opts.ServiceOpts...)
allOpts = append(allOpts, opts.DomainOpts...)
allOpts = append(allOpts, opts.UserOpts...)
allOpts = append(allOpts, opts.AdminOpts...)
for _, opt := range allOpts {
if opt.Default != nil {
merged[opt.Id] = opt.Default
}
}
}
// 2. Override with stored options (user/domain/service scopes).
baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err)
}
if baseOptions != nil {
maps.Copy(merged, *baseOptions)
}
// 3. Override with caller-supplied run options.
maps.Copy(merged, runOpts)
// 4. Inject auto-fill variables (always win over any user-supplied value).
if checker != nil {
merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged)
}
return merged, nil
}
// applyAutoFill resolves auto-fill fields declared by the checker and injects
// the context-resolved values into a copy of opts.
func (tu *checkerUsecase) applyAutoFill(
checker happydns.Checker,
userid *happydns.Identifier,
domainid *happydns.Identifier,
serviceid *happydns.Identifier,
opts happydns.CheckerOptions,
) happydns.CheckerOptions {
// Collect which auto-fill keys are needed.
needed := make(map[string]string) // autoFill constant → field id
options := checker.Options()
for _, groups := range [][]happydns.CheckerOptionDocumentation{
options.RunOpts, options.DomainOpts, options.ServiceOpts,
options.UserOpts, options.AdminOpts,
} {
for _, opt := range groups {
if opt.AutoFill != "" {
needed[opt.AutoFill] = opt.Id
}
}
}
if len(needed) == 0 || tu.autoFillStore == nil {
return opts
}
autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid)
result := maps.Clone(opts)
for autoFillKey, fieldId := range needed {
if val, ok := autoFillCtx[autoFillKey]; ok {
result[fieldId] = val
}
}
return result
}
// buildAutoFillContext resolves the available auto-fill values for the given
// user/domain/service identifiers.
func (tu *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]string {
ctx := make(map[string]string)
if domainid != nil {
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
ctx[happydns.AutoFillDomainName] = domain.DomainName
}
} else if serviceid != nil && userid != nil {
// To resolve service context we need to find which domain/zone owns the service.
user, err := tu.autoFillStore.GetUser(*userid)
if err != nil {
return ctx
}
domains, err := tu.autoFillStore.ListDomains(user)
if err != nil {
return ctx
}
for _, domain := range domains {
if len(domain.ZoneHistory) == 0 {
continue
}
// The first element in ZoneHistory is the current (most recent) zone.
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
if err != nil {
continue
}
for subdomain, svcs := range zoneMsg.Services {
for _, svc := range svcs {
if svc.Id.Equals(*serviceid) {
ctx[happydns.AutoFillDomainName] = domain.DomainName
ctx[happydns.AutoFillSubdomain] = string(subdomain)
ctx[happydns.AutoFillServiceType] = svc.Type
return ctx
}
}
}
}
}
return ctx
}
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
// filter opts that correspond to the level set
checker, err := tu.GetChecker(cname)
if err != nil {
return fmt.Errorf("unable to get checker: %w", err)
}
options := checker.Options()
var optNames []string
if serviceid != nil {
for _, opt := range options.ServiceOpts {
optNames = append(optNames, opt.Id)
}
} else if domainid != nil {
for _, opt := range options.DomainOpts {
optNames = append(optNames, opt.Id)
}
} else if userid != nil {
for _, opt := range options.UserOpts {
optNames = append(optNames, opt.Id)
}
} else {
for _, opt := range options.AdminOpts {
optNames = append(optNames, opt.Id)
}
}
// Filter opts to only include keys that are in optNames
filteredOpts := make(happydns.CheckerOptions)
for _, optName := range optNames {
if val, exists := opts[optName]; exists && val != "" {
filteredOpts[optName] = val
}
}
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, opts)
}
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
if err != nil {
return err
}
maps.Copy(*current, opts)
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,85 @@
package check_test
import (
"sort"
"testing"
uc "git.happydns.org/happyDomain/internal/usecase/check"
"git.happydns.org/happyDomain/model"
)
func TestSortByCheckName(t *testing.T) {
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "zeta"},
{CheckName: "alpha"},
{CheckName: "beta"},
}
sort.Sort(uc.ByOptionPosition(slice))
got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName}
want := []string{"alpha", "beta", "zeta"}
for i := range want {
if got[i] != want[i] {
t.Errorf("expected %v, got %v", want, got)
break
}
}
}
func TestNilBeforeNonNil(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: nil},
}
sort.Sort(uc.ByOptionPosition(slice))
if slice[0].UserId != nil {
t.Errorf("expected nil UserId first, got %+v", slice[0].UserId)
}
}
func TestDomainIdOrder(t *testing.T) {
did, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: &did},
{CheckName: "alpha", UserId: nil, DomainId: nil},
}
sort.Sort(uc.ByOptionPosition(slice))
if slice[0].DomainId != nil {
t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId)
}
}
func TestServiceIdOrder(t *testing.T) {
sid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid},
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil},
}
sort.Sort(uc.ByOptionPosition(slice))
if slice[0].ServiceId != nil {
t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId)
}
}
func TestStableGrouping(t *testing.T) {
uid, _ := happydns.NewRandomIdentifier()
slice := []*happydns.CheckerOptionsPositional{
{CheckName: "alpha", UserId: &uid},
{CheckName: "alpha", UserId: &uid},
}
sort.Sort(uc.ByOptionPosition(slice))
if slice[0].CheckName != slice[1].CheckName {
t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1])
}
}

View file

@ -0,0 +1,222 @@
// 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 checkresult
import (
"fmt"
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultUsecase implements business logic for check results
type CheckResultUsecase struct {
storage CheckResultStorage
options *happydns.Options
}
// NewCheckResultUsecase creates a new check result usecase
func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options) *CheckResultUsecase {
return &CheckResultUsecase{
storage: storage,
options: options,
}
}
// ListCheckResultsByTarget retrieves check results for a specific target
func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Apply default limit if not specified
if limit <= 0 {
limit = 5 // Default to 5 most recent results
}
return u.storage.ListCheckResults(pluginName, targetType, targetId, limit)
}
// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins
func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
// Get all results for the user and filter by target
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
if err != nil {
return nil, err
}
// Filter by target
var results []*happydns.CheckResult
for _, r := range allResults {
if r.CheckType == targetType && r.TargetId.Equals(targetId) {
results = append(results, r)
}
}
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetCheckResult retrieves a specific check result
func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
return u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
}
// CreateCheckResult stores a new check result and enforces retention policy
func (u *CheckResultUsecase) CreateCheckResult(result *happydns.CheckResult) error {
// Store the result
if err := u.storage.CreateCheckResult(result); err != nil {
return err
}
// Enforce retention policy
maxResults := u.options.MaxResultsPerCheck
if maxResults <= 0 {
maxResults = 100 // Default
}
return u.storage.DeleteOldCheckResults(result.CheckerName, result.CheckType, result.TargetId, maxResults)
}
// DeleteCheckResult removes a specific check result
func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId)
}
// DeleteAllCheckResults removes all results for a specific plugin+target combination
func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
// Get all results first
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
// Delete each result
for _, r := range results {
if err := u.storage.DeleteCheckResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
return nil
}
// CleanupOldResults removes check results older than retention period
func (u *CheckResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
retentionDays = 90 // Default
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
// Get all results for all users (inefficient but necessary without a time-based index)
// In a production system, you might want to add a time-based index for this
// For now, we'll iterate through results and delete old ones
// This is a placeholder - the actual implementation would need to be optimized
// based on specific storage patterns
_ = cutoffTime
return nil
}
// GetCheckExecution retrieves the status of a check execution
func (u *CheckResultUsecase) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
return u.storage.GetCheckExecution(executionId)
}
// CreateCheckExecution creates a new check execution record
func (u *CheckResultUsecase) CreateCheckExecution(execution *happydns.CheckExecution) error {
if execution.StartedAt.IsZero() {
execution.StartedAt = time.Now()
}
return u.storage.CreateCheckExecution(execution)
}
// UpdateCheckExecution updates an existing check execution
func (u *CheckResultUsecase) UpdateCheckExecution(execution *happydns.CheckExecution) error {
return u.storage.UpdateCheckExecution(execution)
}
// CompleteCheckExecution marks an execution as completed with a result
func (u *CheckResultUsecase) CompleteCheckExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionCompleted
execution.CompletedAt = &now
execution.ResultId = &resultId
return u.storage.UpdateCheckExecution(execution)
}
// FailCheckExecution marks an execution as failed
func (u *CheckResultUsecase) FailCheckExecution(executionId happydns.Identifier, errorMsg string) error {
execution, err := u.storage.GetCheckExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.CheckExecutionFailed
execution.CompletedAt = &now
// Store error in a result
result := &happydns.CheckResult{
CheckerName: execution.CheckerName,
CheckType: execution.TargetType,
TargetId: execution.TargetId,
OwnerId: execution.OwnerId,
ExecutedAt: time.Now(),
ScheduledCheck: execution.ScheduleId != nil,
Options: execution.Options,
Status: happydns.CheckResultStatusKO,
StatusLine: "Execution failed",
Error: errorMsg,
Duration: now.Sub(execution.StartedAt),
}
if err := u.CreateCheckResult(result); err != nil {
return fmt.Errorf("failed to create error result: %w", err)
}
execution.ResultId = &result.Id
return u.storage.UpdateCheckExecution(execution)
}
// DeleteCompletedExecutions removes execution records that are completed
func (u *CheckResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
// Get active executions (this won't include completed ones)
// We need a different query to get completed executions older than cutoff
// For now, this is a placeholder
_ = cutoffTime
return nil
}

View file

@ -0,0 +1,410 @@
// 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 checkresult
import (
"errors"
"fmt"
"math/rand"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default check intervals
DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks
DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks
DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks
MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed
)
// CheckScheduleUsecase implements business logic for check schedules
type CheckScheduleUsecase struct {
storage CheckResultStorage
options *happydns.Options
domainLister DomainLister
checkerUsecase happydns.CheckerUsecase
}
// NewCheckScheduleUsecase creates a new check schedule usecase
func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options, domainLister DomainLister, checkerUsecase happydns.CheckerUsecase) *CheckScheduleUsecase {
return &CheckScheduleUsecase{
storage: storage,
options: options,
domainLister: domainLister,
checkerUsecase: checkerUsecase,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
return u.storage.GetCheckerSchedule(scheduleId)
}
// CreateSchedule creates a new check schedule with validation
func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error {
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
}
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Calculate next run time: pick a random offset within the interval
// to spread load evenly across all schedules
// TODO: Use a smarter load balance function in the future
if schedule.NextRun.IsZero() {
offset := time.Duration(rand.Int63n(int64(schedule.Interval)))
schedule.NextRun = time.Now().Add(offset)
}
return u.storage.CreateCheckerSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error {
// Validate interval
if schedule.Interval < MinimumCheckInterval {
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetCheckerSchedule(schedule.Id)
if err != nil {
return err
}
// Preserve LastRun if not explicitly changed
if schedule.LastRun == nil {
schedule.LastRun = existing.LastRun
}
// Recalculate next run time if interval changed
if schedule.Interval != existing.Interval {
if schedule.LastRun != nil {
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
} else {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteCheckerSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = true
// Reset next run time if it's in the past
if schedule.NextRun.Before(time.Now()) {
schedule.NextRun = time.Now().Add(schedule.Interval)
}
return u.storage.UpdateCheckerSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateCheckerSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateCheckerSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.CheckerSchedule
for _, schedule := range schedules {
if schedule.NextRun.Before(now) {
dueSchedules = append(dueSchedules, schedule)
}
}
return dueSchedules, nil
}
// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending
func (u *CheckScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.CheckerSchedule, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return nil, err
}
sort.Slice(schedules, func(i, j int) bool {
return schedules[i].NextRun.Before(schedules[j].NextRun)
})
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
return schedules, nil
}
// getDefaultInterval returns the default check interval based on target type
func (u *CheckScheduleUsecase) getDefaultInterval(targetType happydns.CheckScopeType) time.Duration {
switch targetType {
case happydns.CheckScopeUser:
return DefaultUserCheckInterval
case happydns.CheckScopeDomain:
return DefaultDomainCheckInterval
case happydns.CheckScopeService:
return DefaultServiceCheckInterval
default:
return DefaultDomainCheckInterval
}
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
if err != nil {
return err
}
if !schedule.OwnerId.Equals(userId) {
return fmt.Errorf("user does not own this schedule")
}
return nil
}
// CreateDefaultSchedulesForTarget creates default schedules for a new target
func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget(
checkerName string,
targetType happydns.CheckScopeType,
targetId happydns.Identifier,
ownerId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
Options: make(happydns.CheckerOptions),
}
return u.CreateSchedule(schedule)
}
// rescheduleChecks reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)].
func (u *CheckScheduleUsecase) rescheduleChecks(schedules []*happydns.CheckerSchedule, maxOffsetFn func(*happydns.CheckerSchedule) time.Duration) (int, error) {
count := 0
now := time.Now()
for _, schedule := range schedules {
maxOffset := maxOffsetFn(schedule)
if maxOffset <= 0 {
maxOffset = time.Second
}
schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset))))
if err := u.storage.UpdateCheckerSchedule(schedule); err != nil {
return count, err
}
count++
}
return count, nil
}
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly. Useful after a restart.
func (u *CheckScheduleUsecase) RescheduleUpcomingChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
return u.rescheduleChecks(schedules, func(s *happydns.CheckerSchedule) time.Duration {
return s.Interval
})
}
// RescheduleOverdueChecks reschedules checks whose NextRun is in the past,
// spreading them over a short window to avoid scheduler famine (e.g. after
// a long machine suspend or server downtime).
// If there are fewer than 10 overdue checks, they are left as-is so that the
// caller's immediate checkSchedules pass enqueues them directly.
func (u *CheckScheduleUsecase) RescheduleOverdueChecks() (int, error) {
schedules, err := u.storage.ListEnabledCheckerSchedules()
if err != nil {
return 0, err
}
now := time.Now()
var overdue []*happydns.CheckerSchedule
for _, s := range schedules {
if s.NextRun.Before(now) {
overdue = append(overdue, s)
}
}
if len(overdue) == 0 {
return 0, nil
}
// Small backlog: let the caller enqueue them directly on the next
// checkSchedules pass rather than deferring them into the future.
if len(overdue) < 10 {
return 0, nil
}
// Spread overdue checks over a small window proportional to their count,
// capped at MinimumCheckInterval, to prevent all of them from running at once.
spreadWindow := time.Duration(len(overdue)) * 5 * time.Second
if spreadWindow > MinimumCheckInterval {
spreadWindow = MinimumCheckInterval
}
return u.rescheduleChecks(overdue, func(s *happydns.CheckerSchedule) time.Duration {
return spreadWindow
})
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
if err != nil {
return err
}
for _, schedule := range schedules {
if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain)
// pairs that don't yet have an explicit schedule record. This implements the opt-out
// model: checks run automatically unless a schedule with Enabled=false has been saved.
// Non-fatal per-domain errors are collected and returned together.
func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error {
if u.domainLister == nil || u.checkerUsecase == nil {
return nil
}
plugins, err := u.checkerUsecase.ListCheckers()
if err != nil {
return fmt.Errorf("listing check plugins for discovery: %w", err)
}
iter, err := u.domainLister.ListAllDomains()
if err != nil {
return fmt.Errorf("listing domains for schedule discovery: %w", err)
}
defer iter.Close()
var errs []error
for iter.Next() {
domain := iter.Item()
if domain == nil {
continue
}
for checkerName, p := range *plugins {
if !p.Availability().ApplyToDomain {
continue
}
schedules, err := u.ListSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
if err != nil {
errs = append(errs, fmt.Errorf("listing schedules for domain %s: %w", domain.Id, err))
continue
}
hasSchedule := false
for _, sched := range schedules {
if sched.CheckerName == checkerName {
hasSchedule = true
break
}
}
if !hasSchedule {
if err := u.CreateSchedule(&happydns.CheckerSchedule{
CheckerName: checkerName,
OwnerId: domain.Owner,
TargetType: happydns.CheckScopeDomain,
TargetId: domain.Id,
Enabled: true,
}); err != nil {
errs = append(errs, fmt.Errorf("auto-creating schedule for domain %s / plugin %s: %w",
domain.Id, checkerName, err))
}
}
}
}
return errors.Join(errs...)
}

View file

@ -0,0 +1,103 @@
// 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 checkresult
import (
"time"
"git.happydns.org/happyDomain/model"
)
// CheckResultStorage defines the storage interface for check results and related data
type CheckResultStorage interface {
// Check Results
// ListCheckResults retrieves check results for a specific plugin+target combination
ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error)
// ListCheckResultsByUser retrieves all check results for a user
ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
// GetCheckResult retrieves a specific check result by its ID
GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error)
// CreateCheckResult stores a new check result
CreateCheckResult(result *happydns.CheckResult) error
// DeleteCheckResult removes a specific check result
DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
// DeleteOldCheckResults removes old check results keeping only the most recent N results
DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error
// Checker Schedules
// ListEnabledCheckerSchedules retrieves all enabled schedules (for scheduler)
ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
// GetCheckerSchedule retrieves a specific schedule by ID
GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error)
// CreateCheckerSchedule creates a new check schedule
CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// UpdateCheckerSchedule updates an existing schedule
UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error
// DeleteCheckerSchedule removes a schedule
DeleteCheckerSchedule(scheduleId happydns.Identifier) error
// Check Executions
// ListActiveCheckExecutions retrieves all executions that are pending or running
ListActiveCheckExecutions() ([]*happydns.CheckExecution, error)
// GetCheckExecution retrieves a specific execution by ID
GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error)
// CreateCheckExecution creates a new check execution record
CreateCheckExecution(execution *happydns.CheckExecution) error
// UpdateCheckExecution updates an existing execution record
UpdateCheckExecution(execution *happydns.CheckExecution) error
// DeleteCheckExecution removes an execution record
DeleteCheckExecution(executionId happydns.Identifier) error
// Scheduler State
// CheckSchedulerRun marks that the scheduler has run at current time
CheckSchedulerRun() error
// LastCheckSchedulerRun retrieves the last time the scheduler ran
LastCheckSchedulerRun() (*time.Time, error)
}
// DomainLister provides access to domain listings for schedule discovery.
type DomainLister interface {
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
}

195
model/check_result.go Normal file
View file

@ -0,0 +1,195 @@
// 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 happydns
import (
"time"
)
// CheckScopeType represents the scope level at which a check is performed
type CheckScopeType int
const (
CheckScopeInstance CheckScopeType = iota
CheckScopeUser
CheckScopeDomain
CheckScopeService
CheckScopeOnDemand
)
// String returns a string representation of the check scope type
func (t CheckScopeType) String() string {
switch t {
case CheckScopeInstance:
return "instance"
case CheckScopeUser:
return "user"
case CheckScopeDomain:
return "domain"
case CheckScopeService:
return "service"
case CheckScopeOnDemand:
return "ondemand"
default:
return "unknown"
}
}
// CheckExecutionStatus represents the current state of a check execution
type CheckExecutionStatus int
const (
CheckExecutionPending CheckExecutionStatus = iota
CheckExecutionRunning
CheckExecutionCompleted
CheckExecutionFailed
)
// String returns a string representation of the check execution status
func (t CheckExecutionStatus) String() string {
switch t {
case CheckExecutionPending:
return "pending"
case CheckExecutionRunning:
return "running"
case CheckExecutionCompleted:
return "completed"
case CheckExecutionFailed:
return "failed"
default:
return "unknown"
}
}
// CheckResult stores the result of a check execution
type CheckResult struct {
// Id is the unique identifier for this check result
Id Identifier `json:"id" swaggertype:"string"`
// CheckerName identifies which checker was executed
CheckerName string `json:"checker_name"`
// CheckType indicates the scope level of the check
CheckType CheckScopeType `json:"check_type"`
// TargetId is the identifier of the target (User/Domain/Service)
TargetId Identifier `json:"target_id" swaggertype:"string"`
// OwnerId is the owner of the check
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// ExecutedAt is when the check was executed
ExecutedAt time.Time `json:"executed_at"`
// ScheduledCheck indicates if this was a scheduled (true) or on-demand (false) check
ScheduledCheck bool `json:"scheduled_check"`
// Options contains the merged checker configuration used for this check
Options CheckerOptions `json:"options,omitempty"`
// Status is the overall check result status
Status CheckResultStatus `json:"status"`
// StatusLine is a summary message of the check result
StatusLine string `json:"status_line"`
// Report contains the full check report (checker-specific structure)
Report any `json:"report,omitempty"`
// Duration is how long the check took to execute
Duration time.Duration `json:"duration" swaggertype:"integer"`
// Error contains any error message if the execution failed
Error string `json:"error,omitempty"`
}
// CheckExecution tracks an in-progress or completed check execution
type CheckExecution struct {
// Id is the unique identifier for this execution
Id Identifier `json:"id" swaggertype:"string"`
// ScheduleId is the schedule that triggered this execution (nil for on-demand)
ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"`
// CheckerName identifies which checker is being executed
CheckerName string `json:"checker_name"`
// OwnerId is the owner of the check
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates the scope level of the check
TargetType CheckScopeType `json:"target_type"`
// TargetId is the identifier of the target being checked
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Status is the current execution status
Status CheckExecutionStatus `json:"status"`
// StartedAt is when the execution began
StartedAt time.Time `json:"started_at"`
// CompletedAt is when the execution finished (nil if still running)
CompletedAt *time.Time `json:"completed_at,omitempty"`
// ResultId links to the CheckResult (nil if execution not completed)
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
// Options contains the checker configuration for this execution
Options CheckerOptions `json:"options,omitempty"`
}
// CheckResultUsecase defines business logic for check results
type CheckResultUsecase interface {
// ListCheckResultsByTarget retrieves check results for a specific target
ListCheckResultsByTarget(checkerName string, targetType CheckScopeType, targetId Identifier, limit int) ([]*CheckResult, error)
// ListAllCheckResultsByTarget retrieves all check results for a target across all checkers
ListAllCheckResultsByTarget(targetType CheckScopeType, targetId Identifier, userId Identifier, limit int) ([]*CheckResult, error)
// GetCheckResult retrieves a specific check result
GetCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) (*CheckResult, error)
// CreateCheckResult stores a new check result and enforces retention policy
CreateCheckResult(result *CheckResult) error
// DeleteCheckResult removes a specific check result
DeleteCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) error
// DeleteAllCheckResults removes all results for a specific checker+target combination
DeleteAllCheckResults(checkName string, targetType CheckScopeType, targetId Identifier) error
// GetCheckExecution retrieves the status of a check execution
GetCheckExecution(executionId Identifier) (*CheckExecution, error)
// CreateCheckExecution creates a new check execution record
CreateCheckExecution(execution *CheckExecution) error
// UpdateCheckExecution updates an existing check execution
UpdateCheckExecution(execution *CheckExecution) error
// CompleteCheckExecution marks an execution as completed with a result
CompleteCheckExecution(executionId Identifier, resultId Identifier) error
// FailCheckExecution marks an execution as failed
FailCheckExecution(executionId Identifier, errorMsg string) error
}

140
model/check_scheduler.go Normal file
View file

@ -0,0 +1,140 @@
// 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 happydns
import (
"time"
)
// SchedulerUsecase defines the interface for triggering on-demand checks
type SchedulerUsecase interface {
Run()
Close()
TriggerOnDemandCheck(checkerName string, targetType CheckScopeType, targetID Identifier, userID Identifier, options CheckerOptions) (Identifier, error)
GetSchedulerStatus() SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingChecks() (int, error)
}
// CheckerSchedule defines a recurring check schedule
type CheckerSchedule struct {
// Id is the unique identifier for this schedule
Id Identifier `json:"id" swaggertype:"string"`
// CheckerName identifies which checker to execute
CheckerName string `json:"checker_name"`
// OwnerId is the owner of the schedule
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates what type of target to check
TargetType CheckScopeType `json:"target_type"`
// TargetId is the identifier of the target to check
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Interval is how often to run the check
Interval time.Duration `json:"interval" swaggertype:"integer"`
// Enabled indicates if the schedule is active
Enabled bool `json:"enabled"`
// LastRun is when the check was last executed (nil if never run)
LastRun *time.Time `json:"last_run,omitempty"`
// NextRun is when the check should next be executed
NextRun time.Time `json:"next_run"`
// Options contains checker-specific configuration
Options CheckerOptions `json:"options,omitempty"`
}
// SchedulerStatus holds a snapshot of the scheduler state for monitoring
type SchedulerStatus struct {
// ConfigEnabled indicates if the scheduler is enabled in the configuration file
ConfigEnabled bool `json:"config_enabled"`
// RuntimeEnabled indicates if the scheduler is currently enabled at runtime
RuntimeEnabled bool `json:"runtime_enabled"`
// Running indicates if the scheduler goroutine is currently running
Running bool `json:"running"`
// WorkerCount is the number of worker goroutines
WorkerCount int `json:"worker_count"`
// QueueSize is the number of items currently waiting in the execution queue
QueueSize int `json:"queue_size"`
// ActiveCount is the number of checks currently being executed
ActiveCount int `json:"active_count"`
// NextSchedules contains the upcoming scheduled checks sorted by next run time
NextSchedules []*CheckerSchedule `json:"next_schedules"`
}
// CheckerScheduleUsecase defines business logic for check schedules
type CheckerScheduleUsecase interface {
// ListUserSchedules retrieves all schedules for a specific user
ListUserSchedules(userId Identifier) ([]*CheckerSchedule, error)
// ListSchedulesByTarget retrieves all schedules for a specific target
ListSchedulesByTarget(targetType CheckScopeType, targetId Identifier) ([]*CheckerSchedule, error)
// GetSchedule retrieves a specific schedule by ID
GetSchedule(scheduleId Identifier) (*CheckerSchedule, error)
// CreateSchedule creates a new check schedule with validation
CreateSchedule(schedule *CheckerSchedule) error
// UpdateSchedule updates an existing schedule
UpdateSchedule(schedule *CheckerSchedule) error
// DeleteSchedule removes a schedule
DeleteSchedule(scheduleId Identifier) error
// EnableSchedule enables a schedule
EnableSchedule(scheduleId Identifier) error
// DisableSchedule disables a schedule
DisableSchedule(scheduleId Identifier) error
// UpdateScheduleAfterRun updates a schedule after it has been executed
UpdateScheduleAfterRun(scheduleId Identifier) error
// ListDueSchedules retrieves all enabled schedules that are due to run
ListDueSchedules() ([]*CheckerSchedule, error)
// ValidateScheduleOwnership checks if a user owns a schedule
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
// DeleteSchedulesForTarget removes all schedules for a target
DeleteSchedulesForTarget(targetType CheckScopeType, targetId Identifier) error
// RescheduleUpcomingChecks randomizes next run times for all enabled schedules
// within their respective intervals to spread load evenly.
RescheduleUpcomingChecks() (int, error)
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
// short window to avoid scheduler famine after a suspend or server restart.
RescheduleOverdueChecks() (int, error)
}

113
model/checker.go Normal file
View file

@ -0,0 +1,113 @@
// 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 happydns
// Auto-fill variable identifiers for checker option fields.
const (
// AutoFillDomainName fills the option with the fully qualified domain name
// of the domain being tested (e.g. "example.com.").
AutoFillDomainName = "domain_name"
// AutoFillSubdomain fills the option with the subdomain relative to the zone
// (e.g. "www" for "www.example.com." in zone "example.com."). Only
// applicable for service-scoped tests.
AutoFillSubdomain = "subdomain"
// AutoFillServiceType fills the option with the service type identifier
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
AutoFillServiceType = "service_type"
)
const (
CheckResultStatusKO CheckResultStatus = iota
CheckResultStatusWarn
CheckResultStatusInfo
CheckResultStatusOK
)
type CheckResultStatus int
type CheckerOptions map[string]any
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
Options() CheckerOptionsDocumentation
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
type CheckerResponse struct {
ID string `json:"id"`
Name string `json:"name"`
Availability CheckerAvailability `json:"availability"`
Options CheckerOptionsDocumentation `json:"options"`
}
type SetCheckerOptionsRequest struct {
Options CheckerOptions `json:"options"`
}
type CheckerOptionsPositional struct {
CheckName string
UserId *Identifier
DomainId *Identifier
ServiceId *Identifier
Options CheckerOptions
}
type CheckerAvailability struct {
ApplyToDomain bool `json:"applyToDomain,omitempty"`
ApplyToService bool `json:"applyToService,omitempty"`
LimitToProviders []string `json:"limitToProviders,omitempty"`
LimitToServices []string `json:"limitToServices,omitempty"`
}
type CheckerOptionsDocumentation struct {
RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"`
ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"`
DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"`
UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"`
AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"`
}
type CheckerOptionDocumentation Field
// CheckerStatus represents the current status of a checker for a specific target,
// including whether it is enabled, its schedule, and the most recent result.
type CheckerStatus struct {
CheckerName string `json:"checker_name"`
Enabled bool `json:"enabled"`
Schedule *CheckerSchedule `json:"schedule,omitempty"`
LastResult *CheckResult `json:"last_result,omitempty"`
}
type CheckerUsecase interface {
BuildMergedCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) (CheckerOptions, error)
GetStoredCheckerOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (CheckerOptions, error)
GetChecker(string) (Checker, error)
GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error)
ListCheckers() (*map[string]Checker, error)
OverwriteSomeCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
SetCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
}

View file

@ -99,6 +99,20 @@ type Options struct {
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
// 0 means always require captcha at login (when provider is configured).
CaptchaLoginThreshold int
PluginsDirectories []string
// MaxResultsPerCheck is the maximum number of test results to keep per plugin+target combination
MaxResultsPerCheck int
// ResultRetentionDays is how long to keep test results before cleanup
ResultRetentionDays int
// TestWorkers is the number of concurrent test executions allowed
TestWorkers int
// DisableScheduler disables the background test scheduler
DisableScheduler bool
}
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.

View file

@ -27,15 +27,18 @@ import (
)
var (
ErrAuthUserNotFound = errors.New("user not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
ErrAuthUserNotFound = errors.New("user not found")
ErrCheckExecutionNotFound = errors.New("check execution not found")
ErrCheckResultNotFound = errors.New("check result not found")
ErrCheckScheduleNotFound = errors.New("check schedule not found")
ErrDomainNotFound = errors.New("domain not found")
ErrDomainLogNotFound = errors.New("domain log not found")
ErrProviderNotFound = errors.New("provider not found")
ErrSessionNotFound = errors.New("session not found")
ErrUserNotFound = errors.New("user not found")
ErrUserAlreadyExist = errors.New("user already exists")
ErrZoneNotFound = errors.New("zone not found")
ErrNotFound = errors.New("not found")
)
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."

View file

@ -104,6 +104,11 @@ type Field struct {
// Description stores an helpfull sentence describing the field.
Description string `json:"description,omitempty"`
// AutoFill indicates the field value is automatically resolved by the
// software based on test context. When set, the value should not be
// entered by the user.
AutoFill string `json:"autoFill,omitempty"`
}
type FormState struct {

View file

@ -27,6 +27,7 @@ import (
"encoding/base64"
"encoding/gob"
"errors"
"slices"
)
const IDENTIFIER_LEN = 16
@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool {
return bytes.Equal(i, other)
}
func (i Identifier) Compare(other Identifier) int {
return slices.Compare(i, other)
}
func (i *Identifier) String() string {
return base64.RawURLEncoding.EncodeToString(*i)
}

View file

@ -52,6 +52,17 @@ type UserSettings struct {
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
ShowRRTypes bool `json:"showrrtypes,omitempty"`
// TestRetention overrides instance default for how long to keep test results (days)
TestRetention int `json:"test_retention,omitempty"`
// DomainTestInterval is the default interval for domain-level tests (seconds)
// Default: 86400 (24 hours)
DomainTestInterval int64 `json:"domain_test_interval,omitempty"`
// ServiceTestInterval is the default interval for service-level tests (seconds)
// Default: 3600 (1 hour)
ServiceTestInterval int64 `json:"service_test_interval,omitempty"`
}
func DefaultUserSettings() *UserSettings {

1
plugins/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.so

336
plugins/README.md Normal file
View file

@ -0,0 +1,336 @@
# Writing a happyDomain Plugin
happyDomain supports external **check plugins** — shared libraries (`.so` files) that run domain health checks and diagnostics. Plugins are loaded at runtime and integrate seamlessly into happyDomain's domain and service testing UI.
## Overview
A plugin is a Go shared library (`-buildmode=plugin`) that exports a single entry point: `NewCheckPlugin`. At startup, happyDomain scans its configured plugin directories, loads each `.so` file it finds, calls `NewCheckPlugin`, and registers the returned checker under the declared name.
A plugin implements the `Checker` interface from `git.happydns.org/happyDomain/model`:
```go
type Checker interface {
ID() string
Name() string
Availability() CheckerAvailability
Options() CheckerOptionsDocumentation
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
}
```
---
## Project Structure
A minimal plugin lives in its own directory with `package main`:
```
myplugin/
├── go.mod
├── Makefile
└── plugin.go (or split across multiple .go files)
```
### go.mod
Your plugin must declare the same module path as its source tree and depend on the happyDomain model:
```
module git.happydns.org/happyDomain/plugins/myplugin
go 1.25
require git.happydns.org/happyDomain v0.0.0
replace git.happydns.org/happyDomain => ../../
```
The `replace` directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types.
> **Important:** A Go plugin and the host program must be built with the same Go toolchain version and the same versions of all shared dependencies. Any mismatch will cause a runtime load error.
---
## The Entry Point
Every plugin must export a `NewCheckPlugin` function with this exact signature:
```go
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "myplugin", &MyPlugin{}, nil
}
```
The first return value is the unique registration name for the checker. You can use the constructor to perform one-time initialisation (read config files, create HTTP clients, etc.) and return an error if the plugin cannot function.
---
## Implementing the Interface
### `ID() string`
Returns the unique string identifier for the checker. This name is used internally to look up the checker and to store its configuration. Use a short, lowercase, collision-resistant name:
```go
func (p *MyPlugin) ID() string {
return "myplugin"
}
```
The value returned here should match the name returned by `NewCheckPlugin`. If two checkers claim the same ID, the second one is silently ignored and a conflict is logged.
---
### `Name() string`
Returns a human-readable display name for the checker:
```go
func (p *MyPlugin) Name() string {
return "My Plugin"
}
```
---
### `Availability() CheckerAvailability`
Declares where the checker applies:
```go
func (p *MyPlugin) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToDomain: true,
ApplyToService: false,
LimitToProviders: []string{}, // empty = all providers
LimitToServices: []string{"abstract.MatrixIM"},
}
}
```
`CheckerAvailability` fields:
| Field | Type | Description |
|---|---|---|
| `ApplyToDomain` | `bool` | Checker can be run against a whole domain |
| `ApplyToService` | `bool` | Checker can be run against a specific service |
| `LimitToProviders` | `[]string` | Restrict to certain DNS provider identifiers (empty = no restriction) |
| `LimitToServices` | `[]string` | Restrict to certain service type identifiers, e.g. `"abstract.MatrixIM"` (empty = no restriction) |
---
### `Options() CheckerOptionsDocumentation`
Declares all configurable options, grouped by **who sets them** and **at which scope**:
```go
func (p *MyPlugin) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{ /* per-run options */ },
ServiceOpts: []happydns.CheckerOptionDocumentation{ /* per-service options */ },
DomainOpts: []happydns.CheckerOptionDocumentation{ /* per-domain options */ },
UserOpts: []happydns.CheckerOptionDocumentation{ /* per-user options */ },
AdminOpts: []happydns.CheckerOptionDocumentation{ /* admin-only options */ },
}
}
```
#### Option scopes
| Field | Who sets it | Typical use |
|---|---|---|
| `RunOpts` | The user at test time | Test-specific parameters (e.g. domain to test) |
| `ServiceOpts` | The user, per service | Configuration scoped to a DNS service |
| `DomainOpts` | The user, per domain | Configuration scoped to a whole domain |
| `UserOpts` | The user, globally | Personal preferences (e.g. language) |
| `AdminOpts` | The instance administrator | Backend URLs, API keys shared by all users |
Options from all scopes are **merged** before `RunCheck` is called, with more-specific scopes overriding less-specific ones.
#### CheckerOptionDocumentation fields
Each option is described by a `CheckerOptionDocumentation` (an alias for `Field`):
| Field | Type | Description |
|---|---|---|
| `Id` | `string` | **Required.** Key used in `CheckerOptions` map |
| `Type` | `string` | Input type: `"string"`, `"select"`, … |
| `Label` | `string` | Human-readable label shown in the UI |
| `Placeholder` | `string` | Input placeholder text |
| `Default` | `any` | Default value pre-filled in the form |
| `Choices` | `[]string` | Available choices for `"select"` type inputs |
| `Required` | `bool` | Whether the field must be filled before running |
| `Secret` | `bool` | Marks the field as sensitive (e.g. API key) |
| `Hide` | `bool` | Hides the field from the user |
| `Textarea` | `bool` | Displays a multiline text area |
| `Description` | `string` | Help text shown below the field |
| `AutoFill` | `string` | Automatically populate the field from context (see below) |
#### Auto-fill variables
When a field's `AutoFill` is set, happyDomain populates it from the test context — the user does not need to fill it in:
| Constant | Value | Filled with |
|---|---|---|
| `happydns.AutoFillDomainName` | `"domain_name"` | The FQDN of the domain under test (e.g. `"example.com."`) |
| `happydns.AutoFillSubdomain` | `"subdomain"` | Subdomain relative to the zone (service-scoped tests only) |
| `happydns.AutoFillServiceType` | `"service_type"` | Service type identifier (service-scoped tests only) |
```go
{
Id: "domainName",
Type: "string",
Label: "Domain name",
AutoFill: happydns.AutoFillDomainName,
Required: true,
},
```
---
### `RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)`
This is where the actual check happens. `options` is the merged map of all scoped options (keyed by option `Id`). `meta` carries additional context provided by the scheduler (currently reserved for future use).
```go
func (p *MyPlugin) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
domain, ok := options["domainName"].(string)
if !ok || domain == "" {
return nil, fmt.Errorf("domainName is required")
}
// ... perform the check ...
return &happydns.CheckResult{
Status: happydns.CheckResultStatusOK,
StatusLine: "All good",
Report: myDetailedReport,
}, nil
}
```
Return a non-nil `error` for hard failures (network errors, invalid options). Return a `CheckResult` with a `KO` status for expected failures (e.g. the DNS check failed).
#### CheckResult fields set by the plugin
| Field | Type | Description |
|---|---|---|
| `Status` | `CheckResultStatus` | Overall result level |
| `StatusLine` | `string` | Short human-readable summary |
| `Report` | `any` | Arbitrary data (serialised to JSON and stored) |
The remaining fields (`Id`, `CheckerName`, `ExecutedAt`, etc.) are filled in by happyDomain automatically.
#### CheckResultStatus values (ordered worst → best)
| Constant | Meaning |
|---|---|
| `CheckResultStatusKO` | Check failed |
| `CheckResultStatusWarn` | Check passed with warnings |
| `CheckResultStatusInfo` | Informational result |
| `CheckResultStatusOK` | Check fully passed |
---
## Full Example
The matrix federation checker plugin (`matrix/`) illustrates a real-world plugin:
**`main.go`** — exports the entry point:
```go
package main
import "git.happydns.org/happyDomain/model"
func NewCheckPlugin() (string, happydns.Checker, error) {
return "matrixim", &MatrixTester{
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
}, nil
}
```
**`test.go`** — implements the interface on a struct:
```go
func (p *MatrixTester) ID() string { return "matrixim" }
func (p *MatrixTester) Name() string { return "Matrix Federation Tester" }
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.MatrixIM"},
}
}
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { /* ... */ }
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { /* ... */ }
```
The built-in Zonemaster checker (`checks/zonemaster.go`) shows a more complex flow: it starts an asynchronous test, polls for completion, and aggregates results across multiple severity levels. Although it is compiled in rather than loaded as a `.so`, it implements the same `Checker` interface and is a useful reference.
---
## Building
Use `-buildmode=plugin`:
```bash
go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
git.happydns.org/happyDomain/plugins/myplugin
```
A minimal `Makefile`:
```makefile
PLUGIN_NAME=myplugin
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)
```
> **Naming convention:** happyDomain looks for any `.so` file in the plugin directory, but using the prefix `happydomain-plugin-test-` makes the purpose clear.
---
## Deployment
### 1. Copy the `.so` file to a plugin directory
```bash
cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/
```
### 2. Configure happyDomain to load that directory
In your `happydomain.conf`:
```
plugins-directories=/usr/lib/happydomain/plugins
```
Or via an environment variable:
```bash
HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins
```
Multiple directories can be specified as a comma-separated list. happyDomain scans each directory at startup and attempts to load every `.so` file it finds. Loading errors are logged but do not prevent the server from starting.
### 3. Verify
Check the server logs at startup for a line like:
```
Plugin myplugin loaded
```
If a name conflict or load error occurs, a warning is logged with the filename and reason.

7
plugins/matrix/Makefile Normal file
View file

@ -0,0 +1,7 @@
PLUGIN_NAME=matrix
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
all: $(TARGET)
$(TARGET): *.go
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)

11
plugins/matrix/main.go Normal file
View file

@ -0,0 +1,11 @@
package main
import (
"git.happydns.org/happyDomain/model"
)
func NewCheckPlugin() (string, happydns.Checker, error) {
return "matrixim", &MatrixTester{
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
}, nil
}

146
plugins/matrix/test.go Normal file
View file

@ -0,0 +1,146 @@
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
"strings"
"git.happydns.org/happyDomain/model"
)
type MatrixTester struct {
TesterURI string
}
func (p *MatrixTester) ID() string {
return "matrixim"
}
func (p *MatrixTester) Name() string {
return "Matrix Federation Tester"
}
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
return happydns.CheckerAvailability{
ApplyToService: true,
LimitToServices: []string{"abstract.MatrixIM"},
}
}
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation {
return happydns.CheckerOptionsDocumentation{
RunOpts: []happydns.CheckerOptionDocumentation{
{
Id: "serviceDomain",
Type: "string",
Label: "Matrix domain",
Placeholder: "matrix.org",
Default: "matrix.org",
Required: true,
},
},
AdminOpts: []happydns.CheckerOptionDocumentation{
{
Id: "federationTesterServer",
Type: "string",
Label: "Federation Tester Server",
Placeholder: "https://federationtester.matrix.org/",
Default: "https://federationtester.matrix.org/",
Required: true,
},
},
}
}
type FederationTesterResponse struct {
WellKnownResult struct {
Server string `json:"m.server"`
Result string `json:"result"`
}
DNSResult struct {
SRVError *struct {
Message string
}
}
ConnectionReports map[string]struct {
Errors []string
}
ConnectionErrors map[string]struct {
Message string
}
Version struct {
Name string `json:"name"`
Version string `json:"version"`
}
FederationOK bool `json:"FederationOK"`
}
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
var domain string
if dn, ok := options["domain"]; ok {
domain, _ = dn.(string)
} else if origin, ok := options["origin"]; ok {
domain, _ = origin.(string)
}
if domain == "" {
return nil, fmt.Errorf("domain not defined")
}
domain = strings.TrimSuffix(domain, ".")
resp, err := http.Get(fmt.Sprintf(p.TesterURI, domain))
if err != nil {
return nil, fmt.Errorf("unable to perform the test: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return nil, fmt.Errorf("Sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
var status happydns.CheckResultStatus
var statusLine string
var federationTest FederationTesterResponse
err = json.NewDecoder(resp.Body).Decode(&federationTest)
if err != nil {
log.Printf("Error in check_matrix_federation, when decoding json: %s", err.Error())
return nil, fmt.Errorf("sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
if federationTest.FederationOK {
status = happydns.CheckResultStatusOK
statusLine = "Running " + federationTest.Version.Name + " " + federationTest.Version.Version
} else {
status = happydns.CheckResultStatusKO
if federationTest.DNSResult.SRVError != nil && federationTest.WellKnownResult.Result != "" {
statusLine = fmt.Sprintf("%s OR %s", federationTest.DNSResult.SRVError.Message, federationTest.WellKnownResult.Result)
} else if len(federationTest.ConnectionErrors) > 0 {
var msg strings.Builder
for srv, cerr := range federationTest.ConnectionErrors {
if msg.Len() > 0 {
msg.WriteString("; ")
}
msg.WriteString(srv)
msg.WriteString(": ")
msg.WriteString(cerr.Message)
}
statusLine = fmt.Sprintf("Connection errors: %s", msg.String())
} else if federationTest.WellKnownResult.Server != strings.TrimSuffix(domain, ".") {
statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s.", federationTest.WellKnownResult.Server, strings.TrimSuffix(domain, "."))
} else {
statusLine = fmt.Sprintf("An unimplemented error occurs. Please report this to happydomain team. But know that federation seems to be broken. Check https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
}
}
return &happydns.CheckResult{
Status: status,
StatusLine: statusLine,
Report: federationTest,
}, nil
}

View file

@ -110,6 +110,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
// Routes to virtual content
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
router.GET("/checks/*_", serveOrReverse("/", cfg))
router.GET("/domains/*_", serveOrReverse("/", cfg))
router.GET("/providers/*_", serveOrReverse("/", cfg))
router.GET("/sessions/*_", serveOrReverse("/", cfg))

View file

@ -101,6 +101,12 @@
<NavItem>
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
</NavItem>
<NavItem>
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
</NavItem>
<NavItem>
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,150 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getChecks } from "$lib/api-admin";
let checkersQ = $state(getChecks());
let searchQuery = $state("");
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Checkers
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead"> Manage all checkers </span>
{#await checkersQ then checkersR}
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
</InputGroup>
</Col>
</Row>
{#await checkersQ}
Please wait...
{:then checkersR}
{@const checkers = checkersR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !checkers || Object.keys(checkers).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-2">
No checkers available
</td>
</tr>
{:else}
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerName}</strong></td>
<td>
{#if checkerInfo.availability}
{#if checkerInfo.availability.applyToDomain}
<Badge color="success">Domain</Badge>
{/if}
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
<Badge
color="primary"
title={checkerInfo.availability.limitToProviders.join(
", ",
)}
>
Provider-specific
</Badge>
{/if}
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
<Badge
color="info"
title={checkerInfo.availability.limitToServices.join(
", ",
)}
>
Service-specific
</Badge>
{/if}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a
href="/checkers/{checkerName}"
class="btn btn-sm btn-primary"
>
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checkers: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,322 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { toasts } from "$lib/stores/toasts";
import { getChecksByCnameOptions, putChecksByCnameOptions } from "$lib/api-admin";
import { getCheckStatus } from "$lib/api/checks";
import Resource from "$lib/components/inputs/Resource.svelte";
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
let cname = $derived(page.params.cname!);
let checkerStatusQ = $derived(getCheckStatus(cname));
let checkerOptionsQ = $derived(getChecksByCnameOptions({ path: { cname } }));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
checkerOptionsQ.then((optionsR) => {
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: optionValues },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Plugin options updated successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to update options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(adminOpts: any[]) {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putChecksByCnameOptions({
path: { cname },
body: { options: cleanedOptions },
});
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
toasts.addToast({
message: `Orphaned options removed successfully`,
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: "Failed to clean options: " + error,
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: any[]): string[] {
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checks" class="mb-2">
<Icon name="arrow-left"></Icon>
Back to checkers
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{cname}
</h1>
</Col>
</Row>
{#await checkerStatusQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading checker status...
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<strong>Checker Information</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">Name:</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">Availability:</dt>
<dd class="col-sm-8">
{#if status.availableOn}
<div class="d-flex flex-wrap gap-1">
{#if status.availableOn.applyToDomain}
<Badge color="success">Domain-level</Badge>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
Providers: {status.availableOn.limitToProviders.join(
", ",
)}
</Badge>
{/if}
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
<Badge color="info">
Services: {status.availableOn.limitToServices.join(
", ",
)}
</Badge>
{/if}
{#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
<Badge color="secondary">General</Badge>
{/if}
</div>
{:else}
<Badge color="secondary">General</Badge>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</Col>
<Col md={6}>
{#await checkerOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
Loading options...
</p>
</CardBody>
</Card>
{:then _optionsR}
{@const adminOpts = status.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{
key: "userOpts",
label: "User Options",
opts: status.options?.userOpts || [],
},
{
key: "domainOpts",
label: "Domain Options",
opts: status.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: "Service Options",
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: "Run Options",
opts: status.options?.runOpts || [],
},
]}
{@const hasAnyOpts =
adminOpts.length > 0 ||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
<strong>Orphaned options detected:</strong>
{orphanedOpts.join(", ")}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(adminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
Clean Up
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>Admin Options</strong>
</CardHeader>
<CardBody>
<Form on:submit={saveOptions}>
{#each adminOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
Save Changes
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<CheckerOptionsGroups groups={readOnlyOptGroups} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
This checker has no configurable options.
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading options: {error.message}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error: checker data not found
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading checker: {error.message}
</Alert>
{/await}
</Container>

View file

@ -0,0 +1,341 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { onMount } from "svelte";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import { toasts } from "$lib/stores/toasts";
import {
getScheduler,
postSchedulerDisable,
postSchedulerEnable,
postSchedulerRescheduleUpcoming,
} from "$lib/api-admin/sdk.gen";
interface CheckerSchedule {
id: string;
checker_name: string;
owner_id: string;
target_type: number;
target_id: string;
interval: number;
enabled: boolean;
last_run?: string;
next_run: string;
}
interface SchedulerStatus {
config_enabled: boolean;
runtime_enabled: boolean;
running: boolean;
worker_count: number;
queue_size: number;
active_count: number;
next_schedules: CheckerSchedule[] | null;
}
let status = $state<SchedulerStatus | null>(null);
let loading = $state(true);
let actionInProgress = $state(false);
let rescheduleInProgress = $state(false);
let error = $state<string | null>(null);
async function fetchStatus() {
loading = true;
error = null;
try {
const { data, error: err } = await getScheduler();
if (err) throw new Error(String(err));
status = data as SchedulerStatus;
} catch (e: any) {
error = e.message ?? "Unknown error";
} finally {
loading = false;
}
}
async function setEnabled(enabled: boolean) {
actionInProgress = true;
const action = enabled ? "enable" : "disable";
try {
const { data, error: err } = await (enabled
? postSchedulerEnable()
: postSchedulerDisable());
if (err) {
toasts.addErrorToast({ message: `Failed to ${action} scheduler: ${err}` });
return;
}
status = data as SchedulerStatus;
toasts.addToast({ message: `Scheduler ${action}d successfully`, color: "success" });
} catch (e: any) {
toasts.addErrorToast({ message: e.message ?? `Failed to ${action} scheduler` });
} finally {
actionInProgress = false;
}
}
async function rescheduleUpcoming() {
rescheduleInProgress = true;
try {
const { data, error: err } = await postSchedulerRescheduleUpcoming();
if (err) {
toasts.addErrorToast({ message: `Failed to reschedule: ${err}` });
return;
}
toasts.addToast({
message: `Rescheduled ${(data as any).rescheduled} schedule(s) successfully`,
color: "success",
});
await fetchStatus();
} catch (e: any) {
toasts.addErrorToast({ message: e.message ?? "Failed to reschedule upcoming checks" });
} finally {
rescheduleInProgress = false;
}
}
function formatDuration(ns: number): string {
const seconds = ns / 1e9;
if (seconds < 60) return `${Math.round(seconds)}s`;
const minutes = seconds / 60;
if (minutes < 60) return `${Math.round(minutes)}m`;
const hours = minutes / 60;
if (hours < 24) return `${Math.round(hours)}h`;
return `${Math.round(hours / 24)}d`;
}
function targetTypeName(t: number): string {
const names: Record<number, string> = {
0: "instance",
1: "user",
2: "domain",
3: "zone",
4: "service",
5: "ondemand",
};
return names[t] ?? "unknown";
}
onMount(fetchStatus);
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<h1 class="display-5">
<Icon name="clock-history"></Icon>
Test Scheduler
</h1>
<p class="text-muted lead">Monitor and control the background test scheduler</p>
</Col>
</Row>
{#if loading}
<div class="d-flex align-items-center gap-2">
<Spinner size="sm" />
<span>Loading scheduler status...</span>
</div>
{:else if error}
<Card color="danger" body>
<Icon name="exclamation-triangle-fill"></Icon>
Error loading scheduler status: {error}
<Button class="ms-3" size="sm" color="light" onclick={fetchStatus}>Retry</Button>
</Card>
{:else if status}
<!-- Status Card -->
<Card class="mb-4">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<span><Icon name="info-circle-fill"></Icon> Scheduler Status</span>
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
<Icon name="arrow-clockwise"></Icon> Refresh
</Button>
</div>
</CardHeader>
<CardBody>
<Row class="g-3 mb-3">
<Col sm={6} md={4}>
<div class="text-muted small">Config Enabled</div>
{#if status.config_enabled}
<Badge color="success">Yes</Badge>
{:else}
<Badge color="danger">No</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Runtime Enabled</div>
{#if status.runtime_enabled}
<Badge color="success">Yes</Badge>
{:else}
<Badge color="warning">Disabled</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Running</div>
{#if status.running}
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
{:else}
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
{/if}
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Workers</div>
<strong>{status.worker_count}</strong>
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Queue Size</div>
<strong>{status.queue_size}</strong>
</Col>
<Col sm={6} md={4}>
<div class="text-muted small">Active Executions</div>
<strong>{status.active_count}</strong>
</Col>
</Row>
{#if status.config_enabled}
<div class="d-flex gap-2">
{#if status.runtime_enabled}
<Button
color="warning"
disabled={actionInProgress}
onclick={() => setEnabled(false)}
>
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
name="pause-fill"
></Icon>{/if}
Disable Scheduler
</Button>
{:else}
<Button
color="success"
disabled={actionInProgress}
onclick={() => setEnabled(true)}
>
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
name="play-fill"
></Icon>{/if}
Enable Scheduler
</Button>
{/if}
<Button
color="secondary"
outline
disabled={rescheduleInProgress}
onclick={rescheduleUpcoming}
>
{#if rescheduleInProgress}<Spinner size="sm" />{:else}<Icon
name="shuffle"
></Icon>{/if}
Spread Upcoming Checks
</Button>
</div>
{:else}
<p class="text-muted mb-0">
<Icon name="lock-fill"></Icon>
The scheduler is disabled in the server configuration and cannot be enabled at
runtime.
</p>
{/if}
</CardBody>
</Card>
<!-- Upcoming Scheduled Checks -->
<Card>
<CardHeader>
<Icon name="calendar-event-fill"></Icon>
Upcoming Scheduled Checks
{#if status.next_schedules}
<Badge color="secondary" class="ms-2">{status.next_schedules.length}</Badge>
{/if}
</CardHeader>
<CardBody class="p-0">
<div class="table-responsive">
<Table hover class="mb-0">
<thead>
<tr>
<th>Plugin</th>
<th>Target Type</th>
<th>Target ID</th>
<th>Interval</th>
<th>Last Run</th>
<th>Next Run</th>
</tr>
</thead>
<tbody>
{#if !status.next_schedules || status.next_schedules.length === 0}
<tr>
<td colspan="6" class="text-center text-muted py-3">
No scheduled checks
</td>
</tr>
{:else}
{#each status.next_schedules as schedule}
<tr>
<td><strong>{schedule.checker_name}</strong></td>
<td
><Badge color="info"
>{targetTypeName(schedule.target_type)}</Badge
></td
>
<td><code class="small">{schedule.target_id}</code></td>
<td>{formatDuration(schedule.interval)}</td>
<td>
{#if schedule.last_run}
{new Date(schedule.last_run).toLocaleString()}
{:else}
<span class="text-muted">Never</span>
{/if}
</td>
<td>
{#if new Date(schedule.next_run) < new Date()}
<span class="text-danger">
<Icon name="exclamation-circle-fill"></Icon>
{new Date(schedule.next_run).toLocaleString()}
</span>
{:else}
{new Date(schedule.next_run).toLocaleString()}
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
</CardBody>
</Card>
{/if}
</Container>

View file

@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
router.GET("/service-worker.js", serveFile)
// Routes to virtual content
router.GET("/checks/*_", serveIndex)
router.GET("/domains/*_", serveIndex)
router.GET("/email-validation", serveIndex)
router.GET("/forgotten-password", serveIndex)

237
web/src/lib/api/checks.ts Normal file
View file

@ -0,0 +1,237 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import {
getChecks,
getChecksByCid,
getChecksByCidOptions,
postChecksByCidOptions,
putChecksByCidOptions,
getChecksByCidOptionsByOptname,
putChecksByCidOptionsByOptname,
postDomainsByDomainChecksByCname,
getDomainsByDomainChecksByCnameOptions,
getDomainsByDomainChecks,
getDomainsByDomainChecksByCnameResults,
getDomainsByDomainChecksByCnameResultsByResultId,
deleteDomainsByDomainChecksByCnameResults,
deleteDomainsByDomainChecksByCnameResultsByResultId,
getDomainsByDomainChecksByCnameExecutionsByExecutionId,
postPluginsTestsSchedules,
putPluginsTestsSchedulesByScheduleId,
} from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "./errors";
import type {
CheckerList,
CheckerInfo,
CheckerOptions,
AvailableCheck,
CheckerSchedule,
CheckResult,
CheckExecution,
} from "$lib/model/check";
import { CheckScopeType } from "$lib/model/check";
export async function listChecks(): Promise<CheckerList> {
return unwrapSdkResponse(await getChecks()) as CheckerList;
}
export async function getCheckStatus(checkId: string): Promise<CheckerInfo> {
return unwrapSdkResponse(
await getChecksByCid({
path: { cid: checkId },
}),
) as unknown as CheckerInfo;
}
export async function getCheckOptions(checkId: string): Promise<CheckerOptions> {
return unwrapSdkResponse(
await getChecksByCidOptions({
path: { cid: checkId },
}),
) as CheckerOptions;
}
export async function addCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
return unwrapSdkResponse(
await postChecksByCidOptions({
path: { cid: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function updateCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
return unwrapSdkResponse(
await putChecksByCidOptions({
path: { cid: checkId },
body: { options } as any,
}),
) as boolean;
}
export async function getCheckOption(checkId: string, optionName: string): Promise<any> {
return unwrapSdkResponse(
await getChecksByCidOptionsByOptname({
path: { cid: checkId, optname: optionName },
}),
);
}
export async function setcheckOption(
checkId: string,
optionName: string,
value: any,
): Promise<boolean> {
return unwrapSdkResponse(
await putChecksByCidOptionsByOptname({
path: { cid: checkId, optname: optionName },
body: value as any,
}),
) as boolean;
}
export async function getDomainCheckOptions(domainId: string, checkId: string): Promise<CheckerOptions> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameOptions({
path: { domain: domainId, cname: checkId },
}),
) as CheckerOptions;
}
export async function triggerCheck(
domainId: string,
checkId: string,
options?: CheckerOptions,
): Promise<{ execution_id?: string }> {
return unwrapSdkResponse(
await postDomainsByDomainChecksByCname({
path: { domain: domainId, cname: checkId },
body: { options } as any,
}),
) as { execution_id?: string };
}
export async function listAvailableChecks(domainId: string): Promise<AvailableCheck[]> {
return unwrapSdkResponse(
await getDomainsByDomainChecks({
path: { domain: domainId },
}),
) as unknown as AvailableCheck[];
}
export async function createCheckSchedule(data: {
checker_name: string;
target_type: CheckScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: CheckerOptions;
}): Promise<CheckerSchedule> {
return unwrapSdkResponse(
await postPluginsTestsSchedules({
body: {
checker_name: data.checker_name,
target_type: data.target_type,
target_id: data.target_id,
interval: data.interval,
enabled: data.enabled,
options: data.options,
},
}),
) as CheckerSchedule;
}
export async function updateCheckSchedule(
scheduleId: string,
schedule: CheckerSchedule,
): Promise<CheckerSchedule> {
return unwrapSdkResponse(
await putPluginsTestsSchedulesByScheduleId({
path: { schedule_id: scheduleId },
body: {
id: schedule.id,
checker_name: schedule.checker_name,
target_type: schedule.target_type,
target_id: schedule.target_id,
interval: schedule.interval,
enabled: schedule.enabled,
last_run: schedule.last_run,
next_run: schedule.next_run,
options: schedule.options,
},
}),
) as CheckerSchedule;
}
export async function getCheckResult(
domainId: string,
checkName: string,
resultId: string,
): Promise<CheckResult> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResultsByResultId({
path: { domain: domainId, cname: checkName, result_id: resultId },
}),
) as unknown as CheckResult;
}
export async function deleteCheckResult(
domainId: string,
checkName: string,
resultId: string,
): Promise<void> {
await deleteDomainsByDomainChecksByCnameResultsByResultId({
path: { domain: domainId, cname: checkName, result_id: resultId },
});
}
export async function listCheckResults(
domainId: string,
checkName: string,
): Promise<CheckResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameResults({
path: { domain: domainId, cname: checkName },
}),
) as unknown as CheckResult[];
}
export async function deleteAllCheckResults(
domainId: string,
checkName: string,
): Promise<void> {
await deleteDomainsByDomainChecksByCnameResults({
path: { domain: domainId, cname: checkName },
});
}
export async function getCheckExecution(
domainId: string,
checkName: string,
executionId: string,
): Promise<CheckExecution> {
return unwrapSdkResponse(
await getDomainsByDomainChecksByCnameExecutionsByExecutionId({
path: { domain: domainId, cname: checkName, execution_id: executionId },
}),
) as unknown as CheckExecution;
}

View file

@ -183,6 +183,13 @@
>
{$t("menu.dns-resolver")}
</DropdownItem>
<DropdownItem
active={page.route &&
(page.route.id == "/checks" || page.route.id?.startsWith("/checks/"))}
href="/checks"
>
{$t("menu.checkers")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
{$t("menu.my-account")}

View file

@ -23,6 +23,7 @@
<script lang="ts">
import { page } from "$app/state";
import { fly } from "svelte/transition";
import { Icon } from "@sveltestrap/sveltestrap";
@ -42,6 +43,7 @@
<div
class="card"
style="position: fixed; bottom: calc(7vh + max(1.7vw, 1.7vh)); right: calc(4vw + max(1.7vw, 1.7vh)); z-index: 1052; max-width: 400px;"
transition:fly={{ x: 20, y: 20, duration: 200 }}
>
<div class="card-body row row-cols-2 justify-content-center align-items-center">
<div class="col d-flex mb-3 flex-fill">
@ -74,7 +76,7 @@
<a
href="https://framaforms.org/quel-est-votre-avis-sur-happydns-1610366701?u={$userSession.id
? $userSession.id
: 0}&amp;i={instancename}&amp;p={page.route ? ('&amp;p=' + page.route.id) : ''}&amp;l={$locale}"
: 0}&amp;i={instancename}{page.route ? ('&p=' + page.route.id) : ''}&amp;l={$locale}"
target="_blank"
rel="noreferrer"
class="btn btn-lg btn-light flex-fill fw-bolder"

View file

@ -0,0 +1,107 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
const AUTO_FILL_KEYS: Record<string, string> = {
domain_name: "checks.auto-fill.domain_name",
subdomain: "checks.auto-fill.subdomain",
service_type: "checks.auto-fill.service_type",
};
function getAutoFillLabel(autoFill: string): string {
const tKey = AUTO_FILL_KEYS[autoFill];
if (tKey) return $t(tKey);
return $t("checks.auto-fill.generic", { key: autoFill });
}
interface OptionDef {
id?: string;
label?: string;
type?: string;
default?: unknown;
placeholder?: string;
description?: string;
required?: boolean;
autoFill?: string;
}
interface OptionGroup {
label: string;
opts: OptionDef[];
}
interface Props {
groups: OptionGroup[];
}
let { groups }: Props = $props();
</script>
{#each groups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2">{$t("checks.detail.read-only")}</small>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.autoFill}
<span class="badge bg-info me-1">
{getAutoFillLabel(optDoc.autoFill)}
</span>
{:else if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
{/if}
<small class="text-muted">
{$t("checks.option-groups.type", {
type: optDoc.type || "string",
})}
</small>
{#if optDoc.required && !optDoc.autoFill}
<small class="text-danger ms-2">
{$t("checks.option-groups.required")}
</small>
{/if}
</dd>
{/each}
</dl>
</CardBody>
</Card>
{/if}
{/each}

View file

@ -0,0 +1,204 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Alert,
Button,
Form,
FormGroup,
Icon,
Modal,
ModalBody,
ModalFooter,
ModalHeader,
Spinner,
} from "@sveltestrap/sveltestrap";
import { triggerCheck, getDomainCheckOptions, getCheckStatus } from "$lib/api/checks";
import type { CheckerOptions } from "$lib/model/check";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
interface Props {
domainId: string;
onCheckTriggered?: (execution_id: string, checker_name: string) => void;
}
let { domainId, onCheckTriggered }: Props = $props();
let isOpen = $state(false);
let checkName = $state<string>("");
let checkDisplayName = $state<string>("");
let checkStatusPromise = $state<Promise<any> | null>(null);
let domainOptionsPromise = $state<Promise<CheckerOptions> | null>(null);
let runOptions = $state<Record<string, any>>({});
let triggering = $state(false);
let showAdvanced = $state(false);
const toggle = () => (isOpen = !isOpen);
export function open(name: string, displayName: string) {
checkName = name;
checkDisplayName = displayName;
runOptions = {};
checkStatusPromise = getCheckStatus(name);
domainOptionsPromise = getDomainCheckOptions(domainId, name);
isOpen = true;
// Pre-populate with domain options when they load
domainOptionsPromise.then((options) => {
runOptions = { ...(options || {}) };
});
}
async function handleRunCheck() {
triggering = true;
try {
const result = await triggerCheck(domainId, checkName, runOptions);
toasts.addToast({
message: $t("checks.run-check.triggered-success", { id: result.execution_id }),
type: "success",
timeout: 5000,
});
isOpen = false;
if (onCheckTriggered && result.execution_id) {
onCheckTriggered(result.execution_id, checkName);
}
} catch (error) {
toasts.addErrorToast({
message: $t("checks.run-check.trigger-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
triggering = false;
}
}
</script>
<Modal {isOpen} {toggle} size="lg">
<ModalHeader {toggle}>
{$t("checks.run-check.title")}: {checkDisplayName}
</ModalHeader>
<ModalBody>
{#if checkStatusPromise && domainOptionsPromise}
{#await Promise.all([checkStatusPromise, domainOptionsPromise])}
<div class="text-center py-3">
<Spinner />
<p class="mt-2">{$t("checks.run-check.loading-options")}</p>
</div>
{:then [status, _domainOpts]}
{@const runOpts = status.options?.runOpts || []}
{#if runOpts.length > 0}
<p>
{$t("checks.run-check.configure-info")}
</p>
<Form
id="run-test-modal"
on:submit={(e) => {
e.preventDefault();
handleRunCheck();
}}
>
{#each runOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
readonly={!!optDoc.autoFill}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/each}
{@const otherOpts = [
...(status.options?.adminOpts || []),
...(status.options?.userOpts || []),
...(status.options?.domainOpts || []),
...(status.options?.serviceOpts || []),
].filter((o) => o.id)}
{#if otherOpts.length > 0}
<button
type="button"
class="btn btn-link btn-sm px-0 mb-2 text-muted d-flex align-items-center gap-1 text-decoration-none"
onclick={() => (showAdvanced = !showAdvanced)}
>
<Icon name={showAdvanced ? "chevron-down" : "chevron-right"} />
{$t("checks.run-check.advanced-options")}
</button>
{#if showAdvanced}
{#each otherOpts as optDoc}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
readonly={true}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/each}
{/if}
{/if}
</Form>
{:else}
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.run-check.no-options")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.run-check.error-loading-options", { error: error.message })}
</Alert>
{/await}
{/if}
</ModalBody>
<ModalFooter>
<Button type="button" color="secondary" onclick={toggle} disabled={triggering}>
{$t("common.cancel")}
</Button>
<Button
type="submit"
form="run-test-modal"
color="primary"
onclick={handleRunCheck}
disabled={triggering}
>
{#if triggering}
<Spinner size="sm" class="me-1" />
{:else}
<Icon name="play-fill"></Icon>
{/if}
{$t("checks.run-check.run-button")}
</Button>
</ModalFooter>
</Modal>

View file

@ -82,6 +82,7 @@
"share": "Share the zone…",
"upload": "Import a zone file",
"view": "View my zone",
"view-checks": "View checks",
"others": "More actions on {{domain}}"
},
"alert": {
@ -238,6 +239,7 @@
"my-domains": "My domains",
"my-providers": "My domain providers",
"dns-resolver": "DNS resolver",
"checkers": "Domain Checkers",
"my-account": "My account",
"logout": "Sign out",
"provider-features": "Supported providers",
@ -538,10 +540,197 @@
"ttl": "Remaining time in cache",
"showDNSSEC": "Show DNSSEC records in answer (if any)"
},
"checks": {
"run-test": {
"title": "Run Test",
"loading-options": "Loading checker options...",
"configure-info": "Configure test options below. Pre-filled values are from domain-level settings.",
"no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.",
"error-loading-options": "Error loading checker options: {{error}}",
"run-button": "Run Test",
"triggered-success": "Test triggered successfully! Execution ID: {{id}}",
"trigger-failed": "Failed to trigger test: {{error}}",
"advanced-options": "Advanced options"
},
"never": "Never",
"na": "N/A",
"relative": {
"in-less-than-a-minute": "in less than a minute",
"just-now": "just now",
"in": "in {{label}}",
"ago": "{{label}} ago"
},
"status": {
"ok": "OK",
"info": "Info",
"warning": "Warning",
"error": "Error",
"unknown": "Unknown",
"pending": "Pending",
"not-run": "Not run"
},
"list": {
"title": "Checks for ",
"loading": "Loading checkers...",
"loading-checkers": "Loading checker information...",
"no-checks": "No checks available for this domain.",
"run-test": "Run Test",
"view-results": "View Results",
"error-loading": "Error loading checkers: {{error}}",
"unknown-version": "Unknown",
"table": {
"checker": "Checker",
"status": "Status",
"last-run": "Last Run",
"schedule": "Schedule",
"actions": "Actions"
},
"schedule": {
"enabled": "Enabled",
"disabled": "Disabled"
}
},
"schedule": {
"title": "Schedule",
"card-title": "Automatic scheduling",
"auto-enabled": "Run automatically",
"auto-disabled": "Disabled (run manually only)",
"interval-label": "Check interval",
"hours": "hours",
"interval-hint": "Minimum 1 hour. The test will run once per interval.",
"next-run": "Next scheduled run",
"last-run": "Last run",
"no-schedule-yet": "No schedule created yet. Save to create one.",
"save": "Save",
"save-failed": "Failed to save schedule",
"saved": "Schedule saved successfully."
},
"results": {
"loading": "Loading check results...",
"no-results": "No test results yet. Click \"Run Test Now\" to execute the test.",
"title": "Test Results ({{count}})",
"run-test-now": "Run Test Now",
"back-to-checks": "Back to checks",
"delete-all": "Delete All",
"delete-confirm": "Are you sure you want to delete this test result?",
"delete-all-confirm": "Are you sure you want to delete ALL test results for this test? This cannot be undone.",
"delete-failed": "Failed to delete result",
"delete-all-failed": "Failed to delete results",
"configure": "Configure",
"domain-level": "Domain-level",
"error-loading": "Error loading check results: {{error}}",
"table": {
"executed-at": "Executed At",
"status": "Status",
"message": "Message",
"duration": "Duration",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Scheduled",
"manual": "Manual"
},
"pending": {
"queued": "Queued, waiting to run…",
"running": "Running…"
},
"view": "View"
},
"result": {
"title": "Test Result Details",
"loading": "Loading check result...",
"relaunch": "Relaunch Test",
"delete": "Delete Result",
"relaunch-failed": "Failed to relaunch test",
"delete-confirm": "Are you sure you want to delete this test result?",
"delete-failed": "Failed to delete result",
"error-loading": "Error loading check result: {{error}}",
"milliseconds": "milliseconds",
"seconds": "seconds",
"type": {
"scheduled": "Scheduled Test",
"manual": "Manual Test"
},
"test-options": "Test Options",
"full-report": "Full Report",
"field": {
"domain": "Domain:",
"executed-at": "Executed At:",
"duration": "Duration:",
"status": "Status:",
"status-message": "Status Message:",
"error": "Error:"
}
},
"title": "Domain Checkers",
"description": "Configure automated checks for your domains",
"available-count": "Available: {{count}} checks",
"search-placeholder": "Search checks...",
"loading": "Loading checkers...",
"loading-info": "Loading checker information...",
"no-checks": "No checks available",
"error-loading": "Error loading checkers: {{error}}",
"error-loading-test": "Error loading checker: {{error}}",
"test-info-not-found": "Error: Checker information not found",
"back-to-checks": "Back to checks",
"table": {
"name": "Test Name",
"availability": "Availability",
"actions": "Actions"
},
"availability": {
"domain": "Domain",
"provider-specific": "Provider-specific",
"service-specific": "Service-specific",
"general": "General",
"domain-level": "Domain-level",
"providers": "Providers: {{providers}}",
"services": "Services: {{services}}"
},
"actions": {
"configure": "Configure"
},
"detail": {
"test-information": "Test Information",
"name": "Name:",
"availability": "Availability:",
"loading-options": "Loading options...",
"configuration": "Configuration",
"save-changes": "Save Changes",
"no-configurable-options": "This test has no configurable options.",
"error-loading-options": "Error loading options: {{error}}",
"orphaned-options": "Orphaned options detected: {{options}}",
"clean-up": "Clean Up",
"read-only": "(Read-only)"
},
"option-groups": {
"global-settings": "Global Settings",
"domain-settings": "Domain-specific Settings",
"service-settings": "Service-specific Settings",
"test-parameters": "Test Parameters",
"type": "Type: {{type}}",
"required": "Required"
},
"auto-fill": {
"domain_name": "auto-filled: domain name",
"subdomain": "auto-filled: subdomain",
"service_type": "auto-filled: service type",
"generic": "auto-filled: {{key}}"
},
"messages": {
"options-updated": "Checker options updated successfully",
"options-cleaned": "Orphaned options removed successfully",
"update-failed": "Failed to update options: {{error}}",
"clean-failed": "Failed to clean options: {{error}}"
}
},
"zones": {
"upload": "Import a zone",
"import-text": "Import from text",
"import-file": "Import from file",
"return-to": "Go to the zone"
"return-to": "Go to the zone",
"return-to-results": "Back to results",
"return-to-checks": "Back to checks"
}
}

View file

@ -0,0 +1,76 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import type {
HappydnsCheckerAvailability,
HappydnsCheckerOptionDocumentation,
HappydnsCheckerOptionsDocumentation,
HappydnsCheckerOptions,
HappydnsCheckerResponse,
HappydnsCheckerSchedule,
HappydnsCheckResult,
HappydnsCheckExecution,
} from "$lib/api-base/types.gen";
// Re-export auto-generated types with better names
export type CheckerAvailability = HappydnsCheckerAvailability;
export type CheckerInfo = HappydnsCheckerResponse;
export type CheckerList = { [key: string]: HappydnsCheckerResponse };
export type CheckerOptions = HappydnsCheckerOptions;
export type CheckerOptionsDocumentation = HappydnsCheckerOptionsDocumentation;
export type CheckerSchedule = HappydnsCheckerSchedule;
export type CheckResult = HappydnsCheckResult;
export type CheckExecution = HappydnsCheckExecution;
// Make 'id' required for CheckerOptionDocumentation
export interface CheckerOptionDocumentation extends Omit<HappydnsCheckerOptionDocumentation, "id"> {
id: string;
}
// Enums for named access to numeric status/scope values
export enum CheckResultStatus {
KO = 0,
Warn = 1,
Info = 2,
OK = 3,
}
export enum CheckScopeType {
CheckScopeInstance = 0,
CheckScopeUser = 1,
CheckScopeDomain = 2,
CheckScopeService = 3,
CheckScopeOnDemand = 4,
}
export enum CheckExecutionStatus {
CheckExecutionPending = 0,
CheckExecutionRunning = 1,
CheckExecutionCompleted = 2,
CheckExecutionFailed = 3,
}
export interface AvailableCheck {
checker_name: string;
enabled: boolean;
schedule?: CheckerSchedule;
last_result?: CheckResult;
}

View file

@ -31,6 +31,7 @@ export class Field {
required? = $state<boolean>();
secret? = $state<boolean>();
textarea? = $state<boolean>();
autoFill? = $state<string>();
}
export class CustomForm {

106
web/src/lib/model/test.ts Normal file
View file

@ -0,0 +1,106 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import type { CheckerOptions } from "./check";
export enum TestScopeType {
TestScopeInstance = 0,
TestScopeUser = 1,
TestScopeDomain = 2,
TestScopeZone = 3,
TestScopeService = 4,
TestScopeOnDemand = 5,
}
export enum TestExecutionStatus {
TestExecutionPending = 0,
TestExecutionRunning = 1,
TestExecutionCompleted = 2,
TestExecutionFailed = 3,
}
export enum PluginResultStatus {
KO = 0,
Warn = 1,
Info = 2,
OK = 3,
}
export interface TestResult {
id: string;
plugin_name: string;
test_type: TestScopeType;
target_id: string;
user_id: string;
executed_at: string;
scheduled_test: boolean;
options?: CheckerOptions;
status: PluginResultStatus;
status_line: string;
report?: any;
duration?: number;
error?: string;
}
export interface TestSchedule {
id: string;
plugin_name: string;
user_id: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
last_run?: string;
next_run: string;
options?: CheckerOptions;
}
export interface TestExecution {
id: string;
schedule_id?: string;
plugin_name: string;
user_id: string;
target_id: string;
status: TestExecutionStatus;
started_at: string;
completed_at?: string;
result_id?: string;
}
export interface AvailableTest {
plugin_name: string;
enabled: boolean;
schedule?: TestSchedule;
last_result?: TestResult;
}
export interface TriggerTestRequest {
options?: CheckerOptions;
}
export interface CreateScheduleRequest {
plugin_name: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: CheckerOptions;
}

View file

@ -0,0 +1,32 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2022-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/>.
import { listChecks } from "$lib/api/checks";
import type { CheckerList } from "$lib/model/check";
import { writable, type Writable } from "svelte/store";
export const checks: Writable<CheckerList | undefined> = writable(undefined);
export async function refreshChecks() {
const data = await listChecks();
checks.set(data);
return data;
}

View file

@ -39,6 +39,11 @@ interface Params {
min?: number;
max?: number;
suggestion?: string;
key?: string;
error?: string;
providers?: string;
services?: string;
options?: string;
// add more parameters that are used here
}

View file

@ -0,0 +1,39 @@
import type { HappydnsCheckResultStatus } from "$lib/api-base/types.gen";
import { CheckResultStatus } from "$lib/model/check";
export function getStatusColor(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
switch (status) {
case CheckResultStatus.OK:
return "success";
case CheckResultStatus.Info:
return "info";
case CheckResultStatus.Warn:
return "warning";
case CheckResultStatus.KO:
return "danger";
default:
return "secondary";
}
}
export function getStatusKey(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
switch (status) {
case CheckResultStatus.OK:
return "checks.status.ok";
case CheckResultStatus.Info:
return "checks.status.info";
case CheckResultStatus.Warn:
return "checks.status.warning";
case CheckResultStatus.KO:
return "checks.status.error";
default:
return "checks.status.unknown";
}
}
export function formatDuration(duration: number | undefined, t: (k: string) => string): string {
if (!duration) return t("checks.na");
const seconds = duration / 1000000000;
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${t("checks.result.milliseconds")}`;
return `${seconds.toFixed(2)} ${t("checks.result.seconds")}`;
}

View file

@ -4,17 +4,17 @@
* @returns Datetime-local format string, or empty string if invalid
*/
export function toDatetimeLocal(isoString: string | null | undefined): string {
if (!isoString) return '';
if (!isoString) return "";
try {
const date = new Date(isoString);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
const hours = String(date.getHours()).padStart(2, "0");
const minutes = String(date.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch (e) {
return '';
return "";
}
}
@ -31,3 +31,61 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
return null;
}
}
/**
* Format a date string for display in check UI
* @param dateString ISO date string or undefined
* @param style Display style: "short", "medium", or "long"
* @param t i18n translation function
* @returns Formatted date string, or $t("checks.never") if undefined/invalid
*/
export function formatCheckDate(
dateString: string | undefined,
style: "short" | "medium" | "long",
t: (k: string) => string,
): string {
if (!dateString) return t("checks.never");
const d = new Date(dateString);
if (isNaN(d.getTime())) return t("checks.never");
return new Intl.DateTimeFormat(undefined, {
dateStyle: style,
timeStyle: "short",
}).format(d);
}
/**
* Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago")
* @param dateString ISO date string or undefined
* @param t i18n translation function
* @returns Relative time string, or empty string if undefined/invalid
*/
export function formatRelative(dateString: string | undefined, t: (k: string) => string): string {
if (!dateString) return "";
const d = new Date(dateString);
if (isNaN(d.getTime())) return "";
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const absDiffMs = Math.abs(diffMs);
if (absDiffMs < 60_000)
return diffMs > 0
? t("checks.relative.in-less-than-a-minute")
: t("checks.relative.just-now");
const minutes = Math.floor(absDiffMs / 60_000);
const hours = Math.floor(absDiffMs / 3_600_000);
const days = Math.floor(absDiffMs / 86_400_000);
let label: string;
if (days > 0) {
label = `${days}d ${hours % 24}h`;
} else if (hours > 0) {
label = `${hours}h ${minutes % 60}m`;
} else {
label = `${minutes}m`;
}
return diffMs > 0
? t("checks.relative.in").replace("{{label}}", label)
: t("checks.relative.ago").replace("{{label}}", label);
}

View file

@ -2,4 +2,5 @@
* Centralized utility exports
*/
export { toDatetimeLocal, fromDatetimeLocal } from './datetime';
export { toDatetimeLocal, fromDatetimeLocal, formatCheckDate, formatRelative } from "./datetime";
export { getStatusColor, getStatusKey, formatDuration } from "./check";

View file

@ -0,0 +1,168 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { checks, refreshChecks } from "$lib/stores/checks";
let searchQuery = $state("");
// Load checks if not already loaded
$effect(() => {
if ($checks === undefined) {
refreshChecks();
}
});
</script>
<svelte:head>
<title>{$t("checks.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="check-circle-fill"></Icon>
{$t("checks.title")}
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead">
{$t("checks.description")}
</span>
{#if $checks}
<span>
{$t("checks.available-count", {
count: Object.keys($checks).length,
})}
</span>
{/if}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input
type="text"
placeholder={$t("checks.search-placeholder")}
bind:value={searchQuery}
/>
</InputGroup>
</Col>
</Row>
{#if !$checks}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checks.loading")}
</p>
</Card>
{:else}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>{$t("checks.table.name")}</th>
<th>{$t("checks.table.availability")}</th>
<th>{$t("checks.table.actions")}</th>
</tr>
</thead>
<tbody>
{#if Object.keys($checks).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-4">
{$t("checks.no-tests")}
</td>
</tr>
{:else}
{#each Object.entries($checks).filter(([name, _info]) => name
.toLowerCase()
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
<tr>
<td><strong>{checkerInfo.name || checkerName}</strong></td>
<td>
{#if checkerInfo.availability}
{#if checkerInfo.availability.applyToDomain}
<Badge color="success"
>{$t("checks.availability.domain")}</Badge
>
{/if}
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
<Badge
color="primary"
title={checkerInfo.availability.limitToProviders.join(
", ",
)}
>
{$t("checks.availability.provider-specific")}
</Badge>
{/if}
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
<Badge
color="info"
title={checkerInfo.availability.limitToServices.join(
", ",
)}
>
{$t("checks.availability.service-specific")}
</Badge>
{/if}
{:else}
<Badge color="secondary"
>{$t("checks.availability.general")}</Badge
>
{/if}
</td>
<td>
<a href="/checks/{checkerName}" class="btn btn-sm btn-primary">
<Icon name="gear-fill"></Icon>
{$t("checks.actions.configure")}
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{/if}
</Container>

View file

@ -0,0 +1,346 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import { getCheckStatus, getCheckOptions, updateCheckOptions } from "$lib/api/checks";
import Resource from "$lib/components/inputs/Resource.svelte";
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
let cid = $derived(page.params.cid!);
let checkStatusPromise = $derived(getCheckStatus(cid));
let checkOptionsPromise = $derived(getCheckOptions(cid));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
checkOptionsPromise.then((options) => {
optionValues = { ...(options || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await updateCheckOptions(cid, optionValues);
checkOptionsPromise = getCheckOptions(cid);
toasts.addToast({
message: $t("checks.messages.options-updated"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checks.messages.update-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(userOpts: any[]) {
const validOptIds = new Set(userOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await updateCheckOptions(cid, cleanedOptions);
checkOptionsPromise = getCheckOptions(cid);
toasts.addToast({
message: $t("checks.messages.options-cleaned"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t("checks.messages.clean-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(userOpts: any[], readOnlyOptGroups: any[]): string[] {
const validOptIds = new Set(userOpts.map((opt) => opt.id));
for (const group of readOnlyOptGroups) {
for (const opt of group.opts) {
validOptIds.add(opt.id);
}
}
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<svelte:head>
<title>{cid} - {$t("checks.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/checks" class="mb-2">
<Icon name="arrow-left"></Icon>
{$t("checks.back-to-checks")}
</Button>
<h1 class="display-5">
<Icon name="check-circle-fill"></Icon>
{cid}
</h1>
</Col>
</Row>
{#await checkStatusPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checks.loading-info")}
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<strong>{$t("checks.detail.test-information")}</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">{$t("checks.detail.name")}</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">{$t("checks.detail.availability")}</dt>
<dd class="col-sm-8">
{#if status.availability}
<div class="d-flex flex-wrap gap-1">
{#if status.availability.applyToDomain}
<Badge color="success"
>{$t("checks.availability.domain-level")}</Badge
>
{/if}
{#if status.availability.limitToProviders && status.availability.limitToProviders.length > 0}
<Badge color="primary">
{$t("checks.availability.providers", {
providers:
status.availability.limitToProviders.join(
", ",
),
})}
</Badge>
{/if}
{#if status.availability.limitToServices && status.availability.limitToServices.length > 0}
<Badge color="info">
{$t("checks.availability.services", {
services:
status.availability.limitToServices.join(
", ",
),
})}
</Badge>
{/if}
{#if !status.availability.applyToDomain && (!status.availability.limitToProviders || status.availability.limitToProviders.length === 0) && (!status.availability.limitToServices || status.availability.limitToServices.length === 0)}
<Badge color="secondary"
>{$t("checks.availability.general")}</Badge
>
{/if}
</div>
{:else}
<Badge color="secondary"
>{$t("checks.availability.general")}</Badge
>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</Col>
<Col md={6}>
{#await checkOptionsPromise}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("checks.detail.loading-options")}
</p>
</CardBody>
</Card>
{:then options}
{@const userOpts = status.options?.userOpts || []}
{@const readOnlyOptGroups = [
{
key: "adminOpts",
label: $t("checks.option-groups.global-settings"),
opts: status.options?.adminOpts || [],
},
{
key: "domainOpts",
label: $t("checks.option-groups.domain-settings"),
opts: status.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: $t("checks.option-groups.service-settings"),
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: $t("checks.option-groups.test-parameters"),
opts: status.options?.runOpts || [],
},
]}
{@const hasAnyOpts =
userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.detail.orphaned-options", {
options: orphanedOpts.join(", "),
})}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(userOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
{$t("checks.detail.clean-up")}
</Button>
</div>
</Alert>
{/if}
{#if userOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{$t("checks.detail.configuration")}</strong>
</CardHeader>
<CardBody>
<Form
on:submit={(e) => {
e.preventDefault();
saveOptions();
}}
>
{#each userOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
{$t("checks.detail.save-changes")}
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<CheckerOptionsGroups groups={readOnlyOptGroups} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.detail.no-configurable-options")}
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.detail.error-loading-options", {
error: error.message,
})}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.test-info-not-found")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.error-loading-test", { error: error.message })}
</Alert>
{/await}
</Container>

View file

@ -82,7 +82,11 @@
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: ""
: page.route.id.startsWith("/domains/[dn]/checks/[cname]")
? `/checks/${page.params.cname!}`
: page.route.id.startsWith("/domains/[dn]/checks")
? "/checks"
: ""
: ""),
);
}
@ -173,7 +177,35 @@
<SelectDomain bind:selectedDomain />
</div>
{#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs"))}
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks/[cname]")}
{#if page.route.id.startsWith("/domains/[dn]/checks/[cname]/results/")}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/checks/" +
encodeURIComponent(page.params.cname!) +
"/results"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-results")}
</Button>
{:else}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/checks"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-checks")}
</Button>
{/if}
{:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/checks"))}
<Button
class="mt-2"
outline
@ -227,6 +259,9 @@
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
{$t("domains.actions.audit")}
</DropdownItem>
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/checks`}>
{$t("domains.actions.view-tests")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
{$t("domains.actions.view")}

View file

@ -0,0 +1,17 @@
import { type Load } from "@sveltejs/kit";
import { checks, refreshChecks } from "$lib/stores/checks";
import { get } from "svelte/store";
export const load: Load = async ({ parent }) => {
const data = await parent();
if (get(checks) === undefined) {
refreshChecks();
}
return {
...data,
isTestsPage: true,
};
};

View file

@ -0,0 +1,217 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { navigate } from "$lib/stores/config";
import { page } from "$app/state";
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
import { checks } from "$lib/stores/checks";
import { toasts } from "$lib/stores/toasts";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import { getStatusColor, getStatusKey, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
let checksPromise = $derived(listAvailableChecks(data.domain.id));
let runCheckModal: RunCheckModal;
let togglingChecks = $state(new Set<string>());
function handleCheckTriggered(_: string, checkName: string) {
// Refresh the check list to show updated status
checksPromise = listAvailableChecks(data.domain.id);
navigate(`/domains/${page.params.dn!}/checks/${checkName}/results`);
}
async function handleToggleEnabled(check: AvailableCheck) {
const next = new Set(togglingChecks);
next.add(check.checker_name);
togglingChecks = next;
try {
const newEnabled = !check.enabled;
if (check.schedule) {
await updateCheckSchedule(check.schedule.id!, {
...check.schedule,
enabled: newEnabled,
});
} else {
// No schedule record yet — create one to persist the disabled state.
// (Enabled → Enabled needs no action since that's the implicit default.)
await createCheckSchedule({
checker_name: check.checker_name,
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
interval: 0,
enabled: newEnabled,
});
}
checksPromise = listAvailableChecks(data.domain.id);
} catch (e: any) {
toasts.addErrorToast({ title: $t("checks.list.error-loading", { error: e.message }) });
} finally {
const after = new Set(togglingChecks);
after.delete(check.checker_name);
togglingChecks = after;
}
}
</script>
<svelte:head>
<title>Checks - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<h2>
{$t("checks.list.title")}<span class="font-monospace">{data.domain.domain}</span>
</h2>
{#await checksPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading")}</p>
</div>
{:then availableChecks}
{#if !$checks}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading-checks")}</p>
</div>
{:else if !availableChecks || availableChecks.length === 0}
<Card body class="mt-3">
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.list.no-checks")}
</p>
</Card>
{:else}
<Table hover striped class="mt-3">
<thead>
<tr>
<th>{$t("checks.list.table.check")}</th>
<th>{$t("checks.list.table.status")}</th>
<th>{$t("checks.list.table.last-run")}</th>
<th>{$t("checks.list.table.schedule")}</th>
<th>{$t("checks.list.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each availableChecks as check}
{@const checkInfo = $checks[check.checker_name]}
<tr>
<td class="align-middle">
<strong>{checkInfo?.name || check.checker_name}</strong>
</td>
<td class="align-middle text-center">
{#if check.last_result !== undefined}
<Badge color={getStatusColor(check.last_result.status)}>
{$t(getStatusKey(check.last_result.status))}
</Badge>
{:else}
<Badge color="secondary">{$t("checks.status.not-run")}</Badge>
{/if}
</td>
<td class="align-middle">
{formatCheckDate(check.last_result?.executed_at, "short", $t)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="toggle-{check.checker_name}"
checked={check.enabled}
disabled={togglingChecks.has(check.checker_name)}
onchange={() => handleToggleEnabled(check)}
/>
<label
class="form-check-label small"
for="toggle-{check.checker_name}"
>
{check.enabled
? $t("checks.list.schedule.enabled")
: $t("checks.list.schedule.disabled")}
</label>
</div>
</td>
<td class="align-middle">
<div class="d-flex gap-2">
<Button
size="sm"
color="primary"
onclick={() =>
runCheckModal.open(
check.checker_name,
checkInfo?.name || check.checker_name,
)}
>
<Icon name="play-fill"></Icon>
{$t("checks.list.run-check")}
</Button>
<Button
size="sm"
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("checks.list.view-results")}
</Button>
<Button
size="sm"
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}`}
title={$t("checks.list.configure")}
>
<Icon name="gear"></Icon>
</Button>
</div>
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{:catch error}
<Card body color="danger" class="mt-3">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.list.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunCheckModal
domainId={data.domain.id}
onCheckTriggered={handleCheckTriggered}
bind:this={runCheckModal}
/>

View file

@ -0,0 +1,266 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import { page } from "$app/state";
import {
Badge,
Button,
Card,
CardBody,
CardHeader,
Icon,
Input,
Spinner,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
import { checks } from "$lib/stores/checks";
import { toasts } from "$lib/stores/toasts";
import { formatCheckDate, formatRelative } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
const checkDisplayName = $derived($checks?.[checkName]?.name || checkName);
// Resolved check data
let check = $state<AvailableCheck | null>(null);
let loading = $state(true);
let loadError = $state<string | null>(null);
// Form state
let formEnabled = $state(true);
let formIntervalHours = $state(24);
let saving = $state(false);
async function loadCheck() {
loading = true;
loadError = null;
try {
const checks = await listAvailableChecks(data.domain.id);
const found = checks?.find((c) => c.checker_name === checkName) ?? null;
check = found;
if (found) {
formEnabled = found.enabled;
formIntervalHours =
found.schedule && found.schedule.interval !== undefined && found.schedule.interval > 0
? found.schedule.interval / (3600 * 1e9)
: 24;
}
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
loadCheck();
async function handleSave() {
if (!check) return;
saving = true;
try {
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
if (check.schedule) {
await updateCheckSchedule(check.schedule.id!, {
...check.schedule,
enabled: formEnabled,
interval: intervalNs,
});
} else {
await createCheckSchedule({
checker_name: check.checker_name,
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
interval: intervalNs,
enabled: formEnabled,
});
}
toasts.addToast({ title: $t("checks.schedule.saved"), type: "success", timeout: 3000 });
await loadCheck();
} catch (e: any) {
toasts.addErrorToast({ title: $t("checks.schedule.save-failed"), message: e.message });
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>
{checkName} - {$t("checks.schedule.title")} - {data.domain.domain} - happyDomain
</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{checkDisplayName}
&ndash; {$t("checks.schedule.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("checks.list.view-results")}
</Button>
</div>
</div>
{#if loading}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.list.loading")}</p>
</div>
{:else if loadError}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.list.error-loading", { error: loadError })}
</p>
</Card>
{:else if !check}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.list.no-checks")}
</p>
</Card>
{:else}
<Card class="mb-4">
<CardHeader>
<h4 class="mb-0">
<Icon name="clock-history"></Icon>
{$t("checks.schedule.card-title")}
</h4>
</CardHeader>
<CardBody>
<div class="mb-4">
<div class="form-check form-switch">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="schedule-enabled"
bind:checked={formEnabled}
disabled={saving}
/>
<label class="form-check-label" for="schedule-enabled">
{#if formEnabled}
<Badge color="success">{$t("checks.schedule.auto-enabled")}</Badge>
{:else}
<Badge color="secondary">{$t("checks.schedule.auto-disabled")}</Badge
>
{/if}
</label>
</div>
</div>
{#if formEnabled}
<div class="mb-4">
<label for="schedule-interval" class="form-label fw-semibold">
{$t("checks.schedule.interval-label")}
</label>
<div class="input-group" style="max-width: 300px;">
<Input
type="number"
id="schedule-interval"
min={1}
step={1}
bind:value={formIntervalHours}
disabled={saving}
/>
<span class="input-group-text">
{$t("checks.schedule.hours")}
</span>
</div>
<div class="form-text">
{$t("checks.schedule.interval-hint")}
</div>
</div>
{/if}
{#if check.schedule}
<div class="mb-4">
<div class="row g-3">
{#if check.schedule.last_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("checks.schedule.last-run")}:
</span>
<span>
{formatCheckDate(check.schedule.last_run, "medium", $t)}
<small class="text-muted">
({formatRelative(check.schedule.last_run, $t)})
</small>
</span>
</div>
{/if}
{#if check.enabled && check.schedule.next_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("checks.schedule.next-run")}:
</span>
<span>
{formatCheckDate(check.schedule.next_run, "medium", $t)}
<small class="text-muted">
({formatRelative(check.schedule.next_run, $t)})
</small>
</span>
</div>
{/if}
</div>
</div>
{:else}
<p class="text-muted">
<Icon name="info-circle"></Icon>
{$t("checks.schedule.no-schedule-yet")}
</p>
{/if}
<Button color="primary" disabled={saving} onclick={handleSave}>
{#if saving}
<Spinner size="sm" class="me-1" />
{/if}
<Icon name="check-lg"></Icon>
{$t("checks.schedule.save")}
</Button>
</CardBody>
</Card>
{/if}
</div>

View file

@ -0,0 +1,312 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Card,
Alert,
Icon,
Table,
Badge,
Button,
Spinner,
ButtonGroup,
} from "@sveltestrap/sveltestrap";
import { onDestroy } from "svelte";
import { t } from "$lib/translations";
import { page } from "$app/state";
import {
listCheckResults,
deleteCheckResult,
deleteAllCheckResults,
getCheckExecution,
} from "$lib/api/checks";
import { getCheckStatus } from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import type { CheckExecution } from "$lib/model/check";
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/check";
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
let resultsPromise = $derived(listCheckResults(data.domain.id, checkName));
let checkPromise = $derived(getCheckStatus(checkName));
let runCheckModal: RunCheckModal;
let errorMessage = $state<string | null>(null);
let pendingExecutions = $state<CheckExecution[]>([]);
const pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
onDestroy(() => {
for (const id of pollingIntervals.values()) clearInterval(id);
});
function handleCheckTriggered(execution_id: string) {
const placeholder: CheckExecution = {
id: execution_id,
checker_name: checkName,
owner_id: "",
target_type: CheckScopeType.CheckScopeDomain,
target_id: data.domain.id,
status: CheckExecutionStatus.CheckExecutionPending,
started_at: new Date().toISOString(),
};
pendingExecutions = [...pendingExecutions, placeholder];
const intervalId = setInterval(async () => {
try {
const exec = await getCheckExecution(data.domain.id, checkName, execution_id);
pendingExecutions = pendingExecutions.map((e) =>
e.id === execution_id ? exec : e,
);
if (
exec.status === CheckExecutionStatus.CheckExecutionCompleted ||
exec.status === CheckExecutionStatus.CheckExecutionFailed
) {
clearInterval(intervalId);
pollingIntervals.delete(execution_id);
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
resultsPromise = listCheckResults(data.domain.id, checkName);
}
} catch {
clearInterval(intervalId);
pollingIntervals.delete(execution_id);
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
}
}, 2000);
pollingIntervals.set(execution_id, intervalId);
}
async function handleDeleteResult(resultId: string) {
if (!confirm($t("checks.results.delete-confirm"))) {
return;
}
try {
await deleteCheckResult(data.domain.id, checkName, resultId);
resultsPromise = listCheckResults(data.domain.id, checkName);
} catch (error: any) {
errorMessage = error.message || $t("checks.results.delete-failed");
}
}
async function handleDeleteAll() {
if (!confirm($t("checks.results.delete-all-confirm"))) {
return;
}
try {
await deleteAllCheckResults(data.domain.id, checkName);
resultsPromise = listCheckResults(data.domain.id, checkName);
} catch (error: any) {
errorMessage = error.message || $t("checks.results.delete-all-failed");
}
}
</script>
<svelte:head>
<title>{checkName} Results - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{#await checkPromise then check}
{check.name || checkName}
{:catch}
{checkName}
{/await}
</h2>
<div class="d-flex gap-2">
<Button
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`}
>
<Icon name="gear-fill"></Icon>
{$t("checks.results.configure")}
</Button>
{#await checkPromise then check}
<Button
color="primary"
onclick={() => runCheckModal.open(checkName, check.name || checkName)}
>
<Icon name="play-fill"></Icon>
{$t("checks.results.run-check-now")}
</Button>
{/await}
</div>
</div>
{#if errorMessage}
{#key errorMessage}
<Alert color="danger" dismissible>
<Icon name="exclamation-triangle-fill"></Icon>
{errorMessage}
</Alert>
{/key}
{/if}
{#await resultsPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.results.loading")}</p>
</div>
{:then results}
{#if !results || results.length === 0}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("checks.results.no-results")}
</p>
</Card>
{:else}
<div class="d-flex justify-content-between align-items-center mb-2">
<h4>{$t("checks.results.title", { count: results.length })}</h4>
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
<Icon name="trash"></Icon>
{$t("checks.results.delete-all")}
</Button>
</div>
<Table hover striped>
<thead>
<tr>
<th>{$t("checks.results.table.executed-at")}</th>
<th class="text-center">{$t("checks.results.table.status")}</th>
<th>{$t("checks.results.table.message")}</th>
<th>{$t("checks.results.table.duration")}</th>
<th class="text-center">{$t("checks.results.table.type")}</th>
<th>{$t("checks.results.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each pendingExecutions as exec (exec.id)}
<tr class="table-warning">
<td class="align-middle">
{formatCheckDate(exec.started_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge color="secondary">
{$t("checks.status.pending")}
</Badge>
</td>
<td class="align-middle text-muted">
{exec.status === CheckExecutionStatus.CheckExecutionRunning
? $t("checks.results.pending.running")
: $t("checks.results.pending.queued")}
</td>
<td class="align-middle"></td>
<td class="align-middle text-center">
<Badge color="secondary">
{#if exec.schedule_id}
<Icon name="clock"></Icon>
{$t("checks.results.type.scheduled")}
{:else}
<Icon name="hand-index"></Icon>
{$t("checks.results.type.manual")}
{/if}
</Badge>
</td>
<td class="align-middle"></td>
</tr>
{/each}
{#each results as result}
<tr>
<td class="align-middle">
{formatCheckDate(result.executed_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
<td class="align-middle">
{result.status_line}
{#if result.error}
<br />
<small class="text-danger">{result.error}</small>
{/if}
</td>
<td class="align-middle">
{formatDuration(result.duration, $t)}
</td>
<td class="align-middle text-center">
<Badge color="secondary">
{#if result.scheduled_check}
<Icon name="clock"></Icon>
{$t("checks.results.type.scheduled")}
{:else}
<Icon name="hand-index"></Icon>
{$t("checks.results.type.manual")}
{/if}
</Badge>
</td>
<td class="align-middle">
<ButtonGroup size="sm">
<Button
color="primary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results/${encodeURIComponent(result.id!)}`}
>
<Icon name="eye-fill"></Icon>
{$t("checks.results.view")}
</Button>
<Button
color="danger"
outline
onclick={() => handleDeleteResult(result.id!)}
>
<Icon name="trash"></Icon>
</Button>
</ButtonGroup>
</td>
</tr>
{/each}
</tbody>
</Table>
{/if}
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.results.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunCheckModal
domainId={data.domain.id}
onCheckTriggered={handleCheckTriggered}
bind:this={runCheckModal}
/>

View file

@ -0,0 +1,305 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-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/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Icon,
Row,
Spinner,
Table,
} from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { page } from "$app/state";
import { navigate } from "$lib/stores/config";
import {
getCheckStatus,
getCheckResult,
deleteCheckResult,
triggerCheck,
} from "$lib/api/checks";
import type { Domain } from "$lib/model/domain";
import type { CheckResult } from "$lib/model/check";
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const checkName = $derived(page.params.cname || "");
const resultId = $derived(page.params.rid || "");
let resultPromise = $derived(getCheckResult(data.domain.id, checkName, resultId));
let checkPromise = $derived(getCheckStatus(checkName));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<CheckResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
resultPromise.then((r) => {
resolvedResult = r;
});
});
async function handleRelaunch() {
if (!resolvedResult) return;
isRelaunching = true;
try {
await triggerCheck(data.domain.id, checkName, resolvedResult.options);
navigate(
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("checks.result.relaunch-failed");
} finally {
isRelaunching = false;
}
}
async function handleDelete() {
if (!confirm($t("checks.result.delete-confirm"))) {
return;
}
try {
await deleteCheckResult(data.domain.id, checkName, resultId);
navigate(
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("checks.result.delete-failed");
}
}
</script>
<svelte:head>
<title>
Check Result - {checkName} - {data.domain.domain} - happyDomain
</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2 mw-100">
<div class="d-flex justify-content-between align-items-center mb-3">
<h2 class="text-truncate">
<span class="font-monospace">{data.domain.domain}</span>
&ndash;
{$t("checks.result.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="primary"
outline
onclick={handleRelaunch}
disabled={!resolvedResult || isRelaunching}
>
{#if isRelaunching}
<Spinner size="sm" />
{:else}
<Icon name="arrow-repeat"></Icon>
{/if}
<span class="d-none d-lg-inline">
{$t("checks.result.relaunch")}
</span>
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
<Icon name="trash"></Icon>
<span class="d-none d-lg-inline">
{$t("checks.result.delete")}
</span>
</Button>
</div>
</div>
{#if errorMessage}
{#key errorMessage}
<Alert color="danger" dismissible>
<Icon name="exclamation-triangle-fill"></Icon>
{errorMessage}
</Alert>
{/key}
{/if}
{#await Promise.all([resultPromise, checkPromise])}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("checks.result.loading")}</p>
</div>
{:then [result, check]}
<Row>
<Col lg>
<Card class="mb-3">
<CardHeader>
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex align-items-end gap-2">
<h4 class="mb-0">
{check.name || checkName}
</h4>
</div>
{#if result.scheduled_check}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("checks.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("checks.result.type.manual")}
</Badge>
{/if}
</div>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
<tr>
<th style="width: 200px">{$t("checks.result.field.domain")}</th>
<td class="font-monospace">{data.domain.domain}</td>
</tr>
<tr>
<th>{$t("checks.result.field.executed-at")}</th>
<td>{formatCheckDate(result.executed_at, "long", $t)}</td>
</tr>
<tr>
<th>{$t("checks.result.field.duration")}</th>
<td>{formatDuration(result.duration, $t)}</td>
</tr>
<tr>
<th>{$t("checks.result.field.status")}</th>
<td>
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
</tr>
<tr>
<th>{$t("checks.result.field.status-message")}</th>
<td>{result.status_line}</td>
</tr>
{#if result.error}
<tr>
<th>{$t("checks.result.field.error")}</th>
<td class="text-danger">{result.error}</td>
</tr>
{/if}
</tbody>
</Table>
</CardBody>
</Card>
</Col>
{#if result.options && Object.keys(result.options).length > 0}
<Col lg>
<Card class="mb-3">
<CardHeader>
<h5 class="mb-0">
<Icon name="sliders"></Icon>
{$t("checks.result.check-options")}
</h5>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
{#each Object.entries(check.options ?? {}) as [optKey, optVals]}
{#each optVals as option}
{@const value =
(option.id
? result.options[option.id]
: undefined) ||
option.default ||
option.placeholder ||
""}
<tr>
<th
class="text-truncate"
style="max-width: min(200px, 40vw)"
title={option.label}
>
{option.label}:
</th>
<td class:text-truncate={typeof value !== "object"}>
{#if typeof value === "object"}
<pre class="mb-0"><code
>{JSON.stringify(
value,
null,
2,
)}</code
></pre>
{:else}
{value}
{/if}
</td>
</tr>
{/each}
{/each}
</tbody>
</Table>
</CardBody>
</Card>
</Col>
{/if}
</Row>
{#if result.report}
<Card>
<CardHeader>
<h5 class="mb-0">
<Icon name="file-earmark-text"></Icon>
{$t("checks.result.full-report")}
</h5>
</CardHeader>
<CardBody class="text-truncate p-0">
{#if typeof result.report === "string"}
<pre class="bg-light p-3 rounded mb-0"><code>{result.report}</code></pre>
{:else}
<pre class="bg-light p-3 rounded mb-0"><code
>{JSON.stringify(result.report, null, 2)}</code
></pre>
{/if}
</CardBody>
</Card>
{/if}
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("checks.result.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<style>
pre {
overflow-x: scroll;
}
</style>