feat: add node export traffic limits and scroll hints

This commit is contained in:
gary committed 2026-07-04 01:07:52 +08:00
1 parent f29cfa04e8
commit 679fe8fdce
14 files changed
+1270 -34

No files matched your search

+12
View File
@@ -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/磁盘百分比、实时网络、累计流量、流量上限占比、过期状态和异常原因。
## 配置文件
初始化配置:
+672
View File
@@ -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 <json|csv|markdown> [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
`)
}
+187
View File
@@ -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
}
+43 -17
View File
@@ -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,18 +132,10 @@ 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)
baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout)
if err != nil {
fatal(err)
}
baseURL = next.URL
apiKey = next.APIKey
}
client, err := komari.NewClientWithOptions(baseURL, komari.Options{APIKey: apiKey, Timeout: timeout})
if err != nil {
@@ -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 <json|csv|markdown> [--output PATH]
ktui config <path|init|show|set|help>
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
+15
View File
@@ -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
}
+15 -4
View File
@@ -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)),
},
})
}
+43
View File
@@ -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}
+46
View File
@@ -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
+18 -7
View File
@@ -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 {
+48
View File
@@ -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])
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import (
const (
mouseHeaderRows = 4
sheetCardHeight = 9
sheetCardHeight = 10
sheetCardGap = 2
sheetMinWidth = 40
lineHeaderRows = 3
+2 -2
View File
@@ -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 := "-"
+148
View File
@@ -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()
}
+20 -3
View File
@@ -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)
}