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