Compare commits

...

18 commits

Author SHA1 Message Date
b541809470 web: Refactor frontend utilities and eliminate duplicate code
Some checks failed
continuous-integration/drone/push Build is failing
Extract shared test utilities (getStatusColor, getStatusKey, formatDuration)
into web/src/lib/utils/test.ts and extend datetime.ts with formatTestDate and
formatRelative (with full i18n support). Remove 4 sets of duplicate functions
across the tests/* pages, fix $app/stores → $app/state in plugins/[pid], and
fix inline type import in results/[rid].

Create PluginOptionsGroups.svelte shared component for read-only option group
rendering, used by both web and web-admin plugin detail pages. Update
web-admin/src/routes/plugins/[pname]/+page.svelte to use i18n translations
throughout, replacing all hardcoded English strings.

Add i18n keys: tests.relative.{in,ago,just-now,in-less-than-a-minute} and
plugins.tests.back-button to en.json and fr.json.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:58:51 +07:00
6a2ebe83b6 Wire option merging for scheduled test executions
Fetch user/domain/service-level plugin options via GetTestPluginOptions
and merge them with schedule-specific options (schedule opts take priority)
before running a test. Previously tests only used schedule.Options.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:28:49 +07:00
0d4c359fbb Add limit/offset pagination to ListTestSchedules endpoint
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:28:49 +07:00
f72149b854 Add panic recovery in plugin execution goroutine
Wrap plugin.RunTest() call with defer/recover so a panicking plugin sends
an error to errorChan instead of silently killing the worker goroutine.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:28:49 +07:00
8d4dd2d913 Implement cleanup stubs for test result retention
- Add DeleteTestResultsBefore/DeleteCompletedExecutionsBefore to storage interface and KV implementation
- Implement CleanupOldResults() using configured ResultRetentionDays (default 90 days)
- Implement DeleteCompletedExecutions() removing records older than given duration
- Wire both into testScheduler.cleanup() so daily cleanup actually prunes data

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 18:28:49 +07:00
2d1a74c38c Add admin API and frontend for scheduler management 2026-02-11 18:28:49 +07:00
ff760662c0 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 18:28:49 +07:00
e33c19738d web: Replace page data flags with route ID checks for history/logs pages 2026-02-11 18:28:49 +07:00
c32e2febe6 Implement tests scheduler 2026-02-11 18:28:49 +07:00
d74c8ec4ba Implement backend model for test results and schedule 2026-02-11 18:28:49 +07:00
cb1d42c2e3 Add plugin interface: api routes and frontend to manage user plugins 2026-02-11 18:25:52 +07:00
7fb3224796 Add test plugin routes to API + refactor plugins controller 2026-02-11 18:25:52 +07:00
a57e5254b9 web-admin: Implement plugins interface with option editor 2026-02-11 18:25:52 +07:00
5ec9884df0 Implement plugin options retrieval 2026-02-11 18:25:52 +07:00
15dfecf2b0 Add usescases to handle test plugins 2026-02-11 18:25:52 +07:00
960ab87612 plugins: Refactor with intermediate structs 2026-02-11 18:25:52 +07:00
bef3fe569e Add a test plugin for Matrix Federation 2026-02-11 18:25:52 +07:00
6df6a4f219 Load tests plugins 2026-02-11 18:25:52 +07:00
70 changed files with 8411 additions and 70 deletions

View file

@ -0,0 +1,181 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// TestPluginController handles admin-level plugin operations.
// All methods in this controller work with admin-scoped options (nil user/domain/service IDs).
type TestPluginController struct {
*apicontroller.BaseTestPluginController
}
func NewTestPluginController(testPluginService happydns.TestPluginUsecase) *TestPluginController {
return &TestPluginController{
BaseTestPluginController: apicontroller.NewBaseTestPluginController(testPluginService),
}
}
// TestPluginHandler is a middleware that retrieves a test plugin by name and sets it in the context.
func (uc *TestPluginController) TestPluginHandler(c *gin.Context) {
pname := c.Param("pname")
plugin, err := uc.BaseTestPluginController.GetTestPluginService().GetTestPlugin(pname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Plugin not found"})
return
}
c.Set("plugin", plugin)
c.Next()
}
// TestPluginOptionHandler is a middleware that retrieves a specific admin-level plugin option and sets it in the context.
func (uc *TestPluginController) TestPluginOptionHandler(c *gin.Context) {
pname := c.Param("pname")
optname := c.Param("optname")
// Get admin-level options (nil user/domain/service IDs)
opts, err := uc.BaseTestPluginController.GetTestPluginService().GetTestPluginOptions(pname, nil, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// GetTestPluginOptions retrieves all admin-level options for a test plugin.
//
// @Summary Get test plugin options (admin)
// @Schemes
// @Description Retrieves all admin-level configuration options for a specific test plugin.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pname path string true "Plugin name"
// @Success 200 {object} happydns.PluginOptions "Plugin options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pname}/options [get]
func (uc *TestPluginController) GetTestPluginOptions(c *gin.Context) {
pname := c.Param("pname")
// Get admin-level options (nil user/domain/service IDs)
uc.GetTestPluginOptionsWithScope(c, pname, nil, nil, nil)
}
// AddTestPluginOptions adds or overwrites specific admin-level options for a test plugin.
//
// @Summary Add test plugin options (admin)
// @Schemes
// @Description Adds or overwrites specific admin-level configuration options for a test plugin without affecting other options.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pname path string true "Plugin name"
// @Param body body happydns.SetPluginOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pname}/options [post]
func (uc *TestPluginController) AddTestPluginOptions(c *gin.Context) {
pname := c.Param("pname")
// Add admin-level options (nil user/domain/service IDs)
uc.AddTestPluginOptionsWithScope(c, pname, nil, nil, nil)
}
// ChangeTestPluginOptions replaces all admin-level options for a test plugin.
//
// @Summary Replace test plugin options (admin)
// @Schemes
// @Description Replaces all admin-level configuration options for a test plugin with the provided options.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pname path string true "Plugin name"
// @Param body body happydns.SetPluginOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pname}/options [put]
func (uc *TestPluginController) ChangeTestPluginOptions(c *gin.Context) {
pname := c.Param("pname")
// Replace admin-level options (nil user/domain/service IDs)
uc.ChangeTestPluginOptionsWithScope(c, pname, nil, nil, nil)
}
// GetTestPluginOption retrieves a specific admin-level option value for a test plugin.
//
// @Summary Get test plugin option (admin)
// @Schemes
// @Description Retrieves the value of a specific admin-level configuration option for a test plugin.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pname path string true "Plugin name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pname}/options/{optname} [get]
func (uc *TestPluginController) GetTestPluginOption(c *gin.Context) {
uc.GetTestPluginOptionValue(c)
}
// SetTestPluginOption sets or updates a specific admin-level option value for a test plugin.
//
// @Summary Set test plugin option (admin)
// @Schemes
// @Description Sets or updates the value of a specific admin-level configuration option for a test plugin.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pname path string true "Plugin name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pname}/options/{optname} [put]
func (uc *TestPluginController) SetTestPluginOption(c *gin.Context) {
pname := c.Param("pname")
optname := c.Param("optname")
// Set admin-level option (nil user/domain/service IDs)
uc.SetTestPluginOptionWithScope(c, pname, optname, nil, nil, nil)
}

View file

@ -0,0 +1,102 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// AdminSchedulerController handles admin operations on the test scheduler
type AdminSchedulerController struct {
scheduler happydns.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

@ -0,0 +1,58 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api-admin/controller"
"git.happydns.org/happyDomain/internal/storage"
"git.happydns.org/happyDomain/model"
)
func declarePluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies, store storage.Storage) {
apiPluginsRoutes := router.Group("/plugins")
declareTestPluginsRoutes(apiPluginsRoutes, dependancies)
}
func declareTestPluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
tpc := controller.NewTestPluginController(dependancies.TestPluginUsecase())
apiTestPluginsRoutes := router.Group("/tests")
apiTestPluginsRoutes.GET("", tpc.ListTestPlugins)
apiTestPluginRoutes := apiTestPluginsRoutes.Group("/:pname")
apiTestPluginRoutes.Use(tpc.TestPluginHandler)
apiTestPluginRoutes.GET("", tpc.GetTestPluginStatus)
//apiTestPluginRoutes.POST("", tpc.ChangeTestPluginStatus)
apiTestPluginRoutes.GET("/options", tpc.GetTestPluginOptions)
apiTestPluginRoutes.POST("/options", tpc.AddTestPluginOptions)
apiTestPluginRoutes.PUT("/options", tpc.ChangeTestPluginOptions)
apiTestPluginOptionsRoutes := apiTestPluginRoutes.Group("/options/:optname")
apiTestPluginOptionsRoutes.Use(tpc.TestPluginOptionHandler)
apiTestPluginOptionsRoutes.GET("", tpc.GetTestPluginOption)
apiTestPluginOptionsRoutes.PUT("", tpc.SetTestPluginOption)
}

View file

@ -34,7 +34,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
declareBackupRoutes(cfg, apiRoutes, s)
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

@ -0,0 +1,131 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/middleware"
"git.happydns.org/happyDomain/model"
)
// BaseTestPluginController contains shared functionality for test plugin controllers.
// It provides common methods that can be used by both admin and user-scoped controllers.
type BaseTestPluginController struct {
testPluginService happydns.TestPluginUsecase
}
func NewBaseTestPluginController(testPluginService happydns.TestPluginUsecase) *BaseTestPluginController {
return &BaseTestPluginController{
testPluginService,
}
}
// GetTestPluginService returns the test plugin service for use by derived controllers.
func (bc *BaseTestPluginController) GetTestPluginService() happydns.TestPluginUsecase {
return bc.testPluginService
}
// ListTestPlugins retrieves all available test plugins.
func (bc *BaseTestPluginController) ListTestPlugins(c *gin.Context) {
plugins, err := bc.testPluginService.ListTestPlugins()
if err != nil {
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
return
}
ret := map[string]happydns.PluginVersionInfo{}
for _, p := range plugins {
pnames := p.PluginEnvName()
ret[pnames[0]] = p.Version()
}
happydns.ApiResponse(c, ret, nil)
}
// GetTestPluginStatus retrieves the status and available options for a test plugin.
func (bc *BaseTestPluginController) GetTestPluginStatus(c *gin.Context) {
plugin := c.MustGet("plugin").(happydns.TestPlugin)
c.JSON(http.StatusOK, happydns.PluginStatus{
PluginVersionInfo: plugin.Version(),
Opts: plugin.AvailableOptions(),
})
}
// GetTestPluginOptionsWithScope retrieves all options for a test plugin with the given scope.
func (bc *BaseTestPluginController) GetTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
opts, err := bc.testPluginService.GetTestPluginOptions(pname, userId, domainId, serviceId)
happydns.ApiResponse(c, opts, err)
}
// AddTestPluginOptionsWithScope adds or overwrites specific options for a test plugin with the given scope.
func (bc *BaseTestPluginController) AddTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetPluginOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.testPluginService.OverwriteSomeTestPluginOptions(pname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// ChangeTestPluginOptionsWithScope replaces all options for a test plugin with the given scope.
func (bc *BaseTestPluginController) ChangeTestPluginOptionsWithScope(c *gin.Context, pname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req happydns.SetPluginOptionsRequest
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
err = bc.testPluginService.SetTestPluginOptions(pname, userId, domainId, serviceId, req.Options)
happydns.ApiResponse(c, true, err)
}
// GetTestPluginOptionValue retrieves a specific option value from the context.
func (bc *BaseTestPluginController) GetTestPluginOptionValue(c *gin.Context) {
opt := c.MustGet("option")
happydns.ApiResponse(c, opt, nil)
}
// SetTestPluginOptionWithScope sets or updates a specific option value for a test plugin with the given scope.
func (bc *BaseTestPluginController) SetTestPluginOptionWithScope(c *gin.Context, pname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
var req interface{}
err := c.ShouldBindJSON(&req)
if err != nil {
middleware.ErrorResponse(c, http.StatusBadRequest, err)
return
}
po := happydns.PluginOptions{}
po[optname] = req
err = bc.testPluginService.OverwriteSomeTestPluginOptions(pname, userId, domainId, serviceId, po)
happydns.ApiResponse(c, true, err)
}

View file

@ -0,0 +1,211 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/model"
)
// TestPluginController handles user-scoped plugin operations for the main API.
// All methods work with options scoped to the authenticated user.
type TestPluginController struct {
*BaseTestPluginController
}
func NewTestPluginController(testPluginService happydns.TestPluginUsecase) *TestPluginController {
return &TestPluginController{
BaseTestPluginController: NewBaseTestPluginController(testPluginService),
}
}
// ListTestPlugins retrieves all available test plugins.
//
// @Summary List all test plugins
// @Schemes
// @Description Returns a list of all available test plugins with their version information.
// @Tags plugins
// @Accept json
// @Produce json
// @Success 200 {object} map[string]happydns.PluginVersionInfo "Map of plugin names to version info"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests [get]
func (uc *TestPluginController) ListTestPlugins(c *gin.Context) {
uc.BaseTestPluginController.ListTestPlugins(c)
}
// GetTestPluginStatus retrieves the status and available options for a test plugin.
//
// @Summary Get test plugin status
// @Schemes
// @Description Retrieves the status information and available options for a specific test plugin.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Success 200 {object} happydns.PluginStatus "Plugin status with version info and available options"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Router /plugins/tests/{pid} [get]
func (uc *TestPluginController) GetTestPluginStatus(c *gin.Context) {
uc.BaseTestPluginController.GetTestPluginStatus(c)
}
// TestPluginHandler is a middleware that retrieves a test plugin by name and sets it in the context.
func (uc *TestPluginController) TestPluginHandler(c *gin.Context) {
pname := c.Param("pid")
plugin, err := uc.testPluginService.GetTestPlugin(pname)
if err != nil {
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Plugin not found"})
return
}
c.Set("plugin", plugin)
c.Next()
}
// TestPluginOptionHandler is a middleware that retrieves a specific plugin option for the authenticated user and sets it in the context.
func (uc *TestPluginController) TestPluginOptionHandler(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
pname := c.Param("pid")
optname := c.Param("optname")
opts, err := uc.testPluginService.GetTestPluginOptions(pname, &user.Id, nil, nil)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
return
}
c.Set("option", (*opts)[optname])
c.Next()
}
// GetTestPluginOptions retrieves all options for a test plugin for the authenticated user.
//
// @Summary Get test plugin options
// @Schemes
// @Description Retrieves all configuration options for a specific test plugin for the authenticated user.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Success 200 {object} happydns.PluginOptions "Plugin options as key-value pairs"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pid}/options [get]
func (uc *TestPluginController) GetTestPluginOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
pname := c.Param("pid")
uc.GetTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil)
}
// AddTestPluginOptions adds or overwrites specific options for a test plugin for the authenticated user.
//
// @Summary Add test plugin options
// @Schemes
// @Description Adds or overwrites specific configuration options for a test plugin for the authenticated user without affecting other options.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Param body body happydns.SetPluginOptionsRequest true "Options to add or overwrite"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pid}/options [post]
func (uc *TestPluginController) AddTestPluginOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
pname := c.Param("pid")
uc.AddTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil)
}
// ChangeTestPluginOptions replaces all options for a test plugin for the authenticated user.
//
// @Summary Replace test plugin options
// @Schemes
// @Description Replaces all configuration options for a test plugin for the authenticated user with the provided options.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Param body body happydns.SetPluginOptionsRequest true "New complete set of options"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pid}/options [put]
func (uc *TestPluginController) ChangeTestPluginOptions(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
pname := c.Param("pid")
uc.ChangeTestPluginOptionsWithScope(c, pname, &user.Id, nil, nil)
}
// GetTestPluginOption retrieves a specific option value for a test plugin for the authenticated user.
//
// @Summary Get test plugin option
// @Schemes
// @Description Retrieves the value of a specific configuration option for a test plugin for the authenticated user.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Param optname path string true "Option name"
// @Success 200 {object} object "Option value (type varies)"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pid}/options/{optname} [get]
func (uc *TestPluginController) GetTestPluginOption(c *gin.Context) {
uc.GetTestPluginOptionValue(c)
}
// SetTestPluginOption sets or updates a specific option value for a test plugin for the authenticated user.
//
// @Summary Set test plugin option
// @Schemes
// @Description Sets or updates the value of a specific configuration option for a test plugin for the authenticated user.
// @Tags plugins
// @Accept json
// @Produce json
// @Param pid path string true "Plugin name"
// @Param optname path string true "Option name"
// @Param body body object true "Option value (type varies by option)"
// @Success 200 {object} bool "Success status"
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
// @Failure 404 {object} happydns.ErrorResponse "Plugin not found"
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
// @Router /plugins/tests/{pid}/options/{optname} [put]
func (uc *TestPluginController) SetTestPluginOption(c *gin.Context) {
user := c.MustGet("LoggedUser").(*happydns.User)
pname := c.Param("pid")
optname := c.Param("optname")
uc.SetTestPluginOptionWithScope(c, pname, optname, &user.Id, nil, nil)
}

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,231 @@
// 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 schedules for the authenticated user
//
// @Summary List test schedules
// @Description Retrieves test schedules for the authenticated user with optional pagination
// @Tags test-schedules
// @Produce json
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
// @Param offset query int false "Number of schedules to skip (default: 0)"
// @Success 200 {array} happydns.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
}
// Apply pagination
limit := 0
offset := 0
fmt.Sscanf(c.Query("limit"), "%d", &limit)
fmt.Sscanf(c.Query("offset"), "%d", &offset)
if offset > len(schedules) {
offset = len(schedules)
}
schedules = schedules[offset:]
if limit > 0 && len(schedules) > limit {
schedules = schedules[:limit]
}
c.JSON(http.StatusOK, schedules)
}
// 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

@ -0,0 +1,49 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2026 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
happydns "git.happydns.org/happyDomain/model"
)
func DeclarePluginsRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
tpc := controller.NewTestPluginController(dependancies.TestPluginUsecase())
router.GET("/plugins/tests", tpc.ListTestPlugins)
apiTestPluginRoutes := router.Group("/plugins/tests/:pid")
apiTestPluginRoutes.Use(tpc.TestPluginHandler)
apiTestPluginRoutes.GET("", tpc.GetTestPluginStatus)
apiTestPluginRoutes.GET("/options", tpc.GetTestPluginOptions)
apiTestPluginRoutes.POST("/options", tpc.AddTestPluginOptions)
apiTestPluginRoutes.PUT("/options", tpc.ChangeTestPluginOptions)
apiTestPluginOptionsRoutes := apiTestPluginRoutes.Group("/options/:optname")
apiTestPluginOptionsRoutes.Use(tpc.TestPluginOptionHandler)
apiTestPluginOptionsRoutes.GET("", tpc.GetTestPluginOption)
apiTestPluginOptionsRoutes.PUT("", tpc.SetTestPluginOption)
}

View file

@ -77,9 +77,11 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dependancies happy
DeclareAuthenticationCheckRoutes(apiAuthRoutes, dependancies, lc)
DeclareDomainRoutes(apiAuthRoutes, dependancies)
DeclarePluginsRoutes(apiAuthRoutes, dependancies)
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,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 route
import (
"github.com/gin-gonic/gin"
"git.happydns.org/happyDomain/internal/api/controller"
"git.happydns.org/happyDomain/model"
)
// 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.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

@ -40,9 +40,11 @@ import (
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
pluginUC "git.happydns.org/happyDomain/internal/usecase/plugin"
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"
@ -63,6 +65,9 @@ type Usecases struct {
session happydns.SessionUsecase
service happydns.ServiceUsecase
serviceSpecs happydns.ServiceSpecsUsecase
testPlugin happydns.TestPluginUsecase
testResult happydns.TestResultUsecase
testSchedule happydns.TestScheduleUsecase
user happydns.UserUsecase
zone happydns.ZoneUsecase
zoneService happydns.ZoneServiceUsecase
@ -71,14 +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
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 {
@ -137,6 +144,22 @@ func (a *App) SessionUsecase() happydns.SessionUsecase {
return a.usecases.session
}
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
}
@ -166,7 +189,9 @@ func NewApp(cfg *happydns.Options) *App {
app.initStorageEngine()
app.initNewsletter()
app.initInsights()
app.initPlugins()
app.initUsecases()
app.initTestScheduler()
app.setupRouter()
return app
@ -180,7 +205,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
app.initMailer()
app.initNewsletter()
app.initPlugins()
app.initUsecases()
app.initTestScheduler()
app.setupRouter()
return app
@ -243,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)
@ -270,6 +310,9 @@ func (app *App) initUsecases() {
app.usecases.authUser = authUserService
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,
@ -310,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)
@ -331,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()
}
}

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

@ -0,0 +1,111 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package app
import (
"errors"
"fmt"
"log"
"os"
"path"
"plugin"
"git.happydns.org/happyDomain/model"
)
func (a *App) initPlugins() error {
manager := PluginManger{
testsIdx: map[string]happydns.TestPlugin{},
}
a.plugins = &manager
var ret error
for _, directory := range a.cfg.PluginsDirectories {
files, err := os.ReadDir(directory)
if err != nil {
ret = errors.Join(ret, err)
continue
}
for _, file := range files {
if file.IsDir() {
continue
}
fname := path.Join(directory, file.Name())
err = manager.loadPlugin(fname)
if err != nil {
ret = errors.Join(ret, fmt.Errorf("unable to load plugin %q: %w", fname, err))
}
}
}
return ret
}
type PluginManger struct {
tests []happydns.TestPlugin
testsIdx map[string]happydns.TestPlugin
}
func (m *PluginManger) loadPlugin(fname string) error {
p, err := plugin.Open(fname)
if err != nil {
return err
}
newplugin, err := p.Lookup("NewTestPlugin")
if err != nil {
return err
}
myplugin, err := newplugin.(func() (happydns.TestPlugin, error))()
if err != nil {
return err
}
m.tests = append(m.tests, myplugin)
// Index the plugin by its names
pluginNames := myplugin.PluginEnvName()
for _, name := range pluginNames {
if p, exists := m.testsIdx[name]; exists {
log.Printf("Plugin name conflict: the plugin at %q tries to register the name %q but it's already registered by %q", fname, name, p.Version().Name)
continue
}
m.testsIdx[name] = myplugin
}
log.Printf("Plugin %s loaded (version %s)", myplugin.Version().Name, myplugin.Version().Version)
return nil
}
func (m *PluginManger) GetTestPlugins() []happydns.TestPlugin {
return m.tests
}
func (m *PluginManger) GetTestPluginsIndex() map[string]happydns.TestPlugin {
return m.testsIdx
}

View file

@ -0,0 +1,727 @@
// 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 and expired test results
func (s *testScheduler) cleanup() {
log.Println("Running scheduler cleanup...")
// Delete completed/failed execution records older than 7 days
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
log.Printf("Error cleaning up old executions: %v\n", err)
}
// Delete test results older than the configured retention period
if err := s.resultUsecase.CleanupOldResults(); err != nil {
log.Printf("Error cleaning up old test results: %v\n", err)
}
log.Println("Scheduler cleanup complete")
}
// 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
}
// Merge options: global defaults < user opts < domain/service opts < schedule opts
var domainId, serviceId *happydns.Identifier
switch schedule.TargetType {
case happydns.TestScopeDomain:
domainId = &schedule.TargetId
case happydns.TestScopeService:
serviceId = &schedule.TargetId
}
baseOptions, err := w.scheduler.pluginUsecase.GetTestPluginOptions(schedule.PluginName, &schedule.OwnerId, domainId, serviceId)
if err != nil {
log.Printf("Worker %d: warning, could not fetch plugin options for %s: %v\n", w.id, schedule.PluginName, err)
}
var mergedOptions happydns.PluginOptions
if baseOptions != nil {
mergedOptions = w.scheduler.scheduleUsecase.MergePluginOptions(nil, nil, *baseOptions, schedule.Options)
} else {
mergedOptions = schedule.Options
}
// Prepare metadata
meta := make(map[string]string)
meta["target_type"] = schedule.TargetType.String()
meta["target_id"] = schedule.TargetId.String()
// Run the test
startTime := time.Now()
resultChan := make(chan *happydns.PluginResult, 1)
errorChan := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
errorChan <- fmt.Errorf("plugin panicked: %v", r)
}
}()
result, err := plugin.RunTest(mergedOptions, 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

@ -57,6 +57,8 @@ func declareFlags(o *happydns.Options) {
flag.StringVar(&o.MailSMTPPassword, "mail-smtp-password", o.MailSMTPPassword, "Password associated with the given username for SMTP authentication")
flag.BoolVar(&o.MailSMTPTLSSNoVerify, "mail-smtp-tls-no-verify", o.MailSMTPTLSSNoVerify, "Do not verify certificate validity on SMTP connection")
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
}

View file

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

View file

@ -44,6 +44,7 @@ type InMemoryStorage struct {
domainLogs map[string]*happydns.DomainLogWithDomainId
domainLogsByDomains map[string][]*happydns.Identifier
providers map[string]*happydns.ProviderMessage
pluginsCfg map[string]*happydns.PluginOptions
sessions map[string]*happydns.Session
users map[string]*happydns.User
usersByEmail map[string]*happydns.User

View file

@ -26,8 +26,10 @@ import (
"git.happydns.org/happyDomain/internal/usecase/domain"
"git.happydns.org/happyDomain/internal/usecase/domain_log"
"git.happydns.org/happyDomain/internal/usecase/insight"
"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"
@ -43,8 +45,10 @@ type Storage interface {
domain.DomainStorage
domainlog.DomainLogStorage
insight.InsightStorage
plugin.PluginStorage
provider.ProviderStorage
session.SessionStorage
testresult.TestResultStorage
user.UserStorage
zone.ZoneStorage

View file

@ -0,0 +1,185 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package database
import (
"errors"
"fmt"
"strings"
"git.happydns.org/happyDomain/model"
)
func (s *KVStorage) ListAllPluginConfigurations() (happydns.Iterator[happydns.PluginOptions], error) {
iter := s.db.Search("plugincfg-")
return NewKVIterator[happydns.PluginOptions](s.db, iter), nil
}
func buildPluginKey(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
u := ""
if user != nil {
u = user.String()
}
d := ""
if domain != nil {
d = domain.String()
}
s := ""
if service != nil {
s = service.String()
}
return strings.Join([]string{pname, u, d, s}, "/")
}
func keyToPositional(key string, opts *happydns.PluginOptions) (*happydns.PluginOptionsPositional, error) {
tmp := strings.Split(key, "/")
if len(tmp) < 4 {
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
}
pname := tmp[0]
var userid *happydns.Identifier
if len(tmp[1]) > 0 {
u, err := happydns.NewIdentifierFromString(tmp[1])
if err != nil {
return nil, err
}
userid = &u
}
var domainid *happydns.Identifier
if len(tmp[2]) > 0 {
d, err := happydns.NewIdentifierFromString(tmp[2])
if err != nil {
return nil, err
}
domainid = &d
}
var serviceid *happydns.Identifier
if len(tmp[3]) > 0 {
s, err := happydns.NewIdentifierFromString(tmp[3])
if err != nil {
return nil, err
}
serviceid = &s
}
return &happydns.PluginOptionsPositional{
PluginName: pname,
UserId: userid,
DomainId: domainid,
ServiceId: serviceid,
Options: *opts,
}, nil
}
func (s *KVStorage) ListPluginConfiguration(pname string) (configs []*happydns.PluginOptionsPositional, err error) {
iter := s.db.Search("plugincfg-" + pname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.PluginOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "plugincfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
configs = append(configs, opts)
}
return
}
func (s *KVStorage) GetPluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.PluginOptionsPositional, err error) {
iter := s.db.Search("plugincfg-" + pname + "/")
defer iter.Release()
for iter.Next() {
var p happydns.PluginOptions
e := s.db.DecodeData(iter.Value(), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "plugincfg-"), &p)
if e != nil {
err = errors.Join(err, e)
continue
}
// Match logic:
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
matchUser := (user == nil && opts.UserId == nil) ||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
matchDomain := (domain == nil && opts.DomainId == nil) ||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
matchService := (service == nil && opts.ServiceId == nil) ||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
if matchUser && matchDomain && matchService {
configs = append(configs, opts)
}
}
return
}
func (s *KVStorage) UpdatePluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.PluginOptions) error {
return s.db.Put(fmt.Sprintf("plugincfg-%s", buildPluginKey(pname, user, domain, service)), opts)
}
func (s *KVStorage) DeletePluginConfiguration(pname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
return s.db.Delete(fmt.Sprintf("plugincfg-%s", buildPluginKey(pname, user, domain, service)))
}
func (s *KVStorage) ClearPluginConfigurations() error {
iter := s.db.Search("plugincfg-")
defer iter.Release()
for iter.Next() {
err := s.db.Delete(iter.Key())
if err != nil {
return err
}
}
return nil
}

View file

@ -0,0 +1,486 @@
// 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
}
// DeleteTestResultsBefore removes all test results with ExecutedAt older than cutoff
func (s *KVStorage) DeleteTestResultsBefore(cutoff time.Time) error {
iter := s.db.Search("testresult|")
defer iter.Release()
var toDelete []string
for iter.Next() {
var r happydns.TestResult
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
continue
}
if r.ExecutedAt.Before(cutoff) {
toDelete = append(toDelete, string(iter.Key()))
}
}
for _, key := range toDelete {
if err := s.db.Delete(key); 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)
}
// DeleteCompletedExecutionsBefore removes completed or failed execution records older than cutoff
func (s *KVStorage) DeleteCompletedExecutionsBefore(cutoff time.Time) error {
iter := s.db.Search("testexec|")
defer iter.Release()
var toDelete []string
for iter.Next() {
var exec happydns.TestExecution
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
continue
}
if exec.Status != happydns.TestExecutionCompleted && exec.Status != happydns.TestExecutionFailed {
continue
}
if exec.CompletedAt != nil && exec.CompletedAt.Before(cutoff) {
toDelete = append(toDelete, string(iter.Key()))
}
}
for _, key := range toDelete {
if err := s.db.Delete(key); err != nil {
return err
}
}
return nil
}
// 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

@ -0,0 +1,46 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package plugin
import (
"git.happydns.org/happyDomain/model"
)
type PluginStorage interface {
// ListAllPluginConfigurations retrieves the list of known Providers.
ListAllPluginConfigurations() (happydns.Iterator[happydns.PluginOptions], error)
// ListPluginConfiguration retrieves all providers own by the given User.
ListPluginConfiguration(string) ([]*happydns.PluginOptionsPositional, error)
// GetPluginConfiguration retrieves the full Provider with the given identifier and owner.
GetPluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.PluginOptionsPositional, error)
// UpdatePluginConfiguration updates the fields of the given Provider.
UpdatePluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.PluginOptions) error
// DeletePluginConfiguration removes the given Provider from the database.
DeletePluginConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
// ClearPluginConfigurations deletes all Providers present in the database.
ClearPluginConfigurations() error
}

View file

@ -0,0 +1,163 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package plugin
import (
"fmt"
"maps"
"sort"
"git.happydns.org/happyDomain/model"
)
type testPluginUsecase struct {
config *happydns.Options
manager happydns.PluginManager
store PluginStorage
}
func NewTestPluginUsecase(cfg *happydns.Options, manager happydns.PluginManager, store PluginStorage) happydns.TestPluginUsecase {
return &testPluginUsecase{
config: cfg,
manager: manager,
store: store,
}
}
func (tu *testPluginUsecase) GetTestPlugin(pname string) (happydns.TestPlugin, error) {
if plugin, ok := tu.manager.GetTestPluginsIndex()[pname]; !ok {
return nil, fmt.Errorf("unable to find plugin named %q", pname)
} else {
return plugin, nil
}
}
type ByOptionPosition []*happydns.PluginOptionsPositional
func (a ByOptionPosition) Len() int { return len(a) }
func (a ByOptionPosition) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a ByOptionPosition) Less(i, j int) bool {
if a[i].PluginName != a[j].PluginName {
return a[i].PluginName < a[j].PluginName
}
if res := compareIdentifiers(a[i].UserId, a[j].UserId); res != 0 {
return res < 0
}
if res := compareIdentifiers(a[i].DomainId, a[j].DomainId); res != 0 {
return res < 0
}
if res := compareIdentifiers(a[i].ServiceId, a[j].ServiceId); res != 0 {
return res < 0
}
return false
}
func compareIdentifiers(a, b *happydns.Identifier) int {
if a == nil && b == nil {
return 0
}
if a == nil {
return -1
}
if b == nil {
return 1
}
if a.Equals(*b) {
return 0
}
return a.Compare(*b)
}
func (tu *testPluginUsecase) GetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.PluginOptions, error) {
configs, err := tu.store.GetPluginConfiguration(pname, userid, domainid, serviceid)
if err != nil {
return nil, err
}
sort.Sort(ByOptionPosition(configs))
opts := make(happydns.PluginOptions)
for _, c := range configs {
maps.Copy(opts, c.Options)
}
return &opts, nil
}
func (tu *testPluginUsecase) ListTestPlugins() ([]happydns.TestPlugin, error) {
return tu.manager.GetTestPlugins(), nil
}
func (tu *testPluginUsecase) SetTestPluginOptions(pname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.PluginOptions) error {
// 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 {
current, err := tu.GetTestPluginOptions(pname, userid, domainid, serviceid)
if err != nil {
return err
}
maps.Copy(*current, opts)
return tu.store.UpdatePluginConfiguration(pname, userid, domainid, serviceid, *current)
}

View file

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

View file

@ -0,0 +1,104 @@
// 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
// DeleteTestResultsBefore removes all test results older than the given time
DeleteTestResultsBefore(cutoff time.Time) 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
// DeleteCompletedExecutionsBefore removes completed or failed execution records older than the given time
DeleteCompletedExecutionsBefore(cutoff time.Time) 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,206 @@
// 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 the configured retention period
func (u *TestResultUsecase) CleanupOldResults() error {
retentionDays := u.options.ResultRetentionDays
if retentionDays <= 0 {
retentionDays = 90 // Default
}
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
return u.storage.DeleteTestResultsBefore(cutoffTime)
}
// 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 completed or failed execution records older than olderThan
func (u *TestResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
cutoffTime := time.Now().Add(-olderThan)
return u.storage.DeleteCompletedExecutionsBefore(cutoffTime)
}

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

@ -92,6 +92,20 @@ type Options struct {
MailSMTPTLSSNoVerify bool
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."

View file

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

103
model/plugin.go Normal file
View file

@ -0,0 +1,103 @@
// This file is part of the happyDomain (R) project.
// Copyright (c) 2020-2025 happyDomain
// Authors: Pierre-Olivier Mercier, et al.
//
// This program is offered under a commercial and under the AGPL license.
// For commercial licensing, contact us at <contact@happydomain.org>.
//
// For AGPL licensing:
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package happydns
const (
PluginResultStatusKO PluginResultStatus = iota
PluginResultStatusWarn
PluginResultStatusInfo
PluginResultStatusOK
)
type PluginResultStatus int
type PluginOptions map[string]interface{}
type SetPluginOptionsRequest struct {
Options PluginOptions `json:"options"`
}
type PluginOptionsPositional struct {
PluginName string
UserId *Identifier
DomainId *Identifier
ServiceId *Identifier
Options PluginOptions
}
type TestPlugin interface {
PluginEnvName() []string
Version() PluginVersionInfo
AvailableOptions() PluginOptionsDocumentation
RunTest(options PluginOptions, meta map[string]string) (*PluginResult, error)
}
type PluginVersionInfo struct {
Name string `json:"name"`
Version string `json:"version"`
AvailableOn PluginAvailability `json:"availableOn"`
}
type PluginAvailability struct {
ApplyToDomain bool `json:"applyToDomain,omitempty"`
ApplyToService bool `json:"applyToService,omitempty"`
LimitToProviders []string `json:"limitToProviders,omitempty"`
LimitToServices []string `json:"limitToServices,omitempty"`
}
type PluginOptionsDocumentation struct {
RunOpts []PluginOptionDocumentation `json:"runOpts,omitempty"`
ServiceOpts []PluginOptionDocumentation `json:"serviceOpts,omitempty"`
DomainOpts []PluginOptionDocumentation `json:"domainOpts,omitempty"`
UserOpts []PluginOptionDocumentation `json:"userOpts,omitempty"`
AdminOpts []PluginOptionDocumentation `json:"adminOpts,omitempty"`
Variables []PluginVariableDocumentation `json:"vars,omitempty"`
}
type PluginOptionDocumentation Field
type PluginVariableDocumentation Field
type PluginStatus struct {
PluginVersionInfo
Opts PluginOptionsDocumentation `json:"options"`
}
type PluginResult struct {
Status PluginResultStatus `json:"status"`
StatusLine string `json:"statusLine,omitempty"`
Report interface{} `json:"report"`
}
type PluginManager interface {
GetTestPlugins() []TestPlugin
GetTestPluginsIndex() map[string]TestPlugin
}
type TestPluginUsecase interface {
GetTestPlugin(string) (TestPlugin, error)
GetTestPluginOptions(string, *Identifier, *Identifier, *Identifier) (*PluginOptions, error)
ListTestPlugins() ([]TestPlugin, error)
OverwriteSomeTestPluginOptions(string, *Identifier, *Identifier, *Identifier, PluginOptions) error
SetTestPluginOptions(string, *Identifier, *Identifier, *Identifier, PluginOptions) error
}

307
model/test_result.go Normal file
View file

@ -0,0 +1,307 @@
// 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 {
TriggerOnDemandTest(pluginName string, targetType TestScopeType, targetID Identifier, userID Identifier, options PluginOptions) (Identifier, error)
GetSchedulerStatus() SchedulerStatus
SetEnabled(enabled bool) error
RescheduleUpcomingTests() (int, error)
}

View file

@ -34,6 +34,10 @@ type UsecaseDependancies interface {
ServiceUsecase() ServiceUsecase
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 {

1
plugins/.gitignore vendored Normal file
View file

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

7
plugins/matrix/Makefile Normal file
View file

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

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

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

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

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

View file

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

View file

@ -101,6 +101,12 @@
<NavItem>
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
</NavItem>
<NavItem>
<NavLink href="/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,143 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { getPluginsTests } from '$lib/api-admin';
let pluginsQ = $state(getPluginsTests());
let searchQuery = $state('');
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
Plugins Management
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead">
Manage all test plugins
</span>
{#await pluginsQ then pluginsR}
<span>Total: {Object.keys(pluginsR.data ?? {}).length} plugins</span>
{/await}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input
type="text"
placeholder="Search plugins..."
bind:value={searchQuery}
/>
</InputGroup>
</Col>
</Row>
{#await pluginsQ}
Please wait...
{:then pluginsR}
{@const plugins = pluginsR.data}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>Plugin Name</th>
<th>Version</th>
<th>Availability</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{#if !plugins || Object.keys(plugins).length == 0}
<tr>
<td colspan="4" class="text-center text-muted py-2">
No plugins available
</td>
</tr>
{:else}
{#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>
<td>
{#if pluginInfo.availableOn}
{#if pluginInfo.availableOn.applyToDomain}
<Badge color="success">Domain</Badge>
{/if}
{#if pluginInfo.availableOn.limitToProviders && pluginInfo.availableOn.limitToProviders.length > 0}
<Badge color="primary" title={pluginInfo.availableOn.limitToProviders.join(', ')}>
Provider-specific
</Badge>
{/if}
{#if pluginInfo.availableOn.limitToServices && pluginInfo.availableOn.limitToServices.length > 0}
<Badge color="info" title={pluginInfo.availableOn.limitToServices.join(', ')}>
Service-specific
</Badge>
{/if}
{:else}
<Badge color="secondary">General</Badge>
{/if}
</td>
<td>
<a href="/plugins/{pluginName}" class="btn btn-sm btn-primary">
<Icon name="gear-fill"></Icon>
Manage
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{:catch error}
<Card body color="danger">
<p class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
Error loading plugins: {error.message}
</p>
</Card>
{/await}
</Container>

View file

@ -0,0 +1,320 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from '$app/state';
import { t } from '$lib/translations';
import { toasts } from '$lib/stores/toasts';
import {
getPluginsTestsByPnameOptions,
putPluginsTestsByPnameOptions,
} from '$lib/api-admin';
import { getPluginStatus } from '$lib/api/plugins';
import Resource from '$lib/components/inputs/Resource.svelte';
import PluginOptionsGroups from '$lib/components/plugins/PluginOptionsGroups.svelte';
let pname = $derived(page.params.pname!);
let pluginStatusQ = $derived(getPluginStatus(pname));
let pluginOptionsQ = $derived(getPluginsTestsByPnameOptions({ path: { pname } }));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
pluginOptionsQ.then((optionsR) => {
optionValues = { ...(optionsR.data as Record<string, unknown> || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await putPluginsTestsByPnameOptions({
path: { pname },
body: { options: optionValues }
});
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
toasts.addToast({
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) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(adminOpts: any[]) {
const validOptIds = new Set(adminOpts.map(opt => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await putPluginsTestsByPnameOptions({
path: { pname },
body: { options: cleanedOptions }
});
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
toasts.addToast({
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) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(adminOpts: any[]): string[] {
const validOptIds = new Set(adminOpts.map(opt => opt.id));
return Object.keys(optionValues).filter(key => !validOptIds.has(key));
}
</script>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/plugins" class="mb-2">
<Icon name="arrow-left"></Icon>
{$t("plugins.tests.back-button")}
</Button>
<h1 class="display-5">
<Icon name="puzzle-fill"></Icon>
{pname}
</h1>
</Col>
</Row>
{#await pluginStatusQ}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("plugins.tests.loading-info")}
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<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>
<dd class="col-sm-8">{status.name}</dd>
<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>
<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
>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
{$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(', '),
})}
</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}
</div>
{:else}
<Badge color="secondary"
>{$t("plugins.tests.availability.general")}</Badge
>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</Col>
<Col md={6}>
{#await pluginOptionsQ}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("plugins.tests.detail.loading-options")}
</p>
</CardBody>
</Card>
{:then _optionsR}
{@const adminOpts = status.options?.adminOpts || []}
{@const readOnlyOptGroups = [
{ label: $t("plugins.tests.option-groups.global-settings"), opts: status.options?.userOpts || [] },
{ label: $t("plugins.tests.option-groups.domain-settings"), opts: status.options?.domainOpts || [] },
{ label: $t("plugins.tests.option-groups.service-settings"), opts: status.options?.serviceOpts || [] },
{ label: $t("plugins.tests.option-groups.test-parameters"), opts: status.options?.runOpts || [] },
]}
{@const hasAnyOpts = adminOpts.length > 0 || readOnlyOptGroups.some(g => g.opts.length > 0)}
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
{#if orphanedOpts.length > 0}
<Alert color="warning" class="mb-3">
<div class="d-flex justify-content-between align-items-center">
<div>
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.detail.orphaned-options", {
options: orphanedOpts.join(', '),
})}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(adminOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
{$t("plugins.tests.detail.clean-up")}
</Button>
</div>
</Alert>
{/if}
{#if adminOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{$t("plugins.tests.detail.configuration")}</strong>
</CardHeader>
<CardBody>
<Form on:submit={saveOptions}>
{#each adminOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || 'string'}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span class="spinner-border spinner-border-sm me-1"></span>
{/if}
<Icon name="check-circle"></Icon>
{$t("plugins.tests.detail.save-changes")}
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<PluginOptionsGroups groups={readOnlyOptGroups} t={$t} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("plugins.tests.detail.no-configurable-options")}
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.detail.error-loading-options", {
error: error.message,
})}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$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 })}
</Alert>
{/await}
</Container>

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>

View file

@ -0,0 +1,91 @@
// 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 {
getPluginsTests,
getPluginsTestsByPid,
getPluginsTestsByPidOptions,
postPluginsTestsByPidOptions,
putPluginsTestsByPidOptions,
getPluginsTestsByPidOptionsByOptname,
putPluginsTestsByPidOptionsByOptname,
} from "$lib/api-base/sdk.gen";
import { unwrapSdkResponse } from "./errors";
import type {
PluginList,
PluginStatus,
PluginOptions,
} from "$lib/model/plugin";
export async function listPlugins(): Promise<PluginList> {
return unwrapSdkResponse(await getPluginsTests()) as PluginList;
}
export async function getPluginStatus(pluginId: string): Promise<PluginStatus> {
return unwrapSdkResponse(
await getPluginsTestsByPid({
path: { pid: pluginId },
}),
) as PluginStatus;
}
export async function getPluginOptions(pluginId: string): Promise<PluginOptions> {
return unwrapSdkResponse(
await getPluginsTestsByPidOptions({
path: { pid: pluginId },
}),
) as PluginOptions;
}
export async function addPluginOptions(pluginId: string, options: PluginOptions): Promise<boolean> {
return unwrapSdkResponse(
await postPluginsTestsByPidOptions({
path: { pid: pluginId },
body: { options } as any,
}),
) as boolean;
}
export async function updatePluginOptions(pluginId: string, options: PluginOptions): Promise<boolean> {
return unwrapSdkResponse(
await putPluginsTestsByPidOptions({
path: { pid: pluginId },
body: { options } as any,
}),
) as boolean;
}
export async function getPluginOption(pluginId: string, optionName: string): Promise<any> {
return unwrapSdkResponse(
await getPluginsTestsByPidOptionsByOptname({
path: { pid: pluginId, optname: optionName },
}),
);
}
export async function setPluginOption(pluginId: string, optionName: string, value: any): Promise<boolean> {
return unwrapSdkResponse(
await putPluginsTestsByPidOptionsByOptname({
path: { pid: pluginId, optname: optionName },
body: value as any,
}),
) as boolean;
}

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

@ -24,7 +24,7 @@
<script lang="ts">
import { goto } from "$app/navigation";
import { page } from "$app/state";
import type { ClassValue } from 'svelte/elements';
import type { ClassValue } from "svelte/elements";
import {
Button,
@ -47,7 +47,6 @@
import { toasts } from "$lib/stores/toasts";
import { t, locales, locale } from "$lib/translations";
interface Props {
class?: ClassValue;
sw_state: { triedUpdate: boolean; hasUpdate: boolean };
@ -55,15 +54,13 @@
let { class: className, sw_state }: Props = $props();
let helpLink = $derived(
page.route && page.route.id ? (
page.route.id.startsWith("/providers/new/[ptype]") ? (
getHelpPathFromProvider(page.url.pathname.split("/")[3])
) : (
"https://help.happydomain.org/" + encodeURIComponent($locale) + getHelpPathFromRoute(page.route.id)
)
) : (
"https://help.happydomain.org/" + encodeURIComponent($locale)
)
page.route && page.route.id
? page.route.id.startsWith("/providers/new/[ptype]")
? getHelpPathFromProvider(page.url.pathname.split("/")[3])
: "https://help.happydomain.org/" +
encodeURIComponent($locale) +
getHelpPathFromRoute(page.route.id)
: "https://help.happydomain.org/" + encodeURIComponent($locale),
);
function getHelpPathFromProvider(ptype: string): string {
@ -187,11 +184,15 @@
>
{$t("menu.dns-resolver")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem
active={page.route && page.route.id == "/me"}
href="/me"
active={page.route &&
(page.route.id == "/plugins" || page.route.id?.startsWith("/plugins/"))}
href="/plugins"
>
{$t("menu.plugins")}
</DropdownItem>
<DropdownItem divider />
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
{$t("menu.my-account")}
</DropdownItem>
{#if $userSession.email !== "_no_auth"}
@ -232,10 +233,7 @@
<DropdownToggle nav caret>{$locale}</DropdownToggle>
<DropdownMenu end>
{#each $locales as lang}
<DropdownItem
active={$locale == lang}
on:click={() => ($locale = lang)}
>
<DropdownItem active={$locale == lang} on:click={() => ($locale = lang)}>
{$t(`locales.${lang}`)}
</DropdownItem>
{/each}

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

@ -0,0 +1,89 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
interface OptionDef {
id?: string;
label?: string;
type?: string;
default?: unknown;
placeholder?: string;
description?: string;
required?: boolean;
}
interface OptionGroup {
label: string;
opts: OptionDef[];
}
interface Props {
groups: OptionGroup[];
t: (key: string, params?: object) => string;
}
let { groups, t }: Props = $props();
</script>
{#each groups as optGroup}
{#if optGroup.opts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{optGroup.label}</strong>
<small class="text-muted ms-2">{t("plugins.tests.detail.read-only")}</small>
</CardHeader>
<CardBody>
<dl class="row mb-0">
{#each optGroup.opts as optDoc}
{@const optName = optDoc.id!}
<dt class="col-sm-4">
{optDoc.label || optDoc.id}:
</dt>
<dd class="col-sm-8">
{#if optDoc.default}
<span class="text-muted d-block">{optDoc.default}</span>
{:else if optDoc.placeholder}
<em class="text-muted d-block">{optDoc.placeholder}</em>
{/if}
{#if optDoc.description}
<small class="text-muted d-block">{optDoc.description}</small>
{/if}
<small class="text-muted"
>{t("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>
</CardBody>
</Card>
{/if}
{/each}

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": {
@ -235,6 +236,7 @@
"my-domains": "My domains",
"my-providers": "My domain providers",
"dns-resolver": "DNS resolver",
"plugins": "Domain Testers",
"my-account": "My account",
"logout": "Sign out",
"provider-features": "Supported providers",
@ -472,7 +474,7 @@
"showrrtypes-title": "Show DNS record types",
"preferences": {
"title": "Preferences",
"description": "Customize your preferences"
"description": "Customize your preferences"
},
"security": {
"title": "Security & Access",
@ -535,10 +537,193 @@
"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",
"relative": {
"in-less-than-a-minute": "in less than a minute",
"just-now": "just now",
"in": "in {{label}}",
"ago": "{{label}} ago"
},
"status": {
"ok": "OK",
"info": "Info",
"warning": "Warning",
"error": "Error",
"unknown": "Unknown",
"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",
"description": "Configure automated tests for your domains",
"available-count": "Available: {{count}} tests",
"search-placeholder": "Search tests...",
"loading": "Loading tests...",
"loading-info": "Loading test information...",
"no-tests": "No tests available",
"error-loading": "Error loading tests: {{error}}",
"error-loading-test": "Error loading test: {{error}}",
"test-info-not-found": "Error: Test information not found",
"table": {
"name": "Test Name",
"version": "Version",
"availability": "Availability",
"actions": "Actions"
},
"availability": {
"domain": "Domain",
"provider-specific": "Provider-specific",
"service-specific": "Service-specific",
"general": "General",
"domain-level": "Domain-level",
"providers": "Providers: {{providers}}",
"services": "Services: {{services}}"
},
"actions": {
"configure": "Configure"
},
"detail": {
"test-information": "Test Information",
"name": "Name:",
"version": "Version:",
"availability": "Availability:",
"loading-options": "Loading options...",
"configuration": "Configuration",
"save-changes": "Save Changes",
"no-configurable-options": "This test has no configurable options.",
"error-loading-options": "Error loading options: {{error}}",
"orphaned-options": "Orphaned options detected: {{options}}",
"clean-up": "Clean Up",
"read-only": "(Read-only)"
},
"option-groups": {
"global-settings": "Global Settings",
"domain-settings": "Domain-specific Settings",
"service-settings": "Service-specific Settings",
"test-parameters": "Test Parameters",
"type": "Type: {{type}}",
"required": "Required"
},
"messages": {
"options-updated": "Plugin options updated successfully",
"options-cleaned": "Orphaned options removed successfully",
"update-failed": "Failed to update options: {{error}}",
"clean-failed": "Failed to clean options: {{error}}"
},
"back-button": "Back to Plugins"
}
},
"zones": {
"upload": "Import a zone",
"import-text": "Import from text",
"import-file": "Import from file",
"return-to": "Go to the zone"
"return-to": "Go to the zone",
"return-to-results": "Back to Results",
"return-to-tests": "Back to Tests"
}
}

View file

@ -473,6 +473,86 @@
"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"
},
"never": "Jamais",
"na": "N/A",
"relative": {
"in-less-than-a-minute": "dans moins d'une minute",
"just-now": "à l'instant",
"in": "dans {{label}}",
"ago": "il y a {{label}}"
}
},
"plugins": {
"tests": {
"title": "Tests de domaines",
"description": "Configurez les tests automatisés pour vos domaines",
"available-count": "Disponibles : {{count}} tests",
"search-placeholder": "Rechercher des tests...",
"loading": "Chargement des tests...",
"loading-info": "Chargement des informations du test...",
"no-tests": "Aucun test disponible",
"error-loading": "Erreur lors du chargement des tests : {{error}}",
"error-loading-test": "Erreur lors du chargement du test : {{error}}",
"test-info-not-found": "Erreur : Informations du test introuvables",
"back-to-tests": "Retour aux tests",
"table": {
"name": "Nom du test",
"version": "Version",
"availability": "Disponibilité",
"actions": "Actions"
},
"availability": {
"domain": "Domaine",
"provider-specific": "Spécifique au fournisseur",
"service-specific": "Spécifique au service",
"general": "Général",
"domain-level": "Niveau domaine",
"providers": "Fournisseurs : {{providers}}",
"services": "Services : {{services}}"
},
"actions": {
"configure": "Configurer"
},
"detail": {
"test-information": "Informations du test",
"name": "Nom :",
"version": "Version :",
"availability": "Disponibilité :",
"loading-options": "Chargement des options...",
"configuration": "Configuration",
"save-changes": "Enregistrer les modifications",
"no-configurable-options": "Ce test n'a pas d'options configurables.",
"error-loading-options": "Erreur lors du chargement des options : {{error}}",
"orphaned-options": "Options orphelines détectées : {{options}}",
"clean-up": "Nettoyer",
"read-only": "(Lecture seule)"
},
"option-groups": {
"global-settings": "Paramètres globaux",
"domain-settings": "Paramètres spécifiques au domaine",
"service-settings": "Paramètres spécifiques au service",
"test-parameters": "Paramètres du test",
"type": "Type : {{type}}",
"required": "Requis"
},
"messages": {
"options-updated": "Options du plugin mises à jour avec succès",
"options-cleaned": "Options orphelines supprimées avec succès",
"update-failed": "Échec de la mise à jour des options : {{error}}",
"clean-failed": "Échec du nettoyage des options : {{error}}"
},
"back-button": "Retour aux plugins"
}
},
"zones": {
"upload": "Importer une zone",
"import-text": "Depuis du texte",

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

@ -31,3 +31,59 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
return null;
}
}
/**
* Format a date string for display in test UI
* @param dateString ISO date string or undefined
* @param style Display style: "short", "medium", or "long"
* @param t i18n translation function
* @returns Formatted date string, or $t("tests.never") if undefined/invalid
*/
export function formatTestDate(
dateString: string | undefined,
style: "short" | "medium" | "long",
t: (k: string) => 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: style,
timeStyle: "short",
}).format(d);
}
/**
* Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago")
* @param dateString ISO date string or undefined
* @param t i18n translation function
* @returns Relative time string, or empty string if undefined/invalid
*/
export function formatRelative(dateString: string | undefined, t: (k: string) => string): string {
if (!dateString) return "";
const d = new Date(dateString);
if (isNaN(d.getTime())) return "";
const now = new Date();
const diffMs = d.getTime() - now.getTime();
const absDiffMs = Math.abs(diffMs);
if (absDiffMs < 60_000)
return diffMs > 0 ? t("tests.relative.in-less-than-a-minute") : t("tests.relative.just-now");
const minutes = Math.floor(absDiffMs / 60_000);
const hours = Math.floor(absDiffMs / 3_600_000);
const days = Math.floor(absDiffMs / 86_400_000);
let label: string;
if (days > 0) {
label = `${days}d ${hours % 24}h`;
} else if (hours > 0) {
label = `${hours}h ${minutes % 60}m`;
} else {
label = `${minutes}m`;
}
return diffMs > 0
? t("tests.relative.in").replace("{{label}}", label)
: t("tests.relative.ago").replace("{{label}}", label);
}

