Improve TUI about, CLI, and update checks

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

No files matched your search

+33 -16
View File
@@ -29,6 +29,7 @@ https://www.nodeseek.com/post-710243-1
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
- 支持 ASCII / 无颜色兼容模式,适合 Unicode 显示异常的终端
- 支持后台检查 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
View File
@@ -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
`)
}
+233 -122
View File
@@ -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 {
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]))
if err := handleCommand(args); err != nil {
fatal(err)
}
}
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,99 +93,137 @@ 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{
URL: baseURL,
APIKey: apiKey,
RefreshInterval: interval,
FetchTimeout: timeout,
DetailTimeout: timeout,
RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis,
WarnCPU: cfg.WarnCPU,
WarnRAM: cfg.WarnRAM,
WarnDisk: cfg.WarnDisk,
WarnExpiryDays: cfg.WarnExpiryDays,
SaveSettings: saveTUISettings,
CheckUpdate: checkSoftwareUpdate,
ASCII: ascii,
NoColor: noColor,
Mode: mode,
URL: baseURL,
APIKey: apiKey,
RefreshInterval: interval,
FetchTimeout: timeout,
DetailTimeout: timeout,
RealtimePoints: realtimePoints,
ChartYAxisMode: chartYAxis,
WarnCPU: cfg.WarnCPU,
WarnRAM: cfg.WarnRAM,
WarnDisk: cfg.WarnDisk,
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)
}
@@ -652,14 +753,14 @@ Keys:
timeout HTTP timeout, for example 10s
realtime-points
realtime chart sample limit, 0 auto
chart-y-axis
percent chart Y axis mode: absolute or relative
warn-cpu CPU warning threshold percent, for example 90
warn-ram RAM warning threshold percent, for example 85
warn-disk Disk warning threshold percent, for example 90
warn-expiry-days
expiry warning window in days
mode sheet or line
chart-y-axis
percent chart Y axis mode: absolute or relative
warn-cpu CPU warning threshold percent, for example 90
warn-ram RAM warning threshold percent, for example 85
warn-disk Disk warning threshold percent, for example 90
warn-expiry-days
expiry warning window in days
mode sheet or line
ascii true or false
no-color true or false
@@ -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
View File
@@ -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) {
+160
View File
@@ -0,0 +1,160 @@
package komari
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
const DefaultServerReleaseAPIURL = "https://api.github.com/repos/komari-monitor/komari/releases?per_page=100"
type ServerUpdateOptions struct {
CurrentVersion string
APIURL string
Timeout time.Duration
HTTPClient *http.Client
}
type ServerUpdateResult struct {
CurrentVersion string
LatestVersion string
LatestName string
ReleaseURL string
PublishedAt string
ReleaseCount int
Available bool
}
type serverRelease struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
HTMLURL string `json:"html_url"`
Published string `json:"published_at"`
Draft bool `json:"draft"`
Prerelease bool `json:"prerelease"`
}
var semverPrefixRE = regexp.MustCompile(`^(\d+)\.(\d+)\.(\d+)`)
func CheckServerUpdate(ctx context.Context, opts ServerUpdateOptions) (ServerUpdateResult, error) {
if opts.APIURL == "" {
opts.APIURL = DefaultServerReleaseAPIURL
}
if opts.Timeout <= 0 {
opts.Timeout = 10 * time.Second
}
client := opts.HTTPClient
if client == nil {
client = &http.Client{Timeout: opts.Timeout}
}
current := strings.TrimSpace(opts.CurrentVersion)
result := ServerUpdateResult{CurrentVersion: current}
if current == "" {
return result, nil
}
ctx, cancel := context.WithTimeout(ctx, opts.Timeout)
defer cancel()
releases, err := fetchServerReleases(ctx, client, opts.APIURL)
if err != nil {
return ServerUpdateResult{}, err
}
for _, rel := range releases {
if rel.Draft || rel.Prerelease {
continue
}
version := valueOrString(rel.TagName, rel.Name)
if !serverVersionIsNewer(version, current) {
continue
}
if !result.Available {
result.LatestVersion = version
result.LatestName = rel.Name
result.ReleaseURL = rel.HTMLURL
result.PublishedAt = rel.Published
result.Available = true
}
result.ReleaseCount++
}
return result, nil
}
func fetchServerReleases(ctx context.Context, client *http.Client, endpoint string) ([]serverRelease, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/vnd.github+json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
return nil, fmt.Errorf("GET %s: HTTP %d: %s", endpoint, resp.StatusCode, strings.TrimSpace(string(body)))
}
var releases []serverRelease
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
return nil, err
}
return releases, nil
}
func serverVersionIsNewer(latest string, current string) bool {
next, ok := parseServerSemver(latest)
if !ok {
return false
}
base, ok := parseServerSemver(current)
if !ok {
return false
}
for i := 0; i < len(next); i++ {
if next[i] > base[i] {
return true
}
if next[i] < base[i] {
return false
}
}
return false
}
func parseServerSemver(value string) ([3]int, bool) {
value = strings.TrimSpace(value)
value = strings.TrimPrefix(value, "v")
value = strings.TrimPrefix(value, "V")
match := semverPrefixRE.FindStringSubmatch(value)
if match == nil {
return [3]int{}, false
}
var version [3]int
for i := range version {
parsed, err := strconv.Atoi(match[i+1])
if err != nil {
return [3]int{}, false
}
version[i] = parsed
}
return version, true
}
func valueOrString(value string, fallback string) string {
if strings.TrimSpace(value) != "" {
return value
}
return fallback
}
+96
View File
@@ -0,0 +1,96 @@
package komari
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestServerVersionIsNewer(t *testing.T) {
tests := []struct {
latest string
current string
want bool
}{
{latest: "v1.2.4", current: "1.2.3", want: true},
{latest: "1.3.0", current: "v1.2.9", want: true},
{latest: "1.2.3", current: "1.2.3", want: false},
{latest: "1.2.2", current: "1.2.3", want: false},
{latest: "nightly", current: "1.2.3", want: false},
{latest: "1.2.4-beta", current: "1.2.3", want: true},
}
for _, tt := range tests {
if got := serverVersionIsNewer(tt.latest, tt.current); got != tt.want {
t.Fatalf("serverVersionIsNewer(%q, %q) = %t, want %t", tt.latest, tt.current, got, tt.want)
}
}
}
func TestCheckServerUpdateFiltersStableReleases(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Accept") != "application/vnd.github+json" {
t.Errorf("Accept = %q", r.Header.Get("Accept"))
}
releases := []serverRelease{
{TagName: "v1.3.0-beta", Name: "beta", Prerelease: true, HTMLURL: "https://example.com/beta"},
{TagName: "v1.2.5", Name: "v1.2.5", HTMLURL: "https://example.com/v1.2.5", Published: "2026-07-06T00:00:00Z"},
{TagName: "v1.2.4", Name: "v1.2.4", HTMLURL: "https://example.com/v1.2.4"},
{TagName: "v1.2.3", Name: "v1.2.3", HTMLURL: "https://example.com/v1.2.3"},
{TagName: "v1.2.6", Name: "draft", Draft: true, HTMLURL: "https://example.com/draft"},
}
_ = json.NewEncoder(w).Encode(releases)
}))
defer server.Close()
result, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "v1.2.3",
APIURL: server.URL,
})
if err != nil {
t.Fatal(err)
}
if !result.Available || result.LatestVersion != "v1.2.5" || result.ReleaseCount != 2 {
t.Fatalf("result = %+v, want latest v1.2.5 with two stable releases", result)
}
if result.ReleaseURL != "https://example.com/v1.2.5" || result.PublishedAt == "" {
t.Fatalf("result = %+v, want release metadata", result)
}
}
func TestCheckServerUpdateReturnsUnavailableForCurrentVersion(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]serverRelease{
{TagName: "v1.2.3", Name: "v1.2.3"},
{TagName: "v1.2.2", Name: "v1.2.2"},
})
}))
defer server.Close()
result, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "1.2.3",
APIURL: server.URL,
})
if err != nil {
t.Fatal(err)
}
if result.Available || result.ReleaseCount != 0 {
t.Fatalf("result = %+v, want no update", result)
}
}
func TestCheckServerUpdateReportsHTTPError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "rate limited", http.StatusForbidden)
}))
defer server.Close()
_, err := CheckServerUpdate(context.Background(), ServerUpdateOptions{
CurrentVersion: "1.2.3",
APIURL: server.URL,
})
if err == nil {
t.Fatal("expected HTTP error")
}
}
+188
View File
@@ -0,0 +1,188 @@
package tui
import (
"fmt"
"runtime"
"strings"
)
func (a *App) renderAboutBody(width int, bodyHeight int) []string {
if bodyHeight <= 0 {
return nil
}
content := a.aboutContentLines(width)
contentWidth := scrollContentWidth(width, scrollIndicator{
Start: 0,
Height: bodyHeight,
Offset: a.aboutScroll,
Visible: bodyHeight,
Total: len(content),
})
if contentWidth != width {
content = a.aboutContentLines(contentWidth)
}
maxScroll := len(content) - bodyHeight
if maxScroll < 0 {
maxScroll = 0
}
if a.aboutScroll > maxScroll {
a.aboutScroll = maxScroll
}
if a.aboutScroll < 0 {
a.aboutScroll = 0
}
end := min(len(content), a.aboutScroll+bodyHeight)
lines := make([]string, 0, bodyHeight)
lines = append(lines, content[a.aboutScroll:end]...)
lines = fillBody(lines, contentWidth, bodyHeight)
return a.withScrollIndicator(lines, width, scrollIndicator{
Start: 0,
Height: bodyHeight,
Offset: a.aboutScroll,
Visible: bodyHeight,
Total: len(content),
})
}
func (a *App) aboutContentLines(width int) []string {
lines := make([]string, 0, 36)
addSection := func(title string) {
if len(lines) > 0 {
lines = append(lines, "")
}
lines = append(lines, a.style.bold(fitLine(" "+title, width)))
}
addKV := func(label string, value string) {
lines = append(lines, a.aboutKVLine(label, value, width))
}
addSection("ktui")
addKV("version", valueOr(a.appVersion, "dev"))
addKV("commit", valueOr(a.appCommit, "none"))
addKV("built", valueOr(a.appBuildDate, "unknown"))
addKV("runtime", runtime.Version())
addKV("platform", runtime.GOOS+"/"+runtime.GOARCH)
addSection("Komari")
addKV("site", valueOr(a.text(a.snapshot.Public.SiteName), "-"))
addKV("url", valueOr(a.settingsURL, valueOr(a.snapshot.SourceURL, "-")))
addKV("auth", a.authText())
addKV("version", valueOr(a.snapshot.Version.Version, "-"))
addKV("hash", valueOr(a.snapshot.Version.Hash, "-"))
addKV("rpc", valueOr(a.snapshot.RPCVersion, "-"))
addKV("nodes", fmt.Sprintf("%d/%d online", a.snapshot.Online, a.snapshot.Total))
addSection("Connection")
addKV("api_key", configuredText(a.settingsAPIKey))
addKV("interval", a.refreshInterval.String())
addKV("timeout", a.fetchTimeout.String())
addKV("last_refresh", shortTime(a.snapshot.FetchedAt))
addKV("status", a.aboutStatusText())
addSection("Display")
addKV("view_mode", string(a.mode))
addKV("ascii", boolText(a.style.ASCII))
addKV("no_color", boolText(a.style.NoColor))
addKV("chart_y_axis", a.chartYAxisModeText())
addKV("realtime_points", a.realtimePointsText())
addKV("warnings", fmt.Sprintf("cpu %.0f%% ram %.0f%% disk %.0f%% expiry %dd", a.warnCPU, a.warnRAM, a.warnDisk, a.warnExpiryDays))
addSection("ktui Update")
addKV("status", a.aboutUpdateText())
if a.update.Latest != "" {
addKV("latest", a.update.Latest)
}
if a.update.AssetName != "" {
addKV("asset", a.update.AssetName)
}
addSection("Komari Update")
addKV("status", a.aboutKomariUpdateText())
if a.komariUpdate.Current != "" {
addKV("current", a.komariUpdate.Current)
}
if a.komariUpdate.Latest != "" {
addKV("latest", a.komariUpdate.Latest)
}
if a.komariUpdate.ReleaseCount > 0 {
addKV("releases", fmt.Sprintf("%d newer", a.komariUpdate.ReleaseCount))
}
if a.komariUpdate.ReleaseURL != "" {
addKV("github", a.komariUpdate.ReleaseURL)
}
addSection("Commands")
for _, command := range []string{
"ktui version",
"ktui status",
"ktui update check",
"ktui help keys",
} {
lines = append(lines, fitLine(" "+command, width))
}
return lines
}
func (a *App) aboutKVLine(label string, value string, width int) string {
label = strings.TrimSpace(label)
value = strings.TrimSpace(value)
if value == "" {
value = "-"
}
prefix := fmt.Sprintf(" %-15s", label)
return fitLine(a.style.dim(prefix)+value, width)
}
func (a *App) aboutStatusText() string {
switch {
case a.loading:
return "loading"
case a.fetching:
return "refreshing"
case a.err != nil:
return "error: " + a.err.Error()
default:
return "ready"
}
}
func (a *App) aboutUpdateText() string {
switch {
case a.update.Checking:
return "checking"
case a.update.Err != nil:
return "check failed: " + a.update.Err.Error()
case a.update.Available:
return "available"
case a.update.Checked:
return "up to date"
default:
return "not checked"
}
}
func (a *App) aboutKomariUpdateText() string {
switch {
case a.komariUpdate.Checking:
return "checking"
case a.komariUpdate.Err != nil:
return "check failed: " + a.komariUpdate.Err.Error()
case a.komariUpdate.Available:
return "available"
case a.komariUpdate.Checked:
return "up to date"
case strings.TrimSpace(a.snapshot.Version.Version) == "":
return "waiting for server version"
default:
return "not checked"
}
}
func configuredText(value string) string {
if strings.TrimSpace(value) == "" {
return "not configured"
}
return "configured"
}
+134
View File
@@ -0,0 +1,134 @@
package tui
import (
"context"
"strings"
"testing"
"ktui/internal/komari"
)
func TestAboutKeyOpensAndRendersAboutPage(t *testing.T) {
keys := parseKeys([]byte("?"))
if len(keys) != 1 || keys[0].name != "about" || keys[0].text != "?" {
t.Fatalf("keys = %#v, want about key with text", keys)
}
app := NewWithOptions(nil, Options{
URL: "https://komari.example.com",
APIKey: "secret-token",
Version: "v1.2.3",
Commit: "abc123",
BuildDate: "2026-07-06",
ASCII: true,
NoColor: true,
})
app.snapshot = komari.Snapshot{
SourceURL: "https://snapshot.example.com",
Online: 1,
Total: 2,
Version: komari.VersionInfo{Version: "1.0.0", Hash: "hash"},
RPCVersion: "2.0",
Public: komari.PublicInfo{SiteName: "Lab"},
}
app.komariUpdate = komariUpdateState{
Checked: true,
Available: true,
Current: "1.0.0",
Latest: "1.1.0",
ReleaseURL: "https://github.com/komari-monitor/komari/releases/tag/v1.1.0",
ReleaseCount: 1,
}
app.handleKey(context.Background(), keys[0])
if !app.about {
t.Fatal("about page was not opened")
}
lines := app.renderAboutBody(100, 60)
joined := strings.Join(lines, "\n")
for _, want := range []string{
"ktui",
"v1.2.3",
"abc123",
"2026-07-06",
"Komari",
"Lab",
"https://komari.example.com",
"configured",
"1/2 online",
"Komari Update",
"1.1.0",
"https://github.com/komari-monitor/komari/releases/tag/v1.1.0",
"ktui help keys",
} {
if !strings.Contains(joined, want) {
t.Fatalf("about body missing %q:\n%s", want, joined)
}
}
if strings.Contains(joined, "secret-token") {
t.Fatalf("about body leaked API key:\n%s", joined)
}
}
func TestSearchCanTypeQuestionMark(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.searchEditing = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
if app.about {
t.Fatal("about key event should be treated as text while search is editing")
}
if app.searchDraft != "?" {
t.Fatalf("searchDraft = %q, want ?", app.searchDraft)
}
}
func TestAboutBackRestoresPreviousLayer(t *testing.T) {
app := NewWithOptions(nil, Options{})
app.detail = true
app.chartFocus = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
if !app.about || app.detail || app.chartFocus {
t.Fatalf("about=%t detail=%t chart=%t, want about only", app.about, app.detail, app.chartFocus)
}
app.handleAboutKey(keyEvent{name: "back"})
if app.about || !app.detail || !app.chartFocus {
t.Fatalf("about=%t detail=%t chart=%t, want detail chart restored", app.about, app.detail, app.chartFocus)
}
app.settings = true
app.handleKey(context.Background(), keyEvent{name: "about", text: "?"})
app.handleAboutKey(keyEvent{name: "quit"})
if app.about || !app.settings {
t.Fatalf("about=%t settings=%t, want settings restored", app.about, app.settings)
}
}
func TestAboutScrollAndFooterActions(t *testing.T) {
app := NewWithOptions(nil, Options{ASCII: true, NoColor: true})
app.about = true
app.handleAboutKey(keyEvent{name: "down"})
if app.aboutScroll != 1 {
t.Fatalf("aboutScroll = %d, want 1", app.aboutScroll)
}
app.handleAboutKey(keyEvent{name: "up"})
if app.aboutScroll != 0 {
t.Fatalf("aboutScroll = %d, want clamped 0", app.aboutScroll)
}
_, height := terminalSize()
x, ok := footerActionPosition(app, footerRefresh)
if !ok {
t.Fatalf("missing refresh footer action in %q", app.footerText())
}
app.handleKey(context.Background(), keyEvent{name: "mouse-left", x: x, y: height})
select {
case <-app.refreshCh:
default:
t.Fatal("clicking about refresh footer action should request refresh")
}
}
+170 -66
View File
@@ -3,39 +3,49 @@ package tui
import (
"context"
"fmt"
"strings"
"time"
"ktui/internal/komari"
)
type App struct {
client *komari.Client
refreshInterval time.Duration
fetchTimeout time.Duration
detailTimeout time.Duration
detailCacheTTL time.Duration
realtimePoints int
saveSettings func(PersistentSettings) error
checkUpdate func(context.Context) (UpdateCheckResult, error)
style Style
mode Mode
client *komari.Client
refreshInterval time.Duration
fetchTimeout time.Duration
detailTimeout time.Duration
detailCacheTTL time.Duration
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
selected int
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
@@ -54,11 +64,12 @@ type App struct {
frameHeight int
viewCache viewNodesCache
snapshot komari.Snapshot
err error
loading bool
fetching bool
update updateState
snapshot komari.Snapshot
err error
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
@@ -71,32 +82,37 @@ type App struct {
nodeDetail map[detailKey]nodeDetail
realtimeStatus map[string][]komari.Status
renderCh chan struct{}
refreshCh chan struct{}
resultCh chan fetchResult
detailCh chan detailResult
updateCh chan updateResult
keyCh chan keyEvent
renderCh chan struct{}
refreshCh chan struct{}
resultCh chan fetchResult
detailCh chan detailResult
updateCh chan updateResult
komariUpdateCh chan komariUpdateResult
keyCh chan keyEvent
}
type Options struct {
URL string
APIKey string
RefreshInterval time.Duration
FetchTimeout time.Duration
DetailTimeout time.Duration
DetailCacheTTL time.Duration
RealtimePoints int
ChartYAxisMode string
WarnCPU float64
WarnRAM float64
WarnDisk float64
WarnExpiryDays int
SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error)
ASCII bool
NoColor bool
Mode Mode
URL string
APIKey string
RefreshInterval time.Duration
FetchTimeout time.Duration
DetailTimeout time.Duration
DetailCacheTTL time.Duration
RealtimePoints int
ChartYAxisMode string
WarnCPU float64
WarnRAM float64
WarnDisk float64
WarnExpiryDays int
SaveSettings func(PersistentSettings) error
CheckUpdate func(context.Context) (UpdateCheckResult, error)
CheckKomariUpdate func(context.Context, string) (KomariUpdateCheckResult, error)
Version string
Commit string
BuildDate string
ASCII bool
NoColor bool
Mode Mode
}
type UpdateCheckResult struct {
@@ -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
}
@@ -291,34 +340,39 @@ func NewWithOptions(client *komari.Client, opts Options) *App {
opts.WarnExpiryDays = 7
}
return &App{
client: client,
refreshInterval: opts.RefreshInterval,
fetchTimeout: opts.FetchTimeout,
detailTimeout: opts.DetailTimeout,
detailCacheTTL: opts.DetailCacheTTL,
realtimePoints: opts.RealtimePoints,
checkUpdate: opts.CheckUpdate,
settingsURL: opts.URL,
settingsAPIKey: opts.APIKey,
style: Style{ASCII: opts.ASCII, NoColor: opts.NoColor},
mode: opts.Mode,
warnCPU: opts.WarnCPU,
warnRAM: opts.WarnRAM,
warnDisk: opts.WarnDisk,
warnExpiryDays: opts.WarnExpiryDays,
nodeFilter: nodeFilterAll,
nodeSort: nodeSortDefault,
renderCh: make(chan struct{}, 1),
refreshCh: make(chan struct{}, 2),
resultCh: make(chan fetchResult, 2),
detailCh: make(chan detailResult, 4),
updateCh: make(chan updateResult, 1),
keyCh: make(chan keyEvent, 16),
loading: true,
nodeDetail: map[detailKey]nodeDetail{},
realtimeStatus: map[string][]komari.Status{},
chartYAxisMode: chartYAxisMode,
saveSettings: opts.SaveSettings,
client: client,
refreshInterval: opts.RefreshInterval,
fetchTimeout: opts.FetchTimeout,
detailTimeout: opts.DetailTimeout,
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},
mode: opts.Mode,
warnCPU: opts.WarnCPU,
warnRAM: opts.WarnRAM,
warnDisk: opts.WarnDisk,
warnExpiryDays: opts.WarnExpiryDays,
nodeFilter: nodeFilterAll,
nodeSort: nodeSortDefault,
renderCh: make(chan struct{}, 1),
refreshCh: make(chan struct{}, 2),
resultCh: make(chan fetchResult, 2),
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{},
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 {
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
+52 -2
View File
@@ -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
View File
@@ -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
+44
View File
@@ -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
View File
@@ -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 {
+3 -1
View File
@@ -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)...)
+5
View File
@@ -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()
}
+41 -2
View File
@@ -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{
+44
View File
@@ -0,0 +1,44 @@
package tui
import (
"fmt"
"strings"
)
func (a *App) hasAvailableUpdate() bool {
return a.update.Available || a.komariUpdate.Available
}
func (a *App) isCheckingUpdates() bool {
return a.update.Checking || a.komariUpdate.Checking
}
func (a *App) updateHeaderText() string {
switch {
case a.update.Available && a.komariUpdate.Available:
return fmt.Sprintf("UPDATES ktui %s, Komari %s available press u", valueOr(a.update.Latest, "latest"), valueOr(a.komariUpdate.Latest, "latest"))
case a.update.Available:
return fmt.Sprintf("KTUI UPDATE %s available run `ktui update install`", valueOr(a.update.Latest, "latest"))
case a.komariUpdate.Available:
return fmt.Sprintf("KOMARI UPDATE %s available press u", valueOr(a.komariUpdate.Latest, "latest"))
default:
return ""
}
}
func (a *App) updateHintText() string {
parts := []string{}
if a.update.Available {
parts = append(parts, "ktui: "+valueOr(a.update.Latest, "latest")+" available run `ktui update install`")
}
if a.komariUpdate.Available {
current := valueOr(a.komariUpdate.Current, valueOr(a.snapshot.Version.Version, "current"))
latest := valueOr(a.komariUpdate.Latest, "latest")
message := "Komari server: " + current + " -> " + latest + " update from GitHub releases"
if a.komariUpdate.ReleaseURL != "" {
message += " " + a.komariUpdate.ReleaseURL
}
parts = append(parts, message)
}
return strings.Join(parts, " | ")
}