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