Compare commits

...

5 commits

Author SHA1 Message Date
4c7c3b4568 Add admin API and frontend for scheduler management
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-11 12:43:03 +07:00
30fdbff8a3 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-11 12:42:15 +07:00
cde9d405f4 web: Replace page data flags with route ID checks for history/logs pages 2026-02-11 12:42:15 +07:00
262da7bb0e Implement tests scheduler 2026-02-11 12:42:13 +07:00
5b4dd01a13 Implement backend model for test results and schedule 2026-02-11 12:38:38 +07:00
44 changed files with 5752 additions and 138 deletions

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.AdminSchedulerUsecase
}
func NewAdminSchedulerController(scheduler happydns.AdminSchedulerUsecase) *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.RescheduleUpcomingTests()
if err != nil {
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
}

View file

@ -36,6 +36,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
declareDomainRoutes(apiRoutes, dependancies, s)
declarePluginsRoutes(apiRoutes, dependancies, s)
declareProviderRoutes(apiRoutes, dependancies, s)
declareSchedulerRoutes(apiRoutes, dependancies)
declareSessionsRoutes(cfg, apiRoutes, s)
declareUserAuthsRoutes(apiRoutes, dependancies, s)
declareUsersRoutes(apiRoutes, dependancies, s)

View file

@ -0,0 +1,45 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// schedulerDepsProvider is satisfied by App which exposes TestScheduler()
type schedulerDepsProvider interface {
TestScheduler() apicontroller.TestSchedulerInterface
}
func declareSchedulerRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
ctrl := controller.NewAdminSchedulerController(dependancies.TestScheduler())
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

@ -93,7 +93,7 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
c.JSON(http.StatusOK, zone)
}
// GetServiceService retrieves the designated Service.
// GetZoneService retrieves the designated Service.
//
// @Summary Get the Service.
// @Schemes

View file

@ -0,0 +1,583 @@
// 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"
"log"
"maps"
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// TestResultController handles test result operations
type TestResultController struct {
scope happydns.TestScopeType
testPluginUC happydns.TestPluginUsecase
testResultUC happydns.TestResultUsecase
testScheduleUC happydns.TestScheduleUsecase
testScheduler TestSchedulerInterface
}
// TestSchedulerInterface defines the interface for triggering on-demand tests
type TestSchedulerInterface interface {
TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetID happydns.Identifier, userID happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error)
GetSchedulerStatus() happydns.SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingTests() (int, error)
}
func NewTestResultController(
scope happydns.TestScopeType,
testPluginUC happydns.TestPluginUsecase,
testResultUC happydns.TestResultUsecase,
testScheduleUC happydns.TestScheduleUsecase,
testScheduler TestSchedulerInterface,
) *TestResultController {
return &TestResultController{
scope: scope,
testPluginUC: testPluginUC,
testResultUC: testResultUC,
testScheduleUC: testScheduleUC,
testScheduler: testScheduler,
}
}
// getTargetFromContext extracts the target ID from context based on scope
func (tc *TestResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
switch tc.scope {
case happydns.TestScopeUser:
user := c.MustGet("user").(*happydns.User)
return user.Id, nil
case happydns.TestScopeDomain:
domain := c.MustGet("domain").(*happydns.Domain)
return domain.Id, nil
case happydns.TestScopeService:
// Services are stored by ID in context
serviceID := c.MustGet("serviceid").(happydns.Identifier)
return serviceID, nil
default:
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
}
}
// ListAvailableTests lists all available test plugins for the target scope
//
// @Summary List available tests
// @Description Retrieves all available test plugins for the target scope with their last execution status if enabled
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Success 200 {array} object "List of available tests"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests [get]
func (tc *TestResultController) ListAvailableTests(c *gin.Context) {
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get all test plugins
plugins, err := tc.testPluginUC.ListTestPlugins()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Get schedules for this target
schedules, err := tc.testScheduleUC.ListSchedulesByTarget(tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Build schedule map
scheduleMap := make(map[string]*happydns.TestSchedule)
for _, sched := range schedules {
scheduleMap[sched.PluginName] = sched
}
// Build response with last results
type TestInfo struct {
PluginName string `json:"plugin_name"`
Enabled bool `json:"enabled"`
Schedule *happydns.TestSchedule `json:"schedule,omitempty"`
LastResult *happydns.TestResult `json:"last_result,omitempty"`
}
var tests []TestInfo
for _, plugin := range plugins {
// Get plugin version info
versionInfo := plugin.Version()
availability := versionInfo.AvailableOn
// Filter plugins by scope
if tc.scope == happydns.TestScopeDomain && !availability.ApplyToDomain {
continue
}
if tc.scope == happydns.TestScopeService && !availability.ApplyToService {
continue
}
pluginNames := plugin.PluginEnvName()
if len(pluginNames) == 0 {
continue
}
info := TestInfo{
PluginName: pluginNames[0],
Enabled: true, // enabled by default unless explicitly disabled via a schedule
}
// Check if there's a schedule
if sched, ok := scheduleMap[versionInfo.Name]; ok {
info.Enabled = sched.Enabled
info.Schedule = sched
// Get last result
results, err := tc.testResultUC.ListTestResultsByTarget(versionInfo.Name, tc.scope, targetID, 1)
if err == nil && len(results) > 0 {
info.LastResult = results[0]
}
}
tests = append(tests, info)
}
c.JSON(http.StatusOK, tests)
}
// ListLatestTestResults retrieves the latest test results for a specific plugin
//
// @Summary Get latest test results
// @Description Retrieves the 5 most recent test results for a specific plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 200 {array} happydns.TestResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname} [get]
func (tc *TestResultController) ListLatestTestResults(c *gin.Context) {
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
results, err := tc.testResultUC.ListTestResultsByTarget(pluginName, tc.scope, targetID, 5)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// TriggerTest triggers an on-demand test execution
//
// @Summary Trigger test execution
// @Description Triggers an immediate test execution and returns the execution ID
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test 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}/tests/{tname} [post]
func (tc *TestResultController) TriggerTest(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Parse run options
var options happydns.SetPluginOptionsRequest
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.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
mergedOptions := make(happydns.PluginOptions)
// Fill opts with default plugin options
plugin, err := tc.testPluginUC.GetTestPlugin(pluginName)
if err != nil {
log.Printf("Warning: unable to get plugin %q for default options: %v", pluginName, err)
} else {
availableOpts := plugin.AvailableOptions()
// Collect all option documentation from different scopes
allOpts := []happydns.PluginOptionDocumentation{}
allOpts = append(allOpts, availableOpts.RunOpts...)
allOpts = append(allOpts, availableOpts.ServiceOpts...)
allOpts = append(allOpts, availableOpts.DomainOpts...)
allOpts = append(allOpts, availableOpts.UserOpts...)
allOpts = append(allOpts, availableOpts.AdminOpts...)
// Fill defaults
for _, opt := range allOpts {
if opt.Default != nil {
mergedOptions[opt.Id] = opt.Default
}
}
}
// Get merged options from upper levels
baseOptions, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
// Merge request options on top of base options (request options override)
if baseOptions != nil {
maps.Copy(mergedOptions, *baseOptions)
}
maps.Copy(mergedOptions, options.Options)
// Trigger the test via scheduler (returns error if scheduler is disabled)
executionID, err := tc.testScheduler.TriggerOnDemandTest(pluginName, 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()})
}
// GetTestPluginOptions retrieves plugin options for the target scope
//
// @Summary Get test plugin options
// @Description Retrieves configuration options for a test plugin at the target scope
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 200 {object} happydns.PluginOptions
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [get]
func (tc *TestResultController) GetTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
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.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
opts, err := tc.testPluginUC.GetTestPluginOptions(pluginName, &user.Id, domainID, serviceID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, opts)
}
// AddTestPluginOptions adds or overwrites specific options
//
// @Summary Add test plugin options
// @Description Adds or overwrites specific options for a test plugin at the target scope
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param body body happydns.PluginOptions true "Options to add"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [post]
func (tc *TestResultController) AddTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.PluginOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
err = tc.testPluginUC.OverwriteSomeTestPluginOptions(pluginName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// ChangeTestPluginOptions replaces all options
//
// @Summary Replace test plugin options
// @Description Replaces all options for a test plugin at the target scope
// @Tags tests
// @Accept json
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param body body happydns.PluginOptions true "New complete options"
// @Success 200 {object} bool
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/options [put]
func (tc *TestResultController) ChangeTestPluginOptions(c *gin.Context) {
user := middleware.MyUser(c)
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
var options happydns.PluginOptions
if err = c.ShouldBindJSON(&options); err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
var domainID, serviceID *happydns.Identifier
switch tc.scope {
case happydns.TestScopeDomain:
domainID = &targetID
case happydns.TestScopeService:
serviceID = &targetID
}
err = tc.testPluginUC.SetTestPluginOptions(pluginName, &user.Id, domainID, serviceID, options)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, true)
}
// GetTestExecutionStatus retrieves the status of a test execution
//
// @Summary Get test execution status
// @Description Retrieves the current status of a test execution
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param execution_id path string true "Execution ID"
// @Success 200 {object} happydns.TestExecution
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/executions/{execution_id} [get]
func (tc *TestResultController) GetTestExecutionStatus(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.testResultUC.GetTestExecution(executionID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, execution)
}
// ListTestPluginResults lists all results for a test plugin
//
// @Summary List test results
// @Description Lists all test results for a specific test plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param limit query int false "Maximum number of results to return (default: 10)"
// @Success 200 {array} happydns.TestResult
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results [get]
func (tc *TestResultController) ListTestPluginResults(c *gin.Context) {
pluginName := c.Param("tname")
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.testResultUC.ListTestResultsByTarget(pluginName, tc.scope, targetID, limit)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, results)
}
// DropTestPluginResults deletes all results for a test plugin
//
// @Summary Delete all test results
// @Description Deletes all test results for a specific test plugin and target
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Success 204 "No Content"
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results [delete]
func (tc *TestResultController) DropTestPluginResults(c *gin.Context) {
pluginName := c.Param("tname")
targetID, err := tc.getTargetFromContext(c)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
err = tc.testResultUC.DeleteAllTestResults(pluginName, tc.scope, targetID)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.Status(http.StatusNoContent)
}
// GetTestPluginResult retrieves a specific test result
//
// @Summary Get test result
// @Description Retrieves a specific test result by ID
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test plugin name"
// @Param result_id path string true "Result ID"
// @Success 200 {object} happydns.TestResult
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /domains/{domain}/tests/{tname}/results/{result_id} [get]
func (tc *TestResultController) GetTestPluginResult(c *gin.Context) {
pluginName := c.Param("tname")
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.testResultUC.GetTestResult(pluginName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.JSON(http.StatusOK, result)
}
// DropTestPluginResult deletes a specific test result
//
// @Summary Delete test result
// @Description Deletes a specific test result by ID
// @Tags tests
// @Produce json
// @Param domain path string true "Domain identifier"
// @Param tname path string true "Test 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}/tests/{tname}/results/{result_id} [delete]
func (tc *TestResultController) DropTestPluginResult(c *gin.Context) {
pluginName := c.Param("tname")
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.testResultUC.DeleteTestResult(pluginName, tc.scope, targetID, resultID)
if err != nil {
middleware.ErrorResponse(c, http.StatusNotFound, err)
return
}
c.Status(http.StatusNoContent)
}

View file

@ -0,0 +1,215 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2024 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"
)
// TestScheduleController handles test schedule operations
type TestScheduleController struct {
testScheduleUC happydns.TestScheduleUsecase
}
func NewTestScheduleController(testScheduleUC happydns.TestScheduleUsecase) *TestScheduleController {
return &TestScheduleController{
testScheduleUC: testScheduleUC,
}
}
// ListTestSchedules retrieves all schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves all test schedules for the authenticated user
// @Tags test-schedules
// @Produce json
// @Success 200 {array} happydns.TestSchedule
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [get]
func (tc *TestScheduleController) ListTestSchedules(c *gin.Context) {
user := middleware.MyUser(c)
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, schedules)
}
// CreateTestSchedule 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.TestSchedule true "Test schedule to create"
// @Success 201 {object} happydns.TestSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules [post]
func (tc *TestScheduleController) CreateTestSchedule(c *gin.Context) {
user := middleware.MyUser(c)
var schedule happydns.TestSchedule
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)
}
// GetTestSchedule 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.TestSchedule
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [get]
func (tc *TestScheduleController) GetTestSchedule(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)
}
// UpdateTestSchedule 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.TestSchedule true "Updated schedule"
// @Success 200 {object} happydns.TestSchedule
// @Failure 400 {object} happydns.ErrorResponse
// @Failure 404 {object} happydns.ErrorResponse
// @Failure 500 {object} happydns.ErrorResponse
// @Router /plugins/tests/schedules/{schedule_id} [put]
func (tc *TestScheduleController) UpdateTestSchedule(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.TestSchedule
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)
}
// DeleteTestSchedule 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 *TestScheduleController) DeleteTestSchedule(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

@ -48,6 +48,10 @@ func DeclareDomainRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseD
DeclareDomainLogRoutes(apiDomainsRoutes, dependancies)
// Declare test result routes for domain scope
DeclareScopedTestResultRoutes(apiDomainsRoutes, dependancies, happydns.TestScopeDomain)
apiDomainsRoutes.POST("/zone", dc.ImportZone)
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)

