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