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