From 77b0244beb11ebf87e13bffd255036705e15eb29 Mon Sep 17 00:00:00 2001 From: Mohammed Sayed <49954985+cersho@users.noreply.github.com> Date: Sat, 21 Mar 2026 14:12:26 +0200 Subject: [PATCH 1/2] refactor: Add UI functionality for deleting connections and pagination for runs - Implemented integration tests for deleting connections from the UI, ensuring successful deletion and confirmation messages. - Added pagination support for runs in the UI, allowing users to navigate between pages of run records. - Enhanced the HTML templates with new styles for action buttons and menus, improving the user experience. - Introduced confirmation dialogs for destructive actions like deleting connections and notifications, ensuring user awareness of irreversible actions. - Updated JavaScript functions to handle new UI interactions and maintain state across different forms and dialogs. --- internal/repository/repository.go | 16 + internal/ui/handler.go | 404 ++++++++++++++++++- internal/ui/handler_integration_test.go | 67 ++++ internal/ui/templates/app.html | 504 ++++++++++++++++++++++-- 4 files changed, 944 insertions(+), 47 deletions(-) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 50642c6..aa2e658 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -1232,6 +1232,22 @@ func (r *Repository) ListRecentRuns(ctx context.Context, limit int) ([]models.Ba return items, err } +func (r *Repository) ListRecentRunsPage(ctx context.Context, offset, limit int) ([]models.BackupRun, error) { + if limit <= 0 { + limit = 50 + } + if offset < 0 { + offset = 0 + } + var items []models.BackupRun + err := r.db.WithContext(ctx). + Order("started_at desc"). + Offset(offset). + Limit(limit). + Find(&items).Error + return items, err +} + func (r *Repository) ListRunsSince(ctx context.Context, since time.Time) ([]models.BackupRun, error) { var items []models.BackupRun err := r.db.WithContext(ctx). diff --git a/internal/ui/handler.go b/internal/ui/handler.go index f493274..98aed2b 100644 --- a/internal/ui/handler.go +++ b/internal/ui/handler.go @@ -53,6 +53,10 @@ type pageData struct { Runs []models.BackupRun RunItems []runListItem RunLog []runLogLine + RunsPage int + RunsPageSize int + RunsHasPrev bool + RunsHasNext bool SelectedRunID string SelectedRun runListItem HasSelectedRun bool @@ -110,6 +114,15 @@ type runLogLine struct { func NewHandler(repo *repository.Repository, scheduler *scheduler.Scheduler, cfg config.Config) *Handler { tmpl := template.Must(template.New("app").Funcs(template.FuncMap{ + "inc": func(v int) int { + return v + 1 + }, + "dec": func(v int) int { + if v <= 1 { + return 1 + } + return v - 1 + }, "isEnabled": func(v bool) string { if v { return "enabled" @@ -210,21 +223,27 @@ func (h *Handler) Router() http.Handler { r.Get("/connections", h.connectionsPage) r.Get("/connections/section", h.connectionsSection) r.Post("/connections", h.createConnection) + r.Post("/connections/{id}/update", h.updateConnection) + r.Post("/connections/{id}/delete", h.deleteConnection) r.Post("/connections/test", h.testConnection) r.Post("/connections/test-all", h.testAllConnections) r.Post("/connections/{id}/test", h.testSavedConnection) r.Get("/remotes", h.remotesPage) r.Get("/remotes/section", h.remotesSection) r.Post("/remotes", h.createRemote) + r.Post("/remotes/{id}/update", h.updateRemote) + r.Post("/remotes/{id}/delete", h.deleteRemote) r.Get("/notifications", h.notificationsPage) r.Get("/notifications/section", h.notificationsSection) r.Post("/notifications", h.createNotification) + r.Post("/notifications/{id}/update", h.updateNotification) r.Post("/notifications/{id}/test", h.testNotification) r.Post("/notifications/{id}/delete", h.deleteNotification) r.Post("/notifications/bindings", h.saveNotificationBindings) r.Get("/health-checks", h.healthChecksPage) r.Get("/health-checks/section", h.healthChecksSection) r.Post("/health-checks/{id}/settings", h.updateHealthCheckSettings) + r.Post("/health-checks/{id}/archive", h.archiveHealthCheck) r.Post("/health-checks/{id}/run", h.runHealthCheckNow) r.Post("/health-checks/bindings", h.saveHealthCheckBindings) r.Get("/backups", h.backupsPage) @@ -232,6 +251,7 @@ func (h *Handler) Router() http.Handler { r.Get("/backups/section", h.backupsSection) r.Get("/schedules/section", h.backupsSection) r.Post("/backups", h.createBackup) + r.Post("/backups/{id}/update", h.updateBackup) r.Post("/backups/{id}/run", h.runBackupNow) r.Post("/backups/{id}/toggle", h.toggleBackup) r.Post("/backups/{id}/delete", h.deleteBackup) @@ -365,7 +385,11 @@ func (h *Handler) backupRuns(w http.ResponseWriter, r *http.Request) { } func (h *Handler) runsPage(w http.ResponseWriter, r *http.Request) { - data, err := h.loadRunsData(r.Context(), strings.TrimSpace(r.URL.Query().Get("run_id"))) + data, err := h.loadRunsData( + r.Context(), + strings.TrimSpace(r.URL.Query().Get("run_id")), + parsePositiveInt(r.URL.Query().Get("page"), 1), + ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -375,7 +399,11 @@ func (h *Handler) runsPage(w http.ResponseWriter, r *http.Request) { } func (h *Handler) runsSection(w http.ResponseWriter, r *http.Request) { - data, err := h.loadRunsData(r.Context(), strings.TrimSpace(r.URL.Query().Get("run_id"))) + data, err := h.loadRunsData( + r.Context(), + strings.TrimSpace(r.URL.Query().Get("run_id")), + parsePositiveInt(r.URL.Query().Get("page"), 1), + ) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return @@ -383,7 +411,13 @@ func (h *Handler) runsSection(w http.ResponseWriter, r *http.Request) { h.render(w, "runs_section", data) } -func (h *Handler) loadRunsData(ctx context.Context, selectedRunID string) (pageData, error) { +func (h *Handler) loadRunsData(ctx context.Context, selectedRunID string, page int) (pageData, error) { + if page < 1 { + page = 1 + } + const pageSize = 15 + offset := (page - 1) * pageSize + backups, err := h.repo.ListBackups(ctx) if err != nil { return pageData{}, err @@ -395,11 +429,16 @@ func (h *Handler) loadRunsData(ctx context.Context, selectedRunID string) (pageD backupByID[item.ID] = item } - runs, err := h.repo.ListRecentRuns(ctx, 60) + runs, err := h.repo.ListRecentRunsPage(ctx, offset, pageSize+1) if err != nil { return pageData{}, err } + hasNext := len(runs) > pageSize + if hasNext { + runs = runs[:pageSize] + } + runItems := make([]runListItem, 0, len(runs)) for _, run := range runs { backup := backupByID[run.BackupID] @@ -416,7 +455,14 @@ func (h *Handler) loadRunsData(ctx context.Context, selectedRunID string) (pageD }) } - data := pageData{Backups: backups, RunItems: runItems} + data := pageData{ + Backups: backups, + RunItems: runItems, + RunsPage: page, + RunsPageSize: pageSize, + RunsHasPrev: page > 1, + RunsHasNext: hasNext, + } if len(runItems) == 0 { return data, nil } @@ -438,6 +484,96 @@ func (h *Handler) loadRunsData(ctx context.Context, selectedRunID string) (pageD return data, nil } +func (h *Handler) updateConnection(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderConnections(w, "", "connection id is required") + return + } + if err := r.ParseForm(); err != nil { + h.renderConnections(w, "", "Invalid form data") + return + } + + port, err := parsePort(r.FormValue("port")) + if err != nil { + h.renderConnections(w, "", err.Error()) + return + } + + item := models.Connection{ + Name: strings.TrimSpace(r.FormValue("name")), + Type: strings.ToLower(strings.TrimSpace(r.FormValue("type"))), + Host: strings.TrimSpace(r.FormValue("host")), + Port: port, + Database: strings.TrimSpace(r.FormValue("database")), + Username: strings.TrimSpace(r.FormValue("username")), + Password: r.FormValue("password"), + SSLMode: strings.TrimSpace(r.FormValue("ssl_mode")), + } + + if item.Name == "" || item.Type == "" || item.Host == "" { + h.renderConnections(w, "", "name, type, and host are required") + return + } + if item.Type != "postgres" && item.Type != "postgresql" && item.Type != "mysql" && item.Type != "convex" && item.Type != "d1" { + h.renderConnections(w, "", "type must be mysql, postgres, convex, or d1") + return + } + if item.Type == "d1" && item.Database == "" { + h.renderConnections(w, "", "database is required for d1") + return + } + if item.Type != "convex" && item.Type != "d1" && (item.Database == "" || item.Username == "") { + h.renderConnections(w, "", "database and username are required for mysql/postgres") + return + } + if item.Port == 0 { + switch item.Type { + case "postgres", "postgresql": + item.Port = 5432 + case "mysql": + item.Port = 3306 + } + } + if item.Type == "convex" { + if item.Database == "" { + item.Database = "convex" + } + if item.Username == "" { + item.Username = "convex" + } + item.SSLMode = "" + } + if item.Type == "d1" { + if item.Username == "" { + item.Username = "d1" + } + item.Port = 0 + item.SSLMode = "" + } + + if _, err := h.repo.UpdateConnection(r.Context(), id, &item); err != nil { + h.renderConnections(w, "", err.Error()) + return + } + + h.renderConnections(w, "Connection updated", "") +} + +func (h *Handler) deleteConnection(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderConnections(w, "", "connection id is required") + return + } + if err := h.repo.DeleteConnection(r.Context(), id); err != nil { + h.renderConnections(w, "", "connection not found") + return + } + h.renderConnections(w, "Connection deleted", "") +} + func (h *Handler) downloadRun(w http.ResponseWriter, r *http.Request) { runID := strings.TrimSpace(chi.URLParam(r, "id")) if runID == "" { @@ -815,6 +951,63 @@ func (h *Handler) createRemote(w http.ResponseWriter, r *http.Request) { h.renderRemotes(w, "Remote created", "") } +func (h *Handler) updateRemote(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderRemotes(w, "", "remote id is required") + return + } + if err := r.ParseForm(); err != nil { + h.renderRemotes(w, "", "Invalid form data") + return + } + + provider := strings.ToLower(strings.TrimSpace(r.FormValue("provider"))) + if provider == "" { + provider = "s3" + } + + item := models.Remote{ + Name: strings.TrimSpace(r.FormValue("name")), + Provider: provider, + Bucket: strings.TrimSpace(r.FormValue("bucket")), + Region: strings.TrimSpace(r.FormValue("region")), + Endpoint: strings.TrimSpace(r.FormValue("endpoint")), + AccessKey: strings.TrimSpace(r.FormValue("access_key")), + SecretKey: strings.TrimSpace(r.FormValue("secret_key")), + PathPrefix: strings.TrimSpace(r.FormValue("path_prefix")), + } + + if item.Name == "" || item.Bucket == "" || item.Region == "" { + h.renderRemotes(w, "", "name, bucket, and region are required") + return + } + if item.Provider != "s3" { + h.renderRemotes(w, "", "provider must be s3") + return + } + + if _, err := h.repo.UpdateRemote(r.Context(), id, &item); err != nil { + h.renderRemotes(w, "", err.Error()) + return + } + + h.renderRemotes(w, "Remote updated", "") +} + +func (h *Handler) deleteRemote(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderRemotes(w, "", "remote id is required") + return + } + if err := h.repo.DeleteRemote(r.Context(), id); err != nil { + h.renderRemotes(w, "", "remote not found") + return + } + h.renderRemotes(w, "Remote deleted", "") +} + func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderNotifications(w, "", "Invalid form data", strings.TrimSpace(r.FormValue("backup_id"))) @@ -864,6 +1057,68 @@ func (h *Handler) createNotification(w http.ResponseWriter, r *http.Request) { h.renderNotifications(w, "Notification destination created", "", selectedBackupID) } +func (h *Handler) updateNotification(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderNotifications(w, "", "notification id is required", strings.TrimSpace(r.FormValue("backup_id"))) + return + } + if err := r.ParseForm(); err != nil { + h.renderNotifications(w, "", "Invalid form data", strings.TrimSpace(r.FormValue("backup_id"))) + return + } + + selectedBackupID := strings.TrimSpace(r.FormValue("backup_id")) + notificationType := strings.ToLower(strings.TrimSpace(r.FormValue("type"))) + enabled := r.FormValue("enabled") == "on" || r.FormValue("enabled") == "true" + + name := strings.TrimSpace(r.FormValue("name")) + discordWebhookURL := strings.TrimSpace(r.FormValue("discord_webhook_url")) + smtpHost := strings.TrimSpace(r.FormValue("smtp_host")) + smtpUsername := strings.TrimSpace(r.FormValue("smtp_username")) + smtpPassword := r.FormValue("smtp_password") + smtpFrom := strings.TrimSpace(r.FormValue("smtp_from")) + smtpTo := strings.TrimSpace(r.FormValue("smtp_to")) + smtpSecurity := strings.ToLower(strings.TrimSpace(r.FormValue("smtp_security"))) + + smtpPort := 587 + portRaw := strings.TrimSpace(r.FormValue("smtp_port")) + portMode := strings.TrimSpace(r.FormValue("smtp_port_mode")) + if portMode != "" && portMode != "other" { + portRaw = portMode + } + if portMode == "other" { + portRaw = strings.TrimSpace(r.FormValue("smtp_port_custom")) + } + if portRaw != "" { + port, err := strconv.Atoi(portRaw) + if err != nil || port <= 0 || port > 65535 { + h.renderNotifications(w, "", "smtp_port must be between 1 and 65535", selectedBackupID) + return + } + smtpPort = port + } + + if _, err := h.repo.UpdateNotification(r.Context(), id, repository.NotificationPatch{ + Name: stringPtr(name), + Type: stringPtr(notificationType), + Enabled: boolPtr(enabled), + DiscordWebhookURL: stringPtr(discordWebhookURL), + SMTPHost: stringPtr(smtpHost), + SMTPPort: intPtr(smtpPort), + SMTPUsername: stringPtr(smtpUsername), + SMTPPassword: stringPtr(smtpPassword), + SMTPFrom: stringPtr(smtpFrom), + SMTPTo: stringPtr(smtpTo), + SMTPSecurity: stringPtr(smtpSecurity), + }); err != nil { + h.renderNotifications(w, "", err.Error(), selectedBackupID) + return + } + + h.renderNotifications(w, "Notification destination updated", "", selectedBackupID) +} + func (h *Handler) testNotification(w http.ResponseWriter, r *http.Request) { id := strings.TrimSpace(chi.URLParam(r, "id")) selectedBackupID := strings.TrimSpace(r.URL.Query().Get("backup_id")) @@ -1109,6 +1364,21 @@ func (h *Handler) saveHealthCheckBindings(w http.ResponseWriter, r *http.Request h.renderHealthChecks(w, "Health check notifications updated", "", selectedHealthCheckID) } +func (h *Handler) archiveHealthCheck(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + selectedHealthCheckID := strings.TrimSpace(r.URL.Query().Get("health_check_id")) + if id == "" { + h.renderHealthChecks(w, "", "health check id is required", selectedHealthCheckID) + return + } + enabled := false + if _, err := h.repo.UpdateHealthCheck(r.Context(), id, repository.HealthCheckPatch{Enabled: &enabled}); err != nil { + h.renderHealthChecks(w, "", err.Error(), selectedHealthCheckID) + return + } + h.renderHealthChecks(w, "Health check archived", "", selectedHealthCheckID) +} + func (h *Handler) createBackup(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { h.renderBackups(w, "", "Invalid form data") @@ -1203,6 +1473,110 @@ func (h *Handler) createBackup(w http.ResponseWriter, r *http.Request) { h.renderBackups(w, "Backup created", "") } +func (h *Handler) updateBackup(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(chi.URLParam(r, "id")) + if id == "" { + h.renderBackups(w, "", "backup id is required") + return + } + if err := r.ParseForm(); err != nil { + h.renderBackups(w, "", "Invalid form data") + return + } + + retention, err := parseRetention(r.FormValue("retention_days")) + if err != nil { + h.renderBackups(w, "", err.Error()) + return + } + + targetType := strings.TrimSpace(r.FormValue("target_type")) + compression := strings.TrimSpace(r.FormValue("compression")) + if compression == "" { + compression = "gzip" + } + includeFileStorage := r.FormValue("include_file_storage") == "true" || r.FormValue("include_file_storage") == "on" + if targetType != "local" && targetType != "s3" { + h.renderBackups(w, "", "target_type must be local or s3") + return + } + + cronExpr := strings.TrimSpace(r.FormValue("cron_expr")) + if _, err := cron.ParseStandard(cronExpr); err != nil { + h.renderBackups(w, "", "invalid cron_expr") + return + } + + timezone := strings.TrimSpace(r.FormValue("timezone")) + if timezone == "" { + timezone = "UTC" + } + + item := models.Backup{ + Name: strings.TrimSpace(r.FormValue("name")), + ConnectionID: strings.TrimSpace(r.FormValue("connection_id")), + CronExpr: cronExpr, + Timezone: timezone, + TargetType: targetType, + LocalPath: strings.TrimSpace(r.FormValue("local_path")), + RetentionDays: retention, + Compression: compression, + IncludeFileStorage: includeFileStorage, + } + + if item.Name == "" || item.ConnectionID == "" { + h.renderBackups(w, "", "name and connection are required") + return + } + conn, err := h.repo.GetConnection(r.Context(), item.ConnectionID) + if err != nil { + h.renderBackups(w, "", "connection_id not found") + return + } + if strings.EqualFold(conn.Type, "convex") { + item.Compression = "none" + } else { + item.IncludeFileStorage = false + if item.Compression != "gzip" && item.Compression != "none" { + h.renderBackups(w, "", "compression must be gzip or none") + return + } + } + if item.TargetType == "local" && item.LocalPath == "" { + h.renderBackups(w, "", "local_path is required for local target") + return + } + if item.TargetType == "s3" { + remoteID := strings.TrimSpace(r.FormValue("remote_id")) + if remoteID == "" { + h.renderBackups(w, "", "remote_id is required for s3 target") + return + } + if _, err := h.repo.GetRemote(r.Context(), remoteID); err != nil { + h.renderBackups(w, "", "remote_id not found") + return + } + item.RemoteID = &remoteID + } + + updated, err := h.repo.UpdateBackup(r.Context(), id, &item) + if err != nil { + h.renderBackups(w, "", err.Error()) + return + } + + if updated.Enabled { + if err := h.scheduler.Upsert(r.Context(), id); err != nil { + h.renderBackups(w, "", err.Error()) + return + } + } else { + h.scheduler.Delete(id) + } + + h.renderBackups(w, "Backup updated", "") +} + func (h *Handler) runBackupNow(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") if _, err := h.repo.GetBackup(r.Context(), id); err != nil { @@ -1609,6 +1983,26 @@ func parseRetention(value string) (int, error) { return n, nil } +func parsePositiveInt(value string, fallback int) int { + n, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil || n <= 0 { + return fallback + } + return n +} + +func stringPtr(v string) *string { + return &v +} + +func boolPtr(v bool) *bool { + return &v +} + +func intPtr(v int) *int { + return &v +} + func redactConnections(items []models.Connection) { for i := range items { items[i].Password = "" diff --git a/internal/ui/handler_integration_test.go b/internal/ui/handler_integration_test.go index a55a961..c69946d 100644 --- a/internal/ui/handler_integration_test.go +++ b/internal/ui/handler_integration_test.go @@ -2,6 +2,7 @@ package ui_test import ( "context" + "fmt" "io" "net/http" "net/http/httptest" @@ -268,3 +269,69 @@ func TestNotificationTestFromUI(t *testing.T) { t.Fatal("expected webhook to be called") } } + +func TestDeleteConnectionFromUI(t *testing.T) { + stack := testutil.NewStack(t) + h := ui.NewHandler(stack.Repo, stack.Scheduler, stack.Config) + server := httptest.NewServer(h.Router()) + t.Cleanup(server.Close) + + conn := testutil.MustCreateConnection(t, stack.Repo, "ui-delete-connection") + + res, err := http.Post(server.URL+"/connections/"+conn.ID+"/delete", "application/x-www-form-urlencoded", strings.NewReader("")) + if err != nil { + t.Fatalf("post delete connection: %v", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if !strings.Contains(string(body), "Connection deleted") { + t.Fatalf("expected delete message, got %q", string(body)) + } + + items, err := stack.Repo.ListConnections(context.Background()) + if err != nil { + t.Fatalf("list connections: %v", err) + } + if len(items) != 0 { + t.Fatalf("expected 0 connections after delete, got %d", len(items)) + } +} + +func TestRunsPaginationFromUI(t *testing.T) { + stack := testutil.NewStack(t) + h := ui.NewHandler(stack.Repo, stack.Scheduler, stack.Config) + server := httptest.NewServer(h.Router()) + t.Cleanup(server.Close) + + conn := testutil.MustCreateConnection(t, stack.Repo, "ui-runs-paging-connection") + backup := testutil.MustCreateLocalBackup(t, stack.Repo, "ui-runs-paging-backup", conn.ID, t.TempDir(), true) + for i := 0; i < 20; i++ { + testutil.MustCreateFinishedRun(t, stack.Repo, backup.ID, fmt.Sprintf("runs/%d.sql.gz", i)) + } + + res, err := http.Get(server.URL + "/runs/section?page=2") + if err != nil { + t.Fatalf("get runs section page 2: %v", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode != http.StatusOK { + t.Fatalf("expected 200, got %d", res.StatusCode) + } + + body, err := io.ReadAll(res.Body) + if err != nil { + t.Fatalf("read response body: %v", err) + } + if !strings.Contains(string(body), "Page 2") { + t.Fatalf("expected pagination marker, got %q", string(body)) + } +} diff --git a/internal/ui/templates/app.html b/internal/ui/templates/app.html index 0584c63..5172cbe 100644 --- a/internal/ui/templates/app.html +++ b/internal/ui/templates/app.html @@ -410,6 +410,27 @@ .btn-danger:hover { background: rgba(239, 68, 68, 0.2); } .btn-sm { padding: 5px 10px; font-size: 12px; border-radius: var(--radius-sm); } + .icon-btn { + width: 30px; + height: 30px; + padding: 0; + border-radius: var(--radius-sm); + border: 1px solid var(--border-sub); + background: var(--bg-elevated); + color: var(--text-secondary); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + + .icon-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .icon-btn i { width: 14px; height: 14px; } + .flash { margin-bottom: 12px; border-radius: var(--radius-md); @@ -437,6 +458,8 @@ .badge.err { background: var(--red-dim); color: var(--red); } .badge.muted { background: var(--bg-overlay); color: var(--text-secondary); border: 1px solid var(--border-sub); } .badge.info { background: var(--accent-dim); color: var(--accent); } + .badge.enabled { background: var(--green-dim); color: var(--green); } + .badge.disabled { background: var(--red-dim); color: var(--red); } .conn-grid { display: grid; @@ -492,7 +515,7 @@ .table-wrap { border: 1px solid var(--border-sub); border-radius: var(--radius-lg); - overflow: hidden; + overflow: visible; background: var(--bg-surface); } @@ -524,6 +547,81 @@ tbody tr:last-child td { border-bottom: none; } tbody tr:hover { background: var(--bg-hover); } + .row-actions { + display: flex; + justify-content: flex-end; + position: relative; + z-index: 2; + } + + .actions-menu { + position: relative; + } + + .actions-menu[open] { + z-index: 40; + } + + .actions-menu summary { + list-style: none; + cursor: pointer; + } + + .actions-menu summary::-webkit-details-marker { + display: none; + } + + .actions-pop { + position: absolute; + right: 0; + top: calc(100% + 6px); + min-width: 150px; + background: var(--bg-elevated); + border: 1px solid var(--border-sub); + border-radius: var(--radius-sm); + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.35); + padding: 6px; + display: grid; + gap: 4px; + z-index: 30; + } + + .menu-item { + width: 100%; + border: none; + background: transparent; + color: var(--text-secondary); + text-decoration: none; + border-radius: var(--radius-sm); + font-size: 12px; + font-family: var(--font-ui); + text-align: left; + padding: 7px 8px; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 7px; + } + + .menu-item i { + width: 13px; + height: 13px; + } + + .menu-item:hover { + background: var(--bg-hover); + color: var(--text-primary); + } + + .menu-item.danger { + color: #fca5a5; + } + + .menu-item.danger:hover { + background: var(--red-dim); + color: #fecaca; + } + .mono { font-family: var(--font-mono); font-size: 12px; color: var(--text-secondary); } .dialog-modal { @@ -820,6 +918,19 @@ {{if eq .CurrentPage "notifications"}}{{template "notifications_dialog" .}}{{end}} {{if eq .CurrentPage "schedules"}}{{template "schedule_dialog" .}}{{end}} + +
+ Confirm action + +
+
+
This action cannot be undone.
+
+
+ + +
+