Improve TUI about, CLI, and update checks
This commit is contained in:
1 parent
079269abfc
commit
67997b7a6c
17 files changed
+1330
-223
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+170
-66
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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)...)
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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