Improve TUI about, CLI, and update checks
This commit is contained in:
1 parent
079269abfc
commit
67997b7a6c
17 files changed
+1238
-131
No files matched your search
@@ -29,6 +29,7 @@ https://www.nodeseek.com/post-710243-1
|
||||
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
|
||||
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
|
||||
- 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端
|
||||
- 支持后台检查 ktui 自身更新和 Komari 服务端新版本提示
|
||||
- 支持远程一行安装脚本,自动识别系统和 CPU 架构并下载对应 Release
|
||||
|
||||
## 安装
|
||||
@@ -85,6 +86,7 @@ go run ./cmd/ktui
|
||||
|
||||
```sh
|
||||
./ktui help
|
||||
./ktui help status
|
||||
./ktui help config
|
||||
./ktui help keys
|
||||
```
|
||||
@@ -98,14 +100,14 @@ go run ./cmd/ktui
|
||||
检查或安装更新:
|
||||
|
||||
```sh
|
||||
./ktui update --check
|
||||
./ktui update
|
||||
./ktui update check
|
||||
./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
|
||||
ktui update
|
||||
ktui update install
|
||||
```
|
||||
|
||||
指定 Komari 地址:
|
||||
@@ -123,19 +125,33 @@ ktui update
|
||||
只拉取一次并打印摘要,不进入 TUI:
|
||||
|
||||
```sh
|
||||
./ktui --once
|
||||
./ktui status
|
||||
```
|
||||
|
||||
导出当前节点状态:
|
||||
|
||||
```sh
|
||||
./ktui export json
|
||||
./ktui export csv --output nodes.csv
|
||||
./ktui export markdown -o report.md
|
||||
./ktui export csv --output nodes.csv
|
||||
./ktui export json
|
||||
```
|
||||
|
||||
导出内容包含站点摘要、在线状态、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
|
||||
./ktui --sheet
|
||||
./ktui --mode sheet
|
||||
```
|
||||
|
||||
逐行列表视图:
|
||||
|
||||
```sh
|
||||
./ktui --line
|
||||
./ktui --mode line
|
||||
```
|
||||
|
||||
`line` 模式会根据终端宽度自动增减列。宽屏下会显示 CPU、RAM、磁盘、地区、实时网络、负载、运行时间、累计流量、连接数、进程数、过期时间、系统和标签。
|
||||
@@ -282,7 +298,8 @@ EXP -
|
||||
- `r`:立即刷新
|
||||
- `d`:打开或重新加载选中节点的详情数据
|
||||
- `a`:切换 ASCII 兼容模式
|
||||
- `u`:有新版本时显示更新命令
|
||||
- `u`:有新版本时显示 ktui 或 Komari 服务端更新详情
|
||||
- `?`:打开 about 视图
|
||||
- `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` 或设置页调整。
|
||||
@@ -299,12 +316,12 @@ ktui version
|
||||
|
||||
## 自更新
|
||||
|
||||
`ktui update` 会从 Gitea Release 下载当前系统和架构对应的压缩包,使用 `checksums.txt` 校验后替换当前二进制。
|
||||
`ktui update install` 会从 Gitea Release 下载当前系统和架构对应的压缩包,使用 `checksums.txt` 校验后替换当前二进制。只检查新版本时使用 `ktui update check`。
|
||||
|
||||
```sh
|
||||
ktui update --check
|
||||
ktui update
|
||||
ktui update --tag v0.1.0
|
||||
ktui update check
|
||||
ktui update install
|
||||
ktui update install --tag v0.1.0
|
||||
```
|
||||
|
||||
默认更新源:
|
||||
@@ -316,10 +333,10 @@ https://gitea.bytevibe.dev/api/v1/repos/gary/ktui
|
||||
如果仓库是私有仓库,可以通过环境变量传入 token:
|
||||
|
||||
```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
@@ -655,7 +655,7 @@ func printExportHelp() {
|
||||
fmt.Print(`ktui export - export current Komari node status
|
||||
|
||||
Usage:
|
||||
ktui export <json|csv|markdown> [flags]
|
||||
ktui export <markdown|csv|json> [flags]
|
||||
|
||||
Flags:
|
||||
--url URL Komari base URL
|
||||
@@ -665,8 +665,8 @@ Flags:
|
||||
--config PATH config file path
|
||||
|
||||
Examples:
|
||||
ktui export json
|
||||
ktui export csv --output nodes.csv
|
||||
ktui export markdown -o report.md
|
||||
ktui export csv --output nodes.csv
|
||||
ktui export json
|
||||
`)
|
||||
}
|
||||
+207
-96
@@ -36,50 +36,54 @@ func main() {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
if len(args) > 0 && (args[0] == "version" || args[0] == "--version" || args[0] == "-v") {
|
||||
printVersion()
|
||||
return
|
||||
}
|
||||
if len(args) > 0 && args[0] == "update" {
|
||||
if err := handleUpdate(args[1:]); err != nil {
|
||||
if err := handleCommand(args); 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()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
intervalDefault, err := cfg.IntervalDuration()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
timeoutDefault, err := cfg.TimeoutDuration()
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -89,77 +93,53 @@ func main() {
|
||||
timeout time.Duration
|
||||
realtimePoints int
|
||||
chartYAxis string
|
||||
once bool
|
||||
modeValue string
|
||||
ascii 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(&apiKey, "api-key", cfg.APIKey, "Komari API key (sent as Bearer token)")
|
||||
flags.DurationVar(&interval, "interval", intervalDefault, "refresh interval")
|
||||
flags.DurationVar(&timeout, "timeout", timeoutDefault, "HTTP timeout")
|
||||
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.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(&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.Usage = printHelp
|
||||
if err := flags.Parse(args); err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
if flags.NArg() > 0 {
|
||||
usageError(fmt.Errorf("unexpected argument %q", flags.Arg(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))
|
||||
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
|
||||
if cfg.Mode == "line" {
|
||||
mode = tui.ModeLine
|
||||
}
|
||||
if lineMode && sheetMode {
|
||||
fatal(fmt.Errorf("--line and --sheet cannot be used together"))
|
||||
}
|
||||
if lineMode {
|
||||
mode = tui.ModeLine
|
||||
}
|
||||
if sheetMode {
|
||||
mode = tui.ModeSheet
|
||||
mode, err := parseModeFlag(modeValue)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout})
|
||||
if err != nil {
|
||||
fatal(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
|
||||
return err
|
||||
}
|
||||
|
||||
if err := checkSystemClockBeforeTUI(client, timeout); err != nil {
|
||||
fatal(err)
|
||||
return err
|
||||
}
|
||||
|
||||
app := tui.NewWithOptions(client, tui.Options{
|
||||
@@ -176,12 +156,74 @@ func main() {
|
||||
WarnExpiryDays: cfg.WarnExpiryDays,
|
||||
SaveSettings: saveTUISettings,
|
||||
CheckUpdate: checkSoftwareUpdate,
|
||||
CheckKomariUpdate: checkKomariServerUpdate,
|
||||
Version: version,
|
||||
Commit: commit,
|
||||
BuildDate: date,
|
||||
ASCII: ascii,
|
||||
NoColor: noColor,
|
||||
Mode: mode,
|
||||
})
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
if len(args) == 0 {
|
||||
printConfigHelp()
|
||||
@@ -506,13 +565,15 @@ func handleConfig(args []string) error {
|
||||
|
||||
func handleHelp(args []string) error {
|
||||
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 {
|
||||
printHelp()
|
||||
return nil
|
||||
}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
printStatusHelp()
|
||||
case "config":
|
||||
printConfigHelp()
|
||||
case "keys":
|
||||
@@ -528,11 +589,28 @@ func handleHelp(args []string) error {
|
||||
}
|
||||
|
||||
func handleUpdate(args []string) error {
|
||||
fs := flag.NewFlagSet("ktui update", flag.ExitOnError)
|
||||
checkOnly := fs.Bool("check", false, "check whether an update is available without installing it")
|
||||
targetTag := fs.String("tag", "", "install a specific release tag instead of the latest release")
|
||||
if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {
|
||||
printUpdateHelp()
|
||||
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")
|
||||
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
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -543,8 +621,8 @@ func handleUpdate(args []string) error {
|
||||
return update.Run(context.Background(), update.Options{
|
||||
APIBaseURL: *apiURL,
|
||||
CurrentVersion: version,
|
||||
TargetVersion: *targetTag,
|
||||
CheckOnly: *checkOnly,
|
||||
TargetVersion: targetTag,
|
||||
CheckOnly: checkOnly,
|
||||
Timeout: *timeout,
|
||||
Stdout: os.Stdout,
|
||||
})
|
||||
@@ -558,34 +636,37 @@ const helpText = `ktui - Komari terminal UI
|
||||
|
||||
Usage:
|
||||
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 update [--check] [--tag v0.1.0]
|
||||
ktui export <json|csv|markdown> [--output PATH]
|
||||
ktui config <path|init|show|set|help>
|
||||
ktui help [config|keys|update|export]
|
||||
ktui help [status|config|keys|update|export]
|
||||
|
||||
Flags:
|
||||
Connection flags:
|
||||
--url URL Komari base URL
|
||||
--api-key KEY Komari API key, sent as a Bearer token
|
||||
--interval 5s refresh interval
|
||||
--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 chart sample limit, 0 auto
|
||||
--chart-y-axis MODE
|
||||
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
|
||||
--no-color disable ANSI color
|
||||
--once fetch once and print a summary
|
||||
--config PATH config file path
|
||||
|
||||
Examples:
|
||||
ktui
|
||||
ktui --sheet
|
||||
ktui --line --ascii --no-color
|
||||
ktui --mode sheet
|
||||
ktui --mode line --ascii --no-color
|
||||
ktui status
|
||||
ktui version
|
||||
ktui update --check
|
||||
ktui update check
|
||||
ktui update install
|
||||
ktui export markdown
|
||||
ktui export csv --output nodes.csv
|
||||
ktui config init
|
||||
@@ -599,28 +680,48 @@ func printVersion() {
|
||||
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() {
|
||||
fmt.Printf(`ktui update - update ktui from Gitea Releases
|
||||
|
||||
Usage:
|
||||
ktui update [flags]
|
||||
ktui update check [flags]
|
||||
ktui update install [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
|
||||
--timeout 60s HTTP timeout
|
||||
|
||||
Install flags:
|
||||
--tag TAG install a specific release tag, for example v0.1.0
|
||||
|
||||
Default API URL:
|
||||
%s
|
||||
|
||||
Examples:
|
||||
ktui update --check
|
||||
ktui update
|
||||
ktui update --tag v0.1.0
|
||||
ktui update check
|
||||
ktui update install
|
||||
ktui update install --tag v0.1.0
|
||||
|
||||
Private repositories:
|
||||
KTUI_UPDATE_TOKEN=your_token ktui update
|
||||
KTUI_UPDATE_TOKEN=your_token ktui update install
|
||||
`, update.DefaultAPIBaseURL)
|
||||
}
|
||||
|
||||
@@ -690,7 +791,8 @@ List layer:
|
||||
r refresh now
|
||||
d open or reload selected server detail data
|
||||
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
|
||||
|
||||
Detail layer:
|
||||
@@ -705,7 +807,8 @@ Detail layer:
|
||||
Mouse click switch tabs or time window
|
||||
Footer click back/tabs/window/scroll/settings/refresh
|
||||
s open settings
|
||||
u show update command when an update is available
|
||||
? open about
|
||||
u show available update details
|
||||
PgUp, PgDn scroll faster
|
||||
|
||||
Chart focus:
|
||||
@@ -721,8 +824,16 @@ Settings layer:
|
||||
Footer click back/adjust/toggle
|
||||
Left/h, Right/l adjust value
|
||||
Enter toggle or advance value
|
||||
? open about
|
||||
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:
|
||||
Type text match node name, region, tags, group, IP, OS, UUID
|
||||
Backspace delete one character
|
||||
|
||||
+47
-2
@@ -48,14 +48,57 @@ func TestLooksLikeCommand(t *testing.T) {
|
||||
if !looksLikeCommand("add") {
|
||||
t.Fatal("add should look like a command")
|
||||
}
|
||||
if looksLikeCommand("--sheet") {
|
||||
t.Fatal("--sheet should not look like a command")
|
||||
if looksLikeCommand("--mode") {
|
||||
t.Fatal("--mode should not look like a command")
|
||||
}
|
||||
if looksLikeCommand("") {
|
||||
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) {
|
||||
help := keysHelpText()
|
||||
if strings.Contains(help, "\t") {
|
||||
@@ -64,6 +107,8 @@ func TestKeysHelpTextFormattingAndFooterActions(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"Footer click back/tabs/window/scroll/settings/refresh",
|
||||
"Footer click back/previous/next/window/refresh",
|
||||
"About:",
|
||||
"? open about",
|
||||
"url/api_key shown as read-only",
|
||||
} {
|
||||
if !strings.Contains(help, want) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package tui
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"ktui/internal/komari"
|
||||
@@ -17,6 +18,10 @@ type App struct {
|
||||
realtimePoints int
|
||||
saveSettings func(PersistentSettings) error
|
||||
checkUpdate func(context.Context) (UpdateCheckResult, error)
|
||||
checkKomariUpdate func(context.Context, string) (KomariUpdateCheckResult, error)
|
||||
appVersion string
|
||||
appCommit string
|
||||
appBuildDate string
|
||||
style Style
|
||||
mode Mode
|
||||
|
||||
@@ -24,18 +29,23 @@ type App struct {
|
||||
listScroll int
|
||||
detailScroll int
|
||||
settingsScroll int
|
||||
aboutScroll int
|
||||
tab int
|
||||
window int
|
||||
detail bool
|
||||
chartFocus bool
|
||||
chartFocusIndex int
|
||||
settings bool
|
||||
about bool
|
||||
cardStep int
|
||||
settingsWasDetail bool
|
||||
settingsSelected int
|
||||
settingsStatus string
|
||||
settingsURL string
|
||||
settingsAPIKey string
|
||||
aboutWasDetail bool
|
||||
aboutWasSettings bool
|
||||
aboutWasChart bool
|
||||
chartYAxisMode chartYAxisMode
|
||||
warnCPU float64
|
||||
warnRAM float64
|
||||
@@ -59,6 +69,7 @@ type App struct {
|
||||
loading bool
|
||||
fetching bool
|
||||
update updateState
|
||||
komariUpdate komariUpdateState
|
||||
// At most one refresh is queued while an in-flight request is finishing.
|
||||
refreshPending bool
|
||||
intervalChanged bool
|
||||
@@ -76,6 +87,7 @@ type App struct {
|
||||
resultCh chan fetchResult
|
||||
detailCh chan detailResult
|
||||
updateCh chan updateResult
|
||||
komariUpdateCh chan komariUpdateResult
|
||||
keyCh chan keyEvent
|
||||
}
|
||||
|
||||
@@ -94,6 +106,10 @@ type Options struct {
|
||||
WarnExpiryDays int
|
||||
SaveSettings func(PersistentSettings) error
|
||||
CheckUpdate func(context.Context) (UpdateCheckResult, error)
|
||||
CheckKomariUpdate func(context.Context, string) (KomariUpdateCheckResult, error)
|
||||
Version string
|
||||
Commit string
|
||||
BuildDate string
|
||||
ASCII bool
|
||||
NoColor bool
|
||||
Mode Mode
|
||||
@@ -106,6 +122,14 @@ type UpdateCheckResult struct {
|
||||
Available bool
|
||||
}
|
||||
|
||||
type KomariUpdateCheckResult struct {
|
||||
CurrentVersion string
|
||||
LatestVersion string
|
||||
ReleaseURL string
|
||||
ReleaseCount int
|
||||
Available bool
|
||||
}
|
||||
|
||||
type PersistentSettings struct {
|
||||
Interval string
|
||||
Timeout string
|
||||
@@ -181,6 +205,11 @@ type updateResult struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type komariUpdateResult struct {
|
||||
result KomariUpdateCheckResult
|
||||
err error
|
||||
}
|
||||
|
||||
type updateState struct {
|
||||
Checking bool
|
||||
Checked bool
|
||||
@@ -190,6 +219,17 @@ type updateState struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
type komariUpdateState struct {
|
||||
Checking bool
|
||||
Checked bool
|
||||
Available bool
|
||||
Current string
|
||||
Latest string
|
||||
ReleaseURL string
|
||||
ReleaseCount int
|
||||
Err error
|
||||
}
|
||||
|
||||
type detailKey struct {
|
||||
UUID string
|
||||
Window int
|
||||
@@ -278,6 +318,15 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
|
||||
if opts.Mode == "" {
|
||||
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 {
|
||||
opts.WarnCPU = 90
|
||||
}
|
||||
@@ -298,6 +347,10 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
|
||||
detailCacheTTL: opts.DetailCacheTTL,
|
||||
realtimePoints: opts.RealtimePoints,
|
||||
checkUpdate: opts.CheckUpdate,
|
||||
checkKomariUpdate: opts.CheckKomariUpdate,
|
||||
appVersion: opts.Version,
|
||||
appCommit: opts.Commit,
|
||||
appBuildDate: opts.BuildDate,
|
||||
settingsURL: opts.URL,
|
||||
settingsAPIKey: opts.APIKey,
|
||||
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor},
|
||||
@@ -313,6 +366,7 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
|
||||
resultCh: make(chan fetchResult, 2),
|
||||
detailCh: make(chan detailResult, 4),
|
||||
updateCh: make(chan updateResult, 1),
|
||||
komariUpdateCh: make(chan komariUpdateResult, 1),
|
||||
keyCh: make(chan keyEvent, 16),
|
||||
loading: true,
|
||||
nodeDetail: map[detailKey]nodeDetail{},
|
||||
@@ -378,6 +432,7 @@ func (a *App) Run(ctx context.Context) error {
|
||||
if a.detail {
|
||||
a.ensureSelectedDetail(ctx)
|
||||
}
|
||||
a.maybeStartKomariUpdateCheck(ctx)
|
||||
} else {
|
||||
a.recordRealtimeSample(a.realtimeNowOrTime(time.Now()))
|
||||
}
|
||||
@@ -401,6 +456,9 @@ func (a *App) Run(ctx context.Context) error {
|
||||
}
|
||||
}
|
||||
a.render()
|
||||
case update := <-a.komariUpdateCh:
|
||||
a.applyKomariUpdateResult(update)
|
||||
a.render()
|
||||
case key := <-a.keyCh:
|
||||
a.handleKey(ctx, key)
|
||||
a.render()
|
||||
@@ -411,6 +469,52 @@ func (a *App) Run(ctx context.Context) error {
|
||||
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) {
|
||||
if a.checkUpdate == nil {
|
||||
return
|
||||
|
||||
@@ -90,8 +90,58 @@ func TestUpdateAvailableFooterAndHint(t *testing.T) {
|
||||
}
|
||||
|
||||
app.handleKey(context.Background(), keyEvent{name: "update-hint"})
|
||||
if !strings.Contains(app.notice, "ktui update") {
|
||||
t.Fatalf("notice = %q, want update command", app.notice)
|
||||
if !strings.Contains(app.notice, "ktui update install") {
|
||||
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
@@ -118,7 +118,14 @@ func (a *App) headerLines(width int) []string {
|
||||
if query := a.currentSearchQuery(); query != "" || a.searchEditing {
|
||||
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{
|
||||
a.headerChip("view", "settings"),
|
||||
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())
|
||||
} else if a.notice != "" {
|
||||
context = a.style.yellow(a.notice)
|
||||
} else if a.update.Available {
|
||||
context = a.style.yellow(fmt.Sprintf("UPDATE %s available run `ktui update`", valueOr(a.update.Latest, "latest")))
|
||||
} else if a.update.Checking {
|
||||
} else if updateText := a.updateHeaderText(); updateText != "" {
|
||||
context = a.style.yellow(updateText)
|
||||
} else if a.isCheckingUpdates() {
|
||||
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"}},
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return []footerItem{
|
||||
{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: 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"}})
|
||||
}
|
||||
return items
|
||||
@@ -297,7 +315,7 @@ func (a *App) footerItems() []footerItem {
|
||||
{Action: footerASCII, Labels: [footerLabelVariants]string{"a ascii", "ASCII", "A"}},
|
||||
{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"}})
|
||||
}
|
||||
return items
|
||||
|
||||
@@ -86,6 +86,8 @@ func parseKeysWithRemainder(data []byte) ([]keyEvent, []byte) {
|
||||
out = append(out, keyEvent{name: "settings", text: string(data[i])})
|
||||
case 'u', 'U':
|
||||
out = append(out, keyEvent{name: "update-hint", text: string(data[i])})
|
||||
case '?':
|
||||
out = append(out, keyEvent{name: "about", text: string(data[i])})
|
||||
case '/':
|
||||
out = append(out, keyEvent{name: "search", text: "/"})
|
||||
case 'c', 'C':
|
||||
@@ -266,12 +268,19 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
|
||||
if a.settingsScroll < 0 {
|
||||
a.settingsScroll = 0
|
||||
}
|
||||
if a.aboutScroll < 0 {
|
||||
a.aboutScroll = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.searchEditing {
|
||||
a.handleSearchKey(key)
|
||||
return
|
||||
}
|
||||
if a.about {
|
||||
a.handleAboutKey(key)
|
||||
return
|
||||
}
|
||||
if a.settings {
|
||||
a.handleSettingsKey(key)
|
||||
return
|
||||
@@ -301,6 +310,8 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
|
||||
a.openSettings()
|
||||
case "update-hint":
|
||||
a.showUpdateHint()
|
||||
case "about":
|
||||
a.openAbout()
|
||||
case "search":
|
||||
if !a.detail {
|
||||
a.openSearch()
|
||||
@@ -430,6 +441,9 @@ func (a *App) handleKey(ctx context.Context, key keyEvent) {
|
||||
if 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()) {
|
||||
a.ensureSelectedDetail(ctx)
|
||||
}
|
||||
@@ -510,6 +524,8 @@ func (a *App) handleSettingsKey(key keyEvent) {
|
||||
a.quit = true
|
||||
case "quit", "back", "settings":
|
||||
a.closeSettings()
|
||||
case "about":
|
||||
a.openAbout()
|
||||
case "up":
|
||||
a.moveSettingsSelection(-1)
|
||||
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) {
|
||||
if len(tabNames) == 0 {
|
||||
return
|
||||
|
||||
+53
-3
@@ -32,6 +32,13 @@ func (a *App) handleMouseWheel(ctx context.Context, delta int) {
|
||||
previous := a.selected
|
||||
previousTab := a.tab
|
||||
previousWindow := a.window
|
||||
if a.about {
|
||||
a.aboutScroll += delta * 3
|
||||
if a.aboutScroll < 0 {
|
||||
a.aboutScroll = 0
|
||||
}
|
||||
return
|
||||
}
|
||||
if a.settings {
|
||||
a.moveSettingsSelection(delta)
|
||||
return
|
||||
@@ -60,6 +67,9 @@ func (a *App) handleMouseClick(ctx context.Context, x int, y int) {
|
||||
if a.clickFooter(ctx, x, y) {
|
||||
return
|
||||
}
|
||||
if a.about {
|
||||
return
|
||||
}
|
||||
if a.settings {
|
||||
a.clickSettings(y)
|
||||
return
|
||||
@@ -116,6 +126,24 @@ func (a *App) clickFooter(ctx context.Context, x int, y int) bool {
|
||||
}
|
||||
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 {
|
||||
switch action {
|
||||
case footerBack:
|
||||
@@ -304,12 +332,34 @@ func (a *App) closeSettings() {
|
||||
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() {
|
||||
if !a.update.Available {
|
||||
hint := a.updateHintText()
|
||||
if hint == "" {
|
||||
return
|
||||
}
|
||||
latest := valueOr(a.update.Latest, "latest")
|
||||
a.notice = "update available: " + latest + " quit and run `ktui update`"
|
||||
a.notice = hint
|
||||
}
|
||||
|
||||
func footerHit(footer string, label string, x int) bool {
|
||||
|
||||
@@ -36,7 +36,9 @@ func (a *App) render() {
|
||||
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)...)
|
||||
} else if a.detail {
|
||||
lines = append(lines, a.renderDetailBody(drawWidth, bodyHeight)...)
|
||||
|
||||
@@ -29,6 +29,7 @@ const (
|
||||
settingsWarnRAM
|
||||
settingsWarnDisk
|
||||
settingsWarnExpiryDays
|
||||
settingsAbout
|
||||
)
|
||||
|
||||
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_disk", Value: percentSettingText(a.warnDisk), Kind: settingsWarnDisk},
|
||||
{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)
|
||||
case settingsWarnExpiryDays:
|
||||
a.warnExpiryDays = adjustedExpiryDays(a.warnExpiryDays, delta)
|
||||
case settingsAbout:
|
||||
a.openAbout()
|
||||
return
|
||||
}
|
||||
a.persistSettings()
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestSettingsKeyOpensSettingsPage(t *testing.T) {
|
||||
|
||||
lines := app.renderSettingsBody(80, 18)
|
||||
joined := strings.Join(lines, "\n")
|
||||
for _, label := range []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color", "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) {
|
||||
t.Fatalf("settings body missing %s: %#v", label, lines)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestSettingsItemsIncludeAllConfigFields(t *testing.T) {
|
||||
for _, item := range items {
|
||||
got = append(got, item.Label)
|
||||
}
|
||||
want := []string{"url", "api_key", "interval", "timeout", "mode", "realtime_points", "chart_y_axis", "ascii", "no_color", "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, ",") {
|
||||
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) {
|
||||
var saved PersistentSettings
|
||||
app := NewWithOptions(nil, Options{
|
||||
|
||||
@@ -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, " | ")
|
||||
}
|
||||
Reference in new issue
Block a user