From 563339ffecca423bc5a3c2cd7b13e395185ba980 Mon Sep 17 00:00:00 2001 From: Fraser Isbester Date: Mon, 6 Jul 2026 17:20:17 -0700 Subject: [PATCH] feat(tui): manage rules directly from the TUI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an integrated rules editor to the terminal UI (issue #3). In the rules view: `n` creates a rule, `e` edits the selected rule, `d` deletes it (with confirmation), and Space toggles enabled/disabled. Rules are authored via a tview form with per-resource CEL autocomplete and live validation (compiles against the resource's CEL env and requires a bool result). Edits are persisted to ~/.config/tusk/config.yaml and the running engine is hot-swapped so changes take effect on the next tick. Persistence is comment-preserving: SaveProfileRules edits only the profile's `rules:` node in the YAML tree, so comments, key order, and every other field (other profiles, connection URLs) are left untouched — and env-derived defaults (port, refresh_interval) and passwords are never written. Output is 2-space indented to match the documented style. RuleConfig gains omitempty on optional fields so saved rules stay clean. Hardening over the prototype: save/delete/toggle surface errors via a modal and only swap on success; duplicate rule names are rejected; the rules view auto-selects the first row so edit/delete/toggle work on entry; and editing is guarded when there is no config-backed profile (e.g. the positional-URL "cli" path) with a clear message instead of a no-op or crash. SetEngine was added to the rules and violations views for the zero-rules→engine bootstrap. Tests: comment/secret/default-omission round-trips for SaveProfileRules. Verified end-to-end against local Postgres (create, toggle, guard, and bootstrap from a profile with no rules). Closes #3 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 4 + cmd/tusk/main.go | 2 +- internal/config/config.go | 126 ++++++++++++ internal/config/config_save_test.go | 132 +++++++++++++ internal/rules/config.go | 11 +- internal/tui/app.go | 271 +++++++++++++++++++++++++- internal/tui/views/rule_form.go | 289 ++++++++++++++++++++++++++++ internal/tui/views/rules.go | 14 +- internal/tui/views/violations.go | 8 + 9 files changed, 848 insertions(+), 9 deletions(-) create mode 100644 internal/config/config_save_test.go create mode 100644 internal/tui/views/rule_form.go diff --git a/README.md b/README.md index eb05c45..d875f24 100644 --- a/README.md +++ b/README.md @@ -128,6 +128,10 @@ profiles: | `Shift+letter` | Sort by column | | `c` | Cancel query (in query detail) | | `t` | Terminate backend (in query/transaction detail) | +| `n` | New rule (in rules view) | +| `e` | Edit selected rule (in rules view) | +| `d` | Delete selected rule (in rules view) | +| `Space` | Toggle rule enabled/disabled (in rules view) | | `q` | Quit | ## Development diff --git a/cmd/tusk/main.go b/cmd/tusk/main.go index 7c39c68..0f34579 100644 --- a/cmd/tusk/main.go +++ b/cmd/tusk/main.go @@ -77,7 +77,7 @@ func main() { engine = rules.NewEngine(compiled, database, 5*time.Minute, 1000) } - app := tui.NewApp(database, profileName, profileColor, connUser, readonly, engine) + app := tui.NewApp(database, cfg, profileName, profileColor, connUser, readonly, engine) if err := app.Run(); err != nil { return fmt.Errorf("running tusk: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index 7382643..c00d588 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "bytes" "fmt" "net/url" "os" @@ -183,3 +184,128 @@ func envOrDefault(key, fallback string) string { } return fallback } + +// SaveProfileRules writes ruleConfigs back to the given profile in +// ~/.config/tusk/config.yaml, replacing only that profile's `rules:` sequence. +// +// It edits the file's YAML node tree in place rather than re-marshaling the +// whole Config, so comments, key ordering, and every other field (other +// profiles, connection URLs, env-derived values) are preserved untouched. +// Because only the rules subtree is rewritten, defaults injected by +// applyProfileDefaults (port, refresh_interval) and env-derived passwords are +// never persisted. The write is atomic (temp file + rename). +func SaveProfileRules(profileName string, ruleConfigs []rules.RuleConfig) error { + path, err := configPath() + if err != nil { + return err + } + + // Load the existing document (preserving comments/formatting), or start a + // fresh one if the file does not exist yet. + root := &yaml.Node{Kind: yaml.MappingNode} + if data, readErr := os.ReadFile(path); readErr == nil { //nolint:gosec // path from configPath(), not user input + var doc yaml.Node + if parseErr := yaml.Unmarshal(data, &doc); parseErr != nil { + return fmt.Errorf("parsing config %s: %w", path, parseErr) + } + if len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode { + root = doc.Content[0] + } + } else if !os.IsNotExist(readErr) { + return fmt.Errorf("reading config %s: %w", path, readErr) + } + + // profiles: + profiles := mapValue(root, "profiles") + if profiles == nil || profiles.Kind != yaml.MappingNode { + profiles = &yaml.Node{Kind: yaml.MappingNode} + setMapValue(root, "profiles", profiles) + } + + // profiles.: + profile := mapValue(profiles, profileName) + if profile == nil || profile.Kind != yaml.MappingNode { + profile = &yaml.Node{Kind: yaml.MappingNode} + setMapValue(profiles, profileName, profile) + } + + // profiles..rules: (marshal the configs to a fresh sequence node) + rulesNode, err := toNode(ruleConfigs) + if err != nil { + return fmt.Errorf("encoding rules: %w", err) + } + setMapValue(profile, "rules", rulesNode) + + // default_profile: fill in for a freshly created file so it round-trips. + if mapValue(root, "default_profile") == nil { + setMapValue(root, "default_profile", &yaml.Node{ + Kind: yaml.ScalarNode, Tag: "!!str", Value: profileName, + }) + } + + // Encode with 2-space indent to match the documented config style and keep + // diffs minimal (yaml.Marshal defaults to 4). + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(root); err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + _ = enc.Close() + out := buf.Bytes() + + dir := filepath.Dir(path) + if mkErr := os.MkdirAll(dir, 0o750); mkErr != nil { + return fmt.Errorf("creating config dir: %w", mkErr) + } + tmp := path + ".tmp" + if writeErr := os.WriteFile(tmp, out, 0o600); writeErr != nil { + return fmt.Errorf("writing config: %w", writeErr) + } + return os.Rename(tmp, path) +} + +// mapValue returns the value node for key in a mapping node, or nil if absent. +// Mapping content alternates key, value, key, value, ... +func mapValue(m *yaml.Node, key string) *yaml.Node { + if m == nil || m.Kind != yaml.MappingNode { + return nil + } + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + return m.Content[i+1] + } + } + return nil +} + +// setMapValue replaces the value for key in a mapping node, or appends the +// key/value pair if the key is absent. +func setMapValue(m *yaml.Node, key string, val *yaml.Node) { + for i := 0; i+1 < len(m.Content); i += 2 { + if m.Content[i].Value == key { + m.Content[i+1] = val + return + } + } + m.Content = append(m.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: key}, + val, + ) +} + +// toNode marshals v and returns its top-level node (unwrapping the document). +func toNode(v any) (*yaml.Node, error) { + data, err := yaml.Marshal(v) + if err != nil { + return nil, err + } + var doc yaml.Node + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, err + } + if len(doc.Content) == 0 { + return &yaml.Node{Kind: yaml.SequenceNode}, nil + } + return doc.Content[0], nil +} diff --git a/internal/config/config_save_test.go b/internal/config/config_save_test.go new file mode 100644 index 0000000..ae98fb8 --- /dev/null +++ b/internal/config/config_save_test.go @@ -0,0 +1,132 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/fraser-isbester/tusk/internal/config" + "github.com/fraser-isbester/tusk/internal/rules" +) + +var _ = Describe("SaveProfileRules", func() { + var ( + home string + cfgPath string + ) + + boolPtr := func(b bool) *bool { return &b } + + BeforeEach(func() { + home = GinkgoT().TempDir() + GinkgoT().Setenv("HOME", home) + cfgPath = filepath.Join(home, ".config", "tusk", "config.yaml") + }) + + readConfig := func() string { + data, err := os.ReadFile(cfgPath) //nolint:gosec // test-controlled path under a temp HOME + Expect(err).NotTo(HaveOccurred()) + return string(data) + } + + It("creates the config file when it does not exist", func() { + err := config.SaveProfileRules("dev", []rules.RuleConfig{ + {Name: "r1", Resource: "query", When: "true", Action: "log"}, + }) + Expect(err).NotTo(HaveOccurred()) + + out := readConfig() + Expect(out).To(ContainSubstring("default_profile: dev")) + Expect(out).To(ContainSubstring("name: r1")) + + // Round-trips back through Load. + cfg, err := config.Load() + Expect(err).NotTo(HaveOccurred()) + p, err := cfg.ResolveProfile("dev") + Expect(err).NotTo(HaveOccurred()) + Expect(p.Rules).To(HaveLen(1)) + Expect(p.Rules[0].Name).To(Equal("r1")) + }) + + It("preserves comments and unrelated keys in an existing file", func() { + Expect(os.MkdirAll(filepath.Dir(cfgPath), 0o750)).To(Succeed()) + original := `# my tusk config +default_profile: prod + +profiles: + prod: + # production database + url: "postgres://monitor@db.example.com:5432/prod?sslmode=require" + readonly: true + rules: + - name: old-rule + resource: query + when: "true" + action: log +` + Expect(os.WriteFile(cfgPath, []byte(original), 0o600)).To(Succeed()) + + err := config.SaveProfileRules("prod", []rules.RuleConfig{ + {Name: "new-rule", Resource: "transaction", When: "xact_duration > duration('5m')", Action: "terminate", Cooldown: "5m", DryRun: true}, + }) + Expect(err).NotTo(HaveOccurred()) + + out := readConfig() + Expect(out).To(ContainSubstring("# my tusk config")) + Expect(out).To(ContainSubstring("# production database")) + Expect(out).To(ContainSubstring(`url: "postgres://monitor@db.example.com:5432/prod?sslmode=require"`)) + Expect(out).To(ContainSubstring("readonly: true")) + Expect(out).To(ContainSubstring("name: new-rule")) + Expect(out).NotTo(ContainSubstring("old-rule")) // rules replaced, not appended + }) + + It("omits injected defaults and never writes an env-derived password", func() { + // A password present in the environment must not leak into the file, + // and applyProfileDefaults values (port, refresh_interval) must not be + // baked in — SaveProfileRules only touches the rules subtree. + GinkgoT().Setenv("PGPASSWORD", "supersecret") + + Expect(os.MkdirAll(filepath.Dir(cfgPath), 0o750)).To(Succeed()) + original := "default_profile: dev\nprofiles:\n dev:\n host: localhost\n" + Expect(os.WriteFile(cfgPath, []byte(original), 0o600)).To(Succeed()) + + err := config.SaveProfileRules("dev", []rules.RuleConfig{ + {Name: "r1", Resource: "query", When: "true", Action: "log", Enabled: boolPtr(true)}, + }) + Expect(err).NotTo(HaveOccurred()) + + out := readConfig() + Expect(out).NotTo(ContainSubstring("supersecret")) + Expect(out).NotTo(ContainSubstring("password")) + Expect(out).NotTo(ContainSubstring("port:")) + Expect(out).NotTo(ContainSubstring("refresh_interval")) + // enabled:true was explicitly set, so it is written; false/empty fields are omitted. + Expect(out).To(ContainSubstring("enabled: true")) + Expect(out).NotTo(ContainSubstring("dry_run")) + Expect(out).NotTo(ContainSubstring("cooldown")) + }) + + It("adds, edits, and removes rules across saves", func() { + Expect(config.SaveProfileRules("dev", []rules.RuleConfig{ + {Name: "a", Resource: "query", When: "true", Action: "log"}, + {Name: "b", Resource: "query", When: "false", Action: "log"}, + })).To(Succeed()) + + // Replace with a single edited rule. + Expect(config.SaveProfileRules("dev", []rules.RuleConfig{ + {Name: "a", Resource: "query", When: "duration > duration('1s')", Action: "cancel"}, + })).To(Succeed()) + + cfg, err := config.Load() + Expect(err).NotTo(HaveOccurred()) + p, err := cfg.ResolveProfile("dev") + Expect(err).NotTo(HaveOccurred()) + Expect(p.Rules).To(HaveLen(1)) + Expect(p.Rules[0].Name).To(Equal("a")) + Expect(p.Rules[0].Action).To(Equal("cancel")) + Expect(strings.Contains(p.Rules[0].When, "duration")).To(BeTrue()) + }) +}) diff --git a/internal/rules/config.go b/internal/rules/config.go index bc4b996..0b4eb67 100644 --- a/internal/rules/config.go +++ b/internal/rules/config.go @@ -7,15 +7,18 @@ import ( "github.com/google/cel-go/cel" ) -// RuleConfig is the YAML representation of a rule. +// RuleConfig is the YAML representation of a rule. Optional fields use +// omitempty so rules written back to disk (by the TUI editor) stay clean: +// a nil Enabled, false DryRun, or empty Cooldown are omitted rather than +// serialized as null/false/"". type RuleConfig struct { Name string `yaml:"name"` - Enabled *bool `yaml:"enabled"` - DryRun bool `yaml:"dry_run"` + Enabled *bool `yaml:"enabled,omitempty"` + DryRun bool `yaml:"dry_run,omitempty"` Resource string `yaml:"resource"` When string `yaml:"when"` Action string `yaml:"action"` - Cooldown string `yaml:"cooldown"` + Cooldown string `yaml:"cooldown,omitempty"` } // BuildRules compiles rule configs into executable rules. diff --git a/internal/tui/app.go b/internal/tui/app.go index bf4616a..481ace8 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -10,6 +10,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "github.com/fraser-isbester/tusk/internal/config" "github.com/fraser-isbester/tusk/internal/db" "github.com/fraser-isbester/tusk/internal/rules" "github.com/fraser-isbester/tusk/internal/tui/theme" @@ -32,6 +33,7 @@ type App struct { profile string color string readonly bool + config *config.Config layout *tview.Flex header *tview.TextView @@ -74,13 +76,14 @@ type App struct { filterText string } -func NewApp(database *db.DB, profileName, profileColor, connUser string, readonly bool, engine *rules.Engine) *App { +func NewApp(database *db.DB, cfg *config.Config, profileName, profileColor, connUser string, readonly bool, engine *rules.Engine) *App { a := &App{ app: tview.NewApplication(), db: database, profile: profileName, color: profileColor, readonly: readonly, + config: cfg, connUser: connUser, engine: engine, viewMap: make(map[string]View), @@ -500,6 +503,9 @@ func (a *App) parentView() string { if strings.HasPrefix(av, "table-detail") { return "tables" } + if strings.HasPrefix(av, "rule-form") || strings.HasPrefix(av, "delete-confirm") { + return "rules" + } return av } @@ -542,6 +548,8 @@ func (a *App) updateStatus() { hints = append(hints, hint{"c", "cancel"}, hint{"t", "terminate"}) case "locks": hints = append(hints, hint{"t", "terminate"}) + case "rules": + hints = append(hints, hint{"n", "new"}, hint{"e", "edit"}, hint{"d", "delete"}, hint{"space", "toggle"}) } parts := make([]string, 0, len(hints)) @@ -686,14 +694,40 @@ func (a *App) wireNavigation() { }) } - // Rules: Enter -> switch to violations view with rule name filter + // Rules: Enter -> violations filtered by rule; n/e/d/space -> manage rules. if rv, ok := a.viewMap["rules"].(*views.Rules); ok { - rv.Table().SetSelectedFunc(func(row, col int) { + rv.Table().SetSelectedFunc(func(_, _ int) { if name, ok := rv.SelectedRule(); ok { a.switchView("violations") a.applyFilter(name) } }) + rv.Table().SetInputCapture(func(evt *tcell.EventKey) *tcell.EventKey { + if evt.Key() != tcell.KeyRune { + return evt + } + switch evt.Rune() { + case 'n': + a.showRuleForm(nil) + return nil + case 'e': + if r, ok := a.selectedRuleConfig(rv); ok { + a.showRuleForm(&r) + } + return nil + case 'd': + if name, ok := rv.SelectedRule(); ok { + a.showDeleteConfirm(name) + } + return nil + case ' ': + if name, ok := rv.SelectedRule(); ok { + a.toggleRule(name) + } + return nil + } + return evt + }) } // Violations: Enter -> Resource detail for the violated PID @@ -747,6 +781,12 @@ func (a *App) showHelp() { fmt.Sprintf(" %sc%s %sCancel query (pg_cancel_backend)%s", k, r, d, r), fmt.Sprintf(" %st%s %sTerminate backend (pg_terminate_backend)%s", k, r, d, r), "", + h + " Rules View" + r, + fmt.Sprintf(" %sn%s %sNew rule%s", k, r, d, r), + fmt.Sprintf(" %se%s %sEdit selected rule%s", k, r, d, r), + fmt.Sprintf(" %sd%s %sDelete selected rule%s", k, r, d, r), + fmt.Sprintf(" %sSpace%s %sToggle enabled/disabled%s", k, r, d, r), + "", h + " Sorting (in table views)" + r, fmt.Sprintf(" %sShift+Column Key%s %sSort by column (see column headers)%s", k, r, d, r), "", @@ -769,6 +809,231 @@ func (a *App) showHelp() { a.showDetail("help-detail", modal) } +// rulesEditable reports whether the current session can edit rules — it needs +// a loaded config and a real profile (the positional-URL path uses "cli", which +// has no config-backed profile to persist to). +func (a *App) rulesEditable() bool { + return a.config != nil && a.profile != "" && a.profile != "cli" +} + +// selectedRuleConfig returns the RuleConfig backing the currently selected row +// in the rules view, looked up from the config profile. +func (a *App) selectedRuleConfig(rv *views.Rules) (rules.RuleConfig, bool) { + name, ok := rv.SelectedRule() + if !ok || a.config == nil { + return rules.RuleConfig{}, false + } + profile, err := a.config.ResolveProfile(a.profile) + if err != nil { + return rules.RuleConfig{}, false + } + for _, r := range profile.Rules { + if r.Name == name { + return r, true + } + } + return rules.RuleConfig{}, false +} + +// showMessage displays a dismissable modal (used for errors and guards). +func (a *App) showMessage(title, msg string) { + modal := tview.NewModal(). + SetText(fmt.Sprintf("%s\n\n%s", title, msg)). + AddButtons([]string{"OK"}). + SetDoneFunc(func(_ int, _ string) { a.popDetail("message-detail") }) + modal.SetBackgroundColor(tcell.ColorDefault) + a.showDetail("message-detail", modal) +} + +// popDetail removes a detail/modal page and returns to the previous view, +// mirroring the Esc handler's stack management. +func (a *App) popDetail(name string) { + a.pages.RemovePage(name) + if len(a.viewStack) > 0 { + prev := a.viewStack[len(a.viewStack)-1] + a.viewStack = a.viewStack[:len(a.viewStack)-1] + a.activeView = prev + a.switchView(prev) + } +} + +// showRuleForm opens the rule editor. existing is nil for a new rule. +func (a *App) showRuleForm(existing *rules.RuleConfig) { + if !a.rulesEditable() { + a.showMessage("Editing unavailable", + "Rule editing requires a config profile.\nLaunch tusk with -P and a ~/.config/tusk/config.yaml.") + return + } + form := views.NewRuleForm(a.app, a.readonly, existing, func(cfg rules.RuleConfig) { + if a.saveRule(cfg, existing) { + a.popDetail("rule-form-detail") + } + }, func() { + a.popDetail("rule-form-detail") + }) + a.showDetail("rule-form-detail", form) +} + +// saveRule persists a new or edited rule and hot-swaps the engine. It returns +// true on success; on failure it shows an error modal and leaves the form open. +func (a *App) saveRule(cfg rules.RuleConfig, existing *rules.RuleConfig) bool { + profile, err := a.config.ResolveProfile(a.profile) + if err != nil { + a.showMessage("Error", err.Error()) + return false + } + + newRules := make([]rules.RuleConfig, len(profile.Rules)) + copy(newRules, profile.Rules) + + if existing == nil { + for _, r := range newRules { + if r.Name == cfg.Name { + a.showMessage("Duplicate rule", fmt.Sprintf("A rule named %q already exists.", cfg.Name)) + return false + } + } + newRules = append(newRules, cfg) + } else { + // Reject a rename that collides with a different existing rule. + if cfg.Name != existing.Name { + for _, r := range newRules { + if r.Name == cfg.Name { + a.showMessage("Duplicate rule", fmt.Sprintf("A rule named %q already exists.", cfg.Name)) + return false + } + } + } + found := false + for i, r := range newRules { + if r.Name == existing.Name { + newRules[i] = cfg + found = true + break + } + } + if !found { + newRules = append(newRules, cfg) + } + } + + if err := config.SaveProfileRules(a.profile, newRules); err != nil { + a.showMessage("Save failed", err.Error()) + return false + } + profile.Rules = newRules + a.config.Profiles[a.profile] = profile + a.recompileAndSwap() + return true +} + +// deleteRule removes a rule by name, persists, and hot-swaps the engine. +func (a *App) deleteRule(name string) { + if !a.rulesEditable() { + return + } + profile, err := a.config.ResolveProfile(a.profile) + if err != nil { + a.showMessage("Error", err.Error()) + return + } + newRules := make([]rules.RuleConfig, 0, len(profile.Rules)) + for _, r := range profile.Rules { + if r.Name != name { + newRules = append(newRules, r) + } + } + if err := config.SaveProfileRules(a.profile, newRules); err != nil { + a.showMessage("Save failed", err.Error()) + return + } + profile.Rules = newRules + a.config.Profiles[a.profile] = profile + a.recompileAndSwap() +} + +// toggleRule flips the enabled state of a rule by name, persists, and hot-swaps. +func (a *App) toggleRule(name string) { + if !a.rulesEditable() { + a.showMessage("Editing unavailable", + "Rule editing requires a config profile.\nLaunch tusk with -P and a ~/.config/tusk/config.yaml.") + return + } + profile, err := a.config.ResolveProfile(a.profile) + if err != nil { + a.showMessage("Error", err.Error()) + return + } + newRules := make([]rules.RuleConfig, len(profile.Rules)) + copy(newRules, profile.Rules) + for i := range newRules { + if newRules[i].Name == name { + // Enabled defaults to true when unset. + cur := newRules[i].Enabled == nil || *newRules[i].Enabled + next := !cur + newRules[i].Enabled = &next + break + } + } + if err := config.SaveProfileRules(a.profile, newRules); err != nil { + a.showMessage("Save failed", err.Error()) + return + } + profile.Rules = newRules + a.config.Profiles[a.profile] = profile + a.recompileAndSwap() +} + +// recompileAndSwap rebuilds the engine's rules from the current config profile. +// If no engine existed yet (profile previously had zero rules), it creates one +// and wires it into the views that render engine state. +func (a *App) recompileAndSwap() { + profile, err := a.config.ResolveProfile(a.profile) + if err != nil { + return + } + compiled, err := rules.BuildRules(profile.Rules, a.readonly) + if err != nil { + a.showMessage("Rule error", err.Error()) + return + } + if a.engine == nil { + a.engine = rules.NewEngine(compiled, a.db, 5*time.Minute, 1000) + if qv, ok := a.viewMap["queries"].(*views.Queries); ok { + qv.SetEngine(a.engine) + } + if tv, ok := a.viewMap["transactions"].(*views.Transactions); ok { + tv.SetEngine(a.engine) + } + if rv, ok := a.viewMap["rules"].(*views.Rules); ok { + rv.SetEngine(a.engine) + } + if vv, ok := a.viewMap["violations"].(*views.Violations); ok { + vv.SetEngine(a.engine) + } + return + } + a.engine.UpdateRules(compiled) +} + +// showDeleteConfirm asks for confirmation before deleting a rule. +func (a *App) showDeleteConfirm(name string) { + if !a.rulesEditable() { + return + } + modal := tview.NewModal(). + SetText(fmt.Sprintf("Delete rule '%s'?", name)). + AddButtons([]string{"Cancel", "Delete"}). + SetDoneFunc(func(_ int, label string) { + a.popDetail("delete-confirm-detail") + if label == "Delete" { + a.deleteRule(name) + } + }) + modal.SetBackgroundColor(tcell.ColorDefault) + a.showDetail("delete-confirm-detail", modal) +} + func (a *App) showDetail(name string, detail tview.Primitive) { a.pages.AddPage(name, detail, true, true) a.viewStack = append(a.viewStack, a.activeView) diff --git a/internal/tui/views/rule_form.go b/internal/tui/views/rule_form.go new file mode 100644 index 0000000..047944c --- /dev/null +++ b/internal/tui/views/rule_form.go @@ -0,0 +1,289 @@ +package views + +import ( + "fmt" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/google/cel-go/cel" + "github.com/rivo/tview" + + "github.com/fraser-isbester/tusk/internal/rules" + "github.com/fraser-isbester/tusk/internal/tui/theme" +) + +// celVars returns the CEL variable names available for a given resource type, +// used to drive autocomplete in the When field. +func celVars(resource string) []string { + switch resource { + case "query": + return []string{ + "pid", "user", "app", "database", "client_addr", "state", + "duration", "wait_event_type", "wait_event", "query", + "route", "controller", "action_name", "framework", + "blocked_by", "query_id", + } + case "transaction": + return []string{ + "pid", "user", "app", "database", "state", + "xact_duration", "query_duration", "query", "lock_count", + } + case "lock": + return []string{ + "blocked_pid", "blocking_pid", + "blocked_user", "blocking_user", + "blocked_app", "blocking_app", + "lock_type", "mode", "wait_duration", + } + } + return nil +} + +// celBuiltins are common CEL functions and keywords useful in rule expressions. +var celBuiltins = []string{ + "duration(", "true", "false", + "contains(", "startsWith(", "endsWith(", "matches(", + "size(", "int(", "string(", +} + +// lastToken extracts the token currently being typed (after operators/whitespace). +func lastToken(text string) string { + for i := len(text) - 1; i >= 0; i-- { + ch := text[i] + if ch == ' ' || ch == '(' || ch == ')' || ch == '&' || ch == '|' || + ch == '!' || ch == '=' || ch == '<' || ch == '>' || ch == '\'' { + return text[i+1:] + } + } + return text +} + +// NewRuleForm creates a rule builder form. If existing is non-nil, fields are +// pre-populated for editing; onSave receives the assembled RuleConfig once it +// passes validation, and onCancel is called if the user cancels. +func NewRuleForm(_ *tview.Application, readonly bool, existing *rules.RuleConfig, onSave func(rules.RuleConfig), onCancel func()) tview.Primitive { + form := tview.NewForm() + form.SetBackgroundColor(tcell.ColorDefault) + form.SetFieldBackgroundColor(tcell.NewRGBColor(0x1a, 0x1a, 0x1a)) + form.SetFieldTextColor(tcell.ColorWhite) + form.SetLabelColor(theme.ColorLabel) + form.SetButtonBackgroundColor(theme.ColorBorder) + form.SetButtonTextColor(tcell.ColorWhite) + form.SetBorder(true) + form.SetBorderColor(theme.ColorBorder) + form.SetTitleColor(theme.ColorLogo) + + title := " New Rule " + if existing != nil { + title = " Edit Rule " + } + form.SetTitle(title) + + // Status line for CEL validation feedback. + status := tview.NewTextView(). + SetDynamicColors(true). + SetTextAlign(tview.AlignLeft) + status.SetBackgroundColor(tcell.ColorDefault) + + nameVal := "" + resourceIdx := 0 + whenVal := "" + actionIdx := 0 + cooldownVal := "" + enabledVal := true + dryRunVal := false + + if existing != nil { + nameVal = existing.Name + whenVal = existing.When + cooldownVal = existing.Cooldown + if existing.Enabled != nil { + enabledVal = *existing.Enabled + } + dryRunVal = existing.DryRun + switch existing.Resource { + case "transaction": + resourceIdx = 1 + case "lock": + resourceIdx = 2 + } + switch existing.Action { + case "cancel": + actionIdx = 1 + case "terminate": + actionIdx = 2 + } + } + + if readonly { + dryRunVal = true + } + + resources := []string{"query", "transaction", "lock"} + actions := []string{"log", "cancel", "terminate"} + + currentResource := resources[resourceIdx] + + validateCEL := func(resource, expression string) { + if expression == "" { + status.SetText(" [#808080]Enter a CEL expression[-]") + return + } + rt := rules.ResourceType(resource) + env, err := rules.EnvForResource(rt) + if err != nil { + status.SetText(fmt.Sprintf(" [#FF5F5F]%s[-]", err.Error())) + return + } + ast, issues := env.Compile(expression) + if issues != nil && issues.Err() != nil { + status.SetText(fmt.Sprintf(" [#FF5F5F]%s[-]", issues.Err().Error())) + return + } + if ast.OutputType() != cel.BoolType { + status.SetText(fmt.Sprintf(" [#FF5F5F]must return bool, got %s[-]", ast.OutputType())) + return + } + status.SetText(" [#00D700]Expression valid[-]") + } + + // Track the When text separately so callbacks can reference it before the + // form item exists. + whenExpr := whenVal + + autocomplete := func(text string) []string { + tok := lastToken(text) + if tok == "" { + return nil + } + prefix := strings.ToLower(tok) + var matches []string + for _, v := range celVars(currentResource) { + if strings.HasPrefix(v, prefix) && v != tok { + matches = append(matches, v) + } + } + for _, b := range celBuiltins { + if strings.HasPrefix(b, prefix) && b != tok { + matches = append(matches, b) + } + } + if len(matches) == 0 { + return nil + } + base := text[:len(text)-len(tok)] + entries := make([]string, len(matches)) + for i, m := range matches { + entries[i] = base + m + } + return entries + } + + form.AddInputField("Name", nameVal, 0, nil, nil) + form.AddDropDown("Resource", resources, resourceIdx, func(option string, _ int) { + currentResource = option + validateCEL(option, whenExpr) + }) + whenField := tview.NewInputField(). + SetLabel("When"). + SetText(whenVal). + SetFieldWidth(0). + SetFieldBackgroundColor(tcell.NewRGBColor(0x1a, 0x1a, 0x1a)). + SetFieldTextColor(tcell.ColorWhite). + SetLabelColor(theme.ColorLabel) + whenField.SetBackgroundColor(tcell.ColorDefault) + whenField.SetChangedFunc(func(text string) { + whenExpr = text + validateCEL(currentResource, text) + }) + whenField.SetAutocompleteFunc(autocomplete) + whenField.SetAutocompletedFunc(func(text string, _, source int) bool { + whenExpr = text + whenField.SetText(text) + validateCEL(currentResource, text) + return source == tview.AutocompletedNavigate + }) + form.AddFormItem(whenField) + form.AddDropDown("Action", actions, actionIdx, nil) + form.AddInputField("Cooldown", cooldownVal, 20, nil, nil) + form.AddCheckbox("Enabled", enabledVal, nil) + form.AddCheckbox("Dry Run", dryRunVal, nil) + + validateCEL(currentResource, whenVal) + + form.AddButton("Save", func() { + name := form.GetFormItemByLabel("Name").(*tview.InputField).GetText() + if name == "" { + status.SetText(" [#FF5F5F]Name is required[-]") + return + } + + when := whenField.GetText() + if when == "" { + status.SetText(" [#FF5F5F]Expression is required[-]") + return + } + + rt := rules.ResourceType(currentResource) + env, err := rules.EnvForResource(rt) + if err != nil { + status.SetText(fmt.Sprintf(" [#FF5F5F]%s[-]", err.Error())) + return + } + ast, issues := env.Compile(when) + if issues != nil && issues.Err() != nil { + status.SetText(fmt.Sprintf(" [#FF5F5F]%s[-]", issues.Err().Error())) + return + } + if ast.OutputType() != cel.BoolType { + status.SetText(fmt.Sprintf(" [#FF5F5F]must return bool, got %s[-]", ast.OutputType())) + return + } + + cooldown := form.GetFormItemByLabel("Cooldown").(*tview.InputField).GetText() + if cooldown != "" { + if _, err := time.ParseDuration(cooldown); err != nil { + status.SetText(fmt.Sprintf(" [#FF5F5F]Invalid cooldown: %s[-]", err.Error())) + return + } + } + + _, action := form.GetFormItemByLabel("Action").(*tview.DropDown).GetCurrentOption() + enabled := form.GetFormItemByLabel("Enabled").(*tview.Checkbox).IsChecked() + dryRun := form.GetFormItemByLabel("Dry Run").(*tview.Checkbox).IsChecked() + if readonly { + dryRun = true + } + + onSave(rules.RuleConfig{ + Name: name, + Enabled: &enabled, + DryRun: dryRun, + Resource: currentResource, + When: when, + Action: action, + Cooldown: cooldown, + }) + }) + + form.AddButton("Cancel", onCancel) + + inner := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(form, 0, 1, true). + AddItem(status, 1, 0, false) + inner.SetBackgroundColor(tcell.ColorDefault) + + // Center the modal. + modal := tview.NewFlex().SetDirection(tview.FlexRow). + AddItem(nil, 0, 1, false). + AddItem(tview.NewFlex(). + AddItem(nil, 0, 1, false). + AddItem(inner, 70, 0, true). + AddItem(nil, 0, 1, false), + 22, 0, true). + AddItem(nil, 0, 1, false) + modal.SetBackgroundColor(tcell.ColorDefault) + + return modal +} diff --git a/internal/tui/views/rules.go b/internal/tui/views/rules.go index 8b9cd11..7490cf3 100644 --- a/internal/tui/views/rules.go +++ b/internal/tui/views/rules.go @@ -35,6 +35,14 @@ func NewRulesView(engine *rules.Engine) *Rules { // Table returns the underlying tview.Table. func (v *Rules) Table() *tview.Table { return v.table } +// SetEngine attaches (or replaces) the rules engine this view renders from. +// Used when the engine is created lazily after the first rule is added. +func (v *Rules) SetEngine(e *rules.Engine) { + v.mu.Lock() + v.engine = e + v.mu.Unlock() +} + // ItemCount returns the number of configured rules. func (v *Rules) ItemCount() int { if v.engine == nil { @@ -182,7 +190,11 @@ func (v *Rules) render() { v.table.SetCell(row, 6, tview.NewTableCell(violStr).SetTextColor(violColor)) } - if sel > 0 && sel < v.table.GetRowCount() { + switch { + case sel > 0 && sel < v.table.GetRowCount(): v.table.Select(sel, 0) + case v.table.GetRowCount() > 1: + // Auto-select the first rule row so edit/delete/toggle work on entry. + v.table.Select(1, 0) } } diff --git a/internal/tui/views/violations.go b/internal/tui/views/violations.go index 270860b..2a276fe 100644 --- a/internal/tui/views/violations.go +++ b/internal/tui/views/violations.go @@ -39,6 +39,14 @@ func NewViolationsView(engine *rules.Engine) *Violations { // Table returns the underlying tview.Table. func (v *Violations) Table() *tview.Table { return v.table } +// SetEngine attaches (or replaces) the rules engine this view renders from. +// Used when the engine is created lazily after the first rule is added. +func (v *Violations) SetEngine(e *rules.Engine) { + v.mu.Lock() + v.engine = e + v.mu.Unlock() +} + // ItemCount returns the number of recent violations. func (v *Violations) ItemCount() int { if v.engine == nil {