Compare commits
13 commits
b541809470
...
4c7c3b4568
| Author | SHA1 | Date | |
|---|---|---|---|
| 4c7c3b4568 | |||
| 30fdbff8a3 | |||
| cde9d405f4 | |||
| 262da7bb0e | |||
| 5b4dd01a13 | |||
| cac9419947 | |||
| 7c2ee9ad8f | |||
| 34d72cc178 | |||
| cb8bb2f9a1 | |||
| a256d2efe2 | |||
| 8f8cf8db7c | |||
| 82e30391ee | |||
| 6a68bafdbb |
66 changed files with 8336 additions and 69 deletions
181
internal/api-admin/controller/plugin_test_controller.go
Normal file
181
internal/api-admin/controller/plugin_test_controller.go
Normal 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)
|
||||||
|
}
|
||||||
102
internal/api-admin/controller/scheduler_controller.go
Normal file
102
internal/api-admin/controller/scheduler_controller.go
Normal 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})
|
||||||
|
}
|
||||||
58
internal/api-admin/route/plugin.go
Normal file
58
internal/api-admin/route/plugin.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
|
||||||
|
|
||||||
declareBackupRoutes(cfg, apiRoutes, s)
|
declareBackupRoutes(cfg, apiRoutes, s)
|
||||||
declareDomainRoutes(apiRoutes, dependancies, s)
|
declareDomainRoutes(apiRoutes, dependancies, s)
|
||||||
|
declarePluginsRoutes(apiRoutes, dependancies, s)
|
||||||
declareProviderRoutes(apiRoutes, dependancies, s)
|
declareProviderRoutes(apiRoutes, dependancies, s)
|
||||||
|
declareSchedulerRoutes(apiRoutes, dependancies)
|
||||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||||
declareUserAuthsRoutes(apiRoutes, dependancies, s)
|
declareUserAuthsRoutes(apiRoutes, dependancies, s)
|
||||||
declareUsersRoutes(apiRoutes, dependancies, s)
|
declareUsersRoutes(apiRoutes, dependancies, s)
|
||||||
|
|
|
||||||
45
internal/api-admin/route/scheduler.go
Normal file
45
internal/api-admin/route/scheduler.go
Normal 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)
|
||||||
|
}
|
||||||
131
internal/api/controller/plugin_test_base_controller.go
Normal file
131
internal/api/controller/plugin_test_base_controller.go
Normal 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)
|
||||||
|
}
|
||||||
211
internal/api/controller/plugin_test_controller.go
Normal file
211
internal/api/controller/plugin_test_controller.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -93,7 +93,7 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, zone)
|
c.JSON(http.StatusOK, zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServiceService retrieves the designated Service.
|
// GetZoneService retrieves the designated Service.
|
||||||
//
|
//
|
||||||
// @Summary Get the Service.
|
// @Summary Get the Service.
|
||||||
// @Schemes
|
// @Schemes
|
||||||
|
|
|
||||||
583
internal/api/controller/testresult_controller.go
Normal file
583
internal/api/controller/testresult_controller.go
Normal 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)
|
||||||
|
}
|
||||||
215
internal/api/controller/testschedule_controller.go
Normal file
215
internal/api/controller/testschedule_controller.go
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2024 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScheduleController handles test schedule operations
|
||||||
|
type TestScheduleController struct {
|
||||||
|
testScheduleUC happydns.TestScheduleUsecase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewTestScheduleController(testScheduleUC happydns.TestScheduleUsecase) *TestScheduleController {
|
||||||
|
return &TestScheduleController{
|
||||||
|
testScheduleUC: testScheduleUC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestSchedules retrieves all schedules for the authenticated user
|
||||||
|
//
|
||||||
|
// @Summary List test schedules
|
||||||
|
// @Description Retrieves all test schedules for the authenticated user
|
||||||
|
// @Tags test-schedules
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {array} happydns.TestSchedule
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /plugins/tests/schedules [get]
|
||||||
|
func (tc *TestScheduleController) ListTestSchedules(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
|
||||||
|
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, schedules)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestSchedule creates a new test schedule
|
||||||
|
//
|
||||||
|
// @Summary Create test schedule
|
||||||
|
// @Description Creates a new test schedule for the authenticated user
|
||||||
|
// @Tags test-schedules
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body happydns.TestSchedule true "Test schedule to create"
|
||||||
|
// @Success 201 {object} happydns.TestSchedule
|
||||||
|
// @Failure 400 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /plugins/tests/schedules [post]
|
||||||
|
func (tc *TestScheduleController) CreateTestSchedule(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
|
||||||
|
var schedule happydns.TestSchedule
|
||||||
|
if err := c.ShouldBindJSON(&schedule); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set user ID
|
||||||
|
schedule.OwnerId = user.Id
|
||||||
|
|
||||||
|
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestSchedule retrieves a specific schedule
|
||||||
|
//
|
||||||
|
// @Summary Get test schedule
|
||||||
|
// @Description Retrieves a specific test schedule by ID
|
||||||
|
// @Tags test-schedules
|
||||||
|
// @Produce json
|
||||||
|
// @Param schedule_id path string true "Schedule ID"
|
||||||
|
// @Success 200 {object} happydns.TestSchedule
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /plugins/tests/schedules/{schedule_id} [get]
|
||||||
|
func (tc *TestScheduleController) GetTestSchedule(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
scheduleIdStr := c.Param("schedule_id")
|
||||||
|
|
||||||
|
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTestSchedule updates an existing schedule
|
||||||
|
//
|
||||||
|
// @Summary Update test schedule
|
||||||
|
// @Description Updates an existing test schedule
|
||||||
|
// @Tags test-schedules
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param schedule_id path string true "Schedule ID"
|
||||||
|
// @Param body body happydns.TestSchedule true "Updated schedule"
|
||||||
|
// @Success 200 {object} happydns.TestSchedule
|
||||||
|
// @Failure 400 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /plugins/tests/schedules/{schedule_id} [put]
|
||||||
|
func (tc *TestScheduleController) UpdateTestSchedule(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
scheduleIdStr := c.Param("schedule_id")
|
||||||
|
|
||||||
|
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var schedule happydns.TestSchedule
|
||||||
|
if err := c.ShouldBindJSON(&schedule); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure ID matches
|
||||||
|
schedule.Id = scheduleId
|
||||||
|
schedule.OwnerId = user.Id
|
||||||
|
|
||||||
|
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestSchedule deletes a schedule
|
||||||
|
//
|
||||||
|
// @Summary Delete test schedule
|
||||||
|
// @Description Deletes a test schedule
|
||||||
|
// @Tags test-schedules
|
||||||
|
// @Produce json
|
||||||
|
// @Param schedule_id path string true "Schedule ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /plugins/tests/schedules/{schedule_id} [delete]
|
||||||
|
func (tc *TestScheduleController) DeleteTestSchedule(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
scheduleIdStr := c.Param("schedule_id")
|
||||||
|
|
||||||
|
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,10 @@ func DeclareDomainRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseD
|
||||||
|
|
||||||
DeclareDomainLogRoutes(apiDomainsRoutes, dependancies)
|
DeclareDomainLogRoutes(apiDomainsRoutes, dependancies)
|
||||||
|
|
||||||
|
// Declare test result routes for domain scope
|
||||||
|
|
||||||
|
DeclareScopedTestResultRoutes(apiDomainsRoutes, dependancies, happydns.TestScopeDomain)
|
||||||
|
|
||||||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||||
|
|
||||||
|
|
|
||||||
49
internal/api/route/plugin.go
Normal file
49
internal/api/route/plugin.go
Normal 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)
|
||||||
|
}
|
||||||
|
|
@ -77,9 +77,11 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dependancies happy
|
||||||
|
|
||||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, dependancies, lc)
|
DeclareAuthenticationCheckRoutes(apiAuthRoutes, dependancies, lc)
|
||||||
DeclareDomainRoutes(apiAuthRoutes, dependancies)
|
DeclareDomainRoutes(apiAuthRoutes, dependancies)
|
||||||
|
DeclarePluginsRoutes(apiAuthRoutes, dependancies)
|
||||||
DeclareProviderRoutes(apiAuthRoutes, dependancies)
|
DeclareProviderRoutes(apiAuthRoutes, dependancies)
|
||||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dependancies)
|
DeclareProviderSettingsRoutes(apiAuthRoutes, dependancies)
|
||||||
DeclareRecordRoutes(apiAuthRoutes, dependancies)
|
DeclareRecordRoutes(apiAuthRoutes, dependancies)
|
||||||
|
DeclareTestScheduleRoutes(apiAuthRoutes, dependancies)
|
||||||
DeclareUsersRoutes(apiAuthRoutes, dependancies, lc)
|
DeclareUsersRoutes(apiAuthRoutes, dependancies, lc)
|
||||||
DeclareSessionRoutes(apiAuthRoutes, dependancies)
|
DeclareSessionRoutes(apiAuthRoutes, dependancies)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route
|
||||||
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(dependancies.ServiceUsecase()))
|
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(dependancies.ServiceUsecase()))
|
||||||
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
|
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
|
||||||
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
|
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
|
||||||
|
|
||||||
|
// Declare test result routes for service scope
|
||||||
|
DeclareScopedTestResultRoutes(apiZonesSubdomainServiceIdRoutes, dependancies, happydns.TestScopeService)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
internal/api/route/testresults.go
Normal file
103
internal/api/route/testresults.go
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/api/controller"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testSchedulerProvider is an interface for getting the test scheduler from dependencies
|
||||||
|
type testSchedulerProvider interface {
|
||||||
|
TestScheduler() controller.TestSchedulerInterface
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclareScopedTestResultRoutes declares test result routes for a specific scope (domain, zone, or service)
|
||||||
|
func DeclareScopedTestResultRoutes(
|
||||||
|
scopedRouter *gin.RouterGroup,
|
||||||
|
dependancies happydns.UsecaseDependancies,
|
||||||
|
scope happydns.TestScopeType,
|
||||||
|
) {
|
||||||
|
testScheduler := dependancies.(testSchedulerProvider).TestScheduler()
|
||||||
|
|
||||||
|
tc := controller.NewTestResultController(
|
||||||
|
scope,
|
||||||
|
dependancies.TestPluginUsecase(),
|
||||||
|
dependancies.TestResultUsecase(),
|
||||||
|
dependancies.TestScheduleUsecase(),
|
||||||
|
testScheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
// List all available tests with their status
|
||||||
|
scopedRouter.GET("/tests", tc.ListAvailableTests)
|
||||||
|
|
||||||
|
// Test-specific routes
|
||||||
|
apiTestsRoutes := scopedRouter.Group("/tests/:tname")
|
||||||
|
{
|
||||||
|
// Get latest results for a test
|
||||||
|
apiTestsRoutes.GET("", tc.ListLatestTestResults)
|
||||||
|
|
||||||
|
// Trigger an on-demand test
|
||||||
|
apiTestsRoutes.POST("", tc.TriggerTest)
|
||||||
|
|
||||||
|
// Manage test plugin options at this scope
|
||||||
|
apiTestsRoutes.GET("/options", tc.GetTestPluginOptions)
|
||||||
|
apiTestsRoutes.POST("/options", tc.AddTestPluginOptions)
|
||||||
|
apiTestsRoutes.PUT("/options", tc.ChangeTestPluginOptions)
|
||||||
|
|
||||||
|
// Test execution routes
|
||||||
|
apiTestExecutionsRoutes := apiTestsRoutes.Group("/executions/:execution_id")
|
||||||
|
{
|
||||||
|
apiTestExecutionsRoutes.GET("", tc.GetTestExecutionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test results routes
|
||||||
|
apiTestsRoutes.GET("/results", tc.ListTestPluginResults)
|
||||||
|
apiTestsRoutes.DELETE("/results", tc.DropTestPluginResults)
|
||||||
|
|
||||||
|
apiTestResultsRoutes := apiTestsRoutes.Group("/results/:result_id")
|
||||||
|
{
|
||||||
|
apiTestResultsRoutes.GET("", tc.GetTestPluginResult)
|
||||||
|
apiTestResultsRoutes.DELETE("", tc.DropTestPluginResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeclareTestScheduleRoutes declares test schedule management routes
|
||||||
|
func DeclareTestScheduleRoutes(router *gin.RouterGroup, dependancies happydns.UsecaseDependancies) {
|
||||||
|
sc := controller.NewTestScheduleController(dependancies.TestScheduleUsecase())
|
||||||
|
|
||||||
|
schedulesRoutes := router.Group("/plugins/tests/schedules")
|
||||||
|
{
|
||||||
|
schedulesRoutes.GET("", sc.ListTestSchedules)
|
||||||
|
schedulesRoutes.POST("", sc.CreateTestSchedule)
|
||||||
|
|
||||||
|
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
|
||||||
|
{
|
||||||
|
scheduleRoutes.GET("", sc.GetTestSchedule)
|
||||||
|
scheduleRoutes.PUT("", sc.UpdateTestSchedule)
|
||||||
|
scheduleRoutes.DELETE("", sc.DeleteTestSchedule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,9 +40,11 @@ import (
|
||||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||||
|
pluginUC "git.happydns.org/happyDomain/internal/usecase/plugin"
|
||||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||||
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
|
serviceUC "git.happydns.org/happyDomain/internal/usecase/service"
|
||||||
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
|
sessionUC "git.happydns.org/happyDomain/internal/usecase/session"
|
||||||
|
testresultUC "git.happydns.org/happyDomain/internal/usecase/testresult"
|
||||||
userUC "git.happydns.org/happyDomain/internal/usecase/user"
|
userUC "git.happydns.org/happyDomain/internal/usecase/user"
|
||||||
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
|
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
|
||||||
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
|
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
|
||||||
|
|
@ -63,6 +65,9 @@ type Usecases struct {
|
||||||
session happydns.SessionUsecase
|
session happydns.SessionUsecase
|
||||||
service happydns.ServiceUsecase
|
service happydns.ServiceUsecase
|
||||||
serviceSpecs happydns.ServiceSpecsUsecase
|
serviceSpecs happydns.ServiceSpecsUsecase
|
||||||
|
testPlugin happydns.TestPluginUsecase
|
||||||
|
testResult happydns.TestResultUsecase
|
||||||
|
testSchedule happydns.TestScheduleUsecase
|
||||||
user happydns.UserUsecase
|
user happydns.UserUsecase
|
||||||
zone happydns.ZoneUsecase
|
zone happydns.ZoneUsecase
|
||||||
zoneService happydns.ZoneServiceUsecase
|
zoneService happydns.ZoneServiceUsecase
|
||||||
|
|
@ -71,14 +76,16 @@ type Usecases struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
type App struct {
|
type App struct {
|
||||||
cfg *happydns.Options
|
cfg *happydns.Options
|
||||||
mailer *mailer.Mailer
|
mailer *mailer.Mailer
|
||||||
newsletter happydns.NewsletterSubscriptor
|
newsletter happydns.NewsletterSubscriptor
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
insights *insightsCollector
|
insights *insightsCollector
|
||||||
store storage.Storage
|
testScheduler happydns.AdminSchedulerUsecase
|
||||||
usecases Usecases
|
plugins happydns.PluginManager
|
||||||
|
store storage.Storage
|
||||||
|
usecases Usecases
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) AuthenticationUsecase() happydns.AuthenticationUsecase {
|
func (a *App) AuthenticationUsecase() happydns.AuthenticationUsecase {
|
||||||
|
|
@ -137,6 +144,22 @@ func (a *App) SessionUsecase() happydns.SessionUsecase {
|
||||||
return a.usecases.session
|
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 {
|
func (a *App) UserUsecase() happydns.UserUsecase {
|
||||||
return a.usecases.user
|
return a.usecases.user
|
||||||
}
|
}
|
||||||
|
|
@ -166,7 +189,9 @@ func NewApp(cfg *happydns.Options) *App {
|
||||||
app.initStorageEngine()
|
app.initStorageEngine()
|
||||||
app.initNewsletter()
|
app.initNewsletter()
|
||||||
app.initInsights()
|
app.initInsights()
|
||||||
|
app.initPlugins()
|
||||||
app.initUsecases()
|
app.initUsecases()
|
||||||
|
app.initTestScheduler()
|
||||||
app.setupRouter()
|
app.setupRouter()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
@ -180,7 +205,9 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
||||||
|
|
||||||
app.initMailer()
|
app.initMailer()
|
||||||
app.initNewsletter()
|
app.initNewsletter()
|
||||||
|
app.initPlugins()
|
||||||
app.initUsecases()
|
app.initUsecases()
|
||||||
|
app.initTestScheduler()
|
||||||
app.setupRouter()
|
app.setupRouter()
|
||||||
|
|
||||||
return app
|
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() {
|
func (app *App) initUsecases() {
|
||||||
sessionService := sessionUC.NewService(app.store)
|
sessionService := sessionUC.NewService(app.store)
|
||||||
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
|
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
|
||||||
|
|
@ -270,6 +310,9 @@ func (app *App) initUsecases() {
|
||||||
app.usecases.authUser = authUserService
|
app.usecases.authUser = authUserService
|
||||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||||
app.usecases.session = sessionService
|
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(
|
app.usecases.orchestrator = orchestrator.NewOrchestrator(
|
||||||
domainLogService,
|
domainLogService,
|
||||||
|
|
@ -310,6 +353,11 @@ func (app *App) Start() {
|
||||||
go app.insights.Run()
|
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)
|
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("listen: %s\n", err)
|
log.Fatalf("listen: %s\n", err)
|
||||||
|
|
@ -331,4 +379,9 @@ func (app *App) Stop() {
|
||||||
if app.insights != nil {
|
if app.insights != nil {
|
||||||
app.insights.Close()
|
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
111
internal/app/plugins.go
Normal 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
|
||||||
|
}
|
||||||
695
internal/app/testscheduler.go
Normal file
695
internal/app/testscheduler.go
Normal file
|
|
@ -0,0 +1,695 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
"git.happydns.org/happyDomain/internal/usecase/testresult"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
|
||||||
|
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
|
||||||
|
SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets
|
||||||
|
TestExecutionTimeout = 5 * time.Minute // Max time for a single test
|
||||||
|
MaxRetries = 3 // Max retry attempts for failed tests
|
||||||
|
)
|
||||||
|
|
||||||
|
// Priority levels for test execution queue
|
||||||
|
const (
|
||||||
|
PriorityOnDemand = iota // On-demand tests (highest priority)
|
||||||
|
PriorityOverdue // Overdue scheduled tests
|
||||||
|
PriorityScheduled // Regular scheduled tests
|
||||||
|
)
|
||||||
|
|
||||||
|
// testScheduler manages background test execution
|
||||||
|
type testScheduler struct {
|
||||||
|
cfg *happydns.Options
|
||||||
|
store storage.Storage
|
||||||
|
pluginUsecase happydns.TestPluginUsecase
|
||||||
|
resultUsecase *testresult.TestResultUsecase
|
||||||
|
scheduleUsecase *testresult.TestScheduleUsecase
|
||||||
|
stop chan bool
|
||||||
|
runNowChan chan *happydns.TestSchedule
|
||||||
|
queue *priorityQueue
|
||||||
|
activeExecutions map[string]*activeExecution
|
||||||
|
workers []*worker
|
||||||
|
mu sync.RWMutex
|
||||||
|
wg sync.WaitGroup
|
||||||
|
runtimeEnabled bool
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// activeExecution tracks a running test execution
|
||||||
|
type activeExecution struct {
|
||||||
|
execution *happydns.TestExecution
|
||||||
|
cancel context.CancelFunc
|
||||||
|
startTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueItem represents a test execution request in the queue
|
||||||
|
type queueItem struct {
|
||||||
|
schedule *happydns.TestSchedule
|
||||||
|
execution *happydns.TestExecution
|
||||||
|
priority int
|
||||||
|
queuedAt time.Time
|
||||||
|
retries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// priorityQueue manages test execution queue with priority levels
|
||||||
|
type priorityQueue struct {
|
||||||
|
items []*queueItem
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// newPriorityQueue creates a new priority queue
|
||||||
|
func newPriorityQueue() *priorityQueue {
|
||||||
|
return &priorityQueue{
|
||||||
|
items: make([]*queueItem, 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push adds an item to the queue
|
||||||
|
func (q *priorityQueue) Push(item *queueItem) {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
q.items = append(q.items, item)
|
||||||
|
|
||||||
|
// Sort by priority (lower number = higher priority)
|
||||||
|
// Within same priority, FIFO order
|
||||||
|
for i := len(q.items) - 1; i > 0; i-- {
|
||||||
|
if q.items[i].priority < q.items[i-1].priority {
|
||||||
|
q.items[i], q.items[i-1] = q.items[i-1], q.items[i]
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop removes and returns the highest priority item
|
||||||
|
func (q *priorityQueue) Pop() *queueItem {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
|
||||||
|
if len(q.items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
item := q.items[0]
|
||||||
|
q.items = q.items[1:]
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the queue length
|
||||||
|
func (q *priorityQueue) Len() int {
|
||||||
|
q.mu.Lock()
|
||||||
|
defer q.mu.Unlock()
|
||||||
|
return len(q.items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes tests from the queue
|
||||||
|
type worker struct {
|
||||||
|
id int
|
||||||
|
scheduler *testScheduler
|
||||||
|
stop chan bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// disabledScheduler is a no-op implementation used when scheduler is disabled
|
||||||
|
type disabledScheduler struct{}
|
||||||
|
|
||||||
|
// TriggerOnDemandTest returns an error indicating the scheduler is disabled
|
||||||
|
func (d *disabledScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error) {
|
||||||
|
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchedulerStatus returns a status indicating the scheduler is disabled
|
||||||
|
func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
|
||||||
|
return happydns.SchedulerStatus{
|
||||||
|
ConfigEnabled: false,
|
||||||
|
RuntimeEnabled: false,
|
||||||
|
Running: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled returns an error since the scheduler is disabled in configuration
|
||||||
|
func (d *disabledScheduler) SetEnabled(enabled bool) error {
|
||||||
|
return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescheduleUpcomingTests returns an error since the scheduler is disabled
|
||||||
|
func (d *disabledScheduler) RescheduleUpcomingTests() (int, error) {
|
||||||
|
return 0, fmt.Errorf("test scheduler is disabled in configuration")
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestScheduler creates a new test scheduler
|
||||||
|
func newTestScheduler(
|
||||||
|
cfg *happydns.Options,
|
||||||
|
store storage.Storage,
|
||||||
|
pluginUsecase happydns.TestPluginUsecase,
|
||||||
|
) *testScheduler {
|
||||||
|
numWorkers := cfg.TestWorkers
|
||||||
|
if numWorkers <= 0 {
|
||||||
|
numWorkers = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler := &testScheduler{
|
||||||
|
cfg: cfg,
|
||||||
|
store: store,
|
||||||
|
pluginUsecase: pluginUsecase,
|
||||||
|
resultUsecase: testresult.NewTestResultUsecase(store, cfg),
|
||||||
|
scheduleUsecase: testresult.NewTestScheduleUsecase(store, cfg),
|
||||||
|
stop: make(chan bool),
|
||||||
|
runNowChan: make(chan *happydns.TestSchedule, 100),
|
||||||
|
queue: newPriorityQueue(),
|
||||||
|
activeExecutions: make(map[string]*activeExecution),
|
||||||
|
workers: make([]*worker, numWorkers),
|
||||||
|
runtimeEnabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create workers
|
||||||
|
for i := 0; i < numWorkers; i++ {
|
||||||
|
scheduler.workers[i] = &worker{
|
||||||
|
id: i,
|
||||||
|
scheduler: scheduler,
|
||||||
|
stop: make(chan bool),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close stops the scheduler
|
||||||
|
func (s *testScheduler) Close() {
|
||||||
|
log.Println("Stopping test scheduler...")
|
||||||
|
|
||||||
|
// Stop the main loop
|
||||||
|
s.stop <- true
|
||||||
|
|
||||||
|
// Stop all workers
|
||||||
|
for _, w := range s.workers {
|
||||||
|
w.stop <- true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel all active executions
|
||||||
|
s.mu.Lock()
|
||||||
|
for _, exec := range s.activeExecutions {
|
||||||
|
exec.cancel()
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
// Wait for all workers to finish
|
||||||
|
s.wg.Wait()
|
||||||
|
|
||||||
|
log.Println("Test scheduler stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the scheduler main loop
|
||||||
|
func (s *testScheduler) Run() {
|
||||||
|
if s.cfg.DisableScheduler {
|
||||||
|
log.Println("Test scheduler disabled by configuration")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = true
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.running = false
|
||||||
|
s.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
|
||||||
|
|
||||||
|
// Reschedule overdue tests before starting workers so that tests missed
|
||||||
|
// during a server suspend or shutdown are spread into the near future
|
||||||
|
// instead of all firing at once.
|
||||||
|
if n, err := s.scheduleUsecase.RescheduleOverdueTests(); err != nil {
|
||||||
|
log.Printf("Warning: failed to reschedule overdue tests: %v\n", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
log.Printf("Rescheduled %d overdue test(s) into the near future\n", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start workers
|
||||||
|
for _, w := range s.workers {
|
||||||
|
s.wg.Add(1)
|
||||||
|
go w.run(&s.wg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Main scheduling loop
|
||||||
|
checkTicker := time.NewTicker(SchedulerCheckInterval)
|
||||||
|
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
|
||||||
|
discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval)
|
||||||
|
defer checkTicker.Stop()
|
||||||
|
defer cleanupTicker.Stop()
|
||||||
|
defer discoveryTicker.Stop()
|
||||||
|
|
||||||
|
// Initial discovery: create default schedules for all existing targets
|
||||||
|
s.discoverAndEnsureSchedules()
|
||||||
|
// Initial check
|
||||||
|
s.checkSchedules()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-checkTicker.C:
|
||||||
|
s.checkSchedules()
|
||||||
|
|
||||||
|
case <-cleanupTicker.C:
|
||||||
|
s.cleanup()
|
||||||
|
|
||||||
|
case <-discoveryTicker.C:
|
||||||
|
s.discoverAndEnsureSchedules()
|
||||||
|
|
||||||
|
case schedule := <-s.runNowChan:
|
||||||
|
s.queueOnDemandTest(schedule)
|
||||||
|
|
||||||
|
case <-s.stop:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkSchedules checks for due tests and queues them
|
||||||
|
func (s *testScheduler) checkSchedules() {
|
||||||
|
s.mu.RLock()
|
||||||
|
enabled := s.runtimeEnabled
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing due schedules: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
for _, schedule := range dueSchedules {
|
||||||
|
// Determine priority based on how overdue the test is
|
||||||
|
priority := PriorityScheduled
|
||||||
|
if schedule.NextRun.Add(schedule.Interval).Before(now) {
|
||||||
|
priority = PriorityOverdue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create execution record
|
||||||
|
execution := &happydns.TestExecution{
|
||||||
|
ScheduleId: &schedule.Id,
|
||||||
|
PluginName: schedule.PluginName,
|
||||||
|
OwnerId: schedule.OwnerId,
|
||||||
|
TargetType: schedule.TargetType,
|
||||||
|
TargetId: schedule.TargetId,
|
||||||
|
Status: happydns.TestExecutionPending,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Options: schedule.Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
|
||||||
|
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue the test
|
||||||
|
s.queue.Push(&queueItem{
|
||||||
|
schedule: schedule,
|
||||||
|
execution: execution,
|
||||||
|
priority: priority,
|
||||||
|
queuedAt: now,
|
||||||
|
retries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark scheduler run
|
||||||
|
if err := s.store.TestSchedulerRun(); err != nil {
|
||||||
|
log.Printf("Error marking scheduler run: %v\n", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// discoverAndEnsureSchedules creates default (enabled) schedules for all
|
||||||
|
// (plugin, target) pairs that don't yet have an explicit schedule record.
|
||||||
|
// This implements the opt-out model: tests run automatically unless a schedule
|
||||||
|
// with Enabled=false has been explicitly saved.
|
||||||
|
func (s *testScheduler) discoverAndEnsureSchedules() {
|
||||||
|
s.mu.RLock()
|
||||||
|
enabled := s.runtimeEnabled
|
||||||
|
s.mu.RUnlock()
|
||||||
|
if !enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins, err := s.pluginUsecase.ListTestPlugins()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing test plugins for discovery: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter domain-level plugins
|
||||||
|
var domainPlugins []happydns.TestPlugin
|
||||||
|
for _, p := range plugins {
|
||||||
|
if p.Version().AvailableOn.ApplyToDomain {
|
||||||
|
domainPlugins = append(domainPlugins, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(domainPlugins) > 0 {
|
||||||
|
iter, err := s.store.ListAllDomains()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Error listing domains for schedule discovery: %v\n", err)
|
||||||
|
} else {
|
||||||
|
defer iter.Close()
|
||||||
|
for iter.Next() {
|
||||||
|
domain := iter.Item()
|
||||||
|
if domain == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, plugin := range domainPlugins {
|
||||||
|
pluginName := plugin.Version().Name
|
||||||
|
schedules, err := s.scheduleUsecase.ListSchedulesByTarget(happydns.TestScopeDomain, domain.Id)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
hasSchedule := false
|
||||||
|
for _, sched := range schedules {
|
||||||
|
if sched.PluginName == pluginName {
|
||||||
|
hasSchedule = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !hasSchedule {
|
||||||
|
if err := s.scheduleUsecase.CreateSchedule(&happydns.TestSchedule{
|
||||||
|
PluginName: pluginName,
|
||||||
|
OwnerId: domain.Owner,
|
||||||
|
TargetType: happydns.TestScopeDomain,
|
||||||
|
TargetId: domain.Id,
|
||||||
|
Enabled: true,
|
||||||
|
}); err != nil {
|
||||||
|
log.Printf("Error auto-creating schedule for domain %s / plugin %s: %v\n",
|
||||||
|
domain.Id, pluginName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Service-level plugin discovery is deferred: services live inside zones
|
||||||
|
// and enumeration would require iterating all zones across all domains.
|
||||||
|
// Services get auto-scheduled on their first explicit interaction instead.
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueOnDemandTest queues an on-demand test execution
|
||||||
|
func (s *testScheduler) queueOnDemandTest(schedule *happydns.TestSchedule) {
|
||||||
|
execution := &happydns.TestExecution{
|
||||||
|
ScheduleId: nil, // On-demand has no schedule
|
||||||
|
PluginName: schedule.PluginName,
|
||||||
|
OwnerId: schedule.OwnerId,
|
||||||
|
TargetType: schedule.TargetType,
|
||||||
|
TargetId: schedule.TargetId,
|
||||||
|
Status: happydns.TestExecutionPending,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Options: schedule.Options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
|
||||||
|
log.Printf("Error creating on-demand execution: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s.queue.Push(&queueItem{
|
||||||
|
schedule: schedule,
|
||||||
|
execution: execution,
|
||||||
|
priority: PriorityOnDemand,
|
||||||
|
queuedAt: time.Now(),
|
||||||
|
retries: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriggerOnDemandTest triggers an immediate test execution
|
||||||
|
func (s *testScheduler) TriggerOnDemandTest(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.PluginOptions) (happydns.Identifier, error) {
|
||||||
|
// Create a temporary schedule for on-demand execution
|
||||||
|
schedule := &happydns.TestSchedule{
|
||||||
|
PluginName: pluginName,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: 0, // On-demand, no interval
|
||||||
|
Enabled: true,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create execution record
|
||||||
|
execution := &happydns.TestExecution{
|
||||||
|
ScheduleId: nil,
|
||||||
|
PluginName: pluginName,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
Status: happydns.TestExecutionPending,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.resultUsecase.CreateTestExecution(execution); err != nil {
|
||||||
|
return happydns.Identifier{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue with highest priority
|
||||||
|
s.queue.Push(&queueItem{
|
||||||
|
schedule: schedule,
|
||||||
|
execution: execution,
|
||||||
|
priority: PriorityOnDemand,
|
||||||
|
queuedAt: time.Now(),
|
||||||
|
retries: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
return execution.Id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSchedulerStatus returns a snapshot of the current scheduler state
|
||||||
|
func (s *testScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
|
||||||
|
s.mu.RLock()
|
||||||
|
activeCount := len(s.activeExecutions)
|
||||||
|
running := s.running
|
||||||
|
runtimeEnabled := s.runtimeEnabled
|
||||||
|
s.mu.RUnlock()
|
||||||
|
|
||||||
|
nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20)
|
||||||
|
|
||||||
|
return happydns.SchedulerStatus{
|
||||||
|
ConfigEnabled: !s.cfg.DisableScheduler,
|
||||||
|
RuntimeEnabled: runtimeEnabled,
|
||||||
|
Running: running,
|
||||||
|
WorkerCount: len(s.workers),
|
||||||
|
QueueSize: s.queue.Len(),
|
||||||
|
ActiveCount: activeCount,
|
||||||
|
NextSchedules: nextSchedules,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEnabled enables or disables the scheduler at runtime
|
||||||
|
func (s *testScheduler) SetEnabled(enabled bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.runtimeEnabled = enabled
|
||||||
|
s.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RescheduleUpcomingTests randomizes the next run time of all enabled schedules
|
||||||
|
// within their respective intervals, delegating to the schedule usecase.
|
||||||
|
func (s *testScheduler) RescheduleUpcomingTests() (int, error) {
|
||||||
|
return s.scheduleUsecase.RescheduleUpcomingTests()
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanup removes old execution records
|
||||||
|
func (s *testScheduler) cleanup() {
|
||||||
|
// This is a placeholder for cleanup logic
|
||||||
|
// In a full implementation, you'd clean up:
|
||||||
|
// - Old completed executions
|
||||||
|
// - Expired test results beyond retention
|
||||||
|
log.Println("Running scheduler cleanup...")
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker.run processes tests from the queue
|
||||||
|
func (w *worker) run(wg *sync.WaitGroup) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
log.Printf("Worker %d started\n", w.id)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-w.stop:
|
||||||
|
log.Printf("Worker %d stopped\n", w.id)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
// Try to get work from queue
|
||||||
|
item := w.scheduler.queue.Pop()
|
||||||
|
if item == nil {
|
||||||
|
// No work, sleep briefly
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute the test
|
||||||
|
w.executeTest(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeTest runs a test plugin and stores the result
|
||||||
|
func (w *worker) executeTest(item *queueItem) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), TestExecutionTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
execution := item.execution
|
||||||
|
schedule := item.schedule
|
||||||
|
|
||||||
|
// Always update schedule NextRun after execution, whether it succeeds or fails.
|
||||||
|
// This prevents the schedule from being re-queued on the next tick if the test fails.
|
||||||
|
if item.execution.ScheduleId != nil {
|
||||||
|
defer func() {
|
||||||
|
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil {
|
||||||
|
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark execution as running
|
||||||
|
execution.Status = happydns.TestExecutionRunning
|
||||||
|
if err := w.scheduler.resultUsecase.UpdateTestExecution(execution); err != nil {
|
||||||
|
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
|
||||||
|
_ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track active execution
|
||||||
|
w.scheduler.mu.Lock()
|
||||||
|
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
|
||||||
|
execution: execution,
|
||||||
|
cancel: cancel,
|
||||||
|
startTime: time.Now(),
|
||||||
|
}
|
||||||
|
w.scheduler.mu.Unlock()
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
w.scheduler.mu.Lock()
|
||||||
|
delete(w.scheduler.activeExecutions, execution.Id.String())
|
||||||
|
w.scheduler.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Get the plugin
|
||||||
|
plugin, err := w.scheduler.pluginUsecase.GetTestPlugin(schedule.PluginName)
|
||||||
|
if err != nil {
|
||||||
|
errMsg := fmt.Sprintf("plugin not found: %s - %v", schedule.PluginName, err)
|
||||||
|
log.Printf("Worker %d: %s\n", w.id, errMsg)
|
||||||
|
_ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, errMsg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare metadata
|
||||||
|
meta := make(map[string]string)
|
||||||
|
meta["target_type"] = schedule.TargetType.String()
|
||||||
|
meta["target_id"] = schedule.TargetId.String()
|
||||||
|
|
||||||
|
// Run the test
|
||||||
|
startTime := time.Now()
|
||||||
|
resultChan := make(chan *happydns.PluginResult, 1)
|
||||||
|
errorChan := make(chan error, 1)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
result, err := plugin.RunTest(schedule.Options, meta)
|
||||||
|
if err != nil {
|
||||||
|
errorChan <- err
|
||||||
|
} else {
|
||||||
|
resultChan <- result
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result or timeout
|
||||||
|
var pluginResult *happydns.PluginResult
|
||||||
|
var testErr error
|
||||||
|
|
||||||
|
select {
|
||||||
|
case pluginResult = <-resultChan:
|
||||||
|
// Test completed successfully
|
||||||
|
case testErr = <-errorChan:
|
||||||
|
// Test returned an error
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Timeout
|
||||||
|
testErr = fmt.Errorf("test execution timeout after %v", TestExecutionTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
// Store the result
|
||||||
|
result := &happydns.TestResult{
|
||||||
|
PluginName: schedule.PluginName,
|
||||||
|
TestType: schedule.TargetType,
|
||||||
|
TargetId: schedule.TargetId,
|
||||||
|
OwnerId: schedule.OwnerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
ScheduledTest: item.execution.ScheduleId != nil,
|
||||||
|
Options: schedule.Options,
|
||||||
|
Duration: duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if testErr != nil {
|
||||||
|
result.Status = happydns.PluginResultStatusKO
|
||||||
|
result.StatusLine = "Test execution failed"
|
||||||
|
result.Error = testErr.Error()
|
||||||
|
} else if pluginResult != nil {
|
||||||
|
result.Status = pluginResult.Status
|
||||||
|
result.StatusLine = pluginResult.StatusLine
|
||||||
|
result.Report = pluginResult.Report
|
||||||
|
} else {
|
||||||
|
result.Status = happydns.PluginResultStatusKO
|
||||||
|
result.StatusLine = "Unknown error"
|
||||||
|
result.Error = "No result or error returned from plugin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the result
|
||||||
|
if err := w.scheduler.resultUsecase.CreateTestResult(result); err != nil {
|
||||||
|
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
|
||||||
|
_ = w.scheduler.resultUsecase.FailTestExecution(execution.Id, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Complete the execution
|
||||||
|
if err := w.scheduler.resultUsecase.CompleteTestExecution(execution.Id, result.Id); err != nil {
|
||||||
|
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
|
||||||
|
w.id, schedule.PluginName, schedule.TargetId, result.Status, duration)
|
||||||
|
}
|
||||||
|
|
@ -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.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.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
|
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
|
||||||
|
|
||||||
// Define defaults options
|
// Define defaults options
|
||||||
opts = &happydns.Options{
|
opts = &happydns.Options{
|
||||||
AdminBind: "./happydomain.sock",
|
AdminBind: "./happydomain.sock",
|
||||||
BasePath: "/",
|
BasePath: "/",
|
||||||
Bind: ":8081",
|
Bind: ":8081",
|
||||||
DefaultNameServer: "127.0.0.1:53",
|
DefaultNameServer: "127.0.0.1:53",
|
||||||
ExternalURL: *u,
|
ExternalURL: *u,
|
||||||
JWTSigningMethod: "HS512",
|
JWTSigningMethod: "HS512",
|
||||||
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
||||||
MailSMTPPort: 587,
|
MailSMTPPort: 587,
|
||||||
StorageEngine: "leveldb",
|
StorageEngine: "leveldb",
|
||||||
|
MaxResultsPerTest: 100,
|
||||||
|
ResultRetentionDays: 90,
|
||||||
|
TestWorkers: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
declareFlags(opts)
|
declareFlags(opts)
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ type InMemoryStorage struct {
|
||||||
domainLogs map[string]*happydns.DomainLogWithDomainId
|
domainLogs map[string]*happydns.DomainLogWithDomainId
|
||||||
domainLogsByDomains map[string][]*happydns.Identifier
|
domainLogsByDomains map[string][]*happydns.Identifier
|
||||||
providers map[string]*happydns.ProviderMessage
|
providers map[string]*happydns.ProviderMessage
|
||||||
|
pluginsCfg map[string]*happydns.PluginOptions
|
||||||
sessions map[string]*happydns.Session
|
sessions map[string]*happydns.Session
|
||||||
users map[string]*happydns.User
|
users map[string]*happydns.User
|
||||||
usersByEmail map[string]*happydns.User
|
usersByEmail map[string]*happydns.User
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,10 @@ import (
|
||||||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
"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/provider"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/session"
|
"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/user"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/zone"
|
"git.happydns.org/happyDomain/internal/usecase/zone"
|
||||||
"git.happydns.org/happyDomain/model"
|
"git.happydns.org/happyDomain/model"
|
||||||
|
|
@ -43,8 +45,10 @@ type Storage interface {
|
||||||
domain.DomainStorage
|
domain.DomainStorage
|
||||||
domainlog.DomainLogStorage
|
domainlog.DomainLogStorage
|
||||||
insight.InsightStorage
|
insight.InsightStorage
|
||||||
|
plugin.PluginStorage
|
||||||
provider.ProviderStorage
|
provider.ProviderStorage
|
||||||
session.SessionStorage
|
session.SessionStorage
|
||||||
|
testresult.TestResultStorage
|
||||||
user.UserStorage
|
user.UserStorage
|
||||||
zone.ZoneStorage
|
zone.ZoneStorage
|
||||||
|
|
||||||
|
|
|
||||||
185
internal/storage/kvtpl/plugin.go
Normal file
185
internal/storage/kvtpl/plugin.go
Normal 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
|
||||||
|
}
|
||||||
433
internal/storage/kvtpl/testresult.go
Normal file
433
internal/storage/kvtpl/testresult.go
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Test Result storage keys:
|
||||||
|
// testresult|{plugin-name}|{target-type}|{target-id}|{result-id}
|
||||||
|
func makeTestResultKey(pluginName string, targetType happydns.TestScopeType, targetId, resultId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testresult|%s|%d|%s|%s", pluginName, targetType, targetId.String(), resultId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestResultPrefix(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testresult|%s|%d|%s|", pluginName, targetType, targetId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestResults retrieves test results for a specific plugin+target combination
|
||||||
|
func (s *KVStorage) ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
|
||||||
|
prefix := makeTestResultPrefix(pluginName, targetType, targetId)
|
||||||
|
iter := s.db.Search(prefix)
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var results []*happydns.TestResult
|
||||||
|
for iter.Next() {
|
||||||
|
var r happydns.TestResult
|
||||||
|
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
results = append(results, &r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by ExecutedAt descending (most recent first)
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
|
||||||
|
func (s *KVStorage) ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error) {
|
||||||
|
prefix := fmt.Sprintf("testresult|%s|", pluginName)
|
||||||
|
iter := s.db.Search(prefix)
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var results []*happydns.TestResult
|
||||||
|
for iter.Next() {
|
||||||
|
var r happydns.TestResult
|
||||||
|
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Filter by user
|
||||||
|
if r.OwnerId.Equals(userId) {
|
||||||
|
results = append(results, &r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by ExecutedAt descending (most recent first)
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestResultsByUser retrieves all test results for a user
|
||||||
|
func (s *KVStorage) ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
|
||||||
|
iter := s.db.Search("testresult|")
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var results []*happydns.TestResult
|
||||||
|
for iter.Next() {
|
||||||
|
var r happydns.TestResult
|
||||||
|
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Filter by user
|
||||||
|
if r.OwnerId.Equals(userId) {
|
||||||
|
results = append(results, &r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by ExecutedAt descending (most recent first)
|
||||||
|
sort.Slice(results, func(i, j int) bool {
|
||||||
|
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestResult retrieves a specific test result by its ID
|
||||||
|
func (s *KVStorage) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
|
||||||
|
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
|
||||||
|
var result happydns.TestResult
|
||||||
|
err := s.db.Get(key, &result)
|
||||||
|
if errors.Is(err, happydns.ErrNotFound) {
|
||||||
|
return nil, happydns.ErrTestResultNotFound
|
||||||
|
}
|
||||||
|
return &result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestResult stores a new test result
|
||||||
|
func (s *KVStorage) CreateTestResult(result *happydns.TestResult) error {
|
||||||
|
prefix := makeTestResultPrefix(result.PluginName, result.TestType, result.TargetId)
|
||||||
|
key, id, err := s.db.FindIdentifierKey(prefix)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Id = id
|
||||||
|
return s.db.Put(key, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestResult removes a specific test result
|
||||||
|
func (s *KVStorage) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
|
||||||
|
key := makeTestResultKey(pluginName, targetType, targetId, resultId)
|
||||||
|
return s.db.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteOldTestResults removes old test results keeping only the most recent N results
|
||||||
|
func (s *KVStorage) DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error {
|
||||||
|
results, err := s.ListTestResults(pluginName, targetType, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Results are already sorted by ExecutedAt descending
|
||||||
|
// Delete results beyond keepCount
|
||||||
|
if len(results) > keepCount {
|
||||||
|
for _, r := range results[keepCount:] {
|
||||||
|
if err := s.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Schedule storage keys:
|
||||||
|
// testschedule|{schedule-id}
|
||||||
|
// testschedule.byuser|{user-id}|{schedule-id}
|
||||||
|
// testschedule.bytarget|{target-type}|{target-id}|{schedule-id}
|
||||||
|
|
||||||
|
func makeTestScheduleKey(scheduleId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testschedule|%s", scheduleId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testschedule.byuser|%s|%s", userId.String(), scheduleId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeTestScheduleTargetIndexKey(targetType happydns.TestScopeType, targetId, scheduleId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListEnabledTestSchedules retrieves all enabled schedules
|
||||||
|
func (s *KVStorage) ListEnabledTestSchedules() ([]*happydns.TestSchedule, error) {
|
||||||
|
iter := s.db.Search("testschedule|")
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var schedules []*happydns.TestSchedule
|
||||||
|
for iter.Next() {
|
||||||
|
var sched happydns.TestSchedule
|
||||||
|
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if sched.Enabled {
|
||||||
|
schedules = append(schedules, &sched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestSchedulesByUser retrieves all schedules for a specific user
|
||||||
|
func (s *KVStorage) ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error) {
|
||||||
|
prefix := fmt.Sprintf("testschedule.byuser|%s|", userId.String())
|
||||||
|
iter := s.db.Search(prefix)
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var schedules []*happydns.TestSchedule
|
||||||
|
for iter.Next() {
|
||||||
|
// Extract schedule ID from index key
|
||||||
|
key := string(iter.Key())
|
||||||
|
parts := strings.Split(key, "|")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual schedule
|
||||||
|
var sched happydns.TestSchedule
|
||||||
|
schedKey := makeTestScheduleKey(scheduleId)
|
||||||
|
if err := s.db.Get(schedKey, &sched); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules = append(schedules, &sched)
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestSchedulesByTarget retrieves all schedules for a specific target
|
||||||
|
func (s *KVStorage) ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error) {
|
||||||
|
prefix := fmt.Sprintf("testschedule.bytarget|%d|%s|", targetType, targetId.String())
|
||||||
|
iter := s.db.Search(prefix)
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var schedules []*happydns.TestSchedule
|
||||||
|
for iter.Next() {
|
||||||
|
// Extract schedule ID from index key
|
||||||
|
key := string(iter.Key())
|
||||||
|
parts := strings.Split(key, "|")
|
||||||
|
if len(parts) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the actual schedule
|
||||||
|
var sched happydns.TestSchedule
|
||||||
|
schedKey := makeTestScheduleKey(scheduleId)
|
||||||
|
if err := s.db.Get(schedKey, &sched); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules = append(schedules, &sched)
|
||||||
|
}
|
||||||
|
|
||||||
|
return schedules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestSchedule retrieves a specific schedule by ID
|
||||||
|
func (s *KVStorage) GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error) {
|
||||||
|
key := makeTestScheduleKey(scheduleId)
|
||||||
|
var schedule happydns.TestSchedule
|
||||||
|
err := s.db.Get(key, &schedule)
|
||||||
|
if errors.Is(err, happydns.ErrNotFound) {
|
||||||
|
return nil, happydns.ErrTestScheduleNotFound
|
||||||
|
}
|
||||||
|
return &schedule, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestSchedule creates a new test schedule
|
||||||
|
func (s *KVStorage) CreateTestSchedule(schedule *happydns.TestSchedule) error {
|
||||||
|
key, id, err := s.db.FindIdentifierKey("testschedule|")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule.Id = id
|
||||||
|
|
||||||
|
// Store the schedule
|
||||||
|
if err := s.db.Put(key, schedule); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
userIndexKey := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
|
||||||
|
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
|
||||||
|
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTestSchedule updates an existing schedule
|
||||||
|
func (s *KVStorage) UpdateTestSchedule(schedule *happydns.TestSchedule) error {
|
||||||
|
key := makeTestScheduleKey(schedule.Id)
|
||||||
|
return s.db.Put(key, schedule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestSchedule removes a schedule and its indexes
|
||||||
|
func (s *KVStorage) DeleteTestSchedule(scheduleId happydns.Identifier) error {
|
||||||
|
// Get the schedule first to know what indexes to delete
|
||||||
|
schedule, err := s.GetTestSchedule(scheduleId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete indexes
|
||||||
|
userIndexKey := makeTestScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
|
||||||
|
if err := s.db.Delete(userIndexKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
targetIndexKey := makeTestScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
|
||||||
|
if err := s.db.Delete(targetIndexKey); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the schedule itself
|
||||||
|
key := makeTestScheduleKey(scheduleId)
|
||||||
|
return s.db.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Execution storage keys:
|
||||||
|
// testexec|{execution-id}
|
||||||
|
|
||||||
|
func makeTestExecutionKey(executionId happydns.Identifier) string {
|
||||||
|
return fmt.Sprintf("testexec|%s", executionId.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListActiveTestExecutions retrieves all executions that are pending or running
|
||||||
|
func (s *KVStorage) ListActiveTestExecutions() ([]*happydns.TestExecution, error) {
|
||||||
|
iter := s.db.Search("testexec|")
|
||||||
|
defer iter.Release()
|
||||||
|
|
||||||
|
var executions []*happydns.TestExecution
|
||||||
|
for iter.Next() {
|
||||||
|
var exec happydns.TestExecution
|
||||||
|
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if exec.Status == happydns.TestExecutionPending || exec.Status == happydns.TestExecutionRunning {
|
||||||
|
executions = append(executions, &exec)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestExecution retrieves a specific execution by ID
|
||||||
|
func (s *KVStorage) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
|
||||||
|
key := makeTestExecutionKey(executionId)
|
||||||
|
var execution happydns.TestExecution
|
||||||
|
err := s.db.Get(key, &execution)
|
||||||
|
if errors.Is(err, happydns.ErrNotFound) {
|
||||||
|
return nil, happydns.ErrTestExecutionNotFound
|
||||||
|
}
|
||||||
|
return &execution, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestExecution creates a new test execution record
|
||||||
|
func (s *KVStorage) CreateTestExecution(execution *happydns.TestExecution) error {
|
||||||
|
key, id, err := s.db.FindIdentifierKey("testexec|")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
execution.Id = id
|
||||||
|
return s.db.Put(key, execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTestExecution updates an existing execution record
|
||||||
|
func (s *KVStorage) UpdateTestExecution(execution *happydns.TestExecution) error {
|
||||||
|
key := makeTestExecutionKey(execution.Id)
|
||||||
|
return s.db.Put(key, execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestExecution removes an execution record
|
||||||
|
func (s *KVStorage) DeleteTestExecution(executionId happydns.Identifier) error {
|
||||||
|
key := makeTestExecutionKey(executionId)
|
||||||
|
return s.db.Delete(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler state storage key:
|
||||||
|
// testscheduler.lastrun
|
||||||
|
|
||||||
|
// TestSchedulerRun marks that the scheduler has run at current time
|
||||||
|
func (s *KVStorage) TestSchedulerRun() error {
|
||||||
|
now := time.Now()
|
||||||
|
return s.db.Put("testscheduler.lastrun", &now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastTestSchedulerRun retrieves the last time the scheduler ran
|
||||||
|
func (s *KVStorage) LastTestSchedulerRun() (*time.Time, error) {
|
||||||
|
var lastRun time.Time
|
||||||
|
err := s.db.Get("testscheduler.lastrun", &lastRun)
|
||||||
|
if errors.Is(err, happydns.ErrNotFound) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &lastRun, nil
|
||||||
|
}
|
||||||
46
internal/usecase/plugin/plugin_storage.go
Normal file
46
internal/usecase/plugin/plugin_storage.go
Normal 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
|
||||||
|
}
|
||||||
163
internal/usecase/plugin/plugin_test_usecase.go
Normal file
163
internal/usecase/plugin/plugin_test_usecase.go
Normal 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)
|
||||||
|
}
|
||||||
85
internal/usecase/plugin/plugin_test_usecase_test.go
Normal file
85
internal/usecase/plugin/plugin_test_usecase_test.go
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
98
internal/usecase/testresult/storage.go
Normal file
98
internal/usecase/testresult/storage.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package testresult
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResultStorage defines the storage interface for test results and related data
|
||||||
|
type TestResultStorage interface {
|
||||||
|
// Test Results
|
||||||
|
// ListTestResults retrieves test results for a specific plugin+target combination
|
||||||
|
ListTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
|
||||||
|
|
||||||
|
// ListTestResultsByPlugin retrieves all test results for a plugin across all targets for a user
|
||||||
|
ListTestResultsByPlugin(userId happydns.Identifier, pluginName string, limit int) ([]*happydns.TestResult, error)
|
||||||
|
|
||||||
|
// ListTestResultsByUser retrieves all test results for a user
|
||||||
|
ListTestResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.TestResult, error)
|
||||||
|
|
||||||
|
// GetTestResult retrieves a specific test result by its ID
|
||||||
|
GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error)
|
||||||
|
|
||||||
|
// CreateTestResult stores a new test result
|
||||||
|
CreateTestResult(result *happydns.TestResult) error
|
||||||
|
|
||||||
|
// DeleteTestResult removes a specific test result
|
||||||
|
DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
|
||||||
|
|
||||||
|
// DeleteOldTestResults removes old test results keeping only the most recent N results
|
||||||
|
DeleteOldTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, keepCount int) error
|
||||||
|
|
||||||
|
// Test Schedules
|
||||||
|
// ListEnabledTestSchedules retrieves all enabled schedules (for scheduler)
|
||||||
|
ListEnabledTestSchedules() ([]*happydns.TestSchedule, error)
|
||||||
|
|
||||||
|
// ListTestSchedulesByUser retrieves all schedules for a specific user
|
||||||
|
ListTestSchedulesByUser(userId happydns.Identifier) ([]*happydns.TestSchedule, error)
|
||||||
|
|
||||||
|
// ListTestSchedulesByTarget retrieves all schedules for a specific target
|
||||||
|
ListTestSchedulesByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier) ([]*happydns.TestSchedule, error)
|
||||||
|
|
||||||
|
// GetTestSchedule retrieves a specific schedule by ID
|
||||||
|
GetTestSchedule(scheduleId happydns.Identifier) (*happydns.TestSchedule, error)
|
||||||
|
|
||||||
|
// CreateTestSchedule creates a new test schedule
|
||||||
|
CreateTestSchedule(schedule *happydns.TestSchedule) error
|
||||||
|
|
||||||
|
// UpdateTestSchedule updates an existing schedule
|
||||||
|
UpdateTestSchedule(schedule *happydns.TestSchedule) error
|
||||||
|
|
||||||
|
// DeleteTestSchedule removes a schedule
|
||||||
|
DeleteTestSchedule(scheduleId happydns.Identifier) error
|
||||||
|
|
||||||
|
// Test Executions
|
||||||
|
// ListActiveTestExecutions retrieves all executions that are pending or running
|
||||||
|
ListActiveTestExecutions() ([]*happydns.TestExecution, error)
|
||||||
|
|
||||||
|
// GetTestExecution retrieves a specific execution by ID
|
||||||
|
GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error)
|
||||||
|
|
||||||
|
// CreateTestExecution creates a new test execution record
|
||||||
|
CreateTestExecution(execution *happydns.TestExecution) error
|
||||||
|
|
||||||
|
// UpdateTestExecution updates an existing execution record
|
||||||
|
UpdateTestExecution(execution *happydns.TestExecution) error
|
||||||
|
|
||||||
|
// DeleteTestExecution removes an execution record
|
||||||
|
DeleteTestExecution(executionId happydns.Identifier) error
|
||||||
|
|
||||||
|
// Scheduler State
|
||||||
|
// TestSchedulerRun marks that the scheduler has run at current time
|
||||||
|
TestSchedulerRun() error
|
||||||
|
|
||||||
|
// LastTestSchedulerRun retrieves the last time the scheduler ran
|
||||||
|
LastTestSchedulerRun() (*time.Time, error)
|
||||||
|
}
|
||||||
222
internal/usecase/testresult/testresult_usecase.go
Normal file
222
internal/usecase/testresult/testresult_usecase.go
Normal file
|
|
@ -0,0 +1,222 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package testresult
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestResultUsecase implements business logic for test results
|
||||||
|
type TestResultUsecase struct {
|
||||||
|
storage TestResultStorage
|
||||||
|
options *happydns.Options
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTestResultUsecase creates a new test result usecase
|
||||||
|
func NewTestResultUsecase(storage TestResultStorage, options *happydns.Options) *TestResultUsecase {
|
||||||
|
return &TestResultUsecase{
|
||||||
|
storage: storage,
|
||||||
|
options: options,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTestResultsByTarget retrieves test results for a specific target
|
||||||
|
func (u *TestResultUsecase) ListTestResultsByTarget(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
|
||||||
|
// Apply default limit if not specified
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5 // Default to 5 most recent results
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.storage.ListTestResults(pluginName, targetType, targetId, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
|
||||||
|
func (u *TestResultUsecase) ListAllTestResultsByTarget(targetType happydns.TestScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.TestResult, error) {
|
||||||
|
// Get all results for the user and filter by target
|
||||||
|
allResults, err := u.storage.ListTestResultsByUser(userId, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by target
|
||||||
|
var results []*happydns.TestResult
|
||||||
|
for _, r := range allResults {
|
||||||
|
if r.TestType == targetType && r.TargetId.Equals(targetId) {
|
||||||
|
results = append(results, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestResult retrieves a specific test result
|
||||||
|
func (u *TestResultUsecase) GetTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.TestResult, error) {
|
||||||
|
return u.storage.GetTestResult(pluginName, targetType, targetId, resultId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestResult stores a new test result and enforces retention policy
|
||||||
|
func (u *TestResultUsecase) CreateTestResult(result *happydns.TestResult) error {
|
||||||
|
// Store the result
|
||||||
|
if err := u.storage.CreateTestResult(result); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce retention policy
|
||||||
|
maxResults := u.options.MaxResultsPerTest
|
||||||
|
if maxResults <= 0 {
|
||||||
|
maxResults = 100 // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
return u.storage.DeleteOldTestResults(result.PluginName, result.TestType, result.TargetId, maxResults)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteTestResult removes a specific test result
|
||||||
|
func (u *TestResultUsecase) DeleteTestResult(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
|
||||||
|
return u.storage.DeleteTestResult(pluginName, targetType, targetId, resultId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllTestResults removes all results for a specific plugin+target combination
|
||||||
|
func (u *TestResultUsecase) DeleteAllTestResults(pluginName string, targetType happydns.TestScopeType, targetId happydns.Identifier) error {
|
||||||
|
// Get all results first
|
||||||
|
results, err := u.storage.ListTestResults(pluginName, targetType, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete each result
|
||||||
|
for _, r := range results {
|
||||||
|
if err := u.storage.DeleteTestResult(pluginName, targetType, targetId, r.Id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CleanupOldResults removes test results older than retention period
|
||||||
|
func (u *TestResultUsecase) CleanupOldResults() error {
|
||||||
|
retentionDays := u.options.ResultRetentionDays
|
||||||
|
if retentionDays <= 0 {
|
||||||
|
retentionDays = 90 // Default
|
||||||
|
}
|
||||||
|
|
||||||
|
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
|
||||||
|
|
||||||
|
// Get all results for all users (inefficient but necessary without a time-based index)
|
||||||
|
// In a production system, you might want to add a time-based index for this
|
||||||
|
// For now, we'll iterate through results and delete old ones
|
||||||
|
|
||||||
|
// This is a placeholder - the actual implementation would need to be optimized
|
||||||
|
// based on specific storage patterns
|
||||||
|
_ = cutoffTime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTestExecution retrieves the status of a test execution
|
||||||
|
func (u *TestResultUsecase) GetTestExecution(executionId happydns.Identifier) (*happydns.TestExecution, error) {
|
||||||
|
return u.storage.GetTestExecution(executionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateTestExecution creates a new test execution record
|
||||||
|
func (u *TestResultUsecase) CreateTestExecution(execution *happydns.TestExecution) error {
|
||||||
|
if execution.StartedAt.IsZero() {
|
||||||
|
execution.StartedAt = time.Now()
|
||||||
|
}
|
||||||
|
return u.storage.CreateTestExecution(execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateTestExecution updates an existing test execution
|
||||||
|
func (u *TestResultUsecase) UpdateTestExecution(execution *happydns.TestExecution) error {
|
||||||
|
return u.storage.UpdateTestExecution(execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CompleteTestExecution marks an execution as completed with a result
|
||||||
|
func (u *TestResultUsecase) CompleteTestExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
|
||||||
|
execution, err := u.storage.GetTestExecution(executionId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
execution.Status = happydns.TestExecutionCompleted
|
||||||
|
execution.CompletedAt = &now
|
||||||
|
execution.ResultId = &resultId
|
||||||
|
|
||||||
|
return u.storage.UpdateTestExecution(execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// FailTestExecution marks an execution as failed
|
||||||
|
func (u *TestResultUsecase) FailTestExecution(executionId happydns.Identifier, errorMsg string) error {
|
||||||
|
execution, err := u.storage.GetTestExecution(executionId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
execution.Status = happydns.TestExecutionFailed
|
||||||
|
execution.CompletedAt = &now
|
||||||
|
|
||||||
|
// Store error in a result
|
||||||
|
result := &happydns.TestResult{
|
||||||
|
PluginName: execution.PluginName,
|
||||||
|
TestType: execution.TargetType,
|
||||||
|
TargetId: execution.TargetId,
|
||||||
|
OwnerId: execution.OwnerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
ScheduledTest: execution.ScheduleId != nil,
|
||||||
|
Options: execution.Options,
|
||||||
|
Status: happydns.PluginResultStatusKO,
|
||||||
|
StatusLine: "Execution failed",
|
||||||
|
Error: errorMsg,
|
||||||
|
Duration: now.Sub(execution.StartedAt),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := u.CreateTestResult(result); err != nil {
|
||||||
|
return fmt.Errorf("failed to create error result: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
execution.ResultId = &result.Id
|
||||||
|
|
||||||
|
return u.storage.UpdateTestExecution(execution)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteCompletedExecutions removes execution records that are completed
|
||||||
|
func (u *TestResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
|
||||||
|
cutoffTime := time.Now().Add(-olderThan)
|
||||||
|
|
||||||
|
// Get active executions (this won't include completed ones)
|
||||||
|
// We need a different query to get completed executions older than cutoff
|
||||||
|
// For now, this is a placeholder
|
||||||
|
|
||||||
|
_ = cutoffTime
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
367
internal/usecase/testresult/testschedule_usecase.go
Normal file
367
internal/usecase/testresult/testschedule_usecase.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -92,6 +92,20 @@ type Options struct {
|
||||||
MailSMTPTLSSNoVerify bool
|
MailSMTPTLSSNoVerify bool
|
||||||
|
|
||||||
OIDCClients []OIDCSettings
|
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.
|
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.
|
||||||
|
|
|
||||||
|
|
@ -27,15 +27,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAuthUserNotFound = errors.New("user not found")
|
ErrAuthUserNotFound = errors.New("user not found")
|
||||||
ErrDomainNotFound = errors.New("domain not found")
|
ErrDomainNotFound = errors.New("domain not found")
|
||||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||||
ErrProviderNotFound = errors.New("provider not found")
|
ErrProviderNotFound = errors.New("provider not found")
|
||||||
ErrSessionNotFound = errors.New("session not found")
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrTestExecutionNotFound = errors.New("test execution not found")
|
||||||
ErrUserAlreadyExist = errors.New("user already exists")
|
ErrTestResultNotFound = errors.New("test result not found")
|
||||||
ErrZoneNotFound = errors.New("zone not found")
|
ErrTestScheduleNotFound = errors.New("test schedule not found")
|
||||||
ErrNotFound = errors.New("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."
|
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
const IDENTIFIER_LEN = 16
|
const IDENTIFIER_LEN = 16
|
||||||
|
|
@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool {
|
||||||
return bytes.Equal(i, other)
|
return bytes.Equal(i, other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i Identifier) Compare(other Identifier) int {
|
||||||
|
return slices.Compare(i, other)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Identifier) String() string {
|
func (i *Identifier) String() string {
|
||||||
return base64.RawURLEncoding.EncodeToString(*i)
|
return base64.RawURLEncoding.EncodeToString(*i)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
103
model/plugin.go
Normal file
103
model/plugin.go
Normal 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:"applyToDomain,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
|
||||||
|
}
|
||||||
306
model/test_result.go
Normal file
306
model/test_result.go
Normal file
|
|
@ -0,0 +1,306 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package happydns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestScopeType represents the scope level at which a test is performed
|
||||||
|
type TestScopeType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TestScopeInstance TestScopeType = iota
|
||||||
|
TestScopeUser
|
||||||
|
TestScopeDomain
|
||||||
|
TestScopeService
|
||||||
|
TestScopeOnDemand
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a string representation of the test scope type
|
||||||
|
func (t TestScopeType) String() string {
|
||||||
|
switch t {
|
||||||
|
case TestScopeInstance:
|
||||||
|
return "instance"
|
||||||
|
case TestScopeUser:
|
||||||
|
return "user"
|
||||||
|
case TestScopeDomain:
|
||||||
|
return "domain"
|
||||||
|
case TestScopeService:
|
||||||
|
return "service"
|
||||||
|
case TestScopeOnDemand:
|
||||||
|
return "ondemand"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecutionStatus represents the current state of a test execution
|
||||||
|
type TestExecutionStatus int
|
||||||
|
|
||||||
|
const (
|
||||||
|
TestExecutionPending TestExecutionStatus = iota
|
||||||
|
TestExecutionRunning
|
||||||
|
TestExecutionCompleted
|
||||||
|
TestExecutionFailed
|
||||||
|
)
|
||||||
|
|
||||||
|
// String returns a string representation of the test execution status
|
||||||
|
func (t TestExecutionStatus) String() string {
|
||||||
|
switch t {
|
||||||
|
case TestExecutionPending:
|
||||||
|
return "pending"
|
||||||
|
case TestExecutionRunning:
|
||||||
|
return "running"
|
||||||
|
case TestExecutionCompleted:
|
||||||
|
return "completed"
|
||||||
|
case TestExecutionFailed:
|
||||||
|
return "failed"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResult stores the result of a test execution
|
||||||
|
type TestResult struct {
|
||||||
|
// Id is the unique identifier for this test result
|
||||||
|
Id Identifier `json:"id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// PluginName identifies which test plugin was executed
|
||||||
|
PluginName string `json:"plugin_name"`
|
||||||
|
|
||||||
|
// TestType indicates the scope level of the test
|
||||||
|
TestType TestScopeType `json:"test_type"`
|
||||||
|
|
||||||
|
// TargetId is the identifier of the target (User/Domain/Service)
|
||||||
|
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// OwnerId is the owner of the test
|
||||||
|
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// ExecutedAt is when the test was executed
|
||||||
|
ExecutedAt time.Time `json:"executed_at"`
|
||||||
|
|
||||||
|
// ScheduledTest indicates if this was a scheduled (true) or on-demand (false) test
|
||||||
|
ScheduledTest bool `json:"scheduled_test"`
|
||||||
|
|
||||||
|
// Options contains the merged plugin configuration used for this test
|
||||||
|
Options PluginOptions `json:"options,omitempty"`
|
||||||
|
|
||||||
|
// Status is the overall test result status
|
||||||
|
Status PluginResultStatus `json:"status"`
|
||||||
|
|
||||||
|
// StatusLine is a summary message of the test result
|
||||||
|
StatusLine string `json:"status_line"`
|
||||||
|
|
||||||
|
// Report contains the full test report (plugin-specific structure)
|
||||||
|
Report interface{} `json:"report,omitempty"`
|
||||||
|
|
||||||
|
// Duration is how long the test took to execute
|
||||||
|
Duration time.Duration `json:"duration" swaggertype:"integer"`
|
||||||
|
|
||||||
|
// Error contains any error message if the execution failed
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchedule defines a recurring test schedule
|
||||||
|
type TestSchedule struct {
|
||||||
|
// Id is the unique identifier for this schedule
|
||||||
|
Id Identifier `json:"id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// PluginName identifies which test plugin to execute
|
||||||
|
PluginName string `json:"plugin_name"`
|
||||||
|
|
||||||
|
// OwnerId is the owner of the schedule
|
||||||
|
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// TargetType indicates what type of target to test
|
||||||
|
TargetType TestScopeType `json:"target_type"`
|
||||||
|
|
||||||
|
// TargetId is the identifier of the target to test
|
||||||
|
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// Interval is how often to run the test
|
||||||
|
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||||
|
|
||||||
|
// Enabled indicates if the schedule is active
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
|
||||||
|
// LastRun is when the test was last executed (nil if never run)
|
||||||
|
LastRun *time.Time `json:"last_run,omitempty"`
|
||||||
|
|
||||||
|
// NextRun is when the test should next be executed
|
||||||
|
NextRun time.Time `json:"next_run"`
|
||||||
|
|
||||||
|
// Options contains plugin-specific configuration
|
||||||
|
Options PluginOptions `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecution tracks an in-progress or completed test execution
|
||||||
|
type TestExecution struct {
|
||||||
|
// Id is the unique identifier for this execution
|
||||||
|
Id Identifier `json:"id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// ScheduleId is the schedule that triggered this execution (nil for on-demand)
|
||||||
|
ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"`
|
||||||
|
|
||||||
|
// PluginName identifies which test plugin is being executed
|
||||||
|
PluginName string `json:"plugin_name"`
|
||||||
|
|
||||||
|
// OwnerId is the owner of the test
|
||||||
|
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// TargetType indicates the scope level of the test
|
||||||
|
TargetType TestScopeType `json:"target_type"`
|
||||||
|
|
||||||
|
// TargetId is the identifier of the target being tested
|
||||||
|
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||||
|
|
||||||
|
// Status is the current execution status
|
||||||
|
Status TestExecutionStatus `json:"status"`
|
||||||
|
|
||||||
|
// StartedAt is when the execution began
|
||||||
|
StartedAt time.Time `json:"started_at"`
|
||||||
|
|
||||||
|
// CompletedAt is when the execution finished (nil if still running)
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
|
||||||
|
// ResultId links to the TestResult (nil if execution not completed)
|
||||||
|
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
|
||||||
|
|
||||||
|
// Options contains the plugin configuration for this execution
|
||||||
|
Options PluginOptions `json:"options,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SchedulerStatus holds a snapshot of the scheduler state for monitoring
|
||||||
|
type SchedulerStatus struct {
|
||||||
|
// ConfigEnabled indicates if the scheduler is enabled in the configuration file
|
||||||
|
ConfigEnabled bool `json:"config_enabled"`
|
||||||
|
|
||||||
|
// RuntimeEnabled indicates if the scheduler is currently enabled at runtime
|
||||||
|
RuntimeEnabled bool `json:"runtime_enabled"`
|
||||||
|
|
||||||
|
// Running indicates if the scheduler goroutine is currently running
|
||||||
|
Running bool `json:"running"`
|
||||||
|
|
||||||
|
// WorkerCount is the number of worker goroutines
|
||||||
|
WorkerCount int `json:"worker_count"`
|
||||||
|
|
||||||
|
// QueueSize is the number of items currently waiting in the execution queue
|
||||||
|
QueueSize int `json:"queue_size"`
|
||||||
|
|
||||||
|
// ActiveCount is the number of tests currently being executed
|
||||||
|
ActiveCount int `json:"active_count"`
|
||||||
|
|
||||||
|
// NextSchedules contains the upcoming scheduled tests sorted by next run time
|
||||||
|
NextSchedules []*TestSchedule `json:"next_schedules"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResultUsecase defines business logic for test results
|
||||||
|
type TestResultUsecase interface {
|
||||||
|
// ListTestResultsByTarget retrieves test results for a specific target
|
||||||
|
ListTestResultsByTarget(pluginName string, targetType TestScopeType, targetId Identifier, limit int) ([]*TestResult, error)
|
||||||
|
|
||||||
|
// ListAllTestResultsByTarget retrieves all test results for a target across all plugins
|
||||||
|
ListAllTestResultsByTarget(targetType TestScopeType, targetId Identifier, userId Identifier, limit int) ([]*TestResult, error)
|
||||||
|
|
||||||
|
// GetTestResult retrieves a specific test result
|
||||||
|
GetTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) (*TestResult, error)
|
||||||
|
|
||||||
|
// CreateTestResult stores a new test result and enforces retention policy
|
||||||
|
CreateTestResult(result *TestResult) error
|
||||||
|
|
||||||
|
// DeleteTestResult removes a specific test result
|
||||||
|
DeleteTestResult(pluginName string, targetType TestScopeType, targetId Identifier, resultId Identifier) error
|
||||||
|
|
||||||
|
// DeleteAllTestResults removes all results for a specific plugin+target combination
|
||||||
|
DeleteAllTestResults(pluginName string, targetType TestScopeType, targetId Identifier) error
|
||||||
|
|
||||||
|
// GetTestExecution retrieves the status of a test execution
|
||||||
|
GetTestExecution(executionId Identifier) (*TestExecution, error)
|
||||||
|
|
||||||
|
// CreateTestExecution creates a new test execution record
|
||||||
|
CreateTestExecution(execution *TestExecution) error
|
||||||
|
|
||||||
|
// UpdateTestExecution updates an existing test execution
|
||||||
|
UpdateTestExecution(execution *TestExecution) error
|
||||||
|
|
||||||
|
// CompleteTestExecution marks an execution as completed with a result
|
||||||
|
CompleteTestExecution(executionId Identifier, resultId Identifier) error
|
||||||
|
|
||||||
|
// FailTestExecution marks an execution as failed
|
||||||
|
FailTestExecution(executionId Identifier, errorMsg string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScheduleUsecase defines business logic for test schedules
|
||||||
|
type TestScheduleUsecase interface {
|
||||||
|
// ListUserSchedules retrieves all schedules for a specific user
|
||||||
|
ListUserSchedules(userId Identifier) ([]*TestSchedule, error)
|
||||||
|
|
||||||
|
// ListSchedulesByTarget retrieves all schedules for a specific target
|
||||||
|
ListSchedulesByTarget(targetType TestScopeType, targetId Identifier) ([]*TestSchedule, error)
|
||||||
|
|
||||||
|
// GetSchedule retrieves a specific schedule by ID
|
||||||
|
GetSchedule(scheduleId Identifier) (*TestSchedule, error)
|
||||||
|
|
||||||
|
// CreateSchedule creates a new test schedule with validation
|
||||||
|
CreateSchedule(schedule *TestSchedule) error
|
||||||
|
|
||||||
|
// UpdateSchedule updates an existing schedule
|
||||||
|
UpdateSchedule(schedule *TestSchedule) error
|
||||||
|
|
||||||
|
// DeleteSchedule removes a schedule
|
||||||
|
DeleteSchedule(scheduleId Identifier) error
|
||||||
|
|
||||||
|
// EnableSchedule enables a schedule
|
||||||
|
EnableSchedule(scheduleId Identifier) error
|
||||||
|
|
||||||
|
// DisableSchedule disables a schedule
|
||||||
|
DisableSchedule(scheduleId Identifier) error
|
||||||
|
|
||||||
|
// UpdateScheduleAfterRun updates a schedule after it has been executed
|
||||||
|
UpdateScheduleAfterRun(scheduleId Identifier) error
|
||||||
|
|
||||||
|
// ListDueSchedules retrieves all enabled schedules that are due to run
|
||||||
|
ListDueSchedules() ([]*TestSchedule, error)
|
||||||
|
|
||||||
|
// ValidateScheduleOwnership checks if a user owns a schedule
|
||||||
|
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
|
||||||
|
|
||||||
|
// DeleteSchedulesForTarget removes all schedules for a target
|
||||||
|
DeleteSchedulesForTarget(targetType TestScopeType, targetId Identifier) error
|
||||||
|
|
||||||
|
// RescheduleUpcomingTests randomizes next run times for all enabled schedules
|
||||||
|
// within their respective intervals to spread load evenly.
|
||||||
|
RescheduleUpcomingTests() (int, error)
|
||||||
|
|
||||||
|
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
|
||||||
|
// short window to avoid scheduler famine after a suspend or server restart.
|
||||||
|
RescheduleOverdueTests() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSchedulerUsecase is satisfied by both testScheduler and disabledScheduler
|
||||||
|
type AdminSchedulerUsecase interface {
|
||||||
|
GetSchedulerStatus() SchedulerStatus
|
||||||
|
SetEnabled(enabled bool) error
|
||||||
|
RescheduleUpcomingTests() (int, error)
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,10 @@ type UsecaseDependancies interface {
|
||||||
ServiceUsecase() ServiceUsecase
|
ServiceUsecase() ServiceUsecase
|
||||||
ServiceSpecsUsecase() ServiceSpecsUsecase
|
ServiceSpecsUsecase() ServiceSpecsUsecase
|
||||||
SessionUsecase() SessionUsecase
|
SessionUsecase() SessionUsecase
|
||||||
|
TestPluginUsecase() TestPluginUsecase
|
||||||
|
TestResultUsecase() TestResultUsecase
|
||||||
|
TestScheduleUsecase() TestScheduleUsecase
|
||||||
|
TestScheduler() AdminSchedulerUsecase
|
||||||
UserUsecase() UserUsecase
|
UserUsecase() UserUsecase
|
||||||
ZoneCorrectionApplierUsecase() ZoneCorrectionApplierUsecase
|
ZoneCorrectionApplierUsecase() ZoneCorrectionApplierUsecase
|
||||||
ZoneImporterUsecase() ZoneImporterUsecase
|
ZoneImporterUsecase() ZoneImporterUsecase
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,17 @@ type UserSettings struct {
|
||||||
|
|
||||||
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
|
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
|
||||||
ShowRRTypes bool `json:"showrrtypes,omitempty"`
|
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 {
|
func DefaultUserSettings() *UserSettings {
|
||||||
|
|
|
||||||
1
plugins/.gitignore
vendored
Normal file
1
plugins/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
*.so
|
||||||
7
plugins/matrix/Makefile
Normal file
7
plugins/matrix/Makefile
Normal 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
11
plugins/matrix/main.go
Normal 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
148
plugins/matrix/test.go
Normal 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
|
||||||
|
}
|
||||||
|
|
@ -111,6 +111,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
|
||||||
// Routes to virtual content
|
// Routes to virtual content
|
||||||
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
||||||
|
router.GET("/plugins/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/users/*_", serveOrReverse("/", cfg))
|
router.GET("/users/*_", serveOrReverse("/", cfg))
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,12 @@
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
||||||
</NavItem>
|
</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>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</Navbar>
|
||||||
|
|
|
||||||
143
web-admin/src/routes/plugins/+page.svelte
Normal file
143
web-admin/src/routes/plugins/+page.svelte
Normal 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>
|
||||||
326
web-admin/src/routes/plugins/[pname]/+page.svelte
Normal file
326
web-admin/src/routes/plugins/[pname]/+page.svelte
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
<!--
|
||||||
|
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,
|
||||||
|
Label,
|
||||||
|
Row,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
|
||||||
|
import { toasts } from '$lib/stores/toasts';
|
||||||
|
import {
|
||||||
|
getPluginsTestsByPname,
|
||||||
|
getPluginsTestsByPnameOptions,
|
||||||
|
putPluginsTestsByPnameOptions,
|
||||||
|
} from '$lib/api-admin';
|
||||||
|
import Resource from '$lib/components/inputs/Resource.svelte';
|
||||||
|
|
||||||
|
let pname = $derived($page.params.pname!);
|
||||||
|
|
||||||
|
let pluginStatusQ = $derived(getPluginsTestsByPname({ path: { pname } }));
|
||||||
|
let pluginOptionsQ = $derived(getPluginsTestsByPnameOptions({ path: { pname } }));
|
||||||
|
let optionValues = $state<Record<string, any>>({});
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
pluginOptionsQ.then((optionsR) => {
|
||||||
|
optionValues = { ...(optionsR.data || {}) };
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function saveOptions() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await putPluginsTestsByPnameOptions({
|
||||||
|
path: { pname },
|
||||||
|
body: { options: optionValues }
|
||||||
|
});
|
||||||
|
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
|
||||||
|
toasts.addToast({
|
||||||
|
message: `Plugin options updated successfully`,
|
||||||
|
type: 'success',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toasts.addErrorToast({
|
||||||
|
message: 'Failed to update options: ' + error,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanOrphanedOptions(adminOpts: any[]) {
|
||||||
|
const validOptIds = new Set(adminOpts.map(opt => opt.id));
|
||||||
|
const cleanedOptions: Record<string, any> = {};
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(optionValues)) {
|
||||||
|
if (validOptIds.has(key)) {
|
||||||
|
cleanedOptions[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await putPluginsTestsByPnameOptions({
|
||||||
|
path: { pname },
|
||||||
|
body: { options: cleanedOptions }
|
||||||
|
});
|
||||||
|
pluginOptionsQ = getPluginsTestsByPnameOptions({ path: { pname } });
|
||||||
|
toasts.addToast({
|
||||||
|
message: `Orphaned options removed successfully`,
|
||||||
|
type: 'success',
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toasts.addErrorToast({
|
||||||
|
message: 'Failed to clean options: ' + error,
|
||||||
|
timeout: 10000,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOrphanedOptions(adminOpts: any[]): string[] {
|
||||||
|
const validOptIds = new Set(adminOpts.map(opt => opt.id));
|
||||||
|
return Object.keys(optionValues).filter(key => !validOptIds.has(key));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Container class="flex-fill my-5">
|
||||||
|
<Row class="mb-4">
|
||||||
|
<Col>
|
||||||
|
<Button color="link" href="/plugins" class="mb-2">
|
||||||
|
<Icon name="arrow-left"></Icon>
|
||||||
|
Back to Plugins
|
||||||
|
</Button>
|
||||||
|
<h1 class="display-5">
|
||||||
|
<Icon name="puzzle-fill"></Icon>
|
||||||
|
{pname}
|
||||||
|
</h1>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{#await pluginStatusQ}
|
||||||
|
<p>Loading plugin status...</p>
|
||||||
|
{:then statusR}
|
||||||
|
{@const status = statusR.data}
|
||||||
|
{#if status}
|
||||||
|
<Row class="mb-4">
|
||||||
|
<Col md={6}>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<strong>Plugin Information</strong>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
<dt class="col-sm-4">Name:</dt>
|
||||||
|
<dd class="col-sm-8">{status.name}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Version:</dt>
|
||||||
|
<dd class="col-sm-8">{status.version}</dd>
|
||||||
|
|
||||||
|
<dt class="col-sm-4">Availability:</dt>
|
||||||
|
<dd class="col-sm-8">
|
||||||
|
{#if status.availableOn}
|
||||||
|
<div class="d-flex flex-wrap gap-1">
|
||||||
|
{#if status.availableOn.applyToDomain}
|
||||||
|
<Badge color="success">Domain-level</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
|
||||||
|
<Badge color="primary">
|
||||||
|
Providers: {status.availableOn.limitToProviders.join(', ')}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
|
||||||
|
<Badge color="info">
|
||||||
|
Services: {status.availableOn.limitToServices.join(', ')}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
{#if !status.availableOn.applyToDomain &&
|
||||||
|
(!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) &&
|
||||||
|
(!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
|
||||||
|
<Badge color="secondary">General</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">General</Badge>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col md={6}>
|
||||||
|
{#await pluginOptionsQ}
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<p>Loading options...</p>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{:then optionsR}
|
||||||
|
{@const options = optionsR.data}
|
||||||
|
{@const adminOpts = status.options?.adminOpts || []}
|
||||||
|
{@const readOnlyOptGroups = [
|
||||||
|
{ key: 'userOpts', label: 'User Options', opts: status.options?.userOpts || [] },
|
||||||
|
{ key: 'domainOpts', label: 'Domain Options', opts: status.options?.domainOpts || [] },
|
||||||
|
{ key: 'serviceOpts', label: 'Service Options', opts: status.options?.serviceOpts || [] },
|
||||||
|
{ key: 'runOpts', label: 'Run Options', opts: status.options?.runOpts || [] }
|
||||||
|
]}
|
||||||
|
{@const hasAnyOpts = adminOpts.length > 0 || readOnlyOptGroups.some(g => g.opts.length > 0)}
|
||||||
|
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
|
||||||
|
|
||||||
|
{#if orphanedOpts.length > 0}
|
||||||
|
<Alert color="warning" class="mb-3">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div>
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
<strong>Orphaned options detected:</strong> {orphanedOpts.join(', ')}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => cleanOrphanedOptions(adminOpts)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
Clean Up
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if adminOpts.length > 0}
|
||||||
|
<Card class="mb-3">
|
||||||
|
<CardHeader>
|
||||||
|
<strong>Admin Options</strong>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Form on:submit={saveOptions}>
|
||||||
|
{#each adminOpts as optDoc}
|
||||||
|
{#if optDoc.id}
|
||||||
|
{@const optName = optDoc.id}
|
||||||
|
<FormGroup>
|
||||||
|
<Resource
|
||||||
|
edit={true}
|
||||||
|
index={optName}
|
||||||
|
specs={optDoc}
|
||||||
|
type={optDoc.type || 'string'}
|
||||||
|
bind:value={optionValues[optName]}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button type="submit" color="success" disabled={saving}>
|
||||||
|
{#if saving}
|
||||||
|
<span class="spinner-border spinner-border-sm me-1"></span>
|
||||||
|
{/if}
|
||||||
|
<Icon name="check-circle"></Icon>
|
||||||
|
Save Changes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#each readOnlyOptGroups as optGroup}
|
||||||
|
{#if optGroup.opts.length > 0}
|
||||||
|
<Card class="mb-3">
|
||||||
|
<CardHeader>
|
||||||
|
<strong>{optGroup.label}</strong>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<dl class="row mb-0">
|
||||||
|
{#each optGroup.opts as optDoc}
|
||||||
|
<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">Type: {optDoc.type || 'string'}</small>
|
||||||
|
{#if optDoc.required}<small class="text-muted">Required</small>{/if}
|
||||||
|
</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if !hasAnyOpts}
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Alert color="info">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
This plugin has no configurable options.
|
||||||
|
</Alert>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Card>
|
||||||
|
<CardBody>
|
||||||
|
<Alert color="danger">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
Error loading options: {error.message}
|
||||||
|
</Alert>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
{:else}
|
||||||
|
<Alert color="danger">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
Error: Plugin data not found
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Alert color="danger">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
Error loading plugin: {error.message}
|
||||||
|
</Alert>
|
||||||
|
{/await}
|
||||||
|
</Container>
|
||||||
334
web-admin/src/routes/scheduler/+page.svelte
Normal file
334
web-admin/src/routes/scheduler/+page.svelte
Normal 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>
|
||||||
91
web/src/lib/api/plugins.ts
Normal file
91
web/src/lib/api/plugins.ts
Normal 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
190
web/src/lib/api/tests.ts
Normal 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 } }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -24,7 +24,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from "$app/navigation";
|
import { goto } from "$app/navigation";
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
import type { ClassValue } from 'svelte/elements';
|
import type { ClassValue } from "svelte/elements";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
|
|
@ -47,7 +47,6 @@
|
||||||
import { toasts } from "$lib/stores/toasts";
|
import { toasts } from "$lib/stores/toasts";
|
||||||
import { t, locales, locale } from "$lib/translations";
|
import { t, locales, locale } from "$lib/translations";
|
||||||
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: ClassValue;
|
class?: ClassValue;
|
||||||
sw_state: { triedUpdate: boolean; hasUpdate: boolean };
|
sw_state: { triedUpdate: boolean; hasUpdate: boolean };
|
||||||
|
|
@ -55,15 +54,13 @@
|
||||||
|
|
||||||
let { class: className, sw_state }: Props = $props();
|
let { class: className, sw_state }: Props = $props();
|
||||||
let helpLink = $derived(
|
let helpLink = $derived(
|
||||||
page.route && page.route.id ? (
|
page.route && page.route.id
|
||||||
page.route.id.startsWith("/providers/new/[ptype]") ? (
|
? page.route.id.startsWith("/providers/new/[ptype]")
|
||||||
getHelpPathFromProvider(page.url.pathname.split("/")[3])
|
? getHelpPathFromProvider(page.url.pathname.split("/")[3])
|
||||||
) : (
|
: "https://help.happydomain.org/" +
|
||||||
"https://help.happydomain.org/" + encodeURIComponent($locale) + getHelpPathFromRoute(page.route.id)
|
encodeURIComponent($locale) +
|
||||||
)
|
getHelpPathFromRoute(page.route.id)
|
||||||
) : (
|
: "https://help.happydomain.org/" + encodeURIComponent($locale),
|
||||||
"https://help.happydomain.org/" + encodeURIComponent($locale)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
function getHelpPathFromProvider(ptype: string): string {
|
function getHelpPathFromProvider(ptype: string): string {
|
||||||
|
|
@ -187,11 +184,15 @@
|
||||||
>
|
>
|
||||||
{$t("menu.dns-resolver")}
|
{$t("menu.dns-resolver")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
<DropdownItem divider />
|
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
active={page.route && page.route.id == "/me"}
|
active={page.route &&
|
||||||
href="/me"
|
(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")}
|
{$t("menu.my-account")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
{#if $userSession.email !== "_no_auth"}
|
{#if $userSession.email !== "_no_auth"}
|
||||||
|
|
@ -232,10 +233,7 @@
|
||||||
<DropdownToggle nav caret>{$locale}</DropdownToggle>
|
<DropdownToggle nav caret>{$locale}</DropdownToggle>
|
||||||
<DropdownMenu end>
|
<DropdownMenu end>
|
||||||
{#each $locales as lang}
|
{#each $locales as lang}
|
||||||
<DropdownItem
|
<DropdownItem active={$locale == lang} on:click={() => ($locale = lang)}>
|
||||||
active={$locale == lang}
|
|
||||||
on:click={() => ($locale = lang)}
|
|
||||||
>
|
|
||||||
{$t(`locales.${lang}`)}
|
{$t(`locales.${lang}`)}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
{/each}
|
{/each}
|
||||||
|
|
|
||||||
172
web/src/lib/components/modals/RunTestModal.svelte
Normal file
172
web/src/lib/components/modals/RunTestModal.svelte
Normal 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>
|
||||||
|
|
@ -82,6 +82,7 @@
|
||||||
"share": "Share the zone…",
|
"share": "Share the zone…",
|
||||||
"upload": "Import a zone file",
|
"upload": "Import a zone file",
|
||||||
"view": "View my zone",
|
"view": "View my zone",
|
||||||
|
"view-tests": "View tests",
|
||||||
"others": "More actions on {{domain}}"
|
"others": "More actions on {{domain}}"
|
||||||
},
|
},
|
||||||
"alert": {
|
"alert": {
|
||||||
|
|
@ -235,6 +236,7 @@
|
||||||
"my-domains": "My domains",
|
"my-domains": "My domains",
|
||||||
"my-providers": "My domain providers",
|
"my-providers": "My domain providers",
|
||||||
"dns-resolver": "DNS resolver",
|
"dns-resolver": "DNS resolver",
|
||||||
|
"plugins": "Domain Testers",
|
||||||
"my-account": "My account",
|
"my-account": "My account",
|
||||||
"logout": "Sign out",
|
"logout": "Sign out",
|
||||||
"provider-features": "Supported providers",
|
"provider-features": "Supported providers",
|
||||||
|
|
@ -472,7 +474,7 @@
|
||||||
"showrrtypes-title": "Show DNS record types",
|
"showrrtypes-title": "Show DNS record types",
|
||||||
"preferences": {
|
"preferences": {
|
||||||
"title": "Preferences",
|
"title": "Preferences",
|
||||||
"description": "Customize your preferences"
|
"description": "Customize your preferences"
|
||||||
},
|
},
|
||||||
"security": {
|
"security": {
|
||||||
"title": "Security & Access",
|
"title": "Security & Access",
|
||||||
|
|
@ -535,10 +537,186 @@
|
||||||
"ttl": "Remaining time in cache",
|
"ttl": "Remaining time in cache",
|
||||||
"showDNSSEC": "Show DNSSEC records in answer (if any)"
|
"showDNSSEC": "Show DNSSEC records in answer (if any)"
|
||||||
},
|
},
|
||||||
|
"tests": {
|
||||||
|
"run-test": {
|
||||||
|
"title": "Run Test",
|
||||||
|
"loading-options": "Loading test options...",
|
||||||
|
"configure-info": "Configure test options below. Pre-filled values are from domain-level settings.",
|
||||||
|
"no-options": "This test has no configurable options. Click \"Run Test\" to execute with default settings.",
|
||||||
|
"error-loading-options": "Error loading test options: {{error}}",
|
||||||
|
"run-button": "Run Test",
|
||||||
|
"triggered-success": "Test triggered successfully! Execution ID: {{id}}",
|
||||||
|
"trigger-failed": "Failed to trigger test: {{error}}"
|
||||||
|
},
|
||||||
|
"never": "Never",
|
||||||
|
"na": "N/A",
|
||||||
|
"status": {
|
||||||
|
"ok": "OK",
|
||||||
|
"info": "Info",
|
||||||
|
"warning": "Warning",
|
||||||
|
"error": "Error",
|
||||||
|
"unknown": "Unknown",
|
||||||
|
"not-run": "Not run"
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Tests for ",
|
||||||
|
"loading": "Loading tests...",
|
||||||
|
"loading-plugins": "Loading plugin information...",
|
||||||
|
"no-tests": "No tests available for this domain.",
|
||||||
|
"run-test": "Run Test",
|
||||||
|
"view-results": "View Results",
|
||||||
|
"error-loading": "Error loading tests: {{error}}",
|
||||||
|
"unknown-version": "Unknown",
|
||||||
|
"table": {
|
||||||
|
"plugin": "Test Plugin",
|
||||||
|
"status": "Status",
|
||||||
|
"last-run": "Last Run",
|
||||||
|
"schedule": "Schedule",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"enabled": "Enabled",
|
||||||
|
"disabled": "Disabled"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schedule": {
|
||||||
|
"title": "Schedule",
|
||||||
|
"card-title": "Automatic scheduling",
|
||||||
|
"auto-enabled": "Run automatically",
|
||||||
|
"auto-disabled": "Disabled (run manually only)",
|
||||||
|
"interval-label": "Check interval",
|
||||||
|
"hours": "hours",
|
||||||
|
"interval-hint": "Minimum 1 hour. The test will run once per interval.",
|
||||||
|
"next-run": "Next scheduled run",
|
||||||
|
"last-run": "Last run",
|
||||||
|
"no-schedule-yet": "No schedule created yet. Save to create one.",
|
||||||
|
"save": "Save",
|
||||||
|
"save-failed": "Failed to save schedule",
|
||||||
|
"saved": "Schedule saved successfully."
|
||||||
|
},
|
||||||
|
"results": {
|
||||||
|
"loading": "Loading test results...",
|
||||||
|
"no-results": "No test results yet. Click \"Run Test Now\" to execute the test.",
|
||||||
|
"title": "Test Results ({{count}})",
|
||||||
|
"run-test-now": "Run Test Now",
|
||||||
|
"back-to-tests": "Back to Tests",
|
||||||
|
"delete-all": "Delete All",
|
||||||
|
"delete-confirm": "Are you sure you want to delete this test result?",
|
||||||
|
"delete-all-confirm": "Are you sure you want to delete ALL test results for this test? This cannot be undone.",
|
||||||
|
"delete-failed": "Failed to delete result",
|
||||||
|
"delete-all-failed": "Failed to delete results",
|
||||||
|
"configure": "Configure",
|
||||||
|
"domain-level": "Domain-level",
|
||||||
|
"error-loading": "Error loading test results: {{error}}",
|
||||||
|
"table": {
|
||||||
|
"executed-at": "Executed At",
|
||||||
|
"status": "Status",
|
||||||
|
"message": "Message",
|
||||||
|
"duration": "Duration",
|
||||||
|
"type": "Type",
|
||||||
|
"actions": "Actions"
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"scheduled": "Scheduled",
|
||||||
|
"manual": "Manual"
|
||||||
|
},
|
||||||
|
"view": "View"
|
||||||
|
},
|
||||||
|
"result": {
|
||||||
|
"title": "Test Result Details",
|
||||||
|
"loading": "Loading test result...",
|
||||||
|
"relaunch": "Relaunch Test",
|
||||||
|
"delete": "Delete Result",
|
||||||
|
"back-to-results": "Back to Results",
|
||||||
|
"relaunch-failed": "Failed to relaunch test",
|
||||||
|
"delete-confirm": "Are you sure you want to delete this test result?",
|
||||||
|
"delete-failed": "Failed to delete result",
|
||||||
|
"error-loading": "Error loading test result: {{error}}",
|
||||||
|
"milliseconds": "milliseconds",
|
||||||
|
"seconds": "seconds",
|
||||||
|
"type": {
|
||||||
|
"scheduled": "Scheduled Test",
|
||||||
|
"manual": "Manual Test"
|
||||||
|
},
|
||||||
|
"test-options": "Test Options",
|
||||||
|
"full-report": "Full Report",
|
||||||
|
"field": {
|
||||||
|
"domain": "Domain:",
|
||||||
|
"executed-at": "Executed At:",
|
||||||
|
"duration": "Duration:",
|
||||||
|
"status": "Status:",
|
||||||
|
"status-message": "Status Message:",
|
||||||
|
"error": "Error:",
|
||||||
|
"plugin-version": "Plugin Version:"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"tests": {
|
||||||
|
"title": "Domain Tests",
|
||||||
|
"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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"zones": {
|
"zones": {
|
||||||
"upload": "Import a zone",
|
"upload": "Import a zone",
|
||||||
"import-text": "Import from text",
|
"import-text": "Import from text",
|
||||||
"import-file": "Import from file",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -473,6 +473,77 @@
|
||||||
"no-group": "Divers",
|
"no-group": "Divers",
|
||||||
"title": "Vos groupes"
|
"title": "Vos groupes"
|
||||||
},
|
},
|
||||||
|
"tests": {
|
||||||
|
"run-test": {
|
||||||
|
"title": "Lancer le test",
|
||||||
|
"loading-options": "Chargement des options du test...",
|
||||||
|
"configure-info": "Configurez les options du test ci-dessous. Les valeurs préremplies proviennent des paramètres au niveau du domaine.",
|
||||||
|
"no-options": "Ce test n'a pas d'options configurables. Cliquez sur \"Lancer le test\" pour l'exécuter avec les paramètres par défaut.",
|
||||||
|
"error-loading-options": "Erreur lors du chargement des options du test : {{error}}",
|
||||||
|
"run-button": "Lancer le test"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"plugins": {
|
||||||
|
"tests": {
|
||||||
|
"title": "Tests de domaines",
|
||||||
|
"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}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"zones": {
|
"zones": {
|
||||||
"upload": "Importer une zone",
|
"upload": "Importer une zone",
|
||||||
"import-text": "Depuis du texte",
|
"import-text": "Depuis du texte",
|
||||||
|
|
|
||||||
54
web/src/lib/model/plugin.ts
Normal file
54
web/src/lib/model/plugin.ts
Normal 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
106
web/src/lib/model/test.ts
Normal 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;
|
||||||
|
}
|
||||||
32
web/src/lib/stores/plugins.ts
Normal file
32
web/src/lib/stores/plugins.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -39,6 +39,10 @@ interface Params {
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
suggestion?: string;
|
suggestion?: string;
|
||||||
|
error?: string;
|
||||||
|
providers?: string;
|
||||||
|
services?: string;
|
||||||
|
options?: string;
|
||||||
// add more parameters that are used here
|
// add more parameters that are used here
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -76,7 +76,17 @@
|
||||||
goto(
|
goto(
|
||||||
"/domains/" +
|
"/domains/" +
|
||||||
encodeURIComponent(domainLink(dn)) +
|
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) {
|
if (selectedDomain != dn) {
|
||||||
|
|
@ -166,7 +176,34 @@
|
||||||
<SelectDomain bind:selectedDomain />
|
<SelectDomain bind:selectedDomain />
|
||||||
</div>
|
</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
|
<Button
|
||||||
class="mt-2"
|
class="mt-2"
|
||||||
outline
|
outline
|
||||||
|
|
@ -220,6 +257,9 @@
|
||||||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
|
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
|
||||||
{$t("domains.actions.audit")}
|
{$t("domains.actions.audit")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/tests`}>
|
||||||
|
{$t("domains.actions.view-tests")}
|
||||||
|
</DropdownItem>
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
|
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
|
||||||
{$t("domains.actions.view")}
|
{$t("domains.actions.view")}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { Load } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export const load: Load = async ({ parent }) => {
|
|
||||||
const data = await parent();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
isHistoryPage: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import type { Load } from "@sveltejs/kit";
|
|
||||||
|
|
||||||
export const load: Load = async ({ parent }) => {
|
|
||||||
const data = await parent();
|
|
||||||
|
|
||||||
return {
|
|
||||||
...data,
|
|
||||||
isAuditPage: true,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
17
web/src/routes/domains/[dn]/tests/+layout.ts
Normal file
17
web/src/routes/domains/[dn]/tests/+layout.ts
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
256
web/src/routes/domains/[dn]/tests/+page.svelte
Normal file
256
web/src/routes/domains/[dn]/tests/+page.svelte
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
<!--
|
||||||
|
This file is part of the happyDomain (R) project.
|
||||||
|
Copyright (c) 2022-2026 happyDomain
|
||||||
|
Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
This program is offered under a commercial and under the AGPL license.
|
||||||
|
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
|
||||||
|
For AGPL licensing:
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
import { PluginResultStatus, TestScopeType, type AvailableTest } from "$lib/model/test";
|
||||||
|
import { plugins } from "$lib/stores/plugins";
|
||||||
|
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { domain: Domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
let testsPromise = $derived(listAvailableTests(data.domain.id));
|
||||||
|
let runTestModal: RunTestModal;
|
||||||
|
let togglingTests = $state(new Set<string>());
|
||||||
|
|
||||||
|
function handleTestTriggered(_: string, pluginName: string) {
|
||||||
|
// Refresh the test list to show updated status
|
||||||
|
testsPromise = listAvailableTests(data.domain.id);
|
||||||
|
goto(`/domains/${page.params.dn!}/tests/${pluginName}/results`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleToggleEnabled(test: AvailableTest) {
|
||||||
|
const next = new Set(togglingTests);
|
||||||
|
next.add(test.plugin_name);
|
||||||
|
togglingTests = next;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newEnabled = !test.enabled;
|
||||||
|
if (test.schedule) {
|
||||||
|
await updateTestSchedule(test.schedule.id, {
|
||||||
|
...test.schedule,
|
||||||
|
enabled: newEnabled,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// No schedule record yet — create one to persist the disabled state.
|
||||||
|
// (Enabled → Enabled needs no action since that's the implicit default.)
|
||||||
|
await createTestSchedule({
|
||||||
|
plugin_name: test.plugin_name,
|
||||||
|
target_type: TestScopeType.TestScopeDomain,
|
||||||
|
target_id: data.domain.id,
|
||||||
|
interval: 0,
|
||||||
|
enabled: newEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
testsPromise = listAvailableTests(data.domain.id);
|
||||||
|
} catch {
|
||||||
|
// toggle reverts visually on refresh; nothing extra needed
|
||||||
|
} finally {
|
||||||
|
const after = new Set(togglingTests);
|
||||||
|
after.delete(test.plugin_name);
|
||||||
|
togglingTests = after;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "success";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusKey(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "tests.status.ok";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "tests.status.info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "tests.status.warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "tests.status.error";
|
||||||
|
default:
|
||||||
|
return "tests.status.unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString?: string): string {
|
||||||
|
if (!dateString) return $t("tests.never");
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(new Date(dateString));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Tests - {data.domain.domain} - happyDomain</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex-fill pb-4 pt-2">
|
||||||
|
<h2>
|
||||||
|
{$t("tests.list.title")}<span class="font-monospace">{data.domain.domain}</span>
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{#await testsPromise}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("tests.list.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:then tests}
|
||||||
|
{#if !$plugins}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("tests.list.loading-plugins")}</p>
|
||||||
|
</div>
|
||||||
|
{:else if !tests || tests.length === 0}
|
||||||
|
<Card body class="mt-3">
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("tests.list.no-tests")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Table hover striped class="mt-3">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.list.table.plugin")}</th>
|
||||||
|
<th>{$t("tests.list.table.status")}</th>
|
||||||
|
<th>{$t("tests.list.table.last-run")}</th>
|
||||||
|
<th>{$t("tests.list.table.schedule")}</th>
|
||||||
|
<th>{$t("tests.list.table.actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each tests as test}
|
||||||
|
{@const pluginInfo = $plugins[test.plugin_name]}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">
|
||||||
|
<strong>{pluginInfo?.name || test.plugin_name}</strong>
|
||||||
|
<small class="ms-1 text-muted">
|
||||||
|
{pluginInfo?.version || $t("tests.list.unknown-version")}
|
||||||
|
</small>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-center">
|
||||||
|
{#if test.last_result !== undefined}
|
||||||
|
<Badge color={getStatusColor(test.last_result.status)}>
|
||||||
|
{$t(getStatusKey(test.last_result.status))}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">{$t("tests.status.not-run")}</Badge>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{formatDate(test.last_result?.executed_at)}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
id="toggle-{test.plugin_name}"
|
||||||
|
checked={test.enabled}
|
||||||
|
disabled={togglingTests.has(test.plugin_name)}
|
||||||
|
onchange={() => handleToggleEnabled(test)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
class="form-check-label small"
|
||||||
|
for="toggle-{test.plugin_name}"
|
||||||
|
>
|
||||||
|
{test.enabled
|
||||||
|
? $t("tests.list.schedule.enabled")
|
||||||
|
: $t("tests.list.schedule.disabled")}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="primary"
|
||||||
|
onclick={() =>
|
||||||
|
runTestModal.open(
|
||||||
|
test.plugin_name,
|
||||||
|
pluginInfo?.name || test.plugin_name,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon name="play-fill"></Icon>
|
||||||
|
{$t("tests.list.run-test")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="info"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}/results`}
|
||||||
|
>
|
||||||
|
<Icon name="bar-chart-fill"></Icon>
|
||||||
|
{$t("tests.list.view-results")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="dark"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(test.plugin_name)}`}
|
||||||
|
title={$t("tests.list.configure")}
|
||||||
|
>
|
||||||
|
<Icon name="gear"></Icon>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Card body color="danger" class="mt-3">
|
||||||
|
<p class="mb-0">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{$t("tests.list.error-loading", { error: error.message })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RunTestModal
|
||||||
|
domainId={data.domain.id}
|
||||||
|
onTestTriggered={handleTestTriggered}
|
||||||
|
bind:this={runTestModal}
|
||||||
|
/>
|
||||||
311
web/src/routes/domains/[dn]/tests/[tname]/+page.svelte
Normal file
311
web/src/routes/domains/[dn]/tests/[tname]/+page.svelte
Normal file
|
|
@ -0,0 +1,311 @@
|
||||||
|
<!--
|
||||||
|
This file is part of the happyDomain (R) project.
|
||||||
|
Copyright (c) 2022-2026 happyDomain
|
||||||
|
Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
This program is offered under a commercial and under the AGPL license.
|
||||||
|
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
|
||||||
|
For AGPL licensing:
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { listAvailableTests, updateTestSchedule, createTestSchedule } from "$lib/api/tests";
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
import { TestScopeType, type AvailableTest } from "$lib/model/test";
|
||||||
|
import { plugins } from "$lib/stores/plugins";
|
||||||
|
import { toasts } from "$lib/stores/toasts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { domain: Domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const testName = $derived(page.params.tname || "");
|
||||||
|
const pluginName = $derived($plugins?.[testName]?.name || testName);
|
||||||
|
|
||||||
|
// Resolved test data
|
||||||
|
let test = $state<AvailableTest | null>(null);
|
||||||
|
let loading = $state(true);
|
||||||
|
let loadError = $state<string | null>(null);
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
let formEnabled = $state(true);
|
||||||
|
let formIntervalHours = $state(24);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
async function loadTest() {
|
||||||
|
loading = true;
|
||||||
|
loadError = null;
|
||||||
|
try {
|
||||||
|
const tests = await listAvailableTests(data.domain.id);
|
||||||
|
const found = tests?.find((t) => t.plugin_name === testName) ?? null;
|
||||||
|
test = found;
|
||||||
|
if (found) {
|
||||||
|
formEnabled = found.enabled;
|
||||||
|
formIntervalHours =
|
||||||
|
found.schedule && found.schedule.interval > 0
|
||||||
|
? found.schedule.interval / (3600 * 1e9)
|
||||||
|
: 24;
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
loadError = e.message;
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadTest();
|
||||||
|
|
||||||
|
function formatDate(dateString?: string): string {
|
||||||
|
if (!dateString) return $t("tests.never");
|
||||||
|
const d = new Date(dateString);
|
||||||
|
if (isNaN(d.getTime())) return $t("tests.never");
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
}).format(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(dateString?: string): string {
|
||||||
|
if (!dateString) return "";
|
||||||
|
const d = new Date(dateString);
|
||||||
|
if (isNaN(d.getTime())) return "";
|
||||||
|
const now = new Date();
|
||||||
|
const diffMs = d.getTime() - now.getTime();
|
||||||
|
const absDiffMs = Math.abs(diffMs);
|
||||||
|
|
||||||
|
if (absDiffMs < 60_000) return diffMs > 0 ? "in less than a minute" : "just now";
|
||||||
|
|
||||||
|
const minutes = Math.floor(absDiffMs / 60_000);
|
||||||
|
const hours = Math.floor(absDiffMs / 3_600_000);
|
||||||
|
const days = Math.floor(absDiffMs / 86_400_000);
|
||||||
|
|
||||||
|
let label: string;
|
||||||
|
if (days > 0) {
|
||||||
|
label = `${days}d ${hours % 24}h`;
|
||||||
|
} else if (hours > 0) {
|
||||||
|
label = `${hours}h ${minutes % 60}m`;
|
||||||
|
} else {
|
||||||
|
label = `${minutes}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return diffMs > 0 ? `in ${label}` : `${label} ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
if (!test) return;
|
||||||
|
saving = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
|
||||||
|
|
||||||
|
if (test.schedule) {
|
||||||
|
await updateTestSchedule(test.schedule.id, {
|
||||||
|
...test.schedule,
|
||||||
|
enabled: formEnabled,
|
||||||
|
interval: intervalNs,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await createTestSchedule({
|
||||||
|
plugin_name: test.plugin_name,
|
||||||
|
target_type: TestScopeType.TestScopeDomain,
|
||||||
|
target_id: data.domain.id,
|
||||||
|
interval: intervalNs,
|
||||||
|
enabled: formEnabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
toasts.addToast({ title: $t("tests.schedule.saved"), type: "success", timeout: 3000 });
|
||||||
|
await loadTest();
|
||||||
|
} catch (e: any) {
|
||||||
|
toasts.addErrorToast({ title: $t("tests.schedule.save-failed"), message: e.message });
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
{testName} - {$t("tests.schedule.title")} - {data.domain.domain} - happyDomain
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex-fill pb-4 pt-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2>
|
||||||
|
<span class="font-monospace">{data.domain.domain}</span>
|
||||||
|
–
|
||||||
|
{pluginName}
|
||||||
|
– {$t("tests.schedule.title")}
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button
|
||||||
|
color="secondary"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests`}
|
||||||
|
>
|
||||||
|
<Icon name="arrow-left"></Icon>
|
||||||
|
{$t("zones.return-to-tests")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="info"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results`}
|
||||||
|
>
|
||||||
|
<Icon name="bar-chart-fill"></Icon>
|
||||||
|
{$t("tests.list.view-results")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("tests.list.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:else if loadError}
|
||||||
|
<Card body color="danger">
|
||||||
|
<p class="mb-0">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{$t("tests.list.error-loading", { error: loadError })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else if !test}
|
||||||
|
<Card body>
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("tests.list.no-tests")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<Icon name="clock-history"></Icon>
|
||||||
|
{$t("tests.schedule.card-title")}
|
||||||
|
</h4>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="form-check form-switch">
|
||||||
|
<input
|
||||||
|
class="form-check-input"
|
||||||
|
type="checkbox"
|
||||||
|
role="switch"
|
||||||
|
id="schedule-enabled"
|
||||||
|
bind:checked={formEnabled}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<label class="form-check-label" for="schedule-enabled">
|
||||||
|
{#if formEnabled}
|
||||||
|
<Badge color="success"
|
||||||
|
>{$t("tests.schedule.auto-enabled")}</Badge
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary"
|
||||||
|
>{$t("tests.schedule.auto-disabled")}</Badge
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if formEnabled}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="schedule-interval" class="form-label fw-semibold">
|
||||||
|
{$t("tests.schedule.interval-label")}
|
||||||
|
</label>
|
||||||
|
<div class="input-group" style="max-width: 300px;">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
id="schedule-interval"
|
||||||
|
min={1}
|
||||||
|
step={1}
|
||||||
|
bind:value={formIntervalHours}
|
||||||
|
disabled={saving}
|
||||||
|
/>
|
||||||
|
<span class="input-group-text">
|
||||||
|
{$t("tests.schedule.hours")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
{$t("tests.schedule.interval-hint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if test.schedule}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
{#if test.schedule.last_run}
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="text-muted fw-semibold">
|
||||||
|
{$t("tests.schedule.last-run")}:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatDate(test.schedule.last_run)}
|
||||||
|
<small class="text-muted">
|
||||||
|
({formatRelative(test.schedule.last_run)})
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if test.enabled && test.schedule.next_run}
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="text-muted fw-semibold">
|
||||||
|
{$t("tests.schedule.next-run")}:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatDate(test.schedule.next_run)}
|
||||||
|
<small class="text-muted">
|
||||||
|
({formatRelative(test.schedule.next_run)})
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("tests.schedule.no-schedule-yet")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button color="primary" disabled={saving} onclick={handleSave}>
|
||||||
|
{#if saving}
|
||||||
|
<Spinner size="sm" class="me-1" />
|
||||||
|
{/if}
|
||||||
|
<Icon name="check-lg"></Icon>
|
||||||
|
{$t("tests.schedule.save")}
|
||||||
|
</Button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
281
web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte
Normal file
281
web/src/routes/domains/[dn]/tests/[tname]/results/+page.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
||||||
|
<!--
|
||||||
|
This file is part of the happyDomain (R) project.
|
||||||
|
Copyright (c) 2022-2026 happyDomain
|
||||||
|
Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
This program is offered under a commercial and under the AGPL license.
|
||||||
|
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
|
||||||
|
For AGPL licensing:
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Alert,
|
||||||
|
Icon,
|
||||||
|
Table,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Spinner,
|
||||||
|
ButtonGroup,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { listTestResults, deleteTestResult, deleteAllTestResults } from "$lib/api/tests";
|
||||||
|
import { getPluginStatus } from "$lib/api/plugins";
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
import { PluginResultStatus } from "$lib/model/test";
|
||||||
|
import RunTestModal from "$lib/components/modals/RunTestModal.svelte";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { domain: Domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const testName = $derived(page.params.tname || "");
|
||||||
|
|
||||||
|
let resultsPromise = $derived(listTestResults(data.domain.id, testName));
|
||||||
|
let pluginPromise = $derived(getPluginStatus(testName));
|
||||||
|
let runTestModal: RunTestModal;
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
|
||||||
|
function getStatusColor(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "success";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusKey(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "tests.status.ok";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "tests.status.info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "tests.status.warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "tests.status.error";
|
||||||
|
default:
|
||||||
|
return "tests.status.unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "medium",
|
||||||
|
}).format(new Date(dateString));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(duration?: number): string {
|
||||||
|
if (!duration) return $t("tests.na");
|
||||||
|
const seconds = duration / 1000000000;
|
||||||
|
if (seconds < 1) return `${(seconds * 1000).toFixed(0)}ms`;
|
||||||
|
return `${seconds.toFixed(2)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTestTriggered() {
|
||||||
|
// Refresh results list after test is triggered
|
||||||
|
resultsPromise = listTestResults(data.domain.id, testName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteResult(resultId: string) {
|
||||||
|
if (!confirm($t("tests.results.delete-confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTestResult(data.domain.id, testName, resultId);
|
||||||
|
resultsPromise = listTestResults(data.domain.id, testName);
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("tests.results.delete-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAll() {
|
||||||
|
if (!confirm($t("tests.results.delete-all-confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAllTestResults(data.domain.id, testName);
|
||||||
|
resultsPromise = listTestResults(data.domain.id, testName);
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("tests.results.delete-all-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{testName} Results - {data.domain.domain} - happyDomain</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex-fill pb-4 pt-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2>
|
||||||
|
<span class="font-monospace">{data.domain.domain}</span>
|
||||||
|
–
|
||||||
|
{#await pluginPromise then plugin}
|
||||||
|
{plugin.name || testName}
|
||||||
|
{:catch}
|
||||||
|
{testName}
|
||||||
|
{/await}
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button
|
||||||
|
color="dark"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`}
|
||||||
|
>
|
||||||
|
<Icon name="gear-fill"></Icon>
|
||||||
|
{$t("tests.results.configure")}
|
||||||
|
</Button>
|
||||||
|
{#await pluginPromise then plugin}
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onclick={() => runTestModal.open(testName, plugin.name || testName)}
|
||||||
|
>
|
||||||
|
<Icon name="play-fill"></Icon>
|
||||||
|
{$t("tests.results.run-test-now")}
|
||||||
|
</Button>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
{#key errorMessage}
|
||||||
|
<Alert color="danger" dismissible>
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#await resultsPromise}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("tests.results.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:then results}
|
||||||
|
{#if !results || results.length === 0}
|
||||||
|
<Card body>
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("tests.results.no-results")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h4>{$t("tests.results.title", { count: results.length })}</h4>
|
||||||
|
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
{$t("tests.results.delete-all")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table hover striped>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.results.table.executed-at")}</th>
|
||||||
|
<th class="text-center">{$t("tests.results.table.status")}</th>
|
||||||
|
<th>{$t("tests.results.table.message")}</th>
|
||||||
|
<th>{$t("tests.results.table.duration")}</th>
|
||||||
|
<th class="text-center">{$t("tests.results.table.type")}</th>
|
||||||
|
<th>{$t("tests.results.table.actions")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each results as result}
|
||||||
|
<tr>
|
||||||
|
<td class="align-middle">
|
||||||
|
{formatDate(result.executed_at)}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-center">
|
||||||
|
<Badge color={getStatusColor(result.status)}>
|
||||||
|
{$t(getStatusKey(result.status))}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{result.status_line}
|
||||||
|
{#if result.error}
|
||||||
|
<br />
|
||||||
|
<small class="text-danger">{result.error}</small>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
{formatDuration(result.duration)}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-center">
|
||||||
|
{#if result.scheduled_test}
|
||||||
|
<Badge color="info">
|
||||||
|
<Icon name="clock"></Icon>
|
||||||
|
{$t("tests.results.type.scheduled")}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">
|
||||||
|
<Icon name="hand-index"></Icon>
|
||||||
|
{$t("tests.results.type.manual")}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
href={`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}/results/${encodeURIComponent(result.id)}`}
|
||||||
|
>
|
||||||
|
<Icon name="eye-fill"></Icon>
|
||||||
|
{$t("tests.results.view")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="danger"
|
||||||
|
outline
|
||||||
|
onclick={() => handleDeleteResult(result.id)}
|
||||||
|
>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Card body color="danger">
|
||||||
|
<p class="mb-0">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{$t("tests.results.error-loading", { error: error.message })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RunTestModal
|
||||||
|
domainId={data.domain.id}
|
||||||
|
onTestTriggered={handleTestTriggered}
|
||||||
|
bind:this={runTestModal}
|
||||||
|
/>
|
||||||
|
|
@ -0,0 +1,352 @@
|
||||||
|
<!--
|
||||||
|
This file is part of the happyDomain (R) project.
|
||||||
|
Copyright (c) 2022-2026 happyDomain
|
||||||
|
Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
|
||||||
|
This program is offered under a commercial and under the AGPL license.
|
||||||
|
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
|
||||||
|
For AGPL licensing:
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
-->
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Col,
|
||||||
|
Icon,
|
||||||
|
Row,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { page } from "$app/state";
|
||||||
|
import { goto } from "$app/navigation";
|
||||||
|
import { getTestResult, deleteTestResult, triggerTest } from "$lib/api/tests";
|
||||||
|
import { getPluginStatus } from "$lib/api/plugins";
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
import { PluginResultStatus } from "$lib/model/test";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
data: { domain: Domain };
|
||||||
|
}
|
||||||
|
|
||||||
|
let { data }: Props = $props();
|
||||||
|
|
||||||
|
const testName = $derived(page.params.tname || "");
|
||||||
|
const resultId = $derived(page.params.rid || "");
|
||||||
|
|
||||||
|
let resultPromise = $derived(getTestResult(data.domain.id, testName, resultId));
|
||||||
|
let pluginPromise = $derived(getPluginStatus(testName));
|
||||||
|
let errorMessage = $state<string | null>(null);
|
||||||
|
let resolvedResult = $state<import("$lib/model/test").TestResult | null>(null);
|
||||||
|
let isRelaunching = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
resultPromise.then((r) => {
|
||||||
|
resolvedResult = r;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function getStatusColor(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "success";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "danger";
|
||||||
|
default:
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusKey(status: PluginResultStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case PluginResultStatus.OK:
|
||||||
|
return "tests.status.ok";
|
||||||
|
case PluginResultStatus.Info:
|
||||||
|
return "tests.status.info";
|
||||||
|
case PluginResultStatus.Warn:
|
||||||
|
return "tests.status.warning";
|
||||||
|
case PluginResultStatus.KO:
|
||||||
|
return "tests.status.error";
|
||||||
|
default:
|
||||||
|
return "tests.status.unknown";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateString: string): string {
|
||||||
|
return new Intl.DateTimeFormat(undefined, {
|
||||||
|
dateStyle: "long",
|
||||||
|
timeStyle: "medium",
|
||||||
|
}).format(new Date(dateString));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(duration?: number): string {
|
||||||
|
if (!duration) return $t("tests.na");
|
||||||
|
const seconds = duration / 1000000000;
|
||||||
|
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${$t("tests.result.milliseconds")}`;
|
||||||
|
return `${seconds.toFixed(2)} ${$t("tests.result.seconds")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRelaunch() {
|
||||||
|
if (!resolvedResult) return;
|
||||||
|
|
||||||
|
isRelaunching = true;
|
||||||
|
try {
|
||||||
|
await triggerTest(data.domain.id, testName, resolvedResult.options);
|
||||||
|
goto(
|
||||||
|
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("tests.result.relaunch-failed");
|
||||||
|
} finally {
|
||||||
|
isRelaunching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm($t("tests.result.delete-confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteTestResult(data.domain.id, testName, resultId);
|
||||||
|
goto(
|
||||||
|
`/domains/${encodeURIComponent(data.domain.domain)}/tests/${encodeURIComponent(testName)}`,
|
||||||
|
);
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("tests.result.delete-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>
|
||||||
|
Test Result - {testName} - {data.domain.domain} - happyDomain
|
||||||
|
</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex-fill pb-4 pt-2 mw-100">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h2 class="text-truncate">
|
||||||
|
<span class="font-monospace">{data.domain.domain}</span>
|
||||||
|
–
|
||||||
|
{$t("tests.result.title")}
|
||||||
|
</h2>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
onclick={handleRelaunch}
|
||||||
|
disabled={!resolvedResult || isRelaunching}
|
||||||
|
>
|
||||||
|
{#if isRelaunching}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{:else}
|
||||||
|
<Icon name="arrow-repeat"></Icon>
|
||||||
|
{/if}
|
||||||
|
<span class="d-none d-lg-inline">
|
||||||
|
{$t("tests.result.relaunch")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
<span class="d-none d-lg-inline">
|
||||||
|
{$t("tests.result.delete")}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if errorMessage}
|
||||||
|
{#key errorMessage}
|
||||||
|
<Alert color="danger" dismissible>
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{errorMessage}
|
||||||
|
</Alert>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#await Promise.all([resultPromise, pluginPromise])}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("tests.result.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:then [result, plugin]}
|
||||||
|
<Row>
|
||||||
|
<Col lg>
|
||||||
|
<Card class="mb-3">
|
||||||
|
<CardHeader>
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<div class="d-flex align-items-end gap-2">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
{plugin.name || testName}
|
||||||
|
</h4>
|
||||||
|
{#if plugin.version}
|
||||||
|
<small
|
||||||
|
class="text-muted"
|
||||||
|
title={$t("tests.result.field.plugin-version")}
|
||||||
|
>
|
||||||
|
{plugin.version}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if result.scheduled_test}
|
||||||
|
<Badge color="info">
|
||||||
|
<Icon name="clock"></Icon>
|
||||||
|
{$t("tests.result.type.scheduled")}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">
|
||||||
|
<Icon name="hand-index"></Icon>
|
||||||
|
{$t("tests.result.type.manual")}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody class="p-2">
|
||||||
|
<Table borderless size="sm" class="mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 200px">{$t("tests.result.field.domain")}</th>
|
||||||
|
<td class="font-monospace">{data.domain.domain}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.result.field.executed-at")}</th>
|
||||||
|
<td>{formatDate(result.executed_at)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.result.field.duration")}</th>
|
||||||
|
<td>{formatDuration(result.duration)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.result.field.status")}</th>
|
||||||
|
<td>
|
||||||
|
<Badge color={getStatusColor(result.status)}>
|
||||||
|
{$t(getStatusKey(result.status))}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.result.field.status-message")}</th>
|
||||||
|
<td>{result.status_line}</td>
|
||||||
|
</tr>
|
||||||
|
{#if result.error}
|
||||||
|
<tr>
|
||||||
|
<th>{$t("tests.result.field.error")}</th>
|
||||||
|
<td class="text-danger">{result.error}</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
{#if result.options && Object.keys(result.options).length > 0}
|
||||||
|
<Col lg>
|
||||||
|
<Card class="mb-3">
|
||||||
|
<CardHeader>
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<Icon name="sliders"></Icon>
|
||||||
|
{$t("tests.result.test-options")}
|
||||||
|
</h5>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody class="p-2">
|
||||||
|
<Table borderless size="sm" class="mb-0">
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries(plugin.options ?? {}) as [optKey, optVals]}
|
||||||
|
{#each optVals as option}
|
||||||
|
{@const value =
|
||||||
|
(option.id
|
||||||
|
? result.options[option.id]
|
||||||
|
: undefined) ||
|
||||||
|
option.default ||
|
||||||
|
option.placeholder ||
|
||||||
|
""}
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="text-truncate"
|
||||||
|
style="max-width: min(200px, 40vw)"
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
{option.label}:
|
||||||
|
</th>
|
||||||
|
<td class:text-truncate={typeof value !== "object"}>
|
||||||
|
{#if typeof value === "object"}
|
||||||
|
<pre class="mb-0"><code
|
||||||
|
>{JSON.stringify(
|
||||||
|
value,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}</code
|
||||||
|
></pre>
|
||||||
|
{:else}
|
||||||
|
{value}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{#if result.report}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<Icon name="file-earmark-text"></Icon>
|
||||||
|
{$t("tests.result.full-report")}
|
||||||
|
</h5>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody class="text-truncate p-0">
|
||||||
|
{#if typeof result.report === "string"}
|
||||||
|
<pre class="bg-light p-3 rounded mb-0"><code>{result.report}</code></pre>
|
||||||
|
{:else}
|
||||||
|
<pre class="bg-light p-3 rounded mb-0"><code
|
||||||
|
>{JSON.stringify(result.report, null, 2)}</code
|
||||||
|
></pre>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Card body color="danger">
|
||||||
|
<p class="mb-0">
|
||||||
|
<Icon name="exclamation-triangle-fill"></Icon>
|
||||||
|
{$t("tests.result.error-loading", { error: error.message })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
pre {
|
||||||
|
overflow-x: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
150
web/src/routes/plugins/+page.svelte
Normal file
150
web/src/routes/plugins/+page.svelte
Normal 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>
|
||||||
404
web/src/routes/plugins/[pid]/+page.svelte
Normal file
404
web/src/routes/plugins/[pid]/+page.svelte
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
<!--
|
||||||
|
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/stores";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { toasts } from "$lib/stores/toasts";
|
||||||
|
import { getPluginStatus, getPluginOptions, updatePluginOptions } from "$lib/api/plugins";
|
||||||
|
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||||
|
|
||||||
|
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}
|
||||||
|
|
||||||
|
{#each readOnlyOptGroups 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 optionValues[optName]}
|
||||||
|
<span class="text-muted d-block"
|
||||||
|
>{optionValues[optName]}</span
|
||||||
|
>
|
||||||
|
{:else if optDoc.default}
|
||||||
|
<span class="text-muted d-block"
|
||||||
|
>{optDoc.default}</span
|
||||||
|
>
|
||||||
|
{:else if optDoc.placeholder}
|
||||||
|
<em class="text-muted d-block"
|
||||||
|
>{optDoc.placeholder}</em
|
||||||
|
>
|
||||||
|
{/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}
|
||||||
|
|
||||||
|
{#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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue