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
+1272 -36

No files matched your search

+12
View File
@@ -16,6 +16,7 @@ https://www.nodeseek.com/post-710243-1
- `sheet` 卡片视图:按卡片展示每台服务器的 CPU、RAM、磁盘、网络、流量、运行时间和过期信息 - `sheet` 卡片视图:按卡片展示每台服务器的 CPU、RAM、磁盘、网络、流量、运行时间和过期信息
- `line` 列表视图:更紧凑地逐行展示服务器概览,宽屏下会展开负载、流量、连接数、进程数、过期时间、系统和标签等列 - `line` 列表视图:更紧凑地逐行展示服务器概览,宽屏下会展开负载、流量、连接数、进程数、过期时间、系统和标签等列
- 支持节点流量上限展示:已配置流量限额的节点会显示累计流量占比、上下行累计流量和限额类型
- 两层交互:第一层是节点列表,第二层是单节点详情 - 两层交互:第一层是节点列表,第二层是单节点详情
- 详情页标签:`overview`、`node`、`history`、`ping`、`meta` - 详情页标签:`overview`、`node`、`history`、`ping`、`meta`
- 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d` - 历史窗口:`realtime`、`4h`、`1d`、`7d`、`30d`
@@ -23,6 +24,7 @@ https://www.nodeseek.com/post-710243-1
- 详情页图表聚焦模式:点击图表或按 `f` 可用整页查看单张图 - 详情页图表聚焦模式:点击图表或按 `f` 可用整页查看单张图
- Ping 数据:实时 Ping 和历史 Ping 汇总 - Ping 数据:实时 Ping 和历史 Ping 汇总
- 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情 - 支持 Komari API key,能读取 IPv4、IPv6、过期时间等后台节点详情
- 支持导出当前节点状态为 JSON、CSV 或 Markdown,便于写报告、脚本处理或分享排障信息
- 支持配置文件,默认持久化到系统用户配置目录 - 支持配置文件,默认持久化到系统用户配置目录
- 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动 - 支持终端窗口大小变化,窄窗口下会自动裁剪和滚动
- 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口 - 支持鼠标和触控板:列表点击选择、滚轮滚动,详情页可滚动并点击切换标签/窗口
@@ -124,6 +126,16 @@ ktui update
./ktui --once ./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
}
+45 -19
View File
@@ -47,6 +47,12 @@ func main() {
} }
return 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 len(args) > 0 && (args[0] == "help" || args[0] == "--help" || args[0] == "-h") {
if err := handleHelp(args[1:]); err != nil { if err := handleHelp(args[1:]); err != nil {
fatal(err) fatal(err)
@@ -57,15 +63,10 @@ func main() {
usageError(fmt.Errorf("unknown command %q", args[0])) usageError(fmt.Errorf("unknown command %q", args[0]))
} }
cfg, cfgPath, err := config.Load() cfg, cfgPath, err := loadEffectiveConfig()
if err != nil { if err != nil {
fatal(err) fatal(err)
} }
cfg = applyEnv(cfg)
cfg = cfg.WithDefaults()
if err := cfg.Validate(); err != nil {
fatal(err)
}
intervalDefault, err := cfg.IntervalDuration() intervalDefault, err := cfg.IntervalDuration()
if err != nil { if err != nil {
@@ -131,17 +132,9 @@ func main() {
if sheetMode { if sheetMode {
mode = tui.ModeSheet mode = tui.ModeSheet
} }
if strings.TrimSpace(baseURL) == "" { baseURL, apiKey, err = prepareConnectionConfig(cfg, baseURL, apiKey, os.Stdin, os.Stdout)
if !isInteractiveTerminal(os.Stdin) { if err != nil {
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`")) fatal(err)
}
cfg.APIKey = apiKey
next, err := firstRunSetup(cfg, 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}) 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 { func applyEnv(cfg config.Config) config.Config {
if value := os.Getenv("KTUI_URL"); value != "" { if value := os.Getenv("KTUI_URL"); value != "" {
cfg.URL = value cfg.URL = value
@@ -267,6 +273,21 @@ func saveTUISettings(settings tui.PersistentSettings) error {
return err 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) { func firstRunSetup(cfg config.Config, input io.Reader, output io.Writer) (config.Config, error) {
reader := bufio.NewReader(input) reader := bufio.NewReader(input)
fmt.Fprintln(output, "ktui first run setup") fmt.Fprintln(output, "ktui first run setup")
@@ -427,7 +448,7 @@ func handleConfig(args []string) error {
func handleHelp(args []string) error { func handleHelp(args []string) error {
if len(args) > 1 { 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 { if len(args) == 0 {
printHelp() printHelp()
@@ -440,6 +461,8 @@ func handleHelp(args []string) error {
printKeysHelp() printKeysHelp()
case "update": case "update":
printUpdateHelp() printUpdateHelp()
case "export":
printExportHelp()
default: default:
return fmt.Errorf("unknown help topic %q", args[0]) return fmt.Errorf("unknown help topic %q", args[0])
} }
@@ -479,8 +502,9 @@ Usage:
ktui [flags] ktui [flags]
ktui version ktui version
ktui update [--check] [--tag v0.1.0] ktui update [--check] [--tag v0.1.0]
ktui export <json|csv|markdown> [--output PATH]
ktui config <path|init|show|set|help> ktui config <path|init|show|set|help>
ktui help [config|keys|update] ktui help [config|keys|update|export]
Flags: Flags:
--url URL Komari base URL --url URL Komari base URL
@@ -504,6 +528,8 @@ Examples:
ktui --line --ascii --no-color ktui --line --ascii --no-color
ktui version ktui version
ktui update --check ktui update --check
ktui export markdown
ktui export csv --output nodes.csv
ktui config init ktui config init
ktui config set api-key your_api_key ktui config set api-key your_api_key
ktui help keys ktui help keys
+15
View File
@@ -7,6 +7,11 @@ import (
"ktui/internal/komari" "ktui/internal/komari"
) )
const (
trafficWarnPercent = 90.0
trafficCriticalPercent = 100.0
)
type nodeAlert struct { type nodeAlert struct {
Critical bool Critical bool
Warning 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") 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 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 := make([]string, 0, bodyHeight)
lines = append(lines, chrome...) lines = append(lines, chrome...)
lines = append(lines, content[a.scroll:end]...) 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 { 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 { 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))) 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) 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 { if node.TrafficLimit > 0 {
pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType)
sections = append(sections, detailSection{ sections = append(sections, detailSection{
Title: "Traffic Limit", Title: "Total Traffic",
Lines: []string{ Lines: []string{
a.detailUsageLine(" Used", pct, fmt.Sprintf("%.1f%%", pct)), a.detailUsageLine(" Used", pct, fmt.Sprintf("%.1f%%", pct)),
fmt.Sprintf(" Limit %s", bytesIEC(node.TrafficLimit)), fmt.Sprintf(" Flow %s %s %s %s", a.style.up(), trafficBytes(st.NetTotalUp), a.style.down(), trafficBytes(st.NetTotalDown)),
fmt.Sprintf(" Type %s", valueOr(node.TrafficLimitType, "-")), 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) { func TestChartFocusOpensAndClosesWithKeys(t *testing.T) {
app := NewWithOptions(nil, Options{ASCII: true}) app := NewWithOptions(nil, Options{ASCII: true})
node := komari.Node{UUID: "node-1", Name: "node", MemTotal: 1000, DiskTotal: 2000} 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 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 { func displayWidth(s string) int {
s = ansiRE.ReplaceAllString(s, "") s = ansiRE.ReplaceAllString(s, "")
width := 0 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++ { for i := a.scroll; i < end; i++ {
lines = append(lines, a.lineTableRow(i, nodes[i], width)) 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 { 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 nameWidth := 28
regionWidth := 10 regionWidth := 10
netWidth := 19 netWidth := 19
trafficWidth := 19 trafficWidth := 24
uptimeWidth := 10 uptimeWidth := 10
loadWidth := 16 loadWidth := 16
osWidth := 12 osWidth := 12
@@ -78,11 +85,11 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari.
showNet := width >= 104 showNet := width >= 104
showLoad := width >= 122 showLoad := width >= 122
showUptime := width >= 136 showUptime := width >= 136
showTraffic := width >= 156 showTraffic := width >= 162
showRuntime := width >= 174 showRuntime := width >= 180
showExp := width >= 186 showExp := width >= 192
showOS := width >= 202 showOS := width >= 208
showTags := width >= 220 showTags := width >= 226
if width < 64 { if width < 64 {
nameWidth = max(12, width-34) nameWidth = max(12, width-34)
} else if width < 86 { } else if width < 86 {
@@ -185,6 +192,10 @@ func (a *App) lineTableColumns(width int, row bool, node komari.Node, st komari.
} }
if showTraffic { if showTraffic {
traffic := fmt.Sprintf("%s %s %s %s", a.style.up(), bytesIEC(st.NetTotalUp), a.style.down(), bytesIEC(st.NetTotalDown)) 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))) parts = append(parts, fmt.Sprintf(" %-*s", trafficWidth, cleanLine(traffic, trafficWidth)))
} }
if showRuntime { 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) 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 ( const (
mouseHeaderRows = 4 mouseHeaderRows = 4
sheetCardHeight = 9 sheetCardHeight = 10
sheetCardGap = 2 sheetCardGap = 2
sheetMinWidth = 40 sheetMinWidth = 40
lineHeaderRows = 3 lineHeaderRows = 3
+2 -2
View File
@@ -54,7 +54,7 @@ func (a *App) overviewLines(width int, maxLines int) []string {
) )
if node.TrafficLimit > 0 { if node.TrafficLimit > 0 {
pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) 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) return limitLines(lines, width, maxLines)
} }
@@ -100,7 +100,7 @@ func (a *App) detailLines(width int, maxLines int) []string {
} }
if node.TrafficLimit > 0 { if node.TrafficLimit > 0 {
pct := trafficPercent(st.NetTotalUp, st.NetTotalDown, node.TrafficLimit, node.TrafficLimitType) 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 != "" { if node.Price != 0 || node.ExpiredAt.Valid || node.Tags != "" || node.PublicRemark != "" || node.Remark != "" {
price := "-" 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)) 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 { 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("RAM", ramPct, usageCompact(st.RAM, firstNonZero(st.RAMTotal, node.MemTotal)), width),
a.metricLine("DSK", diskPct, usageCompact(st.Disk, firstNonZero(st.DiskTotal, node.DiskTotal)), 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(" 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 { if len(alert.Reasons) > 0 {
lines = append([]string{title, fmt.Sprintf(" WARN %s", alertText(alert))}, lines[1:]...) 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 threshold = a.warnRAM
case "DSK", "DISK": case "DSK", "DISK":
threshold = a.warnDisk threshold = a.warnDisk
case "TRF", "TRAFFIC", "USED":
threshold = trafficWarnPercent
} }
return a.usageBarWithThreshold(pct, threshold, width) return a.usageBarWithThreshold(pct, threshold, width)
} }