feat(cli): upgrade command, rich status, and version cache
- Replace `upgrade` stub with real command (alias for `self-update`) - Also register `update` as alias: `unarr update` works too - Rewrite `status` to show full config, disk usage, daemon state, and update availability with colored sections - Add version check cache (1h TTL) so `status` is instant on repeat runs - Guard against division by zero on empty filesystems - Guard against negative durations from clock skew - Guard against stale PID via heartbeat recency check (2 min) - Add comprehensive test coverage across agent, engine, upgrade, usenet, arr, library, mediaserver, and UI packages - Improve Makefile coverage target to exclude cmd/ glue code - Fix stream handler resource cleanup and ffprobe error handling
This commit is contained in:
parent
01d62ffa13
commit
3e0f3a5a64
33 changed files with 7084 additions and 65 deletions
|
|
@ -1,20 +1,26 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/fatih/color"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/torrentclaw/unarr/internal/agent"
|
||||
"github.com/torrentclaw/unarr/internal/upgrade"
|
||||
)
|
||||
|
||||
func newStatusCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show daemon status and active downloads",
|
||||
Long: `Display the current state of the daemon, active downloads, and recent activity.
|
||||
Short: "Show daemon status, configuration, and update availability",
|
||||
Long: `Display the current state of unarr: version, configuration, daemon status,
|
||||
disk usage, and whether an update is available.
|
||||
|
||||
Shows the configured agent name, download directory, and preferred method.
|
||||
When the daemon is running, also displays active downloads and their progress.`,
|
||||
When the daemon is running, also displays uptime, active downloads, and stats.`,
|
||||
Example: ` unarr status`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runStatus()
|
||||
|
|
@ -25,27 +31,167 @@ When the daemon is running, also displays active downloads and their progress.`,
|
|||
func runStatus() error {
|
||||
bold := color.New(color.Bold)
|
||||
dim := color.New(color.FgHiBlack)
|
||||
green := color.New(color.FgGreen)
|
||||
yellow := color.New(color.FgYellow)
|
||||
cyan := color.New(color.FgCyan)
|
||||
|
||||
fmt.Println()
|
||||
bold.Printf(" unarr %s\n", Version)
|
||||
dim.Printf(" %s/%s\n", runtime.GOOS, runtime.GOARCH)
|
||||
fmt.Println()
|
||||
|
||||
cfg := loadConfig()
|
||||
|
||||
// ── Configuration ──
|
||||
if cfg.Auth.APIKey == "" {
|
||||
dim.Println(" Not configured. Run 'unarr init' first.")
|
||||
yellow.Println(" ⚠ Not configured. Run 'unarr init' first.")
|
||||
fmt.Println()
|
||||
return nil
|
||||
}
|
||||
|
||||
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, cfg.Agent.ID[:8]+"...")
|
||||
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
|
||||
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
|
||||
cyan.Println(" Configuration")
|
||||
agentID := cfg.Agent.ID
|
||||
if len(agentID) > 8 {
|
||||
agentID = agentID[:8] + "..."
|
||||
}
|
||||
fmt.Printf(" Agent: %s (%s)\n", cfg.Agent.Name, agentID)
|
||||
fmt.Printf(" Server: %s\n", cfg.Auth.APIURL)
|
||||
fmt.Printf(" Downloads: %s\n", cfg.Download.Dir)
|
||||
fmt.Printf(" Method: %s\n", cfg.Download.PreferredMethod)
|
||||
if cfg.Download.PreferredQuality != "" {
|
||||
fmt.Printf(" Quality: %s\n", cfg.Download.PreferredQuality)
|
||||
}
|
||||
fmt.Printf(" Concurrent: %d\n", cfg.Download.MaxConcurrent)
|
||||
if cfg.Organize.Enabled {
|
||||
fmt.Printf(" Organize: on")
|
||||
if cfg.Organize.MoviesDir != "" {
|
||||
fmt.Printf(" (movies: %s", cfg.Organize.MoviesDir)
|
||||
if cfg.Organize.TVShowsDir != "" {
|
||||
fmt.Printf(", tv: %s", cfg.Organize.TVShowsDir)
|
||||
}
|
||||
fmt.Print(")")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
dim.Println(" Daemon not running. Start with 'unarr start'")
|
||||
dim.Println(" (Live status will be shown here when daemon is running)")
|
||||
// ── Disk ──
|
||||
if cfg.Download.Dir != "" {
|
||||
if free, total, err := agent.DiskInfo(cfg.Download.Dir); err == nil && total > 0 {
|
||||
usedPct := float64(total-free) / float64(total) * 100
|
||||
cyan.Println(" Disk")
|
||||
fmt.Printf(" Free: %s / %s (%.0f%% used)\n", formatBytes(free), formatBytes(total), usedPct)
|
||||
if usedPct > 90 {
|
||||
yellow.Println(" ⚠ Low disk space!")
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// ── Daemon ──
|
||||
cyan.Println(" Daemon")
|
||||
state := agent.ReadState()
|
||||
if state != nil && isDaemonAlive(state) {
|
||||
green.Printf(" Status: running (PID %d)\n", state.PID)
|
||||
fmt.Printf(" Uptime: %s\n", formatDuration(time.Since(state.StartedAt)))
|
||||
fmt.Printf(" Last beat: %s ago\n", formatDuration(time.Since(state.LastHeartbeat)))
|
||||
fmt.Printf(" Active: %d task(s)\n", state.ActiveTasks)
|
||||
fmt.Printf(" Completed: %d\n", state.CompletedCount)
|
||||
if state.FailedCount > 0 {
|
||||
fmt.Printf(" Failed: %d\n", state.FailedCount)
|
||||
}
|
||||
if state.TotalDownloaded > 0 {
|
||||
fmt.Printf(" Downloaded: %s\n", formatBytes(state.TotalDownloaded))
|
||||
}
|
||||
if len(state.MethodStats) > 0 {
|
||||
parts := make([]string, 0, len(state.MethodStats))
|
||||
for method, count := range state.MethodStats {
|
||||
parts = append(parts, fmt.Sprintf("%s:%d", method, count))
|
||||
}
|
||||
fmt.Printf(" Methods: %s\n", strings.Join(parts, ", "))
|
||||
}
|
||||
} else {
|
||||
dim.Println(" Status: stopped")
|
||||
dim.Println(" Start with: unarr start")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// ── Update check (cached: instant if <1h, otherwise async 3s) ──
|
||||
type versionResult struct {
|
||||
version string
|
||||
fromCache bool
|
||||
err error
|
||||
}
|
||||
versionCh := make(chan versionResult, 1)
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
v, cached, err := upgrade.CheckLatestCached(ctx)
|
||||
versionCh <- versionResult{v, cached, err}
|
||||
}()
|
||||
|
||||
cyan.Println(" Update")
|
||||
fmt.Print(" Checking... ")
|
||||
vr := <-versionCh
|
||||
if vr.err != nil {
|
||||
dim.Println("could not check (offline?)")
|
||||
} else {
|
||||
currentClean := strings.TrimPrefix(Version, "v")
|
||||
if currentClean == vr.version {
|
||||
green.Printf("✓ up to date (v%s)\n", vr.version)
|
||||
} else {
|
||||
yellow.Printf("v%s available! ", vr.version)
|
||||
fmt.Printf("Run: unarr upgrade\n")
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isDaemonAlive checks if the daemon process from the state file is still running.
|
||||
// Guards against PID reuse by also checking heartbeat recency.
|
||||
func isDaemonAlive(state *agent.DaemonState) bool {
|
||||
if state.PID == 0 {
|
||||
return false
|
||||
}
|
||||
// Reject stale state: if last heartbeat is older than 2 minutes, the daemon
|
||||
// likely crashed and the PID may have been reused by another process.
|
||||
if !state.LastHeartbeat.IsZero() && time.Since(state.LastHeartbeat) > 2*time.Minute {
|
||||
return false
|
||||
}
|
||||
return agent.IsProcessAlive(state.PID)
|
||||
}
|
||||
|
||||
// formatBytes formats bytes into human-readable string.
|
||||
func formatBytes(b int64) string {
|
||||
const unit = 1024
|
||||
if b < unit {
|
||||
return fmt.Sprintf("%d B", b)
|
||||
}
|
||||
div, exp := int64(unit), 0
|
||||
for n := b / unit; n >= unit; n /= unit {
|
||||
div *= unit
|
||||
exp++
|
||||
}
|
||||
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
|
||||
}
|
||||
|
||||
// formatDuration formats a duration into a compact human-readable string.
|
||||
func formatDuration(d time.Duration) string {
|
||||
if d < 0 {
|
||||
return "0s"
|
||||
}
|
||||
if d < time.Minute {
|
||||
return fmt.Sprintf("%ds", int(d.Seconds()))
|
||||
}
|
||||
if d < time.Hour {
|
||||
return fmt.Sprintf("%dm %ds", int(d.Minutes()), int(d.Seconds())%60)
|
||||
}
|
||||
if d < 24*time.Hour {
|
||||
return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60)
|
||||
}
|
||||
days := int(d.Hours()) / 24
|
||||
hours := int(d.Hours()) % 24
|
||||
return fmt.Sprintf("%dd %dh", days, hours)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue