diff --git a/README.md b/README.md index 2a2b0c1..7dbe5ac 100644 --- a/README.md +++ b/README.md @@ -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 [flags] +ktui config +ktui update +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` 会下载新文件并提示手动替换。 ## 详情页 diff --git a/cmd/ktui/export.go b/cmd/ktui/export.go index 8129f59..0bd31d2 100644 --- a/cmd/ktui/export.go +++ b/cmd/ktui/export.go @@ -655,7 +655,7 @@ func printExportHelp() { fmt.Print(`ktui export - export current Komari node status Usage: - ktui export [flags] + ktui export [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 `) } diff --git a/cmd/ktui/main.go b/cmd/ktui/main.go index 3cdf951..d9cb8b5 100644 --- a/cmd/ktui/main.go +++ b/cmd/ktui/main.go @@ -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 [flags] + ktui config + ktui update ktui version - ktui update [--check] [--tag v0.1.0] - ktui export [--output PATH] - ktui config - 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 diff --git a/cmd/ktui/main_test.go b/cmd/ktui/main_test.go index 65846f3..da1ee50 100644 --- a/cmd/ktui/main_test.go +++ b/cmd/ktui/main_test.go @@ -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 [flags]", + "ktui update ", + "--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) { diff --git a/internal/komari/server_update.go b/internal/komari/server_update.go new file mode 100644 index 0000000..d8c0f3d --- /dev/null +++ b/internal/komari/server_update.go @@ -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 +} diff --git a/internal/komari/server_update_test.go b/internal/komari/server_update_test.go new file mode 100644 index 0000000..d0d836b --- /dev/null +++ b/internal/komari/server_update_test.go @@ -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") + } +} diff --git a/internal/tui/about.go b/internal/tui/about.go new file mode 100644 index 0000000..aee7be2 --- /dev/null +++ b/internal/tui/about.go @@ -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" +} diff --git a/internal/tui/about_test.go b/internal/tui/about_test.go new file mode 100644 index 0000000..f13549c --- /dev/null +++ b/internal/tui/about_test.go @@ -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") + } +} diff --git a/internal/tui/app.go b/internal/tui/app.go index bad8c23..eee26c1 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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 diff --git a/internal/tui/app_test.go b/internal/tui/app_test.go index 2f5e3bb..dfca40b 100644 --- a/internal/tui/app_test.go +++ b/internal/tui/app_test.go @@ -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) } } diff --git a/internal/tui/frame.go b/internal/tui/frame.go index c82a304..f0ecd2e 100644 --- a/internal/tui/frame.go +++ b/internal/tui/frame.go @@ -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 diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 665aa0f..f07b9fc 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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 diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index 5904374..b7bee7f 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -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 { diff --git a/internal/tui/render.go b/internal/tui/render.go index bec276b..cd423f0 100644 --- a/internal/tui/render.go +++ b/internal/tui/render.go @@ -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)...) diff --git a/internal/tui/settings.go b/internal/tui/settings.go index 7f1edf9..360b8ce 100644 --- a/internal/tui/settings.go +++ b/internal/tui/settings.go @@ -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() } diff --git a/internal/tui/settings_test.go b/internal/tui/settings_test.go index 38285d8..7576fb6 100644 --- a/internal/tui/settings_test.go +++ b/internal/tui/settings_test.go @@ -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{ diff --git a/internal/tui/updates.go b/internal/tui/updates.go new file mode 100644 index 0000000..77ff2a5 --- /dev/null +++ b/internal/tui/updates.go @@ -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, " | ") +}