Compare commits
32 commits
505a1080db
...
d19f7430f2
| Author | SHA1 | Date | |
|---|---|---|---|
| d19f7430f2 | |||
| b209a82bd7 | |||
| 98f3de14d7 | |||
| d474634451 | |||
| 2ff1b4483f | |||
| 032ba41242 | |||
| 5f9beb9dd8 | |||
| c2c0893bfd | |||
| 449c040885 | |||
| f7a16df8d9 | |||
| 0e8ee7e206 | |||
| 655f9003a3 | |||
| a2b2b85bf5 | |||
| 126286e79f | |||
| 6d3b7ceba0 | |||
| f78e6d511c | |||
| 27a436e2ec | |||
| 729c7ea2d3 | |||
| 1ab8bc2f92 | |||
| 55aa611c05 | |||
| 19fbf18791 | |||
| 3f04a89f94 | |||
| 982cb3a87a | |||
| 087960b3c6 | |||
| b3be9ba94d | |||
| f276088fa0 | |||
| a419834199 | |||
| 40141120d2 | |||
| 3536c712a9 | |||
| 4820828919 | |||
| 38661a67d9 | |||
| c384c10a88 |
145 changed files with 17872 additions and 113 deletions
113
checks/domain-expiration.go
Normal file
113
checks/domain-expiration.go
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
package checks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DEFAULT_WARNING_DAYS = 30
|
||||||
|
DEFAULT_CRITICAL_DAYS = 7
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterChecker("domain-expiration", &DomainExpirationCheck{})
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainExpirationCheck struct{}
|
||||||
|
|
||||||
|
func (p *DomainExpirationCheck) ID() string { return "domain-expiration" }
|
||||||
|
func (p *DomainExpirationCheck) Name() string { return "Domain Expiration" }
|
||||||
|
|
||||||
|
func (p *DomainExpirationCheck) Availability() happydns.CheckerAvailability {
|
||||||
|
return happydns.CheckerAvailability{ApplyToDomain: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DomainExpirationCheck) Options() happydns.CheckerOptionsDocumentation {
|
||||||
|
return happydns.CheckerOptionsDocumentation{
|
||||||
|
RunOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "domainName", Type: "string", Label: "Domain name", AutoFill: happydns.AutoFillDomainName, Required: true},
|
||||||
|
},
|
||||||
|
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "warningDays", Type: "number", Label: "Days before expiration to warn", Default: DEFAULT_WARNING_DAYS},
|
||||||
|
{Id: "criticalDays", Type: "number", Label: "Days before expiration to alert", Default: DEFAULT_CRITICAL_DAYS},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *DomainExpirationCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||||
|
// 1. Extract domainName
|
||||||
|
domainName, ok := options["domainName"].(string)
|
||||||
|
if !ok || domainName == "" {
|
||||||
|
return nil, fmt.Errorf("domainName is required")
|
||||||
|
}
|
||||||
|
domainName = strings.TrimSuffix(domainName, ".")
|
||||||
|
|
||||||
|
// 2. Extract thresholds (with defaults)
|
||||||
|
warningDays := extractInt(options, "warningDays", DEFAULT_WARNING_DAYS)
|
||||||
|
criticalDays := extractInt(options, "criticalDays", DEFAULT_CRITICAL_DAYS)
|
||||||
|
|
||||||
|
// 3. Try RDAP, fallback to WHOIS
|
||||||
|
info, err := domaininfo.GetDomainRDAPInfo(ctx, happydns.Origin(domainName))
|
||||||
|
if err != nil {
|
||||||
|
info, err = domaininfo.GetDomainWhoisInfo(ctx, happydns.Origin(domainName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to retrieve domain info: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Check expiration date presence
|
||||||
|
if info.ExpirationDate == nil {
|
||||||
|
return &happydns.CheckResult{
|
||||||
|
Status: happydns.CheckResultStatusUnknown,
|
||||||
|
StatusLine: "Expiration date not available",
|
||||||
|
Report: info,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Compute days remaining
|
||||||
|
daysUntil := int(math.Ceil(time.Until(*info.ExpirationDate).Hours() / 24))
|
||||||
|
|
||||||
|
// 6. Determine status
|
||||||
|
var status happydns.CheckResultStatus
|
||||||
|
var statusLine string
|
||||||
|
switch {
|
||||||
|
case daysUntil < 0:
|
||||||
|
status = happydns.CheckResultStatusCritical
|
||||||
|
statusLine = fmt.Sprintf("Domain expired %d day(s) ago (expired on %s)", -daysUntil, info.ExpirationDate.Format("2006-01-02"))
|
||||||
|
case daysUntil < criticalDays:
|
||||||
|
status = happydns.CheckResultStatusCritical
|
||||||
|
statusLine = fmt.Sprintf("Domain expires in %d day(s) (on %s)", daysUntil, info.ExpirationDate.Format("2006-01-02"))
|
||||||
|
case daysUntil < warningDays:
|
||||||
|
status = happydns.CheckResultStatusWarn
|
||||||
|
statusLine = fmt.Sprintf("Domain expires in %d day(s) (on %s)", daysUntil, info.ExpirationDate.Format("2006-01-02"))
|
||||||
|
default:
|
||||||
|
status = happydns.CheckResultStatusOK
|
||||||
|
statusLine = fmt.Sprintf("Domain valid until %s (%d days)", info.ExpirationDate.Format("2006-01-02"), daysUntil)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &happydns.CheckResult{
|
||||||
|
Status: status,
|
||||||
|
StatusLine: statusLine,
|
||||||
|
Report: info,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractInt reads an int/float64 option with a default fallback.
|
||||||
|
func extractInt(options happydns.CheckerOptions, key string, def int) int {
|
||||||
|
if v, ok := options[key]; ok {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
95
checks/interface.go
Normal file
95
checks/interface.go
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckInterval returns the checker's preferred scheduling bounds,
|
||||||
|
// or nil if the checker does not implement CheckerIntervalProvider.
|
||||||
|
func GetCheckInterval(checker happydns.Checker) *happydns.CheckIntervalSpec {
|
||||||
|
ip, ok := checker.(happydns.CheckerIntervalProvider)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spec := ip.CheckInterval()
|
||||||
|
return &spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHTMLReport renders an HTML report for the given checker and raw JSON report data.
|
||||||
|
// Returns (html, true, nil) if the checker supports HTML reports, or ("", false, nil) if not.
|
||||||
|
func GetHTMLReport(checker happydns.Checker, raw json.RawMessage) (string, bool, error) {
|
||||||
|
hr, ok := checker.(happydns.CheckerHTMLReporter)
|
||||||
|
if !ok {
|
||||||
|
return "", false, nil
|
||||||
|
}
|
||||||
|
html, err := hr.GetHTMLReport(raw)
|
||||||
|
return html, true, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMetrics extracts time-series metrics from a slice of check results.
|
||||||
|
// Returns (report, true, nil) if the checker supports metrics, or (nil, false, nil) if not.
|
||||||
|
func GetMetrics(checker happydns.Checker, results []*happydns.CheckResult) (*happydns.MetricsReport, bool, error) {
|
||||||
|
mr, ok := checker.(happydns.CheckerMetricsReporter)
|
||||||
|
if !ok {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
report, err := mr.ExtractMetrics(results)
|
||||||
|
return report, true, err
|
||||||
|
}
|
||||||
326
checks/ping.go
Normal file
326
checks/ping.go
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
// 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
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
probing "github.com/prometheus-community/pro-bing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
"git.happydns.org/happyDomain/services/abstract"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
RegisterChecker("ping", &PingCheck{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingReport contains the results of a ping check across one or more targets.
|
||||||
|
type PingReport struct {
|
||||||
|
Targets []PingTargetResult `json:"targets"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PingTargetResult contains the ping statistics for a single IP address.
|
||||||
|
type PingTargetResult struct {
|
||||||
|
Address string `json:"address"`
|
||||||
|
RTTMin float64 `json:"rtt_min"`
|
||||||
|
RTTAvg float64 `json:"rtt_avg"`
|
||||||
|
RTTMax float64 `json:"rtt_max"`
|
||||||
|
PacketLoss float64 `json:"packet_loss"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
Received int `json:"received"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type PingCheck struct{}
|
||||||
|
|
||||||
|
func (p *PingCheck) ID() string {
|
||||||
|
return "ping"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PingCheck) Name() string {
|
||||||
|
return "Ping (ICMP)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PingCheck) CheckInterval() happydns.CheckIntervalSpec {
|
||||||
|
return happydns.CheckIntervalSpec{
|
||||||
|
Min: 1 * time.Minute,
|
||||||
|
Max: 1 * time.Hour,
|
||||||
|
Default: 5 * time.Minute,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PingCheck) Availability() happydns.CheckerAvailability {
|
||||||
|
return happydns.CheckerAvailability{
|
||||||
|
ApplyToService: true,
|
||||||
|
LimitToServices: []string{"abstract.Server"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PingCheck) Options() happydns.CheckerOptionsDocumentation {
|
||||||
|
return happydns.CheckerOptionsDocumentation{
|
||||||
|
ServiceOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "warningRTT",
|
||||||
|
Type: "number",
|
||||||
|
Label: "Warning RTT threshold (ms)",
|
||||||
|
Default: float64(100),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "criticalRTT",
|
||||||
|
Type: "number",
|
||||||
|
Label: "Critical RTT threshold (ms)",
|
||||||
|
Default: float64(500),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "warningPacketLoss",
|
||||||
|
Type: "number",
|
||||||
|
Label: "Warning packet loss threshold (%)",
|
||||||
|
Default: float64(10),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "criticalPacketLoss",
|
||||||
|
Type: "number",
|
||||||
|
Label: "Critical packet loss threshold (%)",
|
||||||
|
Default: float64(50),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Id: "count",
|
||||||
|
Type: "number",
|
||||||
|
Label: "Number of pings to send",
|
||||||
|
Default: float64(5),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
RunOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{
|
||||||
|
Id: "service",
|
||||||
|
Label: "Service",
|
||||||
|
AutoFill: happydns.AutoFillService,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getFloatOption(options happydns.CheckerOptions, key string, defaultVal float64) float64 {
|
||||||
|
v, ok := options[key]
|
||||||
|
if !ok {
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
switch val := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return val
|
||||||
|
case json.Number:
|
||||||
|
f, err := val.Float64()
|
||||||
|
if err != nil {
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
default:
|
||||||
|
return defaultVal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getIntOption(options happydns.CheckerOptions, key string, defaultVal int) int {
|
||||||
|
return int(getFloatOption(options, key, float64(defaultVal)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ipsFromServiceOption extracts the IP addresses directly from the auto-filled
|
||||||
|
// service body (abstract.Server). Only IPs actually present in the service
|
||||||
|
// definition are returned, so an IPv4-only service won't trigger IPv6 pings.
|
||||||
|
// The service JSON has the shape:
|
||||||
|
//
|
||||||
|
// {"_svctype":"abstract.Server","Service":{"A":{...},"AAAA":{...}}}
|
||||||
|
func ipsFromServiceOption(svc *happydns.ServiceMessage) []net.IP {
|
||||||
|
var server abstract.Server
|
||||||
|
if err := json.Unmarshal(svc.Service, &server); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var ips []net.IP
|
||||||
|
if server.A != nil && len(server.A.A) > 0 {
|
||||||
|
ips = append(ips, server.A.A)
|
||||||
|
}
|
||||||
|
if server.AAAA != nil && len(server.AAAA.AAAA) > 0 {
|
||||||
|
ips = append(ips, server.AAAA.AAAA)
|
||||||
|
}
|
||||||
|
return ips
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PingCheck) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||||
|
service, ok := options["service"].(*happydns.ServiceMessage)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("service not defined")
|
||||||
|
}
|
||||||
|
if service.Type != "abstract.Server" {
|
||||||
|
return nil, fmt.Errorf("service is %s, expected abstract.Server", service.Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
warningRTT := getFloatOption(options, "warningRTT", 100)
|
||||||
|
criticalRTT := getFloatOption(options, "criticalRTT", 500)
|
||||||
|
warningPacketLoss := getFloatOption(options, "warningPacketLoss", 10)
|
||||||
|
criticalPacketLoss := getFloatOption(options, "criticalPacketLoss", 50)
|
||||||
|
count := getIntOption(options, "count", 5)
|
||||||
|
|
||||||
|
if count < 1 {
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
if count > 20 {
|
||||||
|
count = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefer IPs from the service definition; fall back to live DNS.
|
||||||
|
// Using service IPs avoids pinging addresses not defined in the service
|
||||||
|
// (e.g. live IPv6 records that the service doesn't have).
|
||||||
|
var rawIPs []net.IP
|
||||||
|
if serviceIPs := ipsFromServiceOption(service); len(serviceIPs) > 0 {
|
||||||
|
rawIPs = serviceIPs
|
||||||
|
}
|
||||||
|
if len(rawIPs) == 0 {
|
||||||
|
return nil, fmt.Errorf("no IP addresses found for %s", service.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
report := PingReport{}
|
||||||
|
var overallStatus happydns.CheckResultStatus = happydns.CheckResultStatusOK
|
||||||
|
var summaryParts []string
|
||||||
|
var errs []error
|
||||||
|
|
||||||
|
for _, ip := range rawIPs {
|
||||||
|
addr := ip.String()
|
||||||
|
|
||||||
|
pinger, err := probing.NewPinger(addr)
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("failed to create pinger for %s: %w", addr, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
pinger.Count = count
|
||||||
|
pinger.Timeout = time.Duration(count)*time.Second + 5*time.Second
|
||||||
|
|
||||||
|
if err = pinger.RunWithContext(ctx); err != nil {
|
||||||
|
errs = append(errs, fmt.Errorf("ping failed for %s: %w", addr, err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
stats := pinger.Statistics()
|
||||||
|
target := PingTargetResult{
|
||||||
|
Address: addr,
|
||||||
|
RTTMin: float64(stats.MinRtt.Microseconds()) / 1000.0,
|
||||||
|
RTTAvg: float64(stats.AvgRtt.Microseconds()) / 1000.0,
|
||||||
|
RTTMax: float64(stats.MaxRtt.Microseconds()) / 1000.0,
|
||||||
|
PacketLoss: stats.PacketLoss,
|
||||||
|
Sent: stats.PacketsSent,
|
||||||
|
Received: stats.PacketsRecv,
|
||||||
|
}
|
||||||
|
report.Targets = append(report.Targets, target)
|
||||||
|
|
||||||
|
if target.PacketLoss >= criticalPacketLoss || target.RTTAvg >= criticalRTT {
|
||||||
|
overallStatus = happydns.CheckResultStatusCritical
|
||||||
|
} else if (target.PacketLoss >= warningPacketLoss || target.RTTAvg >= warningRTT) && overallStatus > happydns.CheckResultStatusWarn {
|
||||||
|
overallStatus = happydns.CheckResultStatusWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
summaryParts = append(summaryParts, fmt.Sprintf("%s: %.1fms avg, %.0f%% loss", addr, target.RTTAvg, target.PacketLoss))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no IP responded at all, return the combined errors as a fatal error.
|
||||||
|
if len(report.Targets) == 0 {
|
||||||
|
return nil, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &happydns.CheckResult{
|
||||||
|
Status: overallStatus,
|
||||||
|
StatusLine: strings.Join(summaryParts, " | "),
|
||||||
|
Report: report,
|
||||||
|
}, errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExtractMetrics implements happydns.CheckerMetricsReporter.
|
||||||
|
func (p *PingCheck) ExtractMetrics(results []*happydns.CheckResult) (*happydns.MetricsReport, error) {
|
||||||
|
type seriesKey struct {
|
||||||
|
metric string
|
||||||
|
address string
|
||||||
|
}
|
||||||
|
seriesMap := map[seriesKey]*happydns.MetricSeries{}
|
||||||
|
var seriesOrder []seriesKey
|
||||||
|
|
||||||
|
for _, result := range results {
|
||||||
|
if result.Report == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(result.Report)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var report PingReport
|
||||||
|
if err := json.Unmarshal(raw, &report); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range report.Targets {
|
||||||
|
ts := result.ExecutedAt
|
||||||
|
|
||||||
|
metrics := []struct {
|
||||||
|
suffix string
|
||||||
|
label string
|
||||||
|
unit string
|
||||||
|
value float64
|
||||||
|
}{
|
||||||
|
{"rtt_avg", "RTT Avg", "ms", target.RTTAvg},
|
||||||
|
{"rtt_min", "RTT Min", "ms", target.RTTMin},
|
||||||
|
{"rtt_max", "RTT Max", "ms", target.RTTMax},
|
||||||
|
{"packet_loss", "Packet Loss", "%", target.PacketLoss},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range metrics {
|
||||||
|
key := seriesKey{metric: m.suffix, address: target.Address}
|
||||||
|
s, exists := seriesMap[key]
|
||||||
|
if !exists {
|
||||||
|
s = &happydns.MetricSeries{
|
||||||
|
Name: fmt.Sprintf("%s_%s", m.suffix, target.Address),
|
||||||
|
Label: fmt.Sprintf("%s (%s)", m.label, target.Address),
|
||||||
|
Unit: m.unit,
|
||||||
|
}
|
||||||
|
seriesMap[key] = s
|
||||||
|
seriesOrder = append(seriesOrder, key)
|
||||||
|
}
|
||||||
|
s.Points = append(s.Points, happydns.MetricPoint{
|
||||||
|
Timestamp: ts,
|
||||||
|
Value: m.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var series []happydns.MetricSeries
|
||||||
|
for _, key := range seriesOrder {
|
||||||
|
series = append(series, *seriesMap[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
return &happydns.MetricsReport{Series: series}, nil
|
||||||
|
}
|
||||||
615
checks/zonemaster.go
Normal file
615
checks/zonemaster.go
Normal file
|
|
@ -0,0 +1,615 @@
|
||||||
|
package checks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"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) CheckInterval() happydns.CheckIntervalSpec {
|
||||||
|
return happydns.CheckIntervalSpec{
|
||||||
|
Min: 1 * time.Hour,
|
||||||
|
Max: 7 * 24 * time.Hour,
|
||||||
|
Default: 24 * time.Hour,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
Language string `json:"language,omitempty"`
|
||||||
|
Params map[string]any `json:"params"`
|
||||||
|
Results []testResult `json:"results"`
|
||||||
|
TestcaseDescriptions map[string]string `json:"testcase_descriptions,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ZonemasterCheck) callJSONRPC(ctx context.Context, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewBuffer(jsonData))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
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(ctx context.Context, 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(ctx, 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()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, fmt.Errorf("test cancelled (test ID: %s): %w", testID, ctx.Err())
|
||||||
|
|
||||||
|
case <-ticker.C:
|
||||||
|
result, err := p.callJSONRPC(ctx, 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(ctx, 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)
|
||||||
|
}
|
||||||
|
results.Language = language
|
||||||
|
|
||||||
|
// 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.CheckResultStatusCritical
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML report ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// zmLevelDisplayOrder defines the severity order used for sorting and display.
|
||||||
|
var zmLevelDisplayOrder = []string{"CRITICAL", "ERROR", "WARNING", "NOTICE", "INFO", "DEBUG"}
|
||||||
|
|
||||||
|
var zmLevelRank = func() map[string]int {
|
||||||
|
m := make(map[string]int, len(zmLevelDisplayOrder))
|
||||||
|
for i, l := range zmLevelDisplayOrder {
|
||||||
|
m[l] = len(zmLevelDisplayOrder) - i
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}()
|
||||||
|
|
||||||
|
type zmLevelCount struct {
|
||||||
|
Level string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
type zmModuleGroup struct {
|
||||||
|
Name string
|
||||||
|
Position int // first-seen index, used as tiebreaker in sort
|
||||||
|
Results []testResult
|
||||||
|
Levels []zmLevelCount // sorted by severity desc, zeros omitted
|
||||||
|
Worst string
|
||||||
|
Open bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type zmTemplateData struct {
|
||||||
|
Domain string
|
||||||
|
CreatedAt string
|
||||||
|
HashID string
|
||||||
|
Language string
|
||||||
|
Modules []zmModuleGroup
|
||||||
|
Totals []zmLevelCount // sorted by severity desc, zeros omitted
|
||||||
|
}
|
||||||
|
|
||||||
|
var zonemasterHTMLTemplate = template.Must(
|
||||||
|
template.New("zonemaster").
|
||||||
|
Funcs(template.FuncMap{
|
||||||
|
"badgeClass": func(level string) string {
|
||||||
|
switch strings.ToUpper(level) {
|
||||||
|
case "CRITICAL":
|
||||||
|
return "badge-critical"
|
||||||
|
case "ERROR":
|
||||||
|
return "badge-error"
|
||||||
|
case "WARNING":
|
||||||
|
return "badge-warning"
|
||||||
|
case "NOTICE":
|
||||||
|
return "badge-notice"
|
||||||
|
case "INFO":
|
||||||
|
return "badge-info"
|
||||||
|
default:
|
||||||
|
return "badge-debug"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}).
|
||||||
|
Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="{{.Language}}">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Zonemaster{{if .Domain}} — {{.Domain}}{{end}}</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
body { margin: 0; padding: 1rem; }
|
||||||
|
a { color: inherit; }
|
||||||
|
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||||
|
|
||||||
|
/* Header card */
|
||||||
|
.hd {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem 1.1rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.hd h1 { margin: 0 0 .2rem; font-size: 1.15rem; font-weight: 700; }
|
||||||
|
.hd .meta { color: #6b7280; font-size: .82rem; margin-bottom: .6rem; }
|
||||||
|
.totals { display: flex; gap: .35rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: .18em .55em;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: .72rem; font-weight: 700;
|
||||||
|
letter-spacing: .02em; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge-critical { background: #fee2e2; color: #991b1b; }
|
||||||
|
.badge-error { background: #ffedd5; color: #9a3412; }
|
||||||
|
.badge-warning { background: #fef3c7; color: #92400e; }
|
||||||
|
.badge-notice { background: #e0f2fe; color: #075985; }
|
||||||
|
.badge-info { background: #dbeafe; color: #1e40af; }
|
||||||
|
.badge-debug { background: #f3f4f6; color: #4b5563; }
|
||||||
|
|
||||||
|
/* Accordion */
|
||||||
|
details {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: .45rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
summary::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: .65rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: transform .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
details[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.mod-name { font-weight: 600; flex: 1; font-size: .9rem; }
|
||||||
|
.mod-badges { display: flex; gap: .25rem; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Result rows */
|
||||||
|
.results { border-top: 1px solid #f3f4f6; }
|
||||||
|
.row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: max-content 1fr;
|
||||||
|
gap: .6rem;
|
||||||
|
padding: .45rem 1rem;
|
||||||
|
border-bottom: 1px solid #f9fafb;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.row:last-child { border-bottom: none; }
|
||||||
|
.row-msg { color: #374151; }
|
||||||
|
.row-tc { font-size: .75rem; color: #9ca3af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hd">
|
||||||
|
<h1>Zonemaster{{if .Domain}} — <code>{{.Domain}}</code>{{end}}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
{{- if .CreatedAt}}Run at {{.CreatedAt}}{{end -}}
|
||||||
|
{{- if and .CreatedAt .HashID}} · {{end -}}
|
||||||
|
{{- if .HashID}}ID: <code>{{.HashID}}</code>{{end -}}
|
||||||
|
</div>
|
||||||
|
<div class="totals">
|
||||||
|
{{- range .Totals}}
|
||||||
|
<span class="badge {{badgeClass .Level}}">{{.Level}} {{.Count}}</span>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{range .Modules -}}
|
||||||
|
<details{{if .Open}} open{{end}}>
|
||||||
|
<summary>
|
||||||
|
<span class="mod-name">{{.Name}}</span>
|
||||||
|
<span class="mod-badges">
|
||||||
|
{{- range .Levels}}
|
||||||
|
<span class="badge {{badgeClass .Level}}">{{.Count}}</span>
|
||||||
|
{{- end}}
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div class="results">
|
||||||
|
{{- range .Results}}
|
||||||
|
<div class="row">
|
||||||
|
<span class="badge {{badgeClass .Level}}">{{.Level}}</span>
|
||||||
|
<div>
|
||||||
|
<div class="row-msg">{{.Message}}</div>
|
||||||
|
{{- if .Testcase}}<div class="row-tc">{{.Testcase}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{- end}}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{end -}}
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHTMLReport implements happydns.CheckerHTMLReporter.
|
||||||
|
func (p *ZonemasterCheck) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||||
|
var results zonemasterResults
|
||||||
|
if err := json.Unmarshal(raw, &results); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unmarshal zonemaster results: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group results by module, preserving first-seen order.
|
||||||
|
moduleOrder := []string{}
|
||||||
|
moduleMap := map[string][]testResult{}
|
||||||
|
for _, r := range results.Results {
|
||||||
|
if _, seen := moduleMap[r.Module]; !seen {
|
||||||
|
moduleOrder = append(moduleOrder, r.Module)
|
||||||
|
}
|
||||||
|
moduleMap[r.Module] = append(moduleMap[r.Module], r)
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCounts := map[string]int{}
|
||||||
|
|
||||||
|
var modules []zmModuleGroup
|
||||||
|
for _, name := range moduleOrder {
|
||||||
|
rs := moduleMap[name]
|
||||||
|
counts := map[string]int{}
|
||||||
|
for _, r := range rs {
|
||||||
|
lvl := strings.ToUpper(r.Level)
|
||||||
|
counts[lvl]++
|
||||||
|
totalCounts[lvl]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find worst level and build sorted level-count slice.
|
||||||
|
worst := ""
|
||||||
|
worstRank := -1
|
||||||
|
var levels []zmLevelCount
|
||||||
|
for _, l := range zmLevelDisplayOrder {
|
||||||
|
if n, ok := counts[l]; ok && n > 0 {
|
||||||
|
levels = append(levels, zmLevelCount{Level: l, Count: n})
|
||||||
|
if zmLevelRank[l] > worstRank {
|
||||||
|
worstRank = zmLevelRank[l]
|
||||||
|
worst = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Append any unknown levels last.
|
||||||
|
for l, n := range counts {
|
||||||
|
if _, known := zmLevelRank[l]; !known {
|
||||||
|
levels = append(levels, zmLevelCount{Level: l, Count: n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
modules = append(modules, zmModuleGroup{
|
||||||
|
Name: name,
|
||||||
|
Position: len(modules),
|
||||||
|
Results: rs,
|
||||||
|
Levels: levels,
|
||||||
|
Worst: worst,
|
||||||
|
Open: worst == "CRITICAL" || worst == "ERROR",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort modules: most severe first, then by original appearance order.
|
||||||
|
sort.Slice(modules, func(i, j int) bool {
|
||||||
|
ri, rj := zmLevelRank[modules[i].Worst], zmLevelRank[modules[j].Worst]
|
||||||
|
if ri != rj {
|
||||||
|
return ri > rj
|
||||||
|
}
|
||||||
|
return modules[i].Position < modules[j].Position
|
||||||
|
})
|
||||||
|
|
||||||
|
// Build sorted totals slice.
|
||||||
|
var totals []zmLevelCount
|
||||||
|
for _, l := range zmLevelDisplayOrder {
|
||||||
|
if n, ok := totalCounts[l]; ok && n > 0 {
|
||||||
|
totals = append(totals, zmLevelCount{Level: l, Count: n})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
domain := ""
|
||||||
|
if d, ok := results.Params["domain"]; ok {
|
||||||
|
domain = fmt.Sprintf("%v", d)
|
||||||
|
}
|
||||||
|
|
||||||
|
lang := results.Language
|
||||||
|
if lang == "" {
|
||||||
|
lang = "en"
|
||||||
|
}
|
||||||
|
|
||||||
|
data := zmTemplateData{
|
||||||
|
Domain: domain,
|
||||||
|
CreatedAt: results.CreatedAt,
|
||||||
|
HashID: results.HashID,
|
||||||
|
Language: lang,
|
||||||
|
Modules: modules,
|
||||||
|
Totals: totals,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := zonemasterHTMLTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to render zonemaster HTML report: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
@ -33,6 +33,7 @@ import (
|
||||||
"git.happydns.org/happyDomain/internal/api/controller"
|
"git.happydns.org/happyDomain/internal/api/controller"
|
||||||
"git.happydns.org/happyDomain/internal/app"
|
"git.happydns.org/happyDomain/internal/app"
|
||||||
"git.happydns.org/happyDomain/internal/config"
|
"git.happydns.org/happyDomain/internal/config"
|
||||||
|
"git.happydns.org/happyDomain/internal/metrics"
|
||||||
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
_ "git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||||
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
_ "git.happydns.org/happyDomain/internal/storage/leveldb"
|
||||||
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
_ "git.happydns.org/happyDomain/internal/storage/oracle-nosql"
|
||||||
|
|
@ -56,8 +57,10 @@ func main() {
|
||||||
}
|
}
|
||||||
if Version == "custom-build" {
|
if Version == "custom-build" {
|
||||||
controller.HDVersion.Version = versioninfo.Short()
|
controller.HDVersion.Version = versioninfo.Short()
|
||||||
|
metrics.SetBuildInfo(versioninfo.Short())
|
||||||
} else {
|
} else {
|
||||||
versioninfo.Version = Version
|
versioninfo.Version = Version
|
||||||
|
metrics.SetBuildInfo(Version)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("This is happyDomain", versioninfo.Short())
|
log.Println("This is happyDomain", versioninfo.Short())
|
||||||
|
|
|
||||||
11
go.mod
11
go.mod
|
|
@ -17,10 +17,15 @@ require (
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/gorilla/sessions v1.4.0
|
github.com/gorilla/sessions v1.4.0
|
||||||
github.com/lib/pq v1.11.2
|
github.com/lib/pq v1.11.2
|
||||||
|
github.com/likexian/whois v1.15.7
|
||||||
|
github.com/likexian/whois-parser v1.24.21
|
||||||
github.com/miekg/dns v1.1.72
|
github.com/miekg/dns v1.1.72
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
|
github.com/openrdap/rdap v0.9.1
|
||||||
github.com/oracle/nosql-go-sdk v1.4.7
|
github.com/oracle/nosql-go-sdk v1.4.7
|
||||||
github.com/ovh/go-ovh v1.9.0
|
github.com/ovh/go-ovh v1.9.0
|
||||||
|
github.com/prometheus-community/pro-bing v0.8.0
|
||||||
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
|
github.com/rrivera/identicon v0.0.0-20240116195454-d5ba35832c0d
|
||||||
github.com/swaggo/files v1.0.1
|
github.com/swaggo/files v1.0.1
|
||||||
github.com/swaggo/gin-swagger v1.6.1
|
github.com/swaggo/gin-swagger v1.6.1
|
||||||
|
|
@ -53,6 +58,8 @@ require (
|
||||||
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
github.com/PuerkitoBio/goquery v1.11.0 // indirect
|
||||||
github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05 // indirect
|
github.com/Shopify/goreferrer v0.0.0-20250617153402-88c1d9a79b05 // indirect
|
||||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 // indirect
|
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 // indirect
|
||||||
|
github.com/alecthomas/kingpin/v2 v2.4.0 // indirect
|
||||||
|
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b // indirect
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 // indirect
|
||||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
|
@ -157,6 +164,7 @@ require (
|
||||||
github.com/labstack/echo/v4 v4.15.0 // indirect
|
github.com/labstack/echo/v4 v4.15.0 // indirect
|
||||||
github.com/labstack/gommon v0.4.2 // indirect
|
github.com/labstack/gommon v0.4.2 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/likexian/gokit v0.25.16 // indirect
|
||||||
github.com/luadns/luadns-go v0.3.0 // indirect
|
github.com/luadns/luadns-go v0.3.0 // indirect
|
||||||
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
github.com/mailgun/raymond/v2 v2.0.48 // indirect
|
||||||
github.com/mailru/easyjson v0.9.1 // indirect
|
github.com/mailru/easyjson v0.9.1 // indirect
|
||||||
|
|
@ -179,7 +187,6 @@ require (
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||||
github.com/pquerna/otp v1.5.0 // indirect
|
github.com/pquerna/otp v1.5.0 // indirect
|
||||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
|
||||||
github.com/prometheus/client_model v0.6.2 // indirect
|
github.com/prometheus/client_model v0.6.2 // indirect
|
||||||
github.com/prometheus/common v0.67.5 // indirect
|
github.com/prometheus/common v0.67.5 // indirect
|
||||||
github.com/prometheus/procfs v0.20.0 // indirect
|
github.com/prometheus/procfs v0.20.0 // indirect
|
||||||
|
|
@ -207,6 +214,7 @@ require (
|
||||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||||
github.com/vultr/govultr/v2 v2.17.2 // indirect
|
github.com/vultr/govultr/v2 v2.17.2 // indirect
|
||||||
|
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||||
github.com/yosssi/ace v0.0.5 // indirect
|
github.com/yosssi/ace v0.0.5 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
go.mongodb.org/mongo-driver v1.17.9 // indirect
|
||||||
|
|
@ -216,7 +224,6 @@ require (
|
||||||
go.opentelemetry.io/otel v1.40.0 // indirect
|
go.opentelemetry.io/otel v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
go.opentelemetry.io/otel/metric v1.40.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
go.opentelemetry.io/otel/trace v1.40.0 // indirect
|
||||||
go.uber.org/mock v0.6.0 // indirect
|
|
||||||
go.uber.org/ratelimit v0.3.1 // indirect
|
go.uber.org/ratelimit v0.3.1 // indirect
|
||||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
|
|
|
||||||
21
go.sum
21
go.sum
|
|
@ -62,6 +62,10 @@ github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY
|
||||||
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
github.com/ajstarks/svgo v0.0.0-20180226025133-644b8db467af/go.mod h1:K08gAheRH3/J6wwsYMMT4xOr94bZjxIelGM0+d/wbFw=
|
||||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
|
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0 h1:iGVPe/gPqzpXggbbmVLWR0TyJ9UoPoqKL+kspjseZzE=
|
||||||
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
|
github.com/akamai/AkamaiOPEN-edgegrid-golang/v12 v12.3.0/go.mod h1:76JtkiCKMwTdTOlKe9goT4Md+oWjfMouGBQgy+u1bgc=
|
||||||
|
github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjHpqDjYY=
|
||||||
|
github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE=
|
||||||
|
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0=
|
||||||
|
github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107 h1:qagvUyrgOnBIlVRQWOyCZGVKUIYbMBdGdJ104vBpRFU=
|
||||||
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
github.com/aliyun/alibaba-cloud-sdk-go v1.63.107/go.mod h1:SOSDHfe1kX91v3W5QiBsWSLqeLxImobbMX1mxrFHsVQ=
|
||||||
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
github.com/altcha-org/altcha-lib-go v1.0.0 h1:7oPti0aUS+YCep8nwt5b9g4jYfCU55ZruWESL8G9K5M=
|
||||||
|
|
@ -218,8 +222,6 @@ github.com/gin-contrib/sessions v1.0.4 h1:ha6CNdpYiTOK/hTp05miJLbpTSNfOnFg5Jm2kb
|
||||||
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
github.com/gin-contrib/sessions v1.0.4/go.mod h1:ccmkrb2z6iU2osiAHZG3x3J4suJK+OU27oqzlWOqQgs=
|
||||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
|
github.com/go-gandi/go-gandi v0.7.0 h1:gsP33dUspsN1M+ZW9HEgHchK9HiaSkYnltO73RHhSZA=
|
||||||
|
|
@ -432,6 +434,12 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||||
|
github.com/likexian/gokit v0.25.16 h1:wwBeUIN/OdoPp6t00xTnZE8Di/+s969Bl5N2Kw6bzP8=
|
||||||
|
github.com/likexian/gokit v0.25.16/go.mod h1:Wqd4f+iifV0qxA1N3MqePJTUsmRy/lpst9/yXriDx/4=
|
||||||
|
github.com/likexian/whois v1.15.7 h1:sajjDhi2bVD71AHJhjV7jLYxN92H4AWhTwxM8hmj7c0=
|
||||||
|
github.com/likexian/whois v1.15.7/go.mod h1:kdPQtYb+7SQVftBEbCblDadUkycN7Mg1k1/Li/rwvmc=
|
||||||
|
github.com/likexian/whois-parser v1.24.21 h1:MxsrGRxDOiZIVp7q7N/yAIbKuN4QAkGjCpOtTDA5OsM=
|
||||||
|
github.com/likexian/whois-parser v1.24.21/go.mod h1:o3DUruO65Pb8WXCJCTlSVkTbwuYVrBCeoMTw2q0mxY4=
|
||||||
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
|
github.com/luadns/luadns-go v0.3.0 h1:mN2yhFv/LnGvPw/HmvYUhXe+lc95oXUqjlYVeJeOJng=
|
||||||
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
github.com/luadns/luadns-go v0.3.0/go.mod h1:DmPXbrGMpynq1YNDpvgww3NP5Zf4wXM5raAbGrp5L+8=
|
||||||
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw=
|
||||||
|
|
@ -484,6 +492,8 @@ github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGm
|
||||||
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
github.com/onsi/gomega v1.4.3/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY=
|
||||||
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
|
||||||
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
|
||||||
|
github.com/openrdap/rdap v0.9.1 h1:Rv6YbanbiVPsKRvOLdUmlU1AL5+2OFuEFLjFN+mQsCM=
|
||||||
|
github.com/openrdap/rdap v0.9.1/go.mod h1:vKSiotbsENrjM/vaHXLddXbW8iQkBfa+ldEuYEjyLTQ=
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b h1:FfH+VrHHk6Lxt9HdVS0PXzSXFyS2NbZKXv33FYPol0A=
|
||||||
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
github.com/opentracing/opentracing-go v1.2.1-0.20220228012449-10b1cf09e00b/go.mod h1:AC62GU6hc0BrNm+9RK9VSiwa/EUe1bkIeFORAMcHvJU=
|
||||||
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
github.com/oracle/nosql-go-sdk v1.4.7 h1:dqVBSMulObDj0JHm1mAncTHrQg8wIiQJNC0JRNKPACg=
|
||||||
|
|
@ -509,6 +519,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
|
github.com/prometheus-community/pro-bing v0.8.0 h1:CEY/g1/AgERRDjxw5P32ikcOgmrSuXs7xon7ovx6mNc=
|
||||||
|
github.com/prometheus-community/pro-bing v0.8.0/go.mod h1:Idyxz8raDO6TgkUN6ByiEGvWJNyQd40kN9ZUeho3lN0=
|
||||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||||
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
|
@ -571,6 +583,7 @@ github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1F
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
|
@ -623,6 +636,8 @@ github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHo
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||||
|
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||||
|
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
|
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0 h1:6fRhSjgLCkTD3JnJxvaJ4Sj+TYblw757bqYgZaOq5ZY=
|
||||||
|
|
@ -732,8 +747,6 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
|
||||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,13 @@ import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/StackExchange/dnscontrol/v4/models"
|
"github.com/StackExchange/dnscontrol/v4/models"
|
||||||
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
dnscontrol "github.com/StackExchange/dnscontrol/v4/pkg/providers"
|
||||||
"github.com/miekg/dns"
|
"github.com/miekg/dns"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/metrics"
|
||||||
"git.happydns.org/happyDomain/model"
|
"git.happydns.org/happyDomain/model"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -150,7 +152,11 @@ func NewDNSControlProviderAdapter(configAdapter DNSControlConfigAdapter) (ret ha
|
||||||
auditor = p.RecordAuditor
|
auditor = p.RecordAuditor
|
||||||
}
|
}
|
||||||
|
|
||||||
return &DNSControlAdapterNSProvider{provider, auditor}, nil
|
return &DNSControlAdapterNSProvider{
|
||||||
|
DNSServiceProvider: provider,
|
||||||
|
RecordAuditor: auditor,
|
||||||
|
providerName: configAdapter.DNSControlName(),
|
||||||
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
|
// DNSControlAdapterNSProvider wraps a DNSControl provider to implement the happyDomain ProviderActuator interface.
|
||||||
|
|
@ -160,6 +166,8 @@ type DNSControlAdapterNSProvider struct {
|
||||||
DNSServiceProvider dnscontrol.DNSServiceProvider
|
DNSServiceProvider dnscontrol.DNSServiceProvider
|
||||||
// RecordAuditor validates records for provider-specific requirements
|
// RecordAuditor validates records for provider-specific requirements
|
||||||
RecordAuditor dnscontrol.RecordAuditor
|
RecordAuditor dnscontrol.RecordAuditor
|
||||||
|
// providerName is the DNSControl provider name used for metrics labels
|
||||||
|
providerName string
|
||||||
}
|
}
|
||||||
|
|
||||||
// CanListZones checks if the provider supports listing zones (domains).
|
// CanListZones checks if the provider supports listing zones (domains).
|
||||||
|
|
@ -182,10 +190,17 @@ func (p *DNSControlAdapterNSProvider) CanCreateDomain() bool {
|
||||||
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
|
func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happydns.Record, err error) {
|
||||||
var records models.Records
|
var records models.Records
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
defer func() {
|
defer func() {
|
||||||
if a := recover(); a != nil {
|
if a := recover(); a != nil {
|
||||||
err = fmt.Errorf("%s", a)
|
err = fmt.Errorf("%s", a)
|
||||||
}
|
}
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "get_zone_records", status).Inc()
|
||||||
|
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "get_zone_records").Observe(time.Since(start).Seconds())
|
||||||
}()
|
}()
|
||||||
|
|
||||||
records, err = p.DNSServiceProvider.GetZoneRecords(strings.TrimSuffix(domain, "."), nil)
|
records, err = p.DNSServiceProvider.GetZoneRecords(strings.TrimSuffix(domain, "."), nil)
|
||||||
|
|
@ -205,6 +220,16 @@ func (p *DNSControlAdapterNSProvider) GetZoneRecords(domain string) (ret []happy
|
||||||
// before computing corrections.
|
// before computing corrections.
|
||||||
// Returns a slice of corrections, the total number of corrections needed, and any error.
|
// Returns a slice of corrections, the total number of corrections needed, and any error.
|
||||||
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
|
func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []happydns.Record) (ret []*happydns.Correction, nbCorrections int, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "get_zone_corrections", status).Inc()
|
||||||
|
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "get_zone_corrections").Observe(time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
|
||||||
var dc *models.DomainConfig
|
var dc *models.DomainConfig
|
||||||
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
|
dc, err = NewDNSControlDomainConfig(strings.TrimSuffix(domain, "."), rrs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -255,23 +280,47 @@ func (p *DNSControlAdapterNSProvider) GetZoneCorrections(domain string, rrs []ha
|
||||||
// CreateDomain creates a new zone (domain) on the provider.
|
// CreateDomain creates a new zone (domain) on the provider.
|
||||||
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
|
// The fqdn parameter should be a fully qualified domain name (with or without trailing dot).
|
||||||
// Returns an error if the provider doesn't support domain creation or if creation fails.
|
// Returns an error if the provider doesn't support domain creation or if creation fails.
|
||||||
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) error {
|
func (p *DNSControlAdapterNSProvider) CreateDomain(fqdn string) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "create_domain", status).Inc()
|
||||||
|
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "create_domain").Observe(time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
|
||||||
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
|
zc, ok := p.DNSServiceProvider.(dnscontrol.ZoneCreator)
|
||||||
if !ok {
|
if !ok {
|
||||||
return fmt.Errorf("Provider doesn't support domain creation.")
|
err = fmt.Errorf("Provider doesn't support domain creation.")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
err = zc.EnsureZoneExists(strings.TrimSuffix(fqdn, "."), nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListZones retrieves a list of all zones (domains) managed by this provider.
|
// ListZones retrieves a list of all zones (domains) managed by this provider.
|
||||||
// Returns a slice of domain names or an error if the provider doesn't support listing
|
// Returns a slice of domain names or an error if the provider doesn't support listing
|
||||||
// or if the operation fails.
|
// or if the operation fails.
|
||||||
func (p *DNSControlAdapterNSProvider) ListZones() ([]string, error) {
|
func (p *DNSControlAdapterNSProvider) ListZones() (zones []string, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
defer func() {
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
metrics.ProviderAPICallsTotal.WithLabelValues(p.providerName, "list_zones", status).Inc()
|
||||||
|
metrics.ProviderAPIDuration.WithLabelValues(p.providerName, "list_zones").Observe(time.Since(start).Seconds())
|
||||||
|
}()
|
||||||
|
|
||||||
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
|
zl, ok := p.DNSServiceProvider.(dnscontrol.ZoneLister)
|
||||||
if !ok {
|
if !ok {
|
||||||
return nil, fmt.Errorf("Provider doesn't support domain listing.")
|
err = fmt.Errorf("Provider doesn't support domain listing.")
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return zl.ListZones()
|
zones, err = zl.ListZones()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -74,7 +74,7 @@ func NewDomainController(
|
||||||
func (dc *DomainController) ListDomains(c *gin.Context) {
|
func (dc *DomainController) ListDomains(c *gin.Context) {
|
||||||
user := middleware.MyUser(c)
|
user := middleware.MyUser(c)
|
||||||
if user != nil {
|
if user != nil {
|
||||||
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter)
|
apidc := controller.NewDomainController(dc.domainService, dc.remoteZoneImporter, dc.zoneImporter, nil)
|
||||||
apidc.GetDomains(c)
|
apidc.GetDomains(c)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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})
|
||||||
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ func (zc *ZoneController) DeleteZone(c *gin.Context) {
|
||||||
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
|
// @Router /users/{uid}/domains/{domain}/zones/{zoneid} [get]
|
||||||
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
|
// @Router /users/{uid}/providers/{pid}/domains/{domain}/zones/{zoneid} [get]
|
||||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||||
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService)
|
apizc := controller.NewZoneController(zc.zoneService, zc.domainService, zc.zoneCorrectionService, nil)
|
||||||
apizc.GetZone(c)
|
apizc.GetZone(c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
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.
|
// Dependencies holds all use cases required to register the admin API routes.
|
||||||
type Dependencies struct {
|
type Dependencies struct {
|
||||||
AuthUser happydns.AuthUserUsecase
|
AuthUser happydns.AuthUserUsecase
|
||||||
|
Checker happydns.CheckerUsecase
|
||||||
|
CheckScheduler happydns.SchedulerUsecase
|
||||||
Domain happydns.DomainUsecase
|
Domain happydns.DomainUsecase
|
||||||
Provider happydns.ProviderUsecase
|
Provider happydns.ProviderUsecase
|
||||||
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
|
RemoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||||
|
|
@ -48,7 +50,9 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine, s storage.Storage,
|
||||||
|
|
||||||
declareBackupRoutes(cfg, apiRoutes, s)
|
declareBackupRoutes(cfg, apiRoutes, s)
|
||||||
declareDomainRoutes(apiRoutes, dep, s)
|
declareDomainRoutes(apiRoutes, dep, s)
|
||||||
|
declareChecksRoutes(apiRoutes, dep)
|
||||||
declareProviderRoutes(apiRoutes, dep, s)
|
declareProviderRoutes(apiRoutes, dep, s)
|
||||||
|
declareSchedulerRoutes(apiRoutes, dep)
|
||||||
declareSessionsRoutes(cfg, apiRoutes, s)
|
declareSessionsRoutes(cfg, apiRoutes, s)
|
||||||
declareUserAuthsRoutes(apiRoutes, dep, s)
|
declareUserAuthsRoutes(apiRoutes, dep, s)
|
||||||
declareUsersRoutes(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)
|
||||||
|
}
|
||||||
170
internal/api/controller/check_base_controller.go
Normal file
170
internal/api/controller/check_base_controller.go
Normal file
|
|
@ -0,0 +1,170 @@
|
||||||
|
// 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/checks"
|
||||||
|
"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 {
|
||||||
|
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
|
||||||
|
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
|
||||||
|
res[name] = happydns.CheckerResponse{
|
||||||
|
ID: name,
|
||||||
|
Name: checker.Name(),
|
||||||
|
Availability: checker.Availability(),
|
||||||
|
Options: checker.Options(),
|
||||||
|
Interval: checks.GetCheckInterval(checker),
|
||||||
|
HasHTMLReport: hasHTML,
|
||||||
|
HasMetrics: hasMetrics,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
_, hasHTML := checker.(happydns.CheckerHTMLReporter)
|
||||||
|
_, hasMetrics := checker.(happydns.CheckerMetricsReporter)
|
||||||
|
c.JSON(http.StatusOK, happydns.CheckerResponse{
|
||||||
|
ID: checker.ID(),
|
||||||
|
Name: checker.Name(),
|
||||||
|
Availability: checker.Availability(),
|
||||||
|
Options: checker.Options(),
|
||||||
|
Interval: checks.GetCheckInterval(checker),
|
||||||
|
HasHTMLReport: hasHTML,
|
||||||
|
HasMetrics: hasMetrics,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDomainAndServiceIDFromContext extracts optional domainID and serviceID from the gin context.
|
||||||
|
func getDomainAndServiceIDFromContext(c *gin.Context) (domainID *happydns.Identifier, serviceID *happydns.Identifier) {
|
||||||
|
if dn, ok := c.Get("domain"); ok {
|
||||||
|
domainID = &dn.(*happydns.Domain).Id
|
||||||
|
}
|
||||||
|
if svcid, ok := c.Get("serviceid"); ok {
|
||||||
|
tmp := svcid.(happydns.Identifier)
|
||||||
|
serviceID = &tmp
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// For non-basic type, stringify
|
||||||
|
if opts != nil {
|
||||||
|
for i, opt := range *opts {
|
||||||
|
if svc, ok := opt.(*happydns.ServiceMessage); ok {
|
||||||
|
(*opts)[i] = svc.Type + ": " + svc.Comment
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
247
internal/api/controller/check_controller.go
Normal file
247
internal/api/controller/check_controller.go
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
// 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 cname 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/{cname} [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("cname")
|
||||||
|
|
||||||
|
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("cname")
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckerOptions 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 cname path string true "Check name"
|
||||||
|
// @Param domain path string false "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @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/{cname}/options [get]
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/options [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [get]
|
||||||
|
func (uc *CheckerController) GetCheckerOptions(c *gin.Context) {
|
||||||
|
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||||
|
cname := c.Param("cname")
|
||||||
|
|
||||||
|
domainID, serviceID := getDomainAndServiceIDFromContext(c)
|
||||||
|
|
||||||
|
uc.GetCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 cname path string true "Check name"
|
||||||
|
// @Param domain path string false "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @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/{cname}/options [post]
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/options [post]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [post]
|
||||||
|
func (uc *CheckerController) AddCheckerOptions(c *gin.Context) {
|
||||||
|
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||||
|
cname := c.Param("cname")
|
||||||
|
|
||||||
|
domainID, serviceID := getDomainAndServiceIDFromContext(c)
|
||||||
|
|
||||||
|
uc.AddCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 cname path string true "Checker name"
|
||||||
|
// @Param domain path string false "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @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]
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/options [put]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options [put]
|
||||||
|
func (uc *CheckerController) ChangeCheckerOptions(c *gin.Context) {
|
||||||
|
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||||
|
cname := c.Param("cname")
|
||||||
|
|
||||||
|
domainID, serviceID := getDomainAndServiceIDFromContext(c)
|
||||||
|
|
||||||
|
uc.ChangeCheckerOptionsWithScope(c, cname, &user.Id, domainID, serviceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 cname path string true "Check name"
|
||||||
|
// @Param optname path string true "Option name"
|
||||||
|
// @Param domain path string false "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @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/{cname}/options/{optname} [get]
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/options/{optname} [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/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 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 cname path string true "Check name"
|
||||||
|
// @Param optname path string true "Option name"
|
||||||
|
// @Param domain path string false "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @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/{cname}/options/{optname} [put]
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/options/{optname} [put]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/options/{optname} [put]
|
||||||
|
func (uc *CheckerController) SetCheckerOption(c *gin.Context) {
|
||||||
|
user := c.MustGet("LoggedUser").(*happydns.User)
|
||||||
|
cname := c.Param("cname")
|
||||||
|
optname := c.Param("optname")
|
||||||
|
|
||||||
|
uc.SetCheckerOptionWithScope(c, cname, optname, &user.Id, nil, nil)
|
||||||
|
}
|
||||||
592
internal/api/controller/checkresult_controller.go
Normal file
592
internal/api/controller/checkresult_controller.go
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
// 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 (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/checks"
|
||||||
|
"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
|
||||||
|
checkScheduler happydns.SchedulerUsecase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewCheckResultController(
|
||||||
|
scope happydns.CheckScopeType,
|
||||||
|
checkerUC happydns.CheckerUsecase,
|
||||||
|
checkResultUC happydns.CheckResultUsecase,
|
||||||
|
checkScheduler happydns.SchedulerUsecase,
|
||||||
|
) *CheckResultController {
|
||||||
|
return &CheckResultController{
|
||||||
|
scope: scope,
|
||||||
|
checkerUC: checkerUC,
|
||||||
|
checkResultUC: checkResultUC,
|
||||||
|
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 := middleware.MyUser(c)
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getInsideFromContext extracts the inside scope and ID from context based on scope.
|
||||||
|
// For service scope, the inside is the zone. For other scopes, both are nil.
|
||||||
|
func (tc *CheckResultController) getInsideFromContext(c *gin.Context) (*happydns.CheckScopeType, *happydns.Identifier) {
|
||||||
|
if insideZone, ok := c.Get("zone"); tc.scope == happydns.CheckScopeService && ok {
|
||||||
|
scopetmp := happydns.CheckScopeZone
|
||||||
|
return &scopetmp, &insideZone.(*happydns.Zone).Id
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @Success 200 {array} object "List of available checks"
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /domains/{domain}/checks [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks [get]
|
||||||
|
func (tc *CheckResultController) ListAvailableChecks(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
domain := c.MustGet("domain").(*happydns.Domain)
|
||||||
|
var service *happydns.Service
|
||||||
|
|
||||||
|
if svc, ok := c.Get("service"); ok {
|
||||||
|
service = svc.(*happydns.Service)
|
||||||
|
}
|
||||||
|
|
||||||
|
targetID, err := tc.getTargetFromContext(c)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
checks, err := tc.checkResultUC.ListCheckerStatuses(tc.scope, targetID, insideScope, insideID, user, domain, service)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname} [get]
|
||||||
|
func (tc *CheckResultController) ListLatestCheckResults(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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, 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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/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
|
||||||
|
}
|
||||||
|
|
||||||
|
var insideId *happydns.Identifier
|
||||||
|
var insideScope *happydns.CheckScopeType
|
||||||
|
if insideZone, ok := c.Get("zone"); tc.scope == happydns.CheckScopeService && ok {
|
||||||
|
scopetmp := happydns.CheckScopeZone
|
||||||
|
insideScope = &scopetmp
|
||||||
|
|
||||||
|
insideId = &insideZone.(*happydns.Zone).Id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, insideScope, insideId, user.Id, options.Options)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusAccepted, gin.H{"execution_id": executionID.String()})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/executions/{execution_id} [get]
|
||||||
|
func (tc *CheckResultController) GetCheckExecutionStatus(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
if !execution.OwnerId.Equals(user.Id) {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, happydns.ErrNotFound)
|
||||||
|
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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [get]
|
||||||
|
func (tc *CheckResultController) ListCheckResults(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 limit parameter
|
||||||
|
limit := 10
|
||||||
|
if limitStr := c.Query("limit"); limitStr != "" {
|
||||||
|
fmt.Sscanf(limitStr, "%d", &limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, 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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results [delete]
|
||||||
|
func (tc *CheckResultController) DropCheckResults(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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
err = tc.checkResultUC.DeleteAllCheckResults(checkName, tc.scope, targetID, insideScope, insideID, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckResult 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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [get]
|
||||||
|
func (tc *CheckResultController) GetCheckResult(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckResultHTMLReport returns the HTML report for a specific check result
|
||||||
|
//
|
||||||
|
// @Summary Get check result HTML report
|
||||||
|
// @Description Returns the full HTML document generated from the check result's report data. Only available for checkers that implement HTML reporting.
|
||||||
|
// @Tags checks
|
||||||
|
// @Produce html
|
||||||
|
// @Param domain path string true "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @Param cname path string true "Check plugin name"
|
||||||
|
// @Param result_id path string true "Result ID"
|
||||||
|
// @Success 200 {string} string "HTML document"
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/report [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/report [get]
|
||||||
|
func (tc *CheckResultController) GetCheckResultHTMLReport(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checker, err := tc.checkerUC.GetChecker(checkName)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(result.Report)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
htmlContent, supported, err := checks.GetHTMLReport(checker, json.RawMessage(raw))
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support HTML reports", checkName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Data(http.StatusOK, "text/html; charset=utf-8", []byte(htmlContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckResultMetrics returns time-series metrics extracted from check results
|
||||||
|
//
|
||||||
|
// @Summary Get check result metrics
|
||||||
|
// @Description Returns time-series metrics suitable for charting, extracted from recent check results. Only available for checkers that implement metrics reporting.
|
||||||
|
// @Tags checks
|
||||||
|
// @Produce json
|
||||||
|
// @Param domain path string true "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @Param cname path string true "Check plugin name"
|
||||||
|
// @Param limit query int false "Maximum number of results to extract metrics from (default: 100)"
|
||||||
|
// @Success 200 {object} happydns.MetricsReport
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/metrics [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/metrics [get]
|
||||||
|
func (tc *CheckResultController) GetCheckResultMetrics(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
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 100
|
||||||
|
if limitStr := c.Query("limit"); limitStr != "" {
|
||||||
|
fmt.Sscanf(limitStr, "%d", &limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
checker, err := tc.checkerUC.GetChecker(checkName)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
results, err := tc.checkResultUC.ListCheckResultsByTarget(checkName, tc.scope, targetID, insideScope, insideID, user.Id, limit)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report, supported, err := checks.GetMetrics(checker, results)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSingleCheckResultMetrics returns metrics extracted from a single check result
|
||||||
|
//
|
||||||
|
// @Summary Get single check result metrics
|
||||||
|
// @Description Returns metrics extracted from a single check result. Only available for checkers that implement metrics reporting.
|
||||||
|
// @Tags checks
|
||||||
|
// @Produce json
|
||||||
|
// @Param domain path string true "Domain identifier"
|
||||||
|
// @Param zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service identifier"
|
||||||
|
// @Param cname path string true "Check plugin name"
|
||||||
|
// @Param result_id path string true "Result ID"
|
||||||
|
// @Success 200 {object} happydns.MetricsReport
|
||||||
|
// @Failure 404 {object} happydns.ErrorResponse
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /domains/{domain}/checks/{cname}/results/{result_id}/metrics [get]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id}/metrics [get]
|
||||||
|
func (tc *CheckResultController) GetSingleCheckResultMetrics(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
result, err := tc.checkResultUC.GetCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
checker, err := tc.checkerUC.GetChecker(checkName)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
report, supported, err := checks.GetMetrics(checker, []*happydns.CheckResult{result})
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusInternalServerError, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !supported {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, fmt.Errorf("checker %q does not support metrics", checkName))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, report)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 zoneid path string false "Zone identifier"
|
||||||
|
// @Param subdomain path string false "Subdomain"
|
||||||
|
// @Param serviceid path string false "Service 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]
|
||||||
|
// @Router /domains/{domain}/zone/{zoneid}/{subdomain}/services/{serviceid}/checks/{cname}/results/{result_id} [delete]
|
||||||
|
func (tc *CheckResultController) DropCheckResult(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
insideScope, insideID := tc.getInsideFromContext(c)
|
||||||
|
err = tc.checkResultUC.DeleteCheckResult(checkName, tc.scope, targetID, resultID, insideScope, insideID, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
middleware.ErrorResponse(c, http.StatusNotFound, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
@ -37,13 +37,15 @@ type DomainController struct {
|
||||||
domainService happydns.DomainUsecase
|
domainService happydns.DomainUsecase
|
||||||
remoteZoneImporter happydns.RemoteZoneImporterUsecase
|
remoteZoneImporter happydns.RemoteZoneImporterUsecase
|
||||||
zoneImporter happydns.ZoneImporterUsecase
|
zoneImporter happydns.ZoneImporterUsecase
|
||||||
|
checkResultUC happydns.CheckResultUsecase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase) *DomainController {
|
func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporter happydns.RemoteZoneImporterUsecase, zoneImporter happydns.ZoneImporterUsecase, checkResultUC happydns.CheckResultUsecase) *DomainController {
|
||||||
return &DomainController{
|
return &DomainController{
|
||||||
domainService: domainService,
|
domainService: domainService,
|
||||||
remoteZoneImporter: remoteZoneImporter,
|
remoteZoneImporter: remoteZoneImporter,
|
||||||
zoneImporter: zoneImporter,
|
zoneImporter: zoneImporter,
|
||||||
|
checkResultUC: checkResultUC,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,7 +58,7 @@ func NewDomainController(domainService happydns.DomainUsecase, remoteZoneImporte
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security securitydefinitions.basic
|
// @Security securitydefinitions.basic
|
||||||
// @Success 200 {array} happydns.Domain
|
// @Success 200 {array} happydns.DomainWithCheckStatus
|
||||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||||
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
|
// @Failure 404 {object} happydns.ErrorResponse "Unable to retrieve user's domains"
|
||||||
// @Router /domains [get]
|
// @Router /domains [get]
|
||||||
|
|
@ -73,7 +75,25 @@ func (dc *DomainController) GetDomains(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, domains)
|
var statusByDomain map[string]*happydns.CheckResultStatus
|
||||||
|
if dc.checkResultUC != nil {
|
||||||
|
var err error
|
||||||
|
statusByDomain, err = dc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeDomain, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetWorstCheckStatusByUser: %s", err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]*happydns.DomainWithCheckStatus, 0, len(domains))
|
||||||
|
for _, d := range domains {
|
||||||
|
entry := &happydns.DomainWithCheckStatus{Domain: d}
|
||||||
|
if statusByDomain != nil {
|
||||||
|
entry.LastCheckStatus = statusByDomain[d.Id.String()]
|
||||||
|
}
|
||||||
|
result = append(result, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddDomain appends a new domain to those managed.
|
// AddDomain appends a new domain to those managed.
|
||||||
|
|
|
||||||
74
internal/api/controller/domain_info.go
Normal file
74
internal/api/controller/domain_info.go
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
// 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 (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DomainInfoController struct {
|
||||||
|
diuService happydns.DomainInfoUsecase
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDomainInfoController(diuService happydns.DomainInfoUsecase) *DomainInfoController {
|
||||||
|
return &DomainInfoController{
|
||||||
|
diuService: diuService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDomainInfo retrieves domain's administrative information.
|
||||||
|
//
|
||||||
|
// @Summary Get domain administrative information
|
||||||
|
// @Schemes
|
||||||
|
// @Description Retrieve domain's administrative information.
|
||||||
|
// @Tags domains
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security securitydefinitions.basic
|
||||||
|
// @Param domain path string true "Domain name"
|
||||||
|
// @Success 200 {object} happydns.DomainInfo
|
||||||
|
// @Failure 400 {object} happydns.ErrorResponse "Invalid input"
|
||||||
|
// @Failure 500 {object} happydns.ErrorResponse
|
||||||
|
// @Router /domaininfo/{domain} [post]
|
||||||
|
func (dc *DomainInfoController) GetDomainInfo(c *gin.Context) {
|
||||||
|
domain := c.Param("domain")
|
||||||
|
if dn, ok := c.Get("domain"); ok {
|
||||||
|
domain = dn.(*happydns.Domain).DomainName
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := dc.diuService.GetDomainInfo(c.Request.Context(), happydns.Origin(domain))
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, happydns.DomainDoesNotExist) {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, happydns.ErrorResponse{Message: err.Error()})
|
||||||
|
} else {
|
||||||
|
c.AbortWithStatusJSON(http.StatusInternalServerError, happydns.ErrorResponse{Message: err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, info)
|
||||||
|
}
|
||||||
|
|
@ -34,16 +34,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ServiceController struct {
|
type ServiceController struct {
|
||||||
suService happydns.ServiceUsecase
|
checkResultUC happydns.CheckResultUsecase
|
||||||
duService happydns.ZoneServiceUsecase
|
suService happydns.ServiceUsecase
|
||||||
zuService happydns.ZoneUsecase
|
duService happydns.ZoneServiceUsecase
|
||||||
|
zuService happydns.ZoneUsecase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase) *ServiceController {
|
func NewServiceController(duService happydns.ZoneServiceUsecase, suService happydns.ServiceUsecase, zuService happydns.ZoneUsecase, checkResultUC happydns.CheckResultUsecase) *ServiceController {
|
||||||
return &ServiceController{
|
return &ServiceController{
|
||||||
duService: duService,
|
checkResultUC: checkResultUC,
|
||||||
suService: suService,
|
duService: duService,
|
||||||
zuService: zuService,
|
suService: suService,
|
||||||
|
zuService: zuService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,18 +108,28 @@ func (sc *ServiceController) AddZoneService(c *gin.Context) {
|
||||||
// @Param zoneId path string true "Zone identifier"
|
// @Param zoneId path string true "Zone identifier"
|
||||||
// @Param subdomain path string true "Part of the subdomain considered for the service (@ for the root of the zone ; subdomain is relative to the root, do not include it)"
|
// @Param subdomain path string true "Part of the subdomain considered for the service (@ for the root of the zone ; subdomain is relative to the root, do not include it)"
|
||||||
// @Param serviceId path string true "Service identifier"
|
// @Param serviceId path string true "Service identifier"
|
||||||
// @Success 200 {object} happydns.Service
|
// @Success 200 {object} happydns.ServiceWithCheckStatus
|
||||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||||
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
|
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
|
||||||
// @Router /domains/{domainId}/zone/{zoneId}/{subdomain}/services/{serviceId} [get]
|
// @Router /domains/{domainId}/zone/{zoneId}/{subdomain}/services/{serviceId} [get]
|
||||||
func (sc *ServiceController) GetZoneService(c *gin.Context) {
|
func (sc *ServiceController) GetZoneService(c *gin.Context) {
|
||||||
zone := c.MustGet("zone").(*happydns.Zone)
|
user := middleware.MyUser(c)
|
||||||
serviceid := c.MustGet("serviceid").(happydns.Identifier)
|
svc := c.MustGet("service").(*happydns.Service)
|
||||||
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
|
|
||||||
|
|
||||||
_, svc := zone.FindSubdomainService(subdomain, serviceid)
|
result := &happydns.ServiceWithCheckStatus{Service: svc}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, svc)
|
if sc.checkResultUC != nil && user != nil {
|
||||||
|
insideZone := c.MustGet("zone").(*happydns.Zone)
|
||||||
|
insideScope := happydns.CheckScopeZone
|
||||||
|
status, err := sc.checkResultUC.GetWorstCheckStatus(happydns.CheckScopeService, svc.Id, &insideScope, &insideZone.Id, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetWorstCheckStatus: %s", err.Error())
|
||||||
|
} else {
|
||||||
|
result.LastCheckStatus = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateZoneService adds or updates a service inside the given Zone.
|
// UpdateZoneService adds or updates a service inside the given Zone.
|
||||||
|
|
|
||||||
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)
|
||||||
|
}
|
||||||
|
|
@ -35,13 +35,15 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
type ZoneController struct {
|
type ZoneController struct {
|
||||||
|
checkResultUC happydns.CheckResultUsecase
|
||||||
domainService happydns.DomainUsecase
|
domainService happydns.DomainUsecase
|
||||||
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
|
zoneCorrectionService happydns.ZoneCorrectionApplierUsecase
|
||||||
zoneService happydns.ZoneUsecase
|
zoneService happydns.ZoneUsecase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase) *ZoneController {
|
func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.DomainUsecase, zoneCorrectionService happydns.ZoneCorrectionApplierUsecase, checkResultUC happydns.CheckResultUsecase) *ZoneController {
|
||||||
return &ZoneController{
|
return &ZoneController{
|
||||||
|
checkResultUC: checkResultUC,
|
||||||
domainService: domainService,
|
domainService: domainService,
|
||||||
zoneCorrectionService: zoneCorrectionService,
|
zoneCorrectionService: zoneCorrectionService,
|
||||||
zoneService: zoneService,
|
zoneService: zoneService,
|
||||||
|
|
@ -59,14 +61,37 @@ func NewZoneController(zoneService happydns.ZoneUsecase, domainService happydns.
|
||||||
// @Security securitydefinitions.basic
|
// @Security securitydefinitions.basic
|
||||||
// @Param domainId path string true "Domain identifier"
|
// @Param domainId path string true "Domain identifier"
|
||||||
// @Param zoneId path string true "Zone identifier"
|
// @Param zoneId path string true "Zone identifier"
|
||||||
// @Success 200 {object} happydns.Zone
|
// @Success 200 {object} happydns.ZoneWithServicesCheckStatus
|
||||||
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
// @Failure 401 {object} happydns.ErrorResponse "Authentication failure"
|
||||||
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
|
// @Failure 404 {object} happydns.ErrorResponse "Domain or Zone not found"
|
||||||
// @Router /domains/{domainId}/zone/{zoneId} [get]
|
// @Router /domains/{domainId}/zone/{zoneId} [get]
|
||||||
func (zc *ZoneController) GetZone(c *gin.Context) {
|
func (zc *ZoneController) GetZone(c *gin.Context) {
|
||||||
|
user := middleware.MyUser(c)
|
||||||
zone := c.MustGet("zone").(*happydns.Zone)
|
zone := c.MustGet("zone").(*happydns.Zone)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, zone)
|
result := &happydns.ZoneWithServicesCheckStatus{Zone: zone}
|
||||||
|
|
||||||
|
if zc.checkResultUC != nil && user != nil {
|
||||||
|
statusByService, err := zc.checkResultUC.GetWorstCheckStatusByUser(happydns.CheckScopeService, user.Id)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("GetWorstCheckStatusByUser: %s", err.Error())
|
||||||
|
} else if statusByService != nil {
|
||||||
|
result.ServicesCheckStatus = make(map[string]*happydns.CheckResultStatus)
|
||||||
|
for subdomain := range zone.Services {
|
||||||
|
for _, svc := range zone.Services[subdomain] {
|
||||||
|
key := svc.Id.String()
|
||||||
|
if status, ok := statusByService[key]; ok {
|
||||||
|
result.ServicesCheckStatus[key] = status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.ServicesCheckStatus) == 0 {
|
||||||
|
result.ServicesCheckStatus = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetZoneSubdomain returns the services associated with a given subdomain.
|
// GetZoneSubdomain returns the services associated with a given subdomain.
|
||||||
|
|
|
||||||
|
|
@ -43,3 +43,21 @@ func ServiceIdHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ServiceHandler(suService happydns.ServiceUsecase) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
zone := c.MustGet("zone").(*happydns.Zone)
|
||||||
|
serviceid := c.MustGet("serviceid").(happydns.Identifier)
|
||||||
|
subdomain := c.MustGet("subdomain").(happydns.Subdomain)
|
||||||
|
|
||||||
|
_, svc := zone.FindSubdomainService(subdomain, serviceid)
|
||||||
|
if svc == nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"errmsg": fmt.Sprintf("Service not found: %s", serviceid)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("service", svc)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
93
internal/api/route/checker.go
Normal file
93
internal/api/route/checker.go
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
// 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 DeclareCheckersRoutes(router *gin.RouterGroup, checkerUC happydns.CheckerUsecase) *controller.CheckerController {
|
||||||
|
tpc := controller.NewCheckerController(checkerUC)
|
||||||
|
|
||||||
|
router.GET("/checks", tpc.ListCheckers)
|
||||||
|
|
||||||
|
apiCheckRoutes := router.Group("/checks/:cname")
|
||||||
|
apiCheckRoutes.Use(tpc.CheckerHandler)
|
||||||
|
|
||||||
|
apiCheckRoutes.GET("", tpc.GetCheckerStatus)
|
||||||
|
|
||||||
|
DeclareCheckerOptionsRoutes(apiCheckRoutes, tpc)
|
||||||
|
|
||||||
|
return tpc
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeclareScopedCheckersRoutes(
|
||||||
|
scopedRouter *gin.RouterGroup,
|
||||||
|
checkerUC happydns.CheckerUsecase,
|
||||||
|
checkResultUC happydns.CheckResultUsecase,
|
||||||
|
checkScheduler happydns.SchedulerUsecase,
|
||||||
|
scope happydns.CheckScopeType,
|
||||||
|
tpc *controller.CheckerController,
|
||||||
|
) {
|
||||||
|
tc := controller.NewCheckResultController(
|
||||||
|
scope,
|
||||||
|
checkerUC,
|
||||||
|
checkResultUC,
|
||||||
|
checkScheduler,
|
||||||
|
)
|
||||||
|
|
||||||
|
// List all available tests with their status
|
||||||
|
scopedRouter.GET("/checks", tc.ListAvailableChecks)
|
||||||
|
|
||||||
|
apiChecksRoutes := scopedRouter.Group("/checks/:cname")
|
||||||
|
{
|
||||||
|
DeclareCheckerOptionsRoutes(apiChecksRoutes, tpc)
|
||||||
|
|
||||||
|
// Get latest results for a test
|
||||||
|
apiChecksRoutes.GET("", tc.ListLatestCheckResults)
|
||||||
|
|
||||||
|
// Trigger an on-demand test
|
||||||
|
apiChecksRoutes.POST("", tc.TriggerCheck)
|
||||||
|
|
||||||
|
// Check execution routes
|
||||||
|
apiCheckExecutionsRoutes := apiChecksRoutes.Group("/executions/:execution_id")
|
||||||
|
{
|
||||||
|
apiCheckExecutionsRoutes.GET("", tc.GetCheckExecutionStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
DeclareScopedCheckResultRoutes(apiChecksRoutes, tc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func DeclareCheckerOptionsRoutes(apiCheckRoutes *gin.RouterGroup, tpc *controller.CheckerController) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
46
internal/api/route/checkresults.go
Normal file
46
internal/api/route/checkresults.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
// 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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeclareScopedCheckResultRoutes declares test result routes for a specific scope (domain, zone, or service)
|
||||||
|
func DeclareScopedCheckResultRoutes(apiChecksRoutes *gin.RouterGroup, tc *controller.CheckResultController) {
|
||||||
|
// Check metrics route
|
||||||
|
apiChecksRoutes.GET("/metrics", tc.GetCheckResultMetrics)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
apiCheckResultsRoutes.GET("/report", tc.GetCheckResultHTMLReport)
|
||||||
|
apiCheckResultsRoutes.GET("/metrics", tc.GetSingleCheckResultMetrics)
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -39,11 +39,17 @@ func DeclareDomainRoutes(
|
||||||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||||
serviceUC happydns.ServiceUsecase,
|
serviceUC happydns.ServiceUsecase,
|
||||||
|
checkerUC happydns.CheckerUsecase,
|
||||||
|
checkResultUC happydns.CheckResultUsecase,
|
||||||
|
checkScheduler happydns.SchedulerUsecase,
|
||||||
|
domainInfoUC happydns.DomainInfoUsecase,
|
||||||
|
tpc *controller.CheckerController,
|
||||||
) {
|
) {
|
||||||
dc := controller.NewDomainController(
|
dc := controller.NewDomainController(
|
||||||
domainUC,
|
domainUC,
|
||||||
remoteZoneImporter,
|
remoteZoneImporter,
|
||||||
zoneImporter,
|
zoneImporter,
|
||||||
|
checkResultUC,
|
||||||
)
|
)
|
||||||
|
|
||||||
router.GET("/domains", dc.GetDomains)
|
router.GET("/domains", dc.GetDomains)
|
||||||
|
|
@ -56,8 +62,20 @@ func DeclareDomainRoutes(
|
||||||
apiDomainsRoutes.PUT("", dc.UpdateDomain)
|
apiDomainsRoutes.PUT("", dc.UpdateDomain)
|
||||||
apiDomainsRoutes.DELETE("", dc.DelDomain)
|
apiDomainsRoutes.DELETE("", dc.DelDomain)
|
||||||
|
|
||||||
|
DeclareDomainInfoRoutes(apiDomainsRoutes.Group("/info"), domainInfoUC)
|
||||||
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
|
DeclareDomainLogRoutes(apiDomainsRoutes, domainLogUC)
|
||||||
|
|
||||||
|
// Declare test result routes for domain scope
|
||||||
|
|
||||||
|
DeclareScopedCheckersRoutes(
|
||||||
|
apiDomainsRoutes,
|
||||||
|
checkerUC,
|
||||||
|
checkResultUC,
|
||||||
|
checkScheduler,
|
||||||
|
happydns.CheckScopeDomain,
|
||||||
|
tpc,
|
||||||
|
)
|
||||||
|
|
||||||
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
apiDomainsRoutes.POST("/zone", dc.ImportZone)
|
||||||
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
apiDomainsRoutes.POST("/retrieve_zone", dc.RetrieveZone)
|
||||||
|
|
||||||
|
|
@ -68,5 +86,9 @@ func DeclareDomainRoutes(
|
||||||
zoneCorrApplier,
|
zoneCorrApplier,
|
||||||
zoneServiceUC,
|
zoneServiceUC,
|
||||||
serviceUC,
|
serviceUC,
|
||||||
|
checkerUC,
|
||||||
|
checkResultUC,
|
||||||
|
checkScheduler,
|
||||||
|
tpc,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
37
internal/api/route/domain_info.go
Normal file
37
internal/api/route/domain_info.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package route
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/api/controller"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DeclareDomainInfoRoutes(router *gin.RouterGroup, domainInfoUC happydns.DomainInfoUsecase) {
|
||||||
|
dc := controller.NewDomainInfoController(
|
||||||
|
domainInfoUC,
|
||||||
|
)
|
||||||
|
|
||||||
|
router.POST("", dc.GetDomainInfo)
|
||||||
|
}
|
||||||
|
|
@ -34,7 +34,12 @@ type Dependencies struct {
|
||||||
Authentication happydns.AuthenticationUsecase
|
Authentication happydns.AuthenticationUsecase
|
||||||
AuthUser happydns.AuthUserUsecase
|
AuthUser happydns.AuthUserUsecase
|
||||||
CaptchaVerifier happydns.CaptchaVerifier
|
CaptchaVerifier happydns.CaptchaVerifier
|
||||||
|
Checker happydns.CheckerUsecase
|
||||||
|
CheckResult happydns.CheckResultUsecase
|
||||||
|
CheckerSchedule happydns.CheckerScheduleUsecase
|
||||||
|
CheckScheduler happydns.SchedulerUsecase
|
||||||
Domain happydns.DomainUsecase
|
Domain happydns.DomainUsecase
|
||||||
|
DomainInfo happydns.DomainInfoUsecase
|
||||||
DomainLog happydns.DomainLogUsecase
|
DomainLog happydns.DomainLogUsecase
|
||||||
FailureTracker happydns.FailureTracker
|
FailureTracker happydns.FailureTracker
|
||||||
Provider happydns.ProviderUsecase
|
Provider happydns.ProviderUsecase
|
||||||
|
|
@ -88,6 +93,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
||||||
dep.FailureTracker,
|
dep.FailureTracker,
|
||||||
)
|
)
|
||||||
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
|
auc := DeclareAuthUserRoutes(apiRoutes, dep.AuthUser, lc)
|
||||||
|
DeclareDomainInfoRoutes(apiRoutes.Group("/domaininfo/:domain"), dep.DomainInfo)
|
||||||
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
|
DeclareProviderSpecsRoutes(apiRoutes, dep.ProviderSpecs)
|
||||||
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
|
DeclareRegistrationRoutes(apiRoutes, dep.AuthUser, dep.CaptchaVerifier)
|
||||||
DeclareResolverRoutes(apiRoutes, dep.Resolver)
|
DeclareResolverRoutes(apiRoutes, dep.Resolver)
|
||||||
|
|
@ -106,6 +112,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
||||||
apiAuthRoutes.Use(middleware.AuthRequired())
|
apiAuthRoutes.Use(middleware.AuthRequired())
|
||||||
|
|
||||||
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
DeclareAuthenticationCheckRoutes(apiAuthRoutes, lc)
|
||||||
|
tpc := DeclareCheckersRoutes(apiAuthRoutes, dep.Checker)
|
||||||
DeclareDomainRoutes(
|
DeclareDomainRoutes(
|
||||||
apiAuthRoutes,
|
apiAuthRoutes,
|
||||||
dep.Domain,
|
dep.Domain,
|
||||||
|
|
@ -116,10 +123,16 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, dep Dependenc
|
||||||
dep.ZoneCorrectionApplier,
|
dep.ZoneCorrectionApplier,
|
||||||
dep.ZoneService,
|
dep.ZoneService,
|
||||||
dep.Service,
|
dep.Service,
|
||||||
|
dep.Checker,
|
||||||
|
dep.CheckResult,
|
||||||
|
dep.CheckScheduler,
|
||||||
|
dep.DomainInfo,
|
||||||
|
tpc,
|
||||||
)
|
)
|
||||||
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
DeclareProviderRoutes(apiAuthRoutes, dep.Provider)
|
||||||
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
DeclareProviderSettingsRoutes(apiAuthRoutes, dep.ProviderSettings)
|
||||||
DeclareRecordRoutes(apiAuthRoutes)
|
DeclareRecordRoutes(apiAuthRoutes)
|
||||||
|
DeclareTestScheduleRoutes(apiAuthRoutes, dep.CheckerSchedule)
|
||||||
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
|
DeclareUsersRoutes(apiAuthRoutes, dep.User, lc)
|
||||||
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
|
DeclareSessionRoutes(apiAuthRoutes, dep.Session)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,8 +36,12 @@ func DeclareZoneServiceRoutes(
|
||||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||||
serviceUC happydns.ServiceUsecase,
|
serviceUC happydns.ServiceUsecase,
|
||||||
zoneUC happydns.ZoneUsecase,
|
zoneUC happydns.ZoneUsecase,
|
||||||
|
checkerUC happydns.CheckerUsecase,
|
||||||
|
checkResultUC happydns.CheckResultUsecase,
|
||||||
|
checkScheduler happydns.SchedulerUsecase,
|
||||||
|
tpc *controller.CheckerController,
|
||||||
) {
|
) {
|
||||||
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC)
|
sc := controller.NewServiceController(zoneServiceUC, serviceUC, zoneUC, checkResultUC)
|
||||||
|
|
||||||
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
|
apiZonesRoutes.PATCH("", sc.UpdateZoneService)
|
||||||
|
|
||||||
|
|
@ -45,6 +49,19 @@ func DeclareZoneServiceRoutes(
|
||||||
|
|
||||||
apiZonesSubdomainServiceIDRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
|
apiZonesSubdomainServiceIDRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
|
||||||
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
|
apiZonesSubdomainServiceIDRoutes.Use(middleware.ServiceIdHandler(serviceUC))
|
||||||
apiZonesSubdomainServiceIDRoutes.GET("", sc.GetZoneService)
|
|
||||||
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
|
apiZonesSubdomainServiceIDRoutes.DELETE("", sc.DeleteZoneService)
|
||||||
|
|
||||||
|
apiZonesSubdomainServiceRoutes := apiZonesSubdomainRoutes.Group("/services/:serviceid")
|
||||||
|
apiZonesSubdomainServiceRoutes.Use(middleware.ServiceIdHandler(serviceUC), middleware.ServiceHandler(serviceUC))
|
||||||
|
apiZonesSubdomainServiceRoutes.GET("", sc.GetZoneService)
|
||||||
|
|
||||||
|
// Declare test result routes for service scope
|
||||||
|
DeclareScopedCheckersRoutes(
|
||||||
|
apiZonesSubdomainServiceRoutes,
|
||||||
|
checkerUC,
|
||||||
|
checkResultUC,
|
||||||
|
checkScheduler,
|
||||||
|
happydns.CheckScopeService,
|
||||||
|
tpc,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -36,11 +36,16 @@ func DeclareZoneRoutes(
|
||||||
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
zoneCorrApplier happydns.ZoneCorrectionApplierUsecase,
|
||||||
zoneServiceUC happydns.ZoneServiceUsecase,
|
zoneServiceUC happydns.ZoneServiceUsecase,
|
||||||
serviceUC happydns.ServiceUsecase,
|
serviceUC happydns.ServiceUsecase,
|
||||||
|
checkerUC happydns.CheckerUsecase,
|
||||||
|
checkResultUC happydns.CheckResultUsecase,
|
||||||
|
checkScheduler happydns.SchedulerUsecase,
|
||||||
|
tpc *controller.CheckerController,
|
||||||
) {
|
) {
|
||||||
zc := controller.NewZoneController(
|
zc := controller.NewZoneController(
|
||||||
zoneUC,
|
zoneUC,
|
||||||
domainUC,
|
domainUC,
|
||||||
zoneCorrApplier,
|
zoneCorrApplier,
|
||||||
|
checkResultUC,
|
||||||
)
|
)
|
||||||
|
|
||||||
apiZonesRoutes := router.Group("/zone/:zoneid")
|
apiZonesRoutes := router.Group("/zone/:zoneid")
|
||||||
|
|
@ -65,6 +70,10 @@ func DeclareZoneRoutes(
|
||||||
zoneServiceUC,
|
zoneServiceUC,
|
||||||
serviceUC,
|
serviceUC,
|
||||||
zoneUC,
|
zoneUC,
|
||||||
|
checkerUC,
|
||||||
|
checkResultUC,
|
||||||
|
checkScheduler,
|
||||||
|
tpc,
|
||||||
)
|
)
|
||||||
|
|
||||||
apiZonesRoutes.POST("/records", zc.AddRecords)
|
apiZonesRoutes.POST("/records", zc.AddRecords)
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||||
|
|
||||||
admin "git.happydns.org/happyDomain/internal/api-admin/route"
|
admin "git.happydns.org/happyDomain/internal/api-admin/route"
|
||||||
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
providerUC "git.happydns.org/happyDomain/internal/usecase/provider"
|
||||||
|
|
@ -56,12 +57,16 @@ func NewAdmin(app *App) *Admin {
|
||||||
// Prepare usecases (admin uses unrestricted provider access)
|
// Prepare usecases (admin uses unrestricted provider access)
|
||||||
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
|
app.usecases.providerAdmin = providerUC.NewService(app.store, nil)
|
||||||
|
|
||||||
|
router.GET("/metrics", gin.WrapH(promhttp.Handler()))
|
||||||
|
|
||||||
admin.DeclareRoutes(
|
admin.DeclareRoutes(
|
||||||
app.cfg,
|
app.cfg,
|
||||||
router,
|
router,
|
||||||
app.store,
|
app.store,
|
||||||
admin.Dependencies{
|
admin.Dependencies{
|
||||||
AuthUser: app.usecases.authUser,
|
AuthUser: app.usecases.authUser,
|
||||||
|
Checker: app.usecases.checker,
|
||||||
|
CheckScheduler: app.checkScheduler,
|
||||||
Domain: app.usecases.domain,
|
Domain: app.usecases.domain,
|
||||||
Provider: app.usecases.providerAdmin,
|
Provider: app.usecases.providerAdmin,
|
||||||
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
|
RemoteZoneImporter: app.usecases.orchestrator.RemoteZoneImporter,
|
||||||
|
|
|
||||||
|
|
@ -33,11 +33,14 @@ import (
|
||||||
api "git.happydns.org/happyDomain/internal/api/route"
|
api "git.happydns.org/happyDomain/internal/api/route"
|
||||||
"git.happydns.org/happyDomain/internal/captcha"
|
"git.happydns.org/happyDomain/internal/captcha"
|
||||||
"git.happydns.org/happyDomain/internal/mailer"
|
"git.happydns.org/happyDomain/internal/mailer"
|
||||||
|
"git.happydns.org/happyDomain/internal/metrics"
|
||||||
"git.happydns.org/happyDomain/internal/newsletter"
|
"git.happydns.org/happyDomain/internal/newsletter"
|
||||||
"git.happydns.org/happyDomain/internal/session"
|
"git.happydns.org/happyDomain/internal/session"
|
||||||
"git.happydns.org/happyDomain/internal/storage"
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
"git.happydns.org/happyDomain/internal/usecase"
|
"git.happydns.org/happyDomain/internal/usecase"
|
||||||
authuserUC "git.happydns.org/happyDomain/internal/usecase/authuser"
|
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"
|
domainUC "git.happydns.org/happyDomain/internal/usecase/domain"
|
||||||
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
domainlogUC "git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
"git.happydns.org/happyDomain/internal/usecase/orchestrator"
|
||||||
|
|
@ -48,13 +51,18 @@ import (
|
||||||
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
|
zoneUC "git.happydns.org/happyDomain/internal/usecase/zone"
|
||||||
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
|
zoneServiceUC "git.happydns.org/happyDomain/internal/usecase/zone_service"
|
||||||
"git.happydns.org/happyDomain/model"
|
"git.happydns.org/happyDomain/model"
|
||||||
|
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||||
"git.happydns.org/happyDomain/web"
|
"git.happydns.org/happyDomain/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Usecases struct {
|
type Usecases struct {
|
||||||
authentication happydns.AuthenticationUsecase
|
authentication happydns.AuthenticationUsecase
|
||||||
authUser happydns.AuthUserUsecase
|
authUser happydns.AuthUserUsecase
|
||||||
|
checker happydns.CheckerUsecase
|
||||||
|
checkResult happydns.CheckResultUsecase
|
||||||
|
checkerSchedule happydns.CheckerScheduleUsecase
|
||||||
domain happydns.DomainUsecase
|
domain happydns.DomainUsecase
|
||||||
|
domainInfo happydns.DomainInfoUsecase
|
||||||
domainLog happydns.DomainLogUsecase
|
domainLog happydns.DomainLogUsecase
|
||||||
provider happydns.ProviderUsecase
|
provider happydns.ProviderUsecase
|
||||||
providerAdmin happydns.ProviderUsecase
|
providerAdmin happydns.ProviderUsecase
|
||||||
|
|
@ -81,6 +89,7 @@ type App struct {
|
||||||
router *gin.Engine
|
router *gin.Engine
|
||||||
srv *http.Server
|
srv *http.Server
|
||||||
store storage.Storage
|
store storage.Storage
|
||||||
|
checkScheduler happydns.SchedulerUsecase
|
||||||
usecases Usecases
|
usecases Usecases
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,8 +102,12 @@ func NewApp(cfg *happydns.Options) *App {
|
||||||
app.initStorageEngine()
|
app.initStorageEngine()
|
||||||
app.initNewsletter()
|
app.initNewsletter()
|
||||||
app.initInsights()
|
app.initInsights()
|
||||||
|
if err := app.initPlugins(); err != nil {
|
||||||
|
log.Fatalf("Plugin initialization error: %s", err)
|
||||||
|
}
|
||||||
app.initUsecases()
|
app.initUsecases()
|
||||||
app.initCaptcha()
|
app.initCaptcha()
|
||||||
|
app.initCheckScheduler()
|
||||||
app.setupRouter()
|
app.setupRouter()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
@ -108,8 +121,12 @@ func NewAppWithStorage(cfg *happydns.Options, store storage.Storage) *App {
|
||||||
|
|
||||||
app.initMailer()
|
app.initMailer()
|
||||||
app.initNewsletter()
|
app.initNewsletter()
|
||||||
|
if err := app.initPlugins(); err != nil {
|
||||||
|
log.Fatalf("Plugin initialization error: %s", err)
|
||||||
|
}
|
||||||
app.initUsecases()
|
app.initUsecases()
|
||||||
app.initCaptcha()
|
app.initCaptcha()
|
||||||
|
app.initCheckScheduler()
|
||||||
app.setupRouter()
|
app.setupRouter()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
|
|
@ -162,6 +179,9 @@ func (app *App) initStorageEngine() {
|
||||||
if err = app.store.MigrateSchema(); err != nil {
|
if err = app.store.MigrateSchema(); err != nil {
|
||||||
log.Fatal("Could not migrate database: ", err)
|
log.Fatal("Could not migrate database: ", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.store = newInstrumentedStorage(app.store)
|
||||||
|
metrics.NewStorageStatsCollector(storage.NewStatsProvider(app.store))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -186,6 +206,22 @@ 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,
|
||||||
|
app.usecases.checkResult,
|
||||||
|
app.usecases.checkerSchedule,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
func (app *App) initUsecases() {
|
func (app *App) initUsecases() {
|
||||||
sessionService := sessionUC.NewService(app.store)
|
sessionService := sessionUC.NewService(app.store)
|
||||||
authUserService := authuserUC.NewAuthUserUsecases(
|
authUserService := authuserUC.NewAuthUserUsecases(
|
||||||
|
|
@ -207,6 +243,10 @@ func (app *App) initUsecases() {
|
||||||
app.usecases.service = serviceService
|
app.usecases.service = serviceService
|
||||||
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
|
app.usecases.serviceSpecs = usecase.NewServiceSpecsUsecase()
|
||||||
app.usecases.zone = zoneService
|
app.usecases.zone = zoneService
|
||||||
|
app.usecases.domainInfo = usecase.NewDomainInfoUsecase(
|
||||||
|
domaininfo.GetDomainRDAPInfo,
|
||||||
|
domaininfo.GetDomainWhoisInfo,
|
||||||
|
)
|
||||||
app.usecases.domainLog = domainLogService
|
app.usecases.domainLog = domainLogService
|
||||||
|
|
||||||
domainService := domainUC.NewService(
|
domainService := domainUC.NewService(
|
||||||
|
|
@ -234,6 +274,9 @@ func (app *App) initUsecases() {
|
||||||
app.usecases.authUser = authUserService
|
app.usecases.authUser = authUserService
|
||||||
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
app.usecases.resolver = usecase.NewResolverUsecase(app.cfg)
|
||||||
app.usecases.session = sessionService
|
app.usecases.session = sessionService
|
||||||
|
app.usecases.checker = checkUC.NewCheckerUsecase(app.cfg, app.store, app.store)
|
||||||
|
app.usecases.checkerSchedule = checkresultUC.NewCheckScheduleUsecase(app.store, app.cfg, app.store, app.usecases.checker)
|
||||||
|
app.usecases.checkResult = checkresultUC.NewCheckResultUsecase(app.store, app.cfg, app.usecases.checker, app.usecases.checkerSchedule)
|
||||||
|
|
||||||
app.usecases.orchestrator = orchestrator.NewOrchestrator(
|
app.usecases.orchestrator = orchestrator.NewOrchestrator(
|
||||||
domainLogService,
|
domainLogService,
|
||||||
|
|
@ -255,7 +298,7 @@ func (app *App) setupRouter() {
|
||||||
|
|
||||||
gin.ForceConsoleColor()
|
gin.ForceConsoleColor()
|
||||||
app.router = gin.New()
|
app.router = gin.New()
|
||||||
app.router.Use(gin.Logger(), gin.Recovery(), sessions.Sessions(
|
app.router.Use(gin.Logger(), gin.Recovery(), metrics.HTTPMiddleware(), sessions.Sessions(
|
||||||
session.COOKIE_NAME,
|
session.COOKIE_NAME,
|
||||||
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
|
session.NewSessionStore(app.cfg, app.store, []byte(app.cfg.JWTSecretKey)),
|
||||||
))
|
))
|
||||||
|
|
@ -275,7 +318,12 @@ func (app *App) setupRouter() {
|
||||||
Authentication: app.usecases.authentication,
|
Authentication: app.usecases.authentication,
|
||||||
AuthUser: app.usecases.authUser,
|
AuthUser: app.usecases.authUser,
|
||||||
CaptchaVerifier: app.captchaVerifier,
|
CaptchaVerifier: app.captchaVerifier,
|
||||||
|
Checker: app.usecases.checker,
|
||||||
|
CheckResult: app.usecases.checkResult,
|
||||||
|
CheckerSchedule: app.usecases.checkerSchedule,
|
||||||
|
CheckScheduler: app.checkScheduler,
|
||||||
Domain: app.usecases.domain,
|
Domain: app.usecases.domain,
|
||||||
|
DomainInfo: app.usecases.domainInfo,
|
||||||
DomainLog: app.usecases.domainLog,
|
DomainLog: app.usecases.domainLog,
|
||||||
FailureTracker: app.failureTracker,
|
FailureTracker: app.failureTracker,
|
||||||
Provider: app.usecases.provider,
|
Provider: app.usecases.provider,
|
||||||
|
|
@ -308,6 +356,8 @@ func (app *App) Start() {
|
||||||
go app.insights.Run()
|
go app.insights.Run()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
go app.checkScheduler.Run()
|
||||||
|
|
||||||
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
log.Printf("Public interface listening on %s\n", app.cfg.Bind)
|
||||||
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
if err := app.srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||||
log.Fatalf("listen: %s\n", err)
|
log.Fatalf("listen: %s\n", err)
|
||||||
|
|
@ -333,4 +383,6 @@ func (app *App) Stop() {
|
||||||
if app.failureTracker != nil {
|
if app.failureTracker != nil {
|
||||||
app.failureTracker.Close()
|
app.failureTracker.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
app.checkScheduler.Close()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
641
internal/app/checkscheduler.go
Normal file
641
internal/app/checkscheduler.go
Normal file
|
|
@ -0,0 +1,641 @@
|
||||||
|
// 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/metrics"
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
"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 happydns.CheckResultUsecase
|
||||||
|
scheduleUsecase happydns.CheckerScheduleUsecase
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// newCheckScheduler creates a new test scheduler
|
||||||
|
func newCheckScheduler(
|
||||||
|
cfg *happydns.Options,
|
||||||
|
store storage.Storage,
|
||||||
|
checkerUsecase happydns.CheckerUsecase,
|
||||||
|
resultUsecase happydns.CheckResultUsecase,
|
||||||
|
scheduleUsecase happydns.CheckerScheduleUsecase,
|
||||||
|
) *checkScheduler {
|
||||||
|
numWorkers := cfg.TestWorkers
|
||||||
|
if numWorkers <= 0 {
|
||||||
|
numWorkers = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduler := &checkScheduler{
|
||||||
|
cfg: cfg,
|
||||||
|
store: store,
|
||||||
|
checkerUsecase: checkerUsecase,
|
||||||
|
resultUsecase: resultUsecase,
|
||||||
|
scheduleUsecase: scheduleUsecase,
|
||||||
|
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)
|
||||||
|
metrics.SchedulerQueueDepth.Set(float64(s.queue.Len()))
|
||||||
|
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,
|
||||||
|
InsideType: schedule.InsideType,
|
||||||
|
InsideId: schedule.InsideId,
|
||||||
|
TargetType: schedule.TargetType,
|
||||||
|
TargetId: schedule.TargetId,
|
||||||
|
Status: happydns.CheckExecutionPending,
|
||||||
|
StartedAt: 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, insideScopeType *happydns.CheckScopeType, insideId *happydns.Identifier, ownerId happydns.Identifier, options happydns.CheckerOptions) (happydns.Identifier, error) {
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: checkerName,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
InsideType: insideScopeType,
|
||||||
|
InsideId: insideId,
|
||||||
|
Interval: 0, // On-demand, no interval
|
||||||
|
Enabled: true,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
ScheduleId: nil,
|
||||||
|
CheckerName: checkerName,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
InsideType: insideScopeType,
|
||||||
|
InsideId: insideId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
Status: happydns.CheckExecutionPending,
|
||||||
|
StartedAt: now,
|
||||||
|
Options: options,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.resultUsecase.CreateCheckExecution(execution); err != nil {
|
||||||
|
return happydns.Identifier{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
item := &queueItem{
|
||||||
|
schedule: schedule,
|
||||||
|
execution: execution,
|
||||||
|
priority: PriorityOnDemand,
|
||||||
|
queuedAt: 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 {
|
||||||
|
metrics.SchedulerQueueDepth.Set(float64(w.scheduler.queue.Len()))
|
||||||
|
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
|
||||||
|
|
||||||
|
metrics.SchedulerActiveWorkers.Inc()
|
||||||
|
checkStart := time.Now()
|
||||||
|
defer func() {
|
||||||
|
metrics.SchedulerActiveWorkers.Dec()
|
||||||
|
metrics.SchedulerCheckDuration.WithLabelValues(schedule.CheckerName).Observe(time.Since(checkStart).Seconds())
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 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 execution.ScheduleId != nil {
|
||||||
|
defer func() {
|
||||||
|
if err := w.scheduler.scheduleUsecase.UpdateScheduleAfterRun(*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
|
||||||
|
startTime := time.Now()
|
||||||
|
w.scheduler.mu.Lock()
|
||||||
|
w.scheduler.activeExecutions[execution.Id.String()] = &activeExecution{
|
||||||
|
execution: execution,
|
||||||
|
cancel: cancel,
|
||||||
|
startTime: startTime,
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var domainId, serviceId *happydns.Identifier
|
||||||
|
switch schedule.TargetType {
|
||||||
|
case happydns.CheckScopeDomain:
|
||||||
|
domainId = &schedule.TargetId
|
||||||
|
case happydns.CheckScopeService:
|
||||||
|
serviceId = &schedule.TargetId
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge options: global defaults < user opts < domain/service opts < schedule/on-demand opts < auto-fill
|
||||||
|
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 := map[string]string{
|
||||||
|
"target_type": schedule.TargetType.String(),
|
||||||
|
"target_id": schedule.TargetId.String(),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the check synchronously with context-based timeout.
|
||||||
|
// The checker is responsible for honouring ctx cancellation.
|
||||||
|
checkResult, testErr := w.runCheckSafe(ctx, checker, mergedOptions, meta)
|
||||||
|
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
|
||||||
|
// Build the result record, enriching the partial CheckResult from RunCheck
|
||||||
|
// with execution metadata (owner, target, duration, etc.).
|
||||||
|
result := &happydns.CheckResult{
|
||||||
|
CheckerName: schedule.CheckerName,
|
||||||
|
CheckType: schedule.TargetType,
|
||||||
|
TargetId: schedule.TargetId,
|
||||||
|
OwnerId: schedule.OwnerId,
|
||||||
|
ExecutedAt: startTime,
|
||||||
|
ScheduledCheck: execution.ScheduleId != nil,
|
||||||
|
Options: schedule.Options,
|
||||||
|
Duration: duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
if checkResult != nil {
|
||||||
|
result.Status = checkResult.Status
|
||||||
|
result.StatusLine = checkResult.StatusLine
|
||||||
|
result.Report = checkResult.Report
|
||||||
|
if testErr != nil {
|
||||||
|
result.Error = testErr.Error()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
result.Status = happydns.CheckResultStatusUnknown
|
||||||
|
result.StatusLine = "Check execution failed"
|
||||||
|
if testErr != nil {
|
||||||
|
result.Error = testErr.Error()
|
||||||
|
} else {
|
||||||
|
result.Error = "Unknown check execution error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record check status metric
|
||||||
|
metrics.SchedulerChecksTotal.WithLabelValues(schedule.CheckerName, result.Status.String()).Inc()
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCheckSafe calls checker.RunCheck and recovers from panics.
|
||||||
|
func (w *worker) runCheckSafe(ctx context.Context, checker happydns.Checker, options happydns.CheckerOptions, meta map[string]string) (result *happydns.CheckResult, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
err = fmt.Errorf("checker panicked: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return checker.RunCheck(ctx, options, meta)
|
||||||
|
}
|
||||||
58
internal/app/disabledscheduler.go
Normal file
58
internal/app/disabledscheduler.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// disabledScheduler is a no-op implementation of SchedulerUsecase used when the
|
||||||
|
// scheduler is disabled in configuration.
|
||||||
|
type disabledScheduler struct{}
|
||||||
|
|
||||||
|
func (d *disabledScheduler) Run() {}
|
||||||
|
func (d *disabledScheduler) Close() {}
|
||||||
|
|
||||||
|
func (d *disabledScheduler) TriggerOnDemandCheck(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, _ *happydns.CheckScopeType, _ *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")
|
||||||
|
}
|
||||||
624
internal/app/instrumented_storage.go
Normal file
624
internal/app/instrumented_storage.go
Normal file
|
|
@ -0,0 +1,624 @@
|
||||||
|
// 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 (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/metrics"
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// instrumentedStorage wraps a storage.Storage to record Prometheus metrics for
|
||||||
|
// every operation.
|
||||||
|
type instrumentedStorage struct {
|
||||||
|
inner storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// newInstrumentedStorage wraps the given Storage with metrics instrumentation.
|
||||||
|
func newInstrumentedStorage(s storage.Storage) storage.Storage {
|
||||||
|
return &instrumentedStorage{inner: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
// observe records the duration and outcome of a storage operation.
|
||||||
|
func observeStorage(operation, entity string, start time.Time, err error) {
|
||||||
|
status := "success"
|
||||||
|
if err != nil {
|
||||||
|
status = "error"
|
||||||
|
}
|
||||||
|
metrics.StorageOperationsTotal.WithLabelValues(operation, entity, status).Inc()
|
||||||
|
metrics.StorageOperationDuration.WithLabelValues(operation, entity).Observe(time.Since(start).Seconds())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schema / lifecycle
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) SchemaVersion() int {
|
||||||
|
return s.inner.SchemaVersion()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) MigrateSchema() error {
|
||||||
|
return s.inner.MigrateSchema()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) Close() error {
|
||||||
|
return s.inner.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthUser
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllAuthUsers() (ret happydns.Iterator[happydns.UserAuth], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllAuthUsers()
|
||||||
|
observeStorage("list", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetAuthUser(id happydns.Identifier) (ret *happydns.UserAuth, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetAuthUser(id)
|
||||||
|
observeStorage("get", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetAuthUserByEmail(email string) (ret *happydns.UserAuth, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetAuthUserByEmail(email)
|
||||||
|
observeStorage("get", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) AuthUserExists(email string) (ret bool, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.AuthUserExists(email)
|
||||||
|
observeStorage("get", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateAuthUser(user *happydns.UserAuth) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateAuthUser(user)
|
||||||
|
observeStorage("create", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateAuthUser(user *happydns.UserAuth) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateAuthUser(user)
|
||||||
|
observeStorage("update", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteAuthUser(user *happydns.UserAuth) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteAuthUser(user)
|
||||||
|
observeStorage("delete", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearAuthUsers() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearAuthUsers()
|
||||||
|
observeStorage("delete", "authuser", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Domain
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllDomains() (ret happydns.Iterator[happydns.Domain], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllDomains()
|
||||||
|
observeStorage("list", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListDomains(user *happydns.User) (ret []*happydns.Domain, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListDomains(user)
|
||||||
|
observeStorage("list", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetDomain(domainid happydns.Identifier) (ret *happydns.Domain, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetDomain(domainid)
|
||||||
|
observeStorage("get", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetDomainByDN(user *happydns.User, fqdn string) (ret []*happydns.Domain, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetDomainByDN(user, fqdn)
|
||||||
|
observeStorage("get", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateDomain(domain *happydns.Domain) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateDomain(domain)
|
||||||
|
observeStorage("create", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateDomain(domain *happydns.Domain) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateDomain(domain)
|
||||||
|
observeStorage("update", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteDomain(domainid happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteDomain(domainid)
|
||||||
|
observeStorage("delete", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearDomains() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearDomains()
|
||||||
|
observeStorage("delete", "domain", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// DomainLog
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllDomainLogs() (ret happydns.Iterator[happydns.DomainLogWithDomainId], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllDomainLogs()
|
||||||
|
observeStorage("list", "domain_log", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListDomainLogs(domain *happydns.Domain) (ret []*happydns.DomainLog, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListDomainLogs(domain)
|
||||||
|
observeStorage("list", "domain_log", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateDomainLog(domain, log)
|
||||||
|
observeStorage("create", "domain_log", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateDomainLog(domain, log)
|
||||||
|
observeStorage("update", "domain_log", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteDomainLog(domain *happydns.Domain, log *happydns.DomainLog) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteDomainLog(domain, log)
|
||||||
|
observeStorage("delete", "domain_log", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insight
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) InsightsRun() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.InsightsRun()
|
||||||
|
observeStorage("run", "insight", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) LastInsightsRun() (t *time.Time, id happydns.Identifier, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
t, id, err = s.inner.LastInsightsRun()
|
||||||
|
observeStorage("get", "insight", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllProviders() (ret happydns.Iterator[happydns.ProviderMessage], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllProviders()
|
||||||
|
observeStorage("list", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListProviders(user *happydns.User) (ret happydns.ProviderMessages, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListProviders(user)
|
||||||
|
observeStorage("list", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetProvider(prvdid happydns.Identifier) (ret *happydns.ProviderMessage, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetProvider(prvdid)
|
||||||
|
observeStorage("get", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateProvider(prvd *happydns.Provider) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateProvider(prvd)
|
||||||
|
observeStorage("create", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateProvider(prvd *happydns.Provider) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateProvider(prvd)
|
||||||
|
observeStorage("update", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteProvider(prvdid happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteProvider(prvdid)
|
||||||
|
observeStorage("delete", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearProviders() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearProviders()
|
||||||
|
observeStorage("delete", "provider", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Session
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllSessions() (ret happydns.Iterator[happydns.Session], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllSessions()
|
||||||
|
observeStorage("list", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetSession(sessionid string) (ret *happydns.Session, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetSession(sessionid)
|
||||||
|
observeStorage("get", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAuthUserSessions(user *happydns.UserAuth) (ret []*happydns.Session, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAuthUserSessions(user)
|
||||||
|
observeStorage("list", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListUserSessions(userid happydns.Identifier) (ret []*happydns.Session, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListUserSessions(userid)
|
||||||
|
observeStorage("list", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateSession(session *happydns.Session) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateSession(session)
|
||||||
|
observeStorage("update", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteSession(sessionid string) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteSession(sessionid)
|
||||||
|
observeStorage("delete", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearSessions() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearSessions()
|
||||||
|
observeStorage("delete", "session", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckResult / CheckSchedule / CheckExecution
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, limit int) (ret []*happydns.CheckResult, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckResults(checkName, targetType, targetId, limit)
|
||||||
|
observeStorage("list", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckResultsByPlugin(userId happydns.Identifier, checkName string, limit int) (ret []*happydns.CheckResult, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckResultsByPlugin(userId, checkName, limit)
|
||||||
|
observeStorage("list", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckResultsByUser(userId happydns.Identifier, limit int) (ret []*happydns.CheckResult, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckResultsByUser(userId, limit)
|
||||||
|
observeStorage("list", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (ret *happydns.CheckResult, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetCheckResult(checkName, targetType, targetId, resultId)
|
||||||
|
observeStorage("get", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateCheckResult(result *happydns.CheckResult) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateCheckResult(result)
|
||||||
|
observeStorage("create", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteCheckResult(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteCheckResult(checkName, targetType, targetId, resultId)
|
||||||
|
observeStorage("delete", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteOldCheckResults(checkName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, keepCount int) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteOldCheckResults(checkName, targetType, targetId, keepCount)
|
||||||
|
observeStorage("delete", "check_result", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListEnabledCheckerSchedules() (ret []*happydns.CheckerSchedule, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListEnabledCheckerSchedules()
|
||||||
|
observeStorage("list", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckerSchedulesByUser(userId happydns.Identifier) (ret []*happydns.CheckerSchedule, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckerSchedulesByUser(userId)
|
||||||
|
observeStorage("list", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckerSchedulesByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) (ret []*happydns.CheckerSchedule, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
|
||||||
|
observeStorage("list", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetCheckerSchedule(scheduleId happydns.Identifier) (ret *happydns.CheckerSchedule, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetCheckerSchedule(scheduleId)
|
||||||
|
observeStorage("get", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateCheckerSchedule(schedule *happydns.CheckerSchedule) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateCheckerSchedule(schedule)
|
||||||
|
observeStorage("create", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateCheckerSchedule(schedule *happydns.CheckerSchedule) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateCheckerSchedule(schedule)
|
||||||
|
observeStorage("update", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteCheckerSchedule(scheduleId happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteCheckerSchedule(scheduleId)
|
||||||
|
observeStorage("delete", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListActiveCheckExecutions() (ret []*happydns.CheckExecution, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListActiveCheckExecutions()
|
||||||
|
observeStorage("list", "check_execution", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetCheckExecution(executionId happydns.Identifier) (ret *happydns.CheckExecution, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetCheckExecution(executionId)
|
||||||
|
observeStorage("get", "check_execution", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateCheckExecution(execution *happydns.CheckExecution) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateCheckExecution(execution)
|
||||||
|
observeStorage("create", "check_execution", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateCheckExecution(execution *happydns.CheckExecution) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateCheckExecution(execution)
|
||||||
|
observeStorage("update", "check_execution", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteCheckExecution(executionId happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteCheckExecution(executionId)
|
||||||
|
observeStorage("delete", "check_execution", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CheckSchedulerRun() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CheckSchedulerRun()
|
||||||
|
observeStorage("run", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) LastCheckSchedulerRun() (t *time.Time, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
t, err = s.inner.LastCheckSchedulerRun()
|
||||||
|
observeStorage("get", "check_schedule", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckerConfiguration
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllCheckerConfigurations() (ret happydns.Iterator[happydns.CheckerOptions], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllCheckerConfigurations()
|
||||||
|
observeStorage("list", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListCheckerConfiguration(name string) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListCheckerConfiguration(name)
|
||||||
|
observeStorage("list", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (ret []*happydns.CheckerOptionsPositional, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetCheckerConfiguration(name, a, b, c)
|
||||||
|
observeStorage("get", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier, opts happydns.CheckerOptions) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateCheckerConfiguration(name, a, b, c, opts)
|
||||||
|
observeStorage("update", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteCheckerConfiguration(name string, a *happydns.Identifier, b *happydns.Identifier, c *happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteCheckerConfiguration(name, a, b, c)
|
||||||
|
observeStorage("delete", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearCheckerConfigurations() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearCheckerConfigurations()
|
||||||
|
observeStorage("delete", "check_config", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// User
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllUsers() (ret happydns.Iterator[happydns.User], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllUsers()
|
||||||
|
observeStorage("list", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetUser(userid happydns.Identifier) (ret *happydns.User, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetUser(userid)
|
||||||
|
observeStorage("get", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetUserByEmail(email string) (ret *happydns.User, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetUserByEmail(email)
|
||||||
|
observeStorage("get", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateOrUpdateUser(user *happydns.User) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateOrUpdateUser(user)
|
||||||
|
observeStorage("update", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteUser(userid happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteUser(userid)
|
||||||
|
observeStorage("delete", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearUsers() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearUsers()
|
||||||
|
observeStorage("delete", "user", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zone
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ListAllZones() (ret happydns.Iterator[happydns.ZoneMessage], err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.ListAllZones()
|
||||||
|
observeStorage("list", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetZoneMeta(zoneid happydns.Identifier) (ret *happydns.ZoneMeta, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetZoneMeta(zoneid)
|
||||||
|
observeStorage("get", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) GetZone(zoneid happydns.Identifier) (ret *happydns.ZoneMessage, err error) {
|
||||||
|
start := time.Now()
|
||||||
|
ret, err = s.inner.GetZone(zoneid)
|
||||||
|
observeStorage("get", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) CreateZone(zone *happydns.Zone) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.CreateZone(zone)
|
||||||
|
observeStorage("create", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) UpdateZone(zone *happydns.Zone) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.UpdateZone(zone)
|
||||||
|
observeStorage("update", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) DeleteZone(zoneid happydns.Identifier) (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.DeleteZone(zoneid)
|
||||||
|
observeStorage("delete", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *instrumentedStorage) ClearZones() (err error) {
|
||||||
|
start := time.Now()
|
||||||
|
err = s.inner.ClearZones()
|
||||||
|
observeStorage("delete", "zone", start, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
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.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.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
|
// Others flags are declared in some other files likes sources, storages, ... when they need specials configurations
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,15 +44,18 @@ func ConsolidateConfig() (opts *happydns.Options, err error) {
|
||||||
|
|
||||||
// Define defaults options
|
// Define defaults options
|
||||||
opts = &happydns.Options{
|
opts = &happydns.Options{
|
||||||
AdminBind: "./happydomain.sock",
|
AdminBind: "./happydomain.sock",
|
||||||
BasePath: "/",
|
BasePath: "/",
|
||||||
Bind: ":8081",
|
Bind: ":8081",
|
||||||
DefaultNameServer: "127.0.0.1:53",
|
DefaultNameServer: "127.0.0.1:53",
|
||||||
ExternalURL: *u,
|
ExternalURL: *u,
|
||||||
JWTSigningMethod: "HS512",
|
JWTSigningMethod: "HS512",
|
||||||
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
MailFrom: mail.Address{Name: "happyDomain", Address: "happydomain@localhost"},
|
||||||
MailSMTPPort: 587,
|
MailSMTPPort: 587,
|
||||||
StorageEngine: "leveldb",
|
StorageEngine: "leveldb",
|
||||||
|
MaxResultsPerCheck: 100,
|
||||||
|
ResultRetentionDays: 90,
|
||||||
|
TestWorkers: 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
declareFlags(opts)
|
declareFlags(opts)
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,25 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/mail"
|
"net/mail"
|
||||||
"net/url"
|
"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 {
|
type JWTSecretKey struct {
|
||||||
Secret *[]byte
|
Secret *[]byte
|
||||||
}
|
}
|
||||||
|
|
|
||||||
101
internal/metrics/collector.go
Normal file
101
internal/metrics/collector.go
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
// 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 metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsProvider is the minimal interface required by StorageStatsCollector to
|
||||||
|
// count business entities. It is implemented by internal/app.storageStatsProvider.
|
||||||
|
type StatsProvider interface {
|
||||||
|
CountUsers() (int, error)
|
||||||
|
CountDomains() (int, error)
|
||||||
|
CountZones() (int, error)
|
||||||
|
CountProviders() (int, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StorageStatsCollector is a Prometheus Collector that queries storage at each
|
||||||
|
// scrape to report accurate business-entity counts.
|
||||||
|
type StorageStatsCollector struct {
|
||||||
|
provider StatsProvider
|
||||||
|
|
||||||
|
usersDesc *prometheus.Desc
|
||||||
|
domainsDesc *prometheus.Desc
|
||||||
|
zonesDesc *prometheus.Desc
|
||||||
|
providersDesc *prometheus.Desc
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStorageStatsCollector creates a new collector backed by the given StatsProvider
|
||||||
|
// and registers it with the default Prometheus registry.
|
||||||
|
func NewStorageStatsCollector(p StatsProvider) *StorageStatsCollector {
|
||||||
|
c := &StorageStatsCollector{
|
||||||
|
provider: p,
|
||||||
|
usersDesc: prometheus.NewDesc(
|
||||||
|
"happydomain_registered_users_total",
|
||||||
|
"Current number of registered user accounts.",
|
||||||
|
nil, nil,
|
||||||
|
),
|
||||||
|
domainsDesc: prometheus.NewDesc(
|
||||||
|
"happydomain_domains_total",
|
||||||
|
"Current number of domains managed across all users.",
|
||||||
|
nil, nil,
|
||||||
|
),
|
||||||
|
zonesDesc: prometheus.NewDesc(
|
||||||
|
"happydomain_zones_total",
|
||||||
|
"Current number of zone snapshots stored.",
|
||||||
|
nil, nil,
|
||||||
|
),
|
||||||
|
providersDesc: prometheus.NewDesc(
|
||||||
|
"happydomain_providers_total",
|
||||||
|
"Current number of provider configurations across all users.",
|
||||||
|
nil, nil,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
prometheus.MustRegister(c)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// Describe implements prometheus.Collector.
|
||||||
|
func (c *StorageStatsCollector) Describe(ch chan<- *prometheus.Desc) {
|
||||||
|
ch <- c.usersDesc
|
||||||
|
ch <- c.domainsDesc
|
||||||
|
ch <- c.zonesDesc
|
||||||
|
ch <- c.providersDesc
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect implements prometheus.Collector. It queries storage live so the
|
||||||
|
// values always reflect the actual database state.
|
||||||
|
func (c *StorageStatsCollector) Collect(ch chan<- prometheus.Metric) {
|
||||||
|
if n, err := c.provider.CountUsers(); err == nil {
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.usersDesc, prometheus.GaugeValue, float64(n))
|
||||||
|
}
|
||||||
|
if n, err := c.provider.CountDomains(); err == nil {
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.domainsDesc, prometheus.GaugeValue, float64(n))
|
||||||
|
}
|
||||||
|
if n, err := c.provider.CountZones(); err == nil {
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.zonesDesc, prometheus.GaugeValue, float64(n))
|
||||||
|
}
|
||||||
|
if n, err := c.provider.CountProviders(); err == nil {
|
||||||
|
ch <- prometheus.MustNewConstMetric(c.providersDesc, prometheus.GaugeValue, float64(n))
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/metrics/http.go
Normal file
54
internal/metrics/http.go
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
// 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 metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HTTPMiddleware returns a Gin middleware that records HTTP request metrics.
|
||||||
|
// It uses c.FullPath() to get the route pattern (e.g. /api/domains/:domain)
|
||||||
|
// rather than the actual URL, avoiding high-cardinality labels.
|
||||||
|
func HTTPMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
start := time.Now()
|
||||||
|
HTTPRequestsInFlight.Inc()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
HTTPRequestsInFlight.Dec()
|
||||||
|
|
||||||
|
path := c.FullPath()
|
||||||
|
if path == "" {
|
||||||
|
path = "unknown"
|
||||||
|
}
|
||||||
|
method := c.Request.Method
|
||||||
|
status := strconv.Itoa(c.Writer.Status())
|
||||||
|
duration := time.Since(start).Seconds()
|
||||||
|
|
||||||
|
HTTPRequestsTotal.WithLabelValues(method, path, status).Inc()
|
||||||
|
HTTPRequestDuration.WithLabelValues(method, path).Observe(duration)
|
||||||
|
}
|
||||||
|
}
|
||||||
104
internal/metrics/metrics.go
Normal file
104
internal/metrics/metrics.go
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2026 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package metrics
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
|
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// HTTP metrics
|
||||||
|
HTTPRequestsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "happydomain_http_requests_total",
|
||||||
|
Help: "Total number of HTTP requests.",
|
||||||
|
}, []string{"method", "path", "status"})
|
||||||
|
|
||||||
|
HTTPRequestDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "happydomain_http_request_duration_seconds",
|
||||||
|
Help: "Duration of HTTP requests in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"method", "path"})
|
||||||
|
|
||||||
|
HTTPRequestsInFlight = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "happydomain_http_requests_in_flight",
|
||||||
|
Help: "Current number of HTTP requests being served.",
|
||||||
|
})
|
||||||
|
|
||||||
|
// Scheduler metrics
|
||||||
|
SchedulerQueueDepth = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "happydomain_scheduler_queue_depth",
|
||||||
|
Help: "Number of items currently in the check scheduler queue.",
|
||||||
|
})
|
||||||
|
|
||||||
|
SchedulerActiveWorkers = promauto.NewGauge(prometheus.GaugeOpts{
|
||||||
|
Name: "happydomain_scheduler_active_workers",
|
||||||
|
Help: "Number of check scheduler workers currently executing a check.",
|
||||||
|
})
|
||||||
|
|
||||||
|
SchedulerChecksTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "happydomain_scheduler_checks_total",
|
||||||
|
Help: "Total number of checks executed by the scheduler.",
|
||||||
|
}, []string{"checker", "status"})
|
||||||
|
|
||||||
|
SchedulerCheckDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "happydomain_scheduler_check_duration_seconds",
|
||||||
|
Help: "Duration of individual check executions in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"checker"})
|
||||||
|
|
||||||
|
// DNS provider API metrics
|
||||||
|
ProviderAPICallsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "happydomain_provider_api_calls_total",
|
||||||
|
Help: "Total number of DNS provider API calls.",
|
||||||
|
}, []string{"provider", "operation", "status"})
|
||||||
|
|
||||||
|
ProviderAPIDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "happydomain_provider_api_duration_seconds",
|
||||||
|
Help: "Duration of DNS provider API calls in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"provider", "operation"})
|
||||||
|
|
||||||
|
// Storage metrics
|
||||||
|
StorageOperationsTotal = promauto.NewCounterVec(prometheus.CounterOpts{
|
||||||
|
Name: "happydomain_storage_operations_total",
|
||||||
|
Help: "Total number of storage operations.",
|
||||||
|
}, []string{"operation", "entity", "status"})
|
||||||
|
|
||||||
|
StorageOperationDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{
|
||||||
|
Name: "happydomain_storage_operation_duration_seconds",
|
||||||
|
Help: "Duration of storage operations in seconds.",
|
||||||
|
Buckets: prometheus.DefBuckets,
|
||||||
|
}, []string{"operation", "entity"})
|
||||||
|
|
||||||
|
// Build info
|
||||||
|
BuildInfo = promauto.NewGaugeVec(prometheus.GaugeOpts{
|
||||||
|
Name: "happydomain_build_info",
|
||||||
|
Help: "Build information about the running happyDomain instance.",
|
||||||
|
}, []string{"version"})
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetBuildInfo records the application version in the build info metric.
|
||||||
|
// Call this once during application startup.
|
||||||
|
func SetBuildInfo(version string) {
|
||||||
|
BuildInfo.WithLabelValues(version).Set(1)
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,7 @@ type InMemoryStorage struct {
|
||||||
data map[string][]byte // Generic key-value store for KVStorage interface
|
data map[string][]byte // Generic key-value store for KVStorage interface
|
||||||
authUsers map[string]*happydns.UserAuth
|
authUsers map[string]*happydns.UserAuth
|
||||||
authUsersByEmail map[string]happydns.Identifier
|
authUsersByEmail map[string]happydns.Identifier
|
||||||
|
checksCfg map[string]*happydns.CheckerOptions
|
||||||
domains map[string]*happydns.Domain
|
domains map[string]*happydns.Domain
|
||||||
domainLogs map[string]*happydns.DomainLogWithDomainId
|
domainLogs map[string]*happydns.DomainLogWithDomainId
|
||||||
domainLogsByDomains map[string][]*happydns.Identifier
|
domainLogsByDomains map[string][]*happydns.Identifier
|
||||||
|
|
@ -58,6 +59,7 @@ func NewInMemoryStorage() (*InMemoryStorage, error) {
|
||||||
data: make(map[string][]byte),
|
data: make(map[string][]byte),
|
||||||
authUsers: make(map[string]*happydns.UserAuth),
|
authUsers: make(map[string]*happydns.UserAuth),
|
||||||
authUsersByEmail: make(map[string]happydns.Identifier),
|
authUsersByEmail: make(map[string]happydns.Identifier),
|
||||||
|
checksCfg: make(map[string]*happydns.CheckerOptions),
|
||||||
domains: make(map[string]*happydns.Domain),
|
domains: make(map[string]*happydns.Domain),
|
||||||
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
|
domainLogs: make(map[string]*happydns.DomainLogWithDomainId),
|
||||||
domainLogsByDomains: make(map[string][]*happydns.Identifier),
|
domainLogsByDomains: make(map[string][]*happydns.Identifier),
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,8 @@ package storage // import "git.happydns.org/happyDomain/internal/storage"
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.happydns.org/happyDomain/internal/usecase/authuser"
|
"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"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
"git.happydns.org/happyDomain/internal/usecase/domain_log"
|
||||||
"git.happydns.org/happyDomain/internal/usecase/insight"
|
"git.happydns.org/happyDomain/internal/usecase/insight"
|
||||||
|
|
@ -43,8 +45,10 @@ type Storage interface {
|
||||||
domain.DomainStorage
|
domain.DomainStorage
|
||||||
domainlog.DomainLogStorage
|
domainlog.DomainLogStorage
|
||||||
insight.InsightStorage
|
insight.InsightStorage
|
||||||
|
check.CheckerStorage
|
||||||
provider.ProviderStorage
|
provider.ProviderStorage
|
||||||
session.SessionStorage
|
session.SessionStorage
|
||||||
|
checkresult.CheckResultStorage
|
||||||
user.UserStorage
|
user.UserStorage
|
||||||
zone.ZoneStorage
|
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
|
||||||
|
}
|
||||||
453
internal/storage/kvtpl/checkresult.go
Normal file
453
internal/storage/kvtpl/checkresult.go
Normal file
|
|
@ -0,0 +1,453 @@
|
||||||
|
// 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, insideType *happydns.CheckScopeType, insideId *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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-filter by insideType/insideId
|
||||||
|
if insideType == nil {
|
||||||
|
if sched.InsideType != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if sched.InsideType == nil || *sched.InsideType != *insideType {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if insideId == nil {
|
||||||
|
if sched.InsideId != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if sched.InsideId == nil || !sched.InsideId.Equals(*insideId) {
|
||||||
|
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
|
||||||
|
}
|
||||||
84
internal/storage/stats_provider.go
Normal file
84
internal/storage/stats_provider.go
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// 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 storage
|
||||||
|
|
||||||
|
// StatsProvider implements metrics.StatsProvider using a Storage.
|
||||||
|
type StatsProvider struct {
|
||||||
|
store Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatsProvider creates a StatsProvider backed by the given Storage.
|
||||||
|
func NewStatsProvider(s Storage) *StatsProvider {
|
||||||
|
return &StatsProvider{store: s}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsProvider) CountUsers() (int, error) {
|
||||||
|
it, err := p.store.ListAllUsers()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for it.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
it.Close()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsProvider) CountDomains() (int, error) {
|
||||||
|
it, err := p.store.ListAllDomains()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for it.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
it.Close()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsProvider) CountZones() (int, error) {
|
||||||
|
it, err := p.store.ListAllZones()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for it.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
it.Close()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsProvider) CountProviders() (int, error) {
|
||||||
|
it, err := p.store.ListAllProviders()
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
n := 0
|
||||||
|
for it.Next() {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
it.Close()
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
450
internal/usecase/check/check_options_test.go
Normal file
450
internal/usecase/check/check_options_test.go
Normal file
|
|
@ -0,0 +1,450 @@
|
||||||
|
// 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_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/checks"
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||||
|
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
|
||||||
|
uc "git.happydns.org/happyDomain/internal/usecase/check"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mockCheckerForOptions – registered once at package init.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const testCheckerName = "test-mock-checker-options"
|
||||||
|
|
||||||
|
type mockCheckerForOptions struct{}
|
||||||
|
|
||||||
|
func (m *mockCheckerForOptions) ID() string { return testCheckerName }
|
||||||
|
func (m *mockCheckerForOptions) Name() string { return testCheckerName }
|
||||||
|
func (m *mockCheckerForOptions) Availability() happydns.CheckerAvailability {
|
||||||
|
return happydns.CheckerAvailability{ApplyToDomain: true}
|
||||||
|
}
|
||||||
|
func (m *mockCheckerForOptions) Options() happydns.CheckerOptionsDocumentation {
|
||||||
|
return happydns.CheckerOptionsDocumentation{
|
||||||
|
RunOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "run-param", Default: "run-default"},
|
||||||
|
},
|
||||||
|
DomainOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "domain-autofill", AutoFill: happydns.AutoFillDomainName},
|
||||||
|
{Id: "domain-param", Default: "domain-default"},
|
||||||
|
},
|
||||||
|
UserOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "user-param"},
|
||||||
|
},
|
||||||
|
ServiceOpts: []happydns.CheckerOptionDocumentation{
|
||||||
|
{Id: "service-param"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func (m *mockCheckerForOptions) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
checks.RegisterChecker(testCheckerName, &mockCheckerForOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: create a fresh in-memory database for each test.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func newOptionsTestDB(t *testing.T) storage.Storage {
|
||||||
|
t.Helper()
|
||||||
|
mem, err := inmemory.NewInMemoryStorage()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create in-memory storage: %v", err)
|
||||||
|
}
|
||||||
|
db, err := kv.NewKVDatabase(mem)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create KV database: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCheckerUsecase(db storage.Storage) happydns.CheckerUsecase {
|
||||||
|
return uc.NewCheckerUsecase(&happydns.Options{}, db, db)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GetStoredCheckerOptionsNoDefault tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_GetStoredOptions_EmptyStore(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(opts) != 0 {
|
||||||
|
t.Errorf("expected empty options from empty store, got %v", opts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetStoredOptions_MergesStored(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
userId, _ := happydns.NewRandomIdentifier()
|
||||||
|
// Store user-level option.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, &userId, nil, nil, happydns.CheckerOptions{"user-param": "val"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, &userId, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if opts["user-param"] != "val" {
|
||||||
|
t.Errorf("expected user-param='val', got %v", opts["user-param"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetStoredOptions_AutoFillInjects(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Create a domain in the db.
|
||||||
|
domain := &happydns.Domain{
|
||||||
|
DomainName: "example.com.",
|
||||||
|
}
|
||||||
|
if err := db.CreateDomain(domain); err != nil {
|
||||||
|
t.Fatalf("failed to create domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault(testCheckerName, nil, &domain.Id, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if opts["domain-autofill"] != "example.com." {
|
||||||
|
t.Errorf("expected domain-autofill='example.com.', got %v", opts["domain-autofill"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_GetStoredOptions_UnknownCheckerReturnsStored(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Store options for an unknown checker.
|
||||||
|
if err := db.UpdateCheckerConfiguration("unknown-checker", nil, nil, nil, happydns.CheckerOptions{"some-param": "some-value"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts, err := checkerUC.GetStoredCheckerOptionsNoDefault("unknown-checker", nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if opts["some-param"] != "some-value" {
|
||||||
|
t.Errorf("expected some-param='some-value', got %v", opts["some-param"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// BuildMergedCheckerOptions tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_BuildMerged_DefaultsFirst(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged["run-param"] != "run-default" {
|
||||||
|
t.Errorf("expected run-param='run-default', got %v", merged["run-param"])
|
||||||
|
}
|
||||||
|
if merged["domain-param"] != "domain-default" {
|
||||||
|
t.Errorf("expected domain-param='domain-default', got %v", merged["domain-param"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BuildMerged_StoredOverridesDefault(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
domainId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
// Store domain-level option that overrides the default.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, &domainId, nil, happydns.CheckerOptions{"domain-param": "custom"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged["domain-param"] != "custom" {
|
||||||
|
t.Errorf("expected domain-param='custom' (stored overrides default), got %v", merged["domain-param"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BuildMerged_RunOptsOverrideStored(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Store an admin-level value for run-param.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"run-param": "stored-value"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
runOpts := happydns.CheckerOptions{"run-param": "runtime"}
|
||||||
|
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, nil, nil, runOpts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if merged["run-param"] != "runtime" {
|
||||||
|
t.Errorf("expected run-param='runtime' (runOpts wins), got %v", merged["run-param"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BuildMerged_AutoFillWinsOverAll(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Create domain in db.
|
||||||
|
domain := &happydns.Domain{
|
||||||
|
DomainName: "example.com.",
|
||||||
|
}
|
||||||
|
if err := db.CreateDomain(domain); err != nil {
|
||||||
|
t.Fatalf("failed to create domain: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both stored and runOpts attempt to set domain-autofill.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"domain-autofill": "manual-value"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
runOpts := happydns.CheckerOptions{"domain-autofill": "runtime-value"}
|
||||||
|
|
||||||
|
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domain.Id, nil, runOpts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// Auto-fill always wins.
|
||||||
|
if merged["domain-autofill"] != "example.com." {
|
||||||
|
t.Errorf("expected domain-autofill='example.com.' (auto-fill wins), got %v", merged["domain-autofill"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_BuildMerged_NilAutoFillStoreSkips(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
// Pass nil as the CheckAutoFillStorage interface (not a typed nil).
|
||||||
|
checkerUC := uc.NewCheckerUsecase(&happydns.Options{}, db, nil)
|
||||||
|
|
||||||
|
domainId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
// Should not panic even when autoFillStore is nil.
|
||||||
|
merged, err := checkerUC.BuildMergedCheckerOptions(testCheckerName, nil, &domainId, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// domain-autofill should NOT be set (no auto-fill storage available).
|
||||||
|
if _, ok := merged["domain-autofill"]; ok {
|
||||||
|
t.Errorf("expected domain-autofill to be absent when autoFillStore is nil, got %v", merged["domain-autofill"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetCheckerOptions tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_SetOptions_ServiceLevel(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
userId, _ := happydns.NewRandomIdentifier()
|
||||||
|
domainId, _ := happydns.NewRandomIdentifier()
|
||||||
|
serviceId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
opts := happydns.CheckerOptions{"service-param": "val"}
|
||||||
|
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, &serviceId, opts); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the configuration was stored at service scope.
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, &serviceId)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
// Find the service-level entry (UserId, DomainId, ServiceId all set).
|
||||||
|
found := false
|
||||||
|
for _, c := range configs {
|
||||||
|
if c.UserId != nil && c.DomainId != nil && c.ServiceId != nil {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected a service-level configuration entry to be stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetOptions_DomainLevel(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
userId, _ := happydns.NewRandomIdentifier()
|
||||||
|
domainId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
opts := happydns.CheckerOptions{"domain-param": "val"}
|
||||||
|
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, &domainId, nil, opts); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, &domainId, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, c := range configs {
|
||||||
|
if c.UserId != nil && c.DomainId != nil && c.ServiceId == nil {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected a domain-level configuration entry to be stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetOptions_UserLevel(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
userId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
opts := happydns.CheckerOptions{"user-param": "val"}
|
||||||
|
if err := checkerUC.SetCheckerOptions(testCheckerName, &userId, nil, nil, opts); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, &userId, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
found := false
|
||||||
|
for _, c := range configs {
|
||||||
|
if c.UserId != nil && c.DomainId == nil && c.ServiceId == nil {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("expected a user-level configuration entry to be stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetOptions_AdminLevel(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
opts := happydns.CheckerOptions{"run-param": "admin-val"}
|
||||||
|
if err := checkerUC.SetCheckerOptions(testCheckerName, nil, nil, nil, opts); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
if len(configs) == 0 {
|
||||||
|
t.Error("expected at least one admin-level configuration entry to be stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_SetOptions_UnknownCheckerErrors(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
opts := happydns.CheckerOptions{"param": "val"}
|
||||||
|
if err := checkerUC.SetCheckerOptions("unknown-checker-xyz", nil, nil, nil, opts); err == nil {
|
||||||
|
t.Fatal("expected error for unknown checker")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// OverwriteSomeCheckerOptions tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_Overwrite_MergesWithExisting(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Pre-seed existing options at admin scope.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"b": "2"}); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the stored options and verify both keys are present.
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
if len(configs) == 0 {
|
||||||
|
t.Fatal("expected at least one config entry")
|
||||||
|
}
|
||||||
|
merged := configs[0].Options
|
||||||
|
if merged["a"] != "1" {
|
||||||
|
t.Errorf("expected a='1' to be preserved, got %v", merged["a"])
|
||||||
|
}
|
||||||
|
if merged["b"] != "2" {
|
||||||
|
t.Errorf("expected b='2' to be added, got %v", merged["b"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_Overwrite_OverridesExistingKey(t *testing.T) {
|
||||||
|
db := newOptionsTestDB(t)
|
||||||
|
checkerUC := newTestCheckerUsecase(db)
|
||||||
|
|
||||||
|
// Pre-seed existing options.
|
||||||
|
if err := db.UpdateCheckerConfiguration(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "1"}); err != nil {
|
||||||
|
t.Fatalf("failed to seed option: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := checkerUC.OverwriteSomeCheckerOptions(testCheckerName, nil, nil, nil, happydns.CheckerOptions{"a": "99"}); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, err := db.GetCheckerConfiguration(testCheckerName, nil, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve config: %v", err)
|
||||||
|
}
|
||||||
|
if len(configs) == 0 {
|
||||||
|
t.Fatal("expected at least one config entry")
|
||||||
|
}
|
||||||
|
if configs[0].Options["a"] != "99" {
|
||||||
|
t.Errorf("expected a='99' after overwrite, got %v", configs[0].Options["a"])
|
||||||
|
}
|
||||||
|
}
|
||||||
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)
|
||||||
|
}
|
||||||
331
internal/usecase/check/check_usecase.go
Normal file
331
internal/usecase/check/check_usecase.go
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
// 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]any {
|
||||||
|
ctx := make(map[string]any)
|
||||||
|
|
||||||
|
if domainid != nil {
|
||||||
|
if domain, err := tu.autoFillStore.GetDomain(*domainid); err == nil {
|
||||||
|
ctx[happydns.AutoFillDomainName] = domain.DomainName
|
||||||
|
|
||||||
|
if len(domain.ZoneHistory) > 0 {
|
||||||
|
// The first element in ZoneHistory is the current (most recent) zone.
|
||||||
|
zoneMsg, err := tu.autoFillStore.GetZone(domain.ZoneHistory[0])
|
||||||
|
if err == nil {
|
||||||
|
ctx[happydns.AutoFillZone] = zoneMsg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} 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.AutoFillZone] = zoneMsg
|
||||||
|
ctx[happydns.AutoFillService] = svc
|
||||||
|
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])
|
||||||
|
}
|
||||||
|
}
|
||||||
434
internal/usecase/checkresult/checkresult_usecase.go
Normal file
434
internal/usecase/checkresult/checkresult_usecase.go
Normal file
|
|
@ -0,0 +1,434 @@
|
||||||
|
// 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"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CheckResultUsecase implements business logic for check results
|
||||||
|
type CheckResultUsecase struct {
|
||||||
|
storage CheckResultStorage
|
||||||
|
options *happydns.Options
|
||||||
|
checkerUC happydns.CheckerUsecase
|
||||||
|
checkerScheduleUC happydns.CheckerScheduleUsecase
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCheckResultUsecase creates a new check result usecase
|
||||||
|
func NewCheckResultUsecase(storage CheckResultStorage, options *happydns.Options, checkerUC happydns.CheckerUsecase, checkerScheduleUC happydns.CheckerScheduleUsecase) *CheckResultUsecase {
|
||||||
|
return &CheckResultUsecase{
|
||||||
|
storage: storage,
|
||||||
|
options: options,
|
||||||
|
checkerUC: checkerUC,
|
||||||
|
checkerScheduleUC: checkerScheduleUC,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCheckerStatuses returns all checkers applicable to scope with their schedule
|
||||||
|
// and most recent result for the given target.
|
||||||
|
func (u *CheckResultUsecase) ListCheckerStatuses(scope happydns.CheckScopeType, targetID happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, user *happydns.User, domain *happydns.Domain, service *happydns.Service) ([]happydns.CheckerStatus, error) {
|
||||||
|
plugins, err := u.checkerUC.ListCheckers()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get schedules for this target
|
||||||
|
schedules, err := u.checkerScheduleUC.ListSchedulesByTarget(scope, targetID, insideScope, insideID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build schedule map
|
||||||
|
scheduleMap := make(map[string]*happydns.CheckerSchedule, len(schedules))
|
||||||
|
for _, sched := range schedules {
|
||||||
|
if sched.OwnerId.Equals(user.Id) {
|
||||||
|
scheduleMap[sched.CheckerName] = sched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get service type for LimitToServices filtering
|
||||||
|
var serviceType string
|
||||||
|
if scope == happydns.CheckScopeService && service != nil {
|
||||||
|
serviceType = service.Type
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build response with last results
|
||||||
|
var statuses []happydns.CheckerStatus
|
||||||
|
for checkername, check := range *plugins {
|
||||||
|
// Filter plugins by scope
|
||||||
|
if scope == happydns.CheckScopeDomain && !check.Availability().ApplyToDomain {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if scope == happydns.CheckScopeService && !check.Availability().ApplyToService {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter plugins by service type if LimitToServices is set
|
||||||
|
if scope == happydns.CheckScopeService && serviceType != "" {
|
||||||
|
limitTo := check.Availability().LimitToServices
|
||||||
|
if len(limitTo) > 0 && !slices.Contains(limitTo, serviceType) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
info := happydns.CheckerStatus{
|
||||||
|
CheckerName: checkername,
|
||||||
|
NotDiscovered: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if there's a schedule
|
||||||
|
if sched, ok := scheduleMap[checkername]; ok {
|
||||||
|
info.Enabled = sched.Enabled
|
||||||
|
info.Schedule = sched
|
||||||
|
info.NotDiscovered = false
|
||||||
|
|
||||||
|
// Get last result
|
||||||
|
results, err := u.ListCheckResultsByTarget(checkername, scope, targetID, insideScope, insideID, user.Id, 1)
|
||||||
|
if err == nil && len(results) > 0 {
|
||||||
|
info.LastResult = results[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses = append(statuses, info)
|
||||||
|
}
|
||||||
|
|
||||||
|
return statuses, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListCheckResultsByTarget retrieves check results for a specific target owned by userId
|
||||||
|
func (u *CheckResultUsecase) ListCheckResultsByTarget(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier, limit int) ([]*happydns.CheckResult, error) {
|
||||||
|
// Apply default limit if not specified
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5 // Default to 5 most recent results
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
results = filterResultsByInside(results, insideScope, insideID)
|
||||||
|
|
||||||
|
// Filter by owner
|
||||||
|
owned := results[:0]
|
||||||
|
for _, r := range results {
|
||||||
|
if r.OwnerId.Equals(userId) {
|
||||||
|
owned = append(owned, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return owned, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAllCheckResultsByTarget retrieves all check results for a target across all plugins
|
||||||
|
func (u *CheckResultUsecase) ListAllCheckResultsByTarget(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results = filterResultsByInside(results, insideScope, insideID)
|
||||||
|
|
||||||
|
// Apply limit
|
||||||
|
if limit > 0 && len(results) > limit {
|
||||||
|
results = results[:limit]
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterResultsByInside filters check results by insideScope/insideID.
|
||||||
|
// If insideScope is nil, only results with nil InsideType are returned.
|
||||||
|
func filterResultsByInside(results []*happydns.CheckResult, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier) []*happydns.CheckResult {
|
||||||
|
filtered := results[:0]
|
||||||
|
for _, r := range results {
|
||||||
|
if insideScope == nil {
|
||||||
|
if r.InsideType == nil {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if r.InsideType != nil && *r.InsideType == *insideScope && insideID != nil && r.InsideId != nil && r.InsideId.Equals(*insideID) {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCheckResult retrieves a specific check result owned by userId
|
||||||
|
func (u *CheckResultUsecase) GetCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) (*happydns.CheckResult, error) {
|
||||||
|
result, err := u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the result belongs to the expected inside scope
|
||||||
|
if insideScope == nil {
|
||||||
|
if result.InsideType != nil {
|
||||||
|
return nil, happydns.ErrNotFound
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if result.InsideType == nil || *result.InsideType != *insideScope || insideID == nil || result.InsideId == nil || !result.InsideId.Equals(*insideID) {
|
||||||
|
return nil, happydns.ErrNotFound
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify ownership
|
||||||
|
if !result.OwnerId.Equals(userId) {
|
||||||
|
return nil, happydns.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 owned by userId
|
||||||
|
func (u *CheckResultUsecase) DeleteCheckResult(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, resultId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) error {
|
||||||
|
result, err := u.storage.GetCheckResult(pluginName, targetType, targetId, resultId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !result.OwnerId.Equals(userId) {
|
||||||
|
return happydns.ErrNotFound
|
||||||
|
}
|
||||||
|
return u.storage.DeleteCheckResult(pluginName, targetType, targetId, resultId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllCheckResults removes all results for a specific plugin+target combination owned by userId
|
||||||
|
func (u *CheckResultUsecase) DeleteAllCheckResults(pluginName string, targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) error {
|
||||||
|
// Get all results first
|
||||||
|
results, err := u.storage.ListCheckResults(pluginName, targetType, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
results = filterResultsByInside(results, insideScope, insideID)
|
||||||
|
|
||||||
|
// Delete only results owned by the requesting user
|
||||||
|
for _, r := range results {
|
||||||
|
if !r.OwnerId.Equals(userId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
InsideType: execution.InsideType,
|
||||||
|
InsideId: execution.InsideId,
|
||||||
|
OwnerId: execution.OwnerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
ScheduledCheck: execution.ScheduleId != nil,
|
||||||
|
Options: execution.Options,
|
||||||
|
Status: happydns.CheckResultStatusCritical,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorstCheckStatus returns the worst (most critical) status from the most
|
||||||
|
// recent result of each checker for a given target. Returns nil if no results exist.
|
||||||
|
func (u *CheckResultUsecase) GetWorstCheckStatus(targetType happydns.CheckScopeType, targetId happydns.Identifier, insideScope *happydns.CheckScopeType, insideID *happydns.Identifier, userId happydns.Identifier) (*happydns.CheckResultStatus, error) {
|
||||||
|
results, err := u.ListAllCheckResultsByTarget(targetType, targetId, insideScope, insideID, userId, 0)
|
||||||
|
if err != nil || len(results) == 0 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep only the latest result per checker
|
||||||
|
latest := map[string]*happydns.CheckResult{}
|
||||||
|
for _, r := range results {
|
||||||
|
if prev, ok := latest[r.CheckerName]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
|
||||||
|
latest[r.CheckerName] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find minimum (worst) status among latest results, ignoring Unknown (which
|
||||||
|
// means the check couldn't run, not that the domain is in a bad state).
|
||||||
|
var worst *happydns.CheckResultStatus
|
||||||
|
for _, r := range latest {
|
||||||
|
s := r.Status
|
||||||
|
if s == happydns.CheckResultStatusUnknown {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if worst == nil || s < *worst {
|
||||||
|
worst = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return worst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWorstCheckStatusByUser fetches all results for the user once and returns
|
||||||
|
// a map from target ID string to worst (most critical) status per target.
|
||||||
|
func (u *CheckResultUsecase) GetWorstCheckStatusByUser(targetType happydns.CheckScopeType, userId happydns.Identifier) (map[string]*happydns.CheckResultStatus, error) {
|
||||||
|
allResults, err := u.storage.ListCheckResultsByUser(userId, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type key struct {
|
||||||
|
target string
|
||||||
|
checker string
|
||||||
|
}
|
||||||
|
latest := map[key]*happydns.CheckResult{}
|
||||||
|
for _, r := range allResults {
|
||||||
|
if r.CheckType != targetType || r.CheckerName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k := key{target: r.TargetId.String(), checker: r.CheckerName}
|
||||||
|
if prev, ok := latest[k]; !ok || r.ExecutedAt.After(prev.ExecutedAt) {
|
||||||
|
latest[k] = r
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
worst := map[string]*happydns.CheckResultStatus{}
|
||||||
|
for k, r := range latest {
|
||||||
|
s := r.Status
|
||||||
|
if prev, ok := worst[k.target]; !ok || s < *prev {
|
||||||
|
worst[k.target] = &s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return worst, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
}
|
||||||
490
internal/usecase/checkresult/checkresult_usecase_test.go
Normal file
490
internal/usecase/checkresult/checkresult_usecase_test.go
Normal file
|
|
@ -0,0 +1,490 @@
|
||||||
|
// 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_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
"git.happydns.org/happyDomain/internal/storage/inmemory"
|
||||||
|
kv "git.happydns.org/happyDomain/internal/storage/kvtpl"
|
||||||
|
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestDB creates a fresh in-memory storage for each test.
|
||||||
|
func newTestDB(t *testing.T) storage.Storage {
|
||||||
|
t.Helper()
|
||||||
|
mem, err := inmemory.NewInMemoryStorage()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create in-memory storage: %v", err)
|
||||||
|
}
|
||||||
|
db, err := kv.NewKVDatabase(mem)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to create KV database: %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestCheckResultUsecase(db storage.Storage, maxResults int) *checkresultUC.CheckResultUsecase {
|
||||||
|
return checkresultUC.NewCheckResultUsecase(db, &happydns.Options{MaxResultsPerCheck: maxResults}, nil, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateCheckResult tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_CreateCheckResult_DefaultMaxResults(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
// MaxResultsPerCheck=0 → default 100; verify results are stored correctly.
|
||||||
|
uc := newTestCheckResultUsecase(db, 0)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
result := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateCheckResult(result); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Id.IsEmpty() {
|
||||||
|
t.Error("expected result to have a non-empty ID after create")
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error listing results: %v", err)
|
||||||
|
}
|
||||||
|
if len(stored) != 1 {
|
||||||
|
t.Errorf("expected 1 stored result, got %d", len(stored))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateCheckResult_CustomMaxResults(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
// MaxResultsPerCheck=3: pre-seed 5 results, create 1 more → expect 3 to remain.
|
||||||
|
uc := newTestCheckResultUsecase(db, 3)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
for i := range 5 {
|
||||||
|
r := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now().Add(-time.Duration(5-i) * time.Minute),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckResult(r); err != nil {
|
||||||
|
t.Fatalf("failed to seed result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Creating one more via the usecase triggers retention → prune to 3.
|
||||||
|
if err := uc.CreateCheckResult(&happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error listing results: %v", err)
|
||||||
|
}
|
||||||
|
if len(remaining) != 3 {
|
||||||
|
t.Errorf("expected 3 results after retention (MaxResultsPerCheck=3), got %d", len(remaining))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateCheckResult_StoresResult(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
result := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateCheckResult(result); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if result.Id.IsEmpty() {
|
||||||
|
t.Error("expected result to have a non-empty ID after create")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify retrievable from storage.
|
||||||
|
fetched, err := db.GetCheckResult("checker", happydns.CheckScopeDomain, targetId, result.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected result to be retrievable: %v", err)
|
||||||
|
}
|
||||||
|
if fetched.Id.IsEmpty() {
|
||||||
|
t.Error("retrieved result has empty ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListCheckResultsByTarget tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_ListCheckResultsByTarget_DefaultLimit(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 100)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
for i := range 8 {
|
||||||
|
r := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckResult(r); err != nil {
|
||||||
|
t.Fatalf("failed to seed result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, nil, nil, ownerId, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) > 5 {
|
||||||
|
t.Errorf("expected at most 5 results (default limit), got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ListCheckResultsByTarget_CustomLimit(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 100)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
for i := range 4 {
|
||||||
|
r := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now().Add(time.Duration(i) * time.Second),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckResult(r); err != nil {
|
||||||
|
t.Fatalf("failed to seed result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := uc.ListCheckResultsByTarget("checker", happydns.CheckScopeDomain, targetId, nil, nil, ownerId, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 2 {
|
||||||
|
t.Errorf("expected 2 results with limit=2, got %d", len(results))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DeleteAllCheckResults tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_DeleteAllCheckResults_Empty(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId, nil, nil, targetId); err != nil {
|
||||||
|
t.Fatalf("unexpected error on empty store: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DeleteAllCheckResults_OnlyTargetDeleted(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 100)
|
||||||
|
|
||||||
|
targetId1, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId2, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
for range 3 {
|
||||||
|
r := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId1,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckResult(r); err != nil {
|
||||||
|
t.Fatalf("failed to seed targetId1 result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for range 2 {
|
||||||
|
r := &happydns.CheckResult{
|
||||||
|
CheckerName: "checker",
|
||||||
|
CheckType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId2,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
ExecutedAt: time.Now(),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckResult(r); err != nil {
|
||||||
|
t.Fatalf("failed to seed targetId2 result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.DeleteAllCheckResults("checker", happydns.CheckScopeDomain, targetId1, nil, nil, ownerId); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining1, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId1, 0)
|
||||||
|
if len(remaining1) != 0 {
|
||||||
|
t.Errorf("expected 0 results for targetId1 after delete, got %d", len(remaining1))
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining2, _ := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId2, 0)
|
||||||
|
if len(remaining2) != 2 {
|
||||||
|
t.Errorf("expected 2 results for targetId2 to remain, got %d", len(remaining2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateCheckExecution tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_CreateCheckExecution_SetsStartedAt(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := uc.CreateCheckExecution(execution); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
if execution.StartedAt.IsZero() {
|
||||||
|
t.Error("expected StartedAt to be set")
|
||||||
|
}
|
||||||
|
if execution.StartedAt.Before(before) || execution.StartedAt.After(after) {
|
||||||
|
t.Errorf("StartedAt %v not in expected range [%v, %v]", execution.StartedAt, before, after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateCheckExecution_PreservesStartedAt(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
specificTime := time.Date(2024, 1, 15, 10, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
StartedAt: specificTime,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateCheckExecution(execution); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !execution.StartedAt.Equal(specificTime) {
|
||||||
|
t.Errorf("expected StartedAt to be preserved as %v, got %v", specificTime, execution.StartedAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CompleteCheckExecution tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_CompleteCheckExecution_SetsStatus(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
resultId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Status: happydns.CheckExecutionRunning,
|
||||||
|
StartedAt: time.Now().Add(-time.Second),
|
||||||
|
}
|
||||||
|
if err := uc.CreateCheckExecution(execution); err != nil {
|
||||||
|
t.Fatalf("failed to create execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := uc.CompleteCheckExecution(execution.Id, resultId); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
updated, err := db.GetCheckExecution(execution.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve execution: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Status != happydns.CheckExecutionCompleted {
|
||||||
|
t.Errorf("expected status Completed, got %v", updated.Status)
|
||||||
|
}
|
||||||
|
if updated.CompletedAt == nil {
|
||||||
|
t.Error("expected CompletedAt to be set")
|
||||||
|
} else if updated.CompletedAt.Before(before) || updated.CompletedAt.After(after) {
|
||||||
|
t.Errorf("CompletedAt %v not in expected range [%v, %v]", *updated.CompletedAt, before, after)
|
||||||
|
}
|
||||||
|
if updated.ResultId == nil || !updated.ResultId.Equals(resultId) {
|
||||||
|
t.Error("expected ResultId to be set to the provided resultId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CompleteCheckExecution_NotFound(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
nonExistentId, _ := happydns.NewRandomIdentifier()
|
||||||
|
resultId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
if err := uc.CompleteCheckExecution(nonExistentId, resultId); err == nil {
|
||||||
|
t.Fatal("expected error for non-existent execution ID")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FailCheckExecution tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_FailCheckExecution_CreatesErrorResult(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Status: happydns.CheckExecutionRunning,
|
||||||
|
StartedAt: time.Now().Add(-time.Second),
|
||||||
|
}
|
||||||
|
if err := uc.CreateCheckExecution(execution); err != nil {
|
||||||
|
t.Fatalf("failed to create execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.FailCheckExecution(execution.Id, "something went wrong"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the check result was created.
|
||||||
|
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list results: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 check result to be created on failure, got %d", len(results))
|
||||||
|
}
|
||||||
|
result := results[0]
|
||||||
|
if result.Status != happydns.CheckResultStatusCritical {
|
||||||
|
t.Errorf("expected Status=KO, got %v", result.Status)
|
||||||
|
}
|
||||||
|
if result.Error != "something went wrong" {
|
||||||
|
t.Errorf("expected Error='something went wrong', got %q", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the execution status was updated.
|
||||||
|
updated, err := db.GetCheckExecution(execution.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve execution: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Status != happydns.CheckExecutionFailed {
|
||||||
|
t.Errorf("expected execution status Failed, got %v", updated.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_FailCheckExecution_ScheduledCheckFlag(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckResultUsecase(db, 10)
|
||||||
|
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
scheduleId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
execution := &happydns.CheckExecution{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
ScheduleId: &scheduleId,
|
||||||
|
Status: happydns.CheckExecutionRunning,
|
||||||
|
StartedAt: time.Now().Add(-time.Second),
|
||||||
|
}
|
||||||
|
if err := uc.CreateCheckExecution(execution); err != nil {
|
||||||
|
t.Fatalf("failed to create execution: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.FailCheckExecution(execution.Id, "scheduled check failed"); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
results, err := db.ListCheckResults("checker", happydns.CheckScopeDomain, targetId, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list results: %v", err)
|
||||||
|
}
|
||||||
|
if len(results) != 1 {
|
||||||
|
t.Fatalf("expected 1 result, got %d", len(results))
|
||||||
|
}
|
||||||
|
if !results[0].ScheduledCheck {
|
||||||
|
t.Error("expected ScheduledCheck=true when execution has a non-nil ScheduleId")
|
||||||
|
}
|
||||||
|
}
|
||||||
465
internal/usecase/checkresult/checkschedule_usecase.go
Normal file
465
internal/usecase/checkresult/checkschedule_usecase.go
Normal file
|
|
@ -0,0 +1,465 @@
|
||||||
|
// 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, insideType *happydns.CheckScopeType, insideId *happydns.Identifier) ([]*happydns.CheckerSchedule, error) {
|
||||||
|
return u.storage.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.CheckerName, schedule.TargetType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate interval against per-checker and global bounds
|
||||||
|
if err := u.validateInterval(schedule.CheckerName, &schedule.Interval); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 against per-checker and global bounds
|
||||||
|
if err := u.validateInterval(schedule.CheckerName, &schedule.Interval); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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. If the checker
|
||||||
|
// implements CheckerIntervalProvider, its default is used; otherwise a
|
||||||
|
// scope-based fallback is returned.
|
||||||
|
func (u *CheckScheduleUsecase) getDefaultInterval(checkerName string, targetType happydns.CheckScopeType) time.Duration {
|
||||||
|
if spec := u.getCheckerIntervalSpec(checkerName); spec != nil {
|
||||||
|
return spec.Default
|
||||||
|
}
|
||||||
|
|
||||||
|
switch targetType {
|
||||||
|
case happydns.CheckScopeUser:
|
||||||
|
return DefaultUserCheckInterval
|
||||||
|
case happydns.CheckScopeDomain:
|
||||||
|
return DefaultDomainCheckInterval
|
||||||
|
case happydns.CheckScopeService:
|
||||||
|
return DefaultServiceCheckInterval
|
||||||
|
default:
|
||||||
|
return DefaultDomainCheckInterval
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateInterval clamps the interval to per-checker bounds (if available)
|
||||||
|
// and enforces the global MinimumCheckInterval as an absolute floor.
|
||||||
|
func (u *CheckScheduleUsecase) validateInterval(checkerName string, interval *time.Duration) error {
|
||||||
|
if spec := u.getCheckerIntervalSpec(checkerName); spec != nil {
|
||||||
|
if *interval < spec.Min {
|
||||||
|
*interval = spec.Min
|
||||||
|
}
|
||||||
|
if *interval > spec.Max {
|
||||||
|
*interval = spec.Max
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if *interval < MinimumCheckInterval {
|
||||||
|
return fmt.Errorf("check interval must be at least %v", MinimumCheckInterval)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getCheckerIntervalSpec returns the CheckIntervalSpec for a checker if it
|
||||||
|
// implements CheckerIntervalProvider, or nil otherwise.
|
||||||
|
func (u *CheckScheduleUsecase) getCheckerIntervalSpec(checkerName string) *happydns.CheckIntervalSpec {
|
||||||
|
if u.checkerUsecase == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
checker, err := u.checkerUsecase.GetChecker(checkerName)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
ip, ok := checker.(happydns.CheckerIntervalProvider)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spec := ip.CheckInterval()
|
||||||
|
return &spec
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
insideType *happydns.CheckScopeType,
|
||||||
|
insideId *happydns.Identifier,
|
||||||
|
ownerId happydns.Identifier,
|
||||||
|
enabled bool,
|
||||||
|
) error {
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: checkerName,
|
||||||
|
OwnerId: ownerId,
|
||||||
|
InsideType: insideType,
|
||||||
|
InsideId: insideId,
|
||||||
|
TargetType: targetType,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: u.getDefaultInterval(checkerName, targetType),
|
||||||
|
Enabled: enabled,
|
||||||
|
NextRun: time.Now().Add(u.getDefaultInterval(checkerName, 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, insideType *happydns.CheckScopeType, insideId *happydns.Identifier, ownerId happydns.Identifier) error {
|
||||||
|
schedules, err := u.storage.ListCheckerSchedulesByTarget(targetType, targetId, insideType, insideId)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, schedule := range schedules {
|
||||||
|
if !schedule.OwnerId.Equals(ownerId) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
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, nil, nil)
|
||||||
|
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...)
|
||||||
|
}
|
||||||
890
internal/usecase/checkresult/checkschedule_usecase_test.go
Normal file
890
internal/usecase/checkresult/checkschedule_usecase_test.go
Normal file
|
|
@ -0,0 +1,890 @@
|
||||||
|
// 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_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/internal/storage"
|
||||||
|
checkresultUC "git.happydns.org/happyDomain/internal/usecase/checkresult"
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mockCheckerUsecase – minimal CheckerUsecase backed by a fixed map.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type mockCheckerUsecase struct {
|
||||||
|
checkers map[string]happydns.Checker
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) ListCheckers() (*map[string]happydns.Checker, error) {
|
||||||
|
return &m.checkers, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) GetChecker(name string) (happydns.Checker, error) {
|
||||||
|
c, ok := m.checkers[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("checker not found: %s", name)
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) GetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (*happydns.CheckerOptions, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) BuildMergedCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, runOpts happydns.CheckerOptions) (happydns.CheckerOptions, error) {
|
||||||
|
return runOpts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) GetStoredCheckerOptionsNoDefault(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier) (happydns.CheckerOptions, error) {
|
||||||
|
return make(happydns.CheckerOptions), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) SetCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockCheckerUsecase) OverwriteSomeCheckerOptions(name string, userid *happydns.Identifier, domainid *happydns.Identifier, serviceid *happydns.Identifier, opts happydns.CheckerOptions) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// mockDomainChecker – Checker with configurable Availability.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
type mockDomainChecker struct {
|
||||||
|
name string
|
||||||
|
applyDomain bool
|
||||||
|
applyService bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mockDomainChecker) ID() string { return m.name }
|
||||||
|
func (m *mockDomainChecker) Name() string { return m.name }
|
||||||
|
func (m *mockDomainChecker) Availability() happydns.CheckerAvailability {
|
||||||
|
return happydns.CheckerAvailability{ApplyToDomain: m.applyDomain, ApplyToService: m.applyService}
|
||||||
|
}
|
||||||
|
func (m *mockDomainChecker) Options() happydns.CheckerOptionsDocumentation {
|
||||||
|
return happydns.CheckerOptionsDocumentation{}
|
||||||
|
}
|
||||||
|
func (m *mockDomainChecker) RunCheck(_ context.Context, opts happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Constructor helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func newTestCheckScheduleUsecase(db storage.Storage, checkerUC happydns.CheckerUsecase) *checkresultUC.CheckScheduleUsecase {
|
||||||
|
return checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, db, checkerUC)
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedSchedule creates and stores a schedule in the db, returning it with its assigned ID.
|
||||||
|
func seedSchedule(t *testing.T, db storage.Storage, interval time.Duration) *happydns.CheckerSchedule {
|
||||||
|
t.Helper()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: interval,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: time.Now().Add(interval),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to seed schedule: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CreateSchedule tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_CreateSchedule_DefaultInterval_Domain(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateSchedule(schedule); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if schedule.Interval != checkresultUC.DefaultDomainCheckInterval {
|
||||||
|
t.Errorf("expected default domain interval %v, got %v", checkresultUC.DefaultDomainCheckInterval, schedule.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateSchedule_DefaultInterval_Service(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeService,
|
||||||
|
TargetId: targetId,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateSchedule(schedule); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if schedule.Interval != checkresultUC.DefaultServiceCheckInterval {
|
||||||
|
t.Errorf("expected default service interval %v, got %v", checkresultUC.DefaultServiceCheckInterval, schedule.Interval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateSchedule_MinimumIntervalRejected(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: 4 * time.Minute,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateSchedule(schedule); err == nil {
|
||||||
|
t.Fatal("expected error for interval below minimum (4 minutes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateSchedule_ExactMinimumAccepted(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: checkresultUC.MinimumCheckInterval,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateSchedule(schedule); err != nil {
|
||||||
|
t.Errorf("expected no error for exactly minimum interval, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateSchedule_NextRunSetWhenZero(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
interval := 30 * time.Minute
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: interval,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := uc.CreateSchedule(schedule); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if schedule.NextRun.IsZero() {
|
||||||
|
t.Fatal("expected NextRun to be set")
|
||||||
|
}
|
||||||
|
// NextRun = now + rand(0, interval), so NextRun is in [before, before+interval).
|
||||||
|
if schedule.NextRun.Before(before) || schedule.NextRun.After(before.Add(interval)) {
|
||||||
|
t.Errorf("NextRun %v not in expected range [%v, %v+interval]", schedule.NextRun, before, before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_CreateSchedule_NextRunPreserved(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
specificNextRun := time.Now().Add(3 * time.Hour)
|
||||||
|
|
||||||
|
schedule := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: 30 * time.Minute,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: specificNextRun,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.CreateSchedule(schedule); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if !schedule.NextRun.Equal(specificNextRun) {
|
||||||
|
t.Errorf("expected NextRun to be preserved as %v, got %v", specificNextRun, schedule.NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UpdateSchedule tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_UpdateSchedule_PreservesLastRun(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
lastRun := time.Now().Add(-30 * time.Minute)
|
||||||
|
existing.LastRun = &lastRun
|
||||||
|
// Store LastRun into the db.
|
||||||
|
if err := db.UpdateCheckerSchedule(existing); err != nil {
|
||||||
|
t.Fatalf("failed to persist LastRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update without setting LastRun → should be preserved from existing.
|
||||||
|
update := *existing
|
||||||
|
update.LastRun = nil
|
||||||
|
|
||||||
|
if err := uc.UpdateSchedule(&update); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
if stored.LastRun == nil {
|
||||||
|
t.Error("expected LastRun to be preserved from existing schedule")
|
||||||
|
} else if !stored.LastRun.Equal(lastRun) {
|
||||||
|
t.Errorf("expected LastRun %v, got %v", lastRun, *stored.LastRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_UpdateSchedule_RecalculatesNextRun(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
lastRun := time.Now().Add(-20 * time.Minute)
|
||||||
|
existing.LastRun = &lastRun
|
||||||
|
if err := db.UpdateCheckerSchedule(existing); err != nil {
|
||||||
|
t.Fatalf("failed to persist LastRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newInterval := 2 * time.Hour
|
||||||
|
update := *existing
|
||||||
|
update.Interval = newInterval
|
||||||
|
|
||||||
|
if err := uc.UpdateSchedule(&update); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
expectedNextRun := lastRun.Add(newInterval)
|
||||||
|
if !stored.NextRun.Equal(expectedNextRun) {
|
||||||
|
t.Errorf("expected NextRun=%v (LastRun+newInterval), got %v", expectedNextRun, stored.NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_UpdateSchedule_NextRunFromNowWhenNoLastRun(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
// Ensure no LastRun in stored version.
|
||||||
|
existing.LastRun = nil
|
||||||
|
if err := db.UpdateCheckerSchedule(existing); err != nil {
|
||||||
|
t.Fatalf("failed to persist cleared LastRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newInterval := 2 * time.Hour
|
||||||
|
update := *existing
|
||||||
|
update.Interval = newInterval
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := uc.UpdateSchedule(&update); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
lowerBound := before.Add(newInterval)
|
||||||
|
upperBound := after.Add(newInterval)
|
||||||
|
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
|
||||||
|
t.Errorf("expected NextRun in [%v, %v], got %v", lowerBound, upperBound, stored.NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_UpdateSchedule_MinimumIntervalRejected(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
update := *existing
|
||||||
|
update.Interval = 2 * time.Minute
|
||||||
|
|
||||||
|
if err := uc.UpdateSchedule(&update); err == nil {
|
||||||
|
t.Fatal("expected error for interval below minimum")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// EnableSchedule / DisableSchedule tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_EnableSchedule_SetsEnabled(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
existing.Enabled = false
|
||||||
|
existing.NextRun = time.Now().Add(time.Hour) // future, no reset needed
|
||||||
|
if err := db.UpdateCheckerSchedule(existing); err != nil {
|
||||||
|
t.Fatalf("failed to persist disabled schedule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := uc.EnableSchedule(existing.Id); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
if !stored.Enabled {
|
||||||
|
t.Error("expected Enabled=true after EnableSchedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_EnableSchedule_ResetsNextRunIfPast(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
existing.Enabled = false
|
||||||
|
existing.NextRun = time.Now().Add(-time.Hour) // in the past
|
||||||
|
if err := db.UpdateCheckerSchedule(existing); err != nil {
|
||||||
|
t.Fatalf("failed to persist past NextRun: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
if err := uc.EnableSchedule(existing.Id); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
lowerBound := before.Add(existing.Interval)
|
||||||
|
upperBound := after.Add(existing.Interval)
|
||||||
|
if stored.NextRun.Before(lowerBound) || stored.NextRun.After(upperBound) {
|
||||||
|
t.Errorf("expected NextRun in [%v, %v] after enable, got %v", lowerBound, upperBound, stored.NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DisableSchedule_SetsDisabled(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
// It's already Enabled=true from seedSchedule.
|
||||||
|
|
||||||
|
if err := uc.DisableSchedule(existing.Id); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored, err := db.GetCheckerSchedule(existing.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
if stored.Enabled {
|
||||||
|
t.Error("expected Enabled=false after DisableSchedule")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListDueSchedules tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_ListDueSchedules_FiltersDisabledAndFuture(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
pastTime := time.Now().Add(-time.Minute)
|
||||||
|
futureTime := time.Now().Add(time.Hour)
|
||||||
|
|
||||||
|
makeAndStore := func(enabled bool, nextRun time.Time) *happydns.CheckerSchedule {
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: time.Hour,
|
||||||
|
Enabled: enabled,
|
||||||
|
NextRun: nextRun,
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to create schedule: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
enabledDue := makeAndStore(true, pastTime)
|
||||||
|
_ = makeAndStore(false, pastTime) // disabled + past → not returned
|
||||||
|
_ = makeAndStore(true, futureTime) // enabled + future → not returned
|
||||||
|
|
||||||
|
due, err := uc.ListDueSchedules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 1 {
|
||||||
|
t.Errorf("expected 1 due schedule, got %d", len(due))
|
||||||
|
}
|
||||||
|
if len(due) > 0 && !due[0].Id.Equals(enabledDue.Id) {
|
||||||
|
t.Errorf("expected the enabled+past schedule to be returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ListDueSchedules_Empty(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
due, err := uc.ListDueSchedules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(due) != 0 {
|
||||||
|
t.Errorf("expected 0 due schedules, got %d", len(due))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ListUpcomingSchedules tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_ListUpcomingSchedules_SortedAscending(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
// Insert in reverse order (far future first) to ensure sorting is needed.
|
||||||
|
for i := 3; i >= 1; i-- {
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: time.Hour,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: now.Add(time.Duration(i) * time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to create schedule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upcoming, err := uc.ListUpcomingSchedules(0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
for i := 1; i < len(upcoming); i++ {
|
||||||
|
if upcoming[i].NextRun.Before(upcoming[i-1].NextRun) {
|
||||||
|
t.Errorf("schedules not in ascending order at index %d: %v > %v",
|
||||||
|
i, upcoming[i-1].NextRun, upcoming[i].NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ListUpcomingSchedules_LimitApplied(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
for i := range 5 {
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: time.Hour,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: now.Add(time.Duration(i) * time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to create schedule: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
upcoming, err := uc.ListUpcomingSchedules(3)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(upcoming) != 3 {
|
||||||
|
t.Errorf("expected 3 schedules with limit=3, got %d", len(upcoming))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// ValidateScheduleOwnership tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func Test_ValidateScheduleOwnership_Match(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
|
||||||
|
if err := uc.ValidateScheduleOwnership(existing.Id, existing.OwnerId); err != nil {
|
||||||
|
t.Errorf("expected no error for matching owner, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_ValidateScheduleOwnership_Mismatch(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
existing := seedSchedule(t, db, time.Hour)
|
||||||
|
wrongUserId, _ := happydns.NewRandomIdentifier()
|
||||||
|
|
||||||
|
if err := uc.ValidateScheduleOwnership(existing.Id, wrongUserId); err == nil {
|
||||||
|
t.Fatal("expected error for wrong owner")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// RescheduleOverdueChecks tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func createOverdueSchedules(t *testing.T, db storage.Storage, n int) []*happydns.CheckerSchedule {
|
||||||
|
t.Helper()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
pastTime := time.Now().Add(-2 * time.Hour)
|
||||||
|
|
||||||
|
schedules := make([]*happydns.CheckerSchedule, n)
|
||||||
|
for i := range n {
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: time.Hour,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: pastTime,
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to create overdue schedule: %v", err)
|
||||||
|
}
|
||||||
|
schedules[i] = s
|
||||||
|
}
|
||||||
|
return schedules
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_RescheduleOverdueChecks_FewOverdue_NoChange(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
schedules := createOverdueSchedules(t, db, 5)
|
||||||
|
originalNextRuns := make([]time.Time, len(schedules))
|
||||||
|
for i, s := range schedules {
|
||||||
|
originalNextRuns[i] = s.NextRun
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := uc.RescheduleOverdueChecks()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("expected count=0 for fewer than 10 overdue schedules, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NextRun should not have changed.
|
||||||
|
for i, s := range schedules {
|
||||||
|
stored, err := db.GetCheckerSchedule(s.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule: %v", err)
|
||||||
|
}
|
||||||
|
if !stored.NextRun.Equal(originalNextRuns[i]) {
|
||||||
|
t.Errorf("schedule[%d] NextRun changed when it should not have", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_RescheduleOverdueChecks_ManyOverdue_Rescheduled(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
schedules := createOverdueSchedules(t, db, 15)
|
||||||
|
|
||||||
|
before := time.Now()
|
||||||
|
count, err := uc.RescheduleOverdueChecks()
|
||||||
|
after := time.Now()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if count != 15 {
|
||||||
|
t.Errorf("expected count=15, got %d", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// All schedules should now have NextRun in [before, before+MinimumCheckInterval].
|
||||||
|
for i, s := range schedules {
|
||||||
|
stored, err := db.GetCheckerSchedule(s.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve schedule[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
if !stored.NextRun.After(before) {
|
||||||
|
t.Errorf("schedule[%d] NextRun %v should be after %v", i, stored.NextRun, before)
|
||||||
|
}
|
||||||
|
upperBound := after.Add(checkresultUC.MinimumCheckInterval)
|
||||||
|
if stored.NextRun.After(upperBound) {
|
||||||
|
t.Errorf("schedule[%d] NextRun %v should not exceed %v", i, stored.NextRun, upperBound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_RescheduleOverdueChecks_FutureSchedulesIgnored(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
uc := newTestCheckScheduleUsecase(db, nil)
|
||||||
|
|
||||||
|
overdue := createOverdueSchedules(t, db, 15)
|
||||||
|
_ = overdue // created in db
|
||||||
|
|
||||||
|
// Add 3 future enabled schedules.
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
targetId, _ := happydns.NewRandomIdentifier()
|
||||||
|
futureTime := time.Now().Add(2 * time.Hour)
|
||||||
|
var futureSchedules []*happydns.CheckerSchedule
|
||||||
|
for range 3 {
|
||||||
|
s := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "checker",
|
||||||
|
OwnerId: ownerId,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: targetId,
|
||||||
|
Interval: time.Hour,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: futureTime,
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(s); err != nil {
|
||||||
|
t.Fatalf("failed to create future schedule: %v", err)
|
||||||
|
}
|
||||||
|
futureSchedules = append(futureSchedules, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := uc.RescheduleOverdueChecks(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Future schedules should retain their original NextRun.
|
||||||
|
for i, s := range futureSchedules {
|
||||||
|
stored, err := db.GetCheckerSchedule(s.Id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to retrieve future schedule[%d]: %v", i, err)
|
||||||
|
}
|
||||||
|
if !stored.NextRun.Equal(futureTime) {
|
||||||
|
t.Errorf("future schedule[%d] NextRun changed from %v to %v", i, futureTime, stored.NextRun)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DiscoverAndEnsureSchedules tests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
func createDomain(t *testing.T, db storage.Storage, name string) *happydns.Domain {
|
||||||
|
t.Helper()
|
||||||
|
ownerId, _ := happydns.NewRandomIdentifier()
|
||||||
|
domain := &happydns.Domain{
|
||||||
|
Owner: ownerId,
|
||||||
|
DomainName: name,
|
||||||
|
}
|
||||||
|
if err := db.CreateDomain(domain); err != nil {
|
||||||
|
t.Fatalf("failed to create domain %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DiscoverAndEnsureSchedules_CreatesForMissingPlugin(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
domain := createDomain(t, db, "example.com.")
|
||||||
|
checkerUC := &mockCheckerUsecase{
|
||||||
|
checkers: map[string]happydns.Checker{
|
||||||
|
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
uc := newTestCheckScheduleUsecase(db, checkerUC)
|
||||||
|
|
||||||
|
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list schedules: %v", err)
|
||||||
|
}
|
||||||
|
if len(schedules) != 1 {
|
||||||
|
t.Errorf("expected 1 schedule created, got %d", len(schedules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DiscoverAndEnsureSchedules_SkipsExistingSchedule(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
domain := createDomain(t, db, "example.com.")
|
||||||
|
checkerUC := &mockCheckerUsecase{
|
||||||
|
checkers: map[string]happydns.Checker{
|
||||||
|
"domain-checker": &mockDomainChecker{name: "domain-checker", applyDomain: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-seed a schedule for this domain + checker.
|
||||||
|
pre := &happydns.CheckerSchedule{
|
||||||
|
CheckerName: "domain-checker",
|
||||||
|
OwnerId: domain.Owner,
|
||||||
|
TargetType: happydns.CheckScopeDomain,
|
||||||
|
TargetId: domain.Id,
|
||||||
|
Interval: 24 * time.Hour,
|
||||||
|
Enabled: true,
|
||||||
|
NextRun: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
if err := db.CreateCheckerSchedule(pre); err != nil {
|
||||||
|
t.Fatalf("failed to seed schedule: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
uc := newTestCheckScheduleUsecase(db, checkerUC)
|
||||||
|
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list schedules: %v", err)
|
||||||
|
}
|
||||||
|
if len(schedules) != 1 {
|
||||||
|
t.Errorf("expected 1 schedule (no duplicate), got %d", len(schedules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DiscoverAndEnsureSchedules_SkipsServiceOnlyChecker(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
domain := createDomain(t, db, "example.com.")
|
||||||
|
checkerUC := &mockCheckerUsecase{
|
||||||
|
checkers: map[string]happydns.Checker{
|
||||||
|
"service-only": &mockDomainChecker{name: "service-only", applyDomain: false, applyService: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
uc := newTestCheckScheduleUsecase(db, checkerUC)
|
||||||
|
|
||||||
|
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
schedules, err := db.ListCheckerSchedulesByTarget(happydns.CheckScopeDomain, domain.Id, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list schedules: %v", err)
|
||||||
|
}
|
||||||
|
if len(schedules) != 0 {
|
||||||
|
t.Errorf("expected 0 schedules for service-only checker, got %d", len(schedules))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DiscoverAndEnsureSchedules_NilDependencies(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
// Both domainLister and checkerUsecase are nil → returns nil, no panic.
|
||||||
|
uc := checkresultUC.NewCheckScheduleUsecase(db, &happydns.Options{}, nil, nil)
|
||||||
|
|
||||||
|
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
|
||||||
|
t.Errorf("expected nil error for nil dependencies, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Test_DiscoverAndEnsureSchedules_MultipleDomains(t *testing.T) {
|
||||||
|
db := newTestDB(t)
|
||||||
|
createDomain(t, db, "alpha.com.")
|
||||||
|
createDomain(t, db, "beta.com.")
|
||||||
|
createDomain(t, db, "gamma.com.")
|
||||||
|
|
||||||
|
checkerUC := &mockCheckerUsecase{
|
||||||
|
checkers: map[string]happydns.Checker{
|
||||||
|
"checker-1": &mockDomainChecker{name: "checker-1", applyDomain: true},
|
||||||
|
"checker-2": &mockDomainChecker{name: "checker-2", applyDomain: true},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
uc := newTestCheckScheduleUsecase(db, checkerUC)
|
||||||
|
|
||||||
|
if err := uc.DiscoverAndEnsureSchedules(); err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3 domains × 2 checkers = 6 schedules.
|
||||||
|
enabled, err := db.ListEnabledCheckerSchedules()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to list schedules: %v", err)
|
||||||
|
}
|
||||||
|
if len(enabled) != 6 {
|
||||||
|
t.Errorf("expected 6 schedules (3 domains × 2 checkers), got %d", len(enabled))
|
||||||
|
}
|
||||||
|
}
|
||||||
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, insideType *happydns.CheckScopeType, insideId *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)
|
||||||
|
}
|
||||||
62
internal/usecase/domain_info_usecase.go
Normal file
62
internal/usecase/domain_info_usecase.go
Normal file
|
|
@ -0,0 +1,62 @@
|
||||||
|
// 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 usecase
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type domainInfoUsecase struct {
|
||||||
|
getters []happydns.DomainInfoGetter
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDomainInfoUsecase(getters ...happydns.DomainInfoGetter) happydns.DomainInfoUsecase {
|
||||||
|
return &domainInfoUsecase{getters: getters}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (diu *domainInfoUsecase) GetDomainInfo(ctx context.Context, fqdn happydns.Origin) (*happydns.DomainInfo, error) {
|
||||||
|
domain := happydns.Origin(strings.TrimSuffix(string(fqdn), "."))
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for _, getter := range diu.getters {
|
||||||
|
infos, err := getter(ctx, domain)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, happydns.DomainDoesNotExist) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
lastErr = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if infos == nil {
|
||||||
|
lastErr = fmt.Errorf("no information found")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return infos, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("unable to retrieve RDAP/WHOIS info about the domain name: %w", lastErr)
|
||||||
|
}
|
||||||
226
model/check_result.go
Normal file
226
model/check_result.go
Normal file
|
|
@ -0,0 +1,226 @@
|
||||||
|
// 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
|
||||||
|
CheckScopeZone
|
||||||
|
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"`
|
||||||
|
|
||||||
|
// InsideType indicates the scope level of the parent target
|
||||||
|
InsideType *CheckScopeType `json:"check_type"`
|
||||||
|
|
||||||
|
// InsideId is the identifier of the parent target (User/Domain)
|
||||||
|
InsideId *Identifier `json:"inside_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"`
|
||||||
|
|
||||||
|
// InsideType indicates the scope level of the parent target
|
||||||
|
InsideType *CheckScopeType `json:"check_type"`
|
||||||
|
|
||||||
|
// InsideId is the identifier of the parent target (User/Domain)
|
||||||
|
InsideId *Identifier `json:"inside_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 {
|
||||||
|
// ListCheckerStatuses returns all checkers applicable to scope with their
|
||||||
|
// schedule and most recent result for the given target.
|
||||||
|
ListCheckerStatuses(scope CheckScopeType, targetID Identifier, insideScope *CheckScopeType, insideID *Identifier, user *User, domain *Domain, service *Service) ([]CheckerStatus, error)
|
||||||
|
|
||||||
|
// ListCheckResultsByTarget retrieves check results for a specific target owned by userId
|
||||||
|
ListCheckResultsByTarget(checkerName string, targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier, limit int) ([]*CheckResult, error)
|
||||||
|
|
||||||
|
// ListAllCheckResultsByTarget retrieves all check results for a target across all checkers
|
||||||
|
ListAllCheckResultsByTarget(targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier, limit int) ([]*CheckResult, error)
|
||||||
|
|
||||||
|
// GetCheckResult retrieves a specific check result owned by userId
|
||||||
|
GetCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) (*CheckResult, error)
|
||||||
|
|
||||||
|
// CreateCheckResult stores a new check result and enforces retention policy
|
||||||
|
CreateCheckResult(result *CheckResult) error
|
||||||
|
|
||||||
|
// DeleteCheckResult removes a specific check result owned by userId
|
||||||
|
DeleteCheckResult(checkName string, targetType CheckScopeType, targetId Identifier, resultId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) error
|
||||||
|
|
||||||
|
// DeleteAllCheckResults removes all results for a specific checker+target combination owned by userId
|
||||||
|
DeleteAllCheckResults(checkName string, targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId 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
|
||||||
|
|
||||||
|
// DeleteCompletedExecutions removes execution records older than the given duration.
|
||||||
|
DeleteCompletedExecutions(olderThan time.Duration) error
|
||||||
|
|
||||||
|
// CleanupOldResults removes check results older than the configured retention period.
|
||||||
|
CleanupOldResults() error
|
||||||
|
|
||||||
|
// GetWorstCheckStatus returns the worst (most critical) status from the most
|
||||||
|
// recent result of each checker for a given target. Returns nil if no results exist.
|
||||||
|
GetWorstCheckStatus(targetType CheckScopeType, targetId Identifier, insideScope *CheckScopeType, insideID *Identifier, userId Identifier) (*CheckResultStatus, error)
|
||||||
|
|
||||||
|
// GetWorstCheckStatusByUser returns a map from target ID string to worst check
|
||||||
|
// status for all targets of the given type owned by the user, in a single pass.
|
||||||
|
GetWorstCheckStatusByUser(targetType CheckScopeType, userId Identifier) (map[string]*CheckResultStatus, error)
|
||||||
|
}
|
||||||
153
model/check_scheduler.go
Normal file
153
model/check_scheduler.go
Normal file
|
|
@ -0,0 +1,153 @@
|
||||||
|
// 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, insideScopeType *CheckScopeType, insideID *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"`
|
||||||
|
|
||||||
|
// InsideType indicates the scope level of the parent target
|
||||||
|
InsideType *CheckScopeType `json:"check_type"`
|
||||||
|
|
||||||
|
// InsideId is the identifier of the parent target (User/Domain)
|
||||||
|
InsideId *Identifier `json:"inside_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, insideType *CheckScopeType, insideId *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, insideType *CheckScopeType, insideId *Identifier, ownerId Identifier) error
|
||||||
|
|
||||||
|
// ListUpcomingSchedules retrieves the next limit enabled schedules sorted by NextRun ascending.
|
||||||
|
ListUpcomingSchedules(limit int) ([]*CheckerSchedule, error)
|
||||||
|
|
||||||
|
// DiscoverAndEnsureSchedules creates default enabled schedules for all (plugin, domain) pairs
|
||||||
|
// that do not already have a schedule.
|
||||||
|
DiscoverAndEnsureSchedules() 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)
|
||||||
|
}
|
||||||
199
model/checker.go
Normal file
199
model/checker.go
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
// 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 (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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"
|
||||||
|
|
||||||
|
// AutoFillZone fills the option with the zone object. Only applicable
|
||||||
|
// for domain-scoped and service-scoped tests.
|
||||||
|
AutoFillZone = "zone"
|
||||||
|
|
||||||
|
// AutoFillServiceType fills the option with the service type identifier
|
||||||
|
// (e.g. "abstract.MatrixIM"). Only applicable for service-scoped tests.
|
||||||
|
AutoFillServiceType = "service_type"
|
||||||
|
|
||||||
|
// AutoFillService fills the option with the service object. Only applicable
|
||||||
|
// for service-scoped tests.
|
||||||
|
AutoFillService = "service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
CheckResultStatusUnknown CheckResultStatus = iota
|
||||||
|
CheckResultStatusCritical
|
||||||
|
CheckResultStatusWarn
|
||||||
|
CheckResultStatusInfo
|
||||||
|
CheckResultStatusOK
|
||||||
|
)
|
||||||
|
|
||||||
|
type CheckResultStatus int
|
||||||
|
|
||||||
|
func (s CheckResultStatus) String() string {
|
||||||
|
switch s {
|
||||||
|
case CheckResultStatusOK:
|
||||||
|
return "ok"
|
||||||
|
case CheckResultStatusWarn:
|
||||||
|
return "warning"
|
||||||
|
case CheckResultStatusCritical:
|
||||||
|
return "critical"
|
||||||
|
case CheckResultStatusInfo:
|
||||||
|
return "info"
|
||||||
|
default:
|
||||||
|
return "unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckerOptions map[string]any
|
||||||
|
|
||||||
|
type Checker interface {
|
||||||
|
ID() string
|
||||||
|
Name() string
|
||||||
|
Availability() CheckerAvailability
|
||||||
|
Options() CheckerOptionsDocumentation
|
||||||
|
RunCheck(ctx context.Context, options CheckerOptions, meta map[string]string) (*CheckResult, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckIntervalSpec describes the scheduling bounds for a checker.
|
||||||
|
type CheckIntervalSpec struct {
|
||||||
|
Min time.Duration `json:"min" swaggertype:"integer"`
|
||||||
|
Max time.Duration `json:"max" swaggertype:"integer"`
|
||||||
|
Default time.Duration `json:"default" swaggertype:"integer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckerIntervalProvider is an optional interface checkers can implement
|
||||||
|
// to declare their preferred scheduling bounds. The scheduler enforces
|
||||||
|
// Min/Max; Default is used when creating a new schedule with no explicit interval.
|
||||||
|
type CheckerIntervalProvider interface {
|
||||||
|
CheckInterval() CheckIntervalSpec
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckerHTMLReporter is an optional interface checkers can implement
|
||||||
|
// to render their stored report as a full HTML document (for iframe embedding).
|
||||||
|
// Detect support with a type assertion: _, ok := checker.(CheckerHTMLReporter)
|
||||||
|
type CheckerHTMLReporter interface {
|
||||||
|
// GetHTMLReport generates an HTML document from the JSON-encoded report data
|
||||||
|
// stored in CheckResult.Report.
|
||||||
|
// The raw parameter contains the JSON bytes of the Report field as stored.
|
||||||
|
GetHTMLReport(raw json.RawMessage) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricPoint represents a single data point in a time series.
|
||||||
|
type MetricPoint struct {
|
||||||
|
Timestamp time.Time `json:"timestamp"`
|
||||||
|
Value float64 `json:"value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricSeries represents a named time series with a display label and unit.
|
||||||
|
type MetricSeries struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Unit string `json:"unit"`
|
||||||
|
Points []MetricPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MetricsReport contains all metric series extracted from check results.
|
||||||
|
type MetricsReport struct {
|
||||||
|
Series []MetricSeries `json:"series"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckerMetricsReporter is an optional interface checkers can implement
|
||||||
|
// to signal that they produce time-series metrics suitable for charting.
|
||||||
|
// Detect support with a type assertion: _, ok := checker.(CheckerMetricsReporter)
|
||||||
|
type CheckerMetricsReporter interface {
|
||||||
|
// ExtractMetrics builds time series from a slice of check results.
|
||||||
|
// Each result's ExecutedAt becomes the timestamp for that data point.
|
||||||
|
ExtractMetrics(results []*CheckResult) (*MetricsReport, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type CheckerResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Availability CheckerAvailability `json:"availability"`
|
||||||
|
Options CheckerOptionsDocumentation `json:"options"`
|
||||||
|
Interval *CheckIntervalSpec `json:"interval"`
|
||||||
|
HasHTMLReport bool `json:"has_html_report,omitempty"`
|
||||||
|
HasMetrics bool `json:"has_metrics,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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"`
|
||||||
|
NotDiscovered bool `json:"not_discovered,omitempty"`
|
||||||
|
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.
|
// CaptchaLoginThreshold is the number of consecutive login failures before captcha is required.
|
||||||
// 0 means always require captcha at login (when provider is configured).
|
// 0 means always require captcha at login (when provider is configured).
|
||||||
CaptchaLoginThreshold int
|
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.
|
// GetBaseURL returns the full url to the absolute ExternalURL, including BaseURL.
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,13 @@ type DomainWithZoneMetadata struct {
|
||||||
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
|
ZoneMeta map[string]*ZoneMeta `json:"zone_meta"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DomainWithCheckStatus struct {
|
||||||
|
*Domain
|
||||||
|
// LastCheckStatus is the worst status across the most recent result of each
|
||||||
|
// checker that has run on this domain. Nil if no results exist yet.
|
||||||
|
LastCheckStatus *CheckResultStatus `json:"last_check_status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type Subdomain string
|
type Subdomain string
|
||||||
type Origin string
|
type Origin string
|
||||||
|
|
||||||
|
|
|
||||||
48
model/domain_info.go
Normal file
48
model/domain_info.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package happydns
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
DomainDoesNotExist = errors.New("domain name doesn't exist")
|
||||||
|
)
|
||||||
|
|
||||||
|
type DomainInfo struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Nameservers []string `json:"nameservers"`
|
||||||
|
CreationDate *time.Time `json:"creation"`
|
||||||
|
ExpirationDate *time.Time `json:"expiration"`
|
||||||
|
Registrar string `json:"registrar"`
|
||||||
|
RegistrarURL *string `json:"registrar_url"`
|
||||||
|
Status []string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DomainInfoGetter func(context.Context, Origin) (*DomainInfo, error)
|
||||||
|
|
||||||
|
type DomainInfoUsecase interface {
|
||||||
|
GetDomainInfo(context.Context, Origin) (*DomainInfo, error)
|
||||||
|
}
|
||||||
|
|
@ -27,15 +27,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrAuthUserNotFound = errors.New("user not found")
|
ErrAuthUserNotFound = errors.New("user not found")
|
||||||
ErrDomainNotFound = errors.New("domain not found")
|
ErrCheckExecutionNotFound = errors.New("check execution not found")
|
||||||
ErrDomainLogNotFound = errors.New("domain log not found")
|
ErrCheckResultNotFound = errors.New("check result not found")
|
||||||
ErrProviderNotFound = errors.New("provider not found")
|
ErrCheckScheduleNotFound = errors.New("check schedule not found")
|
||||||
ErrSessionNotFound = errors.New("session not found")
|
ErrDomainNotFound = errors.New("domain not found")
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrDomainLogNotFound = errors.New("domain log not found")
|
||||||
ErrUserAlreadyExist = errors.New("user already exists")
|
ErrProviderNotFound = errors.New("provider not found")
|
||||||
ErrZoneNotFound = errors.New("zone not found")
|
ErrSessionNotFound = errors.New("session not found")
|
||||||
ErrNotFound = errors.New("not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
|
ErrUserAlreadyExist = errors.New("user already exists")
|
||||||
|
ErrZoneNotFound = errors.New("zone not found")
|
||||||
|
ErrNotFound = errors.New("not found")
|
||||||
)
|
)
|
||||||
|
|
||||||
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
const TryAgainErr = "Sorry, we are currently unable to sent email validation link. Please try again later."
|
||||||
|
|
|
||||||
|
|
@ -104,6 +104,11 @@ type Field struct {
|
||||||
|
|
||||||
// Description stores an helpfull sentence describing the field.
|
// Description stores an helpfull sentence describing the field.
|
||||||
Description string `json:"description,omitempty"`
|
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 {
|
type FormState struct {
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/gob"
|
"encoding/gob"
|
||||||
"errors"
|
"errors"
|
||||||
|
"slices"
|
||||||
)
|
)
|
||||||
|
|
||||||
const IDENTIFIER_LEN = 16
|
const IDENTIFIER_LEN = 16
|
||||||
|
|
@ -55,6 +56,10 @@ func (i Identifier) Equals(other Identifier) bool {
|
||||||
return bytes.Equal(i, other)
|
return bytes.Equal(i, other)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (i Identifier) Compare(other Identifier) int {
|
||||||
|
return slices.Compare(i, other)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Identifier) String() string {
|
func (i *Identifier) String() string {
|
||||||
return base64.RawURLEncoding.EncodeToString(*i)
|
return base64.RawURLEncoding.EncodeToString(*i)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -127,6 +127,13 @@ type ServiceRecord struct {
|
||||||
RR Record `json:"rr,omitempty"`
|
RR Record `json:"rr,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ServiceWithCheckStatus struct {
|
||||||
|
*Service
|
||||||
|
// LastCheckStatus is the worst status across the most recent result of each
|
||||||
|
// checker that has run on this service. Nil if no results exist yet.
|
||||||
|
LastCheckStatus *CheckResultStatus `json:"last_check_status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ServiceUsecase interface {
|
type ServiceUsecase interface {
|
||||||
ListRecords(*Domain, *Zone, *Service) ([]Record, error)
|
ListRecords(*Domain, *Zone, *Service) ([]Record, error)
|
||||||
ValidateService(ServiceBody, Subdomain, Origin) ([]byte, error)
|
ValidateService(ServiceBody, Subdomain, Origin) ([]byte, error)
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,17 @@ type UserSettings struct {
|
||||||
|
|
||||||
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
|
// ShowRRTypes tells if we show equivalent RRTypes in interface (for advanced users).
|
||||||
ShowRRTypes bool `json:"showrrtypes,omitempty"`
|
ShowRRTypes bool `json:"showrrtypes,omitempty"`
|
||||||
|
|
||||||
|
// TestRetention overrides instance default for how long to keep test results (days)
|
||||||
|
TestRetention int `json:"test_retention,omitempty"`
|
||||||
|
|
||||||
|
// DomainTestInterval is the default interval for domain-level tests (seconds)
|
||||||
|
// Default: 86400 (24 hours)
|
||||||
|
DomainTestInterval int64 `json:"domain_test_interval,omitempty"`
|
||||||
|
|
||||||
|
// ServiceTestInterval is the default interval for service-level tests (seconds)
|
||||||
|
// Default: 3600 (1 hour)
|
||||||
|
ServiceTestInterval int64 `json:"service_test_interval,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func DefaultUserSettings() *UserSettings {
|
func DefaultUserSettings() *UserSettings {
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,13 @@ type ZoneServices struct {
|
||||||
Services []*Service `json:"services"`
|
Services []*Service `json:"services"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ZoneWithServicesCheckStatus struct {
|
||||||
|
*Zone
|
||||||
|
// ServicesCheckStatus holds the worst check status for each service,
|
||||||
|
// keyed by service identifier string. Nil/absent if no results exist yet.
|
||||||
|
ServicesCheckStatus map[string]*CheckResultStatus `json:"services_check_status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type ZoneUsecase interface {
|
type ZoneUsecase interface {
|
||||||
AddRecord(*Zone, string, Record) error
|
AddRecord(*Zone, string, Record) error
|
||||||
CreateZone(*Zone) error
|
CreateZone(*Zone) error
|
||||||
|
|
|
||||||
111
pkg/domaininfo/rdap.go
Normal file
111
pkg/domaininfo/rdap.go
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
// This file is part of the happyDomain (R) project.
|
||||||
|
// Copyright (c) 2020-2025 happyDomain
|
||||||
|
// Authors: Pierre-Olivier Mercier, et al.
|
||||||
|
//
|
||||||
|
// This program is offered under a commercial and under the AGPL license.
|
||||||
|
// For commercial licensing, contact us at <contact@happydomain.org>.
|
||||||
|
//
|
||||||
|
// For AGPL licensing:
|
||||||
|
// This program is free software: you can redistribute it and/or modify
|
||||||
|
// it under the terms of the GNU Affero General Public License as published by
|
||||||
|
// the Free Software Foundation, either version 3 of the License, or
|
||||||
|
// (at your option) any later version.
|
||||||
|
//
|
||||||
|
// This program is distributed in the hope that it will be useful,
|
||||||
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
// GNU Affero General Public License for more details.
|
||||||
|
//
|
||||||
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
package domaininfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
|
||||||
|
"github.com/openrdap/rdap"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetDomainRDAPInfo(ctx context.Context, domain happydns.Origin) (*happydns.DomainInfo, error) {
|
||||||
|
client := &rdap.Client{}
|
||||||
|
req := rdap.NewDomainRequest(string(domain)).WithContext(ctx)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
var domainInfo *rdap.Domain
|
||||||
|
if err == nil {
|
||||||
|
var ok bool
|
||||||
|
domainInfo, ok = resp.Object.(*rdap.Domain)
|
||||||
|
if !ok {
|
||||||
|
err = context.DeadlineExceeded // shouldn't happen, but guard anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ce, ok := err.(*rdap.ClientError); ok && ce.Type == rdap.ObjectDoesNotExist {
|
||||||
|
return nil, happydns.DomainDoesNotExist
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Registrar
|
||||||
|
registrar := "Unknown"
|
||||||
|
var registrar_url *string
|
||||||
|
for _, ent := range domainInfo.Entities {
|
||||||
|
if ent.Roles != nil {
|
||||||
|
for _, role := range ent.Roles {
|
||||||
|
if role == "registrar" && ent.VCard != nil && len(ent.VCard.Get("fn")) > 0 {
|
||||||
|
registrar = ent.VCard.Get("fn")[0].Value.(string)
|
||||||
|
if len(ent.VCard.Get("url")) > 0 {
|
||||||
|
url := ent.VCard.Get("url")[0].Value.(string)
|
||||||
|
registrar_url = &url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dates
|
||||||
|
var expiration *time.Time
|
||||||
|
var creation *time.Time
|
||||||
|
for _, event := range domainInfo.Events {
|
||||||
|
if (event.Action == "expiration" || event.Action == "registration") && event.Date != "" {
|
||||||
|
date, err := time.Parse(time.RFC3339, event.Date)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if event.Action == "expiration" {
|
||||||
|
expiration = &date
|
||||||
|
} else if event.Action == "registration" {
|
||||||
|
creation = &date
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nameservers
|
||||||
|
var nameservers []string
|
||||||
|
for _, nameserver := range domainInfo.Nameservers {
|
||||||
|
if nameserver.UnicodeName != "" {
|
||||||
|
nameservers = append(nameservers, nameserver.UnicodeName)
|
||||||
|
} else {
|
||||||
|
nameservers = append(nameservers, nameserver.LDHName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
name := domainInfo.UnicodeName
|
||||||
|
if name == "" {
|
||||||
|
name = domainInfo.LDHName
|
||||||
|
}
|
||||||
|
|
||||||
|
return &happydns.DomainInfo{
|
||||||
|
Name: name,
|
||||||
|
Nameservers: nameservers,
|
||||||
|
CreationDate: creation,
|
||||||
|
ExpirationDate: expiration,
|
||||||
|
Registrar: registrar,
|
||||||
|
RegistrarURL: registrar_url,
|
||||||
|
Status: domainInfo.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
72
pkg/domaininfo/rdap_test.go
Normal file
72
pkg/domaininfo/rdap_test.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
// 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 domaininfo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
happydns "git.happydns.org/happyDomain/model"
|
||||||
|
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDomainRDAPInfo_KnownDomain(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping live RDAP integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := domaininfo.GetDomainRDAPInfo(context.Background(), happydns.Origin("example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for example.com: %v", err)
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
t.Fatal("expected non-nil DomainInfo")
|
||||||
|
}
|
||||||
|
if info.Name == "" {
|
||||||
|
t.Error("expected Name to be set")
|
||||||
|
}
|
||||||
|
if len(info.Nameservers) == 0 {
|
||||||
|
t.Error("expected at least one nameserver")
|
||||||
|
}
|
||||||
|
if info.ExpirationDate == nil {
|
||||||
|
t.Error("expected ExpirationDate to be set")
|
||||||
|
}
|
||||||
|
if info.CreationDate == nil {
|
||||||
|
t.Error("expected CreationDate to be set")
|
||||||
|
}
|
||||||
|
if info.Registrar == "" {
|
||||||
|
t.Error("expected Registrar to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDomainRDAPInfo_NonExistentDomain(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping live RDAP integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := domaininfo.GetDomainRDAPInfo(context.Background(), happydns.Origin("this-domain-definitely-does-not-exist-xyz987654321.com"))
|
||||||
|
if !errors.Is(err, happydns.DomainDoesNotExist) {
|
||||||
|
t.Errorf("expected DomainDoesNotExist error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
75
pkg/domaininfo/whois.go
Normal file
75
pkg/domaininfo/whois.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// 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 domaininfo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.happydns.org/happyDomain/model"
|
||||||
|
|
||||||
|
"github.com/likexian/whois"
|
||||||
|
"github.com/likexian/whois-parser"
|
||||||
|
)
|
||||||
|
|
||||||
|
func GetDomainWhoisInfo(ctx context.Context, domain happydns.Origin) (*happydns.DomainInfo, error) {
|
||||||
|
client := whois.NewClient()
|
||||||
|
|
||||||
|
// The whois library has no context support; derive a timeout from the
|
||||||
|
// context deadline so we at least honour it approximately.
|
||||||
|
if deadline, ok := ctx.Deadline(); ok {
|
||||||
|
if remaining := time.Until(deadline); remaining > 0 {
|
||||||
|
client.SetTimeout(remaining)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := client.Whois(string(domain))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := whoisparser.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, whoisparser.ErrNotFoundDomain) {
|
||||||
|
return nil, happydns.DomainDoesNotExist
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
registrar := "Unknown"
|
||||||
|
var registrar_url *string
|
||||||
|
if result.Registrar != nil {
|
||||||
|
registrar = result.Registrar.Name
|
||||||
|
registrar_url = &result.Registrar.ReferralURL
|
||||||
|
}
|
||||||
|
|
||||||
|
return &happydns.DomainInfo{
|
||||||
|
Name: result.Domain.Domain,
|
||||||
|
Nameservers: result.Domain.NameServers,
|
||||||
|
CreationDate: result.Domain.CreatedDateInTime,
|
||||||
|
ExpirationDate: result.Domain.ExpirationDateInTime,
|
||||||
|
Registrar: registrar,
|
||||||
|
RegistrarURL: registrar_url,
|
||||||
|
Status: result.Domain.Status,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
75
pkg/domaininfo/whois_test.go
Normal file
75
pkg/domaininfo/whois_test.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
// 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 domaininfo_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
happydns "git.happydns.org/happyDomain/model"
|
||||||
|
"git.happydns.org/happyDomain/pkg/domaininfo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDomainWhoisInfo_KnownDomain(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping live WHOIS integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := domaininfo.GetDomainWhoisInfo(context.Background(), happydns.Origin("example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for example.com: %v", err)
|
||||||
|
}
|
||||||
|
if info == nil {
|
||||||
|
t.Fatal("expected non-nil DomainInfo")
|
||||||
|
}
|
||||||
|
if info.Name == "" {
|
||||||
|
t.Error("expected Name to be set")
|
||||||
|
}
|
||||||
|
if len(info.Nameservers) == 0 {
|
||||||
|
t.Error("expected at least one nameserver")
|
||||||
|
}
|
||||||
|
if info.ExpirationDate == nil {
|
||||||
|
t.Error("expected ExpirationDate to be set")
|
||||||
|
}
|
||||||
|
if info.CreationDate == nil {
|
||||||
|
t.Error("expected CreationDate to be set")
|
||||||
|
}
|
||||||
|
if info.Registrar == "" {
|
||||||
|
t.Error("expected Registrar to be set")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetDomainWhoisInfo_NilRegistrarURL(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping live WHOIS integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
// example.com's registrar referral URL may be empty; the function should
|
||||||
|
// not panic and should return a valid DomainInfo regardless.
|
||||||
|
info, err := domaininfo.GetDomainWhoisInfo(context.Background(), happydns.Origin("example.com"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
// RegistrarURL may be nil or non-nil depending on the WHOIS data; just
|
||||||
|
// confirm the function handled it without panicking.
|
||||||
|
_ = info.RegistrarURL
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
552
plugins/matrix/test.go
Normal file
552
plugins/matrix/test.go
Normal file
|
|
@ -0,0 +1,552 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html/template"
|
||||||
|
"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",
|
||||||
|
AutoFill: happydns.AutoFillDomainName,
|
||||||
|
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"`
|
||||||
|
CacheExpiresAt int64 `json:"CacheExpiresAt"`
|
||||||
|
}
|
||||||
|
DNSResult struct {
|
||||||
|
SRVSkipped bool `json:"SRVSkipped"`
|
||||||
|
SRVCName string `json:"SRVCName"`
|
||||||
|
SRVRecords []struct {
|
||||||
|
Target string `json:"Target"`
|
||||||
|
Port uint16 `json:"Port"`
|
||||||
|
Priority uint16 `json:"Priority"`
|
||||||
|
Weight uint16 `json:"Weight"`
|
||||||
|
} `json:"SRVRecords"`
|
||||||
|
SRVError *struct {
|
||||||
|
Message string `json:"Message"`
|
||||||
|
} `json:"SRVError"`
|
||||||
|
Hosts map[string]struct {
|
||||||
|
CName string `json:"CName"`
|
||||||
|
Addrs []string `json:"Addrs"`
|
||||||
|
} `json:"Hosts"`
|
||||||
|
Addrs []string `json:"Addrs"`
|
||||||
|
}
|
||||||
|
ConnectionReports map[string]struct {
|
||||||
|
Certificates []struct {
|
||||||
|
SubjectCommonName string `json:"SubjectCommonName"`
|
||||||
|
IssuerCommonName string `json:"IssuerCommonName"`
|
||||||
|
SHA256Fingerprint string `json:"SHA256Fingerprint"`
|
||||||
|
DNSNames []string `json:"DNSNames"`
|
||||||
|
}
|
||||||
|
Cipher struct {
|
||||||
|
Version string `json:"Version"`
|
||||||
|
CipherSuite string `json:"CipherSuite"`
|
||||||
|
}
|
||||||
|
Checks struct {
|
||||||
|
AllChecksOK bool `json:"AllChecksOK"`
|
||||||
|
MatchingServerName bool `json:"MatchingServerName"`
|
||||||
|
FutureValidUntilTS bool `json:"FutureValidUntilTS"`
|
||||||
|
HasEd25519Key bool `json:"HasEd25519Key"`
|
||||||
|
AllEd25519ChecksOK bool `json:"AllEd25519ChecksOK"`
|
||||||
|
ValidCertificates bool `json:"ValidCertificates"`
|
||||||
|
}
|
||||||
|
Errors []string
|
||||||
|
}
|
||||||
|
ConnectionErrors map[string]struct {
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
Version struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
FederationOK bool `json:"FederationOK"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MatrixTester) RunCheck(ctx context.Context, options happydns.CheckerOptions, meta map[string]string) (*happydns.CheckResult, error) {
|
||||||
|
var domain string
|
||||||
|
|
||||||
|
if dn, ok := options["serviceDomain"]; ok {
|
||||||
|
domain, _ = dn.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if domain == "" {
|
||||||
|
return nil, fmt.Errorf("domain not defined")
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = strings.TrimSuffix(domain, ".")
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf(p.TesterURI, domain), nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("unable to build the request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
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.CheckResultStatusCritical
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML report ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type matrixCertData struct {
|
||||||
|
SubjectCommonName string
|
||||||
|
IssuerCommonName string
|
||||||
|
SHA256Fingerprint string
|
||||||
|
DNSNames []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixConnectionData struct {
|
||||||
|
Address string
|
||||||
|
TLSVersion string
|
||||||
|
CipherSuite string
|
||||||
|
Certs []matrixCertData
|
||||||
|
AllChecksOK bool
|
||||||
|
CheckDetails []matrixCheckItem
|
||||||
|
Errors []string
|
||||||
|
Open bool // details element open when checks failed
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixCheckItem struct {
|
||||||
|
Label string
|
||||||
|
OK bool
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixConnErrData struct {
|
||||||
|
Address string
|
||||||
|
Message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixSRVRecord struct {
|
||||||
|
Target string
|
||||||
|
Port uint16
|
||||||
|
Priority uint16
|
||||||
|
Weight uint16
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixHostData struct {
|
||||||
|
Name string
|
||||||
|
CName string
|
||||||
|
Addrs []string
|
||||||
|
}
|
||||||
|
|
||||||
|
type matrixTemplateData struct {
|
||||||
|
FederationOK bool
|
||||||
|
Version string
|
||||||
|
VersionError string
|
||||||
|
WellKnownServer string
|
||||||
|
WellKnownResult string
|
||||||
|
SRVSkipped bool
|
||||||
|
SRVCName string
|
||||||
|
SRVRecords []matrixSRVRecord
|
||||||
|
SRVError string
|
||||||
|
Hosts []matrixHostData
|
||||||
|
Addrs []string
|
||||||
|
Connections []matrixConnectionData
|
||||||
|
ConnectionErrors []matrixConnErrData
|
||||||
|
}
|
||||||
|
|
||||||
|
var matrixHTMLTemplate = template.Must(
|
||||||
|
template.New("matrix").Parse(`<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Matrix Federation Report</title>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
:root {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #1f2937;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
body { margin: 0; padding: 1rem; }
|
||||||
|
code { font-family: ui-monospace, monospace; font-size: .9em; }
|
||||||
|
h2 { font-size: 1rem; font-weight: 700; margin: 0 0 .6rem; }
|
||||||
|
h3 { font-size: .9rem; font-weight: 600; margin: 0 0 .4rem; }
|
||||||
|
|
||||||
|
.hd {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||||
|
}
|
||||||
|
.hd h1 { margin: 0 0 .4rem; font-size: 1.15rem; font-weight: 700; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex; align-items: center;
|
||||||
|
padding: .2em .65em;
|
||||||
|
border-radius: 9999px;
|
||||||
|
font-size: .78rem; font-weight: 700;
|
||||||
|
letter-spacing: .02em;
|
||||||
|
}
|
||||||
|
.ok { background: #d1fae5; color: #065f46; }
|
||||||
|
.fail { background: #fee2e2; color: #991b1b; }
|
||||||
|
|
||||||
|
.version { color: #6b7280; font-size: .82rem; margin-top: .35rem; }
|
||||||
|
|
||||||
|
.section {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: .85rem 1rem;
|
||||||
|
margin-bottom: .6rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: .45rem;
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,.07);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.section details {
|
||||||
|
box-shadow: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
margin-bottom: .4rem;
|
||||||
|
}
|
||||||
|
summary {
|
||||||
|
display: flex; align-items: center; gap: .5rem;
|
||||||
|
padding: .65rem 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
summary::-webkit-details-marker { display: none; }
|
||||||
|
summary::before {
|
||||||
|
content: "▶";
|
||||||
|
font-size: .65rem;
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: transform .15s;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
details[open] > summary::before { transform: rotate(90deg); }
|
||||||
|
.conn-addr { font-weight: 600; flex: 1; font-size: .9rem; font-family: ui-monospace, monospace; }
|
||||||
|
|
||||||
|
.details-body { padding: .6rem 1rem .85rem; border-top: 1px solid #f3f4f6; }
|
||||||
|
|
||||||
|
table { border-collapse: collapse; width: 100%; font-size: .85rem; }
|
||||||
|
th, td { text-align: left; padding: .3rem .5rem; border-bottom: 1px solid #f3f4f6; }
|
||||||
|
th { font-weight: 600; color: #6b7280; }
|
||||||
|
|
||||||
|
.check-ok { color: #059669; }
|
||||||
|
.check-fail { color: #dc2626; }
|
||||||
|
|
||||||
|
.errmsg { color: #dc2626; font-size: .85rem; margin: .25rem 0 0; }
|
||||||
|
.note { color: #6b7280; font-size: .85rem; }
|
||||||
|
|
||||||
|
ul { margin: .25rem 0; padding-left: 1.2rem; }
|
||||||
|
li { margin-bottom: .15rem; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="hd">
|
||||||
|
<h1>Matrix Federation</h1>
|
||||||
|
{{if .FederationOK}}
|
||||||
|
<span class="badge ok">Federation OK</span>
|
||||||
|
{{- else}}
|
||||||
|
<span class="badge fail">Federation FAIL</span>
|
||||||
|
{{- end}}
|
||||||
|
{{if .Version}}<div class="version">Server: <code>{{.Version}}</code>{{if .VersionError}} — {{.VersionError}}{{end}}</div>{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{if .Connections}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Connections ({{len .Connections}})</h2>
|
||||||
|
{{range .Connections}}
|
||||||
|
<details{{if .Open}} open{{end}}>
|
||||||
|
<summary>
|
||||||
|
<span class="conn-addr">{{.Address}}</span>
|
||||||
|
{{if .AllChecksOK}}<span class="badge ok">All checks OK</span>{{else}}<span class="badge fail">Checks failed</span>{{end}}
|
||||||
|
</summary>
|
||||||
|
<div class="details-body">
|
||||||
|
{{if or .TLSVersion .CipherSuite}}
|
||||||
|
<h3>TLS</h3>
|
||||||
|
<p class="note">{{.TLSVersion}}{{if and .TLSVersion .CipherSuite}} — {{end}}{{.CipherSuite}}</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Certs}}
|
||||||
|
<h3>Certificates</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Subject</th><th>Issuer</th><th>DNS Names</th><th>Fingerprint (SHA-256)</th></tr>
|
||||||
|
{{range .Certs}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.SubjectCommonName}}</code></td>
|
||||||
|
<td><code>{{.IssuerCommonName}}</code></td>
|
||||||
|
<td>{{range .DNSNames}}<code>{{.}}</code> {{end}}</td>
|
||||||
|
<td><code>{{.SHA256Fingerprint}}</code></td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .CheckDetails}}
|
||||||
|
<h3 style="margin-top:.7rem">Checks</h3>
|
||||||
|
<table>
|
||||||
|
{{range .CheckDetails}}
|
||||||
|
<tr>
|
||||||
|
<td>{{if .OK}}<span class="check-ok">✓</span>{{else}}<span class="check-fail">✗</span>{{end}}</td>
|
||||||
|
<td>{{.Label}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{range .Errors}}<p class="errmsg">⚠ {{.}}</p>{{end}}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .ConnectionErrors}}
|
||||||
|
<div class="section">
|
||||||
|
<h2>Connection Errors ({{len .ConnectionErrors}})</h2>
|
||||||
|
{{range .ConnectionErrors}}
|
||||||
|
<p><code>{{.Address}}</code><br><span class="errmsg">{{.Message}}</span></p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>Well-Known</h2>
|
||||||
|
{{if .WellKnownServer}}
|
||||||
|
<p>Server: <code>{{.WellKnownServer}}</code></p>
|
||||||
|
{{else if .WellKnownResult}}
|
||||||
|
<p class="note">{{.WellKnownResult}}</p>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">Not found.</p>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h2>DNS Resolution</h2>
|
||||||
|
{{if .SRVSkipped}}
|
||||||
|
<p class="note">SRV lookup skipped{{if .SRVCName}} (CNAME: <code>{{.SRVCName}}</code>){{end}}</p>
|
||||||
|
{{else if .SRVError}}
|
||||||
|
<p class="errmsg">SRV error: {{.SRVError}}</p>
|
||||||
|
{{else if .SRVRecords}}
|
||||||
|
<h3>SRV Records</h3>
|
||||||
|
<table>
|
||||||
|
<tr><th>Target</th><th>Port</th><th>Priority</th><th>Weight</th></tr>
|
||||||
|
{{range .SRVRecords}}
|
||||||
|
<tr>
|
||||||
|
<td><code>{{.Target}}</code></td>
|
||||||
|
<td>{{.Port}}</td>
|
||||||
|
<td>{{.Priority}}</td>
|
||||||
|
<td>{{.Weight}}</td>
|
||||||
|
</tr>
|
||||||
|
{{end}}
|
||||||
|
</table>
|
||||||
|
{{else}}
|
||||||
|
<p class="note">No SRV records found.</p>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{if .Hosts}}
|
||||||
|
<h3 style="margin-top:.6rem">Resolved Hosts</h3>
|
||||||
|
{{range .Hosts}}
|
||||||
|
<p style="margin:.25rem 0">
|
||||||
|
<code>{{.Name}}</code>
|
||||||
|
{{if .CName}} → <code>{{.CName}}</code>{{end}}
|
||||||
|
{{if .Addrs}}: {{range .Addrs}}<code>{{.}}</code> {{end}}{{end}}
|
||||||
|
</p>
|
||||||
|
{{end}}
|
||||||
|
{{else if .Addrs}}
|
||||||
|
<h3 style="margin-top:.6rem">Addresses</h3>
|
||||||
|
<ul>{{range .Addrs}}<li><code>{{.}}</code></li>{{end}}</ul>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>`),
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetHTMLReport implements happydns.CheckerHTMLReporter.
|
||||||
|
func (p *MatrixTester) GetHTMLReport(raw json.RawMessage) (string, error) {
|
||||||
|
var r FederationTesterResponse
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to unmarshal matrix report: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := matrixTemplateData{
|
||||||
|
FederationOK: r.FederationOK,
|
||||||
|
WellKnownServer: r.WellKnownResult.Server,
|
||||||
|
WellKnownResult: r.WellKnownResult.Result,
|
||||||
|
SRVSkipped: r.DNSResult.SRVSkipped,
|
||||||
|
SRVCName: r.DNSResult.SRVCName,
|
||||||
|
Addrs: r.DNSResult.Addrs,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Version
|
||||||
|
if r.Version.Name != "" || r.Version.Version != "" {
|
||||||
|
data.Version = strings.TrimSpace(r.Version.Name + " " + r.Version.Version)
|
||||||
|
}
|
||||||
|
data.VersionError = r.Version.Error
|
||||||
|
|
||||||
|
// SRV records
|
||||||
|
for _, s := range r.DNSResult.SRVRecords {
|
||||||
|
data.SRVRecords = append(data.SRVRecords, matrixSRVRecord{
|
||||||
|
Target: s.Target,
|
||||||
|
Port: s.Port,
|
||||||
|
Priority: s.Priority,
|
||||||
|
Weight: s.Weight,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SRV error
|
||||||
|
if r.DNSResult.SRVError != nil {
|
||||||
|
data.SRVError = r.DNSResult.SRVError.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hosts
|
||||||
|
for name, h := range r.DNSResult.Hosts {
|
||||||
|
data.Hosts = append(data.Hosts, matrixHostData{
|
||||||
|
Name: name,
|
||||||
|
CName: h.CName,
|
||||||
|
Addrs: h.Addrs,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Successful connections
|
||||||
|
for addr, cr := range r.ConnectionReports {
|
||||||
|
conn := matrixConnectionData{
|
||||||
|
Address: addr,
|
||||||
|
TLSVersion: cr.Cipher.Version,
|
||||||
|
CipherSuite: cr.Cipher.CipherSuite,
|
||||||
|
AllChecksOK: cr.Checks.AllChecksOK,
|
||||||
|
Errors: cr.Errors,
|
||||||
|
Open: !cr.Checks.AllChecksOK,
|
||||||
|
}
|
||||||
|
for _, cert := range cr.Certificates {
|
||||||
|
conn.Certs = append(conn.Certs, matrixCertData{
|
||||||
|
SubjectCommonName: cert.SubjectCommonName,
|
||||||
|
IssuerCommonName: cert.IssuerCommonName,
|
||||||
|
SHA256Fingerprint: cert.SHA256Fingerprint,
|
||||||
|
DNSNames: cert.DNSNames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
conn.CheckDetails = []matrixCheckItem{
|
||||||
|
{"Matching server name", cr.Checks.MatchingServerName},
|
||||||
|
{"Certificate valid until future", cr.Checks.FutureValidUntilTS},
|
||||||
|
{"Valid certificates", cr.Checks.ValidCertificates},
|
||||||
|
{"Has Ed25519 key", cr.Checks.HasEd25519Key},
|
||||||
|
{"All Ed25519 checks OK", cr.Checks.AllEd25519ChecksOK},
|
||||||
|
}
|
||||||
|
data.Connections = append(data.Connections, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Failed connections
|
||||||
|
for addr, ce := range r.ConnectionErrors {
|
||||||
|
data.ConnectionErrors = append(data.ConnectionErrors, matrixConnErrData{
|
||||||
|
Address: addr,
|
||||||
|
Message: ce.Message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf strings.Builder
|
||||||
|
if err := matrixHTMLTemplate.Execute(&buf, data); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to render matrix HTML report: %w", err)
|
||||||
|
}
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
@ -109,6 +109,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.Engine) {
|
||||||
|
|
||||||
// Routes to virtual content
|
// Routes to virtual content
|
||||||
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
router.GET("/auth_users/*_", serveOrReverse("/", cfg))
|
||||||
|
router.GET("/checkers/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
router.GET("/domains/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
router.GET("/providers/*_", serveOrReverse("/", cfg))
|
||||||
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
router.GET("/sessions/*_", serveOrReverse("/", cfg))
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,12 @@
|
||||||
<NavItem>
|
<NavItem>
|
||||||
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
<NavLink href="/sessions" active={page && page.url.pathname.startsWith('/sessions')}>Sessions</NavLink>
|
||||||
</NavItem>
|
</NavItem>
|
||||||
|
<NavItem>
|
||||||
|
<NavLink href="/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>
|
</Nav>
|
||||||
</Collapse>
|
</Collapse>
|
||||||
</Navbar>
|
</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>
|
||||||
43
web/package-lock.json
generated
43
web/package-lock.json
generated
|
|
@ -13,6 +13,9 @@
|
||||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||||
"bootstrap": "^5.3.0",
|
"bootstrap": "^5.3.0",
|
||||||
"bootstrap-icons": "^1.13.0",
|
"bootstrap-icons": "^1.13.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html-escaper": "^3.0.0",
|
"html-escaper": "^3.0.0",
|
||||||
"sass": "^1.97.0",
|
"sass": "^1.97.0",
|
||||||
|
|
@ -864,6 +867,12 @@
|
||||||
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
"integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@noble/hashes": {
|
"node_modules/@noble/hashes": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
|
||||||
|
|
@ -2374,6 +2383,29 @@
|
||||||
"url": "https://github.com/chalk/chalk?sponsor=1"
|
"url": "https://github.com/chalk/chalk?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/chartjs-adapter-date-fns": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/chartjs-adapter-date-fns/-/chartjs-adapter-date-fns-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-Rs3iEB3Q5pJ973J93OBTpnP7qoGwvq3nUnoMdtxO+9aoJof7UFcRbWcIDteXuYd1fgAvct/32T9qaLyLuZVwCg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": ">=2.8.0",
|
||||||
|
"date-fns": ">=2.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
|
|
@ -2512,6 +2544,17 @@
|
||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/date-fns": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,9 @@
|
||||||
"@sveltestrap/sveltestrap": "^7.0.0",
|
"@sveltestrap/sveltestrap": "^7.0.0",
|
||||||
"bootstrap": "^5.3.0",
|
"bootstrap": "^5.3.0",
|
||||||
"bootstrap-icons": "^1.13.0",
|
"bootstrap-icons": "^1.13.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
|
"chartjs-adapter-date-fns": "^3.0.0",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"html-escaper": "^3.0.0",
|
"html-escaper": "^3.0.0",
|
||||||
"sass": "^1.97.0",
|
"sass": "^1.97.0",
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,7 @@ func DeclareRoutes(cfg *happydns.Options, router *gin.RouterGroup, captchaVerifi
|
||||||
router.GET("/service-worker.js", serveFile)
|
router.GET("/service-worker.js", serveFile)
|
||||||
|
|
||||||
// Routes to virtual content
|
// Routes to virtual content
|
||||||
|
router.GET("/checkers/*_", serveIndex)
|
||||||
router.GET("/domains/*_", serveIndex)
|
router.GET("/domains/*_", serveIndex)
|
||||||
router.GET("/email-validation", serveIndex)
|
router.GET("/email-validation", serveIndex)
|
||||||
router.GET("/forgotten-password", serveIndex)
|
router.GET("/forgotten-password", serveIndex)
|
||||||
|
|
|
||||||
582
web/src/lib/api/checkers.ts
Normal file
582
web/src/lib/api/checkers.ts
Normal file
|
|
@ -0,0 +1,582 @@
|
||||||
|
// 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,
|
||||||
|
getChecksByCname,
|
||||||
|
getChecksByCnameOptions,
|
||||||
|
postChecksByCnameOptions,
|
||||||
|
putChecksByCnameOptions,
|
||||||
|
getChecksByCnameOptionsByOptname,
|
||||||
|
putChecksByCnameOptionsByOptname,
|
||||||
|
postDomainsByDomainChecksByCname,
|
||||||
|
getDomainsByDomainChecksByCnameOptions,
|
||||||
|
postDomainsByDomainChecksByCnameOptions,
|
||||||
|
putDomainsByDomainChecksByCnameOptions,
|
||||||
|
getDomainsByDomainChecks,
|
||||||
|
getDomainsByDomainChecksByCnameResults,
|
||||||
|
getDomainsByDomainChecksByCnameResultsByResultId,
|
||||||
|
getDomainsByDomainChecksByCnameResultsByResultIdReport,
|
||||||
|
deleteDomainsByDomainChecksByCnameResults,
|
||||||
|
deleteDomainsByDomainChecksByCnameResultsByResultId,
|
||||||
|
getDomainsByDomainChecksByCnameExecutionsByExecutionId,
|
||||||
|
postPluginsTestsSchedules,
|
||||||
|
putPluginsTestsSchedulesByScheduleId,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecks,
|
||||||
|
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCname,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameExecutionsByExecutionId,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
|
||||||
|
postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
|
||||||
|
putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
|
||||||
|
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId,
|
||||||
|
deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport,
|
||||||
|
getDomainsByDomainChecksByCnameMetrics,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics,
|
||||||
|
getDomainsByDomainChecksByCnameResultsByResultIdMetrics,
|
||||||
|
getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics,
|
||||||
|
} from "$lib/api-base/sdk.gen";
|
||||||
|
import { unwrapSdkResponse } from "./errors";
|
||||||
|
import type {
|
||||||
|
CheckerList,
|
||||||
|
CheckerInfo,
|
||||||
|
CheckerOptions,
|
||||||
|
AvailableChecker,
|
||||||
|
CheckerSchedule,
|
||||||
|
CheckResult,
|
||||||
|
CheckExecution,
|
||||||
|
CheckScopeType,
|
||||||
|
MetricsReport,
|
||||||
|
} from "$lib/model/checker";
|
||||||
|
|
||||||
|
export async function listCheckers(): Promise<CheckerList> {
|
||||||
|
return unwrapSdkResponse(await getChecks()) as CheckerList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCheckStatus(checkId: string): Promise<CheckerInfo> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getChecksByCname({
|
||||||
|
path: { cname: checkId },
|
||||||
|
}),
|
||||||
|
) as unknown as CheckerInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCheckOptions(checkId: string): Promise<CheckerOptions> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getChecksByCnameOptions({
|
||||||
|
path: { cname: checkId },
|
||||||
|
}),
|
||||||
|
) as CheckerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addCheckOptions(checkId: string, options: CheckerOptions): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await postChecksByCnameOptions({
|
||||||
|
path: { cname: checkId },
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCheckOptions(
|
||||||
|
checkId: string,
|
||||||
|
options: CheckerOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await putChecksByCnameOptions({
|
||||||
|
path: { cname: checkId },
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCheckOption(checkId: string, optionName: string): Promise<any> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getChecksByCnameOptionsByOptname({
|
||||||
|
path: { cname: checkId, optname: optionName },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setcheckOption(
|
||||||
|
checkId: string,
|
||||||
|
optionName: string,
|
||||||
|
value: any,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await putChecksByCnameOptionsByOptname({
|
||||||
|
path: { cname: 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 addDomainCheckOptions(
|
||||||
|
domainId: string,
|
||||||
|
checkId: string,
|
||||||
|
options: CheckerOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await postDomainsByDomainChecksByCnameOptions({
|
||||||
|
path: { domain: domainId, cname: checkId },
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDomainCheckOptions(
|
||||||
|
domainId: string,
|
||||||
|
checkId: string,
|
||||||
|
options: CheckerOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await putDomainsByDomainChecksByCnameOptions({
|
||||||
|
path: { domain: domainId, cname: checkId },
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 listAvailableCheckers(domainId: string): Promise<AvailableChecker[]> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainChecks({
|
||||||
|
path: { domain: domainId },
|
||||||
|
}),
|
||||||
|
) as unknown as AvailableChecker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 getCheckResultHTMLReport(
|
||||||
|
domainId: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainChecksByCnameResultsByResultIdReport({
|
||||||
|
path: { domain: domainId, cname: checkName, result_id: resultId },
|
||||||
|
}),
|
||||||
|
) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Service-scoped check API functions ---
|
||||||
|
|
||||||
|
export async function listServiceAvailableCheckers(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
): Promise<AvailableChecker[]> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecks({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid },
|
||||||
|
}),
|
||||||
|
) as unknown as AvailableChecker[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerServiceCheck(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: 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 postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCname({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
|
||||||
|
body: { options: filteredOptions } as any,
|
||||||
|
}),
|
||||||
|
) as { execution_id?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckExecution(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
executionId: string,
|
||||||
|
): Promise<CheckExecution> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameExecutionsByExecutionId(
|
||||||
|
{
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
execution_id: executionId,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as unknown as CheckExecution;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckOptions(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkId: string,
|
||||||
|
): Promise<CheckerOptions> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
|
||||||
|
}),
|
||||||
|
) as CheckerOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addServiceCheckOptions(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkId: string,
|
||||||
|
options: CheckerOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await postDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceCheckOptions(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkId: string,
|
||||||
|
options: CheckerOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await putDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameOptions({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkId } as any,
|
||||||
|
body: { options } as any,
|
||||||
|
}),
|
||||||
|
) as boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listServiceCheckResults(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
): Promise<CheckResult[]> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults({
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
} as any,
|
||||||
|
}),
|
||||||
|
) as unknown as CheckResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckResult(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<CheckResult> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId(
|
||||||
|
{
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
result_id: resultId,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as unknown as CheckResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServiceCheckResult(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultId(
|
||||||
|
{
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
result_id: resultId,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteAllServiceCheckResults(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await deleteDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResults({
|
||||||
|
path: { domain: domainId, zoneid: zoneId, subdomain, serviceid, cname: checkName } as any,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckResultHTMLReport(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<string> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdReport(
|
||||||
|
{
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
result_id: resultId,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Metrics API functions ---
|
||||||
|
|
||||||
|
export async function getCheckMetrics(
|
||||||
|
domainId: string,
|
||||||
|
checkName: string,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<MetricsReport> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainChecksByCnameMetrics({
|
||||||
|
path: { domain: domainId, cname: checkName },
|
||||||
|
query: { limit },
|
||||||
|
}),
|
||||||
|
) as unknown as MetricsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckMetrics(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
limit: number = 100,
|
||||||
|
): Promise<MetricsReport> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameMetrics({
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
} as any,
|
||||||
|
query: { limit },
|
||||||
|
}),
|
||||||
|
) as unknown as MetricsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCheckResultMetrics(
|
||||||
|
domainId: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<MetricsReport> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainChecksByCnameResultsByResultIdMetrics({
|
||||||
|
path: { domain: domainId, cname: checkName, result_id: resultId },
|
||||||
|
}),
|
||||||
|
) as unknown as MetricsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getServiceCheckResultMetrics(
|
||||||
|
domainId: string,
|
||||||
|
zoneId: string,
|
||||||
|
subdomain: string,
|
||||||
|
serviceid: string,
|
||||||
|
checkName: string,
|
||||||
|
resultId: string,
|
||||||
|
): Promise<MetricsReport> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await getDomainsByDomainZoneByZoneidBySubdomainServicesByServiceidChecksByCnameResultsByResultIdMetrics(
|
||||||
|
{
|
||||||
|
path: {
|
||||||
|
domain: domainId,
|
||||||
|
zoneid: zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
cname: checkName,
|
||||||
|
result_id: resultId,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
) as unknown as MetricsReport;
|
||||||
|
}
|
||||||
30
web/src/lib/api/domaininfo.ts
Normal file
30
web/src/lib/api/domaininfo.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
// 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 { postDomaininfoByDomain } from "$lib/api-base/sdk.gen";
|
||||||
|
import type { DomainInfo } from "$lib/model/domaininfo";
|
||||||
|
import { unwrapSdkResponse } from "./errors";
|
||||||
|
|
||||||
|
export async function getDomainInfo(domain: string): Promise<DomainInfo> {
|
||||||
|
return unwrapSdkResponse(
|
||||||
|
await postDomaininfoByDomain({ path: { domain } }),
|
||||||
|
) as DomainInfo;
|
||||||
|
}
|
||||||
227
web/src/lib/components/DomainInfoDisplay.svelte
Normal file
227
web/src/lib/components/DomainInfoDisplay.svelte
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
<!--
|
||||||
|
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 {
|
||||||
|
Badge,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Col,
|
||||||
|
ListGroup,
|
||||||
|
ListGroupItem,
|
||||||
|
Row,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import type { DomainInfo } from "$lib/model/domaininfo";
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
info: DomainInfo;
|
||||||
|
domain: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { info, domain }: Props = $props();
|
||||||
|
|
||||||
|
function statusColor(code: string): string {
|
||||||
|
const lc = code.toLowerCase();
|
||||||
|
if (lc === "ok" || lc === "active") return "success";
|
||||||
|
if (lc.includes("hold")) return "danger";
|
||||||
|
if (lc.includes("prohibited") || lc.includes("pending")) return "info";
|
||||||
|
return "secondary";
|
||||||
|
}
|
||||||
|
|
||||||
|
function expirationProgress(expiration?: string): number {
|
||||||
|
if (!expiration) return 0;
|
||||||
|
const days = daysUntilExpiration(expiration);
|
||||||
|
if (days <= 0) return 0;
|
||||||
|
return Math.min(100, Math.round((days / 365) * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
function expirationColor(expiration?: string): string {
|
||||||
|
if (!expiration) return "secondary";
|
||||||
|
const days = Math.round((new Date(expiration).getTime() - Date.now()) / 86400000);
|
||||||
|
if (days < 0) return "danger";
|
||||||
|
if (days < 30) return "danger";
|
||||||
|
if (days < 90) return "warning";
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
|
|
||||||
|
function daysUntilExpiration(expiration?: string): number {
|
||||||
|
if (!expiration) return 0;
|
||||||
|
return Math.round((new Date(expiration).getTime() - Date.now()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso?: string): string {
|
||||||
|
if (!iso) return "";
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, {
|
||||||
|
year: "numeric",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<h2 class="display-7 fw-bold mt-3 mb-1 font-monospace">
|
||||||
|
{info.name ?? domain}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<!-- Status badges -->
|
||||||
|
{#if info.status && info.status.length > 0}
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-muted small mb-1">{$t("domaininfo.status")}</p>
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
{#each info.status as code}
|
||||||
|
<Badge
|
||||||
|
color={statusColor(code)}
|
||||||
|
title={$t(`domaininfo.status-descriptions.${code}`) || code}
|
||||||
|
>
|
||||||
|
{code}
|
||||||
|
</Badge>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{#if info.status.length === 1}
|
||||||
|
{@const desc = $t(
|
||||||
|
`domaininfo.status-descriptions.${info.status[0]}`,
|
||||||
|
)}
|
||||||
|
{#if desc && !desc.startsWith("domaininfo.")}
|
||||||
|
<p class="text-muted small mt-1 mb-0">{desc}</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Dates card -->
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-body">
|
||||||
|
<Row>
|
||||||
|
<Col sm="6" class="mb-3 mb-sm-0">
|
||||||
|
<p class="text-muted small mb-1">
|
||||||
|
<i class="bi bi-calendar-check me-1"></i>
|
||||||
|
{$t("domaininfo.creation-date")}
|
||||||
|
</p>
|
||||||
|
{#if info.creation}
|
||||||
|
<p class="fw-semibold mb-0">{formatDate(info.creation)}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{$t("domaininfo.no-creation")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
<Col sm="6">
|
||||||
|
<p class="text-muted small mb-1">
|
||||||
|
<i class="bi bi-calendar-x me-1"></i>
|
||||||
|
{$t("domaininfo.expiration-date")}
|
||||||
|
</p>
|
||||||
|
{#if info.expiration}
|
||||||
|
{@const days = daysUntilExpiration(info.expiration)}
|
||||||
|
{@const color = expirationColor(info.expiration)}
|
||||||
|
{@const expiresLabel = days < 0 ? $t("domaininfo.expired", { days: Math.abs(days) }) : days === 0 ? $t("domaininfo.expires-today") : $t("domaininfo.expires-in", { days })}
|
||||||
|
<p class="fw-semibold mb-1">
|
||||||
|
{formatDate(info.expiration)}
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
class="progress mb-1"
|
||||||
|
style="height: 6px;"
|
||||||
|
title={expiresLabel}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="progress-bar bg-{color}"
|
||||||
|
role="progressbar"
|
||||||
|
style="width: {expirationProgress(info.expiration)}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<p class="text-{color} small mb-0">
|
||||||
|
{#if days < 0}
|
||||||
|
{$t("domaininfo.expired", { days: Math.abs(days) })}
|
||||||
|
{:else if days === 0}
|
||||||
|
{$t("domaininfo.expires-today")}
|
||||||
|
{:else}
|
||||||
|
{$t("domaininfo.expires-in", { days })}
|
||||||
|
{/if}
|
||||||
|
</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{$t("domaininfo.no-expiration")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Row>
|
||||||
|
<Col md={6}>
|
||||||
|
<!-- Registrar card -->
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="h6 mb-0 fw-bold">
|
||||||
|
<i class="bi bi-building me-1"></i>
|
||||||
|
{$t("domaininfo.registrar")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
{#if info.registrar}
|
||||||
|
<p class="fw-semibold mb-1">{info.registrar}</p>
|
||||||
|
{#if info.registrar_url}
|
||||||
|
<a
|
||||||
|
href={info.registrar_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="btn btn-outline-secondary btn-sm"
|
||||||
|
>
|
||||||
|
<i class="bi bi-box-arrow-up-right me-1"></i>
|
||||||
|
{$t("domaininfo.registrar-url")}
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted mb-0">
|
||||||
|
{$t("domaininfo.no-registrar")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<!-- Nameservers card -->
|
||||||
|
{#if info.nameservers && info.nameservers.length > 0}
|
||||||
|
<Col md={6}>
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="h6 mb-0 fw-bold">
|
||||||
|
<i class="bi bi-hdd-network me-1"></i>
|
||||||
|
{$t("domaininfo.nameservers")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<ListGroup flush>
|
||||||
|
{#each info.nameservers as ns}
|
||||||
|
<ListGroupItem class="font-monospace py-2"
|
||||||
|
>{ns}</ListGroupItem
|
||||||
|
>
|
||||||
|
{/each}
|
||||||
|
</ListGroup>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
{/if}
|
||||||
|
</Row>
|
||||||
|
|
@ -137,6 +137,20 @@
|
||||||
>
|
>
|
||||||
{$t("menu.dns-resolver")}
|
{$t("menu.dns-resolver")}
|
||||||
</DropdownItem>
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
active={page.route && page.route.id == "/whois/[[domain]]"}
|
||||||
|
href="/whois"
|
||||||
|
>
|
||||||
|
{$t("menu.whois")}
|
||||||
|
</DropdownItem>
|
||||||
|
<DropdownItem
|
||||||
|
active={page.route &&
|
||||||
|
(page.route.id == "/checkers" ||
|
||||||
|
page.route.id?.startsWith("/checkers/"))}
|
||||||
|
href="/checkers"
|
||||||
|
>
|
||||||
|
{$t("menu.checkers")}
|
||||||
|
</DropdownItem>
|
||||||
<DropdownItem divider />
|
<DropdownItem divider />
|
||||||
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
|
<DropdownItem active={page.route && page.route.id == "/me"} href="/me">
|
||||||
{$t("menu.my-account")}
|
{$t("menu.my-account")}
|
||||||
|
|
|
||||||
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
158
web/src/lib/components/checkers/CheckMetricsChart.svelte
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
<!--
|
||||||
|
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, onDestroy } from "svelte";
|
||||||
|
import {
|
||||||
|
Chart,
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
TimeScale,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
Filler,
|
||||||
|
} from "chart.js";
|
||||||
|
import "chartjs-adapter-date-fns";
|
||||||
|
import type { MetricsReport } from "$lib/model/checker";
|
||||||
|
|
||||||
|
Chart.register(
|
||||||
|
LineController,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
LinearScale,
|
||||||
|
TimeScale,
|
||||||
|
Legend,
|
||||||
|
Tooltip,
|
||||||
|
Filler,
|
||||||
|
);
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
report: MetricsReport;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { report }: Props = $props();
|
||||||
|
|
||||||
|
let canvas: HTMLCanvasElement;
|
||||||
|
let chart: Chart | null = null;
|
||||||
|
|
||||||
|
const COLORS = [
|
||||||
|
"#0d6efd",
|
||||||
|
"#dc3545",
|
||||||
|
"#198754",
|
||||||
|
"#ffc107",
|
||||||
|
"#6610f2",
|
||||||
|
"#0dcaf0",
|
||||||
|
"#fd7e14",
|
||||||
|
"#d63384",
|
||||||
|
];
|
||||||
|
|
||||||
|
function buildChart() {
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy();
|
||||||
|
chart = null;
|
||||||
|
}
|
||||||
|
if (!canvas || !report?.series?.length) return;
|
||||||
|
|
||||||
|
const units = [...new Set(report.series.map((s) => s.unit))];
|
||||||
|
const hasRightAxis = units.length > 1;
|
||||||
|
const rightUnit = hasRightAxis ? units[1] : null;
|
||||||
|
|
||||||
|
const datasets = report.series.map((series, i) => ({
|
||||||
|
label: series.label,
|
||||||
|
data: series.points.map((p) => ({
|
||||||
|
x: new Date(p.timestamp).getTime(),
|
||||||
|
y: p.value,
|
||||||
|
})),
|
||||||
|
borderColor: COLORS[i % COLORS.length],
|
||||||
|
backgroundColor: COLORS[i % COLORS.length] + "20",
|
||||||
|
borderWidth: 2,
|
||||||
|
pointRadius: 3,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
tension: 0.3,
|
||||||
|
yAxisID: hasRightAxis && series.unit === rightUnit ? "y1" : "y",
|
||||||
|
}));
|
||||||
|
|
||||||
|
const scales: Record<string, any> = {
|
||||||
|
x: {
|
||||||
|
type: "time" as const,
|
||||||
|
time: { tooltipFormat: "PPpp" },
|
||||||
|
title: { display: false },
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
type: "linear" as const,
|
||||||
|
position: "left" as const,
|
||||||
|
title: { display: true, text: units[0] || "" },
|
||||||
|
beginAtZero: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (hasRightAxis && rightUnit) {
|
||||||
|
scales.y1 = {
|
||||||
|
type: "linear" as const,
|
||||||
|
position: "right" as const,
|
||||||
|
title: { display: true, text: rightUnit },
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: { drawOnChartArea: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
chart = new Chart(canvas, {
|
||||||
|
type: "line",
|
||||||
|
data: { datasets },
|
||||||
|
options: {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: "index", intersect: false },
|
||||||
|
scales,
|
||||||
|
plugins: {
|
||||||
|
legend: { position: "bottom" },
|
||||||
|
tooltip: { mode: "index", intersect: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
buildChart();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
// Re-build chart when report changes
|
||||||
|
if (report && canvas) {
|
||||||
|
buildChart();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (chart) {
|
||||||
|
chart.destroy();
|
||||||
|
chart = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="chart-container" style="position: relative; height: 350px; width: 100%;">
|
||||||
|
<canvas bind:this={canvas}></canvas>
|
||||||
|
</div>
|
||||||
346
web/src/lib/components/checkers/CheckResultSidebar.svelte
Normal file
346
web/src/lib/components/checkers/CheckResultSidebar.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">
|
||||||
|
// SvelteKit imports
|
||||||
|
import { navigate } from "$lib/stores/config";
|
||||||
|
|
||||||
|
// Component imports
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Card,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
Icon,
|
||||||
|
Spinner,
|
||||||
|
Table,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
// Store imports
|
||||||
|
import { currentCheckResult, currentCheckInfo, showHTMLReport, reportViewMode } from "$lib/stores/checkers";
|
||||||
|
import { toasts } from "$lib/stores/toasts";
|
||||||
|
|
||||||
|
// API imports
|
||||||
|
import {
|
||||||
|
deleteCheckResult,
|
||||||
|
deleteServiceCheckResult,
|
||||||
|
getCheckResultHTMLReport,
|
||||||
|
getServiceCheckResultHTMLReport,
|
||||||
|
triggerCheck,
|
||||||
|
triggerServiceCheck,
|
||||||
|
} from "$lib/api/checkers";
|
||||||
|
|
||||||
|
// Utility imports
|
||||||
|
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
|
||||||
|
// Model imports
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
|
||||||
|
// Props
|
||||||
|
interface Props {
|
||||||
|
domain: Domain;
|
||||||
|
cname: string;
|
||||||
|
rid: string;
|
||||||
|
checksBase: string;
|
||||||
|
serviceContext?: {
|
||||||
|
zoneId: string;
|
||||||
|
subdomain: string;
|
||||||
|
serviceid: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let { domain, cname, rid, checksBase, serviceContext }: Props = $props();
|
||||||
|
|
||||||
|
// Local state
|
||||||
|
let isRelaunching = $state(false);
|
||||||
|
|
||||||
|
// Functions
|
||||||
|
async function handleRelaunch() {
|
||||||
|
if (!$currentCheckResult) return;
|
||||||
|
isRelaunching = true;
|
||||||
|
try {
|
||||||
|
if (serviceContext) {
|
||||||
|
await triggerServiceCheck(
|
||||||
|
domain.id,
|
||||||
|
serviceContext.zoneId,
|
||||||
|
serviceContext.subdomain,
|
||||||
|
serviceContext.serviceid,
|
||||||
|
cname,
|
||||||
|
$currentCheckResult.options,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await triggerCheck(domain.id, cname, $currentCheckResult.options);
|
||||||
|
}
|
||||||
|
navigate(`${checksBase}/${encodeURIComponent(cname)}/results`);
|
||||||
|
} catch (error: any) {
|
||||||
|
toasts.addErrorToast({
|
||||||
|
message: error.message || $t("checkers.result.relaunch-failed"),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
isRelaunching = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
if (!confirm($t("checkers.result.delete-confirm"))) return;
|
||||||
|
try {
|
||||||
|
if (serviceContext) {
|
||||||
|
await deleteServiceCheckResult(
|
||||||
|
domain.id,
|
||||||
|
serviceContext.zoneId,
|
||||||
|
serviceContext.subdomain,
|
||||||
|
serviceContext.serviceid,
|
||||||
|
cname,
|
||||||
|
rid,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await deleteCheckResult(domain.id, cname, rid);
|
||||||
|
}
|
||||||
|
navigate(`${checksBase}/${encodeURIComponent(cname)}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
toasts.addErrorToast({ message: error.message || $t("checkers.result.delete-failed") });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(content: string, filename: string, mime: string) {
|
||||||
|
const blob = new Blob([content], { type: mime });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadHTML() {
|
||||||
|
let html: string;
|
||||||
|
if (serviceContext) {
|
||||||
|
html = await getServiceCheckResultHTMLReport(
|
||||||
|
domain.id,
|
||||||
|
serviceContext.zoneId,
|
||||||
|
serviceContext.subdomain,
|
||||||
|
serviceContext.serviceid,
|
||||||
|
cname,
|
||||||
|
rid,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
html = await getCheckResultHTMLReport(domain.id, cname, rid);
|
||||||
|
}
|
||||||
|
downloadBlob(html, `${cname}-${rid}.html`, "text/html");
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadJSON() {
|
||||||
|
if (!$currentCheckResult) return;
|
||||||
|
downloadBlob(
|
||||||
|
JSON.stringify($currentCheckResult.report, null, 2),
|
||||||
|
`${cname}-${rid}.json`,
|
||||||
|
"application/json",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $currentCheckResult}
|
||||||
|
<Card class="mt-3">
|
||||||
|
<CardHeader class="px-2">
|
||||||
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
|
<strong class="text-truncate">{$currentCheckInfo?.name || cname}</strong>
|
||||||
|
{#if $currentCheckResult.scheduled_check}
|
||||||
|
<Badge color="info" class="flex-shrink-0">
|
||||||
|
<Icon name="clock"></Icon>
|
||||||
|
{$t("checkers.result.type.scheduled")}
|
||||||
|
</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary" class="flex-shrink-0">
|
||||||
|
<Icon name="hand-index"></Icon>
|
||||||
|
{$t("checkers.result.type.manual")}
|
||||||
|
</Badge>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<div class="overflow-x-auto rounded-2">
|
||||||
|
<Table borderless size="sm" class="mb-0">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 80px; white-space: nowrap">
|
||||||
|
{$t("checkers.result.field.executed-at")}
|
||||||
|
</th>
|
||||||
|
<td>{formatCheckDate($currentCheckResult.executed_at, "short", $t)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("checkers.result.field.duration")}</th>
|
||||||
|
<td>{formatDuration($currentCheckResult.duration, $t)}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("checkers.result.field.status")}</th>
|
||||||
|
<td>
|
||||||
|
<Badge color={getStatusColor($currentCheckResult.status)}>
|
||||||
|
{$t(getStatusKey($currentCheckResult.status))}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("checkers.result.field.status-message")}</th>
|
||||||
|
<td class="text-truncate" style="max-width: 0">
|
||||||
|
{$currentCheckResult.status_line}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{#if $currentCheckResult.error}
|
||||||
|
<tr>
|
||||||
|
<th>{$t("checkers.result.field.error")}</th>
|
||||||
|
<td class="text-danger text-truncate" style="max-width: 0">
|
||||||
|
{$currentCheckResult.error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/if}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{#if $currentCheckInfo?.options && $currentCheckResult.options && Object.keys($currentCheckResult.options).length > 0}
|
||||||
|
<Card class="mt-3">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="h6 mb-0">
|
||||||
|
<Icon name="sliders"></Icon>
|
||||||
|
{$t("checkers.result.check-options")}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<div class="overflow-x-auto rounded-2">
|
||||||
|
<Table borderless size="sm" class="mb-0">
|
||||||
|
<tbody>
|
||||||
|
{#each Object.entries($currentCheckInfo.options) as [optKey, optVals]}
|
||||||
|
{#each optVals as option}
|
||||||
|
{@const value =
|
||||||
|
(option.id
|
||||||
|
? $currentCheckResult.options[option.id]
|
||||||
|
: undefined) ||
|
||||||
|
option.default ||
|
||||||
|
option.placeholder ||
|
||||||
|
""}
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
class="text-truncate"
|
||||||
|
style="max-width: 90px"
|
||||||
|
title={option.label}
|
||||||
|
>
|
||||||
|
{option.label}:
|
||||||
|
</th>
|
||||||
|
<td class:text-truncate={typeof value !== "object"}>
|
||||||
|
{#if typeof value === "object"}
|
||||||
|
<pre class="mb-0" style="font-size: 0.75em"><code
|
||||||
|
>{JSON.stringify(value, null, 2)}</code
|
||||||
|
></pre>
|
||||||
|
{:else}
|
||||||
|
{value}
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="my-3 flex-fill"></div>
|
||||||
|
|
||||||
|
{#if $currentCheckInfo?.has_html_report || $currentCheckInfo?.has_metrics || $currentCheckResult.report != null}
|
||||||
|
{#if $currentCheckInfo?.has_metrics || $currentCheckInfo?.has_html_report}
|
||||||
|
<ButtonGroup class="w-100 mb-2">
|
||||||
|
{#if $currentCheckInfo?.has_metrics}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
active={$reportViewMode === "metrics"}
|
||||||
|
onclick={() => { reportViewMode.set("metrics"); showHTMLReport.set(false); }}
|
||||||
|
>
|
||||||
|
<Icon name="graph-up"></Icon>
|
||||||
|
{$t("checkers.result.view-metrics")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if $currentCheckInfo?.has_html_report}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
active={$reportViewMode === "html" || (!$currentCheckInfo?.has_metrics && $showHTMLReport)}
|
||||||
|
onclick={() => { reportViewMode.set("html"); showHTMLReport.set(true); }}
|
||||||
|
>
|
||||||
|
<Icon name="file-earmark-richtext"></Icon>
|
||||||
|
{$t("checkers.result.view-html")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
color="secondary"
|
||||||
|
outline
|
||||||
|
active={$reportViewMode === "json" || (!$currentCheckInfo?.has_metrics && !$currentCheckInfo?.has_html_report)}
|
||||||
|
onclick={() => { reportViewMode.set("json"); showHTMLReport.set(false); }}
|
||||||
|
>
|
||||||
|
<Icon name="braces"></Icon>
|
||||||
|
{$t("checkers.result.view-json")}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
{/if}
|
||||||
|
<ButtonGroup class="w-100">
|
||||||
|
{#if $currentCheckInfo?.has_html_report}
|
||||||
|
<Button size="sm" color="outline-secondary" onclick={downloadHTML}>
|
||||||
|
<Icon name="download"></Icon>
|
||||||
|
{$t("checkers.result.download-html")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
{#if $currentCheckResult.report != null}
|
||||||
|
<Button size="sm" color="outline-secondary" onclick={downloadJSON}>
|
||||||
|
<Icon name="download"></Icon>
|
||||||
|
{$t("checkers.result.download-json")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</ButtonGroup>
|
||||||
|
{/if}
|
||||||
|
{:else}
|
||||||
|
<div class="flex-fill"></div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="mt-2 d-flex gap-2">
|
||||||
|
<Button
|
||||||
|
class="flex-fill"
|
||||||
|
color="primary"
|
||||||
|
outline
|
||||||
|
onclick={handleRelaunch}
|
||||||
|
disabled={!$currentCheckResult || isRelaunching}
|
||||||
|
>
|
||||||
|
{#if isRelaunching}
|
||||||
|
<Spinner size="sm" />
|
||||||
|
{:else}
|
||||||
|
<Icon name="arrow-repeat"></Icon>
|
||||||
|
{/if}
|
||||||
|
{$t("checkers.result.relaunch")}
|
||||||
|
</Button>
|
||||||
|
<Button color="danger" outline onclick={handleDelete} disabled={!$currentCheckResult}>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
137
web/src/lib/components/checkers/CheckResultView.svelte
Normal file
137
web/src/lib/components/checkers/CheckResultView.svelte
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<!--
|
||||||
|
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, Spinner, Table } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import type { CheckerInfo, CheckResult, MetricsReport } from "$lib/model/checker";
|
||||||
|
import {
|
||||||
|
currentCheckResult,
|
||||||
|
currentCheckInfo,
|
||||||
|
showHTMLReport,
|
||||||
|
reportViewMode,
|
||||||
|
} from "$lib/stores/checkers";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
resultPromise: Promise<CheckResult>;
|
||||||
|
checkPromise: Promise<CheckerInfo>;
|
||||||
|
htmlReportPromise: Promise<string>;
|
||||||
|
getMetrics: () => Promise<MetricsReport>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { resultPromise, checkPromise, htmlReportPromise, getMetrics }: Props = $props();
|
||||||
|
|
||||||
|
let metricsReport = $state<MetricsReport | null>(null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
resultPromise.then((r) => currentCheckResult.set(r));
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
metricsReport = null;
|
||||||
|
checkPromise.then((c) => {
|
||||||
|
currentCheckInfo.set(c);
|
||||||
|
if (c.has_metrics) {
|
||||||
|
reportViewMode.set("metrics");
|
||||||
|
getMetrics()
|
||||||
|
.then((r) => (metricsReport = r))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
currentCheckResult.set(null);
|
||||||
|
currentCheckInfo.set(null);
|
||||||
|
showHTMLReport.set(true);
|
||||||
|
reportViewMode.set("html");
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-fill mw-100 d-flex flex-column">
|
||||||
|
{#await Promise.all([resultPromise, checkPromise])}
|
||||||
|
<div class="mt-5 text-center flex-fill">
|
||||||
|
<Spinner />
|
||||||
|
<p>{$t("checkers.result.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:then [result, check]}
|
||||||
|
{#if result.report || check.has_html_report || check.has_metrics}
|
||||||
|
{#if check.has_metrics && $reportViewMode === "metrics"}
|
||||||
|
<div class="p-3 flex-fill">
|
||||||
|
{#if metricsReport}
|
||||||
|
<Table size="sm" hover striped>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th class="text-end">Value</th>
|
||||||
|
<th>Unit</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each metricsReport.series as series}
|
||||||
|
{#each series.points as point}
|
||||||
|
<tr>
|
||||||
|
<td>{series.label}</td>
|
||||||
|
<td class="text-end font-monospace">{point.value}</td>
|
||||||
|
<td class="text-muted">{series.unit}</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
{:else}
|
||||||
|
<div class="text-center p-4"><Spinner /></div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if check.has_html_report && ($reportViewMode === "html" || ($showHTMLReport && $reportViewMode !== "json"))}
|
||||||
|
{#await htmlReportPromise}
|
||||||
|
<div class="text-center p-4"><Spinner /></div>
|
||||||
|
{:then html}
|
||||||
|
<iframe
|
||||||
|
srcdoc={html}
|
||||||
|
sandbox=""
|
||||||
|
title={$t("checkers.result.full-report")}
|
||||||
|
class="flex-fill"
|
||||||
|
style="width: 100%; border: none; display: block;"
|
||||||
|
></iframe>
|
||||||
|
{:catch}
|
||||||
|
<pre class="bg-light p-3 rounded mb-0"><code
|
||||||
|
>{JSON.stringify(result.report, null, 2)}</code
|
||||||
|
></pre>
|
||||||
|
{/await}
|
||||||
|
{:else 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}
|
||||||
|
{/if}
|
||||||
|
{:catch error}
|
||||||
|
<Alert color="danger" class="m-3">
|
||||||
|
{$t("checkers.result.error-loading", { error: error.message })}
|
||||||
|
</Alert>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
351
web/src/lib/components/checkers/CheckResultsPage.svelte
Normal file
351
web/src/lib/components/checkers/CheckResultsPage.svelte
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
<!--
|
||||||
|
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 { getCheckStatus } from "$lib/api/checkers";
|
||||||
|
import PageTitle from "$lib/components/PageTitle.svelte";
|
||||||
|
import type { Domain } from "$lib/model/domain";
|
||||||
|
import type { CheckExecution, CheckResult, MetricsReport } from "$lib/model/checker";
|
||||||
|
import { CheckExecutionStatus, CheckScopeType } from "$lib/model/checker";
|
||||||
|
import RunCheckModal from "$lib/components/modals/RunCheckModal.svelte";
|
||||||
|
import CheckMetricsChart from "$lib/components/checkers/CheckMetricsChart.svelte";
|
||||||
|
import { getStatusColor, getStatusKey, formatDuration, formatCheckDate } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
domain: Domain;
|
||||||
|
checkerName: string;
|
||||||
|
targetType: CheckScopeType;
|
||||||
|
targetId: string;
|
||||||
|
configurePath: string;
|
||||||
|
resultViewPath: (resultId: string) => string;
|
||||||
|
loadResults: () => Promise<CheckResult[]>;
|
||||||
|
getExecution: (executionId: string) => Promise<CheckExecution>;
|
||||||
|
deleteResult: (resultId: string) => Promise<void>;
|
||||||
|
deleteAllResults: () => Promise<void>;
|
||||||
|
loadMetrics?: () => Promise<MetricsReport>;
|
||||||
|
zoneId?: string;
|
||||||
|
subdomain?: string;
|
||||||
|
serviceid?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let {
|
||||||
|
domain,
|
||||||
|
checkerName,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
configurePath,
|
||||||
|
resultViewPath,
|
||||||
|
loadResults,
|
||||||
|
getExecution,
|
||||||
|
deleteResult,
|
||||||
|
deleteAllResults,
|
||||||
|
loadMetrics,
|
||||||
|
zoneId,
|
||||||
|
subdomain,
|
||||||
|
serviceid,
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let resultsPromise = $state(loadResults());
|
||||||
|
let checkerPromise = $derived(getCheckStatus(checkerName));
|
||||||
|
let checkerDisplayName = $state(checkerName);
|
||||||
|
let metricsReport = $state<MetricsReport | null>(null);
|
||||||
|
$effect(() => {
|
||||||
|
checkerPromise
|
||||||
|
.then((c) => {
|
||||||
|
checkerDisplayName = c.name || checkerName;
|
||||||
|
if (c.has_metrics && loadMetrics) {
|
||||||
|
loadMetrics()
|
||||||
|
.then((r) => (metricsReport = r))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
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: checkerName,
|
||||||
|
owner_id: "",
|
||||||
|
target_type: targetType,
|
||||||
|
target_id: targetId,
|
||||||
|
status: CheckExecutionStatus.CheckExecutionPending,
|
||||||
|
started_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
pendingExecutions = [...pendingExecutions, placeholder];
|
||||||
|
|
||||||
|
const intervalId = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const exec = await getExecution(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 = loadResults();
|
||||||
|
}
|
||||||
|
} 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("checkers.results.delete-confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteResult(resultId);
|
||||||
|
resultsPromise = loadResults();
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("checkers.results.delete-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteAll() {
|
||||||
|
if (!confirm($t("checkers.results.delete-all-confirm"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await deleteAllResults();
|
||||||
|
resultsPromise = loadResults();
|
||||||
|
} catch (error: any) {
|
||||||
|
errorMessage = error.message || $t("checkers.results.delete-all-failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{checkerName} Results - {domain.domain} - happyDomain</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="flex-fill pb-4 pt-2">
|
||||||
|
<PageTitle title={checkerDisplayName} domain={domain.domain}>
|
||||||
|
<div class="d-flex gap-2">
|
||||||
|
<Button color="dark" href={configurePath}>
|
||||||
|
<Icon name="gear-fill"></Icon>
|
||||||
|
{$t("checkers.results.configure")}
|
||||||
|
</Button>
|
||||||
|
{#await checkerPromise then checker}
|
||||||
|
<Button
|
||||||
|
color="primary"
|
||||||
|
onclick={() => runCheckModal.open(checkerName, checker.name || checkerName)}
|
||||||
|
>
|
||||||
|
<Icon name="play-fill"></Icon>
|
||||||
|
{$t("checkers.results.run-check-now")}
|
||||||
|
</Button>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
</PageTitle>
|
||||||
|
|
||||||
|
{#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("checkers.results.loading")}</p>
|
||||||
|
</div>
|
||||||
|
{:then results}
|
||||||
|
{#if metricsReport}
|
||||||
|
<Card class="mb-3">
|
||||||
|
<div class="card-body">
|
||||||
|
<CheckMetricsChart report={metricsReport} />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
{#if (!results || results.length === 0) && pendingExecutions.length === 0}
|
||||||
|
<Card body>
|
||||||
|
<p class="text-center text-muted mb-0">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("checkers.results.no-results")}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else}
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h4>{$t("checkers.results.title", { count: results?.length ?? 0 })}</h4>
|
||||||
|
{#if results?.length}
|
||||||
|
<Button size="sm" color="danger" outline onclick={handleDeleteAll}>
|
||||||
|
<Icon name="trash"></Icon>
|
||||||
|
{$t("checkers.results.delete-all")}
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table hover striped>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{$t("checkers.results.table.executed-at")}</th>
|
||||||
|
<th class="text-center">{$t("checkers.results.table.status")}</th>
|
||||||
|
<th>{$t("checkers.results.table.message")}</th>
|
||||||
|
<th>{$t("checkers.results.table.duration")}</th>
|
||||||
|
<th class="text-center">{$t("checkers.results.table.type")}</th>
|
||||||
|
<th>{$t("checkers.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("checkers.results.pending.running")
|
||||||
|
: $t("checkers.results.pending.queued")}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle text-muted">
|
||||||
|
{exec.status === CheckExecutionStatus.CheckExecutionRunning
|
||||||
|
? $t("checkers.results.pending.running-description")
|
||||||
|
: $t("checkers.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("checkers.results.type.scheduled")}
|
||||||
|
{:else}
|
||||||
|
<Icon name="hand-index"></Icon>
|
||||||
|
{$t("checkers.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("checkers.results.type.scheduled")}
|
||||||
|
{:else}
|
||||||
|
<Icon name="hand-index"></Icon>
|
||||||
|
{$t("checkers.results.type.manual")}
|
||||||
|
{/if}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td class="align-middle">
|
||||||
|
<ButtonGroup size="sm">
|
||||||
|
<Button color="primary" href={resultViewPath(result.id!)}>
|
||||||
|
<Icon name="eye-fill"></Icon>
|
||||||
|
{$t("checkers.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("checkers.results.error-loading", { error: error.message })}
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{/await}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RunCheckModal
|
||||||
|
domainId={domain.id}
|
||||||
|
{zoneId}
|
||||||
|
{subdomain}
|
||||||
|
{serviceid}
|
||||||
|
onCheckTriggered={handleCheckTriggered}
|
||||||
|
bind:this={runCheckModal}
|
||||||
|
/>
|
||||||
107
web/src/lib/components/checkers/CheckerOptionsCard.svelte
Normal file
107
web/src/lib/components/checkers/CheckerOptionsCard.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 {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Form,
|
||||||
|
FormGroup,
|
||||||
|
Icon,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import Resource from "$lib/components/inputs/Resource.svelte";
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import { toasts } from "$lib/stores/toasts";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
options: Array<any>;
|
||||||
|
optionValues: Record<string, any>;
|
||||||
|
title: string;
|
||||||
|
saveOptionsFn: (values: Record<string, any>) => Promise<void | boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { options, optionValues = $bindable(), title, saveOptionsFn }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
async function handleSave() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await saveOptionsFn(optionValues);
|
||||||
|
toasts.addToast({
|
||||||
|
message: $t("checkers.messages.options-updated"),
|
||||||
|
type: "success",
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
} catch (e: any) {
|
||||||
|
toasts.addErrorToast({
|
||||||
|
message: $t("checkers.messages.update-failed", { error: e.message }),
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if options && options.length > 0}
|
||||||
|
<Card class="mt-3">
|
||||||
|
<CardHeader>
|
||||||
|
<strong>{title}</strong>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody>
|
||||||
|
<Form
|
||||||
|
on:submit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
handleSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{#each options 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("checkers.detail.save-changes")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
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: "checkers.auto-fill.domain_name",
|
||||||
|
subdomain: "checkers.auto-fill.subdomain",
|
||||||
|
service_type: "checkers.auto-fill.service_type",
|
||||||
|
};
|
||||||
|
|
||||||
|
function getAutoFillLabel(autoFill: string): string {
|
||||||
|
const tKey = AUTO_FILL_KEYS[autoFill];
|
||||||
|
if (tKey) return $t(tKey);
|
||||||
|
return $t("checkers.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("checkers.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("checkers.option-groups.type", {
|
||||||
|
type: optDoc.type || "string",
|
||||||
|
})}
|
||||||
|
</small>
|
||||||
|
{#if optDoc.required && !optDoc.autoFill}
|
||||||
|
<small class="text-danger ms-2">
|
||||||
|
{$t("checkers.option-groups.required")}
|
||||||
|
</small>
|
||||||
|
{/if}
|
||||||
|
</dd>
|
||||||
|
{/each}
|
||||||
|
</dl>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
149
web/src/lib/components/checkers/CheckerScheduleCard.svelte
Normal file
149
web/src/lib/components/checkers/CheckerScheduleCard.svelte
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
<!--
|
||||||
|
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 {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
CardBody,
|
||||||
|
CardHeader,
|
||||||
|
Icon,
|
||||||
|
Input,
|
||||||
|
Spinner,
|
||||||
|
} from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
import type { AvailableChecker } from "$lib/model/checker";
|
||||||
|
import { formatCheckDate, formatRelative } from "$lib/utils";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
checker: AvailableChecker;
|
||||||
|
formEnabled: boolean;
|
||||||
|
formIntervalHours: number;
|
||||||
|
saving: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { checker, formEnabled = $bindable(), formIntervalHours = $bindable(), saving, onSave }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card class="mb-4">
|
||||||
|
<CardHeader>
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<Icon name="clock-history"></Icon>
|
||||||
|
{$t("checkers.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("checkers.schedule.auto-enabled")}</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge color="secondary">{$t("checkers.schedule.auto-disabled")}</Badge>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if formEnabled}
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="schedule-interval" class="form-label fw-semibold">
|
||||||
|
{$t("checkers.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("checkers.schedule.hours")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="form-text">
|
||||||
|
{$t("checkers.schedule.interval-hint")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if checker.schedule}
|
||||||
|
<div class="mb-4">
|
||||||
|
<div class="row g-3">
|
||||||
|
{#if checker.schedule.last_run}
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="text-muted fw-semibold">
|
||||||
|
{$t("checkers.schedule.last-run")}:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatCheckDate(checker.schedule.last_run, "medium", $t)}
|
||||||
|
<small class="text-muted">
|
||||||
|
({formatRelative(checker.schedule.last_run, $t)})
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if checker.enabled && checker.schedule.next_run}
|
||||||
|
<div class="col-auto">
|
||||||
|
<span class="text-muted fw-semibold">
|
||||||
|
{$t("checkers.schedule.next-run")}:
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{formatCheckDate(checker.schedule.next_run, "medium", $t)}
|
||||||
|
<small class="text-muted">
|
||||||
|
({formatRelative(checker.schedule.next_run, $t)})
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted">
|
||||||
|
<Icon name="info-circle"></Icon>
|
||||||
|
{$t("checkers.schedule.no-schedule-yet")}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Button color="primary" disabled={saving} onclick={onSave}>
|
||||||
|
{#if saving}
|
||||||
|
<Spinner size="sm" class="me-1" />
|
||||||
|
{/if}
|
||||||
|
<Icon name="check-lg"></Icon>
|
||||||
|
{$t("checkers.schedule.save")}
|
||||||
|
</Button>
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
83
web/src/lib/components/checkers/CheckerSidebar.svelte
Normal file
83
web/src/lib/components/checkers/CheckerSidebar.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
<!--
|
||||||
|
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 { Icon, Spinner } from "@sveltestrap/sveltestrap";
|
||||||
|
|
||||||
|
import { checkers } from "$lib/stores/checkers";
|
||||||
|
import { t } from "$lib/translations";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
currentCheckId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { currentCheckId }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<nav class="checker-sidebar d-flex flex-column h-100">
|
||||||
|
<a
|
||||||
|
href="/checkers"
|
||||||
|
class="sidebar-back d-flex align-items-center gap-1 mb-3 text-muted text-decoration-none fw-semibold"
|
||||||
|
>
|
||||||
|
<Icon name="chevron-left" />
|
||||||
|
{$t("checkers.title")}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{#if !$checkers}
|
||||||
|
<div class="d-flex gap-2 align-items-center justify-content-center my-3 text-muted">
|
||||||
|
<Spinner size="sm" color="primary" />
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<ul class="list-unstyled mb-0 flex-fill overflow-auto">
|
||||||
|
{#each Object.entries($checkers) as [checkerName, checkerInfo]}
|
||||||
|
{@const isActive = checkerName === currentCheckId}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="/checkers/{encodeURIComponent(checkerName)}"
|
||||||
|
class="checker-item d-flex align-items-center gap-2 py-2 px-2 rounded text-decoration-none {isActive
|
||||||
|
? 'fw-bold text-primary active'
|
||||||
|
: 'text-muted'}"
|
||||||
|
>
|
||||||
|
<span class="text-truncate">
|
||||||
|
{checkerInfo.name || checkerName}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.checker-item {
|
||||||
|
transition: background-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checker-item:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.checker-item.active {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
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