diff options
| author | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 18:57:37 +0000 |
|---|---|---|
| committer | zwlucas <lucas.fariamo08@gmail.com> | 2026-05-29 18:57:37 +0000 |
| commit | 6cc0bfd1d79074df790272b8091b1f0226d14283 (patch) | |
| tree | cab18b0f708cf3b0eaeeeb98e2cf81459365b33e | |
| download | yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.tar.gz yaum-6cc0bfd1d79074df790272b8091b1f0226d14283.zip | |
feat: upload project
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
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 Binary files differnew file mode 100644 index 0000000..c578706 --- /dev/null +++ b/backend/yaum-server.exe 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' + } + } +}); |