View file

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

39
web/src/lib/utils/test.ts Normal file
View file

@ -0,0 +1,39 @@
import { PluginResultStatus } from "$lib/model/test";
export 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";
}
}
export 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";
}
}
export function formatDuration(duration: number | undefined, t: (k: string) => string): 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")}`;
}

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,221 @@
<!--
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 { TestScopeType, type AvailableTest } from "$lib/model/test";
import { plugins } from "$lib/stores/plugins";
import { toasts } from "$lib/stores/toasts";
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
import { getStatusColor, getStatusKey, formatTestDate } from "$lib/utils";
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 (e: any) {
toasts.addErrorToast({ title: $t("tests.list.error-loading", { error: e.message }) });
} finally {
const after = new Set(togglingTests);
after.delete(test.plugin_name);
togglingTests = after;
}
}
</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">
{formatTestDate(test.last_result?.executed_at, "short", $t)}
</td>
<td class="align-middle">
<div class="form-check form-switch mb-0">
<input
class="form-check-input"
type="checkbox"
role="switch"
id="toggle-{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,276 @@
<!--
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";
import { formatTestDate, formatRelative } from "$lib/utils";
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();
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>
{formatTestDate(test.schedule.last_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.last_run, $t)})
</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>
{formatTestDate(test.schedule.next_run, "medium", $t)}
<small class="text-muted">
({formatRelative(test.schedule.next_run, $t)})
</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,237 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<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 RunTestModal from "$lib/components/modals/RunTestModal.svelte";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
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 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">
{formatTestDate(result.executed_at, "short", $t)}
</td>
<td class="align-middle text-center">
<Badge color={getStatusColor(result.status)}>
{$t(getStatusKey(result.status))}
</Badge>
</td>
<td class="align-middle">
{result.status_line}
{#if result.error}
<br />
<small class="text-danger">{result.error}</small>
{/if}
</td>
<td class="align-middle">
{formatDuration(result.duration, $t)}
</td>
<td class="align-middle text-center">
{#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,309 @@
<!--
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 type { TestResult } from "$lib/model/test";
import { getStatusColor, getStatusKey, formatDuration, formatTestDate } from "$lib/utils";
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<TestResult | null>(null);
let isRelaunching = $state(false);
$effect(() => {
resultPromise.then((r) => {
resolvedResult = r;
});
});
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>{formatTestDate(result.executed_at, "long", $t)}</td>
</tr>
<tr>
<th>{$t("tests.result.field.duration")}</th>
<td>{formatDuration(result.duration, $t)}</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

@ -0,0 +1,150 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Card,
Col,
Container,
Icon,
Input,
InputGroup,
InputGroupText,
Table,
Row,
Badge,
} from "@sveltestrap/sveltestrap";
import { t } from '$lib/translations';
import { plugins, refreshPlugins } from '$lib/stores/plugins';
let searchQuery = $state('');
// Load plugins if not already loaded
$effect(() => {
if ($plugins === undefined) {
refreshPlugins();
}
});
</script>
<svelte:head>
<title>{$t('plugins.tests.title')} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col md={8}>
<h1 class="display-5">
<Icon name="check-circle-fill"></Icon>
{$t('plugins.tests.title')}
</h1>
<p class="d-flex gap-3 align-items-center text-muted">
<span class="lead">
{$t('plugins.tests.description')}
</span>
{#if $plugins}
<span>{$t('plugins.tests.available-count', { count: Object.keys($plugins).length })}</span>
{/if}
</p>
</Col>
</Row>
<Row class="mb-4">
<Col md={8} lg={6}>
<InputGroup>
<InputGroupText>
<Icon name="search"></Icon>
</InputGroupText>
<Input
type="text"
placeholder={$t('plugins.tests.search-placeholder')}
bind:value={searchQuery}
/>
</InputGroup>
</Col>
</Row>
{#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>
{:else}
<div class="table-responsive">
<Table hover bordered>
<thead>
<tr>
<th>{$t('plugins.tests.table.name')}</th>
<th>{$t('plugins.tests.table.version')}</th>
<th>{$t('plugins.tests.table.availability')}</th>
<th>{$t('plugins.tests.table.actions')}</th>
</tr>
</thead>
<tbody>
{#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]}
<tr>
<td><strong>{pluginInfo.name || pluginName}</strong></td>
<td>{pluginInfo.version}</td>
<td>
{#if pluginInfo.availableOn}
{#if pluginInfo.availableOn.applyToDomain}
<Badge color="success">{$t('plugins.tests.availability.domain')}</Badge>
{/if}
{#if pluginInfo.availableOn.limitToProviders && pluginInfo.availableOn.limitToProviders.length > 0}
<Badge color="primary" title={pluginInfo.availableOn.limitToProviders.join(', ')}>
{$t('plugins.tests.availability.provider-specific')}
</Badge>
{/if}
{#if pluginInfo.availableOn.limitToServices && pluginInfo.availableOn.limitToServices.length > 0}
<Badge color="info" title={pluginInfo.availableOn.limitToServices.join(', ')}>
{$t('plugins.tests.availability.service-specific')}
</Badge>
{/if}
{:else}
<Badge color="secondary">{$t('plugins.tests.availability.general')}</Badge>
{/if}
</td>
<td>
<a href="/plugins/{pluginName}" class="btn btn-sm btn-primary">
<Icon name="gear-fill"></Icon>
{$t('plugins.tests.actions.configure')}
</a>
</td>
</tr>
{/each}
{/if}
</tbody>
</Table>
</div>
{/if}
</Container>

View file

@ -0,0 +1,353 @@
<!--
This file is part of the happyDomain (R) project.
Copyright (c) 2022-2026 happyDomain
Authors: Pierre-Olivier Mercier, et al.
This program is offered under a commercial and under the AGPL license.
For commercial licensing, contact us at <contact@happydomain.org>.
For AGPL licensing:
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
-->
<script lang="ts">
import {
Alert,
Badge,
Button,
Card,
CardBody,
CardHeader,
Col,
Container,
Form,
FormGroup,
Icon,
Row,
} from "@sveltestrap/sveltestrap";
import { page } from "$app/state";
import { t } from "$lib/translations";
import { toasts } from "$lib/stores/toasts";
import { getPluginStatus, getPluginOptions, updatePluginOptions } from "$lib/api/plugins";
import Resource from "$lib/components/inputs/Resource.svelte";
import PluginOptionsGroups from "$lib/components/plugins/PluginOptionsGroups.svelte";
let pid = $derived(page.params.pid!);
let pluginStatusPromise = $derived(getPluginStatus(pid));
let pluginOptionsPromise = $derived(getPluginOptions(pid));
let optionValues = $state<Record<string, any>>({});
let saving = $state(false);
$effect(() => {
pluginOptionsPromise.then((options) => {
optionValues = { ...(options || {}) };
});
});
async function saveOptions() {
saving = true;
try {
await updatePluginOptions(pid, optionValues);
pluginOptionsPromise = getPluginOptions(pid);
toasts.addToast({
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) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
async function cleanOrphanedOptions(userOpts: any[]) {
const validOptIds = new Set(userOpts.map((opt) => opt.id));
const cleanedOptions: Record<string, any> = {};
for (const [key, value] of Object.entries(optionValues)) {
if (validOptIds.has(key)) {
cleanedOptions[key] = value;
}
}
saving = true;
try {
await updatePluginOptions(pid, cleanedOptions);
pluginOptionsPromise = getPluginOptions(pid);
toasts.addToast({
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) }),
timeout: 10000,
});
} finally {
saving = false;
}
}
function getOrphanedOptions(userOpts: any[], readOnlyOptGroups: any[]): string[] {
const validOptIds = new Set(userOpts.map((opt) => opt.id));
for (const group of readOnlyOptGroups) {
for (const opt of group.opts) {
validOptIds.add(opt.id);
}
}
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
}
</script>
<svelte:head>
<title>{pid} - {$t("plugins.tests.title")} - happyDomain</title>
</svelte:head>
<Container class="flex-fill my-5">
<Row class="mb-4">
<Col>
<Button color="link" href="/plugins" class="mb-2">
<Icon name="arrow-left"></Icon>
{$t("plugins.tests.back-to-tests")}
</Button>
<h1 class="display-5">
<Icon name="check-circle-fill"></Icon>
{pid}
</h1>
</Col>
</Row>
{#await pluginStatusPromise}
<Card body>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$t("plugins.tests.loading-info")}
</p>
</Card>
{:then status}
{#if status}
<Row class="mb-4">
<Col md={6}>
<Card>
<CardHeader>
<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>
<dd class="col-sm-8">{status.name}</dd>
<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>
<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
>
{/if}
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
<Badge color="primary">
{$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(
", ",
),
})}
</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}
</div>
{:else}
<Badge color="secondary"
>{$t("plugins.tests.availability.general")}</Badge
>
{/if}
</dd>
</dl>
</CardBody>
</Card>
</Col>
<Col md={6}>
{#await pluginOptionsPromise}
<Card>
<CardBody>
<p class="text-center mb-0">
<span class="spinner-border spinner-border-sm me-2"></span>
{$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, 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(", "),
})}
</div>
<Button
color="danger"
size="sm"
onclick={() => cleanOrphanedOptions(userOpts)}
disabled={saving}
>
<Icon name="trash"></Icon>
{$t("plugins.tests.detail.clean-up")}
</Button>
</div>
</Alert>
{/if}
{#if userOpts.length > 0}
<Card class="mb-3">
<CardHeader>
<strong>{$t("plugins.tests.detail.configuration")}</strong>
</CardHeader>
<CardBody>
<Form
on:submit={(e) => {
e.preventDefault();
saveOptions();
}}
>
{#each userOpts as optDoc}
{#if optDoc.id}
{@const optName = optDoc.id}
<FormGroup>
<Resource
edit={true}
index={optName}
specs={optDoc}
type={optDoc.type || "string"}
bind:value={optionValues[optName]}
/>
</FormGroup>
{/if}
{/each}
<div class="d-flex gap-2">
<Button type="submit" color="success" disabled={saving}>
{#if saving}
<span
class="spinner-border spinner-border-sm me-1"
></span>
{/if}
<Icon name="check-circle"></Icon>
{$t("plugins.tests.detail.save-changes")}
</Button>
</div>
</Form>
</CardBody>
</Card>
{/if}
<PluginOptionsGroups groups={readOnlyOptGroups} t={$t} />
{#if !hasAnyOpts}
<Card>
<CardBody>
<Alert color="info" class="mb-0">
<Icon name="info-circle"></Icon>
{$t("plugins.tests.detail.no-configurable-options")}
</Alert>
</CardBody>
</Card>
{/if}
{:catch error}
<Card>
<CardBody>
<Alert color="danger" class="mb-0">
<Icon name="exclamation-triangle-fill"></Icon>
{$t("plugins.tests.detail.error-loading-options", {
error: error.message,
})}
</Alert>
</CardBody>
</Card>
{/await}
</Col>
</Row>
{:else}
<Alert color="danger">
<Icon name="exclamation-triangle-fill"></Icon>
{$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 })}
</Alert>
{/await}
</Container>