From 6cc0bfd1d79074df790272b8091b1f0226d14283 Mon Sep 17 00:00:00 2001 From: zwlucas Date: Fri, 29 May 2026 15:57:37 -0300 Subject: feat: upload project Signed-off-by: zwlucas --- .gitignore | 19 + backend/.env | 7 + backend/.env.example | 7 + backend/cmd/server/main.go | 109 + backend/config.json | 24 + backend/go.mod | 16 + backend/go.sum | 36 + backend/internal/alerts/alerts.go | 113 + backend/internal/alerts/smtp.go | 85 + backend/internal/api/auth.go | 144 ++ backend/internal/api/handler.go | 243 ++ backend/internal/api/router.go | 71 + backend/internal/api/sse.go | 57 + backend/internal/config/config.go | 125 + backend/internal/model/types.go | 52 + backend/internal/monitor/checker.go | 86 + backend/internal/monitor/worker.go | 148 ++ backend/internal/sse/broadcaster.go | 42 + backend/internal/store/memory.go | 277 +++ backend/internal/store/postgres.go | 324 +++ backend/internal/store/store.go | 23 + backend/migrations/schema.sql | 34 + backend/yaum-server.exe | Bin 0 -> 16090112 bytes frontend/package-lock.json | 2560 ++++++++++++++++++++ frontend/package.json | 22 + frontend/postcss.config.js | 6 + frontend/src/app.css | 50 + frontend/src/app.d.ts | 3 + frontend/src/app.html | 13 + frontend/src/hooks.server.js | 31 + frontend/src/lib/api.ts | 39 + frontend/src/lib/components/ActivityLog.svelte | 122 + frontend/src/lib/components/AddServiceModal.svelte | 224 ++ frontend/src/lib/components/ServiceCard.svelte | 90 + frontend/src/lib/components/UptimeTimeline.svelte | 41 + frontend/src/lib/realtime.ts | 42 + frontend/src/lib/types.ts | 44 + frontend/src/routes/(admin)/admin/+page.server.js | 27 + frontend/src/routes/(admin)/admin/+page.svelte | 432 ++++ frontend/src/routes/+layout.svelte | 50 + frontend/src/routes/+page.svelte | 164 ++ frontend/src/routes/+page.ts | 33 + frontend/src/routes/login/+page.server.js | 51 + frontend/src/routes/login/+page.svelte | 61 + frontend/src/routes/services/[id]/+page.svelte | 261 ++ frontend/src/routes/status/[id]/+page.svelte | 278 +++ frontend/src/routes/status/[id]/+page.ts | 22 + frontend/static/favicon.svg | 5 + frontend/svelte.config.js | 12 + frontend/tailwind.config.js | 30 + frontend/tsconfig.json | 14 + frontend/vite.config.ts | 11 + 52 files changed, 6780 insertions(+) create mode 100644 .gitignore create mode 100644 backend/.env create mode 100644 backend/.env.example create mode 100644 backend/cmd/server/main.go create mode 100644 backend/config.json create mode 100644 backend/go.mod create mode 100644 backend/go.sum create mode 100644 backend/internal/alerts/alerts.go create mode 100644 backend/internal/alerts/smtp.go create mode 100644 backend/internal/api/auth.go create mode 100644 backend/internal/api/handler.go create mode 100644 backend/internal/api/router.go create mode 100644 backend/internal/api/sse.go create mode 100644 backend/internal/config/config.go create mode 100644 backend/internal/model/types.go create mode 100644 backend/internal/monitor/checker.go create mode 100644 backend/internal/monitor/worker.go create mode 100644 backend/internal/sse/broadcaster.go create mode 100644 backend/internal/store/memory.go create mode 100644 backend/internal/store/postgres.go create mode 100644 backend/internal/store/store.go create mode 100644 backend/migrations/schema.sql create mode 100644 backend/yaum-server.exe create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/src/app.css create mode 100644 frontend/src/app.d.ts create mode 100644 frontend/src/app.html create mode 100644 frontend/src/hooks.server.js create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/components/ActivityLog.svelte create mode 100644 frontend/src/lib/components/AddServiceModal.svelte create mode 100644 frontend/src/lib/components/ServiceCard.svelte create mode 100644 frontend/src/lib/components/UptimeTimeline.svelte create mode 100644 frontend/src/lib/realtime.ts create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/routes/(admin)/admin/+page.server.js create mode 100644 frontend/src/routes/(admin)/admin/+page.svelte create mode 100644 frontend/src/routes/+layout.svelte create mode 100644 frontend/src/routes/+page.svelte create mode 100644 frontend/src/routes/+page.ts create mode 100644 frontend/src/routes/login/+page.server.js create mode 100644 frontend/src/routes/login/+page.svelte create mode 100644 frontend/src/routes/services/[id]/+page.svelte create mode 100644 frontend/src/routes/status/[id]/+page.svelte create mode 100644 frontend/src/routes/status/[id]/+page.ts create mode 100644 frontend/static/favicon.svg create mode 100644 frontend/svelte.config.js create mode 100644 frontend/tailwind.config.js create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f24604 --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +# Backend +backend/server.exe +backend/server + +# Frontend +frontend/node_modules/ +frontend/.svelte-kit/ +frontend/build/ +frontend/.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db diff --git a/backend/.env b/backend/.env new file mode 100644 index 0000000..5138821 --- /dev/null +++ b/backend/.env @@ -0,0 +1,7 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Para producao, use o hash bcrypt pre-computado: +# ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" suasenha | tr -d ':\n' | sed 's/$2y/$2a/') + +JWT_SECRET= diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..5138821 --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,7 @@ +ADMIN_USERNAME=admin +ADMIN_PASSWORD=admin123 + +# Para producao, use o hash bcrypt pre-computado: +# ADMIN_PASSWORD_HASH=$(htpasswd -bnBC 10 "" suasenha | tr -d ':\n' | sed 's/$2y/$2a/') + +JWT_SECRET= diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..f734885 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,109 @@ +package main + +import ( + "context" + "log" + "net/http" + "os" + + "os/signal" + "syscall" + + "github.com/joho/godotenv" + "golang.org/x/crypto/bcrypt" + + "yaum/internal/alerts" + "yaum/internal/api" + "yaum/internal/config" + "yaum/internal/monitor" + "yaum/internal/sse" + "yaum/internal/store" +) + +func main() { + godotenv.Load() + + cfgPath := os.Getenv("CONFIG_PATH") + cfg, err := config.Load(cfgPath) + if err != nil { + log.Fatalf("config: %v", err) + } + + passwordHash := cfg.AdminPasswordHash() + if passwordHash == "" { + if pw := os.Getenv("ADMIN_PASSWORD"); pw != "" { + hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) + if err != nil { + log.Fatalf("erro ao gerar hash da senha: %v", err) + } + passwordHash = string(hash) + } + } + if passwordHash == "" { + log.Fatal("ADMIN_PASSWORD_HASH ou ADMIN_PASSWORD nao definido — defina no .env ou config.json") + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var st store.Store + + if dbURL := cfg.DatabaseURL(); dbURL != "" { + log.Println("conectando ao postgres...") + pg, err := store.NewPostgresStore(ctx, dbURL) + if err != nil { + log.Fatalf("banco: %v", err) + } + defer pg.Close() + st = pg + log.Println("conectado ao postgres") + } else { + log.Println("database.url vazio — usando store em memoria") + ms := store.NewMemoryStore() + ms.SeedMockData() + st = ms + } + + broadcaster := sse.NewBroadcaster() + + worker := monitor.NewWorker(st, cfg.CheckInterval(), cfg.CheckTimeout(), broadcaster) + if cfg.SMTP.From != "" { + smtpCfg := &alerts.SMTPConfig{ + Host: cfg.SMTP.Host, + Port: cfg.SMTP.Port, + Username: cfg.SMTP.Username, + Password: cfg.SMTP.Password, + From: cfg.SMTP.From, + } + worker.WithSMTP(smtpCfg) + } + handler := api.NewHandler(st, int(cfg.CheckTimeout().Seconds())) + streamHandler := api.NewStreamHandler(broadcaster) + authHandler := api.NewAuthHandler( + cfg.AdminUsername(), + passwordHash, + cfg.JWTSecret(), + ) + + server := &http.Server{ + Addr: cfg.ServerAddr(), + Handler: api.NewRouter(handler, streamHandler, authHandler), + } + + go worker.Start(ctx) + + go func() { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + sig := <-sigCh + log.Printf("sinal recebido: %s. desligando...", sig) + worker.Stop() + server.Shutdown(context.Background()) + }() + + log.Printf("yaum rodando em %s", cfg.ServerAddr()) + if err := server.ListenAndServe(); err != http.ErrServerClosed { + log.Fatalf("erro ao iniciar servidor: %v", err) + } + log.Println("servidor encerrado com sucesso") +} diff --git a/backend/config.json b/backend/config.json new file mode 100644 index 0000000..4d2afdc --- /dev/null +++ b/backend/config.json @@ -0,0 +1,24 @@ +{ + "server": { + "addr": ":8080" + }, + "database": { + "url": "" + }, + "monitor": { + "interval": "60s", + "timeout": "10s" + }, + "smtp": { + "host": "", + "port": 587, + "username": "", + "password": "", + "from": "" + }, + "auth": { + "admin_username": "admin", + "admin_password_hash": "", + "jwt_secret": "" + } +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..68f40b1 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,16 @@ +module yaum + +go 1.25.0 + +require github.com/jackc/pgx/v5 v5.9.2 + +require ( + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + golang.org/x/crypto v0.52.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/text v0.37.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..68f0bf7 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= +github.com/jackc/pgx/v5 v5.9.2/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= +golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk= +golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/alerts/alerts.go b/backend/internal/alerts/alerts.go new file mode 100644 index 0000000..44d4a50 --- /dev/null +++ b/backend/internal/alerts/alerts.go @@ -0,0 +1,113 @@ +package alerts + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + "time" + + "yaum/internal/model" +) + +type SMTPConfig struct { + Host string + Port int + Username string + Password string + From string +} + +func SendDiscord(webhookURL string, svc model.Service, hb model.Heartbeat, wasUp bool) { + color := 15548997 + title := "🔴 Site Fora do Ar" + if hb.IsUp { + color = 5763719 + title = "🟢 Site Recuperado" + } + + desc := "o servico caiu" + fields := []map[string]any{ + {"name": "URL", "value": svc.URL, "inline": true}, + {"name": "Codigo", "value": fmt.Sprintf("`%d`", hb.StatusCode), "inline": true}, + {"name": "Tempo", "value": fmt.Sprintf("%dms", hb.ResponseTimeMs), "inline": true}, + } + if hb.ErrorMessage != "" { + fields = append(fields, map[string]any{"name": "Erro", "value": hb.ErrorMessage, "inline": false}) + } + if hb.IsUp { + desc = "o servico voltou" + } + + payload := map[string]any{ + "embeds": []map[string]any{ + { + "title": title + " | " + svc.Name, + "description": desc, + "color": color, + "fields": fields, + "footer": map[string]any{"text": "YAUM — Yet Another Uptime Monitor"}, + "timestamp": hb.TestedAt.Format(time.RFC3339), + }, + }, + "username": "YAUM", + } + + data, _ := json.Marshal(payload) + + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Post(webhookURL, "application/json", bytes.NewReader(data)) + if err != nil { + log.Printf("[alerta:discord] erro ao enviar: %v", err) + return + } + resp.Body.Close() + if resp.StatusCode >= 400 { + log.Printf("[alerta:discord] webhook retornou %d", resp.StatusCode) + return + } + log.Printf("[alerta:discord] webhook enviado para '%s'", svc.Name) +} + +func SendEmail(svc model.Service, hb model.Heartbeat, wasUp bool, cfg *SMTPConfig) { + if cfg == nil || cfg.From == "" || cfg.Host == "" { + log.Println("[alerta:email] smtp nao configurado — pulando") + return + } + + statusEmoji := "🟢" + statusTxt := "UP" + subject := "[YAUM] Site Recuperado: " + svc.Name + if !hb.IsUp { + statusEmoji = "🔴" + statusTxt = "DOWN" + subject = "[YAUM] Site Fora do Ar: " + svc.Name + } + + body := fmt.Sprintf(`YAUM — Yet Another Uptime Monitor + +%s Status: %s + +Servico: %s +URL: %s +Status HTTP: %d +Tempo de Resposta: %dms +Mensagem: %s +Testado em: %s +`, + statusEmoji, statusTxt, + svc.Name, svc.URL, hb.StatusCode, hb.ResponseTimeMs, hb.ErrorMessage, + hb.TestedAt.Format("2006-01-02 15:04:05 MST"), + ) + + if err := sendMail(hostPort(cfg.Host, cfg.Port), cfg.Username, cfg.Password, cfg.From, []string{svc.AlertEmail}, subject, body); err != nil { + log.Printf("[alerta:email] erro: %v", err) + return + } + log.Printf("[alerta:email] email enviado para %s", svc.AlertEmail) +} + +func hostPort(host string, port int) string { + return fmt.Sprintf("%s:%d", host, port) +} diff --git a/backend/internal/alerts/smtp.go b/backend/internal/alerts/smtp.go new file mode 100644 index 0000000..5bb1ba8 --- /dev/null +++ b/backend/internal/alerts/smtp.go @@ -0,0 +1,85 @@ +package alerts + +import ( + "crypto/tls" + "fmt" + "net/smtp" + "strings" +) + +func sendMail(addr, user, pass, from string, to []string, subject, body string) error { + host := addr + for i, c := range addr { + if c == ':' { + host = addr[:i] + break + } + } + + msg := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s", + from, strings.Join(to, ", "), subject, body) + + auth := newLoginAuth(user, pass) + + c, err := smtp.Dial(addr) + if err != nil { + return fmt.Errorf("dial: %w", err) + } + defer c.Close() + + if err := c.StartTLS(&tls.Config{ServerName: host}); err != nil { + return fmt.Errorf("starttls: %w", err) + } + + if user != "" { + if err := c.Auth(auth); err != nil { + return fmt.Errorf("auth: %w", err) + } + } + + if err := c.Mail(from); err != nil { + return fmt.Errorf("mail from: %w", err) + } + for _, addr := range to { + if err := c.Rcpt(addr); err != nil { + return fmt.Errorf("rcpt %s: %w", addr, err) + } + } + + w, err := c.Data() + if err != nil { + return fmt.Errorf("data: %w", err) + } + if _, err := fmt.Fprint(w, msg); err != nil { + return fmt.Errorf("write: %w", err) + } + if err := w.Close(); err != nil { + return fmt.Errorf("close: %w", err) + } + return c.Quit() +} + +type loginAuth struct { + user, pass string +} + +func newLoginAuth(user, pass string) smtp.Auth { + return &loginAuth{user, pass} +} + +func (a *loginAuth) Start(server *smtp.ServerInfo) (string, []byte, error) { + return "LOGIN", []byte(a.user), nil +} + +func (a *loginAuth) Next(fromServer []byte, more bool) ([]byte, error) { + if more { + msg := string(fromServer) + switch { + case strings.EqualFold(msg, "Username:"): + return []byte(a.user), nil + case strings.EqualFold(msg, "Password:"): + return []byte(a.pass), nil + } + } + return nil, nil +} diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go new file mode 100644 index 0000000..5095194 --- /dev/null +++ b/backend/internal/api/auth.go @@ -0,0 +1,144 @@ +package api + +import ( + "crypto/rand" + "encoding/base64" + "encoding/json" + "log" + "net/http" + "strings" + "time" + + "github.com/golang-jwt/jwt/v5" + "golang.org/x/crypto/bcrypt" +) + +type AuthHandler struct { + username string + passwordHash string + jwtSecret []byte +} + +type loginRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type Claims struct { + Username string `json:"username"` + jwt.RegisteredClaims +} + +func NewAuthHandler(username, passwordHash, jwtSecret string) *AuthHandler { + if username == "" { + username = "admin" + } + secret := []byte(jwtSecret) + if len(secret) == 0 { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + secret = []byte("dev-secret-do-not-use-in-production") + } else { + secret = []byte(base64.RawURLEncoding.EncodeToString(key)) + } + log.Println("[auth] jwt_secret nao configurado — usando chave gerada aleatoriamente") + } + if passwordHash == "" { + log.Fatal("[auth] ADMIN_PASSWORD_HASH ou ADMIN_PASSWORD deve ser definido") + } + return &AuthHandler{ + username: username, + passwordHash: passwordHash, + jwtSecret: secret, + } +} + +func (a *AuthHandler) Login(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido"}) + return + } + if req.Username != a.username { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"}) + return + } + if err := bcrypt.CompareHashAndPassword([]byte(a.passwordHash), []byte(req.Password)); err != nil { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "credenciais invalidas"}) + return + } + + now := time.Now() + claims := Claims{ + Username: req.Username, + RegisteredClaims: jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now), + ExpiresAt: jwt.NewNumericDate(now.Add(7 * 24 * time.Hour)), + Subject: req.Username, + }, + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + signed, err := token.SignedString(a.jwtSecret) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "erro ao gerar token"}) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"token": signed}) +} + +func (a *AuthHandler) Verify(w http.ResponseWriter, r *http.Request) { + tokenStr := extractToken(r) + if tokenStr == "" { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token ausente"}) + return + } + if !a.validateToken(tokenStr) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "token invalido ou expirado"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (a *AuthHandler) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasPrefix(r.URL.Path, "/api/auth/") { + next.ServeHTTP(w, r) + return + } + if r.Method == http.MethodGet || r.Method == http.MethodOptions { + next.ServeHTTP(w, r) + return + } + tokenStr := extractToken(r) + if tokenStr == "" || !a.validateToken(tokenStr) { + writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "nao autorizado"}) + return + } + next.ServeHTTP(w, r) + }) +} + +func (a *AuthHandler) validateToken(tokenStr string) bool { + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (any, error) { + return a.jwtSecret, nil + }) + if err != nil || !token.Valid { + return false + } + return claims.Username == a.username +} + +func extractToken(r *http.Request) string { + auth := r.Header.Get("Authorization") + if strings.HasPrefix(auth, "Bearer ") { + return strings.TrimPrefix(auth, "Bearer ") + } + cookie, err := r.Cookie("auth_token") + if err == nil && cookie.Value != "" { + return cookie.Value + } + return "" +} diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go new file mode 100644 index 0000000..f304351 --- /dev/null +++ b/backend/internal/api/handler.go @@ -0,0 +1,243 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "time" + + "yaum/internal/monitor" + "yaum/internal/model" + "yaum/internal/store" +) + +type ServiceResponse struct { + model.Service + LastHeartbeat *model.Heartbeat `json:"last_heartbeat,omitempty"` +} + +type Handler struct { + store store.Store + timeout int +} + +func NewHandler(s store.Store, timeoutSec int) *Handler { + return &Handler{store: s, timeout: timeoutSec} +} + +func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) { + services, err := h.store.ListServices() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + resp := make([]ServiceResponse, len(services)) + for i, svc := range services { + resp[i] = ServiceResponse{ + Service: svc.Service, + LastHeartbeat: svc.LastHeartbeat, + } + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) CreateService(w http.ResponseWriter, r *http.Request) { + var svc model.Service + if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()}) + return + } + if svc.Name == "" || svc.URL == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name e url sao obrigatorios"}) + return + } + created, err := h.store.AddService(svc) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusCreated, created) +} + +func (h *Handler) ToggleService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + svc, err := h.store.ToggleService(id) + if err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, svc) +} + +func (h *Handler) TestService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + + svc, err := h.store.GetService(id) + if err != nil || svc == nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": "servico nao encontrado"}) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.timeout)*time.Second) + defer cancel() + + hb := monitor.CheckHTTP(ctx, svc.URL, svc.KeywordToFind) + hb.ServiceID = svc.ID + + if saverr := h.store.AddHeartbeat(hb); saverr != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": saverr.Error()}) + return + } + + writeJSON(w, http.StatusOK, hb) +} + +func (h *Handler) UpdateService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + + var svc model.Service + if err := json.NewDecoder(r.Body).Decode(&svc); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "json invalido: " + err.Error()}) + return + } + + updated, err := h.store.UpdateService(id, svc) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, updated) +} + +func (h *Handler) DeleteService(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + if err := h.store.DeleteService(id); err != nil { + writeJSON(w, http.StatusNotFound, map[string]string{"error": err.Error()}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + history, err := h.store.GetHistory(id, 50) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, history) +} + +func (h *Handler) GetActiveMaintenance(w http.ResponseWriter, r *http.Request) { + m, err := h.store.GetActiveMaintenance(time.Now()) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if m == nil { + writeJSON(w, http.StatusOK, map[string]any{"maintenance": nil}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"maintenance": m}) +} + +func (h *Handler) BadgeSVG(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte(badgeSVG("ERRO", "#888"))) + return + } + + svc, err := h.store.GetService(id) + if err != nil || svc == nil { + w.Header().Set("Content-Type", "image/svg+xml") + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(badgeSVG("N/A", "#888"))) + return + } + + stats, _ := h.store.GetServiceStats(id) + lastHb, _ := h.store.GetLastHeartbeat(id) + + var label, color string + if lastHb == nil { + label = "AGUARDANDO" + color = "#888" + } else if lastHb.IsUp { + if stats != nil && stats.Uptime30d > 0 { + label = "UP " + fmt.Sprintf("%.1f", stats.Uptime30d) + "%" + } else { + label = "UP" + } + color = "#22f06a" + } else { + label = "DOWN" + color = "#ff4060" + } + + w.Header().Set("Content-Type", "image/svg+xml") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.Write([]byte(badgeSVG(label, color))) +} + +func badgeSVG(label string, color string) string { + textWidth := len(label) * 8 + if textWidth < 40 { + textWidth = 40 + } + totalWidth := 20 + textWidth + 8 + + return fmt.Sprintf( + ` + + + %s +`, + totalWidth, totalWidth, totalWidth-textWidth-8, textWidth+8, color, + 10+(textWidth/2), label, + ) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + id, err := strconv.Atoi(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"}) + return + } + stats, err := h.store.GetServiceStats(id) + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, stats) +} + +func writeJSON(w http.ResponseWriter, status int, data any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go new file mode 100644 index 0000000..9a18cc5 --- /dev/null +++ b/backend/internal/api/router.go @@ -0,0 +1,71 @@ +package api + +import ( + "net/http" + "strings" +) + +func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("POST /api/auth/login", auth.Login) + mux.HandleFunc("GET /api/auth/verify", auth.Verify) + + mux.HandleFunc("GET /api/services", h.ListServices) + mux.HandleFunc("POST /api/services", h.CreateService) + mux.HandleFunc("PUT /api/services/{id}", h.UpdateService) + mux.HandleFunc("PATCH /api/services/{id}/toggle", h.ToggleService) + mux.HandleFunc("POST /api/services/{id}/test", h.TestService) + mux.HandleFunc("DELETE /api/services/{id}", h.DeleteService) + mux.HandleFunc("GET /api/services/{id}/history", h.GetHistory) + mux.HandleFunc("GET /api/services/{id}/stats", h.GetStats) + mux.HandleFunc("GET /api/services/{id}/badge.svg", h.BadgeSVG) + + mux.HandleFunc("GET /api/active-maintenance", h.GetActiveMaintenance) + + mux.Handle("GET /api/stream", stream) + + return corsMiddleware(auth.Middleware(mux)) +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + origin := r.Header.Get("Origin") + + if origin != "" && isAllowedOrigin(origin) { + w.Header().Set("Access-Control-Allow-Origin", origin) + w.Header().Set("Vary", "Origin") + } else { + w.Header().Set("Access-Control-Allow-Origin", "*") + } + + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + w.Header().Set("Access-Control-Max-Age", "86400") + + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusNoContent) + return + } + + next.ServeHTTP(w, r) + }) +} + +func isAllowedOrigin(origin string) bool { + allowed := []string{ + "http://localhost:5173", + "http://localhost:4173", + "http://127.0.0.1:5173", + "http://127.0.0.1:4173", + } + for _, a := range allowed { + if strings.EqualFold(origin, a) { + return true + } + } + if strings.HasPrefix(origin, "http://localhost:") { + return true + } + return false +} diff --git a/backend/internal/api/sse.go b/backend/internal/api/sse.go new file mode 100644 index 0000000..7c2332c --- /dev/null +++ b/backend/internal/api/sse.go @@ -0,0 +1,57 @@ +package api + +import ( + "fmt" + "log" + "net/http" + + "yaum/internal/sse" +) + +type StreamHandler struct { + broadcaster *sse.Broadcaster +} + +func NewStreamHandler(b *sse.Broadcaster) *StreamHandler { + return &StreamHandler{broadcaster: b} +} + +func (sh *StreamHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming not supported", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + + ch := sh.broadcaster.Subscribe() + defer sh.broadcaster.Unsubscribe(ch) + + _, err := fmt.Fprintf(w, "event: connected\ndata: {}\n\n") + if err != nil { + return + } + flusher.Flush() + + ctx := r.Context() + for { + select { + case data, ok := <-ch: + if !ok { + return + } + _, err := fmt.Fprintf(w, "event: heartbeat\ndata: %s\n\n", data) + if err != nil { + log.Printf("[sse] cliente desconectado: %v", err) + return + } + flusher.Flush() + case <-ctx.Done(): + return + } + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..60d176e --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,125 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "time" +) + +type Config struct { + Server ServerConfig `json:"server"` + Database DatabaseConfig `json:"database"` + Monitor MonitorConfig `json:"monitor"` + SMTP SMTPConfig `json:"smtp"` + Auth AuthConfig `json:"auth"` +} + +type SMTPConfig struct { + Host string `json:"host"` + Port int `json:"port"` + Username string `json:"username"` + Password string `json:"password"` + From string `json:"from"` +} + +type AuthConfig struct { + AdminUsername string `json:"admin_username"` + AdminPasswordHash string `json:"admin_password_hash"` + JWTSecret string `json:"jwt_secret"` +} + +type ServerConfig struct { + Addr string `json:"addr"` +} + +type DatabaseConfig struct { + URL string `json:"url"` +} + +type MonitorConfig struct { + Interval string `json:"interval"` + Timeout string `json:"timeout"` +} + +func Load(path string) (*Config, error) { + if path == "" { + path = "config.json" + } + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("ler config %s: %w", path, err) + } + + var cfg Config + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parse config %s: %w", path, err) + } + + cfg.applyDefaults() + return &cfg, nil +} + +func (c *Config) applyDefaults() { + if c.Server.Addr == "" { + c.Server.Addr = ":8080" + } + if c.Monitor.Interval == "" { + c.Monitor.Interval = "60s" + } + if c.Monitor.Timeout == "" { + c.Monitor.Timeout = "10s" + } +} + +func (c *Config) ServerAddr() string { + if v := os.Getenv("ADDR"); v != "" { + return v + } + return c.Server.Addr +} + +func (c *Config) DatabaseURL() string { + if v := os.Getenv("DATABASE_URL"); v != "" { + return v + } + return c.Database.URL +} + +func (c *Config) CheckInterval() time.Duration { + d, err := time.ParseDuration(c.Monitor.Interval) + if err != nil { + return 60 * time.Second + } + return d +} + +func (c *Config) CheckTimeout() time.Duration { + d, err := time.ParseDuration(c.Monitor.Timeout) + if err != nil { + return 10 * time.Second + } + return d +} + +func (c *Config) AdminUsername() string { + if v := os.Getenv("ADMIN_USERNAME"); v != "" { + return v + } + return c.Auth.AdminUsername +} + +func (c *Config) AdminPasswordHash() string { + if v := os.Getenv("ADMIN_PASSWORD_HASH"); v != "" { + return v + } + return c.Auth.AdminPasswordHash +} + +func (c *Config) JWTSecret() string { + if v := os.Getenv("JWT_SECRET"); v != "" { + return v + } + return c.Auth.JWTSecret +} diff --git a/backend/internal/model/types.go b/backend/internal/model/types.go new file mode 100644 index 0000000..4e439b8 --- /dev/null +++ b/backend/internal/model/types.go @@ -0,0 +1,52 @@ +package model + +import "time" + +type Service struct { + ID int `json:"id"` + Name string `json:"name"` + URL string `json:"url"` + GroupName string `json:"group_name"` + IntervalSeconds int `json:"interval_seconds"` + IsActive bool `json:"is_active"` + DiscordWebhookURL string `json:"discord_webhook_url,omitempty"` + AlertEmail string `json:"alert_email,omitempty"` + KeywordToFind string `json:"keyword_to_find,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Heartbeat struct { + ID int `json:"id"` + ServiceID int `json:"service_id"` + StatusCode int `json:"status_code"` + ResponseTimeMs int64 `json:"response_time_ms"` + IsUp bool `json:"is_up"` + ErrorMessage string `json:"error_message,omitempty"` + TestedAt time.Time `json:"tested_at"` +} + +type Maintenance struct { + ID int `json:"id"` + Title string `json:"title"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + IsActive bool `json:"is_active"` +} + +type ServiceStats struct { + ServiceID int `json:"service_id"` + Uptime24h float64 `json:"uptime_24h"` + Uptime7d float64 `json:"uptime_7d"` + Uptime30d float64 `json:"uptime_30d"` + AvgResponseMs24h float64 `json:"avg_response_ms_24h"` + AvgResponseMs7d float64 `json:"avg_response_ms_7d"` + AvgResponseMs30d float64 `json:"avg_response_ms_30d"` + TotalChecks24h int `json:"total_checks_24h"` + TotalChecks7d int `json:"total_checks_7d"` + TotalChecks30d int `json:"total_checks_30d"` +} + +type ServiceWithHeartbeat struct { + Service + LastHeartbeat *Heartbeat `json:"last_heartbeat,omitempty"` +} diff --git a/backend/internal/monitor/checker.go b/backend/internal/monitor/checker.go new file mode 100644 index 0000000..07f807e --- /dev/null +++ b/backend/internal/monitor/checker.go @@ -0,0 +1,86 @@ +package monitor + +import ( + "context" + "io" + "net/http" + "strings" + "time" + + "yaum/internal/model" +) + +var httpClient = &http.Client{ + Timeout: 10 * time.Second, +} + +func CheckHTTP(ctx context.Context, url string, keyword string) model.Heartbeat { + start := time.Now() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return heartbeatFromError(start, err) + } + + resp, err := httpClient.Do(req) + elapsed := time.Since(start).Milliseconds() + + if err != nil { + return model.Heartbeat{ + StatusCode: 0, + ResponseTimeMs: elapsed, + IsUp: false, + ErrorMessage: err.Error(), + TestedAt: time.Now(), + } + } + defer resp.Body.Close() + + isUp := resp.StatusCode >= 200 && resp.StatusCode < 400 + errMsg := "" + + body, readErr := io.ReadAll(resp.Body) + if readErr != nil { + isUp = false + errMsg = "Erro ao ler corpo: " + readErr.Error() + } else { + bodyStr := string(body) + + if keyword != "" && isUp { + if !strings.Contains(bodyStr, keyword) { + isUp = false + errMsg = "Palavra-chave nao encontrada" + } + } + + if !isUp { + snippet := strings.TrimSpace(bodyStr) + if len(snippet) > 300 { + snippet = snippet[:300] + } + if errMsg == "" { + errMsg = snippet + } else { + errMsg += " | " + snippet + } + } + } + + return model.Heartbeat{ + StatusCode: resp.StatusCode, + ResponseTimeMs: elapsed, + IsUp: isUp, + ErrorMessage: errMsg, + TestedAt: time.Now(), + } +} + +func heartbeatFromError(start time.Time, err error) model.Heartbeat { + return model.Heartbeat{ + StatusCode: 0, + ResponseTimeMs: time.Since(start).Milliseconds(), + IsUp: false, + ErrorMessage: err.Error(), + TestedAt: time.Now(), + } +} diff --git a/backend/internal/monitor/worker.go b/backend/internal/monitor/worker.go new file mode 100644 index 0000000..0cf980a --- /dev/null +++ b/backend/internal/monitor/worker.go @@ -0,0 +1,148 @@ +package monitor + +import ( + "context" + "encoding/json" + "log" + "time" + + "yaum/internal/alerts" + "yaum/internal/model" + "yaum/internal/sse" + "yaum/internal/store" +) + +type checkResult struct { + Heartbeat model.Heartbeat + Service model.Service +} + +type Worker struct { + store store.Store + interval time.Duration + timeout time.Duration + results chan checkResult + broadcaster *sse.Broadcaster + smtpCfg *alerts.SMTPConfig + quit chan struct{} +} + +func NewWorker(s store.Store, interval, timeout time.Duration, b *sse.Broadcaster) *Worker { + return &Worker{ + store: s, + interval: interval, + timeout: timeout, + results: make(chan checkResult, 100), + broadcaster: b, + quit: make(chan struct{}), + } +} + +func (w *Worker) WithSMTP(cfg *alerts.SMTPConfig) *Worker { + w.smtpCfg = cfg + return w +} + +func (w *Worker) Start(ctx context.Context) { + ticker := time.NewTicker(w.interval) + defer ticker.Stop() + + log.Printf("[worker] iniciado | intervalo=%v timeout=%v", w.interval, w.timeout) + + go w.resultConsumer() + + w.runChecks(ctx) + + for { + select { + case <-ticker.C: + w.runChecks(ctx) + case <-w.quit: + log.Println("[worker] encerrando...") + return + } + } +} + +func (w *Worker) resultConsumer() { + for { + select { + case res := <-w.results: + hb := res.Heartbeat + svc := res.Service + + w.checkTransition(svc, hb) + + if err := w.store.AddHeartbeat(hb); err != nil { + log.Printf("[worker] erro ao salvar heartbeat: %v", err) + } + + if w.broadcaster != nil { + data, err := json.Marshal(hb) + if err == nil { + w.broadcaster.Publish(data) + } + } + + status := "UP" + if !hb.IsUp { + status = "DOWN" + } + log.Printf("[worker] service=%d status=%s code=%d time=%dms", + hb.ServiceID, status, hb.StatusCode, hb.ResponseTimeMs) + case <-w.quit: + return + } + } +} + +func (w *Worker) checkTransition(svc model.Service, hb model.Heartbeat) { + prev, err := w.store.GetLastHeartbeat(svc.ID) + if err != nil || prev == nil { + return + } + + if prev.IsUp == hb.IsUp { + return + } + + log.Printf("[worker] transicao de status service=%d: %v -> %v", svc.ID, prev.IsUp, hb.IsUp) + + w.fireAlerts(svc, hb, prev.IsUp) +} + +func (w *Worker) fireAlerts(svc model.Service, hb model.Heartbeat, wasUp bool) { + if svc.DiscordWebhookURL != "" { + go alerts.SendDiscord(svc.DiscordWebhookURL, svc, hb, wasUp) + } + if svc.AlertEmail != "" && w.smtpCfg != nil { + go alerts.SendEmail(svc, hb, wasUp, w.smtpCfg) + } +} + +func (w *Worker) runChecks(ctx context.Context) { + services, err := w.store.ListActiveServices() + if err != nil { + log.Printf("[worker] erro ao listar servicos ativos: %v", err) + return + } + log.Printf("[worker] verificando %d servico(s)", len(services)) + + for _, svc := range services { + go w.checkService(ctx, svc) + } +} + +func (w *Worker) checkService(ctx context.Context, svc model.Service) { + checkCtx, cancel := context.WithTimeout(ctx, w.timeout) + defer cancel() + + hb := CheckHTTP(checkCtx, svc.URL, svc.KeywordToFind) + hb.ServiceID = svc.ID + + w.results <- checkResult{Heartbeat: hb, Service: svc} +} + +func (w *Worker) Stop() { + close(w.quit) +} diff --git a/backend/internal/sse/broadcaster.go b/backend/internal/sse/broadcaster.go new file mode 100644 index 0000000..02dbc65 --- /dev/null +++ b/backend/internal/sse/broadcaster.go @@ -0,0 +1,42 @@ +package sse + +import ( + "sync" +) + +type Broadcaster struct { + mu sync.RWMutex + subscribers map[chan []byte]struct{} +} + +func NewBroadcaster() *Broadcaster { + return &Broadcaster{ + subscribers: make(map[chan []byte]struct{}), + } +} + +func (b *Broadcaster) Subscribe() chan []byte { + ch := make(chan []byte, 64) + b.mu.Lock() + b.subscribers[ch] = struct{}{} + b.mu.Unlock() + return ch +} + +func (b *Broadcaster) Unsubscribe(ch chan []byte) { + b.mu.Lock() + delete(b.subscribers, ch) + b.mu.Unlock() + close(ch) +} + +func (b *Broadcaster) Publish(data []byte) { + b.mu.RLock() + defer b.mu.RUnlock() + for ch := range b.subscribers { + select { + case ch <- data: + default: + } + } +} diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go new file mode 100644 index 0000000..a77e877 --- /dev/null +++ b/backend/internal/store/memory.go @@ -0,0 +1,277 @@ +package store + +import ( + "errors" + "math/rand" + "sync" + "time" + + "yaum/internal/model" +) + +type MemoryStore struct { + mu sync.RWMutex + services map[int]*model.Service + heartbeats map[int][]*model.Heartbeat + maintenances []model.Maintenance + nextSvcID int + nextHbID int + nextMtnID int +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + services: make(map[int]*model.Service), + heartbeats: make(map[int][]*model.Heartbeat), + maintenances: make([]model.Maintenance, 0), + nextSvcID: 1, + nextHbID: 1, + nextMtnID: 1, + } +} + +func (s *MemoryStore) SeedMockData() { + s.mu.Lock() + defer s.mu.Unlock() + + now := time.Now() + mockServices := []model.Service{ + {Name: "Google", URL: "https://www.google.com", GroupName: "Geral", IntervalSeconds: 60, IsActive: true, CreatedAt: now}, + {Name: "GitHub", URL: "https://www.github.com", GroupName: "Geral", IntervalSeconds: 60, IsActive: true, CreatedAt: now}, + {Name: "JSONPlaceholder", URL: "https://jsonplaceholder.typicode.com/todos/1", GroupName: "APIs", IntervalSeconds: 60, IsActive: true, CreatedAt: now}, + {Name: "API Local (falha esperada)", URL: "http://localhost:9999", GroupName: "APIs", IntervalSeconds: 60, IsActive: true, CreatedAt: now}, + } + + for _, svc := range mockServices { + svc.ID = s.nextSvcID + s.nextSvcID++ + s.services[svc.ID] = &svc + s.heartbeats[svc.ID] = make([]*model.Heartbeat, 0) + s.seedHistory(svc.ID) + } +} + +func (s *MemoryStore) seedHistory(serviceID int) { + now := time.Now() + for i := 29; i >= 0; i-- { + isUp := true + if rand.Intn(10) == 0 { + isUp = false + } + + statusCode := 200 + errMsg := "" + if !isUp { + statusCode = 0 + errMsg = "connection timeout" + } + + hb := &model.Heartbeat{ + ID: s.nextHbID, + ServiceID: serviceID, + StatusCode: statusCode, + ResponseTimeMs: int64(50 + rand.Intn(450)), + IsUp: isUp, + ErrorMessage: errMsg, + TestedAt: now.Add(-time.Duration(i) * time.Minute), + } + s.nextHbID++ + s.heartbeats[serviceID] = append(s.heartbeats[serviceID], hb) + } +} + +func (s *MemoryStore) ListServices() ([]model.ServiceWithHeartbeat, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]model.ServiceWithHeartbeat, 0, len(s.services)) + for _, svc := range s.services { + swh := model.ServiceWithHeartbeat{Service: *svc} + if hbs := s.heartbeats[svc.ID]; len(hbs) > 0 { + swh.LastHeartbeat = hbs[len(hbs)-1] + } + result = append(result, swh) + } + return result, nil +} + +func (s *MemoryStore) ListActiveServices() ([]model.Service, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + var result []model.Service + for _, svc := range s.services { + if svc.IsActive { + result = append(result, *svc) + } + } + return result, nil +} + +func (s *MemoryStore) AddService(svc model.Service) (*model.Service, error) { + s.mu.Lock() + defer s.mu.Unlock() + + svc.ID = s.nextSvcID + svc.IsActive = true + svc.CreatedAt = time.Now() + s.nextSvcID++ + s.services[svc.ID] = &svc + s.heartbeats[svc.ID] = make([]*model.Heartbeat, 0) + return &svc, nil +} + +func (s *MemoryStore) GetService(id int) (*model.Service, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + svc, ok := s.services[id] + if !ok { + return nil, nil + } + return svc, nil +} + +func (s *MemoryStore) ToggleService(id int) (*model.Service, error) { + s.mu.Lock() + defer s.mu.Unlock() + + svc, ok := s.services[id] + if !ok { + return nil, errors.New("servico nao encontrado") + } + svc.IsActive = !svc.IsActive + return svc, nil +} + +func (s *MemoryStore) UpdateService(id int, svc model.Service) (*model.Service, error) { + s.mu.Lock() + defer s.mu.Unlock() + + existing, ok := s.services[id] + if !ok { + return nil, errors.New("servico nao encontrado") + } + + svc.ID = id + svc.IsActive = existing.IsActive + svc.CreatedAt = existing.CreatedAt + s.services[id] = &svc + return &svc, nil +} + +func (s *MemoryStore) DeleteService(id int) error { + s.mu.Lock() + defer s.mu.Unlock() + + if _, ok := s.services[id]; !ok { + return errors.New("servico nao encontrado") + } + delete(s.services, id) + delete(s.heartbeats, id) + return nil +} + +func (s *MemoryStore) AddHeartbeat(hb model.Heartbeat) error { + s.mu.Lock() + defer s.mu.Unlock() + + hb.ID = s.nextHbID + s.nextHbID++ + s.heartbeats[hb.ServiceID] = append(s.heartbeats[hb.ServiceID], &hb) + + maxHistory := 500 + if len(s.heartbeats[hb.ServiceID]) > maxHistory { + s.heartbeats[hb.ServiceID] = s.heartbeats[hb.ServiceID][len(s.heartbeats[hb.ServiceID])-maxHistory:] + } + return nil +} + +func (s *MemoryStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + hbs := s.heartbeats[serviceID] + if len(hbs) > limit { + hbs = hbs[len(hbs)-limit:] + } + result := make([]model.Heartbeat, len(hbs)) + for i, hb := range hbs { + result[i] = *hb + } + return result, nil +} + +func (s *MemoryStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + hbs := s.heartbeats[serviceID] + if len(hbs) == 0 { + return nil, nil + } + return hbs[len(hbs)-1], nil +} + +func (s *MemoryStore) GetActiveMaintenance(now time.Time) (*model.Maintenance, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + for _, m := range s.maintenances { + if m.IsActive && now.After(m.StartTime) && now.Before(m.EndTime) { + return &m, nil + } + } + return nil, nil +} + +func (s *MemoryStore) GetServiceStats(serviceID int) (*model.ServiceStats, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + hbs := s.heartbeats[serviceID] + now := time.Now() + + calc := func(since time.Duration) (total, up int, avgMs float64) { + cutoff := now.Add(-since) + var sum int64 + for _, h := range hbs { + if h.TestedAt.Before(cutoff) { + continue + } + total++ + if h.IsUp { + up++ + } + sum += h.ResponseTimeMs + } + if total > 0 { + avgMs = float64(sum) / float64(total) + } + return + } + + t24, u24, a24 := calc(24 * time.Hour) + t7, u7, a7 := calc(7 * 24 * time.Hour) + t30, u30, a30 := calc(30 * 24 * time.Hour) + + pct := func(up, total int) float64 { + if total == 0 { + return 0 + } + return float64(up) / float64(total) * 100 + } + + return &model.ServiceStats{ + ServiceID: serviceID, + Uptime24h: pct(u24, t24), + Uptime7d: pct(u7, t7), + Uptime30d: pct(u30, t30), + AvgResponseMs24h: a24, + AvgResponseMs7d: a7, + AvgResponseMs30d: a30, + TotalChecks24h: t24, + TotalChecks7d: t7, + TotalChecks30d: t30, + }, nil +} diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go new file mode 100644 index 0000000..a8d072a --- /dev/null +++ b/backend/internal/store/postgres.go @@ -0,0 +1,324 @@ +package store + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "yaum/internal/model" +) + +type PostgresStore struct { + pool *pgxpool.Pool +} + +func NewPostgresStore(ctx context.Context, connString string) (*PostgresStore, error) { + pool, err := pgxpool.New(ctx, connString) + if err != nil { + return nil, fmt.Errorf("erro ao conectar no postgres: %w", err) + } + if err := pool.Ping(ctx); err != nil { + return nil, fmt.Errorf("erro ao pingar postgres: %w", err) + } + return &PostgresStore{pool: pool}, nil +} + +func (s *PostgresStore) Close() { + s.pool.Close() +} + +// --------------------------------------------------------------------------- +// Services +// --------------------------------------------------------------------------- + +func (s *PostgresStore) ListServices() ([]model.ServiceWithHeartbeat, error) { + query := ` + SELECT + s.id, s.name, s.url, s.group_name, s.interval_seconds, s.is_active, s.created_at, + h.id AS heartbeat_id, + h.status_code, + h.response_time_ms, + h.is_up, + h.error_message, + h.tested_at + FROM services s + LEFT JOIN LATERAL ( + SELECT id, status_code, response_time_ms, is_up, error_message, tested_at + FROM heartbeats + WHERE service_id = s.id + ORDER BY tested_at DESC + LIMIT 1 + ) h ON true + ORDER BY s.id + ` + + rows, err := s.pool.Query(context.Background(), query) + if err != nil { + return nil, fmt.Errorf("list services: %w", err) + } + defer rows.Close() + + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.ServiceWithHeartbeat, error) { + var ( + swh model.ServiceWithHeartbeat + heartbeatID *int + statusCode *int + responseTimeMs *int64 + isUp *bool + errorMessage *string + testedAt *time.Time + ) + err := row.Scan( + &swh.ID, &swh.Name, &swh.URL, &swh.GroupName, &swh.IntervalSeconds, &swh.IsActive, &swh.CreatedAt, + &heartbeatID, &statusCode, &responseTimeMs, &isUp, &errorMessage, &testedAt, + ) + if err != nil { + return swh, err + } + if heartbeatID != nil { + swh.LastHeartbeat = &model.Heartbeat{ + ID: *heartbeatID, + ServiceID: swh.ID, + StatusCode: *statusCode, + ResponseTimeMs: *responseTimeMs, + IsUp: *isUp, + ErrorMessage: *errorMessage, + TestedAt: *testedAt, + } + } + return swh, nil + }) +} + +func (s *PostgresStore) ListActiveServices() ([]model.Service, error) { + query := ` + SELECT id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at + FROM services + WHERE is_active = true + ORDER BY id + ` + + rows, err := s.pool.Query(context.Background(), query) + if err != nil { + return nil, fmt.Errorf("list active services: %w", err) + } + defer rows.Close() + + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Service, error) { + var svc model.Service + err := row.Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt) + return svc, err + }) +} + +func (s *PostgresStore) GetService(id int) (*model.Service, error) { + query := ` + SELECT id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at + FROM services + WHERE id = $1 + ` + var svc model.Service + err := s.pool.QueryRow(context.Background(), query, id). + Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get service: %w", err) + } + return &svc, nil +} + +func (s *PostgresStore) AddService(svc model.Service) (*model.Service, error) { + query := ` + INSERT INTO services (name, url, group_name, interval_seconds, discord_webhook_url, alert_email, keyword_to_find) + VALUES ($1, $2, $3, $4, $5, $6, $7) + RETURNING id, is_active, created_at + ` + err := s.pool.QueryRow(context.Background(), query, svc.Name, svc.URL, svc.GroupName, svc.IntervalSeconds, svc.DiscordWebhookURL, svc.AlertEmail, svc.KeywordToFind). + Scan(&svc.ID, &svc.IsActive, &svc.CreatedAt) + if err != nil { + return nil, fmt.Errorf("add service: %w", err) + } + return &svc, nil +} + +func (s *PostgresStore) ToggleService(id int) (*model.Service, error) { + query := ` + UPDATE services + SET is_active = NOT is_active + WHERE id = $1 + RETURNING id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at + ` + var svc model.Service + err := s.pool.QueryRow(context.Background(), query, id). + Scan(&svc.ID, &svc.Name, &svc.URL, &svc.GroupName, &svc.IntervalSeconds, &svc.IsActive, &svc.DiscordWebhookURL, &svc.AlertEmail, &svc.KeywordToFind, &svc.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("servico nao encontrado") + } + return nil, fmt.Errorf("toggle service: %w", err) + } + return &svc, nil +} + +func (s *PostgresStore) UpdateService(id int, svc model.Service) (*model.Service, error) { + query := ` + UPDATE services + SET name = $1, url = $2, group_name = $3, interval_seconds = $4, discord_webhook_url = $5, alert_email = $6, keyword_to_find = $7 + WHERE id = $8 + RETURNING id, name, url, group_name, interval_seconds, is_active, discord_webhook_url, alert_email, keyword_to_find, created_at + ` + var updated model.Service + err := s.pool.QueryRow(context.Background(), query, + svc.Name, svc.URL, svc.GroupName, svc.IntervalSeconds, svc.DiscordWebhookURL, svc.AlertEmail, svc.KeywordToFind, id, + ).Scan(&updated.ID, &updated.Name, &updated.URL, &updated.GroupName, &updated.IntervalSeconds, &updated.IsActive, &updated.DiscordWebhookURL, &updated.AlertEmail, &updated.KeywordToFind, &updated.CreatedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, fmt.Errorf("servico nao encontrado") + } + return nil, fmt.Errorf("update service: %w", err) + } + return &updated, nil +} + +func (s *PostgresStore) DeleteService(id int) error { + tag, err := s.pool.Exec(context.Background(), "DELETE FROM services WHERE id = $1", id) + if err != nil { + return fmt.Errorf("delete service: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("servico nao encontrado") + } + return nil +} + +// --------------------------------------------------------------------------- +// Heartbeats +// --------------------------------------------------------------------------- + +func (s *PostgresStore) AddHeartbeat(hb model.Heartbeat) error { + query := ` + INSERT INTO heartbeats (service_id, status_code, response_time_ms, is_up, error_message, tested_at) + VALUES ($1, $2, $3, $4, $5, $6) + ` + _, err := s.pool.Exec(context.Background(), query, + hb.ServiceID, hb.StatusCode, hb.ResponseTimeMs, hb.IsUp, hb.ErrorMessage, hb.TestedAt) + if err != nil { + return fmt.Errorf("add heartbeat: %w", err) + } + return nil +} + +func (s *PostgresStore) GetHistory(serviceID int, limit int) ([]model.Heartbeat, error) { + query := ` + SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at + FROM heartbeats + WHERE service_id = $1 + ORDER BY tested_at DESC + LIMIT $2 + ` + rows, err := s.pool.Query(context.Background(), query, serviceID, limit) + if err != nil { + return nil, fmt.Errorf("get history: %w", err) + } + defer rows.Close() + + return pgx.CollectRows(rows, func(row pgx.CollectableRow) (model.Heartbeat, error) { + var hb model.Heartbeat + err := row.Scan(&hb.ID, &hb.ServiceID, &hb.StatusCode, &hb.ResponseTimeMs, &hb.IsUp, &hb.ErrorMessage, &hb.TestedAt) + return hb, err + }) +} + +func (s *PostgresStore) GetLastHeartbeat(serviceID int) (*model.Heartbeat, error) { + query := ` + SELECT id, service_id, status_code, response_time_ms, is_up, error_message, tested_at + FROM heartbeats + WHERE service_id = $1 + ORDER BY tested_at DESC + LIMIT 1 + ` + hb := &model.Heartbeat{} + err := s.pool.QueryRow(context.Background(), query, serviceID). + Scan(&hb.ID, &hb.ServiceID, &hb.StatusCode, &hb.ResponseTimeMs, &hb.IsUp, &hb.ErrorMessage, &hb.TestedAt) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get last heartbeat: %w", err) + } + return hb, nil +} + +func (s *PostgresStore) GetServiceStats(serviceID int) (*model.ServiceStats, error) { + query := ` + SELECT + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '24 hours' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '24 hours' AND is_up THEN 1 ELSE 0 END), 0), + COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '24 hours'), 0), + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '7 days' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '7 days' AND is_up THEN 1 ELSE 0 END), 0), + COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '7 days'), 0), + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '30 days' THEN 1 ELSE 0 END), 0), + COALESCE(SUM(CASE WHEN tested_at >= NOW() - INTERVAL '30 days' AND is_up THEN 1 ELSE 0 END), 0), + COALESCE(AVG(response_time_ms) FILTER (WHERE tested_at >= NOW() - INTERVAL '30 days'), 0) + FROM heartbeats + WHERE service_id = $1 + ` + var ( + t24, u24 int32 + a24 float64 + t7, u7 int32 + a7 float64 + t30, u30 int32 + a30 float64 + ) + err := s.pool.QueryRow(context.Background(), query, serviceID). + Scan(&t24, &u24, &a24, &t7, &u7, &a7, &t30, &u30, &a30) + if err != nil { + return nil, fmt.Errorf("get service stats: %w", err) + } + + pct := func(up, total int32) float64 { + if total == 0 { + return 0 + } + return float64(up) / float64(total) * 100 + } + + return &model.ServiceStats{ + ServiceID: serviceID, + Uptime24h: pct(u24, t24), + Uptime7d: pct(u7, t7), + Uptime30d: pct(u30, t30), + AvgResponseMs24h: a24, + AvgResponseMs7d: a7, + AvgResponseMs30d: a30, + TotalChecks24h: int(t24), + TotalChecks7d: int(t7), + TotalChecks30d: int(t30), + }, nil +} + +func (s *PostgresStore) GetActiveMaintenance(now time.Time) (*model.Maintenance, error) { + query := ` + SELECT id, title, start_time, end_time, is_active + FROM maintenances + WHERE is_active = true AND start_time <= $1 AND end_time > $1 + LIMIT 1 + ` + var m model.Maintenance + err := s.pool.QueryRow(context.Background(), query, now). + Scan(&m.ID, &m.Title, &m.StartTime, &m.EndTime, &m.IsActive) + if err != nil { + if err == pgx.ErrNoRows { + return nil, nil + } + return nil, fmt.Errorf("get active maintenance: %w", err) + } + return &m, nil +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go new file mode 100644 index 0000000..e5e05f6 --- /dev/null +++ b/backend/internal/store/store.go @@ -0,0 +1,23 @@ +package store + +import ( + "time" + + "yaum/internal/model" +) + +type Store interface { + ListServices() ([]model.ServiceWithHeartbeat, error) + ListActiveServices() ([]model.Service, error) + GetService(int) (*model.Service, error) + AddService(model.Service) (*model.Service, error) + UpdateService(int, model.Service) (*model.Service, error) + ToggleService(int) (*model.Service, error) + DeleteService(int) error + AddHeartbeat(model.Heartbeat) error + GetHistory(int, int) ([]model.Heartbeat, error) + GetLastHeartbeat(int) (*model.Heartbeat, error) + GetServiceStats(int) (*model.ServiceStats, error) + + GetActiveMaintenance(now time.Time) (*model.Maintenance, error) +} diff --git a/backend/migrations/schema.sql b/backend/migrations/schema.sql new file mode 100644 index 0000000..eba89db --- /dev/null +++ b/backend/migrations/schema.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS services ( + id SERIAL PRIMARY KEY, + name VARCHAR(255) NOT NULL, + url TEXT NOT NULL, + group_name VARCHAR(255) NOT NULL DEFAULT 'Geral', + interval_seconds INTEGER NOT NULL DEFAULT 60, + is_active BOOLEAN NOT NULL DEFAULT true, + discord_webhook_url TEXT NOT NULL DEFAULT '', + alert_email TEXT NOT NULL DEFAULT '', + keyword_to_find TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS heartbeats ( + id SERIAL PRIMARY KEY, + service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE, + status_code INTEGER NOT NULL DEFAULT 0, + response_time_ms BIGINT NOT NULL DEFAULT 0, + is_up BOOLEAN NOT NULL DEFAULT false, + error_message TEXT NOT NULL DEFAULT '', + tested_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_heartbeats_service_id ON heartbeats(service_id); +CREATE INDEX IF NOT EXISTS idx_heartbeats_tested_at ON heartbeats(tested_at DESC); +CREATE INDEX IF NOT EXISTS idx_services_is_active ON services(is_active); + +CREATE TABLE IF NOT EXISTS maintenances ( + id SERIAL PRIMARY KEY, + title TEXT NOT NULL, + start_time TIMESTAMPTZ NOT NULL, + end_time TIMESTAMPTZ NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true +); diff --git a/backend/yaum-server.exe b/backend/yaum-server.exe new file mode 100644 index 0000000..c578706 Binary files /dev/null and b/backend/yaum-server.exe differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..09bad19 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2560 @@ +{ + "name": "yaum-frontend", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "yaum-frontend", + "version": "0.1.0", + "hasInstallScript": true, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.3.1", + "@sveltejs/kit": "^2.61.1", + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "svelte": "^5.55.10", + "tailwindcss": "^3.4.19", + "vite": "^5.4.21" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@polka/url": { + "version": "1.0.0-next.29", + "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", + "integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", + "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", + "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", + "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", + "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", + "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", + "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", + "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", + "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", + "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", + "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", + "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", + "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", + "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", + "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", + "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", + "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", + "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", + "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", + "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", + "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", + "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", + "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", + "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", + "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", + "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.10.tgz", + "integrity": "sha512-4WfKk68eTih+MiJD4fSbxN7E8kVBmTMPWHUPYjvl2N0rMs53YLTT8/YjKU5Dtnz5LqDjl7LEw4U7lXR2W3J5WA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@sveltejs/adapter-auto": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-3.3.1.tgz", + "integrity": "sha512-5Sc7WAxYdL6q9j/+D0jJKjGREGlfIevDyHSQ2eNETHcB1TKlQWHcAo8AS8H1QdjNvSXpvOwNjykDUHPEAyGgdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-meta-resolve": "^4.1.0" + }, + "peerDependencies": { + "@sveltejs/kit": "^2.0.0" + } + }, + "node_modules/@sveltejs/kit": { + "version": "2.61.1", + "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.61.1.tgz", + "integrity": "sha512-Ny8s1SR1TyQS2hD2Rvw0XKzU2Nw1eUF52dTb6T2bdcgz7wSC+Nyb5IwjWYlR4b2dvbbR5NJDiQwHg3rnNseghg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@sveltejs/acorn-typescript": "^1.0.9", + "@types/cookie": "^0.6.0", + "acorn": "^8.16.0", + "cookie": "^0.6.0", + "devalue": "^5.8.1", + "esm-env": "^1.2.2", + "kleur": "^4.1.5", + "magic-string": "^0.30.5", + "mrmime": "^2.0.0", + "set-cookie-parser": "^3.0.0", + "sirv": "^3.0.0" + }, + "bin": { + "svelte-kit": "svelte-kit.js" + }, + "engines": { + "node": ">=18.13" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.0.0", + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0", + "svelte": "^4.0.0 || ^5.0.0-next.0", + "typescript": "^5.3.3 || ^6.0.0", + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@sveltejs/vite-plugin-svelte": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-4.0.4.tgz", + "integrity": "sha512-0ba1RQ/PHen5FGpdSrW7Y3fAMQjrXantECALeOiOdBdzR5+5vPP6HVZRLmZaQL+W8m++o+haIAKq5qT+MiZ7VA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sveltejs/vite-plugin-svelte-inspector": "^3.0.0-next.0||^3.0.0", + "debug": "^4.3.7", + "deepmerge": "^4.3.1", + "kleur": "^4.1.5", + "magic-string": "^0.30.12", + "vitefu": "^1.0.3" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-3.0.1.tgz", + "integrity": "sha512-2CKypmj1sM4GE7HjllT7UKmo4Q6L5xFRd7VMGEWhYnZ+wc6AUVU01IBd7yUi6WnFndEwWoMNOd6e8UjoN0nbvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.3.7" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22" + }, + "peerDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0-next.0||^4.0.0", + "svelte": "^5.0.0-next.96 || ^5.0.0", + "vite": "^5.0.0" + } + }, + "node_modules/@types/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.32", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz", + "integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001793", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", + "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/devalue": { + "version": "5.8.1", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.8.1.tgz", + "integrity": "sha512-4CXDYRBGqN+57wVJkuXBYmpAVUSg3L6JAQa/DFqm238G73E1wuyc/JhGQJzN7vUf/CMphYau2zXbfWzDR5aTEw==", + "dev": true, + "license": "MIT" + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.364", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", + "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.2.9", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.9.tgz", + "integrity": "sha512-4KijP+NxCWthMCUC3qHbE6n4vCjqgJS1uAYKhuT/GWfFTf1Qyive2TgOjep+gzbSzRfnNyaN/UU9YmdOt8Eg0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/import-meta-resolve": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/import-meta-resolve/-/import-meta-resolve-4.2.0.tgz", + "integrity": "sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/kleur": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", + "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mrmime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", + "integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.46", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", + "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.4", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", + "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.4", + "@rollup/rollup-android-arm64": "4.60.4", + "@rollup/rollup-darwin-arm64": "4.60.4", + "@rollup/rollup-darwin-x64": "4.60.4", + "@rollup/rollup-freebsd-arm64": "4.60.4", + "@rollup/rollup-freebsd-x64": "4.60.4", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", + "@rollup/rollup-linux-arm-musleabihf": "4.60.4", + "@rollup/rollup-linux-arm64-gnu": "4.60.4", + "@rollup/rollup-linux-arm64-musl": "4.60.4", + "@rollup/rollup-linux-loong64-gnu": "4.60.4", + "@rollup/rollup-linux-loong64-musl": "4.60.4", + "@rollup/rollup-linux-ppc64-gnu": "4.60.4", + "@rollup/rollup-linux-ppc64-musl": "4.60.4", + "@rollup/rollup-linux-riscv64-gnu": "4.60.4", + "@rollup/rollup-linux-riscv64-musl": "4.60.4", + "@rollup/rollup-linux-s390x-gnu": "4.60.4", + "@rollup/rollup-linux-x64-gnu": "4.60.4", + "@rollup/rollup-linux-x64-musl": "4.60.4", + "@rollup/rollup-openbsd-x64": "4.60.4", + "@rollup/rollup-openharmony-arm64": "4.60.4", + "@rollup/rollup-win32-arm64-msvc": "4.60.4", + "@rollup/rollup-win32-ia32-msvc": "4.60.4", + "@rollup/rollup-win32-x64-gnu": "4.60.4", + "@rollup/rollup-win32-x64-msvc": "4.60.4", + "fsevents": "~2.3.2" + } + }, + "node_modules/rollup/node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/set-cookie-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz", + "integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/sirv": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", + "integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@polka/url": "^1.0.0-next.24", + "mrmime": "^2.0.0", + "totalist": "^3.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svelte": { + "version": "5.55.10", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.10.tgz", + "integrity": "sha512-v9mFVBY1USosyIWdXE7Cg4AN0ywyKCMcAhONvli8doMowEhFhMdNLKD1j7O/UnsrdVTHaUOk/jv8hD/HClVy+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@sveltejs/acorn-typescript": "^1.0.10", + "@types/estree": "^1.0.5", + "@types/trusted-types": "^2.0.7", + "acorn": "^8.12.1", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.8.1", + "esm-env": "^1.2.1", + "esrap": "^2.2.9", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/totalist": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz", + "integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vitefu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz", + "integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==", + "dev": true, + "license": "MIT", + "workspaces": [ + "tests/deps/*", + "tests/projects/*", + "tests/projects/workspace/packages/*" + ], + "peerDependencies": { + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/zimmerframe": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", + "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..557023a --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,22 @@ +{ + "name": "yaum-frontend", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "preview": "vite preview", + "postinstall": "svelte-kit sync" + }, + "devDependencies": { + "@sveltejs/adapter-auto": "^3.3.1", + "@sveltejs/kit": "^2.61.1", + "@sveltejs/vite-plugin-svelte": "^4.0.4", + "autoprefixer": "^10.5.0", + "postcss": "^8.5.15", + "svelte": "^5.55.10", + "tailwindcss": "^3.4.19", + "vite": "^5.4.21" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..0f77216 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {} + } +}; diff --git a/frontend/src/app.css b/frontend/src/app.css new file mode 100644 index 0000000..c1d9e47 --- /dev/null +++ b/frontend/src/app.css @@ -0,0 +1,50 @@ +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap'); + +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --bg-primary: #0f1117; + --bg-secondary: #1a1d27; + --bg-card: #1e2130; + --border-color: #2a2e3d; + --text-primary: #e8eaed; + --text-secondary: #9aa0b0; + --text-muted: #5c6278; + --green: #22f06a; + --red: #ff4060; + --amber: #ffb020; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + background-color: var(--bg-primary); + color: var(--text-primary); + font-family: 'Inter', system-ui, -apple-system, sans-serif; + min-height: 100vh; +} + +body { + background: + radial-gradient(ellipse 80% 60% at 50% -20%, rgba(34, 240, 106, 0.04) 0%, transparent 60%), + radial-gradient(ellipse 60% 50% at 80% 20%, rgba(255, 64, 96, 0.03) 0%, transparent 50%), + var(--bg-primary); +} + +::-webkit-scrollbar { + width: 6px; +} +::-webkit-scrollbar-track { + background: transparent; +} +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 3px; +} diff --git a/frontend/src/app.d.ts b/frontend/src/app.d.ts new file mode 100644 index 0000000..779a3d4 --- /dev/null +++ b/frontend/src/app.d.ts @@ -0,0 +1,3 @@ +/// + +declare namespace App {} diff --git a/frontend/src/app.html b/frontend/src/app.html new file mode 100644 index 0000000..6293dcd --- /dev/null +++ b/frontend/src/app.html @@ -0,0 +1,13 @@ + + + + + + + YAUM — Yet Another Uptime Monitor + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/frontend/src/hooks.server.js b/frontend/src/hooks.server.js new file mode 100644 index 0000000..39c89e9 --- /dev/null +++ b/frontend/src/hooks.server.js @@ -0,0 +1,31 @@ +import { redirect } from '@sveltejs/kit'; + +const API = 'http://localhost:8080/api'; + +const PUBLIC_PREFIXES = ['/_app', '/api']; + +export async function handle({ event, resolve }) { + const isStatic = PUBLIC_PREFIXES.some((p) => event.url.pathname.startsWith(p)); + const isLogin = event.url.pathname === '/login'; + const isPublic = event.url.pathname === '/' || event.url.pathname.startsWith('/status/'); + + if (isStatic || isLogin || isPublic) { + return resolve(event); + } + + if (event.url.pathname.startsWith('/admin')) { + const token = event.cookies.get('auth_token'); + if (!token) { + throw redirect(302, '/login'); + } + const res = await fetch(`${API}/auth/verify`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (!res.ok) { + throw redirect(302, '/login'); + } + event.locals.token = token; + } + + return resolve(event); +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..0be9148 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,39 @@ +import type { Service, Heartbeat, ServiceStats } from './types'; + +const BASE = '/api'; + +export async function listServices(): Promise { + const res = await fetch(`${BASE}/services`); + if (!res.ok) throw new Error('falha ao listar serviços'); + return res.json(); +} + +export async function createService(data: { name: string; url: string }): Promise { + const res = await fetch(`${BASE}/services`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err.error || 'falha ao criar serviço'); + } + return res.json(); +} + +export async function deleteService(id: number): Promise { + const res = await fetch(`${BASE}/services/${id}`, { method: 'DELETE' }); + if (!res.ok) throw new Error('falha ao remover serviço'); +} + +export async function getHistory(id: number): Promise { + const res = await fetch(`${BASE}/services/${id}/history`); + if (!res.ok) throw new Error('falha ao obter histórico'); + return res.json(); +} + +export async function getServiceStats(id: number): Promise { + const res = await fetch(`${BASE}/services/${id}/stats`); + if (!res.ok) throw new Error('falha ao obter estatísticas'); + return res.json(); +} diff --git a/frontend/src/lib/components/ActivityLog.svelte b/frontend/src/lib/components/ActivityLog.svelte new file mode 100644 index 0000000..4d7ff79 --- /dev/null +++ b/frontend/src/lib/components/ActivityLog.svelte @@ -0,0 +1,122 @@ + + +
+
+

