Improve TUI about, CLI, and update checks

This commit is contained in:
gary committed 2026-07-06 13:01:05 +08:00
1 parent 079269abfc
commit 67997b7a6c
17 files changed
+1330 -223

No files matched your search

+33 -16
View File
@@ -29,6 +29,7 @@ https://www.nodeseek.com/post-710243-1
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动 - 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口 - 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
- 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端 - 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端
- 支持后台检查 ktui 自身更新和 Komari 服务端新版本提示
- 支持远程一行安装脚本,自动识别系统和 CPU 架构并下载对应 Release - 支持远程一行安装脚本,自动识别系统和 CPU 架构并下载对应 Release
## 安装 ## 安装
@@ -85,6 +86,7 @@ go run ./cmd/ktui
```sh ```sh
./ktui help ./ktui help
./ktui help status
./ktui help config ./ktui help config
./ktui help keys ./ktui help keys
``` ```
@@ -98,14 +100,14 @@ go run ./cmd/ktui
检查或安装更新: 检查或安装更新:
```sh ```sh
./ktui update --check ./ktui update check
./ktui update ./ktui update install
``` ```
进入 TUI 时会自动在后台检查是否有新版本。发现新版本后,界面顶部会提示 `UPDATE ... available`,底部会出现 `u update`;按 `u` 或点击该提示会显示更新命令。实际更新请退出 TUI 后运行: 进入 TUI 时会自动在后台检查 ktui 自身是否有新版本,也会根据当前 Komari 服务端版本检查 `komari-monitor/komari` 的 GitHub Release。发现新版本后,界面顶部会提示 `UPDATE ... available`,底部会出现 `u update`;按 `u` 或点击该提示会显示可用更新详情。ktui 自身更新请退出 TUI 后运行:
```sh ```sh
ktui update ktui update install
``` ```
指定 Komari 地址: 指定 Komari 地址:
@@ -123,19 +125,33 @@ ktui update
只拉取一次并打印摘要,不进入 TUI: 只拉取一次并打印摘要,不进入 TUI:
```sh ```sh
./ktui --once ./ktui status
``` ```
导出当前节点状态: 导出当前节点状态:
```sh ```sh
./ktui export json
./ktui export csv --output nodes.csv
./ktui export markdown -o report.md ./ktui export markdown -o report.md
./ktui export csv --output nodes.csv
./ktui export json
``` ```
导出内容包含站点摘要、在线状态、CPU/RAM/磁盘百分比、实时网络、累计流量、流量上限占比、过期状态和异常原因。 导出内容包含站点摘要、在线状态、CPU/RAM/磁盘百分比、实时网络、累计流量、流量上限占比、过期状态和异常原因。
## 命令结构
```text
ktui [flags]
ktui status [flags]
ktui export <markdown|csv|json> [flags]
ktui config <init|path|show|set|help>
ktui update <check|install>
ktui version
ktui help [status|config|keys|update|export]
```
TUI 专属显示参数使用 `--mode sheet|line`。一次性拉取摘要使用 `ktui status`,更新检查和安装分别使用 `ktui update check`、`ktui update install`。
## 配置文件 ## 配置文件
初始化配置: 初始化配置:
@@ -222,13 +238,13 @@ KTUI_ASCII=1 NO_COLOR=1 ./ktui
卡片视图,默认模式: 卡片视图,默认模式:
```sh ```sh
./ktui --sheet ./ktui --mode sheet
``` ```
逐行列表视图: 逐行列表视图:
```sh ```sh
./ktui --line ./ktui --mode line
``` ```
`line` 模式会根据终端宽度自动增减列。宽屏下会显示 CPU、RAM、磁盘、地区、实时网络、负载、运行时间、累计流量、连接数、进程数、过期时间、系统和标签。 `line` 模式会根据终端宽度自动增减列。宽屏下会显示 CPU、RAM、磁盘、地区、实时网络、负载、运行时间、累计流量、连接数、进程数、过期时间、系统和标签。
@@ -282,7 +298,8 @@ EXP -
- `r`:立即刷新 - `r`:立即刷新
- `d`:打开或重新加载选中节点的详情数据 - `d`:打开或重新加载选中节点的详情数据
- `a`:切换 ASCII 兼容模式 - `a`:切换 ASCII 兼容模式
- `u`:有新版本时显示更新命令 - `u`:有新版本时显示 ktui 或 Komari 服务端更新详情
- `?`:打开 about 视图
- `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` 或设置页调整。 异常高亮阈值可以通过配置文件、`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` 或设置页调整。
@@ -299,12 +316,12 @@ ktui version
## 自更新 ## 自更新
`ktui update` 会从 Gitea Release 下载当前系统和架构对应的压缩包,使用 `checksums.txt` 校验后替换当前二进制。 `ktui update install` 会从 Gitea Release 下载当前系统和架构对应的压缩包,使用 `checksums.txt` 校验后替换当前二进制。只检查新版本时使用 `ktui update check`。
```sh ```sh
ktui update --check ktui update check
ktui update ktui update install
ktui update --tag v0.1.0 ktui update install --tag v0.1.0
``` ```
默认更新源: 默认更新源:
@@ -316,10 +333,10 @@ https://gitea.bytevibe.dev/api/v1/repos/gary/ktui
如果仓库是私有仓库,可以通过环境变量传入 token: 如果仓库是私有仓库,可以通过环境变量传入 token:
```sh ```sh
KTUI_UPDATE_TOKEN=your_token ktui update KTUI_UPDATE_TOKEN=your_token ktui update install
``` ```
Windows 无法在程序运行时直接替换当前 `.exe`,`ktui update` 会下载新文件并提示手动替换。 Windows 无法在程序运行时直接替换当前 `.exe`,`ktui update install` 会下载新文件并提示手动替换。
## 详情页 ## 详情页
+3 -3
View File
@@ -655,7 +655,7 @@ func printExportHelp() {
fmt.Print(`ktui export - export current Komari node status fmt.Print(`ktui export - export current Komari node status
Usage: Usage:
ktui export <json|csv|markdown> [flags] ktui export <markdown|csv|json> [flags]
Flags: Flags:
--url URL Komari base URL --url URL Komari base URL
@@ -665,8 +665,8 @@ Flags:
--config PATH config file path --config PATH config file path
Examples: Examples:
ktui export json
ktui export csv --output nodes.csv
ktui export markdown -o report.md ktui export markdown -o report.md
ktui export csv --output nodes.csv
ktui export json
`) `)
} }
+233 -122
View File
@@ -36,50 +36,54 @@ func main() {
fatal(err) fatal(err)
} }
} }
if len(args) > 0 && (args[0] == "version" || args[0] == "--version" || args[0] == "-v") { if err := handleCommand(args); err != nil {
printVersion() fatal(err)
return
}
if len(args) > 0 && args[0] == "update" {
if err := handleUpdate(args[1:]); err != nil {
fatal(err)
}
return
}
if len(args) > 0 && args[0] == "config" {
if err := handleConfig(args[1:]); err != nil {
fatal(err)
}
return
}
if len(args) > 0 && args[0] == "export" {
if err := handleExport(args[1:]); err != nil {
fatal(err)
}
return
}
if len(args) > 0 && (args[0] == "help" || args[0] == "--help" || args[0] == "-h") {
if err := handleHelp(args[1:]); err != nil {
fatal(err)
}
return
}
if len(args) > 0 && looksLikeCommand(args[0]) {
usageError(fmt.Errorf("unknown command %q", args[0]))
} }
}
func handleCommand(args []string) error {
if len(args) == 0 {
return handleTUI(args)
}
switch args[0] {
case "status":
return handleStatus(args[1:])
case "export":
return handleExport(args[1:])
case "config":
return handleConfig(args[1:])
case "update":
if err := handleUpdate(args[1:]); err != nil {
return err
}
return nil
case "version":
printVersion()
return nil
case "help":
return handleHelp(args[1:])
default:
if !looksLikeCommand(args[0]) {
return handleTUI(args)
}
usageError(fmt.Errorf("unknown command %q", args[0]))
return nil
}
}
func handleTUI(args []string) error {
cfg, cfgPath, err := loadEffectiveConfig() cfg, cfgPath, err := loadEffectiveConfig()
if err != nil { if err != nil {
fatal(err) return err
} }
intervalDefault, err := cfg.IntervalDuration() intervalDefault, err := cfg.IntervalDuration()
if err != nil { if err != nil {
fatal(err) return err
} }
timeoutDefault, err := cfg.TimeoutDuration() timeoutDefault, err := cfg.TimeoutDuration()
if err != nil { if err != nil {
fatal(err) return err
} }
var ( var (
@@ -89,99 +93,137 @@ func main() {
timeout time.Duration timeout time.Duration
realtimePoints int realtimePoints int
chartYAxis string chartYAxis string
once bool modeValue string
ascii bool ascii bool
noColor bool noColor bool
lineMode bool
sheetMode bool
) )
flags := flag.NewFlagSet(os.Args[0], flag.ExitOnError) flags := flag.NewFlagSet("ktui", flag.ExitOnError)
flags.StringVar(&baseURL, "url", cfg.URL, "Komari base URL") flags.StringVar(&baseURL, "url", cfg.URL, "Komari base URL")
flags.StringVar(&apiKey, "api-key", cfg.APIKey, "Komari API key (sent as Bearer token)") flags.StringVar(&apiKey, "api-key", cfg.APIKey, "Komari API key (sent as Bearer token)")
flags.DurationVar(&interval, "interval", intervalDefault, "refresh interval") flags.DurationVar(&interval, "interval", intervalDefault, "refresh interval")
flags.DurationVar(&timeout, "timeout", timeoutDefault, "HTTP timeout") flags.DurationVar(&timeout, "timeout", timeoutDefault, "HTTP timeout")
flags.IntVar(&realtimePoints, "realtime-points", cfg.RealtimePoints, "realtime chart sample limit, 0 auto") flags.IntVar(&realtimePoints, "realtime-points", cfg.RealtimePoints, "realtime chart sample limit, 0 auto")
flags.StringVar(&chartYAxis, "chart-y-axis", cfg.ChartYAxis, "percent chart Y axis mode: absolute or relative") flags.StringVar(&chartYAxis, "chart-y-axis", cfg.ChartYAxis, "percent chart Y axis mode: absolute or relative")
flags.BoolVar(&once, "once", false, "fetch once and print a summary without entering the TUI") flags.StringVar(&modeValue, "mode", cfg.Mode, "view mode: sheet or line")
flags.BoolVar(&ascii, "ascii", cfg.ASCII, "use ASCII-only rendering for terminals/fonts with Unicode issues") flags.BoolVar(&ascii, "ascii", cfg.ASCII, "use ASCII-only rendering for terminals/fonts with Unicode issues")
flags.BoolVar(&noColor, "no-color", cfg.NoColor, "disable ANSI color and inverse video") flags.BoolVar(&noColor, "no-color", cfg.NoColor, "disable ANSI color and inverse video")
flags.BoolVar(&lineMode, "line", false, "show servers as one-by-one line blocks")
flags.BoolVar(&sheetMode, "sheet", false, "show servers in the sheet/table layout")
flags.String("config", cfgPath, "config file path") flags.String("config", cfgPath, "config file path")
flags.Usage = printHelp flags.Usage = printHelp
if err := flags.Parse(args); err != nil { if err := flags.Parse(args); err != nil {
fatal(err) return err
} }
if flags.NArg() > 0 { if flags.NArg() > 0 {
usageError(fmt.Errorf("unexpected argument %q", flags.Arg(0))) usageError(fmt.Errorf("unexpected argument %q", flags.Arg(0)))
} }
if realtimePoints < 0 { if realtimePoints < 0 {
fatal(fmt.Errorf("--realtime-points must be 0 or a positive number")) return fmt.Errorf("--realtime-points must be 0 or a positive number")
} }
chartYAxis = strings.ToLower(strings.TrimSpace(chartYAxis)) chartYAxis = strings.ToLower(strings.TrimSpace(chartYAxis))
if chartYAxis != "absolute" && chartYAxis != "relative" { if chartYAxis != "absolute" && chartYAxis != "relative" {
fatal(fmt.Errorf("--chart-y-axis must be absolute or relative")) return fmt.Errorf("--chart-y-axis must be absolute or relative")
} }
mode := tui.ModeSheet mode, err := parseModeFlag(modeValue)
if cfg.Mode == "line" { if err != nil {
mode = tui.ModeLine return err
}
if lineMode && sheetMode {
fatal(fmt.Errorf("--line and --sheet cannot be used together"))
}
if lineMode {
mode = tui.ModeLine
}
if sheetMode {
mode = tui.ModeSheet
} }
baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout) baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout)
if err != nil { if err != nil {
fatal(err) return err
} }
client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout}) client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout})
if err != nil { if err != nil {
fatal(err) return err
}
if once {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
snapshot, err := client.Snapshot(ctx)
if err != nil {
fatal(err)
}
printSummary(snapshot)
return
} }
if err := checkSystemClockBeforeTUI(client, timeout); err != nil { if err := checkSystemClockBeforeTUI(client, timeout); err != nil {
fatal(err) return err
} }
app := tui.NewWithOptions(client, tui.Options{ app := tui.NewWithOptions(client, tui.Options{
URL: baseURL, URL: baseURL,
APIKey: apiKey, APIKey: apiKey,
RefreshInterval: interval, RefreshInterval: interval,
FetchTimeout: timeout, FetchTimeout: timeout,
DetailTimeout: timeout, DetailTimeout: timeout,
RealtimePoints: realtimePoints, RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis, ChartYAxisMode: chartYAxis,
WarnCPU: cfg.WarnCPU, WarnCPU: cfg.WarnCPU,
WarnRAM: cfg.WarnRAM, WarnRAM: cfg.WarnRAM,
WarnDisk: cfg.WarnDisk, WarnDisk: cfg.WarnDisk,
WarnExpiryDays: cfg.WarnExpiryDays, WarnExpiryDays: cfg.WarnExpiryDays,
SaveSettings: saveTUISettings, SaveSettings: saveTUISettings,
CheckUpdate: checkSoftwareUpdate, CheckUpdate: checkSoftwareUpdate,
ASCII: ascii, CheckKomariUpdate: checkKomariServerUpdate,
NoColor: noColor, Version: version,
Mode: mode, Commit: commit,
BuildDate: date,
ASCII: ascii,
NoColor: noColor,
Mode: mode,
}) })
if err := app.Run(context.Background()); err != nil && err != context.Canceled { if err := app.Run(context.Background()); err != nil && err != context.Canceled {
fatal(err) return err
}
return nil
}
func handleStatus(args []string) error {
cfg, cfgPath, err := loadEffectiveConfig()
if err != nil {
return err
}
timeoutDefault, err := cfg.TimeoutDuration()
if err != nil {
return err
}
var (
baseURL string
apiKey string
timeout time.Duration
)
fs := flag.NewFlagSet("ktui status", flag.ExitOnError)
fs.StringVar(&baseURL, "url", cfg.URL, "Komari base URL")
fs.StringVar(&apiKey, "api-key", cfg.APIKey, "Komari API key (sent as Bearer token)")
fs.DurationVar(&timeout, "timeout", timeoutDefault, "HTTP timeout")
fs.String("config", cfgPath, "config file path")
fs.Usage = printStatusHelp
if err := fs.Parse(args); err != nil {
return err
}
if fs.NArg() > 0 {
return fmt.Errorf("unexpected status argument %q", fs.Arg(0))
}
baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout)
if err != nil {
return err
}
client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout})
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
snapshot, err := client.Snapshot(ctx)
if err != nil {
return err
}
printSummary(snapshot)
return nil
}
func parseModeFlag(value string) (tui.Mode, error) {
switch strings.ToLower(strings.TrimSpace(value)) {
case "sheet":
return tui.ModeSheet, nil
case "line":
return tui.ModeLine, nil
default:
return "", fmt.Errorf("--mode must be sheet or line")
} }
} }
@@ -429,6 +471,23 @@ func checkSoftwareUpdate(ctx context.Context) (tui.UpdateCheckResult, error) {
}, nil }, nil
} }
func checkKomariServerUpdate(ctx context.Context, currentVersion string) (tui.KomariUpdateCheckResult, error) {
result, err := komari.CheckServerUpdate(ctx, komari.ServerUpdateOptions{
CurrentVersion: currentVersion,
Timeout: 8 * time.Second,
})
if err != nil {
return tui.KomariUpdateCheckResult{}, err
}
return tui.KomariUpdateCheckResult{
CurrentVersion: result.CurrentVersion,
LatestVersion: result.LatestVersion,
ReleaseURL: result.ReleaseURL,
ReleaseCount: result.ReleaseCount,
Available: result.Available,
}, nil
}
func handleConfig(args []string) error { func handleConfig(args []string) error {
if len(args) == 0 { if len(args) == 0 {
printConfigHelp() printConfigHelp()
@@ -506,13 +565,15 @@ func handleConfig(args []string) error {
func handleHelp(args []string) error { func handleHelp(args []string) error {
if len(args) > 1 { if len(args) > 1 {
return fmt.Errorf("usage: ktui help [config|keys|update|export]") return fmt.Errorf("usage: ktui help [status|config|keys|update|export]")
} }
if len(args) == 0 { if len(args) == 0 {
printHelp() printHelp()
return nil return nil
} }
switch args[0] { switch args[0] {
case "status":
printStatusHelp()
case "config": case "config":
printConfigHelp() printConfigHelp()
case "keys": case "keys":
@@ -528,11 +589,28 @@ func handleHelp(args []string) error {
} }
func handleUpdate(args []string) error { func handleUpdate(args []string) error {
fs := flag.NewFlagSet("ktui update", flag.ExitOnError) if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
checkOnly := fs.Bool("check", false, "check whether an update is available without installing it") printUpdateHelp()
targetTag := fs.String("tag", "", "install a specific release tag instead of the latest release") return nil
}
switch args[0] {
case "check":
return runUpdateCommand("check", args[1:], true)
case "install":
return runUpdateCommand("install", args[1:], false)
default:
return fmt.Errorf("unknown update command %q", args[0])
}
}
func runUpdateCommand(name string, args []string, checkOnly bool) error {
fs := flag.NewFlagSet("ktui update "+name, flag.ExitOnError)
targetTag := ""
apiURL := fs.String("api-url", update.DefaultAPIBaseURL, "Gitea repository API URL") apiURL := fs.String("api-url", update.DefaultAPIBaseURL, "Gitea repository API URL")
timeout := fs.Duration("timeout", 60*time.Second, "update HTTP timeout") timeout := fs.Duration("timeout", 60*time.Second, "update HTTP timeout")
if !checkOnly {
fs.StringVar(&targetTag, "tag", "", "install a specific release tag instead of the latest release")
}
fs.Usage = printUpdateHelp fs.Usage = printUpdateHelp
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
@@ -543,8 +621,8 @@ func handleUpdate(args []string) error {
return update.Run(context.Background(), update.Options{ return update.Run(context.Background(), update.Options{
APIBaseURL: *apiURL, APIBaseURL: *apiURL,
CurrentVersion: version, CurrentVersion: version,
TargetVersion: *targetTag, TargetVersion: targetTag,
CheckOnly: *checkOnly, CheckOnly: checkOnly,
Timeout: *timeout, Timeout: *timeout,
Stdout: os.Stdout, Stdout: os.Stdout,
}) })
@@ -558,34 +636,37 @@ const helpText = `ktui - Komari terminal UI
Usage: Usage:
ktui [flags] ktui [flags]
ktui status [flags]
ktui export <markdown|csv|json> [flags]
ktui config <init|path|show|set|help>
ktui update <check|install>
ktui version ktui version
ktui update [--check] [--tag v0.1.0] ktui help [status|config|keys|update|export]
ktui export <json|csv|markdown> [--output PATH]
ktui config <path|init|show|set|help>
ktui help [config|keys|update|export]
Flags: Connection flags:
--url URL Komari base URL --url URL Komari base URL
--api-key KEY Komari API key, sent as a Bearer token --api-key KEY Komari API key, sent as a Bearer token
--interval 5s refresh interval
--timeout 10s HTTP timeout --timeout 10s HTTP timeout
--config PATH config file path
TUI flags:
--interval 5s refresh interval
--mode MODE view mode: sheet or line
--realtime-points N --realtime-points N
realtime chart sample limit, 0 auto realtime chart sample limit, 0 auto
--chart-y-axis MODE --chart-y-axis MODE
percent chart Y axis mode: absolute or relative percent chart Y axis mode: absolute or relative
--sheet show the boxed sheet layout
--line show one server block after another
--ascii use ASCII-only rendering --ascii use ASCII-only rendering
--no-color disable ANSI color --no-color disable ANSI color
--once fetch once and print a summary
--config PATH config file path
Examples: Examples:
ktui ktui
ktui --sheet ktui --mode sheet
ktui --line --ascii --no-color ktui --mode line --ascii --no-color
ktui status
ktui version ktui version
ktui update --check ktui update check
ktui update install
ktui export markdown ktui export markdown
ktui export csv --output nodes.csv ktui export csv --output nodes.csv
ktui config init ktui config init
@@ -599,28 +680,48 @@ func printVersion() {
fmt.Printf("built: %s\n", date) fmt.Printf("built: %s\n", date)
} }
func printStatusHelp() {
fmt.Print(`ktui status - fetch once and print a node summary
Usage:
ktui status [flags]
Flags:
--url URL Komari base URL
--api-key KEY Komari API key, sent as a Bearer token
--timeout 10s HTTP timeout
--config PATH config file path
Examples:
ktui status
ktui status --url https://komari.example.com
`)
}
func printUpdateHelp() { func printUpdateHelp() {
fmt.Printf(`ktui update - update ktui from Gitea Releases fmt.Printf(`ktui update - update ktui from Gitea Releases
Usage: Usage:
ktui update [flags] ktui update check [flags]
ktui update install [flags]
Flags: Flags:
--check check for an update without installing it
--tag TAG install a specific release tag, for example v0.1.0
--api-url URL Gitea repository API URL --api-url URL Gitea repository API URL
--timeout 60s HTTP timeout --timeout 60s HTTP timeout
Install flags:
--tag TAG install a specific release tag, for example v0.1.0
Default API URL: Default API URL:
%s %s
Examples: Examples:
ktui update --check ktui update check
ktui update ktui update install
ktui update --tag v0.1.0 ktui update install --tag v0.1.0
Private repositories: Private repositories:
KTUI_UPDATE_TOKEN=your_token ktui update KTUI_UPDATE_TOKEN=your_token ktui update install
`, update.DefaultAPIBaseURL) `, update.DefaultAPIBaseURL)
} }
@@ -652,14 +753,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
warn-cpu CPU warning threshold percent, for example 90 warn-cpu CPU warning threshold percent, for example 90
warn-ram RAM warning threshold percent, for example 85 warn-ram RAM warning threshold percent, for example 85
warn-disk Disk warning threshold percent, for example 90 warn-disk Disk warning threshold percent, for example 90
warn-expiry-days warn-expiry-days
expiry warning window in days expiry warning window in days
mode sheet or line mode sheet or line
ascii true or false ascii true or false
no-color true or false no-color true or false
@@ -690,7 +791,8 @@ List layer:
r refresh now r refresh now
d open or reload selected server detail data d open or reload selected server detail data
a toggle ASCII compatibility mode a toggle ASCII compatibility mode
u show update command when an update is available ? open about
u show available update details
q, Ctrl-C quit q, Ctrl-C quit
Detail layer: Detail layer:
@@ -705,7 +807,8 @@ Detail layer:
Mouse click switch tabs or time window Mouse click switch tabs or time window
Footer click back/tabs/window/scroll/settings/refresh Footer click back/tabs/window/scroll/settings/refresh
s open settings s open settings
u show update command when an update is available ? open about
u show available update details
PgUp, PgDn scroll faster PgUp, PgDn scroll faster
Chart focus: Chart focus:
@@ -721,8 +824,16 @@ Settings layer:
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
? open about
url/api_key shown as read-only url/api_key shown as read-only
About:
Esc, q, ? return to previous layer
Up/k, Down/j scroll
PgUp, PgDn scroll faster
r refresh now
u show available update details
Search: Search:
Type text match node name, region, tags, group, IP, OS, UUID Type text match node name, region, tags, group, IP, OS, UUID
Backspace delete one character Backspace delete one character
+47 -2
View File
@@ -48,14 +48,57 @@ func TestLooksLikeCommand(t *testing.T) {
if !looksLikeCommand("add") { if !looksLikeCommand("add") {
t.Fatal("add should look like a command") t.Fatal("add should look like a command")
} }
if looksLikeCommand("--sheet") { if looksLikeCommand("--mode") {
t.Fatal("--sheet should not look like a command") t.Fatal("--mode should not look like a command")
} }
if looksLikeCommand("") { if looksLikeCommand("") {
t.Fatal("empty string should not look like a command") t.Fatal("empty string should not look like a command")
} }
} }
func TestHelpTextUsesRefactoredCommands(t *testing.T) {
for _, want := range []string{
"ktui status [flags]",
"ktui export <markdown|csv|json> [flags]",
"ktui update <check|install>",
"--mode MODE",
"ktui update check",
"ktui update install",
} {
if !strings.Contains(helpText, want) {
t.Fatalf("help text missing %q:\n%s", want, helpText)
}
}
for _, old := range []string{
"--once",
"--line",
"--sheet",
"ktui update --check",
} {
if strings.Contains(helpText, old) {
t.Fatalf("help text still contains old command %q:\n%s", old, helpText)
}
}
}
func TestParseModeFlag(t *testing.T) {
for _, value := range []string{"sheet", " line "} {
if _, err := parseModeFlag(value); err != nil {
t.Fatalf("parseModeFlag(%q) error = %v", value, err)
}
}
if _, err := parseModeFlag("grid"); err == nil {
t.Fatal("expected invalid mode to fail")
}
}
func TestHandleUpdateRejectsOldFlagSyntax(t *testing.T) {
err := handleUpdate([]string{"--check"})
if err == nil || !strings.Contains(err.Error(), "unknown update command") {
t.Fatalf("handleUpdate old syntax error = %v", err)
}
}
func TestKeysHelpTextFormattingAndFooterActions(t *testing.T) { func TestKeysHelpTextFormattingAndFooterActions(t *testing.T) {
help := keysHelpText() help := keysHelpText()
if strings.Contains(help, "\t") { if strings.Contains(help, "\t") {
@@ -64,6 +107,8 @@ func TestKeysHelpTextFormattingAndFooterActions(t *testing.T) {
for _, want := range []string{ for _, want := range []string{
"Footer click back/tabs/window/scroll/settings/refresh", "Footer click back/tabs/window/scroll/settings/refresh",
"Footer click back/previous/next/window/refresh", "Footer click back/previous/next/window/refresh",
"About:",
"? open about",
"url/api_key shown as read-only", "url/api_key shown as read-only",
} { } {
if !strings.Contains(help, want) { if !strings.Contains(help, want) {
+160
View File
@@ -0,0 +1,160 @@
package komari
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
const DefaultServerReleaseAPIURL = "https://api.github.com/repos/komari-monitor/komari/releases?per_page=100"
type ServerUpdateOptions struct {
CurrentVersion string
APIURL string
Timeout time.Duration
HTTPClient *http.Client
}
type ServerUpdateResult struct {
CurrentVersion string
LatestVersion string
LatestName string
ReleaseURL string
PublishedAt string
ReleaseCount int
Available bool
}
type serverRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
Published string `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
var semverPrefixRE = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)`)
func CheckServerUpdate(ctx context.Context, opts ServerUpdateOptions) (ServerUpdateResult, error) {
if opts.APIURL == "" {
opts.APIURL = DefaultServerReleaseAPIURL
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Second
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: opts.Timeout}
}
current := strings.TrimSpace(opts.CurrentVersion)
result := ServerUpdateResult{CurrentVersion: current}
if current == "" {
return result, nil
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
releases, err := fetchServerReleases(ctx, client, opts.APIURL)
if err != nil {
return ServerUpdateResult{}, err
}
for _, rel := range releases {
if rel.Draft || rel.Prerelease {
continue
}
version := valueOrString(rel.TagName, rel.Name)
if !serverVersionIsNewer(version, current) {
continue
}
if !result.Available {
result.LatestVersion = version
result.LatestName = rel.Name
result.ReleaseURL = rel.HTMLURL
result.PublishedAt = rel.Published
result.Available = true
}
result.ReleaseCount++
}
return result, nil
}
func fetchServerReleases(ctx context.Context, client *http.Client, endpoint string) ([]serverRelease, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
var releases []serverRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func serverVersionIsNewer(latest string, current string) bool {
next, ok := parseServerSemver(latest)
if !ok {
return false
}
base, ok := parseServerSemver(current)
if !ok {
return false
}
for i := 0; i < len(next); i++ {
if next[i] > base[i] {
return true
}
if next[i] < base[i] {
return false
}
}
return false
}
func parseServerSemver(value string) ([3]int, bool) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "v")
value = strings.TrimPrefix(value, "V")
match := semverPrefixRE.FindStringSubmatch(value)
if match == nil {
return [3]int{}, false
}
var version [3]int
for i := range version {
parsed, err := strconv.Atoi(match[i+1])
if err != nil {
return [3]int{}, false
}
version[i] = parsed
}
return version, true
}
func valueOrString(value string, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
+96
View File
@@ -0,0 +1,96 @@
package komari
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestServerVersionIsNewer(t *testing.T) {
tests := []struct {
latest string
current string
want bool
}{
{latest: "v1.2.4", current: "1.2.3", want: true},
{latest: "1.3.0", current: "v1.2.9", want: true},
{latest: "1.2.3", current: "1.2.3", want: false},
{latest: "1.2.2", current: "1.2.3", want: false},
{latest: "nightly", current: "1.2.3", want: false},
{latest: "1.2.4-beta", current: "1.2.3", want: true},
}
for _, tt := range tests {
if got := serverVersionIsNewer(tt.latest, tt.current); got != tt.want {
t.Fatalf("serverVersionIsNewer(%q, %q) = %t, want %t", tt.latest, tt.current, got, tt.want)
}
}
}
func TestCheckServerUpdateFiltersStableReleases(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("Accept = %q", r.Header.Get("Accept"))
}
releases := []serverRelease{
{TagName: "v1.3.0-beta", Name: "beta", Prerelease: true, HTMLURL: "https://example.com/beta"},
{TagName: "v1.2.5", Name: "v1.2.5", HTMLURL: "https://example.com/v1.2.5", Published: "2026-07-06T00:00:00Z"},
{TagName: "v1.2.4", Name: "v1.2.4", HTMLURL: "https://example.com/v1.2.4"},
{TagName: "v1.2.3", Name: "v1.2.3", HTMLURL: "https://example.com/v1.2.3"},
{TagName: "v1.2.6", Name: "draft", Draft: true, HTMLURL: "https://example.com/draft"},
}
_ = json.NewEncoder(w).Encode(releases)
}))
defer server.Close()
result, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "v1.2.3",
APIURL: server.URL,
})
if err != nil {
t.Fatal(err)
}
if !result.Available || result.LatestVersion != "v1.2.5" || result.ReleaseCount != 2 {
t.Fatalf("result = %+v, want latest v1.2.5 with two stable releases", result)
}
if result.ReleaseURL != "https://example.com/v1.2.5" || result.PublishedAt == "" {
t.Fatalf("result = %+v, want release metadata", result)
}
}
func TestCheckServerUpdateReturnsUnavailableForCurrentVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]serverRelease{
{TagName: "v1.2.3", Name: "v1.2.3"},
{TagName: "v1.2.2", Name: "v1.2.2"},
})
}))
defer server.Close()
result, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "1.2.3",
APIURL: server.URL,
})
if err != nil {
t.Fatal(err)
}
if result.Available || result.ReleaseCount != 0 {
t.Fatalf("result = %+v, want no update", result)
}
}
func TestCheckServerUpdateReportsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "rate limited", http.StatusForbidden)
}))
defer server.Close()
_, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "1.2.3",
APIURL: server.URL,
})
if err == nil {
t.Fatal("expected HTTP error")
}
}
+188
View File
@@ -0,0 +1,188 @@
package tui
import (
"fmt"
"runtime"
"strings"
)
func (a *App) renderAboutBody(width int, bodyHeight int) []string {
if bodyHeight <= 0 {
return nil
}
content := a.aboutContentLines(width)
contentWidth := scrollContentWidth(width, scrollIndicator{
Start: 0,
Height: bodyHeight,
Offset: a.aboutScroll,
Visible: bodyHeight,
Total: len(content),
})
if contentWidth != width {
content = a.aboutContentLines(contentWidth)
}
maxScroll := len(content) - bodyHeight
if maxScroll < 0 {
maxScroll = 0
}
if a.aboutScroll > maxScroll {
a.aboutScroll = maxScroll
}
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
end := min(len(content), a.aboutScroll+bodyHeight)
lines := make([]string, 0, bodyHeight)
lines = append(lines, content[a.aboutScroll:end]...)
lines = fillBody(lines, contentWidth, bodyHeight)
return a.withScrollIndicator(lines, width, scrollIndicator{
Start: 0,
Height: bodyHeight,
Offset: a.aboutScroll,
Visible: bodyHeight,
Total: len(content),
})
}
func (a *App) aboutContentLines(width int) []string {
lines := make([]string, 0, 36)
addSection := func(title string) {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, a.style.bold(fitLine(" "+title, width)))
}
addKV := func(label string, value string) {
lines = append(lines, a.aboutKVLine(label, value, width))
}
addSection("ktui")
addKV("version", valueOr(a.appVersion, "dev"))
addKV("commit", valueOr(a.appCommit, "none"))
addKV("built", valueOr(a.appBuildDate, "unknown"))
addKV("runtime", runtime.Version())
addKV("platform", runtime.GOOS+"/"+runtime.GOARCH)
addSection("Komari")
addKV("site", valueOr(a.text(a.snapshot.Public.SiteName), "-"))
addKV("url", valueOr(a.settingsURL, valueOr(a.snapshot.SourceURL, "-")))
addKV("auth", a.authText())
addKV("version", valueOr(a.snapshot.Version.Version, "-"))
addKV("hash", valueOr(a.snapshot.Version.Hash, "-"))
addKV("rpc", valueOr(a.snapshot.RPCVersion, "-"))
addKV("nodes", fmt.Sprintf("%d/%d online", a.snapshot.Online, a.snapshot.Total))
addSection("Connection")
addKV("api_key", configuredText(a.settingsAPIKey))
addKV("interval", a.refreshInterval.String())
addKV("timeout", a.fetchTimeout.String())
addKV("last_refresh", shortTime(a.snapshot.FetchedAt))
addKV("status", a.aboutStatusText())
addSection("Display")
addKV("view_mode", string(a.mode))
addKV("ascii", boolText(a.style.ASCII))
addKV("no_color", boolText(a.style.NoColor))
addKV("chart_y_axis", a.chartYAxisModeText())
addKV("realtime_points", a.realtimePointsText())
addKV("warnings", fmt.Sprintf("cpu %.0f%% ram %.0f%% disk %.0f%% expiry %dd", a.warnCPU, a.warnRAM, a.warnDisk, a.warnExpiryDays))
addSection("ktui Update")
addKV("status", a.aboutUpdateText())
if a.update.Latest != "" {
addKV("latest", a.update.Latest)
}
if a.update.AssetName != "" {
addKV("asset", a.update.AssetName)
}
addSection("Komari Update")
addKV("status", a.aboutKomariUpdateText())
if a.komariUpdate.Current != "" {
addKV("current", a.komariUpdate.Current)
}
if a.komariUpdate.Latest != "" {
addKV("latest", a.komariUpdate.Latest)
}
if a.komariUpdate.ReleaseCount > 0 {
addKV("releases", fmt.Sprintf("%d newer", a.komariUpdate.ReleaseCount))
}
if a.komariUpdate.ReleaseURL != "" {
addKV("github", a.komariUpdate.ReleaseURL)
}
addSection("Commands")
for _, command := range []string{
"ktui version",
"ktui status",
"ktui update check",
"ktui help keys",
} {
lines = append(lines, fitLine(" "+command, width))
}
return lines
}
func (a *App) aboutKVLine(label string, value string, width int) string {
label = strings.TrimSpace(label)
value = strings.TrimSpace(value)
if value == "" {
value = "-"
}
prefix := fmt.Sprintf(" %-15s", label)
return fitLine(a.style.dim(prefix)+value, width)
}
func (a *App) aboutStatusText() string {
switch {
case a.loading:
return "loading"
case a.fetching:
return "refreshing"
case a.err != nil:
return "error: " + a.err.Error()
default:
return "ready"
}
}
func (a *App) aboutUpdateText() string {
switch {
case a.update.Checking:
return "checking"
case a.update.Err != nil:
return "check failed: " + a.update.Err.Error()
case a.update.Available:
return "available"
case a.update.Checked:
return "up to date"
default:
return "not checked"
}
}
func (a *App) aboutKomariUpdateText() string {
switch {
case a.komariUpdate.Checking:
return "checking"
case a.komariUpdate.Err != nil:
return "check failed: " + a.komariUpdate.Err.Error()
case a.komariUpdate.Available:
return "available"
case a.komariUpdate.Checked:
return "up to date"
case strings.TrimSpace(a.snapshot.Version.Version) == "":
return "waiting for server version"
default:
return "not checked"
}
}
func configuredText(value string) string {
if strings.TrimSpace(value) == "" {
return "not configured"
}
return "configured"
}
+134
View File
@@ -0,0 +1,134 @@
package tui
import (
"context"
"strings"
"testing"
"ktui/internal/komari"
)
func TestAboutKeyOpensAndRendersAboutPage(t *testing.T) {
keys := parseKeys([]byte("?"))
if len(keys) != 1 || keys[0].name != "about" || keys[0].text != "?" {
t.Fatalf("keys = %#v, want about key with text", keys)
}
app := NewWithOptions(nil, Options{
URL: "https://komari.example.com",
APIKey: "secret-token",
Version: "v1.2.3",
Commit: "abc123",
BuildDate: "2026-07-06",
ASCII: true,
NoColor: true,
})
app.snapshot = komari.Snapshot{
SourceURL: "https://snapshot.example.com",
Online: 1,
Total: 2,
Version: komari.VersionInfo{Version: "1.0.0", Hash: "hash"},
RPCVersion: "2.0",
Public: komari.PublicInfo{SiteName: "Lab"},
}
app.komariUpdate = komariUpdateState{
Checked: true,
Available: true,
Current: "1.0.0",
Latest: "1.1.0",
ReleaseURL: "https://github.com/komari-monitor/komari/releases/tag/v1.1.0",
ReleaseCount: 1,
}
app.handleKey(context.Background(), keys[0])
if !app.about {
t.Fatal("about page was not opened")
}
lines := app.renderAboutBody(100, 60)
joined := strings.Join(lines, "\n")
for _, want := range []string{
"ktui",
"v1.2.3",
"abc123",
"2026-07-06",
"Komari",
"Lab",
"https://komari.example.com",
"configured",
"1/2 online",
"Komari Update",
"1.1.0",
"https://github.com/komari-monitor/komari/releases/tag/v1.1.0",
"ktui help keys",
} {
if !strings.Contains(joined, want) {
t.Fatalf("about body missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "secret-token") {
t.Fatalf("about body leaked API key:\n%s", joined)
}
}
func TestSearchCanTypeQuestionMark(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.searchEditing = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
if app.about {
t.Fatal("about key event should be treated as text while search is editing")
}
if app.searchDraft != "?" {
t.Fatalf("searchDraft = %q, want ?", app.searchDraft)
}
}
func TestAboutBackRestoresPreviousLayer(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.detail = true
app.chartFocus = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
if !app.about || app.detail || app.chartFocus {
t.Fatalf("about=%t detail=%t chart=%t, want about only", app.about, app.detail, app.chartFocus)
}
app.handleAboutKey(keyEvent{name: "back"})
if app.about || !app.detail || !app.chartFocus {
t.Fatalf("about=%t detail=%t chart=%t, want detail chart restored", app.about, app.detail, app.chartFocus)
}
app.settings = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
app.handleAboutKey(keyEvent{name: "quit"})
if app.about || !app.settings {
t.Fatalf("about=%t settings=%t, want settings restored", app.about, app.settings)
}
}
func TestAboutScrollAndFooterActions(t *testing.T) {
app := NewWithOptions(nil, Options{ASCII: true, NoColor: true})
app.about = true
app.handleAboutKey(keyEvent{name: "down"})
if app.aboutScroll != 1 {
t.Fatalf("aboutScroll = %d, want 1", app.aboutScroll)
}
app.handleAboutKey(keyEvent{name: "up"})
if app.aboutScroll != 0 {
t.Fatalf("aboutScroll = %d, want clamped 0", app.aboutScroll)
}
_, height := terminalSize()
x, ok := footerActionPosition(app, footerRefresh)
if !ok {
t.Fatalf("missing refresh footer action in %q", app.footerText())
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
select {
case <-app.refreshCh:
default:
t.Fatal("clicking about refresh footer action should request refresh")
}
}
+170 -66
View File
@@ -3,39 +3,49 @@ package tui
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"time" "time"
"ktui/internal/komari" "ktui/internal/komari"
) )
type App struct { type App struct {
client *komari.Client client *komari.Client
refreshInterval time.Duration refreshInterval time.Duration
fetchTimeout time.Duration fetchTimeout time.Duration
detailTimeout time.Duration detailTimeout time.Duration
detailCacheTTL time.Duration detailCacheTTL time.Duration
realtimePoints int realtimePoints int
saveSettings func(PersistentSettings) error saveSettings func(PersistentSettings) error
checkUpdate func(context.Context) (UpdateCheckResult, error) checkUpdate func(context.Context) (UpdateCheckResult, error)
style Style checkKomariUpdate func(context.Context, string) (KomariUpdateCheckResult, error)
mode Mode appVersion string
appCommit string
appBuildDate string
style Style
mode Mode
selected int selected int
listScroll int listScroll int
detailScroll int detailScroll int
settingsScroll int settingsScroll int
aboutScroll int
tab int tab int
window int window int
detail bool detail bool
chartFocus bool chartFocus bool
chartFocusIndex int chartFocusIndex int
settings bool settings bool
about bool
cardStep int cardStep int
settingsWasDetail bool settingsWasDetail bool
settingsSelected int settingsSelected int
settingsStatus string settingsStatus string
settingsURL string settingsURL string
settingsAPIKey string settingsAPIKey string
aboutWasDetail bool
aboutWasSettings bool
aboutWasChart bool
chartYAxisMode chartYAxisMode chartYAxisMode chartYAxisMode
warnCPU float64 warnCPU float64
warnRAM float64 warnRAM float64
@@ -54,11 +64,12 @@ type App struct {
frameHeight int frameHeight int
viewCache viewNodesCache viewCache viewNodesCache
snapshot komari.Snapshot snapshot komari.Snapshot
err error err error
loading bool loading bool
fetching bool fetching bool
update updateState update updateState
komariUpdate komariUpdateState
// At most one refresh is queued while an in-flight request is finishing. // At most one refresh is queued while an in-flight request is finishing.
refreshPending bool refreshPending bool
intervalChanged bool intervalChanged bool
@@ -71,32 +82,37 @@ type App struct {
nodeDetail map[detailKey]nodeDetail nodeDetail map[detailKey]nodeDetail
realtimeStatus map[string][]komari.Status realtimeStatus map[string][]komari.Status
renderCh chan struct{} renderCh chan struct{}
refreshCh chan struct{} refreshCh chan struct{}
resultCh chan fetchResult resultCh chan fetchResult
detailCh chan detailResult detailCh chan detailResult
updateCh chan updateResult updateCh chan updateResult
keyCh chan keyEvent komariUpdateCh chan komariUpdateResult
keyCh chan keyEvent
} }
type Options struct { type Options struct {
URL string URL string
APIKey string APIKey string
RefreshInterval time.Duration RefreshInterval time.Duration
FetchTimeout time.Duration FetchTimeout time.Duration
DetailTimeout time.Duration DetailTimeout time.Duration
DetailCacheTTL time.Duration DetailCacheTTL time.Duration
RealtimePoints int RealtimePoints int
ChartYAxisMode string ChartYAxisMode string
WarnCPU float64 WarnCPU float64
WarnRAM float64 WarnRAM float64
WarnDisk float64 WarnDisk float64
WarnExpiryDays int WarnExpiryDays int
SaveSettings func(PersistentSettings) error SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error) CheckUpdate func(context.Context) (UpdateCheckResult, error)
ASCII bool CheckKomariUpdate func(context.Context, string) (KomariUpdateCheckResult, error)
NoColor bool Version string
Mode Mode Commit string
BuildDate string
ASCII bool
NoColor bool
Mode Mode
} }
type UpdateCheckResult struct { type UpdateCheckResult struct {
@@ -106,6 +122,14 @@ type UpdateCheckResult struct {
Available bool Available bool
} }
type KomariUpdateCheckResult struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
ReleaseCount int
Available bool
}
type PersistentSettings struct { type PersistentSettings struct {
Interval string Interval string
Timeout string Timeout string
@@ -181,6 +205,11 @@ type updateResult struct {
err error err error
} }
type komariUpdateResult struct {
result KomariUpdateCheckResult
err error
}
type updateState struct { type updateState struct {
Checking bool Checking bool
Checked bool Checked bool
@@ -190,6 +219,17 @@ type updateState struct {
Err error Err error
} }
type komariUpdateState struct {
Checking bool
Checked bool
Available bool
Current string
Latest string
ReleaseURL string
ReleaseCount int
Err error
}
type detailKey struct { type detailKey struct {
UUID string UUID string
Window int Window int
@@ -278,6 +318,15 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
if opts.Mode == "" { if opts.Mode == "" {
opts.Mode = ModeSheet opts.Mode = ModeSheet
} }
if opts.Version == "" {
opts.Version = "dev"
}
if opts.Commit == "" {
opts.Commit = "none"
}
if opts.BuildDate == "" {
opts.BuildDate = "unknown"
}
if opts.WarnCPU <= 0 { if opts.WarnCPU <= 0 {
opts.WarnCPU = 90 opts.WarnCPU = 90
} }
@@ -291,34 +340,39 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
opts.WarnExpiryDays = 7 opts.WarnExpiryDays = 7
} }
return &App{ return &App{
client: client, client: client,
refreshInterval: opts.RefreshInterval, refreshInterval: opts.RefreshInterval,
fetchTimeout: opts.FetchTimeout, fetchTimeout: opts.FetchTimeout,
detailTimeout: opts.DetailTimeout, detailTimeout: opts.DetailTimeout,
detailCacheTTL: opts.DetailCacheTTL, detailCacheTTL: opts.DetailCacheTTL,
realtimePoints: opts.RealtimePoints, realtimePoints: opts.RealtimePoints,
checkUpdate: opts.CheckUpdate, checkUpdate: opts.CheckUpdate,
settingsURL: opts.URL, checkKomariUpdate: opts.CheckKomariUpdate,
settingsAPIKey: opts.APIKey, appVersion: opts.Version,
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor}, appCommit: opts.Commit,
mode: opts.Mode, appBuildDate: opts.BuildDate,
warnCPU: opts.WarnCPU, settingsURL: opts.URL,
warnRAM: opts.WarnRAM, settingsAPIKey: opts.APIKey,
warnDisk: opts.WarnDisk, style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor},
warnExpiryDays: opts.WarnExpiryDays, mode: opts.Mode,
nodeFilter: nodeFilterAll, warnCPU: opts.WarnCPU,
nodeSort: nodeSortDefault, warnRAM: opts.WarnRAM,
renderCh: make(chan struct{}, 1), warnDisk: opts.WarnDisk,
refreshCh: make(chan struct{}, 2), warnExpiryDays: opts.WarnExpiryDays,
resultCh: make(chan fetchResult, 2), nodeFilter: nodeFilterAll,
detailCh: make(chan detailResult, 4), nodeSort: nodeSortDefault,
updateCh: make(chan updateResult, 1), renderCh: make(chan struct{}, 1),
keyCh: make(chan keyEvent, 16), refreshCh: make(chan struct{}, 2),
loading: true, resultCh: make(chan fetchResult, 2),
nodeDetail: map[detailKey]nodeDetail{}, detailCh: make(chan detailResult, 4),
realtimeStatus: map[string][]komari.Status{}, updateCh: make(chan updateResult, 1),
chartYAxisMode: chartYAxisMode, komariUpdateCh: make(chan komariUpdateResult, 1),
saveSettings: opts.SaveSettings, keyCh: make(chan keyEvent, 16),
loading: true,
nodeDetail: map[detailKey]nodeDetail{},
realtimeStatus: map[string][]komari.Status{},
chartYAxisMode: chartYAxisMode,
saveSettings: opts.SaveSettings,
} }
} }
@@ -378,6 +432,7 @@ func (a *App) Run(ctx context.Context) error {
if a.detail { if a.detail {
a.ensureSelectedDetail(ctx) a.ensureSelectedDetail(ctx)
} }
a.maybeStartKomariUpdateCheck(ctx)
} else { } else {
a.recordRealtimeSample(a.realtimeNowOrTime(time.Now())) a.recordRealtimeSample(a.realtimeNowOrTime(time.Now()))
} }
@@ -401,6 +456,9 @@ func (a *App) Run(ctx context.Context) error {
} }
} }
a.render() a.render()
case update := <-a.komariUpdateCh:
a.applyKomariUpdateResult(update)
a.render()
case key := <-a.keyCh: case key := <-a.keyCh:
a.handleKey(ctx, key) a.handleKey(ctx, key)
a.render() a.render()
@@ -411,6 +469,52 @@ func (a *App) Run(ctx context.Context) error {
return nil return nil
} }
func (a *App) maybeStartKomariUpdateCheck(ctx context.Context) {
if a.checkKomariUpdate == nil {
return
}
current := strings.TrimSpace(a.snapshot.Version.Version)
if current == "" || a.komariUpdate.Checking {
return
}
if a.komariUpdate.Checked && a.komariUpdate.Current == current {
return
}
a.komariUpdate = komariUpdateState{Checking: true, Current: current}
go func() {
result, err := a.checkKomariUpdate(ctx, current)
select {
case a.komariUpdateCh <- komariUpdateResult{result: result, err: err}:
case <-ctx.Done():
}
}()
}
func (a *App) applyKomariUpdateResult(update komariUpdateResult) {
current := update.result.CurrentVersion
if strings.TrimSpace(current) == "" {
current = a.komariUpdate.Current
}
a.komariUpdate.Checking = false
a.komariUpdate.Checked = true
a.komariUpdate.Current = current
a.komariUpdate.Err = update.err
if update.err != nil {
a.komariUpdate.Available = false
a.komariUpdate.Latest = ""
a.komariUpdate.ReleaseURL = ""
a.komariUpdate.ReleaseCount = 0
return
}
a.komariUpdate.Available = update.result.Available
a.komariUpdate.Latest = update.result.LatestVersion
a.komariUpdate.ReleaseURL = update.result.ReleaseURL
a.komariUpdate.ReleaseCount = update.result.ReleaseCount
if update.result.Available {
a.notice = ""
}
}
func (a *App) startUpdateCheck(ctx context.Context) { func (a *App) startUpdateCheck(ctx context.Context) {
if a.checkUpdate == nil { if a.checkUpdate == nil {
return return
+52 -2
View File
@@ -90,8 +90,58 @@ func TestUpdateAvailableFooterAndHint(t *testing.T) {
} }
app.handleKey(context.Background(), keyEvent{name: "update-hint"}) app.handleKey(context.Background(), keyEvent{name: "update-hint"})
if !strings.Contains(app.notice, "ktui update") { if !strings.Contains(app.notice, "ktui update install") {
t.Fatalf("notice = %q, want update command", app.notice) t.Fatalf("notice = %q, want update details", app.notice)
}
}
func TestKomariServerUpdateAvailableFooterAndHint(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.komariUpdate.Available = true
app.komariUpdate.Current = "v1.2.3"
app.komariUpdate.Latest = "v1.2.5"
app.komariUpdate.ReleaseURL = "https://github.com/komari-monitor/komari/releases/tag/v1.2.5"
if !strings.Contains(app.footerText(), "u update") {
t.Fatalf("footer = %q, want update action", app.footerText())
}
app.handleKey(context.Background(), keyEvent{name: "update-hint"})
if !strings.Contains(app.notice, "Komari server: v1.2.3 -> v1.2.5") {
t.Fatalf("notice = %q, want Komari server update", app.notice)
}
if !strings.Contains(app.updateHeaderText(), "KOMARI UPDATE v1.2.5") {
t.Fatalf("header update = %q, want Komari update", app.updateHeaderText())
}
}
func TestMaybeStartKomariUpdateCheckUsesSnapshotVersion(t *testing.T) {
seenCurrent := make(chan string, 1)
app := NewWithOptions(nil, Options{
CheckKomariUpdate: func(ctx context.Context, currentVersion string) (KomariUpdateCheckResult, error) {
seenCurrent <- currentVersion
return KomariUpdateCheckResult{
CurrentVersion: currentVersion,
LatestVersion: "v1.2.5",
ReleaseURL: "https://github.com/komari-monitor/komari/releases/tag/v1.2.5",
ReleaseCount: 2,
Available: true,
}, nil
},
})
app.snapshot.Version.Version = "v1.2.3"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
app.maybeStartKomariUpdateCheck(ctx)
result := <-app.komariUpdateCh
app.applyKomariUpdateResult(result)
if got := <-seenCurrent; got != "v1.2.3" {
t.Fatalf("currentVersion = %q, want v1.2.3", got)
}
if !app.komariUpdate.Available || app.komariUpdate.Latest != "v1.2.5" || app.komariUpdate.ReleaseCount != 2 {
t.Fatalf("komariUpdate = %+v, want available v1.2.5", app.komariUpdate)
} }
} }
+24 -6
View File
@@ -118,7 +118,14 @@ func (a *App) headerLines(width int) []string {
if query := a.currentSearchQuery(); query != "" || a.searchEditing { if query := a.currentSearchQuery(); query != "" || a.searchEditing {
contextParts = append([]string{a.headerChip("search", valueOr(query, "-"))}, contextParts...) contextParts = append([]string{a.headerChip("search", valueOr(query, "-"))}, contextParts...)
} }
if a.settings { if a.about {
contextParts = []string{
a.headerChip("view", "about"),
a.headerChip("ktui", valueOr(a.appVersion, "dev")),
a.headerChip("komari", valueOr(a.snapshot.Version.Version, "-")),
a.headerChip("rpc", valueOr(a.snapshot.RPCVersion, "-")),
}
} else if a.settings {
contextParts = []string{ contextParts = []string{
a.headerChip("view", "settings"), a.headerChip("view", "settings"),
a.headerChip("mode", string(a.mode)+"/"+mode), a.headerChip("mode", string(a.mode)+"/"+mode),
@@ -146,9 +153,9 @@ func (a *App) headerLines(width int) []string {
context = a.style.red("ERROR " + a.err.Error()) context = a.style.red("ERROR " + a.err.Error())
} else if a.notice != "" { } else if a.notice != "" {
context = a.style.yellow(a.notice) context = a.style.yellow(a.notice)
} else if a.update.Available { } else if updateText := a.updateHeaderText(); updateText != "" {
context = a.style.yellow(fmt.Sprintf("UPDATE %s available run `ktui update`", valueOr(a.update.Latest, "latest"))) context = a.style.yellow(updateText)
} else if a.update.Checking { } else if a.isCheckingUpdates() {
context = a.style.dim("checking for updates...") context = a.style.dim("checking for updates...")
} }
@@ -254,6 +261,17 @@ func (a *App) footerItems() []footerItem {
{Action: footerNone, Labels: [footerLabelVariants]string{"Backspace delete", "Delete", "Del"}}, {Action: footerNone, Labels: [footerLabelVariants]string{"Backspace delete", "Delete", "Del"}},
} }
} }
if a.about {
items := []footerItem{
{Action: footerBack, Labels: [footerLabelVariants]string{"Esc/q back", "Back", "B"}},
{Action: footerScroll, Labels: [footerLabelVariants]string{"j/k scroll", "Scroll", "J"}},
{Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}},
}
if a.hasAvailableUpdate() {
items = append(items, footerItem{Action: footerUpdate, Labels: [footerLabelVariants]string{"u update", "Update", "U"}})
}
return items
}
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"}},
@@ -280,7 +298,7 @@ func (a *App) footerItems() []footerItem {
{Action: footerSettings, Labels: [footerLabelVariants]string{"s settings", "Settings", "S"}}, {Action: footerSettings, Labels: [footerLabelVariants]string{"s settings", "Settings", "S"}},
{Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}}, {Action: footerRefresh, Labels: [footerLabelVariants]string{"r refresh", "Refresh", "R"}},
} }
if a.update.Available { if a.hasAvailableUpdate() {
items = append(items, footerItem{Action: footerUpdate, Labels: [footerLabelVariants]string{"u update", "Update", "U"}}) items = append(items, footerItem{Action: footerUpdate, Labels: [footerLabelVariants]string{"u update", "Update", "U"}})
} }
return items return items
@@ -297,7 +315,7 @@ func (a *App) footerItems() []footerItem {
{Action: footerASCII, Labels: [footerLabelVariants]string{"a ascii", "ASCII", "A"}}, {Action: footerASCII, Labels: [footerLabelVariants]string{"a ascii", "ASCII", "A"}},
{Action: footerQuit, Labels: [footerLabelVariants]string{"q quit", "Quit", "Q"}}, {Action: footerQuit, Labels: [footerLabelVariants]string{"q quit", "Quit", "Q"}},
} }
if a.update.Available { if a.hasAvailableUpdate() {
items = append(items, footerItem{Action: footerUpdate, Labels: [footerLabelVariants]string{"u update", "Update", "U"}}) items = append(items, footerItem{Action: footerUpdate, Labels: [footerLabelVariants]string{"u update", "Update", "U"}})
} }
return items return items
+44
View File
@@ -86,6 +86,8 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
out = append(out, keyEvent{name: "settings", text: string(data[i])}) out = append(out, keyEvent{name: "settings", text: string(data[i])})
case 'u', 'U': case 'u', 'U':
out = append(out, keyEvent{name: "update-hint", text: string(data[i])}) out = append(out, keyEvent{name: "update-hint", text: string(data[i])})
case '?':
out = append(out, keyEvent{name: "about", text: string(data[i])})
case '/': case '/':
out = append(out, keyEvent{name: "search", text: "/"}) out = append(out, keyEvent{name: "search", text: "/"})
case 'c', 'C': case 'c', 'C':
@@ -266,12 +268,19 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
if a.settingsScroll < 0 { if a.settingsScroll < 0 {
a.settingsScroll = 0 a.settingsScroll = 0
} }
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
return return
} }
if a.searchEditing { if a.searchEditing {
a.handleSearchKey(key) a.handleSearchKey(key)
return return
} }
if a.about {
a.handleAboutKey(key)
return
}
if a.settings { if a.settings {
a.handleSettingsKey(key) a.handleSettingsKey(key)
return return
@@ -301,6 +310,8 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
a.openSettings() a.openSettings()
case "update-hint": case "update-hint":
a.showUpdateHint() a.showUpdateHint()
case "about":
a.openAbout()
case "search": case "search":
if !a.detail { if !a.detail {
a.openSearch() a.openSearch()
@@ -430,6 +441,9 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
if a.detailScroll < 0 { if a.detailScroll < 0 {
a.detailScroll = 0 a.detailScroll = 0
} }
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
if a.detail && (previous != a.selected || previousTab != a.tab || previousWindow != a.window || a.tabNeedsDetail()) { if a.detail && (previous != a.selected || previousTab != a.tab || previousWindow != a.window || a.tabNeedsDetail()) {
a.ensureSelectedDetail(ctx) a.ensureSelectedDetail(ctx)
} }
@@ -510,6 +524,8 @@ func (a *App) handleSettingsKey(key keyEvent) {
a.quit = true a.quit = true
case "quit", "back", "settings": case "quit", "back", "settings":
a.closeSettings() a.closeSettings()
case "about":
a.openAbout()
case "up": case "up":
a.moveSettingsSelection(-1) a.moveSettingsSelection(-1)
case "down": case "down":
@@ -530,6 +546,34 @@ func (a *App) handleSettingsKey(key keyEvent) {
} }
} }
func (a *App) handleAboutKey(key keyEvent) {
switch key.name {
case "force-quit":
a.quit = true
case "quit", "back", "about":
a.closeAbout()
case "up":
a.aboutScroll--
case "down":
a.aboutScroll++
case "pageup":
a.aboutScroll -= 6
case "pagedown":
a.aboutScroll += 6
case "top":
a.aboutScroll = 0
case "bottom":
a.aboutScroll = 1 << 30
case "refresh":
a.requestFullRefresh()
case "update-hint":
a.showUpdateHint()
}
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
}
func (a *App) cycleDetailTab(ctx context.Context, delta int) { func (a *App) cycleDetailTab(ctx context.Context, delta int) {
if len(tabNames) == 0 { if len(tabNames) == 0 {
return return
+53 -3
View File
@@ -32,6 +32,13 @@ func (a *App) handleMouseWheel(ctx context.Context, delta int) {
previous := a.selected previous := a.selected
previousTab := a.tab previousTab := a.tab
previousWindow := a.window previousWindow := a.window
if a.about {
a.aboutScroll += delta * 3
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
return
}
if a.settings { if a.settings {
a.moveSettingsSelection(delta) a.moveSettingsSelection(delta)
return return
@@ -60,6 +67,9 @@ func (a *App) handleMouseClick(ctx context.Context, x int, y int) {
if a.clickFooter(ctx, x, y) { if a.clickFooter(ctx, x, y) {
return return
} }
if a.about {
return
}
if a.settings { if a.settings {
a.clickSettings(y) a.clickSettings(y)
return return
@@ -116,6 +126,24 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
} }
return false return false
} }
if a.about {
switch action {
case footerBack:
a.closeAbout()
case footerScroll:
a.aboutScroll += 3
case footerRefresh:
a.requestFullRefresh()
case footerUpdate:
a.showUpdateHint()
default:
return false
}
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
return true
}
if a.settings { if a.settings {
switch action { switch action {
case footerBack: case footerBack:
@@ -304,12 +332,34 @@ func (a *App) closeSettings() {
a.settingsWasDetail = false a.settingsWasDetail = false
} }
func (a *App) openAbout() {
a.aboutWasDetail = a.detail
a.aboutWasSettings = a.settings
a.aboutWasChart = a.chartFocus
a.about = true
a.aboutScroll = 0
a.settings = false
a.detail = false
a.chartFocus = false
a.notice = ""
}
func (a *App) closeAbout() {
a.about = false
a.detail = a.aboutWasDetail
a.settings = a.aboutWasSettings
a.chartFocus = a.aboutWasChart && a.detail
a.aboutWasDetail = false
a.aboutWasSettings = false
a.aboutWasChart = false
}
func (a *App) showUpdateHint() { func (a *App) showUpdateHint() {
if !a.update.Available { hint := a.updateHintText()
if hint == "" {
return return
} }
latest := valueOr(a.update.Latest, "latest") a.notice = hint
a.notice = "update available: " + latest + " quit and run `ktui update`"
} }
func footerHit(footer string, label string, x int) bool { func footerHit(footer string, label string, x int) bool {
+3 -1
View File
@@ -36,7 +36,9 @@ func (a *App) render() {
bodyHeight = 1 bodyHeight = 1
} }
if a.settings { if a.about {
lines = append(lines, a.renderAboutBody(drawWidth, bodyHeight)...)
} else if a.settings {
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)...)
+5
View File
@@ -29,6 +29,7 @@ const (
settingsWarnRAM settingsWarnRAM
settingsWarnDisk settingsWarnDisk
settingsWarnExpiryDays settingsWarnExpiryDays
settingsAbout
) )
func (a *App) renderSettingsBody(width int, bodyHeight int) []string { func (a *App) renderSettingsBody(width int, bodyHeight int) []string {
@@ -95,6 +96,7 @@ func (a *App) settingsItems() []settingsItem {
{Label: "warn_ram", Value: percentSettingText(a.warnRAM), Kind: settingsWarnRAM}, {Label: "warn_ram", Value: percentSettingText(a.warnRAM), Kind: settingsWarnRAM},
{Label: "warn_disk", Value: percentSettingText(a.warnDisk), Kind: settingsWarnDisk}, {Label: "warn_disk", Value: percentSettingText(a.warnDisk), Kind: settingsWarnDisk},
{Label: "warn_expiry_days", Value: fmt.Sprintf("%d", a.warnExpiryDays), Kind: settingsWarnExpiryDays}, {Label: "warn_expiry_days", Value: fmt.Sprintf("%d", a.warnExpiryDays), Kind: settingsWarnExpiryDays},
{Label: "about", Value: "open", Kind: settingsAbout},
} }
} }
@@ -233,6 +235,9 @@ func (a *App) adjustSelectedSetting(delta int) {
a.warnDisk = adjustedPercent(a.warnDisk, delta) a.warnDisk = adjustedPercent(a.warnDisk, delta)
case settingsWarnExpiryDays: case settingsWarnExpiryDays:
a.warnExpiryDays = adjustedExpiryDays(a.warnExpiryDays, delta) a.warnExpiryDays = adjustedExpiryDays(a.warnExpiryDays, delta)
case settingsAbout:
a.openAbout()
return
} }
a.persistSettings() a.persistSettings()
} }
+41 -2
View File
@@ -23,7 +23,7 @@ func TestSettingsKeyOpensSettingsPage(t *testing.T) {
lines := app.renderSettingsBody(80, 18) 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", "warn_cpu", "warn_ram", "warn_disk", "warn_expiry_days"} { 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", "about"} {
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", "warn_cpu", "warn_ram", "warn_disk", "warn_expiry_days"} 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", "about"}
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)
} }
@@ -93,6 +93,45 @@ func TestReadOnlySettingsDoNotPersist(t *testing.T) {
} }
} }
func TestSettingsAboutItemOpensAboutWithoutPersisting(t *testing.T) {
called := false
app := NewWithOptions(nil, Options{
SaveSettings: func(settings PersistentSettings) error {
called = true
return nil
},
})
app.settings = true
selectSetting(t, app, "about")
app.handleSettingsKey(keyEvent{name: "open"})
if called {
t.Fatal("about setting should not persist settings")
}
if !app.about || app.settings {
t.Fatalf("about=%t settings=%t, want about open from settings", app.about, app.settings)
}
app.handleAboutKey(keyEvent{name: "back"})
if app.about || !app.settings {
t.Fatalf("about=%t settings=%t, want settings restored", app.about, app.settings)
}
}
func TestSettingsAboutReturnPreservesDetailRestore(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.detail = true
app.handleKey(context.Background(), keyEvent{name: "settings"})
selectSetting(t, app, "about")
app.handleSettingsKey(keyEvent{name: "open"})
app.handleAboutKey(keyEvent{name: "back"})
app.handleSettingsKey(keyEvent{name: "back"})
if app.about || app.settings || !app.detail {
t.Fatalf("about=%t settings=%t detail=%t, want detail restored", app.about, app.settings, app.detail)
}
}
func TestSettingsAdjustConfigFields(t *testing.T) { func TestSettingsAdjustConfigFields(t *testing.T) {
var saved PersistentSettings var saved PersistentSettings
app := NewWithOptions(nil, Options{ app := NewWithOptions(nil, Options{
+44
View File
@@ -0,0 +1,44 @@
package tui
import (
"fmt"
"strings"
)
func (a *App) hasAvailableUpdate() bool {
return a.update.Available || a.komariUpdate.Available
}
func (a *App) isCheckingUpdates() bool {
return a.update.Checking || a.komariUpdate.Checking
}
func (a *App) updateHeaderText() string {
switch {
case a.update.Available && a.komariUpdate.Available:
return fmt.Sprintf("UPDATES ktui %s, Komari %s available press u", valueOr(a.update.Latest, "latest"), valueOr(a.komariUpdate.Latest, "latest"))
case a.update.Available:
return fmt.Sprintf("KTUI UPDATE %s available run `ktui update install`", valueOr(a.update.Latest, "latest"))
case a.komariUpdate.Available:
return fmt.Sprintf("KOMARI UPDATE %s available press u", valueOr(a.komariUpdate.Latest, "latest"))
default:
return ""
}
}
func (a *App) updateHintText() string {
parts := []string{}
if a.update.Available {
parts = append(parts, "ktui: "+valueOr(a.update.Latest, "latest")+" available run `ktui update install`")
}
if a.komariUpdate.Available {
current := valueOr(a.komariUpdate.Current, valueOr(a.snapshot.Version.Version, "current"))
latest := valueOr(a.komariUpdate.Latest, "latest")
message := "Komari server: " + current + " -> " + latest + " update from GitHub releases"
if a.komariUpdate.ReleaseURL != "" {
message += " " + a.komariUpdate.ReleaseURL
}
parts = append(parts, message)
}
return strings.Join(parts, " | ")
}