This commit is contained in:
Tomas Dvorak
2026-04-29 11:32:39 +02:00
parent 193839bd27
commit 67254f89a9
20 changed files with 1792 additions and 1094 deletions
-52
View File
@@ -37,58 +37,6 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}} type=semver,pattern={{major}}
- name: Agent
image: beszel-agent
dockerfile: ./internal/dockerfile_agent
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=edge,enable={{is_default_branch}}
type=ref,event=branch
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Agent Alpine
image: beszel-agent
dockerfile: ./internal/dockerfile_agent_alpine
platforms: linux/amd64,linux/arm64,linux/arm/v7
tags: |
type=raw,value=alpine,enable={{is_default_branch}}
type=raw,value=edge-alpine,enable={{is_default_branch}}
type=ref,event=branch,suffix=-alpine
type=sha,prefix=sha-,suffix=-alpine
type=semver,pattern={{version}}-alpine
type=semver,pattern={{major}}.{{minor}}-alpine
type=semver,pattern={{major}}-alpine
- name: Agent NVIDIA
image: beszel-agent-nvidia
dockerfile: ./internal/dockerfile_agent_nvidia
platforms: linux/amd64
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=edge,enable={{is_default_branch}}
type=ref,event=branch
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
- name: Agent Intel
image: beszel-agent-intel
dockerfile: ./internal/dockerfile_agent_intel
platforms: linux/amd64
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=edge,enable={{is_default_branch}}
type=ref,event=branch
type=sha,prefix=sha-
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
steps: steps:
- name: Check out code - name: Check out code
uses: actions/checkout@v4 uses: actions/checkout@v4
+51 -14
View File
@@ -8,26 +8,57 @@ Beszel is a unified monitoring platform that combines system metrics, service up
### Docker Compose ### Docker Compose
```bash Paste this into Dokploy, CasaOS, or a local `docker-compose.yml`.
# Clone the repository
git clone https://github.com/henrygd/beszel.git
cd beszel
# Copy and edit environment variables (optional) ```yaml
cp .env.example .env services:
beszel:
image: ghcr.io/dvorinka/beszel:latest
container_name: beszel
restart: unless-stopped
ports:
- "${BESZEL_PORT:-8090}:8090"
volumes:
- beszel_data:/beszel_data
environment:
APP_URL: "${APP_URL:-http://localhost:8090}"
PUBLIC_URL: "${PUBLIC_URL:-}"
INSTANCE_NAME: "${INSTANCE_NAME:-Beszel Monitoring}"
INSTANCE_DESCRIPTION: "${INSTANCE_DESCRIPTION:-System, website, and domain monitoring}"
# Start the hub # Optional first admin/user bootstrap. Set these in Dokploy/CasaOS variables.
make start BESZEL_HUB_USER_EMAIL: "${BESZEL_HUB_USER_EMAIL:-}"
# or: docker compose up -d BESZEL_HUB_USER_PASSWORD: "${BESZEL_HUB_USER_PASSWORD:-}"
# View logs # Optional stable Web Push key. Leave empty unless you already have one.
make logs BESZEL_VAPID_PRIVATE_KEY: "${BESZEL_VAPID_PRIVATE_KEY:-}"
# Stop everything # Auth and feature flags
make stop REGISTRATION_ENABLED: "${REGISTRATION_ENABLED:-true}"
TWO_FACTOR_ENABLED: "${TWO_FACTOR_ENABLED:-true}"
PASSKEY_ENABLED: "${PASSKEY_ENABLED:-true}"
STATUS_PAGES_ENABLED: "${STATUS_PAGES_ENABLED:-true}"
BADGES_ENABLED: "${BADGES_ENABLED:-true}"
PAGESPEED_ENABLED: "${PAGESPEED_ENABLED:-true}"
SUBDOMAIN_DISCOVERY: "${SUBDOMAIN_DISCOVERY:-true}"
# Limits
MAX_MONITORS_PER_USER: "${MAX_MONITORS_PER_USER:-50}"
MAX_DOMAINS_PER_USER: "${MAX_DOMAINS_PER_USER:-50}"
MAX_STATUS_PAGES: "${MAX_STATUS_PAGES:-10}"
volumes:
beszel_data:
``` ```
The hub will be available at `http://localhost:8090`. Create your admin account on first visit. Docker Compose pulls the image automatically, or you can pull it manually first:
```bash
docker pull ghcr.io/dvorinka/beszel:latest
docker compose up -d
```
The hub will be available at `http://localhost:8090` by default. For Dokploy or CasaOS, set `APP_URL` to the public URL of your deployment, for example `https://beszel.example.com`.
Agents run on separate hosts and connect to the hub. See [Adding Agents](#adding-agents) below. Agents run on separate hosts and connect to the hub. See [Adding Agents](#adding-agents) below.
@@ -35,8 +66,14 @@ Agents run on separate hosts and connect to the hub. See [Adding Agents](#adding
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| |----------|---------|-------------|
| `BESZEL_PORT` | `8090` | Host port mapped to container port `8090` |
| `APP_URL` | `http://localhost:8090` | Public URL for links | | `APP_URL` | `http://localhost:8090` | Public URL for links |
| `PUBLIC_URL` | empty | Public URL shown in instance settings |
| `INSTANCE_NAME` | `Beszel Monitoring` | Instance display name | | `INSTANCE_NAME` | `Beszel Monitoring` | Instance display name |
| `INSTANCE_DESCRIPTION` | `System, website, and domain monitoring` | Instance description |
| `BESZEL_HUB_USER_EMAIL` | empty | Optional first admin/user email for automated setup |
| `BESZEL_HUB_USER_PASSWORD` | empty | Optional first admin/user password for automated setup |
| `BESZEL_VAPID_PRIVATE_KEY` | empty | Optional stable private key for browser push notifications |
| `REGISTRATION_ENABLED` | `true` | Allow new user registration | | `REGISTRATION_ENABLED` | `true` | Allow new user registration |
| `MAX_MONITORS_PER_USER` | `50` | Monitor limit per user | | `MAX_MONITORS_PER_USER` | `50` | Monitor limit per user |
| `MAX_DOMAINS_PER_USER` | `50` | Domain limit per user | | `MAX_DOMAINS_PER_USER` | `50` | Domain limit per user |
+122 -37
View File
@@ -117,6 +117,12 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
AutoRenew bool `json:"auto_renew"` AutoRenew bool `json:"auto_renew"`
AlertDaysBefore int `json:"alert_days_before"` AlertDaysBefore int `json:"alert_days_before"`
SSLAlertEnabled bool `json:"ssl_alert_enabled"` SSLAlertEnabled bool `json:"ssl_alert_enabled"`
SSLAlertDays int `json:"ssl_alert_days"`
MonitorType string `json:"monitor_type"`
NotifyOnExpiry bool `json:"notify_on_expiry"`
NotifyOnSSL bool `json:"notify_on_ssl_expiry"`
NotifyOnDNS bool `json:"notify_on_dns_change"`
NotifyOnReg bool `json:"notify_on_registrar_change"`
} }
if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil { if err := json.NewDecoder(e.Request.Body).Decode(&req); err != nil {
return e.BadRequestError("invalid request body", err) return e.BadRequestError("invalid request body", err)
@@ -159,6 +165,12 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
record.Set("auto_renew", req.AutoRenew) record.Set("auto_renew", req.AutoRenew)
record.Set("alert_days_before", req.AlertDaysBefore) record.Set("alert_days_before", req.AlertDaysBefore)
record.Set("ssl_alert_enabled", req.SSLAlertEnabled) record.Set("ssl_alert_enabled", req.SSLAlertEnabled)
record.Set("ssl_alert_days", req.SSLAlertDays)
record.Set("monitor_type", req.MonitorType)
record.Set("notify_on_expiry", req.NotifyOnExpiry)
record.Set("notify_on_ssl_expiry", req.NotifyOnSSL)
record.Set("notify_on_dns_change", req.NotifyOnDNS)
record.Set("notify_on_registrar_change", req.NotifyOnReg)
record.Set("user", authRecord.Id) record.Set("user", authRecord.Id)
// Auto-lookup if requested // Auto-lookup if requested
@@ -167,40 +179,7 @@ func (h *APIHandler) createDomain(e *core.RequestEvent) error {
ctx := e.Request.Context() ctx := e.Request.Context()
domainData, err := lookupSvc.LookupDomain(ctx, domainName) domainData, err := lookupSvc.LookupDomain(ctx, domainName)
if err == nil && domainData != nil { if err == nil && domainData != nil {
if domainData.ExpiryDate != nil { h.applyLookupData(record, domainData)
record.Set("expiry_date", *domainData.ExpiryDate)
} else {
record.Set("expiry_date", "")
}
if domainData.CreationDate != nil {
record.Set("creation_date", *domainData.CreationDate)
} else {
record.Set("creation_date", "")
}
if domainData.UpdatedDate != nil {
record.Set("updated_date", *domainData.UpdatedDate)
} else {
record.Set("updated_date", "")
}
record.Set("registrar_name", domainData.RegistrarName)
record.Set("registrar_id", domainData.RegistrarID)
record.Set("registrar_url", domainData.RegistrarURL)
record.Set("dnssec", domainData.DNSSEC)
record.Set("name_servers", domainData.NameServers)
record.Set("mx_records", domainData.MXRecords)
record.Set("txt_records", domainData.TXTRecords)
record.Set("ipv4_addresses", domainData.IPv4Addresses)
record.Set("ipv6_addresses", domainData.IPv6Addresses)
record.Set("ssl_issuer", domainData.SSLIssuer)
if domainData.SSLValidTo != nil {
record.Set("ssl_valid_to", *domainData.SSLValidTo)
} else {
record.Set("ssl_valid_to", "")
}
record.Set("host_country", domainData.HostCountry)
record.Set("host_isp", domainData.HostISP)
record.Set("favicon_url", domainData.FaviconURL)
record.Set("last_checked", time.Now())
} }
} }
@@ -281,6 +260,23 @@ func (h *APIHandler) updateDomain(e *core.RequestEvent) error {
if sslAlert, ok := req["ssl_alert_enabled"]; ok { if sslAlert, ok := req["ssl_alert_enabled"]; ok {
record.Set("ssl_alert_enabled", sslAlert) record.Set("ssl_alert_enabled", sslAlert)
} }
for _, field := range []string{
"ssl_alert_days",
"monitor_type",
"notify_on_expiry",
"notify_on_ssl_expiry",
"notify_on_dns_change",
"notify_on_registrar_change",
"notify_on_value_change",
"value_change_threshold",
"quiet_hours_enabled",
"quiet_hours_start",
"quiet_hours_end",
} {
if value, ok := req[field]; ok {
record.Set(field, value)
}
}
if err := h.app.Save(record); err != nil { if err := h.app.Save(record); err != nil {
return e.InternalServerError("failed to update domain", err) return e.InternalServerError("failed to update domain", err)
@@ -330,12 +326,18 @@ func (h *APIHandler) refreshDomain(e *core.RequestEvent) error {
return e.ForbiddenError("not authorized", nil) return e.ForbiddenError("not authorized", nil)
} }
// Trigger refresh via scheduler
if h.scheduler != nil { if h.scheduler != nil {
h.scheduler.RefreshDomain(id) if err := h.scheduler.RefreshDomain(id); err != nil {
return e.InternalServerError("failed to refresh domain", err)
}
} }
return e.JSON(http.StatusOK, map[string]string{"status": "refreshing"}) updatedRecord, err := h.app.FindRecordById("domains", id)
if err != nil {
return e.InternalServerError("failed to fetch refreshed domain", err)
}
return e.JSON(http.StatusOK, h.recordToResponse(updatedRecord))
} }
// getDomainHistory gets the change history for a domain // getDomainHistory gets the change history for a domain
@@ -537,8 +539,14 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
"days_until_expiry": daysUntilExpiry, "days_until_expiry": daysUntilExpiry,
"registrar_name": record.GetString("registrar_name"), "registrar_name": record.GetString("registrar_name"),
"registrar_id": record.GetString("registrar_id"), "registrar_id": record.GetString("registrar_id"),
"registrar_url": record.GetString("registrar_url"),
"registry_domain_id": record.GetString("registry_domain_id"),
"dnssec": record.GetString("dnssec"),
"name_servers": record.Get("name_servers"), "name_servers": record.Get("name_servers"),
"mx_records": record.Get("mx_records"),
"txt_records": record.Get("txt_records"),
"ipv4_addresses": record.Get("ipv4_addresses"), "ipv4_addresses": record.Get("ipv4_addresses"),
"ipv6_addresses": record.Get("ipv6_addresses"),
"ssl_issuer": record.GetString("ssl_issuer"), "ssl_issuer": record.GetString("ssl_issuer"),
"ssl_issuer_country": record.GetString("ssl_issuer_country"), "ssl_issuer_country": record.GetString("ssl_issuer_country"),
"ssl_subject": record.GetString("ssl_subject"), "ssl_subject": record.GetString("ssl_subject"),
@@ -547,13 +555,37 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
"ssl_key_size": record.GetInt("ssl_key_size"), "ssl_key_size": record.GetInt("ssl_key_size"),
"ssl_signature_algo": record.GetString("ssl_signature_algo"), "ssl_signature_algo": record.GetString("ssl_signature_algo"),
"host_country": record.GetString("host_country"), "host_country": record.GetString("host_country"),
"host_region": record.GetString("host_region"),
"host_city": record.GetString("host_city"),
"host_isp": record.GetString("host_isp"), "host_isp": record.GetString("host_isp"),
"host_org": record.GetString("host_org"),
"host_as": record.GetString("host_as"),
"host_lat": record.GetFloat("host_lat"),
"host_lon": record.GetFloat("host_lon"),
"purchase_price": record.GetFloat("purchase_price"), "purchase_price": record.GetFloat("purchase_price"),
"current_value": record.GetFloat("current_value"), "current_value": record.GetFloat("current_value"),
"renewal_cost": record.GetFloat("renewal_cost"), "renewal_cost": record.GetFloat("renewal_cost"),
"auto_renew": record.GetBool("auto_renew"), "auto_renew": record.GetBool("auto_renew"),
"alert_days_before": record.GetInt("alert_days_before"), "alert_days_before": record.GetInt("alert_days_before"),
"ssl_alert_enabled": record.GetBool("ssl_alert_enabled"), "ssl_alert_enabled": record.GetBool("ssl_alert_enabled"),
"ssl_alert_days": record.GetInt("ssl_alert_days"),
"monitor_type": record.GetString("monitor_type"),
"notify_on_expiry": record.GetBool("notify_on_expiry"),
"notify_on_ssl_expiry": record.GetBool("notify_on_ssl_expiry"),
"notify_on_dns_change": record.GetBool("notify_on_dns_change"),
"notify_on_registrar_change": record.GetBool("notify_on_registrar_change"),
"notify_on_value_change": record.GetBool("notify_on_value_change"),
"value_change_threshold": record.GetFloat("value_change_threshold"),
"quiet_hours_enabled": record.GetBool("quiet_hours_enabled"),
"quiet_hours_start": record.GetString("quiet_hours_start"),
"quiet_hours_end": record.GetString("quiet_hours_end"),
"registrant_name": record.GetString("registrant_name"),
"registrant_org": record.GetString("registrant_org"),
"registrant_country": record.GetString("registrant_country"),
"registrant_city": record.GetString("registrant_city"),
"registrant_state": record.GetString("registrant_state"),
"abuse_email": record.GetString("abuse_email"),
"abuse_phone": record.GetString("abuse_phone"),
"tags": record.Get("tags"), "tags": record.Get("tags"),
"notes": record.GetString("notes"), "notes": record.GetString("notes"),
"favicon_url": record.GetString("favicon_url"), "favicon_url": record.GetString("favicon_url"),
@@ -587,6 +619,59 @@ func (h *APIHandler) recordToResponse(record *core.Record) map[string]interface{
return resp return resp
} }
func (h *APIHandler) applyLookupData(record *core.Record, domainData *domain.Domain) {
if domainData.ExpiryDate != nil {
record.Set("expiry_date", *domainData.ExpiryDate)
}
if domainData.CreationDate != nil {
record.Set("creation_date", *domainData.CreationDate)
}
if domainData.UpdatedDate != nil {
record.Set("updated_date", *domainData.UpdatedDate)
}
record.Set("registrar_name", domainData.RegistrarName)
record.Set("registrar_id", domainData.RegistrarID)
record.Set("registrar_url", domainData.RegistrarURL)
record.Set("registry_domain_id", domainData.RegistryDomainID)
record.Set("dnssec", domainData.DNSSEC)
record.Set("name_servers", domainData.NameServers)
record.Set("mx_records", domainData.MXRecords)
record.Set("txt_records", domainData.TXTRecords)
record.Set("ipv4_addresses", domainData.IPv4Addresses)
record.Set("ipv6_addresses", domainData.IPv6Addresses)
record.Set("ssl_issuer", domainData.SSLIssuer)
record.Set("ssl_issuer_country", domainData.SSLIssuerCountry)
record.Set("ssl_subject", domainData.SSLSubject)
if domainData.SSLValidFrom != nil {
record.Set("ssl_valid_from", *domainData.SSLValidFrom)
}
if domainData.SSLValidTo != nil {
record.Set("ssl_valid_to", *domainData.SSLValidTo)
}
record.Set("ssl_fingerprint", domainData.SSLFingerprint)
record.Set("ssl_key_size", domainData.SSLKeySize)
record.Set("ssl_signature_algo", domainData.SSLSignatureAlgo)
record.Set("host_country", domainData.HostCountry)
record.Set("host_region", domainData.HostRegion)
record.Set("host_city", domainData.HostCity)
record.Set("host_isp", domainData.HostISP)
record.Set("host_org", domainData.HostOrg)
record.Set("host_as", domainData.HostAS)
record.Set("host_lat", domainData.HostLat)
record.Set("host_lon", domainData.HostLon)
record.Set("registrant_name", domainData.RegistrantName)
record.Set("registrant_org", domainData.RegistrantOrg)
record.Set("registrant_street", domainData.RegistrantStreet)
record.Set("registrant_city", domainData.RegistrantCity)
record.Set("registrant_state", domainData.RegistrantState)
record.Set("registrant_country", domainData.RegistrantCountry)
record.Set("registrant_postal", domainData.RegistrantPostal)
record.Set("abuse_email", domainData.AbuseEmail)
record.Set("abuse_phone", domainData.AbusePhone)
record.Set("favicon_url", domainData.FaviconURL)
record.Set("last_checked", time.Now())
}
// cleanDomain cleans and normalizes a domain name // cleanDomain cleans and normalizes a domain name
func cleanDomain(domain string) string { func cleanDomain(domain string) string {
// Remove protocol // Remove protocol
+70 -16
View File
@@ -26,6 +26,7 @@ type Scheduler struct {
stopChan chan struct{} stopChan chan struct{}
wg sync.WaitGroup wg sync.WaitGroup
alertCallback AlertCallback alertCallback AlertCallback
limit chan struct{}
} }
// NewScheduler creates a new domain scheduler // NewScheduler creates a new domain scheduler
@@ -34,6 +35,7 @@ func NewScheduler(app core.App) *Scheduler {
app: app, app: app,
whois: whois.NewLookupService(""), // API key can be configured via env whois: whois.NewLookupService(""), // API key can be configured via env
stopChan: make(chan struct{}), stopChan: make(chan struct{}),
limit: make(chan struct{}, 4),
} }
} }
@@ -92,13 +94,15 @@ func (s *Scheduler) checkDomains() {
s.wg.Add(1) s.wg.Add(1)
go func(r *core.Record) { go func(r *core.Record) {
defer s.wg.Done() defer s.wg.Done()
s.limit <- struct{}{}
defer func() { <-s.limit }()
s.checkDomain(r) s.checkDomain(r)
}(record) }(record)
} }
} }
// checkDomain checks a single domain // checkDomain checks a single domain
func (s *Scheduler) checkDomain(record *core.Record) { func (s *Scheduler) checkDomain(record *core.Record) error {
domainName := record.GetString("domain_name") domainName := record.GetString("domain_name")
userID := record.GetString("user") userID := record.GetString("user")
@@ -112,11 +116,10 @@ func (s *Scheduler) checkDomain(record *core.Record) {
newData, err := s.whois.LookupDomain(ctx, domainName) newData, err := s.whois.LookupDomain(ctx, domainName)
if err != nil { if err != nil {
log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err) log.Printf("[domain-scheduler] Failed to lookup %s: %v", domainName, err)
return return err
} }
// Track changes oldRecord := record.Fresh()
history := s.trackChanges(record, newData)
// Update record (only overwrite if new data is present to preserve valid data on partial lookups) // Update record (only overwrite if new data is present to preserve valid data on partial lookups)
if newData.ExpiryDate != nil { if newData.ExpiryDate != nil {
@@ -137,6 +140,9 @@ func (s *Scheduler) checkDomain(record *core.Record) {
if newData.RegistrarURL != "" { if newData.RegistrarURL != "" {
record.Set("registrar_url", newData.RegistrarURL) record.Set("registrar_url", newData.RegistrarURL)
} }
if newData.RegistryDomainID != "" {
record.Set("registry_domain_id", newData.RegistryDomainID)
}
record.Set("dnssec", newData.DNSSEC) record.Set("dnssec", newData.DNSSEC)
if len(newData.NameServers) > 0 { if len(newData.NameServers) > 0 {
record.Set("name_servers", newData.NameServers) record.Set("name_servers", newData.NameServers)
@@ -179,8 +185,57 @@ func (s *Scheduler) checkDomain(record *core.Record) {
if newData.SSLSignatureAlgo != "" { if newData.SSLSignatureAlgo != "" {
record.Set("ssl_signature_algo", newData.SSLSignatureAlgo) record.Set("ssl_signature_algo", newData.SSLSignatureAlgo)
} }
if newData.HostCountry != "" {
record.Set("host_country", newData.HostCountry) record.Set("host_country", newData.HostCountry)
}
if newData.HostRegion != "" {
record.Set("host_region", newData.HostRegion)
}
if newData.HostCity != "" {
record.Set("host_city", newData.HostCity)
}
if newData.HostISP != "" {
record.Set("host_isp", newData.HostISP) record.Set("host_isp", newData.HostISP)
}
if newData.HostOrg != "" {
record.Set("host_org", newData.HostOrg)
}
if newData.HostAS != "" {
record.Set("host_as", newData.HostAS)
}
if newData.HostLat != 0 {
record.Set("host_lat", newData.HostLat)
}
if newData.HostLon != 0 {
record.Set("host_lon", newData.HostLon)
}
if newData.RegistrantName != "" {
record.Set("registrant_name", newData.RegistrantName)
}
if newData.RegistrantOrg != "" {
record.Set("registrant_org", newData.RegistrantOrg)
}
if newData.RegistrantStreet != "" {
record.Set("registrant_street", newData.RegistrantStreet)
}
if newData.RegistrantCity != "" {
record.Set("registrant_city", newData.RegistrantCity)
}
if newData.RegistrantState != "" {
record.Set("registrant_state", newData.RegistrantState)
}
if newData.RegistrantCountry != "" {
record.Set("registrant_country", newData.RegistrantCountry)
}
if newData.RegistrantPostal != "" {
record.Set("registrant_postal", newData.RegistrantPostal)
}
if newData.AbuseEmail != "" {
record.Set("abuse_email", newData.AbuseEmail)
}
if newData.AbusePhone != "" {
record.Set("abuse_phone", newData.AbusePhone)
}
record.Set("last_checked", time.Now()) record.Set("last_checked", time.Now())
// Update status - fallback to existing record expiry if new lookup didn't return one // Update status - fallback to existing record expiry if new lookup didn't return one
@@ -212,9 +267,11 @@ func (s *Scheduler) checkDomain(record *core.Record) {
} }
record.Set("status", status) record.Set("status", status)
history := s.trackChanges(oldRecord, newData, status)
if err := s.app.Save(record); err != nil { if err := s.app.Save(record); err != nil {
log.Printf("[domain-scheduler] Failed to update %s: %v", domainName, err) log.Printf("[domain-scheduler] Failed to update %s: %v", domainName, err)
return return err
} }
// Save history entries // Save history entries
@@ -239,6 +296,7 @@ func (s *Scheduler) checkDomain(record *core.Record) {
s.discoverSubdomains(record, domainName, userID) s.discoverSubdomains(record, domainName, userID)
log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status) log.Printf("[domain-scheduler] Updated domain: %s (status: %s)", domainName, status)
return nil
} }
// discoverSubdomains discovers and saves subdomains for a domain // discoverSubdomains discovers and saves subdomains for a domain
@@ -297,9 +355,10 @@ func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID s
} }
// trackChanges compares old and new data and returns history entries // trackChanges compares old and new data and returns history entries
func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain) []domain.DomainHistory { func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain, finalStatus string) []domain.DomainHistory {
var history []domain.DomainHistory var history []domain.DomainHistory
now := time.Now() now := time.Now()
hasPreviousCheck := !oldRecord.GetDateTime("last_checked").IsZero()
// Check expiry date change // Check expiry date change
oldExpiry := oldRecord.GetDateTime("expiry_date").Time() oldExpiry := oldRecord.GetDateTime("expiry_date").Time()
@@ -327,13 +386,12 @@ func (s *Scheduler) trackChanges(oldRecord *core.Record, newData *domain.Domain)
// Check status change // Check status change
oldStatus := oldRecord.GetString("status") oldStatus := oldRecord.GetString("status")
newStatus := newData.GetStatus() if hasPreviousCheck && finalStatus != oldStatus {
if newStatus != oldStatus {
history = append(history, domain.DomainHistory{ history = append(history, domain.DomainHistory{
ChangeType: domain.ChangeTypeStatus, ChangeType: domain.ChangeTypeStatus,
FieldName: "status", FieldName: "status",
OldValue: oldStatus, OldValue: oldStatus,
NewValue: newStatus, NewValue: finalStatus,
CreatedAt: now, CreatedAt: now,
}) })
} }
@@ -429,13 +487,9 @@ func (s *Scheduler) RefreshDomain(domainID string) error {
return err return err
} }
s.wg.Add(1) s.limit <- struct{}{}
go func() { defer func() { <-s.limit }()
defer s.wg.Done() return s.checkDomain(record)
s.checkDomain(record)
}()
return nil
} }
// CheckAllDomains manually triggers a check of all active domains // CheckAllDomains manually triggers a check of all active domains
+20 -20
View File
@@ -2,8 +2,11 @@ package whois
import ( import (
"context" "context"
"crypto/ecdsa"
"crypto/rsa" "crypto/rsa"
"crypto/sha256"
"crypto/tls" "crypto/tls"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net" "net"
@@ -706,37 +709,34 @@ func (s *LookupService) lookupSSL(ctx context.Context, domainName string, d *dom
d.SSLValidTo = &cert.NotAfter d.SSLValidTo = &cert.NotAfter
d.SSLSubject = cert.Subject.CommonName d.SSLSubject = cert.Subject.CommonName
// Format fingerprint as colon-separated hex fingerprint := sha256.Sum256(cert.Raw)
if len(cert.Signature) > 0 { d.SSLFingerprint = strings.ToUpper(strings.Join(splitHex(hex.EncodeToString(fingerprint[:])), ":"))
fingerprint := fmt.Sprintf("%X", cert.Signature)
// Add colons every 2 characters for standard format
if len(fingerprint) > 2 {
var formatted []string
for i := 0; i < len(fingerprint); i += 2 {
if i+2 <= len(fingerprint) {
formatted = append(formatted, fingerprint[i:i+2])
}
}
d.SSLFingerprint = strings.Join(formatted, ":")
} else {
d.SSLFingerprint = fingerprint
}
}
// Extract signature algorithm
d.SSLSignatureAlgo = cert.SignatureAlgorithm.String() d.SSLSignatureAlgo = cert.SignatureAlgorithm.String()
// Safely extract key size for different key types
switch key := cert.PublicKey.(type) { switch key := cert.PublicKey.(type) {
case *rsa.PublicKey: case *rsa.PublicKey:
d.SSLKeySize = key.N.BitLen() d.SSLKeySize = key.N.BitLen()
case *ecdsa.PublicKey:
d.SSLKeySize = key.Curve.Params().BitSize
default: default:
// For ECC keys, try to determine from curve d.SSLKeySize = 0
d.SSLKeySize = 256 // Default for ECC
} }
} }
} }
func splitHex(value string) []string {
parts := make([]string, 0, len(value)/2)
for i := 0; i < len(value); i += 2 {
end := i + 2
if end > len(value) {
end = len(value)
}
parts = append(parts, value[i:end])
}
return parts
}
// lookupHost fetches host/geolocation info // lookupHost fetches host/geolocation info
func (s *LookupService) lookupHost(ip string, d *domain.Domain) { func (s *LookupService) lookupHost(ip string, d *domain.Domain) {
// Use ip-api.com (free, no auth required for non-commercial use) // Use ip-api.com (free, no auth required for non-commercial use)
+2 -7
View File
@@ -267,13 +267,8 @@ func (h *Hub) bindDomainHooks() {
return e.Next() return e.Next()
}) })
// On update - refresh if activated // Manual refresh and resume actions trigger lookups explicitly. Avoid
h.OnRecordAfterUpdateSuccess("domains").BindFunc(func(e *core.RecordEvent) error { // refreshing on every domain save because scheduler writes would loop.
if e.Record.GetBool("active") {
h.domainSched.RefreshDomain(e.Record.Id)
}
return e.Next()
})
} }
// GetSSHKey generates key pair if it doesn't exist and returns signer // GetSSHKey generates key pair if it doesn't exist and returns signer
+52 -5
View File
@@ -426,13 +426,17 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
} }
events := []map[string]interface{}{} events := []map[string]interface{}{}
from, to := calendarRange(e)
// Domain expirations // Domain expirations
domains, _ := h.app.FindAllRecords("domains", domains, _ := h.app.FindAllRecords("domains",
dbx.NewExp("user = {:user} && expiry_date != ''", dbx.Params{"user": authRecord.Id}), dbx.NewExp("user = {:user}", dbx.Params{"user": authRecord.Id}),
) )
for _, d := range domains { for _, d := range domains {
expiryDate := d.GetDateTime("expiry_date").Time() expiryDate := d.GetDateTime("expiry_date").Time()
if expiryDate.IsZero() || !dateInRange(expiryDate, from, to) {
continue
}
domainName := d.GetString("domain_name") domainName := d.GetString("domain_name")
daysUntil := int(expiryDate.Sub(time.Now()).Hours() / 24) daysUntil := int(expiryDate.Sub(time.Now()).Hours() / 24)
@@ -447,17 +451,22 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
events = append(events, map[string]interface{}{ events = append(events, map[string]interface{}{
"id": "domain-" + d.Id, "id": "domain-" + d.Id,
"title": "🌐 " + domainName + " expires", "title": domainName + " expires",
"date": expiryDate.Format("2006-01-02"), "date": expiryDate.Format("2006-01-02"),
"type": "domain_expiry", "type": "domain_expiry",
"color": color, "color": color,
"domain_id": d.Id,
"entity_id": d.Id,
"entity_name": domainName,
"link": "/domain/" + d.Id,
"days_until": daysUntil,
}) })
} }
// SSL expirations // SSL expirations
for _, d := range domains { for _, d := range domains {
sslExpiry := d.GetDateTime("ssl_valid_to").Time() sslExpiry := d.GetDateTime("ssl_valid_to").Time()
if sslExpiry.IsZero() { if sslExpiry.IsZero() || !dateInRange(sslExpiry, from, to) {
continue continue
} }
domainName := d.GetString("domain_name") domainName := d.GetString("domain_name")
@@ -474,10 +483,15 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
events = append(events, map[string]interface{}{ events = append(events, map[string]interface{}{
"id": "ssl-" + d.Id, "id": "ssl-" + d.Id,
"title": "🔒 " + domainName + " SSL expires", "title": domainName + " SSL expires",
"date": sslExpiry.Format("2006-01-02"), "date": sslExpiry.Format("2006-01-02"),
"type": "ssl_expiry", "type": "ssl_expiry",
"color": color, "color": color,
"domain_id": d.Id,
"entity_id": d.Id,
"entity_name": domainName,
"link": "/domain/" + d.Id,
"days_until": daysUntil,
}) })
} }
@@ -487,6 +501,9 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
) )
for _, i := range incidents { for _, i := range incidents {
startedAt := i.GetDateTime("started_at").Time() startedAt := i.GetDateTime("started_at").Time()
if startedAt.IsZero() || !dateInRange(startedAt, from, to) {
continue
}
title := i.GetString("title") title := i.GetString("title")
severity := i.GetString("severity") severity := i.GetString("severity")
@@ -502,16 +519,46 @@ func (h *APIHandler) getCalendarEvents(e *core.RequestEvent) error {
events = append(events, map[string]interface{}{ events = append(events, map[string]interface{}{
"id": "incident-" + i.Id, "id": "incident-" + i.Id,
"title": "⚠️ " + title, "title": title,
"date": startedAt.Format("2006-01-02"), "date": startedAt.Format("2006-01-02"),
"type": "incident", "type": "incident",
"color": color, "color": color,
"incident_id": i.Id,
"entity_id": i.Id,
"entity_name": title,
"link": "/incidents",
}) })
} }
return e.JSON(http.StatusOK, events) return e.JSON(http.StatusOK, events)
} }
func calendarRange(e *core.RequestEvent) (*time.Time, *time.Time) {
var from, to *time.Time
if value := e.Request.URL.Query().Get("from"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
from = &parsed
}
}
if value := e.Request.URL.Query().Get("to"); value != "" {
if parsed, err := time.Parse("2006-01-02", value); err == nil {
end := parsed.Add(24*time.Hour - time.Nanosecond)
to = &end
}
}
return from, to
}
func dateInRange(value time.Time, from, to *time.Time) bool {
if from != nil && value.Before(*from) {
return false
}
if to != nil && value.After(*to) {
return false
}
return true
}
// addUpdate adds an update record // addUpdate adds an update record
func (h *APIHandler) addUpdate(incidentID, message, updateType string, oldStatus, newStatus *string, createdBy string) { func (h *APIHandler) addUpdate(incidentID, message, updateType string, oldStatus, newStatus *string, createdBy string) {
collection, err := h.app.FindCollectionByNameOrId("incident_updates") collection, err := h.app.FindCollectionByNameOrId("incident_updates")
+60
View File
@@ -0,0 +1,60 @@
package incidents
import (
"net/http/httptest"
"testing"
"time"
"github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/router"
)
func TestDateInRange(t *testing.T) {
from := mustDate(t, "2026-04-01")
to := mustDate(t, "2026-04-30")
tests := []struct {
name string
date string
want bool
}{
{name: "before range", date: "2026-03-31", want: false},
{name: "start boundary", date: "2026-04-01", want: true},
{name: "inside range", date: "2026-04-15", want: true},
{name: "end boundary", date: "2026-04-30", want: true},
{name: "after range", date: "2026-05-01", want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := dateInRange(mustDate(t, tt.date), &from, &to)
if got != tt.want {
t.Fatalf("dateInRange(%s) = %v, want %v", tt.date, got, tt.want)
}
})
}
}
func TestCalendarRangeParsesDateBounds(t *testing.T) {
request := httptest.NewRequest("GET", "/api/beszel/incidents/calendar?from=2026-04-01&to=2026-04-30", nil)
from, to := calendarRange(&core.RequestEvent{Event: router.Event{Request: request}})
if from == nil || from.Format("2006-01-02") != "2026-04-01" {
t.Fatalf("unexpected from date: %v", from)
}
if to == nil || !dateInRange(mustDate(t, "2026-04-30").Add(23*time.Hour), from, to) {
t.Fatalf("expected to date to include the whole end day, got %v", to)
}
if dateInRange(mustDate(t, "2026-05-01"), from, to) {
t.Fatal("expected range to exclude day after the to bound")
}
}
func mustDate(t *testing.T, value string) time.Time {
t.Helper()
parsed, err := time.Parse("2006-01-02", value)
if err != nil {
t.Fatal(err)
}
return parsed
}
+54 -8
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
"github.com/henrygd/beszel/internal/entities/monitor" "github.com/henrygd/beszel/internal/entities/monitor"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
) )
@@ -408,11 +409,25 @@ func (h *APIHandler) manualCheck(e *core.RequestEvent) error {
return e.InternalServerError("Check failed", err) return e.InternalServerError("Check failed", err)
} }
return e.JSON(http.StatusOK, map[string]interface{}{ response := map[string]interface{}{
"status": result.Status, "status": result.Status,
"ping": result.Ping, "ping": result.Ping,
"msg": result.Msg, "msg": result.Msg,
}) }
if hbs, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
1,
0,
dbx.Params{"monitorId": id},
); err == nil && len(hbs) > 0 {
response["heartbeat_id"] = hbs[0].Id
response["time"] = hbs[0].GetDateTime("time").String()
}
return e.JSON(http.StatusOK, response)
} }
// pauseMonitor pauses a monitor // pauseMonitor pauses a monitor
@@ -493,11 +508,37 @@ func (h *APIHandler) getStats(e *core.RequestEvent) error {
stats24h, _ := h.scheduler.GetUptimeStats(id, 24) stats24h, _ := h.scheduler.GetUptimeStats(id, 24)
stats7d, _ := h.scheduler.GetUptimeStats(id, 168) stats7d, _ := h.scheduler.GetUptimeStats(id, 168)
stats30d, _ := h.scheduler.GetUptimeStats(id, 720) stats30d, _ := h.scheduler.GetUptimeStats(id, 720)
avg24h := 0.0
if stats24h.Total > 0 {
records, _ := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
"-time",
0,
0,
dbx.Params{
"monitorId": id,
"since": time.Now().Add(-24 * time.Hour).Format("2006-01-02 15:04:05"),
"status": string(monitor.StatusUp),
},
)
if len(records) > 0 {
totalPing := 0
for _, record := range records {
totalPing += record.GetInt("ping")
}
avg24h = float64(totalPing) / float64(len(records))
}
}
return e.JSON(http.StatusOK, map[string]interface{}{ return e.JSON(http.StatusOK, map[string]interface{}{
"uptime_24h": stats24h, "uptime_24h": stats24h,
"uptime_7d": stats7d, "uptime_7d": stats7d,
"uptime_30d": stats30d, "uptime_30d": stats30d,
"uptime_percent_24h": percent(stats24h),
"uptime_percent_7d": percent(stats7d),
"uptime_percent_30d": percent(stats30d),
"avg_ping_24h": avg24h,
}) })
} }
@@ -582,16 +623,14 @@ func recordToResponse(record *core.Record) MonitorResponse {
Updated: record.GetDateTime("updated").Time(), Updated: record.GetDateTime("updated").Time(),
} }
// Handle last_check if lc := record.GetDateTime("last_check"); !lc.IsZero() {
if lc := record.Get("last_check"); lc != nil { t := lc.Time()
if t, ok := lc.(time.Time); ok {
resp.LastCheck = &t resp.LastCheck = &t
} }
}
// Handle uptime_stats
if stats := record.Get("uptime_stats"); stats != nil { if stats := record.Get("uptime_stats"); stats != nil {
if s, ok := stats.(map[string]float64); ok { var s map[string]float64
if raw, err := json.Marshal(stats); err == nil && json.Unmarshal(raw, &s) == nil {
resp.UptimeStats = s resp.UptimeStats = s
} }
} }
@@ -605,3 +644,10 @@ func recordToResponse(record *core.Record) MonitorResponse {
return resp return resp
} }
func percent(stats *monitor.UptimeStats) float64 {
if stats == nil || stats.Total == 0 {
return 0
}
return float64(stats.Up) / float64(stats.Total) * 100
}
@@ -0,0 +1,138 @@
package checks
import (
"context"
"net"
"net/http"
"net/http/httptest"
"testing"
"github.com/henrygd/beszel/internal/entities/monitor"
)
func TestHTTPCheckerReportsUpForSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
defer server.Close()
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected status up, got %s: %s", result.Status, result.Msg)
}
if result.Ping < 0 {
t.Fatalf("expected non-negative ping, got %d", result.Ping)
}
}
func TestHTTPCheckerReportsDownForServerError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "broken", http.StatusInternalServerError)
}))
defer server.Close()
result := (&HTTPChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected status down, got %s", result.Status)
}
}
func TestKeywordCheckerHonorsKeywordAndInvert(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("service status ok"))
}))
defer server.Close()
result := (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
Keyword: "status ok",
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected keyword match to be up, got %s: %s", result.Status, result.Msg)
}
result = (&KeywordChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
Keyword: "status ok",
InvertKeyword: true,
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected inverted keyword match to be down, got %s", result.Status)
}
}
func TestJSONQueryCheckerMatchesNestedValue(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":{"status":"ok","version":2}}`))
}))
defer server.Close()
result := (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
JSONQuery: "data.status",
ExpectedValue: "ok",
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected json query match to be up, got %s: %s", result.Status, result.Msg)
}
result = (&JSONQueryChecker{}).Check(context.Background(), &monitor.Monitor{
URL: server.URL,
Timeout: 2,
JSONQuery: "data.status",
ExpectedValue: "down",
})
if result.Status != monitor.StatusDown {
t.Fatalf("expected json query mismatch to be down, got %s", result.Status)
}
}
func TestTCPCheckerUsesConfiguredHostAndPort(t *testing.T) {
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer listener.Close()
done := make(chan struct{})
go func() {
conn, err := listener.Accept()
if err == nil {
_ = conn.Close()
}
close(done)
}()
result := (&TCPChecker{}).Check(context.Background(), &monitor.Monitor{
Hostname: "127.0.0.1",
Port: listener.Addr().(*net.TCPAddr).Port,
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected tcp check to be up, got %s: %s", result.Status, result.Msg)
}
<-done
}
func TestDNSCheckerResolvesLocalhost(t *testing.T) {
result := (&DNSChecker{}).Check(context.Background(), &monitor.Monitor{
Hostname: "localhost",
DNSResolverMode: "A",
Timeout: 2,
})
if result.Status != monitor.StatusUp {
t.Fatalf("expected localhost DNS to resolve, got %s: %s", result.Status, result.Msg)
}
}
+150 -56
View File
@@ -2,13 +2,16 @@ package monitors
import ( import (
"context" "context"
"encoding/json"
"fmt" "fmt"
"log" "log"
"sync" "sync"
"time" "time"
"github.com/henrygd/beszel/internal/entities/incident"
"github.com/henrygd/beszel/internal/entities/monitor" "github.com/henrygd/beszel/internal/entities/monitor"
"github.com/henrygd/beszel/internal/hub/monitors/checks" "github.com/henrygd/beszel/internal/hub/monitors/checks"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/core"
"github.com/pocketbase/pocketbase/tools/store" "github.com/pocketbase/pocketbase/tools/store"
) )
@@ -194,50 +197,15 @@ func (s *Scheduler) runCheck(m *monitor.Monitor) {
// saveResult saves the check result to the database and sends notifications on status change // saveResult saves the check result to the database and sends notifications on status change
func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult) error { func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult) error {
// Update monitor record
record, err := s.app.FindRecordById("monitors", m.ID) record, err := s.app.FindRecordById("monitors", m.ID)
if err != nil { if err != nil {
return fmt.Errorf("failed to find monitor: %w", err) return fmt.Errorf("failed to find monitor: %w", err)
} }
// Get previous status for change detection
prevStatus := monitor.Status(record.GetString("status")) prevStatus := monitor.Status(record.GetString("status"))
newStatus := result.Status newStatus := result.Status
now := time.Now()
// Update status
record.Set("status", string(newStatus))
record.Set("last_check", time.Now())
// Track status changes and send notifications
if prevStatus != newStatus {
s.handleStatusChange(m, record, prevStatus, newStatus, result)
}
// Calculate uptime stats (simplified - in production would aggregate from heartbeats)
if m.UptimeStats == nil {
m.UptimeStats = make(map[string]float64)
}
// Simple rolling uptime calculation (can be improved)
if result.Status == monitor.StatusUp {
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
m.UptimeStats["up"] = m.UptimeStats["up"] + 1
} else {
m.UptimeStats["total"] = m.UptimeStats["total"] + 1
m.UptimeStats["down"] = m.UptimeStats["down"] + 1
}
if total := m.UptimeStats["total"]; total > 0 {
m.UptimeStats["uptime_24h"] = (m.UptimeStats["up"] / total) * 100
}
record.Set("uptime_stats", m.UptimeStats)
if err := s.app.Save(record); err != nil {
return fmt.Errorf("failed to update monitor: %w", err)
}
// Create heartbeat record
hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats") hbCollection, err := s.app.FindCollectionByNameOrId("monitor_heartbeats")
if err != nil { if err != nil {
return fmt.Errorf("failed to find heartbeats collection: %w", err) return fmt.Errorf("failed to find heartbeats collection: %w", err)
@@ -250,12 +218,34 @@ func (s *Scheduler) saveResult(m *monitor.Monitor, result *monitor.CheckResult)
hbRecord.Set("msg", result.Msg) hbRecord.Set("msg", result.Msg)
hbRecord.Set("cert_expiry", result.CertExpiry) hbRecord.Set("cert_expiry", result.CertExpiry)
hbRecord.Set("cert_valid", result.CertValid) hbRecord.Set("cert_valid", result.CertValid)
hbRecord.Set("time", time.Now()) hbRecord.Set("time", now)
if err := s.app.Save(hbRecord); err != nil { if err := s.app.Save(hbRecord); err != nil {
return fmt.Errorf("failed to save heartbeat: %w", err) return fmt.Errorf("failed to save heartbeat: %w", err)
} }
stats, err := s.calculateUptimeStats(m.ID)
if err != nil {
return fmt.Errorf("failed to calculate uptime stats: %w", err)
}
stats["last_ping"] = float64(result.Ping)
record.Set("status", string(newStatus))
record.Set("last_check", now)
record.Set("uptime_stats", stats)
if err := s.app.Save(record); err != nil {
return fmt.Errorf("failed to update monitor: %w", err)
}
m.Status = newStatus
m.LastCheck = now
m.UptimeStats = stats
if prevStatus != newStatus {
s.handleStatusChange(m, record, prevStatus, newStatus, result)
}
return nil return nil
} }
@@ -270,23 +260,17 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
isRecovery := false isRecovery := false
switch { switch {
case prevStatus == monitor.StatusUp && newStatus == monitor.StatusDown: case newStatus == monitor.StatusDown && prevStatus != monitor.StatusDown:
title = fmt.Sprintf("Monitor Down: %s", m.Name) title = fmt.Sprintf("Monitor Down: %s", m.Name)
message = fmt.Sprintf("The monitor %s (%s) is now DOWN.\n\nError: %s", m.Name, m.URL, result.Msg) message = fmt.Sprintf("The monitor %s (%s) is now DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
case prevStatus == monitor.StatusDown && newStatus == monitor.StatusUp: case prevStatus == monitor.StatusDown && newStatus == monitor.StatusUp:
title = fmt.Sprintf("Monitor Recovered: %s", m.Name) title = fmt.Sprintf("Monitor Recovered: %s", m.Name)
message = fmt.Sprintf("The monitor %s (%s) is now UP.\n\nResponse time: %dms", m.Name, m.URL, result.Ping) message = fmt.Sprintf("The monitor %s (%s) is now UP.\n\nResponse time: %dms", m.Name, m.URL, result.Ping)
isRecovery = true isRecovery = true
case newStatus == monitor.StatusDown:
// Still down after retry
title = fmt.Sprintf("Monitor Still Down: %s", m.Name)
message = fmt.Sprintf("The monitor %s (%s) remains DOWN.\n\nError: %s", m.Name, m.URL, result.Msg)
default: default:
// Other status changes, don't notify
return return
} }
// Create incident record for status change
s.createIncident(m, prevStatus, newStatus, result, isRecovery) s.createIncident(m, prevStatus, newStatus, result, isRecovery)
// Send notification via AlertManager if available // Send notification via AlertManager if available
@@ -301,23 +285,72 @@ func (s *Scheduler) handleStatusChange(m *monitor.Monitor, record *core.Record,
// createIncident creates an incident record for the status change // createIncident creates an incident record for the status change
func (s *Scheduler) createIncident(m *monitor.Monitor, prevStatus, newStatus monitor.Status, result *monitor.CheckResult, isRecovery bool) { func (s *Scheduler) createIncident(m *monitor.Monitor, prevStatus, newStatus monitor.Status, result *monitor.CheckResult, isRecovery bool) {
incidentCollection, err := s.app.FindCollectionByNameOrId("monitor_incidents") if isRecovery {
records, err := s.app.FindRecordsByFilter(
"incidents",
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
"-started_at",
0,
0,
dbx.Params{
"monitor": m.ID,
"type": incident.TypeMonitorDown,
"open": incident.StatusOpen,
"acknowledged": incident.StatusAcknowledged,
},
)
if err != nil {
log.Printf("[monitor-scheduler] Failed to find open incident: %v", err)
return
}
now := time.Now()
for _, record := range records {
record.Set("status", incident.StatusResolved)
record.Set("resolved_at", now)
record.Set("resolution", fmt.Sprintf("Monitor recovered: %s", result.Msg))
if err := s.app.Save(record); err != nil {
log.Printf("[monitor-scheduler] Failed to resolve incident: %v", err)
}
}
return
}
if newStatus != monitor.StatusDown || prevStatus == monitor.StatusDown {
return
}
existing, err := s.app.FindFirstRecordByFilter(
"incidents",
"monitor = {:monitor} && type = {:type} && (status = {:open} || status = {:acknowledged})",
dbx.Params{
"monitor": m.ID,
"type": incident.TypeMonitorDown,
"open": incident.StatusOpen,
"acknowledged": incident.StatusAcknowledged,
},
)
if err == nil && existing != nil {
return
}
incidentCollection, err := s.app.FindCollectionByNameOrId("incidents")
if err != nil { if err != nil {
// Collection might not exist, just log
log.Printf("[monitor-scheduler] Could not create incident: %v", err) log.Printf("[monitor-scheduler] Could not create incident: %v", err)
return return
} }
incident := core.NewRecord(incidentCollection) record := core.NewRecord(incidentCollection)
incident.Set("monitor", m.ID) record.Set("title", fmt.Sprintf("Monitor Down: %s", m.Name))
incident.Set("prev_status", string(prevStatus)) record.Set("description", result.Msg)
incident.Set("new_status", string(newStatus)) record.Set("type", incident.TypeMonitorDown)
incident.Set("message", result.Msg) record.Set("severity", incident.SeverityHigh)
incident.Set("ping", result.Ping) record.Set("status", incident.StatusOpen)
incident.Set("is_recovery", isRecovery) record.Set("monitor", m.ID)
incident.Set("time", time.Now()) record.Set("started_at", time.Now())
record.Set("user", m.UserID)
if err := s.app.Save(incident); err != nil { if err := s.app.Save(record); err != nil {
log.Printf("[monitor-scheduler] Failed to save incident: %v", err) log.Printf("[monitor-scheduler] Failed to save incident: %v", err)
} }
} }
@@ -403,6 +436,9 @@ func (s *Scheduler) RunManualCheck(monitorID string) (*monitor.CheckResult, erro
defer cancel() defer cancel()
result := checker.Check(ctx, m) result := checker.Check(ctx, m)
if err := s.saveResult(m, result); err != nil {
return nil, err
}
return result, nil return result, nil
} }
@@ -453,6 +489,61 @@ func (s *Scheduler) GetUptimeStats(monitorID string, hours int) (*monitor.Uptime
return stats, nil return stats, nil
} }
func (s *Scheduler) calculateUptimeStats(monitorID string) (map[string]float64, error) {
stats := make(map[string]float64)
for _, window := range []struct {
hours int
key string
}{
{24, "uptime_24h"},
{168, "uptime_7d"},
{720, "uptime_30d"},
} {
windowStats, err := s.GetUptimeStats(monitorID, window.hours)
if err != nil {
return nil, err
}
if windowStats.Total > 0 {
stats[window.key] = float64(windowStats.Up) / float64(windowStats.Total) * 100
}
stats[fmt.Sprintf("checks_%s", window.key)] = float64(windowStats.Total)
}
avgPing, err := s.averagePing(monitorID, 24)
if err != nil {
return nil, err
}
stats["avg_ping_24h"] = avgPing
return stats, nil
}
func (s *Scheduler) averagePing(monitorID string, hours int) (float64, error) {
since := time.Now().Add(-time.Duration(hours) * time.Hour)
records, err := s.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId} && time >= {:since} && status = {:status}",
"-time",
0,
0,
dbx.Params{
"monitorId": monitorID,
"since": since.Format("2006-01-02 15:04:05"),
"status": string(monitor.StatusUp),
},
)
if err != nil {
return 0, err
}
if len(records) == 0 {
return 0, nil
}
total := 0
for _, record := range records {
total += record.GetInt("ping")
}
return float64(total) / float64(len(records)), nil
}
// recordToMonitor converts a PocketBase record to a Monitor struct // recordToMonitor converts a PocketBase record to a Monitor struct
func recordToMonitor(record *core.Record) *monitor.Monitor { func recordToMonitor(record *core.Record) *monitor.Monitor {
m := &monitor.Monitor{ m := &monitor.Monitor{
@@ -493,10 +584,13 @@ func recordToMonitor(record *core.Record) *monitor.Monitor {
} }
if statsData := record.Get("uptime_stats"); statsData != nil { if statsData := record.Get("uptime_stats"); statsData != nil {
if stats, ok := statsData.(map[string]float64); ok { stats := map[string]float64{}
if raw, err := json.Marshal(statsData); err == nil {
if err := json.Unmarshal(raw, &stats); err == nil {
m.UptimeStats = stats m.UptimeStats = stats
} }
} }
}
if lastCheck := record.Get("last_check"); lastCheck != nil { if lastCheck := record.Get("last_check"); lastCheck != nil {
if t, ok := lastCheck.(time.Time); ok { if t, ok := lastCheck.(time.Time); ok {
@@ -0,0 +1,84 @@
package migrations
import (
"github.com/pocketbase/pocketbase/core"
m "github.com/pocketbase/pocketbase/migrations"
)
func init() {
m.Register(func(app core.App) error {
if err := patchDomainCollection(app); err != nil {
return err
}
return nil
}, func(app core.App) error {
return nil
})
}
func patchDomainCollection(app core.App) error {
collection, err := app.FindCollectionByNameOrId("domains")
if err != nil {
return err
}
addTextField(collection, "registry_domain_id")
addTextField(collection, "ssl_issuer_country")
addTextField(collection, "ssl_subject")
addDateField(collection, "ssl_valid_from")
addTextField(collection, "ssl_fingerprint")
addNumberField(collection, "ssl_key_size", true)
addTextField(collection, "ssl_signature_algo")
addTextField(collection, "host_region")
addTextField(collection, "host_city")
addTextField(collection, "host_org")
addTextField(collection, "host_as")
addNumberField(collection, "host_lat", false)
addNumberField(collection, "host_lon", false)
addTextField(collection, "registrant_name")
addTextField(collection, "registrant_org")
addTextField(collection, "registrant_street")
addTextField(collection, "registrant_city")
addTextField(collection, "registrant_state")
addTextField(collection, "registrant_country")
addTextField(collection, "registrant_postal")
addTextField(collection, "abuse_email")
addTextField(collection, "abuse_phone")
addTextField(collection, "monitor_type")
addNumberField(collection, "ssl_alert_days", true)
addBoolField(collection, "notify_on_expiry")
addBoolField(collection, "notify_on_ssl_expiry")
addBoolField(collection, "notify_on_dns_change")
addBoolField(collection, "notify_on_registrar_change")
addBoolField(collection, "notify_on_value_change")
addNumberField(collection, "value_change_threshold", false)
addBoolField(collection, "quiet_hours_enabled")
addTextField(collection, "quiet_hours_start")
addTextField(collection, "quiet_hours_end")
return app.Save(collection)
}
func addTextField(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.TextField{Name: name})
}
}
func addDateField(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.DateField{Name: name})
}
}
func addBoolField(collection *core.Collection, name string) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.BoolField{Name: name})
}
}
func addNumberField(collection *core.Collection, name string, onlyInt bool) {
if collection.Fields.GetByName(name) == nil {
collection.Fields.Add(&core.NumberField{Name: name, OnlyInt: onlyInt})
}
}
@@ -3,30 +3,30 @@
import { useState, useMemo } from "react" import { useState, useMemo } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { import { Link } from "@/components/router"
ChevronLeft, import { ChevronLeft, ChevronRight, Calendar as CalendarIcon, AlertCircle, Globe, Shield } from "lucide-react"
ChevronRight,
Calendar as CalendarIcon,
AlertCircle,
Globe,
Shield,
} from "lucide-react"
import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents" import { getCalendarEvents, type CalendarEvent } from "@/lib/incidents"
import { formatDate } from "@/lib/domains"
export function CalendarView() { export function CalendarView() {
const [currentDate, setCurrentDate] = useState(new Date()) const [currentDate, setCurrentDate] = useState(new Date())
const { data: events, isLoading } = useQuery({
queryKey: ["calendar-events"],
queryFn: getCalendarEvents,
})
const year = currentDate.getFullYear() const year = currentDate.getFullYear()
const month = currentDate.getMonth() const month = currentDate.getMonth()
const queryRange = useMemo(() => {
const from = new Date(year, month, 1)
const to = new Date(year, month + 13, 0)
return {
from: toDateString(from),
to: toDateString(to),
}
}, [year, month])
const { data: events, isLoading } = useQuery({
queryKey: ["calendar-events", queryRange.from, queryRange.to],
queryFn: () => getCalendarEvents(queryRange),
})
const daysInMonth = useMemo(() => { const daysInMonth = useMemo(() => {
return new Date(year, month + 1, 0).getDate() return new Date(year, month + 1, 0).getDate()
}, [year, month]) }, [year, month])
@@ -53,6 +53,14 @@ export function CalendarView() {
return d return d
}, [year, month, daysInMonth, firstDayOfMonth, events]) }, [year, month, daysInMonth, firstDayOfMonth, events])
const upcomingEvents = useMemo(() => {
const today = toDateString(new Date())
return (events || [])
.filter((event) => event.date >= today)
.sort((a, b) => a.date.localeCompare(b.date))
.slice(0, 8)
}, [events])
const prevMonth = () => { const prevMonth = () => {
setCurrentDate(new Date(year, month - 1, 1)) setCurrentDate(new Date(year, month - 1, 1))
} }
@@ -75,8 +83,18 @@ export function CalendarView() {
} }
const monthNames = [ const monthNames = [
"January", "February", "March", "April", "May", "June", "January",
"July", "August", "September", "October", "November", "December" "February",
"March",
"April",
"May",
"June",
"July",
"August",
"September",
"October",
"November",
"December",
] ]
if (isLoading) { if (isLoading) {
@@ -130,24 +148,23 @@ export function CalendarView() {
{days.map((day, index) => ( {days.map((day, index) => (
<div <div
key={index} key={index}
className={`min-h-[100px] border rounded-lg p-2 ${ className={`min-h-[100px] border rounded-lg p-2 ${day.day === 0 ? "bg-muted/30" : "bg-card"}`}
day.day === 0 ? "bg-muted/30" : "bg-card"
}`}
> >
{day.day > 0 && ( {day.day > 0 && (
<> <>
<div className="font-medium text-sm mb-1">{day.day}</div> <div className="font-medium text-sm mb-1">{day.day}</div>
<div className="space-y-1"> <div className="space-y-1">
{day.events.map((event) => ( {day.events.map((event) => (
<div <Link
key={event.id} key={event.id}
href={event.link || "/calendar"}
className="text-xs p-1 rounded flex items-center gap-1" className="text-xs p-1 rounded flex items-center gap-1"
style={{ backgroundColor: event.color + "20", color: event.color }} style={{ backgroundColor: `${event.color}20`, color: event.color }}
title={event.title} title={event.title}
> >
{getEventIcon(event.type)} {getEventIcon(event.type)}
<span className="truncate">{event.title}</span> <span className="truncate">{event.title}</span>
</div> </Link>
))} ))}
</div> </div>
</> </>
@@ -155,6 +172,43 @@ export function CalendarView() {
</div> </div>
))} ))}
</div> </div>
<div className="mt-6 border-t pt-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-semibold">Upcoming</h3>
<span className="text-xs text-muted-foreground">Next 12 months from this view</span>
</div>
{upcomingEvents.length > 0 ? (
<div className="grid gap-2 sm:grid-cols-2">
{upcomingEvents.map((event) => (
<Link
key={event.id}
href={event.link || "/calendar"}
className="flex items-center gap-3 rounded-md border p-3 text-sm hover:bg-muted/50"
>
<div
className="flex h-8 w-8 shrink-0 items-center justify-center rounded"
style={{ backgroundColor: `${event.color}20`, color: event.color }}
>
{getEventIcon(event.type)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{event.title}</div>
<div className="text-xs text-muted-foreground">{event.date}</div>
</div>
{typeof event.days_until === "number" && (
<div className="text-xs text-muted-foreground">
{event.days_until === 0 ? "Today" : `${event.days_until}d`}
</div>
)}
</Link>
))}
</div>
) : (
<div className="rounded-md border border-dashed p-4 text-sm text-muted-foreground">
No upcoming domain, SSL, or incident events found.
</div>
)}
</div>
<div className="mt-4 flex flex-wrap gap-4 text-sm"> <div className="mt-4 flex flex-wrap gap-4 text-sm">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="w-3 h-3 rounded bg-red-500" /> <div className="w-3 h-3 rounded bg-red-500" />
@@ -177,3 +231,7 @@ export function CalendarView() {
</Card> </Card>
) )
} }
function toDateString(date: Date) {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`
}
@@ -35,40 +35,13 @@ import {
} from "@/lib/monitors" } from "@/lib/monitors"
const MONITOR_TYPES: { value: MonitorType; label: string; group: string }[] = [ const MONITOR_TYPES: { value: MonitorType; label: string; group: string }[] = [
// General
{ value: "http", label: "HTTP", group: "General" }, { value: "http", label: "HTTP", group: "General" },
{ value: "https", label: "HTTPS", group: "General" }, { value: "https", label: "HTTPS", group: "General" },
{ value: "keyword", label: "HTTP Keyword", group: "General" }, { value: "keyword", label: "HTTP Keyword", group: "General" },
{ value: "json-query", label: "HTTP JSON", group: "General" }, { value: "json-query", label: "HTTP JSON", group: "General" },
{ value: "grpc-keyword", label: "gRPC Keyword", group: "General" },
{ value: "real-browser", label: "Browser Engine (Beta)", group: "General" },
{ value: "tcp", label: "TCP Port", group: "General" }, { value: "tcp", label: "TCP Port", group: "General" },
{ value: "ping", label: "Ping", group: "General" }, { value: "ping", label: "Ping", group: "General" },
{ value: "dns", label: "DNS", group: "General" }, { value: "dns", label: "DNS", group: "General" },
{ value: "docker", label: "Docker Container", group: "General" },
{ value: "push", label: "Push", group: "General" },
{ value: "manual", label: "Manual", group: "General" },
// Network / Protocol
{ value: "mqtt", label: "MQTT", group: "Network / Protocol" },
{ value: "rabbitmq", label: "RabbitMQ", group: "Network / Protocol" },
{ value: "kafka-producer", label: "Kafka Producer", group: "Network / Protocol" },
{ value: "smtp", label: "SMTP", group: "Network / Protocol" },
{ value: "snmp", label: "SNMP", group: "Network / Protocol" },
{ value: "websocket-upgrade", label: "WebSocket Upgrade", group: "Network / Protocol" },
{ value: "sip-options", label: "SIP Options Ping", group: "Network / Protocol" },
{ value: "tailscale-ping", label: "Tailscale Ping", group: "Network / Protocol" },
{ value: "globalping", label: "Globalping", group: "Network / Protocol" },
// Database
{ value: "mysql", label: "MySQL / MariaDB", group: "Database" },
{ value: "postgresql", label: "PostgreSQL", group: "Database" },
{ value: "mongodb", label: "MongoDB", group: "Database" },
{ value: "redis", label: "Redis", group: "Database" },
{ value: "sqlserver", label: "Microsoft SQL Server", group: "Database" },
{ value: "oracledb", label: "Oracle DB", group: "Database" },
{ value: "radius", label: "RADIUS", group: "Database" },
// Games
{ value: "gamedig", label: "GameDig", group: "Game Server" },
{ value: "steam", label: "Steam API", group: "Game Server" },
] ]
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"] const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "PATCH"]
@@ -82,12 +55,7 @@ interface AddMonitorDialogProps {
isEdit?: boolean isEdit?: boolean
} }
export function AddMonitorDialog({ export function AddMonitorDialog({ open, onOpenChange, monitor, isEdit = false }: AddMonitorDialogProps) {
open,
onOpenChange,
monitor,
isEdit = false,
}: AddMonitorDialogProps) {
const { t } = useLingui() const { t } = useLingui()
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -234,8 +202,7 @@ export function AddMonitorDialog({
}) })
const updateMutation = useMutation({ const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) => mutationFn: ({ id, data }: { id: string; data: UpdateMonitorRequest }) => updateMonitor(id, data),
updateMonitor(id, data),
onSuccess: () => { onSuccess: () => {
toast({ title: t`Monitor updated successfully` }) toast({ title: t`Monitor updated successfully` })
queryClient.invalidateQueries({ queryKey: ["monitors"] }) queryClient.invalidateQueries({ queryKey: ["monitors"] })
@@ -264,9 +231,7 @@ export function AddMonitorDialog({
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined, url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
hostname: needsHostname ? hostname.trim() || undefined : undefined, hostname: needsHostname ? hostname.trim() || undefined : undefined,
port: port ? Number(port) : undefined, port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type) method: ["http", "https", "keyword", "json-query"].includes(type) ? method : undefined,
? method
: undefined,
headers: headers.trim() || undefined, headers: headers.trim() || undefined,
body: body.trim() || undefined, body: body.trim() || undefined,
interval, interval,
@@ -279,10 +244,7 @@ export function AddMonitorDialog({
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined, dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined, dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined, description: description.trim() || undefined,
ignore_tls_error: ignore_tls_error: type === "https" || type === "keyword" || type === "json-query" ? ignoreTLSError : undefined,
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined, cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined, cert_expiry_days: type === "https" ? certExpiryDays : undefined,
// Notification settings // Notification settings
@@ -312,9 +274,7 @@ export function AddMonitorDialog({
url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined, url: needsDbOptions ? dbConnectionString.trim() || undefined : url.trim() || undefined,
hostname: needsHostname ? hostname.trim() || undefined : undefined, hostname: needsHostname ? hostname.trim() || undefined : undefined,
port: port ? Number(port) : undefined, port: port ? Number(port) : undefined,
method: ["http", "https", "keyword", "json-query"].includes(type) method: ["http", "https", "keyword", "json-query"].includes(type) ? method : undefined,
? method
: undefined,
headers: headers.trim() || undefined, headers: headers.trim() || undefined,
body: body.trim() || undefined, body: body.trim() || undefined,
interval, interval,
@@ -327,10 +287,7 @@ export function AddMonitorDialog({
dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined, dns_resolve_server: type === "dns" ? dnsResolveServer.trim() : undefined,
dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined, dns_resolver_mode: type === "dns" ? dnsResolverMode : undefined,
description: description.trim() || undefined, description: description.trim() || undefined,
ignore_tls_error: ignore_tls_error: type === "https" || type === "keyword" || type === "json-query" ? ignoreTLSError : undefined,
type === "https" || type === "keyword" || type === "json-query"
? ignoreTLSError
: undefined,
cert_expiry_notification: type === "https" ? certExpiryNotification : undefined, cert_expiry_notification: type === "https" ? certExpiryNotification : undefined,
cert_expiry_days: type === "https" ? certExpiryDays : undefined, cert_expiry_days: type === "https" ? certExpiryDays : undefined,
// Notification settings // Notification settings
@@ -356,9 +313,54 @@ export function AddMonitorDialog({
} }
} }
const needsUrl = ["http", "https", "keyword", "json-query", "grpc-keyword", "real-browser", "websocket-upgrade", "push"].includes(type) const needsUrl = [
const needsHostname = ["tcp", "ping", "dns", "mqtt", "rabbitmq", "kafka-producer", "smtp", "snmp", "sip-options", "tailscale-ping", "globalping", "mysql", "postgresql", "mongodb", "redis", "sqlserver", "oracledb", "radius", "gamedig", "steam"].includes(type) "http",
const needsPort = ["tcp", "smtp", "mysql", "postgresql", "redis", "sqlserver", "oracledb", "radius", "mqtt", "rabbitmq", "kafka-producer", "gamedig", "steam", "snmp"].includes(type) "https",
"keyword",
"json-query",
"grpc-keyword",
"real-browser",
"websocket-upgrade",
"push",
].includes(type)
const needsHostname = [
"tcp",
"ping",
"dns",
"mqtt",
"rabbitmq",
"kafka-producer",
"smtp",
"snmp",
"sip-options",
"tailscale-ping",
"globalping",
"mysql",
"postgresql",
"mongodb",
"redis",
"sqlserver",
"oracledb",
"radius",
"gamedig",
"steam",
].includes(type)
const needsPort = [
"tcp",
"smtp",
"mysql",
"postgresql",
"redis",
"sqlserver",
"oracledb",
"radius",
"mqtt",
"rabbitmq",
"kafka-producer",
"gamedig",
"steam",
"snmp",
].includes(type)
const needsHttpOptions = ["http", "https", "keyword", "json-query"].includes(type) const needsHttpOptions = ["http", "https", "keyword", "json-query"].includes(type)
const needsKeyword = type === "keyword" const needsKeyword = type === "keyword"
const needsJsonQuery = type === "json-query" const needsJsonQuery = type === "json-query"
@@ -374,13 +376,9 @@ export function AddMonitorDialog({
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto"> <DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}</DialogTitle>
{isEdit ? <Trans>Edit Monitor</Trans> : <Trans>Add Monitor</Trans>}
</DialogTitle>
<DialogDescription> <DialogDescription>
<Trans> <Trans>Configure a monitor to track website or service availability.</Trans>
Configure a monitor to track website or service availability.
</Trans>
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -419,10 +417,7 @@ export function AddMonitorDialog({
<Label htmlFor="type"> <Label htmlFor="type">
<Trans>Monitor Type</Trans> * <Trans>Monitor Type</Trans> *
</Label> </Label>
<Select <Select value={type} onValueChange={(v) => setType(v as MonitorType)}>
value={type}
onValueChange={(v) => setType(v as MonitorType)}
>
<SelectTrigger id="type"> <SelectTrigger id="type">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -481,11 +476,7 @@ export function AddMonitorDialog({
type="number" type="number"
placeholder={t`443`} placeholder={t`443`}
value={port} value={port}
onChange={(e) => onChange={(e) => setPort(e.target.value ? Number(e.target.value) : "")}
setPort(
e.target.value ? Number(e.target.value) : ""
)
}
required required
/> />
</div> </div>
@@ -524,11 +515,7 @@ export function AddMonitorDialog({
required required
/> />
<div className="flex items-center gap-2 mt-2"> <div className="flex items-center gap-2 mt-2">
<Switch <Switch id="invertKeyword" checked={invertKeyword} onCheckedChange={setInvertKeyword} />
id="invertKeyword"
checked={invertKeyword}
onCheckedChange={setInvertKeyword}
/>
<Label htmlFor="invertKeyword"> <Label htmlFor="invertKeyword">
<Trans>Invert match (alert if keyword found)</Trans> <Trans>Invert match (alert if keyword found)</Trans>
</Label> </Label>
@@ -570,10 +557,7 @@ export function AddMonitorDialog({
<Label htmlFor="dnsResolverMode"> <Label htmlFor="dnsResolverMode">
<Trans>Record Type</Trans> <Trans>Record Type</Trans>
</Label> </Label>
<Select <Select value={dnsResolverMode} onValueChange={setDnsResolverMode}>
value={dnsResolverMode}
onValueChange={setDnsResolverMode}
>
<SelectTrigger id="dnsResolverMode"> <SelectTrigger id="dnsResolverMode">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
@@ -774,11 +758,7 @@ export function AddMonitorDialog({
{needsTlsOptions && ( {needsTlsOptions && (
<div className="space-y-4 border rounded-lg p-4"> <div className="space-y-4 border rounded-lg p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch id="ignoreTLSError" checked={ignoreTLSError} onCheckedChange={setIgnoreTLSError} />
id="ignoreTLSError"
checked={ignoreTLSError}
onCheckedChange={setIgnoreTLSError}
/>
<Label htmlFor="ignoreTLSError"> <Label htmlFor="ignoreTLSError">
<Trans>Ignore TLS/SSL errors</Trans> <Trans>Ignore TLS/SSL errors</Trans>
</Label> </Label>
@@ -797,11 +777,7 @@ export function AddMonitorDialog({
<Label htmlFor="notifyOnDown">Notify when monitor goes down</Label> <Label htmlFor="notifyOnDown">Notify when monitor goes down</Label>
<p className="text-xs text-muted-foreground">Send alert when service becomes unavailable</p> <p className="text-xs text-muted-foreground">Send alert when service becomes unavailable</p>
</div> </div>
<Switch <Switch id="notifyOnDown" checked={notifyOnDown} onCheckedChange={setNotifyOnDown} />
id="notifyOnDown"
checked={notifyOnDown}
onCheckedChange={setNotifyOnDown}
/>
</div> </div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -809,11 +785,7 @@ export function AddMonitorDialog({
<Label htmlFor="notifyOnRecover">Notify when monitor recovers</Label> <Label htmlFor="notifyOnRecover">Notify when monitor recovers</Label>
<p className="text-xs text-muted-foreground">Send alert when service comes back up</p> <p className="text-xs text-muted-foreground">Send alert when service comes back up</p>
</div> </div>
<Switch <Switch id="notifyOnRecover" checked={notifyOnRecover} onCheckedChange={setNotifyOnRecover} />
id="notifyOnRecover"
checked={notifyOnRecover}
onCheckedChange={setNotifyOnRecover}
/>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@@ -947,11 +919,7 @@ export function AddMonitorDialog({
<Label htmlFor="quietHoursEnabled">Enable quiet hours</Label> <Label htmlFor="quietHoursEnabled">Enable quiet hours</Label>
<p className="text-xs text-muted-foreground">Suppress notifications during specific hours</p> <p className="text-xs text-muted-foreground">Suppress notifications during specific hours</p>
</div> </div>
<Switch <Switch id="quietHoursEnabled" checked={quietHoursEnabled} onCheckedChange={setQuietHoursEnabled} />
id="quietHoursEnabled"
checked={quietHoursEnabled}
onCheckedChange={setQuietHoursEnabled}
/>
</div> </div>
{quietHoursEnabled && ( {quietHoursEnabled && (
<div className="grid grid-cols-2 gap-4 pl-4 border-l-2"> <div className="grid grid-cols-2 gap-4 pl-4 border-l-2">
@@ -980,12 +948,7 @@ export function AddMonitorDialog({
</Tabs> </Tabs>
<DialogFooter className="mt-6"> <DialogFooter className="mt-6">
<Button <Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isPending}>
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isPending}
>
<Trans>Cancel</Trans> <Trans>Cancel</Trans>
</Button> </Button>
<Button type="submit" disabled={isPending}> <Button type="submit" disabled={isPending}>
+91 -83
View File
@@ -1,5 +1,5 @@
import { memo, useMemo, useState } from "react" import { memo, useState } from "react"
import { useQuery } from "@tanstack/react-query" import { useQuery, useQueryClient } from "@tanstack/react-query"
import { Trans } from "@lingui/react/macro" import { Trans } from "@lingui/react/macro"
import { useToast } from "@/components/ui/use-toast" import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
@@ -33,8 +33,17 @@ import {
User, User,
Mail, Mail,
Building, Building,
type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { getDomain, getDomainHistory, refreshDomain, deleteDomain, formatDate, formatDays } from "@/lib/domains" import {
type DomainHistory,
getDomain,
getDomainHistory,
refreshDomain,
deleteDomain,
formatDate,
formatDays,
} from "@/lib/domains"
import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts" import { XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar, Cell } from "recharts"
import { Link, navigate } from "@/components/router" import { Link, navigate } from "@/components/router"
import { DomainDialog } from "@/components/domains-table/domain-dialog" import { DomainDialog } from "@/components/domains-table/domain-dialog"
@@ -62,7 +71,19 @@ function StatusBadge({ status }: { status: string }) {
} }
// Info card component // Info card component
function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: string; value: string; icon: any; subtitle?: string; className?: string }) { function InfoCard({
title,
value,
icon: Icon,
subtitle,
className,
}: {
title: string
value: string
icon: LucideIcon
subtitle?: string
className?: string
}) {
return ( return (
<Card className={className}> <Card className={className}>
<CardContent className="p-4"> <CardContent className="p-4">
@@ -83,6 +104,7 @@ function InfoCard({ title, value, icon: Icon, subtitle, className }: { title: st
export default memo(function DomainDetail({ id }: { id: string }) { export default memo(function DomainDetail({ id }: { id: string }) {
const { toast } = useToast() const { toast } = useToast()
const queryClient = useQueryClient()
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false) const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false) const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
@@ -100,8 +122,11 @@ export default memo(function DomainDetail({ id }: { id: string }) {
const handleRefresh = async () => { const handleRefresh = async () => {
try { try {
await refreshDomain(id) const refreshed = await refreshDomain(id)
toast({ title: "Domain refresh started" }) queryClient.setQueryData(["domain", id], refreshed)
queryClient.invalidateQueries({ queryKey: ["domain-history", id] })
queryClient.invalidateQueries({ queryKey: ["domains"] })
toast({ title: "Domain refreshed" })
} catch (error) { } catch (error) {
toast({ toast({
title: "Failed to refresh domain", title: "Failed to refresh domain",
@@ -130,21 +155,6 @@ export default memo(function DomainDetail({ id }: { id: string }) {
} }
} }
// Prepare chart data from history (events by date)
const chartData = useMemo(() => {
if (!history?.length) return []
const counts: Record<string, number> = {}
history.forEach((h: any) => {
const d = h.created_at
? new Date(h.created_at).toISOString().split("T")[0]
: "Unknown"
counts[d] = (counts[d] || 0) + 1
})
return Object.entries(counts)
.map(([date, count]) => ({ date, count }))
.sort((a, b) => a.date.localeCompare(b.date))
}, [history])
if (isDomainLoading) { if (isDomainLoading) {
return ( return (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
@@ -218,29 +228,33 @@ export default memo(function DomainDetail({ id }: { id: string }) {
{/* Info Grid */} {/* Info Grid */}
<div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid sm:grid-cols-2 lg:grid-cols-4 gap-4">
<InfoCard <InfoCard title="Registrar" value={domain.registrar_name || "Unknown"} icon={Server} />
title="Registrar"
value={domain.registrar_name || "Unknown"}
icon={Server}
/>
<InfoCard <InfoCard
title="Domain Expiry" title="Domain Expiry"
value={formatDate(domain.expiry_date)} value={formatDate(domain.expiry_date)}
subtitle={formatDays(domain.days_until_expiry)} subtitle={formatDays(domain.days_until_expiry)}
icon={Calendar} icon={Calendar}
className={domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30 ? "text-yellow-600" : ""} className={
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? "text-yellow-600"
: ""
}
/> />
<InfoCard <InfoCard
title="SSL Expiry" title="SSL Expiry"
value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"} value={domain.ssl_valid_to ? formatDate(domain.ssl_valid_to) : "No SSL"}
subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined} subtitle={domain.ssl_valid_to ? formatDays(domain.ssl_days_until) : undefined}
icon={Shield} icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14 ? "text-red-600" : ""} className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/> />
<InfoCard <InfoCard
title="Location" title="Location"
value={domain.host_country || "Unknown"} value={[domain.host_city, domain.host_region, domain.host_country].filter(Boolean).join(", ") || "Unknown"}
subtitle={domain.host_isp} subtitle={domain.host_isp || domain.host_org}
icon={MapPin} icon={MapPin}
/> />
</div> </div>
@@ -273,21 +287,14 @@ export default memo(function DomainDetail({ id }: { id: string }) {
contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }} contentStyle={{ backgroundColor: "hsl(var(--card))", border: "1px solid hsl(var(--border))" }}
/> />
<Bar dataKey="days" radius={[0, 4, 4, 0]}> <Bar dataKey="days" radius={[0, 4, 4, 0]}>
{[ {[{ days: domain.days_until_expiry ?? 0 }, { days: domain.ssl_days_until ?? 0 }].map(
{ days: domain.days_until_expiry ?? 0 }, (entry, index) => (
{ days: domain.ssl_days_until ?? 0 },
].map((entry, index) => (
<Cell <Cell
key={`cell-${index}`} key={`cell-${index}`}
fill={ fill={entry.days <= 14 ? "#ef4444" : entry.days <= 30 ? "#f59e0b" : "#22c55e"}
entry.days <= 14
? "#ef4444"
: entry.days <= 30
? "#f59e0b"
: "#22c55e"
}
/> />
))} )
)}
</Bar> </Bar>
</BarChart> </BarChart>
</ResponsiveContainer> </ResponsiveContainer>
@@ -299,20 +306,26 @@ export default memo(function DomainDetail({ id }: { id: string }) {
{/* Expiry Timeline Chart */} {/* Expiry Timeline Chart */}
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>History Events</CardTitle> <CardTitle>Change Timeline</CardTitle>
<CardDescription>Domain changes and check events over time</CardDescription> <CardDescription>Recent detected domain, DNS, SSL, and registrar changes</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="h-[300px]"> <div className="space-y-3">
<ResponsiveContainer width="100%" height="100%"> {history?.slice(0, 8).map((event) => (
<BarChart data={chartData}> <div key={event.id} className="flex items-start gap-3 rounded-md border p-3">
<CartesianGrid strokeDasharray="3 3" opacity={0.3} /> <Badge variant="outline" className="mt-0.5">
<XAxis dataKey="date" tick={{ fontSize: 12 }} /> {event.change_type}
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} /> </Badge>
<Tooltip /> <div className="min-w-0 flex-1">
<Bar dataKey="count" fill="#3b82f6" name="Events" /> <p className="text-sm font-medium">{event.field_name}</p>
</BarChart> <p className="text-xs text-muted-foreground break-words">
</ResponsiveContainer> {event.old_value || "Unknown"} {"->"} {event.new_value || "Unknown"}
</p>
<p className="text-xs text-muted-foreground mt-1">{formatDate(event.created_at)}</p>
</div>
</div>
))}
{!history?.length && <p className="text-sm text-muted-foreground">No changes recorded yet.</p>}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -361,9 +374,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-muted-foreground">Auto-renew</span> <span className="text-muted-foreground">Auto-renew</span>
<Badge variant={domain.auto_renew ? "default" : "secondary"}> <Badge variant={domain.auto_renew ? "default" : "secondary"}>{domain.auto_renew ? "Yes" : "No"}</Badge>
{domain.auto_renew ? "Yes" : "No"}
</Badge>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -394,7 +405,9 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<h4 className="text-sm font-medium mb-2 flex items-center gap-2"> <h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Server className="h-4 w-4" /> <Server className="h-4 w-4" />
Nameservers Nameservers
<Badge variant="secondary" className="ml-2">{domain.name_servers?.length || 0}</Badge> <Badge variant="secondary" className="ml-2">
{domain.name_servers?.length || 0}
</Badge>
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
{domain.name_servers?.map((ns: string, i: number) => ( {domain.name_servers?.map((ns: string, i: number) => (
@@ -403,9 +416,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<code className="text-sm">{ns}</code> <code className="text-sm">{ns}</code>
</div> </div>
))} ))}
{!domain.name_servers?.length && ( {!domain.name_servers?.length && <p className="text-muted-foreground text-sm">No nameservers found</p>}
<p className="text-muted-foreground text-sm">No nameservers found</p>
)}
</div> </div>
</div> </div>
@@ -415,7 +426,9 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<h4 className="text-sm font-medium mb-2 flex items-center gap-2"> <h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<Mail className="h-4 w-4" /> <Mail className="h-4 w-4" />
Mail Servers (MX) Mail Servers (MX)
<Badge variant="secondary" className="ml-2">{domain.mx_records.length}</Badge> <Badge variant="secondary" className="ml-2">
{domain.mx_records.length}
</Badge>
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
{domain.mx_records?.map((mx: string, i: number) => ( {domain.mx_records?.map((mx: string, i: number) => (
@@ -434,7 +447,9 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<h4 className="text-sm font-medium mb-2 flex items-center gap-2"> <h4 className="text-sm font-medium mb-2 flex items-center gap-2">
<FileText className="h-4 w-4" /> <FileText className="h-4 w-4" />
TXT Records TXT Records
<Badge variant="secondary" className="ml-2">{domain.txt_records.length}</Badge> <Badge variant="secondary" className="ml-2">
{domain.txt_records.length}
</Badge>
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
{domain.txt_records?.map((txt: string, i: number) => ( {domain.txt_records?.map((txt: string, i: number) => (
@@ -451,9 +466,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
{domain.dnssec && ( {domain.dnssec && (
<div> <div>
<h4 className="text-sm font-medium mb-2">DNSSEC</h4> <h4 className="text-sm font-medium mb-2">DNSSEC</h4>
<Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}> <Badge variant={domain.dnssec === "signed" ? "default" : "secondary"}>{domain.dnssec}</Badge>
{domain.dnssec}
</Badge>
</div> </div>
)} )}
</CardContent> </CardContent>
@@ -471,17 +484,17 @@ export default memo(function DomainDetail({ id }: { id: string }) {
<> <>
{/* Validity */} {/* Validity */}
<div className="grid sm:grid-cols-2 gap-4"> <div className="grid sm:grid-cols-2 gap-4">
<InfoCard <InfoCard title="Valid From" value={formatDate(domain.ssl_valid_from)} icon={Calendar} />
title="Valid From"
value={formatDate(domain.ssl_valid_from)}
icon={Calendar}
/>
<InfoCard <InfoCard
title="Valid Until" title="Valid Until"
value={formatDate(domain.ssl_valid_to)} value={formatDate(domain.ssl_valid_to)}
subtitle={formatDays(domain.ssl_days_until)} subtitle={formatDays(domain.ssl_days_until)}
icon={Shield} icon={Shield}
className={domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14 ? "text-red-600" : ""} className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
/> />
</div> </div>
@@ -663,7 +676,9 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</h4> </h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{domain.status.split(", ").map((status: string, i: number) => ( {domain.status.split(", ").map((status: string, i: number) => (
<Badge key={i} variant="secondary">{status}</Badge> <Badge key={i} variant="secondary">
{status}
</Badge>
))} ))}
</div> </div>
</div> </div>
@@ -679,7 +694,7 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
{history?.map((item: any) => ( {history?.map((item: DomainHistory) => (
<div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0"> <div key={item.id} className="flex items-start gap-4 pb-4 border-b last:border-0">
<div className="p-2 bg-muted rounded-lg"> <div className="p-2 bg-muted rounded-lg">
<Clock className="h-4 w-4 text-muted-foreground" /> <Clock className="h-4 w-4 text-muted-foreground" />
@@ -693,18 +708,11 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</div> </div>
</div> </div>
))} ))}
{!history?.length && ( {!history?.length && <p className="text-muted-foreground text-center py-8">No history available</p>}
<p className="text-muted-foreground text-center py-8">No history available</p>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<DomainDialog <DomainDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} domain={domain} isEdit />
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
domain={domain}
isEdit
/>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
+66 -78
View File
@@ -32,8 +32,10 @@ import {
PlayIcon, PlayIcon,
TrendingUp, TrendingUp,
TrendingDown, TrendingDown,
type LucideIcon,
} from "lucide-react" } from "lucide-react"
import { import {
type Heartbeat,
getMonitor, getMonitor,
getMonitorStats, getMonitorStats,
getMonitorHeartbeats, getMonitorHeartbeats,
@@ -41,13 +43,18 @@ import {
pauseMonitor, pauseMonitor,
resumeMonitor, resumeMonitor,
deleteMonitor, deleteMonitor,
updateMonitor,
getMonitorTypeLabel, getMonitorTypeLabel,
formatUptime, formatUptime,
formatPing, formatPing,
} from "@/lib/monitors" } from "@/lib/monitors"
import { formatDate } from "@/lib/domains" import { formatDate } from "@/lib/domains"
import { getStatusPages, createStatusPage } from "@/lib/statuspages" import {
addMonitorToStatusPage,
createStatusPage,
getStatusPageMonitors,
getStatusPages,
removeMonitorFromStatusPage,
} from "@/lib/statuspages"
import { import {
Bar, Bar,
XAxis, XAxis,
@@ -64,6 +71,8 @@ import { Link, navigate } from "@/components/router"
import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog" import { AddMonitorDialog } from "@/components/monitors-table/add-monitor-dialog"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
type HeartbeatRow = Heartbeat & { timestamp?: string }
// Status badge component // Status badge component
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const configs = { const configs = {
@@ -97,7 +106,7 @@ function StatCard({
}: { }: {
title: string title: string
value: string value: string
icon: any icon: LucideIcon
subtitle?: string subtitle?: string
trend?: "up" | "down" | "neutral" trend?: "up" | "down" | "neutral"
className?: string className?: string
@@ -161,6 +170,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
}) })
queryClient.invalidateQueries({ queryKey: ["monitor", id] }) queryClient.invalidateQueries({ queryKey: ["monitor", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-heartbeats", id] }) queryClient.invalidateQueries({ queryKey: ["monitor-heartbeats", id] })
queryClient.invalidateQueries({ queryKey: ["monitor-stats", id] })
}, },
}) })
@@ -191,10 +201,31 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
queryFn: () => getStatusPages(), queryFn: () => getStatusPages(),
}) })
const { data: linkedStatusPageMonitors } = useQuery({
queryKey: ["monitor-status-page-links", id, statusPages?.map((page) => page.id).join(",")],
queryFn: async () => {
if (!statusPages?.length) return []
const links = await Promise.all(
statusPages.map(async (page) =>
(await getStatusPageMonitors(page.id)).map((link) => ({ ...link, status_page_id: page.id }))
)
)
return links.flat().filter((link) => link.monitor_id === id)
},
enabled: Boolean(statusPages?.length),
})
const updateStatusPagesMutation = useMutation({ const updateStatusPagesMutation = useMutation({
mutationFn: (status_pages: string[]) => updateMonitor(id, { status_pages } as any), mutationFn: async ({ pageId, linked }: { pageId: string; linked: boolean }) => {
if (linked) {
await removeMonitorFromStatusPage(pageId, id)
} else {
await addMonitorToStatusPage(pageId, { monitor: id })
}
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["monitor", id] }) queryClient.invalidateQueries({ queryKey: ["monitor-status-page-links", id] })
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
toast({ title: "Status pages updated" }) toast({ title: "Status pages updated" })
}, },
}) })
@@ -230,7 +261,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
"30d": 30 * 24 * 60 * 60 * 1000, "30d": 30 * 24 * 60 * 60 * 1000,
} }
const cutoff = now - (ranges[timeRange] || ranges["24h"]) const cutoff = now - (ranges[timeRange] || ranges["24h"])
return heartbeats.filter((h: any) => { return heartbeats.filter((h: HeartbeatRow) => {
const t = new Date(h.time || h.timestamp).getTime() const t = new Date(h.time || h.timestamp).getTime()
return t >= cutoff return t >= cutoff
}) })
@@ -242,7 +273,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
return filteredHeartbeats return filteredHeartbeats
.slice() .slice()
.reverse() .reverse()
.map((h: any) => ({ .map((h: HeartbeatRow) => ({
time: new Date(h.time || h.timestamp).toLocaleTimeString(), time: new Date(h.time || h.timestamp).toLocaleTimeString(),
responseTime: h.ping || 0, responseTime: h.ping || 0,
status: h.status === "up" ? 1 : 0, status: h.status === "up" ? 1 : 0,
@@ -253,8 +284,8 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
const uptimeStats = useMemo(() => { const uptimeStats = useMemo(() => {
if (!heartbeats || !Array.isArray(heartbeats) || heartbeats.length === 0) return null if (!heartbeats || !Array.isArray(heartbeats) || heartbeats.length === 0) return null
const total = heartbeats.length const total = heartbeats.length
const up = heartbeats.filter((h: any) => h.status === "up").length const up = heartbeats.filter((h: HeartbeatRow) => h.status === "up").length
const avgResponse = heartbeats.reduce((sum: number, h: any) => sum + (h.ping || 0), 0) / total const avgResponse = heartbeats.reduce((sum: number, h: HeartbeatRow) => sum + (h.ping || 0), 0) / total
return { return {
uptime: ((up / total) * 100).toFixed(2), uptime: ((up / total) * 100).toFixed(2),
avgResponse: avgResponse.toFixed(0), avgResponse: avgResponse.toFixed(0),
@@ -300,10 +331,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
)} )}
> >
<Globe <Globe
className={cn( className={cn("h-6 w-6", isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500")}
"h-6 w-6",
isUp ? "text-green-500" : isPaused ? "text-gray-500" : "text-red-500"
)}
/> />
</div> </div>
<div> <div>
@@ -311,13 +339,9 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<div className="flex items-center gap-2 mt-1"> <div className="flex items-center gap-2 mt-1">
<StatusBadge status={monitor.status} /> <StatusBadge status={monitor.status} />
<Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge> <Badge variant="secondary">{getMonitorTypeLabel(monitor.type)}</Badge>
{monitor.interval && ( {monitor.interval && <Badge variant="outline">{monitor.interval}s interval</Badge>}
<Badge variant="outline">{monitor.interval}s interval</Badge>
)}
</div> </div>
{monitor.url && ( {monitor.url && <p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>}
<p className="text-sm text-muted-foreground mt-1">{monitor.url}</p>
)}
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
@@ -371,25 +395,19 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{/* Summary Bar */} {/* Summary Bar */}
<div className="grid sm:grid-cols-4 gap-4"> <div className="grid sm:grid-cols-4 gap-4">
<StatCard <StatCard title="Uptime (24h)" value={formatUptime(stats?.uptime_percent_24h ?? 0)} icon={Activity} />
title="Uptime (24h)" <StatCard title="Uptime (7d)" value={formatUptime(stats?.uptime_percent_7d ?? 0)} icon={Activity} />
value={formatUptime(stats?.uptime_24h ? (stats.uptime_24h.up / stats.uptime_24h.total) * 100 : 0)} <StatCard title="Uptime (30d)" value={formatUptime(stats?.uptime_percent_30d ?? 0)} icon={Activity} />
icon={Activity}
/>
<StatCard
title="Uptime (7d)"
value={formatUptime(stats?.uptime_7d ? (stats.uptime_7d.up / stats.uptime_7d.total) * 100 : 0)}
icon={Activity}
/>
<StatCard
title="Uptime (30d)"
value={formatUptime(stats?.uptime_30d ? (stats.uptime_30d.up / stats.uptime_30d.total) * 100 : 0)}
icon={Activity}
/>
<StatCard <StatCard
title="Avg Response" title="Avg Response"
value={uptimeStats ? `${uptimeStats.avgResponse}ms` : "-"} value={
subtitle={`${uptimeStats?.totalChecks || 0} checks`} stats?.avg_ping_24h
? `${Math.round(stats.avg_ping_24h)}ms`
: uptimeStats
? `${uptimeStats.avgResponse}ms`
: "-"
}
subtitle={`${stats?.uptime_24h?.total ?? uptimeStats?.totalChecks ?? 0} checks`}
icon={Clock} icon={Clock}
/> />
</div> </div>
@@ -429,11 +447,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</defs> </defs>
<CartesianGrid strokeDasharray="3 3" opacity={0.3} /> <CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" tick={{ fontSize: 12 }} /> <XAxis dataKey="time" tick={{ fontSize: 12 }} />
<YAxis <YAxis yAxisId="left" tick={{ fontSize: 12 }} unit="ms" />
yAxisId="left"
tick={{ fontSize: 12 }}
unit="ms"
/>
<YAxis <YAxis
yAxisId="right" yAxisId="right"
orientation="right" orientation="right"
@@ -457,17 +471,9 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
fill="url(#colorResponse)" fill="url(#colorResponse)"
name="Response Time (ms)" name="Response Time (ms)"
/> />
<Bar <Bar yAxisId="right" dataKey="status" barSize={4} name="Status">
yAxisId="right"
dataKey="status"
barSize={4}
name="Status"
>
{chartData.map((entry, index) => ( {chartData.map((entry, index) => (
<Cell <Cell key={`cell-${index}`} fill={entry.status === 1 ? "#22c55e" : "#ef4444"} />
key={`cell-${index}`}
fill={entry.status === 1 ? "#22c55e" : "#ef4444"}
/>
))} ))}
</Bar> </Bar>
</ComposedChart> </ComposedChart>
@@ -521,7 +527,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
{statusPages && statusPages.length > 0 ? ( {statusPages && statusPages.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{statusPages.map((page) => { {statusPages.map((page) => {
const isLinked = monitor.status_pages?.includes(page.id) || false const isLinked = linkedStatusPageMonitors?.some((link) => link.status_page_id === page.id) || false
return ( return (
<div key={page.id} className="flex items-center justify-between py-1"> <div key={page.id} className="flex items-center justify-between py-1">
<span className="text-sm">{page.name}</span> <span className="text-sm">{page.name}</span>
@@ -529,14 +535,10 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
variant={isLinked ? "default" : "outline"} variant={isLinked ? "default" : "outline"}
size="sm" size="sm"
onClick={() => { onClick={() => {
const current = monitor.status_pages || []
const next = isLinked
? current.filter((sp) => sp !== page.id)
: [...current, page.id]
updateStatusPagesMutation.mutate({ updateStatusPagesMutation.mutate({
id: monitor.id, pageId: page.id,
status_pages: next, linked: isLinked,
} as any) })
}} }}
> >
{isLinked ? "Linked" : "Link"} {isLinked ? "Linked" : "Link"}
@@ -548,12 +550,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
) : ( ) : (
<p className="text-sm text-muted-foreground">No status pages yet.</p> <p className="text-sm text-muted-foreground">No status pages yet.</p>
)} )}
<Button <Button variant="outline" size="sm" className="w-full" onClick={() => setIsCreateStatusPageOpen(true)}>
variant="outline"
size="sm"
className="w-full"
onClick={() => setIsCreateStatusPageOpen(true)}
>
Create Status Page Create Status Page
</Button> </Button>
</CardContent> </CardContent>
@@ -576,13 +573,11 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{heartbeats?.slice(0, 50).map((hb: any) => ( {heartbeats?.slice(0, 50).map((hb: HeartbeatRow) => (
<TableRow key={hb.id}> <TableRow key={hb.id}>
<TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell> <TableCell>{formatDate(hb.time || hb.timestamp)}</TableCell>
<TableCell> <TableCell>
<Badge variant={hb.status === "up" ? "default" : "destructive"}> <Badge variant={hb.status === "up" ? "default" : "destructive"}>{hb.status}</Badge>
{hb.status}
</Badge>
</TableCell> </TableCell>
<TableCell>{formatPing(hb.ping)}</TableCell> <TableCell>{formatPing(hb.ping)}</TableCell>
<TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell> <TableCell className="max-w-xs truncate">{hb.msg || "-"}</TableCell>
@@ -606,9 +601,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Create Status Page</AlertDialogTitle> <AlertDialogTitle>Create Status Page</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>Create a public status page for this monitor.</AlertDialogDescription>
Create a public status page for this monitor.
</AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-4">
<div className="grid gap-2"> <div className="grid gap-2">
@@ -642,12 +635,7 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
)} )}
<AddMonitorDialog <AddMonitorDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} monitor={monitor} isEdit />
open={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
monitor={monitor}
isEdit
/>
<AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}> <AlertDialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<AlertDialogContent> <AlertDialogContent>
@@ -17,14 +17,12 @@ import {
import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual" import { useVirtualizer, type VirtualItem } from "@tanstack/react-virtual"
import { import {
ArrowDownIcon, ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon, ArrowUpIcon,
EyeIcon, EyeIcon,
FilterIcon, FilterIcon,
LayoutGridIcon, LayoutGridIcon,
LayoutListIcon, LayoutListIcon,
PauseIcon,
PlusIcon,
ServerIcon,
Settings2Icon, Settings2Icon,
XIcon, XIcon,
} from "lucide-react" } from "lucide-react"
@@ -34,6 +32,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuCheckboxItem, DropdownMenuCheckboxItem,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuRadioGroup, DropdownMenuRadioGroup,
DropdownMenuRadioItem, DropdownMenuRadioItem,
@@ -47,7 +46,6 @@ import { $downSystems, $pausedSystems, $systems, $upSystems } from "@/lib/stores
import { cn, runOnce, useBrowserStorage } from "@/lib/utils" import { cn, runOnce, useBrowserStorage } from "@/lib/utils"
import type { SystemRecord } from "@/types" import type { SystemRecord } from "@/types"
import AlertButton from "../alerts/alert-button" import AlertButton from "../alerts/alert-button"
import { AddSystemDialog } from "../add-system"
import { $router, Link } from "../router" import { $router, Link } from "../router"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card"
import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns" import { SystemsTableColumns, ActionsButton, IndicatorDot } from "./systems-table-columns"
@@ -72,7 +70,6 @@ export default function SystemsTable() {
) )
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]) const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {}) const [columnVisibility, setColumnVisibility] = useBrowserStorage<VisibilityState>("cols", {})
const [addSystemDialogOpen, setAddSystemDialogOpen] = useState(false)
const locale = i18n.locale const locale = i18n.locale
@@ -137,45 +134,18 @@ export default function SystemsTable() {
const CardHead = useMemo(() => { const CardHead = useMemo(() => {
return ( return (
<CardHeader className="p-0 pb-5"> <CardHeader className="p-0 mb-3 sm:mb-4">
<div className="flex flex-col gap-4"> <div className="grid md:flex gap-x-5 gap-y-3 w-full items-end">
{/* Title row */} <div className="px-2 sm:px-1">
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4"> <CardTitle className="mb-2">
<div className="flex-1">
<CardTitle className="text-xl mb-2 flex items-center gap-2">
<ServerIcon className="h-5 w-5 text-primary" />
<Trans>All Systems</Trans> <Trans>All Systems</Trans>
</CardTitle> </CardTitle>
<CardDescription className="flex flex-wrap items-center gap-x-2 gap-y-1"> <CardDescription className="flex">
<Trans>Click on a system to view more information.</Trans> <Trans>Click on a system to view more information.</Trans>
<span className="text-xs text-muted-foreground">
({upSystemsLength} <ArrowUpIcon className="inline h-3 w-3 text-green-500" />
{downSystemsLength > 0 && (
<>
{" "}
{downSystemsLength}{" "}
<ArrowDownIcon className="inline h-3 w-3 text-red-500" />
</>
)}
{pausedSystemsLength > 0 && (
<>
{" "}
{pausedSystemsLength}{" "}
<PauseIcon className="inline h-3 w-3 text-gray-400" />
</>
)}
/ {data.length})
</span>
</CardDescription> </CardDescription>
</div> </div>
<Button variant="outline" onClick={() => setAddSystemDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div>
{/* Filter row */} <div className="flex gap-2 ms-auto w-full md:w-80">
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
placeholder={t`Filter...`} placeholder={t`Filter...`}
@@ -203,61 +173,116 @@ export default function SystemsTable() {
<Trans>View</Trans> <Trans>View</Trans>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-48"> <DropdownMenuContent align="end" className="h-72 md:h-auto min-w-48 md:min-w-auto overflow-y-auto">
{/* Layout */} <div className="grid grid-cols-1 md:grid-cols-4 divide-y md:divide-s md:divide-y-0">
<DropdownMenuLabel className="flex items-center gap-2"> <div className="border-r">
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<LayoutGridIcon className="size-4" /> <LayoutGridIcon className="size-4" />
<Trans>Layout</Trans> <Trans>Layout</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuRadioGroup value={viewMode} onValueChange={(view) => setViewMode(view as ViewMode)}> <DropdownMenuSeparator />
<DropdownMenuRadioItem value="table" className="gap-2"> <DropdownMenuRadioGroup
className="px-1 pb-1"
value={viewMode}
onValueChange={(view) => setViewMode(view as ViewMode)}
>
<DropdownMenuRadioItem value="table" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutListIcon className="size-4" /> <LayoutListIcon className="size-4" />
<Trans>Table</Trans> <Trans>Table</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="grid" className="gap-2"> <DropdownMenuRadioItem value="grid" onSelect={(e) => e.preventDefault()} className="gap-2">
<LayoutGridIcon className="size-4" /> <LayoutGridIcon className="size-4" />
<Trans>Grid</Trans> <Trans>Grid</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
<DropdownMenuSeparator /> </div>
{/* Status */} <div className="border-r">
<DropdownMenuLabel className="flex items-center gap-2"> <DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<FilterIcon className="size-4" /> <FilterIcon className="size-4" />
<Trans>Status</Trans> <Trans>Status</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuRadioGroup value={statusFilter} onValueChange={(value) => setStatusFilter(value as StatusFilter)}> <DropdownMenuSeparator />
<DropdownMenuRadioItem value="all"> <DropdownMenuRadioGroup
<Trans>All ({data.length})</Trans> className="px-1 pb-1"
value={statusFilter}
onValueChange={(value) => setStatusFilter(value as StatusFilter)}
>
<DropdownMenuRadioItem value="all" onSelect={(e) => e.preventDefault()}>
<Trans>All Systems</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="up"> <DropdownMenuRadioItem value="up" onSelect={(e) => e.preventDefault()}>
<Trans>Up ({upSystemsLength})</Trans> <Trans>Up ({upSystemsLength})</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="down"> <DropdownMenuRadioItem value="down" onSelect={(e) => e.preventDefault()}>
<Trans>Down ({downSystemsLength})</Trans> <Trans>Down ({downSystemsLength})</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="paused"> <DropdownMenuRadioItem value="paused" onSelect={(e) => e.preventDefault()}>
<Trans>Paused ({pausedSystemsLength})</Trans> <Trans>Paused ({pausedSystemsLength})</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
<DropdownMenuSeparator /> </div>
{/* Columns */} <div className="border-r">
<DropdownMenuLabel className="flex items-center gap-2"> <DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" /> <ArrowUpDownIcon className="size-4" />
<Trans>Columns</Trans> <Trans>Sort By</Trans>
</DropdownMenuLabel> </DropdownMenuLabel>
{columns.map((column) => ( <DropdownMenuSeparator />
<div className="px-1 pb-1">
{columns.map((column) => {
if (!column.getCanSort()) return null
let Icon = <span className="w-6"></span>
// if current sort column, show sort direction
if (sorting[0]?.id === column.id) {
if (sorting[0]?.desc) {
Icon = <ArrowUpIcon className="me-2 size-4" />
} else {
Icon = <ArrowDownIcon className="me-2 size-4" />
}
}
return (
<DropdownMenuItem
onSelect={(e) => {
e.preventDefault()
setSorting([{ id: column.id, desc: sorting[0]?.id === column.id && !sorting[0]?.desc }])
}}
key={column.id}
>
{Icon}
{/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuItem>
)
})}
</div>
</div>
<div>
<DropdownMenuLabel className="pt-2 px-3.5 flex items-center gap-2">
<EyeIcon className="size-4" />
<Trans>Visible Fields</Trans>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<div className="px-1.5 pb-1">
{columns
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem <DropdownMenuCheckboxItem
key={column.id} key={column.id}
onSelect={(e) => e.preventDefault()}
checked={column.getIsVisible()} checked={column.getIsVisible()}
onCheckedChange={(value) => column.toggleVisibility(!!value)} onCheckedChange={(value) => column.toggleVisibility(!!value)}
onSelect={(e) => e.preventDefault()}
className="gap-2"
> >
{column.columnDef.header?.toString() ?? column.id} {/* @ts-ignore */}
{column.columnDef.name()}
</DropdownMenuCheckboxItem> </DropdownMenuCheckboxItem>
))} )
})}
</div>
</div>
</div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -273,13 +298,10 @@ export default function SystemsTable() {
upSystemsLength, upSystemsLength,
downSystemsLength, downSystemsLength,
pausedSystemsLength, pausedSystemsLength,
data.length,
filter, filter,
setAddSystemDialogOpen,
]) ])
return ( return (
<>
<Card className="w-full px-3 py-5 sm:py-6 sm:px-6"> <Card className="w-full px-3 py-5 sm:py-6 sm:px-6">
{CardHead} {CardHead}
{viewMode === "table" ? ( {viewMode === "table" ? (
@@ -302,8 +324,6 @@ export default function SystemsTable() {
</div> </div>
)} )}
</Card> </Card>
<AddSystemDialog open={addSystemDialogOpen} setOpen={setAddSystemDialogOpen} />
</>
) )
} }
+42 -2
View File
@@ -29,7 +29,13 @@ export interface Domain {
ssl_signature_algo?: string ssl_signature_algo?: string
ssl_fingerprint?: string ssl_fingerprint?: string
host_country?: string host_country?: string
host_region?: string
host_city?: string
host_isp?: string host_isp?: string
host_org?: string
host_as?: string
host_lat?: number
host_lon?: number
purchase_price?: number purchase_price?: number
current_value?: number current_value?: number
renewal_cost?: number renewal_cost?: number
@@ -43,6 +49,17 @@ export interface Domain {
registrant_state?: string registrant_state?: string
abuse_email?: string abuse_email?: string
abuse_phone?: string abuse_phone?: string
monitor_type?: "expiry" | "watchlist" | "portfolio"
ssl_alert_days?: number
notify_on_expiry?: boolean
notify_on_ssl_expiry?: boolean
notify_on_dns_change?: boolean
notify_on_registrar_change?: boolean
notify_on_value_change?: boolean
value_change_threshold?: number
quiet_hours_enabled?: boolean
quiet_hours_start?: string
quiet_hours_end?: string
tags?: string[] tags?: string[]
notes?: string notes?: string
favicon_url?: string favicon_url?: string
@@ -109,6 +126,17 @@ export interface CreateDomainRequest {
auto_renew?: boolean auto_renew?: boolean
alert_days_before?: number alert_days_before?: number
ssl_alert_enabled?: boolean ssl_alert_enabled?: boolean
ssl_alert_days?: number
monitor_type?: "expiry" | "watchlist" | "portfolio"
notify_on_expiry?: boolean
notify_on_ssl_expiry?: boolean
notify_on_dns_change?: boolean
notify_on_registrar_change?: boolean
notify_on_value_change?: boolean
value_change_threshold?: number
quiet_hours_enabled?: boolean
quiet_hours_start?: string
quiet_hours_end?: string
} }
export interface UpdateDomainRequest { export interface UpdateDomainRequest {
@@ -121,6 +149,17 @@ export interface UpdateDomainRequest {
active?: boolean active?: boolean
alert_days_before?: number alert_days_before?: number
ssl_alert_enabled?: boolean ssl_alert_enabled?: boolean
ssl_alert_days?: number
monitor_type?: "expiry" | "watchlist" | "portfolio"
notify_on_expiry?: boolean
notify_on_ssl_expiry?: boolean
notify_on_dns_change?: boolean
notify_on_registrar_change?: boolean
notify_on_value_change?: boolean
value_change_threshold?: number
quiet_hours_enabled?: boolean
quiet_hours_start?: string
quiet_hours_end?: string
} }
export interface DomainLookupResult { export interface DomainLookupResult {
@@ -230,7 +269,7 @@ export async function deleteDomain(id: string): Promise<void> {
} }
} }
export async function refreshDomain(id: string): Promise<void> { export async function refreshDomain(id: string): Promise<Domain> {
const response = await fetch(`${API_BASE}/${id}/refresh`, { const response = await fetch(`${API_BASE}/${id}/refresh`, {
method: "POST", method: "POST",
headers: { headers: {
@@ -240,6 +279,7 @@ export async function refreshDomain(id: string): Promise<void> {
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to refresh domain: ${response.statusText}`) throw new Error(`Failed to refresh domain: ${response.statusText}`)
} }
return response.json()
} }
export async function getDomainHistory(id: string): Promise<DomainHistory[]> { export async function getDomainHistory(id: string): Promise<DomainHistory[]> {
@@ -331,7 +371,7 @@ export function cleanDomain(domain: string): string {
return domain return domain
.replace(/^https?:\/\//, "") .replace(/^https?:\/\//, "")
.replace(/^www\./, "") .replace(/^www\./, "")
.replace(/[\/\?#:].*$/, "") .replace(/[/?#:].*$/, "")
.toLowerCase() .toLowerCase()
.trim() .trim()
} }
+40 -13
View File
@@ -4,7 +4,14 @@ export interface Incident {
id: string id: string
title: string title: string
description?: string description?: string
type: "monitor_down" | "monitor_up" | "domain_expiring" | "domain_expired" | "ssl_expiring" | "system_offline" | "system_online" type:
| "monitor_down"
| "monitor_up"
| "domain_expiring"
| "domain_expired"
| "ssl_expiring"
| "system_offline"
| "system_online"
severity: "critical" | "high" | "medium" | "low" severity: "critical" | "high" | "medium" | "low"
status: "open" | "acknowledged" | "resolved" | "closed" status: "open" | "acknowledged" | "resolved" | "closed"
monitor?: string monitor?: string
@@ -45,6 +52,12 @@ export interface CalendarEvent {
date: string date: string
type: "domain_expiry" | "ssl_expiry" | "incident" type: "domain_expiry" | "ssl_expiry" | "incident"
color: string color: string
link?: string
entity_id?: string
entity_name?: string
domain_id?: string
incident_id?: string
days_until?: number
} }
export interface CreateIncidentRequest { export interface CreateIncidentRequest {
@@ -156,8 +169,12 @@ export async function getIncidentStats(): Promise<IncidentStats> {
return response.json() return response.json()
} }
export async function getCalendarEvents(): Promise<CalendarEvent[]> { export async function getCalendarEvents(range?: { from?: string; to?: string }): Promise<CalendarEvent[]> {
const response = await fetch(`${API_BASE}/calendar`, { const params = new URLSearchParams()
if (range?.from) params.set("from", range.from)
if (range?.to) params.set("to", range.to)
const query = params.toString()
const response = await fetch(`${API_BASE}/calendar${query ? `?${query}` : ""}`, {
headers: { Authorization: `Bearer ${pb.authStore.token}` }, headers: { Authorization: `Bearer ${pb.authStore.token}` },
}) })
if (!response.ok) throw new Error(`Failed to fetch calendar: ${response.statusText}`) if (!response.ok) throw new Error(`Failed to fetch calendar: ${response.statusText}`)
@@ -166,21 +183,31 @@ export async function getCalendarEvents(): Promise<CalendarEvent[]> {
export function getSeverityColor(severity: string): string { export function getSeverityColor(severity: string): string {
switch (severity) { switch (severity) {
case "critical": return "bg-red-600" case "critical":
case "high": return "bg-orange-500" return "bg-red-600"
case "medium": return "bg-yellow-500" case "high":
case "low": return "bg-blue-500" return "bg-orange-500"
default: return "bg-gray-500" case "medium":
return "bg-yellow-500"
case "low":
return "bg-blue-500"
default:
return "bg-gray-500"
} }
} }
export function getStatusColor(status: string): string { export function getStatusColor(status: string): string {
switch (status) { switch (status) {
case "open": return "bg-red-500" case "open":
case "acknowledged": return "bg-yellow-500" return "bg-red-500"
case "resolved": return "bg-green-500" case "acknowledged":
case "closed": return "bg-gray-500" return "bg-yellow-500"
default: return "bg-gray-500" case "resolved":
return "bg-green-500"
case "closed":
return "bg-gray-500"
default:
return "bg-gray-500"
} }
} }
+14 -8
View File
@@ -192,6 +192,8 @@ export interface CheckResult {
status: MonitorStatus status: MonitorStatus
ping: number ping: number
msg: string msg: string
heartbeat_id?: string
time?: string
} }
// API Functions // API Functions
@@ -200,18 +202,18 @@ export async function listMonitors(): Promise<Monitor[]> {
return response.monitors return response.monitors
} }
export async function getMonitor(id: string): Promise<Monitor> { export function getMonitor(id: string): Promise<Monitor> {
return pb.send<Monitor>(`/api/beszel/monitors/${id}`, {}) return pb.send<Monitor>(`/api/beszel/monitors/${id}`, {})
} }
export async function createMonitor(data: CreateMonitorRequest): Promise<Monitor> { export function createMonitor(data: CreateMonitorRequest): Promise<Monitor> {
return pb.send<Monitor>("/api/beszel/monitors", { return pb.send<Monitor>("/api/beszel/monitors", {
method: "POST", method: "POST",
body: JSON.stringify(data), body: JSON.stringify(data),
}) })
} }
export async function updateMonitor(id: string, data: UpdateMonitorRequest): Promise<Monitor> { export function updateMonitor(id: string, data: UpdateMonitorRequest): Promise<Monitor> {
return pb.send<Monitor>(`/api/beszel/monitors/${id}`, { return pb.send<Monitor>(`/api/beszel/monitors/${id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify(data), body: JSON.stringify(data),
@@ -224,33 +226,37 @@ export async function deleteMonitor(id: string): Promise<void> {
}) })
} }
export async function manualCheck(id: string): Promise<CheckResult> { export function manualCheck(id: string): Promise<CheckResult> {
return pb.send<CheckResult>(`/api/beszel/monitors/${id}/check`, { return pb.send<CheckResult>(`/api/beszel/monitors/${id}/check`, {
method: "POST", method: "POST",
}) })
} }
export async function pauseMonitor(id: string): Promise<Monitor> { export function pauseMonitor(id: string): Promise<Monitor> {
return pb.send<Monitor>(`/api/beszel/monitors/${id}/pause`, { return pb.send<Monitor>(`/api/beszel/monitors/${id}/pause`, {
method: "POST", method: "POST",
}) })
} }
export async function resumeMonitor(id: string): Promise<Monitor> { export function resumeMonitor(id: string): Promise<Monitor> {
return pb.send<Monitor>(`/api/beszel/monitors/${id}/resume`, { return pb.send<Monitor>(`/api/beszel/monitors/${id}/resume`, {
method: "POST", method: "POST",
}) })
} }
export async function getMonitorStats(id: string): Promise<{ export function getMonitorStats(id: string): Promise<{
uptime_24h: UptimeStats uptime_24h: UptimeStats
uptime_7d: UptimeStats uptime_7d: UptimeStats
uptime_30d: UptimeStats uptime_30d: UptimeStats
uptime_percent_24h: number
uptime_percent_7d: number
uptime_percent_30d: number
avg_ping_24h: number
}> { }> {
return pb.send(`/api/beszel/monitors/${id}/stats`, {}) return pb.send(`/api/beszel/monitors/${id}/stats`, {})
} }
export async function getMonitorHeartbeats(id: string): Promise<{ heartbeats: Heartbeat[] }> { export function getMonitorHeartbeats(id: string): Promise<{ heartbeats: Heartbeat[] }> {
return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {}) return pb.send(`/api/beszel/monitors/${id}/heartbeats`, {})
} }