mirror of
https://github.com/Dvorinka/beszel.git
synced 2026-06-04 21:32:57 +00:00
feat(site): enhance monitoring, domain, and system tracking
Build Docker images / Hub (push) Failing after 5m57s
Build Docker images / Hub (push) Failing after 5m57s
- Improve domain lookup by adding CNAME and SRV record support
- Enhance domain status logic to include expiry and DNS resolution verification
- Update monitoring API to perform synchronous initial checks for immediate status updates
- Refactor site UI:
- Add tag filtering to domains and monitors tables
- Improve calendar view with better visual indicators for today and events
- Update monitor detail view with improved status badges and pending states
- Simplify home page layout by removing redundant card wrappers
- Update localization files for numerous languages to support new UI elements
- Add `cleanEndpointsConfig` to hub to safely reuse Docker network settings during container updates
This commit is contained in:
@@ -521,7 +521,7 @@ func (d *dockerAPI) replaceContainer(targetID, image string) error {
|
||||
delete(hostConfig, "AutoRemove")
|
||||
createBody := cloneMap(config)
|
||||
createBody["HostConfig"] = hostConfig
|
||||
createBody["NetworkingConfig"] = map[string]any{"EndpointsConfig": current.NetworkSettings.Networks}
|
||||
createBody["NetworkingConfig"] = map[string]any{"EndpointsConfig": cleanEndpointsConfig(current.NetworkSettings.Networks)}
|
||||
|
||||
var created dockerCreateResponse
|
||||
if err := d.do(http.MethodPost, "/containers/create?name="+url.QueryEscape(newName), createBody, &created); err != nil {
|
||||
@@ -565,3 +565,26 @@ func cloneMap(in map[string]any) map[string]any {
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// cleanEndpointsConfig strips runtime-populated fields from Docker network settings
|
||||
// so they can be safely reused in a container create request.
|
||||
func cleanEndpointsConfig(networks map[string]map[string]any) map[string]any {
|
||||
if networks == nil {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]any, len(networks))
|
||||
for netName, cfg := range networks {
|
||||
cleaned := make(map[string]any, len(cfg))
|
||||
for k, v := range cfg {
|
||||
switch k {
|
||||
case "NetworkID", "EndpointID", "Gateway", "IPAddress", "IPPrefixLen",
|
||||
"IPv6Gateway", "GlobalIPv6Address", "GlobalIPv6PrefixLen", "MacAddress":
|
||||
continue
|
||||
default:
|
||||
cleaned[k] = v
|
||||
}
|
||||
}
|
||||
out[netName] = cleaned
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -46,3 +46,72 @@ func TestDigestValue(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanEndpointsConfig(t *testing.T) {
|
||||
input := map[string]map[string]any{
|
||||
"beszel": {
|
||||
"NetworkID": "abc123",
|
||||
"EndpointID": "ep456",
|
||||
"Gateway": "172.20.0.1",
|
||||
"IPAddress": "172.20.0.5",
|
||||
"IPPrefixLen": 16,
|
||||
"IPv6Gateway": "",
|
||||
"GlobalIPv6Address": "",
|
||||
"GlobalIPv6PrefixLen": 0,
|
||||
"MacAddress": "02:42:ac:14:00:05",
|
||||
"Aliases": []string{"beszel", "beszel-hub"},
|
||||
"Links": nil,
|
||||
"IPAMConfig": nil,
|
||||
},
|
||||
"bridge": {
|
||||
"NetworkID": "bridge-net",
|
||||
"IPAddress": "172.17.0.2",
|
||||
"Aliases": []string{},
|
||||
"DriverOpts": map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
got := cleanEndpointsConfig(input)
|
||||
|
||||
if got == nil {
|
||||
t.Fatal("cleanEndpointsConfig returned nil for non-nil input")
|
||||
}
|
||||
|
||||
for netName, cfgRaw := range got {
|
||||
cfg, ok := cfgRaw.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected network %q config to be map[string]any, got %T", netName, cfgRaw)
|
||||
}
|
||||
for k := range cfg {
|
||||
switch k {
|
||||
case "NetworkID", "EndpointID", "Gateway", "IPAddress", "IPPrefixLen",
|
||||
"IPv6Gateway", "GlobalIPv6Address", "GlobalIPv6PrefixLen", "MacAddress":
|
||||
t.Fatalf("runtime field %q was NOT stripped from network %q", k, netName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beszelCfg, ok := got["beszel"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected beszel network config to be map[string]any")
|
||||
}
|
||||
aliases, ok := beszelCfg["Aliases"].([]string)
|
||||
if !ok || len(aliases) != 2 || aliases[0] != "beszel" {
|
||||
t.Fatalf("expected Aliases to be preserved, got %v", beszelCfg["Aliases"])
|
||||
}
|
||||
|
||||
bridgeCfg, ok := got["bridge"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatal("expected bridge network config to be map[string]any")
|
||||
}
|
||||
if _, ok := bridgeCfg["DriverOpts"]; !ok {
|
||||
t.Fatal("expected DriverOpts to be preserved in bridge network")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanEndpointsConfigNil(t *testing.T) {
|
||||
got := cleanEndpointsConfig(nil)
|
||||
if got != nil {
|
||||
t.Fatalf("expected nil, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,6 +180,22 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
|
||||
domainData, err := lookupSvc.LookupDomain(ctx, domainName)
|
||||
if err == nil && domainData != nil {
|
||||
h.applyLookupData(record, domainData)
|
||||
// Calculate status based on lookup results
|
||||
status := domain.DomainStatusUnknown
|
||||
if domainData.ExpiryDate != nil {
|
||||
daysUntil := int(time.Until(*domainData.ExpiryDate).Hours() / 24)
|
||||
if daysUntil < 0 {
|
||||
status = domain.DomainStatusExpired
|
||||
} else if daysUntil <= req.AlertDaysBefore {
|
||||
status = domain.DomainStatusExpiring
|
||||
} else {
|
||||
status = domain.DomainStatusActive
|
||||
}
|
||||
} else if len(domainData.IPv4Addresses) > 0 || len(domainData.IPv6Addresses) > 0 || len(domainData.NameServers) > 0 {
|
||||
// DNS resolves means the domain is active and functioning
|
||||
status = domain.DomainStatusActive
|
||||
}
|
||||
record.Set("status", status)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,20 +120,31 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
|
||||
newData = &domain.Domain{DomainName: domainName}
|
||||
}
|
||||
|
||||
// Independent DNS resolution check: if WHOIS failed, try to resolve the domain directly
|
||||
if err != nil {
|
||||
ips, lookupErr := net.LookupHost(domainName)
|
||||
if lookupErr == nil && len(ips) > 0 {
|
||||
newData.IPv4Addresses = []string{}
|
||||
newData.IPv6Addresses = []string{}
|
||||
for _, ip := range ips {
|
||||
if strings.Contains(ip, ":") {
|
||||
newData.IPv6Addresses = append(newData.IPv6Addresses, ip)
|
||||
} else {
|
||||
newData.IPv4Addresses = append(newData.IPv4Addresses, ip)
|
||||
}
|
||||
// Always perform independent DNS resolution to verify domain is alive.
|
||||
// This is critical when WHOIS succeeds but returns partial data (e.g. no expiry for some TLDs),
|
||||
// or when WHOIS fails completely. DNS resolution proves the domain exists and is active.
|
||||
ips, lookupErr := net.LookupHost(domainName)
|
||||
if lookupErr == nil && len(ips) > 0 {
|
||||
newData.IPv4Addresses = []string{}
|
||||
newData.IPv6Addresses = []string{}
|
||||
for _, ip := range ips {
|
||||
if strings.Contains(ip, ":") {
|
||||
newData.IPv6Addresses = append(newData.IPv6Addresses, ip)
|
||||
} else {
|
||||
newData.IPv4Addresses = append(newData.IPv4Addresses, ip)
|
||||
}
|
||||
log.Printf("[domain-scheduler] DNS resolution succeeded for %s despite WHOIS failure", domainName)
|
||||
}
|
||||
log.Printf("[domain-scheduler] DNS A/AAAA resolution succeeded for %s", domainName)
|
||||
}
|
||||
|
||||
// Also try to get nameservers independently if WHOIS didn't provide them
|
||||
if len(newData.NameServers) == 0 {
|
||||
nsRecords, nsErr := net.LookupNS(domainName)
|
||||
if nsErr == nil && len(nsRecords) > 0 {
|
||||
for _, ns := range nsRecords {
|
||||
newData.NameServers = append(newData.NameServers, ns.Host)
|
||||
}
|
||||
log.Printf("[domain-scheduler] DNS NS lookup succeeded for %s", domainName)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -672,6 +672,26 @@ func (s *LookupService) lookupDNS(ctx context.Context, domainName string, d *dom
|
||||
txtRecords, _ := net.LookupTXT(domainName)
|
||||
d.TXTRecords = txtRecords
|
||||
|
||||
// CNAME record
|
||||
cname, err := net.LookupCNAME(domainName)
|
||||
if err == nil && cname != domainName && cname != "" {
|
||||
d.CNAMERecord = cname
|
||||
}
|
||||
|
||||
// SRV records (common services)
|
||||
srvServices := []string{"sip", "xmpp-server", "ldap", "autodiscover", "imap", "smtp", "caldavs", "carddavs"}
|
||||
srvProtos := []string{"tcp", "udp", "tls"}
|
||||
for _, service := range srvServices {
|
||||
for _, proto := range srvProtos {
|
||||
_, addrs, err := net.LookupSRV(service, proto, domainName)
|
||||
if err == nil {
|
||||
for _, addr := range addrs {
|
||||
d.SRVRecords = append(d.SRVRecords, fmt.Sprintf("_%s._%s %s:%d (priority: %d, weight: %d)", service, proto, addr.Target, addr.Port, addr.Priority, addr.Weight))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// IPv4
|
||||
ipv4Addrs, _ := net.LookupHost(domainName)
|
||||
for _, ip := range ipv4Addrs {
|
||||
|
||||
@@ -252,12 +252,16 @@ func (h *APIHandler) createMonitor(e *core.RequestEvent) error {
|
||||
// Add to scheduler
|
||||
h.scheduler.AddMonitor(record)
|
||||
|
||||
// Run initial check immediately so the monitor shows real status instead of pending
|
||||
go func() {
|
||||
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
|
||||
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
|
||||
}
|
||||
}()
|
||||
// Run initial check synchronously so the monitor shows real status immediately
|
||||
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
|
||||
log.Printf("[monitor-api] Initial check failed for %s: %v", record.Id, err)
|
||||
}
|
||||
|
||||
// Re-fetch the updated record to get the new status
|
||||
updatedRecord, err := h.app.FindRecordById("monitors", record.Id)
|
||||
if err == nil {
|
||||
record = updatedRecord
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusCreated, recordToResponse(record))
|
||||
}
|
||||
@@ -494,11 +498,15 @@ func (h *APIHandler) resumeMonitor(e *core.RequestEvent) error {
|
||||
h.scheduler.UpdateMonitor(record)
|
||||
|
||||
// Run an immediate check so the monitor shows real status instead of pending
|
||||
go func() {
|
||||
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
|
||||
log.Printf("[monitor-api] Resume check failed for %s: %v", record.Id, err)
|
||||
}
|
||||
}()
|
||||
if _, err := h.scheduler.RunManualCheck(record.Id); err != nil {
|
||||
log.Printf("[monitor-api] Resume check failed for %s: %v", record.Id, err)
|
||||
}
|
||||
|
||||
// Re-fetch the updated record to get the new status
|
||||
updatedRecord, err := h.app.FindRecordById("monitors", record.Id)
|
||||
if err == nil {
|
||||
record = updatedRecord
|
||||
}
|
||||
|
||||
return e.JSON(http.StatusOK, recordToResponse(record))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user