View file

@ -81,6 +81,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dependancies happy
DeclareProviderRoutes(apiAuthRoutes, dependancies)
DeclareProviderSettingsRoutes(apiAuthRoutes, dependancies)
DeclareRecordRoutes(apiAuthRoutes, dependancies)
DeclareTestScheduleRoutes(apiAuthRoutes, dependancies)
DeclareUsersRoutes(apiAuthRoutes, dependancies, lc)
DeclareSessionRoutes(apiAuthRoutes, dependancies)
}

View file

@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(dependancies.ServiceUsecase()))
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
// Declare test result routes for service scope
DeclareScopedTestResultRoutes(apiZonesSubdomainServiceIdRoutes, dependancies, happydns.TestScopeService)
}

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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// testSchedulerProvider is an interface for getting the test scheduler from dependencies
type testSchedulerProvider interface {
TestScheduler() controller.TestSchedulerInterface
}
// DeclareScopedTestResultRoutes declares test result routes for a specific scope (domain, zone, or service)
func DeclareScopedTestResultRoutes(
scopedRouter *gin.RouterGroup,
dependancies happydns.UsecaseDependancies,
scope happydns.TestScopeType,
) {
testScheduler := dependancies.(testSchedulerProvider).TestScheduler()
tc := controller.NewTestResultController(
scope,
dependancies.TestPluginUsecase(),
dependancies.TestResultUsecase(),
dependancies.TestScheduleUsecase(),
testScheduler,
)
// List all available tests with their status
scopedRouter.GET("/tests", tc.ListAvailableTests)
// Test-specific routes
apiTestsRoutes := scopedRouter.Group("/tests/:tname")
{
// Get latest results for a test
apiTestsRoutes.GET("", tc.ListLatestTestResults)
// Trigger an on-demand test
apiTestsRoutes.POST("", tc.TriggerTest)
// Manage test plugin options at this scope
apiTestsRoutes.GET("/options", tc.GetTestPluginOptions)
apiTestsRoutes.POST("/options", tc.AddTestPluginOptions)
apiTestsRoutes.PUT("/options", tc.ChangeTestPluginOptions)
// Test execution routes
apiTestExecutionsRoutes := apiTestsRoutes.Group("/executions/:execution_id")
{
apiTestExecutionsRoutes.GET("", tc.GetTestExecutionStatus)
}
// Test results routes
apiTestsRoutes.GET("/results", tc.ListTestPluginResults)
apiTestsRoutes.DELETE("/results", tc.DropTestPluginResults)
apiTestResultsRoutes := apiTestsRoutes.Group("/results/:result_id")
{
apiTestResultsRoutes.GET("", tc.GetTestPluginResult)
apiTestResultsRoutes.DELETE("", tc.DropTestPluginResult)
}
}
}
// DeclareTestScheduleRoutes declares test schedule management routes
func DeclareTestScheduleRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
sc := controller.NewTestScheduleController(dependancies.TestScheduleUsecase())
schedulesRoutes := router.Group("/plugins/tests/schedules")
{
schedulesRoutes.GET("", sc.ListTestSchedules)
schedulesRoutes.POST("", sc.CreateTestSchedule)
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
{
scheduleRoutes.GET("", sc.GetTestSchedule)
scheduleRoutes.PUT("", sc.UpdateTestSchedule)
scheduleRoutes.DELETE("", sc.DeleteTestSchedule)
}
}
}

View file

@ -44,6 +44,7 @@ import (
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
testresultUC "git.happydns.org/happyDomain/internal/usecase/testresult"
userUC "git.happydns.org/happyDomain/internal/usecase/user"
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
@ -65,6 +66,8 @@ type Usecases struct {
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
testPlugin happydns.TestPluginUsecase
testResult happydns.TestResultUsecase
testSchedule happydns.TestScheduleUsecase
user happydns.UserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
@ -73,15 +76,16 @@ type Usecases struct {
}
type App struct {
cfg *happydns.Options
mailer *mailer.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
insights *insightsCollector
plugins happydns.PluginManager
store storage.Storage
usecases Usecases
cfg *happydns.Options
mailer *mailer.Mailer
newsletter happydns.NewsletterSubscriptor
router *gin.Engine
srv *http.Server
insights *insightsCollector
testScheduler happydns.AdminSchedulerUsecase
plugins happydns.PluginManager
store storage.Storage
usecases Usecases
}
func (a *App) AuthenticationUsecase() happydns.AuthenticationUsecase {
@ -144,6 +148,18 @@ func (a *App) TestPluginUsecase() happydns.TestPluginUsecase {
return a.usecases.testPlugin
}
func (a *App) TestResultUsecase() happydns.TestResultUsecase {
return a.usecases.testResult
}
func (a *App) TestScheduleUsecase() happydns.TestScheduleUsecase {
return a.usecases.testSchedule
}
func (a *App) TestScheduler() happydns.AdminSchedulerUsecase {
return a.testScheduler
}
func (a *App) UserUsecase() happydns.UserUsecase {
return a.usecases.user
}
@ -175,6 +191,7 @@ func NewApp(cfg *happydns.Options) *App {
app.initInsights()
app.initPlugins()
app.initUsecases()
app.initTestScheduler()
app.setupRouter()
return app
@ -190,6 +207,7 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initNewsletter()
app.initPlugins()
app.initUsecases()
app.initTestScheduler()
app.setupRouter()
return app
@ -252,6 +270,19 @@ func (app *App) initInsights() {
}
}
func (app *App) initTestScheduler() {
if !app.cfg.DisableScheduler {
app.testScheduler = newTestScheduler(
app.cfg,
app.store,
app.usecases.testPlugin,
)
} else {
// Use a disabled scheduler that returns clear errors
app.testScheduler = &disabledScheduler{}
}
}
func (app *App) initUsecases() {
sessionService := sessionUC.NewService(app.store)
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
@ -280,6 +311,8 @@ func (app *App) initUsecases() {
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
app.usecases.session = sessionService
app.usecases.testPlugin = pluginUC.NewTestPluginUsecase(app.cfg, app.plugins, app.store)
app.usecases.testResult = testresultUC.NewTestResultUsecase(app.store, app.cfg)
app.usecases.testSchedule = testresultUC.NewTestScheduleUsecase(app.store, app.cfg)
app.usecases.orchestrator = orchestrator.NewOrchestrator(
domainLogService,
@ -320,6 +353,11 @@ func (app *App) Start() {
go app.insights.Run()
}
// Start the test scheduler if it's the real implementation (not disabled)
if scheduler, ok := app.testScheduler.(*testScheduler); ok && scheduler != nil {
go scheduler.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)
@ -341,4 +379,9 @@ func (app *App) Stop() {
if app.insights != nil {
app.insights.Close()
}
// Close the test scheduler if it's the real implementation (not disabled)
if scheduler, ok := app.testScheduler.(*testScheduler); ok && scheduler != nil {
scheduler.Close()
}
}

View file

@ -0,0 +1,695 @@
// 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 (
"context"
"fmt"
"log"
"runtime"
"sync"
"time"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/internal/usecase/testresult"
"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
TestExecutionTimeout = 5 * time.Minute // Max time for a single test
MaxRetries = 3 // Max retry attempts for failed tests
)
// Priority levels for test execution queue
const (
PriorityOnDemand = iota // On-demand tests (highest priority)
PriorityOverdue // Overdue scheduled tests
PriorityScheduled // Regular scheduled tests
)
// testScheduler manages background test execution
type testScheduler struct {
cfg *happydns.Options
store storage.Storage
pluginUsecase happydns.TestPluginUsecase
resultUsecase *testresult.TestResultUsecase
scheduleUsecase *testresult.TestScheduleUsecase
stop chan bool
runNowChan chan *happydns.TestSchedule
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.TestExecution
cancel context.CancelFunc
startTime time.Time
}
// queueItem represents a test execution request in the queue
type queueItem struct {
schedule *happydns.TestSchedule
execution *happydns.TestExecution
priority int
queuedAt time.Time
retries int
}
// priorityQueue manages test execution queue with priority levels
type priorityQueue struct {
items []*queueItem
mu sync.Mutex
}
// newPriorityQueue creates a new priority queue
func newPriorityQueue() *priorityQueue {
return &priorityQueue{
items: make([]*queueItem, 0),
}
}
// Push adds an item to the queue
func (q *priorityQueue) Push(item *queueItem) {
q.mu.Lock()
defer q.mu.Unlock()
q.items = append(q.items, item)
// Sort by priority (lower number = higher priority)
// Within same priority, FIFO order
for i := len(q.items) - 1; i > 0; i-- {
if q.items[i].priority < q.items[i-1].priority {
q.items[i], q.items[i-1] = q.items[i-1], q.items[i]
} else {
break
}
}
}
// Pop removes and returns the highest priority item
func (q *priorityQueue) Pop() *queueItem {
q.mu.Lock()
defer q.mu.Unlock()
if len(q.items) == 0 {
return nil
}
item := q.items[0]
q.items = q.items[1:]
return item
}
// Len returns the queue length
func (q *priorityQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return len(q.items)
}
// worker processes tests from the queue
type worker struct {
id int
scheduler *testScheduler
stop chan bool
}
// disabledScheduler is a no-op implementation used when scheduler is disabled
type disabledScheduler struct{}
// TriggerOnDemandTest returns an error indicating the scheduler is disabled
func (d *disabledScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.PluginOptions) (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")
}
// RescheduleUpcomingTests returns an error since the scheduler is disabled
func (d *disabledScheduler) RescheduleUpcomingTests() (int, error) {
return 0, fmt.Errorf("test scheduler is disabled in configuration")
}
// newTestScheduler creates a new test scheduler
func newTestScheduler(
cfg *happydns.Options,
store storage.Storage,
pluginUsecase happydns.TestPluginUsecase,
) *testScheduler {
numWorkers := cfg.TestWorkers
if numWorkers <= 0 {
numWorkers = runtime.NumCPU()
}
scheduler := &testScheduler{
cfg: cfg,
store: store,
pluginUsecase: pluginUsecase,
resultUsecase: testresult.NewTestResultUsecase(store, cfg),
scheduleUsecase: testresult.NewTestScheduleUsecase(store, cfg),
stop: make(chan bool),
runNowChan: make(chan *happydns.TestSchedule, 100),
queue: newPriorityQueue(),
activeExecutions: make(map[string]*activeExecution),
workers: make([]*worker, numWorkers),
runtimeEnabled: true,
}
// Create workers
for i := 0; i < numWorkers; i++ {
scheduler.workers[i] = &worker{
id: i,
scheduler: scheduler,
stop: make(chan bool),
}
}
return scheduler
}
// Close stops the scheduler
func (s *testScheduler) Close() {
log.Println("Stopping test scheduler...")
// Stop the main loop
s.stop <- true
// Stop all workers
for _, w := range s.workers {
w.stop <- true
}
// Cancel all active executions
s.mu.Lock()
for _, exec := range s.activeExecutions {
exec.cancel()
}
s.mu.Unlock()
// Wait for all workers to finish
s.wg.Wait()
log.Println("Test scheduler stopped")
}
// Run starts the scheduler main loop
func (s *testScheduler) Run() {
if s.cfg.DisableScheduler {
log.Println("Test scheduler disabled by configuration")
return
}
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.RescheduleOverdueTests(); 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
s.discoverAndEnsureSchedules()
// Initial check
s.checkSchedules()
for {
select {
case <-checkTicker.C:
s.checkSchedules()
case <-cleanupTicker.C:
s.cleanup()
case <-discoveryTicker.C:
s.discoverAndEnsureSchedules()
case schedule := <-s.runNowChan:
s.queueOnDemandTest(schedule)
case <-s.stop:
return
}
}
}
// checkSchedules checks for due tests and queues them
func (s *testScheduler) 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.TestExecution{
ScheduleId: &schedule.Id,
PluginName: schedule.PluginName,
OwnerId: schedule.OwnerId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.TestExecutionPending,
StartedAt: time.Now(),
Options: schedule.Options,
}
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id, err)
continue
}
// Queue the test
s.queue.Push(&queueItem{
schedule: schedule,
execution: execution,
priority: priority,
queuedAt: now,
retries: 0,
})
}
// Mark scheduler run
if err := s.store.TestSchedulerRun(); err != nil {
log.Printf("Error marking scheduler run: %v\n", err)
}
}
// discoverAndEnsureSchedules creates default (enabled) schedules for all
// (plugin, target) pairs that don't yet have an explicit schedule record.
// This implements the opt-out model: tests run automatically unless a schedule
// with Enabled=false has been explicitly saved.
func (s *testScheduler) discoverAndEnsureSchedules() {
s.mu.RLock()
enabled := s.runtimeEnabled
s.mu.RUnlock()
if !enabled {
return
}
plugins, err := s.pluginUsecase.ListTestPlugins()
if err != nil {
log.Printf("Error listing test plugins for discovery: %v\n", err)
return
}
// Filter domain-level plugins
var domainPlugins []happydns.TestPlugin
for _, p := range plugins {
if p.Version().AvailableOn.ApplyToDomain {
domainPlugins = append(domainPlugins, p)
}
}
if len(domainPlugins) > 0 {
iter, err := s.store.ListAllDomains()
if err != nil {
log.Printf("Error listing domains for schedule discovery: %v\n", err)
} else {
defer iter.Close()
for iter.Next() {
domain := iter.Item()
if domain == nil {
continue
}
for _, plugin := range domainPlugins {
pluginName := plugin.Version().Name
schedules, err := s.scheduleUsecase.ListSchedulesByTarget(happydns.TestScopeDomain, domain.Id)
if err != nil {
continue
}
hasSchedule := false
for _, sched := range schedules {
if sched.PluginName == pluginName {
hasSchedule = true
break
}
}
if !hasSchedule {
if err := s.scheduleUsecase.CreateSchedule(&happydns.TestSchedule{
PluginName: pluginName,
OwnerId: domain.Owner,
TargetType: happydns.TestScopeDomain,
TargetId: domain.Id,
Enabled: true,
}); err != nil {
log.Printf("Error auto-creating schedule for domain %s / plugin %s: %v\n",
domain.Id, pluginName, err)
}
}
}
}
}
}
// Service-level plugin discovery is deferred: services live inside zones
// and enumeration would require iterating all zones across all domains.
// Services get auto-scheduled on their first explicit interaction instead.
}
// queueOnDemandTest queues an on-demand test execution
func (s *testScheduler) queueOnDemandTest(schedule *happydns.TestSchedule) {
execution := &happydns.TestExecution{
ScheduleId: nil, // On-demand has no schedule
PluginName: schedule.PluginName,
OwnerId: schedule.OwnerId,
TargetType: schedule.TargetType,
TargetId: schedule.TargetId,
Status: happydns.TestExecutionPending,
StartedAt: time.Now(),
Options: schedule.Options,
}
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
log.Printf("Error creating on-demand execution: %v\n", err)
return
}
s.queue.Push(&queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: time.Now(),
retries: 0,
})
}
// TriggerOnDemandTest triggers an immediate test execution
func (s *testScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error) {
// Create a temporary schedule for on-demand execution
schedule := &happydns.TestSchedule{
PluginName: pluginName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: 0, // On-demand, no interval
Enabled: true,
Options: options,
}
// Create execution record
execution := &happydns.TestExecution{
ScheduleId: nil,
PluginName: pluginName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Status: happydns.TestExecutionPending,
StartedAt: time.Now(),
Options: options,
}
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
return happydns.Identifier{}, err
}
// Queue with highest priority
s.queue.Push(&queueItem{
schedule: schedule,
execution: execution,
priority: PriorityOnDemand,
queuedAt: time.Now(),
retries: 0,
})
return execution.Id, nil
}
// GetSchedulerStatus returns a snapshot of the current scheduler state
func (s *testScheduler) 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 *testScheduler) SetEnabled(enabled bool) error {
s.mu.Lock()
s.runtimeEnabled = enabled
s.mu.Unlock()
return nil
}
// RescheduleUpcomingTests randomizes the next run time of all enabled schedules
// within their respective intervals, delegating to the schedule usecase.
func (s *testScheduler) RescheduleUpcomingTests() (int, error) {
return s.scheduleUsecase.RescheduleUpcomingTests()
}
// cleanup removes old execution records
func (s *testScheduler) cleanup() {
// This is a placeholder for cleanup logic
// In a full implementation, you'd clean up:
// - Old completed executions
// - Expired test results beyond retention
log.Println("Running scheduler cleanup...")
}
// worker.run processes tests from the queue
func (w *worker) run(wg *sync.WaitGroup) {
defer wg.Done()
log.Printf("Worker %d started\n", w.id)
for {
select {
case <-w.stop:
log.Printf("Worker %d stopped\n", w.id)
return
default:
// Try to get work from queue
item := w.scheduler.queue.Pop()
if item == nil {
// No work, sleep briefly
time.Sleep(1 * time.Second)
continue
}
// Execute the test
w.executeTest(item)
}
}
}
// executeTest runs a test plugin and stores the result
func (w *worker) executeTest(item *queueItem) {
ctx, cancel := context.WithTimeout(context.Background(), TestExecutionTimeout)
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.TestExecutionRunning
if err := w.scheduler.resultUsecase.UpdateTestExecution(execution); err != nil {
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailTestExecution(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 plugin
plugin, err := w.scheduler.pluginUsecase.GetTestPlugin(schedule.PluginName)
if err != nil {
errMsg := fmt.Sprintf("plugin not found: %s - %v", schedule.PluginName, err)
log.Printf("Worker %d: %s\n", w.id, errMsg)
_ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, errMsg)
return
}
// 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.PluginResult, 1)
errorChan := make(chan error, 1)
go func() {
result, err := plugin.RunTest(schedule.Options, meta)
if err != nil {
errorChan <- err
} else {
resultChan <- result
}
}()
// Wait for result or timeout
var pluginResult *happydns.PluginResult
var testErr error
select {
case pluginResult = <-resultChan:
// Test completed successfully
case testErr = <-errorChan:
// Test returned an error
case <-ctx.Done():
// Timeout
testErr = fmt.Errorf("test execution timeout after %v", TestExecutionTimeout)
}
duration := time.Since(startTime)
// Store the result
result := &happydns.TestResult{
PluginName: schedule.PluginName,
TestType: schedule.TargetType,
TargetId: schedule.TargetId,
OwnerId: schedule.OwnerId,
ExecutedAt: time.Now(),
ScheduledTest: item.execution.ScheduleId != nil,
Options: schedule.Options,
Duration: duration,
}
if testErr != nil {
result.Status = happydns.PluginResultStatusKO
result.StatusLine = "Test execution failed"
result.Error = testErr.Error()
} else if pluginResult != nil {
result.Status = pluginResult.Status
result.StatusLine = pluginResult.StatusLine
result.Report = pluginResult.Report
} else {
result.Status = happydns.PluginResultStatusKO
result.StatusLine = "Unknown error"
result.Error = "No result or error returned from plugin"
}
// Save the result
if err := w.scheduler.resultUsecase.CreateTestResult(result); err != nil {
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
_ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, err.Error())
return
}
// Complete the execution
if err := w.scheduler.resultUsecase.CompleteTestExecution(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.PluginName, schedule.TargetId, result.Status, duration)
}

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",
MaxResultsPerTest: 100,
ResultRetentionDays: 90,
TestWorkers: 2,
}
declareFlags(opts)

