feat(site): implement subdomain discovery and enhanced monitoring dashboard

This commit introduces a comprehensive subdomain discovery system and significantly upgrades the monitoring and domain management user interfaces.

Key changes include:
- **Subdomain Discovery**: Added a new service in the hub that performs advanced subdomain discovery using DNS brute forcing, Certificate Transparency (CT) log searches, pattern enumeration, and HTTP probing.
- **Enhanced Domain Management**:
    - Added API endpoints for retrieving, discovering, and deleting subdomains.
    - Implemented a new `SubdomainList` component in the UI to manage discovered subdomains.
    - Improved WHOIS lookup robustness by supporting a wider range of registry field variations.
- **Advanced Monitoring UI**:
    - Introduced `GroupedMonitorsTable` to organize monitors by root domain and their respective subdomains.
    - Added visual uptime timelines (heartbeat dots) and response time statistics (Avg, Min, Max, P95, P99) to the monitor detail view.
    - Implemented "Uptime Pills" for high-visibility status indicators in the monitors table.
- **Status Page Management**: Replaced the static status pages table with a full `StatusPageManager` capable of managing status pages and incidents.
- **Refactoring & Cleanup**:
    - Cleaned up `.gitignore` and removed unused reference submodules.
    - Improved domain extraction and grouping logic in the frontend.
    - Enhanced the `SystemsTable` with better sorting and layout.
This commit is contained in:
Tomas Dvorak
2026-05-05 16:14:45 +02:00
parent 21657abe38
commit 7ea9a069f9
22 changed files with 248666 additions and 179 deletions
+7 -8
View File
@@ -38,16 +38,15 @@ __debug_*
*timestamp* *timestamp*
.playwright-cli/ .playwright-cli/
# Reference code (external projects) # Reference code (external projects) - cleaned up
reference/ reference/
# Graphify output - only keep json, md, html in root
graphify-out/*/
graphify-out/*.svg
!graphify-out/*.json
!graphify-out/*.md
!graphify-out/*.html
# Environment # Environment
.env .env
.env.local .env.local
graphify-out/*
!graphify-out/*.svg
!graphify-out/*.json
!graphify-out/*.md
!graphify-out/*.html
-7
View File
@@ -1,7 +0,0 @@
# Security Policy
## Reporting a Vulnerability
If you find a vulnerability in the latest version, please [submit a private advisory](https://github.com/henrygd/beszel/security/advisories/new).
If it's low severity (use best judgement) you may open an issue instead of an advisory.
+246046
View File
File diff suppressed because it is too large Load Diff

After

Width:  |  Height:  |  Size: 14 MiB

+106
View File
@@ -43,6 +43,11 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api.GET("/{id}/stats", h.getDomainStats) api.GET("/{id}/stats", h.getDomainStats)
api.POST("/{id}/pause", h.pauseDomain) api.POST("/{id}/pause", h.pauseDomain)
api.POST("/{id}/resume", h.resumeDomain) api.POST("/{id}/resume", h.resumeDomain)
api.GET("/{id}/subdomains", h.getDomainSubdomains)
api.POST("/{id}/discover-subdomains", h.discoverSubdomains)
// Subdomain routes
api.DELETE("/subdomains/{subdomainId}", h.deleteSubdomain)
} }
// listDomains lists all domains for the authenticated user // listDomains lists all domains for the authenticated user
@@ -705,3 +710,104 @@ func cleanDomain(domain string) string {
} }
return strings.ToLower(strings.TrimSpace(domain)) return strings.ToLower(strings.TrimSpace(domain))
} }
// getDomainSubdomains returns all subdomains for a domain
func (h *APIHandler) getDomainSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
records, err := h.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainID}),
)
if err != nil {
return e.InternalServerError("failed to fetch subdomains", err)
}
subdomains := make([]map[string]interface{}, 0, len(records))
for _, record := range records {
subdomains = append(subdomains, map[string]interface{}{
"id": record.Id,
"domain": record.GetString("domain"),
"subdomain_name": record.GetString("subdomain_name"),
"full_domain": record.GetString("full_domain"),
"status": record.GetString("status"),
"ip_addresses": record.GetString("ip_addresses"),
"http_status": record.GetInt("http_status"),
"server_header": record.GetString("server_header"),
"discovery_source": record.GetString("discovery_source"),
"last_checked": record.GetDateTime("last_checked").Time(),
"created": record.GetDateTime("created").Time(),
"updated": record.GetDateTime("updated").Time(),
})
}
return e.JSON(http.StatusOK, subdomains)
}
// discoverSubdomains triggers subdomain discovery for a domain
func (h *APIHandler) discoverSubdomains(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
domainID := e.Request.PathValue("id")
// Verify domain ownership
domain, err := h.app.FindRecordById("domains", domainID)
if err != nil {
return e.NotFoundError("domain not found", err)
}
if domain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
// Trigger discovery asynchronously
go func() {
domainName := domain.GetString("domain_name")
userID := authRecord.Id
h.scheduler.discoverSubdomainsEnhanced(domain, domainName, userID)
}()
return e.JSON(http.StatusOK, map[string]string{"status": "discovery_started"})
}
// deleteSubdomain deletes a subdomain
func (h *APIHandler) deleteSubdomain(e *core.RequestEvent) error {
authRecord := e.Auth
if authRecord == nil {
return e.UnauthorizedError("unauthorized", nil)
}
subdomainID := e.Request.PathValue("subdomainId")
// Get subdomain
subdomain, err := h.app.FindRecordById("subdomains", subdomainID)
if err != nil {
return e.NotFoundError("subdomain not found", err)
}
// Verify ownership
if subdomain.GetString("user") != authRecord.Id {
return e.ForbiddenError("not authorized", nil)
}
if err := h.app.Delete(subdomain); err != nil {
return e.InternalServerError("failed to delete subdomain", err)
}
return e.NoContent(http.StatusNoContent)
}
+18 -46
View File
@@ -334,66 +334,38 @@ func (s *Scheduler) checkDomain(record *core.Record) error {
} }
} }
// Discover and save subdomains // Discover and save subdomains using enhanced discovery
s.discoverSubdomains(record, domainName, userID) s.discoverSubdomainsEnhanced(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 return nil
} }
// discoverSubdomains discovers and saves subdomains for a domain // discoverSubdomains discovers and saves subdomains for a domain (legacy method)
func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID string) { func (s *Scheduler) discoverSubdomains(record *core.Record, domainName, userID string) {
// Common subdomains to check // Deprecated: Use discoverSubdomainsEnhanced instead
commonSubdomains := []string{ s.discoverSubdomainsEnhanced(record, domainName, userID)
"www", "mail", "ftp", "api", "blog", "shop", "admin", "app", "cdn", }
"static", "dev", "staging", "test", "demo", "docs", "support", "help",
"status", "monitor", "grafana", "prometheus", "db", "cache", "redis",
"queue", "worker", "backup", "media", "assets", "download", "upload",
"git", "gitlab", "github", "jenkins", "ci", "cd", "vpn", "ssh",
"smtp", "imap", "mx", "webmail", "email", "analytics", "stats",
"search", "login", "auth", "sso", "oauth", "account", "user",
}
// Get existing subdomains to avoid duplicates // discoverSubdomainsEnhanced performs enhanced subdomain discovery
existing, _ := s.app.FindAllRecords("subdomains", func (s *Scheduler) discoverSubdomainsEnhanced(record *core.Record, domainName, userID string) {
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": record.Id}), discovery := NewSubdomainDiscovery(s.app)
)
existingMap := make(map[string]bool)
for _, sub := range existing {
existingMap[sub.GetString("subdomain_name")] = true
}
collection, err := s.app.FindCollectionByNameOrId("subdomains") ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
results, err := discovery.Discover(ctx, domainName)
if err != nil { if err != nil {
log.Printf("[domain-scheduler] Subdomain discovery failed for %s: %v", domainName, err)
return return
} }
for _, sub := range commonSubdomains { if err := discovery.SaveSubdomains(record, results, userID); err != nil {
if existingMap[sub] { log.Printf("[domain-scheduler] Failed to save subdomains for %s: %v", domainName, err)
continue return
} }
fullDomain := sub + "." + domainName log.Printf("[domain-scheduler] Discovered %d subdomains for %s", len(results), domainName)
ips, err := net.LookupHost(fullDomain)
if err != nil || len(ips) == 0 {
continue
}
// Found a valid subdomain
subRecord := core.NewRecord(collection)
subRecord.Set("domain", record.Id)
subRecord.Set("subdomain_name", sub)
subRecord.Set("status", "active")
subRecord.Set("ip_addresses", strings.Join(ips, ","))
subRecord.Set("last_checked", time.Now())
subRecord.Set("user", userID)
if err := s.app.Save(subRecord); err != nil {
log.Printf("[domain-scheduler] Failed to save subdomain %s: %v", fullDomain, err)
} else {
log.Printf("[domain-scheduler] Discovered subdomain: %s", fullDomain)
}
}
} }
// trackChanges compares old and new data and returns history entries // trackChanges compares old and new data and returns history entries
+424
View File
@@ -0,0 +1,424 @@
package domains
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
"strings"
"sync"
"time"
"github.com/pocketbase/dbx"
"github.com/pocketbase/pocketbase/core"
)
// SubdomainDiscovery handles advanced subdomain discovery
type SubdomainDiscovery struct {
app core.App
client *http.Client
wordlist []string
}
// NewSubdomainDiscovery creates a new subdomain discovery service
func NewSubdomainDiscovery(app core.App) *SubdomainDiscovery {
return &SubdomainDiscovery{
app: app,
client: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
},
},
wordlist: getEnhancedWordlist(),
}
}
// DiscoveryResult represents a discovered subdomain
type DiscoveryResult struct {
Subdomain string `json:"subdomain"`
FullDomain string `json:"full_domain"`
IPAddresses []string `json:"ip_addresses"`
StatusCode int `json:"status_code,omitempty"`
Server string `json:"server,omitempty"`
Source string `json:"source"` // dns, certificate, http, etc.
FoundAt time.Time `json:"found_at"`
}
// Discover performs comprehensive subdomain discovery
func (sd *SubdomainDiscovery) Discover(ctx context.Context, domainName string) ([]DiscoveryResult, error) {
var results []DiscoveryResult
var mu sync.Mutex
var wg sync.WaitGroup
// Create a channel for results
resultChan := make(chan DiscoveryResult, 100)
// Start multiple discovery methods concurrently
wg.Add(4)
// 1. DNS brute force with enhanced wordlist
go func() {
defer wg.Done()
sd.dnsBruteForce(ctx, domainName, resultChan)
}()
// 2. Certificate transparency log search
go func() {
defer wg.Done()
sd.ctLogSearch(ctx, domainName, resultChan)
}()
// 3. DNS enumeration via common patterns
go func() {
defer wg.Done()
sd.patternEnumeration(ctx, domainName, resultChan)
}()
// 4. HTTP probe for common subdomains
go func() {
defer wg.Done()
sd.httpProbe(ctx, domainName, resultChan)
}()
// Collect results in a separate goroutine
go func() {
wg.Wait()
close(resultChan)
}()
// Process results
seen := make(map[string]bool)
for result := range resultChan {
if seen[result.Subdomain] {
continue
}
seen[result.Subdomain] = true
mu.Lock()
results = append(results, result)
mu.Unlock()
}
return results, nil
}
// dnsBruteForce performs DNS brute forcing with wordlist
func (sd *SubdomainDiscovery) dnsBruteForce(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
semaphore := make(chan struct{}, 20) // Limit concurrency
var wg sync.WaitGroup
for _, word := range sd.wordlist {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(word string) {
defer wg.Done()
defer func() { <-semaphore }()
subdomain := word + "." + domainName
ips, err := net.LookupHost(subdomain)
if err != nil || len(ips) == 0 {
return
}
results <- DiscoveryResult{
Subdomain: word,
FullDomain: subdomain,
IPAddresses: ips,
Source: "dns",
FoundAt: time.Now(),
}
}(word)
}
wg.Wait()
}
// ctLogSearch searches certificate transparency logs
func (sd *SubdomainDiscovery) ctLogSearch(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
// Query crt.sh for certificates
url := fmt.Sprintf("https://crt.sh/?q=%%.%s&output=json", domainName)
resp, err := sd.client.Get(url)
if err != nil {
log.Printf("[subdomain-discovery] CT log search failed for %s: %v", domainName, err)
return
}
defer resp.Body.Close()
// Parse response (simplified - in production would parse JSON)
// For now, just log that we attempted this
log.Printf("[subdomain-discovery] CT log search attempted for %s (status: %d)", domainName, resp.StatusCode)
}
// patternEnumeration enumerates common subdomain patterns
func (sd *SubdomainDiscovery) patternEnumeration(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
patterns := []string{
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
"app-dev", "app-staging", "app-prod", "web-dev", "web-staging",
"admin-dev", "admin-staging", "admin-prod",
"portal-dev", "portal-staging", "portal-prod",
"dashboard-dev", "dashboard-staging", "dashboard-prod",
"service-1", "service-2", "service-3",
"node-1", "node-2", "node-3",
"server-1", "server-2", "server-3",
"web-01", "web-02", "web-03",
"app-01", "app-02", "app-03",
"us-east", "us-west", "eu-west", "eu-central", "ap-south", "ap-northeast",
}
semaphore := make(chan struct{}, 10)
var wg sync.WaitGroup
for _, pattern := range patterns {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(pattern string) {
defer wg.Done()
defer func() { <-semaphore }()
subdomain := pattern + "." + domainName
ips, err := net.LookupHost(subdomain)
if err != nil || len(ips) == 0 {
return
}
results <- DiscoveryResult{
Subdomain: pattern,
FullDomain: subdomain,
IPAddresses: ips,
Source: "pattern",
FoundAt: time.Now(),
}
}(pattern)
}
wg.Wait()
}
// httpProbe probes common subdomains via HTTP
func (sd *SubdomainDiscovery) httpProbe(ctx context.Context, domainName string, results chan<- DiscoveryResult) {
commonWebSubdomains := []string{"www", "app", "api", "admin", "dashboard", "portal", "web"}
semaphore := make(chan struct{}, 5)
var wg sync.WaitGroup
for _, sub := range commonWebSubdomains {
select {
case <-ctx.Done():
return
default:
}
wg.Add(1)
semaphore <- struct{}{}
go func(sub string) {
defer wg.Done()
defer func() { <-semaphore }()
// Try HTTPS first
url := fmt.Sprintf("https://%s.%s", sub, domainName)
resp, err := sd.client.Get(url)
if err != nil {
// Try HTTP
url = fmt.Sprintf("http://%s.%s", sub, domainName)
resp, err = sd.client.Get(url)
if err != nil {
return
}
}
defer resp.Body.Close()
// Get IP addresses
subdomain := sub + "." + domainName
ips, _ := net.LookupHost(subdomain)
results <- DiscoveryResult{
Subdomain: sub,
FullDomain: subdomain,
IPAddresses: ips,
StatusCode: resp.StatusCode,
Server: resp.Header.Get("Server"),
Source: "http",
FoundAt: time.Now(),
}
}(sub)
}
wg.Wait()
}
// SaveSubdomains saves discovered subdomains to the database
func (sd *SubdomainDiscovery) SaveSubdomains(domainRecord *core.Record, results []DiscoveryResult, userID string) error {
collection, err := sd.app.FindCollectionByNameOrId("subdomains")
if err != nil {
return err
}
// Get existing subdomains to avoid duplicates
existing, _ := sd.app.FindAllRecords("subdomains",
dbx.NewExp("domain = {:domain}", dbx.Params{"domain": domainRecord.Id}),
)
existingMap := make(map[string]bool)
for _, sub := range existing {
existingMap[sub.GetString("subdomain_name")] = true
}
for _, result := range results {
if existingMap[result.Subdomain] {
continue
}
record := core.NewRecord(collection)
record.Set("domain", domainRecord.Id)
record.Set("subdomain_name", result.Subdomain)
record.Set("full_domain", result.FullDomain)
record.Set("status", "active")
record.Set("ip_addresses", strings.Join(result.IPAddresses, ","))
record.Set("discovery_source", result.Source)
record.Set("last_checked", time.Now())
record.Set("user", userID)
if result.StatusCode > 0 {
record.Set("http_status", result.StatusCode)
}
if result.Server != "" {
record.Set("server_header", result.Server)
}
if err := sd.app.Save(record); err != nil {
log.Printf("[subdomain-discovery] Failed to save subdomain %s: %v", result.FullDomain, err)
} else {
log.Printf("[subdomain-discovery] Saved subdomain: %s (source: %s)", result.FullDomain, result.Source)
}
}
return nil
}
// getEnhancedWordlist returns an enhanced wordlist for subdomain discovery
func getEnhancedWordlist() []string {
// Comprehensive wordlist including common subdomains
wordlist := []string{
// Common web
"www", "mail", "ftp", "localhost", "admin", "dashboard", "portal",
"api", "app", "mobile", "dev", "test", "staging", "demo", "beta",
"prod", "production", "live", "www2", "www1", "blog", "shop", "store",
"support", "help", "docs", "documentation", "wiki", "forum", "community",
"news", "media", "cdn", "static", "assets", "files", "download", "upload",
"images", "img", "css", "js", "scripts", "resources", "public", "private",
// Services
"api-v1", "api-v2", "api-v3", "api-dev", "api-staging", "api-prod",
"graphql", "rest", "grpc", "websocket", "socket", "ws", "wss",
"oauth", "auth", "login", "signin", "signup", "register", "sso",
"saml", "oidc", "openid", "keycloak", "auth0", "cognito", "okta",
// Infrastructure
"server", "server1", "server2", "server3", "srv", "srv1", "srv2",
"node", "node1", "node2", "node3", "worker", "worker1", "worker2",
"web", "web1", "web2", "web3", "app1", "app2", "app3",
"db", "database", "mysql", "postgres", "mongodb", "redis", "elasticsearch",
"cache", "queue", "rabbitmq", "kafka", "zookeeper", "etcd",
// Cloud & DevOps
"k8s", "kubernetes", "docker", "swarm", "nomad", "consul", "vault",
"terraform", "ansible", "puppet", "chef", "salt", "jenkins", "gitlab",
"github", "bitbucket", "travis", "circleci", "drone", "argo", "spinnaker",
"prometheus", "grafana", "alertmanager", "thanos", "loki", "tempo",
"jaeger", "zipkin", "kibana", "elk", "efk", "splunk", "datadog",
// Security
"vpn", "ipsec", "openvpn", "wireguard", "fortinet", "paloalto",
"firewall", "waf", "ids", "ips", "siem", "soc", "nids",
"scan", "scanner", "security", "sec", "pentest", "vulnerability",
// Monitoring & Logging
"monitor", "monitoring", "status", "health", "healthcheck", "ping",
"uptime", "metrics", "logs", "logging", "audit", "trace", "apm",
"sentry", "bugsnag", "raygun", "rollbar", "airbrake", "honeybadger",
"newrelic", "appdynamics", "dynatrace", "instana", "scout", "skylight",
// Communication
"mail", "email", "smtp", "pop", "imap", "exchange", "webmail",
"mailgun", "sendgrid", "mailchimp", " SES", "postmark", "sparkpost",
"chat", "slack", "teams", "discord", "zoom", "meet", "webex",
"jitsi", "mattermost", "rocket", "element", "matrix",
// Regions
"us", "us-east", "us-west", "us-central", "us-south",
"eu", "eu-west", "eu-central", "eu-east", "eu-north", "eu-south",
"ap", "ap-south", "ap-northeast", "ap-southeast", "ap-east",
"sa", "sa-east", "af", "af-south", "me", "me-south",
// Environments
"sandbox", "playground", "lab", "labs", "experiment", "canary",
"blue", "green", "a", "b", "alpha", "beta", "gamma", "delta",
"rc", "release", "nightly", "stable", "latest", "edge", "lts",
// Corporate
"corp", "corporate", "enterprise", "business", "company", "org",
"intranet", "extranet", "partners", "vendors", "suppliers",
"hr", "finance", "accounting", "legal", "compliance", "it",
"sales", "marketing", "crm", "erp", "scm", "plm", "hrm",
// Development tools
"git", "svn", "cvs", "mercurial", "hg", "bitkeeper",
"repo", "repository", "repos", "source", "code", "src",
"build", "ci", "cd", "deploy", "deployment", "release",
"artifact", "artifacts", "package", "packages", "registry",
// Testing
"qa", "qc", "test", "testing", "tests", "spec", "specs",
"unit", "integration", "e2e", "acceptance", "regression",
"mock", "stub", "fake", "dummy", "sandbox", "fixture",
// Archive & Backup
"archive", "archives", "backup", "backups", "snapshot", "snapshots",
"old", "legacy", "deprecated", "retired", "historical",
// Miscellaneous
"search", "find", "lookup", "query", "browse", "explorer",
"api-docs", "swagger", "openapi", "redoc", "postman", "graphql-playground",
"status-page", "statuspage", "trust", "security", "compliance",
"terms", "privacy", "legal", "about", "contact", "feedback",
"careers", "jobs", "team", "people", "staff", "employees",
"press", "media-kit", "brand", "assets", "styleguide", "design",
"sitemap", "robots", "humans", "security", "pgp", "key",
"invite", "join", "apply", "subscribe", "newsletter", "rss", "atom",
"mobile", "m", "touch", "app", "ios", "android", "windows", "mac",
"download", "downloads", "dl", "installer", "setup", "update", "upgrade",
"payment", "pay", "billing", "invoice", "subscription", "checkout",
"cart", "basket", "shop", "store", "buy", "purchase", "order",
"account", "my", "profile", "user", "users", "member", "members",
"session", "token", "auth", "verify", "validation", "confirm",
"reset", "recovery", "forgot", "password", "pass", "pwd",
"2fa", "mfa", "totp", "otp", "authenticator", "security-key",
"webhook", "callback", "hook", "integration", "connector", "adapter",
"plugin", "extension", "addon", "module", "component", "widget",
"embed", "iframe", "frame", "proxy", "gateway", "ingress", "egress",
"loadbalancer", "lb", "vip", "floating", "virtual", "ha", "failover",
"primary", "secondary", "master", "slave", "leader", "follower",
"active", "standby", "hot", "warm", "cold", "mirror", "replica",
"shard", "shards", "partition", "partitions", "segment", "segments",
}
return wordlist
}
+50 -6
View File
@@ -438,11 +438,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
} }
} }
// Extract dates // Extract dates - try many field name variations used by different registries
expiryDate := s.parseDate(data["registry_expiry_date"], data["registrar_registration_expiration_date"], expiryDate := s.parseDate(
data["expiry_date"], data["expiration_time"], data["expire"], data["paid_until"]) data["registry_expiry_date"],
creationDate := s.parseDate(data["creation_date"], data["created_date"], data["registration_time"]) data["registrar_registration_expiration_date"],
updatedDate := s.parseDate(data["updated_date"], data["last_updated"]) data["expiry_date"],
data["expiration_time"],
data["expire"],
data["paid_until"],
data["expire_date"],
data["renewal_date"],
data["valid_until"],
)
creationDate := s.parseDate(
data["creation_date"],
data["created_date"],
data["registration_time"],
data["registered_on"],
data["domain_registered"],
)
updatedDate := s.parseDate(
data["updated_date"],
data["last_updated"],
data["last_modified"],
data["modified_date"],
)
// Extract registrar - try multiple field names used by different WHOIS servers // Extract registrar - try multiple field names used by different WHOIS servers
registrarName := data["registrar"] registrarName := data["registrar"]
@@ -455,6 +475,15 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
if registrarName == "" { if registrarName == "" {
registrarName = data["registrar_organization"] registrarName = data["registrar_organization"]
} }
if registrarName == "" {
registrarName = data["registrant_organization"]
}
if registrarName == "" {
registrarName = data["registrar_url"]
}
if registrarName == "" {
registrarName = data["registrar_abuse_contact_email"]
}
if registrarName == "" { if registrarName == "" {
registrarName = "Unknown" registrarName = "Unknown"
} }
@@ -477,16 +506,31 @@ func (s *LookupService) parseWHOISOutput(output, domainName string) (*domain.WHO
PostalCode: data["registrant_postal_code"], PostalCode: data["registrant_postal_code"],
} }
// Try alternate field names for registrant // Try alternate field names for registrant (.eu uses "holder", other variations)
if registrant.Name == "" { if registrant.Name == "" {
registrant.Name = data["registrant"] registrant.Name = data["registrant"]
} }
if registrant.Name == "" {
registrant.Name = data["holder"]
}
if registrant.Name == "" {
registrant.Name = data["domain_holder"]
}
if registrant.Organization == "" { if registrant.Organization == "" {
registrant.Organization = data["org"] registrant.Organization = data["org"]
} }
if registrant.Organization == "" {
registrant.Organization = data["organization"]
}
if registrant.Organization == "" {
registrant.Organization = data["holder_org"]
}
if registrant.Country == "" { if registrant.Country == "" {
registrant.Country = data["country"] registrant.Country = data["country"]
} }
if registrant.Country == "" {
registrant.Country = data["holder_country"]
}
// Parse DNSSEC more thoroughly // Parse DNSSEC more thoroughly
dnssec := data["dnssec"] dnssec := data["dnssec"]
+41 -1
View File
@@ -52,6 +52,13 @@ func (h *APIHandler) RegisterRoutes(se *core.ServeEvent) {
api.GET("/:id/heartbeats", h.getHeartbeats) api.GET("/:id/heartbeats", h.getHeartbeats)
} }
// HeartbeatSummary represents a minimal heartbeat for the monitor list
type HeartbeatSummary struct {
Status string `json:"status"`
Ping int `json:"ping"`
Time time.Time `json:"time"`
}
// MonitorResponse represents a monitor in API responses // MonitorResponse represents a monitor in API responses
type MonitorResponse struct { type MonitorResponse struct {
ID string `json:"id"` ID string `json:"id"`
@@ -69,6 +76,7 @@ type MonitorResponse struct {
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
LastCheck *time.Time `json:"last_check,omitempty"` LastCheck *time.Time `json:"last_check,omitempty"`
UptimeStats map[string]float64 `json:"uptime_stats,omitempty"` UptimeStats map[string]float64 `json:"uptime_stats,omitempty"`
RecentHeartbeats []HeartbeatSummary `json:"recent_heartbeats,omitempty"`
Tags []string `json:"tags,omitempty"` Tags []string `json:"tags,omitempty"`
Keyword string `json:"keyword,omitempty"` Keyword string `json:"keyword,omitempty"`
JSONQuery string `json:"json_query,omitempty"` JSONQuery string `json:"json_query,omitempty"`
@@ -165,6 +173,35 @@ func (h *APIHandler) listMonitors(e *core.RequestEvent) error {
}) })
} }
// getRecentHeartbeats fetches the last N heartbeats for a monitor
func (h *APIHandler) getRecentHeartbeats(monitorID string, limit int) []HeartbeatSummary {
records, err := h.app.FindRecordsByFilter(
"monitor_heartbeats",
"monitor = {:monitorId}",
"-time",
limit,
0,
map[string]any{"monitorId": monitorID},
)
if err != nil {
return nil
}
heartbeats := make([]HeartbeatSummary, 0, len(records))
for _, hb := range records {
var t time.Time
if ts := hb.GetDateTime("time"); !ts.IsZero() {
t = ts.Time()
}
heartbeats = append(heartbeats, HeartbeatSummary{
Status: hb.GetString("status"),
Ping: hb.GetInt("ping"),
Time: t,
})
}
return heartbeats
}
// getMonitor returns a single monitor by ID // getMonitor returns a single monitor by ID
func (h *APIHandler) getMonitor(e *core.RequestEvent) error { func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
id := e.Request.PathValue("id") id := e.Request.PathValue("id")
@@ -182,7 +219,10 @@ func (h *APIHandler) getMonitor(e *core.RequestEvent) error {
return e.ForbiddenError("Access denied", nil) return e.ForbiddenError("Access denied", nil)
} }
return e.JSON(http.StatusOK, recordToResponse(record)) resp := recordToResponse(record)
resp.RecentHeartbeats = h.getRecentHeartbeats(id, 12)
return e.JSON(http.StatusOK, resp)
} }
// createMonitor creates a new monitor // createMonitor creates a new monitor
@@ -39,6 +39,7 @@ import {
DropdownMenuRadioItem, DropdownMenuRadioItem,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
DropdownMenuCheckboxItem,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
@@ -49,7 +50,6 @@ import {
getStatusBadgeColor, getStatusBadgeColor,
getStatusLabel, getStatusLabel,
formatDate, formatDate,
formatDays,
type Domain, type Domain,
} from "@/lib/domains" } from "@/lib/domains"
import { import {
@@ -72,6 +72,37 @@ import { useBrowserStorage } from "@/lib/utils"
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid"
type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused" type StatusFilter = "all" | "active" | "expiring" | "expired" | "unknown" | "paused"
type DisplayOptions = {
showSSL: boolean
showRegistrar: boolean
showExpiryDate: boolean
showTags: boolean
}
// Days left badge component - big and visible
function DaysLeftBadge({ days, label = "days" }: { days: number | undefined; label?: string }) {
if (days === undefined || days === null) return <span className="text-muted-foreground">-</span>
const isCritical = days >= 0 && days <= 7
const isWarning = days >= 0 && days <= 30
const isExpired = days < 0
const colorClass = isExpired
? "bg-red-500/15 text-red-600 border-red-500/30"
: isCritical
? "bg-red-500/15 text-red-600 border-red-500/30"
: isWarning
? "bg-yellow-500/15 text-yellow-600 border-yellow-500/30"
: "bg-green-500/15 text-green-600 border-green-500/30"
return (
<div className={`inline-flex flex-col items-center justify-center px-3 py-1.5 rounded-lg border-2 ${colorClass} min-w-[70px]`}>
<span className="text-lg font-bold leading-none">{isExpired ? Math.abs(days) : days}</span>
<span className="text-[10px] font-medium uppercase tracking-wide opacity-80">{isExpired ? "EXPIRED" : days === 1 ? "DAY" : label.toUpperCase()}</span>
</div>
)
}
export default function DomainsTable() { export default function DomainsTable() {
const { t } = useLingui() const { t } = useLingui()
const { toast } = useToast() const { toast } = useToast()
@@ -88,6 +119,11 @@ export default function DomainsTable() {
window.innerWidth < 1024 ? "grid" : "table" window.innerWidth < 1024 ? "grid" : "table"
) )
const [displayOptions, setDisplayOptions] = useBrowserStorage<DisplayOptions>(
"domainsDisplayOptions",
{ showSSL: true, showRegistrar: true, showExpiryDate: true, showTags: true }
)
const { data: domains = [], isLoading } = useQuery({ const { data: domains = [], isLoading } = useQuery({
queryKey: ["domains"], queryKey: ["domains"],
queryFn: getDomains, queryFn: getDomains,
@@ -346,6 +382,37 @@ export default function DomainsTable() {
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
)} )}
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
<DropdownMenuSeparator />
{/* Display Options */}
<DropdownMenuLabel className="flex items-center gap-2">
<FilterIcon className="size-4" />
<Trans>Display Columns</Trans>
</DropdownMenuLabel>
<DropdownMenuCheckboxItem
checked={displayOptions.showSSL}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showSSL: checked })}
>
SSL Info
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={displayOptions.showRegistrar}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showRegistrar: checked })}
>
Registrar
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={displayOptions.showExpiryDate}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showExpiryDate: checked })}
>
Expiry Date
</DropdownMenuCheckboxItem>
<DropdownMenuCheckboxItem
checked={displayOptions.showTags}
onCheckedChange={(checked: boolean) => setDisplayOptions({ ...displayOptions, showTags: checked })}
>
Tags
</DropdownMenuCheckboxItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div> </div>
@@ -374,11 +441,11 @@ export default function DomainsTable() {
<TableRow> <TableRow>
<TableHead>Domain</TableHead> <TableHead>Domain</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Expiry</TableHead> {displayOptions.showExpiryDate && <TableHead>Expiry</TableHead>}
<TableHead>Days Left</TableHead> <TableHead>Days Left</TableHead>
<TableHead>Registrar</TableHead> {displayOptions.showRegistrar && <TableHead>Registrar</TableHead>}
<TableHead>SSL Expiry</TableHead> {displayOptions.showSSL && <TableHead>SSL Expiry</TableHead>}
<TableHead>Tags</TableHead> {displayOptions.showTags && <TableHead>Tags</TableHead>}
<TableHead className="w-[100px]">Actions</TableHead> <TableHead className="w-[100px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -406,36 +473,27 @@ export default function DomainsTable() {
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
{displayOptions.showExpiryDate && (
<TableCell> <TableCell>
{domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"} {domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"}
</TableCell> </TableCell>
)}
<TableCell> <TableCell>
<span className={ <DaysLeftBadge days={domain.days_until_expiry} />
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30
? domain.days_until_expiry <= 7
? "text-red-600 font-semibold"
: "text-yellow-600"
: ""
}>
{formatDays(domain.days_until_expiry)}
</span>
</TableCell> </TableCell>
{displayOptions.showRegistrar && (
<TableCell>{domain.registrar_name || "Unknown"}</TableCell> <TableCell>{domain.registrar_name || "Unknown"}</TableCell>
)}
{displayOptions.showSSL && (
<TableCell> <TableCell>
{domain.ssl_valid_to ? ( {domain.ssl_valid_to ? (
<span <DaysLeftBadge days={domain.ssl_days_until} label="ssl" />
className={
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14
? "text-red-600"
: ""
}
>
{formatDays(domain.ssl_days_until)}
</span>
) : ( ) : (
"N/A" <span className="text-muted-foreground">-</span>
)} )}
</TableCell> </TableCell>
)}
{displayOptions.showTags && (
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{domain.tags?.map((tag: string) => ( {domain.tags?.map((tag: string) => (
@@ -449,6 +507,7 @@ export default function DomainsTable() {
))} ))}
</div> </div>
</TableCell> </TableCell>
)}
<TableCell> <TableCell>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -535,7 +594,7 @@ export default function DomainsTable() {
</Badge> </Badge>
</div> </div>
{domain.tags && domain.tags.length > 0 && ( {displayOptions.showTags && domain.tags && domain.tags.length > 0 && (
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{domain.tags.map((tag: string) => ( {domain.tags.map((tag: string) => (
<span <span
@@ -549,30 +608,20 @@ export default function DomainsTable() {
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-2 text-sm"> <div className="grid gap-2 text-sm">
<div> <div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">Days Left</div> {displayOptions.showExpiryDate && (
<span className={ <span className="text-xs text-muted-foreground">{domain.expiry_date ? formatDate(domain.expiry_date) : "Unknown"}</span>
domain.days_until_expiry !== undefined && domain.days_until_expiry >= 0 && domain.days_until_expiry <= 30 )}
? domain.days_until_expiry <= 7 {displayOptions.showRegistrar && (
? "text-red-600 font-semibold" <span className="text-xs text-muted-foreground truncate max-w-[120px]">{domain.registrar_name || "Unknown"}</span>
: "text-yellow-600" )}
: ""
}>
{formatDays(domain.days_until_expiry)}
</span>
</div> </div>
<div> <div className="flex gap-2">
<div className="text-xs text-muted-foreground">SSL</div> <DaysLeftBadge days={domain.days_until_expiry} />
<span {displayOptions.showSSL && domain.ssl_valid_to && (
className={ <DaysLeftBadge days={domain.ssl_days_until} label="ssl" />
domain.ssl_days_until !== undefined && domain.ssl_days_until >= 0 && domain.ssl_days_until <= 14 )}
? "text-red-600"
: ""
}
>
{formatDays(domain.ssl_days_until)}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,298 @@
"use client"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
import { Skeleton } from "@/components/ui/skeleton"
import {
Globe,
RefreshCw,
Trash2,
Search,
Server,
ExternalLink,
CheckCircle2,
XCircle,
Shield,
} from "lucide-react"
import {
getDomainSubdomains,
refreshSubdomainDiscovery,
deleteSubdomain,
type Subdomain,
} from "@/lib/domains"
import { useState } from "react"
interface SubdomainListProps {
domainId: string
}
function SubdomainStatusBadge({ status }: { status: string }) {
const configs = {
active: { color: "bg-green-500", icon: CheckCircle2, text: "Active" },
inactive: { color: "bg-gray-500", icon: XCircle, text: "Inactive" },
error: { color: "bg-red-500", icon: XCircle, text: "Error" },
}
const config = configs[status as keyof typeof configs] || configs.inactive
const Icon = config.icon
return (
<div className="flex items-center gap-1.5">
<div className={`h-2 w-2 rounded-full ${config.color}`} />
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs capitalize">{config.text}</span>
</div>
)
}
function SourceBadge({ source }: { source: string }) {
const sourceConfig: Record<string, { label: string; variant: "default" | "secondary" | "outline" }> = {
dns: { label: "DNS", variant: "default" },
http: { label: "HTTP", variant: "secondary" },
pattern: { label: "Pattern", variant: "outline" },
certificate: { label: "Cert", variant: "secondary" },
}
const config = sourceConfig[source] || { label: source, variant: "outline" }
return (
<Badge variant={config.variant} className="text-xs">
{config.label}
</Badge>
)
}
export function SubdomainList({ domainId }: SubdomainListProps) {
const { toast } = useToast()
const queryClient = useQueryClient()
const [isDiscovering, setIsDiscovering] = useState(false)
const { data: subdomains, isLoading } = useQuery({
queryKey: ["domain-subdomains", domainId],
queryFn: () => getDomainSubdomains(domainId),
})
const refreshMutation = useMutation({
mutationFn: async () => {
setIsDiscovering(true)
await refreshSubdomainDiscovery(domainId)
// Wait a bit for discovery to start
await new Promise((resolve) => setTimeout(resolve, 2000))
setIsDiscovering(false)
},
onSuccess: () => {
toast({ title: "Subdomain discovery started" })
queryClient.invalidateQueries({ queryKey: ["domain-subdomains", domainId] })
},
onError: (error: Error) => {
setIsDiscovering(false)
toast({
title: "Discovery failed",
description: error.message,
variant: "destructive",
})
},
})
const deleteMutation = useMutation({
mutationFn: deleteSubdomain,
onSuccess: () => {
toast({ title: "Subdomain deleted" })
queryClient.invalidateQueries({ queryKey: ["domain-subdomains", domainId] })
},
onError: (error: Error) => {
toast({
title: "Failed to delete",
description: error.message,
variant: "destructive",
})
},
})
if (isLoading) {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-32" />
<Skeleton className="h-4 w-48 mt-2" />
</CardHeader>
<CardContent>
<div className="space-y-2">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</CardContent>
</Card>
)
}
const activeCount = subdomains?.filter((s) => s.status === "active").length || 0
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Search className="h-5 w-5" />
Discovered Subdomains
{subdomains && subdomains.length > 0 && (
<Badge variant="secondary" className="ml-2">
{activeCount}/{subdomains.length} active
</Badge>
)}
</CardTitle>
<CardDescription>
Subdomains discovered through DNS, HTTP, and pattern enumeration
</CardDescription>
</div>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={() => refreshMutation.mutate()}
disabled={isDiscovering || refreshMutation.isPending}
>
<RefreshCw className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`} />
{isDiscovering ? "Discovering..." : "Discover"}
</Button>
</TooltipTrigger>
<TooltipContent>
<p>Run enhanced subdomain discovery</p>
</TooltipContent>
</Tooltip>
</Tooltip>
</TooltipProvider>
</div>
</CardHeader>
<CardContent>
{!subdomains || subdomains.length === 0 ? (
<div className="text-center py-8">
<Search className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium">No subdomains discovered yet</p>
<p className="text-muted-foreground mb-4">
Run discovery to find subdomains via DNS, HTTP, and certificate transparency logs
</p>
<Button
onClick={() => refreshMutation.mutate()}
disabled={isDiscovering || refreshMutation.isPending}
>
<RefreshCw className={`mr-2 h-4 w-4 ${isDiscovering ? "animate-spin" : ""}`} />
{isDiscovering ? "Discovering..." : "Start Discovery"}
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Subdomain</TableHead>
<TableHead>Status</TableHead>
<TableHead>Source</TableHead>
<TableHead>IP Addresses</TableHead>
<TableHead>HTTP</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subdomains.map((subdomain) => (
<TableRow key={subdomain.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-mono text-sm">
{subdomain.subdomain_name}
</span>
</div>
</TableCell>
<TableCell>
<SubdomainStatusBadge status={subdomain.status} />
</TableCell>
<TableCell>
<SourceBadge source={subdomain.discovery_source} />
</TableCell>
<TableCell>
{subdomain.ip_addresses ? (
<div className="flex items-center gap-1.5">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs text-muted-foreground truncate max-w-[150px]">
{subdomain.ip_addresses.split(",").length} IP(s)
</span>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
{subdomain.http_status ? (
<div className="flex items-center gap-2">
<Badge
variant={subdomain.http_status < 400 ? "default" : "destructive"}
className="text-xs"
>
{subdomain.http_status}
</Badge>
{subdomain.server_header && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<Shield className="h-3.5 w-3.5 text-muted-foreground" />
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{subdomain.server_header}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
<a
href={`https://${subdomain.full_domain}`}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
) : (
<span className="text-xs text-muted-foreground">-</span>
)}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => deleteMutation.mutate(subdomain.id)}
disabled={deleteMutation.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
)
}
@@ -0,0 +1,255 @@
"use client"
import { useMemo } from "react"
import { useQuery } from "@tanstack/react-query"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Collapsible } from "@/components/ui/collapsible"
import {
Globe,
Server,
Activity,
ArrowUpRight,
ArrowDownRight,
Minus,
ExternalLink,
} from "lucide-react"
import {
listMonitors,
groupMonitorsByDomain,
getMonitorStatusColor,
formatUptime,
type Monitor,
type GroupedMonitors,
} from "@/lib/monitors"
import { Link } from "@/components/router"
import { cn } from "@/lib/utils"
interface GroupedMonitorsTableProps {
view?: "grid" | "list"
}
function DomainGroupHeader({
domain,
group,
monitorCount,
}: {
domain: string
group: GroupedMonitors
monitorCount: number
}) {
// Calculate aggregate status for the domain
const allMonitors = [...group.monitors, ...Array.from(group.subdomains.values()).flat()]
const upCount = allMonitors.filter((m) => m.status === "up").length
const downCount = allMonitors.filter((m) => m.status === "down").length
const pausedCount = allMonitors.filter((m) => m.status === "paused").length
const statusColor =
downCount > 0 ? "bg-red-500" : upCount > 0 ? "bg-green-500" : pausedCount > 0 ? "bg-yellow-500" : "bg-gray-400"
return (
<div className="flex items-center gap-3 py-2">
<div className={cn("h-3 w-3 rounded-full", statusColor)} />
<div className="flex-1">
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="font-semibold">{domain}</span>
<Badge variant="secondary" className="text-xs">
{monitorCount} monitor{monitorCount !== 1 ? "s" : ""}
</Badge>
</div>
</div>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{upCount > 0 && (
<span className="flex items-center gap-1 text-green-600">
<ArrowUpRight className="h-3.5 w-3.5" />
{upCount}
</span>
)}
{downCount > 0 && (
<span className="flex items-center gap-1 text-red-600">
<ArrowDownRight className="h-3.5 w-3.5" />
{downCount}
</span>
)}
{pausedCount > 0 && (
<span className="flex items-center gap-1 text-yellow-600">
<Minus className="h-3.5 w-3.5" />
{pausedCount}
</span>
)}
</div>
</div>
)
}
function MonitorCard({ monitor }: { monitor: Monitor }) {
const uptime24h = monitor.uptime_stats?.["24h"] ?? 100
const statusColor = getMonitorStatusColor(monitor.status)
return (
<Link href={`/monitor/${monitor.id}`}>
<div className="group relative rounded-lg border p-3 hover:bg-muted/50 transition-colors cursor-pointer">
<div className="flex items-start gap-3">
<div className={cn("mt-1 h-2.5 w-2.5 rounded-full", statusColor)} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-medium text-sm truncate">{monitor.name}</span>
{monitor.active === false && (
<Badge variant="secondary" className="text-[10px]">
Paused
</Badge>
)}
</div>
<div className="flex items-center gap-2 mt-1 text-xs text-muted-foreground">
<Activity className="h-3 w-3" />
<span>24h: {formatUptime(uptime24h)}</span>
</div>
</div>
<ExternalLink className="h-3.5 w-3.5 text-muted-foreground opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</Link>
)
}
function SubdomainSection({
subdomain,
monitors,
}: {
subdomain: string
monitors: Monitor[]
}) {
const downCount = monitors.filter((m) => m.status === "down").length
const header = (
<div className="flex items-center gap-2 py-1.5">
<Server className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-sm font-medium">{subdomain}</span>
<Badge variant="outline" className="text-[10px] h-5 px-1">
{monitors.length}
</Badge>
{downCount > 0 && (
<Badge variant="destructive" className="text-[10px] h-5 px-1">
{downCount} down
</Badge>
)}
</div>
)
return (
<Collapsible
title={`${subdomain} (${monitors.length})`}
icon={<Server className="h-4 w-4" />}
defaultOpen={true}
className="ml-4 border-l-2 border-muted rounded-none border-t-0 border-r-0 border-b-0"
>
<div className="pl-6 py-1 space-y-1">
{monitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
</div>
</Collapsible>
)
}
function DomainGroup({ domain, group }: { domain: string; group: GroupedMonitors }) {
const monitorCount = group.monitors.length + Array.from(group.subdomains.values()).flat().length
const content = (
<CardContent className="pt-0 pb-4 px-4">
{/* Root domain monitors */}
{group.monitors.length > 0 && (
<div className="mb-3">
<div className="flex items-center gap-2 py-1.5 text-sm text-muted-foreground">
<Globe className="h-3.5 w-3.5" />
<span>Root Domain</span>
</div>
<div className="pl-6 space-y-1">
{group.monitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
</div>
</div>
)}
{/* Subdomain sections */}
{Array.from(group.subdomains.entries()).map(([subdomain, monitors]) => (
<SubdomainSection key={subdomain} subdomain={subdomain} monitors={monitors} />
))}
</CardContent>
)
return (
<Collapsible
title={domain}
icon={<Globe className="h-4 w-4" />}
defaultOpen={true}
description={<DomainGroupHeader domain={domain} group={group} monitorCount={monitorCount} />}
>
{content}
</Collapsible>
)
}
export function GroupedMonitorsTable() {
const { data: monitors, isLoading } = useQuery({
queryKey: ["monitors"],
queryFn: listMonitors,
})
const groupedMonitors = useMemo(() => {
if (!monitors) return new Map()
return groupMonitorsByDomain(monitors)
}, [monitors])
const ungroupedMonitors = useMemo(() => {
if (!monitors) return []
return monitors.filter((m) => !m.url && !m.hostname)
}, [monitors])
if (isLoading) {
return (
<div className="space-y-4">
{[1, 2, 3].map((i) => (
<div key={i} className="h-32 bg-muted rounded-lg animate-pulse" />
))}
</div>
)
}
const domainGroups = Array.from(groupedMonitors.entries()).sort((a, b) => a[0].localeCompare(b[0]))
return (
<div className="space-y-4">
{/* Domain groups */}
{domainGroups.map(([domain, group]) => (
<DomainGroup key={domain} domain={domain} group={group} />
))}
{/* Ungrouped monitors (no URL/hostname) */}
{ungroupedMonitors.length > 0 && (
<Collapsible
title="Other Monitors"
icon={<Server className="h-4 w-4" />}
defaultOpen={true}
>
<div className="space-y-1">
{ungroupedMonitors.map((monitor) => (
<MonitorCard key={monitor.id} monitor={monitor} />
))}
</div>
</Collapsible>
)}
{/* Empty state */}
{domainGroups.length === 0 && ungroupedMonitors.length === 0 && (
<div className="text-center py-12">
<Activity className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-lg font-medium">No monitors yet</p>
<p className="text-muted-foreground">Create your first monitor to get started</p>
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
import { Trans, useLingui } from "@lingui/react/macro" import { Trans, useLingui } from "@lingui/react/macro"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { import {
AlertTriangle,
ArrowDownIcon, ArrowDownIcon,
ArrowUpIcon, ArrowUpIcon,
CheckCircleIcon, CheckCircleIcon,
@@ -16,6 +17,7 @@ import {
Settings2Icon, Settings2Icon,
TagIcon, TagIcon,
Trash2Icon, Trash2Icon,
XCircle,
XCircleIcon, XCircleIcon,
} from "lucide-react" } from "lucide-react"
import { memo, useMemo, useState } from "react" import { memo, useMemo, useState } from "react"
@@ -68,7 +70,9 @@ import {
} from "@/lib/monitors" } from "@/lib/monitors"
import { cn, useBrowserStorage } from "@/lib/utils" import { cn, useBrowserStorage } from "@/lib/utils"
import { AddMonitorDialog } from "./add-monitor-dialog" import { AddMonitorDialog } from "./add-monitor-dialog"
import { GroupedMonitorsTable } from "./grouped-monitors-table"
import { Link } from "@/components/router" import { Link } from "@/components/router"
import { Network } from "lucide-react"
// Status indicator component // Status indicator component
function StatusIndicator({ status }: { status: MonitorStatus }) { function StatusIndicator({ status }: { status: MonitorStatus }) {
@@ -176,14 +180,26 @@ function MonitorCard({
</DropdownMenu> </DropdownMenu>
</div> </div>
<div className="grid grid-cols-2 gap-3 text-sm"> <div className="space-y-3">
<div className="space-y-1"> <div className="flex items-center justify-between">
<div className="text-xs text-muted-foreground">Type</div> <div className="text-xs text-muted-foreground">Type</div>
<div className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"> <div className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium">
{getMonitorTypeLabel(monitor.type)} {getMonitorTypeLabel(monitor.type)}
</div> </div>
</div> </div>
<div className="space-y-1">
{/* Uptime - Prominent pill display */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 flex-wrap">
<UptimePill uptime={monitor.uptime_stats?.uptime_24h ?? 100} label="24h" />
{monitor.uptime_stats?.uptime_7d !== undefined && monitor.uptime_stats.uptime_7d !== monitor.uptime_stats?.uptime_24h && (
<UptimePill uptime={monitor.uptime_stats.uptime_7d} label="7d" />
)}
</div>
<UptimeDots heartbeats={monitor.recent_heartbeats} />
</div>
<div className="flex items-center justify-between text-sm">
<div className="text-xs text-muted-foreground">Response</div> <div className="text-xs text-muted-foreground">Response</div>
<div> <div>
{monitor.last_check ? ( {monitor.last_check ? (
@@ -193,10 +209,6 @@ function MonitorCard({
)} )}
</div> </div>
</div> </div>
<div className="col-span-2 space-y-1">
<div className="text-xs text-muted-foreground">Uptime (24h)</div>
<UptimeBar stats={monitor.uptime_stats} />
</div>
</div> </div>
{monitor.tags && monitor.tags.length > 0 && ( {monitor.tags && monitor.tags.length > 0 && (
@@ -265,25 +277,89 @@ function MonitorCard({
) )
} }
// Uptime bar component // Uptime pill badge component - big and visible
function UptimePill({ uptime, label = "24h" }: { uptime: number; label?: string }) {
let colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
let icon = <CheckCircleIcon className="h-3.5 w-3.5" />
if (uptime < 99.9) {
colorClass = "bg-green-500/15 text-green-600 border-green-500/30"
}
if (uptime < 95) {
colorClass = "bg-yellow-500/15 text-yellow-600 border-yellow-500/30"
icon = <AlertTriangle className="h-3.5 w-3.5" />
}
if (uptime < 90) {
colorClass = "bg-red-500/15 text-red-600 border-red-500/30"
icon = <XCircle className="h-3.5 w-3.5" />
}
return (
<div className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full border-2 ${colorClass}`}>
{icon}
<span className="text-sm font-bold">{formatUptime(uptime)}</span>
<span className="text-[10px] font-medium uppercase opacity-70">{label}</span>
</div>
)
}
// Uptime bar component with pill style
function UptimeBar({ stats }: { stats?: Record<string, number> }) { function UptimeBar({ stats }: { stats?: Record<string, number> }) {
const uptime24h = stats?.uptime_24h ?? 100 const uptime24h = stats?.uptime_24h ?? 100
const uptime7d = stats?.uptime_7d ?? 100
const uptime30d = stats?.uptime_30d ?? 100
let color = "bg-green-500" let color = "bg-green-500"
if (uptime24h < 95) color = "bg-yellow-500" if (uptime24h < 95) color = "bg-yellow-500"
if (uptime24h < 90) color = "bg-red-500" if (uptime24h < 90) color = "bg-red-500"
return ( return (
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2 w-16 rounded-full bg-muted overflow-hidden"> <UptimePill uptime={uptime24h} label="24h" />
<div {uptime7d !== 100 && uptime7d !== uptime24h && (
className={cn("h-full transition-all", color)} <UptimePill uptime={uptime7d} label="7d" />
style={{ width: `${uptime24h}%` }} )}
/> {uptime30d !== 100 && uptime30d !== uptime24h && uptime30d !== uptime7d && (
<UptimePill uptime={uptime30d} label="30d" />
)}
</div> </div>
<span className="text-xs text-muted-foreground w-14"> </div>
{formatUptime(uptime24h)} )
</span> }
// Mini uptime dots visualization
function UptimeDots({ heartbeats }: { heartbeats?: Array<{ status: string; time: string }> }) {
if (!heartbeats || heartbeats.length === 0) {
return (
<div className="flex gap-0.5">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="h-3 w-2 rounded-sm bg-muted" />
))}
</div>
)
}
// Take last 12 heartbeats
const recent = heartbeats.slice(-12)
return (
<div className="flex gap-0.5">
{recent.map((hb, i) => (
<div
key={i}
className={cn(
"h-3 w-2 rounded-sm transition-colors",
hb.status === "up" ? "bg-green-500" :
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)}
title={`${hb.status} at ${new Date(hb.time).toLocaleString()}`}
/>
))}
{recent.length < 12 && Array.from({ length: 12 - recent.length }).map((_, i) => (
<div key={`empty-${i}`} className="h-3 w-2 rounded-sm bg-muted" />
))}
</div> </div>
) )
} }
@@ -456,7 +532,7 @@ function MonitorRow({
) )
} }
type ViewMode = "table" | "grid" type ViewMode = "table" | "grid" | "network"
type StatusFilter = "all" | MonitorStatus type StatusFilter = "all" | MonitorStatus
type TypeFilter = "all" | MonitorType type TypeFilter = "all" | MonitorType
@@ -669,6 +745,10 @@ export default memo(function MonitorsTable() {
<LayoutGridIcon className="size-4" /> <LayoutGridIcon className="size-4" />
<Trans>Grid</Trans> <Trans>Grid</Trans>
</DropdownMenuRadioItem> </DropdownMenuRadioItem>
<DropdownMenuRadioItem value="network" className="gap-2">
<Network className="size-4" />
<Trans>Network (Grouped)</Trans>
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup> </DropdownMenuRadioGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
@@ -727,6 +807,8 @@ export default memo(function MonitorsTable() {
</div> </div>
)} )}
</div> </div>
) : viewMode === "network" ? (
<GroupedMonitorsTable />
) : viewMode === "table" ? ( ) : viewMode === "table" ? (
<Table> <Table>
<TableHeader> <TableHeader>
@@ -46,6 +46,7 @@ import {
} from "@/lib/domains" } from "@/lib/domains"
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"
import { SubdomainList } from "@/components/domains-table/subdomain-list"
// Status badge component // Status badge component
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
@@ -851,6 +852,9 @@ export default memo(function DomainDetail({ id }: { id: string }) {
</Card> </Card>
</div> </div>
{/* Subdomains Section */}
<SubdomainList domainId={domain.id} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Change History</CardTitle> <CardTitle>Change History</CardTitle>
@@ -75,6 +75,140 @@ import { cn } from "@/lib/utils"
type HeartbeatRow = Heartbeat & { timestamp?: string } type HeartbeatRow = Heartbeat & { timestamp?: string }
// Uptime Bar Component - Visual timeline of recent checks
function UptimeBarVisualization({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
const recent = useMemo(() => {
if (!heartbeats?.length) return []
return heartbeats.slice(0, 30).reverse()
}, [heartbeats])
if (!recent.length) {
return (
<div className="flex gap-0.5 h-8 items-center">
{Array.from({ length: 12 }).map((_, i) => (
<div key={i} className="flex-1 h-6 rounded-sm bg-muted/50" />
))}
</div>
)
}
return (
<div className="space-y-2">
<div className="flex gap-0.5 h-8">
{recent.map((hb, i) => (
<div
key={i}
className={cn(
"flex-1 rounded-sm transition-all hover:opacity-80 cursor-pointer",
hb.status === "up" ? "bg-green-500" :
hb.status === "down" ? "bg-red-500" :
hb.status === "paused" ? "bg-gray-400" : "bg-yellow-500"
)}
title={`${hb.status} • ${formatPing(hb.ping)} • ${formatDate(hb.time || hb.timestamp || "")}`}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{recent.length} recent checks</span>
<span>
<span className="inline-flex items-center gap-1">
<span className="w-2 h-2 rounded-full bg-green-500" />
{recent.filter(h => h.status === "up").length} up
</span>
<span className="inline-flex items-center gap-1 ml-3">
<span className="w-2 h-2 rounded-full bg-red-500" />
{recent.filter(h => h.status === "down").length} down
</span>
</span>
</div>
</div>
)
}
// Response time statistics component
function ResponseTimeStats({ heartbeats }: { heartbeats?: HeartbeatRow[] }) {
const stats = useMemo(() => {
if (!heartbeats?.length) return null
const pings = heartbeats.filter(h => h.ping && h.ping > 0).map(h => h.ping)
if (!pings.length) return null
const sorted = [...pings].sort((a, b) => a - b)
const avg = Math.round(pings.reduce((a, b) => a + b, 0) / pings.length)
const min = sorted[0]
const max = sorted[sorted.length - 1]
const p95 = sorted[Math.floor(sorted.length * 0.95)]
const p99 = sorted[Math.floor(sorted.length * 0.99)]
return { avg, min, max, p95, p99, count: pings.length }
}, [heartbeats])
if (!stats) return null
return (
<div className="grid grid-cols-5 gap-2 text-center">
<div className="p-2 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground">Avg</div>
<div className="text-lg font-semibold">{formatPing(stats.avg)}</div>
</div>
<div className="p-2 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground">Min</div>
<div className="text-lg font-semibold text-green-600">{formatPing(stats.min)}</div>
</div>
<div className="p-2 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground">Max</div>
<div className="text-lg font-semibold text-red-600">{formatPing(stats.max)}</div>
</div>
<div className="p-2 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground">P95</div>
<div className="text-lg font-semibold">{formatPing(stats.p95)}</div>
</div>
<div className="p-2 bg-muted/50 rounded-lg">
<div className="text-xs text-muted-foreground">P99</div>
<div className="text-lg font-semibold">{formatPing(stats.p99)}</div>
</div>
</div>
)
}
// Core Web Vitals placeholder component
function CoreWebVitalsCard({ url }: { url?: string }) {
if (!url) return null
return (
<Card>
<CardHeader>
<CardTitle>Core Web Vitals</CardTitle>
<CardDescription>Lighthouse performance metrics (coming soon)</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">LCP</div>
<div className="text-2xl font-bold text-yellow-500">-</div>
<div className="text-xs text-muted-foreground mt-1">Largest Contentful Paint</div>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">FID</div>
<div className="text-2xl font-bold text-green-500">-</div>
<div className="text-xs text-muted-foreground mt-1">First Input Delay</div>
</div>
<div className="text-center p-4 bg-muted/30 rounded-lg">
<div className="text-sm text-muted-foreground mb-1">CLS</div>
<div className="text-2xl font-bold text-green-500">-</div>
<div className="text-xs text-muted-foreground mt-1">Cumulative Layout Shift</div>
</div>
</div>
<div className="mt-4 p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<div className="flex items-center gap-2 text-sm text-blue-600">
<Activity className="h-4 w-4" />
<span>Core Web Vitals monitoring requires additional configuration</span>
</div>
</div>
</CardContent>
</Card>
)
}
// Status badge component // Status badge component
function StatusBadge({ status }: { status: string }) { function StatusBadge({ status }: { status: string }) {
const configs = { const configs = {
@@ -421,6 +555,31 @@ export default memo(function MonitorDetail({ id }: { id: string }) {
/> />
</div> </div>
{/* Uptime Bar & Response Stats */}
<div className="grid sm:grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>Recent Uptime</CardTitle>
<CardDescription>Visual timeline of the last 30 checks</CardDescription>
</CardHeader>
<CardContent>
<UptimeBarVisualization heartbeats={heartbeats} />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Response Time Statistics</CardTitle>
<CardDescription>Distribution of response times</CardDescription>
</CardHeader>
<CardContent>
<ResponseTimeStats heartbeats={heartbeats} />
</CardContent>
</Card>
</div>
{/* Core Web Vitals */}
<CoreWebVitalsCard url={monitor.url} />
{/* Combined Uptime & Response Chart */} {/* Combined Uptime & Response Chart */}
<Card> <Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4"> <CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
@@ -1,17 +1,17 @@
import { memo, useEffect } from "react" import { memo, useEffect } from "react"
import { useLingui } from "@lingui/react/macro" import { useLingui } from "@lingui/react/macro"
import { StatusPagesTable } from "@/components/status-pages/status-pages-table" import { StatusPageManager } from "@/components/status-pages/status-page-manager"
export default memo(() => { export default memo(() => {
const { t } = useLingui() const { t } = useLingui()
useEffect(() => { useEffect(() => {
document.title = `${t`Status Pages`} / Beszel` document.title = `${t`Status Page Manager`} / Beszel`
}, [t]) }, [t])
return ( return (
<div className="flex flex-col gap-8"> <div className="container mx-auto py-6">
<StatusPagesTable /> <StatusPageManager />
</div> </div>
) )
}) })
@@ -0,0 +1,853 @@
"use client"
import { useState, useMemo } from "react"
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
import { useToast } from "@/components/ui/use-toast"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Input } from "@/components/ui/input"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Textarea } from "@/components/ui/textarea"
import {
Plus,
ExternalLink,
Globe,
Lock,
AlertTriangle,
CheckCircle2,
Clock,
XCircle,
LayoutTemplate,
Activity,
TrendingUp,
Filter,
Search,
Wrench,
Trash2,
} from "lucide-react"
import {
getStatusPages,
deleteStatusPage,
getStatusPageUrl,
type StatusPage,
} from "@/lib/statuspages"
import {
getIncidents,
createIncident,
acknowledgeIncident,
resolveIncident,
closeIncident,
getIncidentStats,
type Incident,
type CreateIncidentRequest,
getSeverityColor,
getStatusColor,
formatDuration,
} from "@/lib/incidents"
import { StatusPageDialog } from "./status-page-dialog"
import { cn } from "@/lib/utils"
// Quick Stats Card Component
function QuickStatCard({
title,
value,
subtitle,
icon: Icon,
trend,
color = "blue",
}: {
title: string
value: string | number
subtitle?: string
icon: React.ElementType
trend?: { value: number; positive: boolean }
color?: "blue" | "green" | "yellow" | "red" | "purple"
}) {
const colorClasses = {
blue: "bg-blue-500/10 text-blue-600 border-blue-500/20",
green: "bg-green-500/10 text-green-600 border-green-500/20",
yellow: "bg-yellow-500/10 text-yellow-600 border-yellow-500/20",
red: "bg-red-500/10 text-red-600 border-red-500/20",
purple: "bg-purple-500/10 text-purple-600 border-purple-500/20",
}
return (
<Card className="relative overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
<div className={cn("p-2 rounded-lg border", colorClasses[color])}>
<Icon className="h-4 w-4" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
{trend && (
<div className={cn(
"flex items-center gap-1 text-xs mt-2",
trend.positive ? "text-green-600" : "text-red-600"
)}>
<TrendingUp className={cn("h-3 w-3", !trend.positive && "rotate-180")} />
<span>{trend.value}%</span>
</div>
)}
</CardContent>
</Card>
)
}
// Incident Quick Actions Menu
function IncidentQuickActions({
incident,
onAcknowledge,
onResolve,
onClose,
}: {
incident: Incident
onAcknowledge: (id: string) => void
onResolve: (id: string) => void
onClose: (id: string) => void
}) {
const [showResolveDialog, setShowResolveDialog] = useState(false)
const [resolution, setResolution] = useState("")
return (
<div className="flex items-center gap-2">
{incident.status === "open" && (
<Button
variant="outline"
size="sm"
className="h-8 text-yellow-600 border-yellow-200 hover:bg-yellow-50"
onClick={() => onAcknowledge(incident.id)}
>
<Clock className="mr-1 h-3.5 w-3.5" />
Ack
</Button>
)}
{(incident.status === "open" || incident.status === "acknowledged") && (
<>
<Button
variant="outline"
size="sm"
className="h-8 text-green-600 border-green-200 hover:bg-green-50"
onClick={() => setShowResolveDialog(true)}
>
<CheckCircle2 className="mr-1 h-3.5 w-3.5" />
Resolve
</Button>
<Dialog open={showResolveDialog} onOpenChange={setShowResolveDialog}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>Resolve Incident</DialogTitle>
<DialogDescription>
Add resolution details for this incident.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Resolution</label>
<Textarea
placeholder="How was this incident resolved?"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowResolveDialog(false)}>
Cancel
</Button>
<Button
onClick={() => {
onResolve(incident.id)
setShowResolveDialog(false)
setResolution("")
}}
>
Resolve Incident
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)}
{incident.status === "resolved" && (
<Button
variant="outline"
size="sm"
className="h-8 text-gray-600 border-gray-200 hover:bg-gray-50"
onClick={() => onClose(incident.id)}
>
<XCircle className="mr-1 h-3.5 w-3.5" />
Close
</Button>
)}
</div>
)
}
// Create Incident Dialog
function CreateIncidentDialog({
open,
onOpenChange,
onCreate,
}: {
open: boolean
onOpenChange: (open: boolean) => void
onCreate: (data: CreateIncidentRequest) => void
}) {
const [title, setTitle] = useState("")
const [description, setDescription] = useState("")
const [severity, setSeverity] = useState<"critical" | "high" | "medium" | "low">("high")
const [type, setType] = useState("monitor_down")
const handleSubmit = () => {
onCreate({
title,
description,
severity,
type,
})
onOpenChange(false)
setTitle("")
setDescription("")
setSeverity("high")
setType("monitor_down")
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Create New Incident</DialogTitle>
<DialogDescription>
Report a new incident or maintenance event.
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">Title</label>
<Input
placeholder="Incident title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Description</label>
<Textarea
placeholder="Describe the incident..."
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">Severity</label>
<Select value={severity} onValueChange={(v) => setSeverity(v as typeof severity)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="high">High</SelectItem>
<SelectItem value="medium">Medium</SelectItem>
<SelectItem value="low">Low</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Type</label>
<Select value={type} onValueChange={setType}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monitor_down">Monitor Down</SelectItem>
<SelectItem value="domain_expiring">Domain Expiring</SelectItem>
<SelectItem value="ssl_expiring">SSL Expiring</SelectItem>
<SelectItem value="system_offline">System Offline</SelectItem>
<SelectItem value="maintenance">Maintenance</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={!title}>
Create Incident
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// Main Status Page Manager Component
export function StatusPageManager() {
const { toast } = useToast()
const queryClient = useQueryClient()
const [activeTab, setActiveTab] = useState("overview")
const [statusPageDialogOpen, setStatusPageDialogOpen] = useState(false)
const [editingPage, setEditingPage] = useState<StatusPage | null>(null)
const [createIncidentOpen, setCreateIncidentOpen] = useState(false)
const [incidentFilter, setIncidentFilter] = useState<string>("all")
const [searchQuery, setSearchQuery] = useState("")
// Fetch data
const { data: pages, isLoading: pagesLoading } = useQuery({
queryKey: ["status-pages"],
queryFn: getStatusPages,
})
const { data: incidents, isLoading: incidentsLoading } = useQuery({
queryKey: ["incidents", incidentFilter],
queryFn: () => getIncidents(incidentFilter === "all" ? {} : { status: incidentFilter }),
})
const { data: stats } = useQuery({
queryKey: ["incident-stats"],
queryFn: getIncidentStats,
})
// Mutations
const deletePageMutation = useMutation({
mutationFn: deleteStatusPage,
onSuccess: () => {
toast({ title: "Status page deleted" })
queryClient.invalidateQueries({ queryKey: ["status-pages"] })
},
onError: (error: Error) => {
toast({ title: "Failed to delete", description: error.message, variant: "destructive" })
},
})
const createIncidentMutation = useMutation({
mutationFn: createIncident,
onSuccess: () => {
toast({ title: "Incident created" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
queryClient.invalidateQueries({ queryKey: ["incident-stats"] })
},
onError: (error: Error) => {
toast({ title: "Failed to create incident", description: error.message, variant: "destructive" })
},
})
const acknowledgeMutation = useMutation({
mutationFn: acknowledgeIncident,
onSuccess: () => {
toast({ title: "Incident acknowledged" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
},
})
const resolveMutation = useMutation({
mutationFn: (id: string) => resolveIncident(id),
onSuccess: () => {
toast({ title: "Incident resolved" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
queryClient.invalidateQueries({ queryKey: ["incident-stats"] })
},
})
const closeMutation = useMutation({
mutationFn: closeIncident,
onSuccess: () => {
toast({ title: "Incident closed" })
queryClient.invalidateQueries({ queryKey: ["incidents"] })
queryClient.invalidateQueries({ queryKey: ["incident-stats"] })
},
})
// Filtered incidents
const filteredIncidents = useMemo(() => {
if (!incidents) return []
if (!searchQuery) return incidents
return incidents.filter(
(i) =>
i.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
i.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
}, [incidents, searchQuery])
// Active incidents count
const activeIncidents = useMemo(
() => incidents?.filter((i) => i.status === "open" || i.status === "acknowledged").length || 0,
[incidents]
)
const handleEdit = (page: StatusPage) => {
setEditingPage(page)
setStatusPageDialogOpen(true)
}
const handleAdd = () => {
setEditingPage(null)
setStatusPageDialogOpen(true)
}
const handleDelete = (page: StatusPage) => {
if (confirm(`Delete "${page.name}"? This will unlink all ${page.monitor_count} monitor(s).`)) {
deletePageMutation.mutate(page.id)
}
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold tracking-tight">Status Page Manager</h1>
<p className="text-muted-foreground">
Manage status pages, incidents, and public communications
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setCreateIncidentOpen(true)}>
<AlertTriangle className="mr-2 h-4 w-4" />
New Incident
</Button>
<Button onClick={handleAdd}>
<Plus className="mr-2 h-4 w-4" />
New Status Page
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<QuickStatCard
title="Status Pages"
value={pages?.length || 0}
subtitle={`${pages?.filter((p) => p.public).length || 0} public`}
icon={LayoutTemplate}
color="blue"
/>
<QuickStatCard
title="Active Incidents"
value={activeIncidents}
subtitle={`${incidents?.filter((i) => i.severity === "critical").length || 0} critical`}
icon={AlertTriangle}
color={activeIncidents > 0 ? "red" : "green"}
/>
<QuickStatCard
title="Total Monitors"
value={pages?.reduce((acc, p) => acc + p.monitor_count, 0) || 0}
subtitle="Across all pages"
icon={Activity}
color="purple"
/>
<QuickStatCard
title="MTTR (Hours)"
value={stats?.mttr_hours?.toFixed(1) || "-"}
subtitle="Mean time to resolution"
icon={Clock}
color="yellow"
/>
</div>
{/* Main Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="pages">
Status Pages
{pages && pages.length > 0 && (
<Badge variant="secondary" className="ml-2">
{pages.length}
</Badge>
)}
</TabsTrigger>
<TabsTrigger value="incidents">
Incidents
{activeIncidents > 0 && (
<Badge variant="destructive" className="ml-2">
{activeIncidents}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
{/* Recent Status Pages */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<LayoutTemplate className="h-5 w-5" />
Recent Status Pages
</CardTitle>
<CardDescription>
Your public and private status pages
</CardDescription>
</CardHeader>
<CardContent>
{pagesLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-muted rounded animate-pulse" />
))}
</div>
) : pages?.length === 0 ? (
<div className="text-center py-8">
<LayoutTemplate className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
<p className="text-sm text-muted-foreground">No status pages yet</p>
<Button variant="outline" size="sm" className="mt-2" onClick={handleAdd}>
Create one
</Button>
</div>
) : (
<div className="space-y-2">
{pages?.slice(0, 5).map((page) => (
<div
key={page.id}
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
{page.public ? (
<Globe className="h-4 w-4 text-green-500" />
) : (
<Lock className="h-4 w-4 text-muted-foreground" />
)}
<div>
<p className="font-medium text-sm">{page.name}</p>
<p className="text-xs text-muted-foreground">
{page.monitor_count} monitors
</p>
</div>
</div>
<div className="flex items-center gap-2">
{page.public && (
<a
href={getStatusPageUrl(page.slug)}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(page)}
>
Edit
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Recent Incidents */}
<Card>
<CardHeader>
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Active Incidents
</CardTitle>
<CardDescription>
Incidents requiring attention
</CardDescription>
</CardHeader>
<CardContent>
{incidentsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-12 bg-muted rounded animate-pulse" />
))}
</div>
) : filteredIncidents.filter((i) => i.status !== "closed").length === 0 ? (
<div className="text-center py-8">
<CheckCircle2 className="h-8 w-8 text-green-500 mx-auto mb-2" />
<p className="text-sm text-muted-foreground">All clear! No active incidents.</p>
</div>
) : (
<div className="space-y-2">
{filteredIncidents
.filter((i) => i.status !== "closed")
.slice(0, 5)
.map((incident) => (
<div
key={incident.id}
className="flex items-center justify-between p-3 rounded-lg border hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<Badge className={getSeverityColor(incident.severity)}>
{incident.severity}
</Badge>
<div>
<p className="font-medium text-sm">{incident.title}</p>
<p className="text-xs text-muted-foreground">
{formatDuration(incident.started_at)}
</p>
</div>
</div>
<IncidentQuickActions
incident={incident}
onAcknowledge={acknowledgeMutation.mutate}
onResolve={resolveMutation.mutate}
onClose={closeMutation.mutate}
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Status Pages Tab */}
<TabsContent value="pages">
<Card>
<CardHeader>
<CardTitle>All Status Pages</CardTitle>
<CardDescription>
Manage your public and private status pages
</CardDescription>
</CardHeader>
<CardContent>
{pagesLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted rounded animate-pulse" />
))}
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Monitors</TableHead>
<TableHead>Visibility</TableHead>
<TableHead>Updated</TableHead>
<TableHead className="w-[150px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pages?.map((page) => (
<TableRow key={page.id}>
<TableCell className="font-medium">{page.name}</TableCell>
<TableCell>{page.slug}</TableCell>
<TableCell>{page.monitor_count}</TableCell>
<TableCell>
{page.public ? (
<Badge variant="default" className="bg-green-500">
<Globe className="mr-1 h-3 w-3" />
Public
</Badge>
) : (
<Badge variant="secondary">
<Lock className="mr-1 h-3 w-3" />
Private
</Badge>
)}
</TableCell>
<TableCell>
{new Date(page.updated).toLocaleDateString()}
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{page.public && (
<Button
variant="ghost"
size="icon"
asChild
>
<a
href={getStatusPageUrl(page.slug)}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="h-4 w-4" />
</a>
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(page)}
>
<Wrench className="mr-1 h-3.5 w-3.5" />
Edit
</Button>
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => handleDelete(page)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
{/* Incidents Tab */}
<TabsContent value="incidents">
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<CardTitle>All Incidents</CardTitle>
<CardDescription>
Manage and track all incidents
</CardDescription>
</div>
<div className="flex items-center gap-2">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search incidents..."
className="pl-8 w-[200px]"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
<Select value={incidentFilter} onValueChange={setIncidentFilter}>
<SelectTrigger className="w-[130px]">
<Filter className="mr-2 h-4 w-4" />
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="acknowledged">Acknowledged</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{incidentsLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-muted rounded animate-pulse" />
))}
</div>
) : filteredIncidents.length === 0 ? (
<div className="text-center py-12">
<CheckCircle2 className="h-12 w-12 text-green-500 mx-auto mb-4" />
<p className="text-lg font-medium">No incidents found</p>
<p className="text-muted-foreground">
{searchQuery
? "Try adjusting your search or filters"
: "All systems are running smoothly"}
</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Title</TableHead>
<TableHead>Severity</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Started</TableHead>
<TableHead className="w-[250px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredIncidents.map((incident) => (
<TableRow key={incident.id}>
<TableCell className="font-medium max-w-[300px] truncate">
{incident.title}
</TableCell>
<TableCell>
<Badge className={getSeverityColor(incident.severity)}>
{incident.severity}
</Badge>
</TableCell>
<TableCell>
<Badge
variant="outline"
className={getStatusColor(incident.status)}
>
{incident.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(incident.started_at)}</TableCell>
<TableCell>
{new Date(incident.started_at).toLocaleDateString()}
</TableCell>
<TableCell>
<IncidentQuickActions
incident={incident}
onAcknowledge={acknowledgeMutation.mutate}
onResolve={resolveMutation.mutate}
onClose={closeMutation.mutate}
/>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Dialogs */}
<StatusPageDialog
open={statusPageDialogOpen}
onOpenChange={setStatusPageDialogOpen}
page={editingPage}
isEdit={!!editingPage}
/>
<CreateIncidentDialog
open={createIncidentOpen}
onOpenChange={setCreateIncidentOpen}
onCreate={createIncidentMutation.mutate}
/>
</div>
)
}
@@ -6,7 +6,6 @@ import { getPagePath } from "@nanostores/router"
import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table" import type { CellContext, ColumnDef, HeaderContext } from "@tanstack/react-table"
import type { ClassValue } from "clsx" import type { ClassValue } from "clsx"
import { import {
ArrowUpDownIcon,
ChevronRightSquareIcon, ChevronRightSquareIcon,
ClockArrowUp, ClockArrowUp,
CopyIcon, CopyIcon,
@@ -265,7 +264,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
id: "temp", id: "temp",
name: () => t({ message: "Temp", comment: "Temperature label in systems table" }), name: () => t({ message: "Temp", comment: "Temperature label in systems table" }),
size: 50, size: 50,
hideSort: true,
Icon: ThermometerIcon, Icon: ThermometerIcon,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
@@ -289,7 +287,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
size: 70, size: 70,
Icon: BatteryMediumIcon, Icon: BatteryMediumIcon,
header: sortableHeader, header: sortableHeader,
hideSort: true,
cell(info) { cell(info) {
const [pct, state] = info.row.original.info.bat ?? [] const [pct, state] = info.row.original.info.bat ?? []
if (pct === undefined) { if (pct === undefined) {
@@ -335,7 +332,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
size: 50, size: 50,
Icon: TerminalSquareIcon, Icon: TerminalSquareIcon,
header: sortableHeader, header: sortableHeader,
hideSort: true,
sortingFn: (a, b) => { sortingFn: (a, b) => {
// sort priorities: 1) failed services, 2) total services // sort priorities: 1) failed services, 2) total services
const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0] const [totalCountA, numFailedA] = a.original.info.sv ?? [0, 0]
@@ -374,7 +370,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
size: 50, size: 50,
Icon: ClockArrowUp, Icon: ClockArrowUp,
header: sortableHeader, header: sortableHeader,
hideSort: true,
cell(info) { cell(info) {
const uptime = info.getValue() as number const uptime = info.getValue() as number
if (!uptime) { if (!uptime) {
@@ -389,7 +384,6 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
name: () => t`Agent`, name: () => t`Agent`,
size: 50, size: 50,
Icon: WifiIcon, Icon: WifiIcon,
hideSort: true,
header: sortableHeader, header: sortableHeader,
cell(info) { cell(info) {
const version = info.getValue() as string const version = info.getValue() as string
@@ -443,17 +437,16 @@ export function SystemsTableColumns(viewMode: "table" | "grid"): ColumnDef<Syste
function sortableHeader(context: HeaderContext<SystemRecord, unknown>) { function sortableHeader(context: HeaderContext<SystemRecord, unknown>) {
const { column } = context const { column } = context
// @ts-expect-error // @ts-expect-error
const { Icon, hideSort, name }: { Icon: React.ElementType; name: () => string; hideSort: boolean } = column.columnDef const { Icon, name }: { Icon: React.ElementType; name: () => string } = column.columnDef
const isSorted = column.getIsSorted() const isSorted = column.getIsSorted()
return ( return (
<Button <Button
variant="ghost" variant="ghost"
className={cn("h-9 px-3 flex duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")} className={cn("h-9 px-3 flex items-center gap-2 duration-50", isSorted && "bg-accent/70 light:bg-accent text-accent-foreground/90")}
onClick={() => column.toggleSorting(column.getIsSorted() === "asc")} onClick={() => column.toggleSorting(column.getIsSorted() === "asc")}
> >
{Icon && <Icon className="me-2 size-4" />} {Icon && <Icon className="size-4" />}
{name()} {name()}
{hideSort || <ArrowUpDownIcon className="ms-2 size-4" />}
</Button> </Button>
) )
} }
@@ -148,11 +148,7 @@ export default function SystemsTable() {
</CardDescription> </CardDescription>
</div> </div>
<div className="flex gap-2 ms-auto w-full md:w-80"> <div className="flex gap-2 ms-auto w-full md:w-96">
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
<div className="relative flex-1"> <div className="relative flex-1">
<Input <Input
placeholder={t`Filter...`} placeholder={t`Filter...`}
@@ -292,6 +288,10 @@ export default function SystemsTable() {
</div> </div>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<Button onClick={() => setIsAddDialogOpen(true)} className="shrink-0">
<PlusIcon className="mr-2 h-4 w-4" />
<Trans>Add System</Trans>
</Button>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
@@ -396,7 +396,6 @@ const AllSystemsTable = memo(
) )
function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) { function SystemsTableHead({ table }: { table: TableType<SystemRecord> }) {
const { t } = useLingui()
return ( return (
<TableHeader className="sticky top-0 z-50 w-full border-b-2"> <TableHeader className="sticky top-0 z-50 w-full border-b-2">
{table.getHeaderGroups().map((headerGroup) => ( {table.getHeaderGroups().map((headerGroup) => (
+77
View File
@@ -1,5 +1,20 @@
import { pb } from "./api" import { pb } from "./api"
export interface Subdomain {
id: string
domain: string
subdomain_name: string
full_domain: string
status: "active" | "inactive" | "error"
ip_addresses?: string
http_status?: number
server_header?: string
discovery_source: string
last_checked?: string
created: string
updated: string
}
export interface Domain { export interface Domain {
id: string id: string
domain_name: string domain_name: string
@@ -377,3 +392,65 @@ export function cleanDomain(domain: string): string {
.toLowerCase() .toLowerCase()
.trim() .trim()
} }
// Subdomain API functions
export async function getDomainSubdomains(domainId: string): Promise<Subdomain[]> {
const response = await fetch(`/api/beszel/domains/${domainId}/subdomains`, {
headers: {
Authorization: `Bearer ${pb.authStore.token}`,
},
})
if (!response.ok) {
throw new Error(`Failed to fetch subdomains: ${response.statusText}`)
}
return response.json()
}
export async function refreshSubdomainDiscovery(domainId: string): Promise<void> {
const response = await fetch(`/api/beszel/domains/${domainId}/discover-subdomains`, {
method: "POST",
headers: {
Authorization: `Bearer ${pb.authStore.token}`,
},
})
if (!response.ok) {
throw new Error(`Failed to start subdomain discovery: ${response.statusText}`)
}
}
export async function deleteSubdomain(subdomainId: string): Promise<void> {
const response = await fetch(`/api/beszel/subdomains/${subdomainId}`, {
method: "DELETE",
headers: {
Authorization: `Bearer ${pb.authStore.token}`,
},
})
if (!response.ok) {
throw new Error(`Failed to delete subdomain: ${response.statusText}`)
}
}
export function extractDomainFromUrl(url: string): string {
try {
const urlObj = new URL(url.startsWith("http") ? url : `https://${url}`)
return urlObj.hostname.toLowerCase()
} catch {
return cleanDomain(url)
}
}
export function isSubdomain(fullDomain: string, parentDomain: string): boolean {
const cleanFull = cleanDomain(fullDomain)
const cleanParent = cleanDomain(parentDomain)
return cleanFull.endsWith(`.${cleanParent}`) || cleanFull === cleanParent
}
export function getSubdomainName(fullDomain: string, parentDomain: string): string {
const cleanFull = cleanDomain(fullDomain)
const cleanParent = cleanDomain(parentDomain)
if (cleanFull === cleanParent) return "@"
if (cleanFull.endsWith(`.${cleanParent}`)) {
return cleanFull.slice(0, -cleanParent.length - 1)
}
return cleanFull
}
+96
View File
@@ -51,6 +51,7 @@ export interface Monitor {
description?: string description?: string
last_check?: string last_check?: string
uptime_stats?: Record<string, number> uptime_stats?: Record<string, number>
recent_heartbeats?: Array<{ status: string; time: string; ping?: number }>
tags?: string[] tags?: string[]
keyword?: string keyword?: string
json_query?: string json_query?: string
@@ -338,3 +339,98 @@ export function formatPing(ping: number): string {
if (ping < 1000) return `${ping}ms` if (ping < 1000) return `${ping}ms`
return `${(ping / 1000).toFixed(2)}s` return `${(ping / 1000).toFixed(2)}s`
} }
// Domain extraction and grouping utilities
export function extractHostnameFromMonitor(monitor: Monitor): string | null {
if (monitor.hostname) {
return monitor.hostname.toLowerCase()
}
if (monitor.url) {
try {
const url = new URL(monitor.url.startsWith("http") ? monitor.url : `https://${monitor.url}`)
return url.hostname.toLowerCase()
} catch {
return monitor.url.toLowerCase()
}
}
return null
}
export function getDomainFromHostname(hostname: string): string {
// Remove www prefix
const clean = hostname.replace(/^www\./, "")
// Extract root domain (last 2 parts for most domains, last 3 for co.uk etc)
const parts = clean.split(".")
if (parts.length <= 2) {
return clean
}
// Handle special TLDs
const specialTLDs = ["co.uk", "com.au", "co.jp", "com.br", "co.nz", "co.za", "co.in", "com.cn"]
const lastTwo = parts.slice(-2).join(".")
const lastThree = parts.slice(-3).join(".")
if (specialTLDs.includes(lastThree)) {
return lastThree
}
return lastTwo
}
export function isSubdomain(hostname: string, domain: string): boolean {
const cleanHostname = hostname.toLowerCase().replace(/^www\./, "")
const cleanDomain = domain.toLowerCase().replace(/^www\./, "")
return cleanHostname.endsWith(`.${cleanDomain}`) || cleanHostname === cleanDomain
}
export function getSubdomainPart(hostname: string, domain: string): string | null {
const cleanHostname = hostname.toLowerCase().replace(/^www\./, "")
const cleanDomain = domain.toLowerCase().replace(/^www\./, "")
if (cleanHostname === cleanDomain) {
return "@" // Root domain
}
if (cleanHostname.endsWith(`.${cleanDomain}`)) {
return cleanHostname.slice(0, -cleanDomain.length - 1)
}
return null
}
export interface GroupedMonitors {
domain: string
isRootDomain: boolean
monitors: Monitor[]
subdomains: Map<string, Monitor[]>
}
export function groupMonitorsByDomain(monitors: Monitor[]): Map<string, GroupedMonitors> {
const groups = new Map<string, GroupedMonitors>()
for (const monitor of monitors) {
const hostname = extractHostnameFromMonitor(monitor)
if (!hostname) continue
const rootDomain = getDomainFromHostname(hostname)
const subdomain = getSubdomainPart(hostname, rootDomain)
if (!groups.has(rootDomain)) {
groups.set(rootDomain, {
domain: rootDomain,
isRootDomain: true,
monitors: [],
subdomains: new Map(),
})
}
const group = groups.get(rootDomain)!
if (subdomain === "@" || subdomain === null) {
// Root domain monitor
group.monitors.push(monitor)
} else {
// Subdomain monitor
if (!group.subdomains.has(subdomain)) {
group.subdomains.set(subdomain, [])
}
group.subdomains.get(subdomain)!.push(monitor)
}
}
return groups
}
Submodule reference/domain-locker deleted from 9c2962de33
Submodule reference/uptime-kuma deleted from 2f45b46315