feat: add node export traffic limits and scroll hints
This commit is contained in:
1 parent
f29cfa04e8
commit
679fe8fdce
14 files changed
+1272
-36
No files matched your search
@@ -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/磁盘百分比、实时网络、累计流量、流量上限占比、过期状态和异常原因。
|
||||
|
||||
## 配置文件
|
||||
|
||||
初始化配置:
|
||||
|
||||
@@ -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
|
||||
`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+45
-19
@@ -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 <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
|
||||
|
||||
@@ -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
@@ -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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
|
||||
const (
|
||||
mouseHeaderRows = 4
|
||||
sheetCardHeight = 9
|
||||
sheetCardHeight = 10
|
||||
sheetCardGap = 2
|
||||
sheetMinWidth = 40
|
||||
lineHeaderRows = 3
|
||||
|
||||
@@ -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 := "-"
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user