View file

@ -29,6 +29,7 @@ import (
"git.happydns.org/happyDomain/internal/usecase/plugin"
"git.happydns.org/happyDomain/internal/usecase/provider"
"git.happydns.org/happyDomain/internal/usecase/session"
"git.happydns.org/happyDomain/internal/usecase/testresult"
"git.happydns.org/happyDomain/internal/usecase/user"
"git.happydns.org/happyDomain/internal/usecase/zone"
"git.happydns.org/happyDomain/model"
@ -47,6 +48,7 @@ type Storage interface {
plugin.PluginStorage
provider.ProviderStorage
session.SessionStorage
testresult.TestResultStorage
user.UserStorage
zone.ZoneStorage

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"
)
// Test Result storage keys:
// testresult|{plugin-name}|{target-type}|{target-id}|{result-id}
func makeTestResultKey(pluginName string, targetType happydns.TestScopeType, targetId, resultId happydns.Identifier) string {
return fmt.Sprintf("testresult|%s|%d|%s|%s", pluginName, targetType, targetId.String(), resultId.String())
}
func makeTestResultPrefix(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) string {
return fmt.Sprintf("testresult|%s|%d|%s|", pluginName, targetType, targetId.String())
}
// ListTestResults retrieves test results for a specific plugin+target combination
func (s *KVStorage) ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
prefix := makeTestResultPrefix(pluginName, targetType, targetId)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
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
}
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
func (s *KVStorage) ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error) {
prefix := fmt.Sprintf("testresult|%s|", pluginName)
iter := s.db.Search(prefix)
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
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
}
// ListTestResultsByUser retrieves all test results for a user
func (s *KVStorage) ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
iter := s.db.Search("testresult|")
defer iter.Release()
var results []*happydns.TestResult
for iter.Next() {
var r happydns.TestResult
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
}
// GetTestResult retrieves a specific test result by its ID
func (s *KVStorage) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
var result happydns.TestResult
err := s.db.Get(key, &result)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestResultNotFound
}
return &result, err
}
// CreateTestResult stores a new test result
func (s *KVStorage) CreateTestResult(result *happydns.TestResult) error {
prefix := makeTestResultPrefix(result.PluginName, result.TestType, result.TargetId)
key, id, err := s.db.FindIdentifierKey(prefix)
if err != nil {
return err
}
result.Id = id
return s.db.Put(key, result)
}
// DeleteTestResult removes a specific test result
func (s *KVStorage) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
return s.db.Delete(key)
}
// DeleteOldTestResults removes old test results keeping only the most recent N results
func (s *KVStorage) DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error {
results, err := s.ListTestResults(pluginName, 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.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
}
return nil
}
// Test Schedule storage keys:
// testschedule|{schedule-id}
// testschedule.byuser|{user-id}|{schedule-id}
// testschedule.bytarget|{target-type}|{target-id}|{schedule-id}
func makeTestScheduleKey(scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule|%s", scheduleId.String())
}
func makeTestScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule.byuser|%s|%s", userId.String(), scheduleId.String())
}
func makeTestScheduleTargetIndexKey(targetType happydns.TestScopeType, targetId, scheduleId happydns.Identifier) string {
return fmt.Sprintf("testschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
}
// ListEnabledTestSchedules retrieves all enabled schedules
func (s *KVStorage) ListEnabledTestSchedules() ([]*happydns.TestSchedule, error) {
iter := s.db.Search("testschedule|")
defer iter.Release()
var schedules []*happydns.TestSchedule
for iter.Next() {
var sched happydns.TestSchedule
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
return nil, err
}
if sched.Enabled {
schedules = append(schedules, &sched)
}
}
return schedules, nil
}
// ListTestSchedulesByUser retrieves all schedules for a specific user
func (s *KVStorage) ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
prefix := fmt.Sprintf("testschedule.byuser|%s|", userId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.TestSchedule
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.TestSchedule
schedKey := makeTestScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// ListTestSchedulesByTarget retrieves all schedules for a specific target
func (s *KVStorage) ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
prefix := fmt.Sprintf("testschedule.bytarget|%d|%s|", targetType, targetId.String())
iter := s.db.Search(prefix)
defer iter.Release()
var schedules []*happydns.TestSchedule
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.TestSchedule
schedKey := makeTestScheduleKey(scheduleId)
if err := s.db.Get(schedKey, &sched); err != nil {
continue
}
schedules = append(schedules, &sched)
}
return schedules, nil
}
// GetTestSchedule retrieves a specific schedule by ID
func (s *KVStorage) GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
key := makeTestScheduleKey(scheduleId)
var schedule happydns.TestSchedule
err := s.db.Get(key, &schedule)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestScheduleNotFound
}
return &schedule, err
}
// CreateTestSchedule creates a new test schedule
func (s *KVStorage) CreateTestSchedule(schedule *happydns.TestSchedule) error {
key, id, err := s.db.FindIdentifierKey("testschedule|")
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 := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
return err
}
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
return err
}
return nil
}
// UpdateTestSchedule updates an existing schedule
func (s *KVStorage) UpdateTestSchedule(schedule *happydns.TestSchedule) error {
key := makeTestScheduleKey(schedule.Id)
return s.db.Put(key, schedule)
}
// DeleteTestSchedule removes a schedule and its indexes
func (s *KVStorage) DeleteTestSchedule(scheduleId happydns.Identifier) error {
// Get the schedule first to know what indexes to delete
schedule, err := s.GetTestSchedule(scheduleId)
if err != nil {
return err
}
// Delete indexes
userIndexKey := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
if err := s.db.Delete(userIndexKey); err != nil {
return err
}
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
if err := s.db.Delete(targetIndexKey); err != nil {
return err
}
// Delete the schedule itself
key := makeTestScheduleKey(scheduleId)
return s.db.Delete(key)
}
// Test Execution storage keys:
// testexec|{execution-id}
func makeTestExecutionKey(executionId happydns.Identifier) string {
return fmt.Sprintf("testexec|%s", executionId.String())
}
// ListActiveTestExecutions retrieves all executions that are pending or running
func (s *KVStorage) ListActiveTestExecutions() ([]*happydns.TestExecution, error) {
iter := s.db.Search("testexec|")
defer iter.Release()
var executions []*happydns.TestExecution
for iter.Next() {
var exec happydns.TestExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
return nil, err
}
if exec.Status == happydns.TestExecutionPending || exec.Status == happydns.TestExecutionRunning {
executions = append(executions, &exec)
}
}
return executions, nil
}
// GetTestExecution retrieves a specific execution by ID
func (s *KVStorage) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
key := makeTestExecutionKey(executionId)
var execution happydns.TestExecution
err := s.db.Get(key, &execution)
if errors.Is(err, happydns.ErrNotFound) {
return nil, happydns.ErrTestExecutionNotFound
}
return &execution, err
}
// CreateTestExecution creates a new test execution record
func (s *KVStorage) CreateTestExecution(execution *happydns.TestExecution) error {
key, id, err := s.db.FindIdentifierKey("testexec|")
if err != nil {
return err
}
execution.Id = id
return s.db.Put(key, execution)
}
// UpdateTestExecution updates an existing execution record
func (s *KVStorage) UpdateTestExecution(execution *happydns.TestExecution) error {
key := makeTestExecutionKey(execution.Id)
return s.db.Put(key, execution)
}
// DeleteTestExecution removes an execution record
func (s *KVStorage) DeleteTestExecution(executionId happydns.Identifier) error {
key := makeTestExecutionKey(executionId)
return s.db.Delete(key)
}
// Scheduler state storage key:
// testscheduler.lastrun
// TestSchedulerRun marks that the scheduler has run at current time
func (s *KVStorage) TestSchedulerRun() error {
now := time.Now()
return s.db.Put("testscheduler.lastrun", &now)
}
// LastTestSchedulerRun retrieves the last time the scheduler ran
func (s *KVStorage) LastTestSchedulerRun() (*time.Time, error) {
var lastRun time.Time
err := s.db.Get("testscheduler.lastrun", &lastRun)
if errors.Is(err, happydns.ErrNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &lastRun, nil
}

View file

@ -23,6 +23,7 @@ package plugin
import (
"fmt"
"maps"
"sort"
"git.happydns.org/happyDomain/model"
@ -103,9 +104,7 @@ func (tu *testPluginUsecase) GetTestPluginOptions(pname string, userid *happydns
opts := make(happydns.PluginOptions)
for _, c := range configs {
for k, v := range c.Options {
opts[k] = v
}
maps.Copy(opts, c.Options)
}
return &opts, nil
@ -116,7 +115,40 @@ func (tu *testPluginUsecase) ListTestPlugins() ([]happydns.TestPlugin, error) {
}
func (tu *testPluginUsecase) SetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, opts)
// filter opts that correspond to the level set
plugin, err := tu.GetTestPlugin(pname)
if err != nil {
return fmt.Errorf("unable to get test plugin: %w", err)
}
var optNames []string
if serviceid != nil {
for _, opt := range plugin.AvailableOptions().ServiceOpts {
optNames = append(optNames, opt.Id)
}
} else if domainid != nil {
for _, opt := range plugin.AvailableOptions().DomainOpts {
optNames = append(optNames, opt.Id)
}
} else if userid != nil {
for _, opt := range plugin.AvailableOptions().UserOpts {
optNames = append(optNames, opt.Id)
}
} else {
for _, opt := range plugin.AvailableOptions().AdminOpts {
optNames = append(optNames, opt.Id)
}
}
// Filter opts to only include keys that are in optNames
filteredOpts := make(happydns.PluginOptions)
for _, optName := range optNames {
if val, exists := opts[optName]; exists && val != "" {
filteredOpts[optName] = val
}
}
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, filteredOpts)
}
func (tu *testPluginUsecase) OverwriteSomeTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
@ -125,9 +157,7 @@ func (tu *testPluginUsecase) OverwriteSomeTestPluginOptions(pname string, userid
return err
}
for k, v := range opts {
(*current)[k] = v
}
maps.Copy(*current, opts)
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, *current)
}

