feat: add node search alerts and onboarding improvements
CI / test (push) Successful in 47s
Release / goreleaser (push) Successful in 2m6s

This commit is contained in:
gary committed 2026-07-02 21:45:02 +08:00
1 parent 8e3d899e12
commit f29cfa04e8
30 files changed
+2336 -186

No files matched your search

+8
View File
@@ -63,6 +63,14 @@ release:
prerelease: auto prerelease: auto
mode: replace mode: replace
header: | header: |
## 本次更新重点
- 新增首次启动配置向导:未配置 URL 时可直接在终端输入 Komari 地址和可选 API key,并保存到配置文件。
- 新增节点搜索、排序和过滤:支持 `/` 搜索节点名、地区、标签、分组、IP、OS 和 UUID,支持按状态、CPU、RAM、流量和过期时间排序,并可快速筛选离线、即将过期和高负载节点。
- 新增异常高亮和阈值配置:离线、CPU/RAM/磁盘超过阈值、过期或即将过期会在列表和详情页高亮;阈值可通过配置文件、`ktui config set` 或设置页调整。
- 改进详情页图表体验:支持点击图表或按 `f` 进入聚焦模式,并可在聚焦图表间切换。
- 改进终端交互:搜索结果页支持逐级返回,过滤会跳过没有结果的条件,底部操作和鼠标点击行为更稳定。
本 Release 由 GoReleaser 自动生成。 本 Release 由 GoReleaser 自动生成。
请下载与你的系统和架构匹配的 ktui 压缩包,并使用 checksums.txt 校验 SHA256。 请下载与你的系统和架构匹配的 ktui 压缩包,并使用 checksums.txt 校验 SHA256。
+15 -2
View File
@@ -2,7 +2,7 @@
`ktui` 是一个用于查看 [Komari](https://github.com/komari-monitor/komari) 探针监控状态的终端 TUI 工具。它可以让你不用打开浏览器,直接在终端里查看服务器在线状态、资源占用、流量、Ping、节点详情和历史趋势。 `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` - 详情页标签:`overview`、`node`、`history`、`ping`、`meta`
- 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d` - 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d`
- History 坐标图:CPU、RAM、磁盘、网络、连接数、进程数 - History 坐标图:CPU、RAM、磁盘、网络、连接数、进程数
- 详情页图表聚焦模式:点击图表或按 `f` 可用整页查看单张图
- Ping 数据:实时 Ping 和历史 Ping 汇总 - Ping 数据:实时 Ping 和历史 Ping 汇总
- 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情
- 支持配置文件,默认持久化到系统用户配置目录 - 支持配置文件,默认持久化到系统用户配置目录
@@ -131,7 +132,7 @@ ktui update
./ktui config init ./ktui config init
``` ```
首次使用建议先设置 URL: 首次启动未配置 URL 时会自动进入引导。也可以提前手动设置 URL:
```sh ```sh
./ktui config set url https://komari.example.com ./ktui config set url https://komari.example.com
@@ -175,6 +176,10 @@ KTUI_CONFIG=/path/to/config.json ./ktui
"timeout": "10s", "timeout": "10s",
"realtime_points": 0, "realtime_points": 0,
"chart_y_axis": "absolute", "chart_y_axis": "absolute",
"warn_cpu": 90,
"warn_ram": 85,
"warn_disk": 90,
"warn_expiry_days": 7,
"mode": "sheet", "mode": "sheet",
"ascii": false, "ascii": false,
"no_color": false "no_color": false
@@ -195,6 +200,8 @@ KTUI_API_KEY=your_api_key ./ktui
KTUI_MODE=line ./ktui KTUI_MODE=line ./ktui
KTUI_REALTIME_POINTS=150 ./ktui KTUI_REALTIME_POINTS=150 ./ktui
KTUI_CHART_Y_AXIS=relative ./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 KTUI_ASCII=1 NO_COLOR=1 ./ktui
``` ```
@@ -250,6 +257,10 @@ EXP -
- `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片 - `↓` / `j`:列表页选择下一个节点;详情页向下滚动一张卡片
- 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项 - 鼠标/触控板滚轮:列表页切换选择;详情页滚动;设置页切换设置项
- 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回 - 鼠标点击:列表页打开节点详情;底部命令可直接点击;详情页点击底部 `Back` 返回,点击标签/时间窗口切换;设置页选择设置项并可点击底部调整/返回
- `/`:编辑节点搜索,匹配名称、地区、标签、分组、IP、OS 和 UUID;`Enter` 应用,`Esc` 取消
- `c`:循环排序:默认、在线状态、CPU、RAM、累计流量、过期时间
- `v`:循环过滤:全部、离线、即将过期、高负载
- 详情页图表:点击图表或按 `f` 聚焦,`h` / `l` 或 `PgUp` / `PgDn` 切换聚焦图表,`Esc` / `b` / `q` / `Enter` 返回详情页
- `PgUp` / `PgDn`:快速滚动列表或详情页 - `PgUp` / `PgDn`:快速滚动列表或详情页
- `Enter` / `o`:打开选中节点的详情页 - `Enter` / `o`:打开选中节点的详情页
- `Esc` / `b` / `q`:从详情页返回列表页 - `Esc` / `b` / `q`:从详情页返回列表页
@@ -262,6 +273,8 @@ EXP -
- `u`:有新版本时显示更新命令 - `u`:有新版本时显示更新命令
- `q` / `Ctrl-C`:在列表页退出 - `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` 或设置页调整。
## 版本信息 ## 版本信息
查看当前二进制版本: 查看当前二进制版本:
+153 -18
View File
@@ -1,10 +1,12 @@
package main package main
import ( import (
"bufio"
"context" "context"
"encoding/json" "encoding/json"
"flag" "flag"
"fmt" "fmt"
"io"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -130,7 +132,16 @@ func main() {
mode = tui.ModeSheet mode = tui.ModeSheet
} }
if strings.TrimSpace(baseURL) == "" { 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}) client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout})
@@ -157,6 +168,10 @@ func main() {
DetailTimeout: timeout, DetailTimeout: timeout,
RealtimePoints: realtimePoints, RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis, ChartYAxisMode: chartYAxis,
WarnCPU: cfg.WarnCPU,
WarnRAM: cfg.WarnRAM,
WarnDisk: cfg.WarnDisk,
WarnExpiryDays: cfg.WarnExpiryDays,
SaveSettings: saveTUISettings, SaveSettings: saveTUISettings,
CheckUpdate: checkSoftwareUpdate, CheckUpdate: checkSoftwareUpdate,
ASCII: ascii, ASCII: ascii,
@@ -192,6 +207,34 @@ func applyEnv(cfg config.Config) config.Config {
if value := os.Getenv("KTUI_CHART_Y_AXIS"); value != "" { if value := os.Getenv("KTUI_CHART_Y_AXIS"); value != "" {
cfg.ChartYAxis = strings.ToLower(strings.TrimSpace(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 != "" { if value := os.Getenv("KTUI_MODE"); value != "" {
cfg.Mode = value cfg.Mode = value
} }
@@ -216,10 +259,81 @@ func saveTUISettings(settings tui.PersistentSettings) error {
cfg.ChartYAxis = settings.ChartYAxisMode cfg.ChartYAxis = settings.ChartYAxisMode
cfg.ASCII = settings.ASCII cfg.ASCII = settings.ASCII
cfg.NoColor = settings.NoColor cfg.NoColor = settings.NoColor
cfg.WarnCPU = settings.WarnCPU
cfg.WarnRAM = settings.WarnRAM
cfg.WarnDisk = settings.WarnDisk
cfg.WarnExpiryDays = settings.WarnExpiryDays
_, err = config.Save(cfg) _, err = config.Save(cfg)
return err 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) { func checkSoftwareUpdate(ctx context.Context) (tui.UpdateCheckResult, error) {
result, err := update.Check(ctx, update.Options{ result, err := update.Check(ctx, update.Options{
CurrentVersion: version, CurrentVersion: version,
@@ -454,9 +568,14 @@ Keys:
timeout HTTP timeout, for example 10s timeout HTTP timeout, for example 10s
realtime-points realtime-points
realtime chart sample limit, 0 auto realtime chart sample limit, 0 auto
chart-y-axis chart-y-axis
percent chart Y axis mode: absolute or relative percent chart Y axis mode: absolute or relative
mode sheet or line 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 ascii true or false
no-color true or false no-color true or false
@@ -469,13 +588,16 @@ func printKeysHelp() {
fmt.Print(`ktui keys fmt.Print(`ktui keys
List layer: List layer:
Up/k, Down/j select server Up/k, Down/j select server
Mouse wheel select previous/next server Mouse wheel select previous/next server
Mouse click open server detail Mouse click open server detail
Footer click settings/mode/refresh/ascii/quit Footer click search/sort/filter/settings/mode/refresh/ascii/quit
PgUp, PgDn jump faster PgUp, PgDn jump faster
Enter/o open selected server detail / edit node search
s open settings 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 m switch line/sheet mode
r refresh now r refresh now
d open or reload selected server detail data d open or reload selected server detail data
@@ -486,6 +608,8 @@ List layer:
Detail layer: Detail layer:
Esc, b, q return to list layer Esc, b, q return to list layer
Mouse click Back 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 h/l, 1-5, Tab switch detail tabs
[, ] switch time window [, ] switch time window
Up/k, Down/j scroll one card Up/k, Down/j scroll one card
@@ -496,14 +620,25 @@ Detail layer:
u show update command when an update is available u show update command when an update is available
PgUp, PgDn scroll faster 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: Settings layer:
Esc, q, s return to previous layer Esc, q, s return to previous layer
Up/k, Down/j select setting Up/k, Down/j select setting
Mouse wheel/click select setting Mouse wheel/click select setting
Footer click back/adjust/toggle Footer click back/adjust/toggle
Left/h, Right/l adjust value Left/h, Right/l adjust value
Enter toggle or advance 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 { func envBool(key string) bool {
+82 -1
View File
@@ -1,6 +1,13 @@
package main package main
import "testing" import (
"bytes"
"path/filepath"
"strings"
"testing"
"ktui/internal/config"
)
func TestSplitConfigArgSeparateValue(t *testing.T) { func TestSplitConfigArgSeparateValue(t *testing.T) {
args, path := splitConfigArg([]string{"--config", "/tmp/ktui.json", "config", "show"}) 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") 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()
}
+25
View File
@@ -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
}
+25
View File
@@ -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
}
+58
View File
@@ -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)
}
}
+89
View File
@@ -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
}
+43 -12
View File
@@ -42,16 +42,48 @@ detect_arch() {
} }
json_value() { 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() { asset_url() {
name="$1" name="$1"
printf '%s' "$release_json" | if command -v jq >/dev/null 2>&1; then
tr '{' '\n' | printf '%s' "$release_json" |
grep "\"name\"[[:space:]]*:[[:space:]]*\"$name\"" | jq -r --arg name "$name" '
sed -n 's/.*"browser_download_url"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | .assets[]? |
head -n 1 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() { checksum_for() {
@@ -85,8 +117,10 @@ verify_sha256() {
need curl need curl
need tar need tar
need awk need awk
need sed if ! command -v jq >/dev/null 2>&1; then
need grep need sed
need grep
fi
os="$(detect_os)" os="$(detect_os)"
arch="$(detect_arch)" arch="$(detect_arch)"
@@ -102,10 +136,7 @@ release_json="$(http_get "$endpoint")"
tag="$(json_value "$release_json" tag_name)" tag="$(json_value "$release_json" tag_name)"
[ -n "$tag" ] || fail "release response does not include tag_name" [ -n "$tag" ] || fail "release response does not include tag_name"
asset_name="$(printf '%s' "$release_json" | asset_name="$(release_asset_name)"
tr '{' '\n' |
sed -n "s/.*\"name\"[[:space:]]*:[[:space:]]*\"\(ktui_[^\"]*_${os}_${arch}\.tar\.gz\)\".*/\1/p" |
head -n 1)"
[ -n "$asset_name" ] || fail "release $tag does not contain an asset for $os/$arch" [ -n "$asset_name" ] || fail "release $tag does not contain an asset for $os/$arch"
archive_url="$(asset_url "$asset_name")" archive_url="$(asset_url "$asset_name")"
+82 -8
View File
@@ -22,18 +22,26 @@ type Config struct {
Timeout string `json:"timeout"` Timeout string `json:"timeout"`
Mode string `json:"mode"` Mode string `json:"mode"`
// RealtimePoints limits realtime chart samples. 0 keeps the auto strategy. // RealtimePoints limits realtime chart samples. 0 keeps the auto strategy.
RealtimePoints int `json:"realtime_points,omitempty"` RealtimePoints int `json:"realtime_points,omitempty"`
ASCII bool `json:"ascii"` ASCII bool `json:"ascii"`
NoColor bool `json:"no_color"` NoColor bool `json:"no_color"`
ChartYAxis string `json:"chart_y_axis"` 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 { func Default() Config {
return Config{ return Config{
Interval: "5s", Interval: "5s",
Timeout: "10s", Timeout: "10s",
Mode: "sheet", Mode: "sheet",
ChartYAxis: "absolute", ChartYAxis: "absolute",
WarnCPU: 90,
WarnRAM: 85,
WarnDisk: 90,
WarnExpiryDays: 7,
} }
} }
@@ -108,6 +116,18 @@ func (c Config) WithDefaults() Config {
if strings.TrimSpace(c.ChartYAxis) == "" { if strings.TrimSpace(c.ChartYAxis) == "" {
c.ChartYAxis = defaults.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 return c
} }
@@ -131,6 +151,18 @@ func (c Config) Validate() error {
default: default:
return fmt.Errorf("invalid config chart_y_axis %q: use absolute or relative", c.ChartYAxis) 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 return nil
} }
@@ -178,6 +210,30 @@ func Set(cfg Config, key string, value string) (Config, error) {
default: default:
return cfg, fmt.Errorf("invalid chart_y_axis %q", value) 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": case "ascii":
parsed, err := parseBool(value) parsed, err := parseBool(value)
if err != nil { if err != nil {
@@ -197,6 +253,24 @@ func Set(cfg Config, key string, value string) (Config, error) {
return cfg, cfg.Validate() 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) { func parseBool(value string) (bool, error) {
parsed, err := strconv.ParseBool(value) parsed, err := strconv.ParseBool(value)
if err == nil { if err == nil {
+37
View File
@@ -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) { func TestValidateRejectsNegativeRealtimePoints(t *testing.T) {
cfg := Default() cfg := Default()
cfg.RealtimePoints = -1 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) { func TestValidateRejectsInvalidChartYAxis(t *testing.T) {
cfg := Default() cfg := Default()
cfg.ChartYAxis = "fixed" cfg.ChartYAxis = "fixed"
+63
View File
@@ -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, " ")
}
+63 -3
View File
@@ -25,6 +25,8 @@ type App struct {
tab int tab int
window int window int
detail bool detail bool
chartFocus bool
chartFocusIndex int
settings bool settings bool
cardStep int cardStep int
settingsWasDetail bool settingsWasDetail bool
@@ -33,6 +35,15 @@ type App struct {
settingsURL string settingsURL string
settingsAPIKey string settingsAPIKey string
chartYAxisMode chartYAxisMode chartYAxisMode chartYAxisMode
warnCPU float64
warnRAM float64
warnDisk float64
warnExpiryDays int
searchEditing bool
searchQuery string
searchDraft string
nodeFilter nodeFilterMode
nodeSort nodeSortMode
notice string notice string
snapshot komari.Snapshot snapshot komari.Snapshot
@@ -69,6 +80,10 @@ type Options struct {
DetailCacheTTL time.Duration DetailCacheTTL time.Duration
RealtimePoints int RealtimePoints int
ChartYAxisMode string ChartYAxisMode string
WarnCPU float64
WarnRAM float64
WarnDisk float64
WarnExpiryDays int
SaveSettings func(PersistentSettings) error SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error) CheckUpdate func(context.Context) (UpdateCheckResult, error)
ASCII bool ASCII bool
@@ -91,6 +106,10 @@ type PersistentSettings struct {
ChartYAxisMode string ChartYAxisMode string
ASCII bool ASCII bool
NoColor bool NoColor bool
WarnCPU float64
WarnRAM float64
WarnDisk float64
WarnExpiryDays int
} }
type Mode string type Mode string
@@ -107,6 +126,26 @@ const (
chartYAxisRelative chartYAxisMode = "relative" 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 { type nodeDetail struct {
UUID string UUID string
Window int Window int
@@ -150,6 +189,7 @@ type detailKey struct {
type keyEvent struct { type keyEvent struct {
name string name string
text string
x int x int
y int y int
} }
@@ -230,6 +270,18 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
if opts.Mode == "" { if opts.Mode == "" {
opts.Mode = ModeSheet 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{ return &App{
client: client, client: client,
refreshInterval: opts.RefreshInterval, refreshInterval: opts.RefreshInterval,
@@ -242,6 +294,12 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
settingsAPIKey: opts.APIKey, settingsAPIKey: opts.APIKey,
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor}, style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor},
mode: opts.Mode, mode: opts.Mode,
warnCPU: opts.WarnCPU,
warnRAM: opts.WarnRAM,
warnDisk: opts.WarnDisk,
warnExpiryDays: opts.WarnExpiryDays,
nodeFilter: nodeFilterAll,
nodeSort: nodeSortDefault,
renderCh: make(chan struct{}, 1), renderCh: make(chan struct{}, 1),
refreshCh: make(chan struct{}, 2), refreshCh: make(chan struct{}, 2),
resultCh: make(chan fetchResult, 2), resultCh: make(chan fetchResult, 2),
@@ -296,6 +354,7 @@ func (a *App) Run(ctx context.Context) error {
a.fetch(ctx) a.fetch(ctx)
case result := <-a.resultCh: case result := <-a.resultCh:
pending := a.refreshPending pending := a.refreshPending
selectedUUID := a.selectedNodeUUID()
a.refreshPending = false a.refreshPending = false
a.loading = false a.loading = false
a.fetching = false a.fetching = false
@@ -306,7 +365,7 @@ func (a *App) Run(ctx context.Context) error {
a.lastFullFetch = result.snapshot.FetchedAt a.lastFullFetch = result.snapshot.FetchedAt
} }
a.recordRealtimeSnapshot(result.snapshot, result.snapshot.FetchedAt) a.recordRealtimeSnapshot(result.snapshot, result.snapshot.FetchedAt)
a.clampSelection() a.restoreSelection(selectedUUID)
if a.detail { if a.detail {
a.ensureSelectedDetail(ctx) 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) { func (a *App) ensureSelectedDetail(ctx context.Context) {
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
return return
} }
a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, false) a.fetchDetail(ctx, node.UUID, false)
} }
func (a *App) requestRefresh() { func (a *App) requestRefresh() {
+2 -2
View File
@@ -100,9 +100,9 @@ func TestUpdateFooterClickShowsHint(t *testing.T) {
app.update.Available = true app.update.Available = true
app.update.Latest = "v0.2.0" app.update.Latest = "v0.2.0"
_, height := terminalSize() _, height := terminalSize()
x, _, ok := footerLabelBounds(app.footerText(), "u update") x, ok := footerActionPosition(app, footerUpdate)
if !ok { 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}) app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
+19 -4
View File
@@ -70,6 +70,14 @@ func (a *App) percentChart(section detailSection) detailSection {
} }
func (a *App) axisChartLines(chart axisChart, width int, height int) []string { 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 { if height <= 0 {
return nil return nil
} }
@@ -85,7 +93,11 @@ func (a *App) axisChartLines(chart axisChart, width int, height int) []string {
break 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) return limitRawLines(lines, height)
} }
@@ -100,7 +112,11 @@ func (a *App) axisChartLines(chart axisChart, width int, height int) []string {
for _, item := range series { for _, item := range series {
seriesPoints = append(seriesPoints, chartPointsForValues(chart, item.Values, plotWidth)) 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) rows := chartYAxisRows(minVal, maxVal, plotRows, chart.Unit, labelWidth)
out := make([]string, 0, height) 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)) out = append(out, fitLine(b.String(), width))
} }
axis := chartXAxisLine(chart.From, chart.To, width, labelWidth, a.style.ASCII) out = append(out, chartXAxisLines(chart, width, labelWidth, a.style.ASCII, detailedXAxis)...)
out = append(out, axis)
return limitRawLines(out, height) return limitRawLines(out, height)
} }
+131 -26
View File
@@ -3,6 +3,7 @@ package tui
import ( import (
"fmt" "fmt"
"strings" "strings"
"time"
"ktui/internal/komari" "ktui/internal/komari"
) )
@@ -11,7 +12,8 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string {
if bodyHeight <= 0 { if bodyHeight <= 0 {
return nil return nil
} }
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
if a.loading { if a.loading {
return fillBody([]string{"Loading nodes..."}, width, bodyHeight) return fillBody([]string{"Loading nodes..."}, width, bodyHeight)
} }
@@ -19,8 +21,10 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string {
} }
a.clampSelection() a.clampSelection()
node := a.snapshot.Nodes[a.selected]
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
if a.chartFocus {
return a.renderFocusedChartBody(node, st, width, bodyHeight)
}
chrome := a.detailChromeLines(node, st, width) chrome := a.detailChromeLines(node, st, width)
contentHeight := bodyHeight - len(chrome) contentHeight := bodyHeight - len(chrome)
if contentHeight < 1 { if contentHeight < 1 {
@@ -56,8 +60,30 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string {
return fillBody(lines, width, bodyHeight) 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 { func (a *App) detailChromeLines(node komari.Node, st komari.Status, width int) []string {
lines := make([]string, 0, 6) lines := make([]string, 0, 6)
alert := a.alertForNode(node, st, time.Now())
title := fmt.Sprintf(" %s %s %s region %s seen %s", title := fmt.Sprintf(" %s %s %s region %s seen %s",
a.text(node.Name), a.text(node.Name),
a.statusPill(st.Online), a.statusPill(st.Online),
@@ -66,6 +92,9 @@ func (a *App) detailChromeLines(node komari.Node, st komari.Status, width int) [
shortTimeFromNull(st.Time), shortTimeFromNull(st.Time),
) )
lines = append(lines, a.style.bold(fitLine(title, width))) 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.detailMetricStrip(node, st, width))
lines = append(lines, a.detailTabLine(width)) lines = append(lines, a.detailTabLine(width))
lines = append(lines, a.detailWindowLine(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 barWidth = 8
} }
parts := []string{ parts := []string{
fmt.Sprintf(" CPU %5.1f%% %s", st.CPU, a.usageBar(st.CPU, barWidth)), fmt.Sprintf(" CPU %5.1f%% %s", st.CPU, a.usageBarFor("CPU", st.CPU, barWidth)),
fmt.Sprintf(" RAM %5.1f%% %s", ramPct, a.usageBar(ramPct, barWidth)), fmt.Sprintf(" RAM %5.1f%% %s", ramPct, a.usageBarFor("RAM", ramPct, barWidth)),
fmt.Sprintf(" DSK %5.1f%% %s", diskPct, a.usageBar(diskPct, barWidth)), fmt.Sprintf(" DSK %5.1f%% %s", diskPct, a.usageBarFor("DSK", diskPct, barWidth)),
} }
if width >= 104 { 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))) 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 { if len(sections) == 0 {
return []string{fitLine("", width)} return []string{fitLine("", width)}
} }
if width >= 96 { return a.detailSectionGrid(sections, width, detailGridColumns(width), cardHeight)
return a.detailSectionGrid(sections, width, 2, cardHeight)
}
return a.detailSectionGrid(sections, width, 1, cardHeight)
} }
func (a *App) detailSectionGrid(sections []detailSection, width int, columns int, cardHeight int) []string { func (a *App) detailSectionGrid(sections []detailSection, width int, columns int, cardHeight int) []string {
gap := 2
if cardHeight < detailCardHeight { if cardHeight < detailCardHeight {
cardHeight = detailCardHeight cardHeight = detailCardHeight
} }
if columns < 1 { columns, cardWidth := detailGridLayout(width, columns)
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
}
lines := make([]string, 0, len(sections)*(cardHeight+1)) lines := make([]string, 0, len(sections)*(cardHeight+1))
for row := 0; row < len(sections); row += columns { 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 var line strings.Builder
for col, card := range rowCards { for col, card := range rowCards {
if col > 0 { if col > 0 {
line.WriteString(strings.Repeat(" ", gap)) line.WriteString(strings.Repeat(" ", detailGridGap))
} }
line.WriteString(card[lineIndex]) line.WriteString(card[lineIndex])
} }
@@ -185,6 +197,33 @@ func (a *App) detailSectionGrid(sections []detailSection, width int, columns int
return lines 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 { func detailCardHeightFor(contentHeight int) int {
switch { switch {
case contentHeight >= 45: 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 { func (a *App) overviewSections(node komari.Node, st komari.Status) []detailSection {
ramTotal := firstNonZero(st.RAMTotal, node.MemTotal) ramTotal := firstNonZero(st.RAMTotal, node.MemTotal)
diskTotal := firstNonZero(st.DiskTotal, node.DiskTotal) 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 { 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 { func (a *App) sparkSection(title string, values []float64) detailSection {
+88
View File
@@ -1,6 +1,7 @@
package tui package tui
import ( import (
"context"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestHistoryMetricSectionsMatchWebLoadChartSet(t *testing.T) {
app := NewWithOptions(nil, Options{ASCII: true}) app := NewWithOptions(nil, Options{ASCII: true})
node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} 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) { func TestAxisChartLinesShowMoreFixedPercentTicksWhenTall(t *testing.T) {
app := NewWithOptions(nil, Options{ASCII: true}) app := NewWithOptions(nil, Options{ASCII: true})
lines := app.axisChartLines(axisChart{ lines := app.axisChartLines(axisChart{
+97 -19
View File
@@ -90,9 +90,14 @@ func (a *App) headerLines(width int) []string {
contextParts := []string{ contextParts := []string{
a.headerChip("view", "list"), a.headerChip("view", "list"),
a.headerChip("mode", string(a.mode)+"/"+mode), 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("komari", valueOr(a.snapshot.Version.Version, "-")),
a.headerChip("rpc", valueOr(a.snapshot.RPCVersion, "-")), 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 { if a.settings {
contextParts = []string{ contextParts = []string{
a.headerChip("view", "settings"), a.headerChip("view", "settings"),
@@ -102,11 +107,15 @@ func (a *App) headerLines(width int) []string {
} }
} else if a.detail { } else if a.detail {
nodeName := "-" nodeName := "-"
if len(a.snapshot.Nodes) > 0 { if node, ok := a.selectedNode(); ok {
nodeName = a.nodeLabel(a.snapshot.Nodes[a.selected]) nodeName = a.nodeLabel(node)
}
view := "detail"
if a.chartFocus {
view = "chart"
} }
contextParts = []string{ contextParts = []string{
a.headerChip("view", "detail"), a.headerChip("view", view),
a.headerChip("node", nodeName), a.headerChip("node", nodeName),
a.headerChip("komari", valueOr(a.snapshot.Version.Version, "-")), a.headerChip("komari", valueOr(a.snapshot.Version.Version, "-")),
a.headerChip("rpc", valueOr(a.snapshot.RPCVersion, "-")), 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 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 { func headerAlign(left string, right string, width int) string {
left = cleanLine(left, width) left = cleanLine(left, width)
right = cleanLine(right, width) right = cleanLine(right, width)
@@ -173,6 +226,14 @@ func (a *App) footerTextForWidth(width int) string {
} }
func (a *App) footerItems() []footerItem { 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 { if a.settings {
return []footerItem{ return []footerItem{
{Action: footerBack, Labels: [footerLabelVariants]string{"Esc/q back", "Back", "B"}}, {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"}}, {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 { if a.detail {
items := []footerItem{ items := []footerItem{
{Action: footerBack, Labels: [footerLabelVariants]string{"Back(Esc/q)", "Back", "B"}}, {Action: footerBack, Labels: [footerLabelVariants]string{"Back(Esc/q)", "Back", "B"}},
@@ -198,6 +268,9 @@ func (a *App) footerItems() []footerItem {
items := []footerItem{ items := []footerItem{
{Action: footerSelect, Labels: [footerLabelVariants]string{"↑↓/jk select", "Select", "J"}}, {Action: footerSelect, Labels: [footerLabelVariants]string{"↑↓/jk select", "Select", "J"}},
{Action: footerOpen, Labels: [footerLabelVariants]string{"Enter detail", "Detail", "O"}}, {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: footerSettings, Labels: [footerLabelVariants]string{"s settings", "Settings", "S"}},
{Action: footerMode, Labels: [footerLabelVariants]string{"m mode", "Mode", "M"}}, {Action: footerMode, Labels: [footerLabelVariants]string{"m mode", "Mode", "M"}},
{Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}}, {Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}},
@@ -213,21 +286,26 @@ func (a *App) footerItems() []footerItem {
type footerAction string type footerAction string
const ( const (
footerNone footerAction = "" footerNone footerAction = ""
footerSelect footerAction = "select" footerSelect footerAction = "select"
footerOpen footerAction = "open" footerOpen footerAction = "open"
footerSettings footerAction = "settings" footerSettings footerAction = "settings"
footerMode footerAction = "mode" footerMode footerAction = "mode"
footerRefresh footerAction = "refresh" footerRefresh footerAction = "refresh"
footerASCII footerAction = "ascii" footerASCII footerAction = "ascii"
footerQuit footerAction = "quit" footerQuit footerAction = "quit"
footerBack footerAction = "back" footerSearch footerAction = "search"
footerAdjust footerAction = "adjust" footerSort footerAction = "sort"
footerToggle footerAction = "toggle" footerFilter footerAction = "filter"
footerTabs footerAction = "tabs" footerBack footerAction = "back"
footerWindow footerAction = "window" footerPrevChart footerAction = "prev-chart"
footerScroll footerAction = "scroll" footerNextChart footerAction = "next-chart"
footerUpdate footerAction = "update" footerAdjust footerAction = "adjust"
footerToggle footerAction = "toggle"
footerTabs footerAction = "tabs"
footerWindow footerAction = "window"
footerScroll footerAction = "scroll"
footerUpdate footerAction = "update"
) )
const footerLabelVariants = 3 const footerLabelVariants = 3
@@ -308,7 +386,7 @@ func (a *App) adjustScroll(visibleRows int) {
if a.scroll < 0 { if a.scroll < 0 {
a.scroll = 0 a.scroll = 0
} }
maxScroll := len(a.snapshot.Nodes) - visibleRows maxScroll := len(a.viewNodes()) - visibleRows
if maxScroll < 0 { if maxScroll < 0 {
maxScroll = 0 maxScroll = 0
} }
+161 -37
View File
@@ -5,6 +5,8 @@ import (
"io" "io"
"os" "os"
"strconv" "strconv"
"strings"
"unicode/utf8"
) )
func (a *App) readKeys(ctx context.Context) { func (a *App) readKeys(ctx context.Context) {
@@ -50,45 +52,55 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
case 3: case 3:
out = append(out, keyEvent{name: "force-quit"}) out = append(out, keyEvent{name: "force-quit"})
case 'q', 'Q': case 'q', 'Q':
out = append(out, keyEvent{name: "quit"}) out = append(out, keyEvent{name: "quit", text: string(data[i])})
case 'r', 'R': case 'r', 'R':
out = append(out, keyEvent{name: "refresh"}) out = append(out, keyEvent{name: "refresh", text: string(data[i])})
case '\r', '\n': case '\r', '\n':
out = append(out, keyEvent{name: "open"}) out = append(out, keyEvent{name: "open"})
case 'd', 'D': 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': case 'a', 'A':
out = append(out, keyEvent{name: "ascii"}) out = append(out, keyEvent{name: "ascii", text: string(data[i])})
case 'o', 'O': case 'o', 'O':
out = append(out, keyEvent{name: "open"}) out = append(out, keyEvent{name: "open", text: string(data[i])})
case 'b', 'B', 0x7f: case 'b', 'B':
out = append(out, keyEvent{name: "back"}) out = append(out, keyEvent{name: "back", text: string(data[i])})
case 0x7f:
out = append(out, keyEvent{name: "backspace"})
case 'm', 'M': case 'm', 'M':
out = append(out, keyEvent{name: "mode"}) out = append(out, keyEvent{name: "mode", text: string(data[i])})
case 's', 'S': case 's', 'S':
out = append(out, keyEvent{name: "settings"}) out = append(out, keyEvent{name: "settings", text: string(data[i])})
case 'u', 'U': 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': case 'j':
out = append(out, keyEvent{name: "down"}) out = append(out, keyEvent{name: "down", text: string(data[i])})
case 'k': case 'k':
out = append(out, keyEvent{name: "up"}) out = append(out, keyEvent{name: "up", text: string(data[i])})
case 'h': case 'h':
out = append(out, keyEvent{name: "tab-left"}) out = append(out, keyEvent{name: "tab-left", text: string(data[i])})
case 'l': 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': 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': case '\t':
out = append(out, keyEvent{name: "tab-right"}) out = append(out, keyEvent{name: "tab-right"})
case 'g': case 'g':
out = append(out, keyEvent{name: "top"}) out = append(out, keyEvent{name: "top", text: string(data[i])})
case 'G': case 'G':
out = append(out, keyEvent{name: "bottom"}) out = append(out, keyEvent{name: "bottom", text: string(data[i])})
case '[': case '[':
out = append(out, keyEvent{name: "window-left"}) out = append(out, keyEvent{name: "window-left", text: string(data[i])})
case ']': case ']':
out = append(out, keyEvent{name: "window-right"}) out = append(out, keyEvent{name: "window-right", text: string(data[i])})
case 0x1b: case 0x1b:
if i+2 < len(data) && data[i+1] == '[' { if i+2 < len(data) && data[i+1] == '[' {
if data[i+2] == '<' { if data[i+2] == '<' {
@@ -133,6 +145,19 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
} else { } else {
out = append(out, keyEvent{name: "back"}) 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 return out, nil
@@ -203,6 +228,10 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
} }
return return
} }
if a.searchEditing {
a.handleSearchKey(key)
return
}
if a.settings { if a.settings {
a.handleSettingsKey(key) a.handleSettingsKey(key)
return return
@@ -211,37 +240,65 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
case "force-quit": case "force-quit":
a.quit = true a.quit = true
case "quit": case "quit":
if a.detail { if a.chartFocus {
a.closeChartFocus()
} else if a.detail {
a.detail = false a.detail = false
a.scroll = 0 a.scroll = 0
} else if a.clearListScope() {
// q backs out of a narrowed list before it quits the app.
} else { } else {
a.quit = true a.quit = true
} }
case "back": case "back", "backspace":
if a.detail { if a.chartFocus {
a.closeChartFocus()
} else if a.detail {
a.detail = false a.detail = false
a.scroll = 0 a.scroll = 0
} else {
a.clearListScope()
} }
case "settings": case "settings":
a.openSettings() a.openSettings()
case "update-hint": case "update-hint":
a.showUpdateHint() 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": 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.detail = true
a.scroll = 0 a.scroll = 0
a.ensureSelectedDetail(ctx) a.ensureSelectedDetail(ctx)
} }
case "chart-focus":
a.focusChart(0)
case "refresh": case "refresh":
a.requestFullRefresh() a.requestFullRefresh()
if a.detail && len(a.snapshot.Nodes) > 0 { if a.detail {
a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) if node, ok := a.selectedNode(); ok {
a.fetchDetail(ctx, node.UUID, true)
}
} }
case "detail-refresh": case "detail-refresh":
if len(a.snapshot.Nodes) > 0 { if node, ok := a.selectedNode(); ok {
a.detail = true a.detail = true
a.scroll = 0 a.scroll = 0
a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) a.fetchDetail(ctx, node.UUID, true)
} }
case "ascii": case "ascii":
a.style.ASCII = !a.style.ASCII a.style.ASCII = !a.style.ASCII
@@ -254,25 +311,33 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
} }
} }
case "up": 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() a.scroll -= a.detailScrollStep()
} else { } else {
a.selected-- a.selected--
} }
case "down": 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() a.scroll += a.detailScrollStep()
} else { } else {
a.selected++ a.selected++
} }
case "pageup": case "pageup":
if a.detail { if a.chartFocus {
a.moveChartFocus(-1)
} else if a.detail {
a.scroll -= a.detailScrollStep() * 3 a.scroll -= a.detailScrollStep() * 3
} else { } else {
a.selected -= 10 a.selected -= 10
} }
case "pagedown": case "pagedown":
if a.detail { if a.chartFocus {
a.moveChartFocus(1)
} else if a.detail {
a.scroll += a.detailScrollStep() * 3 a.scroll += a.detailScrollStep() * 3
} else { } else {
a.selected += 10 a.selected += 10
@@ -287,32 +352,47 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
if a.detail { if a.detail {
a.scroll = 1 << 30 a.scroll = 1 << 30
} else { } else {
a.selected = len(a.snapshot.Nodes) - 1 a.selected = len(a.viewNodes()) - 1
} }
case "tab-left": 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.tab = (a.tab + len(tabNames) - 1) % len(tabNames)
a.scroll = 0 a.scroll = 0
a.closeChartFocus()
} }
case "tab-right": case "tab-right":
if a.detail { if a.chartFocus {
a.moveChartFocus(1)
} else if a.detail {
a.tab = (a.tab + 1) % len(tabNames) a.tab = (a.tab + 1) % len(tabNames)
a.scroll = 0 a.scroll = 0
a.closeChartFocus()
} }
case "tab-1", "tab-2", "tab-3", "tab-4", "tab-5": case "tab-1", "tab-2", "tab-3", "tab-4", "tab-5":
if a.detail { if a.detail {
a.tab = int(key.name[len(key.name)-1] - '1') a.tab = int(key.name[len(key.name)-1] - '1')
a.scroll = 0 a.scroll = 0
a.closeChartFocus()
} }
case "window-left": case "window-left":
if a.detail { if a.chartFocus {
a.window = (a.window + len(detailWindows) - 1) % len(detailWindows) a.window = (a.window + len(detailWindows) - 1) % len(detailWindows)
a.scroll = 0 a.scroll = 0
} else if a.detail {
a.window = (a.window + len(detailWindows) - 1) % len(detailWindows)
a.scroll = 0
a.closeChartFocus()
} }
case "window-right": case "window-right":
if a.detail { if a.chartFocus {
a.window = (a.window + 1) % len(detailWindows) a.window = (a.window + 1) % len(detailWindows)
a.scroll = 0 a.scroll = 0
} else if a.detail {
a.window = (a.window + 1) % len(detailWindows)
a.scroll = 0
a.closeChartFocus()
} }
} }
a.clampSelection() 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 { func isMouseKey(name string) bool {
switch name { switch name {
case "mouse-left", "mouse-wheel-up", "mouse-wheel-down", "mouse-ignore": case "mouse-left", "mouse-wheel-up", "mouse-wheel-down", "mouse-ignore":
@@ -353,6 +476,7 @@ func (a *App) handleSettingsKey(key keyEvent) {
a.adjustSelectedSetting(1) a.adjustSelectedSetting(1)
case "ascii": case "ascii":
a.style.ASCII = !a.style.ASCII a.style.ASCII = !a.style.ASCII
a.persistSettings()
} }
} }
@@ -368,7 +492,7 @@ func (a *App) detailScrollStep() int {
} }
func (a *App) clampSelection() { func (a *App) clampSelection() {
count := len(a.snapshot.Nodes) count := len(a.viewNodes())
if count == 0 { if count == 0 {
a.selected = 0 a.selected = 0
a.scroll = 0 a.scroll = 0
+21 -6
View File
@@ -12,10 +12,14 @@ func (a *App) renderLineBody(width int, bodyHeight int) []string {
if bodyHeight <= 0 { if bodyHeight <= 0 {
return nil return nil
} }
if len(a.snapshot.Nodes) == 0 { nodes := a.viewNodes()
if len(nodes) == 0 {
if a.loading { if a.loading {
return fillBody([]string{"Loading nodes..."}, width, bodyHeight) 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) 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 := make([]string, 0, bodyHeight)
lines = append(lines, header...) 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++ { 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) 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 { func (a *App) lineTableHeader(width int) []string {
title := a.lineTableColumns(width, false, komari.Node{}, komari.Status{}, false) title := a.lineTableColumns(width, false, komari.Node{}, komari.Status{}, false)
return []string{ 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(title, width)),
a.style.dim(fitLine(a.style.separator(width), 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 { if index == a.selected {
return a.style.inverse(fitLine(line, width)) 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 { if !st.Online {
return a.style.dim(fitLine(line, width)) 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 { if selected {
marker = ">" marker = ">"
} }
alert := a.alertForNode(node, st, time.Now())
state := "on" state := "on"
if !st.Online { if !st.Online {
state = "off" state = "off"
} else if len(alert.Reasons) > 0 {
state = "!"
} }
if !a.style.ASCII { if !a.style.ASCII {
if st.Online { if len(alert.Reasons) > 0 && st.Online {
state = "!"
} else if st.Online {
state = "●" state = "●"
} else { } else {
state = "●" state = "●"
} }
if !selected { if !selected {
if st.Online { if alert.Critical || alert.Warning {
state = a.styleAlertLine(state, alert)
} else if st.Online {
state = a.style.green(state) state = a.style.green(state)
} else { } else {
state = a.style.red(state) state = a.style.red(state)
+140 -3
View File
@@ -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) prefix := strings.Repeat(" ", labelWidth)
if ascii { if ascii {
prefix += "+" prefix += "+"
@@ -426,11 +426,148 @@ func chartXAxisLine(from, to string, width int, labelWidth int, ascii bool) stri
axis = strings.Repeat("─", plotWidth) axis = strings.Repeat("─", plotWidth)
} }
line := prefix + axis 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 { if displayWidth(label) <= plotWidth {
line = prefix + padRight(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 { func appendStatusSample(records []komari.Status, current komari.Status) []komari.Status {
+116 -10
View File
@@ -65,16 +65,28 @@ func (a *App) handleMouseClick(ctx context.Context, x int, y int) {
if a.clickDetailBack(x, y) { if a.clickDetailBack(x, y) {
return return
} }
if a.chartFocus {
return
}
if y <= mouseHeaderRows { if y <= mouseHeaderRows {
return return
} }
if a.clickDetailChart(x, y) {
return
}
a.clickDetailChrome(ctx, x, y) a.clickDetailChrome(ctx, x, y)
return return
} }
if y <= mouseHeaderRows { if y <= mouseHeaderRows {
return 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 return
} }
if a.mode == ModeLine { if a.mode == ModeLine {
@@ -94,6 +106,14 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
drawWidth-- drawWidth--
} }
action := a.footerActionAt(x, 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 { if a.settings {
switch action { switch action {
case footerBack: case footerBack:
@@ -108,14 +128,22 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
if a.detail { if a.detail {
switch action { switch action {
case footerBack: case footerBack:
a.detail = false if a.chartFocus {
a.scroll = 0 a.closeChartFocus()
} else {
a.detail = false
a.scroll = 0
}
case footerPrevChart:
a.moveChartFocus(-1)
case footerNextChart:
a.moveChartFocus(1)
case footerSettings: case footerSettings:
a.openSettings() a.openSettings()
case footerRefresh: case footerRefresh:
a.requestFullRefresh() a.requestFullRefresh()
if len(a.snapshot.Nodes) > 0 { if node, ok := a.selectedNode(); ok {
a.fetchDetail(ctx, a.snapshot.Nodes[a.selected].UUID, true) a.fetchDetail(ctx, node.UUID, true)
} }
case footerUpdate: case footerUpdate:
a.showUpdateHint() a.showUpdateHint()
@@ -127,6 +155,12 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
switch action { switch action {
case footerOpen: case footerOpen:
a.openSelectedDetail(ctx) a.openSelectedDetail(ctx)
case footerSearch:
a.openSearch()
case footerSort:
a.cycleNodeSort()
case footerFilter:
a.cycleNodeFilter()
case footerSettings: case footerSettings:
a.openSettings() a.openSettings()
case footerMode: case footerMode:
@@ -165,13 +199,13 @@ func (a *App) clickSettings(y int) {
} }
func (a *App) clickLine(ctx context.Context, y int) { func (a *App) clickLine(ctx context.Context, y int) {
bodyRow := y - mouseHeaderRows - 1 bodyRow := y - mouseHeaderRows - 1 - a.listSearchRows()
row := bodyRow - lineHeaderRows row := bodyRow - lineHeaderRows
if row < 0 { if row < 0 {
return return
} }
index := a.scroll + row index := a.scroll + row
if index >= 0 && index < len(a.snapshot.Nodes) { if index >= 0 && index < len(a.viewNodes()) {
a.selected = index a.selected = index
a.clampSelection() a.clampSelection()
a.openSelectedDetail(ctx) a.openSelectedDetail(ctx)
@@ -185,11 +219,12 @@ func (a *App) clickSheet(ctx context.Context, x int, y int) {
drawWidth-- drawWidth--
} }
bodyHeight := height - mouseHeaderRows - 1 bodyHeight := height - mouseHeaderRows - 1
bodyHeight -= a.listSearchRows()
if bodyHeight < 1 { if bodyHeight < 1 {
bodyHeight = 1 bodyHeight = 1
} }
columns, cardWidth := sheetLayout(drawWidth) columns, cardWidth := sheetLayout(drawWidth)
bodyRow := y - mouseHeaderRows - 1 bodyRow := y - mouseHeaderRows - 1 - a.listSearchRows()
if bodyRow < 0 { if bodyRow < 0 {
return return
} }
@@ -212,7 +247,7 @@ func (a *App) clickSheet(ctx context.Context, x int, y int) {
return return
} }
index := (a.scroll+row)*columns + col 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.selected = index
a.clampSelection() a.clampSelection()
a.openSelectedDetail(ctx) 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) { func (a *App) openSelectedDetail(ctx context.Context) {
if len(a.snapshot.Nodes) == 0 { if len(a.viewNodes()) == 0 {
return return
} }
a.detail = true a.detail = true
@@ -242,6 +277,7 @@ func (a *App) openSettings() {
a.settingsWasDetail = a.detail a.settingsWasDetail = a.detail
a.settings = true a.settings = true
a.detail = false a.detail = false
a.closeChartFocus()
a.scroll = 0 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) { func sheetLayout(width int) (int, int) {
columns := max(1, (width+sheetCardGap)/(sheetMinWidth+sheetCardGap)) columns := max(1, (width+sheetCardGap)/(sheetMinWidth+sheetCardGap))
for columns > 1 { for columns > 1 {
+104 -9
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"strings" "strings"
"testing" "testing"
"time"
"ktui/internal/komari" "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) { func TestMouseClickDetailFooterBackReturnsToList(t *testing.T) {
app := NewWithOptions(nil, Options{}) app := NewWithOptions(nil, Options{})
app.detail = true 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) { func TestMouseClickListFooterActions(t *testing.T) {
app := NewWithOptions(nil, Options{}) app := NewWithOptions(nil, Options{})
_, height := terminalSize() _, height := terminalSize()
clickFooterLabel := func(label string) { clickFooterAction := func(action footerAction) {
t.Helper() t.Helper()
x, _, ok := footerLabelBounds(app.footerText(), label) x, ok := footerActionPosition(app, action)
if !ok { 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}) app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
} }
clickFooterLabel("s settings") clickFooterAction(footerSettings)
if !app.settings { if !app.settings {
t.Fatal("clicking settings footer action should open settings") t.Fatal("clicking settings footer action should open settings")
} }
app.closeSettings() app.closeSettings()
clickFooterLabel("m mode") clickFooterAction(footerMode)
if app.mode != ModeLine { if app.mode != ModeLine {
t.Fatalf("mode = %s, want line", app.mode) t.Fatalf("mode = %s, want line", app.mode)
} }
clickFooterLabel("r refresh") clickFooterAction(footerRefresh)
select { select {
case <-app.refreshCh: case <-app.refreshCh:
default: default:
t.Fatal("clicking refresh footer action should request refresh") t.Fatal("clicking refresh footer action should request refresh")
} }
clickFooterLabel("a ascii") clickFooterAction(footerASCII)
if !app.style.ASCII { if !app.style.ASCII {
t.Fatal("clicking ascii footer action should toggle ASCII mode") t.Fatal("clicking ascii footer action should toggle ASCII mode")
} }
clickFooterLabel("q quit") clickFooterAction(footerQuit)
if !app.quit { if !app.quit {
t.Fatal("clicking quit footer action should set 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) { func TestFooterFoldsInsteadOfClippingActions(t *testing.T) {
app := NewWithOptions(nil, Options{}) app := NewWithOptions(nil, Options{})
app.update.Available = true app.update.Available = true
@@ -133,7 +228,7 @@ func TestFooterFoldsInsteadOfClippingActions(t *testing.T) {
if displayWidth(footer) > 24 { if displayWidth(footer) > 24 {
t.Fatalf("footer width = %d, want <= 24: %q", displayWidth(footer), footer) 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) { if !strings.Contains(footer, label) {
t.Fatalf("folded footer %q missing %q", footer, label) t.Fatalf("folded footer %q missing %q", footer, label)
} }
+311
View File
@@ -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, " ")
}
+217
View File
@@ -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")
}
}
+10 -10
View File
@@ -26,11 +26,11 @@ func (a *App) panelLines(width int, maxLines int) []string {
} }
func (a *App) overviewLines(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")} lines := []string{a.style.bold("Overview")}
return limitLines(append(lines, "No data yet."), width, maxLines) return limitLines(append(lines, "No data yet."), width, maxLines)
} }
node := a.snapshot.Nodes[a.selected]
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
ramTotal := firstNonZero(st.RAMTotal, node.MemTotal) ramTotal := firstNonZero(st.RAMTotal, node.MemTotal)
diskTotal := firstNonZero(st.DiskTotal, node.DiskTotal) 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 { func (a *App) detailLines(width int, maxLines int) []string {
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
return []string{} return []string{}
} }
node := a.snapshot.Nodes[a.selected]
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
lines := []string{a.style.bold("Node")} 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 { func (a *App) historyLines(width int, maxLines int) []string {
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
return nil return nil
} }
node := a.snapshot.Nodes[a.selected]
window := detailWindows[a.window] window := detailWindows[a.window]
detail := a.currentDetail(node.UUID) detail := a.currentDetail(node.UUID)
lines := []string{a.style.bold(fmt.Sprintf("History: %s window %s", a.text(node.Name), window.Label))} 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 { func (a *App) pingLines(width int, maxLines int) []string {
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
return nil return nil
} }
node := a.snapshot.Nodes[a.selected]
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
window := detailWindows[a.window] window := detailWindows[a.window]
detail := a.currentDetail(node.UUID) 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 { func (a *App) metaLines(width int, maxLines int) []string {
if len(a.snapshot.Nodes) == 0 { node, ok := a.selectedNode()
if !ok {
return nil return nil
} }
node := a.snapshot.Nodes[a.selected]
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
window := detailWindows[a.window] window := detailWindows[a.window]
detail := a.currentDetail(node.UUID) detail := a.currentDetail(node.UUID)
+21 -3
View File
@@ -40,13 +40,31 @@ func (a *App) render() {
lines = append(lines, a.renderSettingsBody(drawWidth, bodyHeight)...) lines = append(lines, a.renderSettingsBody(drawWidth, bodyHeight)...)
} else if a.detail { } else if a.detail {
lines = append(lines, a.renderDetailBody(drawWidth, bodyHeight)...) lines = append(lines, a.renderDetailBody(drawWidth, bodyHeight)...)
} else if a.mode == ModeLine {
lines = append(lines, a.renderLineBody(drawWidth, bodyHeight)...)
} else { } else {
lines = append(lines, a.renderSheetBody(drawWidth, bodyHeight)...) lines = append(lines, a.renderListBody(drawWidth, bodyHeight)...)
} }
lines = append(lines, a.footerLine(drawWidth)) lines = append(lines, a.footerLine(drawWidth))
a.writeFrame(&b, width, height, lines) a.writeFrame(&b, width, height, lines)
fmt.Print(b.String()) 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)
}
+69
View File
@@ -24,6 +24,10 @@ const (
settingsChartYAxis settingsChartYAxis
settingsASCII settingsASCII
settingsNoColor settingsNoColor
settingsWarnCPU
settingsWarnRAM
settingsWarnDisk
settingsWarnExpiryDays
) )
func (a *App) renderSettingsBody(width int, bodyHeight int) []string { 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: "chart_y_axis", Value: a.chartYAxisModeText(), Kind: settingsChartYAxis},
{Label: "ascii", Value: boolText(a.style.ASCII), Kind: settingsASCII}, {Label: "ascii", Value: boolText(a.style.ASCII), Kind: settingsASCII},
{Label: "no_color", Value: boolText(a.style.NoColor), Kind: settingsNoColor}, {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 a.style.ASCII = !a.style.ASCII
case settingsNoColor: case settingsNoColor:
a.style.NoColor = !a.style.NoColor 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() a.persistSettings()
} }
@@ -167,6 +183,10 @@ func (a *App) persistSettings() {
ChartYAxisMode: string(a.chartYAxisMode), ChartYAxisMode: string(a.chartYAxisMode),
ASCII: a.style.ASCII, ASCII: a.style.ASCII,
NoColor: a.style.NoColor, NoColor: a.style.NoColor,
WarnCPU: a.warnCPU,
WarnRAM: a.warnRAM,
WarnDisk: a.warnDisk,
WarnExpiryDays: a.warnExpiryDays,
}) })
if err != nil { if err != nil {
a.settingsStatus = "save failed: " + err.Error() a.settingsStatus = "save failed: " + err.Error()
@@ -219,6 +239,48 @@ func adjustedRealtimePoints(current int, delta int) int {
return presets[0] 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() { func (a *App) toggleChartYAxisMode() {
if a.chartYAxisMode == chartYAxisRelative { if a.chartYAxisMode == chartYAxisRelative {
a.chartYAxisMode = chartYAxisAbsolute a.chartYAxisMode = chartYAxisAbsolute
@@ -240,3 +302,10 @@ func boolText(value bool) string {
} }
return "false" return "false"
} }
func percentSettingText(value float64) string {
if value == float64(int(value)) {
return fmt.Sprintf("%.0f%%", value)
}
return fmt.Sprintf("%.1f%%", value)
}
+34 -3
View File
@@ -21,9 +21,9 @@ func TestSettingsKeyOpensSettingsPage(t *testing.T) {
t.Fatal("settings page was not opened") t.Fatal("settings page was not opened")
} }
lines := app.renderSettingsBody(80, 12) lines := app.renderSettingsBody(80, 18)
joined := strings.Join(lines, "\n") 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) { if !strings.Contains(joined, label) {
t.Fatalf("settings body missing %s: %#v", label, lines) t.Fatalf("settings body missing %s: %#v", label, lines)
} }
@@ -37,7 +37,7 @@ func TestSettingsItemsIncludeAllConfigFields(t *testing.T) {
for _, item := range items { for _, item := range items {
got = append(got, item.Label) 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, ",") { if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("settings labels = %#v, want %#v", got, 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 { if !app.style.NoColor || !saved.NoColor {
t.Fatalf("no_color = %t saved=%t, want true", 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) { 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) { func TestNewWithOptionsUsesChartYAxisMode(t *testing.T) {
app := NewWithOptions(nil, Options{ChartYAxisMode: "relative"}) app := NewWithOptions(nil, Options{ChartYAxisMode: "relative"})
if app.chartYAxisMode != chartYAxisRelative { if app.chartYAxisMode != chartYAxisRelative {
+52 -10
View File
@@ -12,10 +12,14 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
if bodyHeight <= 0 { if bodyHeight <= 0 {
return nil return nil
} }
if len(a.snapshot.Nodes) == 0 { nodes := a.viewNodes()
if len(nodes) == 0 {
if a.loading { if a.loading {
return fillBody([]string{"Loading nodes..."}, width, bodyHeight) 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) 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 { if selectedRow >= a.scroll+rowsVisible {
a.scroll = selectedRow - rowsVisible + 1 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 { if a.scroll > maxScroll {
a.scroll = maxScroll a.scroll = maxScroll
} }
@@ -40,16 +44,16 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string {
lines := make([]string, 0, bodyHeight) lines := make([]string, 0, bodyHeight)
startRow := a.scroll 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++ { for row := startRow; row < endRow; row++ {
rowCards := make([][]string, 0, columns) rowCards := make([][]string, 0, columns)
for col := 0; col < columns; col++ { for col := 0; col < columns; col++ {
index := row*columns + col index := row*columns + col
if index >= len(a.snapshot.Nodes) { if index >= len(nodes) {
rowCards = append(rowCards, emptyCard(cardWidth, cardHeight)) rowCards = append(rowCards, emptyCard(cardWidth, cardHeight))
continue 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++ { for lineIndex := 0; lineIndex < cardHeight; lineIndex++ {
var line strings.Builder var line strings.Builder
@@ -70,11 +74,12 @@ func (a *App) nodeCard(index int, node komari.Node, width int, height int) []str
height = 5 height = 5
} }
st := a.snapshot.Status[node.UUID] st := a.snapshot.Status[node.UUID]
alert := a.alertForNode(node, st, time.Now())
contentHeight := height - 2 contentHeight := height - 2
body := a.overviewCard(index, node, st, max(0, width-2), contentHeight) body := a.overviewCard(index, node, st, max(0, width-2), contentHeight)
lines := make([]string, 0, height) 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++ { for i := 0; i < contentHeight; i++ {
content := "" content := ""
if i < len(body) { 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) line := a.style.boxLine(content, width)
if index == a.selected && i == 0 && !a.style.NoColor { if index == a.selected && i == 0 && !a.style.NoColor {
line = a.style.bold(line) line = a.style.bold(line)
} else if alert.Critical || alert.Warning {
line = a.styleAlertLine(line, alert)
} else if !st.Online { } else if !st.Online {
line = a.style.dim(line) line = a.style.dim(line)
} }
lines = append(lines, 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 return lines
} }
@@ -102,6 +109,7 @@ func (a *App) overviewCard(index int, node komari.Node, st komari.Status, width
marker = ">" marker = ">"
} }
title := fmt.Sprintf(" %s %-*s %s", marker, nameWidth, cleanLine(a.nodeLabel(node), nameWidth), status) title := fmt.Sprintf(" %s %-*s %s", marker, nameWidth, cleanLine(a.nodeLabel(node), nameWidth), status)
alert := a.alertForNode(node, st, time.Now())
lines := []string{ lines := []string{
title, title,
a.metricLine("CPU", st.CPU, "load "+fmt.Sprintf("%.2f %.2f %.2f", st.Load, st.Load5, st.Load15), width), 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(" 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())), 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 != "" { 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), "-"))) 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 { func (a *App) metricLine(label string, pct float64, value string, width int) string {
barWidth := min(12, max(6, width-30)) 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 { 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) bar := a.style.miniBar(pct, width)
switch { switch {
case pct >= 90: case pct >= threshold:
return a.style.red(bar) return a.style.red(bar)
case pct >= 75: case pct >= threshold*0.85:
return a.style.yellow(bar) return a.style.yellow(bar)
default: default:
return a.style.cyan(bar) 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 { 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) line := a.style.boxTop(width)
if selected { if selected {
return a.style.cyan(line) return a.style.cyan(line)
} }
if alert.Critical || alert.Warning {
return a.styleAlertLine(line, alert)
}
return a.style.dim(line) return a.style.dim(line)
} }
func (a *App) cardBottom(width int, selected bool) string { 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) line := a.style.boxBottom(width)
if selected { if selected {
return a.style.cyan(line) return a.style.cyan(line)
} }
if alert.Critical || alert.Warning {
return a.styleAlertLine(line, alert)
}
return a.style.dim(line) return a.style.dim(line)
} }