diff --git a/README.md b/README.md index d7da589..8b99aa1 100644 --- a/README.md +++ b/README.md @@ -106,6 +106,25 @@ ember status Ember polls the Caddy admin API and Prometheus metrics endpoint at a regular interval (default: 1s), computes deltas and derived metrics (RPS, percentiles, error rates), and renders them through one of several output modes: an interactive [Bubble Tea](https://github.com/charmbracelet/bubbletea) TUI (default), streaming JSONL, a headless daemon with Prometheus export, or a one-shot `status` command. +## Plugins (experimental) + +Ember supports a plugin system that lets third-party developers add custom tabs for visualizing metrics from additional Caddy modules (e.g., rate limiters, WAF modules, custom middleware). Plugins are compiled into the binary using Go's blank import pattern, the same approach used by Caddy itself. + +Building a custom Ember binary with plugins is simple: + +```go +import ( + "github.com/alexandre-daubois/ember" + _ "github.com/myorg/ember-myplugin" +) + +func main() { ember.Run() } +``` + +Plugins can provide multiple tabs, subscribe to core metrics, conditionally hide their tabs, and reuse Ember's Prometheus parser via the `pkg/metrics` package. + +See the [Plugin Development Guide](docs/plugins.md) for details on building and integrating plugins. + ## Documentation Full documentation is available in the [docs/](docs/index.md) directory: @@ -119,6 +138,7 @@ Full documentation is available in the [docs/](docs/index.md) directory: - [Prometheus Export](docs/prometheus-export.md): Metrics, health checks, daemon mode - [Docker](docs/docker.md): Container usage - [Agent Skills](docs/skills.md): Skills for AI coding agents +- [Plugins](docs/plugins.md): Building custom plugins for Ember (experimental) - [Troubleshooting](docs/troubleshooting.md): Common issues and solutions ## Contributing diff --git a/cmd/ember/main.go b/cmd/ember/main.go index 9b84bdf..54c8962 100644 --- a/cmd/ember/main.go +++ b/cmd/ember/main.go @@ -4,13 +4,14 @@ import ( "fmt" "os" - "github.com/alexandre-daubois/ember/internal/app" + "github.com/alexandre-daubois/ember" ) var version = "1.0.0-dev" func main() { - if err := app.Run(os.Args[1:], version); err != nil { + ember.Version = version + if err := ember.Run(); err != nil { fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(1) } diff --git a/coverage.txt b/coverage.txt new file mode 100644 index 0000000..d90effa --- /dev/null +++ b/coverage.txt @@ -0,0 +1,1665 @@ +mode: atomic +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:22.44,26.2 3 124 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:28.42,32.2 3 129 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:36.70,38.21 2 24 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:38.21,40.3 1 2 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:41.2,41.54 1 24 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:41.54,43.23 2 24 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:43.23,46.4 2 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:48.3,56.32 8 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:60.43,61.18 1 130 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:61.18,63.3 1 114 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:64.2,64.28 1 16 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:67.79,70.15 3 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:70.15,72.3 1 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:74.2,79.57 6 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:82.78,84.56 2 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:84.56,85.24 1 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:85.24,87.9 2 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:90.2,90.16 1 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:90.16,92.3 1 20 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:94.2,97.56 4 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:97.56,98.24 1 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:98.24,100.4 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:104.81,105.41 1 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:105.41,107.3 1 18 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:109.2,114.29 5 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:114.29,117.3 2 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:119.2,122.29 4 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:122.29,125.3 2 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:127.2,130.29 4 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:130.29,133.3 2 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:135.2,138.29 4 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:138.29,141.3 2 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:144.77,145.29 1 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:145.29,147.3 1 15 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:149.2,154.27 5 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:154.27,156.3 1 16 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:158.2,161.27 4 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:161.27,163.3 1 16 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:165.2,166.27 2 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:166.27,167.24 1 15 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:167.24,169.9 2 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:172.2,172.20 1 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:172.20,176.28 4 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:176.28,177.26 1 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:177.26,178.13 1 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:180.4,184.78 5 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:188.2,191.27 4 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:191.27,193.3 1 16 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:195.2,196.27 2 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:196.27,197.30 1 15 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:197.30,199.9 2 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:202.2,202.15 1 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:202.15,206.28 4 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:206.28,209.59 3 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:209.59,210.35 1 16 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:210.35,212.6 1 8 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:218.65,219.21 1 7 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:219.21,221.3 1 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:222.2,223.32 2 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:223.32,224.10 1 13 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:225.34,226.26 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:227.34,228.26 1 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:229.34,230.26 1 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:231.34,232.26 1 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:235.2,235.16 1 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:238.69,241.59 3 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:241.59,243.3 1 14 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:244.2,244.15 1 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:247.78,249.35 2 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:249.35,250.23 1 14 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:250.23,252.9 2 2 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:255.2,255.16 1 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:255.16,257.3 1 21 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:259.2,262.52 4 2 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:262.52,263.23 1 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:263.23,265.4 1 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:269.77,270.31 1 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:270.31,272.3 1 20 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:274.2,279.69 6 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:282.80,292.2 8 23 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:294.82,296.36 2 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:296.36,298.3 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:300.2,300.44 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:300.44,301.54 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:301.54,303.4 1 0 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:306.2,306.54 1 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:306.54,310.23 3 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:310.23,314.4 3 2 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:316.3,317.27 2 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:317.27,325.4 3 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:327.3,331.5 1 2 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:335.40,340.2 4 102 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:344.67,345.71 1 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:345.71,347.130 2 4 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:347.130,351.4 3 3 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:352.3,352.23 1 1 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:356.56,358.22 2 5 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:358.22,360.3 1 10 +github.com/alexandre-daubois/ember/internal/exporter/exporter.go:361.2,362.14 2 5 +github.com/alexandre-daubois/ember/cmd/ember/main.go:12.13,13.54 1 0 +github.com/alexandre-daubois/ember/cmd/ember/main.go:13.54,16.3 2 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:24.61,26.55 2 7 +github.com/alexandre-daubois/ember/internal/app/daemon.go:26.55,27.23 1 5 +github.com/alexandre-daubois/ember/internal/app/daemon.go:27.23,29.4 1 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:29.9,31.4 1 4 +github.com/alexandre-daubois/ember/internal/app/daemon.go:32.3,33.19 2 5 +github.com/alexandre-daubois/ember/internal/app/daemon.go:34.8,36.3 1 2 +github.com/alexandre-daubois/ember/internal/app/daemon.go:39.51,40.15 1 2 +github.com/alexandre-daubois/ember/internal/app/daemon.go:40.15,44.3 3 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:47.66,49.9 2 3 +github.com/alexandre-daubois/ember/internal/app/daemon.go:49.9,52.3 2 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:53.2,53.46 1 2 +github.com/alexandre-daubois/ember/internal/app/daemon.go:53.46,56.3 2 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:57.2,57.48 1 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:60.37,61.37 1 3 +github.com/alexandre-daubois/ember/internal/app/daemon.go:61.37,63.3 1 1 +github.com/alexandre-daubois/ember/internal/app/daemon.go:64.2,64.38 1 3 +github.com/alexandre-daubois/ember/internal/app/daemon.go:67.75,79.27 9 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:79.27,82.3 2 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:83.2,85.12 2 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:85.12,86.87 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:86.87,88.4 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:91.2,96.17 4 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:96.17,98.17 2 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:98.17,101.4 2 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:102.3,104.38 3 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:107.2,115.6 6 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:115.6,116.10 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:117.21,121.88 4 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:121.88,123.5 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:124.4,124.14 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:125.19,126.10 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:127.17,128.26 1 0 +github.com/alexandre-daubois/ember/internal/app/daemon.go:129.19,130.26 1 0 +github.com/alexandre-daubois/ember/internal/app/diff.go:15.34,31.55 1 37 +github.com/alexandre-daubois/ember/internal/app/diff.go:31.55,33.4 1 0 +github.com/alexandre-daubois/ember/internal/app/diff.go:37.63,39.16 2 14 +github.com/alexandre-daubois/ember/internal/app/diff.go:39.16,41.3 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:42.2,43.16 2 11 +github.com/alexandre-daubois/ember/internal/app/diff.go:43.16,45.3 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:47.2,50.22 3 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:50.22,52.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:53.2,53.12 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:56.52,58.16 2 25 +github.com/alexandre-daubois/ember/internal/app/diff.go:58.16,60.3 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:61.2,61.15 1 24 +github.com/alexandre-daubois/ember/internal/app/diff.go:61.15,61.32 1 24 +github.com/alexandre-daubois/ember/internal/app/diff.go:63.2,64.16 2 24 +github.com/alexandre-daubois/ember/internal/app/diff.go:64.16,66.3 1 0 +github.com/alexandre-daubois/ember/internal/app/diff.go:67.2,67.22 1 24 +github.com/alexandre-daubois/ember/internal/app/diff.go:67.22,69.3 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:71.2,72.56 2 23 +github.com/alexandre-daubois/ember/internal/app/diff.go:72.56,74.3 1 2 +github.com/alexandre-daubois/ember/internal/app/diff.go:75.2,75.17 1 21 +github.com/alexandre-daubois/ember/internal/app/diff.go:97.55,103.16 3 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:103.16,107.60 4 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:107.60,109.4 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:113.2,114.95 2 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:114.95,118.3 3 2 +github.com/alexandre-daubois/ember/internal/app/diff.go:119.2,125.32 6 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:125.32,127.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:128.2,129.33 2 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:129.33,131.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:133.2,134.33 2 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:134.33,136.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:137.2,137.32 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:137.32,139.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:141.2,142.26 2 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:142.26,144.3 1 6 +github.com/alexandre-daubois/ember/internal/app/diff.go:145.2,147.35 2 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:147.35,152.17 4 6 +github.com/alexandre-daubois/ember/internal/app/diff.go:152.17,156.38 4 2 +github.com/alexandre-daubois/ember/internal/app/diff.go:156.38,158.5 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:160.3,163.27 3 6 +github.com/alexandre-daubois/ember/internal/app/diff.go:163.27,164.22 1 13 +github.com/alexandre-daubois/ember/internal/app/diff.go:164.22,166.5 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:168.3,168.16 1 6 +github.com/alexandre-daubois/ember/internal/app/diff.go:168.16,170.4 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:173.2,173.29 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:173.29,174.19 1 68 +github.com/alexandre-daubois/ember/internal/app/diff.go:174.19,176.4 1 4 +github.com/alexandre-daubois/ember/internal/app/diff.go:178.2,178.28 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:178.28,179.29 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:179.29,180.20 1 7 +github.com/alexandre-daubois/ember/internal/app/diff.go:180.20,182.5 1 1 +github.com/alexandre-daubois/ember/internal/app/diff.go:186.2,186.10 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:189.95,197.27 3 88 +github.com/alexandre-daubois/ember/internal/app/diff.go:197.27,200.3 2 65 +github.com/alexandre-daubois/ember/internal/app/diff.go:202.2,202.17 1 23 +github.com/alexandre-daubois/ember/internal/app/diff.go:202.17,204.31 2 4 +github.com/alexandre-daubois/ember/internal/app/diff.go:204.31,206.4 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:207.3,207.11 1 4 +github.com/alexandre-daubois/ember/internal/app/diff.go:210.2,213.14 3 19 +github.com/alexandre-daubois/ember/internal/app/diff.go:213.14,215.3 1 9 +github.com/alexandre-daubois/ember/internal/app/diff.go:216.2,219.36 3 19 +github.com/alexandre-daubois/ember/internal/app/diff.go:219.36,221.3 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:222.2,222.38 1 19 +github.com/alexandre-daubois/ember/internal/app/diff.go:222.38,224.3 1 2 +github.com/alexandre-daubois/ember/internal/app/diff.go:226.2,226.10 1 19 +github.com/alexandre-daubois/ember/internal/app/diff.go:229.47,230.12 1 179 +github.com/alexandre-daubois/ember/internal/app/diff.go:230.12,232.3 1 104 +github.com/alexandre-daubois/ember/internal/app/diff.go:233.2,233.15 1 75 +github.com/alexandre-daubois/ember/internal/app/diff.go:233.15,235.3 1 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:236.2,236.39 1 72 +github.com/alexandre-daubois/ember/internal/app/diff.go:239.38,245.22 4 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:245.22,247.29 2 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:247.29,250.4 2 3 +github.com/alexandre-daubois/ember/internal/app/diff.go:253.2,253.22 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:253.22,255.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:255.8,257.3 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:259.2,259.19 1 10 +github.com/alexandre-daubois/ember/internal/app/diff.go:262.59,263.26 1 13 +github.com/alexandre-daubois/ember/internal/app/diff.go:263.26,265.19 2 75 +github.com/alexandre-daubois/ember/internal/app/diff.go:265.19,267.4 1 5 +github.com/alexandre-daubois/ember/internal/app/diff.go:268.3,268.95 1 75 +github.com/alexandre-daubois/ember/internal/app/init.go:17.45,35.55 3 37 +github.com/alexandre-daubois/ember/internal/app/init.go:35.55,42.47 6 0 +github.com/alexandre-daubois/ember/internal/app/init.go:42.47,44.5 1 0 +github.com/alexandre-daubois/ember/internal/app/init.go:46.4,47.13 2 0 +github.com/alexandre-daubois/ember/internal/app/init.go:47.13,49.5 1 0 +github.com/alexandre-daubois/ember/internal/app/init.go:50.4,50.54 1 0 +github.com/alexandre-daubois/ember/internal/app/init.go:54.2,57.12 3 37 +github.com/alexandre-daubois/ember/internal/app/init.go:66.118,69.45 2 9 +github.com/alexandre-daubois/ember/internal/app/init.go:69.45,72.3 2 1 +github.com/alexandre-daubois/ember/internal/app/init.go:73.2,76.22 3 8 +github.com/alexandre-daubois/ember/internal/app/init.go:76.22,78.3 1 5 +github.com/alexandre-daubois/ember/internal/app/init.go:78.8,80.3 1 3 +github.com/alexandre-daubois/ember/internal/app/init.go:82.2,83.16 2 8 +github.com/alexandre-daubois/ember/internal/app/init.go:83.16,85.3 1 0 +github.com/alexandre-daubois/ember/internal/app/init.go:85.8,85.27 1 8 +github.com/alexandre-daubois/ember/internal/app/init.go:85.27,87.3 1 5 +github.com/alexandre-daubois/ember/internal/app/init.go:87.8,90.110 2 3 +github.com/alexandre-daubois/ember/internal/app/init.go:90.110,93.4 2 1 +github.com/alexandre-daubois/ember/internal/app/init.go:93.9,94.47 1 2 +github.com/alexandre-daubois/ember/internal/app/init.go:94.47,97.5 2 0 +github.com/alexandre-daubois/ember/internal/app/init.go:98.4,98.69 1 2 +github.com/alexandre-daubois/ember/internal/app/init.go:102.2,104.12 3 8 +github.com/alexandre-daubois/ember/internal/app/init.go:104.12,106.3 1 7 +github.com/alexandre-daubois/ember/internal/app/init.go:106.8,110.33 3 1 +github.com/alexandre-daubois/ember/internal/app/init.go:110.33,111.28 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:111.28,113.5 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:114.4,114.37 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:114.37,116.22 2 1 +github.com/alexandre-daubois/ember/internal/app/init.go:116.22,118.6 1 0 +github.com/alexandre-daubois/ember/internal/app/init.go:119.5,119.102 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:124.2,126.48 3 8 +github.com/alexandre-daubois/ember/internal/app/init.go:126.48,128.3 1 5 +github.com/alexandre-daubois/ember/internal/app/init.go:128.8,128.24 1 3 +github.com/alexandre-daubois/ember/internal/app/init.go:128.24,130.3 1 3 +github.com/alexandre-daubois/ember/internal/app/init.go:132.2,132.59 1 8 +github.com/alexandre-daubois/ember/internal/app/init.go:132.59,134.3 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:136.2,136.56 1 8 +github.com/alexandre-daubois/ember/internal/app/init.go:136.56,142.3 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:144.2,145.12 2 8 +github.com/alexandre-daubois/ember/internal/app/init.go:148.43,150.11 2 47 +github.com/alexandre-daubois/ember/internal/app/init.go:150.11,152.3 1 11 +github.com/alexandre-daubois/ember/internal/app/init.go:153.2,153.20 1 47 +github.com/alexandre-daubois/ember/internal/app/init.go:153.20,155.3 1 10 +github.com/alexandre-daubois/ember/internal/app/init.go:155.8,157.3 1 37 +github.com/alexandre-daubois/ember/internal/app/init.go:160.66,163.2 2 14 +github.com/alexandre-daubois/ember/internal/app/init.go:165.78,166.13 1 9 +github.com/alexandre-daubois/ember/internal/app/init.go:166.13,169.3 2 2 +github.com/alexandre-daubois/ember/internal/app/init.go:170.2,172.21 3 7 +github.com/alexandre-daubois/ember/internal/app/init.go:172.21,174.3 1 1 +github.com/alexandre-daubois/ember/internal/app/init.go:175.2,176.57 2 6 +github.com/alexandre-daubois/ember/internal/app/json.go:72.107,76.17 3 1 +github.com/alexandre-daubois/ember/internal/app/json.go:76.17,78.17 2 1 +github.com/alexandre-daubois/ember/internal/app/json.go:78.17,81.4 2 0 +github.com/alexandre-daubois/ember/internal/app/json.go:82.3,83.48 2 1 +github.com/alexandre-daubois/ember/internal/app/json.go:86.2,88.10 2 1 +github.com/alexandre-daubois/ember/internal/app/json.go:88.10,90.3 1 1 +github.com/alexandre-daubois/ember/internal/app/json.go:92.2,95.6 3 0 +github.com/alexandre-daubois/ember/internal/app/json.go:95.6,96.10 1 0 +github.com/alexandre-daubois/ember/internal/app/json.go:97.21,98.10 1 0 +github.com/alexandre-daubois/ember/internal/app/json.go:99.19,100.10 1 0 +github.com/alexandre-daubois/ember/internal/app/json.go:105.77,118.34 2 12 +github.com/alexandre-daubois/ember/internal/app/json.go:118.34,122.3 3 1 +github.com/alexandre-daubois/ember/internal/app/json.go:123.2,123.39 1 12 +github.com/alexandre-daubois/ember/internal/app/json.go:123.39,134.24 2 9 +github.com/alexandre-daubois/ember/internal/app/json.go:134.24,139.4 4 1 +github.com/alexandre-daubois/ember/internal/app/json.go:140.3,140.17 1 9 +github.com/alexandre-daubois/ember/internal/app/json.go:140.17,145.4 4 1 +github.com/alexandre-daubois/ember/internal/app/json.go:146.3,146.36 1 9 +github.com/alexandre-daubois/ember/internal/app/json.go:149.2,151.12 2 12 +github.com/alexandre-daubois/ember/internal/app/json.go:154.68,156.40 2 12 +github.com/alexandre-daubois/ember/internal/app/json.go:156.40,170.3 1 0 +github.com/alexandre-daubois/ember/internal/app/json.go:171.2,174.3 1 12 +github.com/alexandre-daubois/ember/internal/app/json.go:177.39,179.38 2 12 +github.com/alexandre-daubois/ember/internal/app/json.go:179.38,182.3 2 1 +github.com/alexandre-daubois/ember/internal/app/json.go:185.83,187.28 2 14 +github.com/alexandre-daubois/ember/internal/app/json.go:187.28,188.62 1 2 +github.com/alexandre-daubois/ember/internal/app/json.go:188.62,189.12 1 1 +github.com/alexandre-daubois/ember/internal/app/json.go:191.3,191.27 1 1 +github.com/alexandre-daubois/ember/internal/app/json.go:193.2,193.14 1 14 +github.com/alexandre-daubois/ember/internal/app/run.go:38.48,72.68 2 37 +github.com/alexandre-daubois/ember/internal/app/run.go:72.68,74.45 2 12 +github.com/alexandre-daubois/ember/internal/app/run.go:74.45,76.5 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:77.4,78.25 2 12 +github.com/alexandre-daubois/ember/internal/app/run.go:80.55,87.16 6 0 +github.com/alexandre-daubois/ember/internal/app/run.go:87.16,89.19 2 0 +github.com/alexandre-daubois/ember/internal/app/run.go:89.19,91.52 2 0 +github.com/alexandre-daubois/ember/internal/app/run.go:91.52,93.7 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:95.5,95.19 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:95.19,97.6 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:100.4,101.48 2 0 +github.com/alexandre-daubois/ember/internal/app/run.go:101.48,103.5 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:104.4,107.11 3 0 +github.com/alexandre-daubois/ember/internal/app/run.go:108.22,109.56 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:110.20,111.35 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:112.12,113.51 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:115.4,115.14 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:119.2,147.12 26 37 +github.com/alexandre-daubois/ember/internal/app/run.go:150.47,154.2 3 11 +github.com/alexandre-daubois/ember/internal/app/run.go:156.110,157.17 1 3 +github.com/alexandre-daubois/ember/internal/app/run.go:157.17,159.3 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:160.2,160.24 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:160.25,160.26 0 1 +github.com/alexandre-daubois/ember/internal/app/run.go:163.62,170.16 2 3 +github.com/alexandre-daubois/ember/internal/app/run.go:170.16,172.3 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:173.2,173.19 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:173.19,175.3 1 0 +github.com/alexandre-daubois/ember/internal/app/run.go:176.2,176.12 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:179.30,180.23 1 15 +github.com/alexandre-daubois/ember/internal/app/run.go:181.14,182.61 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:183.10,184.61 1 14 +github.com/alexandre-daubois/ember/internal/app/run.go:196.34,197.37 1 19 +github.com/alexandre-daubois/ember/internal/app/run.go:197.37,199.28 2 95 +github.com/alexandre-daubois/ember/internal/app/run.go:199.28,200.12 1 30 +github.com/alexandre-daubois/ember/internal/app/run.go:202.3,202.39 1 65 +github.com/alexandre-daubois/ember/internal/app/run.go:202.39,204.4 1 5 +github.com/alexandre-daubois/ember/internal/app/run.go:210.34,211.36 1 28 +github.com/alexandre-daubois/ember/internal/app/run.go:211.36,213.3 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:214.2,214.31 1 26 +github.com/alexandre-daubois/ember/internal/app/run.go:214.31,216.3 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:217.2,217.28 1 24 +github.com/alexandre-daubois/ember/internal/app/run.go:217.28,219.3 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:220.2,220.32 1 23 +github.com/alexandre-daubois/ember/internal/app/run.go:220.32,222.3 1 3 +github.com/alexandre-daubois/ember/internal/app/run.go:223.2,223.89 1 20 +github.com/alexandre-daubois/ember/internal/app/run.go:223.89,225.3 1 3 +github.com/alexandre-daubois/ember/internal/app/run.go:226.2,226.27 1 17 +github.com/alexandre-daubois/ember/internal/app/run.go:226.27,227.46 1 3 +github.com/alexandre-daubois/ember/internal/app/run.go:227.46,229.4 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:230.3,230.23 1 2 +github.com/alexandre-daubois/ember/internal/app/run.go:230.23,232.4 1 1 +github.com/alexandre-daubois/ember/internal/app/run.go:234.2,234.12 1 15 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:16.36,20.2 3 1 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:23.38,27.2 3 0 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:29.54,30.26 1 2 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:30.26,33.3 2 1 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:35.2,37.16 3 1 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:37.16,40.3 2 0 +github.com/alexandre-daubois/ember/internal/app/signal_unix.go:42.2,42.57 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:19.47,35.55 2 37 +github.com/alexandre-daubois/ember/internal/app/status.go:35.55,42.16 6 1 +github.com/alexandre-daubois/ember/internal/app/status.go:42.16,44.19 2 1 +github.com/alexandre-daubois/ember/internal/app/status.go:44.19,46.6 1 0 +github.com/alexandre-daubois/ember/internal/app/status.go:47.5,47.19 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:50.4,51.47 2 1 +github.com/alexandre-daubois/ember/internal/app/status.go:51.47,53.5 1 0 +github.com/alexandre-daubois/ember/internal/app/status.go:54.4,54.83 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:58.2,60.12 2 37 +github.com/alexandre-daubois/ember/internal/app/status.go:63.132,68.24 4 6 +github.com/alexandre-daubois/ember/internal/app/status.go:68.24,69.15 1 3 +github.com/alexandre-daubois/ember/internal/app/status.go:69.15,71.4 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:71.9,73.4 1 2 +github.com/alexandre-daubois/ember/internal/app/status.go:74.3,74.53 1 3 +github.com/alexandre-daubois/ember/internal/app/status.go:77.2,80.9 3 3 +github.com/alexandre-daubois/ember/internal/app/status.go:81.20,82.19 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:83.30,83.30 0 2 +github.com/alexandre-daubois/ember/internal/app/status.go:86.2,89.14 3 2 +github.com/alexandre-daubois/ember/internal/app/status.go:89.14,91.3 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:92.2,93.12 2 1 +github.com/alexandre-daubois/ember/internal/app/status.go:114.73,126.22 4 4 +github.com/alexandre-daubois/ember/internal/app/status.go:126.22,128.3 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:129.2,129.29 1 4 +github.com/alexandre-daubois/ember/internal/app/status.go:129.29,131.3 1 3 +github.com/alexandre-daubois/ember/internal/app/status.go:132.2,132.19 1 4 +github.com/alexandre-daubois/ember/internal/app/status.go:132.19,139.3 2 1 +github.com/alexandre-daubois/ember/internal/app/status.go:141.2,141.10 1 4 +github.com/alexandre-daubois/ember/internal/app/status.go:144.47,145.17 1 24 +github.com/alexandre-daubois/ember/internal/app/status.go:145.17,147.3 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:148.2,151.35 1 23 +github.com/alexandre-daubois/ember/internal/app/status.go:154.70,160.57 4 7 +github.com/alexandre-daubois/ember/internal/app/status.go:160.57,162.3 1 6 +github.com/alexandre-daubois/ember/internal/app/status.go:164.2,166.22 2 7 +github.com/alexandre-daubois/ember/internal/app/status.go:166.22,168.3 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:170.2,173.29 3 7 +github.com/alexandre-daubois/ember/internal/app/status.go:173.29,175.3 1 5 +github.com/alexandre-daubois/ember/internal/app/status.go:177.2,177.19 1 7 +github.com/alexandre-daubois/ember/internal/app/status.go:177.19,180.64 3 1 +github.com/alexandre-daubois/ember/internal/app/status.go:180.64,182.4 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:183.3,183.32 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:186.2,186.35 1 7 +github.com/alexandre-daubois/ember/internal/app/status.go:189.35,191.16 2 10 +github.com/alexandre-daubois/ember/internal/app/status.go:191.16,193.3 1 1 +github.com/alexandre-daubois/ember/internal/app/status.go:194.2,194.34 1 9 +github.com/alexandre-daubois/ember/internal/app/tui.go:18.87,28.22 3 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:28.22,30.45 2 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:30.45,32.4 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:34.3,39.28 5 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:39.28,42.4 2 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:43.3,46.13 3 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:46.13,47.88 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:47.88,49.5 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:52.3,52.10 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:53.27,54.14 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:55.44,55.44 0 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:58.3,58.37 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:61.2,63.35 3 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:63.35,65.3 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:67.2,67.16 1 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:67.16,71.3 3 0 +github.com/alexandre-daubois/ember/internal/app/tui.go:73.2,73.12 1 0 +github.com/alexandre-daubois/ember/internal/app/version.go:15.51,25.55 2 37 +github.com/alexandre-daubois/ember/internal/app/version.go:25.55,28.14 2 2 +github.com/alexandre-daubois/ember/internal/app/version.go:28.14,30.5 1 2 +github.com/alexandre-daubois/ember/internal/app/version.go:32.4,35.62 3 0 +github.com/alexandre-daubois/ember/internal/app/version.go:39.2,41.12 2 37 +github.com/alexandre-daubois/ember/internal/app/version.go:46.38,46.64 1 10 +github.com/alexandre-daubois/ember/internal/app/version.go:53.81,55.16 2 5 +github.com/alexandre-daubois/ember/internal/app/version.go:55.16,57.3 1 1 +github.com/alexandre-daubois/ember/internal/app/version.go:59.2,62.33 3 4 +github.com/alexandre-daubois/ember/internal/app/version.go:62.33,65.3 2 2 +github.com/alexandre-daubois/ember/internal/app/version.go:68.2,68.44 1 2 +github.com/alexandre-daubois/ember/internal/app/version.go:68.44,71.3 2 1 +github.com/alexandre-daubois/ember/internal/app/version.go:73.2,74.12 2 1 +github.com/alexandre-daubois/ember/internal/app/version.go:77.69,79.16 2 5 +github.com/alexandre-daubois/ember/internal/app/version.go:79.16,81.3 1 0 +github.com/alexandre-daubois/ember/internal/app/version.go:82.2,85.16 3 5 +github.com/alexandre-daubois/ember/internal/app/version.go:85.16,87.3 1 0 +github.com/alexandre-daubois/ember/internal/app/version.go:88.2,88.15 1 5 +github.com/alexandre-daubois/ember/internal/app/version.go:88.15,88.40 1 5 +github.com/alexandre-daubois/ember/internal/app/version.go:90.2,90.38 1 5 +github.com/alexandre-daubois/ember/internal/app/version.go:90.38,92.3 1 1 +github.com/alexandre-daubois/ember/internal/app/version.go:94.2,95.68 2 4 +github.com/alexandre-daubois/ember/internal/app/version.go:95.68,97.3 1 0 +github.com/alexandre-daubois/ember/internal/app/version.go:98.2,98.21 1 4 +github.com/alexandre-daubois/ember/internal/app/wait.go:16.45,32.55 2 37 +github.com/alexandre-daubois/ember/internal/app/wait.go:32.55,39.47 6 0 +github.com/alexandre-daubois/ember/internal/app/wait.go:39.47,41.5 1 0 +github.com/alexandre-daubois/ember/internal/app/wait.go:43.4,44.13 2 0 +github.com/alexandre-daubois/ember/internal/app/wait.go:44.13,46.5 1 0 +github.com/alexandre-daubois/ember/internal/app/wait.go:47.4,47.53 1 0 +github.com/alexandre-daubois/ember/internal/app/wait.go:51.2,53.12 2 37 +github.com/alexandre-daubois/ember/internal/app/wait.go:56.115,58.23 2 6 +github.com/alexandre-daubois/ember/internal/app/wait.go:58.23,61.3 2 3 +github.com/alexandre-daubois/ember/internal/app/wait.go:63.2,68.6 4 3 +github.com/alexandre-daubois/ember/internal/app/wait.go:68.6,69.10 1 8 +github.com/alexandre-daubois/ember/internal/app/wait.go:70.21,71.62 1 2 +github.com/alexandre-daubois/ember/internal/app/wait.go:72.19,74.25 2 6 +github.com/alexandre-daubois/ember/internal/app/wait.go:74.25,77.5 2 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:23.68,24.17 1 56 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:24.17,26.3 1 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:27.2,27.43 1 56 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:30.71,31.21 1 135 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:31.21,33.3 1 3 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:34.2,35.73 2 132 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:38.90,41.12 3 85 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:41.12,43.3 1 78 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:45.2,46.31 2 7 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:46.31,48.3 1 117 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:49.2,54.28 5 7 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:57.55,60.2 2 6 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:62.38,64.2 1 5 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:66.50,69.61 3 223 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:69.61,71.3 1 12 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:72.2,72.11 1 223 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:72.11,74.3 1 2 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:77.59,79.12 2 24 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:79.12,81.3 1 0 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:82.2,82.12 1 24 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:82.12,84.3 1 12 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:85.2,88.16 4 12 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:88.16,90.3 1 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:91.2,92.52 2 11 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:99.103,101.21 2 10 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:101.21,103.3 1 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:105.2,110.50 5 9 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:110.50,111.40 1 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:111.40,113.4 1 2 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:117.2,117.61 1 7 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:121.86,122.20 1 13 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:122.20,124.3 1 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:125.2,125.20 1 12 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:125.20,127.3 1 2 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:129.2,130.25 2 10 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:130.25,132.3 1 29 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:134.2,135.25 2 10 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:135.25,137.12 2 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:137.12,139.4 1 1 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:140.3,143.5 1 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:145.2,145.14 1 10 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:150.78,151.23 1 38 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:151.23,153.3 1 0 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:155.2,156.16 2 38 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:156.16,158.3 1 8 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:160.2,163.28 2 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:163.28,164.31 1 72 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:164.31,165.12 1 42 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:169.3,171.12 3 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:171.12,174.4 2 26 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:177.3,177.34 1 30 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:177.34,179.4 1 5 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:182.3,183.23 2 25 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:183.23,185.4 1 0 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:187.3,187.78 1 25 +github.com/alexandre-daubois/ember/internal/model/percentiles.go:190.2,190.43 1 0 +github.com/alexandre-daubois/ember/internal/model/state.go:28.36,29.11 1 8 +github.com/alexandre-daubois/ember/internal/model/state.go:30.19,31.17 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:32.20,33.18 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:34.17,35.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:36.18,37.16 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:38.20,39.18 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:40.22,41.20 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:42.10,43.17 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:47.37,48.35 1 22 +github.com/alexandre-daubois/ember/internal/model/state.go:48.35,49.13 1 91 +github.com/alexandre-daubois/ember/internal/model/state.go:49.13,51.4 1 21 +github.com/alexandre-daubois/ember/internal/model/state.go:53.2,53.20 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:56.37,57.35 1 22 +github.com/alexandre-daubois/ember/internal/model/state.go:57.35,58.13 1 91 +github.com/alexandre-daubois/ember/internal/model/state.go:58.13,60.4 1 21 +github.com/alexandre-daubois/ember/internal/model/state.go:62.2,62.20 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:82.40,83.11 1 7 +github.com/alexandre-daubois/ember/internal/model/state.go:84.21,85.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:86.21,87.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:88.26,89.21 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:90.21,91.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:92.21,93.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:94.21,95.15 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:96.10,97.16 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:101.45,102.39 1 22 +github.com/alexandre-daubois/ember/internal/model/state.go:102.39,103.13 1 91 +github.com/alexandre-daubois/ember/internal/model/state.go:103.13,105.4 1 21 +github.com/alexandre-daubois/ember/internal/model/state.go:107.2,107.19 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:110.45,111.39 1 15 +github.com/alexandre-daubois/ember/internal/model/state.go:111.39,112.13 1 63 +github.com/alexandre-daubois/ember/internal/model/state.go:112.13,114.4 1 14 +github.com/alexandre-daubois/ember/internal/model/state.go:116.2,116.19 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:158.36,159.26 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:159.26,161.3 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:165.39,169.22 4 10 +github.com/alexandre-daubois/ember/internal/model/state.go:169.22,174.34 5 10 +github.com/alexandre-daubois/ember/internal/model/state.go:174.34,176.43 2 10 +github.com/alexandre-daubois/ember/internal/model/state.go:176.43,179.5 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:180.4,180.34 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:182.3,182.32 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:182.32,184.41 2 2 +github.com/alexandre-daubois/ember/internal/model/state.go:184.41,186.29 2 2 +github.com/alexandre-daubois/ember/internal/model/state.go:186.29,189.6 2 2 +github.com/alexandre-daubois/ember/internal/model/state.go:190.5,190.25 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:190.25,193.6 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:194.5,196.19 3 2 +github.com/alexandre-daubois/ember/internal/model/state.go:198.4,198.30 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:200.3,200.21 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:202.2,202.26 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:202.26,204.36 2 4 +github.com/alexandre-daubois/ember/internal/model/state.go:204.36,206.29 2 4 +github.com/alexandre-daubois/ember/internal/model/state.go:206.29,209.5 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:210.4,210.29 1 4 +github.com/alexandre-daubois/ember/internal/model/state.go:210.29,213.5 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:216.2,216.11 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:219.48,220.26 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:220.26,222.3 1 48 +github.com/alexandre-daubois/ember/internal/model/state.go:224.2,224.32 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:224.32,231.3 6 3 +github.com/alexandre-daubois/ember/internal/model/state.go:233.2,237.40 5 77 +github.com/alexandre-daubois/ember/internal/model/state.go:243.65,244.22 1 83 +github.com/alexandre-daubois/ember/internal/model/state.go:244.22,246.3 1 49 +github.com/alexandre-daubois/ember/internal/model/state.go:247.2,247.138 1 34 +github.com/alexandre-daubois/ember/internal/model/state.go:247.138,249.3 1 3 +github.com/alexandre-daubois/ember/internal/model/state.go:250.2,250.117 1 31 +github.com/alexandre-daubois/ember/internal/model/state.go:250.117,252.3 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:253.2,253.14 1 30 +github.com/alexandre-daubois/ember/internal/model/state.go:261.68,262.22 1 77 +github.com/alexandre-daubois/ember/internal/model/state.go:262.22,264.3 1 48 +github.com/alexandre-daubois/ember/internal/model/state.go:266.2,267.56 2 29 +github.com/alexandre-daubois/ember/internal/model/state.go:267.56,269.3 1 21 +github.com/alexandre-daubois/ember/internal/model/state.go:271.2,271.57 1 29 +github.com/alexandre-daubois/ember/internal/model/state.go:271.57,273.56 2 21 +github.com/alexandre-daubois/ember/internal/model/state.go:273.56,274.12 1 13 +github.com/alexandre-daubois/ember/internal/model/state.go:277.3,278.16 2 8 +github.com/alexandre-daubois/ember/internal/model/state.go:278.16,284.44 2 7 +github.com/alexandre-daubois/ember/internal/model/state.go:284.44,286.5 1 5 +github.com/alexandre-daubois/ember/internal/model/state.go:287.4,288.55 2 7 +github.com/alexandre-daubois/ember/internal/model/state.go:293.49,294.22 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:294.22,296.3 1 0 +github.com/alexandre-daubois/ember/internal/model/state.go:298.2,300.56 2 80 +github.com/alexandre-daubois/ember/internal/model/state.go:300.56,301.15 1 48 +github.com/alexandre-daubois/ember/internal/model/state.go:301.15,303.4 1 15 +github.com/alexandre-daubois/ember/internal/model/state.go:303.9,303.25 1 33 +github.com/alexandre-daubois/ember/internal/model/state.go:303.25,305.4 1 32 +github.com/alexandre-daubois/ember/internal/model/state.go:308.2,308.46 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:308.46,310.3 1 29 +github.com/alexandre-daubois/ember/internal/model/state.go:314.2,314.116 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:314.116,316.9 2 2 +github.com/alexandre-daubois/ember/internal/model/state.go:316.9,322.4 5 2 +github.com/alexandre-daubois/ember/internal/model/state.go:323.8,323.33 1 78 +github.com/alexandre-daubois/ember/internal/model/state.go:323.33,325.9 2 78 +github.com/alexandre-daubois/ember/internal/model/state.go:325.9,330.4 4 4 +github.com/alexandre-daubois/ember/internal/model/state.go:333.2,333.23 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:333.23,335.3 1 51 +github.com/alexandre-daubois/ember/internal/model/state.go:337.2,338.14 2 29 +github.com/alexandre-daubois/ember/internal/model/state.go:338.14,340.3 1 4 +github.com/alexandre-daubois/ember/internal/model/state.go:343.2,344.46 2 25 +github.com/alexandre-daubois/ember/internal/model/state.go:344.46,347.3 2 9 +github.com/alexandre-daubois/ember/internal/model/state.go:348.2,348.47 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:348.47,351.3 2 8 +github.com/alexandre-daubois/ember/internal/model/state.go:354.2,354.70 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:354.70,359.3 4 4 +github.com/alexandre-daubois/ember/internal/model/state.go:363.2,363.38 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:363.38,365.3 1 13 +github.com/alexandre-daubois/ember/internal/model/state.go:367.2,370.20 3 12 +github.com/alexandre-daubois/ember/internal/model/state.go:370.20,373.3 2 11 +github.com/alexandre-daubois/ember/internal/model/state.go:375.2,376.21 2 12 +github.com/alexandre-daubois/ember/internal/model/state.go:376.21,378.3 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:380.2,380.10 1 12 +github.com/alexandre-daubois/ember/internal/model/state.go:383.52,384.59 1 80 +github.com/alexandre-daubois/ember/internal/model/state.go:384.59,386.3 1 56 +github.com/alexandre-daubois/ember/internal/model/state.go:388.2,389.23 2 24 +github.com/alexandre-daubois/ember/internal/model/state.go:389.23,391.3 1 8 +github.com/alexandre-daubois/ember/internal/model/state.go:393.2,394.50 2 24 +github.com/alexandre-daubois/ember/internal/model/state.go:394.50,401.33 2 25 +github.com/alexandre-daubois/ember/internal/model/state.go:401.33,403.4 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:404.3,404.32 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:404.32,406.4 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:408.3,408.37 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:408.37,409.54 1 9 +github.com/alexandre-daubois/ember/internal/model/state.go:409.54,412.23 3 8 +github.com/alexandre-daubois/ember/internal/model/state.go:412.23,415.6 2 8 +github.com/alexandre-daubois/ember/internal/model/state.go:417.5,418.24 2 8 +github.com/alexandre-daubois/ember/internal/model/state.go:418.24,420.6 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:422.5,425.71 3 8 +github.com/alexandre-daubois/ember/internal/model/state.go:425.71,427.12 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:427.12,433.7 5 1 +github.com/alexandre-daubois/ember/internal/model/state.go:436.5,436.63 1 8 +github.com/alexandre-daubois/ember/internal/model/state.go:436.63,438.12 2 1 +github.com/alexandre-daubois/ember/internal/model/state.go:438.12,444.7 5 1 +github.com/alexandre-daubois/ember/internal/model/state.go:449.3,449.30 1 25 +github.com/alexandre-daubois/ember/internal/model/state.go:451.2,451.15 1 24 +github.com/alexandre-daubois/ember/internal/model/state.go:454.87,455.31 1 13 +github.com/alexandre-daubois/ember/internal/model/state.go:455.31,457.3 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:458.2,459.38 2 3 +github.com/alexandre-daubois/ember/internal/model/state.go:459.38,462.16 3 6 +github.com/alexandre-daubois/ember/internal/model/state.go:462.16,464.4 1 5 +github.com/alexandre-daubois/ember/internal/model/state.go:466.2,466.21 1 3 +github.com/alexandre-daubois/ember/internal/model/state.go:466.21,468.3 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:469.2,469.14 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:472.85,473.31 1 13 +github.com/alexandre-daubois/ember/internal/model/state.go:473.31,475.3 1 10 +github.com/alexandre-daubois/ember/internal/model/state.go:476.2,477.36 2 3 +github.com/alexandre-daubois/ember/internal/model/state.go:477.36,480.16 3 6 +github.com/alexandre-daubois/ember/internal/model/state.go:480.16,482.4 1 5 +github.com/alexandre-daubois/ember/internal/model/state.go:484.2,484.21 1 3 +github.com/alexandre-daubois/ember/internal/model/state.go:484.21,486.3 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:487.2,487.14 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:490.43,495.9 4 4 +github.com/alexandre-daubois/ember/internal/model/state.go:496.16,497.45 1 2 +github.com/alexandre-daubois/ember/internal/model/state.go:498.17,499.45 1 1 +github.com/alexandre-daubois/ember/internal/model/state.go:500.10,501.34 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:84.49,85.17 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:85.17,87.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:89.2,91.23 3 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:91.23,93.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:95.2,96.25 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:96.25,98.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:100.2,109.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:112.37,123.2 9 8 +github.com/alexandre-daubois/ember/internal/ui/app.go:125.25,126.27 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:126.27,127.23 1 9 +github.com/alexandre-daubois/ember/internal/ui/app.go:127.23,130.4 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:142.30,144.38 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:144.38,146.38 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:146.38,148.11 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:148.11,150.5 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:151.4,151.40 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:154.2,154.27 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:157.56,158.27 1 91 +github.com/alexandre-daubois/ember/internal/ui/app.go:159.18,160.26 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:161.25,164.16 3 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:165.27,167.16 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:168.15,169.29 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:169.29,171.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:172.3,173.47 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:174.16,178.23 4 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:178.23,180.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:181.3,181.22 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:181.22,182.50 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:182.50,184.5 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:186.4,190.27 4 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:190.27,197.42 7 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:197.42,199.6 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:199.11,201.6 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:202.5,202.18 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:205.4,208.16 4 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:208.16,213.5 4 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:214.4,215.32 2 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:215.32,217.5 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:217.10,219.5 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:220.4,227.43 7 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:227.43,230.5 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:231.4,233.57 2 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:233.57,235.5 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:236.4,237.57 2 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:237.57,239.5 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:240.4,242.37 2 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:242.37,244.5 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:246.3,246.16 1 89 +github.com/alexandre-daubois/ember/internal/ui/app.go:247.24,248.21 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:248.21,250.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:250.9,252.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:253.3,253.16 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:255.2,255.15 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:258.29,259.18 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:259.18,261.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:263.2,266.35 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:266.35,268.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:270.2,272.15 3 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:272.15,274.29 2 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:274.29,276.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:278.2,282.28 4 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:282.28,283.59 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:283.59,285.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:286.3,286.22 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:286.22,288.23 2 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:288.23,290.5 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:293.2,299.21 6 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:300.21,302.42 2 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:302.42,304.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:304.9,310.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:311.16,313.40 2 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:313.40,315.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:315.9,317.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:320.2,321.20 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:321.20,323.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:323.8,323.25 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:323.25,325.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:327.2,328.26 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:328.26,330.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:332.2,333.25 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:333.25,336.26 3 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:336.26,338.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:339.3,340.136 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:341.8,342.23 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:342.23,344.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:345.3,346.23 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:346.23,348.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:349.3,349.30 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:352.2,354.26 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:354.26,355.30 1 4 +github.com/alexandre-daubois/ember/internal/ui/app.go:355.30,356.46 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:356.46,358.18 2 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:358.18,361.6 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:362.5,363.61 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:365.9,365.54 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:365.54,368.17 3 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:368.17,371.5 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:372.4,373.60 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:377.2,377.34 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:377.34,379.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:381.2,381.24 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:381.24,383.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:385.2,385.13 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:388.62,389.16 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:390.18,391.32 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:392.18,393.32 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:394.26,395.33 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:396.17,397.31 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:398.16,399.30 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:400.10,401.30 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:405.67,406.22 1 4 +github.com/alexandre-daubois/ember/internal/ui/app.go:407.18,408.22 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:409.21,410.21 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:411.11,413.20 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:415.2,415.15 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:418.66,419.22 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:420.18,421.22 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:422.21,423.21 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:425.2,425.15 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:428.66,429.22 1 24 +github.com/alexandre-daubois/ember/internal/ui/app.go:430.21,431.21 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:432.13,434.16 2 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:435.11,436.22 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:436.22,438.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:439.3,439.16 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:440.11,441.22 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:441.22,443.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:444.3,444.16 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:445.17,446.19 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:446.19,448.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:449.19,451.18 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:452.14,453.15 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:454.13,456.14 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:456.14,458.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:459.3,459.17 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:460.14,462.19 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:462.19,464.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:465.16,467.18 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:468.11,469.30 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:469.30,471.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:471.9,473.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:474.11,475.30 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:475.30,477.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:477.9,479.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:480.11,481.23 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:482.15,483.22 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:484.11,485.35 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:485.35,487.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:488.11,490.16 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:491.11,493.21 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:494.11,496.20 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:498.2,498.15 1 15 +github.com/alexandre-daubois/ember/internal/ui/app.go:501.68,502.22 1 9 +github.com/alexandre-daubois/ember/internal/ui/app.go:503.18,504.20 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:505.17,506.19 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:506.19,508.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:509.19,511.18 2 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:512.14,513.15 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:514.13,516.14 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:516.14,518.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:519.3,519.17 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:520.14,522.19 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:522.19,524.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:525.16,527.18 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:528.11,529.35 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:529.35,531.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:532.11,534.20 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:536.2,536.15 1 9 +github.com/alexandre-daubois/ember/internal/ui/app.go:539.68,540.22 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:541.13,543.16 2 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:544.15,546.15 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:547.19,548.24 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:548.24,550.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:551.10,552.29 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:552.29,555.4 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:557.2,557.15 1 6 +github.com/alexandre-daubois/ember/internal/ui/app.go:560.69,561.22 1 5 +github.com/alexandre-daubois/ember/internal/ui/app.go:562.16,565.26 3 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:566.10,568.16 2 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:570.2,570.15 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:573.60,574.28 1 27 +github.com/alexandre-daubois/ember/internal/ui/app.go:574.28,576.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:577.2,578.20 2 27 +github.com/alexandre-daubois/ember/internal/ui/app.go:578.20,580.3 1 13 +github.com/alexandre-daubois/ember/internal/ui/app.go:581.2,583.28 3 14 +github.com/alexandre-daubois/ember/internal/ui/app.go:583.28,587.55 1 35 +github.com/alexandre-daubois/ember/internal/ui/app.go:587.55,589.4 1 15 +github.com/alexandre-daubois/ember/internal/ui/app.go:591.2,591.15 1 14 +github.com/alexandre-daubois/ember/internal/ui/app.go:594.51,596.20 2 96 +github.com/alexandre-daubois/ember/internal/ui/app.go:596.20,598.3 1 95 +github.com/alexandre-daubois/ember/internal/ui/app.go:599.2,601.26 3 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:601.26,602.51 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:602.51,604.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:606.2,606.15 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:609.30,611.12 2 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:611.12,613.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:614.2,614.11 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:617.29,618.21 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:619.16,620.32 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:621.10,622.34 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:626.29,628.21 2 101 +github.com/alexandre-daubois/ember/internal/ui/app.go:629.16,630.33 1 93 +github.com/alexandre-daubois/ember/internal/ui/app.go:631.10,632.35 1 8 +github.com/alexandre-daubois/ember/internal/ui/app.go:634.2,635.17 2 101 +github.com/alexandre-daubois/ember/internal/ui/app.go:635.17,637.3 1 93 +github.com/alexandre-daubois/ember/internal/ui/app.go:638.2,638.24 1 101 +github.com/alexandre-daubois/ember/internal/ui/app.go:638.24,640.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:643.48,644.29 1 4 +github.com/alexandre-daubois/ember/internal/ui/app.go:644.29,646.3 1 3 +github.com/alexandre-daubois/ember/internal/ui/app.go:647.2,648.63 2 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:648.63,650.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:651.2,651.10 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:654.34,658.2 3 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:660.32,661.61 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:661.61,663.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:666.33,667.24 1 1 +github.com/alexandre-daubois/ember/internal/ui/app.go:667.24,670.3 2 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:673.35,674.24 1 2 +github.com/alexandre-daubois/ember/internal/ui/app.go:674.24,675.41 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:675.41,677.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:678.3,678.28 1 0 +github.com/alexandre-daubois/ember/internal/ui/app.go:682.66,688.2 2 1 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:13.151,14.16 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:14.16,16.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:17.2,17.22 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:17.22,19.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:21.2,30.11 7 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:30.11,32.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:33.2,33.12 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:33.12,35.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:37.2,38.51 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:38.51,39.33 1 6 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:39.33,40.12 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:42.3,43.15 2 6 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:43.15,45.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:45.9,45.25 1 4 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:45.25,47.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:50.2,51.19 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:51.19,54.28 3 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:54.28,56.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:57.3,57.43 1 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:57.43,59.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:60.8,62.20 2 7 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:62.20,64.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:66.2,67.24 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:67.24,70.3 2 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:71.2,72.13 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:72.13,74.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:75.2,79.9 3 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:80.27,81.38 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:82.26,83.36 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:84.10,85.36 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:87.2,94.15 6 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:94.15,96.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:97.2,98.9 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:99.25,100.73 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:101.24,102.71 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:104.2,108.21 4 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:108.21,111.3 2 1 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:112.2,112.19 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:112.19,114.34 2 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:114.34,116.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:117.3,117.47 1 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:120.2,125.19 4 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:125.19,137.3 7 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:139.2,139.78 1 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:139.78,141.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:143.2,145.55 2 10 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:148.62,149.33 1 7 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:149.33,151.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:152.2,162.24 6 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:167.39,168.43 1 273 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:168.43,170.3 1 18 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:171.2,171.11 1 255 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:174.65,176.28 2 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:176.28,177.33 1 11 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:177.33,179.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:181.2,181.14 1 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:184.34,185.17 1 27 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:185.17,187.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:188.2,188.34 1 25 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:193.66,194.21 1 36 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:194.21,196.3 1 31 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:198.2,199.27 2 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:199.27,200.17 1 28 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:200.17,202.4 1 20 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:205.2,207.27 3 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:207.27,209.3 1 13 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:210.2,210.27 1 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:210.27,211.18 1 28 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:211.18,213.12 2 4 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:215.3,216.14 2 24 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:216.14,218.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:219.3,219.30 1 24 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:219.30,221.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:222.3,222.32 1 24 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:224.2,224.19 1 5 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:227.63,229.2 1 25 +github.com/alexandre-daubois/ember/internal/ui/dashboard.go:231.66,246.2 10 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:25.113,27.16 2 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:27.16,29.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:31.2,36.50 4 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:36.50,38.26 2 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:38.26,40.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:41.3,41.50 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:42.8,44.24 2 8 +github.com/alexandre-daubois/ember/internal/ui/detail.go:44.24,46.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:47.3,47.48 1 8 +github.com/alexandre-daubois/ember/internal/ui/detail.go:50.2,53.67 3 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:53.67,56.28 3 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:56.28,58.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:59.3,59.25 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:59.25,62.4 2 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:63.3,63.29 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:63.29,66.4 2 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:67.8,67.58 1 8 +github.com/alexandre-daubois/ember/internal/ui/detail.go:67.58,71.3 3 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:73.2,73.45 1 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:73.45,76.24 3 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:76.24,78.70 2 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:78.70,80.5 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:82.3,82.25 1 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:82.25,84.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:87.2,95.31 7 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:95.31,97.3 1 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:99.2,99.50 1 10 +github.com/alexandre-daubois/ember/internal/ui/detail.go:102.58,103.9 1 13 +github.com/alexandre-daubois/ember/internal/ui/detail.go:104.16,105.45 1 4 +github.com/alexandre-daubois/ember/internal/ui/detail.go:106.19,107.45 1 7 +github.com/alexandre-daubois/ember/internal/ui/detail.go:108.10,109.66 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:113.52,116.19 3 49 +github.com/alexandre-daubois/ember/internal/ui/detail.go:116.19,118.3 1 48 +github.com/alexandre-daubois/ember/internal/ui/detail.go:119.2,119.36 1 49 +github.com/alexandre-daubois/ember/internal/ui/detail.go:122.43,124.2 1 62 +github.com/alexandre-daubois/ember/internal/ui/detail.go:126.45,127.9 1 9 +github.com/alexandre-daubois/ember/internal/ui/detail.go:128.25,129.44 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:130.22,131.41 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:132.24,133.43 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:134.27,135.43 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:136.10,137.47 1 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:141.63,142.38 1 8 +github.com/alexandre-daubois/ember/internal/ui/detail.go:142.38,144.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/detail.go:145.2,145.19 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:145.19,147.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:149.2,150.26 2 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:150.26,152.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:154.2,156.29 3 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:156.29,157.17 1 7 +github.com/alexandre-daubois/ember/internal/ui/detail.go:157.17,159.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:160.3,160.17 1 7 +github.com/alexandre-daubois/ember/internal/ui/detail.go:160.17,162.4 1 7 +github.com/alexandre-daubois/ember/internal/ui/detail.go:165.2,166.25 2 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:166.25,167.23 1 9 +github.com/alexandre-daubois/ember/internal/ui/detail.go:167.23,169.12 2 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:171.3,172.14 2 9 +github.com/alexandre-daubois/ember/internal/ui/detail.go:172.14,174.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:175.3,175.30 1 9 +github.com/alexandre-daubois/ember/internal/ui/detail.go:175.30,177.4 1 0 +github.com/alexandre-daubois/ember/internal/ui/detail.go:178.3,178.32 1 9 +github.com/alexandre-daubois/ember/internal/ui/detail.go:180.2,180.37 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:183.49,184.24 1 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:184.24,186.3 1 3 +github.com/alexandre-daubois/ember/internal/ui/detail.go:187.2,187.16 1 2 +github.com/alexandre-daubois/ember/internal/ui/detail.go:187.16,189.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:190.2,190.31 1 1 +github.com/alexandre-daubois/ember/internal/ui/detail.go:193.34,195.13 2 21 +github.com/alexandre-daubois/ember/internal/ui/detail.go:195.13,197.3 1 14 +github.com/alexandre-daubois/ember/internal/ui/detail.go:198.2,199.13 2 7 +github.com/alexandre-daubois/ember/internal/ui/detail.go:199.13,201.3 1 5 +github.com/alexandre-daubois/ember/internal/ui/detail.go:202.2,202.31 1 2 +github.com/alexandre-daubois/ember/internal/ui/graph.go:20.108,26.19 2 5 +github.com/alexandre-daubois/ember/internal/ui/graph.go:26.19,31.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/graph.go:33.2,36.38 3 5 +github.com/alexandre-daubois/ember/internal/ui/graph.go:36.38,37.24 1 14 +github.com/alexandre-daubois/ember/internal/ui/graph.go:37.24,42.4 1 9 +github.com/alexandre-daubois/ember/internal/ui/graph.go:42.9,44.4 1 5 +github.com/alexandre-daubois/ember/internal/ui/graph.go:47.2,47.54 1 5 +github.com/alexandre-daubois/ember/internal/ui/graph.go:50.64,51.24 1 33 +github.com/alexandre-daubois/ember/internal/ui/graph.go:51.24,53.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/graph.go:55.2,57.18 3 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:57.18,59.3 1 18 +github.com/alexandre-daubois/ember/internal/ui/graph.go:59.8,61.3 1 9 +github.com/alexandre-daubois/ember/internal/ui/graph.go:63.2,64.21 2 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:64.21,66.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/graph.go:68.2,69.28 2 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:69.28,71.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/graph.go:73.2,74.25 2 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:74.25,76.3 1 124 +github.com/alexandre-daubois/ember/internal/ui/graph.go:78.2,79.29 2 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:79.29,80.17 1 124 +github.com/alexandre-daubois/ember/internal/ui/graph.go:80.17,82.4 1 117 +github.com/alexandre-daubois/ember/internal/ui/graph.go:84.2,85.20 2 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:85.20,87.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/graph.go:89.2,95.57 1 27 +github.com/alexandre-daubois/ember/internal/ui/graph.go:95.57,97.4 1 486 +github.com/alexandre-daubois/ember/internal/ui/graph.go:100.2,102.73 3 27 +github.com/alexandre-daubois/ember/internal/ui/help.go:10.119,12.12 2 12 +github.com/alexandre-daubois/ember/internal/ui/help.go:12.12,14.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/help.go:16.2,22.27 3 12 +github.com/alexandre-daubois/ember/internal/ui/help.go:22.27,24.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/help.go:24.8,26.3 1 8 +github.com/alexandre-daubois/ember/internal/ui/help.go:28.2,34.32 2 12 +github.com/alexandre-daubois/ember/internal/ui/help.go:34.32,36.3 1 8 +github.com/alexandre-daubois/ember/internal/ui/help.go:37.2,45.29 3 12 +github.com/alexandre-daubois/ember/internal/ui/help.go:45.29,47.3 1 104 +github.com/alexandre-daubois/ember/internal/ui/help.go:48.2,50.47 2 12 +github.com/alexandre-daubois/ember/internal/ui/help.go:53.83,75.19 4 2 +github.com/alexandre-daubois/ember/internal/ui/help.go:75.19,77.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/help.go:78.2,83.58 2 2 +github.com/alexandre-daubois/ember/internal/ui/help.go:83.58,86.30 3 4 +github.com/alexandre-daubois/ember/internal/ui/help.go:86.30,88.4 1 27 +github.com/alexandre-daubois/ember/internal/ui/help.go:89.3,89.35 1 4 +github.com/alexandre-daubois/ember/internal/ui/help.go:92.2,94.79 3 2 +github.com/alexandre-daubois/ember/internal/ui/history.go:15.38,20.2 1 55 +github.com/alexandre-daubois/ember/internal/ui/history.go:22.47,22.102 1 440 +github.com/alexandre-daubois/ember/internal/ui/history.go:23.47,23.102 1 90 +github.com/alexandre-daubois/ember/internal/ui/history.go:24.47,24.102 1 90 +github.com/alexandre-daubois/ember/internal/ui/history.go:25.49,27.2 1 90 +github.com/alexandre-daubois/ember/internal/ui/history.go:28.48,28.105 1 90 +github.com/alexandre-daubois/ember/internal/ui/history.go:30.64,33.37 3 20 +github.com/alexandre-daubois/ember/internal/ui/history.go:33.37,35.3 1 5 +github.com/alexandre-daubois/ember/internal/ui/history.go:36.2,36.26 1 20 +github.com/alexandre-daubois/ember/internal/ui/history.go:39.68,40.30 1 91 +github.com/alexandre-daubois/ember/internal/ui/history.go:40.30,41.38 1 4 +github.com/alexandre-daubois/ember/internal/ui/history.go:41.38,43.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/history.go:47.65,48.25 1 91 +github.com/alexandre-daubois/ember/internal/ui/history.go:48.25,49.39 1 86 +github.com/alexandre-daubois/ember/internal/ui/history.go:49.39,51.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/history.go:55.75,57.28 2 1350 +github.com/alexandre-daubois/ember/internal/ui/history.go:57.28,59.3 1 285 +github.com/alexandre-daubois/ember/internal/ui/history.go:60.2,60.16 1 1350 +github.com/alexandre-daubois/ember/internal/ui/history.go:63.48,64.23 1 12 +github.com/alexandre-daubois/ember/internal/ui/history.go:64.23,66.3 1 12 +github.com/alexandre-daubois/ember/internal/ui/history.go:67.2,67.33 1 0 +github.com/alexandre-daubois/ember/internal/ui/history.go:70.58,71.16 1 178 +github.com/alexandre-daubois/ember/internal/ui/history.go:71.16,73.3 1 10 +github.com/alexandre-daubois/ember/internal/ui/history.go:74.2,76.35 3 168 +github.com/alexandre-daubois/ember/internal/ui/history.go:76.35,78.3 1 39 +github.com/alexandre-daubois/ember/internal/ui/history.go:79.2,79.24 1 168 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:13.75,15.16 2 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:15.16,17.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:19.2,22.17 3 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:22.17,24.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:25.2,25.23 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:25.23,27.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:28.2,34.25 6 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:34.25,36.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:38.2,38.21 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:38.21,40.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:42.2,44.22 3 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:44.22,49.3 4 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:50.2,50.19 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:50.19,52.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:53.2,53.41 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:53.41,55.3 1 14 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:57.2,57.15 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:57.15,64.3 6 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:66.2,66.28 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:66.28,70.28 4 3 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:70.28,74.39 4 6 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:74.39,76.5 1 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:76.10,76.29 1 5 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:76.29,78.5 1 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:79.4,79.82 1 6 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:83.2,83.28 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:83.28,87.35 4 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:87.35,89.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:90.3,91.29 2 1 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:91.29,93.21 2 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:93.21,95.5 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:96.4,96.93 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:100.2,100.51 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:100.51,103.27 3 3 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:103.27,105.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:106.3,106.28 1 3 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:106.28,108.4 1 2 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:111.2,119.31 7 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:119.31,121.3 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:123.2,123.50 1 16 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:126.41,127.12 1 20 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:127.12,129.3 1 14 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:130.2,130.43 1 6 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:138.65,140.32 2 7 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:140.32,142.3 1 11 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:143.2,143.58 1 7 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:143.58,145.3 1 10 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:146.2,146.16 1 7 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:154.62,156.36 2 5 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:156.36,158.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:159.2,159.54 1 5 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:159.54,161.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/hostdetail.go:162.2,162.16 1 5 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:24.40,26.12 2 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:26.12,28.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:29.2,29.10 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:34.137,37.85 2 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:37.85,38.22 1 42 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:38.22,40.4 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:41.3,41.12 1 42 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:41.12,43.4 1 36 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:44.3,44.39 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:47.2,61.26 4 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:61.26,63.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:65.2,65.44 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:65.44,71.15 3 54 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:71.15,73.4 1 29 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:74.3,74.59 1 54 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:77.2,78.66 2 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:81.118,83.17 2 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:83.17,85.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:86.2,86.25 1 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:86.25,88.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:90.2,91.15 2 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:91.15,93.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:94.2,95.19 2 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:95.19,97.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:98.2,109.14 9 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:109.14,111.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:113.2,122.14 9 11 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:122.14,125.3 2 6 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:127.2,128.11 2 5 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:128.11,130.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:132.2,134.16 3 5 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:134.16,136.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:137.2,137.16 1 5 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:137.16,139.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:141.2,150.11 2 5 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:150.11,152.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:153.2,153.12 1 4 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:156.65,158.33 2 55 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:158.33,159.31 1 80 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:159.31,161.4 1 24 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:163.2,163.14 1 55 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:166.35,167.12 1 56 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:167.12,169.3 1 30 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:170.2,170.15 1 26 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:170.15,172.3 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:173.2,173.13 1 23 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:173.13,175.3 1 13 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:176.2,176.31 1 10 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:179.87,183.65 3 104 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:183.65,184.13 1 19 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:185.28,186.36 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:187.28,188.44 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:189.33,190.46 1 2 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:191.28,192.106 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:193.28,194.106 1 2 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:195.28,196.106 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:197.11,198.38 1 3 +github.com/alexandre-daubois/ember/internal/ui/hosttable.go:202.2,202.15 1 104 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:17.29,18.11 1 16 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:19.16,20.17 1 10 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:21.21,22.22 1 6 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:23.10,24.13 1 0 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:28.84,30.25 2 10 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:30.25,32.40 2 16 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:32.40,34.4 1 9 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:35.3,35.18 1 16 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:35.18,37.4 1 10 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:37.9,39.4 1 6 +github.com/alexandre-daubois/ember/internal/ui/tabbar.go:41.2,42.53 2 10 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:35.35,37.12 2 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:37.12,39.3 1 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:40.2,40.10 1 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:43.141,44.23 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:44.23,46.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:48.2,50.81 2 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:50.81,51.22 1 21 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:51.22,53.4 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:54.3,54.12 1 21 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:54.12,56.4 1 9 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:57.3,57.39 1 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:60.2,74.28 6 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:74.28,76.25 2 7 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:76.25,79.21 3 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:79.21,81.5 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:82.4,83.21 2 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:85.3,87.11 3 7 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:90.2,91.66 2 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:94.53,95.40 1 242 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:95.40,97.3 1 11 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:98.2,98.18 1 231 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:101.122,105.9 3 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:106.16,108.20 2 9 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:109.19,111.20 2 7 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:112.10,114.20 2 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:117.2,121.39 4 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:121.39,123.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:124.2,124.36 1 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:124.36,126.22 2 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:126.22,128.4 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:131.2,132.23 2 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:132.23,134.29 2 6 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:134.29,135.60 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:135.60,137.34 2 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:137.34,139.6 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:139.11,139.42 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:139.42,141.6 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:146.2,147.24 2 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:147.24,149.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:151.2,152.14 2 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:152.14,156.3 3 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:156.8,156.18 1 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:156.18,159.3 2 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:161.2,168.14 6 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:168.14,173.3 4 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:173.8,173.18 1 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:173.18,179.3 5 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:181.2,191.14 2 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:191.14,193.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:194.2,194.11 1 12 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:194.11,196.3 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:197.2,197.12 1 8 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:200.123,201.24 1 22 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:201.24,203.3 1 16 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:205.2,205.40 1 22 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:205.40,208.10 3 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:209.35,210.28 1 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:211.33,212.26 1 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:213.11,214.36 1 0 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:218.2,218.51 1 22 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:218.51,221.3 2 5 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:223.2,223.25 1 17 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:226.46,227.9 1 11 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:228.25,229.44 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:230.22,231.41 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:232.24,233.43 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:234.27,235.43 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:236.10,237.47 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:241.67,244.2 2 6 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:246.116,250.72 3 36 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:250.72,251.63 1 114 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:251.63,253.4 1 6 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:254.3,254.13 1 108 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:255.26,256.52 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:257.27,258.56 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:259.24,260.50 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:261.27,262.52 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:263.29,264.54 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:265.25,266.72 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:267.11,268.40 1 90 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:272.2,272.15 1 36 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:275.71,276.40 1 11 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:276.40,278.3 1 6 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:279.2,279.17 1 5 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:279.17,281.3 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:282.2,282.10 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:285.49,286.14 1 4 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:286.14,288.3 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:289.2,289.17 1 3 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:289.17,291.3 1 2 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:292.2,292.10 1 1 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:295.35,296.11 1 15 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:296.11,298.3 1 5 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:299.2,299.34 1 10 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:302.38,304.17 2 15 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:304.17,306.3 1 7 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:307.2,308.22 2 8 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:308.22,309.33 1 53 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:309.33,311.4 1 15 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:312.3,312.35 1 53 +github.com/alexandre-daubois/ember/internal/ui/workerlist.go:314.2,314.23 1 8 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:41.61,54.2 2 45 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:57.59,64.2 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:75.59,76.66 1 9 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:76.66,78.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:80.2,84.19 2 8 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:84.19,86.3 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:88.2,88.23 1 8 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:88.23,90.17 2 4 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:90.17,92.4 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:93.3,94.39 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:94.39,96.4 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:97.3,97.27 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:100.2,100.51 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:100.51,102.17 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:102.17,104.4 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:105.3,105.51 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:108.2,108.23 1 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:111.66,116.16 4 38 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:116.16,118.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:119.2,120.16 2 38 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:120.16,122.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:123.2,129.15 7 38 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:132.44,136.2 3 15 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:138.70,143.16 4 48 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:143.16,145.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:146.2,147.16 2 48 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:147.16,149.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:150.2,150.15 1 48 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:150.15,153.3 2 48 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:154.2,154.38 1 48 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:154.38,156.3 1 42 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:158.2,159.68 2 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:159.68,161.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:163.2,164.28 2 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:164.28,166.3 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:167.2,171.14 5 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:174.46,178.2 3 13 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:180.69,196.11 6 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:196.11,197.21 1 7 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:197.21,199.18 2 7 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:199.18,204.5 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:205.4,206.14 2 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:210.2,210.20 1 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:210.20,212.17 2 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:212.17,217.4 4 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:218.3,222.13 5 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:225.2,225.20 1 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:225.20,227.17 2 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:227.17,232.4 4 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:233.3,234.13 2 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:237.2,239.15 2 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:239.15,244.51 2 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:244.51,246.4 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:247.3,247.65 1 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:247.65,250.34 3 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:250.34,252.20 2 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:252.20,254.29 2 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:254.29,256.7 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:259.4,261.17 3 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:263.3,263.66 1 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:263.66,267.4 3 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:271.2,274.44 4 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:274.44,275.30 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:275.30,276.41 1 8 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:276.41,282.5 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:286.2,297.8 4 40 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:304.56,308.13 4 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:308.13,310.15 2 26 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:310.15,314.4 3 8 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:317.2,320.11 4 35 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:320.11,321.53 1 34 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:321.53,325.4 3 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:329.65,334.16 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:334.16,336.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:337.2,338.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:338.16,340.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:341.2,341.15 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:341.15,344.3 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:345.2,345.38 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:345.38,347.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:348.2,348.12 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:351.82,356.16 4 10 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:356.16,358.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:360.2,361.16 2 10 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:361.16,363.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:364.2,364.15 1 9 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:364.15,364.40 1 9 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:366.2,366.38 1 9 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:366.38,368.3 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:370.2,371.67 2 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:371.67,373.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:374.2,374.20 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:377.82,382.16 4 41 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:382.16,384.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:386.2,387.16 2 41 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:387.16,389.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:390.2,390.15 1 41 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:390.15,390.40 1 41 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:392.2,392.38 1 41 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:392.38,394.3 1 5 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:396.2,396.42 1 36 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:399.99,401.52 2 56 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:401.52,402.18 1 62 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:402.18,405.11 3 6 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:406.22,408.26 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:409.19,409.19 0 4 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:411.4,411.24 1 4 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:413.3,414.17 2 60 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:414.17,416.4 1 53 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:417.3,417.16 1 7 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:419.2,419.21 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:423.64,428.16 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:428.16,430.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:431.2,432.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:432.16,434.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:435.2,437.12 3 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:441.78,446.16 4 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:446.16,448.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:449.2,450.16 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:450.16,452.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:453.2,453.15 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:453.15,456.3 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:458.2,458.38 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:458.38,460.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:463.2,464.64 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:464.64,466.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:467.2,467.35 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:471.64,476.16 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:476.16,478.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:479.2,482.16 3 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:482.16,484.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:485.2,485.15 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:485.15,488.3 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:489.2,489.38 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:489.38,491.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:492.2,492.12 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:509.93,514.16 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:514.16,516.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:517.2,518.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:518.16,520.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:521.2,521.15 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:521.15,524.3 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:525.2,525.38 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:525.38,527.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:529.2,530.64 2 1 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:530.64,532.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/http.go:533.2,533.18 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:13.64,15.16 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:15.16,17.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:18.2,18.26 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:18.26,20.17 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:20.17,21.12 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:23.3,23.60 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:23.60,25.4 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:26.3,27.17 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:27.17,28.12 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:30.3,30.63 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:30.63,32.4 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:34.2,34.54 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:37.59,39.16 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:39.16,41.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:42.2,42.26 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:42.26,44.17 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:44.17,45.12 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:47.3,48.81 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:48.81,50.4 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:52.2,52.49 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:62.33,66.2 3 1 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:68.49,72.13 2 45 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:72.13,73.52 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:73.52,75.43 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:75.43,78.5 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:81.2,81.10 1 45 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:84.76,85.19 1 41 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:85.19,87.3 1 41 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:89.2,90.16 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:90.16,93.3 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:95.2,96.16 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:96.16,99.3 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:101.2,102.16 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:102.16,105.3 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:107.2,112.43 5 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:112.43,114.21 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:114.21,116.4 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/os.go:119.2,128.8 3 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:14.76,15.15 1 77 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:15.15,16.31 1 77 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:16.31,19.4 2 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:22.2,24.21 3 77 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:24.21,26.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:28.2,41.67 5 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:41.67,41.83 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:42.66,42.81 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:43.67,43.83 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:44.73,44.95 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:45.74,45.97 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:46.68,46.86 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:47.69,47.88 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:48.72,48.93 1 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:51.2,51.31 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:51.31,53.10 2 608 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:53.10,54.12 1 544 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:56.3,56.37 1 64 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:56.37,58.20 2 128 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:58.20,59.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:61.4,62.33 2 128 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:67.2,80.49 10 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:80.49,82.25 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:82.25,84.4 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:85.3,95.4 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:98.2,98.18 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:101.77,103.9 2 152 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:103.9,105.3 1 120 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:106.2,107.36 2 32 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:107.36,109.3 1 64 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:110.2,110.14 1 32 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:113.110,115.9 2 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:115.9,117.3 1 53 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:118.2,120.36 3 23 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:120.36,121.38 1 43 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:121.38,124.36 3 43 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:124.36,126.5 1 85 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:130.2,131.35 2 23 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:131.35,133.3 1 46 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:134.2,136.38 2 23 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:139.45,140.58 1 63 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:140.58,141.34 1 78 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:141.34,143.4 1 21 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:144.3,144.34 1 57 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:144.34,146.4 1 56 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:147.3,147.11 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:151.78,153.38 2 533 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:153.38,155.3 1 484 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:156.2,156.40 1 49 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:159.41,160.33 1 323 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:160.33,162.3 1 119 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:163.2,163.35 1 204 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:163.35,165.3 1 203 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:166.2,166.35 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:166.35,168.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:169.2,169.10 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:172.52,173.33 1 477 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:173.33,174.26 1 815 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:174.26,176.4 1 310 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:178.2,178.11 1 167 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:181.95,183.9 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:183.9,185.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:186.2,187.36 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:187.36,188.48 1 4 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:188.48,189.48 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:189.48,191.5 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:194.2,194.21 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:194.21,196.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:197.2,197.14 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:200.99,202.9 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:202.9,204.3 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:205.2,206.36 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:206.36,208.15 2 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:208.15,209.12 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:211.3,211.48 1 3 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:211.48,212.48 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:212.48,214.5 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:217.2,217.21 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:217.21,219.3 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:220.2,220.14 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:223.41,224.41 1 132 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:224.41,226.3 1 44 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:227.2,227.32 1 88 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:230.84,233.48 2 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:233.48,235.10 2 124 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:235.10,238.4 2 35 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:239.3,239.12 1 124 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:242.2,243.58 2 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:243.58,244.37 1 24 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:244.37,246.18 2 53 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:246.18,247.13 1 4 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:249.4,252.49 4 49 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:252.49,253.49 1 30 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:253.49,256.6 2 30 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:258.4,258.55 1 49 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:258.55,260.5 1 5 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:264.2,265.68 2 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:265.68,266.37 1 23 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:266.37,268.18 2 43 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:268.18,269.13 1 4 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:271.4,272.16 2 39 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:272.16,273.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:275.4,279.36 4 39 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:279.36,280.50 1 24 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:280.50,281.50 1 18 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:281.50,283.7 1 18 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:287.4,287.31 1 39 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:287.31,289.5 1 33 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:290.4,290.36 1 39 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:290.36,292.5 1 79 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:296.2,296.35 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:296.35,299.29 3 33 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:299.29,301.4 1 67 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:302.3,302.34 1 33 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:305.2,306.69 2 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:306.69,307.37 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:307.37,309.18 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:309.18,310.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:312.4,313.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:313.16,314.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:316.4,320.35 4 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:320.35,322.5 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:323.4,323.36 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:323.36,325.5 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:329.2,329.39 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:329.39,332.29 3 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:332.29,334.4 1 6 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:335.3,335.30 1 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:338.2,338.63 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:338.63,339.37 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:339.37,341.18 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:341.18,342.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:344.4,345.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:345.16,346.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:348.4,350.55 3 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:354.2,354.62 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:354.62,355.37 1 1 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:355.37,357.18 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:357.18,358.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:360.4,361.16 2 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:361.16,362.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:364.4,366.54 3 2 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:370.2,370.64 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:370.64,371.37 1 8 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:371.37,373.18 2 11 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:373.18,374.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:376.4,376.51 1 11 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:380.2,380.62 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:380.62,381.37 1 12 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:381.37,383.18 2 19 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:383.18,384.13 1 0 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:386.4,386.48 1 19 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:390.2,390.14 1 76 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:393.73,395.9 2 128 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:395.9,398.3 2 16 +github.com/alexandre-daubois/ember/internal/fetcher/prometheus.go:399.2,399.11 1 128 diff --git a/demo-traffic.sh b/demo-traffic.sh new file mode 100755 index 0000000..9e0b5e1 --- /dev/null +++ b/demo-traffic.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +# +# Generates traffic against the local Caddy/FrankenPHP setup +# for demo purposes (Ember TUI recording). +# +# Usage: ./demo-traffic.sh [duration_seconds] [concurrency] +# Default: 60 seconds, 8 concurrent workers +# +# Requires: curl + +set -euo pipefail + +DURATION=${1:-60} +CONCURRENCY=${2:-8} + +MAIN="https://localhost" +APP="https://app.localhost:8443" +API="https://api.localhost:9443" + +ROUTES=( + "$MAIN/" + "$MAIN/" + "$MAIN/" + "$MAIN/blog/" + "$MAIN/blog/" + "$MAIN/blog/page/1" + "$MAIN/blog/rss.xml" + "$MAIN/blog/search?q=lorem" + "$MAIN/login" + + "$APP/" + "$APP/" + "$APP/blog/" + "$APP/blog/page/1" + "$APP/blog/search?q=test" + + "$API/" + "$API/blog/" + "$API/blog/rss.xml" + + "$MAIN/leak/" + "$APP/leak/leaker" + + "$MAIN/nonexistent" + "$API/this-does-not-exist" +) + +ROUTE_COUNT=${#ROUTES[@]} + +worker() { + local end=$((SECONDS + DURATION)) + while [ $SECONDS -lt $end ]; do + curl -sk -o /dev/null "${ROUTES[$((RANDOM % ROUTE_COUNT))]}" 2>/dev/null || true + sleep "0.0$((RANDOM % 5 + 1))" + done +} + +echo "Sending traffic for ${DURATION}s with ${CONCURRENCY} concurrent workers..." +echo "Press Ctrl+C to stop early." + +pids=() +for ((i = 0; i < CONCURRENCY; i++)); do + worker & + pids+=($!) +done + +trap 'kill "${pids[@]}" 2>/dev/null; exit 0' INT TERM + +wait "${pids[@]}" 2>/dev/null +echo "Done." diff --git a/docs/index.md b/docs/index.md index d6f3b8a..fd077eb 100644 --- a/docs/index.md +++ b/docs/index.md @@ -11,6 +11,7 @@ Ember is a real-time monitoring tool for [Caddy](https://caddyserver.com/) and [ - [CLI Reference](cli-reference.md): Flags, subcommands, keybindings, and shell completions - [JSON Output](json-output.md): Streaming JSONL mode for scripting - [Prometheus Export](prometheus-export.md): Metrics endpoint, health checks, and daemon mode +- [Plugins](plugins.md): Building custom plugins for Ember - [Docker](docker.md): Running Ember in a container - [AI Agent Skills](skills.md): Skills for AI coding agents (Claude Code, Cursor, Copilot, etc.) - [Troubleshooting](troubleshooting.md): Common issues and how to resolve them diff --git a/docs/plugins.md b/docs/plugins.md new file mode 100644 index 0000000..cf1a1e3 --- /dev/null +++ b/docs/plugins.md @@ -0,0 +1,498 @@ +# Plugin Development Guide + +> **EXPERIMENTAL**: the plugin API is not yet stable. Interfaces, method signatures, and behavior may change in any future release. + +Ember supports compile-time plugins that let you: + +- Add **custom tabs** to the TUI for visualizing metrics from additional Caddy modules (rate limiters, WAF, cache, custom middleware) +- Contribute **Prometheus metrics** to Ember's `/metrics` endpoint +- Or both + +Plugins follow the same pattern as Caddy modules: blank imports + `init()` registration. There is no runtime plugin loading. Users build a custom binary that includes the plugins they need. + +## Quick Start + +Here is a minimal plugin that adds a "stats" tab showing a tick counter: + +```go +package stats + +import ( + "context" + "fmt" + "sync/atomic" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/alexandre-daubois/ember/pkg/plugin" +) + +func init() { + plugin.Register(&statsPlugin{}) +} + +type statsPlugin struct { + counter atomic.Int64 + count int64 +} + +func (p *statsPlugin) Name() string { return "stats" } + +func (p *statsPlugin) Init(_ context.Context, _ plugin.PluginConfig) error { + return nil +} + +// Fetcher: called on every tick. +func (p *statsPlugin) Fetch(_ context.Context) (any, error) { + return p.counter.Add(1), nil +} + +// Renderer: provides a TUI tab. +func (p *statsPlugin) Update(data any, _, _ int) plugin.Renderer { + p.count = data.(int64) + return p +} + +func (p *statsPlugin) View(_, _ int) string { + if p.count == 0 { + return " Waiting for data..." + } + return fmt.Sprintf("\n Tick counter: %d\n", p.count) +} + +func (p *statsPlugin) HandleKey(_ tea.KeyMsg) bool { return false } +func (p *statsPlugin) StatusCount() string { return fmt.Sprintf("%d", p.count) } +func (p *statsPlugin) HelpBindings() []plugin.HelpBinding { return nil } +``` + +Notice how `View` handles the "no data yet" state: before the first `Fetch` completes, Ember already calls `View` on your plugin. Always handle zero/nil data gracefully. + +### Build and run + +Create a small main file that imports your plugin with a blank import, then build: + +```go +package main + +import ( + "fmt" + "os" + + "github.com/alexandre-daubois/ember" + + _ "github.com/example/ember-stats" // your plugin +) + +func main() { + if err := ember.Run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +``` + +```bash +go mod init my-ember +go mod tidy +go build -o ember-custom . +./ember-custom +``` + +The "stats" tab appears alongside Caddy (and FrankenPHP if detected). Press `Tab` or the corresponding number key to switch to it. + +## Configuration + +Plugins receive configuration via environment variables: + +``` +EMBER_PLUGIN_{UPPERCASED_NAME}_{KEY}=value +``` + +For example, a plugin named "ratelimit": + +```bash +export EMBER_PLUGIN_RATELIMIT_MAX_RPS=1000 +export EMBER_PLUGIN_RATELIMIT_WINDOW=60s +``` + +These are passed to `Init()` as `PluginConfig.Options` with lowercased keys: + +```go +func (p *myPlugin) Init(ctx context.Context, cfg plugin.PluginConfig) error { + maxRPS := cfg.Options["max_rps"] + window := cfg.Options["window"] + // ... +} +``` + +`PluginConfig` also carries `CaddyAddr`, the Caddy admin API address Ember is connected to. + +## Lifecycle + +1. **Registration**: `plugin.Register()` is called from `init()` at import time +2. **Initialization**: `Init(ctx, cfg)` is called before the TUI or daemon starts. If it fails, already-initialized plugins that implement `Closer` are closed in reverse order +3. **Runtime**: `Fetch` is called on every tick with a cancellable context. In TUI mode, `Update`/`View`/`HandleKey` are called from the event loop. In daemon mode (`--daemon`), only `Fetch` and `WriteMetrics` are called +4. **Shutdown**: `Close()` is called on plugins that implement the `Closer` interface, in reverse registration order + +## Error Handling + +Ember handles plugin errors at every stage: + +**`Init` returns an error**: startup aborts. Already-initialized plugins that implement `Closer` are closed in reverse order. The error is printed to stderr. + +**`Fetch` returns an error**: the previous data is preserved (Ember does not overwrite it with nil). In TUI mode, the error is displayed in the tab when `View` returns an empty string. In daemon mode, the error is logged and the previous data continues to be served on `/metrics`. Fetch will be retried on the next tick. + +**`Update` or `View` panics**: Ember recovers from the panic and shows "plugin error: ..." in the tab instead of crashing. The same applies to `HandleKey`, `StatusCount`, and `HelpBindings`. + +**`WriteMetrics` panics**: Ember recovers and writes a `# plugin WriteMetrics panic: ...` comment line to the output. The rest of the `/metrics` response (core metrics and other plugins) is unaffected. + +## Adding Prometheus Export + +To expose Prometheus metrics on the `/metrics` endpoint, implement the `Exporter` interface on your plugin: + +```go +func (p *statsPlugin) WriteMetrics(w io.Writer, data any, prefix string) { + if data == nil { + return + } + count := data.(int64) + name := "stats_tick_total" + if prefix != "" { + name = prefix + "_" + name + } + fmt.Fprintf(w, "# TYPE %s counter\n", name) + fmt.Fprintf(w, "%s %d\n", name, count) +} +``` + +When `--expose :9191` is passed, `curl localhost:9191/metrics | grep stats` shows the exported metric. `WriteMetrics` is called on every `/metrics` HTTP request with the latest data from `Fetch`. + +## Export-only Plugins + +A plugin does not need to provide a TUI tab. A `Fetcher` + `Exporter` plugin (without `Renderer`) collects data and exposes Prometheus metrics without adding any tab to the interface: + +```go +package cachemetrics + +import ( + "context" + "fmt" + "io" + "net/http" + + "github.com/alexandre-daubois/ember/pkg/plugin" +) + +func init() { + plugin.Register(&cachePlugin{}) +} + +type cachePlugin struct { + endpoint string +} + +type cacheStats struct { + Hits int64 + Misses int64 +} + +func (p *cachePlugin) Name() string { return "cache" } + +func (p *cachePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error { + p.endpoint = cfg.Options["endpoint"] + if p.endpoint == "" { + p.endpoint = "http://localhost:6379/stats" + } + return nil +} + +func (p *cachePlugin) Fetch(_ context.Context) (any, error) { + resp, err := http.Get(p.endpoint) + if err != nil { + return nil, err + } + defer resp.Body.Close() + // Parse response into cacheStats... + return &cacheStats{Hits: 1000, Misses: 50}, nil +} + +func (p *cachePlugin) WriteMetrics(w io.Writer, data any, prefix string) { + stats, ok := data.(*cacheStats) + if !ok || stats == nil { + return + } + name := "cache_hits_total" + if prefix != "" { + name = prefix + "_" + name + } + fmt.Fprintf(w, "# TYPE %s counter\n", name) + fmt.Fprintf(w, "%s %d\n", name, stats.Hits) + + name = "cache_misses_total" + if prefix != "" { + name = prefix + "_" + name + } + fmt.Fprintf(w, "# TYPE %s counter\n", name) + fmt.Fprintf(w, "%s %d\n", name, stats.Misses) +} +``` + +## Separating the Renderer + +The Quick Start example keeps everything in one struct. For plugins with complex state, you can return a separate `Renderer` from `Update` (Elm architecture): + +```go +func (p *statsPlugin) Update(data any, _, _ int) plugin.Renderer { + return &statsRenderer{count: data.(int64), ts: time.Now()} +} + +// Initial View, before the first Fetch completes +func (p *statsPlugin) View(_, _ int) string { return " Waiting for data..." } + +type statsRenderer struct { + count int64 + ts time.Time +} + +func (r *statsRenderer) Update(data any, _, _ int) plugin.Renderer { + return &statsRenderer{count: data.(int64), ts: time.Now()} +} + +func (r *statsRenderer) View(_, _ int) string { + return fmt.Sprintf("\n Tick counter: %d\n Last update: %s\n", + r.count, r.ts.Format("15:04:05")) +} + +func (r *statsRenderer) HandleKey(msg tea.KeyMsg) bool { return msg.String() == "x" } + +func (r *statsRenderer) StatusCount() string { + return fmt.Sprintf("%d ticks", r.count) +} + +func (r *statsRenderer) HelpBindings() []plugin.HelpBinding { + return []plugin.HelpBinding{{Key: "x", Desc: "Example action"}} +} +``` + +When you use this pattern, Ember calls `View`, `HandleKey`, etc. on the plugin struct itself until the first `Update` returns a new `Renderer`. After that, all calls go to the returned `Renderer`. This is why the plugin struct has a simple `View` that handles the "no data yet" state. + +## Releasing Resources + +If your plugin holds resources (connections, goroutines, file handles), implement the `Closer` interface: + +```go +type Closer interface { + Close() error +} +``` + +Ember calls `Close()` in reverse registration order when the application exits. If a plugin fails during initialization, already-initialized plugins that implement `Closer` are closed automatically. + +## Interface Reference + +All plugin interfaces live in `pkg/plugin/`. + +### Plugin (required) + +```go +type Plugin interface { + Name() string + Init(ctx context.Context, cfg PluginConfig) error +} +``` + +### PluginConfig + +```go +type PluginConfig struct { + CaddyAddr string + Options map[string]string +} +``` + +### Fetcher (optional) + +```go +type Fetcher interface { + Fetch(ctx context.Context) (any, error) +} +``` + +Called on every poll interval. The returned data is opaque to Ember core: only your own `Renderer` and `Exporter` interpret it. + +### Renderer (optional) + +```go +type Renderer interface { + Update(data any, width, height int) Renderer + View(width, height int) string + HandleKey(msg tea.KeyMsg) bool + StatusCount() string + HelpBindings() []HelpBinding +} +``` + +- `Update`: receives the latest data from `Fetch` and terminal dimensions. Returns an updated `Renderer` +- `View`: renders the tab content as a string +- `HandleKey`: handles key presses when the plugin tab is active. Return `true` if the key was consumed +- `StatusCount`: returns a string shown as a badge in the tab bar (e.g., "12 blocked"). Empty string means no badge +- `HelpBindings`: returns keybindings shown in the `?` help overlay + +### Exporter (optional) + +```go +type Exporter interface { + WriteMetrics(w io.Writer, data any, prefix string) +} +``` + +Writes Prometheus-format metric lines. + +### HelpBinding + +```go +type HelpBinding struct { + Key string + Desc string +} +``` + +### Closer (optional) + +```go +type Closer interface { + Close() error +} +``` + +### MetricsSubscriber (optional) + +```go +type MetricsSubscriber interface { + OnMetrics(snap *metrics.Snapshot) +} +``` + +Called synchronously after each successful core fetch, before plugin `Fetch` calls. The snapshot contains Caddy and FrankenPHP metrics already parsed by Ember. The snapshot must not be modified. + +Import `"github.com/alexandre-daubois/ember/pkg/metrics"` to access the `Snapshot` and `MetricsSnapshot` types. + +This avoids the need for plugins to make their own `/metrics` requests to Caddy when they need access to the same core metrics Ember already collects. + +#### Accessing custom metrics + +`MetricsSnapshot.Extra` contains all Prometheus metric families from the `/metrics` endpoint that Ember's core parser did not consume. If your Caddy module registers custom metrics with Caddy's Prometheus collector, they will be available here as `*dto.MetricFamily` values (from `github.com/prometheus/client_model/go`): + +```go +func (p *myPlugin) OnMetrics(snap *metrics.Snapshot) { + fam, ok := snap.Metrics.Extra["mymodule_requests_total"] + if !ok { + return + } + for _, m := range fam.GetMetric() { + // extract label values and counters as needed + } +} +``` + +When there are no extra metrics, `Extra` is nil. + +### MultiRenderer (optional) + +```go +type TabDescriptor struct { + Key string + Name string +} + +type MultiRenderer interface { + Tabs() []TabDescriptor + RendererForTab(key string) Renderer +} +``` + +Implement `MultiRenderer` instead of `Renderer` when your plugin needs multiple TUI tabs. Each tab gets its own `Renderer`, but all tabs share a single `Fetch` call. `Tabs()` is called once after `Init()` to determine the number and order of tabs. `RendererForTab` is called once per tab to create its initial `Renderer`. + +If a plugin implements both `Renderer` and `MultiRenderer`, `MultiRenderer` takes priority. + +### Availability (optional) + +```go +type Availability interface { + Available() bool +} +``` + +Implement `Availability` when your plugin's tab(s) should be shown or hidden based on runtime conditions. For example, a plugin compiled into a custom build but talking to a Caddy instance that does not have the corresponding module enabled. + +`Available()` is checked after each successful `Fetch`. When it returns `false`, the plugin's tab(s) are removed from the tab bar. When it returns `true`, they are re-added. If `Available()` panics, the tab stays visible (fail-open). + +### TabAvailability (optional) + +```go +type TabAvailability interface { + TabAvailable(key string) bool +} +``` + +Implement `TabAvailability` in a `MultiRenderer` plugin when individual tabs should be shown or hidden independently. For example, a WAF plugin with "Rules" and "Analytics" tabs can hide the "Analytics" tab when the analytics module is not active on the Caddy instance. + +`TabAvailable(key)` is checked after each successful `Fetch` for every tab key returned by `Tabs()`. When it returns `false` for a key, that tab is removed from the tab bar. When it returns `true`, the tab is re-added. If `TabAvailable` panics, the tab stays visible (fail-open). + +If a plugin also implements `Availability`, it acts as a master switch: when `Available()` returns `false`, all tabs are hidden regardless of `TabAvailable` results. When `Available()` returns `true`, `TabAvailable` controls each tab individually. + +`TabAvailability` is ignored for single-Renderer plugins (there is only one tab, so `Availability` is sufficient). + +## Reusing Prometheus Parsing + +The `pkg/metrics` package exposes the same Prometheus text parser that Ember uses internally: + +```go +import "github.com/alexandre-daubois/ember/pkg/metrics" + +snap, err := metrics.ParsePrometheus(reader) +``` + +This returns a `MetricsSnapshot` with all Caddy and FrankenPHP metrics parsed. The package also exposes all the data types (`Snapshot`, `MetricsSnapshot`, `HostMetrics`, `WorkerMetrics`, etc.) for plugins that need to work with core metrics. + +## Reserved Keybindings + +The following keys are handled by Ember core and **never** reach your plugin's `HandleKey`: + +| Key | Action | +|--------------------------|-----------------------| +| `q`, `Ctrl+C` | Quit | +| `Tab` | Switch tab | +| `1`-`9` | Jump to tab by number | +| `?` | Toggle help overlay | +| `g` | Toggle graphs | +| `p` | Pause / resume | + +The following keys are used by core tabs (Caddy, FrankenPHP) but **forwarded** to your plugin when its tab is active: + +| Key | Core tab behavior | +|--------------------------|----------------------------------| +| `↑` / `↓` / `j` / `k` | Navigate list | +| `Home` / `End` | Jump to first / last | +| `PgUp` / `PgDn` | Page navigation | +| `s` / `S` | Cycle sort field | +| `Enter` | Open detail panel | +| `/` | Open filter | +| `r` | Restart workers (FrankenPHP) | + +All other keys reach your plugin's `HandleKey` directly. + +## Plugin Name Rules + +Plugin names must: +- Not be empty +- Not contain whitespace (spaces, tabs, newlines) +- Not contain underscores (use hyphens instead) +- Be unique across all registered plugins +- Be distinguishable after hyphen removal: names like `my-plugin` and `myplugin` map to the same environment variable prefix (`EMBER_PLUGIN_MYPLUGIN_`), so they must not coexist + +`Register()` panics at startup if any of these rules are violated. + +## Panic Safety + +Ember wraps all plugin calls (`Fetch`, `Update`, `View`, `HandleKey`, `StatusCount`, `HelpBindings`, `WriteMetrics`) with panic recovery. If your plugin panics, Ember displays an error in the tab instead of crashing. For `WriteMetrics`, a comment line is written to the output instead of crashing the `/metrics` endpoint. diff --git a/ember.go b/ember.go new file mode 100644 index 0000000..2cac068 --- /dev/null +++ b/ember.go @@ -0,0 +1,38 @@ +// Package ember provides the public entry point for running Ember, +// a real-time monitoring tool for Caddy and FrankenPHP. +// +// EXPERIMENTAL: the plugin API (pkg/plugin, pkg/metrics) is not yet +// stable and may change in any future release. +// +// Plugin authors use this package to build custom Ember binaries: +// +// import ( +// "github.com/alexandre-daubois/ember" +// _ "github.com/myorg/ember-myplugin" +// ) +// +// func main() { +// ember.Run() +// } +package ember + +import ( + "os" + + "github.com/alexandre-daubois/ember/internal/app" +) + +// Version is set at build time via -ldflags. +// When empty, it defaults to "dev". +var Version = "dev" + +// Run starts Ember with command-line arguments from os.Args. +func Run() error { + return app.Run(os.Args[1:], Version) +} + +// RunWithArgs starts Ember with the given arguments and version string. +// This is useful for testing or embedding Ember with custom arguments. +func RunWithArgs(args []string, version string) error { + return app.Run(args, version) +} diff --git a/internal/app/daemon.go b/internal/app/daemon.go index ecefe76..e5067d5 100644 --- a/internal/app/daemon.go +++ b/internal/app/daemon.go @@ -6,11 +6,13 @@ import ( "log/slog" "net/http" "strings" + "sync" "time" "github.com/alexandre-daubois/ember/internal/exporter" "github.com/alexandre-daubois/ember/internal/fetcher" "github.com/alexandre-daubois/ember/internal/model" + "github.com/alexandre-daubois/ember/pkg/plugin" ) const errorThrottleInterval = 30 * time.Second @@ -82,12 +84,13 @@ func newMetricsHandler(holder *exporter.StateHolder, cfg *config) http.Handler { return handler } -func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error { +func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config, plugins []plugin.Plugin) error { ctx, cancel := context.WithCancelCause(ctx) defer cancel(nil) holder := &exporter.StateHolder{} var state model.State + dPlugins := newDaemonPlugins(plugins) srv := &http.Server{Addr: cfg.expose, Handler: newMetricsHandler(holder, cfg)} @@ -110,7 +113,9 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error { } errThrottle.recover(log) state.Update(snap) - holder.Store(state.CopyForExport()) + notifyDaemonSubscribers(dPlugins, snap) + fetchDaemonPlugins(ctx, dPlugins, log) + holder.StoreAll(state.CopyForExport(), daemonPluginExports(dPlugins)) } poll() @@ -140,3 +145,80 @@ func runDaemon(ctx context.Context, f fetcher.Fetcher, cfg *config) error { } } } + +type daemonPlugin struct { + p plugin.Plugin + name string + fetcher plugin.Fetcher + exporter plugin.Exporter + data any +} + +func newDaemonPlugins(plugins []plugin.Plugin) []daemonPlugin { + var dps []daemonPlugin + for _, p := range plugins { + dp := daemonPlugin{p: p, name: p.Name()} + if f, ok := p.(plugin.Fetcher); ok { + dp.fetcher = f + } + if e, ok := p.(plugin.Exporter); ok { + dp.exporter = e + } + if dp.fetcher != nil || dp.exporter != nil { + dps = append(dps, dp) + } + } + return dps +} + +// fetchDaemonPlugins fetches data for all daemon plugins concurrently. +// Writes to dps[i].data happen inside goroutines, but wg.Wait() ensures +// all writes complete before this function returns. The caller (poll) +// only reads dps after this returns, so no additional synchronization is needed. +func fetchDaemonPlugins(ctx context.Context, dps []daemonPlugin, log *slog.Logger) { + var wg sync.WaitGroup + for i := range dps { + if dps[i].fetcher == nil { + continue + } + wg.Add(1) + go func(i int) { + defer wg.Done() + data, err := plugin.SafeFetch(ctx, dps[i].fetcher) + if err != nil { + log.Warn("plugin fetch failed", "plugin", dps[i].name, "err", err) + } else { + dps[i].data = data + } + }(i) + } + wg.Wait() +} + +func notifyDaemonSubscribers(dps []daemonPlugin, snap *fetcher.Snapshot) { + for _, dp := range dps { + if sub, ok := dp.p.(plugin.MetricsSubscriber); ok { + safeOnMetrics(sub, snap) + } + } +} + +func safeOnMetrics(sub plugin.MetricsSubscriber, snap *fetcher.Snapshot) { + defer func() { + recover() //nolint:errcheck // fire-and-forget: don't crash Ember if a subscriber panics + }() + sub.OnMetrics(snap) +} + +func daemonPluginExports(dps []daemonPlugin) []plugin.PluginExport { + var exports []plugin.PluginExport + for _, dp := range dps { + if dp.exporter != nil { + exports = append(exports, plugin.PluginExport{ + Exporter: dp.exporter, + Data: dp.data, + }) + } + } + return exports +} diff --git a/internal/app/daemon_test.go b/internal/app/daemon_test.go index d081744..1b5a26b 100644 --- a/internal/app/daemon_test.go +++ b/internal/app/daemon_test.go @@ -3,11 +3,13 @@ package app import ( "bytes" "context" + "io" "log/slog" "testing" "time" "github.com/alexandre-daubois/ember/internal/fetcher" + "github.com/alexandre-daubois/ember/pkg/plugin" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -122,3 +124,159 @@ func TestReloadTLS_NonHTTPFetcher(t *testing.T) { assert.Contains(t, buf.String(), "not supported") } + +type daemonFetchPlugin struct { + testPlugin + fetchData any + fetchErr error +} + +func (p *daemonFetchPlugin) Fetch(_ context.Context) (any, error) { + return p.fetchData, p.fetchErr +} + +type daemonExportPlugin struct { + daemonFetchPlugin +} + +func (p *daemonExportPlugin) WriteMetrics(w io.Writer, _ any, _ string) { + _, _ = io.WriteString(w, "daemon_test_metric 1\n") +} + +type daemonPanicFetchPlugin struct { + testPlugin +} + +func (p *daemonPanicFetchPlugin) Fetch(_ context.Context) (any, error) { + panic("daemon fetch boom") +} + +func TestNewDaemonPlugins_Empty(t *testing.T) { + dps := newDaemonPlugins(nil) + assert.Nil(t, dps) +} + +func TestNewDaemonPlugins_SkipsBarePlugin(t *testing.T) { + bare := &testPlugin{name: "bare"} + dps := newDaemonPlugins([]plugin.Plugin{bare}) + assert.Empty(t, dps) +} + +func TestNewDaemonPlugins_IncludesFetcher(t *testing.T) { + p := &daemonFetchPlugin{testPlugin: testPlugin{name: "fetchy"}, fetchData: "data"} + dps := newDaemonPlugins([]plugin.Plugin{p}) + require.Len(t, dps, 1) + assert.Equal(t, "fetchy", dps[0].name) + assert.NotNil(t, dps[0].fetcher) + assert.Nil(t, dps[0].exporter) +} + +func TestNewDaemonPlugins_IncludesExporter(t *testing.T) { + p := &daemonExportPlugin{daemonFetchPlugin: daemonFetchPlugin{testPlugin: testPlugin{name: "exporty"}}} + dps := newDaemonPlugins([]plugin.Plugin{p}) + require.Len(t, dps, 1) + assert.NotNil(t, dps[0].fetcher) + assert.NotNil(t, dps[0].exporter) +} + +func TestSafeFetch_Normal(t *testing.T) { + p := &daemonFetchPlugin{testPlugin: testPlugin{name: "ok"}, fetchData: "hello"} + data, err := plugin.SafeFetch(context.Background(), p) + assert.NoError(t, err) + assert.Equal(t, "hello", data) +} + +func TestSafeFetch_RecoversPanic(t *testing.T) { + p := &daemonPanicFetchPlugin{testPlugin: testPlugin{name: "panic"}} + data, err := plugin.SafeFetch(context.Background(), p) + assert.Nil(t, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during Fetch") +} + +func TestFetchDaemonPlugins_UpdatesData(t *testing.T) { + var buf bytes.Buffer + log := testLogger(&buf) + + dps := []daemonPlugin{ + {name: "a", fetcher: &daemonFetchPlugin{fetchData: "result"}}, + } + fetchDaemonPlugins(context.Background(), dps, log) + assert.Equal(t, "result", dps[0].data) + assert.Empty(t, buf.String()) +} + +func TestFetchDaemonPlugins_LogsOnPanic(t *testing.T) { + var buf bytes.Buffer + log := testLogger(&buf) + + dps := []daemonPlugin{ + {name: "panicky", fetcher: &daemonPanicFetchPlugin{testPlugin: testPlugin{name: "panicky"}}}, + } + fetchDaemonPlugins(context.Background(), dps, log) + assert.Nil(t, dps[0].data) + assert.Contains(t, buf.String(), "plugin fetch failed") + assert.Contains(t, buf.String(), "panicky") +} + +func TestFetchDaemonPlugins_SkipsNilFetcher(t *testing.T) { + var buf bytes.Buffer + log := testLogger(&buf) + + dps := []daemonPlugin{ + {name: "no-fetch", fetcher: nil, data: "old"}, + } + fetchDaemonPlugins(context.Background(), dps, log) + assert.Equal(t, "old", dps[0].data) +} + +func TestDaemonPluginExports_IncludesOnlyExporters(t *testing.T) { + exp := &daemonExportPlugin{} + dps := []daemonPlugin{ + {name: "no-export", fetcher: &daemonFetchPlugin{}, data: "x"}, + {name: "has-export", exporter: exp, data: "y"}, + } + exports := daemonPluginExports(dps) + require.Len(t, exports, 1) + assert.Equal(t, "y", exports[0].Data) +} + +func TestDaemonPluginExports_Empty(t *testing.T) { + exports := daemonPluginExports(nil) + assert.Nil(t, exports) +} + +type daemonMetricsSubPlugin struct { + testPlugin + called bool +} + +func (p *daemonMetricsSubPlugin) OnMetrics(_ *fetcher.Snapshot) { p.called = true } + +type daemonPanicMetricsSubPlugin struct { + testPlugin +} + +func (p *daemonPanicMetricsSubPlugin) OnMetrics(_ *fetcher.Snapshot) { panic("onmetrics boom") } + +func TestNotifyDaemonSubscribers_CallsOnMetrics(t *testing.T) { + sub := &daemonMetricsSubPlugin{testPlugin: testPlugin{name: "sub"}} + dps := []daemonPlugin{{p: sub, name: "sub"}} + + notifyDaemonSubscribers(dps, &fetcher.Snapshot{}) + assert.True(t, sub.called) +} + +func TestNotifyDaemonSubscribers_PanicDoesNotCrash(t *testing.T) { + panicSub := &daemonPanicMetricsSubPlugin{testPlugin: testPlugin{name: "panic-sub"}} + normalSub := &daemonMetricsSubPlugin{testPlugin: testPlugin{name: "normal-sub"}} + dps := []daemonPlugin{ + {p: panicSub, name: "panic-sub"}, + {p: normalSub, name: "normal-sub"}, + } + + assert.NotPanics(t, func() { + notifyDaemonSubscribers(dps, &fetcher.Snapshot{}) + }) + assert.True(t, normalSub.called, "subscriber after panicking one should still be called") +} diff --git a/internal/app/run.go b/internal/app/run.go index b11dbd8..c6cd962 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -11,6 +11,7 @@ import ( "time" "github.com/alexandre-daubois/ember/internal/fetcher" + "github.com/alexandre-daubois/ember/pkg/plugin" "github.com/spf13/cobra" ) @@ -49,7 +50,7 @@ percentiles, status codes, and more. When FrankenPHP is detected, unlock per-thread introspection, worker management, and memory tracking. Keybindings: - Tab / 1 / 2 Switch between Caddy and FrankenPHP tabs + Tab / 1-9 Switch tab Up / Down / j / k Navigate list Home / End Jump to first / last item PgUp / PgDn Page navigation @@ -105,13 +106,19 @@ Keybindings: hasFrankenPHP := f.DetectFrankenPHP(ctx) f.FetchServerNames(ctx) + plugins, err := initPlugins(ctx, &cfg) + if err != nil { + return err + } + defer closePlugins(plugins) + switch { case cfg.jsonMode: runJSON(ctx, f, cfg.interval, cfg.once, cfg.logger) case cfg.daemon: - return runDaemon(ctx, f, &cfg) + return runDaemon(ctx, f, &cfg, plugins) default: - return runTUI(f, &cfg, hasFrankenPHP, version) + return runTUI(f, &cfg, hasFrankenPHP, version, plugins) } return nil }, @@ -246,3 +253,47 @@ func validate(cfg *config) error { } return nil } + +func initPlugins(ctx context.Context, cfg *config) ([]plugin.Plugin, error) { + all := plugin.All() + if len(all) == 0 { + return nil, nil + } + + var initialized []plugin.Plugin + for _, p := range all { + pcfg := plugin.PluginConfig{ + CaddyAddr: cfg.addr, + Options: pluginEnvOptions(p.Name()), + } + if err := p.Init(ctx, pcfg); err != nil { + closePlugins(initialized) + return nil, fmt.Errorf("plugin %s: %w", p.Name(), err) + } + initialized = append(initialized, p) + } + return initialized, nil +} + +func closePlugins(plugins []plugin.Plugin) { + for i := len(plugins) - 1; i >= 0; i-- { + if c, ok := plugins[i].(plugin.Closer); ok { + _ = c.Close() + } + } +} + +func pluginEnvOptions(name string) map[string]string { + prefix := "EMBER_PLUGIN_" + strings.ToUpper(strings.ReplaceAll(name, "-", "")) + "_" + opts := make(map[string]string) + for _, env := range os.Environ() { + if !strings.HasPrefix(env, prefix) { + continue + } + kv := strings.SplitN(env[len(prefix):], "=", 2) + if len(kv) == 2 { + opts[strings.ToLower(kv[0])] = kv[1] + } + } + return opts +} diff --git a/internal/app/run_test.go b/internal/app/run_test.go index cef516f..c24a379 100644 --- a/internal/app/run_test.go +++ b/internal/app/run_test.go @@ -6,7 +6,9 @@ import ( "testing" "time" + "github.com/alexandre-daubois/ember/pkg/plugin" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestValidate_DaemonRequiresExpose(t *testing.T) { @@ -442,3 +444,154 @@ func TestRun_HelpContainsKeybindings(t *testing.T) { assert.Contains(t, out, "--expose") assert.Contains(t, out, "Examples") } + +type testPlugin struct { + name string + initCfg plugin.PluginConfig + initErr error +} + +func (p *testPlugin) Name() string { return p.name } +func (p *testPlugin) Init(_ context.Context, cfg plugin.PluginConfig) error { + p.initCfg = cfg + return p.initErr +} + +func TestInitPlugins_Empty(t *testing.T) { + plugin.Reset() + cfg := &config{addr: "http://localhost:2019"} + + plugins, err := initPlugins(context.Background(), cfg) + assert.NoError(t, err) + assert.Nil(t, plugins) +} + +func TestInitPlugins_Success(t *testing.T) { + plugin.Reset() + p := &testPlugin{name: "test"} + plugin.Register(p) + + cfg := &config{addr: "http://localhost:2019"} + plugins, err := initPlugins(context.Background(), cfg) + + assert.NoError(t, err) + require.Len(t, plugins, 1) + assert.Equal(t, "http://localhost:2019", p.initCfg.CaddyAddr) +} + +func TestInitPlugins_InitError(t *testing.T) { + plugin.Reset() + plugin.Register(&testPlugin{name: "broken", initErr: assert.AnError}) + + cfg := &config{addr: "http://localhost:2019"} + _, err := initPlugins(context.Background(), cfg) + + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin broken") +} + +func TestPluginEnvOptions(t *testing.T) { + t.Setenv("EMBER_PLUGIN_RATELIMIT_API_KEY", "abc123") + t.Setenv("EMBER_PLUGIN_RATELIMIT_ENDPOINT", "http://localhost:8080") + t.Setenv("EMBER_OTHER_VAR", "ignored") + + opts := pluginEnvOptions("ratelimit") + + assert.Equal(t, "abc123", opts["api_key"]) + assert.Equal(t, "http://localhost:8080", opts["endpoint"]) + assert.NotContains(t, opts, "other_var") +} + +func TestPluginEnvOptions_HyphenatedName(t *testing.T) { + t.Setenv("EMBER_PLUGIN_MYPLUGIN_FOO", "bar") + + opts := pluginEnvOptions("my-plugin") + assert.Equal(t, "bar", opts["foo"]) +} + +func TestPluginEnvOptions_Empty(t *testing.T) { + opts := pluginEnvOptions("nonexistent") + assert.Empty(t, opts) +} + +func TestPluginEnvOptions_ValueWithEquals(t *testing.T) { + t.Setenv("EMBER_PLUGIN_TEST_DSN", "postgres://user:pass@host/db?opt=val") + + opts := pluginEnvOptions("test") + assert.Equal(t, "postgres://user:pass@host/db?opt=val", opts["dsn"]) +} + +func TestInitPlugins_PassesEnvOptions(t *testing.T) { + plugin.Reset() + t.Setenv("EMBER_PLUGIN_MYPLUGIN_KEY", "val") + + p := &testPlugin{name: "myplugin"} + plugin.Register(p) + + cfg := &config{addr: "http://localhost:2019"} + _, err := initPlugins(context.Background(), cfg) + + assert.NoError(t, err) + assert.Equal(t, "val", p.initCfg.Options["key"]) +} + +type closableTestPlugin struct { + testPlugin + closed bool +} + +type closableOrderPlugin struct { + testPlugin + closeFn func() +} + +func (p *closableOrderPlugin) Close() error { + p.closeFn() + return nil +} + +func (p *closableTestPlugin) Close() error { + p.closed = true + return nil +} + +func TestInitPlugins_CleansUpOnFailure(t *testing.T) { + plugin.Reset() + + good := &closableTestPlugin{testPlugin: testPlugin{name: "good"}} + bad := &testPlugin{name: "bad", initErr: assert.AnError} + + plugin.Register(good) + plugin.Register(bad) + + cfg := &config{addr: "http://localhost:2019"} + _, err := initPlugins(context.Background(), cfg) + + require.Error(t, err) + assert.True(t, good.closed, "successfully initialized plugin should be closed on failure") +} + +func TestClosePlugins_SkipsNonCloser(t *testing.T) { + p1 := &testPlugin{name: "first"} + p2 := &testPlugin{name: "second"} + + assert.NotPanics(t, func() { + closePlugins([]plugin.Plugin{p1, p2}) + }) +} + +func TestClosePlugins_ReverseOrder(t *testing.T) { + var order []string + + p1 := &closableOrderPlugin{testPlugin: testPlugin{name: "first"}, closeFn: func() { order = append(order, "first") }} + p2 := &closableOrderPlugin{testPlugin: testPlugin{name: "second"}, closeFn: func() { order = append(order, "second") }} + + closePlugins([]plugin.Plugin{p1, p2}) + assert.Equal(t, []string{"second", "first"}, order) +} + +func TestClosePlugins_Empty(t *testing.T) { + assert.NotPanics(t, func() { + closePlugins(nil) + }) +} diff --git a/internal/app/tui.go b/internal/app/tui.go index 273cb20..92ade9a 100644 --- a/internal/app/tui.go +++ b/internal/app/tui.go @@ -11,23 +11,25 @@ import ( "github.com/alexandre-daubois/ember/internal/fetcher" "github.com/alexandre-daubois/ember/internal/model" "github.com/alexandre-daubois/ember/internal/ui" + "github.com/alexandre-daubois/ember/pkg/plugin" tea "github.com/charmbracelet/bubbletea" ) -func runTUI(f fetcher.Fetcher, cfg *config, hasFrankenPHP bool, version string) error { +func runTUI(f fetcher.Fetcher, cfg *config, hasFrankenPHP bool, version string, plugins []plugin.Plugin) error { uiCfg := ui.Config{ Interval: cfg.interval, SlowThreshold: time.Duration(cfg.slowThreshold) * time.Millisecond, NoColor: cfg.noColor, Version: version, HasFrankenPHP: hasFrankenPHP, + Plugins: plugins, } var srv *http.Server if cfg.expose != "" { holder := &exporter.StateHolder{} - uiCfg.OnStateUpdate = func(s model.State) { - holder.Store(s.CopyForExport()) + uiCfg.OnStateUpdate = func(s model.State, pluginExports []plugin.PluginExport) { + holder.StoreAll(s.CopyForExport(), pluginExports) } srv = &http.Server{Addr: cfg.expose, Handler: newMetricsHandler(holder, cfg)} @@ -49,6 +51,7 @@ func runTUI(f fetcher.Fetcher, cfg *config, hasFrankenPHP bool, version string) } app := ui.NewApp(f, uiCfg) + defer app.Close() p := tea.NewProgram(app, tea.WithAltScreen()) if _, err := p.Run(); err != nil { return err diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 5154a89..8f75442 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -12,11 +12,13 @@ import ( "time" "github.com/alexandre-daubois/ember/internal/model" + "github.com/alexandre-daubois/ember/pkg/plugin" ) type StateHolder struct { - mu sync.RWMutex - state model.State + mu sync.RWMutex + state model.State + pluginExports []plugin.PluginExport } func (h *StateHolder) Store(s model.State) { @@ -25,12 +27,25 @@ func (h *StateHolder) Store(s model.State) { h.mu.Unlock() } +func (h *StateHolder) StoreAll(s model.State, exports []plugin.PluginExport) { + h.mu.Lock() + h.state = s + h.pluginExports = exports + h.mu.Unlock() +} + func (h *StateHolder) Load() model.State { h.mu.RLock() defer h.mu.RUnlock() return h.state } +func (h *StateHolder) loadAll() (model.State, []plugin.PluginExport) { + h.mu.RLock() + defer h.mu.RUnlock() + return h.state, h.pluginExports +} + const prometheusContentType = "text/plain; version=0.0.4; charset=utf-8" func Handler(holder *StateHolder, prefix ...string) http.HandlerFunc { @@ -39,7 +54,7 @@ func Handler(holder *StateHolder, prefix ...string) http.HandlerFunc { p = prefix[0] } return func(w http.ResponseWriter, r *http.Request) { - s := holder.Load() + s, pluginExports := holder.loadAll() if s.Current == nil { http.Error(w, "no data yet", http.StatusServiceUnavailable) return @@ -55,9 +70,24 @@ func Handler(holder *StateHolder, prefix ...string) http.HandlerFunc { writePercentiles(w, &s, p) writeProcessMetrics(w, &s, p) writeReloadMetrics(w, &s, p) + + for _, pe := range pluginExports { + if pe.Exporter != nil && pe.Data != nil { + safeWriteMetrics(w, pe.Exporter, pe.Data, p) + } + } } } +func safeWriteMetrics(w http.ResponseWriter, e plugin.Exporter, data any, prefix string) { + defer func() { + if r := recover(); r != nil { + fmt.Fprintf(w, "# plugin WriteMetrics panic: %v\n", r) + } + }() + e.WriteMetrics(w, data, prefix) +} + func prefixed(prefix, name string) string { if prefix == "" { return name diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index 48180b4..eadac43 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -2,6 +2,7 @@ package exporter import ( "encoding/json" + "io" "net/http" "net/http/httptest" "sync" @@ -10,6 +11,7 @@ import ( "github.com/alexandre-daubois/ember/internal/fetcher" "github.com/alexandre-daubois/ember/internal/model" + "github.com/alexandre-daubois/ember/pkg/plugin" "github.com/prometheus/common/expfmt" prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" @@ -659,6 +661,51 @@ func TestBasicAuth_NoCredentials(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, rec.Code) } +type testExporter struct{} + +func (e *testExporter) WriteMetrics(w io.Writer, _ any, _ string) { + _, _ = io.WriteString(w, "test_plugin_metric 42\n") +} + +type panicExporter struct{} + +func (e *panicExporter) WriteMetrics(_ io.Writer, _ any, _ string) { + panic("exporter boom") +} + +func TestHandler_PluginMetrics(t *testing.T) { + holder := &StateHolder{} + holder.StoreAll(stateWithThreads(nil, nil), []plugin.PluginExport{ + {Exporter: &testExporter{}, Data: "data"}, + }) + + rec := get(holder) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "test_plugin_metric 42") +} + +func TestHandler_PluginWriteMetricsPanic(t *testing.T) { + holder := &StateHolder{} + holder.StoreAll(stateWithThreads(nil, nil), []plugin.PluginExport{ + {Exporter: &panicExporter{}, Data: "data"}, + }) + + rec := get(holder) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "plugin WriteMetrics panic") + assert.Contains(t, rec.Body.String(), "exporter boom") +} + +func TestHandler_PluginPanicDoesNotBreakOtherMetrics(t *testing.T) { + holder := &StateHolder{} + holder.StoreAll(stateWithThreads(nil, nil), []plugin.PluginExport{ + {Exporter: &panicExporter{}, Data: "data"}, + }) + + rec := get(holder) + assert.Contains(t, rec.Body.String(), "process_cpu_percent") +} + func TestBasicAuth_InvalidUser(t *testing.T) { inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/fetcher/fetcher.go b/internal/fetcher/fetcher.go index b2bd580..2f7f3e5 100644 --- a/internal/fetcher/fetcher.go +++ b/internal/fetcher/fetcher.go @@ -3,110 +3,25 @@ package fetcher import ( "context" "time" -) - -type ThreadDebugState struct { - Index int `json:"Index"` - Name string `json:"Name"` - State string `json:"State"` - IsWaiting bool `json:"IsWaiting"` - IsBusy bool `json:"IsBusy"` - WaitingSinceMilliseconds int64 `json:"WaitingSinceMilliseconds"` - - CurrentURI string `json:"CurrentURI,omitempty"` - CurrentMethod string `json:"CurrentMethod,omitempty"` - RequestStartedAt int64 `json:"RequestStartedAt,omitempty"` - MemoryUsage int64 `json:"MemoryUsage,omitempty"` - RequestCount int64 `json:"RequestCount,omitempty"` -} - -type ThreadsResponse struct { - ThreadDebugStates []ThreadDebugState `json:"ThreadDebugStates"` - ReservedThreadCount int `json:"ReservedThreadCount"` -} - -type WorkerMetrics struct { - Worker string `json:"worker"` - Total float64 `json:"total"` - Busy float64 `json:"busy"` - Ready float64 `json:"ready"` - RequestTime float64 `json:"requestTime"` - RequestCount float64 `json:"requestCount"` - Crashes float64 `json:"crashes"` - Restarts float64 `json:"restarts"` - QueueDepth float64 `json:"queueDepth"` -} -type HostMetrics struct { - Host string `json:"host"` - RequestsTotal float64 `json:"requestsTotal"` - DurationSum float64 `json:"durationSum"` - DurationCount float64 `json:"durationCount"` - InFlight float64 `json:"inFlight"` - DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"` - StatusCodes map[int]float64 `json:"statusCodes,omitempty"` - Methods map[string]float64 `json:"methods,omitempty"` - ResponseSizeSum float64 `json:"responseSizeSum"` - ResponseSizeCount float64 `json:"responseSizeCount"` - RequestSizeSum float64 `json:"requestSizeSum"` - RequestSizeCount float64 `json:"requestSizeCount"` - ErrorsTotal float64 `json:"errorsTotal"` - TTFBSum float64 `json:"ttfbSum"` - TTFBCount float64 `json:"ttfbCount"` - TTFBBuckets []HistogramBucket `json:"ttfbBuckets,omitempty"` -} + "github.com/alexandre-daubois/ember/pkg/metrics" +) -type MetricsSnapshot struct { - // FrankenPHP-specific (require frankenphp metrics) - TotalThreads float64 `json:"totalThreads"` - BusyThreads float64 `json:"busyThreads"` - QueueDepth float64 `json:"queueDepth"` - Workers map[string]*WorkerMetrics `json:"workers"` +type ThreadDebugState = metrics.ThreadDebugState - // Caddy HTTP metrics (require `metrics` directive in Caddyfile) - HTTPRequestErrorsTotal float64 `json:"httpRequestErrorsTotal"` - HTTPRequestsTotal float64 `json:"httpRequestsTotal"` - HTTPRequestDurationSum float64 `json:"httpRequestDurationSum"` - HTTPRequestDurationCount float64 `json:"httpRequestDurationCount"` - HTTPRequestsInFlight float64 `json:"httpRequestsInFlight"` - DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"` - HasHTTPMetrics bool `json:"hasHttpMetrics"` +type ThreadsResponse = metrics.ThreadsResponse - // Per-host Caddy HTTP metrics - Hosts map[string]*HostMetrics `json:"hosts,omitempty"` +type WorkerMetrics = metrics.WorkerMetrics - // Go runtime process metrics (from standard Prometheus collector) - ProcessCPUSecondsTotal float64 `json:"processCpuSecondsTotal,omitempty"` - ProcessRSSBytes float64 `json:"processRssBytes,omitempty"` - ProcessStartTimeSeconds float64 `json:"processStartTimeSeconds,omitempty"` +type HostMetrics = metrics.HostMetrics - // Caddy config reload status (built-in Caddy metrics) - HasConfigReloadMetrics bool `json:"hasConfigReloadMetrics"` - ConfigLastReloadSuccessful float64 `json:"configLastReloadSuccessful"` - ConfigLastReloadSuccessTimestamp float64 `json:"configLastReloadSuccessTimestamp"` -} +type MetricsSnapshot = metrics.MetricsSnapshot -type HistogramBucket struct { - UpperBound float64 `json:"upperBound"` - CumulativeCount float64 `json:"cumulativeCount"` -} +type HistogramBucket = metrics.HistogramBucket -type ProcessMetrics struct { - PID int32 `json:"pid"` - CPUPercent float64 `json:"cpuPercent"` - RSS uint64 `json:"rss"` - CreateTime int64 `json:"createTime"` - Uptime time.Duration `json:"uptime"` -} +type ProcessMetrics = metrics.ProcessMetrics -type Snapshot struct { - Threads ThreadsResponse `json:"threads"` - Metrics MetricsSnapshot `json:"metrics"` - Process ProcessMetrics `json:"process"` - FetchedAt time.Time `json:"fetchedAt"` - Errors []string `json:"errors,omitempty"` - HasFrankenPHP bool `json:"hasFrankenPHP"` -} +type Snapshot = metrics.Snapshot type CertificateInfo struct { Subject string diff --git a/internal/fetcher/prometheus.go b/internal/fetcher/prometheus.go index 165626b..e92a54b 100644 --- a/internal/fetcher/prometheus.go +++ b/internal/fetcher/prometheus.go @@ -1,405 +1,23 @@ package fetcher import ( - "fmt" "io" - "slices" - "strconv" dto "github.com/prometheus/client_model/go" - "github.com/prometheus/common/expfmt" - "github.com/prometheus/common/model" -) - -func parsePrometheusMetrics(r io.Reader) (snap MetricsSnapshot, err error) { - defer func() { - if r := recover(); r != nil { - snap = MetricsSnapshot{} - err = fmt.Errorf("parse prometheus: panic: %v", r) - } - }() - - parser := expfmt.NewTextParser(model.UTF8Validation) - families, parseErr := parser.TextToMetricFamilies(r) - if parseErr != nil { - return MetricsSnapshot{}, fmt.Errorf("parse prometheus: %w", parseErr) - } - - snap = MetricsSnapshot{ - Workers: make(map[string]*WorkerMetrics), - Hosts: make(map[string]*HostMetrics), - } - - snap.TotalThreads = scalarValue(families, "frankenphp_total_threads") - snap.BusyThreads = scalarValue(families, "frankenphp_busy_threads") - snap.QueueDepth = scalarValue(families, "frankenphp_queue_depth") - - perWorker := []struct { - name string - setter func(wm *WorkerMetrics, v float64) - }{ - {"frankenphp_total_workers", func(wm *WorkerMetrics, v float64) { wm.Total = v }}, - {"frankenphp_busy_workers", func(wm *WorkerMetrics, v float64) { wm.Busy = v }}, - {"frankenphp_ready_workers", func(wm *WorkerMetrics, v float64) { wm.Ready = v }}, - {"frankenphp_worker_request_time", func(wm *WorkerMetrics, v float64) { wm.RequestTime = v }}, - {"frankenphp_worker_request_count", func(wm *WorkerMetrics, v float64) { wm.RequestCount = v }}, - {"frankenphp_worker_crashes", func(wm *WorkerMetrics, v float64) { wm.Crashes = v }}, - {"frankenphp_worker_restarts", func(wm *WorkerMetrics, v float64) { wm.Restarts = v }}, - {"frankenphp_worker_queue_depth", func(wm *WorkerMetrics, v float64) { wm.QueueDepth = v }}, - } - - for _, pw := range perWorker { - fam, ok := families[pw.name] - if !ok { - continue - } - for _, m := range fam.GetMetric() { - worker := labelValue(m, "worker") - if worker == "" { - continue - } - wm := snap.getOrCreateWorker(worker) - pw.setter(wm, metricValue(m)) - } - } - - // Caddy HTTP metrics (available with `metrics` directive) - snap.HTTPRequestErrorsTotal = sumCounter(families, "caddy_http_request_errors_total") - snap.HTTPRequestsTotal = sumCounter(families, "caddy_http_requests_total") - snap.HTTPRequestDurationSum, snap.HTTPRequestDurationCount, snap.DurationBuckets = histogramData(families, "caddy_http_request_duration_seconds") - snap.HTTPRequestsInFlight = scalarValue(families, "caddy_http_requests_in_flight") - snap.HasHTTPMetrics = snap.HTTPRequestsTotal > 0 || snap.HTTPRequestDurationCount > 0 - - snap.ProcessCPUSecondsTotal = scalarValue(families, "process_cpu_seconds_total") - snap.ProcessRSSBytes = scalarValue(families, "process_resident_memory_bytes") - snap.ProcessStartTimeSeconds = scalarValue(families, "process_start_time_seconds") - - _, hasReload := families["caddy_config_last_reload_successful"] - snap.HasConfigReloadMetrics = hasReload - snap.ConfigLastReloadSuccessful = scalarValue(families, "caddy_config_last_reload_successful") - snap.ConfigLastReloadSuccessTimestamp = scalarValue(families, "caddy_config_last_reload_success_timestamp_seconds") - - snap.Hosts = perHostMetrics(families) - - // Fallback: if HTTP metrics exist but no host labels, aggregate as a single "*" entry - if snap.HasHTTPMetrics && len(snap.Hosts) == 0 { - statusCodes := aggregateStatusCodes(families, "caddy_http_requests_total") - if statusCodes == nil { - statusCodes = statusCodesFromHistogram(families, "caddy_http_request_duration_seconds") - } - snap.Hosts = map[string]*HostMetrics{ - "*": { - Host: "*", - RequestsTotal: snap.HTTPRequestsTotal, - DurationSum: snap.HTTPRequestDurationSum, - DurationCount: snap.HTTPRequestDurationCount, - InFlight: snap.HTTPRequestsInFlight, - DurationBuckets: snap.DurationBuckets, - StatusCodes: statusCodes, - }, - } - } - return snap, nil -} + "github.com/alexandre-daubois/ember/pkg/metrics" +) -func sumCounter(families map[string]*dto.MetricFamily, name string) float64 { - fam, ok := families[name] - if !ok { - return 0 - } - var total float64 - for _, m := range fam.GetMetric() { - total += metricValue(m) - } - return total +func parsePrometheusMetrics(r io.Reader) (MetricsSnapshot, error) { + return metrics.ParsePrometheus(r) } -func histogramData(families map[string]*dto.MetricFamily, name string) (float64, float64, []HistogramBucket) { - fam, ok := families[name] - if !ok { - return 0, 0, nil - } - var sumTotal, countTotal float64 - bucketMap := make(map[float64]float64) - for _, m := range fam.GetMetric() { - if h := m.GetHistogram(); h != nil { - sumTotal += h.GetSampleSum() - countTotal += float64(h.GetSampleCount()) - for _, b := range h.GetBucket() { - bucketMap[b.GetUpperBound()] += float64(b.GetCumulativeCount()) - } - } - } - - var buckets []HistogramBucket - for ub, count := range bucketMap { - buckets = append(buckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) - } - sortBuckets(buckets) - - return sumTotal, countTotal, buckets -} +// Wrappers kept for internal tests that exercise these helpers directly. func sortBuckets(buckets []HistogramBucket) { - slices.SortFunc(buckets, func(a, b HistogramBucket) int { - if a.UpperBound < b.UpperBound { - return -1 - } - if a.UpperBound > b.UpperBound { - return 1 - } - return 0 - }) + metrics.SortBuckets(buckets) } func scalarValue(families map[string]*dto.MetricFamily, name string) float64 { - fam, ok := families[name] - if !ok || len(fam.GetMetric()) == 0 { - return 0 - } - return metricValue(fam.GetMetric()[0]) -} - -func metricValue(m *dto.Metric) float64 { - if g := m.GetGauge(); g != nil { - return g.GetValue() - } - if c := m.GetCounter(); c != nil { - return c.GetValue() - } - if u := m.GetUntyped(); u != nil { - return u.GetValue() - } - return 0 -} - -func labelValue(m *dto.Metric, name string) string { - for _, l := range m.GetLabel() { - if l.GetName() == name { - return l.GetValue() - } - } - return "" -} - -func aggregateStatusCodes(families map[string]*dto.MetricFamily, name string) map[int]float64 { - fam, ok := families[name] - if !ok { - return nil - } - codes := make(map[int]float64) - for _, m := range fam.GetMetric() { - if code := labelValue(m, "code"); code != "" { - if c, err := strconv.Atoi(code); err == nil { - codes[c] += metricValue(m) - } - } - } - if len(codes) == 0 { - return nil - } - return codes -} - -func statusCodesFromHistogram(families map[string]*dto.MetricFamily, name string) map[int]float64 { - fam, ok := families[name] - if !ok { - return nil - } - codes := make(map[int]float64) - for _, m := range fam.GetMetric() { - h := m.GetHistogram() - if h == nil { - continue - } - if code := labelValue(m, "code"); code != "" { - if c, err := strconv.Atoi(code); err == nil { - codes[c] += float64(h.GetSampleCount()) - } - } - } - if len(codes) == 0 { - return nil - } - return codes -} - -func hostOrServer(m *dto.Metric) string { - if h := labelValue(m, "host"); h != "" { - return h - } - return labelValue(m, "server") -} - -func perHostMetrics(families map[string]*dto.MetricFamily) map[string]*HostMetrics { - hosts := make(map[string]*HostMetrics) - - getOrCreate := func(host string) *HostMetrics { - hm, ok := hosts[host] - if !ok { - hm = &HostMetrics{Host: host, StatusCodes: make(map[int]float64), Methods: make(map[string]float64)} - hosts[host] = hm - } - return hm - } - - hostsWithCounterCodes := make(map[string]bool) - if fam, ok := families["caddy_http_requests_total"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - hm := getOrCreate(host) - v := metricValue(m) - hm.RequestsTotal += v - if code := labelValue(m, "code"); code != "" { - if c, err := strconv.Atoi(code); err == nil { - hm.StatusCodes[c] += v - hostsWithCounterCodes[host] = true - } - } - if method := labelValue(m, "method"); method != "" { - hm.Methods[method] += v - } - } - } - - bucketMaps := make(map[string]map[float64]float64) - if fam, ok := families["caddy_http_request_duration_seconds"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - h := m.GetHistogram() - if h == nil { - continue - } - hm := getOrCreate(host) - hm.DurationSum += h.GetSampleSum() - hm.DurationCount += float64(h.GetSampleCount()) - - if !hostsWithCounterCodes[host] { - if code := labelValue(m, "code"); code != "" { - if c, err := strconv.Atoi(code); err == nil { - hm.StatusCodes[c] += float64(h.GetSampleCount()) - } - } - } - - if bucketMaps[host] == nil { - bucketMaps[host] = make(map[float64]float64) - } - for _, b := range h.GetBucket() { - bucketMaps[host][b.GetUpperBound()] += float64(b.GetCumulativeCount()) - } - } - } - - for host, bm := range bucketMaps { - hm := hosts[host] - hm.DurationBuckets = make([]HistogramBucket, 0, len(bm)) - for ub, count := range bm { - hm.DurationBuckets = append(hm.DurationBuckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) - } - sortBuckets(hm.DurationBuckets) - } - - ttfbBucketMaps := make(map[string]map[float64]float64) - if fam, ok := families["caddy_http_response_duration_seconds"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - h := m.GetHistogram() - if h == nil { - continue - } - hm := getOrCreate(host) - hm.TTFBSum += h.GetSampleSum() - hm.TTFBCount += float64(h.GetSampleCount()) - - if ttfbBucketMaps[host] == nil { - ttfbBucketMaps[host] = make(map[float64]float64) - } - for _, b := range h.GetBucket() { - ttfbBucketMaps[host][b.GetUpperBound()] += float64(b.GetCumulativeCount()) - } - } - } - - for host, bm := range ttfbBucketMaps { - hm := hosts[host] - hm.TTFBBuckets = make([]HistogramBucket, 0, len(bm)) - for ub, count := range bm { - hm.TTFBBuckets = append(hm.TTFBBuckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) - } - sortBuckets(hm.TTFBBuckets) - } - - if fam, ok := families["caddy_http_response_size_bytes"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - h := m.GetHistogram() - if h == nil { - continue - } - hm := getOrCreate(host) - hm.ResponseSizeSum += h.GetSampleSum() - hm.ResponseSizeCount += float64(h.GetSampleCount()) - } - } - - if fam, ok := families["caddy_http_request_size_bytes"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - h := m.GetHistogram() - if h == nil { - continue - } - hm := getOrCreate(host) - hm.RequestSizeSum += h.GetSampleSum() - hm.RequestSizeCount += float64(h.GetSampleCount()) - } - } - - if fam, ok := families["caddy_http_request_errors_total"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - getOrCreate(host).ErrorsTotal += metricValue(m) - } - } - - if fam, ok := families["caddy_http_requests_in_flight"]; ok { - for _, m := range fam.GetMetric() { - host := hostOrServer(m) - if host == "" { - continue - } - getOrCreate(host).InFlight += metricValue(m) - } - } - - return hosts -} - -func (s *MetricsSnapshot) getOrCreateWorker(name string) *WorkerMetrics { - wm, ok := s.Workers[name] - if !ok { - wm = &WorkerMetrics{Worker: name} - s.Workers[name] = wm - } - return wm + return metrics.ScalarValue(families, name) } diff --git a/internal/fetcher/prometheus_test.go b/internal/fetcher/prometheus_test.go index ddd3fc7..048d518 100644 --- a/internal/fetcher/prometheus_test.go +++ b/internal/fetcher/prometheus_test.go @@ -734,3 +734,49 @@ frankenphp_busy_threads 5 assert.Equal(t, float64(0), snap.ProcessRSSBytes) assert.Equal(t, float64(0), snap.ProcessStartTimeSeconds) } + +func TestParsePrometheusMetrics_ExtraFamilies(t *testing.T) { + input := `# TYPE frankenphp_busy_threads gauge +frankenphp_busy_threads 5 +# HELP crowdsec_decisions_total Total decisions +# TYPE crowdsec_decisions_total counter +crowdsec_decisions_total{action="ban"} 42 +crowdsec_decisions_total{action="captcha"} 7 +# HELP mymodule_cache_hits Cache hit count +# TYPE mymodule_cache_hits counter +mymodule_cache_hits 1234 +` + snap, err := parsePrometheusMetrics(strings.NewReader(input)) + require.NoError(t, err) + + require.NotNil(t, snap.Extra) + assert.Len(t, snap.Extra, 2) + assert.Contains(t, snap.Extra, "crowdsec_decisions_total") + assert.Contains(t, snap.Extra, "mymodule_cache_hits") + assert.NotContains(t, snap.Extra, "frankenphp_busy_threads") + + fam := snap.Extra["crowdsec_decisions_total"] + assert.Len(t, fam.GetMetric(), 2) +} + +func TestParsePrometheusMetrics_NoExtraWhenAllKnown(t *testing.T) { + snap, err := parsePrometheusMetrics(strings.NewReader(sampleMetrics)) + require.NoError(t, err) + + assert.Nil(t, snap.Extra) +} + +func TestParsePrometheusMetrics_ExtraWithCoreMetrics(t *testing.T) { + input := `# TYPE caddy_http_requests_total counter +caddy_http_requests_total{host="example.com",code="200"} 100 +# TYPE custom_plugin_metric gauge +custom_plugin_metric{instance="a"} 42 +` + snap, err := parsePrometheusMetrics(strings.NewReader(input)) + require.NoError(t, err) + + assert.True(t, snap.HasHTTPMetrics) + require.NotNil(t, snap.Extra) + assert.Len(t, snap.Extra, 1) + assert.Contains(t, snap.Extra, "custom_plugin_metric") +} diff --git a/internal/ui/app.go b/internal/ui/app.go index 9104e2a..6407154 100644 --- a/internal/ui/app.go +++ b/internal/ui/app.go @@ -4,11 +4,14 @@ import ( "context" "encoding/json" "fmt" + "slices" + "strconv" "strings" "time" "github.com/alexandre-daubois/ember/internal/fetcher" "github.com/alexandre-daubois/ember/internal/model" + "github.com/alexandre-daubois/ember/pkg/plugin" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/muesli/termenv" @@ -38,7 +41,8 @@ type Config struct { NoColor bool Version string HasFrankenPHP bool - OnStateUpdate func(model.State) + Plugins []plugin.Plugin + OnStateUpdate func(model.State, []plugin.PluginExport) MetricsServerErr <-chan error } @@ -102,8 +106,15 @@ type App struct { certificates []fetcher.CertificateInfo certSortBy model.CertSortField + + pluginTabs []*pluginTab + pluginGroups []*pluginGroup + ctx context.Context + cancel context.CancelFunc } +const tabPluginBase tab = 100 + func NewApp(f fetcher.Fetcher, cfg Config) *App { if cfg.NoColor { lipgloss.SetColorProfile(termenv.Ascii) @@ -115,11 +126,30 @@ func NewApp(f fetcher.Fetcher, cfg Config) *App { tabs = append(tabs, tabFrankenPHP) } + var pluginTabs []*pluginTab + var pluginGroups []*pluginGroup + nextID := tabPluginBase + for _, p := range cfg.Plugins { + pts, g := newPluginTabs(p, nextID) + pluginGroups = append(pluginGroups, g) + for _, pt := range pts { + pluginTabs = append(pluginTabs, pt) + tabs = append(tabs, pt.tabID) + } + advance := len(pts) + if advance < 1 { + advance = 1 + } + nextID += tab(advance) + } + ts := make(map[tab]*tabState) for _, t := range tabs { ts[t] = &tabState{} } + ctx, cancel := context.WithCancel(context.Background()) + return &App{ fetcher: f, config: cfg, @@ -129,6 +159,17 @@ func NewApp(f fetcher.Fetcher, cfg Config) *App { hasFrankenPHP: cfg.HasFrankenPHP, history: newHistoryStore(), viewTime: time.Now(), + pluginTabs: pluginTabs, + pluginGroups: pluginGroups, + ctx: ctx, + cancel: cancel, + } +} + +// Close cancels the app context, signaling plugin fetches to stop. +func (a *App) Close() { + if a.cancel != nil { + a.cancel() } } @@ -191,6 +232,7 @@ type certFetchMsg struct { func (a *App) Init() tea.Cmd { cmds := []tea.Cmd{a.doFetch(), a.doTick()} + cmds = append(cmds, a.doPluginFetches()...) if a.config.MetricsServerErr != nil { ch := a.config.MetricsServerErr cmds = append(cmds, func() tea.Msg { @@ -215,12 +257,63 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case metricsServerErrMsg: a.status = "⚠ " + msg.err.Error() return a, nil + case pluginFetchMsg: + if msg.groupIndex >= 0 && msg.groupIndex < len(a.pluginGroups) { + g := a.pluginGroups[msg.groupIndex] + g.fetching = false + g.err = msg.err + if msg.err == nil { + g.data = msg.data + + if g.avail != nil { + nowAvail := safePluginAvailable(g.avail) + if nowAvail != g.wasAvail { + g.wasAvail = nowAvail + a.updatePluginTabVisibility(g, nowAvail) + if nowAvail && g.wasTabAvail != nil { + for k := range g.wasTabAvail { + g.wasTabAvail[k] = true + } + } + } + } + + if g.wasAvail && g.tabAvail != nil { + for _, pt := range a.pluginTabs { + if pt.group != g || pt.tabKey == "" { + continue + } + nowAvail := safePluginTabAvailable(g.tabAvail, pt.tabKey) + if nowAvail != g.wasTabAvail[pt.tabKey] { + g.wasTabAvail[pt.tabKey] = nowAvail + a.updateSingleTabVisibility(pt, nowAvail) + } + } + } + + for _, pt := range a.pluginTabs { + if pt.group == g && pt.renderer != nil && msg.data != nil { + updated, updateErr := safePluginUpdate(pt.renderer, msg.data, a.width, a.height) + if updateErr == nil && updated != nil { + pt.renderer = updated + } else if updateErr != nil { + g.err = updateErr + } + } + } + } + } else { + a.status = fmt.Sprintf("⚠ plugin fetch: unexpected index %d", msg.groupIndex) + } + return a, nil case tickMsg: if a.paused || a.fetching { return a, a.doTick() } a.fetching = true - return a, tea.Batch(a.doFetch(), a.doTick()) + cmds := []tea.Cmd{a.doFetch(), a.doTick()} + cmds = append(cmds, a.doPluginFetches()...) + return a, tea.Batch(cmds...) case fetchMsg: a.fetching = false a.viewTime = time.Now() @@ -289,8 +382,10 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } a.history.pruneMem(activeThreads) + a.notifyMetricsSubscribers(msg.snap) + if a.config.OnStateUpdate != nil { - a.config.OnStateUpdate(a.state) + a.config.OnStateUpdate(a.state, a.pluginExports()) } } return a, nil @@ -373,7 +468,15 @@ func (a *App) View() string { if a.certificates != nil { counts[tabCertificates] = fmt.Sprintf("%d certs", len(a.certificates)) } - tabBar := renderTabBar(a.tabs, a.activeTab, listWidth, counts) + for _, pt := range a.pluginTabs { + if pt.renderer != nil { + c, _ := safePluginStatusCount(pt.renderer) + if c != "" { + counts[pt.tabID] = c + } + } + } + tabBar := renderTabBar(a.tabs, a.activeTab, listWidth, counts, a.pluginTabs) help := renderHelp(a.sortBy, a.hostSortBy, a.certSortBy, a.paused, listWidth, a.activeTab) var threads []fetcher.ThreadDebugState @@ -420,6 +523,13 @@ func (a *App) View() string { } else { contentList = renderHostTable(hosts, a.cursor, listWidth, a.hostSortBy, a.history.hostRPS) } + default: + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + contentList = safePluginView(pt.renderer, listWidth, a.height-10) + if pt.group.err != nil && contentList == "" { + contentList = greyStyle.Render(" " + pt.group.err.Error()) + } + } } var statusLine string @@ -485,7 +595,7 @@ func (a *App) View() string { } if a.mode == viewHelp { - return renderHelpOverlay(base, a.width, a.height, a.hasFrankenPHP) + return renderHelpOverlay(base, a.width, a.height, a.hasFrankenPHP, a.pluginTabs, a.tabs) } return base @@ -499,24 +609,10 @@ func (a *App) handleTabSwitch(key string) (tea.Cmd, bool) { case "shift+tab": a.prevTab() return a.switchTabCmd(), true - case "1": - if len(a.tabs) > 0 { - a.switchTab(a.tabs[0]) - } - return a.switchTabCmd(), true - case "2": - if len(a.tabs) > 1 { - a.switchTab(a.tabs[1]) - } - return a.switchTabCmd(), true - case "3": - if len(a.tabs) > 2 { - a.switchTab(a.tabs[2]) - } - return a.switchTabCmd(), true - case "4": - if len(a.tabs) > 3 { - a.switchTab(a.tabs[3]) + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + idx, _ := strconv.Atoi(key) + if idx >= 1 && idx <= len(a.tabs) { + a.switchTab(a.tabs[idx-1]) } return a.switchTabCmd(), true } @@ -599,50 +695,119 @@ func (a *App) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return a.handleCertListKey(msg) } - key := msg.String() - - if cmd, ok := a.handleTabSwitch(key); ok { - return a, cmd - } - - maxIdx := a.listLen() - 1 - if maxIdx < 0 { - maxIdx = 0 - } - moveCursor(key, &a.cursor, maxIdx, a.pageSize()) - - switch key { + switch msg.String() { case "q", "ctrl+c": return a, tea.Quit + case "tab": + a.nextTab() + return a, a.switchTabCmd() + case "shift+tab": + a.prevTab() + return a, a.switchTabCmd() + case "1", "2", "3", "4", "5", "6", "7", "8", "9": + idx, _ := strconv.Atoi(msg.String()) + if idx >= 1 && idx <= len(a.tabs) { + a.switchTab(a.tabs[idx-1]) + } + return a, a.switchTabCmd() + case "up", "k": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else if a.cursor > 0 { + a.cursor-- + } + case "down", "j": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else { + a.cursor++ + a.clampCursor() + } + case "home": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else { + a.cursor = 0 + } + case "end": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else { + max := a.listLen() - 1 + if max < 0 { + max = 0 + } + a.cursor = max + } + case "pgup": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else { + a.cursor -= a.pageSize() + if a.cursor < 0 { + a.cursor = 0 + } + } + case "pgdown": + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } else { + a.cursor += a.pageSize() + a.clampCursor() + } case "s": - if a.activeTab == tabCaddy { + switch a.activeTab { + case tabCaddy: a.hostSortBy = a.hostSortBy.Next() - } else { + case tabFrankenPHP: a.sortBy = a.sortBy.Next() + default: + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } } case "S": - if a.activeTab == tabCaddy { + switch a.activeTab { + case tabCaddy: a.hostSortBy = a.hostSortBy.Prev() - } else { + case tabFrankenPHP: a.sortBy = a.sortBy.Prev() + default: + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } } case "p": a.paused = !a.paused case "enter": - a.mode = viewDetail + if a.activeTab == tabCaddy || a.activeTab == tabFrankenPHP { + a.mode = viewDetail + } else if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } case "r": if a.activeTab == tabFrankenPHP { a.mode = viewConfirmRestart + } else if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational } case "/": - a.mode = viewFilter - a.filter = "" + if a.activeTab == tabCaddy || a.activeTab == tabFrankenPHP { + a.mode = viewFilter + a.filter = "" + } else if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } case "g": a.prevMode = a.mode a.mode = viewGraph case "?": a.prevMode = a.mode a.mode = viewHelp + default: + if pt := a.activePluginTab(); pt != nil && pt.renderer != nil { + safePluginHandleKey(pt.renderer, msg) //nolint:errcheck // consumed status is informational + } } return a, nil } @@ -756,8 +921,10 @@ func (a *App) listLen() int { return len(a.filteredCerts()) case tabCaddy: return len(a.filteredHosts()) - default: + case tabFrankenPHP: return len(a.filteredThreads()) + default: + return 0 } } @@ -771,8 +938,11 @@ func (a *App) clampCursor() { count = len(a.filteredCerts()) case tabCaddy: count = len(a.filteredHosts()) - default: + case tabFrankenPHP: count = len(a.filteredThreads()) + default: + a.cursor = 0 + return } maximum := count - 1 if maximum < 0 { @@ -796,7 +966,7 @@ func (a *App) prevThreadMemory() map[int]int64 { func (a *App) enableFrankenPHP() { a.hasFrankenPHP = true - a.tabs = append(a.tabs, tabFrankenPHP) + a.tabs = slices.Insert(a.tabs, 1, tabFrankenPHP) a.tabStates[tabFrankenPHP] = &tabState{} } @@ -866,6 +1036,83 @@ func (a *App) doFetchCertificates() tea.Cmd { } } +func (a *App) doPluginFetches() []tea.Cmd { + var cmds []tea.Cmd + for i, g := range a.pluginGroups { + if g.fetcher != nil && !g.fetching { + g.fetching = true + cmds = append(cmds, doPluginFetch(a.ctx, i, g.fetcher)) + } + } + return cmds +} + +func (a *App) activePluginTab() *pluginTab { + for _, pt := range a.pluginTabs { + if pt.tabID == a.activeTab { + return pt + } + } + return nil +} + +func (a *App) pluginExports() []plugin.PluginExport { + var exports []plugin.PluginExport + for _, g := range a.pluginGroups { + if g.exporter != nil { + exports = append(exports, plugin.PluginExport{ + Exporter: g.exporter, + Data: g.data, + }) + } + } + return exports +} + +func (a *App) notifyMetricsSubscribers(snap *fetcher.Snapshot) { + for _, g := range a.pluginGroups { + if sub, ok := g.p.(plugin.MetricsSubscriber); ok { + safeOnMetrics(sub, snap) + } + } +} + +func (a *App) updatePluginTabVisibility(g *pluginGroup, visible bool) { + for _, pt := range a.pluginTabs { + if pt.group == g { + a.updateSingleTabVisibility(pt, visible) + } + } +} + +func (a *App) updateSingleTabVisibility(pt *pluginTab, visible bool) { + if visible { + if !slices.Contains(a.tabs, pt.tabID) { + inserted := false + for i, t := range a.tabs { + if t > pt.tabID { + a.tabs = slices.Insert(a.tabs, i, pt.tabID) + inserted = true + break + } + } + if !inserted { + a.tabs = append(a.tabs, pt.tabID) + } + a.tabStates[pt.tabID] = &tabState{} + } + } else { + idx := slices.Index(a.tabs, pt.tabID) + if idx >= 0 { + if a.activeTab == pt.tabID { + a.switchTab(a.tabs[0]) + } + a.tabs = slices.Delete(a.tabs, idx, idx+1) + delete(a.tabStates, pt.tabID) + } + } +} + func renderConfirmOverlay(base string, width, height int) string { popup := boxStyle.Render( titleStyle.Render("Restart all workers?") + "\n\n" + diff --git a/internal/ui/app_test.go b/internal/ui/app_test.go index 0a180e5..9e7cc41 100644 --- a/internal/ui/app_test.go +++ b/internal/ui/app_test.go @@ -7,6 +7,7 @@ import ( "github.com/alexandre-daubois/ember/internal/fetcher" "github.com/alexandre-daubois/ember/internal/model" + "github.com/alexandre-daubois/ember/pkg/plugin" tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" ) @@ -353,7 +354,7 @@ func TestOnStateUpdate_CalledOnFetch(t *testing.T) { app := &App{ history: newHistoryStore(), config: Config{ - OnStateUpdate: func(s model.State) { + OnStateUpdate: func(s model.State, _ []plugin.PluginExport) { called = true }, }, @@ -924,3 +925,315 @@ func TestConfigTab_ThreeKey(t *testing.T) { app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'3'}}) assert.Equal(t, tabFrankenPHP, app.activeTab, "3 should switch to FrankenPHP tab") } + +func TestClampCursor_PluginTabResetsToZero(t *testing.T) { + renderer := &stubPlugin{name: "myplugin"} + cfg := Config{ + Plugins: []plugin.Plugin{renderer}, + } + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + app.cursor = 42 + + app.clampCursor() + assert.Equal(t, 0, app.cursor, "plugin tab cursor should be clamped to 0") +} + +func TestPluginTab_PgDownForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + app.height = 40 + + app.handleListKey(tea.KeyMsg{Type: tea.KeyPgDown}) + assert.Equal(t, "pgdown", p.lastKey, "pgdown should be forwarded to the plugin") +} + +func TestPluginTab_SlashForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + assert.Equal(t, viewList, app.mode, "filter should not activate on plugin tab") + assert.Equal(t, "/", p.lastKey, "/ should be forwarded to the plugin") +} + +func TestPluginTab_FilterWorksOnCaddy(t *testing.T) { + app := NewApp(nil, Config{}) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'/'}}) + assert.Equal(t, viewFilter, app.mode, "filter should activate on Caddy tab") +} + +func TestPluginFetchMsg_PreservesDataOnError(t *testing.T) { + renderer := &stubPlugin{name: "myplugin"} + cfg := Config{ + Plugins: []plugin.Plugin{renderer}, + } + app := NewApp(nil, cfg) + app.pluginGroups[0].data = "good-data" + + app.Update(pluginFetchMsg{groupIndex: 0, data: nil, err: assert.AnError}) + + assert.Equal(t, "good-data", app.pluginGroups[0].data, "data should be preserved on error") + assert.Equal(t, assert.AnError, app.pluginGroups[0].err) +} + +func TestPluginFetchMsg_ClearsErrorOnSuccess(t *testing.T) { + renderer := &stubPlugin{name: "myplugin"} + cfg := Config{ + Plugins: []plugin.Plugin{renderer}, + } + app := NewApp(nil, cfg) + app.pluginGroups[0].err = assert.AnError + + app.Update(pluginFetchMsg{groupIndex: 0, data: "new-data", err: nil}) + + assert.Equal(t, "new-data", app.pluginGroups[0].data) + assert.NoError(t, app.pluginGroups[0].err, "error should be cleared on successful fetch") +} + +func TestPluginFetchMsg_NilDataNilErrClearsError(t *testing.T) { + renderer := &stubPlugin{name: "myplugin"} + cfg := Config{ + Plugins: []plugin.Plugin{renderer}, + } + app := NewApp(nil, cfg) + app.pluginGroups[0].data = "old-data" + app.pluginGroups[0].err = assert.AnError + + app.Update(pluginFetchMsg{groupIndex: 0, data: nil, err: nil}) + + assert.Nil(t, app.pluginGroups[0].data, "data should be updated to nil on nil/nil fetch") + assert.NoError(t, app.pluginGroups[0].err, "error should be cleared") +} + +func TestAppClose(t *testing.T) { + app := NewApp(nil, Config{}) + assert.NotPanics(t, func() { + app.Close() + }) +} + +type nilUpdatePlugin struct { + stubPlugin +} + +func (p *nilUpdatePlugin) Update(_ any, _, _ int) plugin.Renderer { + return nil +} + +type keyTrackingPlugin struct { + stubPlugin + lastKey string +} + +func (p *keyTrackingPlugin) HandleKey(msg tea.KeyMsg) bool { + p.lastKey = msg.String() + return true +} + +func (p *keyTrackingPlugin) Update(data any, _, _ int) plugin.Renderer { + return p +} + +func TestPluginTab_EnterForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, viewList, app.mode, "detail should not activate on plugin tab") + assert.Equal(t, "enter", p.lastKey, "enter should be forwarded to the plugin") +} + +func TestPluginTab_SKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'s'}}) + assert.Equal(t, "s", p.lastKey, "s should be forwarded to the plugin") +} + +func TestPluginTab_ShiftSKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'S'}}) + assert.Equal(t, "S", p.lastKey, "S should be forwarded to the plugin") +} + +func TestPluginTab_RKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + assert.Equal(t, "r", p.lastKey, "r should be forwarded to the plugin") +} + +func TestPluginTab_EnterStillOpensDetailOnCaddy(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyEnter}) + assert.Equal(t, viewDetail, app.mode, "enter should open detail on Caddy tab") + assert.Empty(t, p.lastKey, "plugin should not receive enter on Caddy tab") +} + +func TestPluginTab_RStillRestartsOnFrankenPHP(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}, HasFrankenPHP: true} + app := NewApp(nil, cfg) + app.switchTab(tabFrankenPHP) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'r'}}) + assert.Equal(t, viewConfirmRestart, app.mode, "r should trigger restart on FrankenPHP tab") + assert.Empty(t, p.lastKey, "plugin should not receive r on FrankenPHP tab") +} + +func TestPluginFetchMsg_OutOfRangeSetsStatus(t *testing.T) { + renderer := &stubPlugin{name: "myplugin"} + cfg := Config{Plugins: []plugin.Plugin{renderer}} + app := NewApp(nil, cfg) + + app.Update(pluginFetchMsg{groupIndex: 5, data: "data", err: nil}) + assert.Contains(t, app.status, "unexpected index 5") +} + +func TestPluginFetchMsg_NegativeIndexSetsStatus(t *testing.T) { + app := NewApp(nil, Config{}) + + app.Update(pluginFetchMsg{groupIndex: -1, data: "data", err: nil}) + assert.Contains(t, app.status, "unexpected index -1") +} + +func TestPluginFetchMsg_NilUpdateReturnKeepsOldRenderer(t *testing.T) { + p := &nilUpdatePlugin{stubPlugin: stubPlugin{name: "nilupdate"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + originalRenderer := app.pluginTabs[0].renderer + app.Update(pluginFetchMsg{groupIndex: 0, data: "new-data", err: nil}) + + assert.Equal(t, originalRenderer, app.pluginTabs[0].renderer, "renderer should not change when Update returns nil") + assert.Equal(t, "new-data", app.pluginGroups[0].data, "data should still be updated even when Update returns nil") + assert.NoError(t, app.pluginGroups[0].err, "no error should be set when Update returns nil") +} + +func TestPluginFetchMsg_ClearsFetchingFlag(t *testing.T) { + p := &stubPlugin{name: "test"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.pluginGroups[0].fetching = true + + app.Update(pluginFetchMsg{groupIndex: 0, data: "data", err: nil}) + assert.False(t, app.pluginGroups[0].fetching, "fetching flag should be cleared after msg") +} + +func TestPluginFetchMsg_ClearsFetchingFlagOnError(t *testing.T) { + p := &stubPlugin{name: "test"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.pluginGroups[0].fetching = true + + app.Update(pluginFetchMsg{groupIndex: 0, data: nil, err: assert.AnError}) + assert.False(t, app.pluginGroups[0].fetching, "fetching flag should be cleared even on error") +} + +func TestPluginFetchMsg_UpdatePanicStillUpdatesData(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panicky"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.pluginGroups[0].data = "old-data" + + app.Update(pluginFetchMsg{groupIndex: 0, data: "new-data", err: nil}) + + assert.Equal(t, "new-data", app.pluginGroups[0].data, "data should be updated even when Update panics") + assert.Error(t, app.pluginGroups[0].err, "error should be set from Update panic") + assert.Contains(t, app.pluginGroups[0].err.Error(), "plugin panic during Update") +} + +func TestPluginTab_UpKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, "up", p.lastKey, "up should be forwarded to the plugin") +} + +func TestPluginTab_DownKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyDown}) + assert.Equal(t, "down", p.lastKey, "down should be forwarded to the plugin") +} + +func TestPluginTab_HomeKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyHome}) + assert.Equal(t, "home", p.lastKey, "home should be forwarded to the plugin") +} + +func TestPluginTab_EndKeyForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyEnd}) + assert.Equal(t, "end", p.lastKey, "end should be forwarded to the plugin") +} + +func TestPluginTab_PgUpForwardedToPlugin(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.switchTab(tabPluginBase) + + app.handleListKey(tea.KeyMsg{Type: tea.KeyPgUp}) + assert.Equal(t, "pgup", p.lastKey, "pgup should be forwarded to the plugin") +} + +func TestPluginTab_NavKeysStillWorkOnCaddy(t *testing.T) { + p := &keyTrackingPlugin{stubPlugin: stubPlugin{name: "track"}} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + app.cursor = 1 + + app.handleListKey(tea.KeyMsg{Type: tea.KeyUp}) + assert.Equal(t, 0, app.cursor, "up should move cursor on Caddy tab") + assert.Empty(t, p.lastKey, "plugin should not receive up on Caddy tab") +} + +func TestEnableFrankenPHP_WithPlugins(t *testing.T) { + p := &stubPlugin{name: "myplugin"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + assert.Equal(t, []tab{tabCaddy, tabConfig, tabCertificates, tabPluginBase}, app.tabs) + + app.enableFrankenPHP() + + assert.Equal(t, []tab{tabCaddy, tabFrankenPHP, tabConfig, tabCertificates, tabPluginBase}, app.tabs, + "FrankenPHP should be inserted between Caddy and plugin tabs") +} diff --git a/internal/ui/help.go b/internal/ui/help.go index 2d12ad3..78bed05 100644 --- a/internal/ui/help.go +++ b/internal/ui/help.go @@ -1,6 +1,7 @@ package ui import ( + "slices" "strings" "github.com/alexandre-daubois/ember/internal/model" @@ -76,24 +77,19 @@ func renderHelp(sortBy model.SortField, hostSortBy model.HostSortField, certSort return helpStyle.Width(width).Render(content) } -func renderHelpOverlay(base string, width, height int, hasFrankenPHP bool) string { +func renderHelpOverlay(base string, width, height int, hasFrankenPHP bool, pluginTabs []*pluginTab, visibleTabs []tab) string { type binding struct { key string desc string } - tabHint := "1/2/3" - if hasFrankenPHP { - tabHint = "1/2/3/4" - } - nav := []binding{ {"↑/↓ j/k", "Move cursor"}, {"Enter", "Open detail / expand node"}, {"← / h", "Collapse node (Caddy Config tab)"}, {"Esc", "Close / clear search"}, {"Tab/S-Tab", "Switch tab"}, - {tabHint, "Jump to tab"}, + {"1-9", "Jump to tab"}, {"Home/End", "Jump to first/last"}, {"PgUp/PgDn", "Page up/down"}, } @@ -125,6 +121,22 @@ func renderHelpOverlay(base string, width, height int, hasFrankenPHP bool) strin } body := render("Navigation", nav) + "\n\n" + render("Actions", actions) + + for _, pt := range pluginTabs { + if pt.renderer == nil || !slices.Contains(visibleTabs, pt.tabID) { + continue + } + hb, _ := safePluginHelpBindings(pt.renderer) + if len(hb) == 0 { + continue + } + var pluginBindings []binding + for _, b := range hb { + pluginBindings = append(pluginBindings, binding{b.Key, b.Desc}) + } + body += "\n\n" + render(pt.tabName, pluginBindings) + } + popup := boxStyle.Render(body) return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, popup) } diff --git a/internal/ui/help_test.go b/internal/ui/help_test.go index 7c7387b..29c7d9a 100644 --- a/internal/ui/help_test.go +++ b/internal/ui/help_test.go @@ -76,7 +76,7 @@ func TestRenderHelp_SeparatorsPresent(t *testing.T) { } func TestRenderHelpOverlay_ContainsBindings(t *testing.T) { - out := stripANSI(renderHelpOverlay("base", 120, 40, true)) + out := stripANSI(renderHelpOverlay("base", 120, 40, true, nil, nil)) assert.Contains(t, out, "Navigation") assert.Contains(t, out, "Actions") @@ -88,18 +88,17 @@ func TestRenderHelpOverlay_ContainsBindings(t *testing.T) { assert.Contains(t, out, "Expand / collapse all") assert.Contains(t, out, "Quit") assert.Contains(t, out, "Toggle this help") - assert.Contains(t, out, "1/2/3/4") + assert.Contains(t, out, "1-9") assert.Contains(t, out, "Refresh config/certs / restart workers") } func TestRenderHelpOverlay_WithoutFrankenPHP(t *testing.T) { - out := stripANSI(renderHelpOverlay("base", 120, 40, false)) + out := stripANSI(renderHelpOverlay("base", 120, 40, false, nil, nil)) assert.Contains(t, out, "Navigation") assert.Contains(t, out, "Toggle graphs") assert.Contains(t, out, "Quit") - assert.Contains(t, out, "1/2/3") - assert.NotContains(t, out, "1/2/3/4") + assert.Contains(t, out, "1-9") assert.Contains(t, out, "Refresh config/certs") assert.NotContains(t, out, "restart workers") } diff --git a/internal/ui/plugin_bridge.go b/internal/ui/plugin_bridge.go new file mode 100644 index 0000000..11eef5a --- /dev/null +++ b/internal/ui/plugin_bridge.go @@ -0,0 +1,160 @@ +package ui + +import ( + "context" + "fmt" + + "github.com/alexandre-daubois/ember/internal/fetcher" + "github.com/alexandre-daubois/ember/pkg/plugin" + tea "github.com/charmbracelet/bubbletea" +) + +type pluginGroup struct { + p plugin.Plugin + fetcher plugin.Fetcher + exporter plugin.Exporter + avail plugin.Availability + tabAvail plugin.TabAvailability + wasAvail bool + wasTabAvail map[string]bool + data any + err error + fetching bool +} + +type pluginTab struct { + group *pluginGroup + renderer plugin.Renderer + tabID tab + tabKey string + tabName string +} + +func newPluginTabs(p plugin.Plugin, startID tab) ([]*pluginTab, *pluginGroup) { + g := &pluginGroup{p: p, wasAvail: true} + if f, ok := p.(plugin.Fetcher); ok { + g.fetcher = f + } + if e, ok := p.(plugin.Exporter); ok { + g.exporter = e + } + if a, ok := p.(plugin.Availability); ok { + g.avail = a + } + + var tabs []*pluginTab + + if mr, ok := p.(plugin.MultiRenderer); ok { + if ta, ok := p.(plugin.TabAvailability); ok { + g.tabAvail = ta + g.wasTabAvail = make(map[string]bool) + } + for i, desc := range mr.Tabs() { + r := mr.RendererForTab(desc.Key) + if r != nil { + tabs = append(tabs, &pluginTab{ + group: g, + renderer: r, + tabID: startID + tab(i), + tabKey: desc.Key, + tabName: desc.Name, + }) + if g.wasTabAvail != nil { + g.wasTabAvail[desc.Key] = true + } + } + } + } else if r, ok := p.(plugin.Renderer); ok { + tabs = append(tabs, &pluginTab{ + group: g, + renderer: r, + tabID: startID, + tabName: p.Name(), + }) + } + + return tabs, g +} + +type pluginFetchMsg struct { + groupIndex int + data any + err error +} + +func doPluginFetch(ctx context.Context, groupIndex int, f plugin.Fetcher) tea.Cmd { + return func() tea.Msg { + data, err := plugin.SafeFetch(ctx, f) + return pluginFetchMsg{groupIndex: groupIndex, data: data, err: err} + } +} + +func safePluginUpdate(r plugin.Renderer, data any, w, h int) (_ plugin.Renderer, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("plugin panic during Update: %v", rec) + } + }() + return r.Update(data, w, h), nil +} + +func safePluginView(r plugin.Renderer, w, h int) (s string) { + defer func() { + if rec := recover(); rec != nil { + s = fmt.Sprintf("plugin error: %v", rec) + } + }() + return r.View(w, h) +} + +func safePluginHandleKey(r plugin.Renderer, msg tea.KeyMsg) (consumed bool, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("plugin panic during HandleKey: %v", rec) + } + }() + return r.HandleKey(msg), nil +} + +func safePluginStatusCount(r plugin.Renderer) (_ string, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("plugin panic during StatusCount: %v", rec) + } + }() + return r.StatusCount(), nil +} + +func safePluginHelpBindings(r plugin.Renderer) (_ []plugin.HelpBinding, err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("plugin panic during HelpBindings: %v", rec) + } + }() + return r.HelpBindings(), nil +} + +func safeOnMetrics(sub plugin.MetricsSubscriber, snap *fetcher.Snapshot) { + defer func() { + recover() //nolint:errcheck // fire-and-forget: don't crash Ember if a subscriber panics + }() + sub.OnMetrics(snap) +} + +func safePluginAvailable(a plugin.Availability) (avail bool) { + defer func() { + if rec := recover(); rec != nil { + avail = true + } + }() + return a.Available() +} + +func safePluginTabAvailable(ta plugin.TabAvailability, key string) (avail bool) { + defer func() { + if rec := recover(); rec != nil { + avail = true + } + }() + return ta.TabAvailable(key) +} diff --git a/internal/ui/plugin_bridge_test.go b/internal/ui/plugin_bridge_test.go new file mode 100644 index 0000000..5ef083e --- /dev/null +++ b/internal/ui/plugin_bridge_test.go @@ -0,0 +1,655 @@ +package ui + +import ( + "context" + "io" + "testing" + + "github.com/alexandre-daubois/ember/internal/fetcher" + "github.com/alexandre-daubois/ember/pkg/plugin" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type stubPlugin struct { + name string +} + +func (p *stubPlugin) Name() string { return p.name } +func (p *stubPlugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil } +func (p *stubPlugin) Fetch(_ context.Context) (any, error) { return "data", nil } +func (p *stubPlugin) Update(data any, _, _ int) plugin.Renderer { return p } +func (p *stubPlugin) View(w, _ int) string { return "rendered" } +func (p *stubPlugin) HandleKey(_ tea.KeyMsg) bool { return false } +func (p *stubPlugin) StatusCount() string { return "5 items" } +func (p *stubPlugin) HelpBindings() []plugin.HelpBinding { return nil } +func (p *stubPlugin) WriteMetrics(_ io.Writer, _ any, _ string) {} + +type panicPlugin struct { + stubPlugin +} + +func (p *panicPlugin) Fetch(_ context.Context) (any, error) { panic("fetch boom") } +func (p *panicPlugin) Update(_ any, _, _ int) plugin.Renderer { + panic("update boom") +} +func (p *panicPlugin) View(_, _ int) string { panic("view boom") } +func (p *panicPlugin) HandleKey(_ tea.KeyMsg) bool { panic("key boom") } +func (p *panicPlugin) StatusCount() string { panic("count boom") } +func (p *panicPlugin) HelpBindings() []plugin.HelpBinding { + panic("help boom") +} + +func TestNewPluginTabs_SingleRenderer(t *testing.T) { + p := &stubPlugin{name: "test"} + pts, g := newPluginTabs(p, 100) + + require.Len(t, pts, 1) + assert.Equal(t, tab(100), pts[0].tabID) + assert.Equal(t, "test", pts[0].tabName) + assert.NotNil(t, pts[0].renderer) + assert.NotNil(t, g.fetcher) + assert.NotNil(t, g.exporter) + assert.Same(t, g, pts[0].group) +} + +func TestNewPluginTabs_MinimalPlugin(t *testing.T) { + p := &minimalPlugin{name: "minimal"} + pts, g := newPluginTabs(p, 100) + + assert.Empty(t, pts) + assert.Nil(t, g.fetcher) + assert.Nil(t, g.exporter) +} + +type minimalPlugin struct{ name string } + +func (p *minimalPlugin) Name() string { return p.name } +func (p *minimalPlugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil } + +func TestSafePluginFetch(t *testing.T) { + p := &stubPlugin{name: "ok"} + data, err := plugin.SafeFetch(context.Background(), p) + assert.NoError(t, err) + assert.Equal(t, "data", data) +} + +func TestSafePluginFetchPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + data, err := plugin.SafeFetch(context.Background(), p) + assert.Nil(t, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during Fetch") +} + +func TestSafePluginUpdate(t *testing.T) { + p := &stubPlugin{name: "ok"} + r, err := safePluginUpdate(p, "data", 80, 24) + assert.NoError(t, err) + assert.NotNil(t, r) +} + +func TestSafePluginUpdatePanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + _, err := safePluginUpdate(p, "data", 80, 24) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during Update") +} + +func TestSafePluginView(t *testing.T) { + p := &stubPlugin{name: "ok"} + s := safePluginView(p, 80, 24) + assert.Equal(t, "rendered", s) +} + +func TestSafePluginViewPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + s := safePluginView(p, 80, 24) + assert.Contains(t, s, "plugin error") +} + +func TestSafePluginHandleKey(t *testing.T) { + p := &stubPlugin{name: "ok"} + consumed, err := safePluginHandleKey(p, tea.KeyMsg{}) + assert.NoError(t, err) + assert.False(t, consumed) +} + +func TestSafePluginHandleKeyPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + _, err := safePluginHandleKey(p, tea.KeyMsg{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during HandleKey") +} + +func TestSafePluginStatusCount(t *testing.T) { + p := &stubPlugin{name: "ok"} + s, err := safePluginStatusCount(p) + assert.NoError(t, err) + assert.Equal(t, "5 items", s) +} + +func TestSafePluginStatusCountPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + s, err := safePluginStatusCount(p) + assert.Equal(t, "", s) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during StatusCount") +} + +func TestSafePluginHelpBindings(t *testing.T) { + p := &stubPlugin{name: "ok"} + hb, err := safePluginHelpBindings(p) + assert.NoError(t, err) + assert.Nil(t, hb) +} + +func TestSafePluginHelpBindingsPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + hb, err := safePluginHelpBindings(p) + assert.Nil(t, hb) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during HelpBindings") +} + +func TestDoPluginFetchCmd(t *testing.T) { + p := &stubPlugin{name: "ok"} + cmd := doPluginFetch(context.Background(), 0, p) + msg := cmd() + fm, ok := msg.(pluginFetchMsg) + require.True(t, ok) + assert.Equal(t, 0, fm.groupIndex) + assert.Equal(t, "data", fm.data) + assert.NoError(t, fm.err) +} + +func TestDoPluginFetchCmdPanic(t *testing.T) { + p := &panicPlugin{stubPlugin: stubPlugin{name: "panic"}} + cmd := doPluginFetch(context.Background(), 1, p) + msg := cmd() + fm, ok := msg.(pluginFetchMsg) + require.True(t, ok) + assert.Equal(t, 1, fm.groupIndex) + assert.Error(t, fm.err) +} + +type exporterOnlyStub struct { + name string +} + +func (p *exporterOnlyStub) Name() string { return p.name } +func (p *exporterOnlyStub) Init(_ context.Context, _ plugin.PluginConfig) error { return nil } +func (p *exporterOnlyStub) Fetch(_ context.Context) (any, error) { return "metrics-data", nil } +func (p *exporterOnlyStub) WriteMetrics(_ io.Writer, _ any, _ string) {} + +func TestNewPluginTabs_ExporterOnly(t *testing.T) { + p := &exporterOnlyStub{name: "exporter-only"} + pts, g := newPluginTabs(p, 100) + + assert.Empty(t, pts) + assert.NotNil(t, g.fetcher) + assert.NotNil(t, g.exporter) +} + +func TestNewApp_IncludesExporterOnlyPlugins(t *testing.T) { + renderer := &stubPlugin{name: "with-renderer"} + exporterOnly := &exporterOnlyStub{name: "exporter-only"} + + cfg := Config{ + Plugins: []plugin.Plugin{renderer, exporterOnly}, + } + + app := NewApp(nil, cfg) + + assert.Len(t, app.pluginTabs, 1, "only renderer plugin should be in pluginTabs") + assert.Len(t, app.pluginGroups, 2, "both plugins should be in pluginGroups") + assert.Len(t, app.tabs, 4, "Caddy + Config + Certificates + renderer plugin should be in tabs") + assert.Equal(t, tabCaddy, app.tabs[0]) + assert.Equal(t, tabConfig, app.tabs[1]) + assert.Equal(t, tabCertificates, app.tabs[2]) + assert.Equal(t, tabPluginBase, app.tabs[3]) +} + +func TestPluginExports_IncludesExporterOnly(t *testing.T) { + exporterOnly := &exporterOnlyStub{name: "exporter-only"} + + cfg := Config{ + Plugins: []plugin.Plugin{exporterOnly}, + } + + app := NewApp(nil, cfg) + app.pluginGroups[0].data = "some-data" + + exports := app.pluginExports() + require.Len(t, exports, 1) + assert.Equal(t, "some-data", exports[0].Data) + assert.NotNil(t, exports[0].Exporter) +} + +func TestPluginExports_MixedPlugins(t *testing.T) { + renderer := &stubPlugin{name: "with-renderer"} + exporterOnly := &exporterOnlyStub{name: "exporter-only"} + + cfg := Config{ + Plugins: []plugin.Plugin{renderer, exporterOnly}, + } + + app := NewApp(nil, cfg) + app.pluginGroups[0].data = "renderer-data" + app.pluginGroups[1].data = "exporter-data" + + exports := app.pluginExports() + require.Len(t, exports, 2) +} + +func TestPluginExports_Empty(t *testing.T) { + app := NewApp(nil, Config{}) + exports := app.pluginExports() + assert.Nil(t, exports) +} + +func TestDoPluginFetches_SkipsWhenAlreadyFetching(t *testing.T) { + p := &stubPlugin{name: "test"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + cmds := app.doPluginFetches() + assert.Len(t, cmds, 1, "first call should return a fetch cmd") + + cmds = app.doPluginFetches() + assert.Empty(t, cmds, "second call should return nothing while still fetching") +} + +func TestDoPluginFetches_ResumesAfterFetchComplete(t *testing.T) { + p := &stubPlugin{name: "test"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + app.doPluginFetches() + app.pluginGroups[0].fetching = false + + cmds := app.doPluginFetches() + assert.Len(t, cmds, 1, "should fetch again after previous fetch completed") +} + +func TestDoPluginFetches_IncludesExporterOnly(t *testing.T) { + exporterOnly := &exporterOnlyStub{name: "exporter-only"} + + cfg := Config{ + Plugins: []plugin.Plugin{exporterOnly}, + } + + app := NewApp(nil, cfg) + cmds := app.doPluginFetches() + assert.Len(t, cmds, 1, "exporter-only plugin with Fetcher should produce a fetch cmd") +} + +func TestSafePluginAvailable(t *testing.T) { + t.Run("returns true", func(t *testing.T) { + a := &availPlugin{available: true} + assert.True(t, safePluginAvailable(a)) + }) + + t.Run("returns false", func(t *testing.T) { + a := &availPlugin{available: false} + assert.False(t, safePluginAvailable(a)) + }) + + t.Run("panic returns true (fail-open)", func(t *testing.T) { + a := &panicAvailPlugin{} + assert.True(t, safePluginAvailable(a)) + }) +} + +type availPlugin struct { + available bool +} + +func (a *availPlugin) Available() bool { return a.available } + +type panicAvailPlugin struct{} + +func (p *panicAvailPlugin) Available() bool { panic("avail boom") } + +type multiRendererPlugin struct { + stubPlugin + tabs []plugin.TabDescriptor +} + +func (p *multiRendererPlugin) Tabs() []plugin.TabDescriptor { return p.tabs } +func (p *multiRendererPlugin) RendererForTab(key string) plugin.Renderer { + return &stubPlugin{name: key} +} + +func TestNewPluginTabs_MultiRenderer(t *testing.T) { + p := &multiRendererPlugin{ + stubPlugin: stubPlugin{name: "multi"}, + tabs: []plugin.TabDescriptor{ + {Key: "overview", Name: "My Module Overview"}, + {Key: "details", Name: "My Module Details"}, + }, + } + + pts, g := newPluginTabs(p, 100) + + require.Len(t, pts, 2) + assert.Equal(t, tab(100), pts[0].tabID) + assert.Equal(t, "My Module Overview", pts[0].tabName) + assert.Equal(t, "overview", pts[0].tabKey) + assert.Equal(t, tab(101), pts[1].tabID) + assert.Equal(t, "My Module Details", pts[1].tabName) + assert.Equal(t, "details", pts[1].tabKey) + assert.Same(t, g, pts[0].group) + assert.Same(t, g, pts[1].group) +} + +func TestNewPluginTabs_MultiRendererPriority(t *testing.T) { + // MultiRenderer takes priority over Renderer + p := &multiRendererPlugin{ + stubPlugin: stubPlugin{name: "both"}, + tabs: []plugin.TabDescriptor{ + {Key: "tab1", Name: "Tab 1"}, + }, + } + + pts, _ := newPluginTabs(p, 100) + require.Len(t, pts, 1) + assert.Equal(t, "Tab 1", pts[0].tabName) +} + +func TestNewPluginTabs_AvailabilityDetected(t *testing.T) { + p := &availRendererPlugin{ + stubPlugin: stubPlugin{name: "avail"}, + available: true, + } + _, g := newPluginTabs(p, 100) + assert.NotNil(t, g.avail) + assert.True(t, g.wasAvail) +} + +type availRendererPlugin struct { + stubPlugin + available bool +} + +func (p *availRendererPlugin) Available() bool { return p.available } + +func TestSafeOnMetrics(t *testing.T) { + t.Run("normal call", func(t *testing.T) { + sub := &metricsSubPlugin{} + snap := &fetcher.Snapshot{} + assert.NotPanics(t, func() { safeOnMetrics(sub, snap) }) + assert.True(t, sub.called) + }) + + t.Run("panic recovery", func(t *testing.T) { + sub := &panicMetricsSubPlugin{} + snap := &fetcher.Snapshot{} + assert.NotPanics(t, func() { safeOnMetrics(sub, snap) }) + }) +} + +type metricsSubPlugin struct { + called bool +} + +func (p *metricsSubPlugin) OnMetrics(_ *fetcher.Snapshot) { p.called = true } + +type panicMetricsSubPlugin struct{} + +func (p *panicMetricsSubPlugin) OnMetrics(_ *fetcher.Snapshot) { panic("onmetrics boom") } + +func TestNewPluginTabs_SingleRenderer_EmptyTabKey(t *testing.T) { + p := &stubPlugin{name: "single"} + pts, _ := newPluginTabs(p, 100) + + require.Len(t, pts, 1) + assert.Equal(t, "", pts[0].tabKey) +} + +func TestNewPluginTabs_TabAvailabilityDetected(t *testing.T) { + p := &tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "ta"}, + tabs: []plugin.TabDescriptor{{Key: "a", Name: "A"}, {Key: "b", Name: "B"}}, + }, + tabAvail: map[string]bool{"a": true, "b": true}, + } + _, g := newPluginTabs(p, 100) + + assert.NotNil(t, g.tabAvail) + assert.True(t, g.wasTabAvail["a"]) + assert.True(t, g.wasTabAvail["b"]) +} + +func TestNewPluginTabs_TabAvailabilityIgnoredForSingleRenderer(t *testing.T) { + p := &tabAvailSinglePlugin{ + stubPlugin: stubPlugin{name: "single-ta"}, + tabAvail: map[string]bool{"x": true}, + } + _, g := newPluginTabs(p, 100) + + assert.Nil(t, g.tabAvail, "TabAvailability should be ignored for single-Renderer plugins") +} + +type tabAvailMultiPlugin struct { + multiRendererPlugin + tabAvail map[string]bool +} + +func (p *tabAvailMultiPlugin) TabAvailable(key string) bool { return p.tabAvail[key] } + +type tabAvailSinglePlugin struct { + stubPlugin + tabAvail map[string]bool +} + +func (p *tabAvailSinglePlugin) TabAvailable(key string) bool { return p.tabAvail[key] } + +type panicTabAvailPlugin struct{} + +func (p *panicTabAvailPlugin) TabAvailable(_ string) bool { panic("tab avail boom") } + +func TestSafePluginTabAvailable(t *testing.T) { + t.Run("returns true", func(t *testing.T) { + ta := &tabAvailMultiPlugin{tabAvail: map[string]bool{"a": true}} + assert.True(t, safePluginTabAvailable(ta, "a")) + }) + + t.Run("returns false", func(t *testing.T) { + ta := &tabAvailMultiPlugin{tabAvail: map[string]bool{"a": false}} + assert.False(t, safePluginTabAvailable(ta, "a")) + }) + + t.Run("missing key returns false", func(t *testing.T) { + ta := &tabAvailMultiPlugin{tabAvail: map[string]bool{}} + assert.False(t, safePluginTabAvailable(ta, "unknown")) + }) + + t.Run("panic returns true (fail-open)", func(t *testing.T) { + ta := &panicTabAvailPlugin{} + assert.True(t, safePluginTabAvailable(ta, "any")) + }) +} + +func TestUpdatePluginTabVisibility(t *testing.T) { + p := &stubPlugin{name: "vis"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + require.Contains(t, app.tabs, app.pluginTabs[0].tabID) + + // hide + app.updatePluginTabVisibility(app.pluginGroups[0], false) + assert.NotContains(t, app.tabs, app.pluginTabs[0].tabID) + + // show + app.updatePluginTabVisibility(app.pluginGroups[0], true) + assert.Contains(t, app.tabs, app.pluginTabs[0].tabID) +} + +func TestUpdatePluginTabVisibility_SwitchesActiveTab(t *testing.T) { + p := &stubPlugin{name: "active"} + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + app.switchTab(app.pluginTabs[0].tabID) + assert.Equal(t, app.pluginTabs[0].tabID, app.activeTab) + + app.updatePluginTabVisibility(app.pluginGroups[0], false) + assert.Equal(t, tabCaddy, app.activeTab, "should switch to Caddy when active tab is hidden") +} + +func TestPerTabAvailability_HidesOneTab(t *testing.T) { + p := &tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "waf"}, + tabs: []plugin.TabDescriptor{ + {Key: "rules", Name: "Rules"}, + {Key: "analytics", Name: "Analytics"}, + }, + }, + tabAvail: map[string]bool{"rules": true, "analytics": true}, + } + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + require.Len(t, app.pluginTabs, 2) + assert.Contains(t, app.tabs, app.pluginTabs[0].tabID) + assert.Contains(t, app.tabs, app.pluginTabs[1].tabID) + + p.tabAvail["analytics"] = false + g := app.pluginGroups[0] + for _, pt := range app.pluginTabs { + if pt.group != g || pt.tabKey == "" { + continue + } + nowAvail := safePluginTabAvailable(g.tabAvail, pt.tabKey) + if nowAvail != g.wasTabAvail[pt.tabKey] { + g.wasTabAvail[pt.tabKey] = nowAvail + app.updateSingleTabVisibility(pt, nowAvail) + } + } + + assert.Contains(t, app.tabs, app.pluginTabs[0].tabID, "rules tab should still be visible") + assert.NotContains(t, app.tabs, app.pluginTabs[1].tabID, "analytics tab should be hidden") +} + +func TestPerTabAvailability_MasterSwitchOverrides(t *testing.T) { + p := &tabAvailMasterPlugin{ + tabAvailMultiPlugin: tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "waf"}, + tabs: []plugin.TabDescriptor{ + {Key: "rules", Name: "Rules"}, + {Key: "analytics", Name: "Analytics"}, + }, + }, + tabAvail: map[string]bool{"rules": true, "analytics": true}, + }, + available: true, + } + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + require.Len(t, app.pluginTabs, 2) + + p.available = false + app.updatePluginTabVisibility(app.pluginGroups[0], false) + + assert.NotContains(t, app.tabs, app.pluginTabs[0].tabID, "master switch off hides all") + assert.NotContains(t, app.tabs, app.pluginTabs[1].tabID, "master switch off hides all") +} + +func TestPerTabAvailability_ActiveTabSwitchesOnHide(t *testing.T) { + p := &tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "waf"}, + tabs: []plugin.TabDescriptor{ + {Key: "rules", Name: "Rules"}, + {Key: "analytics", Name: "Analytics"}, + }, + }, + tabAvail: map[string]bool{"rules": true, "analytics": true}, + } + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + + app.switchTab(app.pluginTabs[1].tabID) + assert.Equal(t, app.pluginTabs[1].tabID, app.activeTab) + + app.updateSingleTabVisibility(app.pluginTabs[1], false) + assert.Equal(t, tabCaddy, app.activeTab, "should switch away when active tab is hidden") +} + +type tabAvailMasterPlugin struct { + tabAvailMultiPlugin + available bool +} + +func (p *tabAvailMasterPlugin) Available() bool { return p.available } + +func TestPerTabAvailability_MasterSwitchRestoresWithPerTab(t *testing.T) { + p := &tabAvailMasterPlugin{ + tabAvailMultiPlugin: tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "waf"}, + tabs: []plugin.TabDescriptor{ + {Key: "rules", Name: "Rules"}, + {Key: "analytics", Name: "Analytics"}, + }, + }, + tabAvail: map[string]bool{"rules": true, "analytics": false}, + }, + available: true, + } + cfg := Config{Plugins: []plugin.Plugin{p}} + app := NewApp(nil, cfg) + g := app.pluginGroups[0] + + // per-tab: hide analytics + for _, pt := range app.pluginTabs { + if pt.group != g || pt.tabKey == "" { + continue + } + nowAvail := safePluginTabAvailable(g.tabAvail, pt.tabKey) + if nowAvail != g.wasTabAvail[pt.tabKey] { + g.wasTabAvail[pt.tabKey] = nowAvail + app.updateSingleTabVisibility(pt, nowAvail) + } + } + assert.NotContains(t, app.tabs, app.pluginTabs[1].tabID, "analytics initially hidden") + + // master switch off + p.available = false + g.wasAvail = false + app.updatePluginTabVisibility(g, false) + assert.NotContains(t, app.tabs, app.pluginTabs[0].tabID) + + // master switch back on: simulates what pluginFetchMsg handler does + p.available = true + nowAvail := safePluginAvailable(g.avail) + g.wasAvail = nowAvail + app.updatePluginTabVisibility(g, nowAvail) + if g.wasTabAvail != nil { + for k := range g.wasTabAvail { + g.wasTabAvail[k] = true + } + } + + // per-tab re-evaluation + for _, pt := range app.pluginTabs { + if pt.group != g || pt.tabKey == "" { + continue + } + tabNowAvail := safePluginTabAvailable(g.tabAvail, pt.tabKey) + if tabNowAvail != g.wasTabAvail[pt.tabKey] { + g.wasTabAvail[pt.tabKey] = tabNowAvail + app.updateSingleTabVisibility(pt, tabNowAvail) + } + } + + assert.Contains(t, app.tabs, app.pluginTabs[0].tabID, "rules should be visible after master re-enable") + assert.NotContains(t, app.tabs, app.pluginTabs[1].tabID, "analytics should stay hidden after master re-enable") +} diff --git a/internal/ui/plugin_integration_test.go b/internal/ui/plugin_integration_test.go new file mode 100644 index 0000000..0e6298c --- /dev/null +++ b/internal/ui/plugin_integration_test.go @@ -0,0 +1,186 @@ +package ui + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/alexandre-daubois/ember/pkg/plugin" + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type lifecyclePlugin struct { + name string + fetchCount int + viewData string +} + +func (p *lifecyclePlugin) Name() string { return p.name } +func (p *lifecyclePlugin) Init(_ context.Context, _ plugin.PluginConfig) error { return nil } + +func (p *lifecyclePlugin) Fetch(_ context.Context) (any, error) { + p.fetchCount++ + return fmt.Sprintf("fetch-%d", p.fetchCount), nil +} + +func (p *lifecyclePlugin) Update(data any, _, _ int) plugin.Renderer { + p.viewData = data.(string) + return p +} + +func (p *lifecyclePlugin) View(_, _ int) string { + if p.viewData == "" { + return "waiting..." + } + return "content: " + p.viewData +} + +func (p *lifecyclePlugin) HandleKey(msg tea.KeyMsg) bool { + return msg.String() == "x" +} + +func (p *lifecyclePlugin) StatusCount() string { + if p.viewData == "" { + return "" + } + return p.viewData +} + +func (p *lifecyclePlugin) HelpBindings() []plugin.HelpBinding { + return []plugin.HelpBinding{{Key: "x", Desc: "custom action"}} +} + +func (p *lifecyclePlugin) WriteMetrics(w io.Writer, data any, prefix string) { + if data == nil { + return + } + name := "lifecycle_ticks" + if prefix != "" { + name = prefix + "_" + name + } + fmt.Fprintf(w, "%s{plugin=\"%s\"} 1\n", name, p.name) +} + +func TestIntegration_PluginFullLifecycle(t *testing.T) { + p := &lifecyclePlugin{name: "lifecycle"} + app := NewApp(nil, Config{Plugins: []plugin.Plugin{p}}) + app.width = 120 + app.height = 40 + + // plugin tab is registered + require.Len(t, app.pluginTabs, 1) + require.Len(t, app.pluginGroups, 1) + assert.Contains(t, app.tabs, app.pluginTabs[0].tabID) + + // before first fetch: view shows initial state + app.switchTab(app.pluginTabs[0].tabID) + view := safePluginView(app.pluginTabs[0].renderer, 80, 24) + assert.Equal(t, "waiting...", view) + + // dispatch pluginFetchMsg through Update (simulates completed fetch) + app.Update(pluginFetchMsg{groupIndex: 0, data: "fetch-1"}) + + // data propagated to group + assert.Equal(t, "fetch-1", app.pluginGroups[0].data) + assert.NoError(t, app.pluginGroups[0].err) + + // renderer was updated + view = safePluginView(app.pluginTabs[0].renderer, 80, 24) + assert.Equal(t, "content: fetch-1", view) + + // StatusCount reflects new data + count, err := safePluginStatusCount(app.pluginTabs[0].renderer) + assert.NoError(t, err) + assert.Equal(t, "fetch-1", count) + + // Exports include plugin data + exports := app.pluginExports() + require.Len(t, exports, 1) + assert.Equal(t, "fetch-1", exports[0].Data) + assert.NotNil(t, exports[0].Exporter) + + // second fetch: state updates correctly + app.Update(pluginFetchMsg{groupIndex: 0, data: "fetch-2"}) + assert.Equal(t, "fetch-2", app.pluginGroups[0].data) + view = safePluginView(app.pluginTabs[0].renderer, 80, 24) + assert.Equal(t, "content: fetch-2", view) + + // key handling: plugin-consumed key + consumed, err := safePluginHandleKey(app.pluginTabs[0].renderer, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'x'}}) + assert.NoError(t, err) + assert.True(t, consumed) + + // key handling: non-consumed key + consumed, err = safePluginHandleKey(app.pluginTabs[0].renderer, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'z'}}) + assert.NoError(t, err) + assert.False(t, consumed) + + hb, err := safePluginHelpBindings(app.pluginTabs[0].renderer) + assert.NoError(t, err) + require.Len(t, hb, 1) + assert.Equal(t, "x", hb[0].Key) + + // fetch error: previous data preserved + app.Update(pluginFetchMsg{groupIndex: 0, err: assert.AnError}) + assert.Equal(t, "fetch-2", app.pluginGroups[0].data, "data should be preserved on error") + assert.ErrorIs(t, app.pluginGroups[0].err, assert.AnError) +} + +func TestIntegration_MultiRendererTabAvailability(t *testing.T) { + p := &tabAvailMasterPlugin{ + tabAvailMultiPlugin: tabAvailMultiPlugin{ + multiRendererPlugin: multiRendererPlugin{ + stubPlugin: stubPlugin{name: "multi"}, + tabs: []plugin.TabDescriptor{ + {Key: "overview", Name: "Overview"}, + {Key: "details", Name: "Details"}, + }, + }, + tabAvail: map[string]bool{"overview": true, "details": true}, + }, + available: true, + } + app := NewApp(nil, Config{Plugins: []plugin.Plugin{p}}) + app.width = 120 + app.height = 40 + + require.Len(t, app.pluginTabs, 2) + overviewTabID := app.pluginTabs[0].tabID + detailsTabID := app.pluginTabs[1].tabID + + // both tabs visible initially + assert.Contains(t, app.tabs, overviewTabID) + assert.Contains(t, app.tabs, detailsTabID) + + // fetch with details unavailable: dispatched through Update + p.tabAvail["details"] = false + app.Update(pluginFetchMsg{groupIndex: 0, data: "tick-1"}) + + assert.Contains(t, app.tabs, overviewTabID, "overview stays visible") + assert.NotContains(t, app.tabs, detailsTabID, "details hidden by TabAvailability") + + // details becomes available again + p.tabAvail["details"] = true + app.Update(pluginFetchMsg{groupIndex: 0, data: "tick-2"}) + + assert.Contains(t, app.tabs, overviewTabID) + assert.Contains(t, app.tabs, detailsTabID, "details re-shown") + + // master switch off: hides everything + p.available = false + app.Update(pluginFetchMsg{groupIndex: 0, data: "tick-3"}) + + assert.NotContains(t, app.tabs, overviewTabID, "master off hides overview") + assert.NotContains(t, app.tabs, detailsTabID, "master off hides details") + + // master switch on with details unavailable: only overview returns + p.available = true + p.tabAvail["details"] = false + app.Update(pluginFetchMsg{groupIndex: 0, data: "tick-4"}) + + assert.Contains(t, app.tabs, overviewTabID, "overview restored after master on") + assert.NotContains(t, app.tabs, detailsTabID, "details stays hidden per-tab after master on") +} diff --git a/internal/ui/tabbar.go b/internal/ui/tabbar.go index abeaf86..a5e9ff3 100644 --- a/internal/ui/tabbar.go +++ b/internal/ui/tabbar.go @@ -14,7 +14,7 @@ var ( Foreground(subtle) ) -func tabLabel(t tab) string { +func tabLabel(t tab, pluginTabs []*pluginTab) string { switch t { case tabCaddy: return "Caddy" @@ -25,14 +25,19 @@ func tabLabel(t tab) string { case tabFrankenPHP: return "FrankenPHP" default: + for _, pt := range pluginTabs { + if pt.tabID == t { + return pt.tabName + } + } return "?" } } -func renderTabBar(tabs []tab, active tab, width int, counts map[tab]string) string { +func renderTabBar(tabs []tab, active tab, width int, counts map[tab]string, pluginTabs []*pluginTab) string { var parts []string for _, t := range tabs { - label := tabLabel(t) + label := tabLabel(t, pluginTabs) if c, ok := counts[t]; ok && c != "" { label += " (" + c + ")" } diff --git a/internal/ui/tabbar_test.go b/internal/ui/tabbar_test.go index 42def3c..567174b 100644 --- a/internal/ui/tabbar_test.go +++ b/internal/ui/tabbar_test.go @@ -8,7 +8,7 @@ import ( func TestRenderTabBar_NoCounts(t *testing.T) { tabs := []tab{tabCaddy, tabFrankenPHP} - result := renderTabBar(tabs, tabCaddy, 80, nil) + result := renderTabBar(tabs, tabCaddy, 80, nil, nil) assert.Contains(t, result, "Caddy") assert.Contains(t, result, "FrankenPHP") @@ -20,7 +20,7 @@ func TestRenderTabBar_WithCounts(t *testing.T) { tabCaddy: "5 hosts", tabFrankenPHP: "12 threads", } - result := renderTabBar(tabs, tabCaddy, 80, counts) + result := renderTabBar(tabs, tabCaddy, 80, counts, nil) assert.Contains(t, result, "Caddy (5 hosts)") assert.Contains(t, result, "FrankenPHP (12 threads)") @@ -31,7 +31,7 @@ func TestRenderTabBar_PartialCounts(t *testing.T) { counts := map[tab]string{ tabCaddy: "3 hosts", } - result := renderTabBar(tabs, tabCaddy, 80, counts) + result := renderTabBar(tabs, tabCaddy, 80, counts, nil) assert.Contains(t, result, "Caddy (3 hosts)") assert.NotContains(t, result, "FrankenPHP (") @@ -42,7 +42,7 @@ func TestRenderTabBar_EmptyCountIgnored(t *testing.T) { counts := map[tab]string{ tabCaddy: "", } - result := renderTabBar(tabs, tabCaddy, 80, counts) + result := renderTabBar(tabs, tabCaddy, 80, counts, nil) assert.NotContains(t, result, "()") } diff --git a/pkg/metrics/parse.go b/pkg/metrics/parse.go new file mode 100644 index 0000000..398e897 --- /dev/null +++ b/pkg/metrics/parse.go @@ -0,0 +1,453 @@ +package metrics + +import ( + "fmt" + "io" + "slices" + "strconv" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" +) + +// ParsePrometheus parses Prometheus text-format metrics from r into a MetricsSnapshot. +func ParsePrometheus(r io.Reader) (snap MetricsSnapshot, err error) { + defer func() { + if r := recover(); r != nil { + snap = MetricsSnapshot{} + err = fmt.Errorf("parse prometheus: panic: %v", r) + } + }() + + parser := expfmt.NewTextParser(model.UTF8Validation) + families, parseErr := parser.TextToMetricFamilies(r) + if parseErr != nil { + return MetricsSnapshot{}, fmt.Errorf("parse prometheus: %w", parseErr) + } + + snap = MetricsSnapshot{ + Workers: make(map[string]*WorkerMetrics), + Hosts: make(map[string]*HostMetrics), + } + + snap.TotalThreads = ScalarValue(families, "frankenphp_total_threads") + snap.BusyThreads = ScalarValue(families, "frankenphp_busy_threads") + snap.QueueDepth = ScalarValue(families, "frankenphp_queue_depth") + + perWorker := []struct { + name string + setter func(wm *WorkerMetrics, v float64) + }{ + {"frankenphp_total_workers", func(wm *WorkerMetrics, v float64) { wm.Total = v }}, + {"frankenphp_busy_workers", func(wm *WorkerMetrics, v float64) { wm.Busy = v }}, + {"frankenphp_ready_workers", func(wm *WorkerMetrics, v float64) { wm.Ready = v }}, + {"frankenphp_worker_request_time", func(wm *WorkerMetrics, v float64) { wm.RequestTime = v }}, + {"frankenphp_worker_request_count", func(wm *WorkerMetrics, v float64) { wm.RequestCount = v }}, + {"frankenphp_worker_crashes", func(wm *WorkerMetrics, v float64) { wm.Crashes = v }}, + {"frankenphp_worker_restarts", func(wm *WorkerMetrics, v float64) { wm.Restarts = v }}, + {"frankenphp_worker_queue_depth", func(wm *WorkerMetrics, v float64) { wm.QueueDepth = v }}, + } + + for _, pw := range perWorker { + fam, ok := families[pw.name] + if !ok { + continue + } + for _, m := range fam.GetMetric() { + worker := labelValue(m, "worker") + if worker == "" { + continue + } + wm := snap.getOrCreateWorker(worker) + pw.setter(wm, metricValue(m)) + } + } + + // Caddy HTTP metrics (available with `metrics` directive) + snap.HTTPRequestErrorsTotal = sumCounter(families, "caddy_http_request_errors_total") + snap.HTTPRequestsTotal = sumCounter(families, "caddy_http_requests_total") + snap.HTTPRequestDurationSum, snap.HTTPRequestDurationCount, snap.DurationBuckets = histogramData(families, "caddy_http_request_duration_seconds") + snap.HTTPRequestsInFlight = ScalarValue(families, "caddy_http_requests_in_flight") + snap.HasHTTPMetrics = snap.HTTPRequestsTotal > 0 || snap.HTTPRequestDurationCount > 0 + + snap.ProcessCPUSecondsTotal = ScalarValue(families, "process_cpu_seconds_total") + snap.ProcessRSSBytes = ScalarValue(families, "process_resident_memory_bytes") + snap.ProcessStartTimeSeconds = ScalarValue(families, "process_start_time_seconds") + + _, hasReload := families["caddy_config_last_reload_successful"] + snap.HasConfigReloadMetrics = hasReload + snap.ConfigLastReloadSuccessful = ScalarValue(families, "caddy_config_last_reload_successful") + snap.ConfigLastReloadSuccessTimestamp = ScalarValue(families, "caddy_config_last_reload_success_timestamp_seconds") + + snap.Hosts = perHostMetrics(families) + + // Fallback: if HTTP metrics exist but no host labels, aggregate as a single "*" entry + if snap.HasHTTPMetrics && len(snap.Hosts) == 0 { + statusCodes := aggregateStatusCodes(families, "caddy_http_requests_total") + if statusCodes == nil { + statusCodes = statusCodesFromHistogram(families, "caddy_http_request_duration_seconds") + } + snap.Hosts = map[string]*HostMetrics{ + "*": { + Host: "*", + RequestsTotal: snap.HTTPRequestsTotal, + DurationSum: snap.HTTPRequestDurationSum, + DurationCount: snap.HTTPRequestDurationCount, + InFlight: snap.HTTPRequestsInFlight, + DurationBuckets: snap.DurationBuckets, + StatusCodes: statusCodes, + }, + } + } + + snap.Extra = extraFamilies(families) + + return snap, nil +} + +var coreMetricNames = map[string]struct{}{ + "frankenphp_total_threads": {}, + "frankenphp_busy_threads": {}, + "frankenphp_queue_depth": {}, + "frankenphp_total_workers": {}, + "frankenphp_busy_workers": {}, + "frankenphp_ready_workers": {}, + "frankenphp_worker_request_time": {}, + "frankenphp_worker_request_count": {}, + "frankenphp_worker_crashes": {}, + "frankenphp_worker_restarts": {}, + "frankenphp_worker_queue_depth": {}, + + "caddy_http_request_errors_total": {}, + "caddy_http_requests_total": {}, + "caddy_http_request_duration_seconds": {}, + "caddy_http_requests_in_flight": {}, + "caddy_http_response_duration_seconds": {}, + "caddy_http_response_size_bytes": {}, + "caddy_http_request_size_bytes": {}, + + "process_cpu_seconds_total": {}, + "process_resident_memory_bytes": {}, + "process_start_time_seconds": {}, + + "caddy_config_last_reload_successful": {}, + "caddy_config_last_reload_success_timestamp_seconds": {}, +} + +func extraFamilies(families map[string]*dto.MetricFamily) map[string]*dto.MetricFamily { + var extra map[string]*dto.MetricFamily + for name, fam := range families { + if _, ok := coreMetricNames[name]; !ok { + if extra == nil { + extra = make(map[string]*dto.MetricFamily) + } + extra[name] = fam + } + } + return extra +} + +func (s *MetricsSnapshot) getOrCreateWorker(name string) *WorkerMetrics { + wm, ok := s.Workers[name] + if !ok { + wm = &WorkerMetrics{Worker: name} + s.Workers[name] = wm + } + return wm +} + +func sumCounter(families map[string]*dto.MetricFamily, name string) float64 { + fam, ok := families[name] + if !ok { + return 0 + } + var total float64 + for _, m := range fam.GetMetric() { + total += metricValue(m) + } + return total +} + +func histogramData(families map[string]*dto.MetricFamily, name string) (float64, float64, []HistogramBucket) { + fam, ok := families[name] + if !ok { + return 0, 0, nil + } + var sumTotal, countTotal float64 + bucketMap := make(map[float64]float64) + for _, m := range fam.GetMetric() { + if h := m.GetHistogram(); h != nil { + sumTotal += h.GetSampleSum() + countTotal += float64(h.GetSampleCount()) + for _, b := range h.GetBucket() { + bucketMap[b.GetUpperBound()] += float64(b.GetCumulativeCount()) + } + } + } + + var buckets []HistogramBucket + for ub, count := range bucketMap { + buckets = append(buckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) + } + SortBuckets(buckets) + + return sumTotal, countTotal, buckets +} + +// SortBuckets sorts histogram buckets by ascending upper bound. +func SortBuckets(buckets []HistogramBucket) { + slices.SortFunc(buckets, func(a, b HistogramBucket) int { + if a.UpperBound < b.UpperBound { + return -1 + } + if a.UpperBound > b.UpperBound { + return 1 + } + return 0 + }) +} + +// ScalarValue returns the first metric's gauge/counter/untyped value +// for the named family. Returns 0 if the family is missing or empty. +func ScalarValue(families map[string]*dto.MetricFamily, name string) float64 { + fam, ok := families[name] + if !ok || len(fam.GetMetric()) == 0 { + return 0 + } + return metricValue(fam.GetMetric()[0]) +} + +func metricValue(m *dto.Metric) float64 { + if g := m.GetGauge(); g != nil { + return g.GetValue() + } + if c := m.GetCounter(); c != nil { + return c.GetValue() + } + if u := m.GetUntyped(); u != nil { + return u.GetValue() + } + return 0 +} + +func labelValue(m *dto.Metric, name string) string { + for _, l := range m.GetLabel() { + if l.GetName() == name { + return l.GetValue() + } + } + return "" +} + +func aggregateStatusCodes(families map[string]*dto.MetricFamily, name string) map[int]float64 { + fam, ok := families[name] + if !ok { + return nil + } + codes := make(map[int]float64) + for _, m := range fam.GetMetric() { + if code := labelValue(m, "code"); code != "" { + if c, err := strconv.Atoi(code); err == nil { + codes[c] += metricValue(m) + } + } + } + if len(codes) == 0 { + return nil + } + return codes +} + +func statusCodesFromHistogram(families map[string]*dto.MetricFamily, name string) map[int]float64 { + fam, ok := families[name] + if !ok { + return nil + } + codes := make(map[int]float64) + for _, m := range fam.GetMetric() { + h := m.GetHistogram() + if h == nil { + continue + } + if code := labelValue(m, "code"); code != "" { + if c, err := strconv.Atoi(code); err == nil { + codes[c] += float64(h.GetSampleCount()) + } + } + } + if len(codes) == 0 { + return nil + } + return codes +} + +func hostOrServer(m *dto.Metric) string { + if h := labelValue(m, "host"); h != "" { + return h + } + return labelValue(m, "server") +} + +func perHostMetrics(families map[string]*dto.MetricFamily) map[string]*HostMetrics { + hosts := make(map[string]*HostMetrics) + + getOrCreate := func(host string) *HostMetrics { + hm, ok := hosts[host] + if !ok { + hm = &HostMetrics{Host: host, StatusCodes: make(map[int]float64), Methods: make(map[string]float64)} + hosts[host] = hm + } + return hm + } + + hostsWithCounterCodes := make(map[string]bool) + if fam, ok := families["caddy_http_requests_total"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + hm := getOrCreate(host) + v := metricValue(m) + hm.RequestsTotal += v + if code := labelValue(m, "code"); code != "" { + if c, err := strconv.Atoi(code); err == nil { + hm.StatusCodes[c] += v + hostsWithCounterCodes[host] = true + } + } + if method := labelValue(m, "method"); method != "" { + hm.Methods[method] += v + } + } + } + + bucketMaps := make(map[string]map[float64]float64) + if fam, ok := families["caddy_http_request_duration_seconds"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + h := m.GetHistogram() + if h == nil { + continue + } + hm := getOrCreate(host) + hm.DurationSum += h.GetSampleSum() + hm.DurationCount += float64(h.GetSampleCount()) + + if !hostsWithCounterCodes[host] { + if code := labelValue(m, "code"); code != "" { + if c, err := strconv.Atoi(code); err == nil { + hm.StatusCodes[c] += float64(h.GetSampleCount()) + } + } + } + + if bucketMaps[host] == nil { + bucketMaps[host] = make(map[float64]float64) + } + for _, b := range h.GetBucket() { + bucketMaps[host][b.GetUpperBound()] += float64(b.GetCumulativeCount()) + } + } + } + + for host, bm := range bucketMaps { + hm := hosts[host] + hm.DurationBuckets = make([]HistogramBucket, 0, len(bm)) + for ub, count := range bm { + hm.DurationBuckets = append(hm.DurationBuckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) + } + SortBuckets(hm.DurationBuckets) + } + + ttfbBucketMaps := make(map[string]map[float64]float64) + if fam, ok := families["caddy_http_response_duration_seconds"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + h := m.GetHistogram() + if h == nil { + continue + } + hm := getOrCreate(host) + hm.TTFBSum += h.GetSampleSum() + hm.TTFBCount += float64(h.GetSampleCount()) + + if ttfbBucketMaps[host] == nil { + ttfbBucketMaps[host] = make(map[float64]float64) + } + for _, b := range h.GetBucket() { + ttfbBucketMaps[host][b.GetUpperBound()] += float64(b.GetCumulativeCount()) + } + } + } + + for host, bm := range ttfbBucketMaps { + hm := hosts[host] + hm.TTFBBuckets = make([]HistogramBucket, 0, len(bm)) + for ub, count := range bm { + hm.TTFBBuckets = append(hm.TTFBBuckets, HistogramBucket{UpperBound: ub, CumulativeCount: count}) + } + SortBuckets(hm.TTFBBuckets) + } + + if fam, ok := families["caddy_http_response_size_bytes"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + h := m.GetHistogram() + if h == nil { + continue + } + hm := getOrCreate(host) + hm.ResponseSizeSum += h.GetSampleSum() + hm.ResponseSizeCount += float64(h.GetSampleCount()) + } + } + + if fam, ok := families["caddy_http_request_size_bytes"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + h := m.GetHistogram() + if h == nil { + continue + } + hm := getOrCreate(host) + hm.RequestSizeSum += h.GetSampleSum() + hm.RequestSizeCount += float64(h.GetSampleCount()) + } + } + + if fam, ok := families["caddy_http_request_errors_total"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + getOrCreate(host).ErrorsTotal += metricValue(m) + } + } + + if fam, ok := families["caddy_http_requests_in_flight"]; ok { + for _, m := range fam.GetMetric() { + host := hostOrServer(m) + if host == "" { + continue + } + getOrCreate(host).InFlight += metricValue(m) + } + } + + return hosts +} diff --git a/pkg/metrics/types.go b/pkg/metrics/types.go new file mode 100644 index 0000000..dbb0809 --- /dev/null +++ b/pkg/metrics/types.go @@ -0,0 +1,121 @@ +// Package metrics exposes the data types used by Ember to represent +// Caddy and FrankenPHP metrics. Plugin authors can import this package +// to reuse Ember's metric structures and Prometheus parser. +// +// EXPERIMENTAL: this package is part of the plugin API and is not yet +// stable. Types and fields may change in any future release. +package metrics + +import ( + "time" + + dto "github.com/prometheus/client_model/go" +) + +type ThreadDebugState struct { + Index int `json:"Index"` + Name string `json:"Name"` + State string `json:"State"` + IsWaiting bool `json:"IsWaiting"` + IsBusy bool `json:"IsBusy"` + WaitingSinceMilliseconds int64 `json:"WaitingSinceMilliseconds"` + + CurrentURI string `json:"CurrentURI,omitempty"` + CurrentMethod string `json:"CurrentMethod,omitempty"` + RequestStartedAt int64 `json:"RequestStartedAt,omitempty"` + MemoryUsage int64 `json:"MemoryUsage,omitempty"` + RequestCount int64 `json:"RequestCount,omitempty"` +} + +type ThreadsResponse struct { + ThreadDebugStates []ThreadDebugState `json:"ThreadDebugStates"` + ReservedThreadCount int `json:"ReservedThreadCount"` +} + +type WorkerMetrics struct { + Worker string `json:"worker"` + Total float64 `json:"total"` + Busy float64 `json:"busy"` + Ready float64 `json:"ready"` + RequestTime float64 `json:"requestTime"` + RequestCount float64 `json:"requestCount"` + Crashes float64 `json:"crashes"` + Restarts float64 `json:"restarts"` + QueueDepth float64 `json:"queueDepth"` +} + +type HostMetrics struct { + Host string `json:"host"` + RequestsTotal float64 `json:"requestsTotal"` + DurationSum float64 `json:"durationSum"` + DurationCount float64 `json:"durationCount"` + InFlight float64 `json:"inFlight"` + DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"` + StatusCodes map[int]float64 `json:"statusCodes,omitempty"` + Methods map[string]float64 `json:"methods,omitempty"` + ResponseSizeSum float64 `json:"responseSizeSum"` + ResponseSizeCount float64 `json:"responseSizeCount"` + RequestSizeSum float64 `json:"requestSizeSum"` + RequestSizeCount float64 `json:"requestSizeCount"` + ErrorsTotal float64 `json:"errorsTotal"` + TTFBSum float64 `json:"ttfbSum"` + TTFBCount float64 `json:"ttfbCount"` + TTFBBuckets []HistogramBucket `json:"ttfbBuckets,omitempty"` +} + +type MetricsSnapshot struct { + // FrankenPHP-specific (require frankenphp metrics) + TotalThreads float64 `json:"totalThreads"` + BusyThreads float64 `json:"busyThreads"` + QueueDepth float64 `json:"queueDepth"` + Workers map[string]*WorkerMetrics `json:"workers"` + + // Caddy HTTP metrics (require `metrics` directive in Caddyfile) + HTTPRequestErrorsTotal float64 `json:"httpRequestErrorsTotal"` + HTTPRequestsTotal float64 `json:"httpRequestsTotal"` + HTTPRequestDurationSum float64 `json:"httpRequestDurationSum"` + HTTPRequestDurationCount float64 `json:"httpRequestDurationCount"` + HTTPRequestsInFlight float64 `json:"httpRequestsInFlight"` + DurationBuckets []HistogramBucket `json:"durationBuckets,omitempty"` + HasHTTPMetrics bool `json:"hasHttpMetrics"` + + // Per-host Caddy HTTP metrics + Hosts map[string]*HostMetrics `json:"hosts,omitempty"` + + // Go runtime process metrics (from standard Prometheus collector) + ProcessCPUSecondsTotal float64 `json:"processCpuSecondsTotal,omitempty"` + ProcessRSSBytes float64 `json:"processRssBytes,omitempty"` + ProcessStartTimeSeconds float64 `json:"processStartTimeSeconds,omitempty"` + + // Caddy config reload status (built-in Caddy metrics) + HasConfigReloadMetrics bool `json:"hasConfigReloadMetrics"` + ConfigLastReloadSuccessful float64 `json:"configLastReloadSuccessful"` + ConfigLastReloadSuccessTimestamp float64 `json:"configLastReloadSuccessTimestamp"` + + // Extra contains metric families not consumed by Ember's core parser. + // Plugin authors can use this to access custom metrics registered with + // Caddy's Prometheus collector without making a separate /metrics request. + Extra map[string]*dto.MetricFamily `json:"-"` +} + +type HistogramBucket struct { + UpperBound float64 `json:"upperBound"` + CumulativeCount float64 `json:"cumulativeCount"` +} + +type ProcessMetrics struct { + PID int32 `json:"pid"` + CPUPercent float64 `json:"cpuPercent"` + RSS uint64 `json:"rss"` + CreateTime int64 `json:"createTime"` + Uptime time.Duration `json:"uptime"` +} + +type Snapshot struct { + Threads ThreadsResponse `json:"threads"` + Metrics MetricsSnapshot `json:"metrics"` + Process ProcessMetrics `json:"process"` + FetchedAt time.Time `json:"fetchedAt"` + Errors []string `json:"errors,omitempty"` + HasFrankenPHP bool `json:"hasFrankenPHP"` +} diff --git a/pkg/plugin/plugin.go b/pkg/plugin/plugin.go new file mode 100644 index 0000000..42466d7 --- /dev/null +++ b/pkg/plugin/plugin.go @@ -0,0 +1,207 @@ +// Package plugin defines the interfaces for building Ember plugins. +// +// EXPERIMENTAL: the plugin API is not yet stable. Interfaces, method +// signatures, and behavior may change in any future release. +// +// Plugins extend Ember with custom TUI tabs, Prometheus metrics, or both. +// They are compiled into the binary using Go's blank import pattern (the same +// approach used by Caddy). There is no runtime plugin loading. +// +// A plugin must implement [Plugin] (Name + Init). It can optionally implement +// any combination of [Fetcher], [Renderer]/[MultiRenderer], and [Exporter]: +// +// - Fetcher + Renderer: custom TUI tab (data collection + visualization) +// - Fetcher + MultiRenderer: multiple custom TUI tabs sharing one data source +// - Fetcher + Exporter: headless metrics export on /metrics +// - Fetcher + Renderer + Exporter: TUI tab + metrics export +// +// Register plugins from init() functions: +// +// func init() { +// plugin.Register(&myPlugin{}) +// } +// +// See the Plugin Development Guide (docs/plugins.md) for a full walkthrough. +package plugin + +import ( + "context" + "fmt" + "io" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/alexandre-daubois/ember/pkg/metrics" +) + +// Plugin is the minimal interface every plugin must implement. +// Name returns a unique identifier used in the tab bar and for environment +// variable configuration (EMBER_PLUGIN_{NAME}_{KEY}). +// Init is called once before the TUI or daemon starts. Return an error +// to abort startup; already-initialized plugins implementing [Closer] +// will be closed automatically. +type Plugin interface { + Name() string + Init(ctx context.Context, cfg PluginConfig) error +} + +// PluginConfig carries configuration passed to a plugin during Init. +// CaddyAddr is the Caddy admin API address Ember is connected to. +// Options contains environment variables matching the plugin's name +// (e.g., EMBER_PLUGIN_RATELIMIT_MAX_RPS=1000 becomes Options["max_rps"]="1000"). +type PluginConfig struct { + CaddyAddr string + Options map[string]string +} + +// Fetcher is implemented by plugins that collect data on every poll interval. +// The returned data is opaque to Ember core: only the plugin's own [Renderer] +// and [Exporter] interpret it. +// +// When Fetch returns an error: +// - In TUI mode, the previous data is preserved and the error is shown +// in the tab when View returns an empty string +// - In daemon mode, the error is logged and previous data continues +// to be exported on /metrics +// +// Ember recovers from panics in Fetch and converts them to errors. +// The context is cancelled when the application shuts down. +type Fetcher interface { + Fetch(ctx context.Context) (any, error) +} + +// Renderer is implemented by plugins that provide a TUI tab. +// +// Before the first [Fetcher.Fetch] completes, Ember calls View, HandleKey, etc. +// on the object that originally implements Renderer (typically the plugin struct +// itself). Make sure these methods handle the "no data yet" state gracefully. +// After the first successful Update call, the returned Renderer is used for all +// subsequent calls. +type Renderer interface { + // Update receives the latest data from Fetch and the current terminal + // dimensions. It returns an updated Renderer (Elm architecture). + // Return nil to keep the current Renderer unchanged. + Update(data any, width, height int) Renderer + + // View renders the tab content as a string. + View(width, height int) string + + // HandleKey handles key presses when this plugin's tab is active. + // Return true if the key was consumed. + HandleKey(msg tea.KeyMsg) bool + + // StatusCount returns a short string shown as a badge in the tab bar + // (e.g., "12 blocked"). Return "" for no badge. + StatusCount() string + + // HelpBindings returns keybindings displayed in the ? help overlay. + HelpBindings() []HelpBinding +} + +// HelpBinding describes a single keybinding shown in the help overlay. +type HelpBinding struct { + Key string + Desc string +} + +// Exporter is implemented by plugins that contribute Prometheus metrics +// to Ember's /metrics endpoint. +// +// WriteMetrics is called on every /metrics HTTP request with the latest data +// from [Fetcher.Fetch]. Write Prometheus-format text lines to w. +// Use prefix to namespace metric names (e.g., prefix + "_my_metric"). +// When prefix is empty, emit unqualified names. +// +// Ember recovers from panics in WriteMetrics and writes a comment line +// to the output instead of crashing the endpoint. +type Exporter interface { + WriteMetrics(w io.Writer, data any, prefix string) +} + +// Closer is optionally implemented by plugins that hold resources +// (connections, goroutines, file handles) requiring cleanup. +// Ember calls Close in reverse registration order at shutdown. +// If a plugin fails during Init, already-initialized plugins +// implementing Closer are closed automatically. +type Closer interface { + Close() error +} + +// MetricsSubscriber is optionally implemented by plugins that want to receive +// the core metrics snapshot on every successful poll cycle. This avoids the +// need for plugins to make their own /metrics requests to Caddy. +// +// OnMetrics is called synchronously after each successful core fetch, before +// plugin Fetch calls begin. The snapshot must not be modified. +type MetricsSubscriber interface { + OnMetrics(snap *metrics.Snapshot) +} + +// TabDescriptor describes a single tab provided by a [MultiRenderer] plugin. +// Name is displayed in the tab bar. Key is a stable identifier used +// internally (e.g., "bouncer", "appsec"). Key must be unique within the plugin. +type TabDescriptor struct { + Key string + Name string +} + +// MultiRenderer is implemented by plugins that provide multiple TUI tabs. +// Each tab gets its own [Renderer], but all tabs share the same [Fetcher] data. +// +// Tabs returns the list of tabs this plugin provides. It is called once +// after Init. The order determines the tab order in the tab bar. +// +// RendererForTab returns the initial Renderer for the given tab key. +// It is called once per tab after Init. +// +// A plugin should implement either [Renderer] or MultiRenderer, not both. +// If both are present, MultiRenderer takes priority. +type MultiRenderer interface { + Tabs() []TabDescriptor + RendererForTab(key string) Renderer +} + +// Availability is optionally implemented by plugins whose tab(s) should +// be shown or hidden based on runtime conditions. Ember calls Available +// after each successful Fetch. When Available returns false, the plugin's +// tab(s) are removed from the tab bar. When it returns true, they are +// re-added. +// +// Plugins that do not implement Availability are always visible. +type Availability interface { + Available() bool +} + +// TabAvailability is optionally implemented by [MultiRenderer] plugins that +// need per-tab visibility control. Ember calls TabAvailable for each tab key +// after every successful Fetch. When TabAvailable returns false for a key, +// that specific tab is removed from the tab bar. When it returns true, the +// tab is re-added. +// +// If a plugin also implements [Availability], it acts as a master switch: +// when Available returns false, all tabs are hidden regardless of +// TabAvailable results. When Available returns true, TabAvailable controls +// each tab individually. +// +// TabAvailability is ignored for single-Renderer plugins. +// If TabAvailable panics, the tab stays visible (fail-open). +type TabAvailability interface { + TabAvailable(key string) bool +} + +// PluginExport holds the data needed to export metrics for a single plugin. +// Used internally by Ember to pass plugin data to the /metrics handler. +type PluginExport struct { + Exporter Exporter + Data any +} + +// SafeFetch calls f.Fetch and recovers from panics, converting them to errors. +func SafeFetch(ctx context.Context, f Fetcher) (data any, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("plugin panic during Fetch: %v", r) + } + }() + return f.Fetch(ctx) +} diff --git a/pkg/plugin/plugin_test.go b/pkg/plugin/plugin_test.go new file mode 100644 index 0000000..c287bd7 --- /dev/null +++ b/pkg/plugin/plugin_test.go @@ -0,0 +1,297 @@ +package plugin_test + +import ( + "bytes" + "context" + "io" + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/alexandre-daubois/ember/pkg/plugin" +) + +type fakePlugin struct { + name string + initErr error + initCfg plugin.PluginConfig +} + +func (p *fakePlugin) Name() string { return p.name } +func (p *fakePlugin) Init(_ context.Context, cfg plugin.PluginConfig) error { + p.initCfg = cfg + return p.initErr +} + +type fakeFullPlugin struct { + fakePlugin + fetchData any + fetchErr error +} + +func (p *fakeFullPlugin) Fetch(_ context.Context) (any, error) { + return p.fetchData, p.fetchErr +} + +func (p *fakeFullPlugin) Update(data any, _, _ int) plugin.Renderer { return p } +func (p *fakeFullPlugin) View(_, _ int) string { return "view" } +func (p *fakeFullPlugin) HandleKey(_ tea.KeyMsg) bool { return false } +func (p *fakeFullPlugin) StatusCount() string { return "42" } +func (p *fakeFullPlugin) HelpBindings() []plugin.HelpBinding { + return []plugin.HelpBinding{{Key: "x", Desc: "do something"}} +} +func (p *fakeFullPlugin) WriteMetrics(w io.Writer, _ any, prefix string) { + name := prefix + "_fake_metric" + if prefix == "" { + name = "fake_metric" + } + _, _ = io.WriteString(w, name+" 1\n") +} + +func TestRegisterAndAll(t *testing.T) { + plugin.Reset() + + assert.Empty(t, plugin.All()) + + p1 := &fakePlugin{name: "alpha"} + p2 := &fakePlugin{name: "beta"} + plugin.Register(p1) + plugin.Register(p2) + + all := plugin.All() + require.Len(t, all, 2) + assert.Equal(t, "alpha", all[0].Name()) + assert.Equal(t, "beta", all[1].Name()) +} + +func TestAllReturnsCopy(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "one"}) + first := plugin.All() + first[0] = nil + + second := plugin.All() + assert.NotNil(t, second[0]) +} + +func TestReset(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "temp"}) + assert.Len(t, plugin.All(), 1) + + plugin.Reset() + assert.Empty(t, plugin.All()) +} + +func TestRegisterDuplicatePanics(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "dup"}) + assert.Panics(t, func() { + plugin.Register(&fakePlugin{name: "dup"}) + }) +} + +func TestRegisterDuplicatePanicsMessage(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "myplug"}) + assert.PanicsWithValue(t, "ember: duplicate plugin name: myplug", func() { + plugin.Register(&fakePlugin{name: "myplug"}) + }) +} + +func TestRegisterDifferentNamesOK(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "a"}) + plugin.Register(&fakePlugin{name: "b"}) + assert.Len(t, plugin.All(), 2) +} + +func TestPluginInit(t *testing.T) { + p := &fakePlugin{name: "test"} + cfg := plugin.PluginConfig{ + CaddyAddr: "http://localhost:2019", + Options: map[string]string{"key": "value"}, + } + err := p.Init(context.Background(), cfg) + + assert.NoError(t, err) + assert.Equal(t, cfg, p.initCfg) +} + +func TestPluginInitError(t *testing.T) { + p := &fakePlugin{name: "broken", initErr: assert.AnError} + + err := p.Init(context.Background(), plugin.PluginConfig{}) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestFetcherInterface(t *testing.T) { + p := &fakeFullPlugin{ + fakePlugin: fakePlugin{name: "full"}, + fetchData: "hello", + } + data, err := p.Fetch(context.Background()) + + assert.NoError(t, err) + assert.Equal(t, "hello", data) +} + +func TestFetcherError(t *testing.T) { + p := &fakeFullPlugin{ + fakePlugin: fakePlugin{name: "broken-fetch"}, + fetchErr: assert.AnError, + } + _, err := p.Fetch(context.Background()) + assert.ErrorIs(t, err, assert.AnError) +} + +func TestRendererInterface(t *testing.T) { + p := &fakeFullPlugin{fakePlugin: fakePlugin{name: "renderer"}} + + updated := p.Update("data", 80, 24) + assert.NotNil(t, updated) + assert.Equal(t, "view", updated.View(80, 24)) + assert.False(t, updated.HandleKey(tea.KeyMsg{})) + assert.Equal(t, "42", updated.StatusCount()) + assert.Len(t, updated.HelpBindings(), 1) +} + +func TestExporterInterface(t *testing.T) { + p := &fakeFullPlugin{fakePlugin: fakePlugin{name: "exporter"}} + + var buf bytes.Buffer + p.WriteMetrics(&buf, nil, "ember") + assert.Equal(t, "ember_fake_metric 1\n", buf.String()) +} + +func TestExporterNoPrefix(t *testing.T) { + p := &fakeFullPlugin{fakePlugin: fakePlugin{name: "exporter"}} + + var buf bytes.Buffer + p.WriteMetrics(&buf, nil, "") + assert.Equal(t, "fake_metric 1\n", buf.String()) +} + +func TestRegisterEmptyNamePanics(t *testing.T) { + plugin.Reset() + + assert.PanicsWithValue(t, "ember: plugin name must not be empty", func() { + plugin.Register(&fakePlugin{name: ""}) + }) +} + +func TestRegisterWhitespaceNamePanics(t *testing.T) { + plugin.Reset() + + assert.PanicsWithValue(t, "ember: plugin name must not contain whitespace: my plugin", func() { + plugin.Register(&fakePlugin{name: "my plugin"}) + }) +} + +func TestRegisterTabInNamePanics(t *testing.T) { + plugin.Reset() + + assert.PanicsWithValue(t, "ember: plugin name must not contain whitespace: my\tplugin", func() { + plugin.Register(&fakePlugin{name: "my\tplugin"}) + }) +} + +type fakeCloserPlugin struct { + fakePlugin + closed bool +} + +func (p *fakeCloserPlugin) Close() error { + p.closed = true + return nil +} + +func TestRegisterUnderscoreNamePanics(t *testing.T) { + plugin.Reset() + + assert.PanicsWithValue(t, "ember: plugin name must not contain underscores (use hyphens instead): my_plugin", func() { + plugin.Register(&fakePlugin{name: "my_plugin"}) + }) +} + +func TestRegisterNormalizedCollisionPanics(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "my-plugin"}) + assert.PanicsWithValue(t, "ember: plugin name collision after normalization: myplugin vs my-plugin", func() { + plugin.Register(&fakePlugin{name: "myplugin"}) + }) +} + +func TestRegisterNormalizedCollisionReversePanics(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "myplugin"}) + assert.PanicsWithValue(t, "ember: plugin name collision after normalization: my-plugin vs myplugin", func() { + plugin.Register(&fakePlugin{name: "my-plugin"}) + }) +} + +func TestRegisterDistinctNormalizedNamesOK(t *testing.T) { + plugin.Reset() + + plugin.Register(&fakePlugin{name: "rate-limit"}) + plugin.Register(&fakePlugin{name: "rate-limiter"}) + assert.Len(t, plugin.All(), 2) +} + +func TestCloserInterface(t *testing.T) { + p := &fakeCloserPlugin{fakePlugin: fakePlugin{name: "closable"}} + + closer, ok := any(p).(plugin.Closer) + require.True(t, ok) + assert.NoError(t, closer.Close()) + assert.True(t, p.closed) +} + +func TestNonCloserPlugin(t *testing.T) { + p := &fakePlugin{name: "simple"} + + _, ok := any(p).(plugin.Closer) + assert.False(t, ok) +} + +func TestSafeFetch(t *testing.T) { + p := &fakeFullPlugin{ + fakePlugin: fakePlugin{name: "safe"}, + fetchData: "result", + } + data, err := plugin.SafeFetch(context.Background(), p) + assert.NoError(t, err) + assert.Equal(t, "result", data) +} + +func TestSafeFetch_Error(t *testing.T) { + p := &fakeFullPlugin{ + fakePlugin: fakePlugin{name: "fail"}, + fetchErr: assert.AnError, + } + _, err := plugin.SafeFetch(context.Background(), p) + assert.ErrorIs(t, err, assert.AnError) +} + +type panicFetcher struct { + fakePlugin +} + +func (p *panicFetcher) Fetch(_ context.Context) (any, error) { panic("boom") } + +func TestSafeFetch_RecoversPanic(t *testing.T) { + p := &panicFetcher{fakePlugin: fakePlugin{name: "panic"}} + data, err := plugin.SafeFetch(context.Background(), p) + assert.Nil(t, data) + require.Error(t, err) + assert.Contains(t, err.Error(), "plugin panic during Fetch") +} diff --git a/pkg/plugin/registry.go b/pkg/plugin/registry.go new file mode 100644 index 0000000..228c678 --- /dev/null +++ b/pkg/plugin/registry.go @@ -0,0 +1,70 @@ +package plugin + +import ( + "strings" + "sync" +) + +var ( + mu sync.Mutex + registry []Plugin +) + +// Register adds a plugin to the global registry. +// It is intended to be called from init() functions in plugin packages. +// +// Register panics if: +// - the name is empty +// - the name contains whitespace or underscores +// - a plugin with the same name is already registered +// - another plugin's name collides after hyphen removal +// (e.g., "my-plugin" and "myplugin" would share the same +// environment variable prefix EMBER_PLUGIN_MYPLUGIN_) +func Register(p Plugin) { + mu.Lock() + defer mu.Unlock() + + name := p.Name() + if name == "" { + panic("ember: plugin name must not be empty") + } + if strings.ContainsFunc(name, func(r rune) bool { + return r == ' ' || r == '\t' || r == '\n' || r == '\r' + }) { + panic("ember: plugin name must not contain whitespace: " + name) + } + if strings.Contains(name, "_") { + panic("ember: plugin name must not contain underscores (use hyphens instead): " + name) + } + + norm := normalizeName(name) + for _, existing := range registry { + if existing.Name() == name { + panic("ember: duplicate plugin name: " + name) + } + if normalizeName(existing.Name()) == norm { + panic("ember: plugin name collision after normalization: " + name + " vs " + existing.Name()) + } + } + registry = append(registry, p) +} + +// All returns a copy of all registered plugins, in registration order. +func All() []Plugin { + mu.Lock() + defer mu.Unlock() + result := make([]Plugin, len(registry)) + copy(result, registry) + return result +} + +func normalizeName(name string) string { + return strings.ToUpper(strings.ReplaceAll(name, "-", "")) +} + +// Reset clears the registry. Intended for testing only. +func Reset() { + mu.Lock() + registry = nil + mu.Unlock() +} diff --git a/roadmap b/roadmap new file mode 100644 index 0000000..2786641 --- /dev/null +++ b/roadmap @@ -0,0 +1,44 @@ +/btw il y a d'autres features auquelles tu penserais que nous permettrai de faire l'API admin de Caddy ? + + Bonne question ! En explorant l'admin API de Caddy, voici ce qui est dispo et qu'Ember pourrait exploiter : + + Deja dispo, zero effort cote Caddy + + 1. Config Hot-Reload depuis Ember + - POST /load permet de recharger la config Caddy + - POST /config/... permet de modifier n'importe quel champ a chaud + - Ember pourrait offrir un bouton pour activer/desactiver des routes, changer des upstreams, modifier des rate limits, directement depuis le TUI + + 2. Reverse Proxy Upstream Status + - GET /reverse_proxy/upstreams retourne la sante, les requetes actives et les failures par backend + - Un onglet "Upstreams" avec health indicators vert/rouge, distribution de charge, failure trends + + 3. Config Inspector + - GET /config/ retourne toute la config JSON en temps reel + - Ember pourrait avoir un mode "browse config" pour naviguer l'arbre de config Caddy (comme un jq interactif) + - Utile pour debug : "est-ce que ma route est bien configuree ?" + + 4. PKI / Certificats TLS + - GET /pki/ca//certificates retourne les certs + - Afficher l'etat des certificats TLS : expiration, domaines couverts, renouvellement auto + - Alerter quand un cert approche de l'expiration + + 5. Profiling a la demande + - GET /debug/pprof/profile, /debug/pprof/heap, etc. + - Declencher un CPU/memory profile depuis Ember et telecharger le resultat + - "Ton P99 est a 2s ? Appuie sur P pour capturer un profile CPU de 30s" + + 6. Reload Tracking + - caddy_config_last_reload_successful (gauge 0/1) et caddy_config_last_reload_success_timestamp_seconds + - Afficher dans le dashboard : dernier reload, succes/echec, timestamp + - Alerter sur un reload echoue + + 7. Go Runtime Metrics + - GET /debug/vars expose goroutines, GC stats, memstats + - Enrichir le dashboard avec : nombre de goroutines, pression GC, heap usage + - Utile pour diagnostiquer des fuites memoire cote Caddy + + Le plus game-changer a mon avis + + Le Config Inspector interactif et le Profiling a la demande sont les plus differenciants. Personne ne propose ca dans un TUI. Imagine : tu vois un spike de latence, tu appuies + sur une touche, et 30s plus tard t'as un flame graph. Ca c'est du DX.