aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 18:57:37 +0000
commit6cc0bfd1d79074df790272b8091b1f0226d14283 (patch)
treecab18b0f708cf3b0eaeeeb98e2cf81459365b33e
downloadyaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz
yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
-rw-r--r--.gitignore19
-rw-r--r--backend/.env7
-rw-r--r--backend/.env.example7
-rw-r--r--backend/cmd/server/main.go109
-rw-r--r--backend/config.json24
-rw-r--r--backend/go.mod16
-rw-r--r--backend/go.sum36
-rw-r--r--backend/internal/alerts/alerts.go113
-rw-r--r--backend/internal/alerts/smtp.go85
-rw-r--r--backend/internal/api/auth.go144
-rw-r--r--backend/internal/api/handler.go243
-rw-r--r--backend/internal/api/router.go71
-rw-r--r--backend/internal/api/sse.go57
-rw-r--r--backend/internal/config/config.go125
-rw-r--r--backend/internal/model/types.go52
-rw-r--r--backend/internal/monitor/checker.go86
-rw-r--r--backend/internal/monitor/worker.go148
-rw-r--r--backend/internal/sse/broadcaster.go42
-rw-r--r--backend/internal/store/memory.go277
-rw-r--r--backend/internal/store/postgres.go324
-rw-r--r--backend/internal/store/store.go23
-rw-r--r--backend/migrations/schema.sql34
-rw-r--r--backend/yaum-server.exebin0 -> 16090112 bytes
-rw-r--r--frontend/package-lock.json2560
-rw-r--r--frontend/package.json22
-rw-r--r--frontend/postcss.config.js6
-rw-r--r--frontend/src/app.css50
-rw-r--r--frontend/src/app.d.ts3
-rw-r--r--frontend/src/app.html13
-rw-r--r--frontend/src/hooks.server.js31
-rw-r--r--frontend/src/lib/api.ts39
-rw-r--r--frontend/src/lib/components/ActivityLog.svelte122
-rw-r--r--frontend/src/lib/components/AddServiceModal.svelte224
-rw-r--r--frontend/src/lib/components/ServiceCard.svelte90
-rw-r--r--frontend/src/lib/components/UptimeTimeline.svelte41
-rw-r--r--frontend/src/lib/realtime.ts42
-rw-r--r--frontend/src/lib/types.ts44
-rw-r--r--frontend/src/routes/(admin)/admin/+page.server.js27
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte432
-rw-r--r--frontend/src/routes/+layout.svelte50
-rw-r--r--frontend/src/routes/+page.svelte164
-rw-r--r--frontend/src/routes/+page.ts33
-rw-r--r--frontend/src/routes/login/+page.server.js51
-rw-r--r--frontend/src/routes/login/+page.svelte61
-rw-r--r--frontend/src/routes/services/[id]/+page.svelte261
-rw-r--r--frontend/src/routes/status/[id]/+page.svelte278
-rw-r--r--frontend/src/routes/status/[id]/+page.ts22
-rw-r--r--frontend/static/favicon.svg5
-rw-r--r--frontend/svelte.config.js12
-rw-r--r--frontend/tailwind.config.js30
-rw-r--r--frontend/tsconfig.json14
-rw-r--r--frontend/vite.config.ts11
52 files changed, 6780 insertions, 0 deletions
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(
+ `<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="20">
+ <rect width="%d" height="20" rx="3" fill="#333"/>
+ <rect x="%d" width="%d" height="20" rx="3" fill="%s"/>
+ <text x="%d" y="14" font-family="monospace, sans-serif" font-size="11" font-weight="bold" fill="#fff" text-anchor="middle">%s</text>
+</svg>`,
+ 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
--- /dev/null
+++ b/backend/yaum-server.exe
Binary files 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 @@
+/// <reference types="@sveltejs/kit" />
+
+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 @@
+<!doctype html>
+<html lang="pt-BR">
+ <head>
+ <meta charset="utf-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
+ <link rel="icon" type="image/svg+xml" href="%sveltekit.assets%/favicon.svg" />
+ <title>YAUM — Yet Another Uptime Monitor</title>
+ %sveltekit.head%
+ </head>
+ <body data-sveltekit-prerender="true">
+ <div style="display: contents">%sveltekit.body%</div>
+ </body>
+</html>
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<Service[]> {
+ 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<Service> {
+ 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<void> {
+ 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<Heartbeat[]> {
+ 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<ServiceStats> {
+ 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 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ interface LogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+ }
+
+ let {
+ entries = []
+ }: {
+ entries: LogEntry[];
+ } = $props();
+
+ let el: HTMLDivElement;
+ let autoScroll = $state(true);
+
+ function handleScroll() {
+ if (!el) return;
+ const dist = el.scrollHeight - el.scrollTop - el.clientHeight;
+ autoScroll = dist < 40;
+ }
+
+ $effect(() => {
+ if (entries.length && autoScroll && el) {
+ el.scrollTop = el.scrollHeight;
+ }
+ });
+
+ function timeAgo(date: Date): string {
+ const sec = Math.floor((Date.now() - date.getTime()) / 1000);
+ if (sec < 10) return 'agora';
+ if (sec < 60) return `${sec}s`;
+ const min = Math.floor(sec / 60);
+ if (min === 1) return '1min';
+ if (min < 60) return `${min}min`;
+ return date.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
+ }
+</script>
+
+<div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)]">
+ <div class="flex items-center justify-between border-b border-[var(--border-color)] px-5 py-3">
+ <h2 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Atividade Recente
+ </h2>
+ <span class="text-[10px] tabular-nums text-[var(--text-muted)]">{entries.length} eventos</span>
+ </div>
+ <div
+ bind:this={el}
+ onscroll={handleScroll}
+ class="scrollbar-thin space-y-0.5 overflow-y-auto px-3 py-2"
+ style="max-height: 320px"
+ >
+ {#if entries.length === 0}
+ <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma atividade ainda</p>
+ {:else}
+ {#each entries as entry, i (i)}
+ <div
+ class="flex items-start gap-3 rounded-xl px-3 py-2.5 transition-colors hover:bg-[var(--border-color)]/30"
+ >
+ <div class="mt-0.5 shrink-0">
+ {#if entry.kind === 'created'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--green)]/10 text-[10px] text-[var(--green)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'deleted'}
+ <span class="flex h-5 w-5 items-center justify-center rounded-md bg-[var(--red)]/10 text-[10px] text-[var(--red)]">
+ <svg class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </span>
+ {:else if entry.kind === 'transition'}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '↑' : '↓'}
+ </span>
+ {:else}
+ <span
+ class="flex h-5 w-5 items-center justify-center rounded-md text-[10px]"
+ style="background-color: {entry.hb?.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {entry.hb?.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {entry.hb?.is_up ? '✓' : '✗'}
+ </span>
+ {/if}
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="flex items-baseline gap-2">
+ <span class="truncate text-xs font-medium text-white">{entry.serviceName}</span>
+ <span class="shrink-0 text-[10px] text-[var(--text-muted)]">{timeAgo(entry.timestamp)}</span>
+ </div>
+ <p class="truncate text-[10px] text-[var(--text-muted)]">
+ {#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}
+ </p>
+ </div>
+ </div>
+ {/each}
+ {/if}
+ </div>
+</div>
+
+<style>
+ .scrollbar-thin {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border-color) transparent;
+ }
+</style>
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 @@
+<script lang="ts">
+ import type { Service } from '$lib/types';
+
+ let {
+ show = false,
+ onclose,
+ oncreated
+ }: {
+ show: boolean;
+ onclose: () => void;
+ oncreated: (svc: Service) => void;
+ } = $props();
+
+ let name = $state('');
+ let url = $state('');
+ let discordWebhookUrl = $state('');
+ let alertEmail = $state('');
+ let keywordToFind = $state('');
+ let error = $state('');
+ let saving = $state(false);
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ error = '';
+
+ if (!name.trim() || !url.trim()) {
+ error = 'Preencha nome e URL';
+ return;
+ }
+ try {
+ new URL(url);
+ } catch {
+ error = 'URL inválida — deve começar com http:// ou https://';
+ return;
+ }
+
+ saving = true;
+ try {
+ const res = await fetch('/api/services', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ name: name.trim(),
+ url: url.trim(),
+ discord_webhook_url: discordWebhookUrl.trim() || undefined,
+ alert_email: alertEmail.trim() || undefined,
+ keyword_to_find: keywordToFind.trim() || undefined
+ })
+ });
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Erro ao criar serviço');
+ }
+ const svc: Service = await res.json();
+ oncreated(svc);
+ name = '';
+ url = '';
+ discordWebhookUrl = '';
+ alertEmail = '';
+ keywordToFind = '';
+ onclose();
+ } catch (e: any) {
+ error = e.message;
+ } finally {
+ saving = false;
+ }
+ }
+
+ function handleBackdrop(e: MouseEvent) {
+ if (e.target === e.currentTarget) onclose();
+ }
+
+ function handleKeydown(e: KeyboardEvent) {
+ if (e.key === 'Escape') onclose();
+ }
+</script>
+
+<svelte:window onkeydown={handleKeydown} />
+
+{#if show}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <div
+ role="button"
+ tabindex="-1"
+ aria-label="Fechar modal"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+ onclick={handleBackdrop}
+ >
+ <div
+ class="w-full max-w-md animate-[modalIn_0.2s_ease-out] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] p-6 shadow-2xl"
+ >
+ <div class="mb-5 flex items-center justify-between">
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Novo Serviço
+ </h2>
+ <button
+ onclick={onclose}
+ class="rounded-lg p-1.5 text-[var(--text-muted)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ aria-label="Fechar"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <form onsubmit={handleSubmit} class="space-y-4">
+ <div>
+ <label for="modal-name" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ Nome do Serviço
+ </label>
+ <input
+ id="modal-name"
+ bind:value={name}
+ type="text"
+ placeholder="Meu Site"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="modal-url" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">
+ URL
+ </label>
+ <input
+ id="modal-url"
+ bind:value={url}
+ type="url"
+ placeholder="https://exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="border-t border-[var(--border-color)] pt-4">
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">
+ Alertas (opcional)
+ </p>
+ <div>
+ <label
+ for="modal-discord"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Webhook do Discord
+ </label>
+ <input
+ id="modal-discord"
+ bind:value={discordWebhookUrl}
+ type="url"
+ placeholder="https://discord.com/api/webhooks/..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ <div class="mt-3">
+ <label
+ for="modal-email"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ E-mail de Alerta
+ </label>
+ <input
+ id="modal-email"
+ bind:value={alertEmail}
+ type="email"
+ placeholder="admin@exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ <div class="mt-3">
+ <label
+ for="modal-keyword"
+ class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]"
+ >
+ Verificar Palavra-chave na Página (opcional)
+ </label>
+ <input
+ id="modal-keyword"
+ bind:value={keywordToFind}
+ type="text"
+ placeholder="Bem-vindo, Login, OK..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ <p class="mt-1 text-[9px] text-[var(--text-muted)]">
+ Se informado, o monitor vai ler o HTML e marcar como DOWN se não encontrar essa palavra.
+ </p>
+ </div>
+ </div>
+
+ {#if error}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{error}</p>
+ {/if}
+
+ <div class="flex gap-3 pt-1">
+ <button
+ type="button"
+ onclick={onclose}
+ class="flex-1 rounded-xl border border-[var(--border-color)] px-4 py-2.5 text-sm font-medium text-[var(--text-muted)] transition-colors hover:border-[var(--text-muted)]/30 hover:text-white"
+ >
+ Cancelar
+ </button>
+ <button
+ type="submit"
+ disabled={saving}
+ class="flex-1 rounded-xl border border-[var(--green)] bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {saving ? 'Salvando…' : 'Adicionar'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+{/if}
+
+<style>
+ @keyframes modalIn {
+ from {
+ opacity: 0;
+ transform: scale(0.95) translateY(-8px);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1) translateY(0);
+ }
+ }
+</style>
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 @@
+<script lang="ts">
+ import UptimeTimeline from './UptimeTimeline.svelte';
+ import type { Service, Heartbeat } from '$lib/types';
+
+ let {
+ service,
+ heartbeats = [],
+ ondeleted
+ }: {
+ service: Service;
+ heartbeats: Heartbeat[];
+ ondeleted: (id: number) => void;
+ } = $props();
+
+ let isUp = $derived(service?.last_heartbeat?.is_up ?? true);
+ let lastMs = $derived(service?.last_heartbeat?.response_time_ms ?? null);
+ let statusColor = $derived(isUp ? 'var(--green)' : 'var(--red)');
+ let bgClass = $derived(isUp ? 'bg-emerald-500/10 text-emerald-500' : 'bg-rose-500/10 text-rose-500');
+ let dotGlow = $derived(isUp ? '0 0 6px var(--green)' : '0 0 6px var(--red)');
+
+ let uptime30d = $derived.by(() => {
+ const now = Date.now();
+ const cutoff30d = now - 30 * 24 * 60 * 60 * 1000;
+ const recent = heartbeats.filter((h) => new Date(h.tested_at).getTime() >= cutoff30d);
+ if (recent.length === 0) return null;
+ return (recent.filter((h) => h.is_up).length / recent.length) * 100;
+ });
+
+ let uptimeColor = $derived(uptime30d !== null && uptime30d < 99 ? 'var(--red)' : 'var(--green)');
+</script>
+
+{#if service}
+<div
+ class="group relative overflow-hidden rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 transition-all duration-300 hover:border-[var(--border-color)]/60 hover:shadow-2xl hover:shadow-black/30 hover:-translate-y-0.5"
+>
+ <div
+ class="pointer-events-none absolute -inset-px rounded-2xl opacity-0 transition-opacity duration-500 group-hover:opacity-100"
+ style="background: radial-gradient(600px circle at 50% 50%, {statusColor}06 0%, transparent 60%)"
+ ></div>
+
+ <div class="relative z-10">
+ <div class="mb-3 flex items-start justify-between gap-2">
+ <div class="min-w-0 flex-1">
+ <h3 class="truncate text-sm font-semibold text-white">{service.name}</h3>
+ <p class="mt-0.5 truncate font-mono text-xs text-[var(--text-muted)]">{service.url}</p>
+ </div>
+ <button
+ onclick={() => ondeleted(service.id)}
+ class="shrink-0 rounded-lg p-1.5 text-[var(--text-muted)] opacity-0 transition-all duration-200 hover:bg-rose-500/10 hover:text-rose-500 group-hover:opacity-100"
+ title="Remover serviço"
+ >
+ <svg class="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <div class="mb-4 flex items-center gap-3">
+ <span class="inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1 text-xs font-semibold tracking-wide {bgClass}">
+ <span class="h-1.5 w-1.5 rounded-full" style="background-color: {statusColor}; box-shadow: {dotGlow}"></span>
+ {isUp ? 'UP' : 'DOWN'}
+ </span>
+
+ {#if lastMs !== null}
+ <span class="font-mono text-xs tabular-nums text-[var(--text-muted)]">
+ {lastMs}<span class="text-[var(--text-secondary)]">ms</span>
+ </span>
+ {/if}
+
+ {#if uptime30d !== null}
+ <span
+ class="ml-auto font-mono text-xs tabular-nums"
+ style="color: {uptimeColor}"
+ >
+ {uptime30d.toFixed(2)}%
+ </span>
+ {/if}
+ </div>
+
+ <UptimeTimeline {heartbeats} />
+
+ <a
+ href="/status/{service.id}"
+ class="mt-3 block text-center text-[10px] font-medium uppercase tracking-widest text-[var(--text-muted)] transition-colors hover:text-[var(--text-secondary)]"
+ >
+ Detalhes →
+ </a>
+ </div>
+</div>
+{/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 @@
+<script lang="ts">
+ import type { Heartbeat } from '$lib/types';
+
+ let {
+ heartbeats = []
+ }: {
+ heartbeats: Heartbeat[];
+ } = $props();
+
+ let blocks = $derived.by(() => {
+ const all = heartbeats.slice(-30);
+ const filled = all.map((h) => h.is_up);
+ while (filled.length < 30) {
+ filled.unshift(null);
+ }
+ return filled;
+ });
+
+ function blockColor(v: boolean | null): string {
+ if (v === true) return 'var(--green)';
+ if (v === false) return 'var(--red)';
+ return 'var(--border-color)';
+ }
+</script>
+
+<div class="space-y-1.5">
+ <div class="flex gap-[3px]">
+ {#each blocks as block, i}
+ <div
+ class="h-4 flex-1 rounded-sm transition-all duration-300"
+ style="background-color: {blockColor(block)}; opacity: {block !== null ? 0.8 : 1}"
+ title={block === true ? 'UP' : block === false ? 'DOWN' : 'Sem dados'}
+ ></div>
+ {/each}
+ </div>
+ <div class="flex justify-between text-[10px] font-medium text-[var(--text-muted)]">
+ <span>{heartbeats.length > 0 ? '-30 min' : ''}</span>
+ <span class="tabular-nums">{heartbeats.length} checks</span>
+ <span>agora</span>
+ </div>
+</div>
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<Listener>();
+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 @@
+<script lang="ts">
+ const API = 'http://localhost:8080/api';
+
+ let { data } = $props();
+
+ let initialized = $state(false);
+ let token = $state('');
+ let services = $state<any[]>([]);
+ let showForm = $state(false);
+
+ $effect(() => {
+ if (!initialized && data) {
+ token = data.token;
+ services = data.services ?? [];
+ initialized = true;
+ }
+ });
+ let editingSvc: any = $state(null);
+ let saving = $state(false);
+ let formError = $state('');
+ let testing = $state<Record<number, boolean>>({});
+ let toggling = $state<Record<number, boolean>>({});
+
+ function apiHeaders() {
+ return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
+ }
+
+ async function handleUnauthorized(res: Response) {
+ if (res.status === 401) {
+ window.location.href = '/login';
+ return true;
+ }
+ return false;
+ }
+
+ function openCreate() {
+ editingSvc = null;
+ formError = '';
+ showForm = true;
+ }
+
+ function openEdit(svc: any) {
+ editingSvc = { ...svc };
+ formError = '';
+ showForm = true;
+ }
+
+ function closeForm() {
+ showForm = false;
+ editingSvc = null;
+ formError = '';
+ }
+
+ async function reFetch() {
+ try {
+ const res = await fetch(`${API}/services`, { headers: { Authorization: `Bearer ${token}` } });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const list = await res.json();
+ const withStats = await Promise.all(
+ list.map(async (svc: any) => {
+ try {
+ const sr = await fetch(`${API}/services/${svc.id}/stats`, {
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (sr.ok) svc.stats = await sr.json();
+ } catch {}
+ return svc;
+ })
+ );
+ services = withStats;
+ }
+ } catch {}
+ }
+
+ async function handleSubmit(e: Event) {
+ e.preventDefault();
+ saving = true;
+ formError = '';
+
+ const form = e.target as HTMLFormElement;
+ const fd = new FormData(form);
+
+ const body: Record<string, any> = {
+ name: fd.get('name'),
+ url: fd.get('url'),
+ group_name: (fd.get('group_name') as string) || 'Geral',
+ interval_seconds: parseInt((fd.get('interval_seconds') as string) || '60', 10),
+ };
+ const keyword = fd.get('keyword_to_find') as string;
+ if (keyword) body.keyword_to_find = keyword;
+ const discord = fd.get('discord_webhook_url') as string;
+ if (discord) body.discord_webhook_url = discord;
+ const email = fd.get('alert_email') as string;
+ if (email) body.alert_email = email;
+
+ try {
+ const id = fd.get('id');
+ const method = id ? 'PUT' : 'POST';
+ const url = id ? `${API}/services/${id}` : `${API}/services`;
+ const res = await fetch(url, {
+ method,
+ headers: apiHeaders(),
+ body: JSON.stringify(body),
+ });
+ if (await handleUnauthorized(res)) return;
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({}));
+ throw new Error(err.error || 'Erro ao salvar');
+ }
+ await reFetch();
+ closeForm();
+ } catch (e: any) {
+ formError = e.message;
+ } finally {
+ saving = false;
+ }
+ }
+
+ async function handleDelete(id: number) {
+ try {
+ const res = await fetch(`${API}/services/${id}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) await reFetch();
+ } catch {}
+ }
+
+ async function handleToggle(id: number) {
+ toggling = { ...toggling, [id]: true };
+ try {
+ const res = await fetch(`${API}/services/${id}/toggle`, {
+ method: 'PATCH',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const updated = await res.json();
+ services = services.map((s) => (s.id === id ? { ...s, ...updated } : s));
+ }
+ } catch { /* ignore */ }
+ toggling = { ...toggling, [id]: false };
+ }
+
+ async function handleTest(id: number) {
+ testing = { ...testing, [id]: true };
+ try {
+ const res = await fetch(`${API}/services/${id}/test`, {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${token}` }
+ });
+ if (await handleUnauthorized(res)) return;
+ if (res.ok) {
+ const hb = await res.json();
+ services = services.map((s) =>
+ s.id === id ? { ...s, last_heartbeat: hb } : s
+ );
+ }
+ } catch { /* ignore */ }
+ testing = { ...testing, [id]: false };
+ }
+</script>
+
+<svelte:head>
+ <title>Admin — YAUM</title>
+</svelte:head>
+
+<div class="mb-8 flex items-center justify-between">
+ <div>
+ <h1 class="text-2xl font-semibold tracking-tight text-white">Painel de Controle</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Gerenciamento de serviços monitorados</p>
+ </div>
+ <a
+ href="/"
+ class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"
+ >
+ ← Dashboard
+ </a>
+</div>
+
+<div class="mb-8">
+ <button
+ onclick={openCreate}
+ class="flex items-center gap-2 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-4 py-2.5 text-sm font-medium text-[var(--text-primary)] transition-all hover:border-[var(--green)]/50 hover:text-[var(--green)] hover:shadow-[0_0_20px_rgba(34,240,106,0.08)]"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4" />
+ </svg>
+ Novo Serviço
+ </button>
+</div>
+
+{#if data.error}
+ <div class="rounded-xl border border-[var(--red)]/20 bg-[var(--red)]/5 p-6 text-center">
+ <p class="text-sm text-[var(--red)]">{data.error}</p>
+ </div>
+{:else if services.length === 0}
+ <div class="rounded-xl border border-dashed border-[var(--border-color)] p-12 text-center">
+ <p class="text-sm text-[var(--text-muted)]">Nenhum serviço cadastrado</p>
+ </div>
+{:else}
+ {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group}
+ <div class="mb-6">
+ <h2 class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">{group}</h2>
+ <div class="overflow-hidden rounded-xl border border-[var(--border-color)]">
+ <table class="w-full text-left text-sm">
+ <thead>
+ <tr class="border-b border-[var(--border-color)] bg-[var(--bg-secondary)]/30">
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">ID</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Nome</th>
+ <th class="hidden px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)] md:table-cell">URL</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Status</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">SLA 30d</th>
+ <th class="px-4 py-3 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Ações</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)}
+ <tr class="border-b border-[var(--border-color)] transition-colors hover:bg-[var(--bg-secondary)]/20">
+ <td class="px-4 py-3 font-mono text-xs text-[var(--text-muted)]">{svc.id}</td>
+ <td class="px-4 py-3 font-medium text-white">{svc.name}</td>
+ <td class="hidden max-w-[200px] truncate px-4 py-3 font-mono text-xs text-[var(--text-muted)] md:table-cell">{svc.url}</td>
+ <td class="px-4 py-3">
+ <span
+ class="inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[10px] font-semibold"
+ style="background-color: {svc.last_heartbeat?.is_up ?? true ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {svc.last_heartbeat?.is_up ?? true ? 'var(--green)' : 'var(--red)'}"
+ >
+ {svc.last_heartbeat?.is_up ?? true ? 'UP' : 'DOWN'}
+ </span>
+ </td>
+ <td class="px-4 py-3 font-mono text-xs tabular-nums">
+ {svc.stats ? svc.stats.uptime_30d.toFixed(2) + '%' : '—'}
+ </td>
+ <td class="px-4 py-3">
+ <div class="flex flex-wrap gap-1.5">
+ <button
+ onclick={() => openEdit(svc)}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ >
+ Editar
+ </button>
+ <button
+ onclick={() => handleToggle(svc.id)}
+ disabled={toggling[svc.id] ?? false}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium transition-colors"
+ style="color: {svc.is_active ?? true ? 'var(--green)' : 'var(--red)'}; {(svc.is_active ?? true) ? 'border:1px solid rgba(34,240,106,0.3)' : 'border:1px solid rgba(255,64,96,0.3)'}; {toggling[svc.id] ? 'opacity:0.5' : ''}"
+ >
+ {svc.is_active ?? true ? 'Ativo' : 'Pausado'}
+ </button>
+ <button
+ onclick={() => handleTest(svc.id)}
+ disabled={testing[svc.id] ?? false}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ style={testing[svc.id] ? 'opacity:0.5' : ''}
+ >
+ {testing[svc.id] ? '...' : 'Testar'}
+ </button>
+ <button
+ onclick={() => handleDelete(svc.id)}
+ class="rounded-lg px-2.5 py-1 text-[10px] font-medium text-[var(--red)] transition-colors hover:bg-[var(--red)]/10"
+ >
+ Excluir
+ </button>
+ </div>
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ </div>
+ {/each}
+{/if}
+
+<!-- Modal create / edit -->
+{#if showForm}
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
+ <div
+ role="button"
+ tabindex="-1"
+ aria-label="Fechar"
+ class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
+ onclick={closeForm}
+ >
+ <div
+ class="w-full max-w-md animate-[modalIn_0.2s_ease-out] rounded-2xl border border-[var(--border-color)] bg-[var(--bg-secondary)] p-6 shadow-2xl"
+ onclick={(e) => e.stopPropagation()}
+ >
+ <div class="mb-5 flex items-center justify-between">
+ <h2 class="text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ {editingSvc ? 'Editar Serviço' : 'Novo Serviço'}
+ </h2>
+ <button
+ onclick={closeForm}
+ class="rounded-lg p-1.5 text-[var(--text-muted)] transition-colors hover:bg-[var(--border-color)] hover:text-white"
+ aria-label="Fechar"
+ >
+ <svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ </button>
+ </div>
+
+ <form onsubmit={handleSubmit} class="space-y-4">
+ {#if editingSvc}
+ <input type="hidden" name="id" value={editingSvc.id} />
+ {/if}
+
+ <div>
+ <label for="admin-name" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Nome do Serviço</label>
+ <input
+ id="admin-name"
+ name="name"
+ type="text"
+ value={editingSvc?.name ?? ''}
+ required
+ placeholder="Meu Site"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="admin-url" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">URL</label>
+ <input
+ id="admin-url"
+ name="url"
+ type="url"
+ value={editingSvc?.url ?? ''}
+ required
+ placeholder="https://exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+</div>
+
+<div>
+ <label for="admin-group" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Grupo / Categoria</label>
+ <input
+ id="admin-group"
+ name="group_name"
+ type="text"
+ value={editingSvc?.group_name ?? 'Geral'}
+ placeholder="APIs, Websites, Servidores..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+</div>
+
+<div>
+ <label for="admin-interval" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Intervalo (segundos)</label>
+ <input
+ id="admin-interval"
+ name="interval_seconds"
+ type="number"
+ value={editingSvc?.interval_seconds ?? 60}
+ min="10"
+ max="3600"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="border-t border-[var(--border-color)] pt-4">
+ <p class="mb-3 text-xs font-semibold uppercase tracking-wider text-[var(--text-muted)]">Opcionais</p>
+
+ <div>
+ <label for="admin-keyword" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Palavra-chave</label>
+ <input
+ id="admin-keyword"
+ name="keyword_to_find"
+ type="text"
+ value={editingSvc?.keyword_to_find ?? ''}
+ placeholder="Bem-vindo, Login..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="mt-3">
+ <label for="admin-webhook" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Webhook Discord</label>
+ <input
+ id="admin-webhook"
+ name="discord_webhook_url"
+ type="url"
+ value={editingSvc?.discord_webhook_url ?? ''}
+ placeholder="https://discord.com/api/webhooks/..."
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div class="mt-3">
+ <label for="admin-email" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">E-mail de Alerta</label>
+ <input
+ id="admin-email"
+ name="alert_email"
+ type="email"
+ value={editingSvc?.alert_email ?? ''}
+ placeholder="admin@exemplo.com"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+ </div>
+
+ {#if formError}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{formError}</p>
+ {/if}
+
+ <div class="flex gap-3 pt-1">
+ <button
+ type="button"
+ onclick={closeForm}
+ class="flex-1 rounded-xl border border-[var(--border-color)] px-4 py-2.5 text-sm font-medium text-[var(--text-muted)] transition-colors hover:border-[var(--text-muted)]/30 hover:text-white"
+ >
+ Cancelar
+ </button>
+ <button
+ type="submit"
+ disabled={saving}
+ class="flex-1 rounded-xl border border-[var(--green)] bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20 disabled:cursor-not-allowed disabled:opacity-40"
+ >
+ {saving ? 'Salvando…' : editingSvc ? 'Salvar' : 'Adicionar'}
+ </button>
+ </div>
+ </form>
+ </div>
+ </div>
+{/if}
+
+<style>
+ @keyframes modalIn {
+ from { opacity: 0; transform: scale(0.95) translateY(-8px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+ }
+</style>
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 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import type { Maintenance } from '$lib/types';
+ import '../app.css';
+
+ let { children } = $props();
+ let maintenance = $state<Maintenance | null>(null);
+
+ onMount(async () => {
+ try {
+ const res = await fetch('http://localhost:8080/api/active-maintenance');
+ if (res.ok) {
+ const data = await res.json();
+ maintenance = data.maintenance ?? null;
+ }
+ } catch { /* ignore */ }
+ });
+</script>
+
+<div class="min-h-screen">
+ {#if maintenance}
+ <div class="border-b border-[#facc15]/30 bg-[#facc15]/5 px-6 py-3">
+ <div class="mx-auto flex max-w-6xl items-center justify-center gap-2">
+ <svg class="h-4 w-4 shrink-0 text-[#facc15]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ <p class="text-xs font-medium text-[#facc15]">{maintenance.title}</p>
+ </div>
+ </div>
+ {/if}
+
+ <header class="border-b border-[var(--border-color)] px-6 py-4">
+ <div class="mx-auto flex max-w-6xl items-center justify-between">
+ <div class="flex items-center gap-3">
+ <a href="/" class="text-lg font-bold tracking-tight text-white transition-colors hover:text-[var(--green)]">
+ <span class="text-[var(--green)]">●</span> YAUM
+ </a>
+ <span class="hidden text-xs text-[var(--text-muted)] sm:inline">Yet Another Uptime Monitor</span>
+ </div>
+ <nav class="flex items-center gap-4">
+ <a href="/" class="text-xs text-[var(--text-muted)] transition-colors hover:text-white">Dashboard</a>
+ <a href="/admin" class="text-xs text-[var(--text-muted)] transition-colors hover:text-white">Admin</a>
+ </nav>
+ </div>
+ </header>
+
+ <main class="mx-auto max-w-6xl px-4 py-8">
+ {@render children()}
+ </main>
+</div>
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 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { deleteService } from '$lib/api';
+ import ServiceCard from '$lib/components/ServiceCard.svelte';
+ import AddServiceModal from '$lib/components/AddServiceModal.svelte';
+ import ActivityLog from '$lib/components/ActivityLog.svelte';
+ import { subscribeHeartbeats } from '$lib/realtime';
+ import type { Service, Heartbeat } from '$lib/types';
+
+ let { data } = $props<{
+ data: { services: (Service & { heartbeats: Heartbeat[] })[]; error: string };
+ }>();
+
+ let initialized = $state(false);
+ let services = $state<(Service & { heartbeats: Heartbeat[] })[]>([]);
+ let error = $state('');
+ let showModal = $state(false);
+
+ $effect(() => {
+ if (!initialized && data) {
+ services = [...data.services];
+ error = data.error;
+ initialized = true;
+ }
+ });
+
+ interface LogEntry {
+ kind: 'check' | 'created' | 'deleted' | 'transition';
+ serviceName: string;
+ serviceUrl: string;
+ timestamp: Date;
+ hb?: Heartbeat;
+ wasUp?: boolean;
+ }
+
+ let logEntries = $state<LogEntry[]>([]);
+
+ function pushEntry(entry: LogEntry) {
+ logEntries = [...logEntries, entry];
+ if (logEntries.length > 200) {
+ logEntries = logEntries.slice(-150);
+ }
+ }
+
+ function handleCreated(svc: Service) {
+ services = [{ ...svc, heartbeats: [] }, ...services];
+ pushEntry({
+ kind: 'created',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date()
+ });
+ }
+
+ async function handleDeleted(id: number) {
+ const svc = services.find((s) => s.id === id);
+ try {
+ await deleteService(id);
+ services = services.filter((s) => s.id !== id);
+ if (svc) {
+ pushEntry({
+ kind: 'deleted',
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date()
+ });
+ }
+ } catch {
+ // silent
+ }
+ }
+
+ onMount(() => {
+ const unsub = subscribeHeartbeats((hb) => {
+ let wasUp: boolean | undefined;
+ services = services.map((s) => {
+ if (s.id !== hb.service_id) return s;
+ if (s.last_heartbeat) {
+ wasUp = s.last_heartbeat.is_up;
+ }
+ return {
+ ...s,
+ last_heartbeat: hb,
+ heartbeats: [...s.heartbeats, hb].slice(-30)
+ };
+ });
+
+ const svc = services.find((s) => s.id === hb.service_id);
+ if (!svc) return;
+
+ const kind = wasUp !== undefined && wasUp !== hb.is_up ? 'transition' : 'check';
+ pushEntry({
+ kind,
+ serviceName: svc.name,
+ serviceUrl: svc.url,
+ timestamp: new Date(),
+ hb,
+ wasUp
+ });
+ });
+ return unsub;
+ });
+</script>
+
+<svelte:head>
+ <title>YAUM — Yet Another Uptime Monitor</title>
+</svelte:head>
+
+<div class="mb-8">
+ <h1 class="text-2xl font-semibold tracking-tight text-white">Dashboard</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Status dos serviços monitorados</p>
+</div>
+
+{#if error}
+ <div class="rounded-2xl border border-[var(--red)]/20 bg-[var(--red)]/5 p-8 text-center">
+ <svg class="mx-auto mb-3 h-8 w-8 text-[var(--red)]/60" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ <p class="text-sm text-[var(--red)]">{error}</p>
+ <button
+ onclick={() => location.reload()}
+ class="mt-4 text-xs font-medium text-[var(--text-muted)] underline underline-offset-2 transition-colors hover:text-white"
+ >
+ Tentar novamente
+ </button>
+ </div>
+{:else if services.length === 0}
+ <div class="rounded-2xl border border-dashed border-[var(--border-color)] p-16 text-center">
+ <div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--bg-card)]">
+ <svg class="h-6 w-6 text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M3.75 13.5l10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75z" />
+ </svg>
+ </div>
+ <p class="text-sm font-medium text-[var(--text-secondary)]">Nenhum serviço sendo monitorado</p>
+ <p class="mt-1 text-xs text-[var(--text-muted)]">Adicione o primeiro serviço para começar</p>
+ <button
+ onclick={() => (showModal = true)}
+ class="mt-6 rounded-xl border border-[var(--green)]/30 bg-[var(--green)]/5 px-5 py-2 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/10"
+ >
+ + Adicionar Serviço
+ </button>
+ </div>
+{:else}
+ {#each [...new Set(services.map((s) => s.group_name || 'Geral'))].sort() as group}
+ <div class="mb-8">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">{group}</h2>
+ <div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
+ {#each services.filter((s) => (s.group_name || 'Geral') === group) as svc (svc.id)}
+ <ServiceCard service={svc} heartbeats={svc.heartbeats} ondeleted={handleDeleted} />
+ {/each}
+ </div>
+ </div>
+ {/each}
+
+ <div class="mt-8">
+ <ActivityLog entries={logEntries} />
+ </div>
+{/if}
+
+<AddServiceModal
+ show={showModal}
+ onclose={() => (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 @@
+<script lang="ts">
+ let { form }: { form?: { error?: string } } = $props();
+</script>
+
+<svelte:head>
+ <title>Login — YAUM</title>
+</svelte:head>
+
+<div class="flex min-h-screen items-start justify-center pt-36">
+ <div class="w-full max-w-sm">
+ <div class="mb-8 text-center">
+ <h1 class="text-xl font-semibold tracking-tight text-white">YAUM</h1>
+ <p class="mt-1 text-sm text-[var(--text-muted)]">Yet Another Uptime Monitor</p>
+ </div>
+
+ <div class="rounded-2xl border border-[var(--border-color)] bg-[var(--bg-card)] p-6">
+ <form method="POST" class="space-y-4">
+ <div>
+ <label for="username" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Usuário</label>
+ <input
+ id="username"
+ name="username"
+ type="text"
+ required
+ autocomplete="username"
+ placeholder="admin"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ <div>
+ <label for="password" class="mb-1.5 block text-xs font-medium text-[var(--text-secondary)]">Senha</label>
+ <input
+ id="password"
+ name="password"
+ type="password"
+ required
+ autocomplete="current-password"
+ placeholder="••••••••"
+ class="w-full rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)] px-3.5 py-2.5 text-sm text-white outline-none transition-all placeholder:text-[var(--text-muted)] focus:border-[var(--green)]/50 focus:ring-1 focus:ring-[var(--green)]/20"
+ />
+ </div>
+
+ {#if form?.error}
+ <p class="rounded-lg bg-[var(--red)]/5 px-3 py-2 text-xs text-[var(--red)]">{form.error}</p>
+ {/if}
+
+ <button
+ type="submit"
+ class="w-full rounded-xl border border-[var(--green)]/50 bg-[var(--green)]/10 px-4 py-2.5 text-sm font-medium text-[var(--green)] transition-all hover:bg-[var(--green)]/20"
+ >
+ Entrar
+ </button>
+ </form>
+ </div>
+
+ <p class="mt-6 text-center text-xs text-[var(--text-muted)]">
+ <a href="/" class="underline transition-colors hover:text-white">← Voltar ao Dashboard</a>
+ </p>
+ </div>
+</div>
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 @@
+<script lang="ts">
+ import { onMount } from 'svelte';
+ import { page } from '$app/stores';
+ import { getHistory, getServiceStats, listServices } from '$lib/api';
+ import { subscribeHeartbeats } from '$lib/realtime';
+ import type { Service, Heartbeat, ServiceStats } from '$lib/types';
+
+ let svc: Service | undefined = $state();
+ let history: Heartbeat[] = $state([]);
+ let stats: ServiceStats | null = $state(null);
+ let loading = $state(true);
+ let expandedError: number | null = $state(null);
+
+ onMount(async () => {
+ try {
+ const all = await listServices();
+ svc = all.find((s) => s.id === Number($page.params.id));
+ history = await getHistory(Number($page.params.id));
+ getServiceStats(Number($page.params.id)).then((s) => (stats = s)).catch(() => {});
+ } catch {
+ // handle
+ } finally {
+ loading = false;
+ }
+ });
+
+ $effect(() => {
+ if (!svc && $page.params.id) {
+ getHistory(Number($page.params.id))
+ .then((h) => (history = h))
+ .catch(() => {});
+ }
+ });
+
+ onMount(() => {
+ const unsub = subscribeHeartbeats((hb) => {
+ if (hb.service_id !== Number($page.params.id)) return;
+ history = [...history, hb].slice(-50);
+ });
+ return unsub;
+ });
+
+ let chartPad = { t: 8, b: 20, l: 40, r: 12 };
+ let chartW = $derived(Math.max(history.length * 32, 300));
+ let chartH = 200;
+ let plotW = $derived(chartW - chartPad.l - chartPad.r);
+ let plotH = $derived(chartH - chartPad.t - chartPad.b);
+ let chartMax = $derived(Math.max(...history.map((h) => h.response_time_ms), 100));
+ let chartMin = $derived(0);
+
+ let dots = $derived(
+ history.map((h, i) => {
+ const x = history.length === 1
+ ? chartPad.l + plotW / 2
+ : chartPad.l + (i / (history.length - 1)) * plotW;
+ const y = chartPad.t + plotH - ((h.response_time_ms - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ return { x, y, ...h };
+ })
+ );
+
+ let yTicks = $derived.by(() => {
+ const n = 4;
+ return Array.from({ length: n + 1 }, (_, i) => {
+ const val = chartMin + ((chartMax - chartMin) / n) * i;
+ const y = chartPad.t + plotH - ((val - chartMin) / (chartMax - chartMin || 1)) * plotH;
+ return { val: Math.round(val), y };
+ });
+ });
+</script>
+
+<svelte:head>
+ <title>{svc?.name ?? 'Detalhes'} — YAUM</title>
+</svelte:head>
+
+{#if loading}
+ <div class="flex items-center justify-center py-20">
+ <div
+ class="h-8 w-8 animate-spin rounded-full border-2 border-[var(--border-color)] border-t-[var(--green)]"
+ ></div>
+ </div>
+{:else if !svc}
+ <div class="rounded-xl border border-[var(--border-color)] p-12 text-center">
+ <p class="text-sm text-[var(--text-muted)]">Serviço não encontrado</p>
+ </div>
+{:else}
+ <div class="mb-6">
+ <a
+ href="/"
+ class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white"
+ >
+ ← Voltar
+ </a>
+ </div>
+
+ <div class="mb-8">
+ <h1 class="text-2xl font-semibold text-white">{svc.name}</h1>
+ <p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p>
+ </div>
+
+ <div class="grid gap-6 lg:grid-cols-3">
+ <!-- Stats card -->
+ {#if stats}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Uptime
+ </h2>
+ <div class="space-y-3">
+ {#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}
+ <div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 p-3">
+ <div class="mb-1 flex items-center justify-between">
+ <span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">
+ {item.label}
+ </span>
+ <span class="font-mono text-xs tabular-nums text-[var(--text-secondary)]">
+ {item.checks} checks
+ </span>
+ </div>
+ <div class="flex items-baseline gap-3">
+ <span
+ class="text-lg font-bold tabular-nums"
+ style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {item.uptime.toFixed(2)}%
+ </span>
+ <span class="font-mono text-xs text-[var(--text-muted)]">
+ {item.avg.toFixed(0)}ms méd.
+ </span>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Tempo de Resposta (ms) -->
+ <div
+ class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2"
+ >
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Tempo de Resposta (ms)
+ </h2>
+ <div class="relative h-48">
+ {#if history.length === 0}
+ <div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
+ Sem dados ainda
+ </div>
+ {:else if history.length === 1}
+ <div class="flex h-full flex-col items-center justify-center gap-1">
+ <span
+ class="text-3xl font-bold tabular-nums"
+ style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {history[0].response_time_ms}
+ <span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
+ </span>
+ <span class="text-xs text-[var(--text-muted)]">
+ {history[0].is_up ? 'Online' : 'Offline'} —
+ {history[0].status_code || 'timeout'}
+ </span>
+ </div>
+ {:else}
+ <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+ {#each yTicks as tick}
+ <line
+ x1={chartPad.l}
+ y1={tick.y}
+ x2={chartW - chartPad.r}
+ y2={tick.y}
+ stroke="var(--border-color)"
+ stroke-width="1"
+ stroke-dasharray="3,3"
+ />
+ <text
+ x={chartPad.l - 6}
+ y={tick.y + 3}
+ text-anchor="end"
+ fill="var(--text-muted)"
+ font-size="9"
+ >{tick.val}</text
+ >
+ {/each}
+
+ <polyline
+ points={dots.map((d) => `${d.x},${d.y}`).join(' ')}
+ fill="none"
+ stroke="var(--green)"
+ stroke-width="2"
+ stroke-linecap="round"
+ stroke-linejoin="round"
+ />
+
+ {#each dots as d}
+ <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ {/each}
+ </svg>
+ {/if}
+ </div>
+ <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
+ <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
+ <span
+ >{history[history.length - 1]?.tested_at
+ ? new Date(history[history.length - 1].tested_at).toLocaleTimeString()
+ : ''}</span
+ >
+ </div>
+ </div>
+
+ <!-- Timeline compacta -->
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-sm font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Últimas Verificações
+ </h2>
+ <div class="flex flex-col gap-1.5">
+ {#each history.slice().reverse() as h}
+ <div>
+ <div class="flex items-center justify-between gap-2">
+ <div class="flex items-center gap-2">
+ <span
+ class="h-2 w-2 shrink-0 rounded-full"
+ class:bg-[var(--green)]={h.is_up}
+ class:bg-[var(--red)]={!h.is_up}
+ ></span>
+ <span class="font-mono text-xs">
+ {#if h.status_code > 0}
+ {h.status_code}
+ {:else}
+ TIMEOUT
+ {/if}
+ </span>
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="font-mono text-xs text-[var(--text-muted)]">{h.response_time_ms}ms</span>
+ {#if h.error_message}
+ <button
+ onclick={() => (expandedError = expandedError === h.id ? null : h.id)}
+ class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70"
+ >
+ {expandedError === h.id ? '▲ erro' : '▼ erro'}
+ </button>
+ {/if}
+ </div>
+ </div>
+ {#if expandedError === h.id && h.error_message}
+ <div
+ class="mt-1.5 overflow-auto rounded-md border border-[var(--red)]/20 bg-[var(--bg-secondary)]/50 p-2"
+ >
+ <pre class="break-all font-mono text-[11px] leading-relaxed text-[var(--text-muted)]"
+ >{h.error_message}</pre
+ >
+ </div>
+ {/if}
+ </div>
+ {/each}
+ </div>
+ </div>
+ </div>
+{/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 @@
+<script lang="ts">
+ import type { PageData } from './$types';
+
+ let { data }: { data: PageData } = $props();
+
+ let svc = $derived(data.service);
+ let history = $derived(data.history ?? []);
+ let stats = $derived(data.stats);
+
+ let isUp = $derived(svc?.last_heartbeat?.is_up ?? null);
+ let expandedError: number | null = $state(null);
+
+ let chartPad = { t: 8, b: 20, l: 40, r: 12 };
+ let chartW = $derived(Math.max(history.length * 32, 300));
+ let chartH = 200;
+ let plotW = $derived(chartW - chartPad.l - chartPad.r);
+ let plotH = $derived(chartH - chartPad.t - chartPad.b);
+ let chartMax = $derived(Math.max(...history.map((h: any) => h.response_time_ms), 100));
+
+ let dots = $derived(
+ history.map((h: any, i: number) => {
+ const x = history.length === 1
+ ? chartPad.l + plotW / 2
+ : chartPad.l + (i / (history.length - 1)) * plotW;
+ const y = chartPad.t + plotH - ((h.response_time_ms - 0) / (chartMax || 1)) * plotH;
+ return { x, y, ...h };
+ })
+ );
+
+ let yTicks = $derived.by(() => {
+ if (chartMax <= 0) return [];
+ const n = 4;
+ return Array.from({ length: n + 1 }, (_, i) => {
+ const val = (chartMax / n) * i;
+ const y = chartPad.t + plotH - (val / chartMax) * plotH;
+ return { val: Math.round(val), y };
+ });
+ });
+</script>
+
+<svelte:head>
+ <title>{svc?.name ?? 'Status'} — YAUM</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl">
+ {#if !svc}
+ <div class="rounded-xl border border-[var(--border-color)] p-12 text-center">
+ <div class="mx-auto mb-4 h-16 w-16 rounded-2xl bg-[var(--bg-secondary)] p-4">
+ <svg class="h-full w-full text-[var(--text-muted)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z" />
+ </svg>
+ </div>
+ <p class="text-sm font-medium text-[var(--text-secondary)]">Serviço não encontrado</p>
+ <p class="mt-1 text-xs text-[var(--text-muted)]">
+ <a href="/" class="underline transition-colors hover:text-white">← Voltar ao início</a>
+ </p>
+ </div>
+ {:else}
+ <!-- Back link -->
+ <div class="mb-6">
+ <a href="/" class="text-xs text-[var(--text-muted)] underline transition-colors hover:text-white">
+ ← Voltar ao Dashboard
+ </a>
+ </div>
+
+ <!-- Hero status -->
+ <div
+ class="mb-8 overflow-hidden rounded-2xl border p-8 text-center transition-all"
+ style="border-color: {isUp === true ? 'rgba(34,240,106,0.3)' : isUp === false ? 'rgba(255,64,96,0.3)' : 'var(--border-color)'}; background-color: {isUp === true ? 'rgba(34,240,106,0.04)' : isUp === false ? 'rgba(255,64,96,0.04)' : 'var(--bg-card)'}"
+ >
+ {#if isUp === null}
+ <div class="mb-2 h-4 w-4 animate-pulse rounded-full bg-[var(--text-muted)] mx-auto"></div>
+ <p class="text-sm font-semibold uppercase tracking-widest text-[var(--text-muted)]">Aguardando verificação</p>
+ {:else}
+ <div
+ class="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full"
+ style="background-color: {isUp ? 'rgba(34,240,106,0.15)' : 'rgba(255,64,96,0.15)'}"
+ >
+ {#if isUp}
+ <svg class="h-8 w-8" style="color: var(--green)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
+ </svg>
+ {:else}
+ <svg class="h-8 w-8" style="color: var(--red)" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
+ </svg>
+ {/if}
+ </div>
+ <h1
+ class="text-3xl font-bold tracking-tight"
+ style="color: {isUp ? 'var(--green)' : 'var(--red)'}"
+ >
+ {isUp ? 'Operando Normalmente' : 'Fora do Ar'}
+ </h1>
+ {/if}
+
+ <div class="mt-4">
+ <h2 class="text-xl font-semibold text-white">{svc.name}</h2>
+ <p class="mt-1 font-mono text-sm text-[var(--text-secondary)]">{svc.url}</p>
+ </div>
+
+ {#if stats}
+ <div class="mt-6 inline-flex items-center gap-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-secondary)]/30 px-6 py-3">
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">SLA 30 dias</p>
+ <p
+ class="text-xl font-bold tabular-nums"
+ style="color: {stats.uptime_30d >= 99 ? 'var(--green)' : stats.uptime_30d >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {stats.uptime_30d.toFixed(2)}%
+ </p>
+ </div>
+ <div class="h-8 w-px bg-[var(--border-color)]"></div>
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Latência Média</p>
+ <p class="text-xl font-bold tabular-nums text-white">
+ {stats.avg_response_ms_30d.toFixed(0)}<span class="text-sm font-normal text-[var(--text-secondary)]">ms</span>
+ </p>
+ </div>
+ <div class="h-8 w-px bg-[var(--border-color)]"></div>
+ <div class="text-center">
+ <p class="text-xs font-medium uppercase tracking-wider text-[var(--text-muted)]">Total de Checks</p>
+ <p class="text-xl font-bold tabular-nums text-white">{stats.total_checks_30d}</p>
+ </div>
+ </div>
+ {/if}
+ </div>
+
+ <div class="grid gap-6 lg:grid-cols-5">
+ <!-- SLA Breakdown -->
+ {#if stats}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5 lg:col-span-2">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Uptime
+ </h2>
+ <div class="space-y-2.5">
+ {#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}
+ <div class="rounded-lg border border-[var(--border-color)] bg-[var(--bg-secondary)]/20 p-3">
+ <div class="mb-1 flex items-center justify-between">
+ <span class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">{item.label}</span>
+ <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.checks} checks</span>
+ </div>
+ <div class="flex items-baseline gap-3">
+ <span
+ class="text-base font-bold tabular-nums"
+ style="color: {item.uptime >= 99 ? 'var(--green)' : item.uptime >= 95 ? '#facc15' : 'var(--red)'}"
+ >
+ {item.uptime.toFixed(2)}%
+ </span>
+ <span class="font-mono text-[10px] text-[var(--text-muted)]">{item.avg.toFixed(0)}ms méd.</span>
+ </div>
+ </div>
+ {/each}
+ </div>
+ </div>
+ {/if}
+
+ <!-- Latency chart -->
+ <div
+ class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5"
+ class:lg:col-span-3={!!stats}
+ class:lg:col-span-5={!stats}
+ >
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Tempo de Resposta (ms)
+ </h2>
+ <div class="relative h-48">
+ {#if history.length === 0}
+ <div class="flex h-full items-center justify-center text-xs text-[var(--text-muted)]">
+ Sem dados ainda
+ </div>
+ {:else if history.length === 1}
+ <div class="flex h-full flex-col items-center justify-center gap-1">
+ <span
+ class="text-3xl font-bold tabular-nums"
+ style="color: {history[0].is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {history[0].response_time_ms}
+ <span class="text-base font-normal text-[var(--text-secondary)]">ms</span>
+ </span>
+ <span class="text-xs text-[var(--text-muted)]">
+ {history[0].is_up ? 'Online' : 'Offline'} — {history[0].status_code || 'timeout'}
+ </span>
+ </div>
+ {:else}
+ <svg class="h-full w-full" viewBox="0 0 {chartW} {chartH}" preserveAspectRatio="none">
+ {#each yTicks as tick}
+ <line
+ x1={chartPad.l} y1={tick.y} x2={chartW - chartPad.r} y2={tick.y}
+ stroke="var(--border-color)" stroke-width="1" stroke-dasharray="3,3"
+ />
+ <text x={chartPad.l - 6} y={tick.y + 3} text-anchor="end" fill="var(--text-muted)" font-size="9">{tick.val}</text>
+ {/each}
+ <polyline
+ points={dots.map((d: any) => `${d.x},${d.y}`).join(' ')}
+ fill="none" stroke="var(--green)" stroke-width="2"
+ stroke-linecap="round" stroke-linejoin="round"
+ />
+ {#each dots as d}
+ <circle cx={d.x} cy={d.y} r="3.5" fill={d.is_up ? 'var(--green)' : 'var(--red)'} />
+ {/each}
+ </svg>
+ {/if}
+ </div>
+ <div class="mt-2 flex justify-between text-[10px] text-[var(--text-muted)]">
+ <span>{history[0]?.tested_at ? new Date(history[0].tested_at).toLocaleTimeString() : ''}</span>
+ <span>{history[history.length - 1]?.tested_at ? new Date(history[history.length - 1].tested_at).toLocaleTimeString() : ''}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- History table -->
+ <div class="mt-6 rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-5">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Histórico de Verificações
+ </h2>
+ {#if history.length === 0}
+ <p class="py-8 text-center text-xs text-[var(--text-muted)]">Nenhuma verificação registrada</p>
+ {:else}
+ <div class="overflow-x-auto">
+ <table class="w-full text-left text-xs">
+ <thead>
+ <tr class="border-b border-[var(--border-color)]">
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Horário</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Status</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Código</th>
+ <th class="pb-2 pr-4 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Latência</th>
+ <th class="pb-2 text-[10px] font-semibold uppercase tracking-wider text-[var(--text-muted)]">Erro</th>
+ </tr>
+ </thead>
+ <tbody>
+ {#each [...history].reverse() as h}
+ <tr class="border-b border-[var(--border-color)]/50 transition-colors hover:bg-[var(--bg-secondary)]/20">
+ <td class="py-2 pr-4 font-mono text-[var(--text-muted)]">
+ {new Date(h.tested_at).toLocaleString('pt-BR')}
+ </td>
+ <td class="py-2 pr-4">
+ <span
+ class="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] font-semibold"
+ style="background-color: {h.is_up ? 'rgba(34,240,106,0.1)' : 'rgba(255,64,96,0.1)'}; color: {h.is_up ? 'var(--green)' : 'var(--red)'}"
+ >
+ {h.is_up ? 'UP' : 'DOWN'}
+ </span>
+ </td>
+ <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]">
+ {h.status_code > 0 ? h.status_code : '—'}
+ </td>
+ <td class="py-2 pr-4 font-mono text-[var(--text-secondary)]">
+ {h.response_time_ms}ms
+ </td>
+ <td class="py-2 font-mono text-[var(--text-muted)]">
+ {#if h.error_message}
+ <button
+ onclick={() => (expandedError = expandedError === h.id ? null : h.id)}
+ class="text-[10px] text-[var(--red)] underline underline-offset-2 transition-colors hover:opacity-70"
+ >
+ {expandedError === h.id ? '▲ erro' : '▼ erro'}
+ </button>
+ {#if expandedError === h.id}
+ <pre class="mt-1 break-all text-[11px] leading-relaxed">{(h as any).error_message}</pre>
+ {/if}
+ {:else}
+ —
+ {/if}
+ </td>
+ </tr>
+ {/each}
+ </tbody>
+ </table>
+ </div>
+ {/if}
+ </div>
+ {/if}
+</div>
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 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
+ <circle cx="16" cy="16" r="15" fill="#0f1117" stroke="#22f06a" stroke-width="2"/>
+ <circle cx="16" cy="16" r="6" fill="#22f06a" opacity="0.9"/>
+ <circle cx="16" cy="16" r="2" fill="#0f1117"/>
+</svg>
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'
+ }
+ }
+});