chore: auto-commit uncommitted changes
This commit is contained in:
parent
55367fe06e
commit
472e9dc07b
|
|
@ -6,15 +6,15 @@ DATE := $(shell date -u +%Y%m%d-%H%M)
|
|||
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(DATE)
|
||||
|
||||
# Deploy targets
|
||||
REMOTE := clavitor-hq
|
||||
REMOTE_PATH := /opt/clavitor/bin
|
||||
# Download hosting
|
||||
SITE_HOST := root@clavitor.ai
|
||||
SITE_DIR := /opt/clavitor-web/downloads
|
||||
|
||||
export GOFIPS140 := latest
|
||||
|
||||
# --- Build targets ---
|
||||
|
||||
.PHONY: build build-all linux-amd64 linux-arm64 release test clean deploy
|
||||
.PHONY: build build-all linux-amd64 linux-arm64 test clean publish
|
||||
|
||||
build: linux-amd64
|
||||
|
||||
|
|
@ -28,20 +28,16 @@ linux-arm64:
|
|||
CGO_ENABLED=1 GOOS=linux GOARCH=arm64 CC=aarch64-linux-gnu-gcc \
|
||||
$(GO) build -ldflags "$(LDFLAGS)" -o $(BINARY)-linux-arm64 ./cmd/clavitor
|
||||
|
||||
release:
|
||||
./scripts/release.sh
|
||||
|
||||
test:
|
||||
$(GO) test ./...
|
||||
|
||||
clean:
|
||||
rm -f $(BINARY)-linux-amd64 $(BINARY)-linux-arm64
|
||||
|
||||
# --- Deploy ---
|
||||
# --- Publish binaries to clavitor.ai/download ---
|
||||
|
||||
deploy: linux-amd64
|
||||
scp $(BINARY)-linux-amd64 $(REMOTE):/tmp/$(BINARY)-new
|
||||
ssh $(REMOTE) 'sudo systemctl stop clavitor && mv /tmp/$(BINARY)-new $(REMOTE_PATH)/$(BINARY) && chmod +x $(REMOTE_PATH)/$(BINARY) && sudo systemctl start clavitor'
|
||||
@echo "Deployed. Verifying..."
|
||||
@sleep 2
|
||||
@ssh $(REMOTE) 'sudo systemctl is-active clavitor'
|
||||
publish: build-all
|
||||
ssh $(SITE_HOST) 'mkdir -p $(SITE_DIR)'
|
||||
scp $(BINARY)-linux-amd64 $(SITE_HOST):$(SITE_DIR)/
|
||||
scp $(BINARY)-linux-arm64 $(SITE_HOST):$(SITE_DIR)/
|
||||
@echo "Published $(VERSION) to clavitor.ai/download"
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -2,19 +2,30 @@ package lib
|
|||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/caddyserver/certmagic"
|
||||
"github.com/libdns/cloudflare"
|
||||
"github.com/mholt/acmez/v3/acme"
|
||||
)
|
||||
|
||||
// TLSConfig holds configuration for automatic TLS via Let's Encrypt DNS-01.
|
||||
// TLSConfig holds configuration for TLS.
|
||||
type TLSConfig struct {
|
||||
Domain string // e.g. "use1.clavitor.ai"
|
||||
Domain string // e.g. "use1.clavitor.ai"
|
||||
CFToken string // Cloudflare API token for DNS-01 challenge
|
||||
DataDir string // directory to store certs
|
||||
Email string // ACME account email
|
||||
|
|
@ -30,15 +41,21 @@ func LoadTLSConfig() TLSConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// ListenAndServeTLS starts an HTTPS server on the given address using
|
||||
// Let's Encrypt certificates obtained via Cloudflare DNS-01 challenge.
|
||||
// Falls back to plain HTTP if TLS is not configured.
|
||||
// ListenAndServeTLS starts an HTTPS server.
|
||||
//
|
||||
// Three modes:
|
||||
// - TLS_DOMAIN + CF_API_TOKEN set → Let's Encrypt via Cloudflare DNS-01
|
||||
// - Neither set → self-signed certificate (generated on first run)
|
||||
// - Plain HTTP is never used
|
||||
func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||
if cfg.Domain == "" || cfg.CFToken == "" {
|
||||
log.Printf("TLS not configured (missing TLS_DOMAIN or CF_API_TOKEN), serving plain HTTP")
|
||||
return http.ListenAndServe(addr, handler)
|
||||
if cfg.Domain != "" && cfg.CFToken != "" {
|
||||
return listenLetsEncrypt(addr, handler, cfg)
|
||||
}
|
||||
return listenSelfSigned(addr, handler, cfg)
|
||||
}
|
||||
|
||||
// listenLetsEncrypt uses certmagic for automatic Let's Encrypt certificates.
|
||||
func listenLetsEncrypt(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||
if cfg.DataDir == "" {
|
||||
cfg.DataDir = "/opt/clavitor/certs"
|
||||
}
|
||||
|
|
@ -46,7 +63,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
|||
cfg.Email = "ops@clavitor.ai"
|
||||
}
|
||||
|
||||
// Configure certmagic
|
||||
certmagic.DefaultACME.Agreed = true
|
||||
certmagic.DefaultACME.Email = cfg.Email
|
||||
certmagic.DefaultACME.DNS01Solver = &certmagic.DNS01Solver{
|
||||
|
|
@ -60,7 +76,6 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
|||
magic := certmagic.NewDefault()
|
||||
magic.Storage = &certmagic.FileStorage{Path: cfg.DataDir}
|
||||
|
||||
// Obtain/renew cert
|
||||
ctx := context.Background()
|
||||
if err := magic.ManageSync(ctx, []string{cfg.Domain}); err != nil {
|
||||
return fmt.Errorf("certmagic manage %s: %w", cfg.Domain, err)
|
||||
|
|
@ -76,10 +91,93 @@ func ListenAndServeTLS(addr string, handler http.Handler, cfg TLSConfig) error {
|
|||
}
|
||||
|
||||
log.Printf("Clavitor listening on https://0.0.0.0%s (%s)", addr, cfg.Domain)
|
||||
// TLS certs are managed by certmagic, pass empty cert/key paths
|
||||
return server.ListenAndServeTLS("", "")
|
||||
}
|
||||
|
||||
// listenSelfSigned generates a self-signed certificate and serves HTTPS.
|
||||
// Cert is stored in DataDir (or working directory) so it persists across restarts.
|
||||
// The browser will show a certificate warning — the connection is still encrypted.
|
||||
func listenSelfSigned(addr string, handler http.Handler, cfg TLSConfig) error {
|
||||
certDir := cfg.DataDir
|
||||
if certDir == "" {
|
||||
certDir = "."
|
||||
}
|
||||
certPath := filepath.Join(certDir, "clavitor.crt")
|
||||
keyPath := filepath.Join(certDir, "clavitor.key")
|
||||
|
||||
if err := ensureSelfSignedCert(certPath, keyPath); err != nil {
|
||||
return fmt.Errorf("self-signed cert: %w", err)
|
||||
}
|
||||
|
||||
server := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
}
|
||||
|
||||
log.Printf("Clavitor listening on https://0.0.0.0%s (self-signed)", addr)
|
||||
return server.ListenAndServeTLS(certPath, keyPath)
|
||||
}
|
||||
|
||||
// ensureSelfSignedCert creates a self-signed TLS certificate if one doesn't exist.
|
||||
// Valid for 10 years, covers localhost, 127.0.0.1, and all local network IPs.
|
||||
func ensureSelfSignedCert(certPath, keyPath string) error {
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
return nil // both exist
|
||||
}
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{Organization: []string{"Clavitor"}, CommonName: "clavitor"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(10 * 365 * 24 * time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
}
|
||||
|
||||
// Add all local interface IPs so LAN access works
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
tmpl.IPAddresses = append(tmpl.IPAddresses, ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Write cert and key as PEM
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
keyDER, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
|
||||
if err := os.WriteFile(certPath, certPEM, 0644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0600); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Printf("Generated self-signed TLS certificate: %s", certPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ChallengeError wraps ACME errors with actionable context.
|
||||
func ChallengeError(err error) string {
|
||||
if acmeErr, ok := err.(acme.Problem); ok {
|
||||
|
|
@ -87,3 +185,24 @@ func ChallengeError(err error) string {
|
|||
}
|
||||
return err.Error()
|
||||
}
|
||||
|
||||
// SelfSignedTLSConfig returns a tls.Config with a fresh self-signed certificate.
|
||||
// Used by tests that need HTTPS without filesystem cert files.
|
||||
func SelfSignedTLSConfig() *tls.Config {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "test"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(24 * time.Hour),
|
||||
DNSNames: []string{"localhost"},
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)},
|
||||
}
|
||||
certDER, _ := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &key.PublicKey, key)
|
||||
cert := tls.Certificate{
|
||||
Certificate: [][]byte{certDER},
|
||||
PrivateKey: key,
|
||||
}
|
||||
return &tls.Config{Certificates: []tls.Certificate{cert}}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -147,6 +147,7 @@ code { font-size: 0.875em; }
|
|||
/* === TABLES === */
|
||||
.data-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
|
||||
.data-table th { text-align: left; font-weight: 600; font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-tertiary); padding: 8px 12px; border-bottom: 2px solid var(--border); }
|
||||
.data-table th:first-child, .data-table td:first-child { padding-left: 0; }
|
||||
.data-table td { padding: 10px 12px; border-bottom: 1px solid var(--border); color: var(--text); }
|
||||
.data-table tr:last-child td { border-bottom: none; }
|
||||
|
||||
|
|
@ -328,6 +329,7 @@ code { font-size: 0.875em; }
|
|||
}
|
||||
.glass-status-live { background: var(--brand-black); color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.glass-status-planned { background: var(--surface); color: var(--muted); }
|
||||
.glass-status-outage { background: #dc2626; color: #fff; border-radius: 0; width: 40px; height: 40px; padding: 0; display: inline-flex; align-items: center; justify-content: center; }
|
||||
.glass-country { font-size: 0.72rem; color: var(--muted); margin-left: 6px; text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.glass-latency-block { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 12px; }
|
||||
.glass-latency-left { display: flex; flex-direction: column; }
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -13,6 +13,7 @@ import (
|
|||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -29,6 +30,7 @@ var static embed.FS
|
|||
var templates *template.Template
|
||||
var devMode bool
|
||||
var db *sql.DB
|
||||
var processStartTime = time.Now().Unix()
|
||||
|
||||
var countryNames = map[string]string{
|
||||
"US": "United States", "CA": "Canada", "MX": "Mexico", "CO": "Colombia",
|
||||
|
|
@ -345,18 +347,19 @@ func main() {
|
|||
t.NodeID, t.Version, t.Hostname, t.UptimeSeconds, t.CPUPercent, t.MemTotalMB, t.MemUsedMB, t.DiskTotalMB, t.DiskUsedMB, t.Load1m, t.VaultCount, t.VaultSizeMB, t.VaultEntries, t.Mode)
|
||||
|
||||
// Uptime span tracking: extend existing span or create new one
|
||||
// During maintenance: always extend, never create new (no false gaps)
|
||||
// Normal: gap threshold 60s (2x the 30s interval, allows one missed beat)
|
||||
// Skip span updates if we just restarted — we can't judge gaps yet
|
||||
now := time.Now().Unix()
|
||||
var inMaint bool
|
||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
|
||||
var spanID int64
|
||||
var spanEnd int64
|
||||
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
|
||||
if err == nil && (inMaint || (now-spanEnd) <= 60) {
|
||||
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
|
||||
} else if !inMaint {
|
||||
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
|
||||
if (now - processStartTime) >= 60 {
|
||||
var inMaint bool
|
||||
db.QueryRow(`SELECT COUNT(*) > 0 FROM maintenance WHERE end_at IS NULL`).Scan(&inMaint)
|
||||
var spanID int64
|
||||
var spanEnd int64
|
||||
err = db.QueryRow(`SELECT id, end_at FROM uptime_spans WHERE node_id = ? ORDER BY end_at DESC LIMIT 1`, t.NodeID).Scan(&spanID, &spanEnd)
|
||||
if err == nil && (inMaint || (now-spanEnd) <= 60) {
|
||||
db.Exec(`UPDATE uptime_spans SET end_at = ? WHERE id = ?`, now, spanID)
|
||||
} else if !inMaint {
|
||||
db.Exec(`INSERT INTO uptime_spans (node_id, start_at, end_at) VALUES (?, ?, ?)`, t.NodeID, now, now)
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy daily uptime (kept for backwards compat)
|
||||
|
|
@ -737,10 +740,30 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Outages
|
||||
type Outage struct {
|
||||
ID int `json:"id"`
|
||||
StartAt string `json:"start_at"`
|
||||
EndAt string `json:"end_at"`
|
||||
NodeID string `json:"node_id"`
|
||||
Status string `json:"status"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
var outages []Outage
|
||||
oRows, _ := db.Query(`SELECT id, start_at, COALESCE(end_at,''), node_id, status, description FROM outages ORDER BY id DESC`)
|
||||
if oRows != nil {
|
||||
defer oRows.Close()
|
||||
for oRows.Next() {
|
||||
var o Outage
|
||||
oRows.Scan(&o.ID, &o.StartAt, &o.EndAt, &o.NodeID, &o.Status, &o.Description)
|
||||
outages = append(outages, o)
|
||||
}
|
||||
}
|
||||
|
||||
overall := "All Systems Operational"
|
||||
if inMaintenance {
|
||||
overall = "Scheduled Maintenance"
|
||||
} else if !allOperational {
|
||||
} else if !allOperational || len(outages) > 0 {
|
||||
overall = "Some Systems Degraded"
|
||||
}
|
||||
|
||||
|
|
@ -753,6 +776,7 @@ func main() {
|
|||
"overall": overall,
|
||||
"nodes": nodes,
|
||||
"incidents": incidents,
|
||||
"outages": outages,
|
||||
"dates": dates,
|
||||
"last_heartbeat": lastBeat,
|
||||
})
|
||||
|
|
@ -825,6 +849,23 @@ func main() {
|
|||
render(w, data)
|
||||
})
|
||||
|
||||
// Downloads — direct file serving, no traversal
|
||||
http.HandleFunc("/download/", func(w http.ResponseWriter, r *http.Request) {
|
||||
name := strings.TrimPrefix(r.URL.Path, "/download/")
|
||||
if name == "" || strings.Contains(name, "/") || strings.Contains(name, "\\") || strings.Contains(name, "..") || strings.HasPrefix(name, ".") {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
path := filepath.Join("downloads", name)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.IsDir() {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Disposition", "attachment; filename=\""+name+"\"")
|
||||
http.ServeFile(w, r, path)
|
||||
})
|
||||
|
||||
// SEO
|
||||
http.HandleFunc("/robots.txt", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain")
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<a href="/" style="font-family:var(--font-sans);font-weight:700;letter-spacing:0.15em;text-transform:uppercase;font-size:0.8rem;color:var(--text);display:inline-flex;align-items:center;gap:6px"><span style="display:inline-block;width:12px;height:12px;background:var(--brand-black);border-radius:2px"></span>clavitor</a>
|
||||
<span style="opacity:0.4">GitHub</span>
|
||||
<a href="#">Discord</a>
|
||||
<a href="#">X</a>
|
||||
<a href="https://x.com/clavitorai">X</a>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="/privacy">Privacy</a>
|
||||
|
|
|
|||
|
|
@ -7,15 +7,15 @@
|
|||
|
||||
<div class="section container" style="padding-top:24px">
|
||||
<div style="background:#fffbeb;border:1px solid #fde68a;border-radius:var(--radius-sm);padding:16px 20px;margin-bottom:24px;font-size:0.85rem;color:#854d0e">
|
||||
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Expanding our network</strong><br>
|
||||
We are actively deploying new points of presence. Some planned nodes may show as unavailable during provisioning. Dubai (me-central-1) is temporarily offline due to AWS infrastructure damage in the UAE.
|
||||
<strong style="text-transform:uppercase;letter-spacing:0.06em;font-size:0.75rem">Dubai (me-central-1)</strong><br>
|
||||
The AWS UAE region remains offline following drone strikes on March 1, 2026. No customer data was affected. We are awaiting updates from AWS on restoration, expected in the coming weeks.
|
||||
</div>
|
||||
<div class="glass-grid">
|
||||
{{range .Pops}}
|
||||
<div class="glass-pop {{if eq .Status "live"}}glass-live{{else}}glass-planned{{end}}">
|
||||
<div class="glass-header">
|
||||
<div><div class="pop-city">{{.City}}</div><div class="pop-country">{{.CountryFull}}</div></div>
|
||||
<span class="glass-status {{if eq .Status "live"}}glass-status-live{{else}}glass-status-planned{{end}}">{{.Status}}</span>
|
||||
<span class="glass-status {{if eq .Status "live"}}glass-status-live{{else if eq .Status "outage"}}glass-status-outage{{else}}glass-status-planned{{end}}">{{.Status}}</span>
|
||||
</div>
|
||||
{{if eq .Status "live"}}<div class="glass-latency-block">
|
||||
<div class="glass-latency-left"><span class="glass-latency-title">Response time</span><span class="glass-latency-hint">lower is better</span></div>
|
||||
|
|
|
|||
|
|
@ -87,9 +87,10 @@
|
|||
const nodes = data.nodes || [];
|
||||
const dates = data.dates || [];
|
||||
let html = '';
|
||||
// Separate live and planned
|
||||
// Separate live, planned, and outage
|
||||
const live = nodes.filter(n => n.status === 'live');
|
||||
const planned = nodes.filter(n => n.status !== 'live');
|
||||
const planned = nodes.filter(n => n.status === 'planned');
|
||||
const outageNodes = nodes.filter(n => n.status === 'outage');
|
||||
|
||||
for (const n of live) {
|
||||
const days = n.uptime_90 || [];
|
||||
|
|
@ -103,7 +104,7 @@
|
|||
|
||||
html += `<div class="st-node">
|
||||
<div class="st-header">
|
||||
<div><span class="pop-city">${n.city}</span><span class="pop-country">${n.country}</span></div>
|
||||
<div><span class="pop-city">${n.city}</span> <span class="pop-country">${n.country}</span></div>
|
||||
<span class="st-health ${healthClass}">${healthLabel}</span>
|
||||
</div>
|
||||
<div class="st-bars">`;
|
||||
|
|
@ -126,30 +127,42 @@
|
|||
</div>`;
|
||||
}
|
||||
|
||||
if (outageNodes.length > 0) {
|
||||
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Awaiting Recovery</div>`;
|
||||
for (const n of outageNodes) {
|
||||
html += `<div class="st-node" style="opacity:0.6"><div class="st-header"><div><span class="pop-city">${n.city}</span> <span class="pop-country">${n.country}</span></div><span class="st-health st-health-down">Outage</span></div></div>`;
|
||||
}
|
||||
}
|
||||
if (planned.length > 0) {
|
||||
html += `<div style="margin-top:32px;margin-bottom:16px;font-size:0.7rem;font-weight:500;letter-spacing:0.12em;text-transform:uppercase;color:var(--text-tertiary)">Planned</div>`;
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:8px">`;
|
||||
for (const n of planned) {
|
||||
html += `<div style="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span><span class="pop-country">${n.country}</span></div>`;
|
||||
html += `<div style="padding:8px 0"><span class="pop-city" style="color:var(--text-tertiary)">${n.city}</span> <span class="pop-country">${n.country}</span></div>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
document.getElementById('status-nodes').innerHTML = html;
|
||||
|
||||
// Incidents
|
||||
const incidents = data.incidents || [];
|
||||
if (incidents.length > 0) {
|
||||
let ihtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Recent Incidents</h3>';
|
||||
for (const inc of incidents) {
|
||||
ihtml += `<div class="st-incident">
|
||||
<div class="st-incident-title">${inc.title}</div>
|
||||
<div class="st-incident-meta">${inc.date} · ${inc.status}</div>
|
||||
</div>`;
|
||||
// Outages
|
||||
const outages = data.outages || [];
|
||||
if (outages.length > 0) {
|
||||
let ohtml = '<h3 style="font-size:1rem;font-weight:600;margin-bottom:16px">Incidents</h3>';
|
||||
ohtml += '<table class="data-table"><thead><tr><th>Start</th><th>End</th><th>Node</th><th>Status</th><th>Description</th></tr></thead><tbody>';
|
||||
for (const o of outages) {
|
||||
const statusColor = o.status === 'resolved' ? '#16a34a' : o.status === 'ongoing' ? '#dc2626' : '#ca8a04';
|
||||
ohtml += `<tr>
|
||||
<td style="white-space:nowrap">${o.start_at}</td>
|
||||
<td style="white-space:nowrap">${o.end_at || '—'}</td>
|
||||
<td>${o.node_id}</td>
|
||||
<td><span style="color:${statusColor};font-weight:600;font-size:0.8rem;text-transform:uppercase">${o.status}</span></td>
|
||||
<td style="color:var(--text-secondary)">${o.description}</td>
|
||||
</tr>`;
|
||||
}
|
||||
document.getElementById('status-incidents').innerHTML = ihtml;
|
||||
ohtml += '</tbody></table>';
|
||||
document.getElementById('status-incidents').innerHTML = ohtml;
|
||||
} else {
|
||||
document.getElementById('status-incidents').innerHTML =
|
||||
'<p class="text-sm text-tertiary" style="margin-top:32px">No recent incidents.</p>';
|
||||
'<p class="text-sm text-tertiary" style="margin-top:32px">No incidents.</p>';
|
||||
}
|
||||
|
||||
document.getElementById('status-updated').textContent = 'Updated ' + new Date().toLocaleTimeString();
|
||||
|
|
|
|||
Loading…
Reference in New Issue