feat(nadezhda): M5 bubbletea TUI browser (cybercore) + open-in-browser
Interactive terminal UI over the ranked story clusters. Rave Maximal cybercore theming: cyan-to-magenta spectrum score bars, severity-reactive rows, hot KEV chips, cool-ramp outlet dots, and a scrollable detail dossier with per-outlet links and full CVE cards (CVSS meter, KEV / ransomware, EPSS, CWE, vector, description). Reuses store.DigestClusters and rank.Rank verbatim and preloads full store.CVE records for detail. - internal/tui: model/update/view state machine with an injected Loader and opener, so Update is fully unit-testable without a terminal or DB (nav / clamp / open / back / quit / resize / empty-store / too-small). - o opens the selected story in the default browser, keyboard-only, cross-platform, and refuses non-http urls. - tui command replaces the M5 stub; supports --since. - deps: charmbracelet bubbletea v1.3.10 + bubbles v1.0.0 + lipgloss v1.1.0.
This commit is contained in:
parent
8713712bf9
commit
26651547e1
|
|
@ -21,7 +21,6 @@ func init() {
|
|||
short string
|
||||
milestone string
|
||||
}{
|
||||
{"tui", "Browse aggregated news in an interactive terminal UI", "milestone M5"},
|
||||
{"ideate", "Generate content angles from ranked clusters via an AI provider", "milestone M6"},
|
||||
{"watch", "Run as a daemon, re-ingesting on an interval", "milestone M7"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
// ©AngelaMos | 2026
|
||||
// tui.go
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/tui"
|
||||
)
|
||||
|
||||
var tuiSince string
|
||||
|
||||
var tuiCmd = &cobra.Command{
|
||||
Use: "tui",
|
||||
Short: "Browse aggregated news in an interactive terminal UI",
|
||||
RunE: runTUI,
|
||||
}
|
||||
|
||||
func init() {
|
||||
tuiCmd.Flags().StringVar(&tuiSince, "since", "", "only clusters active within this window (e.g. 24h, 168h)")
|
||||
rootCmd.AddCommand(tuiCmd)
|
||||
}
|
||||
|
||||
func runTUI(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
var since int64
|
||||
if tuiSince != "" {
|
||||
d, err := time.ParseDuration(tuiSince)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid --since %q: %w", tuiSince, err)
|
||||
}
|
||||
since = now.Add(-d).Unix()
|
||||
}
|
||||
|
||||
st, err := store.Open(cfg.DBPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
|
||||
loader := func() (tui.Data, error) {
|
||||
clusters, err := st.DigestClusters(since)
|
||||
if err != nil {
|
||||
return tui.Data{}, err
|
||||
}
|
||||
scored := rank.Rank(clusters, cfg.Rank, cfg.Watchlist, now)
|
||||
detail := make(map[string]store.CVE)
|
||||
for _, s := range scored {
|
||||
for _, v := range s.Cluster.CVEs {
|
||||
if _, ok := detail[v.ID]; ok {
|
||||
continue
|
||||
}
|
||||
full, err := st.GetCVE(v.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
detail[v.ID] = full
|
||||
}
|
||||
}
|
||||
return tui.Data{Scored: scored, CVEDetail: detail}, nil
|
||||
}
|
||||
|
||||
return tui.Run(loader)
|
||||
}
|
||||
|
|
@ -4,6 +4,9 @@ go 1.25.0
|
|||
|
||||
require (
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/charmbracelet/bubbles v1.0.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/mmcdole/gofeed v1.3.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/temoto/robotstxt v1.1.2
|
||||
|
|
@ -15,17 +18,34 @@ require (
|
|||
|
||||
require (
|
||||
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.11.6 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
|
||||
github.com/charmbracelet/x/term v0.2.2 // indirect
|
||||
github.com/clipperhouse/displaywidth v0.9.0 // indirect
|
||||
github.com/clipperhouse/stringish v0.1.1 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
|
|
|
|||
|
|
@ -2,12 +2,36 @@ github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO
|
|||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
|
||||
github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
|
||||
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
|
||||
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
|
||||
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
|
||||
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
|
||||
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
|
||||
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
|
||||
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
|
|
@ -20,8 +44,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
|
|||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
|
||||
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4=
|
||||
github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE=
|
||||
github.com/mmcdole/goxpp v1.1.1-0.20240225020742-a0c311522b23 h1:Zr92CAlFhy2gL+V1F+EyIuzbQNbSgP4xhTODZtrXUtk=
|
||||
|
|
@ -31,12 +61,20 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w
|
|||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
|
|
@ -48,6 +86,8 @@ github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKs
|
|||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg=
|
||||
github.com/temoto/robotstxt v1.1.2/go.mod h1:+1AmkuG3IYkh1kv0d2qEB9Le88ehNO0zwOr3ujewlOo=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
|
|
@ -56,6 +96,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
|
||||
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
|
|
@ -86,6 +128,7 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
|||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
// ©AngelaMos | 2026
|
||||
// browser.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
func openURL(target string) error {
|
||||
u, err := url.Parse(target)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
|
||||
return fmt.Errorf("refusing to open non-http url: %q", target)
|
||||
}
|
||||
name, args := openerCommand(target)
|
||||
return exec.Command(name, args...).Start()
|
||||
}
|
||||
|
||||
func openerCommand(target string) (string, []string) {
|
||||
switch runtime.GOOS {
|
||||
case "windows":
|
||||
return "rundll32", []string{"url.dll,FileProtocolHandler", target}
|
||||
case "darwin":
|
||||
return "open", []string{target}
|
||||
default:
|
||||
return "xdg-open", []string{target}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,202 @@
|
|||
// ©AngelaMos | 2026
|
||||
// detail.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
cvssMaxScore = 10.0
|
||||
nvdBaseURL = "https://nvd.nist.gov/vuln/detail/"
|
||||
detailIndent = 3
|
||||
metaDivider = " · "
|
||||
)
|
||||
|
||||
func (m Model) renderDetailBody() string {
|
||||
s := m.selected()
|
||||
c := s.Cluster
|
||||
w := m.bodyWidth()
|
||||
t := m.theme
|
||||
indent := strings.Repeat(" ", detailIndent)
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(t.HeadlineSel.Width(w).Render(headlineOf(c)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(m.detailMeta(s))
|
||||
b.WriteString("\n\n")
|
||||
|
||||
b.WriteString(m.sectionHeader("OUTLETS", w))
|
||||
b.WriteString("\n")
|
||||
for _, a := range sortedArticles(c) {
|
||||
b.WriteString(m.renderArticle(a, w, indent))
|
||||
}
|
||||
|
||||
cves := m.sortedDetailCVEs(c)
|
||||
if len(cves) > 0 {
|
||||
b.WriteString(m.sectionHeader("VULNERABILITIES", w))
|
||||
b.WriteString("\n")
|
||||
for _, v := range cves {
|
||||
b.WriteString(m.renderCVE(v, w, indent))
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(b.String(), "\n")
|
||||
}
|
||||
|
||||
func (m Model) detailMeta(s rank.Scored) string {
|
||||
t := m.theme
|
||||
c := s.Cluster
|
||||
parts := []string{
|
||||
t.spectrumBar(s.Score, colScoreBarW) + " " + t.Meta.Bold(true).Render(fmt.Sprintf("%.2f", s.Score)),
|
||||
t.Muted.Render(fmt.Sprintf("%d outlets", outletCount(c))),
|
||||
}
|
||||
if mx := clusterMaxCVSS(c); mx != nil {
|
||||
b := cvssBand(mx)
|
||||
parts = append(parts, t.Muted.Render("CVSS ")+t.bandFG(b).Bold(true).Render(cvssString(mx)))
|
||||
}
|
||||
if me := clusterMaxEPSS(c); me != nil {
|
||||
b := epssBand(me)
|
||||
parts = append(parts, t.Muted.Render("EPSS ")+t.bandFG(b).Bold(true).Render(epssString(me)))
|
||||
}
|
||||
parts = append(parts,
|
||||
t.Muted.Render("first "+relativeAge(c.FirstSeen, m.now)),
|
||||
t.Muted.Render("last "+relativeAge(c.LastSeen, m.now)),
|
||||
)
|
||||
return strings.Join(parts, t.Dim.Render(metaDivider))
|
||||
}
|
||||
|
||||
func (m Model) sectionHeader(title string, w int) string {
|
||||
t := m.theme
|
||||
head := t.fg(colorViolet).Bold(true).Render(glyphSelected + " " + title + " ")
|
||||
n := w - lipgloss.Width(head)
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
return head + t.Dim.Render(strings.Repeat(ruleBody, n))
|
||||
}
|
||||
|
||||
func (m Model) renderArticle(a store.DigestArticle, w int, indent string) string {
|
||||
t := m.theme
|
||||
head := t.fg(colorCyan).Bold(true).Render(a.SourceName) +
|
||||
t.Dim.Render(metaDivider) + t.Muted.Render(relativeAge(a.PublishedAt, m.now))
|
||||
title := m.wrapIndent(a.Title, w, t.Text)
|
||||
url := indent + t.Link.Render(truncate(a.CanonicalURL, w-detailIndent))
|
||||
return head + "\n" + title + "\n" + url + "\n\n"
|
||||
}
|
||||
|
||||
func (m Model) renderCVE(v store.CVE, w int, indent string) string {
|
||||
t := m.theme
|
||||
bnd := cvssBand(v.CVSSScore)
|
||||
var b strings.Builder
|
||||
|
||||
id := t.fg(colorCyan).Bold(true).Render(v.ID)
|
||||
if v.IsKEV {
|
||||
id += " " + t.chip("KEV", colorMagenta)
|
||||
}
|
||||
if v.KEVRansomware {
|
||||
id += " " + t.chip("RANSOMWARE", colorMagenta)
|
||||
}
|
||||
id += " " + t.bandFG(bnd).Bold(true).Render(bandLabel(bnd))
|
||||
b.WriteString(id + "\n")
|
||||
|
||||
if v.CVSSScore != nil {
|
||||
line := indent + t.Label.Render("CVSS ") +
|
||||
t.bandBar(bnd, *v.CVSSScore/cvssMaxScore, colScoreBarW) + " " +
|
||||
t.bandFG(bnd).Bold(true).Render(cvssString(v.CVSSScore))
|
||||
if v.CVSSSeverity != "" {
|
||||
line += " " + t.Muted.Render(v.CVSSSeverity)
|
||||
}
|
||||
if v.CVSSVersion != "" {
|
||||
line += t.Dim.Render(" (v" + v.CVSSVersion + ")")
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
if v.EPSS != nil {
|
||||
eb := epssBand(v.EPSS)
|
||||
line := indent + t.Label.Render("EPSS ") + t.bandFG(eb).Bold(true).Render(epssString(v.EPSS))
|
||||
if v.EPSSPercentile != nil {
|
||||
line += t.Dim.Render(" (percentile ") + t.Muted.Render(epssString(v.EPSSPercentile)) + t.Dim.Render(")")
|
||||
}
|
||||
b.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
var meta []string
|
||||
if v.CWE != "" {
|
||||
meta = append(meta, t.Label.Render("CWE ")+t.Muted.Render(v.CWE))
|
||||
}
|
||||
if v.CVSSVector != "" {
|
||||
meta = append(meta, t.Dim.Render(v.CVSSVector))
|
||||
}
|
||||
if len(meta) > 0 {
|
||||
b.WriteString(indent + strings.Join(meta, t.Dim.Render(metaDivider)) + "\n")
|
||||
}
|
||||
|
||||
if strings.TrimSpace(v.Description) != "" {
|
||||
b.WriteString(m.wrapIndent(v.Description, w, t.Muted) + "\n")
|
||||
}
|
||||
b.WriteString(indent + t.Link.Render(nvdBaseURL+v.ID) + "\n\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m Model) wrapIndent(s string, w int, style lipgloss.Style) string {
|
||||
width := w - detailIndent
|
||||
if width < 1 {
|
||||
width = 1
|
||||
}
|
||||
wrapped := style.Width(width).Render(strings.TrimSpace(s))
|
||||
pad := strings.Repeat(" ", detailIndent)
|
||||
lines := strings.Split(wrapped, "\n")
|
||||
for i := range lines {
|
||||
lines[i] = pad + lines[i]
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func sortedArticles(c store.DigestCluster) []store.DigestArticle {
|
||||
out := make([]store.DigestArticle, len(c.Articles))
|
||||
copy(out, c.Articles)
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].PublishedAt != out[j].PublishedAt {
|
||||
return out[i].PublishedAt > out[j].PublishedAt
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func (m Model) sortedDetailCVEs(c store.DigestCluster) []store.CVE {
|
||||
out := make([]store.CVE, 0, len(c.CVEs))
|
||||
for _, dc := range c.CVEs {
|
||||
if full, ok := m.cveDetail[dc.ID]; ok {
|
||||
out = append(out, full)
|
||||
continue
|
||||
}
|
||||
out = append(out, store.CVE{ID: dc.ID, CVSSScore: dc.CVSSScore, EPSS: dc.EPSS, IsKEV: dc.IsKEV})
|
||||
}
|
||||
sort.SliceStable(out, func(i, j int) bool {
|
||||
if out[i].IsKEV != out[j].IsKEV {
|
||||
return out[i].IsKEV
|
||||
}
|
||||
if ci, cj := scoreOf(out[i].CVSSScore), scoreOf(out[j].CVSSScore); ci != cj {
|
||||
return ci > cj
|
||||
}
|
||||
return out[i].ID < out[j].ID
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func scoreOf(p *float64) float64 {
|
||||
if p == nil {
|
||||
return -1
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
// ©AngelaMos | 2026
|
||||
// format.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
type band int
|
||||
|
||||
const (
|
||||
bandNone band = iota
|
||||
bandLow
|
||||
bandMedium
|
||||
bandHigh
|
||||
bandCritical
|
||||
)
|
||||
|
||||
const (
|
||||
cvssCritical = 9.0
|
||||
cvssHigh = 7.0
|
||||
cvssMedium = 4.0
|
||||
|
||||
epssHot = 0.5
|
||||
epssWarm = 0.1
|
||||
|
||||
secsPerMinute = 60
|
||||
secsPerHour = 3600
|
||||
secsPerDay = 86400
|
||||
|
||||
ellipsis = "…"
|
||||
emptyMarker = "───"
|
||||
naMarker = "—"
|
||||
)
|
||||
|
||||
func clamp01(v float64) float64 {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 1 {
|
||||
return 1
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func cvssBand(score *float64) band {
|
||||
if score == nil {
|
||||
return bandNone
|
||||
}
|
||||
switch v := *score; {
|
||||
case v >= cvssCritical:
|
||||
return bandCritical
|
||||
case v >= cvssHigh:
|
||||
return bandHigh
|
||||
case v >= cvssMedium:
|
||||
return bandMedium
|
||||
case v > 0:
|
||||
return bandLow
|
||||
default:
|
||||
return bandNone
|
||||
}
|
||||
}
|
||||
|
||||
func bandLabel(b band) string {
|
||||
switch b {
|
||||
case bandCritical:
|
||||
return "CRITICAL"
|
||||
case bandHigh:
|
||||
return "HIGH"
|
||||
case bandMedium:
|
||||
return "MEDIUM"
|
||||
case bandLow:
|
||||
return "LOW"
|
||||
default:
|
||||
return naMarker
|
||||
}
|
||||
}
|
||||
|
||||
func clusterHasKEV(c store.DigestCluster) bool {
|
||||
for _, v := range c.CVEs {
|
||||
if v.IsKEV {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func clusterMaxCVSS(c store.DigestCluster) *float64 {
|
||||
var best *float64
|
||||
for _, v := range c.CVEs {
|
||||
if v.CVSSScore == nil {
|
||||
continue
|
||||
}
|
||||
if best == nil || *v.CVSSScore > *best {
|
||||
best = v.CVSSScore
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func clusterMaxEPSS(c store.DigestCluster) *float64 {
|
||||
var best *float64
|
||||
for _, v := range c.CVEs {
|
||||
if v.EPSS == nil {
|
||||
continue
|
||||
}
|
||||
if best == nil || *v.EPSS > *best {
|
||||
best = v.EPSS
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func clusterBand(c store.DigestCluster) band {
|
||||
if clusterHasKEV(c) {
|
||||
return bandCritical
|
||||
}
|
||||
return cvssBand(clusterMaxCVSS(c))
|
||||
}
|
||||
|
||||
func epssBand(p *float64) band {
|
||||
if p == nil {
|
||||
return bandNone
|
||||
}
|
||||
switch v := *p; {
|
||||
case v >= epssHot:
|
||||
return bandCritical
|
||||
case v >= epssWarm:
|
||||
return bandHigh
|
||||
case v > 0:
|
||||
return bandMedium
|
||||
default:
|
||||
return bandNone
|
||||
}
|
||||
}
|
||||
|
||||
func cvssString(score *float64) string {
|
||||
if score == nil {
|
||||
return naMarker
|
||||
}
|
||||
return fmt.Sprintf("%.1f", *score)
|
||||
}
|
||||
|
||||
func epssString(p *float64) string {
|
||||
if p == nil {
|
||||
return naMarker
|
||||
}
|
||||
return fmt.Sprintf("%.1f%%", clamp01(*p)*100)
|
||||
}
|
||||
|
||||
func relativeAge(unix int64, now time.Time) string {
|
||||
if unix <= 0 {
|
||||
return naMarker
|
||||
}
|
||||
secs := now.Unix() - unix
|
||||
if secs < 0 {
|
||||
secs = 0
|
||||
}
|
||||
switch {
|
||||
case secs < secsPerMinute:
|
||||
return "just now"
|
||||
case secs < secsPerHour:
|
||||
return fmt.Sprintf("%dm ago", secs/secsPerMinute)
|
||||
case secs < secsPerDay:
|
||||
return fmt.Sprintf("%dh ago", secs/secsPerHour)
|
||||
default:
|
||||
return fmt.Sprintf("%dd ago", secs/secsPerDay)
|
||||
}
|
||||
}
|
||||
|
||||
func truncate(s string, max int) string {
|
||||
s = strings.Join(strings.Fields(s), " ")
|
||||
r := []rune(s)
|
||||
if max <= 0 {
|
||||
return ""
|
||||
}
|
||||
if len(r) <= max {
|
||||
return s
|
||||
}
|
||||
if max == 1 {
|
||||
return ellipsis
|
||||
}
|
||||
return string(r[:max-1]) + ellipsis
|
||||
}
|
||||
|
||||
func padRight(s string, width int) string {
|
||||
w := len([]rune(s))
|
||||
if w >= width {
|
||||
return s
|
||||
}
|
||||
return s + strings.Repeat(" ", width-w)
|
||||
}
|
||||
|
|
@ -0,0 +1,173 @@
|
|||
// ©AngelaMos | 2026
|
||||
// format_test.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
func ptr(v float64) *float64 { return &v }
|
||||
|
||||
func TestCVSSBand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in *float64
|
||||
want band
|
||||
}{
|
||||
{nil, bandNone},
|
||||
{ptr(0), bandNone},
|
||||
{ptr(3.9), bandLow},
|
||||
{ptr(4.0), bandMedium},
|
||||
{ptr(6.9), bandMedium},
|
||||
{ptr(7.0), bandHigh},
|
||||
{ptr(8.9), bandHigh},
|
||||
{ptr(9.0), bandCritical},
|
||||
{ptr(10.0), bandCritical},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := cvssBand(c.in); got != c.want {
|
||||
t.Errorf("cvssBand(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEPSSBand(t *testing.T) {
|
||||
cases := []struct {
|
||||
in *float64
|
||||
want band
|
||||
}{
|
||||
{nil, bandNone},
|
||||
{ptr(0), bandNone},
|
||||
{ptr(0.05), bandMedium},
|
||||
{ptr(0.1), bandHigh},
|
||||
{ptr(0.49), bandHigh},
|
||||
{ptr(0.5), bandCritical},
|
||||
{ptr(0.97), bandCritical},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := epssBand(c.in); got != c.want {
|
||||
t.Errorf("epssBand(%v) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBandLabel(t *testing.T) {
|
||||
cases := map[band]string{
|
||||
bandCritical: "CRITICAL",
|
||||
bandHigh: "HIGH",
|
||||
bandMedium: "MEDIUM",
|
||||
bandLow: "LOW",
|
||||
bandNone: naMarker,
|
||||
}
|
||||
for b, want := range cases {
|
||||
if got := bandLabel(b); got != want {
|
||||
t.Errorf("bandLabel(%v) = %q, want %q", b, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCVSSString(t *testing.T) {
|
||||
if got := cvssString(nil); got != naMarker {
|
||||
t.Errorf("cvssString(nil) = %q, want %q", got, naMarker)
|
||||
}
|
||||
if got := cvssString(ptr(9.8)); got != "9.8" {
|
||||
t.Errorf("cvssString(9.8) = %q, want 9.8", got)
|
||||
}
|
||||
if got := cvssString(ptr(10)); got != "10.0" {
|
||||
t.Errorf("cvssString(10) = %q, want 10.0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEPSSString(t *testing.T) {
|
||||
if got := epssString(nil); got != naMarker {
|
||||
t.Errorf("epssString(nil) = %q, want %q", got, naMarker)
|
||||
}
|
||||
if got := epssString(ptr(0.5)); got != "50.0%" {
|
||||
t.Errorf("epssString(0.5) = %q, want 50.0%%", got)
|
||||
}
|
||||
if got := epssString(ptr(0.001)); got != "0.1%" {
|
||||
t.Errorf("epssString(0.001) = %q, want 0.1%%", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeAge(t *testing.T) {
|
||||
now := time.Unix(1_000_000, 0)
|
||||
cases := []struct {
|
||||
unix int64
|
||||
want string
|
||||
}{
|
||||
{0, naMarker},
|
||||
{now.Unix() + 100, "just now"},
|
||||
{now.Unix() - 30, "just now"},
|
||||
{now.Unix() - 120, "2m ago"},
|
||||
{now.Unix() - 7200, "2h ago"},
|
||||
{now.Unix() - 172800, "2d ago"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := relativeAge(c.unix, now); got != c.want {
|
||||
t.Errorf("relativeAge(%d) = %q, want %q", c.unix, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncate(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
max int
|
||||
want string
|
||||
}{
|
||||
{"hello world", 20, "hello world"},
|
||||
{"hello world", 5, "hell" + ellipsis},
|
||||
{" multi space ", 20, "multi space"},
|
||||
{"abc", 1, ellipsis},
|
||||
{"abc", 0, ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := truncate(c.in, c.max); got != c.want {
|
||||
t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClusterSignals(t *testing.T) {
|
||||
c := store.DigestCluster{
|
||||
CVEs: []store.DigestCVE{
|
||||
{ID: "CVE-A", CVSSScore: ptr(7.5), EPSS: ptr(0.2)},
|
||||
{ID: "CVE-B", CVSSScore: ptr(9.8), EPSS: ptr(0.9), IsKEV: true},
|
||||
{ID: "CVE-C"},
|
||||
},
|
||||
}
|
||||
if !clusterHasKEV(c) {
|
||||
t.Error("clusterHasKEV = false, want true")
|
||||
}
|
||||
if got := clusterMaxCVSS(c); got == nil || *got != 9.8 {
|
||||
t.Errorf("clusterMaxCVSS = %v, want 9.8", got)
|
||||
}
|
||||
if got := clusterMaxEPSS(c); got == nil || *got != 0.9 {
|
||||
t.Errorf("clusterMaxEPSS = %v, want 0.9", got)
|
||||
}
|
||||
if got := clusterBand(c); got != bandCritical {
|
||||
t.Errorf("clusterBand = %v, want bandCritical (KEV)", got)
|
||||
}
|
||||
|
||||
noKev := store.DigestCluster{CVEs: []store.DigestCVE{{ID: "CVE-D", CVSSScore: ptr(7.5)}}}
|
||||
if got := clusterBand(noKev); got != bandHigh {
|
||||
t.Errorf("clusterBand(no kev, 7.5) = %v, want bandHigh", got)
|
||||
}
|
||||
empty := store.DigestCluster{}
|
||||
if got := clusterBand(empty); got != bandNone {
|
||||
t.Errorf("clusterBand(empty) = %v, want bandNone", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOutletColor(t *testing.T) {
|
||||
cases := map[int]string{1: colorDim, 2: colorBlue, 3: colorCyan, 4: colorViolet, 9: colorViolet}
|
||||
for n, want := range cases {
|
||||
if got := outletColor(n); got != want {
|
||||
t.Errorf("outletColor(%d) = %q, want %q", n, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
// ©AngelaMos | 2026
|
||||
// keys.go
|
||||
|
||||
package tui
|
||||
|
||||
import "github.com/charmbracelet/bubbles/key"
|
||||
|
||||
type keyMap struct {
|
||||
Up key.Binding
|
||||
Down key.Binding
|
||||
Top key.Binding
|
||||
Bottom key.Binding
|
||||
Open key.Binding
|
||||
Browser key.Binding
|
||||
Back key.Binding
|
||||
Quit key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
return keyMap{
|
||||
Up: key.NewBinding(
|
||||
key.WithKeys("up", "k"),
|
||||
key.WithHelp("↑/k", "up"),
|
||||
),
|
||||
Down: key.NewBinding(
|
||||
key.WithKeys("down", "j"),
|
||||
key.WithHelp("↓/j", "down"),
|
||||
),
|
||||
Top: key.NewBinding(
|
||||
key.WithKeys("g", "home"),
|
||||
key.WithHelp("g", "top"),
|
||||
),
|
||||
Bottom: key.NewBinding(
|
||||
key.WithKeys("G", "end"),
|
||||
key.WithHelp("G", "bottom"),
|
||||
),
|
||||
Open: key.NewBinding(
|
||||
key.WithKeys("enter"),
|
||||
key.WithHelp("⏎", "detail"),
|
||||
),
|
||||
Browser: key.NewBinding(
|
||||
key.WithKeys("o"),
|
||||
key.WithHelp("o", "open"),
|
||||
),
|
||||
Back: key.NewBinding(
|
||||
key.WithKeys("esc", "backspace"),
|
||||
key.WithHelp("esc", "back"),
|
||||
),
|
||||
Quit: key.NewBinding(
|
||||
key.WithKeys("q", "ctrl+c"),
|
||||
key.WithHelp("q", "quit"),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,222 @@
|
|||
// ©AngelaMos | 2026
|
||||
// list.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
const (
|
||||
colSelBarW = 1
|
||||
colRankW = 2
|
||||
colScoreBarW = 10
|
||||
colScoreNumW = 4
|
||||
colOutletW = 6
|
||||
colCVEW = 5
|
||||
colGaps = 6
|
||||
minHeadlineW = 12
|
||||
|
||||
glyphSelected = "◆"
|
||||
glyphHot = "▐"
|
||||
glyphOutlet = "◉"
|
||||
)
|
||||
|
||||
func (m Model) listView() string {
|
||||
header := m.listChrome()
|
||||
footer := m.listFooter()
|
||||
capacity := m.height - lipgloss.Height(header) - lipgloss.Height(footer)
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
body := m.listBody(capacity)
|
||||
return lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
|
||||
}
|
||||
|
||||
func (m Model) listChrome() string {
|
||||
right := m.theme.Meta.Render(fmt.Sprintf("%d stories", len(m.scored)))
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.spread(m.wordmark(), right),
|
||||
m.theme.rule(m.width),
|
||||
m.listColHeader(),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) listColHeader() string {
|
||||
t := m.theme.ColHead
|
||||
left := strings.Join([]string{
|
||||
" ",
|
||||
fmt.Sprintf("%*s", colRankW, "#"),
|
||||
padRight("STORY", m.headlineWidth()),
|
||||
padRight("SCORE", colScoreBarW),
|
||||
fmt.Sprintf("%*s", colScoreNumW, ""),
|
||||
padRight("OUT", colOutletW),
|
||||
}, " ")
|
||||
return m.spread(t.Render(left), t.Render("CVE"))
|
||||
}
|
||||
|
||||
func (m Model) listFooter() string {
|
||||
keys := m.keyHints(m.keys.Up, m.keys.Down, m.keys.Open, m.keys.Browser, m.keys.Quit)
|
||||
right := m.severityLegend()
|
||||
if s := m.statusText(); s != "" {
|
||||
right = s
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
m.theme.rule(m.width),
|
||||
m.spread(keys, right),
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) listBody(capacity int) string {
|
||||
if len(m.scored) == 0 {
|
||||
return m.emptyBody(capacity)
|
||||
}
|
||||
first, last := windowRange(m.cursor, capacity, len(m.scored))
|
||||
lines := make([]string, 0, capacity)
|
||||
for i := first; i < last; i++ {
|
||||
lines = append(lines, m.renderRow(i, m.scored[i], i == m.cursor))
|
||||
}
|
||||
for len(lines) < capacity {
|
||||
lines = append(lines, "")
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m Model) emptyBody(capacity int) string {
|
||||
t := m.theme
|
||||
msg := t.Muted.Render("no stories in the store yet — run ") +
|
||||
t.KeyGlyph.Render("nadezhda scrape") +
|
||||
t.Muted.Render(" then ") +
|
||||
t.KeyGlyph.Render("nadezhda enrich")
|
||||
return lipgloss.Place(m.width, capacity, lipgloss.Center, lipgloss.Center, msg)
|
||||
}
|
||||
|
||||
func (m Model) renderRow(i int, s rank.Scored, selected bool) string {
|
||||
t := m.theme
|
||||
c := s.Cluster
|
||||
b := clusterBand(c)
|
||||
kev := clusterHasKEV(c)
|
||||
|
||||
barStyle := t.Dim
|
||||
barGlyph := " "
|
||||
switch {
|
||||
case selected:
|
||||
barGlyph, barStyle = glyphSelected, t.SelBar
|
||||
case kev || b == bandCritical:
|
||||
barGlyph, barStyle = glyphHot, t.bandFG(bandCritical)
|
||||
case b != bandNone:
|
||||
barGlyph, barStyle = glyphHot, t.bandFG(b)
|
||||
}
|
||||
|
||||
rankStyle := t.Rank
|
||||
headStyle := t.Headline
|
||||
if selected {
|
||||
rankStyle, headStyle = t.RankSel, t.HeadlineSel
|
||||
}
|
||||
|
||||
hlW := m.headlineWidth()
|
||||
left := strings.Join([]string{
|
||||
barStyle.Render(barGlyph),
|
||||
rankStyle.Render(fmt.Sprintf("%*d", colRankW, i+1)),
|
||||
headStyle.Render(padRight(truncate(headlineOf(c), hlW), hlW)),
|
||||
t.spectrumBar(s.Score, colScoreBarW),
|
||||
t.Meta.Render(fmt.Sprintf("%*s", colScoreNumW, fmt.Sprintf("%.2f", s.Score))),
|
||||
m.renderOutlets(c),
|
||||
}, " ")
|
||||
return m.spread(left, m.renderCVEChip(c, b, kev))
|
||||
}
|
||||
|
||||
func windowRange(cursor, capacity, total int) (int, int) {
|
||||
if capacity < 1 {
|
||||
capacity = 1
|
||||
}
|
||||
first := 0
|
||||
if cursor >= capacity {
|
||||
first = cursor - capacity + 1
|
||||
}
|
||||
last := first + capacity
|
||||
if last > total {
|
||||
last = total
|
||||
}
|
||||
return first, last
|
||||
}
|
||||
|
||||
func (m Model) renderOutlets(c store.DigestCluster) string {
|
||||
n := outletCount(c)
|
||||
dots := n
|
||||
if dots > colOutletW {
|
||||
dots = colOutletW
|
||||
}
|
||||
styled := m.theme.fg(outletColor(n)).Render(strings.Repeat(glyphOutlet, dots))
|
||||
if pad := colOutletW - dots; pad > 0 {
|
||||
styled += strings.Repeat(" ", pad)
|
||||
}
|
||||
return styled
|
||||
}
|
||||
|
||||
func (m Model) renderCVEChip(c store.DigestCluster, b band, kev bool) string {
|
||||
t := m.theme
|
||||
if kev {
|
||||
return t.chip("KEV", colorMagenta)
|
||||
}
|
||||
if max := clusterMaxCVSS(c); max != nil {
|
||||
return t.bandFG(b).Bold(true).Render(cvssString(max))
|
||||
}
|
||||
if len(c.CVEs) > 0 {
|
||||
return t.Dim.Render("cve")
|
||||
}
|
||||
return t.Dim.Render(emptyMarker)
|
||||
}
|
||||
|
||||
func (m Model) headlineWidth() int {
|
||||
fixed := colSelBarW + colRankW + colScoreBarW + colScoreNumW + colOutletW + colCVEW + colGaps
|
||||
if w := m.width - fixed; w > minHeadlineW {
|
||||
return w
|
||||
}
|
||||
return minHeadlineW
|
||||
}
|
||||
|
||||
func headlineArticle(c store.DigestCluster) store.DigestArticle {
|
||||
var best store.DigestArticle
|
||||
for i, a := range c.Articles {
|
||||
if i == 0 || a.PublishedAt > best.PublishedAt ||
|
||||
(a.PublishedAt == best.PublishedAt && a.ID < best.ID) {
|
||||
best = a
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func headlineOf(c store.DigestCluster) string {
|
||||
if a := headlineArticle(c); strings.TrimSpace(a.Title) != "" {
|
||||
return a.Title
|
||||
}
|
||||
return "(untitled cluster)"
|
||||
}
|
||||
|
||||
func outletCount(c store.DigestCluster) int {
|
||||
seen := make(map[string]struct{}, len(c.Articles))
|
||||
for _, a := range c.Articles {
|
||||
seen[a.SourceName] = struct{}{}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
func outletColor(n int) string {
|
||||
switch {
|
||||
case n >= 4:
|
||||
return colorViolet
|
||||
case n == 3:
|
||||
return colorCyan
|
||||
case n == 2:
|
||||
return colorBlue
|
||||
default:
|
||||
return colorDim
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,253 @@
|
|||
// ©AngelaMos | 2026
|
||||
// model.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
type viewState int
|
||||
|
||||
const (
|
||||
stateLoading viewState = iota
|
||||
stateList
|
||||
stateDetail
|
||||
stateError
|
||||
)
|
||||
|
||||
const (
|
||||
defaultWidth = 100
|
||||
defaultHeight = 32
|
||||
detailChrome = 4
|
||||
spinnerFPS = time.Second / 10
|
||||
)
|
||||
|
||||
var raveSpinner = spinner.Spinner{
|
||||
Frames: []string{"◇", "◈", "◆", "◈", "◇", "·"},
|
||||
FPS: spinnerFPS,
|
||||
}
|
||||
|
||||
type Data struct {
|
||||
Scored []rank.Scored
|
||||
CVEDetail map[string]store.CVE
|
||||
}
|
||||
|
||||
type Loader func() (Data, error)
|
||||
|
||||
type dataMsg struct{ data Data }
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
||||
type openedMsg struct {
|
||||
url string
|
||||
err error
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
state viewState
|
||||
loader Loader
|
||||
now time.Time
|
||||
theme Theme
|
||||
keys keyMap
|
||||
spinner spinner.Model
|
||||
viewport viewport.Model
|
||||
|
||||
width int
|
||||
height int
|
||||
|
||||
scored []rank.Scored
|
||||
cveDetail map[string]store.CVE
|
||||
|
||||
cursor int
|
||||
err error
|
||||
|
||||
opener func(string) error
|
||||
status string
|
||||
statusErr bool
|
||||
}
|
||||
|
||||
func New(loader Loader, now time.Time) Model {
|
||||
th := NewTheme()
|
||||
sp := spinner.New(spinner.WithSpinner(raveSpinner), spinner.WithStyle(th.Spinner))
|
||||
m := Model{
|
||||
state: stateLoading,
|
||||
loader: loader,
|
||||
now: now,
|
||||
theme: th,
|
||||
keys: defaultKeyMap(),
|
||||
spinner: sp,
|
||||
viewport: viewport.New(defaultWidth, defaultHeight-detailChrome),
|
||||
width: defaultWidth,
|
||||
height: defaultHeight,
|
||||
cveDetail: map[string]store.CVE{},
|
||||
opener: openURL,
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.spinner.Tick, m.load())
|
||||
}
|
||||
|
||||
func (m Model) load() tea.Cmd {
|
||||
loader := m.loader
|
||||
return func() tea.Msg {
|
||||
data, err := loader()
|
||||
if err != nil {
|
||||
return errMsg{err}
|
||||
}
|
||||
return dataMsg{data}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.applySize(msg.Width, msg.Height)
|
||||
return m, nil
|
||||
case dataMsg:
|
||||
m.scored = msg.data.Scored
|
||||
m.cveDetail = msg.data.CVEDetail
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
case errMsg:
|
||||
m.state = stateError
|
||||
m.err = msg.err
|
||||
return m, nil
|
||||
case openedMsg:
|
||||
if msg.err != nil {
|
||||
m.status, m.statusErr = "open failed: "+msg.err.Error(), true
|
||||
} else {
|
||||
m.status, m.statusErr = "opened in browser", false
|
||||
}
|
||||
return m, nil
|
||||
case spinner.TickMsg:
|
||||
if m.state != stateLoading {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.spinner, cmd = m.spinner.Update(msg)
|
||||
return m, cmd
|
||||
case tea.KeyMsg:
|
||||
return m.handleKey(msg)
|
||||
}
|
||||
if m.state == stateDetail {
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.status = ""
|
||||
if key.Matches(msg, m.keys.Quit) {
|
||||
return m, tea.Quit
|
||||
}
|
||||
if key.Matches(msg, m.keys.Browser) && (m.state == stateList || m.state == stateDetail) {
|
||||
return m, m.openSelected()
|
||||
}
|
||||
switch m.state {
|
||||
case stateList:
|
||||
return m.handleListKey(msg)
|
||||
case stateDetail:
|
||||
return m.handleDetailKey(msg)
|
||||
default:
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) openSelected() tea.Cmd {
|
||||
target := headlineArticle(m.selected().Cluster).CanonicalURL
|
||||
if target == "" {
|
||||
return nil
|
||||
}
|
||||
open := m.opener
|
||||
return func() tea.Msg {
|
||||
return openedMsg{url: target, err: open(target)}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) handleListKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.cursor < len(m.scored)-1 {
|
||||
m.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Top):
|
||||
m.cursor = 0
|
||||
case key.Matches(msg, m.keys.Bottom):
|
||||
if len(m.scored) > 0 {
|
||||
m.cursor = len(m.scored) - 1
|
||||
}
|
||||
case key.Matches(msg, m.keys.Open):
|
||||
if len(m.scored) > 0 {
|
||||
m.state = stateDetail
|
||||
m.viewport.SetContent(m.renderDetailBody())
|
||||
m.viewport.GotoTop()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m Model) handleDetailKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if key.Matches(msg, m.keys.Back) {
|
||||
m.state = stateList
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.viewport, cmd = m.viewport.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) applySize(w, h int) {
|
||||
m.width = w
|
||||
m.height = h
|
||||
m.viewport.Width = m.bodyWidth()
|
||||
m.viewport.Height = m.detailBodyHeight()
|
||||
if m.state == stateDetail {
|
||||
m.viewport.SetContent(m.renderDetailBody())
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) bodyWidth() int {
|
||||
if m.width > 1 {
|
||||
return m.width
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (m Model) detailBodyHeight() int {
|
||||
if h := m.height - detailChrome; h > 1 {
|
||||
return h
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func (m Model) selected() rank.Scored {
|
||||
if m.cursor < 0 || m.cursor >= len(m.scored) {
|
||||
return rank.Scored{}
|
||||
}
|
||||
return m.scored[m.cursor]
|
||||
}
|
||||
|
||||
func Run(loader Loader) error {
|
||||
m := New(loader, time.Now())
|
||||
_, err := tea.NewProgram(m, tea.WithAltScreen()).Run()
|
||||
return err
|
||||
}
|
||||
|
||||
var _ tea.Model = Model{}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
// ©AngelaMos | 2026
|
||||
// model_test.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/rank"
|
||||
"github.com/CarterPerez-dev/nadezhda/internal/store"
|
||||
)
|
||||
|
||||
func testNow() time.Time { return time.Unix(1_720_000_000, 0) }
|
||||
|
||||
func runeKey(r rune) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}} }
|
||||
|
||||
func sampleData() Data {
|
||||
now := testNow().Unix()
|
||||
c1 := store.DigestCluster{
|
||||
ClusterID: 1,
|
||||
Key: "log4shell",
|
||||
Size: 2,
|
||||
FirstSeen: now - 3600,
|
||||
LastSeen: now - 600,
|
||||
Articles: []store.DigestArticle{
|
||||
{ID: 1, SourceName: "krebs", SourceWeight: 1.0, Title: "Log4Shell RCE exploited in the wild", CanonicalURL: "https://krebsonsecurity.com/log4shell", PublishedAt: now - 600},
|
||||
{ID: 2, SourceName: "theregister", SourceWeight: 0.9, Title: "Log4j flaw under active attack", CanonicalURL: "https://theregister.com/log4j", PublishedAt: now - 3600},
|
||||
},
|
||||
CVEs: []store.DigestCVE{
|
||||
{ID: "CVE-2021-44228", CVSSScore: ptr(10.0), EPSS: ptr(0.97), IsKEV: true},
|
||||
},
|
||||
}
|
||||
c2 := store.DigestCluster{
|
||||
ClusterID: 2,
|
||||
Key: "policy",
|
||||
Size: 1,
|
||||
FirstSeen: now - 7200,
|
||||
LastSeen: now - 7200,
|
||||
Articles: []store.DigestArticle{
|
||||
{ID: 3, SourceName: "darkreading", SourceWeight: 0.7, Title: "New disclosure guidelines published", CanonicalURL: "https://darkreading.com/policy", PublishedAt: now - 7200},
|
||||
},
|
||||
}
|
||||
scored := []rank.Scored{{Cluster: c1, Score: 0.94}, {Cluster: c2, Score: 0.42}}
|
||||
detail := map[string]store.CVE{
|
||||
"CVE-2021-44228": {
|
||||
ID: "CVE-2021-44228",
|
||||
Description: "Apache Log4j2 JNDI features do not protect against attacker controlled LDAP endpoints.",
|
||||
CVSSScore: ptr(10.0),
|
||||
CVSSVersion: "3.1",
|
||||
CVSSSeverity: "CRITICAL",
|
||||
CVSSVector: "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H",
|
||||
CWE: "CWE-502",
|
||||
IsKEV: true,
|
||||
EPSS: ptr(0.97),
|
||||
},
|
||||
}
|
||||
return Data{Scored: scored, CVEDetail: detail}
|
||||
}
|
||||
|
||||
func toModel(t *testing.T, tm tea.Model) Model {
|
||||
t.Helper()
|
||||
m, ok := tm.(Model)
|
||||
if !ok {
|
||||
t.Fatalf("Update returned %T, want tui.Model", tm)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func step(t *testing.T, m Model, msg tea.Msg) Model {
|
||||
t.Helper()
|
||||
tm, _ := m.Update(msg)
|
||||
return toModel(t, tm)
|
||||
}
|
||||
|
||||
func loadedModel(t *testing.T) Model {
|
||||
t.Helper()
|
||||
return step(t, New(nil, testNow()), dataMsg{sampleData()})
|
||||
}
|
||||
|
||||
func TestInitialStateIsLoading(t *testing.T) {
|
||||
if m := New(nil, testNow()); m.state != stateLoading {
|
||||
t.Fatalf("initial state = %v, want stateLoading", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataMsgTransitionsToList(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
if m.state != stateList {
|
||||
t.Fatalf("state = %v, want stateList", m.state)
|
||||
}
|
||||
if len(m.scored) != 2 {
|
||||
t.Fatalf("len(scored) = %d, want 2", len(m.scored))
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrMsgTransitionsToError(t *testing.T) {
|
||||
m := step(t, New(nil, testNow()), errMsg{errors.New("wire down")})
|
||||
if m.state != stateError {
|
||||
t.Fatalf("state = %v, want stateError", m.state)
|
||||
}
|
||||
if m.err == nil {
|
||||
t.Fatal("err = nil, want non-nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNavigationClamps(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
m = step(t, m, runeKey('j'))
|
||||
if m.cursor != 1 {
|
||||
t.Fatalf("after down, cursor = %d, want 1", m.cursor)
|
||||
}
|
||||
m = step(t, m, runeKey('j'))
|
||||
if m.cursor != 1 {
|
||||
t.Fatalf("after down past end, cursor = %d, want 1", m.cursor)
|
||||
}
|
||||
m = step(t, m, runeKey('k'))
|
||||
if m.cursor != 0 {
|
||||
t.Fatalf("after up, cursor = %d, want 0", m.cursor)
|
||||
}
|
||||
m = step(t, m, runeKey('k'))
|
||||
if m.cursor != 0 {
|
||||
t.Fatalf("after up past start, cursor = %d, want 0", m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListTopBottom(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
m = step(t, m, runeKey('G'))
|
||||
if m.cursor != 1 {
|
||||
t.Fatalf("after G, cursor = %d, want 1", m.cursor)
|
||||
}
|
||||
m = step(t, m, runeKey('g'))
|
||||
if m.cursor != 0 {
|
||||
t.Fatalf("after g, cursor = %d, want 0", m.cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAndBack(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
m = step(t, m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if m.state != stateDetail {
|
||||
t.Fatalf("after enter, state = %v, want stateDetail", m.state)
|
||||
}
|
||||
m = step(t, m, tea.KeyMsg{Type: tea.KeyEsc})
|
||||
if m.state != stateList {
|
||||
t.Fatalf("after esc, state = %v, want stateList", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQuitReturnsQuitCmd(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
_, cmd := m.Update(runeKey('q'))
|
||||
if cmd == nil {
|
||||
t.Fatal("quit returned nil cmd")
|
||||
}
|
||||
if reflect.TypeOf(cmd()) != reflect.TypeOf(tea.Quit()) {
|
||||
t.Fatalf("quit cmd yielded %T, want tea.QuitMsg", cmd())
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowSizeSizesViewport(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
m = step(t, m, tea.WindowSizeMsg{Width: 120, Height: 40})
|
||||
if m.width != 120 || m.height != 40 {
|
||||
t.Fatalf("size = %dx%d, want 120x40", m.width, m.height)
|
||||
}
|
||||
if m.viewport.Width != 120 {
|
||||
t.Fatalf("viewport.Width = %d, want 120", m.viewport.Width)
|
||||
}
|
||||
if m.viewport.Height != 40-detailChrome {
|
||||
t.Fatalf("viewport.Height = %d, want %d", m.viewport.Height, 40-detailChrome)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpinnerTickIgnoredOutsideLoading(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
m = step(t, m, spinner.TickMsg{})
|
||||
if m.state != stateList {
|
||||
t.Fatalf("spinner tick changed state to %v, want stateList", m.state)
|
||||
}
|
||||
}
|
||||
|
||||
func TestViewsRenderNonEmpty(t *testing.T) {
|
||||
loading := New(nil, testNow())
|
||||
if strings.TrimSpace(loading.View()) == "" {
|
||||
t.Error("loading view is empty")
|
||||
}
|
||||
|
||||
list := loadedModel(t)
|
||||
lv := list.View()
|
||||
if !strings.Contains(lv, "NADEZHDA") {
|
||||
t.Error("list view missing brand")
|
||||
}
|
||||
if !strings.Contains(lv, "Log4Shell") {
|
||||
t.Error("list view missing headline")
|
||||
}
|
||||
|
||||
detail := step(t, list, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
dv := detail.View()
|
||||
if !strings.Contains(dv, "CVE-2021-44228") {
|
||||
t.Error("detail view missing CVE id")
|
||||
}
|
||||
if !strings.Contains(dv, "CRITICAL") {
|
||||
t.Error("detail view missing severity label")
|
||||
}
|
||||
|
||||
errv := step(t, New(nil, testNow()), errMsg{errors.New("boom")})
|
||||
if strings.TrimSpace(errv.View()) == "" {
|
||||
t.Error("error view is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyStoreRendersHint(t *testing.T) {
|
||||
m := step(t, New(nil, testNow()), dataMsg{Data{}})
|
||||
if m.state != stateList {
|
||||
t.Fatalf("state = %v, want stateList", m.state)
|
||||
}
|
||||
if !strings.Contains(m.View(), "scrape") {
|
||||
t.Error("empty list view missing scrape hint")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTooSmallTerminal(t *testing.T) {
|
||||
m := step(t, loadedModel(t), tea.WindowSizeMsg{Width: 20, Height: 5})
|
||||
if !strings.Contains(m.View(), "too small") {
|
||||
t.Error("tiny terminal did not render the too-small notice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowRange(t *testing.T) {
|
||||
cases := []struct {
|
||||
cursor, capacity, total int
|
||||
first, last int
|
||||
}{
|
||||
{0, 5, 10, 0, 5},
|
||||
{4, 5, 10, 0, 5},
|
||||
{5, 5, 10, 1, 6},
|
||||
{9, 5, 10, 5, 10},
|
||||
{0, 5, 3, 0, 3},
|
||||
{2, 5, 3, 0, 3},
|
||||
{0, 0, 10, 0, 1},
|
||||
{7, 3, 10, 5, 8},
|
||||
}
|
||||
for _, c := range cases {
|
||||
first, last := windowRange(c.cursor, c.capacity, c.total)
|
||||
if first != c.first || last != c.last {
|
||||
t.Errorf("windowRange(%d, %d, %d) = (%d, %d), want (%d, %d)",
|
||||
c.cursor, c.capacity, c.total, first, last, c.first, c.last)
|
||||
}
|
||||
if c.cursor < c.total && (c.cursor < first || c.cursor >= last) {
|
||||
t.Errorf("windowRange(%d, %d, %d) window [%d,%d) excludes cursor",
|
||||
c.cursor, c.capacity, c.total, first, last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHeadlineFreshest(t *testing.T) {
|
||||
fresh := store.DigestCluster{Articles: []store.DigestArticle{
|
||||
{ID: 1, Title: "older", PublishedAt: 100},
|
||||
{ID: 2, Title: "newest", PublishedAt: 300},
|
||||
{ID: 3, Title: "mid", PublishedAt: 200},
|
||||
}}
|
||||
if got := headlineOf(fresh); got != "newest" {
|
||||
t.Errorf("headlineOf(fresh) = %q, want newest", got)
|
||||
}
|
||||
tie := store.DigestCluster{Articles: []store.DigestArticle{
|
||||
{ID: 5, Title: "high-id", PublishedAt: 300},
|
||||
{ID: 2, Title: "low-id", PublishedAt: 300},
|
||||
}}
|
||||
if got := headlineOf(tie); got != "low-id" {
|
||||
t.Errorf("headlineOf(tie) = %q, want low-id (lowest id on equal time)", got)
|
||||
}
|
||||
if got := headlineOf(store.DigestCluster{}); got != "(untitled cluster)" {
|
||||
t.Errorf("headlineOf(empty) = %q, want (untitled cluster)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenInBrowser(t *testing.T) {
|
||||
m := loadedModel(t)
|
||||
var opened string
|
||||
m.opener = func(u string) error { opened = u; return nil }
|
||||
|
||||
tm, cmd := m.Update(runeKey('o'))
|
||||
m = toModel(t, tm)
|
||||
if cmd == nil {
|
||||
t.Fatal("pressing o produced no command")
|
||||
}
|
||||
msg := cmd()
|
||||
if opened != "https://krebsonsecurity.com/log4shell" {
|
||||
t.Fatalf("opened %q, want the selected cluster's headline url", opened)
|
||||
}
|
||||
om, ok := msg.(openedMsg)
|
||||
if !ok {
|
||||
t.Fatalf("open cmd returned %T, want openedMsg", msg)
|
||||
}
|
||||
m = step(t, m, om)
|
||||
if m.status == "" || m.statusErr {
|
||||
t.Errorf("after open: status=%q err=%v, want a success status", m.status, m.statusErr)
|
||||
}
|
||||
if !strings.Contains(m.View(), m.status) {
|
||||
t.Error("open status not shown in the view")
|
||||
}
|
||||
m = step(t, m, runeKey('j'))
|
||||
if m.status != "" {
|
||||
t.Error("status should clear on the next keypress")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenURLRejectsNonHTTP(t *testing.T) {
|
||||
for _, bad := range []string{"file:///etc/passwd", "javascript:alert(1)", "", "ftp://x/y"} {
|
||||
if err := openURL(bad); err == nil {
|
||||
t.Errorf("openURL(%q) = nil, want refusal", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,151 @@
|
|||
// ©AngelaMos | 2026
|
||||
// theme.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
colorVoid = "#0B0620"
|
||||
colorText = "#E7E0FF"
|
||||
colorMuted = "#9A8AD0"
|
||||
colorDim = "#574B7D"
|
||||
|
||||
colorMagenta = "#FF2E97"
|
||||
colorAmber = "#FF8A3D"
|
||||
colorYellow = "#FFE04D"
|
||||
colorCyan = "#00E5FF"
|
||||
colorViolet = "#A65CFF"
|
||||
colorBlue = "#2D6BFF"
|
||||
)
|
||||
|
||||
const (
|
||||
barFull = "█"
|
||||
barEmpty = "░"
|
||||
ruleLead = "▓▒░"
|
||||
ruleBody = "─"
|
||||
)
|
||||
|
||||
func bandColor(b band) string {
|
||||
switch b {
|
||||
case bandCritical:
|
||||
return colorMagenta
|
||||
case bandHigh:
|
||||
return colorAmber
|
||||
case bandMedium:
|
||||
return colorYellow
|
||||
case bandLow:
|
||||
return colorCyan
|
||||
default:
|
||||
return colorDim
|
||||
}
|
||||
}
|
||||
|
||||
type Theme struct {
|
||||
Brand lipgloss.Style
|
||||
BrandMark lipgloss.Style
|
||||
Meta lipgloss.Style
|
||||
ColHead lipgloss.Style
|
||||
Text lipgloss.Style
|
||||
Muted lipgloss.Style
|
||||
Dim lipgloss.Style
|
||||
Rank lipgloss.Style
|
||||
RankSel lipgloss.Style
|
||||
Headline lipgloss.Style
|
||||
HeadlineSel lipgloss.Style
|
||||
SelBar lipgloss.Style
|
||||
KeyGlyph lipgloss.Style
|
||||
KeyDesc lipgloss.Style
|
||||
Spinner lipgloss.Style
|
||||
PanelTitle lipgloss.Style
|
||||
Label lipgloss.Style
|
||||
Link lipgloss.Style
|
||||
}
|
||||
|
||||
func NewTheme() Theme {
|
||||
base := lipgloss.NewStyle().Foreground(lipgloss.Color(colorText))
|
||||
return Theme{
|
||||
Brand: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true),
|
||||
BrandMark: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
|
||||
Meta: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)),
|
||||
ColHead: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)).Bold(true),
|
||||
Text: base,
|
||||
Muted: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
|
||||
Dim: lipgloss.NewStyle().Foreground(lipgloss.Color(colorDim)),
|
||||
Rank: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
|
||||
RankSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
|
||||
Headline: base,
|
||||
HeadlineSel: lipgloss.NewStyle().Foreground(lipgloss.Color(colorText)).Bold(true),
|
||||
SelBar: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
|
||||
KeyGlyph: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Bold(true),
|
||||
KeyDesc: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMuted)),
|
||||
Spinner: lipgloss.NewStyle().Foreground(lipgloss.Color(colorMagenta)).Bold(true),
|
||||
PanelTitle: lipgloss.NewStyle().Foreground(lipgloss.Color(colorViolet)).Bold(true),
|
||||
Label: lipgloss.NewStyle().Foreground(lipgloss.Color(colorBlue)).Bold(true),
|
||||
Link: lipgloss.NewStyle().Foreground(lipgloss.Color(colorCyan)).Underline(true),
|
||||
}
|
||||
}
|
||||
|
||||
func (t Theme) fg(color string) lipgloss.Style {
|
||||
return lipgloss.NewStyle().Foreground(lipgloss.Color(color))
|
||||
}
|
||||
|
||||
func (t Theme) bandFG(b band) lipgloss.Style {
|
||||
return t.fg(bandColor(b))
|
||||
}
|
||||
|
||||
func (t Theme) chip(text, color string) string {
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(lipgloss.Color(colorVoid)).
|
||||
Background(lipgloss.Color(color)).
|
||||
Bold(true).
|
||||
Render(" " + text + " ")
|
||||
}
|
||||
|
||||
func (t Theme) spectrumBar(frac float64, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
filled := int(math.Round(clamp01(frac) * float64(width)))
|
||||
stops := []string{colorCyan, colorBlue, colorViolet, colorMagenta}
|
||||
var b strings.Builder
|
||||
for i := 0; i < width; i++ {
|
||||
if i < filled {
|
||||
b.WriteString(t.fg(stops[i*len(stops)/width]).Render(barFull))
|
||||
} else {
|
||||
b.WriteString(t.Dim.Render(barEmpty))
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (t Theme) bandBar(b band, frac float64, width int) string {
|
||||
if width <= 0 {
|
||||
return ""
|
||||
}
|
||||
filled := int(math.Round(clamp01(frac) * float64(width)))
|
||||
color := bandColor(b)
|
||||
var sb strings.Builder
|
||||
for i := 0; i < width; i++ {
|
||||
if i < filled {
|
||||
sb.WriteString(t.fg(color).Render(barFull))
|
||||
} else {
|
||||
sb.WriteString(t.Dim.Render(barEmpty))
|
||||
}
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
func (t Theme) rule(width int) string {
|
||||
lead := len([]rune(ruleLead)) + 1
|
||||
n := width - lead
|
||||
if n < 0 {
|
||||
return t.fg(colorViolet).Render(ruleLead)
|
||||
}
|
||||
return t.fg(colorViolet).Render(ruleLead) + " " + t.Dim.Render(strings.Repeat(ruleBody, n))
|
||||
}
|
||||
|
|
@ -0,0 +1,159 @@
|
|||
// ©AngelaMos | 2026
|
||||
// view.go
|
||||
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/key"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
const (
|
||||
brandName = "NADEZHDA"
|
||||
tagline = "THREAT.WIRE"
|
||||
|
||||
markOpen = "◤ "
|
||||
markClose = " ◢"
|
||||
markMid = "═◆═"
|
||||
|
||||
errWrapMax = 80
|
||||
errWrapMin = 20
|
||||
errWrapMargin = 6
|
||||
|
||||
minRenderWidth = 44
|
||||
minRenderHeight = 10
|
||||
)
|
||||
|
||||
func (m Model) View() string {
|
||||
if m.width < minRenderWidth || m.height < minRenderHeight {
|
||||
return m.tooSmallView()
|
||||
}
|
||||
switch m.state {
|
||||
case stateLoading:
|
||||
return m.loadingView()
|
||||
case stateError:
|
||||
return m.errorView()
|
||||
case stateDetail:
|
||||
return m.detailView()
|
||||
default:
|
||||
return m.listView()
|
||||
}
|
||||
}
|
||||
|
||||
func (m Model) tooSmallView() string {
|
||||
w, h := m.width, m.height
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
msg := m.theme.Muted.Render(fmt.Sprintf("terminal too small — need at least %dx%d", minRenderWidth, minRenderHeight))
|
||||
return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, msg)
|
||||
}
|
||||
|
||||
func (m Model) wordmark() string {
|
||||
t := m.theme
|
||||
return t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose) +
|
||||
" " + t.fg(colorCyan).Render(markMid) + " " + t.fg(colorViolet).Render(tagline) + " " + t.fg(colorCyan).Render(markMid)
|
||||
}
|
||||
|
||||
func (m Model) spread(left, right string) string {
|
||||
gap := m.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
return left + strings.Repeat(" ", gap) + right
|
||||
}
|
||||
|
||||
func (m Model) keyHints(bindings ...key.Binding) string {
|
||||
t := m.theme
|
||||
parts := make([]string, 0, len(bindings))
|
||||
for _, b := range bindings {
|
||||
h := b.Help()
|
||||
parts = append(parts, t.KeyGlyph.Render(h.Key)+" "+t.KeyDesc.Render(h.Desc))
|
||||
}
|
||||
return strings.Join(parts, t.Dim.Render(" · "))
|
||||
}
|
||||
|
||||
func (m Model) severityLegend() string {
|
||||
t := m.theme
|
||||
item := func(color, label string) string {
|
||||
return t.fg(color).Render("■") + " " + t.KeyDesc.Render(label)
|
||||
}
|
||||
return strings.Join([]string{
|
||||
item(colorMagenta, "KEV/CRIT"),
|
||||
item(colorAmber, "HIGH"),
|
||||
item(colorYellow, "MED"),
|
||||
item(colorCyan, "LOW"),
|
||||
}, " ")
|
||||
}
|
||||
|
||||
func (m Model) loadingView() string {
|
||||
t := m.theme
|
||||
brand := t.BrandMark.Render(markOpen) + t.Brand.Render(brandName) + t.BrandMark.Render(markClose)
|
||||
tag := t.fg(colorViolet).Render(tagline)
|
||||
line := m.spinner.View() + " " + t.Meta.Render("assembling threat wire") + " " + m.spinner.View()
|
||||
block := lipgloss.JoinVertical(lipgloss.Center, brand, tag, "", line)
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
|
||||
}
|
||||
|
||||
func (m Model) errorView() string {
|
||||
t := m.theme
|
||||
title := t.fg(colorMagenta).Bold(true).Render("▚ WIRE FAULT ▞")
|
||||
block := lipgloss.JoinVertical(lipgloss.Center,
|
||||
title,
|
||||
"",
|
||||
m.errString(),
|
||||
"",
|
||||
t.KeyDesc.Render("press ")+t.KeyGlyph.Render("q")+t.KeyDesc.Render(" to quit"),
|
||||
)
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, block)
|
||||
}
|
||||
|
||||
func (m Model) errString() string {
|
||||
if m.err == nil {
|
||||
return m.theme.Text.Render("unknown fault")
|
||||
}
|
||||
w := m.width - errWrapMargin
|
||||
if w > errWrapMax {
|
||||
w = errWrapMax
|
||||
}
|
||||
if w < errWrapMin {
|
||||
w = errWrapMin
|
||||
}
|
||||
return m.theme.Text.Width(w).Align(lipgloss.Center).Render(m.err.Error())
|
||||
}
|
||||
|
||||
func (m Model) detailView() string {
|
||||
t := m.theme
|
||||
scroll := fmt.Sprintf("%3.0f%%", m.viewport.ScrollPercent()*100)
|
||||
left := t.PanelTitle.Render(markOpen+"DOSSIER"+markClose) + " " +
|
||||
t.Muted.Render(fmt.Sprintf("story %d of %d", m.cursor+1, len(m.scored)))
|
||||
head := m.spread(left, t.Meta.Render(scroll))
|
||||
foot := m.spread(
|
||||
m.keyHints(m.keys.Back, m.keys.Browser, m.keys.Down, m.keys.Up, m.keys.Quit),
|
||||
m.statusText(),
|
||||
)
|
||||
return lipgloss.JoinVertical(lipgloss.Left,
|
||||
head,
|
||||
t.rule(m.width),
|
||||
m.viewport.View(),
|
||||
t.rule(m.width),
|
||||
foot,
|
||||
)
|
||||
}
|
||||
|
||||
func (m Model) statusText() string {
|
||||
if m.status == "" {
|
||||
return ""
|
||||
}
|
||||
color := colorCyan
|
||||
if m.statusErr {
|
||||
color = colorMagenta
|
||||
}
|
||||
return m.theme.fg(color).Render(m.status)
|
||||
}
|
||||
Loading…
Reference in New Issue