feat: small UI improvements (#394)
This commit is contained in:
committed by
GitHub
parent
af41fc6cb8
commit
f192139cc3
@@ -0,0 +1,48 @@
|
||||
package ui
|
||||
|
||||
import "strings"
|
||||
|
||||
// BuildInfo represents build metadata injected at link time.
|
||||
type BuildInfo struct {
|
||||
Version string
|
||||
Commit string
|
||||
Date string
|
||||
}
|
||||
|
||||
// DisplayVersion returns the version prefixed with "v" when needed.
|
||||
func (b BuildInfo) DisplayVersion() string {
|
||||
version := strings.TrimSpace(b.Version)
|
||||
if version == "" {
|
||||
version = "dev"
|
||||
}
|
||||
if strings.HasPrefix(version, "v") {
|
||||
return version
|
||||
}
|
||||
return "v" + version
|
||||
}
|
||||
|
||||
// LogVersion returns the version without a leading "v".
|
||||
func (b BuildInfo) LogVersion() string {
|
||||
version := strings.TrimSpace(b.Version)
|
||||
if version == "" {
|
||||
return "dev"
|
||||
}
|
||||
return strings.TrimPrefix(version, "v")
|
||||
}
|
||||
|
||||
// ShortCommit returns a shortened commit hash suitable for display.
|
||||
func (b BuildInfo) ShortCommit() string {
|
||||
commit := strings.TrimSpace(b.Commit)
|
||||
if commit == "" || commit == "none" || commit == "unknown" {
|
||||
return "unknown"
|
||||
}
|
||||
if len(commit) > 7 {
|
||||
return commit[:7]
|
||||
}
|
||||
return commit
|
||||
}
|
||||
|
||||
// TUIHeader returns the header used by the TUI.
|
||||
func (b BuildInfo) TUIHeader() string {
|
||||
return "Cameradar — " + b.DisplayVersion() + " (" + b.ShortCommit() + ")"
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package ui_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Ullaakut/cameradar/v6/internal/ui"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildInfo_DisplayVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty defaults to dev with prefix",
|
||||
version: "",
|
||||
want: "vdev",
|
||||
},
|
||||
{
|
||||
name: "dev without prefix",
|
||||
version: "dev",
|
||||
want: "vdev",
|
||||
},
|
||||
{
|
||||
name: "already prefixed",
|
||||
version: "v1.2.3",
|
||||
want: "v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "adds prefix",
|
||||
version: "1.2.3",
|
||||
want: "v1.2.3",
|
||||
},
|
||||
{
|
||||
name: "trims spaces with prefix",
|
||||
version: " v2.0 ",
|
||||
want: "v2.0",
|
||||
},
|
||||
{
|
||||
name: "trims spaces without prefix",
|
||||
version: " 2.0 ",
|
||||
want: "v2.0",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
info := ui.BuildInfo{Version: test.version}
|
||||
assert.Equal(t, test.want, info.DisplayVersion())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInfo_LogVersion(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty defaults to dev",
|
||||
version: "",
|
||||
want: "dev",
|
||||
},
|
||||
{
|
||||
name: "removes leading v",
|
||||
version: "v1.2.3",
|
||||
want: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "keeps version without prefix",
|
||||
version: "1.2.3",
|
||||
want: "1.2.3",
|
||||
},
|
||||
{
|
||||
name: "trims spaces and removes prefix",
|
||||
version: " v2.0 ",
|
||||
want: "2.0",
|
||||
},
|
||||
{
|
||||
name: "removes only first prefix",
|
||||
version: "vv1",
|
||||
want: "v1",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
info := ui.BuildInfo{Version: test.version}
|
||||
assert.Equal(t, test.want, info.LogVersion())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInfo_ShortCommit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commit string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "empty defaults to unknown",
|
||||
commit: "",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "none defaults to unknown",
|
||||
commit: "none",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "unknown defaults to unknown",
|
||||
commit: "unknown",
|
||||
want: "unknown",
|
||||
},
|
||||
{
|
||||
name: "short commit preserved",
|
||||
commit: "abcdef",
|
||||
want: "abcdef",
|
||||
},
|
||||
{
|
||||
name: "seven chars preserved",
|
||||
commit: "abcdefg",
|
||||
want: "abcdefg",
|
||||
},
|
||||
{
|
||||
name: "long commit shortened",
|
||||
commit: "abcdefghi",
|
||||
want: "abcdefg",
|
||||
},
|
||||
{
|
||||
name: "trims spaces before shortening",
|
||||
commit: " 1234567890 ",
|
||||
want: "1234567",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
info := ui.BuildInfo{Commit: test.commit}
|
||||
assert.Equal(t, test.want, info.ShortCommit())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildInfo_TUIHeader(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
version string
|
||||
commit string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "uses display version and short commit",
|
||||
version: "1.2.3",
|
||||
commit: "abcdefghi",
|
||||
want: "Cameradar — v1.2.3 (abcdefg)",
|
||||
},
|
||||
{
|
||||
name: "uses defaults for empty values",
|
||||
version: "",
|
||||
commit: "",
|
||||
want: "Cameradar — vdev (unknown)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
info := ui.BuildInfo{Version: test.version, Commit: test.commit}
|
||||
assert.Equal(t, test.want, info.TUIHeader())
|
||||
})
|
||||
}
|
||||
}
|
||||
+33
-4
@@ -22,9 +22,22 @@ func NewPlainReporter(out io.Writer, debug bool) *PlainReporter {
|
||||
}
|
||||
}
|
||||
|
||||
// PrintStartup prints build metadata and configuration options.
|
||||
func (r *PlainReporter) PrintStartup(buildInfo BuildInfo, options []string) {
|
||||
step := cameradar.Step("Startup")
|
||||
message := fmt.Sprintf("Running cameradar version %s, commit %s", buildInfo.LogVersion(), buildInfo.ShortCommit())
|
||||
r.print(step, "INFO", message)
|
||||
if len(options) == 0 {
|
||||
return
|
||||
}
|
||||
for _, option := range options {
|
||||
r.print(step, "INFO", option)
|
||||
}
|
||||
}
|
||||
|
||||
// Start prints the beginning of a step.
|
||||
func (r *PlainReporter) Start(step cameradar.Step, message string) {
|
||||
r.print(step, "START", message)
|
||||
r.print(step, "STEP", message)
|
||||
}
|
||||
|
||||
// Done prints the completion of a step.
|
||||
@@ -45,7 +58,7 @@ func (r *PlainReporter) Debug(step cameradar.Step, message string) {
|
||||
if !r.debug {
|
||||
return
|
||||
}
|
||||
r.print(step, "DEBUG", message)
|
||||
r.print(step, "DBUG", message)
|
||||
}
|
||||
|
||||
// Error prints an error message.
|
||||
@@ -53,7 +66,7 @@ func (r *PlainReporter) Error(step cameradar.Step, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
r.print(step, "ERROR", err.Error())
|
||||
r.print(step, "EROR", err.Error())
|
||||
}
|
||||
|
||||
// Summary prints the final summary.
|
||||
@@ -71,5 +84,21 @@ func (r *PlainReporter) print(step cameradar.Step, level, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(r.out, "[%s] %s: %s (%s)\n", level, cameradar.StepLabel(step), message, time.Now().Format(time.RFC3339))
|
||||
level = normalizeLevel(level)
|
||||
_, _ = fmt.Fprintf(r.out, "%s [%s] %s: %s\n", time.Now().Format(time.RFC3339), level, cameradar.StepLabel(step), message)
|
||||
}
|
||||
|
||||
func normalizeLevel(level string) string {
|
||||
switch level {
|
||||
case "DEBUG":
|
||||
return "DBUG"
|
||||
case "ERROR":
|
||||
return "EROR"
|
||||
case "START", "STEP":
|
||||
return "STEP"
|
||||
}
|
||||
if len(level) >= 4 {
|
||||
return level[:4]
|
||||
}
|
||||
return fmt.Sprintf("%-4s", level)
|
||||
}
|
||||
|
||||
@@ -24,11 +24,11 @@ func TestPlainReporter_Outputs(t *testing.T) {
|
||||
reporter.Summary([]cameradar.Stream{}, nil)
|
||||
|
||||
content := out.String()
|
||||
assert.Contains(t, content, "[START] Scan targets: starting")
|
||||
assert.Contains(t, content, "[INFO] Scan targets: working")
|
||||
assert.Contains(t, content, "[DEBUG] Scan targets: details")
|
||||
assert.Contains(t, content, "[DONE] Scan targets: finished")
|
||||
assert.Contains(t, content, "[ERROR] Scan targets: boom")
|
||||
assert.Contains(t, content, " [STEP] Scan targets: starting")
|
||||
assert.Contains(t, content, " [INFO] Scan targets: working")
|
||||
assert.Contains(t, content, " [DBUG] Scan targets: details")
|
||||
assert.Contains(t, content, " [DONE] Scan targets: finished")
|
||||
assert.Contains(t, content, " [EROR] Scan targets: boom")
|
||||
assert.Contains(t, content, "Summary\n-------\nAccessible streams: 0")
|
||||
})
|
||||
|
||||
@@ -41,7 +41,35 @@ func TestPlainReporter_Outputs(t *testing.T) {
|
||||
reporter.Error(cameradar.StepScan, nil)
|
||||
|
||||
content := out.String()
|
||||
assert.NotContains(t, content, "DEBUG")
|
||||
assert.NotContains(t, content, "DBUG")
|
||||
assert.Equal(t, "", strings.TrimSpace(content))
|
||||
})
|
||||
}
|
||||
|
||||
func TestPlainReporter_PrintStartup(t *testing.T) {
|
||||
t.Run("prints build info and options", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
reporter := ui.NewPlainReporter(out, false)
|
||||
|
||||
reporter.PrintStartup(ui.BuildInfo{Version: "v1.2.3", Commit: "abcdefghi"}, []string{
|
||||
"targets: 127.0.0.1",
|
||||
"ports: 554",
|
||||
})
|
||||
|
||||
content := out.String()
|
||||
assert.Contains(t, content, " [INFO] Startup: Running cameradar version 1.2.3, commit abcdefg")
|
||||
assert.Contains(t, content, " [INFO] Startup: targets: 127.0.0.1")
|
||||
assert.Contains(t, content, " [INFO] Startup: ports: 554")
|
||||
})
|
||||
|
||||
t.Run("prints only build info when options empty", func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
reporter := ui.NewPlainReporter(out, false)
|
||||
|
||||
reporter.PrintStartup(ui.BuildInfo{Version: "", Commit: "none"}, nil)
|
||||
|
||||
content := out.String()
|
||||
assert.Contains(t, content, " [INFO] Startup: Running cameradar version dev, commit unknown")
|
||||
assert.Equal(t, 1, strings.Count(content, " Startup: "))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -20,7 +21,7 @@ type Reporter interface {
|
||||
}
|
||||
|
||||
// NewReporter creates a Reporter based on the requested mode.
|
||||
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool) (Reporter, error) {
|
||||
func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive bool, buildInfo BuildInfo, cancel context.CancelFunc) (Reporter, error) {
|
||||
if debug {
|
||||
return NewPlainReporter(out, debug), nil
|
||||
}
|
||||
@@ -32,10 +33,10 @@ func NewReporter(mode cameradar.Mode, debug bool, out io.Writer, interactive boo
|
||||
if !interactive {
|
||||
return nil, errors.New("tui mode requires an interactive terminal")
|
||||
}
|
||||
return NewTUIReporter(debug, out)
|
||||
return NewTUIReporter(debug, out, buildInfo, cancel)
|
||||
case cameradar.ModeAuto:
|
||||
if interactive {
|
||||
return NewTUIReporter(debug, out)
|
||||
return NewTUIReporter(debug, out, buildInfo, cancel)
|
||||
}
|
||||
return NewPlainReporter(out, debug), nil
|
||||
default:
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestNewReporter(t *testing.T) {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
out := &bytes.Buffer{}
|
||||
|
||||
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive)
|
||||
reporter, err := ui.NewReporter(test.mode, false, out, test.interactive, ui.BuildInfo{Version: "dev", Commit: "none"}, func() {})
|
||||
|
||||
if test.wantErrContains != "" {
|
||||
require.Error(t, err)
|
||||
|
||||
+12
-1
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/Ullaakut/cameradar/v6"
|
||||
@@ -16,6 +17,8 @@ type modelState struct {
|
||||
summary []summaryTable
|
||||
summaryStreams []cameradar.Stream
|
||||
summaryFinal bool
|
||||
buildInfo BuildInfo
|
||||
cancel context.CancelFunc
|
||||
debug bool
|
||||
spinner spinner.Model
|
||||
progress progress.Model
|
||||
@@ -45,6 +48,14 @@ func (m *modelState) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.handleProgressMsg(typed)
|
||||
case closeMsg:
|
||||
m.quitting = true
|
||||
case tea.KeyMsg:
|
||||
if typed.Type == tea.KeyCtrlC {
|
||||
if m.cancel != nil {
|
||||
m.cancel()
|
||||
}
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
case spinner.TickMsg:
|
||||
cmds = m.handleSpinnerMsg(typed)
|
||||
case tea.WindowSizeMsg:
|
||||
@@ -129,7 +140,7 @@ func (m *modelState) handleWindowSizeMsg(msg tea.WindowSizeMsg) {
|
||||
|
||||
func (m *modelState) View() string {
|
||||
var builder strings.Builder
|
||||
builder.WriteString(sectionStyle.Render("Steps"))
|
||||
builder.WriteString(sectionStyle.Render(m.buildInfo.TUIHeader()))
|
||||
builder.WriteString("\n")
|
||||
builder.WriteString(renderProgress(m))
|
||||
builder.WriteString("\n")
|
||||
|
||||
+4
-1
@@ -1,6 +1,7 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
@@ -72,7 +73,7 @@ type TUIReporter struct {
|
||||
}
|
||||
|
||||
// NewTUIReporter creates a new Bubble Tea reporter.
|
||||
func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
|
||||
func NewTUIReporter(debug bool, out io.Writer, buildInfo BuildInfo, cancel context.CancelFunc) (*TUIReporter, error) {
|
||||
spin := spinner.New()
|
||||
spin.Spinner = spinner.Dot
|
||||
spin.Style = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
|
||||
@@ -88,6 +89,8 @@ func NewTUIReporter(debug bool, out io.Writer) (*TUIReporter, error) {
|
||||
steps: cameradar.Steps(),
|
||||
status: make(map[cameradar.Step]state),
|
||||
debug: debug,
|
||||
buildInfo: buildInfo,
|
||||
cancel: cancel,
|
||||
spinner: spin,
|
||||
progress: prog,
|
||||
progressTotals: make(map[cameradar.Step]int),
|
||||
|
||||
Reference in New Issue
Block a user