From f29cfa04e84d39515ec8343dc65091052cb816cb Mon Sep 17 00:00:00 2001 From: Gary Gan Date: Thu, 2 Jul 2026 21:45:02 +0800 Subject: [PATCH] feat: add node search alerts and onboarding improvements --- .goreleaser.yaml | 8 + README.md | 17 +- cmd/ktui/main.go | 171 +++++++++++++++-- cmd/ktui/main_test.go | 83 ++++++++- cmd/ktui/secret_termios_bsd.go | 25 +++ cmd/ktui/secret_termios_linux.go | 25 +++ cmd/ktui/secret_unix.go | 58 ++++++ cmd/ktui/secret_windows.go | 89 +++++++++ install.sh | 55 ++++-- internal/config/config.go | 90 ++++++++- internal/config/config_test.go | 37 ++++ internal/tui/alerts.go | 63 +++++++ internal/tui/app.go | 66 ++++++- internal/tui/app_test.go | 4 +- internal/tui/charts.go | 23 ++- internal/tui/detail.go | 157 +++++++++++++--- internal/tui/detail_test.go | 88 +++++++++ internal/tui/frame.go | 116 ++++++++++-- internal/tui/keys.go | 198 ++++++++++++++++---- internal/tui/line.go | 27 ++- internal/tui/metrics.go | 143 +++++++++++++- internal/tui/mouse.go | 126 ++++++++++++- internal/tui/mouse_test.go | 113 ++++++++++- internal/tui/node_view.go | 311 +++++++++++++++++++++++++++++++ internal/tui/node_view_test.go | 217 +++++++++++++++++++++ internal/tui/panel.go | 20 +- internal/tui/render.go | 24 ++- internal/tui/settings.go | 69 +++++++ internal/tui/settings_test.go | 37 +++- internal/tui/sheet.go | 62 +++++- 30 files changed, 2336 insertions(+), 186 deletions(-) create mode 100644 cmd/ktui/secret_termios_bsd.go create mode 100644 cmd/ktui/secret_termios_linux.go create mode 100644 cmd/ktui/secret_unix.go create mode 100644 cmd/ktui/secret_windows.go create mode 100644 internal/tui/alerts.go create mode 100644 internal/tui/node_view.go create mode 100644 internal/tui/node_view_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 199d8ef..a8ed9a6 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -63,6 +63,14 @@ release: prerelease: auto mode: replace header: | + ## 本次更新重点 + + - 新增首次启动配置向导:未配置 URL 时可直接在终端输入 Komari 地址和可选 API key,并保存到配置文件。 + - 新增节点搜索、排序和过滤:支持 `/` 搜索节点名、地区、标签、分组、IP、OS 和 UUID,支持按状态、CPU、RAM、流量和过期时间排序,并可快速筛选离线、即将过期和高负载节点。 + - 新增异常高亮和阈值配置:离线、CPU/RAM/磁盘超过阈值、过期或即将过期会在列表和详情页高亮;阈值可通过配置文件、`ktui config set` 或设置页调整。 + - 改进详情页图表体验:支持点击图表或按 `f` 进入聚焦模式,并可在聚焦图表间切换。 + - 改进终端交互:搜索结果页支持逐级返回,过滤会跳过没有结果的条件,底部操作和鼠标点击行为更稳定。 + 本 Release 由 GoReleaser 自动生成。 请下载与你的系统和架构匹配的 ktui 压缩包,并使用 checksums.txt 校验 SHA256。 diff --git a/README.md b/README.md index 7957f0d..1d04d78 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ `ktui` 是一个用于查看 [Komari](https://github.com/komari-monitor/komari) 探针监控状态的终端 TUI 工具。它可以让你不用打开浏览器,直接在终端里查看服务器在线状态、资源占用、流量、Ping、节点详情和历史趋势。 -`ktui` 不内置默认 Komari 地址。首次使用前需要通过配置文件、环境变量或命令行参数设置你的 Komari 实例地址。 +`ktui` 不内置默认 Komari 地址。首次启动且未配置 URL 时,会在终端中引导输入 Komari 地址和可选 API key,并保存到配置文件。 ## 灵感来源 @@ -20,6 +20,7 @@ https://www.nodeseek.com/post-710243-1 - 详情页标签:`overview`、`node`、`history`、`ping`、`meta` - 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d` - History 坐标图:CPU、RAM、磁盘、网络、连接数、进程数 +- 详情页图表聚焦模式:点击图表或按 `f` 可用整页查看单张图 - Ping 数据:实时 Ping 和历史 Ping 汇总 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 - 支持配置文件,默认持久化到系统用户配置目录 @@ -131,7 +132,7 @@ ktui update ./ktui config init ``` -首次使用建议先设置 URL: +首次启动未配置 URL 时会自动进入引导。也可以提前手动设置 URL: ```sh ./ktui config set url https://komari.example.com @@ -175,6 +176,10 @@ KTUI_CONFIG=/path/to/config.json ./ktui "timeout": "10s", "realtime_points": 0, "chart_y_axis": "absolute", + "warn_cpu": 90, + "warn_ram": 85, + "warn_disk": 90, + "warn_expiry_days": 7, "mode": "sheet", "ascii": false, "no_color": false @@ -195,6 +200,8 @@ KTUI_API_KEY=your_api_key ./ktui KTUI_MODE=line ./ktui KTUI_REALTIME_POINTS=150 ./ktui KTUI_CHART_Y_AXIS=relative ./ktui +KTUI_WARN_CPU=85 KTUI_WARN_DISK=90 ./ktui +KTUI_WARN_EXPIRY_DAYS=14 ./ktui KTUI_ASCII=1 NO_COLOR=1 ./ktui ``` @@ -250,6 +257,10 @@ EXP - - `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片 - 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项 - 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回 +- `/`:编辑节点搜索,匹配名称、地区、标签、分组、IP、OS 和 UUID;`Enter` 应用,`Esc` 取消 +- `c`:循环排序:默认、在线状态、CPU、RAM、累计流量、过期时间 +- `v`:循环过滤:全部、离线、即将过期、高负载 +- 详情页图表:点击图表或按 `f` 聚焦,`h` / `l` 或 `PgUp` / `PgDn` 切换聚焦图表,`Esc` / `b` / `q` / `Enter` 返回详情页 - `PgUp` / `PgDn`:快速滚动列表或详情页 - `Enter` / `o`:打开选中节点的详情页 - `Esc` / `b` / `q`:从详情页返回列表页 @@ -262,6 +273,8 @@ EXP - - `u`:有新版本时显示更新命令 - `q` / `Ctrl-C`:在列表页退出 +异常高亮阈值可以通过配置文件、`ktui config set warn-cpu 85`、`ktui config set warn-ram 85`、`ktui config set warn-disk 90`、`ktui config set warn-expiry-days 14` 或设置页调整。 + ## 版本信息 查看当前二进制版本: diff --git a/cmd/ktui/main.go b/cmd/ktui/main.go index 39d4448..e887437 100644 --- a/cmd/ktui/main.go +++ b/cmd/ktui/main.go @@ -1,10 +1,12 @@ package main import ( + "bufio" "context" "encoding/json" "flag" "fmt" + "io" "os" "strconv" "strings" @@ -130,7 +132,16 @@ func main() { mode = tui.ModeSheet } if strings.TrimSpace(baseURL) == "" { - fatal(fmt.Errorf("Komari URL is not set. Run `ktui config set url https://your-komari.example.com` or pass `--url https://your-komari.example.com`")) + if !isInteractiveTerminal(os.Stdin) { + fatal(fmt.Errorf("Komari URL is not set. Run `ktui config set url https://your-komari.example.com` or pass `--url https://your-komari.example.com`")) + } + cfg.APIKey = apiKey + next, err := firstRunSetup(cfg, os.Stdin, os.Stdout) + if err != nil { + fatal(err) + } + baseURL = next.URL + apiKey = next.APIKey } client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout}) @@ -157,6 +168,10 @@ func main() { DetailTimeout: timeout, RealtimePoints: realtimePoints, ChartYAxisMode: chartYAxis, + WarnCPU: cfg.WarnCPU, + WarnRAM: cfg.WarnRAM, + WarnDisk: cfg.WarnDisk, + WarnExpiryDays: cfg.WarnExpiryDays, SaveSettings: saveTUISettings, CheckUpdate: checkSoftwareUpdate, ASCII: ascii, @@ -192,6 +207,34 @@ func applyEnv(cfg config.Config) config.Config { if value := os.Getenv("KTUI_CHART_Y_AXIS"); value != "" { cfg.ChartYAxis = strings.ToLower(strings.TrimSpace(value)) } + if value := os.Getenv("KTUI_WARN_CPU"); value != "" { + if parsed, err := strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(value), "%"), 64); err == nil { + cfg.WarnCPU = parsed + } else { + cfg.WarnCPU = -1 + } + } + if value := os.Getenv("KTUI_WARN_RAM"); value != "" { + if parsed, err := strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(value), "%"), 64); err == nil { + cfg.WarnRAM = parsed + } else { + cfg.WarnRAM = -1 + } + } + if value := os.Getenv("KTUI_WARN_DISK"); value != "" { + if parsed, err := strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(value), "%"), 64); err == nil { + cfg.WarnDisk = parsed + } else { + cfg.WarnDisk = -1 + } + } + if value := os.Getenv("KTUI_WARN_EXPIRY_DAYS"); value != "" { + if parsed, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + cfg.WarnExpiryDays = parsed + } else { + cfg.WarnExpiryDays = -1 + } + } if value := os.Getenv("KTUI_MODE"); value != "" { cfg.Mode = value } @@ -216,10 +259,81 @@ func saveTUISettings(settings tui.PersistentSettings) error { cfg.ChartYAxis = settings.ChartYAxisMode cfg.ASCII = settings.ASCII cfg.NoColor = settings.NoColor + cfg.WarnCPU = settings.WarnCPU + cfg.WarnRAM = settings.WarnRAM + cfg.WarnDisk = settings.WarnDisk + cfg.WarnExpiryDays = settings.WarnExpiryDays _, err = config.Save(cfg) return err } +func firstRunSetup(cfg config.Config, input io.Reader, output io.Writer) (config.Config, error) { + reader := bufio.NewReader(input) + fmt.Fprintln(output, "ktui first run setup") + fmt.Fprintln(output, "Komari URL is required before opening the TUI.") + baseURL, err := promptLine(reader, output, "Komari URL: ") + if err != nil { + return cfg, err + } + baseURL = strings.TrimSpace(baseURL) + if baseURL == "" { + return cfg, fmt.Errorf("Komari URL is required") + } + client, err := komari.NewClientWithOptions(baseURL, komari.Options{}) + if err != nil { + return cfg, err + } + apiKey, err := promptSecretLine(reader, input, output, "API key (optional): ") + if err != nil && err != io.EOF { + return cfg, err + } + cfg.URL = client.BaseURL() + if strings.TrimSpace(apiKey) != "" { + cfg.APIKey = strings.TrimSpace(apiKey) + } + cfg = cfg.WithDefaults() + if err := cfg.Validate(); err != nil { + return cfg, err + } + path, err := config.Save(cfg) + if err != nil { + return cfg, err + } + fmt.Fprintf(output, "Saved config: %s\n", path) + return cfg, nil +} + +func promptLine(reader *bufio.Reader, output io.Writer, prompt string) (string, error) { + fmt.Fprint(output, prompt) + value, err := reader.ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + if err == io.EOF && value == "" { + return "", io.EOF + } + return strings.TrimRight(value, "\r\n"), nil +} + +func promptSecretLine(reader *bufio.Reader, input io.Reader, output io.Writer, prompt string) (string, error) { + file, ok := input.(*os.File) + if !ok || reader.Buffered() > 0 || !isInteractiveTerminal(file) { + return promptLine(reader, output, prompt) + } + fmt.Fprint(output, prompt) + value, err := readSecretFromTerminal(file) + fmt.Fprintln(output) + return value, err +} + +func isInteractiveTerminal(file *os.File) bool { + info, err := file.Stat() + if err != nil { + return false + } + return info.Mode()&os.ModeCharDevice != 0 +} + func checkSoftwareUpdate(ctx context.Context) (tui.UpdateCheckResult, error) { result, err := update.Check(ctx, update.Options{ CurrentVersion: version, @@ -454,9 +568,14 @@ Keys: timeout HTTP timeout, for example 10s realtime-points realtime chart sample limit, 0 auto - chart-y-axis - percent chart Y axis mode: absolute or relative - mode sheet or line + chart-y-axis + percent chart Y axis mode: absolute or relative + warn-cpu CPU warning threshold percent, for example 90 + warn-ram RAM warning threshold percent, for example 85 + warn-disk Disk warning threshold percent, for example 90 + warn-expiry-days + expiry warning window in days + mode sheet or line ascii true or false no-color true or false @@ -469,13 +588,16 @@ func printKeysHelp() { fmt.Print(`ktui keys List layer: - Up/k, Down/j select server - Mouse wheel select previous/next server - Mouse click open server detail - Footer click settings/mode/refresh/ascii/quit - PgUp, PgDn jump faster - Enter/o open selected server detail - s open settings + Up/k, Down/j select server + Mouse wheel select previous/next server + Mouse click open server detail + Footer click search/sort/filter/settings/mode/refresh/ascii/quit + PgUp, PgDn jump faster + / edit node search + c cycle sort: default/status/cpu/ram/traffic/expiry + v cycle filter: all/offline/expiring/high-load + Enter/o open selected server detail + s open settings m switch line/sheet mode r refresh now d open or reload selected server detail data @@ -486,6 +608,8 @@ List layer: Detail layer: Esc, b, q return to list layer Mouse click Back return to list layer + f, Enter focus first chart on chart tabs + Mouse click chart focus clicked chart h/l, 1-5, Tab switch detail tabs [, ] switch time window Up/k, Down/j scroll one card @@ -496,14 +620,25 @@ Detail layer: u show update command when an update is available PgUp, PgDn scroll faster +Chart focus: + Esc, b, q, Enter return to detail layer + h/l, PgUp/PgDn switch focused chart + [, ] switch time window + Settings layer: - Esc, q, s return to previous layer - Up/k, Down/j select setting - Mouse wheel/click select setting - Footer click back/adjust/toggle - Left/h, Right/l adjust value - Enter toggle or advance value -`) + Esc, q, s return to previous layer + Up/k, Down/j select setting + Mouse wheel/click select setting + Footer click back/adjust/toggle + Left/h, Right/l adjust value + Enter toggle or advance value + +Search: + Type text match node name, region, tags, group, IP, OS, UUID + Backspace delete one character + Enter apply search + Esc cancel editing + `) } func envBool(key string) bool { diff --git a/cmd/ktui/main_test.go b/cmd/ktui/main_test.go index 45d6c39..6cb635c 100644 --- a/cmd/ktui/main_test.go +++ b/cmd/ktui/main_test.go @@ -1,6 +1,13 @@ package main -import "testing" +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "ktui/internal/config" +) func TestSplitConfigArgSeparateValue(t *testing.T) { args, path := splitConfigArg([]string{"--config", "/tmp/ktui.json", "config", "show"}) @@ -45,3 +52,77 @@ func TestLooksLikeCommand(t *testing.T) { t.Fatal("empty string should not look like a command") } } + +func TestFirstRunSetupSavesURLAndAPIKey(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + t.Setenv("KTUI_CONFIG", path) + var out bytes.Buffer + + cfg, err := firstRunSetup(applyEnvConfigForTest(), strings.NewReader("https://komari.example.com\nsecret\n"), &out) + if err != nil { + t.Fatal(err) + } + if cfg.URL != "https://komari.example.com" || cfg.APIKey != "secret" { + t.Fatalf("cfg = %+v", cfg) + } + if !strings.Contains(out.String(), "Saved config") { + t.Fatalf("output = %q, want saved config message", out.String()) + } +} + +func TestFirstRunSetupPreservesExistingAPIKeyWhenBlank(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + t.Setenv("KTUI_CONFIG", path) + cfg := config.Default() + cfg.APIKey = "existing" + + got, err := firstRunSetup(cfg, strings.NewReader("https://komari.example.com\n\n"), &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if got.APIKey != "existing" { + t.Fatalf("APIKey = %q, want existing", got.APIKey) + } +} + +func TestFirstRunSetupAllowsMissingOptionalAPIKeyAtEOF(t *testing.T) { + t.Setenv("KTUI_CONFIG", filepath.Join(t.TempDir(), "config.json")) + got, err := firstRunSetup(config.Default(), strings.NewReader("https://komari.example.com\n"), &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if got.URL != "https://komari.example.com" || got.APIKey != "" { + t.Fatalf("cfg = %+v", got) + } +} + +func TestFirstRunSetupNormalizesURL(t *testing.T) { + t.Setenv("KTUI_CONFIG", filepath.Join(t.TempDir(), "config.json")) + got, err := firstRunSetup(config.Default(), strings.NewReader("komari.example.com\n"), &bytes.Buffer{}) + if err != nil { + t.Fatal(err) + } + if got.URL != "https://komari.example.com" { + t.Fatalf("URL = %q, want normalized https URL", got.URL) + } +} + +func TestFirstRunSetupRequiresURL(t *testing.T) { + t.Setenv("KTUI_CONFIG", filepath.Join(t.TempDir(), "config.json")) + _, err := firstRunSetup(applyEnvConfigForTest(), strings.NewReader("\n"), &bytes.Buffer{}) + if err == nil { + t.Fatal("expected missing URL to fail") + } +} + +func TestFirstRunSetupRejectsUnsupportedURLScheme(t *testing.T) { + t.Setenv("KTUI_CONFIG", filepath.Join(t.TempDir(), "config.json")) + _, err := firstRunSetup(config.Default(), strings.NewReader("ftp://komari.example.com\n"), &bytes.Buffer{}) + if err == nil { + t.Fatal("expected unsupported scheme to fail") + } +} + +func applyEnvConfigForTest() config.Config { + return config.Default() +} diff --git a/cmd/ktui/secret_termios_bsd.go b/cmd/ktui/secret_termios_bsd.go new file mode 100644 index 0000000..24bd9f7 --- /dev/null +++ b/cmd/ktui/secret_termios_bsd.go @@ -0,0 +1,25 @@ +//go:build darwin || freebsd || openbsd || netbsd || dragonfly + +package main + +import ( + "syscall" + "unsafe" +) + +func getTerminalState(fd int) (syscall.Termios, error) { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCGETA), uintptr(unsafe.Pointer(&termios))) + if errno != 0 { + return termios, errno + } + return termios, nil +} + +func setTerminalState(fd int, termios syscall.Termios) error { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TIOCSETA), uintptr(unsafe.Pointer(&termios))) + if errno != 0 { + return errno + } + return nil +} diff --git a/cmd/ktui/secret_termios_linux.go b/cmd/ktui/secret_termios_linux.go new file mode 100644 index 0000000..0705318 --- /dev/null +++ b/cmd/ktui/secret_termios_linux.go @@ -0,0 +1,25 @@ +//go:build linux + +package main + +import ( + "syscall" + "unsafe" +) + +func getTerminalState(fd int) (syscall.Termios, error) { + var termios syscall.Termios + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TCGETS), uintptr(unsafe.Pointer(&termios))) + if errno != 0 { + return termios, errno + } + return termios, nil +} + +func setTerminalState(fd int, termios syscall.Termios) error { + _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), uintptr(syscall.TCSETS), uintptr(unsafe.Pointer(&termios))) + if errno != 0 { + return errno + } + return nil +} diff --git a/cmd/ktui/secret_unix.go b/cmd/ktui/secret_unix.go new file mode 100644 index 0000000..adebfe2 --- /dev/null +++ b/cmd/ktui/secret_unix.go @@ -0,0 +1,58 @@ +//go:build linux || darwin || freebsd || openbsd || netbsd || dragonfly + +package main + +import ( + "bufio" + "io" + "os" + "os/signal" + "strings" + "syscall" +) + +func readSecretFromTerminal(file *os.File) (string, error) { + fd := int(file.Fd()) + oldState, err := getTerminalState(fd) + if err != nil { + return "", err + } + secretState := oldState + secretState.Lflag &^= syscall.ECHO + if err := setTerminalState(fd, secretState); err != nil { + return "", err + } + stopSignals := installSecretSignalRestore(fd, oldState) + defer stopSignals() + defer func() { + _ = setTerminalState(fd, oldState) + }() + + value, err := bufio.NewReader(file).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + if err == io.EOF && value == "" { + return "", io.EOF + } + return strings.TrimRight(value, "\r\n"), nil +} + +func installSecretSignalRestore(fd int, state syscall.Termios) func() { + sigCh := make(chan os.Signal, 2) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + done := make(chan struct{}) + go func() { + select { + case sig := <-sigCh: + _ = setTerminalState(fd, state) + signal.Reset(os.Interrupt, syscall.SIGTERM) + _ = syscall.Kill(syscall.Getpid(), sig.(syscall.Signal)) + case <-done: + } + }() + return func() { + close(done) + signal.Stop(sigCh) + } +} diff --git a/cmd/ktui/secret_windows.go b/cmd/ktui/secret_windows.go new file mode 100644 index 0000000..16d8c52 --- /dev/null +++ b/cmd/ktui/secret_windows.go @@ -0,0 +1,89 @@ +//go:build windows + +package main + +import ( + "bufio" + "io" + "os" + "os/signal" + "strings" + "syscall" + "unsafe" +) + +const secretEnableEchoInput = 0x0004 + +var ( + secretKernel32 = syscall.NewLazyDLL("kernel32.dll") + secretProcGetConsoleMode = secretKernel32.NewProc("GetConsoleMode") + secretProcSetConsoleMode = secretKernel32.NewProc("SetConsoleMode") +) + +func readSecretFromTerminal(file *os.File) (string, error) { + handle := syscall.Handle(file.Fd()) + oldMode, err := secretGetConsoleMode(handle) + if err != nil { + return "", err + } + secretMode := oldMode &^ secretEnableEchoInput + if err := secretSetConsoleMode(handle, secretMode); err != nil { + return "", err + } + stopSignals := installSecretSignalRestore(handle, oldMode) + defer stopSignals() + defer func() { + _ = secretSetConsoleMode(handle, oldMode) + }() + + value, err := bufio.NewReader(file).ReadString('\n') + if err != nil && err != io.EOF { + return "", err + } + if err == io.EOF && value == "" { + return "", io.EOF + } + return strings.TrimRight(value, "\r\n"), nil +} + +func installSecretSignalRestore(handle syscall.Handle, mode uint32) func() { + sigCh := make(chan os.Signal, 2) + signal.Notify(sigCh, os.Interrupt) + done := make(chan struct{}) + go func() { + select { + case <-sigCh: + _ = secretSetConsoleMode(handle, mode) + signal.Reset(os.Interrupt) + os.Exit(130) + case <-done: + } + }() + return func() { + close(done) + signal.Stop(sigCh) + } +} + +func secretGetConsoleMode(handle syscall.Handle) (uint32, error) { + var mode uint32 + r1, _, err := secretProcGetConsoleMode.Call(uintptr(handle), uintptr(unsafe.Pointer(&mode))) + if r1 == 0 { + if err != syscall.Errno(0) { + return 0, err + } + return 0, syscall.EINVAL + } + return mode, nil +} + +func secretSetConsoleMode(handle syscall.Handle, mode uint32) error { + r1, _, err := secretProcSetConsoleMode.Call(uintptr(handle), uintptr(mode)) + if r1 == 0 { + if err != syscall.Errno(0) { + return err + } + return syscall.EINVAL + } + return nil +} diff --git a/install.sh b/install.sh index 735f079..e73138e 100755 --- a/install.sh +++ b/install.sh @@ -42,16 +42,48 @@ detect_arch() { } json_value() { - printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 + if command -v jq >/dev/null 2>&1; then + printf '%s' "$1" | jq -r --arg key "$2" '.[$key] // empty' | head -n 1 + else + printf '%s' "$1" | sed -n "s/.*\"$2\"[[:space:]]*:[[:space:]]*\"\([^\"]*\)\".*/\1/p" | head -n 1 + fi +} + +release_asset_name() { + suffix="_${os}_${arch}.tar.gz" + if command -v jq >/dev/null 2>&1; then + printf '%s' "$release_json" | + jq -r --arg suffix "$suffix" ' + .assets[]? | + select((.name // "") | startswith("ktui_") and endswith($suffix)) | + .name + ' | + head -n 1 + else + printf '%s' "$release_json" | + tr '{' '\n' | + sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"\(ktui_[^\"]*_${os}_${arch}\.tar\.gz\)\".*/\1/p" | + head -n 1 + fi } asset_url() { name="$1" - printf '%s' "$release_json" | - tr '{' '\n' | - grep "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" | - sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | - head -n 1 + if command -v jq >/dev/null 2>&1; then + printf '%s' "$release_json" | + jq -r --arg name "$name" ' + .assets[]? | + select(.name == $name) | + .browser_download_url // empty + ' | + head -n 1 + else + printf '%s' "$release_json" | + tr '{' '\n' | + grep "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" | + sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | + head -n 1 + fi } checksum_for() { @@ -85,8 +117,10 @@ verify_sha256() { need curl need tar need awk -need sed -need grep +if ! command -v jq >/dev/null 2>&1; then + need sed + need grep +fi os="$(detect_os)" arch="$(detect_arch)" @@ -102,10 +136,7 @@ release_json="$(http_get "$endpoint")" tag="$(json_value "$release_json" tag_name)" [ -n "$tag" ] || fail "release response does not include tag_name" -asset_name="$(printf '%s' "$release_json" | - tr '{' '\n' | - sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"\(ktui_[^\"]*_${os}_${arch}\.tar\.gz\)\".*/\1/p" | - head -n 1)" +asset_name="$(release_asset_name)" [ -n "$asset_name" ] || fail "release $tag does not contain an asset for $os/$arch" archive_url="$(asset_url "$asset_name")" diff --git a/internal/config/config.go b/internal/config/config.go index 3c2ca9e..a88661d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -22,18 +22,26 @@ type Config struct { Timeout string `json:"timeout"` Mode string `json:"mode"` // RealtimePoints limits realtime chart samples. 0 keeps the auto strategy. - RealtimePoints int `json:"realtime_points,omitempty"` - ASCII bool `json:"ascii"` - NoColor bool `json:"no_color"` - ChartYAxis string `json:"chart_y_axis"` + RealtimePoints int `json:"realtime_points,omitempty"` + ASCII bool `json:"ascii"` + NoColor bool `json:"no_color"` + ChartYAxis string `json:"chart_y_axis"` + WarnCPU float64 `json:"warn_cpu"` + WarnRAM float64 `json:"warn_ram"` + WarnDisk float64 `json:"warn_disk"` + WarnExpiryDays int `json:"warn_expiry_days"` } func Default() Config { return Config{ - Interval: "5s", - Timeout: "10s", - Mode: "sheet", - ChartYAxis: "absolute", + Interval: "5s", + Timeout: "10s", + Mode: "sheet", + ChartYAxis: "absolute", + WarnCPU: 90, + WarnRAM: 85, + WarnDisk: 90, + WarnExpiryDays: 7, } } @@ -108,6 +116,18 @@ func (c Config) WithDefaults() Config { if strings.TrimSpace(c.ChartYAxis) == "" { c.ChartYAxis = defaults.ChartYAxis } + if c.WarnCPU == 0 { + c.WarnCPU = defaults.WarnCPU + } + if c.WarnRAM == 0 { + c.WarnRAM = defaults.WarnRAM + } + if c.WarnDisk == 0 { + c.WarnDisk = defaults.WarnDisk + } + if c.WarnExpiryDays == 0 { + c.WarnExpiryDays = defaults.WarnExpiryDays + } return c } @@ -131,6 +151,18 @@ func (c Config) Validate() error { default: return fmt.Errorf("invalid config chart_y_axis %q: use absolute or relative", c.ChartYAxis) } + if err := validatePercent("warn_cpu", c.WarnCPU); err != nil { + return err + } + if err := validatePercent("warn_ram", c.WarnRAM); err != nil { + return err + } + if err := validatePercent("warn_disk", c.WarnDisk); err != nil { + return err + } + if c.WarnExpiryDays <= 0 { + return fmt.Errorf("invalid config warn_expiry_days %d: use a positive number", c.WarnExpiryDays) + } return nil } @@ -178,6 +210,30 @@ func Set(cfg Config, key string, value string) (Config, error) { default: return cfg, fmt.Errorf("invalid chart_y_axis %q", value) } + case "warn_cpu", "warn-cpu": + parsed, err := parsePercent(value) + if err != nil { + return cfg, fmt.Errorf("invalid warn_cpu %q", value) + } + cfg.WarnCPU = parsed + case "warn_ram", "warn-ram": + parsed, err := parsePercent(value) + if err != nil { + return cfg, fmt.Errorf("invalid warn_ram %q", value) + } + cfg.WarnRAM = parsed + case "warn_disk", "warn-disk": + parsed, err := parsePercent(value) + if err != nil { + return cfg, fmt.Errorf("invalid warn_disk %q", value) + } + cfg.WarnDisk = parsed + case "warn_expiry_days", "warn-expiry-days": + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return cfg, fmt.Errorf("invalid warn_expiry_days %q", value) + } + cfg.WarnExpiryDays = parsed case "ascii": parsed, err := parseBool(value) if err != nil { @@ -197,6 +253,24 @@ func Set(cfg Config, key string, value string) (Config, error) { return cfg, cfg.Validate() } +func parsePercent(value string) (float64, error) { + parsed, err := strconv.ParseFloat(strings.TrimSuffix(strings.TrimSpace(value), "%"), 64) + if err != nil { + return 0, err + } + return parsed, validatePercent("", parsed) +} + +func validatePercent(name string, value float64) error { + if value <= 0 || value > 100 { + if name == "" { + name = "percent" + } + return fmt.Errorf("invalid config %s %.1f: use a value from 1 to 100", name, value) + } + return nil +} + func parseBool(value string) (bool, error) { parsed, err := strconv.ParseBool(value) if err == nil { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 16f9d84..abb3290 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -72,6 +72,35 @@ func TestSetChartYAxis(t *testing.T) { } } +func TestDefaultWarningThresholds(t *testing.T) { + cfg := Default() + if cfg.WarnCPU != 90 || cfg.WarnRAM != 85 || cfg.WarnDisk != 90 || cfg.WarnExpiryDays != 7 { + t.Fatalf("warning defaults = cpu %.1f ram %.1f disk %.1f expiry %d", cfg.WarnCPU, cfg.WarnRAM, cfg.WarnDisk, cfg.WarnExpiryDays) + } +} + +func TestSetWarningThresholds(t *testing.T) { + cfg, err := Set(Default(), "warn-cpu", "85") + if err != nil { + t.Fatal(err) + } + cfg, err = Set(cfg, "warn-ram", "80%") + if err != nil { + t.Fatal(err) + } + cfg, err = Set(cfg, "warn-disk", "95") + if err != nil { + t.Fatal(err) + } + cfg, err = Set(cfg, "warn-expiry-days", "14") + if err != nil { + t.Fatal(err) + } + if cfg.WarnCPU != 85 || cfg.WarnRAM != 80 || cfg.WarnDisk != 95 || cfg.WarnExpiryDays != 14 { + t.Fatalf("warning thresholds = %+v", cfg) + } +} + func TestValidateRejectsNegativeRealtimePoints(t *testing.T) { cfg := Default() cfg.RealtimePoints = -1 @@ -80,6 +109,14 @@ func TestValidateRejectsNegativeRealtimePoints(t *testing.T) { } } +func TestValidateRejectsInvalidWarningThreshold(t *testing.T) { + cfg := Default() + cfg.WarnCPU = 101 + if err := cfg.Validate(); err == nil { + t.Fatal("expected invalid warn_cpu to be rejected") + } +} + func TestValidateRejectsInvalidChartYAxis(t *testing.T) { cfg := Default() cfg.ChartYAxis = "fixed" diff --git a/internal/tui/alerts.go b/internal/tui/alerts.go new file mode 100644 index 0000000..2c77856 --- /dev/null +++ b/internal/tui/alerts.go @@ -0,0 +1,63 @@ +package tui + +import ( + "strings" + "time" + + "ktui/internal/komari" +) + +type nodeAlert struct { + Critical bool + Warning bool + Reasons []string +} + +func (a *App) alertForNode(node komari.Node, st komari.Status, now time.Time) nodeAlert { + var alert nodeAlert + if !st.Online { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "offline") + } + if st.CPU >= a.warnCPU { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "cpu") + } + if percent(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)) >= a.warnRAM { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "ram") + } + if percent(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)) >= a.warnDisk { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "disk") + } + if node.ExpiredAt.Valid && node.Price >= 0 { + until := node.ExpiredAt.Time.Sub(now) + if until < 0 { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "expired") + } else if until <= time.Duration(a.warnExpiryDays)*24*time.Hour { + alert.Warning = true + alert.Reasons = append(alert.Reasons, "expires") + } + } + return alert +} + +func (a *App) styleAlertLine(line string, alert nodeAlert) string { + switch { + case alert.Critical: + return a.style.red(line) + case alert.Warning: + return a.style.yellow(line) + default: + return line + } +} + +func alertText(alert nodeAlert) string { + if len(alert.Reasons) == 0 { + return "ok" + } + return strings.Join(alert.Reasons, " ") +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 06111cd..f20f394 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -25,6 +25,8 @@ type App struct { tab int window int detail bool + chartFocus bool + chartFocusIndex int settings bool cardStep int settingsWasDetail bool @@ -33,6 +35,15 @@ type App struct { settingsURL string settingsAPIKey string chartYAxisMode chartYAxisMode + warnCPU float64 + warnRAM float64 + warnDisk float64 + warnExpiryDays int + searchEditing bool + searchQuery string + searchDraft string + nodeFilter nodeFilterMode + nodeSort nodeSortMode notice string snapshot komari.Snapshot @@ -69,6 +80,10 @@ type Options struct { DetailCacheTTL time.Duration RealtimePoints int ChartYAxisMode string + WarnCPU float64 + WarnRAM float64 + WarnDisk float64 + WarnExpiryDays int SaveSettings func(PersistentSettings) error CheckUpdate func(context.Context) (UpdateCheckResult, error) ASCII bool @@ -91,6 +106,10 @@ type PersistentSettings struct { ChartYAxisMode string ASCII bool NoColor bool + WarnCPU float64 + WarnRAM float64 + WarnDisk float64 + WarnExpiryDays int } type Mode string @@ -107,6 +126,26 @@ const ( chartYAxisRelative chartYAxisMode = "relative" ) +type nodeFilterMode string + +const ( + nodeFilterAll nodeFilterMode = "all" + nodeFilterOffline nodeFilterMode = "offline" + nodeFilterExpiring nodeFilterMode = "expiring" + nodeFilterHighLoad nodeFilterMode = "high-load" +) + +type nodeSortMode string + +const ( + nodeSortDefault nodeSortMode = "default" + nodeSortStatus nodeSortMode = "status" + nodeSortCPU nodeSortMode = "cpu" + nodeSortRAM nodeSortMode = "ram" + nodeSortTraffic nodeSortMode = "traffic" + nodeSortExpiry nodeSortMode = "expiry" +) + type nodeDetail struct { UUID string Window int @@ -150,6 +189,7 @@ type detailKey struct { type keyEvent struct { name string + text string x int y int } @@ -230,6 +270,18 @@ func NewWithOptions(client *komari.Client, opts Options) *App { if opts.Mode == "" { opts.Mode = ModeSheet } + if opts.WarnCPU <= 0 { + opts.WarnCPU = 90 + } + if opts.WarnRAM <= 0 { + opts.WarnRAM = 85 + } + if opts.WarnDisk <= 0 { + opts.WarnDisk = 90 + } + if opts.WarnExpiryDays <= 0 { + opts.WarnExpiryDays = 7 + } return &App{ client: client, refreshInterval: opts.RefreshInterval, @@ -242,6 +294,12 @@ func NewWithOptions(client *komari.Client, opts Options) *App { settingsAPIKey: opts.APIKey, style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor}, mode: opts.Mode, + warnCPU: opts.WarnCPU, + warnRAM: opts.WarnRAM, + warnDisk: opts.WarnDisk, + warnExpiryDays: opts.WarnExpiryDays, + nodeFilter: nodeFilterAll, + nodeSort: nodeSortDefault, renderCh: make(chan struct{}, 1), refreshCh: make(chan struct{}, 2), resultCh: make(chan fetchResult, 2), @@ -296,6 +354,7 @@ func (a *App) Run(ctx context.Context) error { a.fetch(ctx) case result := <-a.resultCh: pending := a.refreshPending + selectedUUID := a.selectedNodeUUID() a.refreshPending = false a.loading = false a.fetching = false @@ -306,7 +365,7 @@ func (a *App) Run(ctx context.Context) error { a.lastFullFetch = result.snapshot.FetchedAt } a.recordRealtimeSnapshot(result.snapshot, result.snapshot.FetchedAt) - a.clampSelection() + a.restoreSelection(selectedUUID) if a.detail { a.ensureSelectedDetail(ctx) } @@ -464,10 +523,11 @@ func (a *App) fetchDetail(ctx context.Context, uuid string, force bool) { } func (a *App) ensureSelectedDetail(ctx context.Context) { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { return } - a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, false) + a.fetchDetail(ctx, node.UUID, false) } func (a *App) requestRefresh() { diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index e4bb840..2f5e3bb 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -100,9 +100,9 @@ func TestUpdateFooterClickShowsHint(t *testing.T) { app.update.Available = true app.update.Latest = "v0.2.0" _, height := terminalSize() - x, _, ok := footerLabelBounds(app.footerText(), "u update") + x, ok := footerActionPosition(app, footerUpdate) if !ok { - t.Fatal("missing u update footer label") + t.Fatal("missing update footer action") } app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) diff --git a/internal/tui/charts.go b/internal/tui/charts.go index 32916df..569fc0c 100644 --- a/internal/tui/charts.go +++ b/internal/tui/charts.go @@ -70,6 +70,14 @@ func (a *App) percentChart(section detailSection) detailSection { } func (a *App) axisChartLines(chart axisChart, width int, height int) []string { + return a.axisChartLinesWithOptions(chart, width, height, false) +} + +func (a *App) axisChartLinesDetailed(chart axisChart, width int, height int) []string { + return a.axisChartLinesWithOptions(chart, width, height, true) +} + +func (a *App) axisChartLinesWithOptions(chart axisChart, width int, height int, detailedXAxis bool) []string { if height <= 0 { return nil } @@ -85,7 +93,11 @@ func (a *App) axisChartLines(chart axisChart, width int, height int) []string { break } } - lines = append(lines, fmt.Sprintf(" %s -> %s", chart.From, chart.To)) + if detailedXAxis { + lines = append(lines, fitLine(" "+chartDetailedXAxisLabel(chart), width)) + } else { + lines = append(lines, fmt.Sprintf(" %s -> %s", chart.From, chart.To)) + } return limitRawLines(lines, height) } @@ -100,7 +112,11 @@ func (a *App) axisChartLines(chart axisChart, width int, height int) []string { for _, item := range series { seriesPoints = append(seriesPoints, chartPointsForValues(chart, item.Values, plotWidth)) } - plotRows := max(1, height-1) + axisRows := 1 + if detailedXAxis { + axisRows = 2 + } + plotRows := max(1, height-axisRows) rows := chartYAxisRows(minVal, maxVal, plotRows, chart.Unit, labelWidth) out := make([]string, 0, height) @@ -143,8 +159,7 @@ func (a *App) axisChartLines(chart axisChart, width int, height int) []string { out = append(out, fitLine(b.String(), width)) } - axis := chartXAxisLine(chart.From, chart.To, width, labelWidth, a.style.ASCII) - out = append(out, axis) + out = append(out, chartXAxisLines(chart, width, labelWidth, a.style.ASCII, detailedXAxis)...) return limitRawLines(out, height) } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index 4fa955b..b2ba5ad 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -3,6 +3,7 @@ package tui import ( "fmt" "strings" + "time" "ktui/internal/komari" ) @@ -11,7 +12,8 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string { if bodyHeight <= 0 { return nil } - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { if a.loading { return fillBody([]string{"Loading nodes..."}, width, bodyHeight) } @@ -19,8 +21,10 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string { } a.clampSelection() - node := a.snapshot.Nodes[a.selected] st := a.snapshot.Status[node.UUID] + if a.chartFocus { + return a.renderFocusedChartBody(node, st, width, bodyHeight) + } chrome := a.detailChromeLines(node, st, width) contentHeight := bodyHeight - len(chrome) if contentHeight < 1 { @@ -56,8 +60,30 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string { return fillBody(lines, width, bodyHeight) } +func (a *App) renderFocusedChartBody(node komari.Node, st komari.Status, width int, bodyHeight int) []string { + sections := a.chartSections(node, st) + if len(sections) == 0 { + a.chartFocus = false + return a.renderDetailBody(width, bodyHeight) + } + a.clampChartFocus(len(sections)) + section := sections[a.chartFocusIndex] + title := fmt.Sprintf(" %s %s %s", section.Title, a.nodeLabel(node), detailWindows[a.window].Label) + lines := []string{ + a.style.bold(fitLine(title, width)), + a.style.dim(fitLine(" Esc/b/q back h/l previous/next chart [ ] window r refresh", width)), + } + chartHeight := bodyHeight - len(lines) + if chartHeight < 1 { + chartHeight = 1 + } + lines = append(lines, a.axisChartLinesDetailed(*section.Chart, width, chartHeight)...) + return fillBody(lines, width, bodyHeight) +} + func (a *App) detailChromeLines(node komari.Node, st komari.Status, width int) []string { lines := make([]string, 0, 6) + alert := a.alertForNode(node, st, time.Now()) title := fmt.Sprintf(" %s %s %s region %s seen %s", a.text(node.Name), a.statusPill(st.Online), @@ -66,6 +92,9 @@ func (a *App) detailChromeLines(node komari.Node, st komari.Status, width int) [ shortTimeFromNull(st.Time), ) lines = append(lines, a.style.bold(fitLine(title, width))) + if len(alert.Reasons) > 0 { + lines = append(lines, a.styleAlertLine(fitLine(" WARN "+alertText(alert), width), alert)) + } lines = append(lines, a.detailMetricStrip(node, st, width)) lines = append(lines, a.detailTabLine(width)) lines = append(lines, a.detailWindowLine(width)) @@ -82,9 +111,9 @@ func (a *App) detailMetricStrip(node komari.Node, st komari.Status, width int) s barWidth = 8 } parts := []string{ - fmt.Sprintf(" CPU %5.1f%% %s", st.CPU, a.usageBar(st.CPU, barWidth)), - fmt.Sprintf(" RAM %5.1f%% %s", ramPct, a.usageBar(ramPct, barWidth)), - fmt.Sprintf(" DSK %5.1f%% %s", diskPct, a.usageBar(diskPct, barWidth)), + fmt.Sprintf(" CPU %5.1f%% %s", st.CPU, a.usageBarFor("CPU", st.CPU, barWidth)), + fmt.Sprintf(" RAM %5.1f%% %s", ramPct, a.usageBarFor("RAM", ramPct, barWidth)), + fmt.Sprintf(" DSK %5.1f%% %s", diskPct, a.usageBarFor("DSK", diskPct, barWidth)), } if width >= 104 { parts = append(parts, fmt.Sprintf(" NET %s %s %s %s", a.style.up(), speedIEC(st.NetOut), a.style.down(), speedIEC(st.NetIn))) @@ -134,31 +163,14 @@ func (a *App) detailContentLines(node komari.Node, st komari.Status, width int, if len(sections) == 0 { return []string{fitLine("", width)} } - if width >= 96 { - return a.detailSectionGrid(sections, width, 2, cardHeight) - } - return a.detailSectionGrid(sections, width, 1, cardHeight) + return a.detailSectionGrid(sections, width, detailGridColumns(width), cardHeight) } func (a *App) detailSectionGrid(sections []detailSection, width int, columns int, cardHeight int) []string { - gap := 2 if cardHeight < detailCardHeight { cardHeight = detailCardHeight } - if columns < 1 { - columns = 1 - } - for columns > 1 { - cardWidth := (width - gap*(columns-1)) / columns - if cardWidth >= 42 { - break - } - columns-- - } - cardWidth := width - if columns > 1 { - cardWidth = (width - gap*(columns-1)) / columns - } + columns, cardWidth := detailGridLayout(width, columns) lines := make([]string, 0, len(sections)*(cardHeight+1)) for row := 0; row < len(sections); row += columns { @@ -175,7 +187,7 @@ func (a *App) detailSectionGrid(sections []detailSection, width int, columns int var line strings.Builder for col, card := range rowCards { if col > 0 { - line.WriteString(strings.Repeat(" ", gap)) + line.WriteString(strings.Repeat(" ", detailGridGap)) } line.WriteString(card[lineIndex]) } @@ -185,6 +197,33 @@ func (a *App) detailSectionGrid(sections []detailSection, width int, columns int return lines } +const detailGridGap = 2 + +func detailGridColumns(width int) int { + if width >= 96 { + return 2 + } + return 1 +} + +func detailGridLayout(width int, columns int) (int, int) { + if columns < 1 { + columns = 1 + } + for columns > 1 { + cardWidth := (width - detailGridGap*(columns-1)) / columns + if cardWidth >= 42 { + break + } + columns-- + } + cardWidth := width + if columns > 1 { + cardWidth = (width - detailGridGap*(columns-1)) / columns + } + return columns, cardWidth +} + func detailCardHeightFor(contentHeight int) int { switch { case contentHeight >= 45: @@ -237,6 +276,71 @@ func (a *App) detailSections(node komari.Node, st komari.Status) []detailSection } } +func (a *App) chartSections(node komari.Node, st komari.Status) []detailSection { + sections := a.detailSections(node, st) + charts := make([]detailSection, 0, len(sections)) + for _, section := range sections { + if section.Chart != nil { + charts = append(charts, section) + } + } + return charts +} + +func (a *App) focusChart(index int) bool { + node, ok := a.selectedNode() + if !a.detail || !ok { + return false + } + st := a.snapshot.Status[node.UUID] + charts := a.chartSections(node, st) + if len(charts) == 0 { + return false + } + if index < 0 { + index = 0 + } + if index >= len(charts) { + index = len(charts) - 1 + } + a.chartFocus = true + a.chartFocusIndex = index + a.scroll = 0 + return true +} + +func (a *App) closeChartFocus() { + a.chartFocus = false + a.chartFocusIndex = 0 +} + +func (a *App) moveChartFocus(delta int) { + node, ok := a.selectedNode() + if !a.chartFocus || !ok { + return + } + st := a.snapshot.Status[node.UUID] + charts := a.chartSections(node, st) + if len(charts) == 0 { + a.closeChartFocus() + return + } + a.chartFocusIndex = (a.chartFocusIndex + delta + len(charts)) % len(charts) +} + +func (a *App) clampChartFocus(count int) { + if count <= 0 { + a.closeChartFocus() + return + } + if a.chartFocusIndex < 0 { + a.chartFocusIndex = 0 + } + if a.chartFocusIndex >= count { + a.chartFocusIndex = count - 1 + } +} + func (a *App) overviewSections(node komari.Node, st komari.Status) []detailSection { ramTotal := firstNonZero(st.RAMTotal, node.MemTotal) diskTotal := firstNonZero(st.DiskTotal, node.DiskTotal) @@ -535,7 +639,8 @@ func (a *App) metaSections(node komari.Node, st komari.Status) []detailSection { } func (a *App) detailUsageLine(label string, pct float64, value string) string { - return fmt.Sprintf(" %-5s %5.1f%% %s %s", strings.TrimSpace(label), pct, a.usageBar(pct, 10), value) + cleanLabel := strings.TrimSpace(label) + return fmt.Sprintf(" %-5s %5.1f%% %s %s", cleanLabel, pct, a.usageBarFor(cleanLabel, pct, 10), value) } func (a *App) sparkSection(title string, values []float64) detailSection { diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index 867c682..c9d3c2b 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -1,6 +1,7 @@ package tui import ( + "context" "strings" "testing" "time" @@ -83,6 +84,54 @@ func TestHistorySectionsIncludeAllMetricCharts(t *testing.T) { } } +func TestChartFocusOpensAndClosesWithKeys(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true}) + node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{node}, + Status: map[string]komari.Status{node.UUID: {CPU: 20, Time: komari.NullTime{Time: now, Valid: true}}}, + } + app.detail = true + app.tab = 2 + + app.handleKey(context.Background(), keyEvent{name: "chart-focus"}) + if !app.chartFocus { + t.Fatal("chart focus was not opened") + } + lines := app.renderDetailBody(90, 18) + if joined := strings.Join(lines, "\n"); !strings.Contains(joined, "CPU Chart") { + t.Fatalf("focused chart body missing title: %#v", lines) + } + + app.handleKey(context.Background(), keyEvent{name: "tab-right"}) + if !app.chartFocus || app.chartFocusIndex != 1 { + t.Fatalf("chart focus index = %d focus=%t, want next focused chart", app.chartFocusIndex, app.chartFocus) + } + app.handleKey(context.Background(), keyEvent{name: "back"}) + if app.chartFocus || !app.detail { + t.Fatalf("focus=%t detail=%t, want focus closed and detail retained", app.chartFocus, app.detail) + } +} + +func TestChartFocusWindowKeysKeepFocus(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true}) + node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{node}, + Status: map[string]komari.Status{node.UUID: {CPU: 20, Time: komari.NullTime{Time: now, Valid: true}}}, + } + app.detail = true + app.tab = 2 + app.focusChart(0) + + app.handleKey(context.Background(), keyEvent{name: "window-right"}) + if !app.chartFocus || app.window != 1 { + t.Fatalf("focus=%t window=%d, want focused window 1", app.chartFocus, app.window) + } +} + func TestHistoryMetricSectionsMatchWebLoadChartSet(t *testing.T) { app := NewWithOptions(nil, Options{ASCII: true}) node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} @@ -203,6 +252,45 @@ func TestAxisChartLinesIncludeYAxisAndTimeAxis(t *testing.T) { } } +func TestAxisChartLinesDetailedShowsIntermediateTimeTicks(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true}) + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + lines := app.axisChartLinesDetailed(axisChart{ + Values: []float64{10, 20, 5, 30}, + Times: []time.Time{ + now.Add(-4 * time.Hour), + now.Add(-2 * time.Hour), + now.Add(-time.Hour), + now, + }, + From: "07-02 08:00", + To: "07-02 12:00", + Unit: "%", + Window: 4 * time.Hour, + Until: now, + }, 80, 5) + + ticks := lines[len(lines)-2] + axis := lines[len(lines)-1] + if strings.Contains(axis, "->") || strings.Contains(axis, "07-02") { + t.Fatalf("detailed axis should not use range arrow: %#v", lines) + } + if strings.Count(ticks, "|") < 5 { + t.Fatalf("missing detailed tick marks: %#v", lines) + } + startLabel := now.Add(-4 * time.Hour).Local().Format("15:04") + endLabel := now.Local().Format("15:04") + for _, label := range []string{startLabel, endLabel} { + if !strings.Contains(axis, label) { + t.Fatalf("missing axis label %q: %#v", label, lines) + } + } + midLabel := now.Add(-2 * time.Hour).Local().Format("15:04") + if !strings.Contains(axis, midLabel) { + t.Fatalf("missing intermediate time tick: %#v", lines) + } +} + func TestAxisChartLinesShowMoreFixedPercentTicksWhenTall(t *testing.T) { app := NewWithOptions(nil, Options{ASCII: true}) lines := app.axisChartLines(axisChart{ diff --git a/internal/tui/frame.go b/internal/tui/frame.go index a39d5d6..9efb677 100644 --- a/internal/tui/frame.go +++ b/internal/tui/frame.go @@ -90,9 +90,14 @@ func (a *App) headerLines(width int) []string { contextParts := []string{ a.headerChip("view", "list"), a.headerChip("mode", string(a.mode)+"/"+mode), + a.headerChip("filter", a.filterText()), + a.headerChip("sort", a.sortText()), a.headerChip("komari", valueOr(a.snapshot.Version.Version, "-")), a.headerChip("rpc", valueOr(a.snapshot.RPCVersion, "-")), } + if query := a.currentSearchQuery(); query != "" || a.searchEditing { + contextParts = append([]string{a.headerChip("search", valueOr(query, "-"))}, contextParts...) + } if a.settings { contextParts = []string{ a.headerChip("view", "settings"), @@ -102,11 +107,15 @@ func (a *App) headerLines(width int) []string { } } else if a.detail { nodeName := "-" - if len(a.snapshot.Nodes) > 0 { - nodeName = a.nodeLabel(a.snapshot.Nodes[a.selected]) + if node, ok := a.selectedNode(); ok { + nodeName = a.nodeLabel(node) + } + view := "detail" + if a.chartFocus { + view = "chart" } contextParts = []string{ - a.headerChip("view", "detail"), + a.headerChip("view", view), a.headerChip("node", nodeName), a.headerChip("komari", valueOr(a.snapshot.Version.Version, "-")), a.headerChip("rpc", valueOr(a.snapshot.RPCVersion, "-")), @@ -139,6 +148,50 @@ func (a *App) headerChip(label string, value string) string { return a.style.dim(strings.ToUpper(label)) + " " + value } +func (a *App) listSearchVisible() bool { + return a.searchEditing || strings.TrimSpace(a.searchQuery) != "" +} + +func (a *App) listSearchRows() int { + if a.listSearchVisible() { + return 1 + } + return 0 +} + +func (a *App) listSearchLine(width int) string { + value := a.searchQuery + state := "active" + suffix := " / edit" + if a.searchEditing { + value = a.searchDraft + state = "typing" + suffix = " Enter apply Esc cancel Backspace delete" + } + + prefix := a.style.bold(" Search ") + a.style.dim(state) + " " + fieldWidth := width - displayWidth(prefix) - displayWidth(suffix) + if fieldWidth < 8 { + suffix = "" + fieldWidth = width - displayWidth(prefix) + } + if fieldWidth < 4 { + return fitLine(" Search "+value, width) + } + + content := value + if a.searchEditing { + content += "|" + } + field := "[" + fitLine(content, fieldWidth-2) + "]" + if a.searchEditing { + field = a.style.cyan(field) + } else { + field = a.style.yellow(field) + } + return fitLine(prefix+field+a.style.dim(suffix), width) +} + func headerAlign(left string, right string, width int) string { left = cleanLine(left, width) right = cleanLine(right, width) @@ -173,6 +226,14 @@ func (a *App) footerTextForWidth(width int) string { } func (a *App) footerItems() []footerItem { + if a.searchEditing { + return []footerItem{ + {Action: footerNone, Labels: [footerLabelVariants]string{"type search", "Typing", "Type"}}, + {Action: footerNone, Labels: [footerLabelVariants]string{"Enter apply", "Apply", "OK"}}, + {Action: footerBack, Labels: [footerLabelVariants]string{"Esc cancel", "Cancel", "Esc"}}, + {Action: footerNone, Labels: [footerLabelVariants]string{"Backspace delete", "Delete", "Del"}}, + } + } if a.settings { return []footerItem{ {Action: footerBack, Labels: [footerLabelVariants]string{"Esc/q back", "Back", "B"}}, @@ -181,6 +242,15 @@ func (a *App) footerItems() []footerItem { {Action: footerToggle, Labels: [footerLabelVariants]string{"Enter toggle", "Toggle", "Tog"}}, } } + if a.chartFocus { + return []footerItem{ + {Action: footerBack, Labels: [footerLabelVariants]string{"Esc/q back", "Back", "B"}}, + {Action: footerPrevChart, Labels: [footerLabelVariants]string{"h previous", "Previous", "Prev"}}, + {Action: footerNextChart, Labels: [footerLabelVariants]string{"l next", "Next", "Next"}}, + {Action: footerWindow, Labels: [footerLabelVariants]string{"[ ] window", "Window", "Win"}}, + {Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "Ref"}}, + } + } if a.detail { items := []footerItem{ {Action: footerBack, Labels: [footerLabelVariants]string{"Back(Esc/q)", "Back", "B"}}, @@ -198,6 +268,9 @@ func (a *App) footerItems() []footerItem { items := []footerItem{ {Action: footerSelect, Labels: [footerLabelVariants]string{"↑↓/jk select", "Select", "J"}}, {Action: footerOpen, Labels: [footerLabelVariants]string{"Enter detail", "Detail", "O"}}, + {Action: footerSearch, Labels: [footerLabelVariants]string{"/ search", "Search", "/"}}, + {Action: footerSort, Labels: [footerLabelVariants]string{"c sort", "Sort", "C"}}, + {Action: footerFilter, Labels: [footerLabelVariants]string{"v filter", "Filter", "V"}}, {Action: footerSettings, Labels: [footerLabelVariants]string{"s settings", "Settings", "S"}}, {Action: footerMode, Labels: [footerLabelVariants]string{"m mode", "Mode", "M"}}, {Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}}, @@ -213,21 +286,26 @@ func (a *App) footerItems() []footerItem { type footerAction string const ( - footerNone footerAction = "" - footerSelect footerAction = "select" - footerOpen footerAction = "open" - footerSettings footerAction = "settings" - footerMode footerAction = "mode" - footerRefresh footerAction = "refresh" - footerASCII footerAction = "ascii" - footerQuit footerAction = "quit" - footerBack footerAction = "back" - footerAdjust footerAction = "adjust" - footerToggle footerAction = "toggle" - footerTabs footerAction = "tabs" - footerWindow footerAction = "window" - footerScroll footerAction = "scroll" - footerUpdate footerAction = "update" + footerNone footerAction = "" + footerSelect footerAction = "select" + footerOpen footerAction = "open" + footerSettings footerAction = "settings" + footerMode footerAction = "mode" + footerRefresh footerAction = "refresh" + footerASCII footerAction = "ascii" + footerQuit footerAction = "quit" + footerSearch footerAction = "search" + footerSort footerAction = "sort" + footerFilter footerAction = "filter" + footerBack footerAction = "back" + footerPrevChart footerAction = "prev-chart" + footerNextChart footerAction = "next-chart" + footerAdjust footerAction = "adjust" + footerToggle footerAction = "toggle" + footerTabs footerAction = "tabs" + footerWindow footerAction = "window" + footerScroll footerAction = "scroll" + footerUpdate footerAction = "update" ) const footerLabelVariants = 3 @@ -308,7 +386,7 @@ func (a *App) adjustScroll(visibleRows int) { if a.scroll < 0 { a.scroll = 0 } - maxScroll := len(a.snapshot.Nodes) - visibleRows + maxScroll := len(a.viewNodes()) - visibleRows if maxScroll < 0 { maxScroll = 0 } diff --git a/internal/tui/keys.go b/internal/tui/keys.go index fdd4982..f33fea5 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -5,6 +5,8 @@ import ( "io" "os" "strconv" + "strings" + "unicode/utf8" ) func (a *App) readKeys(ctx context.Context) { @@ -50,45 +52,55 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) { case 3: out = append(out, keyEvent{name: "force-quit"}) case 'q', 'Q': - out = append(out, keyEvent{name: "quit"}) + out = append(out, keyEvent{name: "quit", text: string(data[i])}) case 'r', 'R': - out = append(out, keyEvent{name: "refresh"}) + out = append(out, keyEvent{name: "refresh", text: string(data[i])}) case '\r', '\n': out = append(out, keyEvent{name: "open"}) case 'd', 'D': - out = append(out, keyEvent{name: "detail-refresh"}) + out = append(out, keyEvent{name: "detail-refresh", text: string(data[i])}) + case 'f', 'F': + out = append(out, keyEvent{name: "chart-focus", text: string(data[i])}) case 'a', 'A': - out = append(out, keyEvent{name: "ascii"}) + out = append(out, keyEvent{name: "ascii", text: string(data[i])}) case 'o', 'O': - out = append(out, keyEvent{name: "open"}) - case 'b', 'B', 0x7f: - out = append(out, keyEvent{name: "back"}) + out = append(out, keyEvent{name: "open", text: string(data[i])}) + case 'b', 'B': + out = append(out, keyEvent{name: "back", text: string(data[i])}) + case 0x7f: + out = append(out, keyEvent{name: "backspace"}) case 'm', 'M': - out = append(out, keyEvent{name: "mode"}) + out = append(out, keyEvent{name: "mode", text: string(data[i])}) case 's', 'S': - out = append(out, keyEvent{name: "settings"}) + out = append(out, keyEvent{name: "settings", text: string(data[i])}) case 'u', 'U': - out = append(out, keyEvent{name: "update-hint"}) + out = append(out, keyEvent{name: "update-hint", text: string(data[i])}) + case '/': + out = append(out, keyEvent{name: "search", text: "/"}) + case 'c', 'C': + out = append(out, keyEvent{name: "sort", text: string(data[i])}) + case 'v', 'V': + out = append(out, keyEvent{name: "filter", text: string(data[i])}) case 'j': - out = append(out, keyEvent{name: "down"}) + out = append(out, keyEvent{name: "down", text: string(data[i])}) case 'k': - out = append(out, keyEvent{name: "up"}) + out = append(out, keyEvent{name: "up", text: string(data[i])}) case 'h': - out = append(out, keyEvent{name: "tab-left"}) + out = append(out, keyEvent{name: "tab-left", text: string(data[i])}) case 'l': - out = append(out, keyEvent{name: "tab-right"}) + out = append(out, keyEvent{name: "tab-right", text: string(data[i])}) case '1', '2', '3', '4', '5': - out = append(out, keyEvent{name: "tab-" + string(data[i])}) + out = append(out, keyEvent{name: "tab-" + string(data[i]), text: string(data[i])}) case '\t': out = append(out, keyEvent{name: "tab-right"}) case 'g': - out = append(out, keyEvent{name: "top"}) + out = append(out, keyEvent{name: "top", text: string(data[i])}) case 'G': - out = append(out, keyEvent{name: "bottom"}) + out = append(out, keyEvent{name: "bottom", text: string(data[i])}) case '[': - out = append(out, keyEvent{name: "window-left"}) + out = append(out, keyEvent{name: "window-left", text: string(data[i])}) case ']': - out = append(out, keyEvent{name: "window-right"}) + out = append(out, keyEvent{name: "window-right", text: string(data[i])}) case 0x1b: if i+2 < len(data) && data[i+1] == '[' { if data[i+2] == '<' { @@ -133,6 +145,19 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) { } else { out = append(out, keyEvent{name: "back"}) } + default: + if data[i] >= utf8.RuneSelf { + if !utf8.FullRune(data[i:]) { + return out, append([]byte(nil), data[i:]...) + } + r, size := utf8.DecodeRune(data[i:]) + if r != utf8.RuneError { + out = append(out, keyEvent{name: "char", text: string(r)}) + i += size - 1 + } + } else if data[i] >= 32 && data[i] != 0x7f { + out = append(out, keyEvent{name: "char", text: string(data[i])}) + } } } return out, nil @@ -203,6 +228,10 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { } return } + if a.searchEditing { + a.handleSearchKey(key) + return + } if a.settings { a.handleSettingsKey(key) return @@ -211,37 +240,65 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { case "force-quit": a.quit = true case "quit": - if a.detail { + if a.chartFocus { + a.closeChartFocus() + } else if a.detail { a.detail = false a.scroll = 0 + } else if a.clearListScope() { + // q backs out of a narrowed list before it quits the app. } else { a.quit = true } - case "back": - if a.detail { + case "back", "backspace": + if a.chartFocus { + a.closeChartFocus() + } else if a.detail { a.detail = false a.scroll = 0 + } else { + a.clearListScope() } case "settings": a.openSettings() case "update-hint": a.showUpdateHint() + case "search": + if !a.detail { + a.openSearch() + } + case "sort": + if !a.detail { + a.cycleNodeSort() + } + case "filter": + if !a.detail { + a.cycleNodeFilter() + } case "open": - if len(a.snapshot.Nodes) > 0 { + if a.chartFocus { + a.closeChartFocus() + } else if a.detail { + a.focusChart(a.chartFocusIndex) + } else if _, ok := a.selectedNode(); ok { a.detail = true a.scroll = 0 a.ensureSelectedDetail(ctx) } + case "chart-focus": + a.focusChart(0) case "refresh": a.requestFullRefresh() - if a.detail && len(a.snapshot.Nodes) > 0 { - a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) + if a.detail { + if node, ok := a.selectedNode(); ok { + a.fetchDetail(ctx, node.UUID, true) + } } case "detail-refresh": - if len(a.snapshot.Nodes) > 0 { + if node, ok := a.selectedNode(); ok { a.detail = true a.scroll = 0 - a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) + a.fetchDetail(ctx, node.UUID, true) } case "ascii": a.style.ASCII = !a.style.ASCII @@ -254,25 +311,33 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { } } case "up": - if a.detail { + if a.chartFocus { + // no-op; focused charts use h/l for chart selection. + } else if a.detail { a.scroll -= a.detailScrollStep() } else { a.selected-- } case "down": - if a.detail { + if a.chartFocus { + // no-op; focused charts use h/l for chart selection. + } else if a.detail { a.scroll += a.detailScrollStep() } else { a.selected++ } case "pageup": - if a.detail { + if a.chartFocus { + a.moveChartFocus(-1) + } else if a.detail { a.scroll -= a.detailScrollStep() * 3 } else { a.selected -= 10 } case "pagedown": - if a.detail { + if a.chartFocus { + a.moveChartFocus(1) + } else if a.detail { a.scroll += a.detailScrollStep() * 3 } else { a.selected += 10 @@ -287,32 +352,47 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { if a.detail { a.scroll = 1 << 30 } else { - a.selected = len(a.snapshot.Nodes) - 1 + a.selected = len(a.viewNodes()) - 1 } case "tab-left": - if a.detail { + if a.chartFocus { + a.moveChartFocus(-1) + } else if a.detail { a.tab = (a.tab + len(tabNames) - 1) % len(tabNames) a.scroll = 0 + a.closeChartFocus() } case "tab-right": - if a.detail { + if a.chartFocus { + a.moveChartFocus(1) + } else if a.detail { a.tab = (a.tab + 1) % len(tabNames) a.scroll = 0 + a.closeChartFocus() } case "tab-1", "tab-2", "tab-3", "tab-4", "tab-5": if a.detail { a.tab = int(key.name[len(key.name)-1] - '1') a.scroll = 0 + a.closeChartFocus() } case "window-left": - if a.detail { + if a.chartFocus { a.window = (a.window + len(detailWindows) - 1) % len(detailWindows) a.scroll = 0 + } else if a.detail { + a.window = (a.window + len(detailWindows) - 1) % len(detailWindows) + a.scroll = 0 + a.closeChartFocus() } case "window-right": - if a.detail { + if a.chartFocus { a.window = (a.window + 1) % len(detailWindows) a.scroll = 0 + } else if a.detail { + a.window = (a.window + 1) % len(detailWindows) + a.scroll = 0 + a.closeChartFocus() } } a.clampSelection() @@ -324,6 +404,49 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) { } } +func (a *App) openSearch() { + a.searchEditing = true + a.searchDraft = a.searchQuery + a.notice = "" +} + +func (a *App) handleSearchKey(key keyEvent) { + selected := a.selectedNodeUUID() + switch key.name { + case "force-quit": + a.quit = true + case "open": + if key.text == "" { + a.searchQuery = strings.TrimSpace(a.searchDraft) + a.searchEditing = false + } else { + a.searchDraft += key.text + } + case "back": + if key.text == "" { + a.searchDraft = a.searchQuery + a.searchEditing = false + } else { + a.searchDraft += key.text + } + case "backspace": + a.searchDraft = dropLastRune(a.searchDraft) + default: + if key.text != "" { + a.searchDraft += key.text + } + } + a.restoreSelection(selected) +} + +func dropLastRune(value string) string { + if value == "" { + return "" + } + runes := []rune(value) + return string(runes[:len(runes)-1]) +} + func isMouseKey(name string) bool { switch name { case "mouse-left", "mouse-wheel-up", "mouse-wheel-down", "mouse-ignore": @@ -353,6 +476,7 @@ func (a *App) handleSettingsKey(key keyEvent) { a.adjustSelectedSetting(1) case "ascii": a.style.ASCII = !a.style.ASCII + a.persistSettings() } } @@ -368,7 +492,7 @@ func (a *App) detailScrollStep() int { } func (a *App) clampSelection() { - count := len(a.snapshot.Nodes) + count := len(a.viewNodes()) if count == 0 { a.selected = 0 a.scroll = 0 diff --git a/internal/tui/line.go b/internal/tui/line.go index f4e06e2..06c5634 100644 --- a/internal/tui/line.go +++ b/internal/tui/line.go @@ -12,10 +12,14 @@ func (a *App) renderLineBody(width int, bodyHeight int) []string { if bodyHeight <= 0 { return nil } - if len(a.snapshot.Nodes) == 0 { + nodes := a.viewNodes() + if len(nodes) == 0 { if a.loading { return fillBody([]string{"Loading nodes..."}, width, bodyHeight) } + if len(a.snapshot.Nodes) > 0 { + return fillBody([]string{"No nodes match the current search/filter."}, width, bodyHeight) + } return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight) } @@ -26,9 +30,9 @@ func (a *App) renderLineBody(width int, bodyHeight int) []string { lines := make([]string, 0, bodyHeight) lines = append(lines, header...) - end := min(len(a.snapshot.Nodes), a.scroll+visibleRows) + end := min(len(nodes), a.scroll+visibleRows) for i := a.scroll; i < end; i++ { - lines = append(lines, a.lineTableRow(i, a.snapshot.Nodes[i], width)) + lines = append(lines, a.lineTableRow(i, nodes[i], width)) } return fillBody(lines, width, bodyHeight) } @@ -36,7 +40,7 @@ func (a *App) renderLineBody(width int, bodyHeight int) []string { func (a *App) lineTableHeader(width int) []string { title := a.lineTableColumns(width, false, komari.Node{}, komari.Status{}, false) return []string{ - a.style.bold(fitLine("Servers "+a.style.dim(fmt.Sprintf("(%d nodes)", len(a.snapshot.Nodes))), width)), + a.style.bold(fitLine(a.listTitle(), width)), a.style.dim(fitLine(title, width)), a.style.dim(fitLine(a.style.separator(width), width)), } @@ -49,6 +53,10 @@ func (a *App) lineTableRow(index int, node komari.Node, width int) string { if index == a.selected { return a.style.inverse(fitLine(line, width)) } + alert := a.alertForNode(node, st, time.Now()) + if alert.Critical || alert.Warning { + return a.styleAlertLine(fitLine(line, width), alert) + } if !st.Online { return a.style.dim(fitLine(line, width)) } @@ -122,18 +130,25 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari. if selected { marker = ">" } + alert := a.alertForNode(node, st, time.Now()) state := "on" if !st.Online { state = "off" + } else if len(alert.Reasons) > 0 { + state = "!" } if !a.style.ASCII { - if st.Online { + if len(alert.Reasons) > 0 && st.Online { + state = "!" + } else if st.Online { state = "●" } else { state = "●" } if !selected { - if st.Online { + if alert.Critical || alert.Warning { + state = a.styleAlertLine(state, alert) + } else if st.Online { state = a.style.green(state) } else { state = a.style.red(state) diff --git a/internal/tui/metrics.go b/internal/tui/metrics.go index 0ddc92d..d997a47 100644 --- a/internal/tui/metrics.go +++ b/internal/tui/metrics.go @@ -413,7 +413,7 @@ func compactByteFloat(value float64) string { } } -func chartXAxisLine(from, to string, width int, labelWidth int, ascii bool) string { +func chartXAxisLines(chart axisChart, width int, labelWidth int, ascii bool, detailed bool) []string { prefix := strings.Repeat(" ", labelWidth) if ascii { prefix += "+" @@ -426,11 +426,148 @@ func chartXAxisLine(from, to string, width int, labelWidth int, ascii bool) stri axis = strings.Repeat("─", plotWidth) } line := prefix + axis - label := from + " -> " + to + if detailed { + if labels := chartDetailedXAxisLabels(chart, plotWidth); len(labels) > 0 { + return []string{ + cleanLine(prefix+placeAxisTicks(labels, plotWidth, ascii), width), + cleanLine(strings.Repeat(" ", displayWidth(prefix))+placeAxisLabels(labels, plotWidth), width), + } + } + } + label := chart.From + " -> " + chart.To if displayWidth(label) <= plotWidth { line = prefix + padRight(label, plotWidth) } - return cleanLine(line, width) + return []string{cleanLine(line, width)} +} + +type chartAxisLabel struct { + Pos int + Label string +} + +func chartDetailedXAxisLabels(chart axisChart, plotWidth int) []chartAxisLabel { + from, to, ok := chartTimeRange(chart) + if !ok || plotWidth < 8 { + return nil + } + count := detailedXAxisTickCount(plotWidth, chartAxisTimeLayout(to.Sub(from))) + if count < 2 { + return nil + } + labels := make([]chartAxisLabel, 0, count) + layout := chartAxisTimeLayout(to.Sub(from)) + for i := 0; i < count; i++ { + ratio := 0.0 + if count > 1 { + ratio = float64(i) / float64(count-1) + } + ts := from.Add(time.Duration(float64(to.Sub(from)) * ratio)) + pos := int(math.Round(ratio * float64(plotWidth-1))) + labels = append(labels, chartAxisLabel{Pos: pos, Label: ts.Local().Format(layout)}) + } + return labels +} + +func chartDetailedXAxisLabel(chart axisChart) string { + from, to, ok := chartTimeRange(chart) + if !ok { + return chart.From + " -> " + chart.To + } + layout := chartAxisTimeLayout(to.Sub(from)) + return from.Local().Format(layout) + " -> " + to.Local().Format(layout) +} + +func chartTimeRange(chart axisChart) (time.Time, time.Time, bool) { + if chart.Window > 0 && !chart.Until.IsZero() { + return chart.Until.Add(-chart.Window), chart.Until, true + } + var from time.Time + var to time.Time + for _, ts := range chart.Times { + if ts.IsZero() { + continue + } + if from.IsZero() || ts.Before(from) { + from = ts + } + if to.IsZero() || ts.After(to) { + to = ts + } + } + if from.IsZero() || to.IsZero() { + return time.Time{}, time.Time{}, false + } + return from, to, true +} + +func chartAxisTimeLayout(span time.Duration) string { + if span <= 2*time.Hour { + return "15:04:05" + } + return "15:04" +} + +func detailedXAxisTickCount(plotWidth int, layout string) int { + labelWidth := len(time.Date(2026, 7, 2, 15, 4, 5, 0, time.Local).Format(layout)) + count := plotWidth / (labelWidth + 2) + if count < 2 { + return 2 + } + if count > 9 { + return 9 + } + return count +} + +func placeAxisTicks(labels []chartAxisLabel, plotWidth int, ascii bool) string { + tick := '|' + if !ascii { + tick = '│' + } + fill := '-' + if !ascii { + fill = '─' + } + cells := make([]rune, plotWidth) + for i := range cells { + cells[i] = fill + } + for _, item := range labels { + if item.Pos >= 0 && item.Pos < plotWidth { + cells[item.Pos] = tick + } + } + return string(cells) +} + +func placeAxisLabels(labels []chartAxisLabel, plotWidth int) string { + cells := make([]byte, plotWidth) + for i := range cells { + cells[i] = ' ' + } + lastEnd := -1 + for _, item := range labels { + if item.Label == "" { + continue + } + start := item.Pos - displayWidth(item.Label)/2 + if start < 0 { + start = 0 + } + if end := start + displayWidth(item.Label); end > plotWidth { + start = plotWidth - displayWidth(item.Label) + } + if start <= lastEnd+1 { + start = lastEnd + 2 + } + if start < 0 || start+displayWidth(item.Label) > plotWidth { + continue + } + copy(cells[start:], item.Label) + lastEnd = start + displayWidth(item.Label) - 1 + } + return string(cells) } func appendStatusSample(records []komari.Status, current komari.Status) []komari.Status { diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 64a1465..de05aa3 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -65,16 +65,28 @@ func (a *App) handleMouseClick(ctx context.Context, x int, y int) { if a.clickDetailBack(x, y) { return } + if a.chartFocus { + return + } if y <= mouseHeaderRows { return } + if a.clickDetailChart(x, y) { + return + } a.clickDetailChrome(ctx, x, y) return } if y <= mouseHeaderRows { return } - if len(a.snapshot.Nodes) == 0 { + if rows := a.listSearchRows(); rows > 0 { + if y <= mouseHeaderRows+rows { + a.openSearch() + return + } + } + if len(a.viewNodes()) == 0 { return } if a.mode == ModeLine { @@ -94,6 +106,14 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool { drawWidth-- } action := a.footerActionAt(x, drawWidth) + if a.searchEditing { + if action == footerBack { + a.searchDraft = a.searchQuery + a.searchEditing = false + return true + } + return false + } if a.settings { switch action { case footerBack: @@ -108,14 +128,22 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool { if a.detail { switch action { case footerBack: - a.detail = false - a.scroll = 0 + if a.chartFocus { + a.closeChartFocus() + } else { + a.detail = false + a.scroll = 0 + } + case footerPrevChart: + a.moveChartFocus(-1) + case footerNextChart: + a.moveChartFocus(1) case footerSettings: a.openSettings() case footerRefresh: a.requestFullRefresh() - if len(a.snapshot.Nodes) > 0 { - a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) + if node, ok := a.selectedNode(); ok { + a.fetchDetail(ctx, node.UUID, true) } case footerUpdate: a.showUpdateHint() @@ -127,6 +155,12 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool { switch action { case footerOpen: a.openSelectedDetail(ctx) + case footerSearch: + a.openSearch() + case footerSort: + a.cycleNodeSort() + case footerFilter: + a.cycleNodeFilter() case footerSettings: a.openSettings() case footerMode: @@ -165,13 +199,13 @@ func (a *App) clickSettings(y int) { } func (a *App) clickLine(ctx context.Context, y int) { - bodyRow := y - mouseHeaderRows - 1 + bodyRow := y - mouseHeaderRows - 1 - a.listSearchRows() row := bodyRow - lineHeaderRows if row < 0 { return } index := a.scroll + row - if index >= 0 && index < len(a.snapshot.Nodes) { + if index >= 0 && index < len(a.viewNodes()) { a.selected = index a.clampSelection() a.openSelectedDetail(ctx) @@ -185,11 +219,12 @@ func (a *App) clickSheet(ctx context.Context, x int, y int) { drawWidth-- } bodyHeight := height - mouseHeaderRows - 1 + bodyHeight -= a.listSearchRows() if bodyHeight < 1 { bodyHeight = 1 } columns, cardWidth := sheetLayout(drawWidth) - bodyRow := y - mouseHeaderRows - 1 + bodyRow := y - mouseHeaderRows - 1 - a.listSearchRows() if bodyRow < 0 { return } @@ -212,7 +247,7 @@ func (a *App) clickSheet(ctx context.Context, x int, y int) { return } index := (a.scroll+row)*columns + col - if index >= 0 && index < len(a.snapshot.Nodes) { + if index >= 0 && index < len(a.viewNodes()) { a.selected = index a.clampSelection() a.openSelectedDetail(ctx) @@ -220,7 +255,7 @@ func (a *App) clickSheet(ctx context.Context, x int, y int) { } func (a *App) openSelectedDetail(ctx context.Context) { - if len(a.snapshot.Nodes) == 0 { + if len(a.viewNodes()) == 0 { return } a.detail = true @@ -242,6 +277,7 @@ func (a *App) openSettings() { a.settingsWasDetail = a.detail a.settings = true a.detail = false + a.closeChartFocus() a.scroll = 0 } @@ -295,6 +331,76 @@ func (a *App) clickDetailChrome(ctx context.Context, x int, y int) { } } +func (a *App) clickDetailChart(x int, y int) bool { + index, ok := a.chartIndexAt(x, y) + if !ok { + return false + } + return a.focusChart(index) +} + +func (a *App) chartIndexAt(x int, y int) (int, bool) { + if len(a.viewNodes()) == 0 { + return 0, false + } + width, height := terminalSize() + drawWidth := width + if drawWidth > 1 { + drawWidth-- + } + bodyHeight := height - mouseHeaderRows - 1 + if bodyHeight < 1 { + bodyHeight = 1 + } + chromeRows := 4 + contentHeight := bodyHeight - chromeRows + if contentHeight < 1 { + return 0, false + } + cardHeight := detailCardHeightFor(contentHeight) + if contentHeight >= cardHeight { + contentHeight = contentHeight / cardHeight * cardHeight + } + bodyRow := y - mouseHeaderRows - 1 + contentRow := bodyRow - chromeRows + if contentRow < 0 || contentRow >= contentHeight { + return 0, false + } + contentRow += a.scroll + columns, cardWidth := detailGridLayout(drawWidth, detailGridColumns(drawWidth)) + row := contentRow / cardHeight + lineInCard := contentRow % cardHeight + if lineInCard >= cardHeight { + return 0, false + } + x0 := x - 1 + columnSpan := cardWidth + detailGridGap + col := x0 / columnSpan + if col < 0 || col >= columns { + return 0, false + } + if x0%columnSpan >= cardWidth { + return 0, false + } + sectionIndex := row*columns + col + node, ok := a.selectedNode() + if !ok { + return 0, false + } + st := a.snapshot.Status[node.UUID] + sections := a.detailSections(node, st) + if sectionIndex < 0 || sectionIndex >= len(sections) || sections[sectionIndex].Chart == nil { + return 0, false + } + chartIndex := 0 + for i := 0; i < sectionIndex; i++ { + if sections[i].Chart != nil { + chartIndex++ + } + } + return chartIndex, true +} + func sheetLayout(width int) (int, int) { columns := max(1, (width+sheetCardGap)/(sheetMinWidth+sheetCardGap)) for columns > 1 { diff --git a/internal/tui/mouse_test.go b/internal/tui/mouse_test.go index 0142b2f..146ce42 100644 --- a/internal/tui/mouse_test.go +++ b/internal/tui/mouse_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" "ktui/internal/komari" ) @@ -65,6 +66,26 @@ func TestMouseClickLineOpensNodeDetail(t *testing.T) { } } +func TestMouseClickLineWithSearchBarOpensCorrectNode(t *testing.T) { + app := NewWithOptions(nil, Options{Mode: ModeLine}) + app.searchQuery = "o" + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{ + {UUID: "n1", Name: "one"}, + {UUID: "n2", Name: "two"}, + }, + Status: map[string]komari.Status{}, + } + + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: 2, y: mouseHeaderRows + app.listSearchRows() + lineHeaderRows + 2}) + if app.selected != 1 { + t.Fatalf("selected = %d, want 1", app.selected) + } + if !app.detail { + t.Fatal("clicking a node should open detail view") + } +} + func TestMouseClickDetailFooterBackReturnsToList(t *testing.T) { app := NewWithOptions(nil, Options{}) app.detail = true @@ -84,48 +105,122 @@ func TestMouseClickDetailFooterBackReturnsToList(t *testing.T) { } } +func TestMouseClickDetailChartOpensFocus(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true}) + now := time.Date(2026, 7, 2, 12, 0, 0, 0, time.UTC) + node := komari.Node{UUID: "n1", Name: "one", MemTotal: 1000, DiskTotal: 2000} + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{node}, + Status: map[string]komari.Status{node.UUID: {CPU: 30, Time: komari.NullTime{Time: now, Valid: true}}}, + } + app.detail = true + app.tab = 2 + + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: 2, y: mouseHeaderRows + 5}) + if !app.chartFocus { + t.Fatal("clicking chart card should open chart focus") + } +} + +func TestMouseClickChartFocusFooterActions(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true}) + node := komari.Node{UUID: "n1", Name: "one", MemTotal: 1000, DiskTotal: 2000} + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{node}, + Status: map[string]komari.Status{node.UUID: {CPU: 30}}, + } + app.detail = true + app.tab = 2 + app.focusChart(0) + _, height := terminalSize() + + x, _, ok := footerLabelBounds(app.footerText(), "l next") + if !ok { + t.Fatal("missing next footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if app.chartFocusIndex != 1 { + t.Fatalf("chartFocusIndex = %d, want 1", app.chartFocusIndex) + } + + x, _, ok = footerLabelBounds(app.footerText(), "Esc/q back") + if !ok { + t.Fatal("missing focus back footer label") + } + app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) + if app.chartFocus { + t.Fatal("clicking chart focus back should close focus") + } +} + func TestMouseClickListFooterActions(t *testing.T) { app := NewWithOptions(nil, Options{}) _, height := terminalSize() - clickFooterLabel := func(label string) { + clickFooterAction := func(action footerAction) { t.Helper() - x, _, ok := footerLabelBounds(app.footerText(), label) + x, ok := footerActionPosition(app, action) if !ok { - t.Fatalf("missing footer label %q in %q", label, app.footerText()) + t.Fatalf("missing footer action %q in %q", action, app.footerText()) } app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height}) } - clickFooterLabel("s settings") + clickFooterAction(footerSettings) if !app.settings { t.Fatal("clicking settings footer action should open settings") } app.closeSettings() - clickFooterLabel("m mode") + clickFooterAction(footerMode) if app.mode != ModeLine { t.Fatalf("mode = %s, want line", app.mode) } - clickFooterLabel("r refresh") + clickFooterAction(footerRefresh) select { case <-app.refreshCh: default: t.Fatal("clicking refresh footer action should request refresh") } - clickFooterLabel("a ascii") + clickFooterAction(footerASCII) if !app.style.ASCII { t.Fatal("clicking ascii footer action should toggle ASCII mode") } - clickFooterLabel("q quit") + clickFooterAction(footerQuit) if !app.quit { t.Fatal("clicking quit footer action should set quit") } } +func footerActionPosition(app *App, action footerAction) (int, bool) { + width, _ := terminalSize() + drawWidth := width + if drawWidth > 1 { + drawWidth-- + } + variant := app.footerVariantForWidth(drawWidth) + pos := 2 + for _, item := range app.footerItems() { + label := item.Labels[variant] + end := pos + displayWidth(label) - 1 + if item.Action == action { + return pos, true + } + pos = end + 1 + if variant == 0 { + pos += 3 + } else if variant == 1 { + pos += 2 + } else { + pos++ + } + } + return 0, false +} + func TestFooterFoldsInsteadOfClippingActions(t *testing.T) { app := NewWithOptions(nil, Options{}) app.update.Available = true @@ -133,7 +228,7 @@ func TestFooterFoldsInsteadOfClippingActions(t *testing.T) { if displayWidth(footer) > 24 { t.Fatalf("footer width = %d, want <= 24: %q", displayWidth(footer), footer) } - for _, label := range []string{"J", "O", "S", "M", "R", "A", "Q", "U"} { + for _, label := range []string{"J", "O", "/", "C", "V", "S", "M", "R", "A", "Q", "U"} { if !strings.Contains(footer, label) { t.Fatalf("folded footer %q missing %q", footer, label) } diff --git a/internal/tui/node_view.go b/internal/tui/node_view.go new file mode 100644 index 0000000..e824e87 --- /dev/null +++ b/internal/tui/node_view.go @@ -0,0 +1,311 @@ +package tui + +import ( + "fmt" + "sort" + "strings" + "time" + + "ktui/internal/komari" +) + +func (a *App) viewNodes() []komari.Node { + return a.viewNodesForFilter(a.nodeFilter) +} + +func (a *App) viewNodesForFilter(filter nodeFilterMode) []komari.Node { + nodes := make([]komari.Node, 0, len(a.snapshot.Nodes)) + query := a.currentSearchQuery() + for _, node := range a.snapshot.Nodes { + st := a.snapshot.Status[node.UUID] + if query != "" && !a.nodeMatchesSearch(node, query) { + continue + } + if !a.nodeMatchesFilterMode(filter, node, st) { + continue + } + nodes = append(nodes, node) + } + a.sortNodes(nodes) + return nodes +} + +func (a *App) selectedNode() (komari.Node, bool) { + nodes := a.viewNodes() + if len(nodes) == 0 { + return komari.Node{}, false + } + if a.selected < 0 { + a.selected = 0 + } + if a.selected >= len(nodes) { + a.selected = len(nodes) - 1 + } + return nodes[a.selected], true +} + +func (a *App) selectedNodeUUID() string { + node, ok := a.selectedNode() + if !ok { + return "" + } + return node.UUID +} + +func (a *App) selectNodeUUID(uuid string) bool { + if uuid == "" { + return false + } + for i, node := range a.viewNodes() { + if node.UUID == uuid { + a.selected = i + return true + } + } + return false +} + +func (a *App) restoreSelection(uuid string) { + if !a.selectNodeUUID(uuid) { + a.selected = 0 + a.scroll = 0 + } + a.clampSelection() +} + +func (a *App) currentSearchQuery() string { + if a.searchEditing { + return strings.TrimSpace(a.searchDraft) + } + return strings.TrimSpace(a.searchQuery) +} + +func (a *App) nodeMatchesSearch(node komari.Node, query string) bool { + query = strings.ToLower(strings.TrimSpace(query)) + if query == "" { + return true + } + fields := []string{ + node.Name, + node.Region, + node.Group, + node.Tags, + node.IPv4, + node.IPv6, + node.UUID, + node.OS, + node.Arch, + node.PublicRemark, + node.Remark, + } + for _, field := range fields { + if strings.Contains(strings.ToLower(field), query) { + return true + } + } + return false +} + +func (a *App) nodeMatchesFilter(node komari.Node, st komari.Status) bool { + return a.nodeMatchesFilterMode(a.nodeFilter, node, st) +} + +func (a *App) nodeMatchesFilterMode(filter nodeFilterMode, node komari.Node, st komari.Status) bool { + switch filter { + case nodeFilterOffline: + return !st.Online + case nodeFilterExpiring: + return a.nodeExpiring(node, time.Now()) + case nodeFilterHighLoad: + return a.nodeHighLoad(node, st) + default: + return true + } +} + +func (a *App) sortNodes(nodes []komari.Node) { + switch a.nodeSort { + case nodeSortStatus: + sort.SliceStable(nodes, func(i, j int) bool { + left := a.snapshot.Status[nodes[i].UUID] + right := a.snapshot.Status[nodes[j].UUID] + if left.Online != right.Online { + return !left.Online + } + return nodes[i].Name < nodes[j].Name + }) + case nodeSortCPU: + sort.SliceStable(nodes, func(i, j int) bool { + left := a.snapshot.Status[nodes[i].UUID].CPU + right := a.snapshot.Status[nodes[j].UUID].CPU + if left == right { + return nodes[i].Name < nodes[j].Name + } + return left > right + }) + case nodeSortRAM: + sort.SliceStable(nodes, func(i, j int) bool { + left := a.nodeRAMPercent(nodes[i]) + right := a.nodeRAMPercent(nodes[j]) + if left == right { + return nodes[i].Name < nodes[j].Name + } + return left > right + }) + case nodeSortTraffic: + sort.SliceStable(nodes, func(i, j int) bool { + left := a.nodeTrafficTotal(nodes[i]) + right := a.nodeTrafficTotal(nodes[j]) + if left == right { + return nodes[i].Name < nodes[j].Name + } + return left > right + }) + case nodeSortExpiry: + sort.SliceStable(nodes, func(i, j int) bool { + left := nodes[i].ExpiredAt + right := nodes[j].ExpiredAt + if left.Valid != right.Valid { + return left.Valid + } + if left.Valid && !left.Time.Equal(right.Time) { + return left.Time.Before(right.Time) + } + return nodes[i].Name < nodes[j].Name + }) + } +} + +func (a *App) nodeRAMPercent(node komari.Node) float64 { + st := a.snapshot.Status[node.UUID] + return percent(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)) +} + +func (a *App) nodeDiskPercent(node komari.Node) float64 { + st := a.snapshot.Status[node.UUID] + return percent(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)) +} + +func (a *App) nodeTrafficTotal(node komari.Node) int64 { + st := a.snapshot.Status[node.UUID] + return st.NetTotalUp + st.NetTotalDown +} + +func (a *App) nodeHighLoad(node komari.Node, st komari.Status) bool { + return st.CPU >= a.warnCPU || + percent(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)) >= a.warnRAM || + percent(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)) >= a.warnDisk +} + +func (a *App) nodeExpiring(node komari.Node, now time.Time) bool { + if !node.ExpiredAt.Valid || a.warnExpiryDays <= 0 || node.Price < 0 { + return false + } + return node.ExpiredAt.Time.Sub(now) <= time.Duration(a.warnExpiryDays)*24*time.Hour +} + +func (a *App) cycleNodeSort() { + selected := a.selectedNodeUUID() + switch a.nodeSort { + case nodeSortDefault, "": + a.nodeSort = nodeSortStatus + case nodeSortStatus: + a.nodeSort = nodeSortCPU + case nodeSortCPU: + a.nodeSort = nodeSortRAM + case nodeSortRAM: + a.nodeSort = nodeSortTraffic + case nodeSortTraffic: + a.nodeSort = nodeSortExpiry + default: + a.nodeSort = nodeSortDefault + } + a.restoreSelection(selected) +} + +func (a *App) cycleNodeFilter() { + selected := a.selectedNodeUUID() + order := []nodeFilterMode{nodeFilterAll, nodeFilterOffline, nodeFilterExpiring, nodeFilterHighLoad} + current := a.normalizedNodeFilter() + start := 0 + for i, filter := range order { + if filter == current { + start = i + break + } + } + + for step := 1; step <= len(order); step++ { + next := order[(start+step)%len(order)] + if next != nodeFilterAll && len(a.viewNodesForFilter(next)) == 0 { + continue + } + if next == current { + a.notice = "no nodes match offline, expiring, or high-load filters" + a.nodeFilter = nodeFilterAll + } else { + a.notice = "" + a.nodeFilter = next + } + a.restoreSelection(selected) + return + } + a.notice = "no nodes match offline, expiring, or high-load filters" + a.nodeFilter = nodeFilterAll + a.restoreSelection(selected) +} + +func (a *App) normalizedNodeFilter() nodeFilterMode { + if a.nodeFilter == "" { + return nodeFilterAll + } + return a.nodeFilter +} + +func (a *App) listScopeActive() bool { + return strings.TrimSpace(a.searchQuery) != "" || + (a.nodeFilter != "" && a.nodeFilter != nodeFilterAll) +} + +func (a *App) clearListScope() bool { + if !a.listScopeActive() { + return false + } + selected := a.selectedNodeUUID() + a.searchEditing = false + a.searchQuery = "" + a.searchDraft = "" + a.nodeFilter = nodeFilterAll + a.restoreSelection(selected) + return true +} + +func (a *App) filterText() string { + if a.nodeFilter == "" { + return string(nodeFilterAll) + } + return string(a.nodeFilter) +} + +func (a *App) sortText() string { + if a.nodeSort == "" { + return string(nodeSortDefault) + } + return string(a.nodeSort) +} + +func (a *App) listTitle() string { + visible := len(a.viewNodes()) + total := len(a.snapshot.Nodes) + parts := []string{fmt.Sprintf("Servers (%d/%d nodes)", visible, total)} + if query := a.currentSearchQuery(); query != "" { + parts = append(parts, "search:"+query) + } + if a.nodeFilter != "" && a.nodeFilter != nodeFilterAll { + parts = append(parts, "filter:"+a.filterText()) + } + if a.nodeSort != "" && a.nodeSort != nodeSortDefault { + parts = append(parts, "sort:"+a.sortText()) + } + return strings.Join(parts, " ") +} diff --git a/internal/tui/node_view_test.go b/internal/tui/node_view_test.go new file mode 100644 index 0000000..c95c25a --- /dev/null +++ b/internal/tui/node_view_test.go @@ -0,0 +1,217 @@ +package tui + +import ( + "context" + "strings" + "testing" + "time" + + "ktui/internal/komari" +) + +func testNodeViewApp() *App { + now := time.Now() + app := NewWithOptions(nil, Options{WarnCPU: 90, WarnRAM: 85, WarnDisk: 90, WarnExpiryDays: 7}) + nodes := []komari.Node{ + {UUID: "n1", Name: "alpha", Region: "tokyo", Tags: "edge", IPv4: "192.0.2.1", MemTotal: 1000, DiskTotal: 1000, ExpiredAt: komari.NullTime{Time: now.Add(30 * 24 * time.Hour), Valid: true}}, + {UUID: "n2", Name: "beta", Region: "london", Tags: "db", IPv6: "2001:db8::2", MemTotal: 1000, DiskTotal: 1000, ExpiredAt: komari.NullTime{Time: now.Add(3 * 24 * time.Hour), Valid: true}}, + {UUID: "n3", Name: "gamma", Region: "new-york", Tags: "cache", MemTotal: 1000, DiskTotal: 1000}, + } + app.snapshot = komari.Snapshot{ + Nodes: nodes, + Status: map[string]komari.Status{ + "n1": {Online: true, CPU: 20, RAM: 200, RAMTotal: 1000, Disk: 200, DiskTotal: 1000, NetTotalUp: 100, NetTotalDown: 100}, + "n2": {Online: false, CPU: 50, RAM: 500, RAMTotal: 1000, Disk: 500, DiskTotal: 1000, NetTotalUp: 1000, NetTotalDown: 2000}, + "n3": {Online: true, CPU: 95, RAM: 900, RAMTotal: 1000, Disk: 100, DiskTotal: 1000, NetTotalUp: 500, NetTotalDown: 500}, + }, + } + return app +} + +func TestNodeSearchMatchesNameRegionTagsAndIP(t *testing.T) { + app := testNodeViewApp() + for _, tc := range []struct { + query string + want string + }{ + {query: "alp", want: "n1"}, + {query: "london", want: "n2"}, + {query: "cache", want: "n3"}, + {query: "2001:db8", want: "n2"}, + } { + app.searchQuery = tc.query + nodes := app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != tc.want { + t.Fatalf("query %q nodes = %#v, want %s", tc.query, nodes, tc.want) + } + } +} + +func TestNodeFilters(t *testing.T) { + app := testNodeViewApp() + + app.nodeFilter = nodeFilterOffline + nodes := app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != "n2" { + t.Fatalf("offline nodes = %#v, want n2", nodes) + } + + app.nodeFilter = nodeFilterHighLoad + nodes = app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != "n3" { + t.Fatalf("high-load nodes = %#v, want n3", nodes) + } + + app.nodeFilter = nodeFilterExpiring + nodes = app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != "n2" { + t.Fatalf("expiring nodes = %#v, want n2", nodes) + } +} + +func TestCycleNodeFilterDoesNotEnterEmptyFilter(t *testing.T) { + app := NewWithOptions(nil, Options{WarnCPU: 90, WarnRAM: 85, WarnDisk: 90, WarnExpiryDays: 7}) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{ + {UUID: "n1", Name: "alpha", MemTotal: 1000, DiskTotal: 1000}, + {UUID: "n2", Name: "beta", MemTotal: 1000, DiskTotal: 1000}, + }, + Status: map[string]komari.Status{ + "n1": {Online: true, CPU: 20, RAM: 200, RAMTotal: 1000, Disk: 200, DiskTotal: 1000}, + "n2": {Online: true, CPU: 30, RAM: 300, RAMTotal: 1000, Disk: 300, DiskTotal: 1000}, + }, + } + + app.handleKey(context.Background(), keyEvent{name: "filter", text: "v"}) + if app.nodeFilter != nodeFilterAll { + t.Fatalf("nodeFilter = %q, want all when every special filter is empty", app.nodeFilter) + } + if len(app.viewNodes()) != 2 { + t.Fatalf("visible nodes = %d, want full list", len(app.viewNodes())) + } + if app.notice == "" { + t.Fatal("notice should explain that special filters have no matches") + } +} + +func TestCycleNodeFilterSkipsEmptyFilters(t *testing.T) { + app := NewWithOptions(nil, Options{WarnCPU: 90, WarnRAM: 85, WarnDisk: 90, WarnExpiryDays: 7}) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{ + {UUID: "n1", Name: "alpha", MemTotal: 1000, DiskTotal: 1000}, + {UUID: "n2", Name: "beta", MemTotal: 1000, DiskTotal: 1000}, + }, + Status: map[string]komari.Status{ + "n1": {Online: true, CPU: 20, RAM: 200, RAMTotal: 1000, Disk: 200, DiskTotal: 1000}, + "n2": {Online: true, CPU: 95, RAM: 300, RAMTotal: 1000, Disk: 300, DiskTotal: 1000}, + }, + } + + app.handleKey(context.Background(), keyEvent{name: "filter", text: "v"}) + if app.nodeFilter != nodeFilterHighLoad { + t.Fatalf("nodeFilter = %q, want high-load after skipping empty filters", app.nodeFilter) + } + nodes := app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != "n2" { + t.Fatalf("high-load nodes = %#v, want n2", nodes) + } +} + +func TestNodeSorts(t *testing.T) { + app := testNodeViewApp() + + app.nodeSort = nodeSortCPU + nodes := app.viewNodes() + if nodes[0].UUID != "n3" { + t.Fatalf("CPU sort first = %s, want n3", nodes[0].UUID) + } + + app.nodeSort = nodeSortTraffic + nodes = app.viewNodes() + if nodes[0].UUID != "n2" { + t.Fatalf("traffic sort first = %s, want n2", nodes[0].UUID) + } + + app.nodeSort = nodeSortExpiry + nodes = app.viewNodes() + if nodes[0].UUID != "n2" { + t.Fatalf("expiry sort first = %s, want n2", nodes[0].UUID) + } +} + +func TestSearchEditingTreatsCommandKeysAsText(t *testing.T) { + app := testNodeViewApp() + + app.handleKey(context.Background(), keyEvent{name: "search", text: "/"}) + app.handleKey(context.Background(), keyEvent{name: "quit", text: "q"}) + app.handleKey(context.Background(), keyEvent{name: "settings", text: "s"}) + app.handleKey(context.Background(), keyEvent{name: "open", text: "o"}) + app.handleKey(context.Background(), keyEvent{name: "open"}) + + if app.quit || app.settings { + t.Fatalf("search input triggered command: quit=%t settings=%t", app.quit, app.settings) + } + if app.searchQuery != "qso" { + t.Fatalf("searchQuery = %q, want qso", app.searchQuery) + } +} + +func TestSearchEditingRendersVisibleInputLine(t *testing.T) { + app := testNodeViewApp() + app.style.NoColor = true + app.handleKey(context.Background(), keyEvent{name: "search", text: "/"}) + app.handleKey(context.Background(), keyEvent{name: "char", text: "alpha"}) + + lines := app.renderListBody(80, 8) + if len(lines) == 0 { + t.Fatal("renderListBody returned no lines") + } + if !strings.Contains(lines[0], "Search") || !strings.Contains(lines[0], "alpha|") { + t.Fatalf("search input line = %q, want visible search draft with cursor", lines[0]) + } + + app.handleKey(context.Background(), keyEvent{name: "open"}) + lines = app.renderListBody(80, 8) + if !strings.Contains(lines[0], "Search") || !strings.Contains(lines[0], "alpha") { + t.Fatalf("active search line = %q, want visible applied search", lines[0]) + } +} + +func TestQuitBacksOutOfSearchResultsBeforeExiting(t *testing.T) { + app := testNodeViewApp() + app.searchQuery = "beta" + + nodes := app.viewNodes() + if len(nodes) != 1 || nodes[0].UUID != "n2" { + t.Fatalf("search nodes = %#v, want n2 only", nodes) + } + + app.handleKey(context.Background(), keyEvent{name: "open"}) + if !app.detail { + t.Fatal("open should enter detail") + } + + app.handleKey(context.Background(), keyEvent{name: "quit", text: "q"}) + if app.detail { + t.Fatal("first q should leave detail") + } + if app.searchQuery != "beta" { + t.Fatalf("searchQuery = %q, want beta after leaving detail", app.searchQuery) + } + + app.handleKey(context.Background(), keyEvent{name: "quit", text: "q"}) + if app.quit { + t.Fatal("second q should clear search results instead of exiting") + } + if app.searchQuery != "" { + t.Fatalf("searchQuery = %q, want cleared", app.searchQuery) + } + if len(app.viewNodes()) != len(app.snapshot.Nodes) { + t.Fatalf("visible nodes = %d, want full list %d", len(app.viewNodes()), len(app.snapshot.Nodes)) + } + + app.handleKey(context.Background(), keyEvent{name: "quit", text: "q"}) + if !app.quit { + t.Fatal("third q from full list should exit") + } +} diff --git a/internal/tui/panel.go b/internal/tui/panel.go index a827902..f42ccc7 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -26,11 +26,11 @@ func (a *App) panelLines(width int, maxLines int) []string { } func (a *App) overviewLines(width int, maxLines int) []string { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { lines := []string{a.style.bold("Overview")} return limitLines(append(lines, "No data yet."), width, maxLines) } - node := a.snapshot.Nodes[a.selected] st := a.snapshot.Status[node.UUID] ramTotal := firstNonZero(st.RAMTotal, node.MemTotal) diskTotal := firstNonZero(st.DiskTotal, node.DiskTotal) @@ -60,10 +60,10 @@ func (a *App) overviewLines(width int, maxLines int) []string { } func (a *App) detailLines(width int, maxLines int) []string { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { return []string{} } - node := a.snapshot.Nodes[a.selected] st := a.snapshot.Status[node.UUID] lines := []string{a.style.bold("Node")} @@ -123,10 +123,10 @@ func (a *App) detailLines(width int, maxLines int) []string { } func (a *App) historyLines(width int, maxLines int) []string { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { return nil } - node := a.snapshot.Nodes[a.selected] window := detailWindows[a.window] detail := a.currentDetail(node.UUID) lines := []string{a.style.bold(fmt.Sprintf("History: %s window %s", a.text(node.Name), window.Label))} @@ -179,10 +179,10 @@ func (a *App) historyLines(width int, maxLines int) []string { } func (a *App) pingLines(width int, maxLines int) []string { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { return nil } - node := a.snapshot.Nodes[a.selected] st := a.snapshot.Status[node.UUID] window := detailWindows[a.window] detail := a.currentDetail(node.UUID) @@ -242,10 +242,10 @@ func (a *App) pingLines(width int, maxLines int) []string { } func (a *App) metaLines(width int, maxLines int) []string { - if len(a.snapshot.Nodes) == 0 { + node, ok := a.selectedNode() + if !ok { return nil } - node := a.snapshot.Nodes[a.selected] st := a.snapshot.Status[node.UUID] window := detailWindows[a.window] detail := a.currentDetail(node.UUID) diff --git a/internal/tui/render.go b/internal/tui/render.go index 5a75ec7..bec276b 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -40,13 +40,31 @@ func (a *App) render() { lines = append(lines, a.renderSettingsBody(drawWidth, bodyHeight)...) } else if a.detail { lines = append(lines, a.renderDetailBody(drawWidth, bodyHeight)...) - } else if a.mode == ModeLine { - lines = append(lines, a.renderLineBody(drawWidth, bodyHeight)...) } else { - lines = append(lines, a.renderSheetBody(drawWidth, bodyHeight)...) + lines = append(lines, a.renderListBody(drawWidth, bodyHeight)...) } lines = append(lines, a.footerLine(drawWidth)) a.writeFrame(&b, width, height, lines) fmt.Print(b.String()) } + +func (a *App) renderListBody(width int, bodyHeight int) []string { + if bodyHeight <= 0 { + return nil + } + remaining := bodyHeight + lines := make([]string, 0, bodyHeight) + if a.listSearchVisible() { + lines = append(lines, a.listSearchLine(width)) + remaining-- + } + if remaining > 0 { + if a.mode == ModeLine { + lines = append(lines, a.renderLineBody(width, remaining)...) + } else { + lines = append(lines, a.renderSheetBody(width, remaining)...) + } + } + return fillBody(lines, width, bodyHeight) +} diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 7406655..8affecc 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -24,6 +24,10 @@ const ( settingsChartYAxis settingsASCII settingsNoColor + settingsWarnCPU + settingsWarnRAM + settingsWarnDisk + settingsWarnExpiryDays ) func (a *App) renderSettingsBody(width int, bodyHeight int) []string { @@ -68,6 +72,10 @@ func (a *App) settingsItems() []settingsItem { {Label: "chart_y_axis", Value: a.chartYAxisModeText(), Kind: settingsChartYAxis}, {Label: "ascii", Value: boolText(a.style.ASCII), Kind: settingsASCII}, {Label: "no_color", Value: boolText(a.style.NoColor), Kind: settingsNoColor}, + {Label: "warn_cpu", Value: percentSettingText(a.warnCPU), Kind: settingsWarnCPU}, + {Label: "warn_ram", Value: percentSettingText(a.warnRAM), Kind: settingsWarnRAM}, + {Label: "warn_disk", Value: percentSettingText(a.warnDisk), Kind: settingsWarnDisk}, + {Label: "warn_expiry_days", Value: fmt.Sprintf("%d", a.warnExpiryDays), Kind: settingsWarnExpiryDays}, } } @@ -150,6 +158,14 @@ func (a *App) adjustSelectedSetting(delta int) { a.style.ASCII = !a.style.ASCII case settingsNoColor: a.style.NoColor = !a.style.NoColor + case settingsWarnCPU: + a.warnCPU = adjustedPercent(a.warnCPU, delta) + case settingsWarnRAM: + a.warnRAM = adjustedPercent(a.warnRAM, delta) + case settingsWarnDisk: + a.warnDisk = adjustedPercent(a.warnDisk, delta) + case settingsWarnExpiryDays: + a.warnExpiryDays = adjustedExpiryDays(a.warnExpiryDays, delta) } a.persistSettings() } @@ -167,6 +183,10 @@ func (a *App) persistSettings() { ChartYAxisMode: string(a.chartYAxisMode), ASCII: a.style.ASCII, NoColor: a.style.NoColor, + WarnCPU: a.warnCPU, + WarnRAM: a.warnRAM, + WarnDisk: a.warnDisk, + WarnExpiryDays: a.warnExpiryDays, }) if err != nil { a.settingsStatus = "save failed: " + err.Error() @@ -219,6 +239,48 @@ func adjustedRealtimePoints(current int, delta int) int { return presets[0] } +func adjustedPercent(current float64, delta int) float64 { + presets := []float64{50, 60, 70, 75, 80, 85, 90, 95, 100} + if delta == 0 { + delta = 1 + } + if delta > 0 { + for _, preset := range presets { + if preset > current { + return preset + } + } + return presets[len(presets)-1] + } + for i := len(presets) - 1; i >= 0; i-- { + if presets[i] < current { + return presets[i] + } + } + return presets[0] +} + +func adjustedExpiryDays(current int, delta int) int { + presets := []int{1, 3, 7, 14, 30, 60, 90} + if delta == 0 { + delta = 1 + } + if delta > 0 { + for _, preset := range presets { + if preset > current { + return preset + } + } + return presets[len(presets)-1] + } + for i := len(presets) - 1; i >= 0; i-- { + if presets[i] < current { + return presets[i] + } + } + return presets[0] +} + func (a *App) toggleChartYAxisMode() { if a.chartYAxisMode == chartYAxisRelative { a.chartYAxisMode = chartYAxisAbsolute @@ -240,3 +302,10 @@ func boolText(value bool) string { } return "false" } + +func percentSettingText(value float64) string { + if value == float64(int(value)) { + return fmt.Sprintf("%.0f%%", value) + } + return fmt.Sprintf("%.1f%%", value) +} diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 5f4b138..21eec18 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -21,9 +21,9 @@ func TestSettingsKeyOpensSettingsPage(t *testing.T) { t.Fatal("settings page was not opened") } - lines := app.renderSettingsBody(80, 12) + lines := app.renderSettingsBody(80, 18) joined := strings.Join(lines, "\n") - for _, label := range []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color"} { + for _, label := range []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color", "warn_cpu", "warn_ram", "warn_disk", "warn_expiry_days"} { if !strings.Contains(joined, label) { t.Fatalf("settings body missing %s: %#v", label, lines) } @@ -37,7 +37,7 @@ func TestSettingsItemsIncludeAllConfigFields(t *testing.T) { for _, item := range items { got = append(got, item.Label) } - want := []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color"} + want := []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color", "warn_cpu", "warn_ram", "warn_disk", "warn_expiry_days"} if strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("settings labels = %#v, want %#v", got, want) } @@ -120,6 +120,18 @@ func TestSettingsAdjustConfigFields(t *testing.T) { if !app.style.NoColor || !saved.NoColor { t.Fatalf("no_color = %t saved=%t, want true", app.style.NoColor, saved.NoColor) } + + selectSetting(t, app, "warn_cpu") + app.handleSettingsKey(keyEvent{name: "tab-right"}) + if app.warnCPU != 95 || saved.WarnCPU != 95 { + t.Fatalf("warn_cpu = %.1f saved=%.1f, want 95", app.warnCPU, saved.WarnCPU) + } + + selectSetting(t, app, "warn_expiry_days") + app.handleSettingsKey(keyEvent{name: "tab-right"}) + if app.warnExpiryDays != 14 || saved.WarnExpiryDays != 14 { + t.Fatalf("warn_expiry_days = %d saved=%d, want 14", app.warnExpiryDays, saved.WarnExpiryDays) + } } func TestSettingsBodyContainsCoreEditableItems(t *testing.T) { @@ -191,6 +203,25 @@ func TestSettingsPersistAfterAdjustment(t *testing.T) { } } +func TestSettingsASCIIShortcutPersists(t *testing.T) { + var saved PersistentSettings + app := NewWithOptions(nil, Options{ + SaveSettings: func(settings PersistentSettings) error { + saved = settings + return nil + }, + }) + app.settings = true + + app.handleSettingsKey(keyEvent{name: "ascii"}) + if !app.style.ASCII || !saved.ASCII { + t.Fatalf("ascii = %t saved=%t, want true", app.style.ASCII, saved.ASCII) + } + if app.settingsStatus != "saved" { + t.Fatalf("settingsStatus = %q, want saved", app.settingsStatus) + } +} + func TestNewWithOptionsUsesChartYAxisMode(t *testing.T) { app := NewWithOptions(nil, Options{ChartYAxisMode: "relative"}) if app.chartYAxisMode != chartYAxisRelative { diff --git a/internal/tui/sheet.go b/internal/tui/sheet.go index 58a3248..09f851c 100644 --- a/internal/tui/sheet.go +++ b/internal/tui/sheet.go @@ -12,10 +12,14 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { if bodyHeight <= 0 { return nil } - if len(a.snapshot.Nodes) == 0 { + nodes := a.viewNodes() + if len(nodes) == 0 { if a.loading { return fillBody([]string{"Loading nodes..."}, width, bodyHeight) } + if len(a.snapshot.Nodes) > 0 { + return fillBody([]string{"No nodes match the current search/filter."}, width, bodyHeight) + } return fillBody([]string{"No nodes returned by Komari."}, width, bodyHeight) } @@ -30,7 +34,7 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { if selectedRow >= a.scroll+rowsVisible { a.scroll = selectedRow - rowsVisible + 1 } - maxScroll := max(0, (len(a.snapshot.Nodes)+columns-1)/columns-rowsVisible) + maxScroll := max(0, (len(nodes)+columns-1)/columns-rowsVisible) if a.scroll > maxScroll { a.scroll = maxScroll } @@ -40,16 +44,16 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { lines := make([]string, 0, bodyHeight) startRow := a.scroll - endRow := min((len(a.snapshot.Nodes)+columns-1)/columns, startRow+rowsVisible) + endRow := min((len(nodes)+columns-1)/columns, startRow+rowsVisible) for row := startRow; row < endRow; row++ { rowCards := make([][]string, 0, columns) for col := 0; col < columns; col++ { index := row*columns + col - if index >= len(a.snapshot.Nodes) { + if index >= len(nodes) { rowCards = append(rowCards, emptyCard(cardWidth, cardHeight)) continue } - rowCards = append(rowCards, a.nodeCard(index, a.snapshot.Nodes[index], cardWidth, cardHeight)) + rowCards = append(rowCards, a.nodeCard(index, nodes[index], cardWidth, cardHeight)) } for lineIndex := 0; lineIndex < cardHeight; lineIndex++ { var line strings.Builder @@ -70,11 +74,12 @@ func (a *App) nodeCard(index int, node komari.Node, width int, height int) []str height = 5 } st := a.snapshot.Status[node.UUID] + alert := a.alertForNode(node, st, time.Now()) contentHeight := height - 2 body := a.overviewCard(index, node, st, max(0, width-2), contentHeight) lines := make([]string, 0, height) - lines = append(lines, a.cardTop(width, index == a.selected)) + lines = append(lines, a.cardTopForAlert(width, index == a.selected, alert)) for i := 0; i < contentHeight; i++ { content := "" if i < len(body) { @@ -83,12 +88,14 @@ func (a *App) nodeCard(index int, node komari.Node, width int, height int) []str line := a.style.boxLine(content, width) if index == a.selected && i == 0 && !a.style.NoColor { line = a.style.bold(line) + } else if alert.Critical || alert.Warning { + line = a.styleAlertLine(line, alert) } else if !st.Online { line = a.style.dim(line) } lines = append(lines, line) } - lines = append(lines, a.cardBottom(width, index == a.selected)) + lines = append(lines, a.cardBottomForAlert(width, index == a.selected, alert)) return lines } @@ -102,6 +109,7 @@ func (a *App) overviewCard(index int, node komari.Node, st komari.Status, width marker = ">" } title := fmt.Sprintf(" %s %-*s %s", marker, nameWidth, cleanLine(a.nodeLabel(node), nameWidth), status) + alert := a.alertForNode(node, st, time.Now()) lines := []string{ title, a.metricLine("CPU", st.CPU, "load "+fmt.Sprintf("%.2f %.2f %.2f", st.Load, st.Load5, st.Load15), width), @@ -111,6 +119,9 @@ func (a *App) overviewCard(index int, node komari.Node, st komari.Status, width fmt.Sprintf(" FLOW %s %s %s %s", a.style.up(), bytesIEC(st.NetTotalUp), a.style.down(), bytesIEC(st.NetTotalDown)), fmt.Sprintf(" UP %-10s EXP %s", durationCompact(st.Uptime), expiryText(node, time.Now())), } + if len(alert.Reasons) > 0 { + lines = append([]string{title, fmt.Sprintf(" WARN %s", alertText(alert))}, lines[1:]...) + } if node.Tags != "" || node.Group != "" { lines = append(lines, fmt.Sprintf(" OS %s TAG %s %s", valueOr(a.text(node.OS), "-"), valueOr(a.text(node.Group), "-"), valueOr(a.text(node.Tags), "-"))) } @@ -119,15 +130,32 @@ func (a *App) overviewCard(index int, node komari.Node, st komari.Status, width func (a *App) metricLine(label string, pct float64, value string, width int) string { barWidth := min(12, max(6, width-30)) - return fmt.Sprintf(" %-4s %5.1f%% %s %s", label, pct, a.usageBar(pct, barWidth), value) + return fmt.Sprintf(" %-4s %5.1f%% %s %s", label, pct, a.usageBarFor(label, pct, barWidth), value) } func (a *App) usageBar(pct float64, width int) string { + return a.usageBarWithThreshold(pct, 90, width) +} + +func (a *App) usageBarFor(label string, pct float64, width int) string { + threshold := 90.0 + switch strings.ToUpper(strings.TrimSpace(label)) { + case "CPU": + threshold = a.warnCPU + case "RAM": + threshold = a.warnRAM + case "DSK", "DISK": + threshold = a.warnDisk + } + return a.usageBarWithThreshold(pct, threshold, width) +} + +func (a *App) usageBarWithThreshold(pct float64, threshold float64, width int) string { bar := a.style.miniBar(pct, width) switch { - case pct >= 90: + case pct >= threshold: return a.style.red(bar) - case pct >= 75: + case pct >= threshold*0.85: return a.style.yellow(bar) default: return a.style.cyan(bar) @@ -148,17 +176,31 @@ func (a *App) statusPill(online bool) string { } func (a *App) cardTop(width int, selected bool) string { + return a.cardTopForAlert(width, selected, nodeAlert{}) +} + +func (a *App) cardTopForAlert(width int, selected bool, alert nodeAlert) string { line := a.style.boxTop(width) if selected { return a.style.cyan(line) } + if alert.Critical || alert.Warning { + return a.styleAlertLine(line, alert) + } return a.style.dim(line) } func (a *App) cardBottom(width int, selected bool) string { + return a.cardBottomForAlert(width, selected, nodeAlert{}) +} + +func (a *App) cardBottomForAlert(width int, selected bool, alert nodeAlert) string { line := a.style.boxBottom(width) if selected { return a.style.cyan(line) } + if alert.Critical || alert.Warning { + return a.styleAlertLine(line, alert) + } return a.style.dim(line) }