diff --git a/README.md b/README.md index 1d04d78..2a2b0c1 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ https://www.nodeseek.com/post-710243-1 - `sheet` 卡片视图:按卡片展示每台服务器的 CPU、RAM、磁盘、网络、流量、运行时间和过期信息 - `line` 列表视图:更紧凑地逐行展示服务器概览,宽屏下会展开负载、流量、连接数、进程数、过期时间、系统和标签等列 +- 支持节点流量上限展示:已配置流量限额的节点会显示累计流量占比、上下行累计流量和限额类型 - 两层交互:第一层是节点列表,第二层是单节点详情 - 详情页标签:`overview`、`node`、`history`、`ping`、`meta` - 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d` @@ -23,6 +24,7 @@ https://www.nodeseek.com/post-710243-1 - 详情页图表聚焦模式:点击图表或按 `f` 可用整页查看单张图 - Ping 数据:实时 Ping 和历史 Ping 汇总 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 +- 支持导出当前节点状态为 JSON、CSV 或 Markdown,便于写报告、脚本处理或分享排障信息 - 支持配置文件,默认持久化到系统用户配置目录 - 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动 - 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口 @@ -124,6 +126,16 @@ ktui update ./ktui --once ``` +导出当前节点状态: + +```sh +./ktui export json +./ktui export csv --output nodes.csv +./ktui export markdown -o report.md +``` + +导出内容包含站点摘要、在线状态、CPU/RAM/磁盘百分比、实时网络、累计流量、流量上限占比、过期状态和异常原因。 + ## 配置文件 初始化配置: diff --git a/cmd/ktui/export.go b/cmd/ktui/export.go new file mode 100644 index 0000000..8129f59 --- /dev/null +++ b/cmd/ktui/export.go @@ -0,0 +1,672 @@ +package main + +import ( + "context" + "encoding/csv" + "encoding/json" + "flag" + "fmt" + "io" + "math" + "os" + "strings" + "time" + + "ktui/internal/config" + "ktui/internal/komari" +) + +const ( + exportFormatJSON = "json" + exportFormatCSV = "csv" + exportFormatMarkdown = "markdown" +) + +type exportDocument struct { + GeneratedAt string `json:"generated_at"` + FetchedAt string `json:"fetched_at"` + SourceURL string `json:"source_url"` + SiteName string `json:"site_name"` + Version string `json:"version,omitempty"` + RPCVersion string `json:"rpc_version,omitempty"` + Auth string `json:"auth"` + Summary exportSummary `json:"summary"` + Thresholds exportThresholds `json:"thresholds"` + Messages []string `json:"messages,omitempty"` + Nodes []exportNodeState `json:"nodes"` +} + +type exportSummary struct { + Online int `json:"online"` + Total int `json:"total"` + Regions []string `json:"regions"` + TotalUpBytes int64 `json:"total_up_bytes"` + TotalDownBytes int64 `json:"total_down_bytes"` + SpeedUpBps int64 `json:"speed_up_bps"` + SpeedDownBps int64 `json:"speed_down_bps"` +} + +type exportThresholds struct { + WarnCPU float64 `json:"warn_cpu"` + WarnRAM float64 `json:"warn_ram"` + WarnDisk float64 `json:"warn_disk"` + WarnExpiryDays int `json:"warn_expiry_days"` +} + +type exportNodeState struct { + Name string `json:"name"` + UUID string `json:"uuid"` + Status string `json:"status"` + Online bool `json:"online"` + Region string `json:"region,omitempty"` + Group string `json:"group,omitempty"` + Tags string `json:"tags,omitempty"` + IPv4 string `json:"ipv4,omitempty"` + IPv6 string `json:"ipv6,omitempty"` + OS string `json:"os,omitempty"` + Arch string `json:"arch,omitempty"` + CPUPercent float64 `json:"cpu_percent"` + RAMPercent float64 `json:"ram_percent"` + DiskPercent float64 `json:"disk_percent"` + RAMUsedBytes int64 `json:"ram_used_bytes"` + RAMTotalBytes int64 `json:"ram_total_bytes"` + DiskUsedBytes int64 `json:"disk_used_bytes"` + DiskTotalBytes int64 `json:"disk_total_bytes"` + NetInBps int64 `json:"net_in_bps"` + NetOutBps int64 `json:"net_out_bps"` + NetTotalUpBytes int64 `json:"net_total_up_bytes"` + NetTotalDownBytes int64 `json:"net_total_down_bytes"` + TrafficTotal string `json:"traffic_total"` + TrafficLimitBytes int64 `json:"traffic_limit_bytes,omitempty"` + TrafficLimitType string `json:"traffic_limit_type,omitempty"` + TrafficLimit string `json:"traffic_limit,omitempty"` + TrafficPercent float64 `json:"traffic_percent,omitempty"` + UptimeSeconds int64 `json:"uptime_seconds"` + Uptime string `json:"uptime"` + ExpiresAt string `json:"expires_at,omitempty"` + Expiry string `json:"expiry"` + AlertLevel string `json:"alert_level"` + AlertReasons []string `json:"alert_reasons,omitempty"` + Message string `json:"message,omitempty"` +} + +type exportAlert struct { + Level string + Reasons []string +} + +func handleExport(args []string) error { + if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" { + printExportHelp() + return nil + } + format := strings.ToLower(strings.TrimSpace(args[0])) + if !validExportFormat(format) { + return fmt.Errorf("unknown export format %q: use json, csv, or markdown", args[0]) + } + + 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 + output string + ) + fs := flag.NewFlagSet("ktui export "+format, 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.StringVar(&output, "output", "", "write export to a file instead of stdout") + fs.StringVar(&output, "o", "", "write export to a file instead of stdout") + fs.String("config", cfgPath, "config file path") + fs.Usage = printExportHelp + if err := fs.Parse(args[1:]); err != nil { + return err + } + if fs.NArg() > 0 { + return fmt.Errorf("unexpected export 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 + } + doc := buildExportDocument(snapshot, cfg, time.Now()) + + writer := io.Writer(os.Stdout) + var file *os.File + if strings.TrimSpace(output) != "" { + file, err = os.Create(output) + if err != nil { + return err + } + defer file.Close() + writer = file + } + if err := writeExport(format, writer, doc); err != nil { + return err + } + return nil +} + +func validExportFormat(format string) bool { + switch format { + case exportFormatJSON, exportFormatCSV, exportFormatMarkdown: + return true + default: + return false + } +} + +func buildExportDocument(snapshot komari.Snapshot, cfg config.Config, now time.Time) exportDocument { + siteName := snapshot.Public.SiteName + if siteName == "" { + siteName = "Komari" + } + auth := "guest" + if snapshot.Me.LoggedIn { + auth = valueOr(snapshot.Me.Username, "api-key") + } + messages := []string{} + if snapshot.LastErr != nil { + messages = append(messages, "public_info: "+snapshot.LastErr.Error()) + } + if snapshot.NodeDetailErr != nil { + messages = append(messages, "node_detail: "+snapshot.NodeDetailErr.Error()) + } + + doc := exportDocument{ + GeneratedAt: exportTime(now), + FetchedAt: exportTime(snapshot.FetchedAt), + SourceURL: snapshot.SourceURL, + SiteName: siteName, + Version: snapshot.Version.Version, + RPCVersion: snapshot.RPCVersion, + Auth: auth, + Summary: exportSummary{ + Online: snapshot.Online, + Total: snapshot.Total, + Regions: append([]string(nil), snapshot.RegionList...), + TotalUpBytes: snapshot.TotalUp, + TotalDownBytes: snapshot.TotalDown, + SpeedUpBps: snapshot.SpeedUp, + SpeedDownBps: snapshot.SpeedDown, + }, + Thresholds: exportThresholds{ + WarnCPU: cfg.WarnCPU, + WarnRAM: cfg.WarnRAM, + WarnDisk: cfg.WarnDisk, + WarnExpiryDays: cfg.WarnExpiryDays, + }, + Messages: messages, + Nodes: make([]exportNodeState, 0, len(snapshot.Nodes)), + } + + for _, node := range snapshot.Nodes { + st := snapshot.Status[node.UUID] + alert := exportAlertForNode(node, st, cfg, now) + ramTotal := firstNonZero(st.RAMTotal, node.MemTotal) + diskTotal := firstNonZero(st.DiskTotal, node.DiskTotal) + trafficLimitType := "" + trafficLimitText := "" + if node.TrafficLimit > 0 { + trafficLimitType = exportTrafficLimitType(node.TrafficLimitType) + trafficLimitText = exportTrafficLimitText(node.TrafficLimit, node.TrafficLimitType) + } + status := "offline" + if st.Online { + status = "online" + } + expiresAt := "" + if node.ExpiredAt.Valid { + expiresAt = exportTime(node.ExpiredAt.Time) + } + doc.Nodes = append(doc.Nodes, exportNodeState{ + Name: node.Name, + UUID: node.UUID, + Status: status, + Online: st.Online, + Region: node.Region, + Group: node.Group, + Tags: node.Tags, + IPv4: node.IPv4, + IPv6: node.IPv6, + OS: node.OS, + Arch: node.Arch, + CPUPercent: exportRound1(st.CPU), + RAMPercent: exportRound1(percentage(st.RAM, ramTotal)), + DiskPercent: exportRound1(percentage(st.Disk, diskTotal)), + RAMUsedBytes: st.RAM, + RAMTotalBytes: ramTotal, + DiskUsedBytes: st.Disk, + DiskTotalBytes: diskTotal, + NetInBps: st.NetIn, + NetOutBps: st.NetOut, + NetTotalUpBytes: st.NetTotalUp, + NetTotalDownBytes: st.NetTotalDown, + TrafficTotal: fmt.Sprintf("%s %s %s %s", "up", exportTrafficBytes(st.NetTotalUp), "down", exportTrafficBytes(st.NetTotalDown)), + TrafficLimitBytes: node.TrafficLimit, + TrafficLimitType: trafficLimitType, + TrafficLimit: trafficLimitText, + TrafficPercent: exportRound1(exportTrafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType)), + UptimeSeconds: st.Uptime, + Uptime: exportDuration(st.Uptime), + ExpiresAt: expiresAt, + Expiry: exportExpiryText(node, now), + AlertLevel: alert.Level, + AlertReasons: alert.Reasons, + Message: st.Message, + }) + } + return doc +} + +func writeExport(format string, writer io.Writer, doc exportDocument) error { + switch format { + case exportFormatJSON: + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return encoder.Encode(doc) + case exportFormatCSV: + return writeExportCSV(writer, doc) + case exportFormatMarkdown: + _, err := fmt.Fprint(writer, exportMarkdown(doc)) + return err + default: + return fmt.Errorf("unknown export format %q", format) + } +} + +func writeExportCSV(writer io.Writer, doc exportDocument) error { + cw := csv.NewWriter(writer) + if err := cw.Write([]string{ + "fetched_at", + "site", + "name", + "uuid", + "status", + "region", + "group", + "tags", + "ipv4", + "ipv6", + "os", + "arch", + "cpu_percent", + "ram_percent", + "disk_percent", + "net_in_bps", + "net_out_bps", + "net_total_up_bytes", + "net_total_down_bytes", + "traffic_percent", + "traffic_limit", + "traffic_limit_bytes", + "traffic_limit_type", + "uptime_seconds", + "expires_at", + "expiry", + "alert_level", + "alert_reasons", + }); err != nil { + return err + } + for _, node := range doc.Nodes { + if err := cw.Write([]string{ + doc.FetchedAt, + doc.SiteName, + node.Name, + node.UUID, + node.Status, + node.Region, + node.Group, + node.Tags, + node.IPv4, + node.IPv6, + node.OS, + node.Arch, + fmt.Sprintf("%.1f", node.CPUPercent), + fmt.Sprintf("%.1f", node.RAMPercent), + fmt.Sprintf("%.1f", node.DiskPercent), + fmt.Sprintf("%d", node.NetInBps), + fmt.Sprintf("%d", node.NetOutBps), + fmt.Sprintf("%d", node.NetTotalUpBytes), + fmt.Sprintf("%d", node.NetTotalDownBytes), + exportPercentCell(node.TrafficLimitBytes, node.TrafficPercent), + node.TrafficLimit, + exportInt64Cell(node.TrafficLimitBytes), + exportLimitTypeCell(node.TrafficLimitBytes, node.TrafficLimitType), + fmt.Sprintf("%d", node.UptimeSeconds), + node.ExpiresAt, + node.Expiry, + node.AlertLevel, + strings.Join(node.AlertReasons, " "), + }); err != nil { + return err + } + } + cw.Flush() + return cw.Error() +} + +func exportMarkdown(doc exportDocument) string { + var b strings.Builder + fmt.Fprintf(&b, "# ktui node export\n\n") + fmt.Fprintf(&b, "- Site: %s\n", markdownCell(doc.SiteName)) + fmt.Fprintf(&b, "- Source: %s\n", markdownCell(valueOr(doc.SourceURL, "-"))) + fmt.Fprintf(&b, "- Fetched: %s\n", markdownCell(valueOr(doc.FetchedAt, "-"))) + fmt.Fprintf(&b, "- Online: %d/%d\n", doc.Summary.Online, doc.Summary.Total) + fmt.Fprintf(&b, "- Traffic: up %s / down %s\n", exportBytesIEC(doc.Summary.TotalUpBytes), exportBytesIEC(doc.Summary.TotalDownBytes)) + if len(doc.Messages) > 0 { + fmt.Fprintf(&b, "- Messages: %s\n", markdownCell(strings.Join(doc.Messages, "; "))) + } + fmt.Fprintln(&b) + fmt.Fprintln(&b, "| Node | Status | Region | CPU | RAM | Disk | Net | Traffic | Limit | Expiry | Alert |") + fmt.Fprintln(&b, "| --- | --- | --- | ---: | ---: | ---: | --- | --- | --- | --- | --- |") + for _, node := range doc.Nodes { + alert := node.AlertLevel + if len(node.AlertReasons) > 0 { + alert += " (" + strings.Join(node.AlertReasons, " ") + ")" + } + net := fmt.Sprintf("in %s / out %s", exportSpeedIEC(node.NetInBps), exportSpeedIEC(node.NetOutBps)) + traffic := fmt.Sprintf("up %s / down %s", exportTrafficBytes(node.NetTotalUpBytes), exportTrafficBytes(node.NetTotalDownBytes)) + limit := "-" + if node.TrafficLimitBytes > 0 { + limit = fmt.Sprintf("%.1f%% %s", node.TrafficPercent, node.TrafficLimit) + } + fmt.Fprintf(&b, "| %s | %s | %s | %.1f%% | %.1f%% | %.1f%% | %s | %s | %s | %s | %s |\n", + markdownCell(node.Name), + markdownCell(node.Status), + markdownCell(valueOr(node.Region, "-")), + node.CPUPercent, + node.RAMPercent, + node.DiskPercent, + markdownCell(net), + markdownCell(traffic), + markdownCell(limit), + markdownCell(node.Expiry), + markdownCell(alert), + ) + } + return b.String() +} + +func exportAlertForNode(node komari.Node, st komari.Status, cfg config.Config, now time.Time) exportAlert { + reasons := []string{} + critical := false + warning := false + if !st.Online { + critical = true + reasons = append(reasons, "offline") + } + if st.CPU >= cfg.WarnCPU { + critical = true + reasons = append(reasons, "cpu") + } + if percentage(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)) >= cfg.WarnRAM { + critical = true + reasons = append(reasons, "ram") + } + if percentage(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)) >= cfg.WarnDisk { + critical = true + reasons = append(reasons, "disk") + } + if node.ExpiredAt.Valid && node.Price >= 0 { + until := node.ExpiredAt.Time.Sub(now) + if until < 0 { + critical = true + reasons = append(reasons, "expired") + } else if until <= time.Duration(cfg.WarnExpiryDays)*24*time.Hour { + warning = true + reasons = append(reasons, "expires") + } + } + if node.TrafficLimit > 0 { + trafficPct := exportTrafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) + if trafficPct >= 100 { + critical = true + reasons = append(reasons, "traffic") + } else if trafficPct >= 90 { + warning = true + reasons = append(reasons, "traffic") + } + } + switch { + case critical: + return exportAlert{Level: "critical", Reasons: reasons} + case warning: + return exportAlert{Level: "warning", Reasons: reasons} + default: + return exportAlert{Level: "ok"} + } +} + +func exportPercentCell(limit int64, value float64) string { + if limit <= 0 { + return "" + } + return fmt.Sprintf("%.1f", value) +} + +func exportInt64Cell(value int64) string { + if value <= 0 { + return "" + } + return fmt.Sprintf("%d", value) +} + +func exportLimitTypeCell(limit int64, limitType string) string { + if limit <= 0 { + return "" + } + return exportTrafficLimitType(limitType) +} + +func exportTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func exportExpiryText(node komari.Node, now time.Time) string { + if node.Price < 0 { + return "free" + } + if !node.ExpiredAt.Valid { + return "-" + } + until := node.ExpiredAt.Time.Sub(now) + if until < 0 { + return "expired" + } + if until >= 20*365*24*time.Hour { + return "lifetime" + } + if until < 24*time.Hour { + return "today" + } + days := int(until.Hours() / 24) + if until > time.Duration(days)*24*time.Hour { + days++ + } + return fmt.Sprintf("%dd", days) +} + +func exportDuration(seconds int64) string { + if seconds < 0 { + seconds = 0 + } + days := seconds / 86400 + seconds %= 86400 + hours := seconds / 3600 + seconds %= 3600 + minutes := seconds / 60 + switch { + case days > 0: + return fmt.Sprintf("%dd %dh", days, hours) + case hours > 0: + return fmt.Sprintf("%dh %dm", hours, minutes) + case minutes > 0: + return fmt.Sprintf("%dm", minutes) + default: + return fmt.Sprintf("%ds", seconds) + } +} + +func exportTrafficPercent(up, down, limit int64, limitType string) float64 { + if limit <= 0 { + return 0 + } + var used int64 + switch strings.ToLower(limitType) { + case "max": + if up > down { + used = up + } else { + used = down + } + case "min": + if up < down { + used = up + } else { + used = down + } + case "up": + used = up + case "down": + used = down + default: + used = up + down + } + return float64(used) / float64(limit) * 100 +} + +func exportTrafficLimitType(limitType string) string { + switch strings.ToLower(strings.TrimSpace(limitType)) { + case "up": + return "Up" + case "down": + return "Down" + case "max": + return "Max" + case "min": + return "Min" + default: + return "Sum" + } +} + +func exportTrafficLimitText(limit int64, limitType string) string { + if limit <= 0 { + return "" + } + return fmt.Sprintf("%s(%s)", exportTrafficLimitType(limitType), exportTrafficBytes(limit)) +} + +func exportBytesIEC(n int64) string { + negative := n < 0 + if negative { + n = -n + } + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + value := float64(n) + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + var out string + switch { + case unit == 0: + out = fmt.Sprintf("%d %s", n, units[unit]) + case value >= 100: + out = fmt.Sprintf("%.0f %s", value, units[unit]) + case value >= 10: + out = fmt.Sprintf("%.1f %s", value, units[unit]) + default: + out = fmt.Sprintf("%.2f %s", value, units[unit]) + } + if negative { + return "-" + out + } + return out +} + +func exportSpeedIEC(n int64) string { + return exportBytesIEC(n) + "/s" +} + +func exportTrafficBytes(n int64) string { + negative := n < 0 + if negative { + n = -n + } + units := []string{"B", "KB", "MB", "GB", "TB", "PB"} + value := float64(n) + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + var out string + if unit == 0 { + out = fmt.Sprintf("%d %s", n, units[unit]) + } else { + out = fmt.Sprintf("%.2f %s", value, units[unit]) + } + if negative { + return "-" + out + } + return out +} + +func exportRound1(value float64) float64 { + return math.Round(value*10) / 10 +} + +func markdownCell(value string) string { + value = strings.ReplaceAll(value, "\r", " ") + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "|", `\|`) + return value +} + +func printExportHelp() { + fmt.Print(`ktui export - export current Komari node status + +Usage: + ktui export [flags] + +Flags: + --url URL Komari base URL + --api-key KEY Komari API key, sent as a Bearer token + --timeout 10s HTTP timeout + -o, --output PATH write export to a file instead of stdout + --config PATH config file path + +Examples: + ktui export json + ktui export csv --output nodes.csv + ktui export markdown -o report.md +`) +} diff --git a/cmd/ktui/export_test.go b/cmd/ktui/export_test.go new file mode 100644 index 0000000..8f91c0b --- /dev/null +++ b/cmd/ktui/export_test.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "strings" + "testing" + "time" + + "ktui/internal/config" + "ktui/internal/komari" +) + +func TestBuildExportDocumentIncludesAlertsAndThresholds(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + doc := buildExportDocument(testExportSnapshot(now), testExportConfig(), now) + + if doc.SiteName != "Demo Komari" || doc.Summary.Online != 1 || doc.Summary.Total != 2 { + t.Fatalf("doc summary = %+v site=%q", doc.Summary, doc.SiteName) + } + if doc.Thresholds.WarnCPU != 90 || doc.Thresholds.WarnExpiryDays != 7 { + t.Fatalf("thresholds = %+v", doc.Thresholds) + } + if len(doc.Nodes) != 2 { + t.Fatalf("nodes len = %d, want 2", len(doc.Nodes)) + } + alpha := doc.Nodes[0] + if alpha.AlertLevel != "critical" || !hasExportReason(alpha.AlertReasons, "cpu") || !hasExportReason(alpha.AlertReasons, "expires") || !hasExportReason(alpha.AlertReasons, "traffic") { + t.Fatalf("alpha alert = %s %#v, want critical cpu expires traffic", alpha.AlertLevel, alpha.AlertReasons) + } + if alpha.Expiry != "3d" { + t.Fatalf("alpha expiry = %q, want 3d", alpha.Expiry) + } + if alpha.TrafficPercent != 92.5 || alpha.TrafficLimit != "Sum(500.00 GB)" { + t.Fatalf("alpha traffic = %.1f %q, want 92.5 Sum(500.00 GB)", alpha.TrafficPercent, alpha.TrafficLimit) + } + beta := doc.Nodes[1] + if beta.Status != "offline" || beta.AlertLevel != "critical" || !hasExportReason(beta.AlertReasons, "offline") { + t.Fatalf("beta = %+v, want offline critical", beta) + } +} + +func TestWriteExportJSON(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + doc := buildExportDocument(testExportSnapshot(now), testExportConfig(), now) + var out bytes.Buffer + + if err := writeExport(exportFormatJSON, &out, doc); err != nil { + t.Fatal(err) + } + var got exportDocument + if err := json.Unmarshal(out.Bytes(), &got); err != nil { + t.Fatalf("invalid JSON: %v\n%s", err, out.String()) + } + if got.Nodes[0].Name != "alpha|edge" || got.Nodes[0].CPUPercent != 92.4 || got.Nodes[0].TrafficPercent != 92.5 { + t.Fatalf("json node = %+v", got.Nodes[0]) + } +} + +func TestWriteExportCSV(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + doc := buildExportDocument(testExportSnapshot(now), testExportConfig(), now) + var out bytes.Buffer + + if err := writeExport(exportFormatCSV, &out, doc); err != nil { + t.Fatal(err) + } + records, err := csv.NewReader(strings.NewReader(out.String())).ReadAll() + if err != nil { + t.Fatalf("invalid CSV: %v\n%s", err, out.String()) + } + if len(records) != 3 { + t.Fatalf("records len = %d, want header + 2 rows", len(records)) + } + if records[0][0] != "fetched_at" || records[0][2] != "name" { + t.Fatalf("header = %#v", records[0]) + } + if records[1][2] != "alpha|edge" || records[1][12] != "92.4" || records[1][19] != "92.5" || records[1][20] != "Sum(500.00 GB)" || !strings.Contains(records[1][27], "traffic") { + t.Fatalf("first row = %#v", records[1]) + } +} + +func TestWriteExportMarkdown(t *testing.T) { + now := time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC) + doc := buildExportDocument(testExportSnapshot(now), testExportConfig(), now) + var out bytes.Buffer + + if err := writeExport(exportFormatMarkdown, &out, doc); err != nil { + t.Fatal(err) + } + text := out.String() + for _, want := range []string{ + "# ktui node export", + "- Online: 1/2", + "| Node | Status | Region | CPU | RAM | Disk | Net | Traffic | Limit | Expiry | Alert |", + "alpha\\|edge", + "up 229.59 GB / down 233.03 GB", + "92.5% Sum(500.00 GB)", + "critical (cpu expires traffic)", + } { + if !strings.Contains(text, want) { + t.Fatalf("markdown missing %q:\n%s", want, text) + } + } +} + +func testExportConfig() config.Config { + return config.Config{ + WarnCPU: 90, + WarnRAM: 85, + WarnDisk: 90, + WarnExpiryDays: 7, + }.WithDefaults() +} + +func testExportSnapshot(now time.Time) komari.Snapshot { + alpha := komari.Node{ + UUID: "n1", + Name: "alpha|edge", + Region: "tokyo", + Group: "prod", + Tags: "web", + IPv4: "192.0.2.1", + OS: "linux", + Arch: "amd64", + MemTotal: 1000, + DiskTotal: 2000, + TrafficLimit: 500 * 1024 * 1024 * 1024, + Price: 5, + ExpiredAt: komari.NullTime{Time: now.Add(72 * time.Hour), Valid: true}, + } + beta := komari.Node{ + UUID: "n2", + Name: "beta", + Region: "london", + MemTotal: 1000, + DiskTotal: 2000, + } + return komari.Snapshot{ + Nodes: []komari.Node{alpha, beta}, + FetchedAt: now.Add(-time.Minute), + SourceURL: "https://komari.example.com", + Public: komari.PublicInfo{SiteName: "Demo Komari"}, + Version: komari.VersionInfo{Version: "1.0.0"}, + RPCVersion: "2.0", + Online: 1, + Total: 2, + TotalUp: 246_520_385_372, + TotalDown: 250_214_057_246, + SpeedUp: 1024, + SpeedDown: 2048, + RegionList: []string{"tokyo"}, + Status: map[string]komari.Status{ + "n1": { + Online: true, + CPU: 92.44, + RAM: 400, + RAMTotal: 1000, + Disk: 500, + DiskTotal: 2000, + NetIn: 2048, + NetOut: 1024, + NetTotalUp: 246_520_385_372, + NetTotalDown: 250_214_057_246, + Uptime: 90061, + }, + "n2": { + Online: false, + CPU: 10, + RAM: 100, + RAMTotal: 1000, + Disk: 200, + DiskTotal: 2000, + }, + }, + } +} + +func hasExportReason(reasons []string, want string) bool { + for _, reason := range reasons { + if reason == want { + return true + } + } + return false +} diff --git a/cmd/ktui/main.go b/cmd/ktui/main.go index e887437..82f6518 100644 --- a/cmd/ktui/main.go +++ b/cmd/ktui/main.go @@ -47,6 +47,12 @@ func main() { } 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) @@ -57,15 +63,10 @@ func main() { usageError(fmt.Errorf("unknown command %q", args[0])) } - cfg, cfgPath, err := config.Load() + cfg, cfgPath, err := loadEffectiveConfig() if err != nil { fatal(err) } - cfg = applyEnv(cfg) - cfg = cfg.WithDefaults() - if err := cfg.Validate(); err != nil { - fatal(err) - } intervalDefault, err := cfg.IntervalDuration() if err != nil { @@ -131,17 +132,9 @@ func main() { if sheetMode { mode = tui.ModeSheet } - if strings.TrimSpace(baseURL) == "" { - if !isInteractiveTerminal(os.Stdin) { - fatal(fmt.Errorf("Komari URL is not set. Run `ktui config set url https://your-komari.example.com` or pass `--url https://your-komari.example.com`")) - } - cfg.APIKey = apiKey - next, err := firstRunSetup(cfg, os.Stdin, os.Stdout) - if err != nil { - fatal(err) - } - baseURL = next.URL - apiKey = next.APIKey + baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout) + if err != nil { + fatal(err) } client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout}) @@ -183,6 +176,19 @@ func main() { } } +func loadEffectiveConfig() (config.Config, string, error) { + cfg, path, err := config.Load() + if err != nil { + return cfg, path, err + } + cfg = applyEnv(cfg) + cfg = cfg.WithDefaults() + if err := cfg.Validate(); err != nil { + return cfg, path, err + } + return cfg, path, nil +} + func applyEnv(cfg config.Config) config.Config { if value := os.Getenv("KTUI_URL"); value != "" { cfg.URL = value @@ -267,6 +273,21 @@ func saveTUISettings(settings tui.PersistentSettings) error { return err } +func prepareConnectionConfig(cfg config.Config, baseURL string, apiKey string, input *os.File, output io.Writer) (string, string, error) { + if strings.TrimSpace(baseURL) != "" { + return baseURL, apiKey, nil + } + if !isInteractiveTerminal(input) { + return "", "", fmt.Errorf("Komari URL is not set. Run `ktui config set url https://your-komari.example.com` or pass `--url https://your-komari.example.com`") + } + cfg.APIKey = apiKey + next, err := firstRunSetup(cfg, input, output) + if err != nil { + return "", "", err + } + return next.URL, next.APIKey, nil +} + func firstRunSetup(cfg config.Config, input io.Reader, output io.Writer) (config.Config, error) { reader := bufio.NewReader(input) fmt.Fprintln(output, "ktui first run setup") @@ -427,7 +448,7 @@ func handleConfig(args []string) error { func handleHelp(args []string) error { if len(args) > 1 { - return fmt.Errorf("usage: ktui help [config|keys|update]") + return fmt.Errorf("usage: ktui help [config|keys|update|export]") } if len(args) == 0 { printHelp() @@ -440,6 +461,8 @@ func handleHelp(args []string) error { printKeysHelp() case "update": printUpdateHelp() + case "export": + printExportHelp() default: return fmt.Errorf("unknown help topic %q", args[0]) } @@ -479,8 +502,9 @@ Usage: ktui [flags] ktui version ktui update [--check] [--tag v0.1.0] + ktui export [--output PATH] ktui config - ktui help [config|keys|update] + ktui help [config|keys|update|export] Flags: --url URL Komari base URL @@ -504,6 +528,8 @@ Examples: ktui --line --ascii --no-color ktui version ktui update --check + ktui export markdown + ktui export csv --output nodes.csv ktui config init ktui config set api-key your_api_key ktui help keys diff --git a/internal/tui/alerts.go b/internal/tui/alerts.go index 2c77856..ad8fc84 100644 --- a/internal/tui/alerts.go +++ b/internal/tui/alerts.go @@ -7,6 +7,11 @@ import ( "ktui/internal/komari" ) +const ( + trafficWarnPercent = 90.0 + trafficCriticalPercent = 100.0 +) + type nodeAlert struct { Critical bool Warning bool @@ -41,6 +46,16 @@ func (a *App) alertForNode(node komari.Node, st komari.Status, now time.Time) no alert.Reasons = append(alert.Reasons, "expires") } } + if node.TrafficLimit > 0 { + trafficPct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) + if trafficPct >= trafficCriticalPercent { + alert.Critical = true + alert.Reasons = append(alert.Reasons, "traffic") + } else if trafficPct >= trafficWarnPercent { + alert.Warning = true + alert.Reasons = append(alert.Reasons, "traffic") + } + } return alert } diff --git a/internal/tui/detail.go b/internal/tui/detail.go index b2ba5ad..1d8e515 100644 --- a/internal/tui/detail.go +++ b/internal/tui/detail.go @@ -57,7 +57,14 @@ func (a *App) renderDetailBody(width int, bodyHeight int) []string { lines := make([]string, 0, bodyHeight) lines = append(lines, chrome...) lines = append(lines, content[a.scroll:end]...) - return fillBody(lines, width, bodyHeight) + lines = fillBody(lines, width, bodyHeight) + return a.withScrollIndicator(lines, width, scrollIndicator{ + Start: len(chrome), + Height: contentHeight, + Offset: a.scroll, + Visible: contentHeight, + Total: len(content), + }) } func (a *App) renderFocusedChartBody(node komari.Node, st komari.Status, width int, bodyHeight int) []string { @@ -118,6 +125,10 @@ func (a *App) detailMetricStrip(node komari.Node, st komari.Status, width int) s if width >= 104 { parts = append(parts, fmt.Sprintf(" NET %s %s %s %s", a.style.up(), speedIEC(st.NetOut), a.style.down(), speedIEC(st.NetIn))) } + if width >= 128 && node.TrafficLimit > 0 { + pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) + parts = append(parts, fmt.Sprintf(" TRF %5.1f%% %s", pct, a.usageBarFor("TRF", pct, barWidth))) + } return fitLine(strings.Join(parts, " "), width) } @@ -391,11 +402,11 @@ func (a *App) overviewSections(node komari.Node, st komari.Status) []detailSecti if node.TrafficLimit > 0 { pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) sections = append(sections, detailSection{ - Title: "Traffic Limit", + Title: "Total Traffic", Lines: []string{ a.detailUsageLine(" Used", pct, fmt.Sprintf("%.1f%%", pct)), - fmt.Sprintf(" Limit %s", bytesIEC(node.TrafficLimit)), - fmt.Sprintf(" Type %s", valueOr(node.TrafficLimitType, "-")), + fmt.Sprintf(" Flow %s %s %s %s", a.style.up(), trafficBytes(st.NetTotalUp), a.style.down(), trafficBytes(st.NetTotalDown)), + fmt.Sprintf(" Limit %s", trafficLimitText(node.TrafficLimit, node.TrafficLimitType)), }, }) } diff --git a/internal/tui/detail_test.go b/internal/tui/detail_test.go index c9d3c2b..08ce8c4 100644 --- a/internal/tui/detail_test.go +++ b/internal/tui/detail_test.go @@ -84,6 +84,49 @@ func TestHistorySectionsIncludeAllMetricCharts(t *testing.T) { } } +func TestOverviewSectionsIncludeTrafficLimit(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true, NoColor: true}) + node := komari.Node{ + UUID: "node-1", + Name: "node", + TrafficLimit: 500 * 1024 * 1024 * 1024, + } + st := komari.Status{ + Online: true, + NetTotalUp: 246_520_385_372, + NetTotalDown: 250_214_057_246, + } + + section := sectionByTitle(app.overviewSections(node, st), "Total Traffic") + if section == nil { + t.Fatal("missing Total Traffic section") + } + joined := strings.Join(section.Lines, "\n") + for _, want := range []string{"92.5%", "229.59 GB", "233.03 GB", "Sum(500.00 GB)"} { + if !strings.Contains(joined, want) { + t.Fatalf("traffic section missing %q: %q", want, joined) + } + } +} + +func TestDetailBodyShowsScrollIndicatorWhenMoreContentExists(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true, NoColor: true}) + node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{node}, + Status: map[string]komari.Status{node.UUID: {Online: true, CPU: 10}}, + } + app.detail = true + + lines := app.renderDetailBody(80, 12) + if len(lines) < 11 { + t.Fatalf("lines len = %d, want at least 11", len(lines)) + } + if !strings.HasSuffix(lines[10], "v") { + t.Fatalf("detail body line = %q, want down scroll indicator", lines[10]) + } +} + func TestChartFocusOpensAndClosesWithKeys(t *testing.T) { app := NewWithOptions(nil, Options{ASCII: true}) node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} diff --git a/internal/tui/format.go b/internal/tui/format.go index dc233b3..b19e0ff 100644 --- a/internal/tui/format.go +++ b/internal/tui/format.go @@ -349,6 +349,52 @@ func trafficPercent(up, down, limit int64, limitType string) float64 { return float64(used) / float64(limit) * 100 } +func trafficLimitKind(limitType string) string { + switch strings.ToLower(strings.TrimSpace(limitType)) { + case "up": + return "Up" + case "down": + return "Down" + case "max": + return "Max" + case "min": + return "Min" + default: + return "Sum" + } +} + +func trafficLimitText(limit int64, limitType string) string { + if limit <= 0 { + return "-" + } + return fmt.Sprintf("%s(%s)", trafficLimitKind(limitType), trafficBytes(limit)) +} + +func trafficBytes(n int64) string { + negative := n < 0 + if negative { + n = -n + } + units := []string{"B", "KB", "MB", "GB", "TB", "PB"} + value := float64(n) + unit := 0 + for value >= 1024 && unit < len(units)-1 { + value /= 1024 + unit++ + } + var out string + if unit == 0 { + out = fmt.Sprintf("%d %s", n, units[unit]) + } else { + out = fmt.Sprintf("%.2f %s", value, units[unit]) + } + if negative { + return "-" + out + } + return out +} + func displayWidth(s string) int { s = ansiRE.ReplaceAllString(s, "") width := 0 diff --git a/internal/tui/line.go b/internal/tui/line.go index 06c5634..4312188 100644 --- a/internal/tui/line.go +++ b/internal/tui/line.go @@ -34,7 +34,14 @@ func (a *App) renderLineBody(width int, bodyHeight int) []string { for i := a.scroll; i < end; i++ { lines = append(lines, a.lineTableRow(i, nodes[i], width)) } - return fillBody(lines, width, bodyHeight) + lines = fillBody(lines, width, bodyHeight) + return a.withScrollIndicator(lines, width, scrollIndicator{ + Start: len(header), + Height: visibleRows, + Offset: a.scroll, + Visible: visibleRows, + Total: len(nodes), + }) } func (a *App) lineTableHeader(width int) []string { @@ -67,7 +74,7 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari. nameWidth := 28 regionWidth := 10 netWidth := 19 - trafficWidth := 19 + trafficWidth := 24 uptimeWidth := 10 loadWidth := 16 osWidth := 12 @@ -78,11 +85,11 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari. showNet := width >= 104 showLoad := width >= 122 showUptime := width >= 136 - showTraffic := width >= 156 - showRuntime := width >= 174 - showExp := width >= 186 - showOS := width >= 202 - showTags := width >= 220 + showTraffic := width >= 162 + showRuntime := width >= 180 + showExp := width >= 192 + showOS := width >= 208 + showTags := width >= 226 if width < 64 { nameWidth = max(12, width-34) } else if width < 86 { @@ -185,6 +192,10 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari. } if showTraffic { traffic := fmt.Sprintf("%s %s %s %s", a.style.up(), bytesIEC(st.NetTotalUp), a.style.down(), bytesIEC(st.NetTotalDown)) + if node.TrafficLimit > 0 { + pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) + traffic = fmt.Sprintf("%.1f%% %s", pct, trafficLimitText(node.TrafficLimit, node.TrafficLimitType)) + } parts = append(parts, fmt.Sprintf(" %-*s", trafficWidth, cleanLine(traffic, trafficWidth))) } if showRuntime { diff --git a/internal/tui/line_test.go b/internal/tui/line_test.go index 0fecde1..8b187d4 100644 --- a/internal/tui/line_test.go +++ b/internal/tui/line_test.go @@ -61,3 +61,51 @@ func TestLineTableWideColumns(t *testing.T) { t.Fatalf("row width = %d, want <= %d: %q", displayWidth(row), width, row) } } + +func TestLineTableShowsTrafficLimitWhenAvailable(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true, NoColor: true}) + width := 230 + node := komari.Node{ + UUID: "node-1", + Name: "server", + TrafficLimit: 500 * 1024 * 1024 * 1024, + } + st := komari.Status{ + Online: true, + NetTotalUp: 246_520_385_372, + NetTotalDown: 250_214_057_246, + } + + row := app.lineTableColumns(width, true, node, st, false) + if !strings.Contains(row, "92.5% Sum(500.00 GB)") { + t.Fatalf("row missing traffic limit: %q", row) + } +} + +func TestLineBodyShowsScrollIndicatorWhenMoreNodesExist(t *testing.T) { + app := NewWithOptions(nil, Options{ASCII: true, NoColor: true, Mode: ModeLine}) + app.snapshot = komari.Snapshot{ + Nodes: []komari.Node{ + {UUID: "n1", Name: "one"}, + {UUID: "n2", Name: "two"}, + {UUID: "n3", Name: "three"}, + {UUID: "n4", Name: "four"}, + {UUID: "n5", Name: "five"}, + }, + Status: map[string]komari.Status{}, + } + + lines := app.renderLineBody(80, 5) + if !strings.HasSuffix(lines[4], "v") { + t.Fatalf("last visible line = %q, want down scroll indicator", lines[4]) + } + if strings.Contains(lines[4], "…") { + t.Fatalf("last visible line = %q, should not show ellipsis next to scroll indicator", lines[4]) + } + + app.selected = 4 + lines = app.renderLineBody(80, 5) + if !strings.HasSuffix(lines[3], "^") { + t.Fatalf("first visible row = %q, want up scroll indicator", lines[3]) + } +} diff --git a/internal/tui/mouse.go b/internal/tui/mouse.go index de05aa3..d4cb936 100644 --- a/internal/tui/mouse.go +++ b/internal/tui/mouse.go @@ -7,7 +7,7 @@ import ( const ( mouseHeaderRows = 4 - sheetCardHeight = 9 + sheetCardHeight = 10 sheetCardGap = 2 sheetMinWidth = 40 lineHeaderRows = 3 diff --git a/internal/tui/panel.go b/internal/tui/panel.go index f42ccc7..791f5d2 100644 --- a/internal/tui/panel.go +++ b/internal/tui/panel.go @@ -54,7 +54,7 @@ func (a *App) overviewLines(width int, maxLines int) []string { ) if node.TrafficLimit > 0 { pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) - lines = append(lines, fmt.Sprintf("Limit %.1f%% of %s (%s)", pct, bytesIEC(node.TrafficLimit), node.TrafficLimitType)) + lines = append(lines, fmt.Sprintf("Limit %.1f%% of %s", pct, trafficLimitText(node.TrafficLimit, node.TrafficLimitType))) } return limitLines(lines, width, maxLines) } @@ -100,7 +100,7 @@ func (a *App) detailLines(width int, maxLines int) []string { } if node.TrafficLimit > 0 { pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) - lines = append(lines, fmt.Sprintf("Limit %.1f%% of %s (%s)", pct, bytesIEC(node.TrafficLimit), node.TrafficLimitType)) + lines = append(lines, fmt.Sprintf("Limit %.1f%% of %s", pct, trafficLimitText(node.TrafficLimit, node.TrafficLimitType))) } if node.Price != 0 || node.ExpiredAt.Valid || node.Tags != "" || node.PublicRemark != "" || node.Remark != "" { price := "-" diff --git a/internal/tui/scrollbar.go b/internal/tui/scrollbar.go new file mode 100644 index 0000000..5682b0c --- /dev/null +++ b/internal/tui/scrollbar.go @@ -0,0 +1,148 @@ +package tui + +import ( + "strings" + "unicode/utf8" +) + +type scrollIndicator struct { + Start int + Height int + Offset int + Visible int + Total int +} + +func (a *App) withScrollIndicator(lines []string, width int, indicator scrollIndicator) []string { + if width < 4 || indicator.Height <= 0 || indicator.Visible <= 0 || indicator.Total <= indicator.Visible { + return lines + } + start := max(0, indicator.Start) + end := min(len(lines), start+indicator.Height) + if end <= start { + return lines + } + + out := append([]string(nil), lines...) + height := end - start + offset := indicator.Offset + maxOffset := indicator.Total - indicator.Visible + if offset < 0 { + offset = 0 + } + if offset > maxOffset { + offset = maxOffset + } + + thumbHeight := (indicator.Visible*height + indicator.Total - 1) / indicator.Total + if thumbHeight < 1 { + thumbHeight = 1 + } + if thumbHeight > height { + thumbHeight = height + } + thumbStart := 0 + if maxOffset > 0 && height > thumbHeight { + thumbStart = offset * (height - thumbHeight) / maxOffset + } + + track, thumb, up, down := a.scrollIndicatorGlyphs() + moreAbove := offset > 0 + moreBelow := offset+indicator.Visible < indicator.Total + for i := start; i < end; i++ { + row := i - start + mark := track + if row >= thumbStart && row < thumbStart+thumbHeight { + mark = thumb + } + if moreAbove && row == 0 { + mark = up + } + if moreBelow && row == height-1 { + mark = down + } + out[i] = fitLineNoEllipsis(out[i], width-1) + mark + } + return out +} + +func (a *App) scrollIndicatorGlyphs() (string, string, string, string) { + if a.style.ASCII { + return a.style.dim("|"), a.style.cyan("#"), a.style.yellow("^"), a.style.yellow("v") + } + return a.style.dim("│"), a.style.cyan("█"), a.style.yellow("▲"), a.style.yellow("▼") +} + +func fitLineNoEllipsis(value string, width int) string { + if width <= 0 { + return "" + } + value = strings.ReplaceAll(value, "\n", " ") + value = strings.ReplaceAll(value, "\r", " ") + if strings.Contains(value, "\x1b[") { + value = truncateANSINoEllipsis(value, width) + } else { + value = truncateNoEllipsis(value, width) + } + visible := displayWidth(value) + if visible >= width { + return value + } + return value + strings.Repeat(" ", width-visible) +} + +func truncateNoEllipsis(value string, width int) string { + if displayWidth(value) <= width { + return value + } + var out strings.Builder + visible := 0 + for _, r := range value { + rw := runeWidth(r) + if visible+rw > width { + break + } + out.WriteRune(r) + visible += rw + } + return out.String() +} + +func truncateANSINoEllipsis(value string, width int) string { + if visibleWidth(value) <= width { + return value + } + var out strings.Builder + visible := 0 + ansiOpen := false + for i := 0; i < len(value); { + if value[i] == 0x1b && i+1 < len(value) && value[i+1] == '[' { + end := i + 2 + for end < len(value) && ((value[end] < 'A' || value[end] > 'Z') && (value[end] < 'a' || value[end] > 'z')) { + end++ + } + if end < len(value) { + out.WriteString(value[i : end+1]) + ansiOpen = true + i = end + 1 + continue + } + } + + r, size := rune(value[i]), 1 + if r >= utf8.RuneSelf { + r, size = utf8.DecodeRuneInString(value[i:]) + } + rw := runeWidth(r) + if visible+rw > width { + break + } + out.WriteRune(r) + visible += rw + i += size + } + if ansiOpen { + out.WriteString("\x1b[0m") + } + return out.String() +} diff --git a/internal/tui/sheet.go b/internal/tui/sheet.go index 09f851c..761cc55 100644 --- a/internal/tui/sheet.go +++ b/internal/tui/sheet.go @@ -66,7 +66,14 @@ func (a *App) renderSheetBody(width int, bodyHeight int) []string { lines = append(lines, fitLine(line.String(), width)) } } - return fillBody(lines, width, bodyHeight) + lines = fillBody(lines, width, bodyHeight) + return a.withScrollIndicator(lines, width, scrollIndicator{ + Start: 0, + Height: bodyHeight, + Offset: a.scroll, + Visible: rowsVisible, + Total: (len(nodes) + columns - 1) / columns, + }) } func (a *App) nodeCard(index int, node komari.Node, width int, height int) []string { @@ -116,9 +123,17 @@ func (a *App) overviewCard(index int, node komari.Node, st komari.Status, width a.metricLine("RAM", ramPct, usageCompact(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)), width), a.metricLine("DSK", diskPct, usageCompact(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)), width), fmt.Sprintf(" NET %s %s %s %s", a.style.up(), speedIEC(st.NetOut), a.style.down(), speedIEC(st.NetIn)), - fmt.Sprintf(" FLOW %s %s %s %s", a.style.up(), bytesIEC(st.NetTotalUp), a.style.down(), bytesIEC(st.NetTotalDown)), - fmt.Sprintf(" UP %-10s EXP %s", durationCompact(st.Uptime), expiryText(node, time.Now())), } + if node.TrafficLimit > 0 { + pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) + lines = append(lines, + fmt.Sprintf(" FLOW %5.1f%% %s %s %s %s", pct, a.style.up(), trafficBytes(st.NetTotalUp), a.style.down(), trafficBytes(st.NetTotalDown)), + fmt.Sprintf(" LIM %s", trafficLimitText(node.TrafficLimit, node.TrafficLimitType)), + ) + } else { + lines = append(lines, fmt.Sprintf(" FLOW %s %s %s %s", a.style.up(), bytesIEC(st.NetTotalUp), a.style.down(), bytesIEC(st.NetTotalDown))) + } + lines = append(lines, fmt.Sprintf(" UP %-10s EXP %s", durationCompact(st.Uptime), expiryText(node, time.Now()))) if len(alert.Reasons) > 0 { lines = append([]string{title, fmt.Sprintf(" WARN %s", alertText(alert))}, lines[1:]...) } @@ -146,6 +161,8 @@ func (a *App) usageBarFor(label string, pct float64, width int) string { threshold = a.warnRAM case "DSK", "DISK": threshold = a.warnDisk + case "TRF", "TRAFFIC", "USED": + threshold = trafficWarnPercent } return a.usageBarWithThreshold(pct, threshold, width) }