+ Atividade Recente +

+ {entries.length} eventos +
+
+ {#if entries.length === 0} +

Nenhuma atividade ainda

+ {:else} + {#each entries as entry, i (i)} +
+
+ {#if entry.kind === 'created'} + + + + + + {:else if entry.kind === 'deleted'} + + + + + + {:else if entry.kind === 'transition'} + + {entry.hb?.is_up ? '↑' : '↓'} + + {:else} + + {entry.hb?.is_up ? '✓' : '✗'} + + {/if} +
+
+
+ {entry.serviceName} + {timeAgo(entry.timestamp)} +
+

+ {#if entry.kind === 'created'} + Serviço adicionado + {:else if entry.kind === 'deleted'} + Serviço removido + {:else if entry.kind === 'transition'} + {entry.hb?.is_up ? 'Recuperado' : 'Falha'} — {entry.hb?.response_time_ms ?? '?'}ms + {:else} + {entry.hb?.is_up ? 'Online' : 'Offline'} — {entry.hb?.status_code} / {entry.hb?.response_time_ms}ms + {/if} +

+
+
+ {/each} + {/if} +
+
+ + diff --git a/frontend/src/lib/components/AddServiceModal.svelte b/frontend/src/lib/components/AddServiceModal.svelte new file mode 100644 index 0000000..de1ba13 --- /dev/null +++ b/frontend/src/lib/components/AddServiceModal.svelte @@ -0,0 +1,224 @@ + + + + +{#if show} + +
+
+
+

+ Novo Serviço +

+ +
+ +
+
+ + +
+ +
+ + +
+ +
+

+ Alertas (opcional) +

+
+ + +
+
+ + +
+
+ + +

+ Se informado, o monitor vai ler o HTML e marcar como DOWN se não encontrar essa palavra. +

+
+
+ + {#if error} +

{error}

+ {/if} + +
+ + +
+
+
+
+{/if} + + diff --git a/frontend/src/lib/components/ServiceCard.svelte b/frontend/src/lib/components/ServiceCard.svelte new file mode 100644 index 0000000..29305f2 --- /dev/null +++ b/frontend/src/lib/components/ServiceCard.svelte @@ -0,0 +1,90 @@ + + +{#if service} +
+
+ +
+
+
+

{service.name}

+

{service.url}

+
+ +
+ +
+ + + {isUp ? 'UP' : 'DOWN'} + + + {#if lastMs !== null} + + {lastMs}ms + + {/if} + + {#if uptime30d !== null} + + {uptime30d.toFixed(2)}% + + {/if} +
+ + + + + Detalhes → + +
+
+{/if} diff --git a/frontend/src/lib/components/UptimeTimeline.svelte b/frontend/src/lib/components/UptimeTimeline.svelte new file mode 100644 index 0000000..6cb47df --- /dev/null +++ b/frontend/src/lib/components/UptimeTimeline.svelte @@ -0,0 +1,41 @@ + + +
+
+ {#each blocks as block, i} +
+ {/each} +
+
+ {heartbeats.length > 0 ? '-30 min' : ''} + {heartbeats.length} checks + agora +
+
diff --git a/frontend/src/lib/realtime.ts b/frontend/src/lib/realtime.ts new file mode 100644 index 0000000..fb9c75c --- /dev/null +++ b/frontend/src/lib/realtime.ts @@ -0,0 +1,42 @@ +import type { Heartbeat } from '$lib/types'; + +type Listener = (hb: Heartbeat) => void; + +const listeners = new Set(); +let es: EventSource | null = null; + +function connect() { + if (es) return; + + es = new EventSource('/api/stream'); + + es.addEventListener('connected', () => { + console.log('[sse] conectado'); + }); + + es.addEventListener('heartbeat', (event: MessageEvent) => { + try { + const hb: Heartbeat = JSON.parse(event.data); + listeners.forEach((fn) => fn(hb)); + } catch { + // ignore parse errors + } + }); + + es.onerror = () => { + console.warn('[sse] erro — reconectando...'); + }; +} + +export function subscribeHeartbeats(fn: Listener) { + listeners.add(fn); + connect(); + + return () => { + listeners.delete(fn); + if (listeners.size === 0 && es) { + es.close(); + es = null; + } + }; +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..f80c933 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,44 @@ +export interface Service { + id: number; + name: string; + url: string; + group_name: string; + interval_seconds: number; + is_active: boolean; + discord_webhook_url?: string; + alert_email?: string; + keyword_to_find?: string; + created_at: string; + last_heartbeat?: Heartbeat | null; +} + +export interface Maintenance { + id: number; + title: string; + start_time: string; + end_time: string; + is_active: boolean; +} + +export interface Heartbeat { + id: number; + service_id: number; + status_code: number; + response_time_ms: number; + is_up: boolean; + error_message?: string; + tested_at: string; +} + +export interface ServiceStats { + service_id: number; + uptime_24h: number; + uptime_7d: number; + uptime_30d: number; + avg_response_ms_24h: number; + avg_response_ms_7d: number; + avg_response_ms_30d: number; + total_checks_24h: number; + total_checks_7d: number; + total_checks_30d: number; +} diff --git a/frontend/src/routes/(admin)/admin/+page.server.js b/frontend/src/routes/(admin)/admin/+page.server.js new file mode 100644 index 0000000..bdf498b --- /dev/null +++ b/frontend/src/routes/(admin)/admin/+page.server.js @@ -0,0 +1,27 @@ +const API = 'http://localhost:8080/api'; + +export async function load({ locals }) { + const token = locals.token; + + const headers = token ? { Authorization: `Bearer ${token}` } : {}; + + const res = await fetch(`${API}/services`, { headers }); + if (!res.ok) { + return { services: [], error: 'Falha ao carregar serviços', token: token ?? '' }; + } + const services = await res.json(); + + const servicesWithStats = await Promise.all( + services.map(async (svc) => { + try { + const statsRes = await fetch(`${API}/services/${svc.id}/stats`, { headers }); + if (statsRes.ok) { + svc.stats = await statsRes.json(); + } + } catch { /* ignore */ } + return svc; + }) + ); + + return { services: servicesWithStats, error: null, token: token ?? '' }; +} diff --git a/frontend/src/routes/(admin)/admin/+page.svelte b/frontend/src/routes/(admin)/admin/+page.svelte new file mode 100644 index 0000000..24b56db --- /dev/null +++ b/frontend/src/routes/(admin)/admin/+page.svelte @@ -0,0 +1,432 @@ + + + + Admin — YAUM + + +
+
+

Painel de Controle

+

Gerenciamento de serviços monitorados

+
+ + ← Dashboard + +
+ +
+ +
+ +{#if data.error} +
+

{data.error}

+
+{:else if services.length === 0} +
+

Nenhum serviço cadastrado

+
+{:else} + {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group} +
+

{group}

+
+ + + + + + + + + + + + + {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)} + + + + + + + + + {/each} + +
IDNomeStatusSLA 30dAções
{svc.id}{svc.name} + + {svc.last_heartbeat?.is_up ?? true ? 'UP' : 'DOWN'} + + + {svc.stats ? svc.stats.uptime_30d.toFixed(2) + '%' : '—'} + +
+ + + + +
+
+
+
+ {/each} +{/if} + + +{#if showForm} + +
+
e.stopPropagation()} + > +
+

+ {editingSvc ? 'Editar Serviço' : 'Novo Serviço'} +

+ +
+ +
+ {#if editingSvc} + + {/if} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+

Opcionais

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ + {#if formError} +

{formError}

+ {/if} + +
+ + +
+
+
+
+{/if} + + diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte new file mode 100644 index 0000000..9ed74ce --- /dev/null +++ b/frontend/src/routes/+layout.svelte @@ -0,0 +1,50 @@ + + +
+ {#if maintenance} +
+
+ + + +

{maintenance.title}

+
+
+ {/if} + +
+
+
+ + YAUM + + +
+ +
+
+ +
+ {@render children()} +
+
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte new file mode 100644 index 0000000..511460a --- /dev/null +++ b/frontend/src/routes/+page.svelte @@ -0,0 +1,164 @@ + + + + YAUM — Yet Another Uptime Monitor + + +
+

Dashboard

+

Status dos serviços monitorados

+
+ +{#if error} +
+ + + +

{error}

+ +
+{:else if services.length === 0} +
+
+ + + +
+

Nenhum serviço sendo monitorado

+

Adicione o primeiro serviço para começar

+ +
+{:else} + {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group} +
+

{group}

+
+ {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)} + + {/each} +
+
+ {/each} + +
+ +
+{/if} + + (showModal = false)} + oncreated={handleCreated} +/> diff --git a/frontend/src/routes/+page.ts b/frontend/src/routes/+page.ts new file mode 100644 index 0000000..ba70169 --- /dev/null +++ b/frontend/src/routes/+page.ts @@ -0,0 +1,33 @@ +import type { Service, Heartbeat } from '$lib/types'; + +export interface EnhancedService extends Service { + heartbeats: Heartbeat[]; +} + +export async function load({ fetch }) { + let services: EnhancedService[] = []; + let error = ''; + + try { + const res = await fetch('/api/services'); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const list: Service[] = await res.json(); + + services = await Promise.all( + list.map(async (svc) => { + try { + const hres = await fetch(`/api/services/${svc.id}/history`); + if (!hres.ok) throw new Error(`HTTP ${hres.status}`); + const heartbeats: Heartbeat[] = await hres.json(); + return { ...svc, heartbeats }; + } catch { + return { ...svc, heartbeats: [] }; + } + }) + ); + } catch (e) { + error = 'Não foi possível conectar ao servidor'; + } + + return { services, error }; +} diff --git a/frontend/src/routes/login/+page.server.js b/frontend/src/routes/login/+page.server.js new file mode 100644 index 0000000..4272ef7 --- /dev/null +++ b/frontend/src/routes/login/+page.server.js @@ -0,0 +1,51 @@ +import { redirect } from '@sveltejs/kit'; + +const API = 'http://localhost:8080/api'; + +export async function load({ cookies }) { + const token = cookies.get('auth_token'); + if (token) { + const res = await fetch(`${API}/auth/verify`, { + headers: { Authorization: `Bearer ${token}` } + }); + if (res.ok) { + throw redirect(302, '/admin'); + } + } + return {}; +} + +export const actions = { + default: async ({ request, cookies }) => { + const data = await request.formData(); + const username = data.get('username'); + const password = data.get('password'); + + if (!username || !password) { + return { error: 'Preencha todos os campos' }; + } + + const res = await fetch(`${API}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ username, password }) + }); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + return { error: err.error || 'Credenciais inválidas' }; + } + + const { token } = await res.json(); + + cookies.set('auth_token', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: 7 * 24 * 60 * 60 + }); + + throw redirect(302, '/admin'); + } +}; diff --git a/frontend/src/routes/login/+page.svelte b/frontend/src/routes/login/+page.svelte new file mode 100644 index 0000000..a472c89 --- /dev/null +++ b/frontend/src/routes/login/+page.svelte @@ -0,0 +1,61 @@ + + + + Login — YAUM + + +
+
+
+

YAUM

+

Yet Another Uptime Monitor

+
+ +
+
+
+ + +
+ +
+ + +
+ + {#if form?.error} +

{form.error}

+ {/if} + + +
+
+ +

+ ← Voltar ao Dashboard +

+
+
diff --git a/frontend/src/routes/services/[id]/+page.svelte b/frontend/src/routes/services/[id]/+page.svelte new file mode 100644 index 0000000..36ea105 --- /dev/null +++ b/frontend/src/routes/services/[id]/+page.svelte @@ -0,0 +1,261 @@ + + + + {svc?.name ?? 'Detalhes'} — YAUM + + +{#if loading} +
+
+
+{:else if !svc} +
+

Serviço não encontrado

+
+{:else} + + +
+

{svc.name}

+

{svc.url}

+
+ +
+ + {#if stats} +
+

+ Uptime +

+
+ {#each [ + { label: '24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h }, + { label: '7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d }, + { label: '30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d } + ] as item} +
+
+ + {item.label} + + + {item.checks} checks + +
+
+ + {item.uptime.toFixed(2)}% + + + {item.avg.toFixed(0)}ms méd. + +
+
+ {/each} +
+
+ {/if} + + +
+

+ Tempo de Resposta (ms) +

+
+ {#if history.length === 0} +
+ Sem dados ainda +
+ {:else if history.length === 1} +
+ + {history[0].response_time_ms} + ms + + + {history[0].is_up ? 'Online' : 'Offline'} — + {history[0].status_code || 'timeout'} + +
+ {:else} + + {#each yTicks as tick} + + {tick.val} + {/each} + + `${d.x},${d.y}`).join(' ')} + fill="none" + stroke="var(--green)" + stroke-width="2" + stroke-linecap="round" + stroke-linejoin="round" + /> + + {#each dots as d} + + {/each} + + {/if} +
+
+ {history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''} + {history[history.length - 1]?.tested_at + ? new Date(history[history.length - 1].tested_at).toLocaleTimeString() + : ''} +
+
+ + +
+

+ Últimas Verificações +

+
+ {#each history.slice().reverse() as h} +
+
+
+ + + {#if h.status_code > 0} + {h.status_code} + {:else} + TIMEOUT + {/if} + +
+
+ {h.response_time_ms}ms + {#if h.error_message} + + {/if} +
+
+ {#if expandedError === h.id && h.error_message} +
+
{h.error_message}
+
+ {/if} +
+ {/each} +
+
+
+{/if} diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte new file mode 100644 index 0000000..afc59a2 --- /dev/null +++ b/frontend/src/routes/status/[id]/+page.svelte @@ -0,0 +1,278 @@ + + + + {svc?.name ?? 'Status'} — YAUM + + +
+ {#if !svc} +
+
+ + + +
+

Serviço não encontrado

+

+ ← Voltar ao início +

+
+ {:else} + + + + +
+ {#if isUp === null} +
+

Aguardando verificação

+ {:else} +
+ {#if isUp} + + + + {:else} + + + + {/if} +
+

+ {isUp ? 'Operando Normalmente' : 'Fora do Ar'} +

+ {/if} + +
+

{svc.name}

+

{svc.url}

+
+ + {#if stats} +
+
+

SLA 30 dias

+

+ {stats.uptime_30d.toFixed(2)}% +

+
+
+
+

Latência Média

+

+ {stats.avg_response_ms_30d.toFixed(0)}ms +

+
+
+
+

Total de Checks

+

{stats.total_checks_30d}

+
+
+ {/if} +
+ +
+ + {#if stats} +
+

+ Uptime +

+
+ {#each [ + { label: 'Últimas 24h', uptime: stats.uptime_24h, checks: stats.total_checks_24h, avg: stats.avg_response_ms_24h }, + { label: 'Últimos 7 dias', uptime: stats.uptime_7d, checks: stats.total_checks_7d, avg: stats.avg_response_ms_7d }, + { label: 'Últimos 30 dias', uptime: stats.uptime_30d, checks: stats.total_checks_30d, avg: stats.avg_response_ms_30d } + ] as item} +
+
+ {item.label} + {item.checks} checks +
+
+ + {item.uptime.toFixed(2)}% + + {item.avg.toFixed(0)}ms méd. +
+
+ {/each} +
+
+ {/if} + + +
+

+ Tempo de Resposta (ms) +

+
+ {#if history.length === 0} +
+ Sem dados ainda +
+ {:else if history.length === 1} +
+ + {history[0].response_time_ms} + ms + + + {history[0].is_up ? 'Online' : 'Offline'} — {history[0].status_code || 'timeout'} + +
+ {:else} + + {#each yTicks as tick} + + {tick.val} + {/each} + `${d.x},${d.y}`).join(' ')} + fill="none" stroke="var(--green)" stroke-width="2" + stroke-linecap="round" stroke-linejoin="round" + /> + {#each dots as d} + + {/each} + + {/if} +
+
+ {history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''} + {history[history.length - 1]?.tested_at ? new Date(history[history.length - 1].tested_at).toLocaleTimeString() : ''} +
+
+
+ + +
+

+ Histórico de Verificações +

+ {#if history.length === 0} +

Nenhuma verificação registrada

+ {:else} +
+ + + + + + + + + + + + {#each [...history].reverse() as h} + + + + + + + + {/each} + +
HorárioStatusCódigoLatênciaErro
+ {new Date(h.tested_at).toLocaleString('pt-BR')} + + + {h.is_up ? 'UP' : 'DOWN'} + + + {h.status_code > 0 ? h.status_code : '—'} + + {h.response_time_ms}ms + + {#if h.error_message} + + {#if expandedError === h.id} +
{(h as any).error_message}
+ {/if} + {:else} + — + {/if} +
+
+ {/if} +
+ {/if} +
diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts new file mode 100644 index 0000000..4351aae --- /dev/null +++ b/frontend/src/routes/status/[id]/+page.ts @@ -0,0 +1,22 @@ +import type { PageLoad } from './$types'; + +export const load: PageLoad = async ({ params, fetch }) => { + const id = params.id; + + const [servicesRes, historyRes, statsRes] = await Promise.all([ + fetch(`http://localhost:8080/api/services`).catch(() => null), + fetch(`http://localhost:8080/api/services/${id}/history`).catch(() => null), + fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null) + ]); + + const services = servicesRes?.ok ? await servicesRes.json() : []; + const svc = services.find((s: any) => s.id === Number(id)); + const history = historyRes?.ok ? await historyRes.json() : []; + const stats = statsRes?.ok ? await statsRes.json() : null; + + return { + service: svc ?? null, + history, + stats + }; +}; diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg new file mode 100644 index 0000000..ddd3ce8 --- /dev/null +++ b/frontend/static/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js new file mode 100644 index 0000000..fcf06bd --- /dev/null +++ b/frontend/svelte.config.js @@ -0,0 +1,12 @@ +import adapter from '@sveltejs/adapter-auto'; +import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; + +/** @type {import('@sveltejs/kit').Config} */ +const config = { + preprocess: vitePreprocess(), + kit: { + adapter: adapter() + } +}; + +export default config; diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js new file mode 100644 index 0000000..d043770 --- /dev/null +++ b/frontend/tailwind.config.js @@ -0,0 +1,30 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ['./src/**/*.{html,js,svelte,ts}'], + theme: { + extend: { + fontFamily: { + sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'], + mono: ['JetBrains Mono', 'Fira Code', 'monospace'] + }, + colors: { + surface: { + 50: '#f8fafc', + 100: '#0f1117', + 200: '#1a1d27', + 300: '#242837', + 400: '#2e3347', + }, + neon: { + green: '#22f06a', + red: '#ff4060', + amber: '#ffb020', + } + }, + animation: { + 'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite', + } + } + }, + plugins: [] +}; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..a8f10c8 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./.svelte-kit/tsconfig.json", + "compilerOptions": { + "allowJs": true, + "checkJs": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "moduleResolution": "bundler" + } +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..86e2da4 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,11 @@ +import { sveltekit } from '@sveltejs/kit/vite'; +import { defineConfig } from 'vite'; + +export default defineConfig({ + plugins: [sveltekit()], + server: { + proxy: { + '/api': 'http://localhost:8080' + } + } +}); -- cgit v1.2.3