Compare commits
21 commits
1bba7d1279
...
41a3f9f584
| Author | SHA1 | Date | |
|---|---|---|---|
| 41a3f9f584 | |||
| f0dca07597 | |||
| ad619ce566 | |||
| 939c988fdc | |||
| 6ed26d00b1 | |||
| 0afd77151c | |||
| 38351bc468 | |||
| 6c1c5d8d83 | |||
| 02aa3c2cbf | |||
| b5202a37df | |||
| 5c451af2eb | |||
| e707c95117 | |||
| 058deefec5 | |||
| 0cc96399ad | |||
| 8396638841 | |||
| e3f0da390d | |||
| 623db65b3e | |||
| 80a6dd70a0 | |||
| 8a9026b411 | |||
| d68377394a | |||
| 200a96a688 |
109 changed files with 9739 additions and 344 deletions
61
checks/interface.go
Normal file
61
checks/interface.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// Package checks provides the registry for domain health checkers.
|
||||
// It allows individual checker implementations to self-register at startup
|
||||
// via init() functions and exposes functions to retrieve registered checkers.
|
||||
package checks // import "git.happydns.org/happyDomain/checks"
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// checkersList is the ordered list of all registered checks.
|
||||
var checkersList map[string]happydns.Checker = map[string]happydns.Checker{}
|
||||
|
||||
// RegisterChecker declares the existence of the given check. It is intended to
|
||||
// be called from init() functions in individual check files so that each check
|
||||
// self-registers at program startup.
|
||||
//
|
||||
// If two checks try to register the same environment name the program will
|
||||
// terminate: name collisions are a configuration error, not a runtime one.
|
||||
func RegisterChecker(name string, checker happydns.Checker) {
|
||||
log.Println("Registering new checker:")
|
||||
checkersList[name] = checker
|
||||
}
|
||||
|
||||
// GetCheckers returns the ordered list of all registered checks.
|
||||
func GetCheckers() *map[string]happydns.Checker {
|
||||
return &checkersList
|
||||
}
|
||||
|
||||
// FindChecker returns the check registered under the given environment name,
|
||||
// or an error if no check with that name exists.
|
||||
func FindChecker(name string) (happydns.Checker, error) {
|
||||
c, ok := checkersList[name]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unable to find check %q", name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
307
checks/zonemaster.go
Normal file
307
checks/zonemaster.go
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
package checks
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func init() {
|
||||
RegisterChecker("zonemaster", &ZonemasterCheck{})
|
||||
}
|
||||
|
||||
type ZonemasterCheck struct{}
|
||||
|
||||
func (p *ZonemasterCheck) ID() string {
|
||||
return "zonemaster"
|
||||
}
|
||||
|
||||
func (p *ZonemasterCheck) Name() string {
|
||||
return "Zonemaster"
|
||||
}
|
||||
|
||||
func (p *ZonemasterCheck) Availability() happydns.CheckerAvailability {
|
||||
return happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ZonemasterCheck) Options() happydns.CheckerOptionsDocumentation {
|
||||
return happydns.CheckerOptionsDocumentation{
|
||||
RunOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
Label: "Domain name to check",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
{
|
||||
Id: "profile",
|
||||
Type: "string",
|
||||
Label: "Profile",
|
||||
Placeholder: "default",
|
||||
Default: "default",
|
||||
},
|
||||
},
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "language",
|
||||
Type: "select",
|
||||
Label: "Result language",
|
||||
Default: "en",
|
||||
Choices: []string{
|
||||
"en", // English
|
||||
"fr", // French
|
||||
"de", // German
|
||||
"es", // Spanish
|
||||
"sv", // Swedish
|
||||
"da", // Danish
|
||||
"fi", // Finnish
|
||||
"nb", // Norwegian Bokmål
|
||||
"nl", // Dutch
|
||||
"pt", // Portuguese
|
||||
},
|
||||
},
|
||||
},
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "zonemasterAPIURL",
|
||||
Type: "string",
|
||||
Label: "Zonemaster API URL",
|
||||
Placeholder: "https://zonemaster.net/api",
|
||||
Default: "https://zonemaster.net/api",
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// JSON-RPC request/response structures
|
||||
type jsonRPCRequest struct {
|
||||
Jsonrpc string `json:"jsonrpc"`
|
||||
Method string `json:"method"`
|
||||
Params any `json:"params"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
type jsonRPCResponse struct {
|
||||
Jsonrpc string `json:"jsonrpc"`
|
||||
Result json.RawMessage `json:"result,omitempty"`
|
||||
Error *struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
ID int `json:"id"`
|
||||
}
|
||||
|
||||
// Zonemaster API structures
|
||||
type startTestParams struct {
|
||||
Domain string `json:"domain"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
IPv4 bool `json:"ipv4,omitempty"`
|
||||
IPv6 bool `json:"ipv6,omitempty"`
|
||||
}
|
||||
|
||||
type testProgressParams struct {
|
||||
TestID string `json:"test_id"`
|
||||
}
|
||||
|
||||
type getResultsParams struct {
|
||||
ID string `json:"id"`
|
||||
Language string `json:"language"`
|
||||
}
|
||||
|
||||
type testResult struct {
|
||||
Module string `json:"module"`
|
||||
Message string `json:"message"`
|
||||
Level string `json:"level"`
|
||||
Testcase string `json:"testcase,omitempty"`
|
||||
}
|
||||
|
||||
type zonemasterResults struct {
|
||||
CreatedAt string `json:"created_at"`
|
||||
HashID string `json:"hash_id"`
|
||||
Params map[string]any `json:"params"`
|
||||
Results []testResult `json:"results"`
|
||||
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
|
||||
}
|
||||
|
||||
func (p *ZonemasterCheck) callJSONRPC(apiURL, method string, params any) (json.RawMessage, error) {
|
||||
reqBody := jsonRPCRequest{
|
||||
Jsonrpc: "2.0",
|
||||
Method: method,
|
||||
Params: params,
|
||||
ID: 1,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := http.Post(apiURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to call API: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var rpcResp jsonRPCResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&rpcResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||||
}
|
||||
|
||||
if rpcResp.Error != nil {
|
||||
return nil, fmt.Errorf("API error %d: %s", rpcResp.Error.Code, rpcResp.Error.Message)
|
||||
}
|
||||
|
||||
return rpcResp.Result, nil
|
||||
}
|
||||
|
||||
func (p *ZonemasterCheck) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||
// Extract options
|
||||
domainName, ok := options["domainName"].(string)
|
||||
if !ok || domainName == "" {
|
||||
return nil, fmt.Errorf("domainName is required")
|
||||
}
|
||||
domainName = strings.TrimSuffix(domainName, ".")
|
||||
|
||||
apiURL, ok := options["zonemasterAPIURL"].(string)
|
||||
if !ok || apiURL == "" {
|
||||
return nil, fmt.Errorf("zonemasterAPIURL is required")
|
||||
}
|
||||
apiURL = strings.TrimSuffix(apiURL, "/")
|
||||
|
||||
language := "en"
|
||||
if lang, ok := options["language"].(string); ok && lang != "" {
|
||||
language = lang
|
||||
}
|
||||
|
||||
profile := "default"
|
||||
if prof, ok := options["profile"].(string); ok && prof != "" {
|
||||
profile = prof
|
||||
}
|
||||
|
||||
// Step 1: Start the test
|
||||
startParams := startTestParams{
|
||||
Domain: domainName,
|
||||
Profile: profile,
|
||||
IPv4: true,
|
||||
IPv6: true,
|
||||
}
|
||||
|
||||
result, err := p.callJSONRPC(apiURL, "start_domain_test", startParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to start test: %w", err)
|
||||
}
|
||||
|
||||
var testID string
|
||||
if err := json.Unmarshal(result, &testID); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse test ID: %w", err)
|
||||
}
|
||||
|
||||
if testID == "" {
|
||||
return nil, fmt.Errorf("received empty test ID")
|
||||
}
|
||||
|
||||
// Step 2: Poll for test completion
|
||||
progressParams := testProgressParams{TestID: testID}
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
timeout := time.After(10 * time.Minute)
|
||||
for {
|
||||
select {
|
||||
case <-timeout:
|
||||
return nil, fmt.Errorf("test timeout after 10 minutes (test ID: %s)", testID)
|
||||
|
||||
case <-ticker.C:
|
||||
result, err := p.callJSONRPC(apiURL, "test_progress", progressParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to test progress: %w", err)
|
||||
}
|
||||
|
||||
var progress float64
|
||||
if err := json.Unmarshal(result, &progress); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse progress: %w", err)
|
||||
}
|
||||
|
||||
if progress >= 100 {
|
||||
goto testComplete
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
testComplete:
|
||||
// Step 3: Get test results
|
||||
resultsParams := getResultsParams{
|
||||
ID: testID,
|
||||
Language: language,
|
||||
}
|
||||
|
||||
result, err = p.callJSONRPC(apiURL, "get_test_results", resultsParams)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get results: %w", err)
|
||||
}
|
||||
|
||||
var results zonemasterResults
|
||||
if err := json.Unmarshal(result, &results); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse results: %w", err)
|
||||
}
|
||||
|
||||
// Analyze results to determine overall status
|
||||
var (
|
||||
errorCount int
|
||||
warningCount int
|
||||
infoCount int
|
||||
criticalMsgs []string
|
||||
)
|
||||
|
||||
for _, r := range results.Results {
|
||||
switch strings.ToUpper(r.Level) {
|
||||
case "CRITICAL", "ERROR":
|
||||
errorCount++
|
||||
if len(criticalMsgs) < 5 { // Keep first 5 critical messages
|
||||
criticalMsgs = append(criticalMsgs, r.Message)
|
||||
}
|
||||
case "WARNING":
|
||||
warningCount++
|
||||
case "INFO", "NOTICE":
|
||||
infoCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Determine status
|
||||
var status happydns.CheckResultStatus
|
||||
var statusLine string
|
||||
|
||||
if errorCount > 0 {
|
||||
status = happydns.CheckResultStatusKO
|
||||
statusLine = fmt.Sprintf("%d error(s), %d warning(s) found", errorCount, warningCount)
|
||||
if len(criticalMsgs) > 0 {
|
||||
statusLine += ": " + strings.Join(criticalMsgs[:min(2, len(criticalMsgs))], "; ")
|
||||
}
|
||||
} else if warningCount > 0 {
|
||||
status = happydns.CheckResultStatusWarn
|
||||
statusLine = fmt.Sprintf("%d warning(s) found", warningCount)
|
||||
} else {
|
||||
status = happydns.CheckResultStatusOK
|
||||
statusLine = fmt.Sprintf("All checks passed (%d checks)", len(results.Results))
|
||||
}
|
||||
|
||||
return &happydns.CheckResult{
|
||||
Status: status,
|
||||
StatusLine: statusLine,
|
||||
Report: results,
|
||||
}, nil
|
||||
}
|
||||
211
internal/api-admin/controller/check_controller.go
Normal file
211
internal/api-admin/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
apicontroller "git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerController handles admin-level operations.
|
||||
// All methods in this controller work with admin-scoped options (nil user/domain/service IDs).
|
||||
type CheckerController struct {
|
||||
*apicontroller.BaseCheckerController
|
||||
}
|
||||
|
||||
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
|
||||
return &CheckerController{
|
||||
BaseCheckerController: apicontroller.NewBaseCheckerController(checkerService),
|
||||
}
|
||||
}
|
||||
|
||||
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
|
||||
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
|
||||
checker, err := uc.BaseCheckerController.GetCheckerService().GetChecker(cname)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("checker", checker)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// CheckerOptionHandler is a middleware that retrieves a specific option and sets it in the context.
|
||||
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
optname := c.Param("optname")
|
||||
|
||||
opts, err := uc.BaseCheckerController.GetCheckerService().GetCheckerOptions(cname, nil, nil, nil)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("option", (*opts)[optname])
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// ListCheckers retrieves all available checks.
|
||||
//
|
||||
// @Summary List checkers (admin)
|
||||
// @Schemes
|
||||
// @Description Returns a list of all available checks with their version information.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of checker names to info"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks [get]
|
||||
func (uc *CheckerController) ListCheckers(c *gin.Context) {
|
||||
uc.BaseCheckerController.ListCheckers(c)
|
||||
}
|
||||
|
||||
// GetCheckerStatus retrieves the status and available options for a check.
|
||||
//
|
||||
// @Summary Get check info
|
||||
// @Schemes
|
||||
// @Description Retrieves the status information and available options for a specific checker.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Success 200 {object} happydns.CheckerResponse "Checker status with version info and available options"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Router /checks/{cname} [get]
|
||||
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
|
||||
uc.BaseCheckerController.GetCheckerStatus(c)
|
||||
}
|
||||
|
||||
// GetCheckerOptions retrieves all options for a check.
|
||||
//
|
||||
// @Summary Get check options (admin)
|
||||
// @Schemes
|
||||
// @Description Retrieves all configuration options for a specific check.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Success 200 {object} happydns.CheckerOptions "Checker options as key-value pairs"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cname}/options [get]
|
||||
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
|
||||
// Get admin-level options (nil user/domain/service IDs)
|
||||
uc.GetCheckerOptionsWithScope(c, cname, nil, nil, nil)
|
||||
}
|
||||
|
||||
// AddCheckerOptions adds or overwrites specific admin-level options for a check.
|
||||
//
|
||||
// @Summary Add checker options
|
||||
// @Schemes
|
||||
// @Description Adds or overwrites specific configuration options for a checker without affecting other options.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cname}/options [post]
|
||||
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
|
||||
// Add admin-level options (nil user/domain/service IDs)
|
||||
uc.AddCheckerOptionsWithScope(c, cname, nil, nil, nil)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptions replaces all options for a checker.
|
||||
//
|
||||
// @Summary Replace checker options (admin)
|
||||
// @Schemes
|
||||
// @Description Replaces all configuration options for a check with the provided options.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cname}/options [put]
|
||||
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
|
||||
// Replace admin-level options (nil user/domain/service IDs)
|
||||
uc.ChangeCheckerOptionsWithScope(c, cname, nil, nil, nil)
|
||||
}
|
||||
|
||||
// GetCheckerOption retrieves a specific option value for a checker.
|
||||
//
|
||||
// @Summary Get checker option (admin)
|
||||
// @Schemes
|
||||
// @Description Retrieves the value of a specific configuration option for a checker.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Success 200 {object} object "Option value (type varies)"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cname}/options/{optname} [get]
|
||||
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
|
||||
uc.GetCheckerOptionValue(c)
|
||||
}
|
||||
|
||||
// SetCheckerOption sets or updates a specific option value for a checker.
|
||||
//
|
||||
// @Summary Set checker option (admin)
|
||||
// @Schemes
|
||||
// @Description Sets or updates the value of a specific configuration option for a checker.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cname path string true "Checker name"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param body body object true "Option value (type varies by option)"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cname}/options/{optname} [put]
|
||||
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
|
||||
cname := c.Param("cname")
|
||||
optname := c.Param("optname")
|
||||
|
||||
// Set admin-level option (nil user/domain/service IDs)
|
||||
uc.SetCheckerOptionWithScope(c, cname, optname, nil, nil, nil)
|
||||
}
|
||||
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.SchedulerUsecase
|
||||
}
|
||||
|
||||
func NewAdminSchedulerController(scheduler happydns.SchedulerUsecase) *AdminSchedulerController {
|
||||
return &AdminSchedulerController{scheduler: scheduler}
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns the current scheduler state
|
||||
//
|
||||
// @Summary Get scheduler status
|
||||
// @Description Returns the current state of the test scheduler including worker count, queue size, and upcoming schedules
|
||||
// @Tags scheduler
|
||||
// @Produce json
|
||||
// @Success 200 {object} happydns.SchedulerStatus
|
||||
// @Router /scheduler [get]
|
||||
func (ctrl *AdminSchedulerController) GetSchedulerStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
|
||||
}
|
||||
|
||||
// EnableScheduler enables the test scheduler at runtime
|
||||
//
|
||||
// @Summary Enable scheduler
|
||||
// @Description Enables the test scheduler at runtime without restarting the server
|
||||
// @Tags scheduler
|
||||
// @Success 200 {object} happydns.SchedulerStatus
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /scheduler/enable [post]
|
||||
func (ctrl *AdminSchedulerController) EnableScheduler(c *gin.Context) {
|
||||
if err := ctrl.scheduler.SetEnabled(true); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
|
||||
}
|
||||
|
||||
// DisableScheduler disables the test scheduler at runtime
|
||||
//
|
||||
// @Summary Disable scheduler
|
||||
// @Description Disables the test scheduler at runtime without restarting the server
|
||||
// @Tags scheduler
|
||||
// @Success 200 {object} happydns.SchedulerStatus
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /scheduler/disable [post]
|
||||
func (ctrl *AdminSchedulerController) DisableScheduler(c *gin.Context) {
|
||||
if err := ctrl.scheduler.SetEnabled(false); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, ctrl.scheduler.GetSchedulerStatus())
|
||||
}
|
||||
|
||||
// RescheduleUpcoming randomizes the next run time of all enabled schedules
|
||||
// within their respective intervals to spread load evenly.
|
||||
//
|
||||
// @Summary Reschedule upcoming tests
|
||||
// @Description Randomizes the next run time of all enabled schedules within their intervals to spread load
|
||||
// @Tags scheduler
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]int
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /scheduler/reschedule-upcoming [post]
|
||||
func (ctrl *AdminSchedulerController) RescheduleUpcoming(c *gin.Context) {
|
||||
n, err := ctrl.scheduler.RescheduleUpcomingChecks()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rescheduled": n})
|
||||
}
|
||||
51
internal/api-admin/route/check.go
Normal file
51
internal/api-admin/route/check.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareChecksRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
cc := controller.NewCheckerController(dep.Checker)
|
||||
|
||||
apiChecksRoutes := router.Group("/checks")
|
||||
|
||||
apiChecksRoutes.GET("", cc.ListCheckers)
|
||||
|
||||
apiCheckerRoutes := apiChecksRoutes.Group("/:cname")
|
||||
apiCheckerRoutes.Use(cc.CheckerHandler)
|
||||
|
||||
apiCheckerRoutes.GET("", cc.GetCheckerStatus)
|
||||
//apiCheckerRoutes.POST("", tpc.ChangeCheckerStatus)
|
||||
|
||||
apiCheckerRoutes.GET("/options", cc.GetCheckerOptions)
|
||||
apiCheckerRoutes.POST("/options", cc.AddCheckerOptions)
|
||||
apiCheckerRoutes.PUT("/options", cc.ChangeCheckerOptions)
|
||||
|
||||
apiCheckerOptionsRoutes := apiCheckerRoutes.Group("/options/:optname")
|
||||
apiCheckerOptionsRoutes.Use(cc.CheckerOptionHandler)
|
||||
apiCheckerOptionsRoutes.GET("", cc.GetCheckerOption)
|
||||
apiCheckerOptionsRoutes.PUT("", cc.SetCheckerOption)
|
||||
}
|
||||
|
|
@ -32,6 +32,8 @@ import (
|
|||
// Dependencies holds all use cases required to register the admin API routes.
|
||||
type Dependencies struct {
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
Checker happydns.CheckerUsecase
|
||||
CheckScheduler happydns.SchedulerUsecase
|
||||
Domain happydns.DomainUsecase
|
||||
Provider happydns.ProviderUsecase
|
||||
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||
|
|
@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
|
|||
|
||||
declareBackupRoutes(cfg, apiRoutes, s)
|
||||
declareDomainRoutes(apiRoutes, dep, s)
|
||||
declareChecksRoutes(apiRoutes, dep)
|
||||
declareProviderRoutes(apiRoutes, dep, s)
|
||||
declareSchedulerRoutes(apiRoutes, dep)
|
||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||
declareUserAuthsRoutes(apiRoutes, dep, s)
|
||||
declareUsersRoutes(apiRoutes, dep, s)
|
||||
|
|
|
|||
38
internal/api-admin/route/scheduler.go
Normal file
38
internal/api-admin/route/scheduler.go
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api-admin/controller"
|
||||
)
|
||||
|
||||
func declareSchedulerRoutes(router *gin.RouterGroup, dep Dependencies) {
|
||||
ctrl := controller.NewAdminSchedulerController(dep.CheckScheduler)
|
||||
|
||||
schedulerRoute := router.Group("/scheduler")
|
||||
schedulerRoute.GET("", ctrl.GetSchedulerStatus)
|
||||
schedulerRoute.POST("/enable", ctrl.EnableScheduler)
|
||||
schedulerRoute.POST("/disable", ctrl.DisableScheduler)
|
||||
schedulerRoute.POST("/reschedule-upcoming", ctrl.RescheduleUpcoming)
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ func NewLoginController(authService happydns.AuthenticationUsecase, captchaVerif
|
|||
// @Security securitydefinitions.basic
|
||||
// @Success 200 {object} happydns.User
|
||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||
// @Router /auth/user [get]
|
||||
// @Router /auth [get]
|
||||
func (lc *LoginController) GetLoggedUser(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, c.MustGet("LoggedUser"))
|
||||
}
|
||||
|
|
|
|||
137
internal/api/controller/check_base_controller.go
Normal file
137
internal/api/controller/check_base_controller.go
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2025 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// BaseCheckerController contains shared functionality for check controllers.
|
||||
// It provides common methods that can be used by both admin and user-scoped controllers.
|
||||
type BaseCheckerController struct {
|
||||
checkerService happydns.CheckerUsecase
|
||||
}
|
||||
|
||||
func NewBaseCheckerController(checkerService happydns.CheckerUsecase) *BaseCheckerController {
|
||||
return &BaseCheckerController{
|
||||
checkerService,
|
||||
}
|
||||
}
|
||||
|
||||
// GetCheckerService returns the check service for use by derived controllers.
|
||||
func (bc *BaseCheckerController) GetCheckerService() happydns.CheckerUsecase {
|
||||
return bc.checkerService
|
||||
}
|
||||
|
||||
// ListCheckers retrieves all available checks.
|
||||
func (bc *BaseCheckerController) ListCheckers(c *gin.Context) {
|
||||
checkers, err := bc.checkerService.ListCheckers()
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
res := map[string]happydns.CheckerResponse{}
|
||||
|
||||
for name, checker := range *checkers {
|
||||
res[name] = happydns.CheckerResponse{
|
||||
ID: name,
|
||||
Name: checker.Name(),
|
||||
Availability: checker.Availability(),
|
||||
Options: checker.Options(),
|
||||
}
|
||||
}
|
||||
|
||||
happydns.ApiResponse(c, res, nil)
|
||||
}
|
||||
|
||||
// GetCheckerStatus retrieves the status and available options for a check.
|
||||
func (bc *BaseCheckerController) GetCheckerStatus(c *gin.Context) {
|
||||
checker := c.MustGet("checker").(happydns.Checker)
|
||||
|
||||
c.JSON(http.StatusOK, happydns.CheckerResponse{
|
||||
ID: checker.ID(),
|
||||
Name: checker.Name(),
|
||||
Availability: checker.Availability(),
|
||||
Options: checker.Options(),
|
||||
})
|
||||
}
|
||||
|
||||
// GetCheckerOptionsWithScope retrieves all options for a check with the given scope.
|
||||
func (bc *BaseCheckerController) GetCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
opts, err := bc.checkerService.GetCheckerOptions(cname, userId, domainId, serviceId)
|
||||
happydns.ApiResponse(c, opts, err)
|
||||
}
|
||||
|
||||
// AddCheckerOptionsWithScope adds or overwrites specific options for a check with the given scope.
|
||||
func (bc *BaseCheckerController) AddCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
var req happydns.SetCheckerOptionsRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, req.Options)
|
||||
happydns.ApiResponse(c, true, err)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptionsWithScope replaces all options for a check with the given scope.
|
||||
func (bc *BaseCheckerController) ChangeCheckerOptionsWithScope(c *gin.Context, cname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
var req happydns.SetCheckerOptionsRequest
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = bc.checkerService.SetCheckerOptions(cname, userId, domainId, serviceId, req.Options)
|
||||
happydns.ApiResponse(c, true, err)
|
||||
}
|
||||
|
||||
// GetCheckerOptionValue retrieves a specific option value from the context.
|
||||
func (bc *BaseCheckerController) GetCheckerOptionValue(c *gin.Context) {
|
||||
opt := c.MustGet("option")
|
||||
|
||||
happydns.ApiResponse(c, opt, nil)
|
||||
}
|
||||
|
||||
// SetCheckerOptionWithScope sets or updates a specific option value for a check with the given scope.
|
||||
func (bc *BaseCheckerController) SetCheckerOptionWithScope(c *gin.Context, cname string, optname string, userId *happydns.Identifier, domainId *happydns.Identifier, serviceId *happydns.Identifier) {
|
||||
var req any
|
||||
err := c.ShouldBindJSON(&req)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
po := happydns.CheckerOptions{}
|
||||
po[optname] = req
|
||||
|
||||
err = bc.checkerService.OverwriteSomeCheckerOptions(cname, userId, domainId, serviceId, po)
|
||||
happydns.ApiResponse(c, true, err)
|
||||
}
|
||||
211
internal/api/controller/check_controller.go
Normal file
211
internal/api/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerController handles user-scoped check operations for the main API.
|
||||
// All methods work with options scoped to the authenticated user.
|
||||
type CheckerController struct {
|
||||
*BaseCheckerController
|
||||
}
|
||||
|
||||
func NewCheckerController(checkerService happydns.CheckerUsecase) *CheckerController {
|
||||
return &CheckerController{
|
||||
BaseCheckerController: NewBaseCheckerController(checkerService),
|
||||
}
|
||||
}
|
||||
|
||||
// ListCheckers retrieves all available checks.
|
||||
//
|
||||
// @Summary List all checks
|
||||
// @Schemes
|
||||
// @Description Returns a list of all available checks with their version information.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]happydns.CheckerResponse "Map of check names to version info"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks [get]
|
||||
func (uc *CheckerController) ListCheckers(c *gin.Context) {
|
||||
uc.BaseCheckerController.ListCheckers(c)
|
||||
}
|
||||
|
||||
// GetCheckerStatus retrieves the status and available options for a check.
|
||||
//
|
||||
// @Summary Get check status
|
||||
// @Schemes
|
||||
// @Description Retrieves the status information and available options for a specific check.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Check name"
|
||||
// @Success 200 {object} happydns.CheckerResponse "Check status with version info and available options"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
|
||||
// @Router /checks/{cid} [get]
|
||||
func (uc *CheckerController) GetCheckerStatus(c *gin.Context) {
|
||||
uc.BaseCheckerController.GetCheckerStatus(c)
|
||||
}
|
||||
|
||||
// CheckerHandler is a middleware that retrieves a check by name and sets it in the context.
|
||||
func (uc *CheckerController) CheckerHandler(c *gin.Context) {
|
||||
cname := c.Param("cid")
|
||||
|
||||
check, err := uc.checkerService.GetChecker(cname)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: "Check not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("checker", check)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// CheckerOptionHandler is a middleware that retrieves a specific check option for the authenticated user and sets it in the context.
|
||||
func (uc *CheckerController) CheckerOptionHandler(c *gin.Context) {
|
||||
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||
cname := c.Param("cid")
|
||||
optname := c.Param("optname")
|
||||
|
||||
opts, err := uc.checkerService.GetCheckerOptions(cname, &user.Id, nil, nil)
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.Set("option", (*opts)[optname])
|
||||
|
||||
c.Next()
|
||||
}
|
||||
|
||||
// GetCheckOptions retrieves all options for a check for the authenticated user.
|
||||
//
|
||||
// @Summary Get check options
|
||||
// @Schemes
|
||||
// @Description Retrieves all configuration options for a specific check for the authenticated user.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Check name"
|
||||
// @Success 200 {object} happydns.CheckerOptions "Check options as key-value pairs"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cid}/options [get]
|
||||
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
|
||||
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||
cname := c.Param("cid")
|
||||
|
||||
uc.GetCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
|
||||
}
|
||||
|
||||
// AddCheckerOptions adds or overwrites specific options for a check for the authenticated user.
|
||||
//
|
||||
// @Summary Add check options
|
||||
// @Schemes
|
||||
// @Description Adds or overwrites specific configuration options for a check for the authenticated user without affecting other options.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Check name"
|
||||
// @Param body body happydns.SetCheckerOptionsRequest true "Options to add or overwrite"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cid}/options [post]
|
||||
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
|
||||
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||
cname := c.Param("cid")
|
||||
|
||||
uc.AddCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptions replaces all options for a check for the authenticated user.
|
||||
//
|
||||
// @Summary Replace check options
|
||||
// @Schemes
|
||||
// @Description Replaces all configuration options for a check for the authenticated user with the provided options.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Checker name"
|
||||
// @Param body body happydns.SetCheckerOptionsRequest true "New complete set of options"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Checker not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cid}/options [put]
|
||||
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
|
||||
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||
cname := c.Param("cid")
|
||||
|
||||
uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, nil, nil)
|
||||
}
|
||||
|
||||
// GetCheckerOption retrieves a specific option value for a check for the authenticated user.
|
||||
//
|
||||
// @Summary Get check option
|
||||
// @Schemes
|
||||
// @Description Retrieves the value of a specific configuration option for a check for the authenticated user.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Check name"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Success 200 {object} object "Option value (type varies)"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cid}/options/{optname} [get]
|
||||
func (uc *CheckerController) GetCheckerOption(c *gin.Context) {
|
||||
uc.GetCheckerOptionValue(c)
|
||||
}
|
||||
|
||||
// SetCheckerOption sets or updates a specific option value for a check for the authenticated user.
|
||||
//
|
||||
// @Summary Set check option
|
||||
// @Schemes
|
||||
// @Description Sets or updates the value of a specific configuration option for a check for the authenticated user.
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param cid path string true "Check name"
|
||||
// @Param optname path string true "Option name"
|
||||
// @Param body body object true "Option value (type varies by option)"
|
||||
// @Success 200 {object} bool "Success status"
|
||||
// @Failure 400 {object} happydns.ErrorResponse "Invalid request body"
|
||||
// @Failure 404 {object} happydns.ErrorResponse "Check not found"
|
||||
// @Failure 500 {object} happydns.ErrorResponse "Internal server error"
|
||||
// @Router /checks/{cid}/options/{optname} [put]
|
||||
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
|
||||
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||
cname := c.Param("cid")
|
||||
optname := c.Param("optname")
|
||||
|
||||
uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil)
|
||||
}
|
||||
510
internal/api/controller/checkresult_controller.go
Normal file
510
internal/api/controller/checkresult_controller.go
Normal file
|
|
@ -0,0 +1,510 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckResultController handles check result operations
|
||||
type CheckResultController struct {
|
||||
scope happydns.CheckScopeType
|
||||
checkerUC happydns.CheckerUsecase
|
||||
checkResultUC happydns.CheckResultUsecase
|
||||
checkerScheduleUC happydns.CheckerScheduleUsecase
|
||||
checkScheduler happydns.SchedulerUsecase
|
||||
}
|
||||
|
||||
func NewCheckResultController(
|
||||
scope happydns.CheckScopeType,
|
||||
checkerUC happydns.CheckerUsecase,
|
||||
checkResultUC happydns.CheckResultUsecase,
|
||||
checkerScheduleUC happydns.CheckerScheduleUsecase,
|
||||
checkScheduler happydns.SchedulerUsecase,
|
||||
) *CheckResultController {
|
||||
return &CheckResultController{
|
||||
scope: scope,
|
||||
checkerUC: checkerUC,
|
||||
checkResultUC: checkResultUC,
|
||||
checkerScheduleUC: checkerScheduleUC,
|
||||
checkScheduler: checkScheduler,
|
||||
}
|
||||
}
|
||||
|
||||
// getTargetFromContext extracts the target ID from context based on scope
|
||||
func (tc *CheckResultController) getTargetFromContext(c *gin.Context) (happydns.Identifier, error) {
|
||||
switch tc.scope {
|
||||
case happydns.CheckScopeUser:
|
||||
user := c.MustGet("user").(*happydns.User)
|
||||
return user.Id, nil
|
||||
case happydns.CheckScopeDomain:
|
||||
domain := c.MustGet("domain").(*happydns.Domain)
|
||||
return domain.Id, nil
|
||||
case happydns.CheckScopeService:
|
||||
// Services are stored by ID in context
|
||||
serviceID := c.MustGet("serviceid").(happydns.Identifier)
|
||||
return serviceID, nil
|
||||
default:
|
||||
return happydns.Identifier{}, fmt.Errorf("unsupported scope")
|
||||
}
|
||||
}
|
||||
|
||||
// ListAvailableChecks lists all available check plugins for the target scope
|
||||
//
|
||||
// @Summary List available checks
|
||||
// @Description Retrieves all available check plugins for the target scope with their last execution status if enabled
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Success 200 {array} object "List of available checks"
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks [get]
|
||||
func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get all check plugins
|
||||
plugins, err := tc.checkerUC.ListCheckers()
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Get schedules for this target
|
||||
schedules, err := tc.checkerScheduleUC.ListSchedulesByTarget(tc.scope, targetID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Build schedule map
|
||||
scheduleMap := make(map[string]*happydns.CheckerSchedule)
|
||||
for _, sched := range schedules {
|
||||
scheduleMap[sched.CheckerName] = sched
|
||||
}
|
||||
|
||||
// Build response with last results
|
||||
var checks []happydns.CheckerStatus
|
||||
for checkername, check := range *plugins {
|
||||
// Filter plugins by scope
|
||||
if tc.scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain {
|
||||
continue
|
||||
}
|
||||
if tc.scope == happydns.CheckScopeService && !check.Availability().ApplyToService {
|
||||
continue
|
||||
}
|
||||
|
||||
info := happydns.CheckerStatus{
|
||||
CheckerName: checkername,
|
||||
Enabled: true, // enabled by default unless explicitly disabled via a schedule
|
||||
}
|
||||
|
||||
// Check if there's a schedule
|
||||
if sched, ok := scheduleMap[checkername]; ok {
|
||||
info.Enabled = sched.Enabled
|
||||
info.Schedule = sched
|
||||
|
||||
// Get last result
|
||||
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkername, tc.scope, targetID, 1)
|
||||
if err == nil && len(results) > 0 {
|
||||
info.LastResult = results[0]
|
||||
}
|
||||
}
|
||||
|
||||
checks = append(checks, info)
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, checks)
|
||||
}
|
||||
|
||||
// ListLatestCheckResults retrieves the lacheck check results for a specific plugin
|
||||
//
|
||||
// @Summary Get lacheck check results
|
||||
// @Description Retrieves the 5 most recent check results for a specific plugin and target
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Success 200 {array} happydns.CheckResult
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname} [get]
|
||||
func (tc *CheckResultController) ListLatestCheckResults(c *gin.Context) {
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, 5)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// TriggerCheck triggers an on-demand check execution
|
||||
//
|
||||
// @Summary Trigger check execution
|
||||
// @Description Triggers an immediate check execution and returns the execution ID
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param body body object false "Optional: Plugin options"
|
||||
// @Success 202 {object} object{execution_id=string}
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname} [post]
|
||||
func (tc *CheckResultController) TriggerCheck(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse run options
|
||||
var options happydns.SetCheckerOptionsRequest
|
||||
if err = c.ShouldBindJSON(&options); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Trigger the test via scheduler (returns error if scheduler is disabled)
|
||||
executionID, err := tc.checkScheduler.TriggerOnDemandCheck(checkName, tc.scope, targetID, user.Id, options.Options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
|
||||
}
|
||||
|
||||
// GetCheckerOptions retrieves plugin options for the target scope
|
||||
//
|
||||
// @Summary Get check plugin options
|
||||
// @Description Retrieves configuration options for a checker at the target scope
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Success 200 {object} happydns.CheckerOptions
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/options [get]
|
||||
func (tc *CheckResultController) GetCheckerOptions(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var domainID, serviceID *happydns.Identifier
|
||||
switch tc.scope {
|
||||
case happydns.CheckScopeDomain:
|
||||
domainID = &targetID
|
||||
case happydns.CheckScopeService:
|
||||
serviceID = &targetID
|
||||
}
|
||||
|
||||
opts, err := tc.checkerUC.GetStoredCheckerOptionsNoDefault(checkName, &user.Id, domainID, serviceID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, opts)
|
||||
}
|
||||
|
||||
// AddCheckerOptions adds or overwrites specific options
|
||||
//
|
||||
// @Summary Add check plugin options
|
||||
// @Description Adds or overwrites specific options for a check plugin at the target scope
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param body body happydns.CheckerOptions true "Options to add"
|
||||
// @Success 200 {object} bool
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/options [post]
|
||||
func (tc *CheckResultController) AddCheckerOptions(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var options happydns.CheckerOptions
|
||||
if err = c.ShouldBindJSON(&options); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var domainID, serviceID *happydns.Identifier
|
||||
switch tc.scope {
|
||||
case happydns.CheckScopeDomain:
|
||||
domainID = &targetID
|
||||
case happydns.CheckScopeService:
|
||||
serviceID = &targetID
|
||||
}
|
||||
|
||||
err = tc.checkerUC.OverwriteSomeCheckerOptions(checkName, &user.Id, domainID, serviceID, options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// ChangeCheckerOptions replaces all options
|
||||
//
|
||||
// @Summary Replace check plugin options
|
||||
// @Description Replaces all options for a check plugin at the target scope
|
||||
// @Tags checks
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param body body happydns.CheckerOptions true "New complete options"
|
||||
// @Success 200 {object} bool
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/options [put]
|
||||
func (tc *CheckResultController) ChangeCheckerOptions(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
var options happydns.CheckerOptions
|
||||
if err = c.ShouldBindJSON(&options); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
var domainID, serviceID *happydns.Identifier
|
||||
switch tc.scope {
|
||||
case happydns.CheckScopeDomain:
|
||||
domainID = &targetID
|
||||
case happydns.CheckScopeService:
|
||||
serviceID = &targetID
|
||||
}
|
||||
|
||||
err = tc.checkerUC.SetCheckerOptions(checkName, &user.Id, domainID, serviceID, options)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, true)
|
||||
}
|
||||
|
||||
// GetCheckExecutionStatus retrieves the status of a check execution
|
||||
//
|
||||
// @Summary Get check execution status
|
||||
// @Description Retrieves the current status of a check execution
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param execution_id path string true "Execution ID"
|
||||
// @Success 200 {object} happydns.CheckExecution
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/executions/{execution_id} [get]
|
||||
func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) {
|
||||
executionIDStr := c.Param("execution_id")
|
||||
executionID, err := happydns.NewIdentifierFromString(executionIDStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid execution ID"))
|
||||
return
|
||||
}
|
||||
|
||||
execution, err := tc.checkResultUC.GetCheckExecution(executionID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, execution)
|
||||
}
|
||||
|
||||
// ListCheckResults lists all results for a check plugin
|
||||
//
|
||||
// @Summary List check results
|
||||
// @Description Lists all check results for a specific check plugin and target
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param limit query int false "Maximum number of results to return (default: 10)"
|
||||
// @Success 200 {array} happydns.CheckResult
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/results [get]
|
||||
func (tc *CheckResultController) ListCheckResults(c *gin.Context) {
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse limit parameter
|
||||
limit := 10
|
||||
if limitStr := c.Query("limit"); limitStr != "" {
|
||||
fmt.Sscanf(limitStr, "%d", &limit)
|
||||
}
|
||||
|
||||
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, limit)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, results)
|
||||
}
|
||||
|
||||
// DropCheckResults deletes all results for a check plugin
|
||||
//
|
||||
// @Summary Delete all check results
|
||||
// @Description Deletes all check results for a specific check plugin and target
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/results [delete]
|
||||
func (tc *CheckResultController) DropCheckResults(c *gin.Context) {
|
||||
checkName := c.Param("cname")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// GetCheckPluginResult retrieves a specific check result
|
||||
//
|
||||
// @Summary Get check result
|
||||
// @Description Retrieves a specific check result by ID
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param result_id path string true "Result ID"
|
||||
// @Success 200 {object} happydns.CheckResult
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [get]
|
||||
func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
|
||||
checkName := c.Param("cname")
|
||||
resultIDStr := c.Param("result_id")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
|
||||
return
|
||||
}
|
||||
|
||||
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
// DropCheckResult deletes a specific check result
|
||||
//
|
||||
// @Summary Delete check result
|
||||
// @Description Deletes a specific check result by ID
|
||||
// @Tags checks
|
||||
// @Produce json
|
||||
// @Param domain path string true "Domain identifier"
|
||||
// @Param cname path string true "Check plugin name"
|
||||
// @Param result_id path string true "Result ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /domains/{domain}/checks/{cname}/results/{result_id} [delete]
|
||||
func (tc *CheckResultController) DropCheckResult(c *gin.Context) {
|
||||
checkName := c.Param("cname")
|
||||
resultIDStr := c.Param("result_id")
|
||||
targetID, err := tc.getTargetFromContext(c)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
resultID, err := happydns.NewIdentifierFromString(resultIDStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid result ID"))
|
||||
return
|
||||
}
|
||||
|
||||
err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
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-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package controller
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/middleware"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckerScheduleController handles test schedule operations
|
||||
type CheckerScheduleController struct {
|
||||
testScheduleUC happydns.CheckerScheduleUsecase
|
||||
}
|
||||
|
||||
func NewCheckerScheduleController(testScheduleUC happydns.CheckerScheduleUsecase) *CheckerScheduleController {
|
||||
return &CheckerScheduleController{
|
||||
testScheduleUC: testScheduleUC,
|
||||
}
|
||||
}
|
||||
|
||||
// ListCheckerSchedules retrieves schedules for the authenticated user
|
||||
//
|
||||
// @Summary List test schedules
|
||||
// @Description Retrieves test schedules for the authenticated user with optional pagination
|
||||
// @Tags test-schedules
|
||||
// @Produce json
|
||||
// @Param limit query int false "Maximum number of schedules to return (0 = all)"
|
||||
// @Param offset query int false "Number of schedules to skip (default: 0)"
|
||||
// @Success 200 {array} happydns.CheckerSchedule
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /plugins/tests/schedules [get]
|
||||
func (tc *CheckerScheduleController) ListCheckerSchedules(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
schedules, err := tc.testScheduleUC.ListUserSchedules(user.Id)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Apply pagination
|
||||
limit := 0
|
||||
offset := 0
|
||||
fmt.Sscanf(c.Query("limit"), "%d", &limit)
|
||||
fmt.Sscanf(c.Query("offset"), "%d", &offset)
|
||||
|
||||
if offset > len(schedules) {
|
||||
offset = len(schedules)
|
||||
}
|
||||
schedules = schedules[offset:]
|
||||
if limit > 0 && len(schedules) > limit {
|
||||
schedules = schedules[:limit]
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, schedules)
|
||||
}
|
||||
|
||||
// CreateCheckerSchedule creates a new test schedule
|
||||
//
|
||||
// @Summary Create test schedule
|
||||
// @Description Creates a new test schedule for the authenticated user
|
||||
// @Tags test-schedules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body happydns.CheckerSchedule true "Check schedule to create"
|
||||
// @Success 201 {object} happydns.CheckerSchedule
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /plugins/tests/schedules [post]
|
||||
func (tc *CheckerScheduleController) CreateCheckerSchedule(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
|
||||
var schedule happydns.CheckerSchedule
|
||||
if err := c.ShouldBindJSON(&schedule); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Set user ID
|
||||
schedule.OwnerId = user.Id
|
||||
|
||||
if err := tc.testScheduleUC.CreateSchedule(&schedule); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, schedule)
|
||||
}
|
||||
|
||||
// GetCheckerSchedule retrieves a specific schedule
|
||||
//
|
||||
// @Summary Get test schedule
|
||||
// @Description Retrieves a specific test schedule by ID
|
||||
// @Tags test-schedules
|
||||
// @Produce json
|
||||
// @Param schedule_id path string true "Schedule ID"
|
||||
// @Success 200 {object} happydns.CheckerSchedule
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /plugins/tests/schedules/{schedule_id} [get]
|
||||
func (tc *CheckerScheduleController) GetCheckerSchedule(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
scheduleIdStr := c.Param("schedule_id")
|
||||
|
||||
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
|
||||
schedule, err := tc.testScheduleUC.GetSchedule(scheduleId)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, schedule)
|
||||
}
|
||||
|
||||
// UpdateCheckerSchedule updates an existing schedule
|
||||
//
|
||||
// @Summary Update test schedule
|
||||
// @Description Updates an existing test schedule
|
||||
// @Tags test-schedules
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param schedule_id path string true "Schedule ID"
|
||||
// @Param body body happydns.CheckerSchedule true "Updated schedule"
|
||||
// @Success 200 {object} happydns.CheckerSchedule
|
||||
// @Failure 400 {object} happydns.ErrorResponse
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /plugins/tests/schedules/{schedule_id} [put]
|
||||
func (tc *CheckerScheduleController) UpdateCheckerSchedule(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
scheduleIdStr := c.Param("schedule_id")
|
||||
|
||||
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
|
||||
var schedule happydns.CheckerSchedule
|
||||
if err := c.ShouldBindJSON(&schedule); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Ensure ID matches
|
||||
schedule.Id = scheduleId
|
||||
schedule.OwnerId = user.Id
|
||||
|
||||
if err := tc.testScheduleUC.UpdateSchedule(&schedule); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, schedule)
|
||||
}
|
||||
|
||||
// DeleteCheckerSchedule deletes a schedule
|
||||
//
|
||||
// @Summary Delete test schedule
|
||||
// @Description Deletes a test schedule
|
||||
// @Tags test-schedules
|
||||
// @Produce json
|
||||
// @Param schedule_id path string true "Schedule ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} happydns.ErrorResponse
|
||||
// @Failure 500 {object} happydns.ErrorResponse
|
||||
// @Router /plugins/tests/schedules/{schedule_id} [delete]
|
||||
func (tc *CheckerScheduleController) DeleteCheckerSchedule(c *gin.Context) {
|
||||
user := middleware.MyUser(c)
|
||||
scheduleIdStr := c.Param("schedule_id")
|
||||
|
||||
scheduleId, err := happydns.NewIdentifierFromString(scheduleIdStr)
|
||||
if err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusBadRequest, fmt.Errorf("invalid schedule ID"))
|
||||
return
|
||||
}
|
||||
|
||||
// Verify ownership
|
||||
if err := tc.testScheduleUC.ValidateScheduleOwnership(scheduleId, user.Id); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusForbidden, err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.testScheduleUC.DeleteSchedule(scheduleId); err != nil {
|
||||
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
49
internal/api/route/check.go
Normal file
49
internal/api/route/check.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 DeclareChecksRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) {
|
||||
tpc := controller.NewCheckerController(checkerUC)
|
||||
|
||||
router.GET("/checks", tpc.ListCheckers)
|
||||
|
||||
apiCheckRoutes := router.Group("/checks/:cid")
|
||||
apiCheckRoutes.Use(tpc.CheckerHandler)
|
||||
|
||||
apiCheckRoutes.GET("", tpc.GetCheckerStatus)
|
||||
|
||||
apiCheckRoutes.GET("/options", tpc.GetCheckerOptions)
|
||||
apiCheckRoutes.POST("/options", tpc.AddCheckerOptions)
|
||||
apiCheckRoutes.PUT("/options", tpc.ChangeCheckerOptions)
|
||||
|
||||
apiCheckOptionsRoutes := apiCheckRoutes.Group("/options/:optname")
|
||||
apiCheckOptionsRoutes.Use(tpc.CheckerOptionHandler)
|
||||
apiCheckOptionsRoutes.GET("", tpc.GetCheckerOption)
|
||||
apiCheckOptionsRoutes.PUT("", tpc.SetCheckerOption)
|
||||
}
|
||||
81
internal/api/route/checkresults.go
Normal file
81
internal/api/route/checkresults.go
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
|
||||
func DeclareScopedCheckResultRoutes(
|
||||
scopedRouter *gin.RouterGroup,
|
||||
checkerUC happydns.CheckerUsecase,
|
||||
checkResultUC happydns.CheckResultUsecase,
|
||||
checkerScheduleUC happydns.CheckerScheduleUsecase,
|
||||
checkScheduler happydns.SchedulerUsecase,
|
||||
scope happydns.CheckScopeType,
|
||||
) {
|
||||
tc := controller.NewCheckResultController(
|
||||
scope,
|
||||
checkerUC,
|
||||
checkResultUC,
|
||||
checkerScheduleUC,
|
||||
checkScheduler,
|
||||
)
|
||||
|
||||
// List all available tests with their status
|
||||
scopedRouter.GET("/checks", tc.ListAvailableChecks)
|
||||
|
||||
// Check-specific routes
|
||||
apiChecksRoutes := scopedRouter.Group("/checks/:cname")
|
||||
{
|
||||
// Get latest results for a test
|
||||
apiChecksRoutes.GET("", tc.ListLatestCheckResults)
|
||||
|
||||
// Trigger an on-demand test
|
||||
apiChecksRoutes.POST("", tc.TriggerCheck)
|
||||
|
||||
// Manage check options at this scope
|
||||
apiChecksRoutes.GET("/options", tc.GetCheckerOptions)
|
||||
apiChecksRoutes.POST("/options", tc.AddCheckerOptions)
|
||||
apiChecksRoutes.PUT("/options", tc.ChangeCheckerOptions)
|
||||
|
||||
// Check execution routes
|
||||
apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id")
|
||||
{
|
||||
apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus)
|
||||
}
|
||||
|
||||
// Check results routes
|
||||
apiChecksRoutes.GET("/results", tc.ListCheckResults)
|
||||
apiChecksRoutes.DELETE("/results", tc.DropCheckResults)
|
||||
|
||||
apiCheckResultsRoutes := apiChecksRoutes.Group("/results/:result_id")
|
||||
{
|
||||
apiCheckResultsRoutes.GET("", tc.GetCheckResult)
|
||||
apiCheckResultsRoutes.DELETE("", tc.DropCheckResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
47
internal/api/route/checkschedule.go
Normal file
47
internal/api/route/checkschedule.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package route
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/api/controller"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// DeclareTestScheduleRoutes declares test schedule management routes
|
||||
func DeclareTestScheduleRoutes(router *gin.RouterGroup, checkerScheduleUC happydns.CheckerScheduleUsecase) {
|
||||
sc := controller.NewCheckerScheduleController(checkerScheduleUC)
|
||||
|
||||
schedulesRoutes := router.Group("/plugins/tests/schedules")
|
||||
{
|
||||
schedulesRoutes.GET("", sc.ListCheckerSchedules)
|
||||
schedulesRoutes.POST("", sc.CreateCheckerSchedule)
|
||||
|
||||
scheduleRoutes := schedulesRoutes.Group("/:schedule_id")
|
||||
{
|
||||
scheduleRoutes.GET("", sc.GetCheckerSchedule)
|
||||
scheduleRoutes.PUT("", sc.UpdateCheckerSchedule)
|
||||
scheduleRoutes.DELETE("", sc.DeleteCheckerSchedule)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,7 +29,7 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
|
||||
func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecase, domainLogUC happydns.DomainLogUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, zoneUC happydns.ZoneUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
|
||||
dc := controller.NewDomainController(
|
||||
domainUC,
|
||||
remoteZoneImporter,
|
||||
|
|
@ -48,8 +48,12 @@ func DeclareDomainRoutes(router *gin.RouterGroup, domainUC happydns.DomainUsecas
|
|||
|
||||
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
|
||||
|
||||
// Declare test result routes for domain scope
|
||||
|
||||
DeclareScopedCheckResultRoutes(apiDomainsRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeDomain)
|
||||
|
||||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||
|
||||
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC)
|
||||
DeclareZoneRoutes(apiDomainsRoutes, zoneUC, domainUC, zoneCorrApplier, zoneServiceUC, serviceUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,10 @@ type Dependencies struct {
|
|||
Authentication happydns.AuthenticationUsecase
|
||||
AuthUser happydns.AuthUserUsecase
|
||||
CaptchaVerifier happydns.CaptchaVerifier
|
||||
Checker happydns.CheckerUsecase
|
||||
CheckResult happydns.CheckResultUsecase
|
||||
CheckerSchedule happydns.CheckerScheduleUsecase
|
||||
CheckScheduler happydns.SchedulerUsecase
|
||||
Domain happydns.DomainUsecase
|
||||
DomainLog happydns.DomainLogUsecase
|
||||
FailureTracker happydns.FailureTracker
|
||||
|
|
@ -72,7 +76,7 @@ type Dependencies struct {
|
|||
// @name Authorization
|
||||
// @description Description for what is this security definition being used
|
||||
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dep Dependencies) {
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependencies) {
|
||||
baseRoutes := router.Group("")
|
||||
|
||||
declareRouteSwagger(cfg, baseRoutes)
|
||||
|
|
@ -99,10 +103,12 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, dep Dependencies)
|
|||
apiAuthRoutes.Use(middleware.AuthRequired())
|
||||
|
||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service)
|
||||
DeclareChecksRoutes(apiAuthRoutes, dep.Checker)
|
||||
DeclareDomainRoutes(apiAuthRoutes, dep.Domain, dep.DomainLog, dep.RemoteZoneImporter, dep.ZoneImporter, dep.Zone, dep.ZoneCorrectionApplier, dep.ZoneService, dep.Service, dep.Checker, dep.CheckResult, dep.CheckerSchedule, dep.CheckScheduler)
|
||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||
DeclareRecordRoutes(apiAuthRoutes)
|
||||
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
|
||||
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
|
||||
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import (
|
|||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase) {
|
||||
func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.RouterGroup, zc *controller.ZoneController, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, zoneUC happydns.ZoneUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
|
||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
||||
|
||||
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
|
||||
|
|
@ -40,4 +40,7 @@ func DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes *gin.Route
|
|||
apiZonesSubdomainServiceIdRoutes.Use(middleware.ServiceIdHandler(serviceUC))
|
||||
apiZonesSubdomainServiceIdRoutes.GET("", sc.GetZoneService)
|
||||
apiZonesSubdomainServiceIdRoutes.DELETE("", sc.DeleteZoneService)
|
||||
|
||||
// Declare test result routes for service scope
|
||||
DeclareScopedCheckResultRoutes(apiZonesSubdomainServiceIdRoutes, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler, happydns.CheckScopeService)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ import (
|
|||
happydns "git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase) {
|
||||
func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, domainUC happydns.DomainUsecase, zoneCorrApplier happydns.ZoneCorrectionApplierUsecase, zoneServiceUC happydns.ZoneServiceUsecase, serviceUC happydns.ServiceUsecase, checkerUC happydns.CheckerUsecase, checkResultUC happydns.CheckResultUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase, checkScheduler happydns.SchedulerUsecase) {
|
||||
zc := controller.NewZoneController(
|
||||
zoneUC,
|
||||
domainUC,
|
||||
|
|
@ -50,7 +50,7 @@ func DeclareZoneRoutes(router *gin.RouterGroup, zoneUC happydns.ZoneUsecase, dom
|
|||
apiZonesSubdomainRoutes.Use(middleware.SubdomainHandler)
|
||||
apiZonesSubdomainRoutes.GET("", zc.GetZoneSubdomain)
|
||||
|
||||
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC)
|
||||
DeclareZoneServiceRoutes(apiZonesRoutes, apiZonesSubdomainRoutes, zc, zoneServiceUC, serviceUC, zoneUC, checkerUC, checkResultUC, checkerScheduleUC, checkScheduler)
|
||||
|
||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||
apiZonesRoutes.POST("/records/delete", zc.DeleteRecords)
|
||||
|
|
|
|||
|
|
@ -58,6 +58,8 @@ func NewAdmin(app *App) *Admin {
|
|||
|
||||
admin.DeclareRoutes(app.cfg, router, app.store, admin.Dependencies{
|
||||
AuthUser: app.usecases.authUser,
|
||||
Checker: app.usecases.checker,
|
||||
CheckScheduler: app.checkScheduler,
|
||||
Domain: app.usecases.domain,
|
||||
Provider: app.usecases.providerAdmin,
|
||||
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,8 @@ import (
|
|||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase"
|
||||
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
checkUC "git.happydns.org/happyDomain/internal/usecase/check"
|
||||
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
|
||||
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||
|
|
@ -54,6 +56,9 @@ import (
|
|||
type Usecases struct {
|
||||
authentication happydns.AuthenticationUsecase
|
||||
authUser happydns.AuthUserUsecase
|
||||
checker happydns.CheckerUsecase
|
||||
checkResult happydns.CheckResultUsecase
|
||||
checkerSchedule happydns.CheckerScheduleUsecase
|
||||
domain happydns.DomainUsecase
|
||||
domainLog happydns.DomainLogUsecase
|
||||
provider happydns.ProviderUsecase
|
||||
|
|
@ -81,10 +86,10 @@ type App struct {
|
|||
router *gin.Engine
|
||||
srv *http.Server
|
||||
store storage.Storage
|
||||
checkScheduler happydns.SchedulerUsecase
|
||||
usecases Usecases
|
||||
}
|
||||
|
||||
|
||||
func NewApp(cfg *happydns.Options) *App {
|
||||
app := &App{
|
||||
cfg: cfg,
|
||||
|
|
@ -94,8 +99,12 @@ func NewApp(cfg *happydns.Options) *App {
|
|||
app.initStorageEngine()
|
||||
app.initNewsletter()
|
||||
app.initInsights()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.initCheckScheduler()
|
||||
app.setupRouter()
|
||||
|
||||
return app
|
||||
|
|
@ -109,8 +118,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
|||
|
||||
app.initMailer()
|
||||
app.initNewsletter()
|
||||
if err := app.initPlugins(); err != nil {
|
||||
log.Fatalf("Plugin initialization error: %s", err)
|
||||
}
|
||||
app.initUsecases()
|
||||
app.initCaptcha()
|
||||
app.initCheckScheduler()
|
||||
app.setupRouter()
|
||||
|
||||
return app
|
||||
|
|
@ -184,6 +197,20 @@ func (app *App) initInsights() {
|
|||
}
|
||||
}
|
||||
|
||||
func (app *App) initCheckScheduler() {
|
||||
if app.cfg.DisableScheduler {
|
||||
// Use a disabled scheduler that returns clear errors
|
||||
app.checkScheduler = &disabledScheduler{}
|
||||
return
|
||||
}
|
||||
|
||||
app.checkScheduler = newCheckScheduler(
|
||||
app.cfg,
|
||||
app.store,
|
||||
app.usecases.checker,
|
||||
)
|
||||
}
|
||||
|
||||
func (app *App) initUsecases() {
|
||||
sessionService := sessionUC.NewService(app.store)
|
||||
authUserService := authuserUC.NewAuthUserUsecases(app.cfg, app.mailer, app.store, sessionService)
|
||||
|
|
@ -211,6 +238,9 @@ func (app *App) initUsecases() {
|
|||
app.usecases.authUser = authUserService
|
||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||
app.usecases.session = sessionService
|
||||
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
|
||||
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg)
|
||||
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
|
||||
|
||||
app.usecases.orchestrator = orchestrator.NewOrchestrator(
|
||||
domainLogService,
|
||||
|
|
@ -236,10 +266,22 @@ func (app *App) setupRouter() {
|
|||
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
|
||||
))
|
||||
|
||||
api.DeclareRoutes(app.cfg, app.router, api.Dependencies{
|
||||
if len(app.cfg.BasePath) > 0 {
|
||||
app.router.GET("/", func(c *gin.Context) {
|
||||
c.Redirect(http.StatusFound, app.cfg.BasePath)
|
||||
})
|
||||
}
|
||||
|
||||
baserouter := app.router.Group(app.cfg.BasePath)
|
||||
|
||||
api.DeclareRoutes(app.cfg, baserouter, api.Dependencies{
|
||||
Authentication: app.usecases.authentication,
|
||||
AuthUser: app.usecases.authUser,
|
||||
CaptchaVerifier: app.captchaVerifier,
|
||||
Checker: app.usecases.checker,
|
||||
CheckResult: app.usecases.checkResult,
|
||||
CheckerSchedule: app.usecases.checkerSchedule,
|
||||
CheckScheduler: app.checkScheduler,
|
||||
Domain: app.usecases.domain,
|
||||
DomainLog: app.usecases.domainLog,
|
||||
FailureTracker: app.failureTracker,
|
||||
|
|
@ -257,7 +299,8 @@ func (app *App) setupRouter() {
|
|||
ZoneImporter: app.usecases.orchestrator.ZoneImporter,
|
||||
ZoneService: app.usecases.zoneService,
|
||||
})
|
||||
web.DeclareRoutes(app.cfg, app.router, app.captchaVerifier)
|
||||
web.DeclareRoutes(app.cfg, baserouter, app.captchaVerifier)
|
||||
web.NoRoute(app.cfg, app.router)
|
||||
}
|
||||
|
||||
func (app *App) Start() {
|
||||
|
|
@ -271,6 +314,8 @@ func (app *App) Start() {
|
|||
go app.insights.Run()
|
||||
}
|
||||
|
||||
go app.checkScheduler.Run()
|
||||
|
||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
log.Fatalf("listen: %s\n", err)
|
||||
|
|
@ -296,4 +341,6 @@ func (app *App) Stop() {
|
|||
if app.failureTracker != nil {
|
||||
app.failureTracker.Close()
|
||||
}
|
||||
|
||||
app.checkScheduler.Close()
|
||||
}
|
||||
|
|
|
|||
664
internal/app/checkscheduler.go
Normal file
664
internal/app/checkscheduler.go
Normal file
|
|
@ -0,0 +1,664 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/internal/storage"
|
||||
"git.happydns.org/happyDomain/internal/usecase/checkresult"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
SchedulerCheckInterval = 1 * time.Minute // How often to check for due tests
|
||||
SchedulerCleanupInterval = 24 * time.Hour // How often to clean up old executions
|
||||
SchedulerDiscoveryInterval = 1 * time.Hour // How often to auto-discover new targets
|
||||
CheckExecutionTimeout = 5 * time.Minute // Max time for a single check
|
||||
MaxRetries = 3 // Max retry attempts for failed checks
|
||||
)
|
||||
|
||||
// Priority levels for test execution queue
|
||||
const (
|
||||
PriorityOnDemand = iota // On-demand tests (highest priority)
|
||||
PriorityOverdue // Overdue scheduled tests
|
||||
PriorityScheduled // Regular scheduled tests
|
||||
)
|
||||
|
||||
// checkScheduler manages background test execution
|
||||
type checkScheduler struct {
|
||||
cfg *happydns.Options
|
||||
store storage.Storage
|
||||
checkerUsecase happydns.CheckerUsecase
|
||||
resultUsecase *checkresult.CheckResultUsecase
|
||||
scheduleUsecase *checkresult.CheckScheduleUsecase
|
||||
stop chan struct{} // closed to stop the main Run loop
|
||||
stopWorkers chan struct{} // closed to stop all workers simultaneously
|
||||
runNowChan chan *queueItem // on-demand items routed through the main loop
|
||||
workAvail chan struct{} // non-blocking signals that queue has new work
|
||||
queue *priorityQueue
|
||||
activeExecutions map[string]*activeExecution
|
||||
workers []*worker
|
||||
mu sync.RWMutex
|
||||
wg sync.WaitGroup
|
||||
runtimeEnabled bool
|
||||
running bool
|
||||
}
|
||||
|
||||
// activeExecution tracks a running test execution
|
||||
type activeExecution struct {
|
||||
execution *happydns.CheckExecution
|
||||
cancel context.CancelFunc
|
||||
startTime time.Time
|
||||
}
|
||||
|
||||
// queueItem represents a test execution request in the queue
|
||||
type queueItem struct {
|
||||
schedule *happydns.CheckerSchedule
|
||||
execution *happydns.CheckExecution
|
||||
priority int
|
||||
queuedAt time.Time
|
||||
retries int
|
||||
}
|
||||
|
||||
// --- container/heap implementation for priorityQueue ---
|
||||
|
||||
// priorityHeap is the underlying heap, ordered by priority then arrival time.
|
||||
type priorityHeap []*queueItem
|
||||
|
||||
func (h priorityHeap) Len() int { return len(h) }
|
||||
func (h priorityHeap) Less(i, j int) bool {
|
||||
if h[i].priority != h[j].priority {
|
||||
return h[i].priority < h[j].priority
|
||||
}
|
||||
return h[i].queuedAt.Before(h[j].queuedAt)
|
||||
}
|
||||
func (h priorityHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
|
||||
func (h *priorityHeap) Push(x any) { *h = append(*h, x.(*queueItem)) }
|
||||
func (h *priorityHeap) Pop() any {
|
||||
old := *h
|
||||
n := len(old)
|
||||
x := old[n-1]
|
||||
old[n-1] = nil // avoid memory leak
|
||||
*h = old[:n-1]
|
||||
return x
|
||||
}
|
||||
|
||||
// priorityQueue is a thread-safe min-heap of queueItems.
|
||||
type priorityQueue struct {
|
||||
h priorityHeap
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newPriorityQueue() *priorityQueue {
|
||||
pq := &priorityQueue{}
|
||||
heap.Init(&pq.h)
|
||||
return pq
|
||||
}
|
||||
|
||||
// Push adds an item to the queue.
|
||||
func (q *priorityQueue) Push(item *queueItem) {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
heap.Push(&q.h, item)
|
||||
}
|
||||
|
||||
// Pop removes and returns the highest-priority item, or nil if empty.
|
||||
func (q *priorityQueue) Pop() *queueItem {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
if q.h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return heap.Pop(&q.h).(*queueItem)
|
||||
}
|
||||
|
||||
// Len returns the queue length.
|
||||
func (q *priorityQueue) Len() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.h.Len()
|
||||
}
|
||||
|
||||
// worker processes tests from the queue
|
||||
type worker struct {
|
||||
id int
|
||||
scheduler *checkScheduler
|
||||
}
|
||||
|
||||
// disabledScheduler is a no-op implementation used when scheduler is disabled
|
||||
type disabledScheduler struct{}
|
||||
|
||||
func (d *disabledScheduler) Run() {}
|
||||
func (d *disabledScheduler) Close() {}
|
||||
|
||||
// TriggerOnDemandCheck returns an error indicating the scheduler is disabled
|
||||
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
|
||||
return happydns.Identifier{}, fmt.Errorf("test scheduler is disabled in configuration")
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns a status indicating the scheduler is disabled
|
||||
func (d *disabledScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
|
||||
return happydns.SchedulerStatus{
|
||||
ConfigEnabled: false,
|
||||
RuntimeEnabled: false,
|
||||
Running: false,
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled returns an error since the scheduler is disabled in configuration
|
||||
func (d *disabledScheduler) SetEnabled(enabled bool) error {
|
||||
return fmt.Errorf("scheduler is disabled in configuration, cannot enable at runtime")
|
||||
}
|
||||
|
||||
// RescheduleUpcomingChecks returns an error since the scheduler is disabled
|
||||
func (d *disabledScheduler) RescheduleUpcomingChecks() (int, error) {
|
||||
return 0, fmt.Errorf("test scheduler is disabled in configuration")
|
||||
}
|
||||
|
||||
// newCheckScheduler creates a new test scheduler
|
||||
func newCheckScheduler(
|
||||
cfg *happydns.Options,
|
||||
store storage.Storage,
|
||||
checkerUsecase happydns.CheckerUsecase,
|
||||
) *checkScheduler {
|
||||
numWorkers := cfg.TestWorkers
|
||||
if numWorkers <= 0 {
|
||||
numWorkers = runtime.NumCPU()
|
||||
}
|
||||
|
||||
scheduler := &checkScheduler{
|
||||
cfg: cfg,
|
||||
store: store,
|
||||
checkerUsecase: checkerUsecase,
|
||||
resultUsecase: checkresult.NewCheckResultUsecase(store, cfg),
|
||||
scheduleUsecase: checkresult.NewCheckScheduleUsecase(store, cfg, store, checkerUsecase),
|
||||
stop: make(chan struct{}),
|
||||
stopWorkers: make(chan struct{}),
|
||||
runNowChan: make(chan *queueItem, 100),
|
||||
workAvail: make(chan struct{}, numWorkers),
|
||||
queue: newPriorityQueue(),
|
||||
activeExecutions: make(map[string]*activeExecution),
|
||||
workers: make([]*worker, numWorkers),
|
||||
runtimeEnabled: true,
|
||||
}
|
||||
|
||||
for i := 0; i < numWorkers; i++ {
|
||||
scheduler.workers[i] = &worker{
|
||||
id: i,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
return scheduler
|
||||
}
|
||||
|
||||
// enqueue pushes an item to the priority queue and wakes one idle worker.
|
||||
func (s *checkScheduler) enqueue(item *queueItem) {
|
||||
s.queue.Push(item)
|
||||
select {
|
||||
case s.workAvail <- struct{}{}:
|
||||
default:
|
||||
// All workers are already busy or already notified; they will drain
|
||||
// the queue on their own after finishing the current item.
|
||||
}
|
||||
}
|
||||
|
||||
// Close stops the scheduler and waits for all workers to finish.
|
||||
func (s *checkScheduler) Close() {
|
||||
log.Println("Stopping test scheduler...")
|
||||
|
||||
// Unblock the main Run loop.
|
||||
close(s.stop)
|
||||
|
||||
// Unblock all workers simultaneously.
|
||||
close(s.stopWorkers)
|
||||
|
||||
// Cancel all active test executions.
|
||||
s.mu.Lock()
|
||||
for _, exec := range s.activeExecutions {
|
||||
exec.cancel()
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
// Wait for all workers to finish their current item.
|
||||
s.wg.Wait()
|
||||
|
||||
log.Println("Check scheduler stopped")
|
||||
}
|
||||
|
||||
// Run starts the scheduler main loop. It must not be called more than once.
|
||||
func (s *checkScheduler) Run() {
|
||||
s.mu.Lock()
|
||||
s.running = true
|
||||
s.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
s.running = false
|
||||
s.mu.Unlock()
|
||||
}()
|
||||
|
||||
log.Printf("Starting test scheduler with %d workers...\n", len(s.workers))
|
||||
|
||||
// Reschedule overdue tests before starting workers so that tests missed
|
||||
// during a server suspend or shutdown are spread into the near future
|
||||
// instead of all firing at once.
|
||||
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
|
||||
log.Printf("Warning: failed to reschedule overdue tests: %v\n", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("Rescheduled %d overdue test(s) into the near future\n", n)
|
||||
}
|
||||
|
||||
// Start workers
|
||||
for _, w := range s.workers {
|
||||
s.wg.Add(1)
|
||||
go w.run(&s.wg)
|
||||
}
|
||||
|
||||
// Main scheduling loop
|
||||
checkTicker := time.NewTicker(SchedulerCheckInterval)
|
||||
cleanupTicker := time.NewTicker(SchedulerCleanupInterval)
|
||||
discoveryTicker := time.NewTicker(SchedulerDiscoveryInterval)
|
||||
defer checkTicker.Stop()
|
||||
defer cleanupTicker.Stop()
|
||||
defer discoveryTicker.Stop()
|
||||
|
||||
// Initial discovery: create default schedules for all existing targets
|
||||
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
|
||||
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
|
||||
}
|
||||
// Initial check
|
||||
s.checkSchedules()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-checkTicker.C:
|
||||
s.checkSchedules()
|
||||
|
||||
case <-cleanupTicker.C:
|
||||
s.cleanup()
|
||||
|
||||
case <-discoveryTicker.C:
|
||||
if err := s.scheduleUsecase.DiscoverAndEnsureSchedules(); err != nil {
|
||||
log.Printf("Warning: schedule discovery encountered errors: %v\n", err)
|
||||
}
|
||||
|
||||
case item := <-s.runNowChan:
|
||||
s.enqueue(item)
|
||||
|
||||
case <-s.stop:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkSchedules checks for due tests and queues them
|
||||
func (s *checkScheduler) checkSchedules() {
|
||||
s.mu.RLock()
|
||||
enabled := s.runtimeEnabled
|
||||
s.mu.RUnlock()
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
|
||||
dueSchedules, err := s.scheduleUsecase.ListDueSchedules()
|
||||
if err != nil {
|
||||
log.Printf("Error listing due schedules: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, schedule := range dueSchedules {
|
||||
// Determine priority based on how overdue the test is
|
||||
priority := PriorityScheduled
|
||||
if schedule.NextRun.Add(schedule.Interval).Before(now) {
|
||||
priority = PriorityOverdue
|
||||
}
|
||||
|
||||
// Create execution record
|
||||
execution := &happydns.CheckExecution{
|
||||
ScheduleId: &schedule.Id,
|
||||
CheckerName: schedule.CheckerName,
|
||||
OwnerId: schedule.OwnerId,
|
||||
TargetType: schedule.TargetType,
|
||||
TargetId: schedule.TargetId,
|
||||
Status: happydns.CheckExecutionPending,
|
||||
StartedAt: time.Now(),
|
||||
Options: schedule.Options,
|
||||
}
|
||||
|
||||
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
|
||||
log.Printf("Error creating execution for schedule %s: %v\n", schedule.Id.String(), err)
|
||||
continue
|
||||
}
|
||||
|
||||
s.enqueue(&queueItem{
|
||||
schedule: schedule,
|
||||
execution: execution,
|
||||
priority: priority,
|
||||
queuedAt: now,
|
||||
retries: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// Mark scheduler run
|
||||
if err := s.store.CheckSchedulerRun(); err != nil {
|
||||
log.Printf("Error marking scheduler run: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerOnDemandCheck triggers an immediate test execution.
|
||||
// It creates the execution record synchronously (so the caller gets an ID back)
|
||||
// and then routes the item through runNowChan so the main loop controls
|
||||
// all queue insertions.
|
||||
func (s *checkScheduler) TriggerOnDemandCheck(checkerName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
|
||||
schedule := &happydns.CheckerSchedule{
|
||||
CheckerName: checkerName,
|
||||
OwnerId: ownerId,
|
||||
TargetType: targetType,
|
||||
TargetId: targetId,
|
||||
Interval: 0, // On-demand, no interval
|
||||
Enabled: true,
|
||||
Options: options,
|
||||
}
|
||||
|
||||
execution := &happydns.CheckExecution{
|
||||
ScheduleId: nil,
|
||||
CheckerName: checkerName,
|
||||
OwnerId: ownerId,
|
||||
TargetType: targetType,
|
||||
TargetId: targetId,
|
||||
Status: happydns.CheckExecutionPending,
|
||||
StartedAt: time.Now(),
|
||||
Options: options,
|
||||
}
|
||||
|
||||
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
|
||||
return happydns.Identifier{}, err
|
||||
}
|
||||
|
||||
item := &queueItem{
|
||||
schedule: schedule,
|
||||
execution: execution,
|
||||
priority: PriorityOnDemand,
|
||||
queuedAt: time.Now(),
|
||||
retries: 0,
|
||||
}
|
||||
|
||||
// Route through the main loop when possible; fall back to direct enqueue
|
||||
// if the channel is full so that the caller never blocks.
|
||||
select {
|
||||
case s.runNowChan <- item:
|
||||
default:
|
||||
s.enqueue(item)
|
||||
}
|
||||
|
||||
return execution.Id, nil
|
||||
}
|
||||
|
||||
// GetSchedulerStatus returns a snapshot of the current scheduler state
|
||||
func (s *checkScheduler) GetSchedulerStatus() happydns.SchedulerStatus {
|
||||
s.mu.RLock()
|
||||
activeCount := len(s.activeExecutions)
|
||||
running := s.running
|
||||
runtimeEnabled := s.runtimeEnabled
|
||||
s.mu.RUnlock()
|
||||
|
||||
nextSchedules, _ := s.scheduleUsecase.ListUpcomingSchedules(20)
|
||||
|
||||
return happydns.SchedulerStatus{
|
||||
ConfigEnabled: !s.cfg.DisableScheduler,
|
||||
RuntimeEnabled: runtimeEnabled,
|
||||
Running: running,
|
||||
WorkerCount: len(s.workers),
|
||||
QueueSize: s.queue.Len(),
|
||||
ActiveCount: activeCount,
|
||||
NextSchedules: nextSchedules,
|
||||
}
|
||||
}
|
||||
|
||||
// SetEnabled enables or disables the scheduler at runtime
|
||||
func (s *checkScheduler) SetEnabled(enabled bool) error {
|
||||
s.mu.Lock()
|
||||
wasEnabled := s.runtimeEnabled
|
||||
s.runtimeEnabled = enabled
|
||||
s.mu.Unlock()
|
||||
|
||||
if enabled && !wasEnabled {
|
||||
// Spread out any overdue tests to avoid a thundering herd, then
|
||||
// immediately enqueue whatever is now due.
|
||||
if n, err := s.scheduleUsecase.RescheduleOverdueChecks(); err != nil {
|
||||
log.Printf("Warning: failed to reschedule overdue tests on re-enable: %v\n", err)
|
||||
} else if n > 0 {
|
||||
log.Printf("Rescheduled %d overdue test(s) after scheduler re-enable\n", n)
|
||||
}
|
||||
s.checkSchedules()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
|
||||
// within their respective intervals, delegating to the schedule usecase.
|
||||
func (s *checkScheduler) RescheduleUpcomingChecks() (int, error) {
|
||||
return s.scheduleUsecase.RescheduleUpcomingChecks()
|
||||
}
|
||||
|
||||
// cleanup removes old execution records and expired test results
|
||||
func (s *checkScheduler) cleanup() {
|
||||
log.Println("Running scheduler cleanup...")
|
||||
|
||||
// Delete completed/failed execution records older than 7 days
|
||||
if err := s.resultUsecase.DeleteCompletedExecutions(7 * 24 * time.Hour); err != nil {
|
||||
log.Printf("Error cleaning up old executions: %v\n", err)
|
||||
}
|
||||
|
||||
// Delete test results older than the configured retention period
|
||||
if err := s.resultUsecase.CleanupOldResults(); err != nil {
|
||||
log.Printf("Error cleaning up old test results: %v\n", err)
|
||||
}
|
||||
|
||||
log.Println("Scheduler cleanup complete")
|
||||
}
|
||||
|
||||
// run is the worker's main loop. It drains the queue eagerly and waits for a
|
||||
// workAvail signal when idle, rather than sleeping on a fixed timer.
|
||||
func (w *worker) run(wg *sync.WaitGroup) {
|
||||
defer wg.Done()
|
||||
|
||||
log.Printf("Worker %d started\n", w.id)
|
||||
|
||||
for {
|
||||
// Drain: try to grab work before blocking.
|
||||
if item := w.scheduler.queue.Pop(); item != nil {
|
||||
w.executeCheck(item)
|
||||
continue
|
||||
}
|
||||
|
||||
// Queue is empty; wait for new work or a stop signal.
|
||||
select {
|
||||
case <-w.scheduler.workAvail:
|
||||
// Loop back to attempt a Pop.
|
||||
case <-w.scheduler.stopWorkers:
|
||||
log.Printf("Worker %d stopped\n", w.id)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeCheck runs a checker and stores the result
|
||||
func (w *worker) executeCheck(item *queueItem) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), CheckExecutionTimeout)
|
||||
defer cancel()
|
||||
|
||||
execution := item.execution
|
||||
schedule := item.schedule
|
||||
|
||||
// Always update schedule NextRun after execution, whether it succeeds or fails.
|
||||
// This prevents the schedule from being re-queued on the next tick if the test fails.
|
||||
if item.execution.ScheduleId != nil {
|
||||
defer func() {
|
||||
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*item.execution.ScheduleId); err != nil {
|
||||
log.Printf("Worker %d: Error updating schedule after run: %v\n", w.id, err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Mark execution as running
|
||||
execution.Status = happydns.CheckExecutionRunning
|
||||
if err := w.scheduler.resultUsecase.UpdateCheckExecution(execution); err != nil {
|
||||
log.Printf("Worker %d: Error updating execution status: %v\n", w.id, err)
|
||||
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Track active execution
|
||||
w.scheduler.mu.Lock()
|
||||
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
|
||||
execution: execution,
|
||||
cancel: cancel,
|
||||
startTime: time.Now(),
|
||||
}
|
||||
w.scheduler.mu.Unlock()
|
||||
|
||||
defer func() {
|
||||
w.scheduler.mu.Lock()
|
||||
delete(w.scheduler.activeExecutions, execution.Id.String())
|
||||
w.scheduler.mu.Unlock()
|
||||
}()
|
||||
|
||||
// Get the checker
|
||||
checker, err := w.scheduler.checkerUsecase.GetChecker(schedule.CheckerName)
|
||||
if err != nil {
|
||||
errMsg := fmt.Sprintf("checker not found: %s - %v", schedule.CheckerName, err)
|
||||
log.Printf("Worker %d: %s\n", w.id, errMsg)
|
||||
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
// Merge options: global defaults < user opts < domain/service opts < schedule/on-demand opts < auto-fill
|
||||
var mergedOptions happydns.CheckerOptions
|
||||
|
||||
var domainId, serviceId *happydns.Identifier
|
||||
switch schedule.TargetType {
|
||||
case happydns.CheckScopeDomain:
|
||||
domainId = &schedule.TargetId
|
||||
case happydns.CheckScopeService:
|
||||
serviceId = &schedule.TargetId
|
||||
}
|
||||
var mergeErr error
|
||||
mergedOptions, mergeErr = w.scheduler.checkerUsecase.BuildMergedCheckerOptions(schedule.CheckerName, &schedule.OwnerId, domainId, serviceId, schedule.Options)
|
||||
if mergeErr != nil {
|
||||
// Non-fatal: fall back to schedule-only options
|
||||
log.Printf("Worker %d: warning, could not prepare checker options for %s: %v\n", w.id, schedule.CheckerName, mergeErr)
|
||||
mergedOptions = schedule.Options
|
||||
}
|
||||
|
||||
// Prepare metadata
|
||||
meta := make(map[string]string)
|
||||
meta["target_type"] = schedule.TargetType.String()
|
||||
meta["target_id"] = schedule.TargetId.String()
|
||||
|
||||
// Run the test
|
||||
startTime := time.Now()
|
||||
resultChan := make(chan *happydns.CheckResult, 1)
|
||||
errorChan := make(chan error, 1)
|
||||
|
||||
go func() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
errorChan <- fmt.Errorf("checker panicked: %v", r)
|
||||
}
|
||||
}()
|
||||
result, err := checker.RunCheck(mergedOptions, meta)
|
||||
if err != nil {
|
||||
errorChan <- err
|
||||
} else {
|
||||
resultChan <- result
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for result or timeout
|
||||
var checkResult *happydns.CheckResult
|
||||
var testErr error
|
||||
|
||||
select {
|
||||
case checkResult = <-resultChan:
|
||||
// Check completed successfully
|
||||
case testErr = <-errorChan:
|
||||
// Check returned an error
|
||||
case <-ctx.Done():
|
||||
// Timeout
|
||||
testErr = fmt.Errorf("test execution timeout after %v", CheckExecutionTimeout)
|
||||
}
|
||||
|
||||
duration := time.Since(startTime)
|
||||
|
||||
// Store the result
|
||||
result := &happydns.CheckResult{
|
||||
CheckerName: schedule.CheckerName,
|
||||
CheckType: schedule.TargetType,
|
||||
TargetId: schedule.TargetId,
|
||||
OwnerId: schedule.OwnerId,
|
||||
ExecutedAt: time.Now(),
|
||||
ScheduledCheck: item.execution.ScheduleId != nil,
|
||||
Options: schedule.Options,
|
||||
Duration: duration,
|
||||
}
|
||||
|
||||
if testErr != nil {
|
||||
result.Status = happydns.CheckResultStatusKO
|
||||
result.StatusLine = "Check execution failed"
|
||||
result.Error = testErr.Error()
|
||||
} else if checkResult != nil {
|
||||
result.Status = checkResult.Status
|
||||
result.StatusLine = checkResult.StatusLine
|
||||
result.Report = checkResult.Report
|
||||
} else {
|
||||
result.Status = happydns.CheckResultStatusKO
|
||||
result.StatusLine = "Unknown error"
|
||||
result.Error = "No result or error returned from check"
|
||||
}
|
||||
|
||||
// Save the result
|
||||
if err := w.scheduler.resultUsecase.CreateCheckResult(result); err != nil {
|
||||
log.Printf("Worker %d: Error saving test result: %v\n", w.id, err)
|
||||
_ = w.scheduler.resultUsecase.FailCheckExecution(execution.Id, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Complete the execution
|
||||
if err := w.scheduler.resultUsecase.CompleteCheckExecution(execution.Id, result.Id); err != nil {
|
||||
log.Printf("Worker %d: Error completing execution: %v\n", w.id, err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Printf("Worker %d: Completed test %s for target %s (status: %d, duration: %v)\n",
|
||||
w.id, schedule.CheckerName, schedule.TargetId.String(), result.Status, duration)
|
||||
}
|
||||
134
internal/app/plugins.go
Normal file
134
internal/app/plugins.go
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"plugin"
|
||||
|
||||
"git.happydns.org/happyDomain/checks"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// pluginLoader attempts to find and register one specific kind of plugin
|
||||
// symbol from an already-opened .so file.
|
||||
//
|
||||
// It returns (true, nil) when the symbol was found and registration
|
||||
// succeeded, (true, err) when the symbol was found but something went wrong,
|
||||
// and (false, nil) when the symbol simply isn't present in that file (which
|
||||
// is not considered an error — a single .so may implement only a subset of
|
||||
// the known plugin types).
|
||||
type pluginLoader func(p *plugin.Plugin, fname string) (found bool, err error)
|
||||
|
||||
// pluginLoaders is the authoritative list of plugin types that happyDomain
|
||||
// knows about. To support a new plugin type, add a single entry here.
|
||||
var pluginLoaders = []pluginLoader{
|
||||
loadCheckPlugin,
|
||||
}
|
||||
|
||||
// loadCheckPlugin handles the NewTestPlugin symbol.
|
||||
func loadCheckPlugin(p *plugin.Plugin, fname string) (bool, error) {
|
||||
sym, err := p.Lookup("NewCheckPlugin")
|
||||
if err != nil {
|
||||
// Symbol not present in this .so — not an error.
|
||||
return false, nil
|
||||
}
|
||||
|
||||
factory, ok := sym.(func() (string, happydns.Checker, error))
|
||||
if !ok {
|
||||
return true, fmt.Errorf("symbol NewCheckPlugin has unexpected type %T", sym)
|
||||
}
|
||||
|
||||
pluginname, myplugin, err := factory()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
checks.RegisterChecker(pluginname, myplugin)
|
||||
log.Printf("Plugin %s loaded", pluginname)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// initPlugins scans each directory listed in cfg.PluginsDirectories, loads
|
||||
// every .so file found as a Go plugin, and registers it in the application's
|
||||
// PluginManager. All load errors are collected and returned as a joined error
|
||||
// so that a single bad plugin does not prevent the others from loading.
|
||||
func (a *App) initPlugins() error {
|
||||
for _, directory := range a.cfg.PluginsDirectories {
|
||||
files, err := os.ReadDir(directory)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to read plugins directory %q: %s", directory, err)
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
if file.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
// Only attempt to load shared-object files.
|
||||
if filepath.Ext(file.Name()) != ".so" {
|
||||
continue
|
||||
}
|
||||
|
||||
fname := filepath.Join(directory, file.Name())
|
||||
|
||||
err = loadPlugin(fname)
|
||||
if err != nil {
|
||||
log.Printf("Unable to load plugin %q: %s", fname, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadPlugin opens the .so file at fname and runs every registered
|
||||
// pluginLoader against it. A loader that does not find its symbol is silently
|
||||
// skipped. If no loader recognises any symbol in the file a warning is logged,
|
||||
// but no error is returned because the file might be a valid plugin for a
|
||||
// future version of happyDomain. The first loader error that is encountered
|
||||
// is returned immediately.
|
||||
func loadPlugin(fname string) error {
|
||||
p, err := plugin.Open(fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
anyFound := false
|
||||
for _, loader := range pluginLoaders {
|
||||
found, err := loader(p, fname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if found {
|
||||
anyFound = true
|
||||
}
|
||||
}
|
||||
|
||||
if !anyFound {
|
||||
log.Printf("Warning: plugin %q exports no recognised symbols", fname)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -60,6 +60,8 @@ func declareFlags(o *happydns.Options) {
|
|||
flag.StringVar(&o.CaptchaProvider, "captcha-provider", o.CaptchaProvider, "Captcha provider to use for bot protection (altcha, hcaptcha, recaptchav2, turnstile, or empty to disable)")
|
||||
flag.IntVar(&o.CaptchaLoginThreshold, "captcha-login-threshold", 3, "Number of failed login attempts before captcha is required (0 = always require when provider configured)")
|
||||
|
||||
flag.Var(&ArrayArgs{&o.PluginsDirectories}, "plugins-directory", "Path to a directory containing plugins (can be repeated multiple times)")
|
||||
|
||||
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
|
|||
|
||||
// Define defaults options
|
||||
opts = &happydns.Options{
|
||||
AdminBind: "./happydomain.sock",
|
||||
BasePath: "/",
|
||||
Bind: ":8081",
|
||||
DefaultNameServer: "127.0.0.1:53",
|
||||
ExternalURL: *u,
|
||||
JWTSigningMethod: "HS512",
|
||||
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
||||
MailSMTPPort: 587,
|
||||
StorageEngine: "leveldb",
|
||||
AdminBind: "./happydomain.sock",
|
||||
BasePath: "/",
|
||||
Bind: ":8081",
|
||||
DefaultNameServer: "127.0.0.1:53",
|
||||
ExternalURL: *u,
|
||||
JWTSigningMethod: "HS512",
|
||||
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
||||
MailSMTPPort: 587,
|
||||
StorageEngine: "leveldb",
|
||||
MaxResultsPerCheck: 100,
|
||||
ResultRetentionDays: 90,
|
||||
TestWorkers: 2,
|
||||
}
|
||||
|
||||
declareFlags(opts)
|
||||
|
|
@ -98,6 +101,10 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
|
|||
} else {
|
||||
opts.BasePath = ""
|
||||
}
|
||||
if opts.DevProxy != "" && opts.BasePath != "" {
|
||||
err = fmt.Errorf("-base-path is not supported in -dev mode")
|
||||
return
|
||||
}
|
||||
|
||||
if opts.NoMail && opts.MailSMTPHost != "" {
|
||||
err = fmt.Errorf("-no-mail and -mail-smtp-* cannot be defined at the same time")
|
||||
|
|
|
|||
|
|
@ -25,8 +25,25 @@ import (
|
|||
"encoding/base64"
|
||||
"net/mail"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ArrayArgs struct {
|
||||
Slice *[]string
|
||||
}
|
||||
|
||||
func (i *ArrayArgs) String() string {
|
||||
if i == nil || i.Slice == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(*i.Slice, ",")
|
||||
}
|
||||
|
||||
func (i *ArrayArgs) Set(value string) error {
|
||||
*i.Slice = append(*i.Slice, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
type JWTSecretKey struct {
|
||||
Secret *[]byte
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ type InMemoryStorage struct {
|
|||
data map[string][]byte // Generic key-value store for KVStorage interface
|
||||
authUsers map[string]*happydns.UserAuth
|
||||
authUsersByEmail map[string]happydns.Identifier
|
||||
checksCfg map[string]*happydns.CheckerOptions
|
||||
domains map[string]*happydns.Domain
|
||||
domainLogs map[string]*happydns.DomainLogWithDomainId
|
||||
domainLogsByDomains map[string][]*happydns.Identifier
|
||||
|
|
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
|
|||
data: make(map[string][]byte),
|
||||
authUsers: make(map[string]*happydns.UserAuth),
|
||||
authUsersByEmail: make(map[string]happydns.Identifier),
|
||||
checksCfg: make(map[string]*happydns.CheckerOptions),
|
||||
domains: make(map[string]*happydns.Domain),
|
||||
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
|
||||
domainLogsByDomains: make(map[string][]*happydns.Identifier),
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
|
|||
|
||||
import (
|
||||
"git.happydns.org/happyDomain/internal/usecase/authuser"
|
||||
"git.happydns.org/happyDomain/internal/usecase/check"
|
||||
"git.happydns.org/happyDomain/internal/usecase/checkresult"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain"
|
||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||
|
|
@ -43,8 +45,10 @@ type Storage interface {
|
|||
domain.DomainStorage
|
||||
domainlog.DomainLogStorage
|
||||
insight.InsightStorage
|
||||
check.CheckerStorage
|
||||
provider.ProviderStorage
|
||||
session.SessionStorage
|
||||
checkresult.CheckResultStorage
|
||||
user.UserStorage
|
||||
zone.ZoneStorage
|
||||
|
||||
|
|
|
|||
185
internal/storage/kvtpl/check.go
Normal file
185
internal/storage/kvtpl/check.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) ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error) {
|
||||
iter := s.db.Search("chckrcfg-")
|
||||
return NewKVIterator[happydns.CheckerOptions](s.db, iter), nil
|
||||
}
|
||||
|
||||
func buildCheckerKey(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) string {
|
||||
u := ""
|
||||
if user != nil {
|
||||
u = user.String()
|
||||
}
|
||||
|
||||
d := ""
|
||||
if domain != nil {
|
||||
d = domain.String()
|
||||
}
|
||||
|
||||
s := ""
|
||||
if service != nil {
|
||||
s = service.String()
|
||||
}
|
||||
|
||||
return strings.Join([]string{cname, u, d, s}, "/")
|
||||
}
|
||||
|
||||
func keyToPositional(key string, opts *happydns.CheckerOptions) (*happydns.CheckerOptionsPositional, error) {
|
||||
tmp := strings.Split(key, "/")
|
||||
|
||||
if len(tmp) < 4 {
|
||||
return nil, fmt.Errorf("malformed plugin configuration key, got %q", key)
|
||||
}
|
||||
|
||||
cname := tmp[0]
|
||||
|
||||
var userid *happydns.Identifier
|
||||
if len(tmp[1]) > 0 {
|
||||
u, err := happydns.NewIdentifierFromString(tmp[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userid = &u
|
||||
}
|
||||
|
||||
var domainid *happydns.Identifier
|
||||
if len(tmp[2]) > 0 {
|
||||
d, err := happydns.NewIdentifierFromString(tmp[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domainid = &d
|
||||
}
|
||||
|
||||
var serviceid *happydns.Identifier
|
||||
if len(tmp[3]) > 0 {
|
||||
s, err := happydns.NewIdentifierFromString(tmp[3])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
serviceid = &s
|
||||
}
|
||||
|
||||
return &happydns.CheckerOptionsPositional{
|
||||
CheckName: cname,
|
||||
UserId: userid,
|
||||
DomainId: domainid,
|
||||
ServiceId: serviceid,
|
||||
Options: *opts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *KVStorage) ListCheckerConfiguration(cname string) (configs []*happydns.CheckerOptionsPositional, err error) {
|
||||
iter := s.db.Search("chckrcfg-" + cname + "/")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var p happydns.CheckerOptions
|
||||
|
||||
e := s.db.DecodeData(iter.Value(), &p)
|
||||
if e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
|
||||
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
|
||||
if e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
|
||||
configs = append(configs, opts)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *KVStorage) GetCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) (configs []*happydns.CheckerOptionsPositional, err error) {
|
||||
iter := s.db.Search("chckrcfg-" + cname + "/")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
var p happydns.CheckerOptions
|
||||
|
||||
e := s.db.DecodeData(iter.Value(), &p)
|
||||
if e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
|
||||
opts, e := keyToPositional(strings.TrimPrefix(iter.Key(), "chckrcfg-"), &p)
|
||||
if e != nil {
|
||||
err = errors.Join(err, e)
|
||||
continue
|
||||
}
|
||||
|
||||
// Match logic:
|
||||
// - When parameter is nil: match ONLY configs with nil ID (requesting specific scope)
|
||||
// - When parameter is not nil: match configs with nil ID (admin-level) OR matching ID
|
||||
matchUser := (user == nil && opts.UserId == nil) ||
|
||||
(user != nil && (opts.UserId == nil || opts.UserId.Equals(*user)))
|
||||
|
||||
matchDomain := (domain == nil && opts.DomainId == nil) ||
|
||||
(domain != nil && (opts.DomainId == nil || opts.DomainId.Equals(*domain)))
|
||||
|
||||
matchService := (service == nil && opts.ServiceId == nil) ||
|
||||
(service != nil && (opts.ServiceId == nil || opts.ServiceId.Equals(*service)))
|
||||
|
||||
if matchUser && matchDomain && matchService {
|
||||
configs = append(configs, opts)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (s *KVStorage) UpdateCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||
return s.db.Put(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)), opts)
|
||||
}
|
||||
|
||||
func (s *KVStorage) DeleteCheckerConfiguration(cname string, user *happydns.Identifier, domain *happydns.Identifier, service *happydns.Identifier) error {
|
||||
return s.db.Delete(fmt.Sprintf("chckrcfg-%s", buildCheckerKey(cname, user, domain, service)))
|
||||
}
|
||||
|
||||
func (s *KVStorage) ClearCheckerConfigurations() error {
|
||||
iter := s.db.Search("chckrcfg-")
|
||||
defer iter.Release()
|
||||
|
||||
for iter.Next() {
|
||||
err := s.db.Delete(iter.Key())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
433
internal/storage/kvtpl/checkresult.go
Normal file
433
internal/storage/kvtpl/checkresult.go
Normal file
|
|
@ -0,0 +1,433 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// Check Result storage keys:
|
||||
// checkresult|{plugin-name}|{target-type}|{target-id}|{result-id}
|
||||
func makeCheckResultKey(checkName string, targetType happydns.CheckScopeType, targetId, resultId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkresult|%s|%d|%s|%s", checkName, targetType, targetId.String(), resultId.String())
|
||||
}
|
||||
|
||||
func makeCheckResultPrefix(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkresult|%s|%d|%s|", checkName, targetType, targetId.String())
|
||||
}
|
||||
|
||||
// ListCheckResults retrieves check results for a specific plugin+target combination
|
||||
func (s *KVStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
|
||||
prefix := makeCheckResultPrefix(checkName, targetType, targetId)
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var results []*happydns.CheckResult
|
||||
for iter.Next() {
|
||||
var r happydns.CheckResult
|
||||
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, &r)
|
||||
}
|
||||
|
||||
// Sort by ExecutedAt descending (most recent first)
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
|
||||
func (s *KVStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error) {
|
||||
prefix := fmt.Sprintf("checkresult|%s|", checkName)
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var results []*happydns.CheckResult
|
||||
for iter.Next() {
|
||||
var r happydns.CheckResult
|
||||
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Filter by user
|
||||
if r.OwnerId.Equals(userId) {
|
||||
results = append(results, &r)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ExecutedAt descending (most recent first)
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ListCheckResultsByUser retrieves all check results for a user
|
||||
func (s *KVStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
|
||||
iter := s.db.Search("checkresult|")
|
||||
defer iter.Release()
|
||||
|
||||
var results []*happydns.CheckResult
|
||||
for iter.Next() {
|
||||
var r happydns.CheckResult
|
||||
if err := s.db.DecodeData(iter.Value(), &r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Filter by user
|
||||
if r.OwnerId.Equals(userId) {
|
||||
results = append(results, &r)
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by ExecutedAt descending (most recent first)
|
||||
sort.Slice(results, func(i, j int) bool {
|
||||
return results[i].ExecutedAt.After(results[j].ExecutedAt)
|
||||
})
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCheckResult retrieves a specific check result by its ID
|
||||
func (s *KVStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
|
||||
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
|
||||
var result happydns.CheckResult
|
||||
err := s.db.Get(key, &result)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrCheckResultNotFound
|
||||
}
|
||||
return &result, err
|
||||
}
|
||||
|
||||
// CreateCheckResult stores a new check result
|
||||
func (s *KVStorage) CreateCheckResult(result *happydns.CheckResult) error {
|
||||
prefix := makeCheckResultPrefix(result.CheckerName, result.CheckType, result.TargetId)
|
||||
key, id, err := s.db.FindIdentifierKey(prefix)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result.Id = id
|
||||
return s.db.Put(key, result)
|
||||
}
|
||||
|
||||
// DeleteCheckResult removes a specific check result
|
||||
func (s *KVStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
|
||||
key := makeCheckResultKey(checkName, targetType, targetId, resultId)
|
||||
return s.db.Delete(key)
|
||||
}
|
||||
|
||||
// DeleteOldCheckResults removes old check results keeping only the most recent N results
|
||||
func (s *KVStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error {
|
||||
results, err := s.ListCheckResults(checkName, targetType, targetId, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Results are already sorted by ExecutedAt descending
|
||||
// Delete results beyond keepCount
|
||||
if len(results) > keepCount {
|
||||
for _, r := range results[keepCount:] {
|
||||
if err := s.DeleteCheckResult(checkName, targetType, targetId, r.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Checker Schedule storage keys:
|
||||
// checkschedule|{schedule-id}
|
||||
// checkschedule.byuser|{user-id}|{schedule-id}
|
||||
// checkschedule.bytarget|{target-type}|{target-id}|{schedule-id}
|
||||
|
||||
func makeCheckerScheduleKey(scheduleId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkschedule|%s", scheduleId.String())
|
||||
}
|
||||
|
||||
func makeCheckerScheduleUserIndexKey(userId, scheduleId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkschedule.byuser|%s|%s", userId.String(), scheduleId.String())
|
||||
}
|
||||
|
||||
func makeCheckerScheduleTargetIndexKey(targetType happydns.CheckScopeType, targetId, scheduleId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkschedule.bytarget|%d|%s|%s", targetType, targetId.String(), scheduleId.String())
|
||||
}
|
||||
|
||||
// ListEnabledCheckerSchedules retrieves all enabled schedules
|
||||
func (s *KVStorage) ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error) {
|
||||
iter := s.db.Search("checkschedule|")
|
||||
defer iter.Release()
|
||||
|
||||
var schedules []*happydns.CheckerSchedule
|
||||
for iter.Next() {
|
||||
var sched happydns.CheckerSchedule
|
||||
if err := s.db.DecodeData(iter.Value(), &sched); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sched.Enabled {
|
||||
schedules = append(schedules, &sched)
|
||||
}
|
||||
}
|
||||
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
|
||||
func (s *KVStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
|
||||
prefix := fmt.Sprintf("checkschedule.byuser|%s|", userId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var schedules []*happydns.CheckerSchedule
|
||||
for iter.Next() {
|
||||
// Extract schedule ID from index key
|
||||
key := string(iter.Key())
|
||||
parts := strings.Split(key, "|")
|
||||
if len(parts) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
scheduleId, err := happydns.NewIdentifierFromString(parts[2])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the actual schedule
|
||||
var sched happydns.CheckerSchedule
|
||||
schedKey := makeCheckerScheduleKey(scheduleId)
|
||||
if err := s.db.Get(schedKey, &sched); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
schedules = append(schedules, &sched)
|
||||
}
|
||||
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
|
||||
func (s *KVStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
|
||||
prefix := fmt.Sprintf("checkschedule.bytarget|%d|%s|", targetType, targetId.String())
|
||||
iter := s.db.Search(prefix)
|
||||
defer iter.Release()
|
||||
|
||||
var schedules []*happydns.CheckerSchedule
|
||||
for iter.Next() {
|
||||
// Extract schedule ID from index key
|
||||
key := string(iter.Key())
|
||||
parts := strings.Split(key, "|")
|
||||
if len(parts) < 4 {
|
||||
continue
|
||||
}
|
||||
|
||||
scheduleId, err := happydns.NewIdentifierFromString(parts[3])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get the actual schedule
|
||||
var sched happydns.CheckerSchedule
|
||||
schedKey := makeCheckerScheduleKey(scheduleId)
|
||||
if err := s.db.Get(schedKey, &sched); err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
schedules = append(schedules, &sched)
|
||||
}
|
||||
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// GetCheckerSchedule retrieves a specific schedule by ID
|
||||
func (s *KVStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
|
||||
key := makeCheckerScheduleKey(scheduleId)
|
||||
var schedule happydns.CheckerSchedule
|
||||
err := s.db.Get(key, &schedule)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrCheckScheduleNotFound
|
||||
}
|
||||
return &schedule, err
|
||||
}
|
||||
|
||||
// CreateCheckerSchedule creates a new check schedule
|
||||
func (s *KVStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
|
||||
key, id, err := s.db.FindIdentifierKey("checkschedule|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedule.Id = id
|
||||
|
||||
// Store the schedule
|
||||
if err := s.db.Put(key, schedule); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create indexes
|
||||
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
|
||||
if err := s.db.Put(userIndexKey, []byte{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
|
||||
if err := s.db.Put(targetIndexKey, []byte{}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateCheckerSchedule updates an existing schedule
|
||||
func (s *KVStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error {
|
||||
key := makeCheckerScheduleKey(schedule.Id)
|
||||
return s.db.Put(key, schedule)
|
||||
}
|
||||
|
||||
// DeleteCheckerSchedule removes a schedule and its indexes
|
||||
func (s *KVStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) error {
|
||||
// Get the schedule first to know what indexes to delete
|
||||
schedule, err := s.GetCheckerSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete indexes
|
||||
userIndexKey := makeCheckerScheduleUserIndexKey(schedule.OwnerId, schedule.Id)
|
||||
if err := s.db.Delete(userIndexKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
targetIndexKey := makeCheckerScheduleTargetIndexKey(schedule.TargetType, schedule.TargetId, schedule.Id)
|
||||
if err := s.db.Delete(targetIndexKey); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete the schedule itself
|
||||
key := makeCheckerScheduleKey(scheduleId)
|
||||
return s.db.Delete(key)
|
||||
}
|
||||
|
||||
// Check Execution storage keys:
|
||||
// checkexec|{execution-id}
|
||||
|
||||
func makeCheckExecutionKey(executionId happydns.Identifier) string {
|
||||
return fmt.Sprintf("checkexec|%s", executionId.String())
|
||||
}
|
||||
|
||||
// ListActiveCheckExecutions retrieves all executions that are pending or running
|
||||
func (s *KVStorage) ListActiveCheckExecutions() ([]*happydns.CheckExecution, error) {
|
||||
iter := s.db.Search("checkexec|")
|
||||
defer iter.Release()
|
||||
|
||||
var executions []*happydns.CheckExecution
|
||||
for iter.Next() {
|
||||
var exec happydns.CheckExecution
|
||||
if err := s.db.DecodeData(iter.Value(), &exec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exec.Status == happydns.CheckExecutionPending || exec.Status == happydns.CheckExecutionRunning {
|
||||
executions = append(executions, &exec)
|
||||
}
|
||||
}
|
||||
|
||||
return executions, nil
|
||||
}
|
||||
|
||||
// GetCheckExecution retrieves a specific execution by ID
|
||||
func (s *KVStorage) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
|
||||
key := makeCheckExecutionKey(executionId)
|
||||
var execution happydns.CheckExecution
|
||||
err := s.db.Get(key, &execution)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, happydns.ErrCheckExecutionNotFound
|
||||
}
|
||||
return &execution, err
|
||||
}
|
||||
|
||||
// CreateCheckExecution creates a new check execution record
|
||||
func (s *KVStorage) CreateCheckExecution(execution *happydns.CheckExecution) error {
|
||||
key, id, err := s.db.FindIdentifierKey("checkexec|")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
execution.Id = id
|
||||
return s.db.Put(key, execution)
|
||||
}
|
||||
|
||||
// UpdateCheckExecution updates an existing execution record
|
||||
func (s *KVStorage) UpdateCheckExecution(execution *happydns.CheckExecution) error {
|
||||
key := makeCheckExecutionKey(execution.Id)
|
||||
return s.db.Put(key, execution)
|
||||
}
|
||||
|
||||
// DeleteCheckExecution removes an execution record
|
||||
func (s *KVStorage) DeleteCheckExecution(executionId happydns.Identifier) error {
|
||||
key := makeCheckExecutionKey(executionId)
|
||||
return s.db.Delete(key)
|
||||
}
|
||||
|
||||
// Scheduler state storage key:
|
||||
// checkscheduler.lastrun
|
||||
|
||||
// CheckerSchedulerRun marks that the scheduler has run at current time
|
||||
func (s *KVStorage) CheckSchedulerRun() error {
|
||||
now := time.Now()
|
||||
return s.db.Put("checkscheduler.lastrun", &now)
|
||||
}
|
||||
|
||||
// LastCheckSchedulerRun retrieves the last time the scheduler ran
|
||||
func (s *KVStorage) LastCheckSchedulerRun() (*time.Time, error) {
|
||||
var lastRun time.Time
|
||||
err := s.db.Get("checkscheduler.lastrun", &lastRun)
|
||||
if errors.Is(err, happydns.ErrNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &lastRun, nil
|
||||
}
|
||||
62
internal/usecase/check/check_storage.go
Normal file
62
internal/usecase/check/check_storage.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type CheckerStorage interface {
|
||||
// ListAllCheckConfigurations retrieves the list of known Providers.
|
||||
ListAllCheckerConfigurations() (happydns.Iterator[happydns.CheckerOptions], error)
|
||||
|
||||
// ListCheckerConfiguration retrieves all providers own by the given User.
|
||||
ListCheckerConfiguration(string) ([]*happydns.CheckerOptionsPositional, error)
|
||||
|
||||
// GetCheckerConfiguration retrieves the full Provider with the given identifier and owner.
|
||||
GetCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) ([]*happydns.CheckerOptionsPositional, error)
|
||||
|
||||
// UpdateCheckerConfiguration updates the fields of the given Provider.
|
||||
UpdateCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier, happydns.CheckerOptions) error
|
||||
|
||||
// DeleteCheckerConfiguration removes the given Provider from the database.
|
||||
DeleteCheckerConfiguration(string, *happydns.Identifier, *happydns.Identifier, *happydns.Identifier) error
|
||||
|
||||
// ClearCheckerConfigurations deletes all Providers present in the database.
|
||||
ClearCheckerConfigurations() error
|
||||
}
|
||||
|
||||
// CheckAutoFillStorage provides the domain/zone/user lookups needed to
|
||||
// resolve auto-fill variables for test check options.
|
||||
type CheckAutoFillStorage interface {
|
||||
// GetDomain retrieves the Domain with the given identifier.
|
||||
GetDomain(domainid happydns.Identifier) (*happydns.Domain, error)
|
||||
|
||||
// GetUser retrieves the User with the given identifier.
|
||||
GetUser(userid happydns.Identifier) (*happydns.User, error)
|
||||
|
||||
// ListDomains retrieves all Domains associated to the given User.
|
||||
ListDomains(user *happydns.User) ([]*happydns.Domain, error)
|
||||
|
||||
// GetZone retrieves the full Zone (including Services and metadata) for the given identifier.
|
||||
GetZone(zoneid happydns.Identifier) (*happydns.ZoneMessage, error)
|
||||
}
|
||||
321
internal/usecase/check/check_usecase.go
Normal file
321
internal/usecase/check/check_usecase.go
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package check
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
"git.happydns.org/happyDomain/checks"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type checkerUsecase struct {
|
||||
config *happydns.Options
|
||||
store CheckerStorage
|
||||
autoFillStore CheckAutoFillStorage
|
||||
}
|
||||
|
||||
func NewCheckerUsecase(cfg *happydns.Options, store CheckerStorage, autoFillStore CheckAutoFillStorage) happydns.CheckerUsecase {
|
||||
return &checkerUsecase{
|
||||
config: cfg,
|
||||
store: store,
|
||||
autoFillStore: autoFillStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (tu *checkerUsecase) GetChecker(cname string) (happydns.Checker, error) {
|
||||
checker, err := checks.FindChecker(cname)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to find check named %q: %w", cname, err)
|
||||
}
|
||||
|
||||
return checker, nil
|
||||
}
|
||||
|
||||
// copyNonEmpty copies key/value pairs from src into dst, skipping nil or empty-string values.
|
||||
func copyNonEmpty(dst, src happydns.CheckerOptions) {
|
||||
for k, v := range src {
|
||||
if v == nil {
|
||||
continue
|
||||
}
|
||||
if s, ok := v.(string); ok && s == "" {
|
||||
continue
|
||||
}
|
||||
dst[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// CompareCheckerOptionsPositional defines the merge precedence ordering for
|
||||
// checker option configs: admin < user < domain < service.
|
||||
func CompareCheckerOptionsPositional(a, b *happydns.CheckerOptionsPositional) int {
|
||||
if a.CheckName != b.CheckName {
|
||||
return cmp.Compare(a.CheckName, b.CheckName)
|
||||
}
|
||||
if res := compareIdentifiers(a.UserId, b.UserId); res != 0 {
|
||||
return res
|
||||
}
|
||||
if res := compareIdentifiers(a.DomainId, b.DomainId); res != 0 {
|
||||
return res
|
||||
}
|
||||
return compareIdentifiers(a.ServiceId, b.ServiceId)
|
||||
}
|
||||
|
||||
func (tu *checkerUsecase) GetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
|
||||
configs, err := tu.store.GetCheckerConfiguration(cname, userid, domainid, serviceid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slices.SortFunc(configs, CompareCheckerOptionsPositional)
|
||||
|
||||
opts := make(happydns.CheckerOptions)
|
||||
|
||||
for _, c := range configs {
|
||||
maps.Copy(opts, c.Options)
|
||||
}
|
||||
|
||||
return &opts, nil
|
||||
}
|
||||
|
||||
func (tu *checkerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
|
||||
return checks.GetCheckers(), nil
|
||||
}
|
||||
|
||||
// GetStoredCheckerOptionsNoDefault returns the stored options (user/domain/service scopes)
|
||||
// with auto-fill variables applied, but without checker-defined defaults or run-time overrides.
|
||||
func (tu *checkerUsecase) GetStoredCheckerOptionsNoDefault(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
|
||||
stored, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var opts happydns.CheckerOptions
|
||||
if stored != nil {
|
||||
opts = *stored
|
||||
} else {
|
||||
opts = make(happydns.CheckerOptions)
|
||||
}
|
||||
|
||||
checker, err := tu.GetChecker(cname)
|
||||
if err != nil {
|
||||
return opts, nil
|
||||
}
|
||||
|
||||
return tu.applyAutoFill(checker, userid, domainid, serviceid, opts), nil
|
||||
}
|
||||
|
||||
// BuildMergedCheckerOptions merges checker options from all sources in priority order:
|
||||
// checker defaults < stored (user/domain/service) options < runOpts < auto-fill variables.
|
||||
func (tu *checkerUsecase) BuildMergedCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
|
||||
merged := make(happydns.CheckerOptions)
|
||||
|
||||
// 1. Fill checker defaults.
|
||||
checker, err := tu.GetChecker(cname)
|
||||
if err != nil {
|
||||
log.Printf("Warning: unable to get checker %q for default options: %v", cname, err)
|
||||
} else {
|
||||
opts := checker.Options()
|
||||
|
||||
allOpts := []happydns.CheckerOptionDocumentation{}
|
||||
allOpts = append(allOpts, opts.RunOpts...)
|
||||
allOpts = append(allOpts, opts.ServiceOpts...)
|
||||
allOpts = append(allOpts, opts.DomainOpts...)
|
||||
allOpts = append(allOpts, opts.UserOpts...)
|
||||
allOpts = append(allOpts, opts.AdminOpts...)
|
||||
for _, opt := range allOpts {
|
||||
if opt.Default != nil {
|
||||
merged[opt.Id] = opt.Default
|
||||
} else if opt.Placeholder != "" {
|
||||
merged[opt.Id] = opt.Placeholder
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Override with stored options (user/domain/service scopes).
|
||||
baseOptions, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
|
||||
if err != nil {
|
||||
return merged, fmt.Errorf("could not fetch stored checker options for %s: %w", cname, err)
|
||||
}
|
||||
if baseOptions != nil {
|
||||
copyNonEmpty(merged, *baseOptions)
|
||||
}
|
||||
|
||||
// 3. Override with caller-supplied run options.
|
||||
copyNonEmpty(merged, runOpts)
|
||||
|
||||
// 4. Inject auto-fill variables (always win over any user-supplied value).
|
||||
if checker != nil {
|
||||
merged = tu.applyAutoFill(checker, userid, domainid, serviceid, merged)
|
||||
}
|
||||
|
||||
return merged, nil
|
||||
}
|
||||
|
||||
// applyAutoFill resolves auto-fill fields declared by the checker and injects
|
||||
// the context-resolved values into a copy of opts.
|
||||
func (tu *checkerUsecase) applyAutoFill(
|
||||
checker happydns.Checker,
|
||||
userid *happydns.Identifier,
|
||||
domainid *happydns.Identifier,
|
||||
serviceid *happydns.Identifier,
|
||||
opts happydns.CheckerOptions,
|
||||
) happydns.CheckerOptions {
|
||||
// Collect which auto-fill keys are needed.
|
||||
needed := make(map[string]string) // autoFill constant → field id
|
||||
options := checker.Options()
|
||||
for _, groups := range [][]happydns.CheckerOptionDocumentation{
|
||||
options.RunOpts, options.DomainOpts, options.ServiceOpts,
|
||||
options.UserOpts, options.AdminOpts,
|
||||
} {
|
||||
for _, opt := range groups {
|
||||
if opt.AutoFill != "" {
|
||||
needed[opt.AutoFill] = opt.Id
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(needed) == 0 || tu.autoFillStore == nil {
|
||||
return opts
|
||||
}
|
||||
|
||||
autoFillCtx := tu.buildAutoFillContext(userid, domainid, serviceid)
|
||||
|
||||
result := maps.Clone(opts)
|
||||
for autoFillKey, fieldId := range needed {
|
||||
if val, ok := autoFillCtx[autoFillKey]; ok {
|
||||
result[fieldId] = val
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildAutoFillContext resolves the available auto-fill values for the given
|
||||
// user/domain/service identifiers.
|
||||
func (tu *checkerUsecase) buildAutoFillContext(userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) map[string]string {
|
||||
ctx := make(map[string]string)
|
||||
|
||||
if domainid != nil {
|
||||
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
|
||||
ctx[happydns.AutoFillDomainName] = domain.DomainName
|
||||
}
|
||||
} else if serviceid != nil && userid != nil {
|
||||
// To resolve service context we need to find which domain/zone owns the service.
|
||||
user, err := tu.autoFillStore.GetUser(*userid)
|
||||
if err != nil {
|
||||
return ctx
|
||||
}
|
||||
domains, err := tu.autoFillStore.ListDomains(user)
|
||||
if err != nil {
|
||||
return ctx
|
||||
}
|
||||
for _, domain := range domains {
|
||||
if len(domain.ZoneHistory) == 0 {
|
||||
continue
|
||||
}
|
||||
// The first element in ZoneHistory is the current (most recent) zone.
|
||||
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for subdomain, svcs := range zoneMsg.Services {
|
||||
for _, svc := range svcs {
|
||||
if svc.Id.Equals(*serviceid) {
|
||||
ctx[happydns.AutoFillDomainName] = domain.DomainName
|
||||
ctx[happydns.AutoFillSubdomain] = string(subdomain)
|
||||
ctx[happydns.AutoFillServiceType] = svc.Type
|
||||
return ctx
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (tu *checkerUsecase) SetCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||
// filter opts that correspond to the level set
|
||||
checker, err := tu.GetChecker(cname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unable to get checker: %w", err)
|
||||
}
|
||||
|
||||
options := checker.Options()
|
||||
|
||||
var relevantOpts []happydns.CheckerOptionDocumentation
|
||||
if serviceid != nil {
|
||||
relevantOpts = options.ServiceOpts
|
||||
} else if domainid != nil {
|
||||
relevantOpts = options.DomainOpts
|
||||
} else if userid != nil {
|
||||
relevantOpts = options.UserOpts
|
||||
} else {
|
||||
relevantOpts = options.AdminOpts
|
||||
}
|
||||
|
||||
allowed := make(map[string]struct{}, len(relevantOpts))
|
||||
for _, opt := range relevantOpts {
|
||||
allowed[opt.Id] = struct{}{}
|
||||
}
|
||||
|
||||
filteredOpts := make(happydns.CheckerOptions)
|
||||
for id := range allowed {
|
||||
if val, exists := opts[id]; exists && val != "" {
|
||||
filteredOpts[id] = val
|
||||
}
|
||||
}
|
||||
|
||||
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, filteredOpts)
|
||||
}
|
||||
|
||||
func (tu *checkerUsecase) OverwriteSomeCheckerOptions(cname string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||
current, err := tu.GetCheckerOptions(cname, userid, domainid, serviceid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
maps.Copy(*current, opts)
|
||||
|
||||
return tu.store.UpdateCheckerConfiguration(cname, userid, domainid, serviceid, *current)
|
||||
}
|
||||
85
internal/usecase/check/check_usecase_test.go
Normal file
85
internal/usecase/check/check_usecase_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package check_test
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
uc "git.happydns.org/happyDomain/internal/usecase/check"
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
func TestSortByCheckName(t *testing.T) {
|
||||
slice := []*happydns.CheckerOptionsPositional{
|
||||
{CheckName: "zeta"},
|
||||
{CheckName: "alpha"},
|
||||
{CheckName: "beta"},
|
||||
}
|
||||
|
||||
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
|
||||
|
||||
got := []string{slice[0].CheckName, slice[1].CheckName, slice[2].CheckName}
|
||||
want := []string{"alpha", "beta", "zeta"}
|
||||
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("expected %v, got %v", want, got)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNilBeforeNonNil(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
slice := []*happydns.CheckerOptionsPositional{
|
||||
{CheckName: "alpha", UserId: &uid},
|
||||
{CheckName: "alpha", UserId: nil},
|
||||
}
|
||||
|
||||
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
|
||||
|
||||
if slice[0].UserId != nil {
|
||||
t.Errorf("expected nil UserId first, got %+v", slice[0].UserId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDomainIdOrder(t *testing.T) {
|
||||
did, _ := happydns.NewRandomIdentifier()
|
||||
slice := []*happydns.CheckerOptionsPositional{
|
||||
{CheckName: "alpha", UserId: nil, DomainId: &did},
|
||||
{CheckName: "alpha", UserId: nil, DomainId: nil},
|
||||
}
|
||||
|
||||
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
|
||||
|
||||
if slice[0].DomainId != nil {
|
||||
t.Errorf("expected nil DomainId first, got %+v", slice[0].DomainId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceIdOrder(t *testing.T) {
|
||||
sid, _ := happydns.NewRandomIdentifier()
|
||||
slice := []*happydns.CheckerOptionsPositional{
|
||||
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: &sid},
|
||||
{CheckName: "alpha", UserId: nil, DomainId: nil, ServiceId: nil},
|
||||
}
|
||||
|
||||
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
|
||||
|
||||
if slice[0].ServiceId != nil {
|
||||
t.Errorf("expected nil ServiceId first, got %+v", slice[0].ServiceId)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableGrouping(t *testing.T) {
|
||||
uid, _ := happydns.NewRandomIdentifier()
|
||||
|
||||
slice := []*happydns.CheckerOptionsPositional{
|
||||
{CheckName: "alpha", UserId: &uid},
|
||||
{CheckName: "alpha", UserId: &uid},
|
||||
}
|
||||
|
||||
slices.SortFunc(slice, uc.CompareCheckerOptionsPositional)
|
||||
if slice[0].CheckName != slice[1].CheckName {
|
||||
t.Errorf("expected grouping, got %+v vs %+v", slice[0], slice[1])
|
||||
}
|
||||
}
|
||||
222
internal/usecase/checkresult/checkresult_usecase.go
Normal file
222
internal/usecase/checkresult/checkresult_usecase.go
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkresult
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckResultUsecase implements business logic for check results
|
||||
type CheckResultUsecase struct {
|
||||
storage CheckResultStorage
|
||||
options *happydns.Options
|
||||
}
|
||||
|
||||
// NewCheckResultUsecase creates a new check result usecase
|
||||
func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options) *CheckResultUsecase {
|
||||
return &CheckResultUsecase{
|
||||
storage: storage,
|
||||
options: options,
|
||||
}
|
||||
}
|
||||
|
||||
// ListCheckResultsByTarget retrieves check results for a specific target
|
||||
func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
|
||||
// Apply default limit if not specified
|
||||
if limit <= 0 {
|
||||
limit = 5 // Default to 5 most recent results
|
||||
}
|
||||
|
||||
return u.storage.ListCheckResults(pluginName, targetType, targetId, limit)
|
||||
}
|
||||
|
||||
// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins
|
||||
func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
|
||||
// Get all results for the user and filter by target
|
||||
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Filter by target
|
||||
var results []*happydns.CheckResult
|
||||
for _, r := range allResults {
|
||||
if r.CheckType == targetType && r.TargetId.Equals(targetId) {
|
||||
results = append(results, r)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply limit
|
||||
if limit > 0 && len(results) > limit {
|
||||
results = results[:limit]
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// GetCheckResult retrieves a specific check result
|
||||
func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error) {
|
||||
return u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
|
||||
}
|
||||
|
||||
// CreateCheckResult stores a new check result and enforces retention policy
|
||||
func (u *CheckResultUsecase) CreateCheckResult(result *happydns.CheckResult) error {
|
||||
// Store the result
|
||||
if err := u.storage.CreateCheckResult(result); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Enforce retention policy
|
||||
maxResults := u.options.MaxResultsPerCheck
|
||||
if maxResults <= 0 {
|
||||
maxResults = 100 // Default
|
||||
}
|
||||
|
||||
return u.storage.DeleteOldCheckResults(result.CheckerName, result.CheckType, result.TargetId, maxResults)
|
||||
}
|
||||
|
||||
// DeleteCheckResult removes a specific check result
|
||||
func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error {
|
||||
return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId)
|
||||
}
|
||||
|
||||
// DeleteAllCheckResults removes all results for a specific plugin+target combination
|
||||
func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
|
||||
// Get all results first
|
||||
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete each result
|
||||
for _, r := range results {
|
||||
if err := u.storage.DeleteCheckResult(pluginName, targetType, targetId, r.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CleanupOldResults removes check results older than retention period
|
||||
func (u *CheckResultUsecase) CleanupOldResults() error {
|
||||
retentionDays := u.options.ResultRetentionDays
|
||||
if retentionDays <= 0 {
|
||||
retentionDays = 90 // Default
|
||||
}
|
||||
|
||||
cutoffTime := time.Now().AddDate(0, 0, -retentionDays)
|
||||
|
||||
// Get all results for all users (inefficient but necessary without a time-based index)
|
||||
// In a production system, you might want to add a time-based index for this
|
||||
// For now, we'll iterate through results and delete old ones
|
||||
|
||||
// This is a placeholder - the actual implementation would need to be optimized
|
||||
// based on specific storage patterns
|
||||
_ = cutoffTime
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetCheckExecution retrieves the status of a check execution
|
||||
func (u *CheckResultUsecase) GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error) {
|
||||
return u.storage.GetCheckExecution(executionId)
|
||||
}
|
||||
|
||||
// CreateCheckExecution creates a new check execution record
|
||||
func (u *CheckResultUsecase) CreateCheckExecution(execution *happydns.CheckExecution) error {
|
||||
if execution.StartedAt.IsZero() {
|
||||
execution.StartedAt = time.Now()
|
||||
}
|
||||
return u.storage.CreateCheckExecution(execution)
|
||||
}
|
||||
|
||||
// UpdateCheckExecution updates an existing check execution
|
||||
func (u *CheckResultUsecase) UpdateCheckExecution(execution *happydns.CheckExecution) error {
|
||||
return u.storage.UpdateCheckExecution(execution)
|
||||
}
|
||||
|
||||
// CompleteCheckExecution marks an execution as completed with a result
|
||||
func (u *CheckResultUsecase) CompleteCheckExecution(executionId happydns.Identifier, resultId happydns.Identifier) error {
|
||||
execution, err := u.storage.GetCheckExecution(executionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
execution.Status = happydns.CheckExecutionCompleted
|
||||
execution.CompletedAt = &now
|
||||
execution.ResultId = &resultId
|
||||
|
||||
return u.storage.UpdateCheckExecution(execution)
|
||||
}
|
||||
|
||||
// FailCheckExecution marks an execution as failed
|
||||
func (u *CheckResultUsecase) FailCheckExecution(executionId happydns.Identifier, errorMsg string) error {
|
||||
execution, err := u.storage.GetCheckExecution(executionId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
execution.Status = happydns.CheckExecutionFailed
|
||||
execution.CompletedAt = &now
|
||||
|
||||
// Store error in a result
|
||||
result := &happydns.CheckResult{
|
||||
CheckerName: execution.CheckerName,
|
||||
CheckType: execution.TargetType,
|
||||
TargetId: execution.TargetId,
|
||||
OwnerId: execution.OwnerId,
|
||||
ExecutedAt: time.Now(),
|
||||
ScheduledCheck: execution.ScheduleId != nil,
|
||||
Options: execution.Options,
|
||||
Status: happydns.CheckResultStatusKO,
|
||||
StatusLine: "Execution failed",
|
||||
Error: errorMsg,
|
||||
Duration: now.Sub(execution.StartedAt),
|
||||
}
|
||||
|
||||
if err := u.CreateCheckResult(result); err != nil {
|
||||
return fmt.Errorf("failed to create error result: %w", err)
|
||||
}
|
||||
|
||||
execution.ResultId = &result.Id
|
||||
|
||||
return u.storage.UpdateCheckExecution(execution)
|
||||
}
|
||||
|
||||
// DeleteCompletedExecutions removes execution records that are completed
|
||||
func (u *CheckResultUsecase) DeleteCompletedExecutions(olderThan time.Duration) error {
|
||||
cutoffTime := time.Now().Add(-olderThan)
|
||||
|
||||
// Get active executions (this won't include completed ones)
|
||||
// We need a different query to get completed executions older than cutoff
|
||||
// For now, this is a placeholder
|
||||
|
||||
_ = cutoffTime
|
||||
|
||||
return nil
|
||||
}
|
||||
410
internal/usecase/checkresult/checkschedule_usecase.go
Normal file
410
internal/usecase/checkresult/checkschedule_usecase.go
Normal file
|
|
@ -0,0 +1,410 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkresult
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
const (
|
||||
// Default check intervals
|
||||
DefaultUserCheckInterval = 4 * time.Hour // 4 hours for user checks
|
||||
DefaultDomainCheckInterval = 24 * time.Hour // 24 hours for domain checks
|
||||
DefaultServiceCheckInterval = 1 * time.Hour // 1 hour for service checks
|
||||
MinimumCheckInterval = 5 * time.Minute // Minimum interval allowed
|
||||
)
|
||||
|
||||
// CheckScheduleUsecase implements business logic for check schedules
|
||||
type CheckScheduleUsecase struct {
|
||||
storage CheckResultStorage
|
||||
options *happydns.Options
|
||||
domainLister DomainLister
|
||||
checkerUsecase happydns.CheckerUsecase
|
||||
}
|
||||
|
||||
// NewCheckScheduleUsecase creates a new check schedule usecase
|
||||
func NewCheckScheduleUsecase(storage CheckResultStorage, options *happydns.Options, domainLister DomainLister, checkerUsecase happydns.CheckerUsecase) *CheckScheduleUsecase {
|
||||
return &CheckScheduleUsecase{
|
||||
storage: storage,
|
||||
options: options,
|
||||
domainLister: domainLister,
|
||||
checkerUsecase: checkerUsecase,
|
||||
}
|
||||
}
|
||||
|
||||
// ListUserSchedules retrieves all schedules for a specific user
|
||||
func (u *CheckScheduleUsecase) ListUserSchedules(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
|
||||
return u.storage.ListCheckerSchedulesByUser(userId)
|
||||
}
|
||||
|
||||
// ListSchedulesByTarget retrieves all schedules for a specific target
|
||||
func (u *CheckScheduleUsecase) ListSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
|
||||
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
|
||||
}
|
||||
|
||||
// GetSchedule retrieves a specific schedule by ID
|
||||
func (u *CheckScheduleUsecase) GetSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error) {
|
||||
return u.storage.GetCheckerSchedule(scheduleId)
|
||||
}
|
||||
|
||||
// CreateSchedule creates a new check schedule with validation
|
||||
func (u *CheckScheduleUsecase) CreateSchedule(schedule *happydns.CheckerSchedule) error {
|
||||
// Set default interval if not specified
|
||||
if schedule.Interval == 0 {
|
||||
schedule.Interval = u.getDefaultInterval(schedule.TargetType)
|
||||
}
|
||||
|
||||
// Validate interval
|
||||
if schedule.Interval < MinimumCheckInterval {
|
||||
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
|
||||
}
|
||||
|
||||
// Calculate next run time: pick a random offset within the interval
|
||||
// to spread load evenly across all schedules
|
||||
// TODO: Use a smarter load balance function in the future
|
||||
if schedule.NextRun.IsZero() {
|
||||
offset := time.Duration(rand.Int63n(int64(schedule.Interval)))
|
||||
schedule.NextRun = time.Now().Add(offset)
|
||||
}
|
||||
|
||||
return u.storage.CreateCheckerSchedule(schedule)
|
||||
}
|
||||
|
||||
// UpdateSchedule updates an existing schedule
|
||||
func (u *CheckScheduleUsecase) UpdateSchedule(schedule *happydns.CheckerSchedule) error {
|
||||
// Validate interval
|
||||
if schedule.Interval < MinimumCheckInterval {
|
||||
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
|
||||
}
|
||||
|
||||
// Get existing schedule to preserve certain fields
|
||||
existing, err := u.storage.GetCheckerSchedule(schedule.Id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Preserve LastRun if not explicitly changed
|
||||
if schedule.LastRun == nil {
|
||||
schedule.LastRun = existing.LastRun
|
||||
}
|
||||
|
||||
// Recalculate next run time if interval changed
|
||||
if schedule.Interval != existing.Interval {
|
||||
if schedule.LastRun != nil {
|
||||
schedule.NextRun = schedule.LastRun.Add(schedule.Interval)
|
||||
} else {
|
||||
schedule.NextRun = time.Now().Add(schedule.Interval)
|
||||
}
|
||||
}
|
||||
|
||||
return u.storage.UpdateCheckerSchedule(schedule)
|
||||
}
|
||||
|
||||
// DeleteSchedule removes a schedule
|
||||
func (u *CheckScheduleUsecase) DeleteSchedule(scheduleId happydns.Identifier) error {
|
||||
return u.storage.DeleteCheckerSchedule(scheduleId)
|
||||
}
|
||||
|
||||
// EnableSchedule enables a schedule
|
||||
func (u *CheckScheduleUsecase) EnableSchedule(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedule.Enabled = true
|
||||
|
||||
// Reset next run time if it's in the past
|
||||
if schedule.NextRun.Before(time.Now()) {
|
||||
schedule.NextRun = time.Now().Add(schedule.Interval)
|
||||
}
|
||||
|
||||
return u.storage.UpdateCheckerSchedule(schedule)
|
||||
}
|
||||
|
||||
// DisableSchedule disables a schedule
|
||||
func (u *CheckScheduleUsecase) DisableSchedule(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
schedule.Enabled = false
|
||||
return u.storage.UpdateCheckerSchedule(schedule)
|
||||
}
|
||||
|
||||
// UpdateScheduleAfterRun updates a schedule after it has been executed
|
||||
func (u *CheckScheduleUsecase) UpdateScheduleAfterRun(scheduleId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
schedule.LastRun = &now
|
||||
schedule.NextRun = now.Add(schedule.Interval)
|
||||
|
||||
return u.storage.UpdateCheckerSchedule(schedule)
|
||||
}
|
||||
|
||||
// ListDueSchedules retrieves all enabled schedules that are due to run
|
||||
func (u *CheckScheduleUsecase) ListDueSchedules() ([]*happydns.CheckerSchedule, error) {
|
||||
schedules, err := u.storage.ListEnabledCheckerSchedules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var dueSchedules []*happydns.CheckerSchedule
|
||||
|
||||
for _, schedule := range schedules {
|
||||
if schedule.NextRun.Before(now) {
|
||||
dueSchedules = append(dueSchedules, schedule)
|
||||
}
|
||||
}
|
||||
|
||||
return dueSchedules, nil
|
||||
}
|
||||
|
||||
// ListUpcomingSchedules retrieves the next `limit` enabled schedules sorted by NextRun ascending
|
||||
func (u *CheckScheduleUsecase) ListUpcomingSchedules(limit int) ([]*happydns.CheckerSchedule, error) {
|
||||
schedules, err := u.storage.ListEnabledCheckerSchedules()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sort.Slice(schedules, func(i, j int) bool {
|
||||
return schedules[i].NextRun.Before(schedules[j].NextRun)
|
||||
})
|
||||
|
||||
if limit > 0 && len(schedules) > limit {
|
||||
schedules = schedules[:limit]
|
||||
}
|
||||
|
||||
return schedules, nil
|
||||
}
|
||||
|
||||
// getDefaultInterval returns the default check interval based on target type
|
||||
func (u *CheckScheduleUsecase) getDefaultInterval(targetType happydns.CheckScopeType) time.Duration {
|
||||
switch targetType {
|
||||
case happydns.CheckScopeUser:
|
||||
return DefaultUserCheckInterval
|
||||
case happydns.CheckScopeDomain:
|
||||
return DefaultDomainCheckInterval
|
||||
case happydns.CheckScopeService:
|
||||
return DefaultServiceCheckInterval
|
||||
default:
|
||||
return DefaultDomainCheckInterval
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateScheduleOwnership checks if a user owns a schedule
|
||||
func (u *CheckScheduleUsecase) ValidateScheduleOwnership(scheduleId happydns.Identifier, userId happydns.Identifier) error {
|
||||
schedule, err := u.storage.GetCheckerSchedule(scheduleId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !schedule.OwnerId.Equals(userId) {
|
||||
return fmt.Errorf("user does not own this schedule")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateDefaultSchedulesForTarget creates default schedules for a new target
|
||||
func (u *CheckScheduleUsecase) CreateDefaultSchedulesForTarget(
|
||||
checkerName string,
|
||||
targetType happydns.CheckScopeType,
|
||||
targetId happydns.Identifier,
|
||||
ownerId happydns.Identifier,
|
||||
enabled bool,
|
||||
) error {
|
||||
schedule := &happydns.CheckerSchedule{
|
||||
CheckerName: checkerName,
|
||||
OwnerId: ownerId,
|
||||
TargetType: targetType,
|
||||
TargetId: targetId,
|
||||
Interval: u.getDefaultInterval(targetType),
|
||||
Enabled: enabled,
|
||||
NextRun: time.Now().Add(u.getDefaultInterval(targetType)),
|
||||
Options: make(happydns.CheckerOptions),
|
||||
}
|
||||
|
||||
return u.CreateSchedule(schedule)
|
||||
}
|
||||
|
||||
// rescheduleChecks reschedules each given schedule to a random time in [now, now+maxOffsetFn(schedule)].
|
||||
func (u *CheckScheduleUsecase) rescheduleChecks(schedules []*happydns.CheckerSchedule, maxOffsetFn func(*happydns.CheckerSchedule) time.Duration) (int, error) {
|
||||
count := 0
|
||||
now := time.Now()
|
||||
for _, schedule := range schedules {
|
||||
maxOffset := maxOffsetFn(schedule)
|
||||
if maxOffset <= 0 {
|
||||
maxOffset = time.Second
|
||||
}
|
||||
schedule.NextRun = now.Add(time.Duration(rand.Int63n(int64(maxOffset))))
|
||||
if err := u.storage.UpdateCheckerSchedule(schedule); err != nil {
|
||||
return count, err
|
||||
}
|
||||
count++
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// RescheduleUpcomingChecks randomizes the next run time of all enabled schedules
|
||||
// within their respective intervals to spread load evenly. Useful after a restart.
|
||||
func (u *CheckScheduleUsecase) RescheduleUpcomingChecks() (int, error) {
|
||||
schedules, err := u.storage.ListEnabledCheckerSchedules()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.rescheduleChecks(schedules, func(s *happydns.CheckerSchedule) time.Duration {
|
||||
return s.Interval
|
||||
})
|
||||
}
|
||||
|
||||
// RescheduleOverdueChecks reschedules checks whose NextRun is in the past,
|
||||
// spreading them over a short window to avoid scheduler famine (e.g. after
|
||||
// a long machine suspend or server downtime).
|
||||
// If there are fewer than 10 overdue checks, they are left as-is so that the
|
||||
// caller's immediate checkSchedules pass enqueues them directly.
|
||||
func (u *CheckScheduleUsecase) RescheduleOverdueChecks() (int, error) {
|
||||
schedules, err := u.storage.ListEnabledCheckerSchedules()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var overdue []*happydns.CheckerSchedule
|
||||
for _, s := range schedules {
|
||||
if s.NextRun.Before(now) {
|
||||
overdue = append(overdue, s)
|
||||
}
|
||||
}
|
||||
|
||||
if len(overdue) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Small backlog: let the caller enqueue them directly on the next
|
||||
// checkSchedules pass rather than deferring them into the future.
|
||||
if len(overdue) < 10 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Spread overdue checks over a small window proportional to their count,
|
||||
// capped at MinimumCheckInterval, to prevent all of them from running at once.
|
||||
spreadWindow := time.Duration(len(overdue)) * 5 * time.Second
|
||||
if spreadWindow > MinimumCheckInterval {
|
||||
spreadWindow = MinimumCheckInterval
|
||||
}
|
||||
|
||||
return u.rescheduleChecks(overdue, func(s *happydns.CheckerSchedule) time.Duration {
|
||||
return spreadWindow
|
||||
})
|
||||
}
|
||||
|
||||
// DeleteSchedulesForTarget removes all schedules for a target
|
||||
func (u *CheckScheduleUsecase) DeleteSchedulesForTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) error {
|
||||
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, schedule := range schedules {
|
||||
if err := u.storage.DeleteCheckerSchedule(schedule.Id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain)
|
||||
// pairs that don't yet have an explicit schedule record. This implements the opt-out
|
||||
// model: checks run automatically unless a schedule with Enabled=false has been saved.
|
||||
// Non-fatal per-domain errors are collected and returned together.
|
||||
func (u *CheckScheduleUsecase) DiscoverAndEnsureSchedules() error {
|
||||
if u.domainLister == nil || u.checkerUsecase == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
plugins, err := u.checkerUsecase.ListCheckers()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing check plugins for discovery: %w", err)
|
||||
}
|
||||
|
||||
iter, err := u.domainLister.ListAllDomains()
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing domains for schedule discovery: %w", err)
|
||||
}
|
||||
defer iter.Close()
|
||||
|
||||
var errs []error
|
||||
for iter.Next() {
|
||||
domain := iter.Item()
|
||||
if domain == nil {
|
||||
continue
|
||||
}
|
||||
for checkerName, p := range *plugins {
|
||||
if !p.Availability().ApplyToDomain {
|
||||
continue
|
||||
}
|
||||
|
||||
schedules, err := u.ListSchedulesByTarget(happydns.CheckScopeDomain, domain.Id)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("listing schedules for domain %s: %w", domain.Id, err))
|
||||
continue
|
||||
}
|
||||
|
||||
hasSchedule := false
|
||||
for _, sched := range schedules {
|
||||
if sched.CheckerName == checkerName {
|
||||
hasSchedule = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasSchedule {
|
||||
if err := u.CreateSchedule(&happydns.CheckerSchedule{
|
||||
CheckerName: checkerName,
|
||||
OwnerId: domain.Owner,
|
||||
TargetType: happydns.CheckScopeDomain,
|
||||
TargetId: domain.Id,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
errs = append(errs, fmt.Errorf("auto-creating schedule for domain %s / plugin %s: %w",
|
||||
domain.Id, checkerName, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
103
internal/usecase/checkresult/storage.go
Normal file
103
internal/usecase/checkresult/storage.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package checkresult
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
// CheckResultStorage defines the storage interface for check results and related data
|
||||
type CheckResultStorage interface {
|
||||
// Check Results
|
||||
// ListCheckResults retrieves check results for a specific plugin+target combination
|
||||
ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
|
||||
|
||||
// ListCheckResultsByPlugin retrieves all check results for a plugin across all targets for a user
|
||||
ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) ([]*happydns.CheckResult, error)
|
||||
|
||||
// ListCheckResultsByUser retrieves all check results for a user
|
||||
ListCheckResultsByUser(userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error)
|
||||
|
||||
// GetCheckResult retrieves a specific check result by its ID
|
||||
GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (*happydns.CheckResult, error)
|
||||
|
||||
// CreateCheckResult stores a new check result
|
||||
CreateCheckResult(result *happydns.CheckResult) error
|
||||
|
||||
// DeleteCheckResult removes a specific check result
|
||||
DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) error
|
||||
|
||||
// DeleteOldCheckResults removes old check results keeping only the most recent N results
|
||||
DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) error
|
||||
|
||||
// Checker Schedules
|
||||
// ListEnabledCheckerSchedules retrieves all enabled schedules (for scheduler)
|
||||
ListEnabledCheckerSchedules() ([]*happydns.CheckerSchedule, error)
|
||||
|
||||
// ListCheckerSchedulesByUser retrieves all schedules for a specific user
|
||||
ListCheckerSchedulesByUser(userId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
|
||||
|
||||
// ListCheckerSchedulesByTarget retrieves all schedules for a specific target
|
||||
ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier) ([]*happydns.CheckerSchedule, error)
|
||||
|
||||
// GetCheckerSchedule retrieves a specific schedule by ID
|
||||
GetCheckerSchedule(scheduleId happydns.Identifier) (*happydns.CheckerSchedule, error)
|
||||
|
||||
// CreateCheckerSchedule creates a new check schedule
|
||||
CreateCheckerSchedule(schedule *happydns.CheckerSchedule) error
|
||||
|
||||
// UpdateCheckerSchedule updates an existing schedule
|
||||
UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) error
|
||||
|
||||
// DeleteCheckerSchedule removes a schedule
|
||||
DeleteCheckerSchedule(scheduleId happydns.Identifier) error
|
||||
|
||||
// Check Executions
|
||||
// ListActiveCheckExecutions retrieves all executions that are pending or running
|
||||
ListActiveCheckExecutions() ([]*happydns.CheckExecution, error)
|
||||
|
||||
// GetCheckExecution retrieves a specific execution by ID
|
||||
GetCheckExecution(executionId happydns.Identifier) (*happydns.CheckExecution, error)
|
||||
|
||||
// CreateCheckExecution creates a new check execution record
|
||||
CreateCheckExecution(execution *happydns.CheckExecution) error
|
||||
|
||||
// UpdateCheckExecution updates an existing execution record
|
||||
UpdateCheckExecution(execution *happydns.CheckExecution) error
|
||||
|
||||
// DeleteCheckExecution removes an execution record
|
||||
DeleteCheckExecution(executionId happydns.Identifier) error
|
||||
|
||||
// Scheduler State
|
||||
// CheckSchedulerRun marks that the scheduler has run at current time
|
||||
CheckSchedulerRun() error
|
||||
|
||||
// LastCheckSchedulerRun retrieves the last time the scheduler ran
|
||||
LastCheckSchedulerRun() (*time.Time, error)
|
||||
}
|
||||
|
||||
// DomainLister provides access to domain listings for schedule discovery.
|
||||
type DomainLister interface {
|
||||
ListAllDomains() (happydns.Iterator[happydns.Domain], error)
|
||||
}
|
||||
195
model/check_result.go
Normal file
195
model/check_result.go
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// CheckScopeType represents the scope level at which a check is performed
|
||||
type CheckScopeType int
|
||||
|
||||
const (
|
||||
CheckScopeInstance CheckScopeType = iota
|
||||
CheckScopeUser
|
||||
CheckScopeDomain
|
||||
CheckScopeService
|
||||
CheckScopeOnDemand
|
||||
)
|
||||
|
||||
// String returns a string representation of the check scope type
|
||||
func (t CheckScopeType) String() string {
|
||||
switch t {
|
||||
case CheckScopeInstance:
|
||||
return "instance"
|
||||
case CheckScopeUser:
|
||||
return "user"
|
||||
case CheckScopeDomain:
|
||||
return "domain"
|
||||
case CheckScopeService:
|
||||
return "service"
|
||||
case CheckScopeOnDemand:
|
||||
return "ondemand"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// CheckExecutionStatus represents the current state of a check execution
|
||||
type CheckExecutionStatus int
|
||||
|
||||
const (
|
||||
CheckExecutionPending CheckExecutionStatus = iota
|
||||
CheckExecutionRunning
|
||||
CheckExecutionCompleted
|
||||
CheckExecutionFailed
|
||||
)
|
||||
|
||||
// String returns a string representation of the check execution status
|
||||
func (t CheckExecutionStatus) String() string {
|
||||
switch t {
|
||||
case CheckExecutionPending:
|
||||
return "pending"
|
||||
case CheckExecutionRunning:
|
||||
return "running"
|
||||
case CheckExecutionCompleted:
|
||||
return "completed"
|
||||
case CheckExecutionFailed:
|
||||
return "failed"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// CheckResult stores the result of a check execution
|
||||
type CheckResult struct {
|
||||
// Id is the unique identifier for this check result
|
||||
Id Identifier `json:"id" swaggertype:"string"`
|
||||
|
||||
// CheckerName identifies which checker was executed
|
||||
CheckerName string `json:"checker_name"`
|
||||
|
||||
// CheckType indicates the scope level of the check
|
||||
CheckType CheckScopeType `json:"check_type"`
|
||||
|
||||
// TargetId is the identifier of the target (User/Domain/Service)
|
||||
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||
|
||||
// OwnerId is the owner of the check
|
||||
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||
|
||||
// ExecutedAt is when the check was executed
|
||||
ExecutedAt time.Time `json:"executed_at"`
|
||||
|
||||
// ScheduledCheck indicates if this was a scheduled (true) or on-demand (false) check
|
||||
ScheduledCheck bool `json:"scheduled_check"`
|
||||
|
||||
// Options contains the merged checker configuration used for this check
|
||||
Options CheckerOptions `json:"options,omitempty"`
|
||||
|
||||
// Status is the overall check result status
|
||||
Status CheckResultStatus `json:"status"`
|
||||
|
||||
// StatusLine is a summary message of the check result
|
||||
StatusLine string `json:"status_line"`
|
||||
|
||||
// Report contains the full check report (checker-specific structure)
|
||||
Report any `json:"report,omitempty"`
|
||||
|
||||
// Duration is how long the check took to execute
|
||||
Duration time.Duration `json:"duration" swaggertype:"integer"`
|
||||
|
||||
// Error contains any error message if the execution failed
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// CheckExecution tracks an in-progress or completed check execution
|
||||
type CheckExecution struct {
|
||||
// Id is the unique identifier for this execution
|
||||
Id Identifier `json:"id" swaggertype:"string"`
|
||||
|
||||
// ScheduleId is the schedule that triggered this execution (nil for on-demand)
|
||||
ScheduleId *Identifier `json:"schedule_id,omitempty" swaggertype:"string"`
|
||||
|
||||
// CheckerName identifies which checker is being executed
|
||||
CheckerName string `json:"checker_name"`
|
||||
|
||||
// OwnerId is the owner of the check
|
||||
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||
|
||||
// TargetType indicates the scope level of the check
|
||||
TargetType CheckScopeType `json:"target_type"`
|
||||
|
||||
// TargetId is the identifier of the target being checked
|
||||
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||
|
||||
// Status is the current execution status
|
||||
Status CheckExecutionStatus `json:"status"`
|
||||
|
||||
// StartedAt is when the execution began
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
|
||||
// CompletedAt is when the execution finished (nil if still running)
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
|
||||
// ResultId links to the CheckResult (nil if execution not completed)
|
||||
ResultId *Identifier `json:"result_id,omitempty" swaggertype:"string"`
|
||||
|
||||
// Options contains the checker configuration for this execution
|
||||
Options CheckerOptions `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// CheckResultUsecase defines business logic for check results
|
||||
type CheckResultUsecase interface {
|
||||
// ListCheckResultsByTarget retrieves check results for a specific target
|
||||
ListCheckResultsByTarget(checkerName string, targetType CheckScopeType, targetId Identifier, limit int) ([]*CheckResult, error)
|
||||
|
||||
// ListAllCheckResultsByTarget retrieves all check results for a target across all checkers
|
||||
ListAllCheckResultsByTarget(targetType CheckScopeType, targetId Identifier, userId Identifier, limit int) ([]*CheckResult, error)
|
||||
|
||||
// GetCheckResult retrieves a specific check result
|
||||
GetCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) (*CheckResult, error)
|
||||
|
||||
// CreateCheckResult stores a new check result and enforces retention policy
|
||||
CreateCheckResult(result *CheckResult) error
|
||||
|
||||
// DeleteCheckResult removes a specific check result
|
||||
DeleteCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier) error
|
||||
|
||||
// DeleteAllCheckResults removes all results for a specific checker+target combination
|
||||
DeleteAllCheckResults(checkName string, targetType CheckScopeType, targetId Identifier) error
|
||||
|
||||
// GetCheckExecution retrieves the status of a check execution
|
||||
GetCheckExecution(executionId Identifier) (*CheckExecution, error)
|
||||
|
||||
// CreateCheckExecution creates a new check execution record
|
||||
CreateCheckExecution(execution *CheckExecution) error
|
||||
|
||||
// UpdateCheckExecution updates an existing check execution
|
||||
UpdateCheckExecution(execution *CheckExecution) error
|
||||
|
||||
// CompleteCheckExecution marks an execution as completed with a result
|
||||
CompleteCheckExecution(executionId Identifier, resultId Identifier) error
|
||||
|
||||
// FailCheckExecution marks an execution as failed
|
||||
FailCheckExecution(executionId Identifier, errorMsg string) error
|
||||
}
|
||||
140
model/check_scheduler.go
Normal file
140
model/check_scheduler.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// SchedulerUsecase defines the interface for triggering on-demand checks
|
||||
type SchedulerUsecase interface {
|
||||
Run()
|
||||
Close()
|
||||
TriggerOnDemandCheck(checkerName string, targetType CheckScopeType, targetID Identifier, userID Identifier, options CheckerOptions) (Identifier, error)
|
||||
GetSchedulerStatus() SchedulerStatus
|
||||
SetEnabled(enabled bool) error
|
||||
RescheduleUpcomingChecks() (int, error)
|
||||
}
|
||||
|
||||
// CheckerSchedule defines a recurring check schedule
|
||||
type CheckerSchedule struct {
|
||||
// Id is the unique identifier for this schedule
|
||||
Id Identifier `json:"id" swaggertype:"string"`
|
||||
|
||||
// CheckerName identifies which checker to execute
|
||||
CheckerName string `json:"checker_name"`
|
||||
|
||||
// OwnerId is the owner of the schedule
|
||||
OwnerId Identifier `json:"owner_id" swaggertype:"string"`
|
||||
|
||||
// TargetType indicates what type of target to check
|
||||
TargetType CheckScopeType `json:"target_type"`
|
||||
|
||||
// TargetId is the identifier of the target to check
|
||||
TargetId Identifier `json:"target_id" swaggertype:"string"`
|
||||
|
||||
// Interval is how often to run the check
|
||||
Interval time.Duration `json:"interval" swaggertype:"integer"`
|
||||
|
||||
// Enabled indicates if the schedule is active
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
// LastRun is when the check was last executed (nil if never run)
|
||||
LastRun *time.Time `json:"last_run,omitempty"`
|
||||
|
||||
// NextRun is when the check should next be executed
|
||||
NextRun time.Time `json:"next_run"`
|
||||
|
||||
// Options contains checker-specific configuration
|
||||
Options CheckerOptions `json:"options,omitempty"`
|
||||
}
|
||||
|
||||
// SchedulerStatus holds a snapshot of the scheduler state for monitoring
|
||||
type SchedulerStatus struct {
|
||||
// ConfigEnabled indicates if the scheduler is enabled in the configuration file
|
||||
ConfigEnabled bool `json:"config_enabled"`
|
||||
|
||||
// RuntimeEnabled indicates if the scheduler is currently enabled at runtime
|
||||
RuntimeEnabled bool `json:"runtime_enabled"`
|
||||
|
||||
// Running indicates if the scheduler goroutine is currently running
|
||||
Running bool `json:"running"`
|
||||
|
||||
// WorkerCount is the number of worker goroutines
|
||||
WorkerCount int `json:"worker_count"`
|
||||
|
||||
// QueueSize is the number of items currently waiting in the execution queue
|
||||
QueueSize int `json:"queue_size"`
|
||||
|
||||
// ActiveCount is the number of checks currently being executed
|
||||
ActiveCount int `json:"active_count"`
|
||||
|
||||
// NextSchedules contains the upcoming scheduled checks sorted by next run time
|
||||
NextSchedules []*CheckerSchedule `json:"next_schedules"`
|
||||
}
|
||||
|
||||
// CheckerScheduleUsecase defines business logic for check schedules
|
||||
type CheckerScheduleUsecase interface {
|
||||
// ListUserSchedules retrieves all schedules for a specific user
|
||||
ListUserSchedules(userId Identifier) ([]*CheckerSchedule, error)
|
||||
|
||||
// ListSchedulesByTarget retrieves all schedules for a specific target
|
||||
ListSchedulesByTarget(targetType CheckScopeType, targetId Identifier) ([]*CheckerSchedule, error)
|
||||
|
||||
// GetSchedule retrieves a specific schedule by ID
|
||||
GetSchedule(scheduleId Identifier) (*CheckerSchedule, error)
|
||||
|
||||
// CreateSchedule creates a new check schedule with validation
|
||||
CreateSchedule(schedule *CheckerSchedule) error
|
||||
|
||||
// UpdateSchedule updates an existing schedule
|
||||
UpdateSchedule(schedule *CheckerSchedule) error
|
||||
|
||||
// DeleteSchedule removes a schedule
|
||||
DeleteSchedule(scheduleId Identifier) error
|
||||
|
||||
// EnableSchedule enables a schedule
|
||||
EnableSchedule(scheduleId Identifier) error
|
||||
|
||||
// DisableSchedule disables a schedule
|
||||
DisableSchedule(scheduleId Identifier) error
|
||||
|
||||
// UpdateScheduleAfterRun updates a schedule after it has been executed
|
||||
UpdateScheduleAfterRun(scheduleId Identifier) error
|
||||
|
||||
// ListDueSchedules retrieves all enabled schedules that are due to run
|
||||
ListDueSchedules() ([]*CheckerSchedule, error)
|
||||
|
||||
// ValidateScheduleOwnership checks if a user owns a schedule
|
||||
ValidateScheduleOwnership(scheduleId Identifier, ownerId Identifier) error
|
||||
|
||||
// DeleteSchedulesForTarget removes all schedules for a target
|
||||
DeleteSchedulesForTarget(targetType CheckScopeType, targetId Identifier) error
|
||||
|
||||
// RescheduleUpcomingChecks randomizes next run times for all enabled schedules
|
||||
// within their respective intervals to spread load evenly.
|
||||
RescheduleUpcomingChecks() (int, error)
|
||||
|
||||
// RescheduleOverdueTests reschedules overdue tests to run soon, spread over a
|
||||
// short window to avoid scheduler famine after a suspend or server restart.
|
||||
RescheduleOverdueChecks() (int, error)
|
||||
}
|
||||
113
model/checker.go
Normal file
113
model/checker.go
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2020-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package happydns
|
||||
|
||||
// Auto-fill variable identifiers for checker option fields.
|
||||
const (
|
||||
// AutoFillDomainName fills the option with the fully qualified domain name
|
||||
// of the domain being tested (e.g. "example.com.").
|
||||
AutoFillDomainName = "domain_name"
|
||||
|
||||
// AutoFillSubdomain fills the option with the subdomain relative to the zone
|
||||
// (e.g. "www" for "www.example.com." in zone "example.com."). Only
|
||||
// applicable for service-scoped tests.
|
||||
AutoFillSubdomain = "subdomain"
|
||||
|
||||
// AutoFillServiceType fills the option with the service type identifier
|
||||
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
|
||||
AutoFillServiceType = "service_type"
|
||||
)
|
||||
|
||||
const (
|
||||
CheckResultStatusKO CheckResultStatus = iota
|
||||
CheckResultStatusWarn
|
||||
CheckResultStatusInfo
|
||||
CheckResultStatusOK
|
||||
)
|
||||
|
||||
type CheckResultStatus int
|
||||
|
||||
type CheckerOptions map[string]any
|
||||
|
||||
type Checker interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Availability() CheckerAvailability
|
||||
Options() CheckerOptionsDocumentation
|
||||
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
|
||||
}
|
||||
|
||||
type CheckerResponse struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Availability CheckerAvailability `json:"availability"`
|
||||
Options CheckerOptionsDocumentation `json:"options"`
|
||||
}
|
||||
|
||||
type SetCheckerOptionsRequest struct {
|
||||
Options CheckerOptions `json:"options"`
|
||||
}
|
||||
|
||||
type CheckerOptionsPositional struct {
|
||||
CheckName string
|
||||
UserId *Identifier
|
||||
DomainId *Identifier
|
||||
ServiceId *Identifier
|
||||
|
||||
Options CheckerOptions
|
||||
}
|
||||
|
||||
type CheckerAvailability struct {
|
||||
ApplyToDomain bool `json:"applyToDomain,omitempty"`
|
||||
ApplyToService bool `json:"applyToService,omitempty"`
|
||||
LimitToProviders []string `json:"limitToProviders,omitempty"`
|
||||
LimitToServices []string `json:"limitToServices,omitempty"`
|
||||
}
|
||||
|
||||
type CheckerOptionsDocumentation struct {
|
||||
RunOpts []CheckerOptionDocumentation `json:"runOpts,omitempty"`
|
||||
ServiceOpts []CheckerOptionDocumentation `json:"serviceOpts,omitempty"`
|
||||
DomainOpts []CheckerOptionDocumentation `json:"domainOpts,omitempty"`
|
||||
UserOpts []CheckerOptionDocumentation `json:"userOpts,omitempty"`
|
||||
AdminOpts []CheckerOptionDocumentation `json:"adminOpts,omitempty"`
|
||||
}
|
||||
|
||||
type CheckerOptionDocumentation Field
|
||||
|
||||
// CheckerStatus represents the current status of a checker for a specific target,
|
||||
// including whether it is enabled, its schedule, and the most recent result.
|
||||
type CheckerStatus struct {
|
||||
CheckerName string `json:"checker_name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Schedule *CheckerSchedule `json:"schedule,omitempty"`
|
||||
LastResult *CheckResult `json:"last_result,omitempty"`
|
||||
}
|
||||
|
||||
type CheckerUsecase interface {
|
||||
BuildMergedCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) (CheckerOptions, error)
|
||||
GetStoredCheckerOptionsNoDefault(string, *Identifier, *Identifier, *Identifier) (CheckerOptions, error)
|
||||
GetChecker(string) (Checker, error)
|
||||
GetCheckerOptions(string, *Identifier, *Identifier, *Identifier) (*CheckerOptions, error)
|
||||
ListCheckers() (*map[string]Checker, error)
|
||||
OverwriteSomeCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
|
||||
SetCheckerOptions(string, *Identifier, *Identifier, *Identifier, CheckerOptions) error
|
||||
}
|
||||
|
|
@ -99,6 +99,20 @@ type Options struct {
|
|||
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
|
||||
// 0 means always require captcha at login (when provider is configured).
|
||||
CaptchaLoginThreshold int
|
||||
|
||||
PluginsDirectories []string
|
||||
|
||||
// MaxResultsPerCheck is the maximum number of test results to keep per plugin+target combination
|
||||
MaxResultsPerCheck int
|
||||
|
||||
// ResultRetentionDays is how long to keep test results before cleanup
|
||||
ResultRetentionDays int
|
||||
|
||||
// TestWorkers is the number of concurrent test executions allowed
|
||||
TestWorkers int
|
||||
|
||||
// DisableScheduler disables the background test scheduler
|
||||
DisableScheduler bool
|
||||
}
|
||||
|
||||
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.
|
||||
|
|
|
|||
|
|
@ -27,15 +27,18 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
ErrAuthUserNotFound = errors.New("user not found")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrAuthUserNotFound = errors.New("user not found")
|
||||
ErrCheckExecutionNotFound = errors.New("check execution not found")
|
||||
ErrCheckResultNotFound = errors.New("check result not found")
|
||||
ErrCheckScheduleNotFound = errors.New("check schedule not found")
|
||||
ErrDomainNotFound = errors.New("domain not found")
|
||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||
ErrProviderNotFound = errors.New("provider not found")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrUserAlreadyExist = errors.New("user already exists")
|
||||
ErrZoneNotFound = errors.New("zone not found")
|
||||
ErrNotFound = errors.New("not found")
|
||||
)
|
||||
|
||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||
|
|
|
|||
|
|
@ -104,6 +104,11 @@ type Field struct {
|
|||
|
||||
// Description stores an helpfull sentence describing the field.
|
||||
Description string `json:"description,omitempty"`
|
||||
|
||||
// AutoFill indicates the field value is automatically resolved by the
|
||||
// software based on test context. When set, the value should not be
|
||||
// entered by the user.
|
||||
AutoFill string `json:"autoFill,omitempty"`
|
||||
}
|
||||
|
||||
type FormState struct {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
336
plugins/README.md
Normal file
336
plugins/README.md
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
# Writing a happyDomain Plugin
|
||||
|
||||
happyDomain supports external **check plugins** — shared libraries (`.so` files) that run domain health checks and diagnostics. Plugins are loaded at runtime and integrate seamlessly into happyDomain's domain and service testing UI.
|
||||
|
||||
## Overview
|
||||
|
||||
A plugin is a Go shared library (`-buildmode=plugin`) that exports a single entry point: `NewCheckPlugin`. At startup, happyDomain scans its configured plugin directories, loads each `.so` file it finds, calls `NewCheckPlugin`, and registers the returned checker under the declared name.
|
||||
|
||||
A plugin implements the `Checker` interface from `git.happydns.org/happyDomain/model`:
|
||||
|
||||
```go
|
||||
type Checker interface {
|
||||
ID() string
|
||||
Name() string
|
||||
Availability() CheckerAvailability
|
||||
Options() CheckerOptionsDocumentation
|
||||
RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
A minimal plugin lives in its own directory with `package main`:
|
||||
|
||||
```
|
||||
myplugin/
|
||||
├── go.mod
|
||||
├── Makefile
|
||||
└── plugin.go (or split across multiple .go files)
|
||||
```
|
||||
|
||||
### go.mod
|
||||
|
||||
Your plugin must declare the same module path as its source tree and depend on the happyDomain model:
|
||||
|
||||
```
|
||||
module git.happydns.org/happyDomain/plugins/myplugin
|
||||
|
||||
go 1.25
|
||||
|
||||
require git.happydns.org/happyDomain v0.0.0
|
||||
replace git.happydns.org/happyDomain => ../../
|
||||
```
|
||||
|
||||
The `replace` directive points to your local happyDomain checkout, ensuring the plugin is compiled against the exact same types.
|
||||
|
||||
> **Important:** A Go plugin and the host program must be built with the same Go toolchain version and the same versions of all shared dependencies. Any mismatch will cause a runtime load error.
|
||||
|
||||
---
|
||||
|
||||
## The Entry Point
|
||||
|
||||
Every plugin must export a `NewCheckPlugin` function with this exact signature:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "git.happydns.org/happyDomain/model"
|
||||
|
||||
func NewCheckPlugin() (string, happydns.Checker, error) {
|
||||
return "myplugin", &MyPlugin{}, nil
|
||||
}
|
||||
```
|
||||
|
||||
The first return value is the unique registration name for the checker. You can use the constructor to perform one-time initialisation (read config files, create HTTP clients, etc.) and return an error if the plugin cannot function.
|
||||
|
||||
---
|
||||
|
||||
## Implementing the Interface
|
||||
|
||||
### `ID() string`
|
||||
|
||||
Returns the unique string identifier for the checker. This name is used internally to look up the checker and to store its configuration. Use a short, lowercase, collision-resistant name:
|
||||
|
||||
```go
|
||||
func (p *MyPlugin) ID() string {
|
||||
return "myplugin"
|
||||
}
|
||||
```
|
||||
|
||||
The value returned here should match the name returned by `NewCheckPlugin`. If two checkers claim the same ID, the second one is silently ignored and a conflict is logged.
|
||||
|
||||
---
|
||||
|
||||
### `Name() string`
|
||||
|
||||
Returns a human-readable display name for the checker:
|
||||
|
||||
```go
|
||||
func (p *MyPlugin) Name() string {
|
||||
return "My Plugin"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `Availability() CheckerAvailability`
|
||||
|
||||
Declares where the checker applies:
|
||||
|
||||
```go
|
||||
func (p *MyPlugin) Availability() happydns.CheckerAvailability {
|
||||
return happydns.CheckerAvailability{
|
||||
ApplyToDomain: true,
|
||||
ApplyToService: false,
|
||||
LimitToProviders: []string{}, // empty = all providers
|
||||
LimitToServices: []string{"abstract.MatrixIM"},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`CheckerAvailability` fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `ApplyToDomain` | `bool` | Checker can be run against a whole domain |
|
||||
| `ApplyToService` | `bool` | Checker can be run against a specific service |
|
||||
| `LimitToProviders` | `[]string` | Restrict to certain DNS provider identifiers (empty = no restriction) |
|
||||
| `LimitToServices` | `[]string` | Restrict to certain service type identifiers, e.g. `"abstract.MatrixIM"` (empty = no restriction) |
|
||||
|
||||
---
|
||||
|
||||
### `Options() CheckerOptionsDocumentation`
|
||||
|
||||
Declares all configurable options, grouped by **who sets them** and **at which scope**:
|
||||
|
||||
```go
|
||||
func (p *MyPlugin) Options() happydns.CheckerOptionsDocumentation {
|
||||
return happydns.CheckerOptionsDocumentation{
|
||||
RunOpts: []happydns.CheckerOptionDocumentation{ /* per-run options */ },
|
||||
ServiceOpts: []happydns.CheckerOptionDocumentation{ /* per-service options */ },
|
||||
DomainOpts: []happydns.CheckerOptionDocumentation{ /* per-domain options */ },
|
||||
UserOpts: []happydns.CheckerOptionDocumentation{ /* per-user options */ },
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{ /* admin-only options */ },
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Option scopes
|
||||
|
||||
| Field | Who sets it | Typical use |
|
||||
|---|---|---|
|
||||
| `RunOpts` | The user at test time | Test-specific parameters (e.g. domain to test) |
|
||||
| `ServiceOpts` | The user, per service | Configuration scoped to a DNS service |
|
||||
| `DomainOpts` | The user, per domain | Configuration scoped to a whole domain |
|
||||
| `UserOpts` | The user, globally | Personal preferences (e.g. language) |
|
||||
| `AdminOpts` | The instance administrator | Backend URLs, API keys shared by all users |
|
||||
|
||||
Options from all scopes are **merged** before `RunCheck` is called, with more-specific scopes overriding less-specific ones.
|
||||
|
||||
#### CheckerOptionDocumentation fields
|
||||
|
||||
Each option is described by a `CheckerOptionDocumentation` (an alias for `Field`):
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `Id` | `string` | **Required.** Key used in `CheckerOptions` map |
|
||||
| `Type` | `string` | Input type: `"string"`, `"select"`, … |
|
||||
| `Label` | `string` | Human-readable label shown in the UI |
|
||||
| `Placeholder` | `string` | Input placeholder text |
|
||||
| `Default` | `any` | Default value pre-filled in the form |
|
||||
| `Choices` | `[]string` | Available choices for `"select"` type inputs |
|
||||
| `Required` | `bool` | Whether the field must be filled before running |
|
||||
| `Secret` | `bool` | Marks the field as sensitive (e.g. API key) |
|
||||
| `Hide` | `bool` | Hides the field from the user |
|
||||
| `Textarea` | `bool` | Displays a multiline text area |
|
||||
| `Description` | `string` | Help text shown below the field |
|
||||
| `AutoFill` | `string` | Automatically populate the field from context (see below) |
|
||||
|
||||
#### Auto-fill variables
|
||||
|
||||
When a field's `AutoFill` is set, happyDomain populates it from the test context — the user does not need to fill it in:
|
||||
|
||||
| Constant | Value | Filled with |
|
||||
|---|---|---|
|
||||
| `happydns.AutoFillDomainName` | `"domain_name"` | The FQDN of the domain under test (e.g. `"example.com."`) |
|
||||
| `happydns.AutoFillSubdomain` | `"subdomain"` | Subdomain relative to the zone (service-scoped tests only) |
|
||||
| `happydns.AutoFillServiceType` | `"service_type"` | Service type identifier (service-scoped tests only) |
|
||||
|
||||
```go
|
||||
{
|
||||
Id: "domainName",
|
||||
Type: "string",
|
||||
Label: "Domain name",
|
||||
AutoFill: happydns.AutoFillDomainName,
|
||||
Required: true,
|
||||
},
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `RunCheck(options CheckerOptions, meta map[string]string) (*CheckResult, error)`
|
||||
|
||||
This is where the actual check happens. `options` is the merged map of all scoped options (keyed by option `Id`). `meta` carries additional context provided by the scheduler (currently reserved for future use).
|
||||
|
||||
```go
|
||||
func (p *MyPlugin) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||
domain, ok := options["domainName"].(string)
|
||||
if !ok || domain == "" {
|
||||
return nil, fmt.Errorf("domainName is required")
|
||||
}
|
||||
|
||||
// ... perform the check ...
|
||||
|
||||
return &happydns.CheckResult{
|
||||
Status: happydns.CheckResultStatusOK,
|
||||
StatusLine: "All good",
|
||||
Report: myDetailedReport,
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
Return a non-nil `error` for hard failures (network errors, invalid options). Return a `CheckResult` with a `KO` status for expected failures (e.g. the DNS check failed).
|
||||
|
||||
#### CheckResult fields set by the plugin
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `Status` | `CheckResultStatus` | Overall result level |
|
||||
| `StatusLine` | `string` | Short human-readable summary |
|
||||
| `Report` | `any` | Arbitrary data (serialised to JSON and stored) |
|
||||
|
||||
The remaining fields (`Id`, `CheckerName`, `ExecutedAt`, etc.) are filled in by happyDomain automatically.
|
||||
|
||||
#### CheckResultStatus values (ordered worst → best)
|
||||
|
||||
| Constant | Meaning |
|
||||
|---|---|
|
||||
| `CheckResultStatusKO` | Check failed |
|
||||
| `CheckResultStatusWarn` | Check passed with warnings |
|
||||
| `CheckResultStatusInfo` | Informational result |
|
||||
| `CheckResultStatusOK` | Check fully passed |
|
||||
|
||||
---
|
||||
|
||||
## Full Example
|
||||
|
||||
The matrix federation checker plugin (`matrix/`) illustrates a real-world plugin:
|
||||
|
||||
**`main.go`** — exports the entry point:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import "git.happydns.org/happyDomain/model"
|
||||
|
||||
func NewCheckPlugin() (string, happydns.Checker, error) {
|
||||
return "matrixim", &MatrixTester{
|
||||
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
|
||||
}, nil
|
||||
}
|
||||
```
|
||||
|
||||
**`test.go`** — implements the interface on a struct:
|
||||
|
||||
```go
|
||||
func (p *MatrixTester) ID() string { return "matrixim" }
|
||||
|
||||
func (p *MatrixTester) Name() string { return "Matrix Federation Tester" }
|
||||
|
||||
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
|
||||
return happydns.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.MatrixIM"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation { /* ... */ }
|
||||
|
||||
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) { /* ... */ }
|
||||
```
|
||||
|
||||
The built-in Zonemaster checker (`checks/zonemaster.go`) shows a more complex flow: it starts an asynchronous test, polls for completion, and aggregates results across multiple severity levels. Although it is compiled in rather than loaded as a `.so`, it implements the same `Checker` interface and is a useful reference.
|
||||
|
||||
---
|
||||
|
||||
## Building
|
||||
|
||||
Use `-buildmode=plugin`:
|
||||
|
||||
```bash
|
||||
go build -buildmode=plugin -o happydomain-plugin-test-myplugin.so \
|
||||
git.happydns.org/happyDomain/plugins/myplugin
|
||||
```
|
||||
|
||||
A minimal `Makefile`:
|
||||
|
||||
```makefile
|
||||
PLUGIN_NAME=myplugin
|
||||
TARGET=../happydomain-plugin-test-$(PLUGIN_NAME).so
|
||||
|
||||
all: $(TARGET)
|
||||
|
||||
$(TARGET): *.go
|
||||
go build -buildmode=plugin -o $@ git.happydns.org/happyDomain/plugins/$(PLUGIN_NAME)
|
||||
```
|
||||
|
||||
> **Naming convention:** happyDomain looks for any `.so` file in the plugin directory, but using the prefix `happydomain-plugin-test-` makes the purpose clear.
|
||||
|
||||
---
|
||||
|
||||
## Deployment
|
||||
|
||||
### 1. Copy the `.so` file to a plugin directory
|
||||
|
||||
```bash
|
||||
cp happydomain-plugin-test-myplugin.so /usr/lib/happydomain/plugins/
|
||||
```
|
||||
|
||||
### 2. Configure happyDomain to load that directory
|
||||
|
||||
In your `happydomain.conf`:
|
||||
|
||||
```
|
||||
plugins-directories=/usr/lib/happydomain/plugins
|
||||
```
|
||||
|
||||
Or via an environment variable:
|
||||
|
||||
```bash
|
||||
HAPPYDOMAIN_PLUGINS_DIRECTORIES=/usr/lib/happydomain/plugins
|
||||
```
|
||||
|
||||
Multiple directories can be specified as a comma-separated list. happyDomain scans each directory at startup and attempts to load every `.so` file it finds. Loading errors are logged but do not prevent the server from starting.
|
||||
|
||||
### 3. Verify
|
||||
|
||||
Check the server logs at startup for a line like:
|
||||
|
||||
```
|
||||
Plugin myplugin loaded
|
||||
```
|
||||
|
||||
If a name conflict or load error occurs, a warning is logged with the filename and reason.
|
||||
7
plugins/matrix/Makefile
Normal file
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 NewCheckPlugin() (string, happydns.Checker, error) {
|
||||
return "matrixim", &MatrixTester{
|
||||
TesterURI: "https://federationtester.matrix.org/api/report?server_name=%s",
|
||||
}, nil
|
||||
}
|
||||
146
plugins/matrix/test.go
Normal file
146
plugins/matrix/test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.happydns.org/happyDomain/model"
|
||||
)
|
||||
|
||||
type MatrixTester struct {
|
||||
TesterURI string
|
||||
}
|
||||
|
||||
func (p *MatrixTester) ID() string {
|
||||
return "matrixim"
|
||||
}
|
||||
|
||||
func (p *MatrixTester) Name() string {
|
||||
return "Matrix Federation Tester"
|
||||
}
|
||||
|
||||
func (p *MatrixTester) Availability() happydns.CheckerAvailability {
|
||||
return happydns.CheckerAvailability{
|
||||
ApplyToService: true,
|
||||
LimitToServices: []string{"abstract.MatrixIM"},
|
||||
}
|
||||
}
|
||||
|
||||
func (p *MatrixTester) Options() happydns.CheckerOptionsDocumentation {
|
||||
return happydns.CheckerOptionsDocumentation{
|
||||
RunOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "serviceDomain",
|
||||
Type: "string",
|
||||
Label: "Matrix domain",
|
||||
Placeholder: "matrix.org",
|
||||
Default: "matrix.org",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
AdminOpts: []happydns.CheckerOptionDocumentation{
|
||||
{
|
||||
Id: "federationTesterServer",
|
||||
Type: "string",
|
||||
Label: "Federation Tester Server",
|
||||
Placeholder: "https://federationtester.matrix.org/",
|
||||
Default: "https://federationtester.matrix.org/",
|
||||
Required: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type FederationTesterResponse struct {
|
||||
WellKnownResult struct {
|
||||
Server string `json:"m.server"`
|
||||
Result string `json:"result"`
|
||||
}
|
||||
DNSResult struct {
|
||||
SRVError *struct {
|
||||
Message string
|
||||
}
|
||||
}
|
||||
ConnectionReports map[string]struct {
|
||||
Errors []string
|
||||
}
|
||||
ConnectionErrors map[string]struct {
|
||||
Message string
|
||||
}
|
||||
Version struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
}
|
||||
FederationOK bool `json:"FederationOK"`
|
||||
}
|
||||
|
||||
func (p *MatrixTester) RunCheck(options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||
var domain string
|
||||
|
||||
if dn, ok := options["domain"]; ok {
|
||||
domain, _ = dn.(string)
|
||||
} else if origin, ok := options["origin"]; ok {
|
||||
domain, _ = origin.(string)
|
||||
}
|
||||
|
||||
if domain == "" {
|
||||
return nil, fmt.Errorf("domain not defined")
|
||||
}
|
||||
|
||||
domain = strings.TrimSuffix(domain, ".")
|
||||
|
||||
resp, err := http.Get(fmt.Sprintf(p.TesterURI, domain))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to perform the test: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("Sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
|
||||
}
|
||||
|
||||
var status happydns.CheckResultStatus
|
||||
var statusLine string
|
||||
var federationTest FederationTesterResponse
|
||||
|
||||
err = json.NewDecoder(resp.Body).Decode(&federationTest)
|
||||
if err != nil {
|
||||
log.Printf("Error in check_matrix_federation, when decoding json: %s", err.Error())
|
||||
return nil, fmt.Errorf("sorry, the federation tester is broken. Check on https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
|
||||
}
|
||||
|
||||
if federationTest.FederationOK {
|
||||
status = happydns.CheckResultStatusOK
|
||||
statusLine = "Running " + federationTest.Version.Name + " " + federationTest.Version.Version
|
||||
} else {
|
||||
status = happydns.CheckResultStatusKO
|
||||
|
||||
if federationTest.DNSResult.SRVError != nil && federationTest.WellKnownResult.Result != "" {
|
||||
statusLine = fmt.Sprintf("%s OR %s", federationTest.DNSResult.SRVError.Message, federationTest.WellKnownResult.Result)
|
||||
} else if len(federationTest.ConnectionErrors) > 0 {
|
||||
var msg strings.Builder
|
||||
for srv, cerr := range federationTest.ConnectionErrors {
|
||||
if msg.Len() > 0 {
|
||||
msg.WriteString("; ")
|
||||
}
|
||||
msg.WriteString(srv)
|
||||
msg.WriteString(": ")
|
||||
msg.WriteString(cerr.Message)
|
||||
}
|
||||
statusLine = fmt.Sprintf("Connection errors: %s", msg.String())
|
||||
} else if federationTest.WellKnownResult.Server != strings.TrimSuffix(domain, ".") {
|
||||
statusLine = fmt.Sprintf("Bad homeserver_name: got %s, expected %s.", federationTest.WellKnownResult.Server, strings.TrimSuffix(domain, "."))
|
||||
} else {
|
||||
statusLine = fmt.Sprintf("An unimplemented error occurs. Please report this to happydomain team. But know that federation seems to be broken. Check https://federationtester.matrix.org/#%s", strings.TrimSuffix(domain, "."))
|
||||
}
|
||||
}
|
||||
|
||||
return &happydns.CheckResult{
|
||||
Status: status,
|
||||
StatusLine: statusLine,
|
||||
Report: federationTest,
|
||||
}, nil
|
||||
}
|
||||
|
|
@ -110,6 +110,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
|
|||
|
||||
// Routes to virtual content
|
||||
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/checkers/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
||||
|
|
|
|||
|
|
@ -101,6 +101,12 @@
|
|||
<NavItem>
|
||||
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink href="/checkers" active={page && page.url.pathname.startsWith('/checkers')}>Checkers</NavLink>
|
||||
</NavItem>
|
||||
<NavItem>
|
||||
<NavLink href="/scheduler" active={page && page.url.pathname.startsWith('/scheduler')}>Scheduler</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
</Collapse>
|
||||
</Navbar>
|
||||
|
|
|
|||
150
web-admin/src/routes/checkers/+page.svelte
Normal file
150
web-admin/src/routes/checkers/+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 { getChecks } from "$lib/api-admin";
|
||||
|
||||
let checkersQ = $state(getChecks());
|
||||
|
||||
let searchQuery = $state("");
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col md={8}>
|
||||
<h1 class="display-5">
|
||||
<Icon name="puzzle-fill"></Icon>
|
||||
Checkers
|
||||
</h1>
|
||||
<p class="d-flex gap-3 align-items-center text-muted">
|
||||
<span class="lead"> Manage all checkers </span>
|
||||
{#await checkersQ then checkersR}
|
||||
<span>Total: {Object.keys(checkersR.data ?? {}).length} checkers</span>
|
||||
{/await}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row class="mb-4">
|
||||
<Col md={8} lg={6}>
|
||||
<InputGroup>
|
||||
<InputGroupText>
|
||||
<Icon name="search"></Icon>
|
||||
</InputGroupText>
|
||||
<Input type="text" placeholder="Search checker..." bind:value={searchQuery} />
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checkersQ}
|
||||
Please wait...
|
||||
{:then checkersR}
|
||||
{@const checkers = checkersR.data}
|
||||
<div class="table-responsive">
|
||||
<Table hover bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin Name</th>
|
||||
<th>Availability</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !checkers || Object.keys(checkers).length == 0}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-2">
|
||||
No checkers available
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each Object.entries(checkers ?? {}).filter(([name, _info]) => name
|
||||
.toLowerCase()
|
||||
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
|
||||
<tr>
|
||||
<td><strong>{checkerInfo.name || checkerName}</strong></td>
|
||||
<td>
|
||||
{#if checkerInfo.availability}
|
||||
{#if checkerInfo.availability.applyToDomain}
|
||||
<Badge color="success">Domain</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
|
||||
<Badge
|
||||
color="primary"
|
||||
title={checkerInfo.availability.limitToProviders.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
Provider-specific
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
|
||||
<Badge
|
||||
color="info"
|
||||
title={checkerInfo.availability.limitToServices.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
Service-specific
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else}
|
||||
<Badge color="secondary">General</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="/checkers/{checkerName}"
|
||||
class="btn btn-sm btn-primary"
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
Manage
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading checkers: {error.message}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</Container>
|
||||
322
web-admin/src/routes/checkers/[cname]/+page.svelte
Normal file
322
web-admin/src/routes/checkers/[cname]/+page.svelte
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Row,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { getChecksByCnameOptions, putChecksByCnameOptions } from "$lib/api-admin";
|
||||
import { getCheckStatus } from "$lib/api/checks";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
|
||||
|
||||
let cname = $derived(page.params.cname!);
|
||||
|
||||
let checkerStatusQ = $derived(getCheckStatus(cname));
|
||||
let checkerOptionsQ = $derived(getChecksByCnameOptions({ path: { cname } }));
|
||||
let optionValues = $state<Record<string, any>>({});
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
checkerOptionsQ.then((optionsR) => {
|
||||
optionValues = { ...((optionsR.data as Record<string, unknown>) || {}) };
|
||||
});
|
||||
});
|
||||
|
||||
async function saveOptions() {
|
||||
saving = true;
|
||||
try {
|
||||
await putChecksByCnameOptions({
|
||||
path: { cname },
|
||||
body: { options: optionValues },
|
||||
});
|
||||
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
|
||||
toasts.addToast({
|
||||
message: `Plugin options updated successfully`,
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: "Failed to update options: " + error,
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanOrphanedOptions(adminOpts: any[]) {
|
||||
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
|
||||
const cleanedOptions: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(optionValues)) {
|
||||
if (validOptIds.has(key)) {
|
||||
cleanedOptions[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await putChecksByCnameOptions({
|
||||
path: { cname },
|
||||
body: { options: cleanedOptions },
|
||||
});
|
||||
checkerOptionsQ = getChecksByCnameOptions({ path: { cname } });
|
||||
toasts.addToast({
|
||||
message: `Orphaned options removed successfully`,
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: "Failed to clean options: " + error,
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrphanedOptions(adminOpts: any[]): string[] {
|
||||
const validOptIds = new Set(adminOpts.map((opt) => opt.id));
|
||||
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
|
||||
}
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col>
|
||||
<Button color="link" href="/checks" class="mb-2">
|
||||
<Icon name="arrow-left"></Icon>
|
||||
Back to checkers
|
||||
</Button>
|
||||
<h1 class="display-5">
|
||||
<Icon name="puzzle-fill"></Icon>
|
||||
{cname}
|
||||
</h1>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checkerStatusQ}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Loading checker status...
|
||||
</p>
|
||||
</Card>
|
||||
{:then status}
|
||||
{#if status}
|
||||
<Row class="mb-4">
|
||||
<Col md={6}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<strong>Checker Information</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">Name:</dt>
|
||||
<dd class="col-sm-8">{status.name}</dd>
|
||||
|
||||
<dt class="col-sm-4">Availability:</dt>
|
||||
<dd class="col-sm-8">
|
||||
{#if status.availableOn}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{#if status.availableOn.applyToDomain}
|
||||
<Badge color="success">Domain-level</Badge>
|
||||
{/if}
|
||||
{#if status.availableOn.limitToProviders && status.availableOn.limitToProviders.length > 0}
|
||||
<Badge color="primary">
|
||||
Providers: {status.availableOn.limitToProviders.join(
|
||||
", ",
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if status.availableOn.limitToServices && status.availableOn.limitToServices.length > 0}
|
||||
<Badge color="info">
|
||||
Services: {status.availableOn.limitToServices.join(
|
||||
", ",
|
||||
)}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !status.availableOn.applyToDomain && (!status.availableOn.limitToProviders || status.availableOn.limitToProviders.length === 0) && (!status.availableOn.limitToServices || status.availableOn.limitToServices.length === 0)}
|
||||
<Badge color="secondary">General</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Badge color="secondary">General</Badge>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
{#await checkerOptionsQ}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
Loading options...
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:then _optionsR}
|
||||
{@const adminOpts = status.options?.adminOpts || []}
|
||||
{@const readOnlyOptGroups = [
|
||||
{
|
||||
key: "userOpts",
|
||||
label: "User Options",
|
||||
opts: status.options?.userOpts || [],
|
||||
},
|
||||
{
|
||||
key: "domainOpts",
|
||||
label: "Domain Options",
|
||||
opts: status.options?.domainOpts || [],
|
||||
},
|
||||
{
|
||||
key: "serviceOpts",
|
||||
label: "Service Options",
|
||||
opts: status.options?.serviceOpts || [],
|
||||
},
|
||||
{
|
||||
key: "runOpts",
|
||||
label: "Run Options",
|
||||
opts: status.options?.runOpts || [],
|
||||
},
|
||||
]}
|
||||
{@const hasAnyOpts =
|
||||
adminOpts.length > 0 ||
|
||||
readOnlyOptGroups.some((g) => g.opts.length > 0)}
|
||||
{@const orphanedOpts = getOrphanedOptions(adminOpts)}
|
||||
|
||||
{#if orphanedOpts.length > 0}
|
||||
<Alert color="warning" class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
<strong>Orphaned options detected:</strong>
|
||||
{orphanedOpts.join(", ")}
|
||||
</div>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() => cleanOrphanedOptions(adminOpts)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Icon name="trash"></Icon>
|
||||
Clean Up
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if adminOpts.length > 0}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>Admin Options</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form on:submit={saveOptions}>
|
||||
{#each adminOpts as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={optionValues[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="d-flex gap-2">
|
||||
<Button type="submit" color="success" disabled={saving}>
|
||||
{#if saving}
|
||||
<span
|
||||
class="spinner-border spinner-border-sm me-1"
|
||||
></span>
|
||||
{/if}
|
||||
<Icon name="check-circle"></Icon>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<CheckerOptionsGroups groups={readOnlyOptGroups} />
|
||||
|
||||
{#if !hasAnyOpts}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
This checker has no configurable options.
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="danger" class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading options: {error.message}
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/await}
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error: checker data not found
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading checker: {error.message}
|
||||
</Alert>
|
||||
{/await}
|
||||
</Container>
|
||||
341
web-admin/src/routes/scheduler/+page.svelte
Normal file
341
web-admin/src/routes/scheduler/+page.svelte
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Row,
|
||||
Spinner,
|
||||
Table,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import {
|
||||
getScheduler,
|
||||
postSchedulerDisable,
|
||||
postSchedulerEnable,
|
||||
postSchedulerRescheduleUpcoming,
|
||||
} from "$lib/api-admin/sdk.gen";
|
||||
|
||||
interface CheckerSchedule {
|
||||
id: string;
|
||||
checker_name: string;
|
||||
owner_id: string;
|
||||
target_type: number;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
last_run?: string;
|
||||
next_run: string;
|
||||
}
|
||||
|
||||
interface SchedulerStatus {
|
||||
config_enabled: boolean;
|
||||
runtime_enabled: boolean;
|
||||
running: boolean;
|
||||
worker_count: number;
|
||||
queue_size: number;
|
||||
active_count: number;
|
||||
next_schedules: CheckerSchedule[] | null;
|
||||
}
|
||||
|
||||
let status = $state<SchedulerStatus | null>(null);
|
||||
let loading = $state(true);
|
||||
let actionInProgress = $state(false);
|
||||
let rescheduleInProgress = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
async function fetchStatus() {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const { data, error: err } = await getScheduler();
|
||||
if (err) throw new Error(String(err));
|
||||
status = data as SchedulerStatus;
|
||||
} catch (e: any) {
|
||||
error = e.message ?? "Unknown error";
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function setEnabled(enabled: boolean) {
|
||||
actionInProgress = true;
|
||||
const action = enabled ? "enable" : "disable";
|
||||
try {
|
||||
const { data, error: err } = await (enabled
|
||||
? postSchedulerEnable()
|
||||
: postSchedulerDisable());
|
||||
if (err) {
|
||||
toasts.addErrorToast({ message: `Failed to ${action} scheduler: ${err}` });
|
||||
return;
|
||||
}
|
||||
status = data as SchedulerStatus;
|
||||
toasts.addToast({ message: `Scheduler ${action}d successfully`, color: "success" });
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ message: e.message ?? `Failed to ${action} scheduler` });
|
||||
} finally {
|
||||
actionInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function rescheduleUpcoming() {
|
||||
rescheduleInProgress = true;
|
||||
try {
|
||||
const { data, error: err } = await postSchedulerRescheduleUpcoming();
|
||||
if (err) {
|
||||
toasts.addErrorToast({ message: `Failed to reschedule: ${err}` });
|
||||
return;
|
||||
}
|
||||
toasts.addToast({
|
||||
message: `Rescheduled ${(data as any).rescheduled} schedule(s) successfully`,
|
||||
color: "success",
|
||||
});
|
||||
await fetchStatus();
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ message: e.message ?? "Failed to reschedule upcoming checks" });
|
||||
} finally {
|
||||
rescheduleInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(ns: number): string {
|
||||
const seconds = ns / 1e9;
|
||||
if (seconds < 60) return `${Math.round(seconds)}s`;
|
||||
const minutes = seconds / 60;
|
||||
if (minutes < 60) return `${Math.round(minutes)}m`;
|
||||
const hours = minutes / 60;
|
||||
if (hours < 24) return `${Math.round(hours)}h`;
|
||||
return `${Math.round(hours / 24)}d`;
|
||||
}
|
||||
|
||||
function targetTypeName(t: number): string {
|
||||
const names: Record<number, string> = {
|
||||
0: "instance",
|
||||
1: "user",
|
||||
2: "domain",
|
||||
3: "zone",
|
||||
4: "service",
|
||||
5: "ondemand",
|
||||
};
|
||||
return names[t] ?? "unknown";
|
||||
}
|
||||
|
||||
onMount(fetchStatus);
|
||||
</script>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col>
|
||||
<h1 class="display-5">
|
||||
<Icon name="clock-history"></Icon>
|
||||
Test Scheduler
|
||||
</h1>
|
||||
<p class="text-muted lead">Monitor and control the background test scheduler</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#if loading}
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading scheduler status...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<Card color="danger" body>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
Error loading scheduler status: {error}
|
||||
<Button class="ms-3" size="sm" color="light" onclick={fetchStatus}>Retry</Button>
|
||||
</Card>
|
||||
{:else if status}
|
||||
<!-- Status Card -->
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<span><Icon name="info-circle-fill"></Icon> Scheduler Status</span>
|
||||
<Button size="sm" color="secondary" outline onclick={fetchStatus}>
|
||||
<Icon name="arrow-clockwise"></Icon> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Row class="g-3 mb-3">
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Config Enabled</div>
|
||||
{#if status.config_enabled}
|
||||
<Badge color="success">Yes</Badge>
|
||||
{:else}
|
||||
<Badge color="danger">No</Badge>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Runtime Enabled</div>
|
||||
{#if status.runtime_enabled}
|
||||
<Badge color="success">Yes</Badge>
|
||||
{:else}
|
||||
<Badge color="warning">Disabled</Badge>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Running</div>
|
||||
{#if status.running}
|
||||
<Badge color="success"><Icon name="play-fill"></Icon> Running</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary"><Icon name="stop-fill"></Icon> Stopped</Badge>
|
||||
{/if}
|
||||
</Col>
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Workers</div>
|
||||
<strong>{status.worker_count}</strong>
|
||||
</Col>
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Queue Size</div>
|
||||
<strong>{status.queue_size}</strong>
|
||||
</Col>
|
||||
<Col sm={6} md={4}>
|
||||
<div class="text-muted small">Active Executions</div>
|
||||
<strong>{status.active_count}</strong>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#if status.config_enabled}
|
||||
<div class="d-flex gap-2">
|
||||
{#if status.runtime_enabled}
|
||||
<Button
|
||||
color="warning"
|
||||
disabled={actionInProgress}
|
||||
onclick={() => setEnabled(false)}
|
||||
>
|
||||
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
|
||||
name="pause-fill"
|
||||
></Icon>{/if}
|
||||
Disable Scheduler
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
color="success"
|
||||
disabled={actionInProgress}
|
||||
onclick={() => setEnabled(true)}
|
||||
>
|
||||
{#if actionInProgress}<Spinner size="sm" />{:else}<Icon
|
||||
name="play-fill"
|
||||
></Icon>{/if}
|
||||
Enable Scheduler
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
color="secondary"
|
||||
outline
|
||||
disabled={rescheduleInProgress}
|
||||
onclick={rescheduleUpcoming}
|
||||
>
|
||||
{#if rescheduleInProgress}<Spinner size="sm" />{:else}<Icon
|
||||
name="shuffle"
|
||||
></Icon>{/if}
|
||||
Spread Upcoming Checks
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted mb-0">
|
||||
<Icon name="lock-fill"></Icon>
|
||||
The scheduler is disabled in the server configuration and cannot be enabled at
|
||||
runtime.
|
||||
</p>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
<!-- Upcoming Scheduled Checks -->
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Icon name="calendar-event-fill"></Icon>
|
||||
Upcoming Scheduled Checks
|
||||
{#if status.next_schedules}
|
||||
<Badge color="secondary" class="ms-2">{status.next_schedules.length}</Badge>
|
||||
{/if}
|
||||
</CardHeader>
|
||||
<CardBody class="p-0">
|
||||
<div class="table-responsive">
|
||||
<Table hover class="mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Plugin</th>
|
||||
<th>Target Type</th>
|
||||
<th>Target ID</th>
|
||||
<th>Interval</th>
|
||||
<th>Last Run</th>
|
||||
<th>Next Run</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if !status.next_schedules || status.next_schedules.length === 0}
|
||||
<tr>
|
||||
<td colspan="6" class="text-center text-muted py-3">
|
||||
No scheduled checks
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each status.next_schedules as schedule}
|
||||
<tr>
|
||||
<td><strong>{schedule.checker_name}</strong></td>
|
||||
<td
|
||||
><Badge color="info"
|
||||
>{targetTypeName(schedule.target_type)}</Badge
|
||||
></td
|
||||
>
|
||||
<td><code class="small">{schedule.target_id}</code></td>
|
||||
<td>{formatDuration(schedule.interval)}</td>
|
||||
<td>
|
||||
{#if schedule.last_run}
|
||||
{new Date(schedule.last_run).toLocaleString()}
|
||||
{:else}
|
||||
<span class="text-muted">Never</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
{#if new Date(schedule.next_run) < new Date()}
|
||||
<span class="text-danger">
|
||||
<Icon name="exclamation-circle-fill"></Icon>
|
||||
{new Date(schedule.next_run).toLocaleString()}
|
||||
</span>
|
||||
{:else}
|
||||
{new Date(schedule.next_run).toLocaleString()}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</Container>
|
||||
259
web/routes.go
259
web/routes.go
|
|
@ -22,10 +22,10 @@
|
|||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
|
@ -39,7 +39,6 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
indexTpl *template.Template
|
||||
CustomHeadHTML = ""
|
||||
CustomBodyHTML = ""
|
||||
HideVoxPeople = false
|
||||
|
|
@ -55,7 +54,7 @@ func init() {
|
|||
flag.StringVar(&MsgHeaderColor, "msg-header-color", MsgHeaderColor, "Background color of the banner added at the top of the app")
|
||||
}
|
||||
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, captchaVerifier happydns.CaptchaVerifier) {
|
||||
func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifier happydns.CaptchaVerifier) {
|
||||
appConfig := map[string]interface{}{}
|
||||
|
||||
if cfg.DisableProviders {
|
||||
|
|
@ -100,105 +99,137 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, captchaVerifier ha
|
|||
CustomHeadHTML += `<script id="app-config" type="application/json">` + string(appcfg) + `</script>`
|
||||
}
|
||||
|
||||
if cfg.DevProxy != "" {
|
||||
router.GET("/.svelte-kit/*_", serveOrReverse("", cfg))
|
||||
router.GET("/node_modules/*_", serveOrReverse("", cfg))
|
||||
router.GET("/@vite/*_", serveOrReverse("", cfg))
|
||||
router.GET("/@id/*_", serveOrReverse("", cfg))
|
||||
router.GET("/@fs/*_", serveOrReverse("", cfg))
|
||||
router.GET("/src/*_", serveOrReverse("", cfg))
|
||||
router.GET("/home/*_", serveOrReverse("", cfg))
|
||||
}
|
||||
router.GET("/_app/*_", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
serveFile := serveOrReverse("", cfg)
|
||||
serveIndex := serveOrReverse("/", cfg)
|
||||
serveManifest := serveOrReverse("/manifest.json", cfg)
|
||||
immutable := func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }
|
||||
|
||||
router.GET("/", serveOrReverse("/", cfg))
|
||||
router.GET("/index.html", serveOrReverse("/", cfg))
|
||||
if cfg.DevProxy != "" {
|
||||
router.GET("/.svelte-kit/*_", serveFile)
|
||||
router.GET("/node_modules/*_", serveFile)
|
||||
router.GET("/@vite/*_", serveFile)
|
||||
router.GET("/@id/*_", serveFile)
|
||||
router.GET("/@fs/*_", serveFile)
|
||||
router.GET("/src/*_", serveFile)
|
||||
router.GET("/home/*_", serveFile)
|
||||
}
|
||||
router.GET("/_app/*_", immutable, serveFile)
|
||||
|
||||
router.GET("/", serveIndex)
|
||||
router.GET("/index.html", serveIndex)
|
||||
|
||||
// Routes handled by the showcase
|
||||
router.GET("/en/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/fr/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/en/*_", serveIndex)
|
||||
router.GET("/fr/*_", serveIndex)
|
||||
|
||||
// Routes for real existings files
|
||||
router.GET("/fonts/*path", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
router.GET("/img/*path", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
router.GET("/favicon.ico", func(c *gin.Context) { c.Writer.Header().Set("Cache-Control", "public, max-age=604800, immutable") }, serveOrReverse("", cfg))
|
||||
router.GET("/manifest.json", serveOrReverse("", cfg))
|
||||
router.GET("/robots.txt", serveOrReverse("", cfg))
|
||||
router.GET("/service-worker.js", serveOrReverse("", cfg))
|
||||
router.GET("/fonts/*path", immutable, serveFile)
|
||||
router.GET("/img/*path", immutable, serveFile)
|
||||
router.GET("/favicon.ico", immutable, serveFile)
|
||||
router.GET("/manifest.json", serveManifest)
|
||||
router.GET("/robots.txt", serveFile)
|
||||
router.GET("/service-worker.js", serveFile)
|
||||
|
||||
// Routes to virtual content
|
||||
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/email-validation", serveOrReverse("/", cfg))
|
||||
router.GET("/forgotten-password", serveOrReverse("/", cfg))
|
||||
router.GET("/join", serveOrReverse("/", cfg))
|
||||
router.GET("/login", serveOrReverse("/", cfg))
|
||||
router.GET("/me", serveOrReverse("/", cfg))
|
||||
router.GET("/onboarding/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/services/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/tools/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/resolver/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/zones/*_", serveOrReverse("/", cfg))
|
||||
router.GET("/checks/*_", serveIndex)
|
||||
router.GET("/domains/*_", serveIndex)
|
||||
router.GET("/email-validation", serveIndex)
|
||||
router.GET("/forgotten-password", serveIndex)
|
||||
router.GET("/join", serveIndex)
|
||||
router.GET("/login", serveIndex)
|
||||
router.GET("/me", serveIndex)
|
||||
router.GET("/onboarding/*_", serveIndex)
|
||||
router.GET("/providers/*_", serveIndex)
|
||||
router.GET("/services/*_", serveIndex)
|
||||
router.GET("/tools/*_", serveIndex)
|
||||
router.GET("/resolver/*_", serveIndex)
|
||||
router.GET("/zones/*_", serveIndex)
|
||||
}
|
||||
|
||||
func NoRoute(cfg *happydns.Options, router *gin.Engine) {
|
||||
serveIndex := serveOrReverse("/", cfg)
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
if strings.HasPrefix(c.Request.URL.Path, "/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
|
||||
if strings.HasPrefix(c.Request.URL.Path, cfg.BasePath+"/api") || strings.Contains(c.Request.Header.Get("Accept"), "application/json") {
|
||||
c.JSON(404, gin.H{"code": "PAGE_NOT_FOUND", "errmsg": "Page not found"})
|
||||
} else if cfg.BasePath != "" && !strings.HasPrefix(c.Request.URL.Path, cfg.BasePath) {
|
||||
c.Redirect(http.StatusFound, cfg.BasePath+c.Request.URL.Path)
|
||||
} else {
|
||||
serveOrReverse("/", cfg)(c)
|
||||
serveIndex(c)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
||||
if cfg.DevProxy != "" {
|
||||
// Parse once at creation time, not per request
|
||||
devURL, err := url.Parse(cfg.DevProxy)
|
||||
if err != nil {
|
||||
return func(c *gin.Context) {
|
||||
http.Error(c.Writer, "invalid dev proxy URL: "+err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
// Forward to the Vue dev proxy
|
||||
return func(c *gin.Context) {
|
||||
if u, err := url.Parse(cfg.DevProxy); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
u := *devURL // copy to avoid mutating shared state across requests
|
||||
if forced_url != "" {
|
||||
u.Path = path.Join(u.Path, forced_url)
|
||||
} else {
|
||||
if forced_url != "" {
|
||||
u.Path = path.Join(u.Path, forced_url)
|
||||
} else {
|
||||
u.Path = path.Join(u.Path, c.Request.URL.Path)
|
||||
u.Path = path.Join(u.Path, c.Request.URL.Path)
|
||||
}
|
||||
u.RawQuery = c.Request.URL.RawQuery
|
||||
|
||||
r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body)
|
||||
if err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(r)
|
||||
if err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if u.Path != "/" || resp.StatusCode != 200 {
|
||||
for key, vals := range resp.Header {
|
||||
for _, v := range vals {
|
||||
c.Writer.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
|
||||
u.RawQuery = c.Request.URL.RawQuery
|
||||
|
||||
if r, err := http.NewRequest(c.Request.Method, u.String(), c.Request.Body); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
} else if resp, err := http.DefaultClient.Do(r); err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
|
||||
} else {
|
||||
defer resp.Body.Close()
|
||||
|
||||
if u.Path != "/" || resp.StatusCode != 200 {
|
||||
for key := range resp.Header {
|
||||
c.Writer.Header().Add(key, resp.Header.Get(key))
|
||||
}
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
} else {
|
||||
for key := range resp.Header {
|
||||
if strings.ToLower(key) != "content-length" {
|
||||
c.Writer.Header().Add(key, resp.Header.Get(key))
|
||||
}
|
||||
}
|
||||
|
||||
v, _ := ioutil.ReadAll(resp.Body)
|
||||
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
c.Writer.WriteHeader(resp.StatusCode)
|
||||
io.Copy(c.Writer, resp.Body)
|
||||
} else {
|
||||
for key, vals := range resp.Header {
|
||||
if !strings.EqualFold(key, "content-length") {
|
||||
for _, v := range vals {
|
||||
c.Writer.Header().Add(key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
v, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Local template per request — no race condition on a package-level var
|
||||
tpl, err := template.New("index.html").Parse(
|
||||
strings.Replace(strings.Replace(string(v),
|
||||
"</head>", "{{ .Head }}</head>", 1),
|
||||
"</body>", "{{ .Body }}</body>", 1),
|
||||
)
|
||||
if err != nil {
|
||||
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := tpl.Execute(c.Writer, map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if Assets == nil {
|
||||
|
|
@ -206,25 +237,63 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
|||
c.String(http.StatusNotFound, "404 Page not found - interface not embedded in binary, please compile with -tags web")
|
||||
}
|
||||
} else if forced_url == "/" {
|
||||
// Serve altered index.html
|
||||
// Pre-render index.html once at handler creation time
|
||||
f, err := Assets.Open("index.html")
|
||||
if err != nil {
|
||||
log.Println("Unable to open embedded index.html:", err)
|
||||
return func(c *gin.Context) {
|
||||
c.String(http.StatusInternalServerError, "index.html not found in embedded assets")
|
||||
}
|
||||
}
|
||||
v, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
log.Println("Unable to read embedded index.html:", err)
|
||||
return func(c *gin.Context) {
|
||||
c.String(http.StatusInternalServerError, "failed to read embedded index.html")
|
||||
}
|
||||
}
|
||||
|
||||
rendered := []byte(strings.Replace(strings.Replace(string(v), "</head>", CustomHeadHTML+"</head>", 1), "</body>", CustomBodyHTML+"</body>", 1))
|
||||
|
||||
if cfg.BasePath != "" {
|
||||
rendered = bytes.ReplaceAll(
|
||||
bytes.ReplaceAll(
|
||||
bytes.ReplaceAll(
|
||||
bytes.ReplaceAll(
|
||||
rendered,
|
||||
[]byte(`href="/`),
|
||||
append([]byte(`href="`), append([]byte(cfg.BasePath), '/')...),
|
||||
),
|
||||
[]byte(`import("/`),
|
||||
append([]byte(`import("`), append([]byte(cfg.BasePath), '/')...),
|
||||
),
|
||||
[]byte(`base: "`),
|
||||
append([]byte(`base: "`), []byte(cfg.BasePath)...),
|
||||
),
|
||||
[]byte("</head>"),
|
||||
[]byte(`<base href="`+cfg.BasePath+`"></head>`),
|
||||
)
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
if indexTpl == nil {
|
||||
// Create template from file
|
||||
f, _ := Assets.Open("index.html")
|
||||
v, _ := ioutil.ReadAll(f)
|
||||
|
||||
v2 := strings.Replace(strings.Replace(string(v), "</head>", "{{ .Head }}</head>", 1), "</body>", "{{ .Body }}</body>", 1)
|
||||
|
||||
indexTpl = template.Must(template.New("index.html").Parse(v2))
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", rendered)
|
||||
}
|
||||
} else if forced_url == "/manifest.json" {
|
||||
// Serve altered manifest.json
|
||||
return func(c *gin.Context) {
|
||||
f, err := Assets.Open("manifest.json")
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "manifest.json not found in embedded assets")
|
||||
return
|
||||
}
|
||||
|
||||
// Serve template
|
||||
if err := indexTpl.ExecuteTemplate(c.Writer, "index.html", map[string]string{
|
||||
"Body": CustomBodyHTML,
|
||||
"Head": CustomHeadHTML,
|
||||
}); err != nil {
|
||||
log.Println("Unable to return index.html:", err.Error())
|
||||
v, err := io.ReadAll(f)
|
||||
if err != nil {
|
||||
c.String(http.StatusInternalServerError, "failed to read manifest.json")
|
||||
return
|
||||
}
|
||||
v2 := strings.Replace(strings.Replace(string(v), "\"id\": \"/\"", "\"id\": \""+cfg.BasePath+"\"", 1), "\"start_url\": \"/\"", "\"start_url\": \""+cfg.BasePath+"\"", 1)
|
||||
|
||||
c.Data(http.StatusOK, "application/manifest+json", []byte(v2))
|
||||
}
|
||||
} else if forced_url != "" {
|
||||
// Serve forced_url
|
||||
|
|
@ -234,7 +303,7 @@ func serveOrReverse(forced_url string, cfg *happydns.Options) gin.HandlerFunc {
|
|||
} else {
|
||||
// Serve requested file
|
||||
return func(c *gin.Context) {
|
||||
c.FileFromFS(c.Request.URL.Path, Assets)
|
||||
c.FileFromFS(strings.TrimPrefix(c.Request.URL.Path, cfg.BasePath), Assets)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
27
web/src/lib/api/auth.ts
Normal file
27
web/src/lib/api/auth.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// 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 { base } from "$lib/stores/config";
|
||||
|
||||
export async function getOidcProvider(): Promise<{ provider: string }> {
|
||||
const res = await fetch(`${base}/auth/has_oidc`);
|
||||
return res.json();
|
||||
}
|
||||
240
web/src/lib/api/checks.ts
Normal file
240
web/src/lib/api/checks.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import {
|
||||
getChecks,
|
||||
getChecksByCid,
|
||||
getChecksByCidOptions,
|
||||
postChecksByCidOptions,
|
||||
putChecksByCidOptions,
|
||||
getChecksByCidOptionsByOptname,
|
||||
putChecksByCidOptionsByOptname,
|
||||
postDomainsByDomainChecksByCname,
|
||||
getDomainsByDomainChecksByCnameOptions,
|
||||
getDomainsByDomainChecks,
|
||||
getDomainsByDomainChecksByCnameResults,
|
||||
getDomainsByDomainChecksByCnameResultsByResultId,
|
||||
deleteDomainsByDomainChecksByCnameResults,
|
||||
deleteDomainsByDomainChecksByCnameResultsByResultId,
|
||||
getDomainsByDomainChecksByCnameExecutionsByExecutionId,
|
||||
postPluginsTestsSchedules,
|
||||
putPluginsTestsSchedulesByScheduleId,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import { unwrapSdkResponse } from "./errors";
|
||||
import type {
|
||||
CheckerList,
|
||||
CheckerInfo,
|
||||
CheckerOptions,
|
||||
AvailableCheck,
|
||||
CheckerSchedule,
|
||||
CheckResult,
|
||||
CheckExecution,
|
||||
} from "$lib/model/check";
|
||||
import { CheckScopeType } from "$lib/model/check";
|
||||
|
||||
export async function listChecks(): Promise<CheckerList> {
|
||||
return unwrapSdkResponse(await getChecks()) as CheckerList;
|
||||
}
|
||||
|
||||
export async function getCheckStatus(checkId: string): Promise<CheckerInfo> {
|
||||
return unwrapSdkResponse(
|
||||
await getChecksByCid({
|
||||
path: { cid: checkId },
|
||||
}),
|
||||
) as unknown as CheckerInfo;
|
||||
}
|
||||
|
||||
export async function getCheckOptions(checkId: string): Promise<CheckerOptions> {
|
||||
return unwrapSdkResponse(
|
||||
await getChecksByCidOptions({
|
||||
path: { cid: checkId },
|
||||
}),
|
||||
) as CheckerOptions;
|
||||
}
|
||||
|
||||
export async function addCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
|
||||
return unwrapSdkResponse(
|
||||
await postChecksByCidOptions({
|
||||
path: { cid: checkId },
|
||||
body: { options } as any,
|
||||
}),
|
||||
) as boolean;
|
||||
}
|
||||
|
||||
export async function updateCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
|
||||
return unwrapSdkResponse(
|
||||
await putChecksByCidOptions({
|
||||
path: { cid: checkId },
|
||||
body: { options } as any,
|
||||
}),
|
||||
) as boolean;
|
||||
}
|
||||
|
||||
export async function getCheckOption(checkId: string, optionName: string): Promise<any> {
|
||||
return unwrapSdkResponse(
|
||||
await getChecksByCidOptionsByOptname({
|
||||
path: { cid: checkId, optname: optionName },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function setcheckOption(
|
||||
checkId: string,
|
||||
optionName: string,
|
||||
value: any,
|
||||
): Promise<boolean> {
|
||||
return unwrapSdkResponse(
|
||||
await putChecksByCidOptionsByOptname({
|
||||
path: { cid: checkId, optname: optionName },
|
||||
body: value as any,
|
||||
}),
|
||||
) as boolean;
|
||||
}
|
||||
|
||||
export async function getDomainCheckOptions(domainId: string, checkId: string): Promise<CheckerOptions> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameOptions({
|
||||
path: { domain: domainId, cname: checkId },
|
||||
}),
|
||||
) as CheckerOptions;
|
||||
}
|
||||
|
||||
export async function triggerCheck(
|
||||
domainId: string,
|
||||
checkId: string,
|
||||
options?: CheckerOptions,
|
||||
): Promise<{ execution_id?: string }> {
|
||||
const filteredOptions = options
|
||||
? Object.fromEntries(Object.entries(options).filter(([, v]) => v !== "" && v !== null && v !== undefined))
|
||||
: undefined;
|
||||
return unwrapSdkResponse(
|
||||
await postDomainsByDomainChecksByCname({
|
||||
path: { domain: domainId, cname: checkId },
|
||||
body: { options: filteredOptions } as any,
|
||||
}),
|
||||
) as { execution_id?: string };
|
||||
}
|
||||
|
||||
export async function listAvailableChecks(domainId: string): Promise<AvailableCheck[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecks({
|
||||
path: { domain: domainId },
|
||||
}),
|
||||
) as unknown as AvailableCheck[];
|
||||
}
|
||||
|
||||
export async function createCheckSchedule(data: {
|
||||
checker_name: string;
|
||||
target_type: CheckScopeType;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
options?: CheckerOptions;
|
||||
}): Promise<CheckerSchedule> {
|
||||
return unwrapSdkResponse(
|
||||
await postPluginsTestsSchedules({
|
||||
body: {
|
||||
checker_name: data.checker_name,
|
||||
target_type: data.target_type,
|
||||
target_id: data.target_id,
|
||||
interval: data.interval,
|
||||
enabled: data.enabled,
|
||||
options: data.options,
|
||||
},
|
||||
}),
|
||||
) as CheckerSchedule;
|
||||
}
|
||||
|
||||
export async function updateCheckSchedule(
|
||||
scheduleId: string,
|
||||
schedule: CheckerSchedule,
|
||||
): Promise<CheckerSchedule> {
|
||||
return unwrapSdkResponse(
|
||||
await putPluginsTestsSchedulesByScheduleId({
|
||||
path: { schedule_id: scheduleId },
|
||||
body: {
|
||||
id: schedule.id,
|
||||
checker_name: schedule.checker_name,
|
||||
target_type: schedule.target_type,
|
||||
target_id: schedule.target_id,
|
||||
interval: schedule.interval,
|
||||
enabled: schedule.enabled,
|
||||
last_run: schedule.last_run,
|
||||
next_run: schedule.next_run,
|
||||
options: schedule.options,
|
||||
},
|
||||
}),
|
||||
) as CheckerSchedule;
|
||||
}
|
||||
|
||||
export async function getCheckResult(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
resultId: string,
|
||||
): Promise<CheckResult> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameResultsByResultId({
|
||||
path: { domain: domainId, cname: checkName, result_id: resultId },
|
||||
}),
|
||||
) as unknown as CheckResult;
|
||||
}
|
||||
|
||||
export async function deleteCheckResult(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
resultId: string,
|
||||
): Promise<void> {
|
||||
await deleteDomainsByDomainChecksByCnameResultsByResultId({
|
||||
path: { domain: domainId, cname: checkName, result_id: resultId },
|
||||
});
|
||||
}
|
||||
|
||||
export async function listCheckResults(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
): Promise<CheckResult[]> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameResults({
|
||||
path: { domain: domainId, cname: checkName },
|
||||
}),
|
||||
) as unknown as CheckResult[];
|
||||
}
|
||||
|
||||
export async function deleteAllCheckResults(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
): Promise<void> {
|
||||
await deleteDomainsByDomainChecksByCnameResults({
|
||||
path: { domain: domainId, cname: checkName },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getCheckExecution(
|
||||
domainId: string,
|
||||
checkName: string,
|
||||
executionId: string,
|
||||
): Promise<CheckExecution> {
|
||||
return unwrapSdkResponse(
|
||||
await getDomainsByDomainChecksByCnameExecutionsByExecutionId({
|
||||
path: { domain: domainId, cname: checkName, execution_id: executionId },
|
||||
}),
|
||||
) as unknown as CheckExecution;
|
||||
}
|
||||
|
|
@ -32,6 +32,7 @@ import {
|
|||
postUsersByUserIdSettings,
|
||||
getUsersByUserId,
|
||||
} from "$lib/api-base/sdk.gen";
|
||||
import { client } from "$lib/api-base/client.gen";
|
||||
import type { UserSettings } from "$lib/model/usersettings";
|
||||
import type { User, SignUpForm, LoginForm } from "$lib/model/user";
|
||||
import { unwrapSdkResponse, unwrapEmptyResponse } from "./errors";
|
||||
|
|
@ -44,43 +45,6 @@ export async function authUser(form: LoginForm): Promise<User> {
|
|||
return unwrapSdkResponse(await postAuth({ body: form })) as unknown as User;
|
||||
}
|
||||
|
||||
export class CaptchaRequiredError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CaptchaRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function authUserWithCaptcha(form: LoginForm): Promise<User> {
|
||||
// Use raw fetch to bypass the customFetch session-refresh wrapper,
|
||||
// which would consume the 401 response before we can inspect captcha_required.
|
||||
const baseUrl = typeof window !== 'undefined' ? "/api/" : "http://localhost/api/";
|
||||
const response = await fetch(`${baseUrl}auth`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let body: Record<string, unknown> = {};
|
||||
try {
|
||||
body = await response.json();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
if (body.captcha_required) {
|
||||
throw new CaptchaRequiredError(
|
||||
typeof body.errmsg === 'string' ? body.errmsg : "Captcha verification required."
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(typeof body.errmsg === 'string' ? body.errmsg : "Invalid username or password.");
|
||||
}
|
||||
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
|
||||
export async function logout(): Promise<boolean> {
|
||||
return unwrapEmptyResponse(await postAuthLogout());
|
||||
}
|
||||
|
|
@ -185,6 +149,14 @@ export function cleanUserSession(): void {
|
|||
}
|
||||
}
|
||||
|
||||
export async function isAuthUser(user: User): Promise<boolean> {
|
||||
const result = await client.get({
|
||||
url: "/users/{userId}/is_auth_user",
|
||||
path: { userId: user.id },
|
||||
} as any);
|
||||
return result.response?.status === 204;
|
||||
}
|
||||
|
||||
export async function getUser(id: string): Promise<User> {
|
||||
return unwrapSdkResponse(
|
||||
await getUsersByUserId({
|
||||
|
|
|
|||
|
|
@ -22,9 +22,8 @@
|
|||
-->
|
||||
|
||||
<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,
|
||||
|
|
@ -41,13 +40,12 @@
|
|||
import { logout as APILogout } from "$lib/api/user";
|
||||
import HelpButton from "$lib/components/Help.svelte";
|
||||
import Logo from "$lib/components/Logo.svelte";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { appConfig, navigate } from "$lib/stores/config";
|
||||
import { providersSpecs } from "$lib/stores/providers";
|
||||
import { userSession, refreshUserSession } from "$lib/stores/usersession";
|
||||
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 +53,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 {
|
||||
|
|
@ -107,7 +103,7 @@
|
|||
refreshUserSession().then(
|
||||
() => {},
|
||||
() => {
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -187,11 +183,15 @@
|
|||
>
|
||||
{$t("menu.dns-resolver")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem
|
||||
active={page.route && page.route.id == "/me"}
|
||||
href="/me"
|
||||
active={page.route &&
|
||||
(page.route.id == "/checks" || page.route.id?.startsWith("/checks/"))}
|
||||
href="/checks"
|
||||
>
|
||||
{$t("menu.checkers")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
|
||||
{$t("menu.my-account")}
|
||||
</DropdownItem>
|
||||
{#if $userSession.email !== "_no_auth"}
|
||||
|
|
@ -232,10 +232,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}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import { fly } from "svelte/transition";
|
||||
|
||||
import { Icon } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -42,6 +43,7 @@
|
|||
<div
|
||||
class="card"
|
||||
style="position: fixed; bottom: calc(7vh + max(1.7vw, 1.7vh)); right: calc(4vw + max(1.7vw, 1.7vh)); z-index: 1052; max-width: 400px;"
|
||||
transition:fly={{ x: 20, y: 20, duration: 200 }}
|
||||
>
|
||||
<div class="card-body row row-cols-2 justify-content-center align-items-center">
|
||||
<div class="col d-flex mb-3 flex-fill">
|
||||
|
|
@ -74,7 +76,7 @@
|
|||
<a
|
||||
href="https://framaforms.org/quel-est-votre-avis-sur-happydns-1610366701?u={$userSession.id
|
||||
? $userSession.id
|
||||
: 0}&i={instancename}&p={page.route ? ('&p=' + page.route.id) : ''}&l={$locale}"
|
||||
: 0}&i={instancename}{page.route ? ('&p=' + page.route.id) : ''}&l={$locale}"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
class="btn btn-lg btn-light flex-fill fw-bolder"
|
||||
|
|
|
|||
107
web/src/lib/components/checkers/CheckerOptionsGroups.svelte
Normal file
107
web/src/lib/components/checkers/CheckerOptionsGroups.svelte
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { Card, CardBody, CardHeader } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
const AUTO_FILL_KEYS: Record<string, string> = {
|
||||
domain_name: "checks.auto-fill.domain_name",
|
||||
subdomain: "checks.auto-fill.subdomain",
|
||||
service_type: "checks.auto-fill.service_type",
|
||||
};
|
||||
|
||||
function getAutoFillLabel(autoFill: string): string {
|
||||
const tKey = AUTO_FILL_KEYS[autoFill];
|
||||
if (tKey) return $t(tKey);
|
||||
return $t("checks.auto-fill.generic", { key: autoFill });
|
||||
}
|
||||
|
||||
interface OptionDef {
|
||||
id?: string;
|
||||
label?: string;
|
||||
type?: string;
|
||||
default?: unknown;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
autoFill?: string;
|
||||
}
|
||||
|
||||
interface OptionGroup {
|
||||
label: string;
|
||||
opts: OptionDef[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
groups: OptionGroup[];
|
||||
}
|
||||
|
||||
let { groups }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#each groups as optGroup}
|
||||
{#if optGroup.opts.length > 0}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>{optGroup.label}</strong>
|
||||
<small class="text-muted ms-2">{$t("checks.detail.read-only")}</small>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
{#each optGroup.opts as optDoc}
|
||||
{@const optName = optDoc.id!}
|
||||
<dt class="col-sm-4">
|
||||
{optDoc.label || optDoc.id}:
|
||||
</dt>
|
||||
<dd class="col-sm-8">
|
||||
{#if optDoc.autoFill}
|
||||
<span class="badge bg-info me-1">
|
||||
{getAutoFillLabel(optDoc.autoFill)}
|
||||
</span>
|
||||
{:else if optDoc.default}
|
||||
<span class="text-muted d-block">{optDoc.default}</span>
|
||||
{:else if optDoc.placeholder}
|
||||
<em class="text-muted d-block">{optDoc.placeholder}</em>
|
||||
{/if}
|
||||
{#if optDoc.description}
|
||||
<small class="text-muted d-block">{optDoc.description}</small>
|
||||
{/if}
|
||||
<small class="text-muted">
|
||||
{$t("checks.option-groups.type", {
|
||||
type: optDoc.type || "string",
|
||||
})}
|
||||
</small>
|
||||
{#if optDoc.required && !optDoc.autoFill}
|
||||
<small class="text-danger ms-2">
|
||||
{$t("checks.option-groups.required")}
|
||||
</small>
|
||||
{/if}
|
||||
</dd>
|
||||
{/each}
|
||||
</dl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{/each}
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import {
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
}
|
||||
|
||||
if (!provider) {
|
||||
goto("/domains/new/" + encodeURIComponent(value));
|
||||
navigate("/domains/new/" + encodeURIComponent(value));
|
||||
} else {
|
||||
addDomain(value, provider).then(
|
||||
(domain) => {
|
||||
|
|
|
|||
204
web/src/lib/components/modals/RunCheckModal.svelte
Normal file
204
web/src/lib/components/modals/RunCheckModal.svelte
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalFooter,
|
||||
ModalHeader,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { triggerCheck, getDomainCheckOptions, getCheckStatus } from "$lib/api/checks";
|
||||
import type { CheckerOptions } from "$lib/model/check";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
|
||||
interface Props {
|
||||
domainId: string;
|
||||
onCheckTriggered?: (execution_id: string, checker_name: string) => void;
|
||||
}
|
||||
|
||||
let { domainId, onCheckTriggered }: Props = $props();
|
||||
|
||||
let isOpen = $state(false);
|
||||
let checkName = $state<string>("");
|
||||
let checkDisplayName = $state<string>("");
|
||||
let checkStatusPromise = $state<Promise<any> | null>(null);
|
||||
let domainOptionsPromise = $state<Promise<CheckerOptions> | null>(null);
|
||||
let runOptions = $state<Record<string, any>>({});
|
||||
let triggering = $state(false);
|
||||
let showAdvanced = $state(false);
|
||||
|
||||
const toggle = () => (isOpen = !isOpen);
|
||||
|
||||
export function open(name: string, displayName: string) {
|
||||
checkName = name;
|
||||
checkDisplayName = displayName;
|
||||
runOptions = {};
|
||||
checkStatusPromise = getCheckStatus(name);
|
||||
domainOptionsPromise = getDomainCheckOptions(domainId, name);
|
||||
isOpen = true;
|
||||
|
||||
// Pre-populate with domain options when they load
|
||||
domainOptionsPromise.then((options) => {
|
||||
runOptions = { ...(options || {}) };
|
||||
});
|
||||
}
|
||||
|
||||
async function handleRunCheck() {
|
||||
triggering = true;
|
||||
try {
|
||||
const result = await triggerCheck(domainId, checkName, runOptions);
|
||||
toasts.addToast({
|
||||
message: $t("checks.run-check.triggered-success", { id: result.execution_id }),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
isOpen = false;
|
||||
if (onCheckTriggered && result.execution_id) {
|
||||
onCheckTriggered(result.execution_id, checkName);
|
||||
}
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checks.run-check.trigger-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
triggering = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {isOpen} {toggle} size="lg">
|
||||
<ModalHeader {toggle}>
|
||||
{$t("checks.run-check.title")}: {checkDisplayName}
|
||||
</ModalHeader>
|
||||
<ModalBody>
|
||||
{#if checkStatusPromise && domainOptionsPromise}
|
||||
{#await Promise.all([checkStatusPromise, domainOptionsPromise])}
|
||||
<div class="text-center py-3">
|
||||
<Spinner />
|
||||
<p class="mt-2">{$t("checks.run-check.loading-options")}</p>
|
||||
</div>
|
||||
{:then [status, _domainOpts]}
|
||||
{@const runOpts = status.options?.runOpts || []}
|
||||
{#if runOpts.length > 0}
|
||||
<p>
|
||||
{$t("checks.run-check.configure-info")}
|
||||
</p>
|
||||
<Form
|
||||
id="run-test-modal"
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
handleRunCheck();
|
||||
}}
|
||||
>
|
||||
{#each runOpts as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
readonly={!!optDoc.autoFill}
|
||||
bind:value={runOptions[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
{@const otherOpts = [
|
||||
...(status.options?.adminOpts || []),
|
||||
...(status.options?.userOpts || []),
|
||||
...(status.options?.domainOpts || []),
|
||||
...(status.options?.serviceOpts || []),
|
||||
].filter((o) => o.id)}
|
||||
{#if otherOpts.length > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link btn-sm px-0 mb-2 text-muted d-flex align-items-center gap-1 text-decoration-none"
|
||||
onclick={() => (showAdvanced = !showAdvanced)}
|
||||
>
|
||||
<Icon name={showAdvanced ? "chevron-down" : "chevron-right"} />
|
||||
{$t("checks.run-check.advanced-options")}
|
||||
</button>
|
||||
{#if showAdvanced}
|
||||
{#each otherOpts as optDoc}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
readonly={true}
|
||||
bind:value={runOptions[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/each}
|
||||
{/if}
|
||||
{/if}
|
||||
</Form>
|
||||
{:else}
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.run-check.no-options")}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.run-check.error-loading-options", { error: error.message })}
|
||||
</Alert>
|
||||
{/await}
|
||||
{/if}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button type="button" color="secondary" onclick={toggle} disabled={triggering}>
|
||||
{$t("common.cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="run-test-modal"
|
||||
color="primary"
|
||||
onclick={handleRunCheck}
|
||||
disabled={triggering}
|
||||
>
|
||||
{#if triggering}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{:else}
|
||||
<Icon name="play-fill"></Icon>
|
||||
{/if}
|
||||
{$t("checks.run-check.run-button")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Button, Col, Container, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -58,14 +58,14 @@
|
|||
function createProviderForm(ptype: string, providerId: string | null, value: ProviderSettingsState | null, edit: boolean): ProviderForm {
|
||||
const pf = new ProviderForm(
|
||||
ptype,
|
||||
() => refreshProviders().then(() => goto("/?newProvider")),
|
||||
() => refreshProviders().then(() => navigate("/?newProvider")),
|
||||
providerId,
|
||||
value,
|
||||
() => {
|
||||
if (edit) {
|
||||
goto("/providers");
|
||||
navigate("/providers");
|
||||
} else {
|
||||
goto("/providers/new");
|
||||
navigate("/providers/new");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import {
|
||||
Button,
|
||||
|
|
@ -56,7 +56,7 @@
|
|||
if (actionAddDomain) {
|
||||
addDomainToProvider();
|
||||
} else if (filteredDomains.length > 0) {
|
||||
goto("/domains/" + encodeURIComponent(filteredDomains[0].id));
|
||||
navigate("/domains/" + encodeURIComponent(filteredDomains[0].id));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -88,7 +88,7 @@
|
|||
},
|
||||
);
|
||||
} else {
|
||||
goto("/domains/new/" + encodeURIComponent($filteredName));
|
||||
navigate("/domains/new/" + encodeURIComponent($filteredName));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
|
|
@ -33,7 +31,7 @@
|
|||
|
||||
import ProviderList from "$lib/components/providers/List.svelte";
|
||||
import type { Provider } from "$lib/model/provider";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { appConfig, navigate } from "$lib/stores/config";
|
||||
import {
|
||||
providers,
|
||||
providersSpecs,
|
||||
|
|
@ -67,7 +65,7 @@
|
|||
items={$providers}
|
||||
noLabel
|
||||
bind:selectedProvider={filteredProvider}
|
||||
on:new-provider={() => goto("/providers/new")}
|
||||
on:new-provider={() => navigate("/providers/new")}
|
||||
/>
|
||||
{/if}
|
||||
</Card>
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
import {
|
||||
|
|
@ -108,7 +108,7 @@
|
|||
|
||||
function updateProvider(event: Event, item: Provider) {
|
||||
event.stopPropagation();
|
||||
goto("/providers/" + encodeURIComponent(item._id));
|
||||
navigate("/providers/" + encodeURIComponent(item._id));
|
||||
}
|
||||
|
||||
async function delProvider(event: Event, item: Provider) {
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
// 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 { base } from "$lib/stores/config";
|
||||
import { refreshUserSession } from "$lib/stores/usersession";
|
||||
import type { CreateClientConfig } from "./api-base/client.gen";
|
||||
|
||||
|
|
@ -29,6 +30,13 @@ export class NotAuthorizedError extends Error {
|
|||
}
|
||||
}
|
||||
|
||||
export class CaptchaRequiredError extends NotAuthorizedError {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = "CaptchaRequiredError";
|
||||
}
|
||||
}
|
||||
|
||||
export class ProviderNoDomainListingSupport extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
|
|
@ -39,8 +47,24 @@ export class ProviderNoDomainListingSupport extends Error {
|
|||
async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
|
||||
const response = await fetch(input, init);
|
||||
|
||||
// Handle 401 Unauthorized - attempt session refresh and retry
|
||||
// Handle 401 Unauthorized - check for captcha requirement first, then attempt session refresh
|
||||
if (response.status === 401) {
|
||||
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||
const clone = response.clone();
|
||||
try {
|
||||
const json = await clone.json();
|
||||
if (json.captcha_required) {
|
||||
throw new CaptchaRequiredError(
|
||||
typeof json.errmsg === "string"
|
||||
? json.errmsg
|
||||
: "Captcha verification required.",
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof CaptchaRequiredError) throw err;
|
||||
// ignore JSON parsing errors
|
||||
}
|
||||
}
|
||||
try {
|
||||
await refreshUserSession();
|
||||
// Retry the original request after successful session refresh
|
||||
|
|
@ -66,7 +90,7 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
|
|||
if (
|
||||
response.status === 400 &&
|
||||
json.error ===
|
||||
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
|
||||
"error in openapi3filter.SecurityRequirementsError: security requirements failed: invalid session"
|
||||
) {
|
||||
throw new NotAuthorizedError(json.error.substring(80));
|
||||
}
|
||||
|
|
@ -79,7 +103,10 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
|
|||
}
|
||||
} catch (err) {
|
||||
// If it's one of our custom errors, re-throw it
|
||||
if (err instanceof NotAuthorizedError || err instanceof ProviderNoDomainListingSupport) {
|
||||
if (
|
||||
err instanceof NotAuthorizedError ||
|
||||
err instanceof ProviderNoDomainListingSupport
|
||||
) {
|
||||
throw err;
|
||||
}
|
||||
// Otherwise, ignore JSON parsing errors and return the original response
|
||||
|
|
@ -92,7 +119,7 @@ async function customFetch(input: RequestInfo | URL, init?: RequestInit): Promis
|
|||
export const createClientConfig: CreateClientConfig = (config) => {
|
||||
// In test environments (Node.js), we need a full URL with protocol and host
|
||||
// In browser environments, relative URLs work fine
|
||||
const baseUrl = typeof window !== 'undefined' ? "/api/" : "http://localhost/api/";
|
||||
const baseUrl = typeof window !== "undefined" ? base + "/api/" : "http://localhost/api/";
|
||||
|
||||
return {
|
||||
...config,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@
|
|||
"share": "Share the zone…",
|
||||
"upload": "Import a zone file",
|
||||
"view": "View my zone",
|
||||
"view-checks": "View checks",
|
||||
"others": "More actions on {{domain}}"
|
||||
},
|
||||
"alert": {
|
||||
|
|
@ -238,6 +239,7 @@
|
|||
"my-domains": "My domains",
|
||||
"my-providers": "My domain providers",
|
||||
"dns-resolver": "DNS resolver",
|
||||
"checkers": "Domain Checkers",
|
||||
"my-account": "My account",
|
||||
"logout": "Sign out",
|
||||
"provider-features": "Supported providers",
|
||||
|
|
@ -475,7 +477,7 @@
|
|||
"showrrtypes-title": "Show DNS record types",
|
||||
"preferences": {
|
||||
"title": "Preferences",
|
||||
"description": "Customize your preferences"
|
||||
"description": "Customize your preferences"
|
||||
},
|
||||
"security": {
|
||||
"title": "Security & Access",
|
||||
|
|
@ -538,10 +540,200 @@
|
|||
"ttl": "Remaining time in cache",
|
||||
"showDNSSEC": "Show DNSSEC records in answer (if any)"
|
||||
},
|
||||
"checks": {
|
||||
"run-check": {
|
||||
"title": "Run Check",
|
||||
"loading-options": "Loading checker options...",
|
||||
"configure-info": "Configure checker options below. Pre-filled values are from domain-level settings.",
|
||||
"no-options": "This check has no configurable options. Click \"Run Check\" to execute with default settings.",
|
||||
"error-loading-options": "Error loading checker options: {{error}}",
|
||||
"run-button": "Run Check",
|
||||
"triggered-success": "Check triggered successfully! Execution ID: {{id}}",
|
||||
"trigger-failed": "Failed to trigger check: {{error}}",
|
||||
"advanced-options": "Advanced options"
|
||||
},
|
||||
"never": "Never",
|
||||
"na": "N/A",
|
||||
"relative": {
|
||||
"in-less-than-a-minute": "in less than a minute",
|
||||
"just-now": "just now",
|
||||
"in": "in {{label}}",
|
||||
"ago": "{{label}} ago"
|
||||
},
|
||||
"status": {
|
||||
"ok": "OK",
|
||||
"info": "Info",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"unknown": "Unknown",
|
||||
"pending": "Pending",
|
||||
"not-run": "Not run"
|
||||
},
|
||||
"list": {
|
||||
"title": "Checks for ",
|
||||
"loading": "Loading checkers...",
|
||||
"loading-checkers": "Loading checker information...",
|
||||
"no-checks": "No checks available for this domain.",
|
||||
"run-check": "Run Check",
|
||||
"view-results": "View Results",
|
||||
"configure": "Configure",
|
||||
"error-loading": "Error loading checkers: {{error}}",
|
||||
"unknown-version": "Unknown",
|
||||
"table": {
|
||||
"checker": "Checker",
|
||||
"status": "Status",
|
||||
"last-run": "Last Run",
|
||||
"schedule": "Schedule",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"schedule": {
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled"
|
||||
}
|
||||
},
|
||||
"schedule": {
|
||||
"title": "Schedule",
|
||||
"card-title": "Automatic scheduling",
|
||||
"auto-enabled": "Run automatically",
|
||||
"auto-disabled": "Disabled (run manually only)",
|
||||
"interval-label": "Check interval",
|
||||
"hours": "hours",
|
||||
"interval-hint": "Minimum 1 hour. The check will run once per interval.",
|
||||
"next-run": "Next scheduled run",
|
||||
"last-run": "Last run",
|
||||
"no-schedule-yet": "No schedule created yet. Save to create one.",
|
||||
"save": "Save",
|
||||
"save-failed": "Failed to save schedule",
|
||||
"saved": "Schedule saved successfully."
|
||||
},
|
||||
"results": {
|
||||
"loading": "Loading check results...",
|
||||
"no-results": "No check results yet. Click \"Run Check Now\" to execute the check.",
|
||||
"title": "Check Results ({{count}})",
|
||||
"run-check-now": "Run Check Now",
|
||||
"back-to-checks": "Back to checks",
|
||||
"delete-all": "Delete All",
|
||||
"delete-confirm": "Are you sure you want to delete this check result?",
|
||||
"delete-all-confirm": "Are you sure you want to delete ALL check results for this check? This cannot be undone.",
|
||||
"delete-failed": "Failed to delete result",
|
||||
"delete-all-failed": "Failed to delete results",
|
||||
"configure": "Configure",
|
||||
"domain-level": "Domain-level",
|
||||
"error-loading": "Error loading check results: {{error}}",
|
||||
"table": {
|
||||
"executed-at": "Executed At",
|
||||
"status": "Status",
|
||||
"message": "Message",
|
||||
"duration": "Duration",
|
||||
"type": "Type",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"type": {
|
||||
"scheduled": "Scheduled",
|
||||
"manual": "Manual"
|
||||
},
|
||||
"pending": {
|
||||
"queued": "Queued",
|
||||
"queued-description": "Queued, waiting to run…",
|
||||
"running": "Running",
|
||||
"running-description": "Check is currently running…"
|
||||
},
|
||||
"view": "View"
|
||||
},
|
||||
"result": {
|
||||
"title": "Check Result Details",
|
||||
"loading": "Loading check result...",
|
||||
"relaunch": "Relaunch Check",
|
||||
"delete": "Delete Result",
|
||||
"relaunch-failed": "Failed to relaunch check",
|
||||
"delete-confirm": "Are you sure you want to delete this check result?",
|
||||
"delete-failed": "Failed to delete result",
|
||||
"error-loading": "Error loading check result: {{error}}",
|
||||
"milliseconds": "milliseconds",
|
||||
"seconds": "seconds",
|
||||
"type": {
|
||||
"scheduled": "Scheduled Check",
|
||||
"manual": "Manual Check"
|
||||
},
|
||||
"check-options": "Check Options",
|
||||
"full-report": "Full Report",
|
||||
"field": {
|
||||
"domain": "Domain:",
|
||||
"executed-at": "Executed At:",
|
||||
"duration": "Duration:",
|
||||
"status": "Status:",
|
||||
"status-message": "Status Message:",
|
||||
"error": "Error:"
|
||||
}
|
||||
},
|
||||
"title": "Domain Checkers",
|
||||
"description": "Configure automated checks for your domains",
|
||||
"available-count": "Available: {{count}} checks",
|
||||
"search-placeholder": "Search checks...",
|
||||
"loading": "Loading checkers...",
|
||||
"loading-info": "Loading checker information...",
|
||||
"no-checkers": "No checkers available",
|
||||
"error-loading": "Error loading checkers: {{error}}",
|
||||
"error-loading-check": "Error loading checker: {{error}}",
|
||||
"check-info-not-found": "Error: Checker information not found",
|
||||
"back-to-checks": "Back to checks",
|
||||
"table": {
|
||||
"name": "Checker Name",
|
||||
"availability": "Availability",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"availability": {
|
||||
"domain": "Domain",
|
||||
"provider-specific": "Provider-specific",
|
||||
"service-specific": "Service-specific",
|
||||
"general": "General",
|
||||
"domain-level": "Domain-level",
|
||||
"providers": "Providers: {{providers}}",
|
||||
"services": "Services: {{services}}"
|
||||
},
|
||||
"actions": {
|
||||
"configure": "Configure"
|
||||
},
|
||||
"detail": {
|
||||
"check-information": "Check Information",
|
||||
"name": "Name:",
|
||||
"availability": "Availability:",
|
||||
"loading-options": "Loading options...",
|
||||
"configuration": "Configuration",
|
||||
"save-changes": "Save Changes",
|
||||
"no-configurable-options": "This check 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",
|
||||
"check-parameters": "Check Parameters",
|
||||
"type": "Type: {{type}}",
|
||||
"required": "Required"
|
||||
},
|
||||
"auto-fill": {
|
||||
"domain_name": "auto-filled: domain name",
|
||||
"subdomain": "auto-filled: subdomain",
|
||||
"service_type": "auto-filled: service type",
|
||||
"generic": "auto-filled: {{key}}"
|
||||
},
|
||||
"messages": {
|
||||
"options-updated": "Checker options updated successfully",
|
||||
"options-cleaned": "Orphaned options removed successfully",
|
||||
"update-failed": "Failed to update options: {{error}}",
|
||||
"clean-failed": "Failed to clean options: {{error}}"
|
||||
}
|
||||
},
|
||||
"zones": {
|
||||
"upload": "Import a zone",
|
||||
"import-text": "Import from text",
|
||||
"import-file": "Import from file",
|
||||
"return-to": "Go to the zone"
|
||||
"return-to": "Go to the zone",
|
||||
"return-to-results": "Back to results",
|
||||
"return-to-checks": "Back to checks"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
76
web/src/lib/model/check.ts
Normal file
76
web/src/lib/model/check.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// This file is part of the happyDomain (R) project.
|
||||
// Copyright (c) 2022-2026 happyDomain
|
||||
// Authors: Pierre-Olivier Mercier, et al.
|
||||
//
|
||||
// This program is offered under a commercial and under the AGPL license.
|
||||
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
//
|
||||
// For AGPL licensing:
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import type {
|
||||
HappydnsCheckerAvailability,
|
||||
HappydnsCheckerOptionDocumentation,
|
||||
HappydnsCheckerOptionsDocumentation,
|
||||
HappydnsCheckerOptions,
|
||||
HappydnsCheckerResponse,
|
||||
HappydnsCheckerSchedule,
|
||||
HappydnsCheckResult,
|
||||
HappydnsCheckExecution,
|
||||
} from "$lib/api-base/types.gen";
|
||||
|
||||
// Re-export auto-generated types with better names
|
||||
export type CheckerAvailability = HappydnsCheckerAvailability;
|
||||
export type CheckerInfo = HappydnsCheckerResponse;
|
||||
export type CheckerList = { [key: string]: HappydnsCheckerResponse };
|
||||
export type CheckerOptions = HappydnsCheckerOptions;
|
||||
export type CheckerOptionsDocumentation = HappydnsCheckerOptionsDocumentation;
|
||||
export type CheckerSchedule = HappydnsCheckerSchedule;
|
||||
export type CheckResult = HappydnsCheckResult;
|
||||
export type CheckExecution = HappydnsCheckExecution;
|
||||
|
||||
// Make 'id' required for CheckerOptionDocumentation
|
||||
export interface CheckerOptionDocumentation extends Omit<HappydnsCheckerOptionDocumentation, "id"> {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Enums for named access to numeric status/scope values
|
||||
export enum CheckResultStatus {
|
||||
KO = 0,
|
||||
Warn = 1,
|
||||
Info = 2,
|
||||
OK = 3,
|
||||
}
|
||||
|
||||
export enum CheckScopeType {
|
||||
CheckScopeInstance = 0,
|
||||
CheckScopeUser = 1,
|
||||
CheckScopeDomain = 2,
|
||||
CheckScopeService = 3,
|
||||
CheckScopeOnDemand = 4,
|
||||
}
|
||||
|
||||
export enum CheckExecutionStatus {
|
||||
CheckExecutionPending = 0,
|
||||
CheckExecutionRunning = 1,
|
||||
CheckExecutionCompleted = 2,
|
||||
CheckExecutionFailed = 3,
|
||||
}
|
||||
|
||||
export interface AvailableCheck {
|
||||
checker_name: string;
|
||||
enabled: boolean;
|
||||
schedule?: CheckerSchedule;
|
||||
last_result?: CheckResult;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ export class Field {
|
|||
required? = $state<boolean>();
|
||||
secret? = $state<boolean>();
|
||||
textarea? = $state<boolean>();
|
||||
autoFill? = $state<string>();
|
||||
}
|
||||
|
||||
export class CustomForm {
|
||||
|
|
|
|||
106
web/src/lib/model/test.ts
Normal file
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 { CheckerOptions } from "./check";
|
||||
|
||||
export enum TestScopeType {
|
||||
TestScopeInstance = 0,
|
||||
TestScopeUser = 1,
|
||||
TestScopeDomain = 2,
|
||||
TestScopeZone = 3,
|
||||
TestScopeService = 4,
|
||||
TestScopeOnDemand = 5,
|
||||
}
|
||||
|
||||
export enum TestExecutionStatus {
|
||||
TestExecutionPending = 0,
|
||||
TestExecutionRunning = 1,
|
||||
TestExecutionCompleted = 2,
|
||||
TestExecutionFailed = 3,
|
||||
}
|
||||
|
||||
export enum PluginResultStatus {
|
||||
KO = 0,
|
||||
Warn = 1,
|
||||
Info = 2,
|
||||
OK = 3,
|
||||
}
|
||||
|
||||
export interface TestResult {
|
||||
id: string;
|
||||
plugin_name: string;
|
||||
test_type: TestScopeType;
|
||||
target_id: string;
|
||||
user_id: string;
|
||||
executed_at: string;
|
||||
scheduled_test: boolean;
|
||||
options?: CheckerOptions;
|
||||
status: PluginResultStatus;
|
||||
status_line: string;
|
||||
report?: any;
|
||||
duration?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TestSchedule {
|
||||
id: string;
|
||||
plugin_name: string;
|
||||
user_id: string;
|
||||
target_type: TestScopeType;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
last_run?: string;
|
||||
next_run: string;
|
||||
options?: CheckerOptions;
|
||||
}
|
||||
|
||||
export interface TestExecution {
|
||||
id: string;
|
||||
schedule_id?: string;
|
||||
plugin_name: string;
|
||||
user_id: string;
|
||||
target_id: string;
|
||||
status: TestExecutionStatus;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
result_id?: string;
|
||||
}
|
||||
|
||||
export interface AvailableTest {
|
||||
plugin_name: string;
|
||||
enabled: boolean;
|
||||
schedule?: TestSchedule;
|
||||
last_result?: TestResult;
|
||||
}
|
||||
|
||||
export interface TriggerTestRequest {
|
||||
options?: CheckerOptions;
|
||||
}
|
||||
|
||||
export interface CreateScheduleRequest {
|
||||
plugin_name: string;
|
||||
target_type: TestScopeType;
|
||||
target_id: string;
|
||||
interval: number;
|
||||
enabled: boolean;
|
||||
options?: CheckerOptions;
|
||||
}
|
||||
32
web/src/lib/stores/checks.ts
Normal file
32
web/src/lib/stores/checks.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 { listChecks } from "$lib/api/checks";
|
||||
import type { CheckerList } from "$lib/model/check";
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
|
||||
export const checks: Writable<CheckerList | undefined> = writable(undefined);
|
||||
|
||||
export async function refreshChecks() {
|
||||
const data = await listChecks();
|
||||
checks.set(data);
|
||||
return data;
|
||||
}
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
// 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 { goto } from '$app/navigation';
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
import type { Color } from "@sveltestrap/sveltestrap";
|
||||
|
|
@ -65,3 +66,11 @@ function getConfigFromScriptTag(): AppConfig | null {
|
|||
const initialConfig = getConfigFromScriptTag() || defaultConfig;
|
||||
|
||||
export const appConfig = writable<AppConfig>(initialConfig);
|
||||
|
||||
export const base: string = typeof document !== 'undefined'
|
||||
? (document.querySelector('base')?.getAttribute('href') ?? '')
|
||||
: '';
|
||||
|
||||
export function navigate(url: string, opts?: Parameters<typeof goto>[1]) {
|
||||
return goto(base + url, opts);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
// 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 { getProvidersSpecs } from "$lib/api-base/sdk.gen";
|
||||
import { unwrapSdkResponse } from "$lib/api/errors";
|
||||
import { listProviders } from "$lib/api/provider";
|
||||
import type { Provider, ProviderInfos } from "$lib/model/provider";
|
||||
import { filteredProvider } from "$lib/stores/home";
|
||||
|
|
@ -62,12 +64,7 @@ export const providers_idx = derived(providers, ($providers: Array<Provider> | u
|
|||
});
|
||||
|
||||
export async function refreshProvidersSpecs() {
|
||||
const res = await fetch("/api/providers/_specs", { headers: { Accept: "application/json" } });
|
||||
if (res.status == 200) {
|
||||
const map = await res.json();
|
||||
providersSpecs.set(map);
|
||||
return map;
|
||||
} else {
|
||||
throw new Error((await res.json()).errmsg);
|
||||
}
|
||||
const map = unwrapSdkResponse(await getProvidersSpecs()) as Record<string, ProviderInfos>;
|
||||
providersSpecs.set(map);
|
||||
return map;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@
|
|||
// 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 { getServiceSpecs } from "$lib/api-base/sdk.gen";
|
||||
import { unwrapSdkResponse } from "$lib/api/errors";
|
||||
import { derived, writable, type Writable } from "svelte/store";
|
||||
import type { ServiceInfos } from "$lib/model/service_specs.svelte";
|
||||
|
||||
|
|
@ -30,16 +32,15 @@ export async function refreshServicesSpecs() {
|
|||
servicesSpecsLoaded.set(false);
|
||||
servicesSpecsError.set(null);
|
||||
|
||||
const res = await fetch("/api/service_specs", { headers: { Accept: "application/json" } });
|
||||
if (res.status == 200) {
|
||||
const map = await res.json();
|
||||
try {
|
||||
const map = unwrapSdkResponse(await getServiceSpecs()) as Record<string, ServiceInfos>;
|
||||
servicesSpecs.set(map);
|
||||
servicesSpecsLoaded.set(true);
|
||||
return map;
|
||||
} else {
|
||||
const errmsg = (await res.json()).errmsg;
|
||||
} catch (err) {
|
||||
const errmsg = err instanceof Error ? err.message : String(err);
|
||||
servicesSpecsError.set(errmsg);
|
||||
throw new Error(errmsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,23 +20,19 @@
|
|||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
import { writable, type Writable } from "svelte/store";
|
||||
import { getAuth } from "$lib/api-base/sdk.gen";
|
||||
import { unwrapSdkResponse } from "$lib/api/errors";
|
||||
import type { User } from "$lib/model/user";
|
||||
|
||||
export const userSession: Writable<User> = writable({} as User);
|
||||
|
||||
export async function refreshUserSession(
|
||||
fetch: (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit | undefined,
|
||||
) => Promise<Response> = window.fetch,
|
||||
) {
|
||||
const res = await fetch("/api/auth", { headers: { Accept: "application/json" } });
|
||||
if (res.status == 200) {
|
||||
const user = (await res.json()) as User;
|
||||
export async function refreshUserSession() {
|
||||
try {
|
||||
const user = unwrapSdkResponse(await getAuth()) as unknown as User;
|
||||
userSession.set(user);
|
||||
return user;
|
||||
} else {
|
||||
} catch (err) {
|
||||
userSession.set({} as User);
|
||||
throw new Error((await res.json()).errmsg);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,12 @@ interface Params {
|
|||
min?: number;
|
||||
max?: number;
|
||||
suggestion?: string;
|
||||
key?: string;
|
||||
error?: string;
|
||||
providers?: string;
|
||||
services?: string;
|
||||
options?: string;
|
||||
label?: string;
|
||||
// add more parameters that are used here
|
||||
}
|
||||
|
||||
|
|
|
|||
39
web/src/lib/utils/check.ts
Normal file
39
web/src/lib/utils/check.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import type { HappydnsCheckResultStatus } from "$lib/api-base/types.gen";
|
||||
import { CheckResultStatus } from "$lib/model/check";
|
||||
|
||||
export function getStatusColor(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
|
||||
switch (status) {
|
||||
case CheckResultStatus.OK:
|
||||
return "success";
|
||||
case CheckResultStatus.Info:
|
||||
return "info";
|
||||
case CheckResultStatus.Warn:
|
||||
return "warning";
|
||||
case CheckResultStatus.KO:
|
||||
return "danger";
|
||||
default:
|
||||
return "secondary";
|
||||
}
|
||||
}
|
||||
|
||||
export function getStatusKey(status: CheckResultStatus | HappydnsCheckResultStatus | undefined): string {
|
||||
switch (status) {
|
||||
case CheckResultStatus.OK:
|
||||
return "checks.status.ok";
|
||||
case CheckResultStatus.Info:
|
||||
return "checks.status.info";
|
||||
case CheckResultStatus.Warn:
|
||||
return "checks.status.warning";
|
||||
case CheckResultStatus.KO:
|
||||
return "checks.status.error";
|
||||
default:
|
||||
return "checks.status.unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDuration(duration: number | undefined, t: (k: string) => string): string {
|
||||
if (!duration) return t("checks.na");
|
||||
const seconds = duration / 1000000000;
|
||||
if (seconds < 1) return `${(seconds * 1000).toFixed(0)} ${t("checks.result.milliseconds")}`;
|
||||
return `${seconds.toFixed(2)} ${t("checks.result.seconds")}`;
|
||||
}
|
||||
|
|
@ -4,17 +4,17 @@
|
|||
* @returns Datetime-local format string, or empty string if invalid
|
||||
*/
|
||||
export function toDatetimeLocal(isoString: string | null | undefined): string {
|
||||
if (!isoString) return '';
|
||||
if (!isoString) return "";
|
||||
try {
|
||||
const date = new Date(isoString);
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(date.getDate()).padStart(2, "0");
|
||||
const hours = String(date.getHours()).padStart(2, "0");
|
||||
const minutes = String(date.getMinutes()).padStart(2, "0");
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
} catch (e) {
|
||||
return '';
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -31,3 +31,61 @@ export function fromDatetimeLocal(datetimeLocal: string): string | null {
|
|||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string for display in check UI
|
||||
* @param dateString ISO date string or undefined
|
||||
* @param style Display style: "short", "medium", or "long"
|
||||
* @param t i18n translation function
|
||||
* @returns Formatted date string, or $t("checks.never") if undefined/invalid
|
||||
*/
|
||||
export function formatCheckDate(
|
||||
dateString: string | undefined,
|
||||
style: "short" | "medium" | "long",
|
||||
t: (k: string) => string,
|
||||
): string {
|
||||
if (!dateString) return t("checks.never");
|
||||
const d = new Date(dateString);
|
||||
if (isNaN(d.getTime())) return t("checks.never");
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: style,
|
||||
timeStyle: "short",
|
||||
}).format(d);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date string as a relative time (e.g. "in 3h 20m" or "5m ago")
|
||||
* @param dateString ISO date string or undefined
|
||||
* @param t i18n translation function
|
||||
* @returns Relative time string, or empty string if undefined/invalid
|
||||
*/
|
||||
export function formatRelative(dateString: string | undefined, t: (k: string, params?: Record<string, string>) => string): string {
|
||||
if (!dateString) return "";
|
||||
const d = new Date(dateString);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const now = new Date();
|
||||
const diffMs = d.getTime() - now.getTime();
|
||||
const absDiffMs = Math.abs(diffMs);
|
||||
|
||||
if (absDiffMs < 60_000)
|
||||
return diffMs > 0
|
||||
? t("checks.relative.in-less-than-a-minute")
|
||||
: t("checks.relative.just-now");
|
||||
|
||||
const minutes = Math.floor(absDiffMs / 60_000);
|
||||
const hours = Math.floor(absDiffMs / 3_600_000);
|
||||
const days = Math.floor(absDiffMs / 86_400_000);
|
||||
|
||||
let label: string;
|
||||
if (days > 0) {
|
||||
label = `${days}d ${hours % 24}h`;
|
||||
} else if (hours > 0) {
|
||||
label = `${hours}h ${minutes % 60}m`;
|
||||
} else {
|
||||
label = `${minutes}m`;
|
||||
}
|
||||
|
||||
return diffMs > 0
|
||||
? t("checks.relative.in", { label })
|
||||
: t("checks.relative.ago", { label });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,5 @@
|
|||
* Centralized utility exports
|
||||
*/
|
||||
|
||||
export { toDatetimeLocal, fromDatetimeLocal } from './datetime';
|
||||
export { toDatetimeLocal, fromDatetimeLocal, formatCheckDate, formatRelative } from "./datetime";
|
||||
export { getStatusColor, getStatusKey, formatDuration } from "./check";
|
||||
|
|
|
|||
|
|
@ -25,12 +25,10 @@
|
|||
import "bootstrap-icons/font/bootstrap-icons.css";
|
||||
import "../app.scss";
|
||||
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import Header from "$lib/components/Header.svelte";
|
||||
import Toaster from "$lib/components/Toaster.svelte";
|
||||
import VoxPeople from "$lib/components/VoxPeople.svelte";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { appConfig, navigate } from "$lib/stores/config";
|
||||
import { providers } from "$lib/stores/providers";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { locale, t } from "$lib/translations";
|
||||
|
|
@ -53,7 +51,7 @@
|
|||
|
||||
window.onunhandledrejection = (e) => {
|
||||
if (e.reason.name == "NotAuthorizedError") {
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
providers.set(undefined);
|
||||
toasts.addErrorToast({
|
||||
title: $t("errors.session.title"),
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ function onSWupdate(sw_state: { hasUpdate: boolean }, installingWorker: ServiceW
|
|||
sw_state.hasUpdate = true;
|
||||
}
|
||||
|
||||
export const load: Load = async ({ fetch, route, url }) => {
|
||||
export const load: Load = async ({ route, url }) => {
|
||||
const { MODE } = import.meta.env;
|
||||
|
||||
const initLocale =
|
||||
|
|
@ -94,7 +94,7 @@ export const load: Load = async ({ fetch, route, url }) => {
|
|||
|
||||
// Load user session if any
|
||||
try {
|
||||
const user = await refreshUserSession(fetch);
|
||||
const user = await refreshUserSession();
|
||||
if (!url.searchParams.has("lang") && get(locale) != user.settings.language) {
|
||||
locale.set(user.settings.language);
|
||||
}
|
||||
|
|
|
|||
168
web/src/routes/checks/+page.svelte
Normal file
168
web/src/routes/checks/+page.svelte
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Card,
|
||||
Col,
|
||||
Container,
|
||||
Icon,
|
||||
Input,
|
||||
InputGroup,
|
||||
InputGroupText,
|
||||
Table,
|
||||
Row,
|
||||
Badge,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { checks, refreshChecks } from "$lib/stores/checks";
|
||||
|
||||
let searchQuery = $state("");
|
||||
|
||||
// Load checks if not already loaded
|
||||
$effect(() => {
|
||||
if ($checks === undefined) {
|
||||
refreshChecks();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$t("checks.title")} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col md={8}>
|
||||
<h1 class="display-5">
|
||||
<Icon name="check-circle-fill"></Icon>
|
||||
{$t("checks.title")}
|
||||
</h1>
|
||||
<p class="d-flex gap-3 align-items-center text-muted">
|
||||
<span class="lead">
|
||||
{$t("checks.description")}
|
||||
</span>
|
||||
{#if $checks}
|
||||
<span>
|
||||
{$t("checks.available-count", {
|
||||
count: Object.keys($checks).length,
|
||||
})}
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Row class="mb-4">
|
||||
<Col md={8} lg={6}>
|
||||
<InputGroup>
|
||||
<InputGroupText>
|
||||
<Icon name="search"></Icon>
|
||||
</InputGroupText>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={$t("checks.search-placeholder")}
|
||||
bind:value={searchQuery}
|
||||
/>
|
||||
</InputGroup>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#if !$checks}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t("checks.loading")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="table-responsive">
|
||||
<Table hover bordered>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checks.table.name")}</th>
|
||||
<th>{$t("checks.table.availability")}</th>
|
||||
<th>{$t("checks.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#if Object.keys($checks).length == 0}
|
||||
<tr>
|
||||
<td colspan="4" class="text-center text-muted py-4">
|
||||
{$t("checks.no-checkers")}
|
||||
</td>
|
||||
</tr>
|
||||
{:else}
|
||||
{#each Object.entries($checks).filter(([name, _info]) => name
|
||||
.toLowerCase()
|
||||
.indexOf(searchQuery.toLowerCase()) > -1) as [checkerName, checkerInfo]}
|
||||
<tr>
|
||||
<td><strong>{checkerInfo.name || checkerName}</strong></td>
|
||||
<td>
|
||||
{#if checkerInfo.availability}
|
||||
{#if checkerInfo.availability.applyToDomain}
|
||||
<Badge color="success"
|
||||
>{$t("checks.availability.domain")}</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToProviders && checkerInfo.availability.limitToProviders.length > 0}
|
||||
<Badge
|
||||
color="primary"
|
||||
title={checkerInfo.availability.limitToProviders.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
{$t("checks.availability.provider-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if checkerInfo.availability.limitToServices && checkerInfo.availability.limitToServices.length > 0}
|
||||
<Badge
|
||||
color="info"
|
||||
title={checkerInfo.availability.limitToServices.join(
|
||||
", ",
|
||||
)}
|
||||
>
|
||||
{$t("checks.availability.service-specific")}
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else}
|
||||
<Badge color="secondary"
|
||||
>{$t("checks.availability.general")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/checks/{checkerName}" class="btn btn-sm btn-primary">
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t("checks.actions.configure")}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</div>
|
||||
{/if}
|
||||
</Container>
|
||||
346
web/src/routes/checks/[cid]/+page.svelte
Normal file
346
web/src/routes/checks/[cid]/+page.svelte
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Container,
|
||||
Form,
|
||||
FormGroup,
|
||||
Icon,
|
||||
Row,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { getCheckStatus, getCheckOptions, updateCheckOptions } from "$lib/api/checks";
|
||||
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||
import CheckerOptionsGroups from "$lib/components/checkers/CheckerOptionsGroups.svelte";
|
||||
|
||||
let cid = $derived(page.params.cid!);
|
||||
|
||||
let checkStatusPromise = $derived(getCheckStatus(cid));
|
||||
let checkOptionsPromise = $derived(getCheckOptions(cid));
|
||||
let optionValues = $state<Record<string, any>>({});
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
checkOptionsPromise.then((options) => {
|
||||
optionValues = { ...(options || {}) };
|
||||
});
|
||||
});
|
||||
|
||||
async function saveOptions() {
|
||||
saving = true;
|
||||
try {
|
||||
await updateCheckOptions(cid, optionValues);
|
||||
checkOptionsPromise = getCheckOptions(cid);
|
||||
toasts.addToast({
|
||||
message: $t("checks.messages.options-updated"),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checks.messages.update-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanOrphanedOptions(userOpts: any[]) {
|
||||
const validOptIds = new Set(userOpts.map((opt) => opt.id));
|
||||
const cleanedOptions: Record<string, any> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(optionValues)) {
|
||||
if (validOptIds.has(key)) {
|
||||
cleanedOptions[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await updateCheckOptions(cid, cleanedOptions);
|
||||
checkOptionsPromise = getCheckOptions(cid);
|
||||
toasts.addToast({
|
||||
message: $t("checks.messages.options-cleaned"),
|
||||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch (error) {
|
||||
toasts.addErrorToast({
|
||||
message: $t("checks.messages.clean-failed", { error: String(error) }),
|
||||
timeout: 10000,
|
||||
});
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrphanedOptions(userOpts: any[], readOnlyOptGroups: any[]): string[] {
|
||||
const validOptIds = new Set(userOpts.map((opt) => opt.id));
|
||||
|
||||
for (const group of readOnlyOptGroups) {
|
||||
for (const opt of group.opts) {
|
||||
validOptIds.add(opt.id);
|
||||
}
|
||||
}
|
||||
|
||||
return Object.keys(optionValues).filter((key) => !validOptIds.has(key));
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{cid} - {$t("checks.title")} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<Container class="flex-fill my-5">
|
||||
<Row class="mb-4">
|
||||
<Col>
|
||||
<Button color="link" href="/checks" class="mb-2">
|
||||
<Icon name="arrow-left"></Icon>
|
||||
{$t("checks.back-to-checks")}
|
||||
</Button>
|
||||
<h1 class="display-5">
|
||||
<Icon name="check-circle-fill"></Icon>
|
||||
{cid}
|
||||
</h1>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{#await checkStatusPromise}
|
||||
<Card body>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t("checks.loading-info")}
|
||||
</p>
|
||||
</Card>
|
||||
{:then status}
|
||||
{#if status}
|
||||
<Row class="mb-4">
|
||||
<Col md={6}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<strong>{$t("checks.detail.check-information")}</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-sm-4">{$t("checks.detail.name")}</dt>
|
||||
<dd class="col-sm-8">{status.name}</dd>
|
||||
|
||||
<dt class="col-sm-4">{$t("checks.detail.availability")}</dt>
|
||||
<dd class="col-sm-8">
|
||||
{#if status.availability}
|
||||
<div class="d-flex flex-wrap gap-1">
|
||||
{#if status.availability.applyToDomain}
|
||||
<Badge color="success"
|
||||
>{$t("checks.availability.domain-level")}</Badge
|
||||
>
|
||||
{/if}
|
||||
{#if status.availability.limitToProviders && status.availability.limitToProviders.length > 0}
|
||||
<Badge color="primary">
|
||||
{$t("checks.availability.providers", {
|
||||
providers:
|
||||
status.availability.limitToProviders.join(
|
||||
", ",
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if status.availability.limitToServices && status.availability.limitToServices.length > 0}
|
||||
<Badge color="info">
|
||||
{$t("checks.availability.services", {
|
||||
services:
|
||||
status.availability.limitToServices.join(
|
||||
", ",
|
||||
),
|
||||
})}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if !status.availability.applyToDomain && (!status.availability.limitToProviders || status.availability.limitToProviders.length === 0) && (!status.availability.limitToServices || status.availability.limitToServices.length === 0)}
|
||||
<Badge color="secondary"
|
||||
>{$t("checks.availability.general")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<Badge color="secondary"
|
||||
>{$t("checks.availability.general")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</dd>
|
||||
</dl>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col md={6}>
|
||||
{#await checkOptionsPromise}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<p class="text-center mb-0">
|
||||
<span class="spinner-border spinner-border-sm me-2"></span>
|
||||
{$t("checks.detail.loading-options")}
|
||||
</p>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{:then options}
|
||||
{@const userOpts = status.options?.userOpts || []}
|
||||
{@const readOnlyOptGroups = [
|
||||
{
|
||||
key: "adminOpts",
|
||||
label: $t("checks.option-groups.global-settings"),
|
||||
opts: status.options?.adminOpts || [],
|
||||
},
|
||||
{
|
||||
key: "domainOpts",
|
||||
label: $t("checks.option-groups.domain-settings"),
|
||||
opts: status.options?.domainOpts || [],
|
||||
},
|
||||
{
|
||||
key: "serviceOpts",
|
||||
label: $t("checks.option-groups.service-settings"),
|
||||
opts: status.options?.serviceOpts || [],
|
||||
},
|
||||
{
|
||||
key: "runOpts",
|
||||
label: $t("checks.option-groups.check-parameters"),
|
||||
opts: status.options?.runOpts || [],
|
||||
},
|
||||
]}
|
||||
{@const hasAnyOpts =
|
||||
userOpts.length > 0 || readOnlyOptGroups.some((g) => g.opts.length > 0)}
|
||||
{@const orphanedOpts = getOrphanedOptions(userOpts, readOnlyOptGroups)}
|
||||
|
||||
{#if orphanedOpts.length > 0}
|
||||
<Alert color="warning" class="mb-3">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.detail.orphaned-options", {
|
||||
options: orphanedOpts.join(", "),
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
color="danger"
|
||||
size="sm"
|
||||
onclick={() => cleanOrphanedOptions(userOpts)}
|
||||
disabled={saving}
|
||||
>
|
||||
<Icon name="trash"></Icon>
|
||||
{$t("checks.detail.clean-up")}
|
||||
</Button>
|
||||
</div>
|
||||
</Alert>
|
||||
{/if}
|
||||
|
||||
{#if userOpts.length > 0}
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<strong>{$t("checks.detail.configuration")}</strong>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Form
|
||||
on:submit={(e) => {
|
||||
e.preventDefault();
|
||||
saveOptions();
|
||||
}}
|
||||
>
|
||||
{#each userOpts as optDoc}
|
||||
{#if optDoc.id}
|
||||
{@const optName = optDoc.id}
|
||||
<FormGroup>
|
||||
<Resource
|
||||
edit={true}
|
||||
index={optName}
|
||||
specs={optDoc}
|
||||
type={optDoc.type || "string"}
|
||||
bind:value={optionValues[optName]}
|
||||
/>
|
||||
</FormGroup>
|
||||
{/if}
|
||||
{/each}
|
||||
<div class="d-flex gap-2">
|
||||
<Button type="submit" color="success" disabled={saving}>
|
||||
{#if saving}
|
||||
<span
|
||||
class="spinner-border spinner-border-sm me-1"
|
||||
></span>
|
||||
{/if}
|
||||
<Icon name="check-circle"></Icon>
|
||||
{$t("checks.detail.save-changes")}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
|
||||
<CheckerOptionsGroups groups={readOnlyOptGroups} />
|
||||
|
||||
{#if !hasAnyOpts}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="info" class="mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.detail.no-configurable-options")}
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card>
|
||||
<CardBody>
|
||||
<Alert color="danger" class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.detail.error-loading-options", {
|
||||
error: error.message,
|
||||
})}
|
||||
</Alert>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/await}
|
||||
</Col>
|
||||
</Row>
|
||||
{:else}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.check-info-not-found")}
|
||||
</Alert>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Alert color="danger">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.error-loading-check", { error: error.message })}
|
||||
</Alert>
|
||||
{/await}
|
||||
</Container>
|
||||
|
|
@ -22,7 +22,8 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto, invalidateAll } from "$app/navigation";
|
||||
import { invalidateAll } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import { page } from "$app/state";
|
||||
|
||||
import {
|
||||
|
|
@ -73,7 +74,7 @@
|
|||
let selectedDomain = $derived(data.domain.id);
|
||||
function domainChange(dn: string) {
|
||||
if (dn != data.domain.id) {
|
||||
goto(
|
||||
navigate(
|
||||
"/domains/" +
|
||||
encodeURIComponent(domainLink(dn)) +
|
||||
(page.route.id
|
||||
|
|
@ -81,7 +82,11 @@
|
|||
? "/logs"
|
||||
: page.route.id.startsWith("/domains/[dn]/history")
|
||||
? "/history"
|
||||
: ""
|
||||
: page.route.id.startsWith("/domains/[dn]/checks/[cname]")
|
||||
? `/checks/${page.params.cname!}`
|
||||
: page.route.id.startsWith("/domains/[dn]/checks")
|
||||
? "/checks"
|
||||
: ""
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
|
|
@ -102,7 +107,7 @@
|
|||
retrievalInProgress = false;
|
||||
if (page.data.definedhistory) {
|
||||
refreshDomains().then(() => {
|
||||
goto(
|
||||
navigate(
|
||||
"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/" +
|
||||
|
|
@ -130,11 +135,11 @@
|
|||
refreshDomains().then(
|
||||
() => {
|
||||
deleteInProgress = false;
|
||||
goto("/domains");
|
||||
navigate("/domains");
|
||||
},
|
||||
() => {
|
||||
deleteInProgress = false;
|
||||
goto("/domains");
|
||||
navigate("/domains");
|
||||
},
|
||||
);
|
||||
},
|
||||
|
|
@ -172,7 +177,35 @@
|
|||
<SelectDomain bind:selectedDomain />
|
||||
</div>
|
||||
|
||||
{#if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs"))}
|
||||
{#if page.route.id && page.route.id.startsWith("/domains/[dn]/checks/[cname]")}
|
||||
{#if page.route.id.startsWith("/domains/[dn]/checks/[cname]/results/")}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
color="primary"
|
||||
href={"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/checks/" +
|
||||
encodeURIComponent(page.params.cname!) +
|
||||
"/results"}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("zones.return-to-results")}
|
||||
</Button>
|
||||
{:else}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
color="primary"
|
||||
href={"/domains/" +
|
||||
encodeURIComponent(domainLink(selectedDomain)) +
|
||||
"/checks"}
|
||||
>
|
||||
<Icon name="chevron-left" />
|
||||
{$t("zones.return-to-checks")}
|
||||
</Button>
|
||||
{/if}
|
||||
{:else if page.route.id && (page.route.id.startsWith("/domains/[dn]/history") || page.route.id.startsWith("/domains/[dn]/logs") || page.route.id.startsWith("/domains/[dn]/checks"))}
|
||||
<Button
|
||||
class="mt-2"
|
||||
outline
|
||||
|
|
@ -226,6 +259,9 @@
|
|||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/logs`}>
|
||||
{$t("domains.actions.audit")}
|
||||
</DropdownItem>
|
||||
<DropdownItem href={`/domains/${domainLink(selectedDomain)}/checks`}>
|
||||
{$t("domains.actions.view-checks")}
|
||||
</DropdownItem>
|
||||
<DropdownItem divider />
|
||||
<DropdownItem on:click={viewZone} disabled={!$sortedDomains}>
|
||||
{$t("domains.actions.view")}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
let selectedHistory: string = $derived(data.history);
|
||||
function historyChange(history: string) {
|
||||
if (data.history != history) {
|
||||
goto(
|
||||
navigate(
|
||||
"/domains/" +
|
||||
encodeURIComponent(
|
||||
$domains_idx[data.domain.domain]
|
||||
|
|
|
|||
17
web/src/routes/domains/[dn]/checks/+layout.ts
Normal file
17
web/src/routes/domains/[dn]/checks/+layout.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { type Load } from "@sveltejs/kit";
|
||||
|
||||
import { checks, refreshChecks } from "$lib/stores/checks";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export const load: Load = async ({ parent }) => {
|
||||
const data = await parent();
|
||||
|
||||
if (get(checks) === undefined) {
|
||||
refreshChecks();
|
||||
}
|
||||
|
||||
return {
|
||||
...data,
|
||||
isTestsPage: true,
|
||||
};
|
||||
};
|
||||
217
web/src/routes/domains/[dn]/checks/+page.svelte
Normal file
217
web/src/routes/domains/[dn]/checks/+page.svelte
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import { page } from "$app/state";
|
||||
import { Card, Icon, Table, Badge, Button, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
|
||||
import { checks } from "$lib/stores/checks";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
let checksPromise = $derived(listAvailableChecks(data.domain.id));
|
||||
let runCheckModal: RunCheckModal;
|
||||
let togglingChecks = $state(new Set<string>());
|
||||
|
||||
function handleCheckTriggered(_: string, checkName: string) {
|
||||
// Refresh the check list to show updated status
|
||||
checksPromise = listAvailableChecks(data.domain.id);
|
||||
navigate(`/domains/${page.params.dn!}/checks/${checkName}/results`);
|
||||
}
|
||||
|
||||
async function handleToggleEnabled(check: AvailableCheck) {
|
||||
const next = new Set(togglingChecks);
|
||||
next.add(check.checker_name);
|
||||
togglingChecks = next;
|
||||
|
||||
try {
|
||||
const newEnabled = !check.enabled;
|
||||
if (check.schedule) {
|
||||
await updateCheckSchedule(check.schedule.id!, {
|
||||
...check.schedule,
|
||||
enabled: newEnabled,
|
||||
});
|
||||
} else {
|
||||
// No schedule record yet — create one to persist the disabled state.
|
||||
// (Enabled → Enabled needs no action since that's the implicit default.)
|
||||
await createCheckSchedule({
|
||||
checker_name: check.checker_name,
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: 0,
|
||||
enabled: newEnabled,
|
||||
});
|
||||
}
|
||||
checksPromise = listAvailableChecks(data.domain.id);
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ title: $t("checks.list.error-loading", { error: e.message }) });
|
||||
} finally {
|
||||
const after = new Set(togglingChecks);
|
||||
after.delete(check.checker_name);
|
||||
togglingChecks = after;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Checks - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<h2>
|
||||
{$t("checks.list.title")}<span class="font-monospace">{data.domain.domain}</span>
|
||||
</h2>
|
||||
|
||||
{#await checksPromise}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checks.list.loading")}</p>
|
||||
</div>
|
||||
{:then availableChecks}
|
||||
{#if !$checks}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checks.list.loading-checks")}</p>
|
||||
</div>
|
||||
{:else if !availableChecks || availableChecks.length === 0}
|
||||
<Card body class="mt-3">
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.list.no-checks")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<Table hover striped class="mt-3">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checks.list.table.checker")}</th>
|
||||
<th>{$t("checks.list.table.status")}</th>
|
||||
<th>{$t("checks.list.table.last-run")}</th>
|
||||
<th>{$t("checks.list.table.schedule")}</th>
|
||||
<th>{$t("checks.list.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each availableChecks as check}
|
||||
{@const checkInfo = $checks[check.checker_name]}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
<strong>{checkInfo?.name || check.checker_name}</strong>
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{#if check.last_result !== undefined}
|
||||
<Badge color={getStatusColor(check.last_result.status)}>
|
||||
{$t(getStatusKey(check.last_result.status))}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">{$t("checks.status.not-run")}</Badge>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(check.last_result?.executed_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="toggle-{check.checker_name}"
|
||||
checked={check.enabled}
|
||||
disabled={togglingChecks.has(check.checker_name)}
|
||||
onchange={() => handleToggleEnabled(check)}
|
||||
/>
|
||||
<label
|
||||
class="form-check-label small"
|
||||
for="toggle-{check.checker_name}"
|
||||
>
|
||||
{check.enabled
|
||||
? $t("checks.list.schedule.enabled")
|
||||
: $t("checks.list.schedule.disabled")}
|
||||
</label>
|
||||
</div>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onclick={() =>
|
||||
runCheckModal.open(
|
||||
check.checker_name,
|
||||
checkInfo?.name || check.checker_name,
|
||||
)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("checks.list.run-check")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("checks.list.view-results")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(check.checker_name)}`}
|
||||
title={$t("checks.list.configure")}
|
||||
>
|
||||
<Icon name="gear"></Icon>
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger" class="mt-3">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.list.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunCheckModal
|
||||
domainId={data.domain.id}
|
||||
onCheckTriggered={handleCheckTriggered}
|
||||
bind:this={runCheckModal}
|
||||
/>
|
||||
266
web/src/routes/domains/[dn]/checks/[cname]/+page.svelte
Normal file
266
web/src/routes/domains/[dn]/checks/[cname]/+page.svelte
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Icon,
|
||||
Input,
|
||||
Spinner,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { listAvailableChecks, updateCheckSchedule, createCheckSchedule } from "$lib/api/checks";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import { CheckScopeType, type AvailableCheck } from "$lib/model/check";
|
||||
import { checks } from "$lib/stores/checks";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { formatCheckDate, formatRelative } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
const checkDisplayName = $derived($checks?.[checkName]?.name || checkName);
|
||||
|
||||
// Resolved check data
|
||||
let check = $state<AvailableCheck | null>(null);
|
||||
let loading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
|
||||
// Form state
|
||||
let formEnabled = $state(true);
|
||||
let formIntervalHours = $state(24);
|
||||
let saving = $state(false);
|
||||
|
||||
async function loadCheck() {
|
||||
loading = true;
|
||||
loadError = null;
|
||||
try {
|
||||
const checks = await listAvailableChecks(data.domain.id);
|
||||
const found = checks?.find((c) => c.checker_name === checkName) ?? null;
|
||||
check = found;
|
||||
if (found) {
|
||||
formEnabled = found.enabled;
|
||||
formIntervalHours =
|
||||
found.schedule && found.schedule.interval !== undefined && found.schedule.interval > 0
|
||||
? found.schedule.interval / (3600 * 1e9)
|
||||
: 24;
|
||||
}
|
||||
} catch (e: any) {
|
||||
loadError = e.message;
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
loadCheck();
|
||||
|
||||
async function handleSave() {
|
||||
if (!check) return;
|
||||
saving = true;
|
||||
|
||||
try {
|
||||
const intervalNs = Math.max(formIntervalHours, 1) * 3600 * 1e9;
|
||||
|
||||
if (check.schedule) {
|
||||
await updateCheckSchedule(check.schedule.id!, {
|
||||
...check.schedule,
|
||||
enabled: formEnabled,
|
||||
interval: intervalNs,
|
||||
});
|
||||
} else {
|
||||
await createCheckSchedule({
|
||||
checker_name: check.checker_name,
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
interval: intervalNs,
|
||||
enabled: formEnabled,
|
||||
});
|
||||
}
|
||||
|
||||
toasts.addToast({ title: $t("checks.schedule.saved"), type: "success", timeout: 3000 });
|
||||
await loadCheck();
|
||||
} catch (e: any) {
|
||||
toasts.addErrorToast({ title: $t("checks.schedule.save-failed"), message: e.message });
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
{checkName} - {$t("checks.schedule.title")} - {data.domain.domain} - happyDomain
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{checkDisplayName}
|
||||
– {$t("checks.schedule.title")}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="info"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results`}
|
||||
>
|
||||
<Icon name="bar-chart-fill"></Icon>
|
||||
{$t("checks.list.view-results")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checks.list.loading")}</p>
|
||||
</div>
|
||||
{:else if loadError}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.list.error-loading", { error: loadError })}
|
||||
</p>
|
||||
</Card>
|
||||
{:else if !check}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.list.no-checks")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<Card class="mb-4">
|
||||
<CardHeader>
|
||||
<h4 class="mb-0">
|
||||
<Icon name="clock-history"></Icon>
|
||||
{$t("checks.schedule.card-title")}
|
||||
</h4>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div class="mb-4">
|
||||
<div class="form-check form-switch">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
id="schedule-enabled"
|
||||
bind:checked={formEnabled}
|
||||
disabled={saving}
|
||||
/>
|
||||
<label class="form-check-label" for="schedule-enabled">
|
||||
{#if formEnabled}
|
||||
<Badge color="success">{$t("checks.schedule.auto-enabled")}</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">{$t("checks.schedule.auto-disabled")}</Badge
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if formEnabled}
|
||||
<div class="mb-4">
|
||||
<label for="schedule-interval" class="form-label fw-semibold">
|
||||
{$t("checks.schedule.interval-label")}
|
||||
</label>
|
||||
<div class="input-group" style="max-width: 300px;">
|
||||
<Input
|
||||
type="number"
|
||||
id="schedule-interval"
|
||||
min={1}
|
||||
step={1}
|
||||
bind:value={formIntervalHours}
|
||||
disabled={saving}
|
||||
/>
|
||||
<span class="input-group-text">
|
||||
{$t("checks.schedule.hours")}
|
||||
</span>
|
||||
</div>
|
||||
<div class="form-text">
|
||||
{$t("checks.schedule.interval-hint")}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if check.schedule}
|
||||
<div class="mb-4">
|
||||
<div class="row g-3">
|
||||
{#if check.schedule.last_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("checks.schedule.last-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatCheckDate(check.schedule.last_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(check.schedule.last_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
{#if check.enabled && check.schedule.next_run}
|
||||
<div class="col-auto">
|
||||
<span class="text-muted fw-semibold">
|
||||
{$t("checks.schedule.next-run")}:
|
||||
</span>
|
||||
<span>
|
||||
{formatCheckDate(check.schedule.next_run, "medium", $t)}
|
||||
<small class="text-muted">
|
||||
({formatRelative(check.schedule.next_run, $t)})
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="text-muted">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.schedule.no-schedule-yet")}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button color="primary" disabled={saving} onclick={handleSave}>
|
||||
{#if saving}
|
||||
<Spinner size="sm" class="me-1" />
|
||||
{/if}
|
||||
<Icon name="check-lg"></Icon>
|
||||
{$t("checks.schedule.save")}
|
||||
</Button>
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
</div>
|
||||
320
web/src/routes/domains/[dn]/checks/[cname]/results/+page.svelte
Normal file
320
web/src/routes/domains/[dn]/checks/[cname]/results/+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 {
|
||||
Card,
|
||||
Alert,
|
||||
Icon,
|
||||
Table,
|
||||
Badge,
|
||||
Button,
|
||||
Spinner,
|
||||
ButtonGroup,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { onDestroy } from "svelte";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { page } from "$app/state";
|
||||
import {
|
||||
listCheckResults,
|
||||
deleteCheckResult,
|
||||
deleteAllCheckResults,
|
||||
getCheckExecution,
|
||||
} from "$lib/api/checks";
|
||||
import { getCheckStatus } from "$lib/api/checks";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { CheckExecution } from "$lib/model/check";
|
||||
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/check";
|
||||
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
|
||||
let resultsPromise = $derived(listCheckResults(data.domain.id, checkName));
|
||||
let checkPromise = $derived(getCheckStatus(checkName));
|
||||
let runCheckModal: RunCheckModal;
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let pendingExecutions = $state<CheckExecution[]>([]);
|
||||
const pollingIntervals = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
onDestroy(() => {
|
||||
for (const id of pollingIntervals.values()) clearInterval(id);
|
||||
});
|
||||
|
||||
function handleCheckTriggered(execution_id: string) {
|
||||
const placeholder: CheckExecution = {
|
||||
id: execution_id,
|
||||
checker_name: checkName,
|
||||
owner_id: "",
|
||||
target_type: CheckScopeType.CheckScopeDomain,
|
||||
target_id: data.domain.id,
|
||||
status: CheckExecutionStatus.CheckExecutionPending,
|
||||
started_at: new Date().toISOString(),
|
||||
};
|
||||
pendingExecutions = [...pendingExecutions, placeholder];
|
||||
|
||||
const intervalId = setInterval(async () => {
|
||||
try {
|
||||
const exec = await getCheckExecution(data.domain.id, checkName, execution_id);
|
||||
pendingExecutions = pendingExecutions.map((e) =>
|
||||
e.id === execution_id ? exec : e,
|
||||
);
|
||||
|
||||
if (
|
||||
exec.status === CheckExecutionStatus.CheckExecutionCompleted ||
|
||||
exec.status === CheckExecutionStatus.CheckExecutionFailed
|
||||
) {
|
||||
clearInterval(intervalId);
|
||||
pollingIntervals.delete(execution_id);
|
||||
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(intervalId);
|
||||
pollingIntervals.delete(execution_id);
|
||||
pendingExecutions = pendingExecutions.filter((e) => e.id !== execution_id);
|
||||
}
|
||||
}, 2000);
|
||||
pollingIntervals.set(execution_id, intervalId);
|
||||
}
|
||||
|
||||
async function handleDeleteResult(resultId: string) {
|
||||
if (!confirm($t("checks.results.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCheckResult(data.domain.id, checkName, resultId);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checks.results.delete-failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteAll() {
|
||||
if (!confirm($t("checks.results.delete-all-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteAllCheckResults(data.domain.id, checkName);
|
||||
resultsPromise = listCheckResults(data.domain.id, checkName);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checks.results.delete-all-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{checkName} Results - {data.domain.domain} - happyDomain</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2>
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{#await checkPromise then check}
|
||||
{check.name || checkName}
|
||||
{:catch}
|
||||
{checkName}
|
||||
{/await}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="dark"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`}
|
||||
>
|
||||
<Icon name="gear-fill"></Icon>
|
||||
{$t("checks.results.configure")}
|
||||
</Button>
|
||||
{#await checkPromise then check}
|
||||
<Button
|
||||
color="primary"
|
||||
onclick={() => runCheckModal.open(checkName, check.name || checkName)}
|
||||
>
|
||||
<Icon name="play-fill"></Icon>
|
||||
{$t("checks.results.run-check-now")}
|
||||
</Button>
|
||||
{/await}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
{#key errorMessage}
|
||||
<Alert color="danger" dismissible>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#await resultsPromise}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checks.results.loading")}</p>
|
||||
</div>
|
||||
{:then results}
|
||||
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
|
||||
<Card body>
|
||||
<p class="text-center text-muted mb-0">
|
||||
<Icon name="info-circle"></Icon>
|
||||
{$t("checks.results.no-results")}
|
||||
</p>
|
||||
</Card>
|
||||
{:else}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h4>{$t("checks.results.title", { count: results?.length ?? 0 })}</h4>
|
||||
{#if results?.length}
|
||||
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
|
||||
<Icon name="trash"></Icon>
|
||||
{$t("checks.results.delete-all")}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Table hover striped>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{$t("checks.results.table.executed-at")}</th>
|
||||
<th class="text-center">{$t("checks.results.table.status")}</th>
|
||||
<th>{$t("checks.results.table.message")}</th>
|
||||
<th>{$t("checks.results.table.duration")}</th>
|
||||
<th class="text-center">{$t("checks.results.table.type")}</th>
|
||||
<th>{$t("checks.results.table.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each pendingExecutions as exec (exec.id)}
|
||||
<tr class="table-warning">
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(exec.started_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge
|
||||
color={exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||
? "info"
|
||||
: "secondary"}
|
||||
>
|
||||
{exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||
? $t("checks.results.pending.running")
|
||||
: $t("checks.results.pending.queued")}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle text-muted">
|
||||
{exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||
? $t("checks.results.pending.running-description")
|
||||
: $t("checks.results.pending.queued-description")}
|
||||
</td>
|
||||
<td class="align-middle">—</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge color="secondary">
|
||||
{#if exec.schedule_id}
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checks.results.type.scheduled")}
|
||||
{:else}
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checks.results.type.manual")}
|
||||
{/if}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle"></td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#each results ?? [] as result}
|
||||
<tr>
|
||||
<td class="align-middle">
|
||||
{formatCheckDate(result.executed_at, "short", $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge color={getStatusColor(result.status)}>
|
||||
{$t(getStatusKey(result.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{result.status_line}
|
||||
{#if result.error}
|
||||
<br />
|
||||
<small class="text-danger">{result.error}</small>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
{formatDuration(result.duration, $t)}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<Badge color="secondary">
|
||||
{#if result.scheduled_check}
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checks.results.type.scheduled")}
|
||||
{:else}
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checks.results.type.manual")}
|
||||
{/if}
|
||||
</Badge>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<ButtonGroup size="sm">
|
||||
<Button
|
||||
color="primary"
|
||||
href={`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}/results/${encodeURIComponent(result.id!)}`}
|
||||
>
|
||||
<Icon name="eye-fill"></Icon>
|
||||
{$t("checks.results.view")}
|
||||
</Button>
|
||||
<Button
|
||||
color="danger"
|
||||
outline
|
||||
onclick={() => handleDeleteResult(result.id!)}
|
||||
>
|
||||
<Icon name="trash"></Icon>
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.results.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<RunCheckModal
|
||||
domainId={data.domain.id}
|
||||
onCheckTriggered={handleCheckTriggered}
|
||||
bind:this={runCheckModal}
|
||||
/>
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
<!--
|
||||
This file is part of the happyDomain (R) project.
|
||||
Copyright (c) 2022-2026 happyDomain
|
||||
Authors: Pierre-Olivier Mercier, et al.
|
||||
|
||||
This program is offered under a commercial and under the AGPL license.
|
||||
For commercial licensing, contact us at <contact@happydomain.org>.
|
||||
|
||||
For AGPL licensing:
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardBody,
|
||||
CardHeader,
|
||||
Col,
|
||||
Icon,
|
||||
Row,
|
||||
Spinner,
|
||||
Table,
|
||||
} from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t } from "$lib/translations";
|
||||
import { page } from "$app/state";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
import {
|
||||
getCheckStatus,
|
||||
getCheckResult,
|
||||
deleteCheckResult,
|
||||
triggerCheck,
|
||||
} from "$lib/api/checks";
|
||||
import type { Domain } from "$lib/model/domain";
|
||||
import type { CheckResult } from "$lib/model/check";
|
||||
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||
|
||||
interface Props {
|
||||
data: { domain: Domain };
|
||||
}
|
||||
|
||||
let { data }: Props = $props();
|
||||
|
||||
const checkName = $derived(page.params.cname || "");
|
||||
const resultId = $derived(page.params.rid || "");
|
||||
|
||||
let resultPromise = $derived(getCheckResult(data.domain.id, checkName, resultId));
|
||||
let checkPromise = $derived(getCheckStatus(checkName));
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let resolvedResult = $state<CheckResult | null>(null);
|
||||
let isRelaunching = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
resultPromise.then((r) => {
|
||||
resolvedResult = r;
|
||||
});
|
||||
});
|
||||
|
||||
async function handleRelaunch() {
|
||||
if (!resolvedResult) return;
|
||||
|
||||
isRelaunching = true;
|
||||
try {
|
||||
await triggerCheck(data.domain.id, checkName, resolvedResult.options);
|
||||
navigate(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checks.result.relaunch-failed");
|
||||
} finally {
|
||||
isRelaunching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm($t("checks.result.delete-confirm"))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteCheckResult(data.domain.id, checkName, resultId);
|
||||
navigate(
|
||||
`/domains/${encodeURIComponent(data.domain.domain)}/checks/${encodeURIComponent(checkName)}`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
errorMessage = error.message || $t("checks.result.delete-failed");
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>
|
||||
Check Result - {checkName} - {data.domain.domain} - happyDomain
|
||||
</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex-fill pb-4 pt-2 mw-100">
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<h2 class="text-truncate">
|
||||
<span class="font-monospace">{data.domain.domain}</span>
|
||||
–
|
||||
{$t("checks.result.title")}
|
||||
</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
outline
|
||||
onclick={handleRelaunch}
|
||||
disabled={!resolvedResult || isRelaunching}
|
||||
>
|
||||
{#if isRelaunching}
|
||||
<Spinner size="sm" />
|
||||
{:else}
|
||||
<Icon name="arrow-repeat"></Icon>
|
||||
{/if}
|
||||
<span class="d-none d-lg-inline">
|
||||
{$t("checks.result.relaunch")}
|
||||
</span>
|
||||
</Button>
|
||||
<Button color="danger" outline onclick={handleDelete} disabled={!resolvedResult}>
|
||||
<Icon name="trash"></Icon>
|
||||
<span class="d-none d-lg-inline">
|
||||
{$t("checks.result.delete")}
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if errorMessage}
|
||||
{#key errorMessage}
|
||||
<Alert color="danger" dismissible>
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
{#await Promise.all([resultPromise, checkPromise])}
|
||||
<div class="mt-5 text-center flex-fill">
|
||||
<Spinner />
|
||||
<p>{$t("checks.result.loading")}</p>
|
||||
</div>
|
||||
{:then [result, check]}
|
||||
<Row>
|
||||
<Col lg>
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="d-flex align-items-end gap-2">
|
||||
<h4 class="mb-0">
|
||||
{check.name || checkName}
|
||||
</h4>
|
||||
</div>
|
||||
{#if result.scheduled_check}
|
||||
<Badge color="info">
|
||||
<Icon name="clock"></Icon>
|
||||
{$t("checks.result.type.scheduled")}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge color="secondary">
|
||||
<Icon name="hand-index"></Icon>
|
||||
{$t("checks.result.type.manual")}
|
||||
</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody class="p-2">
|
||||
<Table borderless size="sm" class="mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th style="width: 200px">{$t("checks.result.field.domain")}</th>
|
||||
<td class="font-monospace">{data.domain.domain}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checks.result.field.executed-at")}</th>
|
||||
<td>{formatCheckDate(result.executed_at, "long", $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checks.result.field.duration")}</th>
|
||||
<td>{formatDuration(result.duration, $t)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checks.result.field.status")}</th>
|
||||
<td>
|
||||
<Badge color={getStatusColor(result.status)}>
|
||||
{$t(getStatusKey(result.status))}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>{$t("checks.result.field.status-message")}</th>
|
||||
<td>{result.status_line}</td>
|
||||
</tr>
|
||||
{#if result.error}
|
||||
<tr>
|
||||
<th>{$t("checks.result.field.error")}</th>
|
||||
<td class="text-danger">{result.error}</td>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
{#if result.options && Object.keys(result.options).length > 0}
|
||||
<Col lg>
|
||||
<Card class="mb-3">
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">
|
||||
<Icon name="sliders"></Icon>
|
||||
{$t("checks.result.check-options")}
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardBody class="p-2">
|
||||
<Table borderless size="sm" class="mb-0">
|
||||
<tbody>
|
||||
{#each Object.entries(check.options ?? {}) as [optKey, optVals]}
|
||||
{#each optVals as option}
|
||||
{@const value =
|
||||
(option.id
|
||||
? result.options[option.id]
|
||||
: undefined) ||
|
||||
option.default ||
|
||||
option.placeholder ||
|
||||
""}
|
||||
<tr>
|
||||
<th
|
||||
class="text-truncate"
|
||||
style="max-width: min(200px, 40vw)"
|
||||
title={option.label}
|
||||
>
|
||||
{option.label}:
|
||||
</th>
|
||||
<td class:text-truncate={typeof value !== "object"}>
|
||||
{#if typeof value === "object"}
|
||||
<pre class="mb-0"><code
|
||||
>{JSON.stringify(
|
||||
value,
|
||||
null,
|
||||
2,
|
||||
)}</code
|
||||
></pre>
|
||||
{:else}
|
||||
{value}
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</Col>
|
||||
{/if}
|
||||
</Row>
|
||||
|
||||
{#if result.report}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h5 class="mb-0">
|
||||
<Icon name="file-earmark-text"></Icon>
|
||||
{$t("checks.result.full-report")}
|
||||
</h5>
|
||||
</CardHeader>
|
||||
<CardBody class="text-truncate p-0">
|
||||
{#if typeof result.report === "string"}
|
||||
<pre class="bg-light p-3 rounded mb-0"><code>{result.report}</code></pre>
|
||||
{:else}
|
||||
<pre class="bg-light p-3 rounded mb-0"><code
|
||||
>{JSON.stringify(result.report, null, 2)}</code
|
||||
></pre>
|
||||
{/if}
|
||||
</CardBody>
|
||||
</Card>
|
||||
{/if}
|
||||
{:catch error}
|
||||
<Card body color="danger">
|
||||
<p class="mb-0">
|
||||
<Icon name="exclamation-triangle-fill"></Icon>
|
||||
{$t("checks.result.error-loading", { error: error.message })}
|
||||
</p>
|
||||
</Card>
|
||||
{/await}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
pre {
|
||||
overflow-x: scroll;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Alert, Icon, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -43,7 +43,7 @@
|
|||
() => {
|
||||
refreshDomains().then(
|
||||
() => {
|
||||
goto(
|
||||
navigate(
|
||||
`/domains/${encodeURIComponent($domains_idx[data.domain.domain] ? data.domain.domain : data.domain.id)}`,
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Button, Col, Container, Icon, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -69,7 +69,7 @@
|
|||
});
|
||||
|
||||
refreshDomains();
|
||||
goto("/domains/");
|
||||
navigate("/domains/");
|
||||
},
|
||||
(error) => {
|
||||
addingNewDomain = false;
|
||||
|
|
|
|||
|
|
@ -22,12 +22,10 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { Alert, Col, Container, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { validateEmail } from "$lib/api/user";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { appConfig, navigate } from "$lib/stores/config";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import { t } from "$lib/translations";
|
||||
import EmailConfirmationForm from "./EmailConfirmationForm.svelte";
|
||||
|
|
@ -50,7 +48,7 @@
|
|||
timeout: 5000,
|
||||
type: "success",
|
||||
});
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
},
|
||||
(err) => {
|
||||
error = err;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -59,7 +59,7 @@
|
|||
timeout: 5000,
|
||||
type: "success",
|
||||
});
|
||||
goto("/");
|
||||
navigate("/");
|
||||
},
|
||||
(error) => {
|
||||
formSent = false;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -55,7 +55,7 @@
|
|||
timeout: 20000,
|
||||
color: "success",
|
||||
});
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
},
|
||||
(error) => {
|
||||
formSent = false;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import { navigate } from "$lib/stores/config";
|
||||
|
||||
import { Button, Col, Input, Row, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
|
|
@ -63,7 +63,7 @@
|
|||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
},
|
||||
(error) => {
|
||||
formSent = false;
|
||||
|
|
|
|||
|
|
@ -22,15 +22,13 @@
|
|||
-->
|
||||
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
|
||||
import { Button, FormGroup, Icon, Input, Label, Spinner } from "@sveltestrap/sveltestrap";
|
||||
|
||||
import { t, locale } from "$lib/translations";
|
||||
import { registerUser } from "$lib/api/user";
|
||||
import type { SignUpForm } from "$lib/model/user";
|
||||
import { checkWeakPassword, checkPasswordConfirmation } from "$lib/password";
|
||||
import { appConfig } from "$lib/stores/config";
|
||||
import { appConfig, navigate } from "$lib/stores/config";
|
||||
import { toasts } from "$lib/stores/toasts";
|
||||
import CaptchaWidget from "$lib/components/CaptchaWidget.svelte";
|
||||
|
||||
|
|
@ -73,7 +71,7 @@
|
|||
type: "success",
|
||||
timeout: 5000,
|
||||
});
|
||||
goto("/login");
|
||||
navigate("/login");
|
||||
},
|
||||
(error) => {
|
||||
formSent = false;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue