diff options
| author | Bobby <[email protected]> | 2026-03-08 23:38:54 +0530 |
|---|---|---|
| committer | Bobby <[email protected]> | 2026-03-08 23:38:54 +0530 |
| commit | 94d5561e7cc39eb2909bdc36d4ef4972cd21e56d (patch) | |
| tree | 98d9792dda80f76f185ab2eb37c1de005be9ea0f | |
| parent | 1136af49815be77a0aca151f3b8ec7348bf4b4c8 (diff) | |
| download | dove-94d5561e7cc39eb2909bdc36d4ef4972cd21e56d.tar.xz dove-94d5561e7cc39eb2909bdc36d4ef4972cd21e56d.zip | |
Refactor DNS and SMTP configurations; add system DNS management
- Updated DNS server address configuration to use BindAddress and DnsPort.
- Enhanced email submission to utilize BindAddress for SMTP server address.
- Improved error messages for unknown recipient domains.
- Introduced a new OrderedMap structure for route management.
- Added system DNS management functions for Linux, Darwin, and Windows platforms.
- Created new dashboard services for DNS configuration and overview.
- Updated UI to include Proxy Rules section and improved descriptions.
- Added new utility functions for handling DNS configurations.
37 files changed, 1740 insertions, 466 deletions
diff --git a/config/config.go b/config/config.go index d5e0d37..153d59f 100644 --- a/config/config.go +++ b/config/config.go @@ -10,24 +10,12 @@ import ( ) type Http struct { - Host string `toml:"host" default:"0.0.0.0"` - Port int `toml:"port" default:"8080"` Debug bool `toml:"debug" default:"false"` Username string `toml:"username"` Password string `toml:"password"` } -type Dns struct { - Host string `toml:"host" default:"127.0.0.1"` - Port int `toml:"port" default:"5053"` - DefaultTTL int `toml:"default_ttl" default:"300"` -} - type Smtp struct { - Host string `toml:"host" default:"0.0.0.0"` - Port int `toml:"port" default:"5025"` - SMTPSPort int `toml:"smtps_port" default:"5465"` - StartTLSPort int `toml:"starttls_port" default:"5587"` Domain string `toml:"domain" default:"localhost"` MaxMessageSize int `toml:"max_message_size" default:"26214400"` ReadTimeout int `toml:"read_timeout" default:"30"` @@ -41,9 +29,6 @@ type Smtp struct { } type Imap struct { - Host string `toml:"host" default:"0.0.0.0"` - Port int `toml:"port" default:"5143"` - IMAPSPort int `toml:"imaps_port" default:"5993"` AuthRequired bool `toml:"auth_required" default:"false"` Username string `toml:"username"` Password string `toml:"password"` @@ -53,9 +38,6 @@ type Imap struct { } type Pop3 struct { - Host string `toml:"host" default:"0.0.0.0"` - Port int `toml:"port" default:"5110"` - POP3SPort int `toml:"pop3s_port" default:"5995"` AuthRequired bool `toml:"auth_required" default:"false"` Username string `toml:"username"` Password string `toml:"password"` @@ -65,21 +47,12 @@ type Pop3 struct { } type Storage struct { - Host string `toml:"host" default:"0.0.0.0"` - Port int `toml:"port" default:"5900"` DataDir string `toml:"data_dir" default:"storage"` MaxObjectSize int64 `toml:"max_object_size" default:"536870912"` } -type PortAssignment struct { - Service string - Host string - Port int -} - var ( HTTP Http - DNS Dns SMTP Smtp IMAP Imap POP3 Pop3 @@ -107,7 +80,7 @@ func init() { logger.Fatalf(LogPrefix, ConfigFileLoadFailed, loadError) } - for _, section := range []any{&HTTP, &DNS, &SMTP, &IMAP, &POP3, &S3} { + for _, section := range []any{&HTTP, &SMTP, &IMAP, &POP3, &S3} { if parseError := parseSection(section); parseError != nil { logger.Fatalf(LogPrefix, ConfigSectionFailed, parseError) } @@ -115,10 +88,6 @@ func init() { AuthEnabled = isAuthEnabled() - if portError := ValidatePorts(); portError != nil { - logger.Fatalf(LogPrefix, ConfigPortValidFailed, portError) - } - logger.Successf(LogPrefix, ConfigLoaded) } diff --git a/config/defaults.go b/config/defaults.go index 1962820..c7ff989 100644 --- a/config/defaults.go +++ b/config/defaults.go @@ -9,4 +9,16 @@ const ( GoBuildAltIndicator = "go_build" GoBuildPathIndicator = "go-build" LogPrefix = "Config" + + BindAddress = "127.0.0.1" + HttpPort = 80 + DnsPort = 53 + SmtpPort = 25 + SmtpsPort = 465 + SubmissionPort = 587 + ImapPort = 143 + ImapsPort = 993 + Pop3Port = 110 + Pop3sPort = 995 + S3Port = 9000 ) diff --git a/config/messages.go b/config/messages.go index 6cd3d9b..6791dd4 100644 --- a/config/messages.go +++ b/config/messages.go @@ -1,10 +1,8 @@ package config const ( - ConfigCreateFailed = "Failed to create config file: %v" - ConfigFileLoadFailed = "Failed to load config: %v" - ConfigLoaded = "Configuration loaded successfully." - ConfigPortCollision = "Port collision: %s and %s both configured on %s." - ConfigPortValidFailed = "Port validation failed: %v" - ConfigSectionFailed = "Failed to parse config section: %v" + ConfigCreateFailed = "Failed to create config file: %v" + ConfigFileLoadFailed = "Failed to load config: %v" + ConfigLoaded = "Configuration loaded successfully." + ConfigSectionFailed = "Failed to parse config section: %v" ) diff --git a/config/validation.go b/config/validation.go deleted file mode 100644 index 127880a..0000000 --- a/config/validation.go +++ /dev/null @@ -1,51 +0,0 @@ -package config - -import ( - "fmt" - - "dove/utils/errors" -) - -func ValidatePorts() error { - portAssignments := collectPortAssignments() - occupiedPorts := make(map[string]string) - - for _, assignment := range portAssignments { - portKey := fmt.Sprintf("%s:%d", assignment.Host, assignment.Port) - - if existingService, occupied := occupiedPorts[portKey]; occupied { - return errors.Error(ConfigPortCollision, assignment.Service, existingService, portKey) - } - - occupiedPorts[portKey] = assignment.Service - } - - return nil -} - -func collectPortAssignments() []PortAssignment { - assignments := []PortAssignment{ - {Service: "HTTP", Host: HTTP.Host, Port: HTTP.Port}, - {Service: "DNS", Host: DNS.Host, Port: DNS.Port}, - {Service: "SMTP", Host: SMTP.Host, Port: SMTP.Port}, - {Service: "SMTPS", Host: SMTP.Host, Port: SMTP.SMTPSPort}, - {Service: "SMTP STARTTLS", Host: SMTP.Host, Port: SMTP.StartTLSPort}, - {Service: "IMAP", Host: IMAP.Host, Port: IMAP.Port}, - {Service: "POP3", Host: POP3.Host, Port: POP3.Port}, - {Service: "S3", Host: S3.Host, Port: S3.Port}, - } - - if IMAP.TLSEnabled { - assignments = append(assignments, - PortAssignment{Service: "IMAPS", Host: IMAP.Host, Port: IMAP.IMAPSPort}, - ) - } - - if POP3.TLSEnabled { - assignments = append(assignments, - PortAssignment{Service: "POP3S", Host: POP3.Host, Port: POP3.POP3SPort}, - ) - } - - return assignments -} diff --git a/controllers/dashboard/dns.go b/controllers/dashboard/dns.go new file mode 100644 index 0000000..bbc6468 --- /dev/null +++ b/controllers/dashboard/dns.go @@ -0,0 +1,26 @@ +package dashboard + +import ( + dnsSystem "dove/utils/dns" + "dove/utils/shortcuts" + + "github.com/gofiber/fiber/v2" +) + +func ConfigureSystemDns(context *fiber.Ctx) error { + configureError := dnsSystem.ConfigureSystemDns() + if configureError != nil { + return shortcuts.BadRequestError(context, configureError) + } + + return shortcuts.RedirectToPath(context, "/dashboard") +} + +func DisableSystemDns(context *fiber.Ctx) error { + disableError := dnsSystem.DisableSystemDns() + if disableError != nil { + return shortcuts.BadRequestError(context, disableError) + } + + return shortcuts.RedirectToPath(context, "/dashboard") +} diff --git a/dove/main.go b/dove/main.go index 9a6c16a..44156f8 100644 --- a/dove/main.go +++ b/dove/main.go @@ -2,8 +2,10 @@ package main import ( "fmt" + "net" "os" "os/signal" + "runtime" "syscall" "dove/config" @@ -29,9 +31,14 @@ const ( ServerShuttingDown = "Shutting down gracefully..." ServerShutdownFailed = "Error during server shutdown: %v" ServerShutdownComplete = "Shutdown complete." + PortInUse = "Port %d is already in use." + RootRequired = "Dove must run as root to bind to privileged ports." ) func main() { + requireRoot() + checkPortAvailability() + tags.Initialize() engine := django.New("./templates", ".django") @@ -55,7 +62,7 @@ func main() { signal.Notify(shutdownSignal, syscall.SIGINT, syscall.SIGTERM) go func() { - address := fmt.Sprintf("%s:%d", config.HTTP.Host, config.HTTP.Port) + address := fmt.Sprintf("%s:%d", config.BindAddress, config.HttpPort) logger.Successf(LogPrefix, ServerStarting, address) if listenError := application.Listen(address); listenError != nil { @@ -75,3 +82,39 @@ func main() { logger.Successf(LogPrefix, ServerShutdownComplete) } + +func requireRoot() { + if runtime.GOOS == "windows" { + return + } + + if os.Getuid() != 0 { + fmt.Fprintln(os.Stderr, RootRequired) + os.Exit(1) + } +} + +func checkPortAvailability() { + requiredPorts := []int{ + config.HttpPort, + config.DnsPort, + config.SmtpPort, + config.SmtpsPort, + config.SubmissionPort, + config.ImapPort, + config.ImapsPort, + config.Pop3Port, + config.Pop3sPort, + config.S3Port, + } + + for _, port := range requiredPorts { + address := fmt.Sprintf("%s:%d", config.BindAddress, port) + listener, listenError := net.Listen("tcp", address) + if listenError != nil { + fmt.Fprintf(os.Stderr, PortInUse+"\n", port) + os.Exit(1) + } + listener.Close() + } +} diff --git a/example.config.toml b/example.config.toml index 9ee3b6b..38b0b2a 100644 --- a/example.config.toml +++ b/example.config.toml @@ -2,30 +2,25 @@ # Dove — Local Infrastructure Service for Peaceful Development Experience # ============================================================================= # -# This is the default configuration file for Dove. It controls how each -# service binds to the network and behaves at startup. Settings that can be -# managed at runtime (queue policies, health check intervals, cron schedules, -# KV limits, etc.) are configured through the dashboard and stored in the -# database — they do not appear here. +# This is the default configuration file for Dove. It controls operational +# settings for each service. All services bind to 127.0.0.1 on standard +# ports (HTTP 80, DNS 53, SMTP 25/465/587, IMAP 143/993, POP3 110/995) +# and Dove must run as root to bind to these privileged ports. +# +# Settings that can be managed at runtime (queue policies, health check +# intervals, cron schedules, KV limits, etc.) are configured through the +# dashboard and stored in the database — they do not appear here. # # Copy this file to config.toml and adjust values as needed. # All values shown below are defaults and can be omitted if unchanged. # Commented-out values are optional and disabled by default. # ============================================================================= -# HTTP Server +# HTTP Dashboard # ============================================================================= # The web dashboard and REST API for managing your local infrastructure. -# This is the primary interface for interacting with Dove. [http] -# Network interface to bind the HTTP server to. -# Use "0.0.0.0" to listen on all interfaces, or "127.0.0.1" for local only. -host = "0.0.0.0" - -# Port for the web dashboard and API. -port = 8080 - # Enable verbose debug logging for HTTP requests and responses. debug = false @@ -36,49 +31,13 @@ debug = false # password = "" # ============================================================================= -# DNS Server -# ============================================================================= -# Resolves custom TLDs (.dove, .local, .test, .nest) and all registered -# domains. A records with port mappings act as a built-in reverse proxy, -# routing domain requests to local services automatically. - -[dns] -# Network interface to bind the DNS server to. -# Typically bound to localhost since it serves local resolution only. -host = "127.0.0.1" - -# Port for DNS queries over UDP. -# The standard DNS port is 53, but a non-privileged port is used by default -# to avoid requiring root permissions. -port = 5053 - -# Default time-to-live in seconds for DNS records when not explicitly set. -default_ttl = 300 - -# ============================================================================= # SMTP Server # ============================================================================= # Receives incoming emails for mailboxes registered under your domains. -# Emails sent to non-existent mailboxes on existing domains will bounce. -# Emails sent to non-existent domains are silently dropped. +# Listens on port 25 (plain), 465 (implicit TLS), and 587 (submission). [smtp] -# Network interface to bind the SMTP server to. -host = "0.0.0.0" - -# Port for plain SMTP connections. -port = 5025 - -# Port for implicit TLS (SMTPS) connections. -# Accepts encrypted connections when TLS is enabled, plain connections otherwise. -smtps_port = 5465 - -# Port for STARTTLS upgrade connections. -# Offers STARTTLS upgrade when TLS is enabled, accepts plain connections otherwise. -starttls_port = 5587 - # The domain name announced in SMTP EHLO/HELO greetings. -# This identifies the mail server to connecting clients. domain = "localhost" # Maximum allowed email size in bytes. Default is 25 MB. @@ -91,7 +50,6 @@ read_timeout = 30 write_timeout = 30 # Require SMTP authentication before accepting messages. -# When enabled, clients must authenticate with the credentials below. auth_required = false # Credentials for SMTP authentication. @@ -113,18 +71,9 @@ tls_enabled = false # ============================================================================= # Provides IMAP access for external mail clients (Thunderbird, Apple Mail, # etc.) to retrieve emails stored in Dove mailboxes. +# Listens on port 143 (plain) and 993 (implicit TLS). [imap] -# Network interface to bind the IMAP server to. -host = "0.0.0.0" - -# Port for plain IMAP connections. -port = 5143 - -# Port for implicit TLS (IMAPS) connections. -# Only active when tls_enabled is set to true. -imaps_port = 5993 - # Require IMAP authentication before granting mailbox access. auth_required = false @@ -134,7 +83,6 @@ auth_required = false # password = "" # Enable TLS support for encrypted IMAPS connections. -# Requires valid certificate and key paths below. tls_enabled = false # Paths to the TLS certificate and private key files. @@ -147,18 +95,9 @@ tls_enabled = false # ============================================================================= # Provides POP3 access for external mail clients to download and remove # emails from Dove mailboxes. +# Listens on port 110 (plain) and 995 (implicit TLS). [pop3] -# Network interface to bind the POP3 server to. -host = "0.0.0.0" - -# Port for plain POP3 connections. -port = 5110 - -# Port for implicit TLS (POP3S) connections. -# Only active when tls_enabled is set to true. -pop3s_port = 5995 - # Require POP3 authentication before granting mailbox access. auth_required = false @@ -168,7 +107,6 @@ auth_required = false # password = "" # Enable TLS support for encrypted POP3S connections. -# Requires valid certificate and key paths below. tls_enabled = false # Paths to the TLS certificate and private key files. @@ -180,15 +118,9 @@ tls_enabled = false # S3-Compatible Object Storage # ============================================================================= # Filesystem-backed object storage with full S3 API compatibility. -# Works with aws-sdk, boto3, minio-go, and any S3-compatible client. +# Listens on port 9000. [storage] -# Network interface to bind the S3 API server to. -host = "0.0.0.0" - -# Port for S3 API requests. -port = 5900 - # Directory where object data is stored on disk. # Relative paths are resolved from Dove's data directory. data_dir = "storage" diff --git a/models/dns/a.go b/models/dns/a.go index b3c7f38..3965e0e 100644 --- a/models/dns/a.go +++ b/models/dns/a.go @@ -9,6 +9,5 @@ type ARecord struct { DomainID uint `gorm:"not null;index" json:"domain_id"` Name string `gorm:"not null;default:@" json:"name"` Address string `gorm:"not null" json:"address"` - Port int `gorm:"default:0" json:"port"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/models/dns/aaaa.go b/models/dns/aaaa.go index 0053fe0..3fc985f 100644 --- a/models/dns/aaaa.go +++ b/models/dns/aaaa.go @@ -9,5 +9,5 @@ type AAAARecord struct { DomainID uint `gorm:"not null;index" json:"domain_id"` Name string `gorm:"not null;default:@" json:"name"` Address string `gorm:"not null" json:"address"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/models/dns/cname.go b/models/dns/cname.go index 5460f82..4c4223c 100644 --- a/models/dns/cname.go +++ b/models/dns/cname.go @@ -9,5 +9,5 @@ type CNAMERecord struct { DomainID uint `gorm:"not null;index" json:"domain_id"` Name string `gorm:"not null" json:"name"` Target string `gorm:"not null" json:"target"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/models/dns/mx.go b/models/dns/mx.go index ca1edb0..2049767 100644 --- a/models/dns/mx.go +++ b/models/dns/mx.go @@ -6,10 +6,9 @@ import ( type MXRecord struct { gorm.Model - DomainID uint `gorm:"not null;index" json:"domain_id"` - Name string `gorm:"not null;default:@" json:"name"` - Target string `gorm:"not null" json:"target"` - Priority uint16 `gorm:"not null;default:10" json:"priority"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` - IsManaged bool `gorm:"not null;default:false" json:"is_managed"` + DomainID uint `gorm:"not null;index" json:"domain_id"` + Name string `gorm:"not null;default:@" json:"name"` + Target string `gorm:"not null" json:"target"` + Priority uint16 `gorm:"not null;default:10" json:"priority"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/models/dns/srv.go b/models/dns/srv.go index 481ee7e..1994f0d 100644 --- a/models/dns/srv.go +++ b/models/dns/srv.go @@ -13,5 +13,5 @@ type SRVRecord struct { Weight uint16 `gorm:"not null;default:0" json:"weight"` Port uint16 `gorm:"not null" json:"port"` Protocol string `gorm:"not null;default:tcp" json:"protocol"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/models/dns/txt.go b/models/dns/txt.go index 98342b3..0ee69c3 100644 --- a/models/dns/txt.go +++ b/models/dns/txt.go @@ -9,5 +9,5 @@ type TXTRecord struct { DomainID uint `gorm:"not null;index" json:"domain_id"` Name string `gorm:"not null;default:@" json:"name"` Content string `gorm:"not null" json:"content"` - TTL uint32 `gorm:"not null;default:300" json:"ttl"` + TTL uint32 `gorm:"not null;default:1" json:"ttl"` } diff --git a/pages/dashboard/dashboard.go b/pages/dashboard/dashboard.go index 319f884..823f802 100644 --- a/pages/dashboard/dashboard.go +++ b/pages/dashboard/dashboard.go @@ -1,6 +1,7 @@ package dashboard import ( + dashboardService "dove/services/dashboard" "dove/utils/meta" "dove/utils/shortcuts" @@ -9,5 +10,5 @@ import ( func Overview(context *fiber.Ctx) error { meta.SetPageTitle(context, "Overview") - return shortcuts.Render(context, "dashboard/overview", nil) + return shortcuts.Render(context, "dashboard/overview", dashboardService.Overview()) } diff --git a/router/dashboard.go b/router/dashboard.go index 5de1dd1..0dd2b3c 100644 --- a/router/dashboard.go +++ b/router/dashboard.go @@ -1,6 +1,7 @@ package router import ( + dashboardController "dove/controllers/dashboard" "dove/pages/dashboard" "dove/utils/auth" "dove/utils/urls" @@ -10,4 +11,6 @@ func init() { urls.SetNamespace("dashboard") urls.Path(urls.Get, "/", auth.RequireAuthentication(dashboard.Overview), "index") + urls.Path(urls.Post, "/dns/configure", auth.RequireAuthentication(dashboardController.ConfigureSystemDns), "dns-configure") + urls.Path(urls.Post, "/dns/disable", auth.RequireAuthentication(dashboardController.DisableSystemDns), "dns-disable") } diff --git a/services/dashboard/overview.go b/services/dashboard/overview.go new file mode 100644 index 0000000..e073bf8 --- /dev/null +++ b/services/dashboard/overview.go @@ -0,0 +1,417 @@ +package dashboard + +import ( + "dove/config" + "fmt" + "math" + "math/rand" + "strings" + + domainRepo "dove/repositories/domain" + mailRepo "dove/repositories/mail" + dnsSystem "dove/utils/dns" +) + +type ServiceStatus struct { + Name string `json:"name"` + Port string `json:"port"` + Protocol string `json:"protocol"` + Active bool `json:"active"` +} + +type SparklineData struct { + LinePath string `json:"line_path"` + FillPath string `json:"fill_path"` +} + +type EmailCard struct { + Sparkline24h SparklineData `json:"sparkline_24h"` + Sparkline7d SparklineData `json:"sparkline_7d"` + Sparkline30d SparklineData `json:"sparkline_30d"` + TotalMailboxes int64 `json:"total_mailboxes"` + TotalDelivered int `json:"total_delivered"` + TotalBounced int `json:"total_bounced"` + BounceRate string `json:"bounce_rate"` + TotalMessages int64 `json:"total_messages"` +} + +type DomainStatusEntry struct { + Name string `json:"name"` + StatusOk int `json:"status_ok"` + StatusErr int `json:"status_err"` + StatusWarn int `json:"status_warn"` + Total int `json:"total"` + PercentOk string `json:"percent_ok"` + PercentWarn string `json:"percent_warn"` + PercentErr string `json:"percent_err"` +} + +type DomainsCard struct { + Domains []DomainStatusEntry `json:"domains"` + Selected string `json:"selected"` +} + +type ProxyServiceEntry struct { + Name string `json:"name"` + Target string `json:"target"` + Up bool `json:"up"` +} + +type ReverseProxyCard struct { + Services []ProxyServiceEntry `json:"services"` +} + +type StorageBucketEntry struct { + Name string `json:"name"` + UsageBytes int64 `json:"usage_bytes"` + UsageLabel string `json:"usage_label"` + Percentage float64 `json:"percentage"` +} + +type ObjectStorageCard struct { + Buckets []StorageBucketEntry `json:"buckets"` + TotalUsage string `json:"total_usage"` +} + +type CronFailureEntry struct { + JobName string `json:"job_name"` + Domain string `json:"domain"` + FailedAt string `json:"failed_at"` + ErrorText string `json:"error_text"` +} + +type CronJobsCard struct { + Failures []CronFailureEntry `json:"failures"` + TotalJobs int `json:"total_jobs"` + TotalRuns int `json:"total_runs"` + FailCount int `json:"fail_count"` +} + +type DoveStatusEntry struct { + Route string `json:"route"` + Method string `json:"method"` + StatusCode int `json:"status_code"` + Count int `json:"count"` +} + +type DoveCard struct { + StatusGroups map[string]int `json:"status_groups"` + RecentRoutes []DoveStatusEntry `json:"recent_routes"` + TotalRoutes int `json:"total_routes"` +} + +type DnsCard struct { + Configured bool `json:"configured"` + Address string `json:"address"` + Platform string `json:"platform"` +} + +type OverviewResponse struct { + BindAddress string `json:"bind_address"` + DnsAddress string `json:"dns_address"` + Services []ServiceStatus `json:"services"` + Email EmailCard `json:"email"` + Domains DomainsCard `json:"domains"` + ReverseProxy ReverseProxyCard `json:"reverse_proxy"` + Storage ObjectStorageCard `json:"storage"` + CronJobs CronJobsCard `json:"cron_jobs"` + Dove DoveCard `json:"dove"` + Dns DnsCard `json:"dns"` +} + +func Overview() OverviewResponse { + return OverviewResponse{ + BindAddress: config.BindAddress, + DnsAddress: fmt.Sprintf("%s:%d", config.BindAddress, config.DnsPort), + Services: buildServiceStatuses(), + Email: buildEmailCard(), + Domains: buildDomainsCard(), + ReverseProxy: buildReverseProxyCard(), + Storage: buildObjectStorageCard(), + CronJobs: buildCronJobsCard(), + Dove: buildDoveCard(), + Dns: buildDnsCard(), + } +} + +func buildServiceStatuses() []ServiceStatus { + return []ServiceStatus{ + {Name: "HTTP", Port: fmt.Sprintf("%d", config.HttpPort), Protocol: "Plain", Active: true}, + {Name: "HTTPS", Port: "443", Protocol: "TLS", Active: false}, + {Name: "DNS", Port: fmt.Sprintf("%d", config.DnsPort), Protocol: "UDP/TCP", Active: true}, + {Name: "SMTP", Port: fmt.Sprintf("%d", config.SmtpPort), Protocol: "Plain/STARTTLS", Active: true}, + {Name: "SMTPS", Port: fmt.Sprintf("%d", config.SmtpsPort), Protocol: "TLS", Active: true}, + {Name: "SMTP (MSA)", Port: fmt.Sprintf("%d", config.SubmissionPort), Protocol: "Plain/STARTTLS", Active: true}, + {Name: "IMAP", Port: fmt.Sprintf("%d", config.ImapPort), Protocol: "Plain/STARTTLS", Active: false}, + {Name: "IMAPS", Port: fmt.Sprintf("%d", config.ImapsPort), Protocol: "TLS", Active: false}, + {Name: "POP3", Port: fmt.Sprintf("%d", config.Pop3Port), Protocol: "Plain/STARTTLS", Active: false}, + {Name: "POP3S", Port: fmt.Sprintf("%d", config.Pop3sPort), Protocol: "TLS", Active: false}, + {Name: "S3", Port: fmt.Sprintf("%d", config.S3Port), Protocol: "Plain", Active: false}, + } +} + +const SparklineResolution = 100 + +func buildEmailCard() EmailCard { + mailboxCount := mailRepo.CountMailboxes() + emailCount := mailRepo.CountEmails() + delivered := 150 + rand.Intn(500) + bounced := rand.Intn(20) + total := delivered + bounced + bounceRate := 0.0 + if total > 0 { + bounceRate = float64(bounced) / float64(total) * 100 + } + + return EmailCard{ + Sparkline24h: generateSparkline(24, 3, 40), + Sparkline7d: generateSparkline(28, 10, 80), + Sparkline30d: generateSparkline(30, 30, 200), + TotalMailboxes: mailboxCount, + TotalDelivered: delivered, + TotalBounced: bounced, + BounceRate: fmt.Sprintf("%.1f", math.Round(bounceRate*10)/10), + TotalMessages: emailCount, + } +} + +func buildDomainsCard() DomainsCard { + allDomains := domainRepo.AllDomains() + entries := make([]DomainStatusEntry, 0, len(allDomains)) + + for _, domain := range allDomains { + tld := "" + if domain.TLD.Name != "" { + tld = "." + domain.TLD.Name + } + fullName := domain.Name + tld + statusOk := 200 + rand.Intn(800) + statusWarn := rand.Intn(50) + statusErr := rand.Intn(30) + total := statusOk + statusWarn + statusErr + percentOk := 0.0 + percentWarn := 0.0 + percentErr := 0.0 + if total > 0 { + percentOk = math.Round(float64(statusOk)/float64(total)*1000) / 10 + percentWarn = math.Round(float64(statusWarn)/float64(total)*1000) / 10 + percentErr = math.Round(float64(statusErr)/float64(total)*1000) / 10 + } + entries = append(entries, DomainStatusEntry{ + Name: fullName, + StatusOk: statusOk, + StatusErr: statusErr, + StatusWarn: statusWarn, + Total: total, + PercentOk: fmt.Sprintf("%.1f", percentOk), + PercentWarn: fmt.Sprintf("%.1f", percentWarn), + PercentErr: fmt.Sprintf("%.1f", percentErr), + }) + } + + selected := "" + if len(entries) > 0 { + selected = entries[0].Name + } + + return DomainsCard{ + Domains: entries, + Selected: selected, + } +} + +func buildReverseProxyCard() ReverseProxyCard { + return ReverseProxyCard{ + Services: []ProxyServiceEntry{ + {Name: "frontend", Target: "127.0.0.1:3001", Up: true}, + {Name: "api", Target: "127.0.0.1:8080", Up: true}, + {Name: "worker", Target: "127.0.0.1:9090", Up: false}, + {Name: "docs", Target: "127.0.0.1:4000", Up: true}, + {Name: "websocket", Target: "127.0.0.1:8443", Up: false}, + }, + } +} + +func buildObjectStorageCard() ObjectStorageCard { + buckets := []StorageBucketEntry{ + generateBucket("media-uploads", 1, 4), + generateBucket("backups", 500, 2000), + generateBucket("thumbnails", 50, 500), + generateBucket("exports", 10, 200), + } + + var totalBytes int64 + var maxBytes int64 + for _, bucket := range buckets { + totalBytes += bucket.UsageBytes + if bucket.UsageBytes > maxBytes { + maxBytes = bucket.UsageBytes + } + } + + for index := range buckets { + buckets[index].Percentage = float64(buckets[index].UsageBytes) / float64(maxBytes) * 100 + } + + return ObjectStorageCard{ + Buckets: buckets, + TotalUsage: formatBytes(totalBytes), + } +} + +func buildCronJobsCard() CronJobsCard { + totalJobs := 5 + rand.Intn(8) + totalRuns := 20 + rand.Intn(80) + failures := []CronFailureEntry{ + {JobName: "cleanup-temp", Domain: "api.dove", FailedAt: "2m ago", ErrorText: "Connection refused on :8080"}, + {JobName: "sync-storage", Domain: "cdn.dove", FailedAt: "18m ago", ErrorText: "Bucket not found: staging"}, + {JobName: "health-ping", Domain: "monitor.dove", FailedAt: "1h ago", ErrorText: "Timeout after 30s"}, + } + + return CronJobsCard{ + Failures: failures, + TotalJobs: totalJobs, + TotalRuns: totalRuns, + FailCount: len(failures), + } +} + +func buildDoveCard() DoveCard { + statusGroups := map[string]int{ + "2xx": 800 + rand.Intn(2000), + "3xx": 50 + rand.Intn(200), + "4xx": 10 + rand.Intn(50), + "5xx": rand.Intn(10), + } + + recentRoutes := []DoveStatusEntry{ + {Route: "/dashboard", Method: "GET", StatusCode: 200, Count: 120 + rand.Intn(200)}, + {Route: "/domains", Method: "GET", StatusCode: 200, Count: 80 + rand.Intn(150)}, + {Route: "/mail/webmail", Method: "GET", StatusCode: 200, Count: 60 + rand.Intn(100)}, + {Route: "/domains/records", Method: "POST", StatusCode: 201, Count: 15 + rand.Intn(30)}, + {Route: "/auth/login", Method: "POST", StatusCode: 302, Count: 10 + rand.Intn(20)}, + {Route: "/mail/compose", Method: "POST", StatusCode: 500, Count: rand.Intn(5)}, + {Route: "/domains/manage", Method: "DELETE", StatusCode: 404, Count: rand.Intn(8)}, + } + + return DoveCard{ + StatusGroups: statusGroups, + RecentRoutes: recentRoutes, + TotalRoutes: len(recentRoutes), + } +} + +func generateBucket(name string, minMegabytes int, maxMegabytes int) StorageBucketEntry { + megabytes := int64(minMegabytes) + int64(rand.Intn(maxMegabytes-minMegabytes)) + bytes := megabytes * 1024 * 1024 + return StorageBucketEntry{ + Name: name, + UsageBytes: bytes, + UsageLabel: formatBytes(bytes), + } +} + +func generateSparkline(anchorCount int, minValue float64, maxValue float64) SparklineData { + viewboxWidth := 200.0 + viewboxHeight := 40.0 + padding := 2.0 + + anchorValues := make([]float64, anchorCount) + currentValue := minValue + (maxValue-minValue)*0.3 + rand.Float64()*(maxValue-minValue)*0.4 + + for index := range anchorValues { + delta := (rand.Float64() - 0.45) * (maxValue - minValue) * 0.25 + currentValue += delta + currentValue = math.Max(minValue, math.Min(maxValue, currentValue)) + anchorValues[index] = currentValue + } + + smoothValues := catmullRomResample(anchorValues, SparklineResolution) + + minPoint := smoothValues[0] + maxPoint := smoothValues[0] + for _, point := range smoothValues { + if point < minPoint { + minPoint = point + } + if point > maxPoint { + maxPoint = point + } + } + + pointRange := maxPoint - minPoint + if pointRange == 0 { + pointRange = 1 + } + + lineCoordinates := make([]string, SparklineResolution) + for index, point := range smoothValues { + horizontalPosition := padding + (float64(index)/float64(SparklineResolution-1))*(viewboxWidth-2*padding) + verticalPosition := padding + (1-(point-minPoint)/pointRange)*(viewboxHeight-2*padding) + lineCoordinates[index] = fmt.Sprintf("%.1f,%.1f", horizontalPosition, verticalPosition) + } + + linePath := strings.Join(lineCoordinates, " ") + + fillPath := fmt.Sprintf("M%.1f,%.1f L%s L%.1f,%.1f Z", + padding, viewboxHeight, + linePath, + viewboxWidth-padding, viewboxHeight, + ) + + return SparklineData{ + LinePath: linePath, + FillPath: fillPath, + } +} + +func catmullRomResample(inputValues []float64, outputCount int) []float64 { + inputCount := len(inputValues) + if inputCount < 2 { + return inputValues + } + + resampledValues := make([]float64, outputCount) + for outputIndex := 0; outputIndex < outputCount; outputIndex++ { + normalizedPosition := float64(outputIndex) / float64(outputCount-1) * float64(inputCount-1) + segmentIndex := int(normalizedPosition) + segmentFraction := normalizedPosition - float64(segmentIndex) + + point0 := inputValues[max(0, segmentIndex-1)] + point1 := inputValues[min(inputCount-1, segmentIndex)] + point2 := inputValues[min(inputCount-1, segmentIndex+1)] + point3 := inputValues[min(inputCount-1, segmentIndex+2)] + + fractionSquared := segmentFraction * segmentFraction + fractionCubed := fractionSquared * segmentFraction + + resampledValues[outputIndex] = 0.5 * ((2 * point1) + + (-point0+point2)*segmentFraction + + (2*point0-5*point1+4*point2-point3)*fractionSquared + + (-point0+3*point1-3*point2+point3)*fractionCubed) + } + + return resampledValues +} + +func buildDnsCard() DnsCard { + systemDnsStatus := dnsSystem.CheckSystemDns() + return DnsCard{ + Configured: systemDnsStatus.Configured, + Address: systemDnsStatus.Address, + Platform: systemDnsStatus.Platform, + } +} + +func formatBytes(bytes int64) string { + if bytes < 1024 { + return fmt.Sprintf("%d B", bytes) + } + if bytes < 1024*1024 { + return fmt.Sprintf("%.1f KB", float64(bytes)/1024) + } + if bytes < 1024*1024*1024 { + return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024)) + } + return fmt.Sprintf("%.1f GB", float64(bytes)/(1024*1024*1024)) +} diff --git a/services/dns/messages.go b/services/dns/messages.go index 2e24add..6b46c58 100644 --- a/services/dns/messages.go +++ b/services/dns/messages.go @@ -6,13 +6,11 @@ const ( AddressRequired = "Address is required." ContentRequired = "TXT record content is required." DomainNotFound = "Domain not found." - ManagedRecordLocked = "System-managed records cannot be deleted." NameRequired = "Record name is required." PortRequired = "Port is required for SRV records." RecordCreationFailed = "Failed to create DNS record." RecordDeletionFailed = "Failed to delete DNS record." RecordUpdateFailed = "Failed to update DNS record." - ManagedRecordUpdate = "System-managed records cannot be modified." RecordNotFound = "DNS record not found." TargetRequired = "Target is required." TypeInvalid = "Invalid DNS record type." diff --git a/services/dns/records.go b/services/dns/records.go index 782b943..6a66ab7 100644 --- a/services/dns/records.go +++ b/services/dns/records.go @@ -1,7 +1,6 @@ package dns import ( - "fmt" "net" "strings" @@ -13,13 +12,12 @@ import ( ) type RecordEntry struct { - ID uint `json:"id"` - Type string `json:"type"` - Name string `json:"name"` - Value string `json:"value"` - TTL uint32 `json:"ttl"` - Priority uint16 `json:"priority"` - IsManaged bool `json:"is_managed"` + ID uint `json:"id"` + Type string `json:"type"` + Name string `json:"name"` + Value string `json:"value"` + TTL uint32 `json:"ttl"` + Priority uint16 `json:"priority"` } type DomainRecordsResponse struct { @@ -44,7 +42,6 @@ type CreateRecordRequest struct { Weight uint16 `form:"weight"` Port uint16 `form:"port"` Protocol string `form:"protocol"` - PortMap int `form:"port_map"` } func DomainRecords(domainID uint) (*DomainRecordsResponse, *shortcuts.Error) { @@ -71,12 +68,12 @@ func CreateRecord(request CreateRecordRequest) *shortcuts.Error { value := strings.TrimSpace(request.Value) ttl := request.TTL if ttl == 0 { - ttl = 300 + ttl = 1 } switch request.RecordType { case "A": - return createARecord(request.DomainID, name, value, ttl, request.PortMap) + return createARecord(request.DomainID, name, value, ttl) case "AAAA": return createAAAARecord(request.DomainID, name, value, ttl) case "CNAME": @@ -154,9 +151,6 @@ func UpdateRecord(recordType string, recordID uint, request UpdateRecordRequest) if record == nil { return shortcuts.ServiceError(shortcuts.NotFound, RecordNotFound) } - if record.IsManaged { - return shortcuts.ServiceError(shortcuts.Forbidden, ManagedRecordUpdate) - } if name != "" { record.Name = name } @@ -247,9 +241,6 @@ func DeleteRecord(recordType string, recordID uint) *shortcuts.Error { if record == nil { return shortcuts.ServiceError(shortcuts.NotFound, RecordNotFound) } - if record.IsManaged { - return shortcuts.ServiceError(shortcuts.Forbidden, ManagedRecordLocked) - } if deleteError := dnsRepo.DeleteMXRecord(record); deleteError != nil { return shortcuts.ServiceError(shortcuts.Internal, RecordDeletionFailed) } @@ -283,15 +274,11 @@ func collectAllRecords(domainID uint) []RecordEntry { var entries []RecordEntry for _, record := range dnsRepo.FindARecordsByDomainID(domainID) { - value := record.Address - if record.Port > 0 { - value = fmt.Sprintf("%s (port %d)", record.Address, record.Port) - } entries = append(entries, RecordEntry{ ID: record.ID, Type: "A", Name: record.Name, - Value: value, + Value: record.Address, TTL: record.TTL, }) } @@ -318,13 +305,12 @@ func collectAllRecords(domainID uint) []RecordEntry { for _, record := range dnsRepo.FindMXRecordsByDomainID(domainID) { entries = append(entries, RecordEntry{ - ID: record.ID, - Type: "MX", - Name: record.Name, - Value: record.Target, - TTL: record.TTL, - Priority: record.Priority, - IsManaged: record.IsManaged, + ID: record.ID, + Type: "MX", + Name: record.Name, + Value: record.Target, + TTL: record.TTL, + Priority: record.Priority, }) } @@ -352,7 +338,7 @@ func collectAllRecords(domainID uint) []RecordEntry { return entries } -func createARecord(domainID uint, name string, address string, ttl uint32, portMap int) *shortcuts.Error { +func createARecord(domainID uint, name string, address string, ttl uint32) *shortcuts.Error { if address == "" { return shortcuts.ServiceError(shortcuts.BadRequest, AddressRequired) } @@ -370,7 +356,6 @@ func createARecord(domainID uint, name string, address string, ttl uint32, portM DomainID: domainID, Name: name, Address: address, - Port: portMap, TTL: ttl, } diff --git a/services/domain/domain.go b/services/domain/domain.go index 35a6578..91ca1e9 100644 --- a/services/domain/domain.go +++ b/services/domain/domain.go @@ -78,19 +78,11 @@ func CreateDomain(request CreateDomainRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Internal, DomainCreationFailed) } - seedDefaultARecord(newDomain.ID) + seedDefaultRecords(newDomain.ID, name, tldName) return nil } -func seedDefaultARecord(domainID uint) { - dnsRepo.CreateARecord(&dnsModel.ARecord{ - DomainID: domainID, - Name: "@", - Address: DefaultAddress, - }) -} - func DeleteDomain(domainID uint) *shortcuts.Error { foundDomain := domainRepo.FindDomainByID(domainID) if foundDomain == nil { @@ -110,6 +102,59 @@ func DeleteDomain(domainID uint) *shortcuts.Error { return nil } +func seedDefaultRecords(domainID uint, domainName string, tldName string) { + mailHostname := DefaultMXTarget + "." + domainName + "." + tldName + "." + + dnsRepo.CreateARecord(&dnsModel.ARecord{ + DomainID: domainID, + Name: "@", + Address: DefaultAddress, + }) + + dnsRepo.CreateARecord(&dnsModel.ARecord{ + DomainID: domainID, + Name: DefaultMXTarget, + Address: DefaultAddress, + }) + + dnsRepo.CreateMXRecord(&dnsModel.MXRecord{ + DomainID: domainID, + Name: "@", + Target: mailHostname, + Priority: 10, + }) + + dnsRepo.CreateSRVRecord(&dnsModel.SRVRecord{ + DomainID: domainID, + Name: "_submission._tcp", + Target: mailHostname, + Port: 587, + Priority: 0, + Weight: 0, + Protocol: "tcp", + }) + + dnsRepo.CreateSRVRecord(&dnsModel.SRVRecord{ + DomainID: domainID, + Name: "_imap._tcp", + Target: mailHostname, + Port: 143, + Priority: 0, + Weight: 0, + Protocol: "tcp", + }) + + dnsRepo.CreateSRVRecord(&dnsModel.SRVRecord{ + DomainID: domainID, + Name: "_pop3._tcp", + Target: mailHostname, + Port: 110, + Priority: 0, + Weight: 0, + Protocol: "tcp", + }) +} + func deleteAllDNSRecords(domainID uint) { dnsRepo.DeleteARecordsByDomainID(domainID) dnsRepo.DeleteAAAARecordsByDomainID(domainID) @@ -117,4 +162,4 @@ func deleteAllDNSRecords(domainID uint) { dnsRepo.DeleteMXRecordsByDomainID(domainID) dnsRepo.DeleteTXTRecordsByDomainID(domainID) dnsRepo.DeleteSRVRecordsByDomainID(domainID) -} +}
\ No newline at end of file diff --git a/services/mail/mailboxes.go b/services/mail/mailboxes.go index d6c17d7..69c6ee7 100644 --- a/services/mail/mailboxes.go +++ b/services/mail/mailboxes.go @@ -3,10 +3,8 @@ package mail import ( "strings" - dnsModel "dove/models/dns" domainModel "dove/models/domain" mailModel "dove/models/mail" - dnsRepo "dove/repositories/dns" domainRepo "dove/repositories/domain" mailRepo "dove/repositories/mail" "dove/utils/meta" @@ -91,28 +89,9 @@ func CreateMailbox(request CreateMailboxRequest) *shortcuts.Error { return shortcuts.ServiceError(shortcuts.Internal, FolderSeedFailed) } - seedMXRecordForDomain(foundDomain) - return nil } -func seedMXRecordForDomain(targetDomain *domainModel.Domain) { - fullDomainName := targetDomain.Name + "." + targetDomain.TLD.Name - existingMX := dnsRepo.FindMXRecordByTarget(targetDomain.ID, fullDomainName) - if existingMX != nil { - return - } - - dnsRepo.CreateMXRecord(&dnsModel.MXRecord{ - DomainID: targetDomain.ID, - Name: "@", - Target: fullDomainName, - Priority: 10, - TTL: 1, - IsManaged: true, - }) -} - func EditMailboxFormData(mailboxID uint) (*EditMailboxFormResponse, *shortcuts.Error) { mailbox := mailRepo.FindMailboxByIDWithRelations(mailboxID) if mailbox == nil { diff --git a/static/css/tailwind.css b/static/css/tailwind.css index d741e81..98ce60a 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -308,6 +308,29 @@ background: rgba(99, 102, 241, 0.1); } +.dropdown-compact .dropdown-menu { + top: calc(100% + 0.25rem); + min-width: 10rem; + border-radius: 0.5rem; + left: auto; + right: 0; +} + +.dropdown-compact .dropdown-options { + max-height: 9rem; + padding: 0.125rem; +} + +.dropdown-compact .dropdown-option { + padding: 0.3rem 0.5rem; + border-radius: 0.375rem; + font-size: 0.6875rem; +} + +.dropdown-compact .dropdown-option p { + font-size: 0.6875rem; +} + .alert-toast { padding: 0.75rem 1rem; border-radius: 0.75rem; diff --git a/templates/auth/login.django b/templates/auth/login.django index babff22..b40f345 100644 --- a/templates/auth/login.django +++ b/templates/auth/login.django @@ -10,7 +10,7 @@ </svg> </div> <h1 class="text-3xl font-bold gradient-text tracking-tight">Dove</h1> - <p class="mt-2 text-sm text-zinc-500">Local SMTP server for peaceful email testing</p> + <p class="mt-2 text-sm text-zinc-500">Local Infrastructure Service for Peaceful Development Experience.</p> </div> {% if ErrorMessage %} diff --git a/templates/dashboard/htmx/overview.htmx.django b/templates/dashboard/htmx/overview.htmx.django index 8e1529e..95c4926 100644 --- a/templates/dashboard/htmx/overview.htmx.django +++ b/templates/dashboard/htmx/overview.htmx.django @@ -1,57 +1,488 @@ -<div class="slide-up space-y-8"> - <div class="grid grid-cols-3 gap-5"> - <div class="glass rounded-xl p-5 glow-border"> - <div class="flex items-center gap-3 mb-4"> - <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10"> - <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M2.25 13.5h3.86a2.25 2.25 0 0 1 2.012 1.244l.256.512a2.25 2.25 0 0 0 2.013 1.244h2.21a2.25 2.25 0 0 0 2.013-1.244l.256-.512a2.25 2.25 0 0 1 2.013-1.244h3.859m-19.5.338V18a2.25 2.25 0 0 0 2.25 2.25h15A2.25 2.25 0 0 0 21.75 18v-4.162c0-.224-.034-.447-.1-.661L19.24 5.338a2.25 2.25 0 0 0-2.15-1.588H6.911a2.25 2.25 0 0 0-2.15 1.588L2.35 13.177a2.25 2.25 0 0 0-.1.661Z" /> - </svg> +<style> + @keyframes pulseGlow { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.4; } + } + .pulse-dot { + animation: pulseGlow 2s ease-in-out infinite; + } + @keyframes barGrow { + from { width: 0; } + } + .animate-bar { + animation: barGrow 0.8s ease-out forwards; + } + .dove-scroll { + max-height: 220px; + overflow-y: auto; + } + .dove-scroll::-webkit-scrollbar { + width: 4px; + } + .dove-scroll::-webkit-scrollbar-track { + background: transparent; + } + .dove-scroll::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.08); + border-radius: 2px; + } + .dove-scroll::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.15); + } + @keyframes drawLine { + from { stroke-dashoffset: var(--line-length); } + to { stroke-dashoffset: 0; } + } + @keyframes fadeGradient { + from { opacity: 0; } + to { opacity: 1; } + } + .sparkline-draw { + animation: drawLine 1s ease-out forwards; + } + .sparkline-fill-fade { + opacity: 0; + animation: fadeGradient 0.6s ease-out 0.4s forwards; + } + @keyframes statusBarGrow { + from { width: 0; } + } + .status-bar-segment { + animation: statusBarGrow 0.6s ease-out forwards; + } +</style> + +<div class="slide-up space-y-4"> + + <div class="grid grid-cols-2 gap-4"> + + <div class="glass rounded-xl glow-border overflow-hidden" data-email-card> + <div class="flex items-center justify-between px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Email</h3> + <div class="flex items-center gap-1 text-[10px] text-zinc-600"> + <button class="px-1.5 py-0.5 rounded bg-white/[0.06] text-zinc-400 transition-colors duration-150" data-email-range="24h" data-email-active>24h</button> + <button class="px-1.5 py-0.5 rounded hover:bg-white/[0.04] transition-colors duration-150" data-email-range="7d">7d</button> + <button class="px-1.5 py-0.5 rounded hover:bg-white/[0.04] transition-colors duration-150" data-email-range="30d">30d</button> + </div> + </div> + <div class="px-5 pt-3 pb-1"> + <svg viewBox="0 0 200 40" class="w-full h-20" preserveAspectRatio="none" data-email-svg> + <defs> + <linearGradient id="emailGradient" x1="0" y1="0" x2="0" y2="1"> + <stop offset="0%" stop-color="#34d399" stop-opacity="0.15" /> + <stop offset="100%" stop-color="#34d399" stop-opacity="0" /> + </linearGradient> + </defs> + <path d="{{ email.Sparkline24h.FillPath }}" fill="url(#emailGradient)" class="sparkline-fill-fade" data-email-fill /> + <polyline points="{{ email.Sparkline24h.LinePath }}" fill="none" stroke="#34d399" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="sparkline-draw" data-email-line /> + </svg> + </div> + <div class="grid grid-cols-4 gap-4 px-5 py-3 border-t border-white/[0.04]"> + <div> + <p class="text-[10px] text-zinc-600">Mailboxes</p> + <p class="text-sm text-zinc-200 font-mono tabular-nums">{{ email.TotalMailboxes }}</p> + </div> + <div> + <p class="text-[10px] text-zinc-600">Delivered</p> + <p class="text-sm text-zinc-200 font-mono tabular-nums">{{ email.TotalDelivered }}</p> + </div> + <div> + <p class="text-[10px] text-zinc-600">Bounced</p> + <p class="text-sm text-zinc-200 font-mono tabular-nums">{{ email.TotalBounced }}</p> + </div> + <div> + <p class="text-[10px] text-zinc-600">Bounce rate</p> + <p class="text-sm text-zinc-200 font-mono tabular-nums">{{ email.BounceRate }}%</p> + </div> + </div> + </div> + + <div class="glass rounded-xl glow-border overflow-hidden flex flex-col" data-domains-card> + <div class="flex items-center justify-between px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Domains</h3> + {% if domains.Domains %} + <div class="dropdown dropdown-compact" data-dropdown data-domain-dropdown> + <input type="hidden" value="{{ domains.Selected }}" data-dropdown-value data-domain-selected> + <button type="button" data-dropdown-trigger class="flex items-center gap-1.5 px-2 py-0.5 rounded bg-white/[0.06] border border-white/[0.06] text-[10px] text-zinc-400 transition-colors duration-150 hover:border-white/[0.1]"> + <span class="truncate max-w-[120px]" data-dropdown-label>{{ domains.Selected }}</span> + <svg class="w-2.5 h-2.5 text-zinc-600 shrink-0 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2"> + <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> + </svg> + </button> + <div class="dropdown-menu" data-dropdown-menu> + <div class="dropdown-options" data-dropdown-options> + {% for domain in domains.Domains %} + <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ domain.Name }}" data-label="{{ domain.Name }}"> + <p>{{ domain.Name }}</p> + </button> + {% endfor %} + </div> + </div> + </div> + {% endif %} + </div> + {% if domains.Domains %} + <div class="flex-1 flex flex-col justify-center"> + {% for domain in domains.Domains %} + <div class="px-5 py-3 {% if domain.Name != domains.Selected %}hidden{% endif %}" data-domain-graph="{{ domain.Name }}"> + <div class="flex items-center gap-2 mb-3"> + <div class="w-full h-2 rounded-full bg-white/[0.04] overflow-hidden flex"> + <div class="h-full bg-emerald-500/70 rounded-l-full status-bar-segment" style="width: {{ domain.PercentOk }}%"></div> + <div class="h-full bg-amber-500/70 status-bar-segment" style="width: {{ domain.PercentWarn }}%"></div> + <div class="h-full bg-red-500/70 rounded-r-full status-bar-segment" style="width: {{ domain.PercentErr }}%"></div> + </div> + </div> + <div class="grid grid-cols-3 gap-4 text-center"> + <div> + <span class="inline-flex items-center gap-1 text-[10px] mb-1"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-500/70"></span> + <span class="text-zinc-500">2xx</span> + <span class="text-zinc-300 font-mono tabular-nums">{{ domain.PercentOk }}%</span> + </span> + <p class="text-sm text-emerald-400 font-mono tabular-nums">{{ domain.StatusOk }}</p> + </div> + <div> + <span class="inline-flex items-center gap-1 text-[10px] mb-1"> + <span class="w-1.5 h-1.5 rounded-full bg-amber-500/70"></span> + <span class="text-zinc-500">3xx</span> + <span class="text-zinc-300 font-mono tabular-nums">{{ domain.PercentWarn }}%</span> + </span> + <p class="text-sm text-amber-400 font-mono tabular-nums">{{ domain.StatusWarn }}</p> + </div> + <div> + <span class="inline-flex items-center gap-1 text-[10px] mb-1"> + <span class="w-1.5 h-1.5 rounded-full bg-red-500/70"></span> + <span class="text-zinc-500">4xx+</span> + <span class="text-zinc-300 font-mono tabular-nums">{{ domain.PercentErr }}%</span> + </span> + <p class="text-sm text-red-400 font-mono tabular-nums">{{ domain.StatusErr }}</p> + </div> + </div> </div> - <span class="text-xs font-medium text-zinc-500 uppercase tracking-wider">Mailboxes</span> + {% endfor %} + </div> + {% else %} + <div class="flex-1 flex items-center justify-center"> + <p class="text-xs text-zinc-600">No domains registered</p> + </div> + {% endif %} + </div> + + </div> + + <div class="grid grid-cols-3 gap-4"> + + <div class="glass rounded-xl glow-border overflow-hidden"> + <div class="px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Reverse Proxy</h3> + </div> + <div class="divide-y divide-white/[0.04]"> + {% for service in reverse_proxy.Services %} + <div class="flex items-center justify-between px-5 py-2.5"> + <div class="flex items-center gap-3"> + {% if service.Up %} + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)] pulse-dot"></span> + {% else %} + <span class="w-1.5 h-1.5 rounded-full bg-red-400 shadow-[0_0_6px_rgba(248,113,113,0.4)] pulse-dot"></span> + {% endif %} + <span class="text-xs {% if service.Up %}text-zinc-300{% else %}text-red-400/70{% endif %}">{{ service.Name }}</span> + </div> + <span class="text-xs text-zinc-600 font-mono">{{ service.Target }}</span> + </div> + {% endfor %} </div> - <p class="text-3xl font-bold text-zinc-100 tracking-tight">{{ MailboxCount }}</p> - <p class="mt-1 text-xs text-zinc-600">Active inboxes</p> </div> - <div class="glass rounded-xl p-5 glow-border"> - <div class="flex items-center gap-3 mb-4"> - <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10"> - <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> - </svg> + <div class="glass rounded-xl glow-border overflow-hidden"> + <div class="flex items-center justify-between px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Object Storage</h3> + <span class="text-[10px] text-zinc-600 font-mono">{{ storage.TotalUsage }}</span> + </div> + <div class="divide-y divide-white/[0.04]"> + {% for bucket in storage.Buckets %} + <div class="px-5 py-2.5"> + <div class="flex items-center justify-between mb-1.5"> + <span class="text-xs text-zinc-300">{{ bucket.Name }}</span> + <span class="text-[10px] text-zinc-600 font-mono">{{ bucket.UsageLabel }}</span> + </div> + <div class="w-full h-1 rounded-full bg-white/[0.04]"> + <div class="h-full rounded-full bg-cyan-500/60 animate-bar" style="width: {{ bucket.Percentage }}%"></div> + </div> </div> - <span class="text-xs font-medium text-zinc-500 uppercase tracking-wider">Emails</span> + {% endfor %} </div> - <p class="text-3xl font-bold text-zinc-100 tracking-tight">{{ EmailCount }}</p> - <p class="mt-1 text-xs text-zinc-600">Total received</p> </div> - <div class="glass rounded-xl p-5 glow-border"> - <div class="flex items-center gap-3 mb-4"> - <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-emerald-500/10"> - <svg class="w-4.5 h-4.5 text-emerald-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M9.348 14.652a3.75 3.75 0 0 1 0-5.304m5.304 0a3.75 3.75 0 0 1 0 5.304m-7.425 2.121a6.75 6.75 0 0 1 0-9.546m9.546 0a6.75 6.75 0 0 1 0 9.546M5.106 18.894c-3.808-3.807-3.808-9.98 0-13.788m13.788 0c3.808 3.807 3.808 9.98 0 13.788M12 12h.008v.008H12V12Zm.375 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Z" /> - </svg> + <div class="glass rounded-xl glow-border overflow-hidden"> + <div class="flex items-center justify-between px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Cron Jobs</h3> + <span class="text-[10px] text-zinc-600">{{ cron_jobs.FailCount }} failed</span> + </div> + {% if cron_jobs.Failures %} + <div class="divide-y divide-white/[0.04]"> + {% for failure in cron_jobs.Failures %} + <div class="px-5 py-2.5"> + <div class="flex items-center justify-between mb-0.5"> + <span class="text-xs text-zinc-300">{{ failure.JobName }}</span> + <span class="text-[10px] text-zinc-600">{{ failure.FailedAt }}</span> + </div> + <p class="text-[10px] text-red-400/70 truncate">{{ failure.ErrorText }}</p> </div> - <span class="text-xs font-medium text-zinc-500 uppercase tracking-wider">Server</span> + {% endfor %} + </div> + {% else %} + <div class="flex items-center justify-center py-8"> + <p class="text-xs text-emerald-400/60">All jobs running successfully</p> </div> - <p class="text-3xl font-bold text-emerald-400 tracking-tight">Online</p> - <p class="mt-1 text-xs text-zinc-600">SMTP listening on {{ SMTPAddress }}</p> + {% endif %} </div> + </div> - <div class="glass rounded-xl glow-border"> - <div class="flex items-center justify-between px-5 py-4 border-b border-white/[0.04]"> - <h2 class="text-sm font-medium text-zinc-200">Recent Emails</h2> + <div class="grid grid-cols-[3fr_1fr] gap-4"> + + <div class="glass rounded-xl glow-border overflow-hidden"> + <div class="flex items-center justify-between px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Dove</h3> + <div class="flex items-center gap-4 text-[10px]"> + {% for group, count in dove.StatusGroups %} + <span class="{% if group == "2xx" %}text-emerald-400{% elif group == "3xx" %}text-amber-400{% elif group == "4xx" %}text-orange-400{% else %}text-red-400{% endif %} font-mono tabular-nums">{{ group }}: {{ count }}</span> + {% endfor %} + </div> + </div> + <div class="dove-scroll"> + <table class="w-full text-xs"> + <thead class="sticky top-0 bg-surface-900/95 backdrop-blur-sm"> + <tr class="border-b border-white/[0.04]"> + <th class="text-left text-zinc-600 font-medium px-5 py-2">Method</th> + <th class="text-left text-zinc-600 font-medium px-5 py-2">Route</th> + <th class="text-left text-zinc-600 font-medium px-5 py-2">Status</th> + <th class="text-right text-zinc-600 font-medium px-5 py-2">Hits</th> + </tr> + </thead> + <tbody class="divide-y divide-white/[0.04]"> + {% for entry in dove.RecentRoutes %} + <tr> + <td class="px-5 py-1.5"> + <span class="inline-flex px-1.5 py-0.5 rounded text-[10px] font-medium + {% if entry.Method == "GET" %}bg-blue-500/10 text-blue-400 + {% elif entry.Method == "POST" %}bg-emerald-500/10 text-emerald-400 + {% elif entry.Method == "PUT" %}bg-amber-500/10 text-amber-400 + {% elif entry.Method == "DELETE" %}bg-red-500/10 text-red-400 + {% else %}bg-zinc-500/10 text-zinc-400{% endif %}">{{ entry.Method }}</span> + </td> + <td class="px-5 py-1.5 text-zinc-400 font-mono">{{ entry.Route }}</td> + <td class="px-5 py-1.5"> + <span class="font-mono tabular-nums + {% if entry.StatusCode >= 200 and entry.StatusCode < 300 %}text-emerald-400 + {% elif entry.StatusCode >= 300 and entry.StatusCode < 400 %}text-amber-400 + {% elif entry.StatusCode >= 400 and entry.StatusCode < 500 %}text-orange-400 + {% else %}text-red-400{% endif %}">{{ entry.StatusCode }}</span> + </td> + <td class="px-5 py-1.5 text-right text-zinc-500 font-mono tabular-nums">{{ entry.Count }}</td> + </tr> + {% endfor %} + </tbody> + </table> + </div> </div> - <div class="flex flex-col items-center justify-center py-16 text-center"> - <div class="flex items-center justify-center w-12 h-12 rounded-2xl bg-surface-800 mb-4"> - <svg class="w-6 h-6 text-zinc-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.243a2.25 2.25 0 0 1-1.07 1.916l-7.5 4.615a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.916V6.75" /> - </svg> + + <div class="glass rounded-xl glow-border overflow-hidden flex flex-col"> + <div class="flex items-center justify-between px-4 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">System DNS</h3> + </div> + <div class="flex flex-col items-center justify-center flex-1 py-6 gap-3 px-4"> + {% if dns.Configured %} + <div class="flex items-center gap-2"> + <span class="w-2 h-2 rounded-full bg-emerald-400 shadow-[0_0_8px_rgba(52,211,153,0.5)] pulse-dot"></span> + <span class="text-xs text-emerald-400 font-medium">Configured</span> + </div> + <p class="text-[10px] text-zinc-500 text-center leading-relaxed">Resolving through Dove on {{ dns.Platform }}.</p> + <span class="text-[10px] text-zinc-600 font-mono">{{ dns.Address }}</span> + <form method="POST" action="/dashboard/dns/disable"> + <button type="submit" class="mt-1 px-3 py-1 rounded-md text-[11px] font-medium text-red-400 bg-red-500/10 border border-red-500/20 transition-all duration-150 hover:bg-red-500/20 hover:border-red-500/30 cursor-pointer">Disable</button> + </form> + {% else %} + <div class="flex items-center gap-2"> + <span class="w-2 h-2 rounded-full bg-zinc-600"></span> + <span class="text-xs text-zinc-400 font-medium">Not configured</span> + </div> + <p class="text-[10px] text-zinc-500 text-center leading-relaxed">Dove's DNS is not set as a system nameserver.</p> + <span class="text-[10px] text-zinc-600 font-mono">{{ dns.Address }}</span> + <form method="POST" action="/dashboard/dns/configure"> + <button type="submit" class="mt-1 px-3 py-1 rounded-md text-[11px] font-medium text-emerald-400 bg-emerald-500/10 border border-emerald-500/20 transition-all duration-150 hover:bg-emerald-500/20 hover:border-emerald-500/30 cursor-pointer">Configure</button> + </form> + {% endif %} </div> - <p class="text-sm text-zinc-400">No emails yet</p> - <p class="mt-1 text-xs text-zinc-600">Send an email to any address and it will appear here</p> </div> + + </div> + + <div class="glass rounded-xl glow-border overflow-hidden"> + <div class="px-5 py-3 border-b border-white/[0.04]"> + <h3 class="text-xs font-medium text-zinc-400">Listeners</h3> + </div> + <table class="w-full text-xs"> + <thead> + <tr class="border-b border-white/[0.04]"> + <th class="text-left text-zinc-500 font-medium px-5 py-2.5">Name</th> + <th class="text-left text-zinc-500 font-medium px-5 py-2.5">Address</th> + <th class="text-left text-zinc-500 font-medium px-5 py-2.5">Protocol</th> + <th class="text-right text-zinc-500 font-medium px-5 py-2.5">Status</th> + </tr> + </thead> + <tbody class="divide-y divide-white/[0.04]"> + {% for service in services %} + <tr> + <td class="px-5 py-2 text-zinc-300 font-medium font-mono">{{ service.Name }}</td> + <td class="px-5 py-2 text-zinc-500 font-mono">{{ bind_address }}:{{ service.Port }}</td> + <td class="px-5 py-2 text-zinc-500">{{ service.Protocol }}</td> + <td class="px-5 py-2 text-right"> + {% if service.Active %} + <span class="inline-flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full bg-emerald-400 shadow-[0_0_6px_rgba(52,211,153,0.4)] pulse-dot"></span> + <span class="text-emerald-400">Running</span> + </span> + {% else %} + <span class="inline-flex items-center gap-1.5"> + <span class="w-1.5 h-1.5 rounded-full bg-red-400/50 pulse-dot"></span> + <span class="text-red-400/50">Inactive</span> + </span> + {% endif %} + </td> + </tr> + {% endfor %} + </tbody> + </table> </div> -</div>
\ No newline at end of file + +</div> + +<script> +(function() { + var emailLine = document.querySelector('[data-email-line]'); + var emailFill = document.querySelector('[data-email-fill]'); + + if (emailLine) { + var lineLength = emailLine.getTotalLength(); + emailLine.style.strokeDasharray = lineLength; + emailLine.style.strokeDashoffset = lineLength; + emailLine.style.setProperty('--line-length', lineLength); + emailLine.classList.add('sparkline-draw'); + + emailLine.addEventListener('animationend', function() { + emailLine.style.strokeDasharray = ''; + emailLine.style.strokeDashoffset = ''; + emailLine.classList.remove('sparkline-draw'); + }, { once: true }); + } + + var emailData = { + '24h': { line: '{{ email.Sparkline24h.LinePath }}', fill: '{{ email.Sparkline24h.FillPath }}' }, + '7d': { line: '{{ email.Sparkline7d.LinePath }}', fill: '{{ email.Sparkline7d.FillPath }}' }, + '30d': { line: '{{ email.Sparkline30d.LinePath }}', fill: '{{ email.Sparkline30d.FillPath }}' } + }; + + var emailButtons = document.querySelectorAll('[data-email-range]'); + var animating = false; + + function parsePoints(pointString) { + return pointString.split(' ').map(function(pair) { + var coordinates = pair.split(','); + return { x: parseFloat(coordinates[0]), y: parseFloat(coordinates[1]) }; + }); + } + + function interpolatePoints(fromPoints, toPoints, progress) { + var result = []; + var length = fromPoints.length; + for (var index = 0; index < length; index++) { + var interpolatedX = fromPoints[index].x + (toPoints[index].x - fromPoints[index].x) * progress; + var interpolatedY = fromPoints[index].y + (toPoints[index].y - fromPoints[index].y) * progress; + result.push(interpolatedX.toFixed(1) + ',' + interpolatedY.toFixed(1)); + } + return result.join(' '); + } + + function buildFillPath(pointString) { + return 'M2.0,40.0 L' + pointString + ' L198.0,40.0 Z'; + } + + function animateSparkline(targetRange, duration) { + if (animating) return; + animating = true; + + var currentLinePoints = parsePoints(emailLine.getAttribute('points')); + var targetLinePoints = parsePoints(emailData[targetRange].line); + var startTime = null; + + function step(timestamp) { + if (!startTime) startTime = timestamp; + var elapsed = timestamp - startTime; + var progress = Math.min(elapsed / duration, 1); + var eased = 1 - Math.pow(1 - progress, 3); + + var interpolatedLine = interpolatePoints(currentLinePoints, targetLinePoints, eased); + emailLine.setAttribute('points', interpolatedLine); + emailFill.setAttribute('d', buildFillPath(interpolatedLine)); + + if (progress < 1) { + requestAnimationFrame(step); + } else { + emailLine.setAttribute('points', emailData[targetRange].line); + emailFill.setAttribute('d', emailData[targetRange].fill); + animating = false; + } + } + + requestAnimationFrame(step); + } + + emailButtons.forEach(function(button) { + button.addEventListener('click', function() { + var range = button.getAttribute('data-email-range'); + + emailButtons.forEach(function(otherButton) { + otherButton.classList.remove('bg-white/[0.06]', 'text-zinc-400'); + otherButton.removeAttribute('data-email-active'); + }); + button.classList.add('bg-white/[0.06]', 'text-zinc-400'); + button.setAttribute('data-email-active', ''); + + animateSparkline(range, 400); + }); + }); + + var domainDropdown = document.querySelector('[data-domain-dropdown]'); + if (domainDropdown) { + var domainHidden = domainDropdown.querySelector('[data-domain-selected]'); + var domainOptions = domainDropdown.querySelectorAll('[data-dropdown-option]'); + + domainOptions.forEach(function(option) { + option.addEventListener('click', function() { + setTimeout(function() { + var selectedDomain = domainHidden.value; + var allGraphs = document.querySelectorAll('[data-domain-graph]'); + var allStats = document.querySelectorAll('[data-domain-stats]'); + + allGraphs.forEach(function(graph) { + if (graph.getAttribute('data-domain-graph') === selectedDomain) { + graph.classList.remove('hidden'); + } else { + graph.classList.add('hidden'); + } + }); + + allStats.forEach(function(stats) { + if (stats.getAttribute('data-domain-stats') === selectedDomain) { + stats.classList.remove('hidden'); + } else { + stats.classList.add('hidden'); + } + }); + }, 10); + }); + }); + } +})(); +</script> diff --git a/templates/domains/editdomain.django b/templates/domains/editdomain.django deleted file mode 100644 index 7b10b37..0000000 --- a/templates/domains/editdomain.django +++ /dev/null @@ -1,5 +0,0 @@ -{% extends "layouts/dashboard.django" %} - -{% block dashboard %} -{% include "domains/htmx/editdomain.htmx.django" %} -{% endblock %} diff --git a/templates/domains/htmx/detail.htmx.django b/templates/domains/htmx/detail.htmx.django index eaa3806..05b2ccf 100644 --- a/templates/domains/htmx/detail.htmx.django +++ b/templates/domains/htmx/detail.htmx.django @@ -82,10 +82,6 @@ <label class="block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5">Priority</label> <input type="number" name="priority" value="10" min="0" class="input-field text-xs"> </div> - <div data-port-map-field> - <label class="block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5">Port Map</label> - <input type="number" name="port_map" value="0" min="0" class="input-field text-xs"> - </div> <div data-srv-weight style="display: none;"> <label class="block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5">Weight</label> <input type="number" name="weight" value="0" min="0" class="input-field text-xs"> @@ -121,19 +117,19 @@ </div> {% if records %} - <div class="divide-y divide-white/[0.04]"> - {% for record in records %} - <div class="flex items-center justify-between px-5 py-3 group" data-record-row="{{ record.Type }}-{{ record.ID }}"> - <div class="flex items-center gap-3 min-w-0" data-record-display> - {% if record.IsManaged %} - <div class="flex items-center justify-center w-6 h-6 rounded bg-amber-500/10 shrink-0"> - <svg class="w-3.5 h-3.5 text-amber-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z" /> - </svg> - </div> - {% endif %} - <div class="flex items-center gap-2 min-w-0"> - <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 + <div> + <div class="grid px-5 py-2.5 border-b border-white/[0.06]" style="grid-template-columns: 4.5rem 1fr 2fr 5rem 6rem;"> + <span class="text-[10px] font-medium text-zinc-600 uppercase tracking-wider">Type</span> + <span class="text-[10px] font-medium text-zinc-600 uppercase tracking-wider">Name</span> + <span class="text-[10px] font-medium text-zinc-600 uppercase tracking-wider">Content</span> + <span class="text-[10px] font-medium text-zinc-600 uppercase tracking-wider">TTL</span> + <span class="text-[10px] font-medium text-zinc-600 uppercase tracking-wider text-right">Actions</span> + </div> + <div class="divide-y divide-white/[0.04]" data-records-list> + {% for record in records %} + <div class="group" data-record-row="{{ record.Type }}-{{ record.ID }}"> + <div class="grid items-center px-5 py-3" style="grid-template-columns: 4.5rem 1fr 2fr 5rem 6rem;"> + <span class="inline-flex items-center w-fit px-1.5 py-0.5 rounded text-[10px] font-medium {% if record.Type == "A" %}bg-blue-500/10 text-blue-400 {% elif record.Type == "AAAA" %}bg-blue-500/10 text-blue-300 {% elif record.Type == "CNAME" %}bg-purple-500/10 text-purple-400 @@ -141,27 +137,22 @@ {% elif record.Type == "TXT" %}bg-green-500/10 text-green-400 {% elif record.Type == "SRV" %}bg-pink-500/10 text-pink-400 {% endif %}">{{ record.Type }}</span> - <p class="text-sm text-zinc-200 shrink-0">{{ record.Name }}</p> - <svg class="w-3 h-3 text-zinc-700 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" /> - </svg> - <p class="text-sm text-zinc-400 truncate">{{ record.Value }}</p> - {% if record.Priority > 0 %} - <span class="text-xs text-zinc-600 shrink-0">pri {{ record.Priority }}</span> - {% endif %} - <span class="text-xs text-zinc-700 shrink-0">{{ record.TTL }}s</span> + <p class="text-sm text-zinc-200 truncate pr-3">{{ record.Name }}</p> + <div class="flex items-center gap-2 min-w-0 pr-3"> + <p class="text-sm text-zinc-400 truncate">{{ record.Value }}</p> + {% if record.Priority > 0 %} + <span class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-surface-800 text-zinc-500 shrink-0">pri {{ record.Priority }}</span> + {% endif %} + </div> + <span class="text-xs text-zinc-600">{{ record.TTL }}s</span> + <div class="flex items-center justify-end gap-3"> + <button type="button" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150" data-edit-record data-record-type="{{ record.Type }}" data-record-id="{{ record.ID }}" data-record-name="{{ record.Name }}" data-record-value="{{ record.Value }}" data-record-ttl="{{ record.TTL }}" data-record-priority="{{ record.Priority }}">Edit</button> + <button data-confirm-trigger data-confirm-title="Delete {{ record.Type }} record" data-confirm-message="This DNS record will be permanently removed. This action cannot be undone." data-confirm-action="/domains/records/{{ record.Type }}/{{ record.ID }}?domain_id={{ domain.ID }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150">Delete</button> + </div> </div> </div> - <div class="flex items-center gap-3 shrink-0 ml-4" data-record-actions> - {% if record.IsManaged %} - <span class="text-xs text-amber-500/60">System managed</span> - {% else %} - <button type="button" class="text-xs text-zinc-600 hover:text-zinc-300 transition-colors duration-150 opacity-0 group-hover:opacity-100" data-edit-record data-record-type="{{ record.Type }}" data-record-id="{{ record.ID }}" data-record-name="{{ record.Name }}" data-record-value="{{ record.Value }}" data-record-ttl="{{ record.TTL }}" data-record-priority="{{ record.Priority }}">Edit</button> - <button data-confirm-trigger data-confirm-title="Delete {{ record.Type }} record" data-confirm-message="This DNS record will be permanently removed. This action cannot be undone." data-confirm-action="/domains/records/{{ record.Type }}/{{ record.ID }}?domain_id={{ domain.ID }}" class="text-xs text-zinc-600 hover:text-red-400 transition-colors duration-150 opacity-0 group-hover:opacity-100">Delete</button> - {% endif %} - </div> + {% endfor %} </div> - {% endfor %} </div> {% else %} <div class="flex flex-col items-center justify-center py-16 text-center"> @@ -206,7 +197,6 @@ var typeDropdown = document.querySelector('[data-record-type-dropdown]'); var typeHiddenInput = typeDropdown.querySelector('[data-dropdown-value]'); var priorityField = document.querySelector('[data-priority-field]'); - var portMapField = document.querySelector('[data-port-map-field]'); var srvWeight = document.querySelector('[data-srv-weight]'); var srvPort = document.querySelector('[data-srv-port]'); var srvProtocol = document.querySelector('[data-srv-protocol]'); @@ -222,12 +212,12 @@ }); var fieldConfig = { - 'A': { label: 'Address (IPv4)', placeholder: '127.0.0.1', priority: false, portMap: true, srv: false }, - 'AAAA': { label: 'Address (IPv6)', placeholder: '::1', priority: false, portMap: false, srv: false }, - 'CNAME': { label: 'Target', placeholder: 'other.example.dove.', priority: false, portMap: false, srv: false }, - 'MX': { label: 'Mail Server', placeholder: 'mail.example.dove.', priority: true, portMap: false, srv: false }, - 'TXT': { label: 'Content', placeholder: 'v=spf1 include:...', priority: false, portMap: false, srv: false }, - 'SRV': { label: 'Target Host', placeholder: 'service.example.dove.', priority: true, portMap: false, srv: true } + 'A': { label: 'Address (IPv4)', placeholder: '127.0.0.1', priority: false, srv: false }, + 'AAAA': { label: 'Address (IPv6)', placeholder: '::1', priority: false, srv: false }, + 'CNAME': { label: 'Target', placeholder: 'other.example.dove.', priority: false, srv: false }, + 'MX': { label: 'Mail Server', placeholder: 'mail.example.dove.', priority: true, srv: false }, + 'TXT': { label: 'Content', placeholder: 'v=spf1 include:...', priority: false, srv: false }, + 'SRV': { label: 'Target Host', placeholder: 'service.example.dove.', priority: true, srv: true } }; function updateFields() { @@ -238,7 +228,6 @@ valueLabel.textContent = config.label; valueInput.placeholder = config.placeholder; priorityField.style.display = config.priority ? '' : 'none'; - portMapField.style.display = config.portMap ? '' : 'none'; srvWeight.style.display = config.srv ? '' : 'none'; srvPort.style.display = config.srv ? '' : 'none'; srvProtocol.style.display = config.srv ? '' : 'none'; @@ -253,73 +242,197 @@ updateFields(); - var ttlOptions = [['1', 'Immediate'], ['300', '5 min'], ['900', '15 min'], ['3600', '1 hour'], ['14400', '4 hours'], ['86400', '1 day']]; + var ttlOptions = [ + { value: '1', label: 'Immediate', desc: '1 second' }, + { value: '300', label: '5 minutes', desc: '300 seconds' }, + { value: '900', label: '15 minutes', desc: '900 seconds' }, + { value: '3600', label: '1 hour', desc: '3600 seconds' }, + { value: '14400', label: '4 hours', desc: '14400 seconds' }, + { value: '86400', label: '1 day', desc: '86400 seconds' } + ]; + + var editFieldConfig = { + 'A': { label: 'Address (IPv4)', placeholder: '127.0.0.1', priority: false }, + 'AAAA': { label: 'Address (IPv6)', placeholder: '::1', priority: false }, + 'CNAME': { label: 'Target', placeholder: 'other.example.dove.', priority: false }, + 'MX': { label: 'Mail Server', placeholder: 'mail.example.dove.', priority: true }, + 'TXT': { label: 'Content', placeholder: 'v=spf1 include:...', priority: false }, + 'SRV': { label: 'Target Host', placeholder: 'service.example.dove.', priority: true } + }; + + var chevronSvg = '<svg class="w-3.5 h-3.5 text-zinc-500 shrink-0 ml-1 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"><path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /></svg>'; + + function buildTtlDropdown(currentTtl) { + var wrapper = document.createElement('div'); + wrapper.className = 'dropdown'; + wrapper.setAttribute('data-dropdown', ''); + + var currentLabel = 'Immediate'; + for (var i = 0; i < ttlOptions.length; i++) { + if (ttlOptions[i].value === currentTtl) { + currentLabel = ttlOptions[i].label; + break; + } + } + + var hidden = document.createElement('input'); + hidden.type = 'hidden'; + hidden.name = 'ttl'; + hidden.value = currentTtl; + hidden.setAttribute('data-dropdown-value', ''); + wrapper.appendChild(hidden); + + var trigger = document.createElement('button'); + trigger.type = 'button'; + trigger.setAttribute('data-dropdown-trigger', ''); + trigger.className = 'input-field text-xs text-left flex items-center justify-between'; + + var labelSpan = document.createElement('span'); + labelSpan.className = 'truncate'; + labelSpan.setAttribute('data-dropdown-label', ''); + labelSpan.textContent = currentLabel; + trigger.appendChild(labelSpan); + trigger.insertAdjacentHTML('beforeend', chevronSvg); + wrapper.appendChild(trigger); + + var menuDiv = document.createElement('div'); + menuDiv.className = 'dropdown-menu'; + menuDiv.setAttribute('data-dropdown-menu', ''); + + var optionsDiv = document.createElement('div'); + optionsDiv.className = 'dropdown-options'; + optionsDiv.setAttribute('data-dropdown-options', ''); + + for (var j = 0; j < ttlOptions.length; j++) { + var optBtn = document.createElement('button'); + optBtn.type = 'button'; + optBtn.className = 'dropdown-option'; + optBtn.setAttribute('data-dropdown-option', ''); + optBtn.setAttribute('data-value', ttlOptions[j].value); + optBtn.setAttribute('data-label', ttlOptions[j].label); + + var optLabel = document.createElement('p'); + optLabel.className = 'text-sm text-zinc-200'; + optLabel.textContent = ttlOptions[j].label; + optBtn.appendChild(optLabel); + + var optDesc = document.createElement('p'); + optDesc.className = 'text-xs text-zinc-500'; + optDesc.textContent = ttlOptions[j].desc; + optBtn.appendChild(optDesc); + + optionsDiv.appendChild(optBtn); + } + + menuDiv.appendChild(optionsDiv); + wrapper.appendChild(menuDiv); + return wrapper; + } function createEditForm(recordType, recordId, recordName, recordValue, recordTtl, recordPriority) { + var config = editFieldConfig[recordType] || editFieldConfig['A']; + var form = document.createElement('form'); form.setAttribute('hx-put', '/domains/records/' + recordType + '/' + recordId + '?domain_id={{ domain.ID }}'); form.setAttribute('hx-swap', 'none'); form.setAttribute('data-inline-edit', ''); - form.className = 'flex items-center gap-3 w-full'; + form.className = 'px-5 py-4 space-y-3'; - var typeBadge = document.createElement('span'); - typeBadge.className = 'inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-medium shrink-0 bg-surface-800 text-zinc-400'; + var row1 = document.createElement('div'); + row1.className = 'grid grid-cols-5 gap-3'; + + var typeCol = document.createElement('div'); + var typeLabel = document.createElement('label'); + typeLabel.className = 'block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5'; + typeLabel.textContent = 'Type'; + typeCol.appendChild(typeLabel); + var typeBadge = document.createElement('div'); + typeBadge.className = 'input-field text-xs flex items-center opacity-60 cursor-not-allowed'; typeBadge.textContent = recordType; - form.appendChild(typeBadge); + typeCol.appendChild(typeBadge); + row1.appendChild(typeCol); + var nameCol = document.createElement('div'); + var nameLabel = document.createElement('label'); + nameLabel.className = 'block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5'; + nameLabel.textContent = 'Name'; + nameCol.appendChild(nameLabel); var nameInput = document.createElement('input'); nameInput.type = 'text'; nameInput.name = 'name'; nameInput.value = recordName; - nameInput.className = 'input-field text-xs w-20'; + nameInput.autocomplete = 'off'; nameInput.placeholder = '@'; - form.appendChild(nameInput); - - var valueInput = document.createElement('input'); - valueInput.type = 'text'; - valueInput.name = 'value'; - valueInput.value = recordValue; - valueInput.required = true; - valueInput.className = 'input-field text-xs flex-1'; - valueInput.placeholder = 'Value'; - form.appendChild(valueInput); - - var hasPriority = parseInt(recordPriority, 10) > 0; + nameInput.className = 'input-field text-xs'; + nameCol.appendChild(nameInput); + row1.appendChild(nameCol); + + var valueCol = document.createElement('div'); + valueCol.className = 'col-span-2'; + var valueLbl = document.createElement('label'); + valueLbl.className = 'block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5'; + valueLbl.textContent = config.label; + valueCol.appendChild(valueLbl); + var valInput = document.createElement('input'); + valInput.type = 'text'; + valInput.name = 'value'; + valInput.value = recordValue; + valInput.required = true; + valInput.autocomplete = 'off'; + valInput.placeholder = config.placeholder; + valInput.className = 'input-field text-xs'; + valueCol.appendChild(valInput); + row1.appendChild(valueCol); + + var ttlCol = document.createElement('div'); + var ttlLabel = document.createElement('label'); + ttlLabel.className = 'block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5'; + ttlLabel.textContent = 'TTL'; + ttlCol.appendChild(ttlLabel); + ttlCol.appendChild(buildTtlDropdown(recordTtl)); + row1.appendChild(ttlCol); + + form.appendChild(row1); + + var hasPriority = config.priority && parseInt(recordPriority, 10) > 0; if (hasPriority) { - var priorityInput = document.createElement('input'); - priorityInput.type = 'number'; - priorityInput.name = 'priority'; - priorityInput.value = recordPriority; - priorityInput.min = '0'; - priorityInput.className = 'input-field text-xs w-16'; - priorityInput.placeholder = 'Pri'; - form.appendChild(priorityInput); - } + var row2 = document.createElement('div'); + row2.className = 'grid grid-cols-5 gap-3'; - var ttlSelect = document.createElement('select'); - ttlSelect.name = 'ttl'; - ttlSelect.className = 'input-field text-xs w-24'; - for (var i = 0; i < ttlOptions.length; i++) { - var opt = document.createElement('option'); - opt.value = ttlOptions[i][0]; - opt.textContent = ttlOptions[i][1]; - if (recordTtl === ttlOptions[i][0]) opt.selected = true; - ttlSelect.appendChild(opt); + var priCol = document.createElement('div'); + var priLabel = document.createElement('label'); + priLabel.className = 'block text-[10px] font-medium text-zinc-500 mb-1 ml-0.5'; + priLabel.textContent = 'Priority'; + priCol.appendChild(priLabel); + var priInput = document.createElement('input'); + priInput.type = 'number'; + priInput.name = 'priority'; + priInput.value = recordPriority; + priInput.min = '0'; + priInput.className = 'input-field text-xs'; + priCol.appendChild(priInput); + row2.appendChild(priCol); + + form.appendChild(row2); } - form.appendChild(ttlSelect); + + var row3 = document.createElement('div'); + row3.className = 'flex items-center gap-3 pt-1'; var saveButton = document.createElement('button'); saveButton.type = 'submit'; saveButton.className = 'btn-small'; - saveButton.textContent = 'Save'; - form.appendChild(saveButton); + saveButton.textContent = 'Save Record'; + row3.appendChild(saveButton); var cancelLink = document.createElement('a'); cancelLink.href = 'javascript:void(0)'; - cancelLink.className = 'text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150 shrink-0'; + cancelLink.className = 'text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150'; cancelLink.textContent = 'Cancel'; cancelLink.setAttribute('data-cancel-edit', ''); - form.appendChild(cancelLink); + row3.appendChild(cancelLink); + + form.appendChild(row3); return form; } @@ -339,13 +452,18 @@ if (!row || row.querySelector('[data-inline-edit]')) return; var originalContent = row.innerHTML; + var originalClass = row.className; + row.className = 'border-b border-white/[0.04]'; while (row.firstChild) row.removeChild(row.firstChild); var form = createEditForm(recordType, recordId, recordName, recordValue, recordTtl, recordPriority); row.appendChild(form); + initDropdowns(); + form.querySelector('[data-cancel-edit]').addEventListener('click', function() { + row.className = originalClass; row.innerHTML = originalContent; htmx.process(row); }); @@ -353,7 +471,7 @@ htmx.process(row); } - var recordsList = document.querySelector('.divide-y'); + var recordsList = document.querySelector('[data-records-list]'); if (recordsList) { recordsList.addEventListener('click', handleEditClick); } diff --git a/templates/domains/htmx/editdomain.htmx.django b/templates/domains/htmx/editdomain.htmx.django deleted file mode 100644 index 7969999..0000000 --- a/templates/domains/htmx/editdomain.htmx.django +++ /dev/null @@ -1,48 +0,0 @@ -<div class="slide-up flex items-start justify-center pt-12"> - <div class="glass rounded-xl glow-border w-full max-w-lg"> - <div class="px-5 py-4 border-b border-white/[0.04]"> - <h2 class="text-sm font-medium text-zinc-200">Edit Domain</h2> - </div> - <div class="p-5"> - {% url "domains.manage.update" id=domain.ID as update_path %} - <form hx-put="{{ update_path }}" hx-swap="none" class="space-y-4"> - <div> - <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">Domain Name</label> - <input type="text" name="name" value="{{ domain.Name }}" required autocomplete="off" class="input-field"> - </div> - <div> - <label class="block text-xs font-medium text-zinc-400 mb-1.5 ml-1">TLD</label> - <div class="dropdown" data-dropdown> - <input type="hidden" name="tld_name" value="{{ domain.TLD.Name }}" data-dropdown-value> - <button type="button" data-dropdown-trigger class="input-field text-left flex items-center justify-between"> - <span class="truncate" data-dropdown-label>.{{ domain.TLD.Name }}</span> - <svg class="w-4 h-4 text-zinc-500 shrink-0 ml-2 transition-transform duration-150" data-dropdown-chevron fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" /> - </svg> - </button> - <div class="dropdown-menu" data-dropdown-menu> - <div class="p-2 border-b border-white/[0.04]"> - <input type="text" placeholder="Search TLDs..." class="dropdown-search" data-dropdown-search> - </div> - <div class="dropdown-options" data-dropdown-options> - {% for tld in tlds %} - <button type="button" class="dropdown-option" data-dropdown-option data-value="{{ tld.Name }}" data-label=".{{ tld.Name }}"> - <p class="text-sm text-zinc-200">.{{ tld.Name }}</p> - </button> - {% endfor %} - </div> - <div class="dropdown-empty hidden" data-dropdown-empty> - <p class="text-xs text-zinc-500 text-center py-3">No TLDs found</p> - </div> - </div> - </div> - </div> - <div class="flex items-center gap-3 pt-2"> - <button type="submit" class="btn-primary">Save Changes</button> - {% url "domains.manage" as domains_path %} - <a href="{{ domains_path }}" hx-get="{{ domains_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="text-xs text-zinc-500 hover:text-zinc-300 transition-colors duration-150">Cancel</a> - </div> - </form> - </div> - </div> -</div>
\ No newline at end of file diff --git a/templates/domains/htmx/index.htmx.django b/templates/domains/htmx/index.htmx.django index 3b2ef96..62272a5 100644 --- a/templates/domains/htmx/index.htmx.django +++ b/templates/domains/htmx/index.htmx.django @@ -4,7 +4,7 @@ <h2 class="text-sm font-medium text-zinc-200">Domain Manager</h2> </div> <div class="px-5 py-4"> - <p class="text-xs text-zinc-500 leading-relaxed">Dove runs a local DNS server that resolves your registered TLDs and domains. Each domain gets DNS records (A, AAAA, CNAME, MX, TXT, SRV) with port mapping support, so you can route <span class="text-zinc-400">myapp.dove</span> to <span class="text-zinc-400">127.0.0.1:3000</span>. Queries for unregistered domains are forwarded to your system's upstream DNS, so your regular internet browsing is not disrupted. Point your system's DNS resolver at Dove's DNS address to start resolving local domains.</p> + <p class="text-xs text-zinc-500 leading-relaxed">Dove runs a local DNS server that resolves your registered TLDs and domains. Each domain gets DNS records (A, AAAA, CNAME, MX, TXT, SRV) that resolve locally. Proxy rules let you route domains like <span class="text-zinc-400">myapp.dove</span> to local services on any port. Queries for unregistered domains are forwarded to your system's upstream DNS, so your regular internet browsing is not disrupted. Point your system's DNS resolver at <span class="text-zinc-400">127.0.0.1</span> to start resolving local domains.</p> </div> </div> @@ -35,19 +35,31 @@ <p class="text-xs text-zinc-500 leading-relaxed">Register domains under your TLDs. Each domain resolves locally and can be used for mail, service routing, and DNS records.</p> </a> + <a href="{{ manage_path }}" hx-get="{{ manage_path }}" hx-target="#content" hx-swap="innerHTML" hx-push-url="true" class="glass rounded-xl p-5 glow-border hover:bg-white/[0.02] transition-colors duration-150 group"> + <div class="flex items-center gap-3 mb-3"> + <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-accent-500/10"> + <svg class="w-4.5 h-4.5 text-accent-400" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /> + </svg> + </div> + <h3 class="text-sm font-medium text-zinc-200 group-hover:text-zinc-100">DNS Records</h3> + </div> + <p class="text-xs text-zinc-500 leading-relaxed">Manage A, AAAA, CNAME, MX, TXT, and SRV records per domain. Default records are seeded automatically when a domain is created. Open any domain to manage its records.</p> + </a> + <div class="glass rounded-xl p-5 glow-border opacity-60"> <div class="flex items-center gap-3 mb-3"> <div class="flex items-center justify-center w-9 h-9 rounded-lg bg-zinc-500/10"> <svg class="w-4.5 h-4.5 text-zinc-500" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> - <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 12h16.5m-16.5 3.75h16.5M3.75 19.5h16.5M5.625 4.5h12.75a1.875 1.875 0 0 1 0 3.75H5.625a1.875 1.875 0 0 1 0-3.75Z" /> + <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /> </svg> </div> <div class="flex items-center gap-2"> - <h3 class="text-sm font-medium text-zinc-400">DNS Records</h3> + <h3 class="text-sm font-medium text-zinc-400">Proxy Rules</h3> <span class="text-[10px] text-zinc-600 bg-surface-800 px-1.5 py-0.5 rounded">Coming soon</span> </div> </div> - <p class="text-xs text-zinc-600 leading-relaxed">Manage A, AAAA, CNAME, MX, TXT, and SRV records per domain. A records support port mapping for routing domains to local services. Wildcard subdomain records for multi-tenant apps.</p> + <p class="text-xs text-zinc-600 leading-relaxed">Route traffic from your domains to local services. Pick any domain, subdomain, or wildcard pattern and forward HTTP requests to a port on your machine.</p> </div> <div class="glass rounded-xl p-5 glow-border opacity-60"> diff --git a/templates/partials/sidebar.django b/templates/partials/sidebar.django index 01dd602..7568241 100644 --- a/templates/partials/sidebar.django +++ b/templates/partials/sidebar.django @@ -50,6 +50,12 @@ </svg> Domains </a> + <a href="#" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150 opacity-50 pointer-events-none"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <path stroke-linecap="round" stroke-linejoin="round" d="M7.5 21 3 16.5m0 0L7.5 12M3 16.5h13.5m0-13.5L21 7.5m0 0L16.5 12M21 7.5H7.5" /> + </svg> + Proxy Rules + </a> </div> </div> @@ -88,18 +94,14 @@ </div> </nav> + {% if AuthEnabled %} <div class="mt-auto p-3 border-t border-white/[0.04]"> - <div class="flex items-center gap-2 px-3 py-2 text-xs text-zinc-600"> - <span class="inline-block w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse"></span> - SMTP listening - </div> - {% if AuthEnabled %} - <a href="/auth/logout" class="flex items-center gap-3 px-3 py-2 rounded-lg text-xs text-zinc-500 hover:text-zinc-300 hover:bg-white/[0.04] transition-colors duration-150"> - <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> + <a href="/auth/logout" class="nav-link flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors duration-150"> + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="1.5"> <path stroke-linecap="round" stroke-linejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15m3 0 3-3m0 0-3-3m3 3H9" /> </svg> Logout </a> - {% endif %} </div> + {% endif %} </aside>
\ No newline at end of file diff --git a/utils/collections/orderedmap.go b/utils/collections/orderedmap.go new file mode 100644 index 0000000..b9f17b7 --- /dev/null +++ b/utils/collections/orderedmap.go @@ -0,0 +1,37 @@ +package collections + +type OrderedMap[K comparable, V any] struct { + keys []K + values map[K]V +} + +func OrderedMapOf[K comparable, V any]() OrderedMap[K, V] { + return OrderedMap[K, V]{ + keys: make([]K, 0), + values: make(map[K]V), + } +} + +func (orderedMap *OrderedMap[K, V]) Set(key K, value V) { + if _, exists := orderedMap.values[key]; !exists { + orderedMap.keys = append(orderedMap.keys, key) + } + orderedMap.values[key] = value +} + +func (orderedMap *OrderedMap[K, V]) Get(key K) (V, bool) { + value, exists := orderedMap.values[key] + return value, exists +} + +func (orderedMap *OrderedMap[K, V]) All() []V { + result := make([]V, 0, len(orderedMap.keys)) + for _, key := range orderedMap.keys { + result = append(result, orderedMap.values[key]) + } + return result +} + +func (orderedMap *OrderedMap[K, V]) Len() int { + return len(orderedMap.keys) +} diff --git a/utils/dns/server.go b/utils/dns/server.go index 7ae8770..1cd5623 100644 --- a/utils/dns/server.go +++ b/utils/dns/server.go @@ -14,7 +14,7 @@ import ( var activeServer *mdns.Server func Start() { - address := fmt.Sprintf("%s:%d", config.DNS.Host, config.DNS.Port) + address := fmt.Sprintf("%s:%d", config.BindAddress, config.DnsPort) mdns.HandleFunc(".", handleQuery) diff --git a/utils/dns/system.go b/utils/dns/system.go new file mode 100644 index 0000000..85f2a85 --- /dev/null +++ b/utils/dns/system.go @@ -0,0 +1,358 @@ +package dns + +import ( + "dove/config" + "fmt" + "os" + "os/exec" + "runtime" + "strings" +) + +type SystemDnsStatus struct { + Configured bool `json:"configured"` + Address string `json:"address"` + Platform string `json:"platform"` +} + +func CheckSystemDns() SystemDnsStatus { + doveAddress := fmt.Sprintf("%s", config.BindAddress) + status := SystemDnsStatus{ + Address: fmt.Sprintf("%s:%d", config.BindAddress, config.DnsPort), + Platform: runtime.GOOS, + } + + switch runtime.GOOS { + case "linux": + status.Configured = checkLinuxDns(doveAddress) + case "darwin": + status.Configured = checkDarwinDns(doveAddress) + case "windows": + status.Configured = checkWindowsDns(doveAddress) + } + + return status +} + +func ConfigureSystemDns() error { + doveAddress := config.BindAddress + + switch runtime.GOOS { + case "linux": + return configureLinuxDns(doveAddress) + case "darwin": + return configureDarwinDns(doveAddress) + case "windows": + return configureWindowsDns(doveAddress) + } + + return fmt.Errorf("Unsupported platform: %s.", runtime.GOOS) +} + +func DisableSystemDns() error { + doveAddress := config.BindAddress + + switch runtime.GOOS { + case "linux": + return disableLinuxDns(doveAddress) + case "darwin": + return disableDarwinDns(doveAddress) + case "windows": + return disableWindowsDns(doveAddress) + } + + return fmt.Errorf("Unsupported platform: %s.", runtime.GOOS) +} + +func checkLinuxDns(doveAddress string) bool { + if isSystemdResolved() { + output, commandError := exec.Command("resolvectl", "dns").Output() + if commandError != nil { + return false + } + return strings.Contains(string(output), doveAddress) + } + + resolvContents, readError := os.ReadFile("/etc/resolv.conf") + if readError != nil { + return false + } + + expectedLine := fmt.Sprintf("nameserver %s", doveAddress) + for _, line := range strings.Split(string(resolvContents), "\n") { + if strings.TrimSpace(line) == expectedLine { + return true + } + } + return false +} + +func configureLinuxDns(doveAddress string) error { + if isSystemdResolved() { + activeInterfaces, interfaceError := getActiveLinuxInterfaces() + if interfaceError != nil { + return interfaceError + } + for _, networkInterface := range activeInterfaces { + configureError := exec.Command("resolvectl", "dns", networkInterface, doveAddress).Run() + if configureError != nil { + return fmt.Errorf("Failed to configure DNS on %s: %w.", networkInterface, configureError) + } + } + return nil + } + + return prependNameserverToResolvConf(doveAddress) +} + +func disableLinuxDns(doveAddress string) error { + if isSystemdResolved() { + activeInterfaces, interfaceError := getActiveLinuxInterfaces() + if interfaceError != nil { + return interfaceError + } + for _, networkInterface := range activeInterfaces { + exec.Command("resolvectl", "revert", networkInterface).Run() + } + return nil + } + + return removeNameserverFromResolvConf(doveAddress) +} + +func isSystemdResolved() bool { + _, lookupError := exec.LookPath("resolvectl") + if lookupError != nil { + return false + } + serviceCheckError := exec.Command("systemctl", "is-active", "--quiet", "systemd-resolved").Run() + return serviceCheckError == nil +} + +func getActiveLinuxInterfaces() ([]string, error) { + output, commandError := exec.Command("ip", "-o", "link", "show", "up").Output() + if commandError != nil { + return nil, fmt.Errorf("Failed to list network interfaces: %w.", commandError) + } + + var activeInterfaces []string + for _, line := range strings.Split(string(output), "\n") { + fields := strings.Fields(line) + if len(fields) >= 2 { + interfaceName := strings.TrimSuffix(fields[1], ":") + if interfaceName != "lo" { + activeInterfaces = append(activeInterfaces, interfaceName) + } + } + } + + if len(activeInterfaces) == 0 { + return nil, fmt.Errorf("No active network interfaces found.") + } + return activeInterfaces, nil +} + +func prependNameserverToResolvConf(doveAddress string) error { + resolvContents, readError := os.ReadFile("/etc/resolv.conf") + if readError != nil { + return fmt.Errorf("Failed to read /etc/resolv.conf: %w.", readError) + } + + newLine := fmt.Sprintf("nameserver %s", doveAddress) + existingLines := strings.Split(string(resolvContents), "\n") + + for _, line := range existingLines { + if strings.TrimSpace(line) == newLine { + return nil + } + } + + updatedContents := newLine + "\n" + string(resolvContents) + return os.WriteFile("/etc/resolv.conf", []byte(updatedContents), 0644) +} + +func removeNameserverFromResolvConf(doveAddress string) error { + resolvContents, readError := os.ReadFile("/etc/resolv.conf") + if readError != nil { + return fmt.Errorf("Failed to read /etc/resolv.conf: %w.", readError) + } + + targetLine := fmt.Sprintf("nameserver %s", doveAddress) + existingLines := strings.Split(string(resolvContents), "\n") + var filteredLines []string + + for _, line := range existingLines { + if strings.TrimSpace(line) != targetLine { + filteredLines = append(filteredLines, line) + } + } + + return os.WriteFile("/etc/resolv.conf", []byte(strings.Join(filteredLines, "\n")), 0644) +} + +func checkDarwinDns(doveAddress string) bool { + output, commandError := exec.Command("scutil", "--dns").Output() + if commandError != nil { + return false + } + return strings.Contains(string(output), doveAddress) +} + +func configureDarwinDns(doveAddress string) error { + output, commandError := exec.Command("networksetup", "-listallnetworkservices").Output() + if commandError != nil { + return fmt.Errorf("Failed to list network services: %w.", commandError) + } + + lines := strings.Split(string(output), "\n") + for _, serviceName := range lines { + serviceName = strings.TrimSpace(serviceName) + if serviceName == "" || strings.HasPrefix(serviceName, "An asterisk") { + continue + } + + existingDns, _ := exec.Command("networksetup", "-getdnsservers", serviceName).Output() + existingServers := strings.TrimSpace(string(existingDns)) + + var dnsServers []string + dnsServers = append(dnsServers, doveAddress) + if existingServers != "" && !strings.Contains(existingServers, "aren't any") { + for _, server := range strings.Split(existingServers, "\n") { + server = strings.TrimSpace(server) + if server != "" && server != doveAddress { + dnsServers = append(dnsServers, server) + } + } + } + + configureError := exec.Command("networksetup", "-setdnsservers", serviceName, strings.Join(dnsServers, " ")).Run() + if configureError != nil { + return fmt.Errorf("Failed to configure DNS on %s: %w.", serviceName, configureError) + } + } + + return nil +} + +func disableDarwinDns(doveAddress string) error { + output, commandError := exec.Command("networksetup", "-listallnetworkservices").Output() + if commandError != nil { + return fmt.Errorf("Failed to list network services: %w.", commandError) + } + + lines := strings.Split(string(output), "\n") + for _, serviceName := range lines { + serviceName = strings.TrimSpace(serviceName) + if serviceName == "" || strings.HasPrefix(serviceName, "An asterisk") { + continue + } + + existingDns, _ := exec.Command("networksetup", "-getdnsservers", serviceName).Output() + existingServers := strings.TrimSpace(string(existingDns)) + + var remainingServers []string + if existingServers != "" && !strings.Contains(existingServers, "aren't any") { + for _, server := range strings.Split(existingServers, "\n") { + server = strings.TrimSpace(server) + if server != "" && server != doveAddress { + remainingServers = append(remainingServers, server) + } + } + } + + if len(remainingServers) == 0 { + exec.Command("networksetup", "-setdnsservers", serviceName, "Empty").Run() + } else { + exec.Command("networksetup", "-setdnsservers", serviceName, strings.Join(remainingServers, " ")).Run() + } + } + + return nil +} + +func checkWindowsDns(doveAddress string) bool { + output, commandError := exec.Command("powershell", "-Command", + "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses").Output() + if commandError != nil { + return false + } + return strings.Contains(string(output), doveAddress) +} + +func configureWindowsDns(doveAddress string) error { + output, commandError := exec.Command("powershell", "-Command", + "Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -ExpandProperty InterfaceAlias").Output() + if commandError != nil { + return fmt.Errorf("Failed to list network adapters: %w.", commandError) + } + + adapters := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, adapterName := range adapters { + adapterName = strings.TrimSpace(adapterName) + if adapterName == "" { + continue + } + + existingOutput, _ := exec.Command("powershell", "-Command", + fmt.Sprintf("(Get-DnsClientServerAddress -InterfaceAlias '%s' -AddressFamily IPv4).ServerAddresses -join ','", adapterName)).Output() + existingServers := strings.TrimSpace(string(existingOutput)) + + var dnsServers []string + dnsServers = append(dnsServers, fmt.Sprintf("'%s'", doveAddress)) + if existingServers != "" { + for _, server := range strings.Split(existingServers, ",") { + server = strings.TrimSpace(server) + if server != "" && server != doveAddress { + dnsServers = append(dnsServers, fmt.Sprintf("'%s'", server)) + } + } + } + + setCommand := fmt.Sprintf("Set-DnsClientServerAddress -InterfaceAlias '%s' -ServerAddresses (%s)", + adapterName, strings.Join(dnsServers, ",")) + configureError := exec.Command("powershell", "-Command", setCommand).Run() + if configureError != nil { + return fmt.Errorf("Failed to configure DNS on %s: %w.", adapterName, configureError) + } + } + + return nil +} + +func disableWindowsDns(doveAddress string) error { + output, commandError := exec.Command("powershell", "-Command", + "Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -ExpandProperty InterfaceAlias").Output() + if commandError != nil { + return fmt.Errorf("Failed to list network adapters: %w.", commandError) + } + + adapters := strings.Split(strings.TrimSpace(string(output)), "\n") + for _, adapterName := range adapters { + adapterName = strings.TrimSpace(adapterName) + if adapterName == "" { + continue + } + + existingOutput, _ := exec.Command("powershell", "-Command", + fmt.Sprintf("(Get-DnsClientServerAddress -InterfaceAlias '%s' -AddressFamily IPv4).ServerAddresses -join ','", adapterName)).Output() + + var remainingServers []string + for _, server := range strings.Split(strings.TrimSpace(string(existingOutput)), ",") { + server = strings.TrimSpace(server) + if server != "" && server != doveAddress { + remainingServers = append(remainingServers, fmt.Sprintf("'%s'", server)) + } + } + + if len(remainingServers) == 0 { + exec.Command("powershell", "-Command", + fmt.Sprintf("Set-DnsClientServerAddress -InterfaceAlias '%s' -ResetServerAddresses", adapterName)).Run() + } else { + setCommand := fmt.Sprintf("Set-DnsClientServerAddress -InterfaceAlias '%s' -ServerAddresses (%s)", + adapterName, strings.Join(remainingServers, ",")) + exec.Command("powershell", "-Command", setCommand).Run() + } + } + + return nil +} diff --git a/utils/email/submit.go b/utils/email/submit.go index 255c630..82589e7 100644 --- a/utils/email/submit.go +++ b/utils/email/submit.go @@ -8,13 +8,13 @@ import ( ) func Submit(senderAddress string, recipients []string, rawMessage []byte) error { - serverAddress := resolveLocalSMTPAddress() + serverAddress := fmt.Sprintf("%s:%d", config.BindAddress, config.SmtpPort) logger.Debugf(LogPrefix, SubmittingMessage, senderAddress, recipients, serverAddress) var smtpAuth smtp.Auth if config.SMTP.AuthRequired { - smtpAuth = smtp.PlainAuth("", config.SMTP.Username, config.SMTP.Password, config.SMTP.Host) + smtpAuth = smtp.PlainAuth("", config.SMTP.Username, config.SMTP.Password, config.BindAddress) } sendError := smtp.SendMail(serverAddress, smtpAuth, senderAddress, recipients, rawMessage) @@ -26,12 +26,3 @@ func Submit(senderAddress string, recipients []string, rawMessage []byte) error logger.Infof(LogPrefix, SubmitSuccess, senderAddress, recipients) return nil } - -func resolveLocalSMTPAddress() string { - host := config.SMTP.Host - if host == "0.0.0.0" || host == "::" { - host = "127.0.0.1" - } - - return fmt.Sprintf("%s:%d", host, config.SMTP.Port) -} diff --git a/utils/smtp/messages.go b/utils/smtp/messages.go index 0fd280b..8b14f8d 100644 --- a/utils/smtp/messages.go +++ b/utils/smtp/messages.go @@ -19,5 +19,5 @@ const ( ShutdownComplete = "All listeners stopped." ShutdownFailed = "Failed to shutdown %s listener: %v" TLSCertLoadFailed = "Failed to load TLS certificate: %v" - UnknownRecipientDomain = "Recipient domain %s is not managed by this server." + UnknownRecipientDomain = "No MX records found for recipient domain %s." ) diff --git a/utils/smtp/server.go b/utils/smtp/server.go index 0518ed7..572aea5 100644 --- a/utils/smtp/server.go +++ b/utils/smtp/server.go @@ -23,12 +23,12 @@ func Start() { tlsConfig = loadTLSConfig() } - plainAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.Port) + plainAddress := fmt.Sprintf("%s:%d", config.BindAddress, config.SmtpPort) plainServer := createServer(plainAddress) activeServers = append(activeServers, ServerInstance{Server: plainServer, Label: "SMTP"}) go startListener(plainServer, "SMTP", plainAddress) - smtpsAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.SMTPSPort) + smtpsAddress := fmt.Sprintf("%s:%d", config.BindAddress, config.SmtpsPort) smtpsServer := createServer(smtpsAddress) activeServers = append(activeServers, ServerInstance{Server: smtpsServer, Label: "SMTPS"}) if tlsConfig != nil { @@ -38,14 +38,14 @@ func Start() { go startListener(smtpsServer, "SMTPS", smtpsAddress) } - starttlsAddress := fmt.Sprintf("%s:%d", config.SMTP.Host, config.SMTP.StartTLSPort) - starttlsServer := createServer(starttlsAddress) - starttlsServer.EnableSMTPUTF8 = true - activeServers = append(activeServers, ServerInstance{Server: starttlsServer, Label: "STARTTLS"}) + submissionAddress := fmt.Sprintf("%s:%d", config.BindAddress, config.SubmissionPort) + submissionServer := createServer(submissionAddress) + submissionServer.EnableSMTPUTF8 = true + activeServers = append(activeServers, ServerInstance{Server: submissionServer, Label: "Submission"}) if tlsConfig != nil { - starttlsServer.TLSConfig = tlsConfig + submissionServer.TLSConfig = tlsConfig } - go startListener(starttlsServer, "STARTTLS", starttlsAddress) + go startListener(submissionServer, "Submission", submissionAddress) } func Shutdown() { diff --git a/utils/urls/attach.go b/utils/urls/attach.go index baf9929..906d765 100644 --- a/utils/urls/attach.go +++ b/utils/urls/attach.go @@ -6,7 +6,7 @@ func Attach(application *fiber.App) { registry.Mutex.Lock() defer registry.Mutex.Unlock() - for _, route := range registry.Routes { + for _, route := range registry.Routes.All() { bindRoute(application, route) } } diff --git a/utils/urls/path.go b/utils/urls/path.go index a3b3ec9..28f400f 100644 --- a/utils/urls/path.go +++ b/utils/urls/path.go @@ -26,21 +26,21 @@ func Path(method HTTPMethod, path string, handler fiber.Handler, name string) { fullName := resolveFullName(namespace, name) fullPath := resolveFullPath(namespace, path) - registry.Routes[fullName] = RegisteredRoute{ + registry.Routes.Set(fullName, RegisteredRoute{ Method: method, Path: path, Handler: handler, Namespace: namespace, Name: name, FullPath: fullPath, - } + }) } func GetFullPath(routeName string) (string, bool) { registry.Mutex.Lock() defer registry.Mutex.Unlock() - route, exists := registry.Routes[routeName] + route, exists := registry.Routes.Get(routeName) if !exists { return "", false } diff --git a/utils/urls/registry.go b/utils/urls/registry.go index e95d30c..e384a1e 100644 --- a/utils/urls/registry.go +++ b/utils/urls/registry.go @@ -20,11 +20,11 @@ type RegisteredRoute struct { type RouteRegistry struct { Mutex sync.Mutex CurrentNamespace string - Routes collections.Record[string, RegisteredRoute] + Routes collections.OrderedMap[string, RegisteredRoute] } var registry = &RouteRegistry{ - Routes: make(collections.Record[string, RegisteredRoute]), + Routes: collections.OrderedMapOf[string, RegisteredRoute](), } func SetNamespace(namespace string) { |