View file

@ -0,0 +1,98 @@
// 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 testresult
import (
"time"
"git.happydns.org/happyDomain/model"
)
// TestResultStorage defines the storage interface for test results and related data
type TestResultStorage interface {
// Test Results
// ListTestResults retrieves test results for a specific plugin+target combination
ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error)
// ListTestResultsByUser retrieves all test results for a user
ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
// GetTestResult retrieves a specific test result by its ID
GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error)
// CreateTestResult stores a new test result
CreateTestResult(result *happydns.TestResult) error
// DeleteTestResult removes a specific test result
DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
// DeleteOldTestResults removes old test results keeping only the most recent N results
DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error
// Test Schedules
// ListEnabledTestSchedules retrieves all enabled schedules (for scheduler)
ListEnabledTestSchedules() ([]*happydns.TestSchedule, error)
// ListTestSchedulesByUser retrieves all schedules for a specific user
ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error)
// ListTestSchedulesByTarget retrieves all schedules for a specific target
ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error)
// GetTestSchedule retrieves a specific schedule by ID
GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error)
// CreateTestSchedule creates a new test schedule
CreateTestSchedule(schedule *happydns.TestSchedule) error
// UpdateTestSchedule updates an existing schedule
UpdateTestSchedule(schedule *happydns.TestSchedule) error
// DeleteTestSchedule removes a schedule
DeleteTestSchedule(scheduleId happydns.Identifier) error
// Test Executions
// ListActiveTestExecutions retrieves all executions that are pending or running
ListActiveTestExecutions() ([]*happydns.TestExecution, error)
// GetTestExecution retrieves a specific execution by ID
GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error)
// CreateTestExecution creates a new test execution record
CreateTestExecution(execution *happydns.TestExecution) error
// UpdateTestExecution updates an existing execution record
UpdateTestExecution(execution *happydns.TestExecution) error
// DeleteTestExecution removes an execution record
DeleteTestExecution(executionId happydns.Identifier) error
// Scheduler State
// TestSchedulerRun marks that the scheduler has run at current time
TestSchedulerRun() error
// LastTestSchedulerRun retrieves the last time the scheduler ran
LastTestSchedulerRun() (*time.Time, error)
}

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 testresult
import (
"fmt"
"time"
"git.happydns.org/happyDomain/model"
)
// TestResultUsecase implements business logic for test results
type TestResultUsecase struct {
storage TestResultStorage
options *happydns.Options
}
// NewTestResultUsecase creates a new test result usecase
func NewTestResultUsecase(storage TestResultStorage, options *happydns.Options) *TestResultUsecase {
return &TestResultUsecase{
storage: storage,
options: options,
}
}
// ListTestResultsByTarget retrieves test results for a specific target
func (u *TestResultUsecase) ListTestResultsByTarget(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
// Apply default limit if not specified
if limit <= 0 {
limit = 5 // Default to 5 most recent results
}
return u.storage.ListTestResults(pluginName, targetType, targetId, limit)
}
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
func (u *TestResultUsecase) ListAllTestResultsByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
// Get all results for the user and filter by target
allResults, err := u.storage.ListTestResultsByUser(userId, 0)
if err != nil {
return nil, err
}
// Filter by target
var results []*happydns.TestResult
for _, r := range allResults {
if r.TestType == targetType && r.TargetId.Equals(targetId) {
results = append(results, r)
}
}
// Apply limit
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results, nil
}
// GetTestResult retrieves a specific test result
func (u *TestResultUsecase) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
return u.storage.GetTestResult(pluginName, targetType, targetId, resultId)
}
// CreateTestResult stores a new test result and enforces retention policy
func (u *TestResultUsecase) CreateTestResult(result *happydns.TestResult) error {
// Store the result
if err := u.storage.CreateTestResult(result); err != nil {
return err
}
// Enforce retention policy
maxResults := u.options.MaxResultsPerTest
if maxResults <= 0 {
maxResults = 100 // Default
}
return u.storage.DeleteOldTestResults(result.PluginName, result.TestType, result.TargetId, maxResults)
}
// DeleteTestResult removes a specific test result
func (u *TestResultUsecase) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
return u.storage.DeleteTestResult(pluginName, targetType, targetId, resultId)
}
// DeleteAllTestResults removes all results for a specific plugin+target combination
func (u *TestResultUsecase) DeleteAllTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) error {
// Get all results first
results, err := u.storage.ListTestResults(pluginName, targetType, targetId, 0)
if err != nil {
return err
}
// Delete each result
for _, r := range results {
if err := u.storage.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
return err
}
}
return nil
}
// CleanupOldResults removes test results older than retention period
func (u *TestResultUsecase) 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
}
// GetTestExecution retrieves the status of a test execution
func (u *TestResultUsecase) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
return u.storage.GetTestExecution(executionId)
}
// CreateTestExecution creates a new test execution record
func (u *TestResultUsecase) CreateTestExecution(execution *happydns.TestExecution) error {
if execution.StartedAt.IsZero() {
execution.StartedAt = time.Now()
}
return u.storage.CreateTestExecution(execution)
}
// UpdateTestExecution updates an existing test execution
func (u *TestResultUsecase) UpdateTestExecution(execution *happydns.TestExecution) error {
return u.storage.UpdateTestExecution(execution)
}
// CompleteTestExecution marks an execution as completed with a result
func (u *TestResultUsecase) CompleteTestExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
execution, err := u.storage.GetTestExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.TestExecutionCompleted
execution.CompletedAt = &now
execution.ResultId = &resultId
return u.storage.UpdateTestExecution(execution)
}
// FailTestExecution marks an execution as failed
func (u *TestResultUsecase) FailTestExecution(executionId happydns.Identifier, errorMsg string) error {
execution, err := u.storage.GetTestExecution(executionId)
if err != nil {
return err
}
now := time.Now()
execution.Status = happydns.TestExecutionFailed
execution.CompletedAt = &now
// Store error in a result
result := &happydns.TestResult{
PluginName: execution.PluginName,
TestType: execution.TargetType,
TargetId: execution.TargetId,
OwnerId: execution.OwnerId,
ExecutedAt: time.Now(),
ScheduledTest: execution.ScheduleId != nil,
Options: execution.Options,
Status: happydns.PluginResultStatusKO,
StatusLine: "Execution failed",
Error: errorMsg,
Duration: now.Sub(execution.StartedAt),
}
if err := u.CreateTestResult(result); err != nil {
return fmt.Errorf("failed to create error result: %w", err)
}
execution.ResultId = &result.Id
return u.storage.UpdateTestExecution(execution)
}
// DeleteCompletedExecutions removes execution records that are completed
func (u *TestResultUsecase) 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,367 @@
// 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 testresult
import (
"fmt"
"math/rand"
"sort"
"time"
"git.happydns.org/happyDomain/model"
)
const (
// Default test intervals
DefaultUserTestInterval = 4 * time.Hour // 4 hours for domain tests
DefaultDomainTestInterval = 24 * time.Hour // 24 hours for domain tests
DefaultServiceTestInterval = 1 * time.Hour // 1 hour for service tests
MinimumTestInterval = 5 * time.Minute // Minimum interval allowed
)
// TestScheduleUsecase implements business logic for test schedules
type TestScheduleUsecase struct {
storage TestResultStorage
options *happydns.Options
}
// NewTestScheduleUsecase creates a new test schedule usecase
func NewTestScheduleUsecase(storage TestResultStorage, options *happydns.Options) *TestScheduleUsecase {
return &TestScheduleUsecase{
storage: storage,
options: options,
}
}
// ListUserSchedules retrieves all schedules for a specific user
func (u *TestScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
return u.storage.ListTestSchedulesByUser(userId)
}
// ListSchedulesByTarget retrieves all schedules for a specific target
func (u *TestScheduleUsecase) ListSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
return u.storage.ListTestSchedulesByTarget(targetType, targetId)
}
// GetSchedule retrieves a specific schedule by ID
func (u *TestScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
return u.storage.GetTestSchedule(scheduleId)
}
// CreateSchedule creates a new test schedule with validation
func (u *TestScheduleUsecase) CreateSchedule(schedule *happydns.TestSchedule) error {
// Set default interval if not specified
if schedule.Interval == 0 {
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
}
// Validate interval
if schedule.Interval < MinimumTestInterval {
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
}
// 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.CreateTestSchedule(schedule)
}
// UpdateSchedule updates an existing schedule
func (u *TestScheduleUsecase) UpdateSchedule(schedule *happydns.TestSchedule) error {
// Validate interval
if schedule.Interval < MinimumTestInterval {
return fmt.Errorf("test interval must be at least %v", MinimumTestInterval)
}
// Get existing schedule to preserve certain fields
existing, err := u.storage.GetTestSchedule(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.UpdateTestSchedule(schedule)
}
// DeleteSchedule removes a schedule
func (u *TestScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
return u.storage.DeleteTestSchedule(scheduleId)
}
// EnableSchedule enables a schedule
func (u *TestScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(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.UpdateTestSchedule(schedule)
}
// DisableSchedule disables a schedule
func (u *TestScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
schedule.Enabled = false
return u.storage.UpdateTestSchedule(schedule)
}
// UpdateScheduleAfterRun updates a schedule after it has been executed
func (u *TestScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(scheduleId)
if err != nil {
return err
}
now := time.Now()
schedule.LastRun = &now
schedule.NextRun = now.Add(schedule.Interval)
return u.storage.UpdateTestSchedule(schedule)
}
// ListDueSchedules retrieves all enabled schedules that are due to run
func (u *TestScheduleUsecase) ListDueSchedules() ([]*happydns.TestSchedule, error) {
schedules, err := u.storage.ListEnabledTestSchedules()
if err != nil {
return nil, err
}
now := time.Now()
var dueSchedules []*happydns.TestSchedule
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 *TestScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.TestSchedule, error) {
schedules, err := u.storage.ListEnabledTestSchedules()
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 test interval based on target type
func (u *TestScheduleUsecase) getDefaultInterval(targetType happydns.TestScopeType) time.Duration {
switch targetType {
case happydns.TestScopeUser:
return DefaultUserTestInterval
case happydns.TestScopeDomain:
return DefaultDomainTestInterval
case happydns.TestScopeService:
return DefaultServiceTestInterval
default:
return DefaultDomainTestInterval
}
}
// MergePluginOptions merges plugin options from different scopes
// Priority: schedule options > domain options > user options > global options
func (u *TestScheduleUsecase) MergePluginOptions(
globalOpts happydns.PluginOptions,
userOpts happydns.PluginOptions,
domainOpts happydns.PluginOptions,
scheduleOpts happydns.PluginOptions,
) happydns.PluginOptions {
merged := make(happydns.PluginOptions)
// Start with global options
for k, v := range globalOpts {
merged[k] = v
}
// Override with user options
for k, v := range userOpts {
merged[k] = v
}
// Override with domain options
for k, v := range domainOpts {
merged[k] = v
}
// Override with schedule options (highest priority)
for k, v := range scheduleOpts {
merged[k] = v
}
return merged
}
// ValidateScheduleOwnership checks if a user owns a schedule
func (u *TestScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
schedule, err := u.storage.GetTestSchedule(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 *TestScheduleUsecase) CreateDefaultSchedulesForTarget(
pluginName string,
targetType happydns.TestScopeType,
targetId happydns.Identifier,
ownerId happydns.Identifier,
enabled bool,
) error {
schedule := &happydns.TestSchedule{
PluginName: pluginName,
OwnerId: ownerId,
TargetType: targetType,
TargetId: targetId,
Interval: u.getDefaultInterval(targetType),
Enabled: enabled,
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
Options: make(happydns.PluginOptions),
}
return u.CreateSchedule(schedule)
}
// rescheduleTests reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)].
func (u *TestScheduleUsecase) rescheduleTests(schedules []*happydns.TestSchedule, maxOffsetFn func(*happydns.TestSchedule) 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.UpdateTestSchedule(schedule); err != nil {
return count, err
}
count++
}
return count, nil
}
// RescheduleUpcomingTests randomizes the next run time of all enabled schedules
// within their respective intervals to spread load evenly. Useful after a restart.
func (u *TestScheduleUsecase) RescheduleUpcomingTests() (int, error) {
schedules, err := u.storage.ListEnabledTestSchedules()
if err != nil {
return 0, err
}
return u.rescheduleTests(schedules, func(s *happydns.TestSchedule) time.Duration {
return s.Interval
})
}
// RescheduleOverdueTests reschedules tests 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).
func (u *TestScheduleUsecase) RescheduleOverdueTests() (int, error) {
schedules, err := u.storage.ListEnabledTestSchedules()
if err != nil {
return 0, err
}
now := time.Now()
var overdue []*happydns.TestSchedule
for _, s := range schedules {
if s.NextRun.Before(now) {
overdue = append(overdue, s)
}
}
if len(overdue) == 0 {
return 0, nil
}
// Spread overdue tests over a small window proportional to their count,
// capped at MinimumTestInterval, to prevent all of them from running at once.
spreadWindow := time.Duration(len(overdue)) * 5 * time.Second
if spreadWindow > MinimumTestInterval {
spreadWindow = MinimumTestInterval
}
return u.rescheduleTests(overdue, func(s *happydns.TestSchedule) time.Duration {
return spreadWindow
})
}
// DeleteSchedulesForTarget removes all schedules for a target
func (u *TestScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) error {
schedules, err := u.storage.ListTestSchedulesByTarget(targetType, targetId)
if err != nil {
return err
}
for _, schedule := range schedules {
if err := u.storage.DeleteTestSchedule(schedule.Id); err != nil {
return err
}
}
return nil
}

View file

@ -94,6 +94,18 @@ type Options struct {
OIDCClients []OIDCSettings
PluginsDirectories []string
// MaxResultsPerTest is the maximum number of test results to keep per plugin+target combination
MaxResultsPerTest 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")
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")
ErrTestExecutionNotFound = errors.New("test execution not found")
ErrTestResultNotFound = errors.New("test result not found")
ErrTestScheduleNotFound = errors.New("test schedule 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."

306
model/test_result.go Normal file
View file

@ -0,0 +1,306 @@
// 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"
)
// TestScopeType represents the scope level at which a test is performed
type TestScopeType int
const (
TestScopeInstance TestScopeType = iota
TestScopeUser
TestScopeDomain
TestScopeService
TestScopeOnDemand
)
// String returns a string representation of the test scope type
func (t TestScopeType) String() string {
switch t {
case TestScopeInstance:
return "instance"
case TestScopeUser:
return "user"
case TestScopeDomain:
return "domain"
case TestScopeService:
return "service"
case TestScopeOnDemand:
return "ondemand"
default:
return "unknown"
}
}
// TestExecutionStatus represents the current state of a test execution
type TestExecutionStatus int
const (
TestExecutionPending TestExecutionStatus = iota
TestExecutionRunning
TestExecutionCompleted
TestExecutionFailed
)
// String returns a string representation of the test execution status
func (t TestExecutionStatus) String() string {
switch t {
case TestExecutionPending:
return "pending"
case TestExecutionRunning:
return "running"
case TestExecutionCompleted:
return "completed"
case TestExecutionFailed:
return "failed"
default:
return "unknown"
}
}
// TestResult stores the result of a test execution
type TestResult struct {
// Id is the unique identifier for this test result
Id Identifier `json:"id" swaggertype:"string"`
// PluginName identifies which test plugin was executed
PluginName string `json:"plugin_name"`
// TestType indicates the scope level of the test
TestType TestScopeType `json:"test_type"`
// TargetId is the identifier of the target (User/Domain/Service)
TargetId Identifier `json:"target_id" swaggertype:"string"`
// OwnerId is the owner of the test
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// ExecutedAt is when the test was executed
ExecutedAt time.Time `json:"executed_at"`
// ScheduledTest indicates if this was a scheduled (true) or on-demand (false) test
ScheduledTest bool `json:"scheduled_test"`
// Options contains the merged plugin configuration used for this test
Options PluginOptions `json:"options,omitempty"`
// Status is the overall test result status
Status PluginResultStatus `json:"status"`
// StatusLine is a summary message of the test result
StatusLine string `json:"status_line"`
// Report contains the full test report (plugin-specific structure)
Report interface{} `json:"report,omitempty"`
// Duration is how long the test took to execute
Duration time.Duration `json:"duration" swaggertype:"integer"`
// Error contains any error message if the execution failed
Error string `json:"error,omitempty"`
}
// TestSchedule defines a recurring test schedule
type TestSchedule struct {
// Id is the unique identifier for this schedule
Id Identifier `json:"id" swaggertype:"string"`
// PluginName identifies which test plugin to execute
PluginName string `json:"plugin_name"`
// OwnerId is the owner of the schedule
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates what type of target to test
TargetType TestScopeType `json:"target_type"`
// TargetId is the identifier of the target to test
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Interval is how often to run the test
Interval time.Duration `json:"interval" swaggertype:"integer"`
// Enabled indicates if the schedule is active
Enabled bool `json:"enabled"`
// LastRun is when the test was last executed (nil if never run)
LastRun *time.Time `json:"last_run,omitempty"`
// NextRun is when the test should next be executed
NextRun time.Time `json:"next_run"`
// Options contains plugin-specific configuration
Options PluginOptions `json:"options,omitempty"`
}
// TestExecution tracks an in-progress or completed test execution
type TestExecution 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"`
// PluginName identifies which test plugin is being executed
PluginName string `json:"plugin_name"`
// OwnerId is the owner of the test
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
// TargetType indicates the scope level of the test
TargetType TestScopeType `json:"target_type"`
// TargetId is the identifier of the target being tested
TargetId Identifier `json:"target_id" swaggertype:"string"`
// Status is the current execution status
Status TestExecutionStatus `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 TestResult (nil if execution not completed)
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
// Options contains the plugin configuration for this execution
Options PluginOptions `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 tests currently being executed
ActiveCount int `json:"active_count"`
// NextSchedules contains the upcoming scheduled tests sorted by next run time
NextSchedules []*TestSchedule `json:"next_schedules"`
}
// TestResultUsecase defines business logic for test results
type TestResultUsecase interface {
// ListTestResultsByTarget retrieves test results for a specific target
ListTestResultsByTarget(pluginName string, targetType TestScopeType, targetId Identifier, limit int) ([]*TestResult, error)
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
ListAllTestResultsByTarget(targetType TestScopeType, targetId Identifier, userId Identifier, limit int) ([]*TestResult, error)
// GetTestResult retrieves a specific test result
GetTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) (*TestResult, error)
// CreateTestResult stores a new test result and enforces retention policy
CreateTestResult(result *TestResult) error
// DeleteTestResult removes a specific test result
DeleteTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) error
// DeleteAllTestResults removes all results for a specific plugin+target combination
DeleteAllTestResults(pluginName string, targetType TestScopeType, targetId Identifier) error
// GetTestExecution retrieves the status of a test execution
GetTestExecution(executionId Identifier) (*TestExecution, error)
// CreateTestExecution creates a new test execution record
CreateTestExecution(execution *TestExecution) error
// UpdateTestExecution updates an existing test execution
UpdateTestExecution(execution *TestExecution) error
// CompleteTestExecution marks an execution as completed with a result
CompleteTestExecution(executionId Identifier, resultId Identifier) error
// FailTestExecution marks an execution as failed
FailTestExecution(executionId Identifier, errorMsg string) error
}
// TestScheduleUsecase defines business logic for test schedules
type TestScheduleUsecase interface {
// ListUserSchedules retrieves all schedules for a specific user
ListUserSchedules(userId Identifier) ([]*TestSchedule, error)
// ListSchedulesByTarget retrieves all schedules for a specific target
ListSchedulesByTarget(targetType TestScopeType, targetId Identifier) ([]*TestSchedule, error)
// GetSchedule retrieves a specific schedule by ID
GetSchedule(scheduleId Identifier) (*TestSchedule, error)
// CreateSchedule creates a new test schedule with validation
CreateSchedule(schedule *TestSchedule) error
// UpdateSchedule updates an existing schedule
UpdateSchedule(schedule *TestSchedule) 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() ([]*TestSchedule, error)
// ValidateScheduleOwnership checks if a user owns a schedule
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
// DeleteSchedulesForTarget removes all schedules for a target
DeleteSchedulesForTarget(targetType TestScopeType, targetId Identifier) error
// RescheduleUpcomingTests randomizes next run times for all enabled schedules
// within their respective intervals to spread load evenly.
RescheduleUpcomingTests() (int, error)
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
// short window to avoid scheduler famine after a suspend or server restart.
RescheduleOverdueTests() (int, error)
}
// AdminSchedulerUsecase is satisfied by both testScheduler and disabledScheduler
type AdminSchedulerUsecase interface {
GetSchedulerStatus() SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingTests() (int, error)
}

View file

@ -35,6 +35,9 @@ type UsecaseDependancies interface {
ServiceSpecsUsecase() ServiceSpecsUsecase
SessionUsecase() SessionUsecase
TestPluginUsecase() TestPluginUsecase
TestResultUsecase() TestResultUsecase
TestScheduleUsecase() TestScheduleUsecase
TestScheduler() AdminSchedulerUsecase
UserUsecase() UserUsecase
ZoneCorrectionApplierUsecase() ZoneCorrectionApplierUsecase
ZoneImporterUsecase() ZoneImporterUsecase

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 {

View file

@ -104,6 +104,9 @@
<NavItem>
<NavLink href="/plugins" active={page && page.url.pathname.startsWith('/plugins')}>Plugins</NavLink>
</NavItem>
<NavItem>
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
</NavItem>
</Nav>
</Collapse>
</Navbar>

View file

@ -0,0 +1,334 @@
<!--
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 TestSchedule {
id: string;
plugin_name: string;
user_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: TestSchedule[] | 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 tests" });
} 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 Tests
</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 Tests -->
<Card>
<CardHeader>
<Icon name="calendar-event-fill"></Icon>
Upcoming Scheduled Tests
{#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 tests
</td>
</tr>
{:else}
{#each status.next_schedules as schedule}
<tr>
<td><strong>{schedule.plugin_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>

190
web/src/lib/api/tests.ts Normal file
View file

@ -0,0 +1,190 @@
// 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 { PostDomainsByDomainTestsByTnameResponse } from "$lib/api-base/types.gen";
import {
getDomainsByDomainTests,
getDomainsByDomainTestsByTname,
postDomainsByDomainTestsByTname,
getDomainsByDomainTestsByTnameExecutionsByExecutionId,
getDomainsByDomainTestsByTnameOptions,
putDomainsByDomainTestsByTnameOptions,
getDomainsByDomainTestsByTnameResults,
getDomainsByDomainTestsByTnameResultsByResultId,
deleteDomainsByDomainTestsByTnameResultsByResultId,
deleteDomainsByDomainTestsByTnameResults,
getPluginsTestsSchedules,
getPluginsTestsSchedulesByScheduleId,
postPluginsTestsSchedules,
putPluginsTestsSchedulesByScheduleId,
deletePluginsTestsSchedulesByScheduleId,
} from "$lib/api-base/sdk.gen";
import type {
TestResult,
TestExecution,
TestSchedule,
AvailableTest,
CreateScheduleRequest,
} from "$lib/model/test";
import type { PluginOptions } from "$lib/model/plugin";
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
// Domain test operations
export async function listAvailableTests(domainId: string): Promise<AvailableTest[]> {
return unwrapSdkResponse(
await getDomainsByDomainTests({ path: { domain: domainId } }),
) as unknown as AvailableTest[];
}
export async function listTestResults(
domainId: string,
testName: string,
limit?: number,
): Promise<TestResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameResults({
path: { domain: domainId, tname: testName },
query: limit !== undefined ? { limit } : undefined,
}),
) as TestResult[];
}
export async function getLatestTestResults(
domainId: string,
testName: string,
): Promise<TestResult[]> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTname({ path: { domain: domainId, tname: testName } }),
) as TestResult[];
}
export async function triggerTest(
domainId: string,
testName: string,
options?: PluginOptions,
): Promise<PostDomainsByDomainTestsByTnameResponse> {
return unwrapSdkResponse(
await postDomainsByDomainTestsByTname({
path: { domain: domainId, tname: testName },
body: { options } as any,
}),
) as PostDomainsByDomainTestsByTnameResponse;
}
export async function getTestExecution(
domainId: string,
testName: string,
executionId: string,
): Promise<TestExecution> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameExecutionsByExecutionId({
path: { domain: domainId, tname: testName, execution_id: executionId },
}),
) as TestExecution;
}
export async function getTestResult(
domainId: string,
testName: string,
resultId: string,
): Promise<TestResult> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameResultsByResultId({
path: { domain: domainId, tname: testName, result_id: resultId },
}),
) as TestResult;
}
export async function deleteTestResult(
domainId: string,
testName: string,
resultId: string,
): Promise<void> {
unwrapEmptyResponse(
await deleteDomainsByDomainTestsByTnameResultsByResultId({
path: { domain: domainId, tname: testName, result_id: resultId },
}),
);
}
export async function deleteAllTestResults(domainId: string, testName: string): Promise<void> {
unwrapEmptyResponse(
await deleteDomainsByDomainTestsByTnameResults({
path: { domain: domainId, tname: testName },
}),
);
}
export async function getTestOptions(domainId: string, testName: string): Promise<PluginOptions> {
return unwrapSdkResponse(
await getDomainsByDomainTestsByTnameOptions({
path: { domain: domainId, tname: testName },
}),
) as PluginOptions;
}
export async function updateTestOptions(
domainId: string,
testName: string,
options: PluginOptions,
): Promise<void> {
unwrapEmptyResponse(
await putDomainsByDomainTestsByTnameOptions({
path: { domain: domainId, tname: testName },
body: { options } as any,
}),
);
}
// Schedule operations
export async function listUserSchedules(): Promise<TestSchedule[]> {
return unwrapSdkResponse(await getPluginsTestsSchedules()) as TestSchedule[];
}
export async function getTestSchedule(scheduleId: string): Promise<TestSchedule> {
return unwrapSdkResponse(
await getPluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
) as TestSchedule;
}
export async function createTestSchedule(schedule: CreateScheduleRequest): Promise<TestSchedule> {
return unwrapSdkResponse(
await postPluginsTestsSchedules({ body: schedule as any }),
) as TestSchedule;
}
export async function updateTestSchedule(
scheduleId: string,
schedule: Partial<TestSchedule>,
): Promise<void> {
unwrapEmptyResponse(
await putPluginsTestsSchedulesByScheduleId({
path: { schedule_id: scheduleId },
body: schedule as any,
}),
);
}
export async function deleteTestSchedule(scheduleId: string): Promise<void> {
unwrapEmptyResponse(
await deletePluginsTestsSchedulesByScheduleId({ path: { schedule_id: scheduleId } }),
);
}

View file

@ -0,0 +1,172 @@
<!--
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 { triggerTest, getTestOptions } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { PluginOptions } from "$lib/model/plugin";
import Resource from "$lib/components/inputs/Resource.svelte";
import { toasts } from "$lib/stores/toasts";
import { t } from "$lib/translations";
interface Props {
domainId: string;
onTestTriggered?: (execution_id: string, plugin_name: string) => void;
}
let { domainId, onTestTriggered }: Props = $props();
let isOpen = $state(false);
let pluginName = $state<string>("");
let pluginDisplayName = $state<string>("");
let pluginStatusPromise = $state<Promise<any> | null>(null);
let domainOptionsPromise = $state<Promise<PluginOptions> | null>(null);
let runOptions = $state<Record<string, any>>({});
let triggering = $state(false);
const toggle = () => (isOpen = !isOpen);
export function open(testPluginName: string, testDisplayName: string) {
pluginName = testPluginName;
pluginDisplayName = testDisplayName;
runOptions = {};
pluginStatusPromise = getPluginStatus(testPluginName);
domainOptionsPromise = getTestOptions(domainId, testPluginName);
isOpen = true;
// Pre-populate with domain options when they load
domainOptionsPromise.then((options) => {
runOptions = { ...(options || {}) };
});
}
async function handleRunTest() {
triggering = true;
try {
const result = await triggerTest(domainId, pluginName, runOptions);
toasts.addToast({
message: $t("tests.run-test.triggered-success", { id: result.execution_id }),
type: "success",
timeout: 5000,
});
isOpen = false;
if (onTestTriggered && result.execution_id) {
onTestTriggered(result.execution_id, pluginName);
}
} catch (error) {
toasts.addErrorToast({
message: $t("tests.run-test.trigger-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
triggering = false;
}
}
</script>
<Modal {isOpen} {toggle} size="lg">
<ModalHeader {toggle}>
{$t("tests.run-test.title")}: {pluginDisplayName}
</ModalHeader>
<ModalBody>
{#if pluginStatusPromise && domainOptionsPromise}
{#await Promise.all([pluginStatusPromise, domainOptionsPromise])}
<div class="text-center py-3">
<Spinner />
<p class="mt-2">{$t("tests.run-test.loading-options")}</p>
</div>
{:then [status, _domainOpts]}
{@const runOpts = status.options?.runOpts || []}
{#if runOpts.length > 0}
<p>
{$t("tests.run-test.configure-info")}
</p>
<Form
id="run-test-modal"
on:submit={(e) => {
e.preventDefault();
handleRunTest();
}}
>
{#each runOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={runOptions[optName]}
/>
</FormGroup>
{/if}
{/each}
</Form>
{:else}
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.run-test.no-options")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("tests.run-test.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={handleRunTest}
disabled={triggering}
>
{#if triggering}
<Spinner size="sm" class="me-1" />
{:else}
<Icon name="play-fill"></Icon>
{/if}
{$t("tests.run-test.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-tests": "View tests",
"others": "More actions on {{domain}}"
},
"alert": {
@ -536,6 +537,120 @@
"ttl": "Remaining time in cache",
"showDNSSEC": "Show DNSSEC records in answer (if any)"
},
"tests": {
"run-test": {
"title": "Run Test",
"loading-options": "Loading test 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 test options: {{error}}",
"run-button": "Run Test",
"triggered-success": "Test triggered successfully! Execution ID: {{id}}",
"trigger-failed": "Failed to trigger test: {{error}}"
},
"never": "Never",
"na": "N/A",
"status": {
"ok": "OK",
"info": "Info",
"warning": "Warning",
"error": "Error",
"unknown": "Unknown",
"not-run": "Not run"
},
"list": {
"title": "Tests for ",
"loading": "Loading tests...",
"loading-plugins": "Loading plugin information...",
"no-tests": "No tests available for this domain.",
"run-test": "Run Test",
"view-results": "View Results",
"error-loading": "Error loading tests: {{error}}",
"unknown-version": "Unknown",
"table": {
"plugin": "Test Plugin",
"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 test 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-tests": "Back to Tests",
"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 test results: {{error}}",
"table": {
"executed-at": "Executed At",
"status": "Status",
"message": "Message",
"duration": "Duration",
"type": "Type",
"actions": "Actions"
},
"type": {
"scheduled": "Scheduled",
"manual": "Manual"
},
"view": "View"
},
"result": {
"title": "Test Result Details",
"loading": "Loading test result...",
"relaunch": "Relaunch Test",
"delete": "Delete Result",
"back-to-results": "Back to Results",
"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 test 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:",
"plugin-version": "Plugin Version:"
}
}
},
"plugins": {
"tests": {
"title": "Domain Tests",
@ -548,7 +663,6 @@
"error-loading": "Error loading tests: {{error}}",
"error-loading-test": "Error loading test: {{error}}",
"test-info-not-found": "Error: Test information not found",
"back-to-tests": "Back to Tests",
"table": {
"name": "Test Name",
"version": "Version",
@ -601,6 +715,8 @@
"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-tests": "Back to Tests"
}
}

View file

@ -473,6 +473,16 @@
"no-group": "Divers",
"title": "Vos groupes"
},
"tests": {
"run-test": {
"title": "Lancer le test",
"loading-options": "Chargement des options du test...",
"configure-info": "Configurez les options du test ci-dessous. Les valeurs préremplies proviennent des paramètres au niveau du domaine.",
"no-options": "Ce test n'a pas d'options configurables. Cliquez sur \"Lancer le test\" pour l'exécuter avec les paramètres par défaut.",
"error-loading-options": "Erreur lors du chargement des options du test : {{error}}",
"run-button": "Lancer le test"
}
},
"plugins": {
"tests": {
"title": "Tests de domaines",

View file

@ -0,0 +1,54 @@
// 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 {
HappydnsPluginAvailability,
HappydnsPluginOptionDocumentation,
HappydnsPluginOptionsDocumentation,
HappydnsPluginOptions,
} from "$lib/api-base/types.gen";
// Re-export auto-generated types with better names
export type PluginAvailability = HappydnsPluginAvailability;
export type PluginOptions = HappydnsPluginOptions;
export type PluginOptionsDocumentation = HappydnsPluginOptionsDocumentation;
// Make 'id' required for PluginOptionDocumentation
export interface PluginOptionDocumentation extends Omit<HappydnsPluginOptionDocumentation, "id"> {
id: string;
}
// Make 'name' and 'version' required for PluginVersionInfo
export interface PluginVersionInfo {
name: string;
version: string;
availableOn?: PluginAvailability;
}
// Make 'name' and 'version' required for PluginStatus
export interface PluginStatus {
name: string;
version: string;
availableOn?: PluginAvailability;
options?: PluginOptionsDocumentation;
}
export type PluginList = Record<string, PluginVersionInfo>;

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 { PluginOptions } from './plugin';
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?: PluginOptions;
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?: PluginOptions;
}
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?: PluginOptions;
}
export interface CreateScheduleRequest {
plugin_name: string;
target_type: TestScopeType;
target_id: string;
interval: number;
enabled: boolean;
options?: PluginOptions;
}

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 { listPlugins } from "$lib/api/plugins";
import type { PluginList } from "$lib/model/plugin";
import { writable, type Writable } from "svelte/store";
export const plugins: Writable<PluginList | undefined> = writable(undefined);
export async function refreshPlugins() {
const data = await listPlugins();
plugins.set(data);
return data;
}

View file

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

View file

@ -76,7 +76,17 @@
goto(
"/domains/" +
encodeURIComponent(domainLink(dn)) +
(page.data.isAuditPage ? "/logs" : page.data.isHistoryPage ? "/history" : ""),
(page.route.id
? page.route.id.startsWith("/domains/[dn]/logs")
? "/logs"
: page.route.id.startsWith("/domains/[dn]/history")
? "/history"
: page.route.id.startsWith("/domains/[dn]/tests/[tname]")
? `/tests/${page.params.tname!}`
: page.route.id.startsWith("/domains/[dn]/tests")
? "/tests"
: ""
: ""),
);
}
if (selectedDomain != dn) {
@ -166,7 +176,34 @@
<SelectDomain bind:selectedDomain />
</div>
{#if page.data.isHistoryPage || page.data.isAuditPage}
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/tests/[tname]")}
{#if page.route.id.startsWith("/domains/[dn]/tests/[tname]/results/")}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/tests/" +
encodeURIComponent(page.params.tname!)}
>
<Icon name="chevron-left" />
{$t("zones.return-to-results")}
</Button>
{:else}
<Button
class="mt-2"
outline
color="primary"
href={"/domains/" +
encodeURIComponent(domainLink(selectedDomain)) +
"/tests"}
>
<Icon name="chevron-left" />
{$t("zones.return-to-tests")}
</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]/tests"))}
<Button
class="mt-2"
outline
@ -220,6 +257,9 @@
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
{$t("domains.actions.audit")}
</DropdownItem>
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/tests`}>
{$t("domains.actions.view-tests")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
{$t("domains.actions.view")}

View file

@ -1,10 +0,0 @@
import type { Load } from "@sveltejs/kit";
export const load: Load = async ({ parent }) => {
const data = await parent();
return {
...data,
isHistoryPage: true,
};
};

View file

@ -1,10 +0,0 @@
import type { Load } from "@sveltejs/kit";
export const load: Load = async ({ parent }) => {
const data = await parent();
return {
...data,
isAuditPage: true,
};
};

View file

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

View file

@ -0,0 +1,256 @@
<!--
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 { goto } from "$app/navigation";
import { page } from "$app/state";
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
import { t } from "$lib/translations";
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus, TestScopeType, type AvailableTest } from "$lib/model/test";
import { plugins } from "$lib/stores/plugins";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
let testsPromise = $derived(listAvailableTests(data.domain.id));
let runTestModal: RunTestModal;
let togglingTests = $state(new Set<string>());
function handleTestTriggered(_: string, pluginName: string) {
// Refresh the test list to show updated status
testsPromise = listAvailableTests(data.domain.id);
goto(`/domains/${page.params.dn!}/tests/${pluginName}/results`);
}
async function handleToggleEnabled(test: AvailableTest) {
const next = new Set(togglingTests);
next.add(test.plugin_name);
togglingTests = next;
try {
const newEnabled = !test.enabled;
if (test.schedule) {
await updateTestSchedule(test.schedule.id, {
...test.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 createTestSchedule({
plugin_name: test.plugin_name,
target_type: TestScopeType.TestScopeDomain,
target_id: data.domain.id,
interval: 0,
enabled: newEnabled,
});
}
testsPromise = listAvailableTests(data.domain.id);
} catch {
// toggle reverts visually on refresh; nothing extra needed
} finally {
const after = new Set(togglingTests);
after.delete(test.plugin_name);
togglingTests = after;
}
}
function getStatusColor(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "success";
case PluginResultStatus.Info:
return "info";
case PluginResultStatus.Warn:
return "warning";
case PluginResultStatus.KO:
return "danger";
default:
return "secondary";
}
}
function getStatusKey(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "tests.status.ok";
case PluginResultStatus.Info:
return "tests.status.info";
case PluginResultStatus.Warn:
return "tests.status.warning";
case PluginResultStatus.KO:
return "tests.status.error";
default:
return "tests.status.unknown";
}
}
function formatDate(dateString?: string): string {
if (!dateString) return $t("tests.never");
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "short",
}).format(new Date(dateString));
}
</script>
<svelte:head>
<title>Tests - {data.domain.domain} - happyDomain</title>
</svelte:head>
<div class="flex-fill pb-4 pt-2">
<h2>
{$t("tests.list.title")}<span class="font-monospace">{data.domain.domain}</span>
</h2>
{#await testsPromise}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading")}</p>
</div>
{:then tests}
{#if !$plugins}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading-plugins")}</p>
</div>
{:else if !tests || tests.length === 0}
<Card body class="mt-3">
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.list.no-tests")}
</p>
</Card>
{:else}
<Table hover striped class="mt-3">
<thead>
<tr>
<th>{$t("tests.list.table.plugin")}</th>
<th>{$t("tests.list.table.status")}</th>
<th>{$t("tests.list.table.last-run")}</th>
<th>{$t("tests.list.table.schedule")}</th>
<th>{$t("tests.list.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each tests as test}
{@const pluginInfo = $plugins[test.plugin_name]}
<tr>
<td class="align-middle">
<strong>{pluginInfo?.name || test.plugin_name}</strong>
<small class="ms-1 text-muted">
{pluginInfo?.version || $t("tests.list.unknown-version")}
</small>
</td>
<td class="align-middle text-center">
{#if test.last_result !== undefined}
<Badge color={getStatusColor(test.last_result.status)}>
{$t(getStatusKey(test.last_result.status))}
</Badge>
{:else}
<Badge color="secondary">{$t("tests.status.not-run")}</Badge>
{/if}
</td>
<td class="align-middle">
{formatDate(test.last_result?.executed_at)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="toggle-{test.plugin_name}"
checked={test.enabled}
disabled={togglingTests.has(test.plugin_name)}
onchange={() => handleToggleEnabled(test)}
/>
<label
class="form-check-label small"
for="toggle-{test.plugin_name}"
>
{test.enabled
? $t("tests.list.schedule.enabled")
: $t("tests.list.schedule.disabled")}
</label>
</div>
</td>
<td class="align-middle">
<div class="d-flex gap-2">
<Button
size="sm"
color="primary"
onclick={() =>
runTestModal.open(
test.plugin_name,
pluginInfo?.name || test.plugin_name,
)}
>
<Icon name="play-fill"></Icon>
{$t("tests.list.run-test")}
</Button>
<Button
size="sm"
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("tests.list.view-results")}
</Button>
<Button
size="sm"
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}`}
title={$t("tests.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("tests.list.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunTestModal
domainId={data.domain.id}
onTestTriggered={handleTestTriggered}
bind:this={runTestModal}
/>

View file

@ -0,0 +1,311 @@
<!--
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 { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
import type { Domain } from "$lib/model/domain";
import { TestScopeType, type AvailableTest } from "$lib/model/test";
import { plugins } from "$lib/stores/plugins";
import { toasts } from "$lib/stores/toasts";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
const pluginName = $derived($plugins?.[testName]?.name || testName);
// Resolved test data
let test = $state<AvailableTest | 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 loadTest() {
loading = true;
loadError = null;
try {
const tests = await listAvailableTests(data.domain.id);
const found = tests?.find((t) => t.plugin_name === testName) ?? null;
test = found;
if (found) {
formEnabled = found.enabled;
formIntervalHours =
found.schedule && found.schedule.interval > 0
? found.schedule.interval / (3600 * 1e9)
: 24;
}
} catch (e: any) {
loadError = e.message;
} finally {
loading = false;
}
}
loadTest();
function formatDate(dateString?: string): string {
if (!dateString) return $t("tests.never");
const d = new Date(dateString);
if (isNaN(d.getTime())) return $t("tests.never");
return new Intl.DateTimeFormat(undefined, {
dateStyle: "medium",
timeStyle: "short",
}).format(d);
}
function formatRelative(dateString?: 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 ? "in less than a minute" : "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 ? `in ${label}` : `${label} ago`;
}
async function handleSave() {
if (!test) return;
saving = true;
try {
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
if (test.schedule) {
await updateTestSchedule(test.schedule.id, {
...test.schedule,
enabled: formEnabled,
interval: intervalNs,
});
} else {
await createTestSchedule({
plugin_name: test.plugin_name,
target_type: TestScopeType.TestScopeDomain,
target_id: data.domain.id,
interval: intervalNs,
enabled: formEnabled,
});
}
toasts.addToast({ title: $t("tests.schedule.saved"), type: "success", timeout: 3000 });
await loadTest();
} catch (e: any) {
toasts.addErrorToast({ title: $t("tests.schedule.save-failed"), message: e.message });
} finally {
saving = false;
}
}
</script>
<svelte:head>
<title>
{testName} - {$t("tests.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;
{pluginName}
&ndash; {$t("tests.schedule.title")}
</h2>
<div class="d-flex gap-2">
<Button
color="secondary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests`}
>
<Icon name="arrow-left"></Icon>
{$t("zones.return-to-tests")}
</Button>
<Button
color="info"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results`}
>
<Icon name="bar-chart-fill"></Icon>
{$t("tests.list.view-results")}
</Button>
</div>
</div>
{#if loading}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.list.loading")}</p>
</div>
{:else if loadError}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("tests.list.error-loading", { error: loadError })}
</p>
</Card>
{:else if !test}
<Card body>
<p class="text-center text-muted mb-0">
<Icon name="info-circle"></Icon>
{$t("tests.list.no-tests")}
</p>
</Card>
{:else}
<Card class="mb-4">
<CardHeader>
<h4 class="mb-0">
<Icon name="clock-history"></Icon>
{$t("tests.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("tests.schedule.auto-enabled")}</Badge
>
{:else}
<Badge color="secondary"
>{$t("tests.schedule.auto-disabled")}</Badge
>
{/if}
</label>
</div>
</div>
{#if formEnabled}
<div class="mb-4">
<label for="schedule-interval" class="form-label fw-semibold">
{$t("tests.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("tests.schedule.hours")}
</span>
</div>
<div class="form-text">
{$t("tests.schedule.interval-hint")}
</div>
</div>
{/if}
{#if test.schedule}
<div class="mb-4">
<div class="row g-3">
{#if test.schedule.last_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("tests.schedule.last-run")}:
</span>
<span>
{formatDate(test.schedule.last_run)}
<small class="text-muted">
({formatRelative(test.schedule.last_run)})
</small>
</span>
</div>
{/if}
{#if test.enabled && test.schedule.next_run}
<div class="col-auto">
<span class="text-muted fw-semibold">
{$t("tests.schedule.next-run")}:
</span>
<span>
{formatDate(test.schedule.next_run)}
<small class="text-muted">
({formatRelative(test.schedule.next_run)})
</small>
</span>
</div>
{/if}
</div>
</div>
{:else}
<p class="text-muted">
<Icon name="info-circle"></Icon>
{$t("tests.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("tests.schedule.save")}
</Button>
</CardBody>
</Card>
{/if}
</div>

View file

@ -0,0 +1,281 @@
<!--
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 { t } from "$lib/translations";
import { page } from "$app/state";
import { listTestResults, deleteTestResult, deleteAllTestResults } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus } from "$lib/model/test";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
let resultsPromise = $derived(listTestResults(data.domain.id, testName));
let pluginPromise = $derived(getPluginStatus(testName));
let runTestModal: RunTestModal;
let errorMessage = $state<string | null>(null);
function getStatusColor(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "success";
case PluginResultStatus.Info:
return "info";
case PluginResultStatus.Warn:
return "warning";
case PluginResultStatus.KO:
return "danger";
default:
return "secondary";
}
}
function getStatusKey(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "tests.status.ok";
case PluginResultStatus.Info:
return "tests.status.info";
case PluginResultStatus.Warn:
return "tests.status.warning";
case PluginResultStatus.KO:
return "tests.status.error";
default:
return "tests.status.unknown";
}
}
function formatDate(dateString: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "short",
timeStyle: "medium",
}).format(new Date(dateString));
}
function formatDuration(duration?: number): string {
if (!duration) return $t("tests.na");
const seconds = duration / 1000000000;
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
return `${seconds.toFixed(2)}s`;
}
function handleTestTriggered() {
// Refresh results list after test is triggered
resultsPromise = listTestResults(data.domain.id, testName);
}
async function handleDeleteResult(resultId: string) {
if (!confirm($t("tests.results.delete-confirm"))) {
return;
}
try {
await deleteTestResult(data.domain.id, testName, resultId);
resultsPromise = listTestResults(data.domain.id, testName);
} catch (error: any) {
errorMessage = error.message || $t("tests.results.delete-failed");
}
}
async function handleDeleteAll() {
if (!confirm($t("tests.results.delete-all-confirm"))) {
return;
}
try {
await deleteAllTestResults(data.domain.id, testName);
resultsPromise = listTestResults(data.domain.id, testName);
} catch (error: any) {
errorMessage = error.message || $t("tests.results.delete-all-failed");
}
}
</script>
<svelte:head>
<title>{testName} 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 pluginPromise then plugin}
{plugin.name || testName}
{:catch}
{testName}
{/await}
</h2>
<div class="d-flex gap-2">
<Button
color="dark"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`}
>
<Icon name="gear-fill"></Icon>
{$t("tests.results.configure")}
</Button>
{#await pluginPromise then plugin}
<Button
color="primary"
onclick={() => runTestModal.open(testName, plugin.name || testName)}
>
<Icon name="play-fill"></Icon>
{$t("tests.results.run-test-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("tests.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("tests.results.no-results")}
</p>
</Card>
{:else}
<div class="d-flex justify-content-between align-items-center mb-2">
<h4>{$t("tests.results.title", { count: results.length })}</h4>
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
<Icon name="trash"></Icon>
{$t("tests.results.delete-all")}
</Button>
</div>
<Table hover striped>
<thead>
<tr>
<th>{$t("tests.results.table.executed-at")}</th>
<th class="text-center">{$t("tests.results.table.status")}</th>
<th>{$t("tests.results.table.message")}</th>
<th>{$t("tests.results.table.duration")}</th>
<th class="text-center">{$t("tests.results.table.type")}</th>
<th>{$t("tests.results.table.actions")}</th>
</tr>
</thead>
<tbody>
{#each results as result}
<tr>
<td class="align-middle">
{formatDate(result.executed_at)}
</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)}
</td>
<td class="align-middle text-center">
{#if result.scheduled_test}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("tests.results.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("tests.results.type.manual")}
</Badge>
{/if}
</td>
<td class="align-middle">
<ButtonGroup size="sm">
<Button
color="primary"
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results/${encodeURIComponent(result.id)}`}
>
<Icon name="eye-fill"></Icon>
{$t("tests.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("tests.results.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<RunTestModal
domainId={data.domain.id}
onTestTriggered={handleTestTriggered}
bind:this={runTestModal}
/>

View file

@ -0,0 +1,352 @@
<!--
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 { goto } from "$app/navigation";
import { getTestResult, deleteTestResult, triggerTest } from "$lib/api/tests";
import { getPluginStatus } from "$lib/api/plugins";
import type { Domain } from "$lib/model/domain";
import { PluginResultStatus } from "$lib/model/test";
interface Props {
data: { domain: Domain };
}
let { data }: Props = $props();
const testName = $derived(page.params.tname || "");
const resultId = $derived(page.params.rid || "");
let resultPromise = $derived(getTestResult(data.domain.id, testName, resultId));
let pluginPromise = $derived(getPluginStatus(testName));
let errorMessage = $state<string | null>(null);
let resolvedResult = $state<import("$lib/model/test").TestResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
resultPromise.then((r) => {
resolvedResult = r;
});
});
function getStatusColor(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "success";
case PluginResultStatus.Info:
return "info";
case PluginResultStatus.Warn:
return "warning";
case PluginResultStatus.KO:
return "danger";
default:
return "secondary";
}
}
function getStatusKey(status: PluginResultStatus): string {
switch (status) {
case PluginResultStatus.OK:
return "tests.status.ok";
case PluginResultStatus.Info:
return "tests.status.info";
case PluginResultStatus.Warn:
return "tests.status.warning";
case PluginResultStatus.KO:
return "tests.status.error";
default:
return "tests.status.unknown";
}
}
function formatDate(dateString: string): string {
return new Intl.DateTimeFormat(undefined, {
dateStyle: "long",
timeStyle: "medium",
}).format(new Date(dateString));
}
function formatDuration(duration?: number): string {
if (!duration) return $t("tests.na");
const seconds = duration / 1000000000;
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${$t("tests.result.milliseconds")}`;
return `${seconds.toFixed(2)} ${$t("tests.result.seconds")}`;
}
async function handleRelaunch() {
if (!resolvedResult) return;
isRelaunching = true;
try {
await triggerTest(data.domain.id, testName, resolvedResult.options);
goto(
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("tests.result.relaunch-failed");
} finally {
isRelaunching = false;
}
}
async function handleDelete() {
if (!confirm($t("tests.result.delete-confirm"))) {
return;
}
try {
await deleteTestResult(data.domain.id, testName, resultId);
goto(
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
);
} catch (error: any) {
errorMessage = error.message || $t("tests.result.delete-failed");
}
}
</script>
<svelte:head>
<title>
Test Result - {testName} - {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("tests.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("tests.result.relaunch")}
</span>
</Button>
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
<Icon name="trash"></Icon>
<span class="d-none d-lg-inline">
{$t("tests.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, pluginPromise])}
<div class="mt-5 text-center flex-fill">
<Spinner />
<p>{$t("tests.result.loading")}</p>
</div>
{:then [result, plugin]}
<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">
{plugin.name || testName}
</h4>
{#if plugin.version}
<small
class="text-muted"
title={$t("tests.result.field.plugin-version")}
>
{plugin.version}
</small>
{/if}
</div>
{#if result.scheduled_test}
<Badge color="info">
<Icon name="clock"></Icon>
{$t("tests.result.type.scheduled")}
</Badge>
{:else}
<Badge color="secondary">
<Icon name="hand-index"></Icon>
{$t("tests.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("tests.result.field.domain")}</th>
<td class="font-monospace">{data.domain.domain}</td>
</tr>
<tr>
<th>{$t("tests.result.field.executed-at")}</th>
<td>{formatDate(result.executed_at)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.duration")}</th>
<td>{formatDuration(result.duration)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.status")}</th>
<td>
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
</tr>
<tr>
<th>{$t("tests.result.field.status-message")}</th>
<td>{result.status_line}</td>
</tr>
{#if result.error}
<tr>
<th>{$t("tests.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("tests.result.test-options")}
</h5>
</CardHeader>
<CardBody class="p-2">
<Table borderless size="sm" class="mb-0">
<tbody>
{#each Object.entries(plugin.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("tests.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("tests.result.error-loading", { error: error.message })}
</p>
</Card>
{/await}
</div>
<style>
pre {
overflow-x: scroll;
}
</style>

View file

@ -36,11 +36,16 @@
} from "@sveltestrap/sveltestrap";
import { t } from '$lib/translations';
import { listPlugins } from '$lib/api/plugins';
let pluginsPromise = $state(listPlugins());
import { plugins, refreshPlugins } from '$lib/stores/plugins';
let searchQuery = $state('');
// Load plugins if not already loaded
$effect(() => {
if ($plugins === undefined) {
refreshPlugins();
}
});
</script>
<svelte:head>
@ -58,9 +63,9 @@
<span class="lead">
{$t('plugins.tests.description')}
</span>
{#await pluginsPromise then plugins}
<span>{$t('plugins.tests.available-count', { count: Object.keys(plugins ?? {}).length })}</span>
{/await}
{#if $plugins}
<span>{$t('plugins.tests.available-count', { count: Object.keys($plugins).length })}</span>
{/if}
</p>
</Col>
</Row>
@ -80,14 +85,14 @@
</Col>
</Row>
{#await pluginsPromise}
{#if !$plugins}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t('plugins.tests.loading')}
</p>
</Card>
{:then plugins}
{:else}
<div class="table-responsive">
<Table hover bordered>
<thead>
@ -99,14 +104,14 @@
</tr>
</thead>
<tbody>
{#if !plugins || Object.keys(plugins).length == 0}
{#if Object.keys($plugins).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-4">
{$t('plugins.tests.no-tests')}
</td>
</tr>
{:else}
{#each Object.entries(plugins ?? {}).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
{#each Object.entries($plugins).filter(([name, _info]) => name.toLowerCase().indexOf(searchQuery.toLowerCase()) > -1) as [pluginName, pluginInfo]}
<tr>
<td><strong>{pluginInfo.name || pluginName}</strong></td>
<td>{pluginInfo.version}</td>
@ -141,12 +146,5 @@
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t('plugins.tests.error-loading', { error: error.message })}
</p>
</Card>
{/await}
{/if}
</Container>

View file

@ -36,16 +36,12 @@
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from '$app/stores';
import { page } from "$app/stores";
import { t } from '$lib/translations';
import { toasts } from '$lib/stores/toasts';
import {
getPluginStatus,
getPluginOptions,
updatePluginOptions,
} from '$lib/api/plugins';
import Resource from '$lib/components/inputs/Resource.svelte';
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import { getPluginStatus, getPluginOptions, updatePluginOptions } from "$lib/api/plugins";
import Resource from "$lib/components/inputs/Resource.svelte";
let pid = $derived($page.params.pid!);
@ -66,13 +62,13 @@
await updatePluginOptions(pid, optionValues);
pluginOptionsPromise = getPluginOptions(pid);
toasts.addToast({
message: $t('plugins.tests.messages.options-updated'),
type: 'success',
message: $t("plugins.tests.messages.options-updated"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t('plugins.tests.messages.update-failed', { error: String(error) }),
message: $t("plugins.tests.messages.update-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
@ -81,7 +77,7 @@
}
async function cleanOrphanedOptions(userOpts: any[]) {
const validOptIds = new Set(userOpts.map(opt => opt.id));
const validOptIds = new Set(userOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
@ -95,13 +91,13 @@
await updatePluginOptions(pid, cleanedOptions);
pluginOptionsPromise = getPluginOptions(pid);
toasts.addToast({
message: $t('plugins.tests.messages.options-cleaned'),
type: 'success',
message: $t("plugins.tests.messages.options-cleaned"),
type: "success",
timeout: 5000,
});
} catch (error) {
toasts.addErrorToast({
message: $t('plugins.tests.messages.clean-failed', { error: String(error) }),
message: $t("plugins.tests.messages.clean-failed", { error: String(error) }),
timeout: 10000,
});
} finally {
@ -109,14 +105,21 @@
}
}
function getOrphanedOptions(userOpts: any[]): string[] {
const validOptIds = new Set(userOpts.map(opt => opt.id));
return Object.keys(optionValues).filter(key => !validOptIds.has(key));
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>{pid} - {$t('plugins.tests.title')} - happyDomain</title>
<title>{pid} - {$t("plugins.tests.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
@ -124,7 +127,7 @@
<Col>
<Button color="link" href="/plugins" class="mb-2">
<Icon name="arrow-left"></Icon>
{$t('plugins.tests.back-to-tests')}
{$t("plugins.tests.back-to-tests")}
</Button>
<h1 class="display-5">
<Icon name="check-circle-fill"></Icon>
@ -137,7 +140,7 @@
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t('plugins.tests.loading-info')}
{$t("plugins.tests.loading-info")}
</p>
</Card>
{:then status}
@ -146,41 +149,59 @@
<Col md={6}>
<Card>
<CardHeader>
<strong>{$t('plugins.tests.detail.test-information')}</strong>
<strong>{$t("plugins.tests.detail.test-information")}</strong>
</CardHeader>
<CardBody>
<dl class="row mb-0">
<dt class="col-sm-4">{$t('plugins.tests.detail.name')}</dt>
<dt class="col-sm-4">{$t("plugins.tests.detail.name")}</dt>
<dd class="col-sm-8">{status.name}</dd>
<dt class="col-sm-4">{$t('plugins.tests.detail.version')}</dt>
<dt class="col-sm-4">{$t("plugins.tests.detail.version")}</dt>
<dd class="col-sm-8">{status.version}</dd>
<dt class="col-sm-4">{$t('plugins.tests.detail.availability')}</dt>
<dt class="col-sm-4">{$t("plugins.tests.detail.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">{$t('plugins.tests.availability.domain-level')}</Badge>
<Badge color="success"
>{$t(
"plugins.tests.availability.domain-level",
)}</Badge
>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
{$t('plugins.tests.availability.providers', { providers: status.availableOn.limitToProviders.join(', ') })}
{$t("plugins.tests.availability.providers", {
providers:
status.availableOn.limitToProviders.join(
", ",
),
})}
</Badge>
{/if}
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
<Badge color="info">
{$t('plugins.tests.availability.services', { services: status.availableOn.limitToServices.join(', ') })}
{$t("plugins.tests.availability.services", {
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">{$t('plugins.tests.availability.general')}</Badge>
{#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
<Badge color="secondary"
>{$t(
"plugins.tests.availability.general",
)}</Badge
>
{/if}
</div>
{:else}
<Badge color="secondary">{$t('plugins.tests.availability.general')}</Badge>
<Badge color="secondary"
>{$t("plugins.tests.availability.general")}</Badge
>
{/if}
</dd>
</dl>
@ -194,27 +215,46 @@
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t('plugins.tests.detail.loading-options')}
{$t("plugins.tests.detail.loading-options")}
</p>
</CardBody>
</Card>
{:then options}
{@const userOpts = status.options?.userOpts || []}
{@const readOnlyOptGroups = [
{ key: 'adminOpts', label: $t('plugins.tests.option-groups.global-settings'), opts: status.options?.adminOpts || [] },
{ key: 'domainOpts', label: $t('plugins.tests.option-groups.domain-settings'), opts: status.options?.domainOpts || [] },
{ key: 'serviceOpts', label: $t('plugins.tests.option-groups.service-settings'), opts: status.options?.serviceOpts || [] },
{ key: 'runOpts', label: $t('plugins.tests.option-groups.test-parameters'), opts: status.options?.runOpts || [] }
]}
{@const hasAnyOpts = userOpts.length > 0 || readOnlyOptGroups.some(g => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(userOpts)}
{
key: "adminOpts",
label: $t("plugins.tests.option-groups.global-settings"),
opts: status.options?.adminOpts || [],
},
{
key: "domainOpts",
label: $t("plugins.tests.option-groups.domain-settings"),
opts: status.options?.domainOpts || [],
},
{
key: "serviceOpts",
label: $t("plugins.tests.option-groups.service-settings"),
opts: status.options?.serviceOpts || [],
},
{
key: "runOpts",
label: $t("plugins.tests.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('plugins.tests.detail.orphaned-options', { options: orphanedOpts.join(', ') })}
{$t("plugins.tests.detail.orphaned-options", {
options: orphanedOpts.join(", "),
})}
</div>
<Button
color="danger"
@ -223,7 +263,7 @@
disabled={saving}
>
<Icon name="trash"></Icon>
{$t('plugins.tests.detail.clean-up')}
{$t("plugins.tests.detail.clean-up")}
</Button>
</div>
</Alert>
@ -232,10 +272,15 @@
{#if userOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{$t('plugins.tests.detail.configuration')}</strong>
<strong>{$t("plugins.tests.detail.configuration")}</strong>
</CardHeader>
<CardBody>
<Form on:submit={(e) => { e.preventDefault(); saveOptions(); }}>
<Form
on:submit={(e) => {
e.preventDefault();
saveOptions();
}}
>
{#each userOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
@ -244,7 +289,7 @@
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || 'string'}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
@ -253,10 +298,12 @@
<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>
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
{$t('plugins.tests.detail.save-changes')}
{$t("plugins.tests.detail.save-changes")}
</Button>
</div>
</Form>
@ -269,23 +316,47 @@
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2">{$t('plugins.tests.detail.read-only')}</small>
<small class="text-muted ms-2"
>{$t("plugins.tests.detail.read-only")}</small
>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
<dt class="col-sm-4">{optDoc.label || optDoc.id}:</dt>
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{#if optionValues[optName]}
<span class="text-muted d-block"
>{optionValues[optName]}</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>
<em class="text-muted d-block"
>{optDoc.placeholder}</em
>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
<small class="text-muted d-block"
>{optDoc.description}</small
>
{/if}
<small class="text-muted">{$t('plugins.tests.option-groups.type', { type: optDoc.type || 'string' })}</small>
{#if optDoc.required}<small class="text-danger ms-2">{$t('plugins.tests.option-groups.required')}</small>{/if}
<small class="text-muted"
>{$t("plugins.tests.option-groups.type", {
type: optDoc.type || "string",
})}</small
>
{#if optDoc.required}<small
class="text-danger ms-2"
>{$t(
"plugins.tests.option-groups.required",
)}</small
>{/if}
</dd>
{/each}
</dl>
@ -299,7 +370,7 @@
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t('plugins.tests.detail.no-configurable-options')}
{$t("plugins.tests.detail.no-configurable-options")}
</Alert>
</CardBody>
</Card>
@ -309,7 +380,9 @@
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t('plugins.tests.detail.error-loading-options', { error: error.message })}
{$t("plugins.tests.detail.error-loading-options", {
error: error.message,
})}
</Alert>
</CardBody>
</Card>
@ -319,13 +392,13 @@
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t('plugins.tests.test-info-not-found')}
{$t("plugins.tests.test-info-not-found")}
</Alert>
{/if}
{:catch error}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$t('plugins.tests.error-loading-test', { error: error.message })}
{$t("plugins.tests.error-loading-test", { error: error.message })}
</Alert>
{/await}
</Container>