feat: initial caddyview implementation
SSH-connected TUI monitor for Caddy web server logs. Live view with 1s refresh, request stats, top hosts/IPs, status codes, transfer speed, CPU and RAM from /proc. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
0c57089abf
8 changed files with 867 additions and 0 deletions
18
cmd/root.go
Normal file
18
cmd/root.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "caddyview",
|
||||||
|
Short: "TUI monitor for Caddy web server logs over SSH",
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
if err := rootCmd.Execute(); err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
cmd/ssh.go
Normal file
44
cmd/ssh.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package cmd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/janvete/caddyview/internal/sshclient"
|
||||||
|
"github.com/janvete/caddyview/internal/tui"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
sshPort int
|
||||||
|
logPath string
|
||||||
|
sshKeyPath string
|
||||||
|
)
|
||||||
|
|
||||||
|
var sshCmd = &cobra.Command{
|
||||||
|
Use: "ssh [user@host]",
|
||||||
|
Short: "Connect to server and monitor Caddy logs",
|
||||||
|
Example: ` caddyview ssh root@192.168.1.1
|
||||||
|
caddyview ssh -p 2222 admin@myserver.com
|
||||||
|
caddyview ssh -l /var/log/caddy/access.log root@192.168.1.1`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
target := args[0]
|
||||||
|
|
||||||
|
client, err := sshclient.Connect(target, sshPort, sshKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "SSH connection failed: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
return tui.Run(client, logPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(sshCmd)
|
||||||
|
sshCmd.Flags().IntVarP(&sshPort, "port", "p", 22, "SSH port")
|
||||||
|
sshCmd.Flags().StringVarP(&logPath, "log", "l", "/var/log/caddy/access.log", "Path to Caddy access log on remote server")
|
||||||
|
sshCmd.Flags().StringVarP(&sshKeyPath, "key", "i", "", "SSH private key path (default: ~/.ssh/id_rsa or id_ed25519)")
|
||||||
|
}
|
||||||
29
go.mod
Normal file
29
go.mod
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
module github.com/janvete/caddyview
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 // indirect
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
|
github.com/muesli/termenv v0.16.0 // indirect
|
||||||
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
github.com/spf13/cobra v1.10.2 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.9 // indirect
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
golang.org/x/crypto v0.53.0 // indirect
|
||||||
|
golang.org/x/sys v0.46.0 // indirect
|
||||||
|
golang.org/x/text v0.38.0 // indirect
|
||||||
|
)
|
||||||
57
go.sum
Normal file
57
go.sum
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||||
|
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||||
|
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||||
|
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||||
|
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||||
|
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||||
|
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||||
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||||
|
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||||
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||||
|
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||||
|
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||||
|
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||||
|
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||||
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
|
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||||
|
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||||
|
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||||
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||||
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
|
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||||
|
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||||
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||||
|
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
|
||||||
|
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||||
|
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
|
||||||
|
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
130
internal/parser/caddy.go
Normal file
130
internal/parser/caddy.go
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
package parser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LogEntry represents one Caddy JSON access log line.
|
||||||
|
type LogEntry struct {
|
||||||
|
Timestamp float64 `json:"ts"`
|
||||||
|
Level string `json:"level"`
|
||||||
|
Logger string `json:"logger"`
|
||||||
|
Request struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
URI string `json:"uri"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Proto string `json:"proto"`
|
||||||
|
RemoteIP string `json:"remote_ip"`
|
||||||
|
} `json:"request"`
|
||||||
|
Duration float64 `json:"duration"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *LogEntry) Time() time.Time {
|
||||||
|
sec := int64(e.Timestamp)
|
||||||
|
nsec := int64((e.Timestamp - float64(sec)) * 1e9)
|
||||||
|
return time.Unix(sec, nsec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLine parses a single Caddy JSON log line. Returns nil if not a request log.
|
||||||
|
func ParseLine(line string) *LogEntry {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" || !strings.HasPrefix(line, "{") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var entry LogEntry
|
||||||
|
if err := json.Unmarshal([]byte(line), &entry); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if entry.Request.Host == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &entry
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseLines parses multiple lines and returns valid entries.
|
||||||
|
func ParseLines(lines []string) []*LogEntry {
|
||||||
|
entries := make([]*LogEntry, 0, len(lines))
|
||||||
|
for _, l := range lines {
|
||||||
|
if e := ParseLine(l); e != nil {
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats holds aggregated metrics for a time window.
|
||||||
|
type Stats struct {
|
||||||
|
TotalRequests int64
|
||||||
|
TotalBytes int64
|
||||||
|
StatusCodes map[int]int64
|
||||||
|
TopHosts map[string]int64
|
||||||
|
TopIPs map[string]int64
|
||||||
|
AvgDurationMs float64
|
||||||
|
|
||||||
|
durationSum float64
|
||||||
|
durationN int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStats() *Stats {
|
||||||
|
return &Stats{
|
||||||
|
StatusCodes: make(map[int]int64),
|
||||||
|
TopHosts: make(map[string]int64),
|
||||||
|
TopIPs: make(map[string]int64),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Stats) Add(e *LogEntry) {
|
||||||
|
s.TotalRequests++
|
||||||
|
s.TotalBytes += e.Size
|
||||||
|
s.StatusCodes[e.Status]++
|
||||||
|
s.TopHosts[e.Request.Host]++
|
||||||
|
s.TopIPs[e.Request.RemoteIP]++
|
||||||
|
s.durationSum += e.Duration * 1000
|
||||||
|
s.durationN++
|
||||||
|
if s.durationN > 0 {
|
||||||
|
s.AvgDurationMs = s.durationSum / float64(s.durationN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BytesPerSec calculates transfer rate given a window duration in seconds.
|
||||||
|
func (s *Stats) BytesPerSec(windowSecs float64) float64 {
|
||||||
|
if windowSecs <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return float64(s.TotalBytes) / windowSecs
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucket aggregates entries into fixed time buckets for the history view.
|
||||||
|
type Bucket struct {
|
||||||
|
Time time.Time
|
||||||
|
Requests int64
|
||||||
|
Bytes int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bucketize groups entries into buckets of the given duration.
|
||||||
|
func Bucketize(entries []*LogEntry, bucketSize time.Duration) []Bucket {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets := map[int64]*Bucket{}
|
||||||
|
for _, e := range entries {
|
||||||
|
t := e.Time().Truncate(bucketSize)
|
||||||
|
key := t.Unix()
|
||||||
|
if _, ok := buckets[key]; !ok {
|
||||||
|
buckets[key] = &Bucket{Time: t}
|
||||||
|
}
|
||||||
|
buckets[key].Requests++
|
||||||
|
buckets[key].Bytes += e.Size
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]Bucket, 0, len(buckets))
|
||||||
|
for _, b := range buckets {
|
||||||
|
result = append(result, *b)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
221
internal/sshclient/client.go
Normal file
221
internal/sshclient/client.go
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
package sshclient
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
conn *ssh.Client
|
||||||
|
host string
|
||||||
|
}
|
||||||
|
|
||||||
|
func Connect(target string, port int, keyPath string) (*Client, error) {
|
||||||
|
user, host, err := parseTarget(target)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
authMethods, err := buildAuthMethods(keyPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth setup failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
config := &ssh.ClientConfig{
|
||||||
|
User: user,
|
||||||
|
Auth: authMethods,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
}
|
||||||
|
|
||||||
|
addr := fmt.Sprintf("%s:%d", host, port)
|
||||||
|
conn, err := ssh.Dial("tcp", addr, config)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Client{conn: conn, host: host}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Close() {
|
||||||
|
c.conn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Host() string {
|
||||||
|
return c.host
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunCommand runs a command and returns its output as a string.
|
||||||
|
func (c *Client) RunCommand(cmd string) (string, error) {
|
||||||
|
session, err := c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
out, err := session.Output(cmd)
|
||||||
|
return string(out), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// TailLines reads the last n lines of a remote file.
|
||||||
|
func (c *Client) TailLines(path string, n int) ([]string, error) {
|
||||||
|
out, err := c.RunCommand(fmt.Sprintf("tail -n %d %s 2>/dev/null", n, path))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read %s: %w", path, err)
|
||||||
|
}
|
||||||
|
lines := strings.Split(strings.TrimSpace(out), "\n")
|
||||||
|
if len(lines) == 1 && lines[0] == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return lines, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StreamLines streams new lines from a remote file (like tail -f) into the channel.
|
||||||
|
func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struct{}) error {
|
||||||
|
session, err := c.conn.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
stdout, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
session.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := session.Start(fmt.Sprintf("tail -n 0 -f %s 2>/dev/null", path)); err != nil {
|
||||||
|
session.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer session.Close()
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case lines <- scanner.Text():
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
<-done
|
||||||
|
session.Close()
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SysStats fetches CPU and memory usage from the remote server.
|
||||||
|
func (c *Client) SysStats() (cpuPercent float64, memUsedMB, memTotalMB int64, err error) {
|
||||||
|
// Memory from /proc/meminfo
|
||||||
|
memOut, err := c.RunCommand("cat /proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, err
|
||||||
|
}
|
||||||
|
memTotalMB, memUsedMB = parseMeminfo(memOut)
|
||||||
|
|
||||||
|
// CPU: two /proc/stat samples 500ms apart
|
||||||
|
cpuOut, err := c.RunCommand("cat /proc/stat; sleep 0.5; cat /proc/stat")
|
||||||
|
if err != nil {
|
||||||
|
return 0, memUsedMB, memTotalMB, err
|
||||||
|
}
|
||||||
|
cpuPercent = parseCPUStat(cpuOut)
|
||||||
|
|
||||||
|
return cpuPercent, memUsedMB, memTotalMB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseTarget(target string) (user, host string, err error) {
|
||||||
|
parts := strings.SplitN(target, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", fmt.Errorf("invalid target %q — expected user@host", target)
|
||||||
|
}
|
||||||
|
return parts[0], parts[1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildAuthMethods(keyPath string) ([]ssh.AuthMethod, error) {
|
||||||
|
candidates := []string{}
|
||||||
|
if keyPath != "" {
|
||||||
|
candidates = append(candidates, keyPath)
|
||||||
|
} else {
|
||||||
|
home, _ := os.UserHomeDir()
|
||||||
|
candidates = append(candidates,
|
||||||
|
filepath.Join(home, ".ssh", "id_ed25519"),
|
||||||
|
filepath.Join(home, ".ssh", "id_rsa"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, p := range candidates {
|
||||||
|
data, err := os.ReadFile(p)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
signer, err := ssh.ParsePrivateKey(data)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no usable SSH private key found (tried: %v)", candidates)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseMeminfo(raw string) (totalMB, usedMB int64) {
|
||||||
|
var total, available int64
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
var val int64
|
||||||
|
if _, err := fmt.Sscanf(line, "MemTotal: %d kB", &val); err == nil {
|
||||||
|
total = val
|
||||||
|
}
|
||||||
|
if _, err := fmt.Sscanf(line, "MemAvailable: %d kB", &val); err == nil {
|
||||||
|
available = val
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total / 1024, (total - available) / 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCPUStat(raw string) float64 {
|
||||||
|
type cpuSample struct{ user, nice, sys, idle, iowait, irq, softirq uint64 }
|
||||||
|
parse := func(line string) (cpuSample, bool) {
|
||||||
|
var s cpuSample
|
||||||
|
var label string
|
||||||
|
n, _ := fmt.Sscanf(line, "%s %d %d %d %d %d %d %d",
|
||||||
|
&label, &s.user, &s.nice, &s.sys, &s.idle, &s.iowait, &s.irq, &s.softirq)
|
||||||
|
return s, n >= 5 && label == "cpu"
|
||||||
|
}
|
||||||
|
|
||||||
|
var samples []cpuSample
|
||||||
|
for _, line := range strings.Split(raw, "\n") {
|
||||||
|
if s, ok := parse(line); ok {
|
||||||
|
samples = append(samples, s)
|
||||||
|
if len(samples) == 2 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(samples) < 2 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
total1 := samples[0].user + samples[0].nice + samples[0].sys + samples[0].idle + samples[0].iowait + samples[0].irq + samples[0].softirq
|
||||||
|
total2 := samples[1].user + samples[1].nice + samples[1].sys + samples[1].idle + samples[1].iowait + samples[1].irq + samples[1].softirq
|
||||||
|
idle1 := samples[0].idle + samples[0].iowait
|
||||||
|
idle2 := samples[1].idle + samples[1].iowait
|
||||||
|
|
||||||
|
totalDiff := float64(total2 - total1)
|
||||||
|
idleDiff := float64(idle2 - idle1)
|
||||||
|
if totalDiff == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return (totalDiff - idleDiff) / totalDiff * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensure net import is used (used in future dial variants)
|
||||||
|
var _ = net.IPv4len
|
||||||
361
internal/tui/app.go
Normal file
361
internal/tui/app.go
Normal file
|
|
@ -0,0 +1,361 @@
|
||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/janvete/caddyview/internal/parser"
|
||||||
|
"github.com/janvete/caddyview/internal/sshclient"
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- styles -------------------------------------------------------------------
|
||||||
|
|
||||||
|
var (
|
||||||
|
styleBorder = lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("62")).
|
||||||
|
Padding(0, 1)
|
||||||
|
|
||||||
|
styleTitle = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("212"))
|
||||||
|
|
||||||
|
styleHeader = lipgloss.NewStyle().
|
||||||
|
Bold(true).
|
||||||
|
Foreground(lipgloss.Color("39"))
|
||||||
|
|
||||||
|
styleGood = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
|
||||||
|
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
|
||||||
|
styleBad = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
|
||||||
|
|
||||||
|
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
|
||||||
|
)
|
||||||
|
|
||||||
|
// -- messages -----------------------------------------------------------------
|
||||||
|
|
||||||
|
type tickMsg time.Time
|
||||||
|
type newLineMsg string
|
||||||
|
type sysStatsMsg struct {
|
||||||
|
cpu float64
|
||||||
|
memUsed int64
|
||||||
|
memTotal int64
|
||||||
|
}
|
||||||
|
type errMsg struct{ err error }
|
||||||
|
|
||||||
|
// -- model --------------------------------------------------------------------
|
||||||
|
|
||||||
|
type model struct {
|
||||||
|
client *sshclient.Client
|
||||||
|
logPath string
|
||||||
|
lineCh chan string
|
||||||
|
doneCh chan struct{}
|
||||||
|
|
||||||
|
liveEntries []*parser.LogEntry
|
||||||
|
liveStats *parser.Stats
|
||||||
|
liveStart time.Time
|
||||||
|
|
||||||
|
cpu float64
|
||||||
|
memUsed int64
|
||||||
|
memTotal int64
|
||||||
|
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func initialModel(client *sshclient.Client, logPath string) model {
|
||||||
|
return model{
|
||||||
|
client: client,
|
||||||
|
logPath: logPath,
|
||||||
|
lineCh: make(chan string, 256),
|
||||||
|
doneCh: make(chan struct{}),
|
||||||
|
liveStats: parser.NewStats(),
|
||||||
|
liveStart: time.Now(),
|
||||||
|
width: 120,
|
||||||
|
height: 40,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- init ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (m model) Init() tea.Cmd {
|
||||||
|
return tea.Batch(
|
||||||
|
startStreaming(m.client, m.logPath, m.lineCh, m.doneCh),
|
||||||
|
tick(),
|
||||||
|
fetchSysStats(m.client),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func startStreaming(client *sshclient.Client, logPath string, ch chan string, done chan struct{}) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
if err := client.StreamLines(logPath, ch, done); err != nil {
|
||||||
|
return errMsg{err}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func tick() tea.Cmd {
|
||||||
|
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
|
||||||
|
return tickMsg(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchSysStats(client *sshclient.Client) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
cpu, used, total, err := client.SysStats()
|
||||||
|
if err != nil {
|
||||||
|
return sysStatsMsg{}
|
||||||
|
}
|
||||||
|
return sysStatsMsg{cpu: cpu, memUsed: used, memTotal: total}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func drainLines(ch chan string) tea.Cmd {
|
||||||
|
return func() tea.Msg {
|
||||||
|
select {
|
||||||
|
case line := <-ch:
|
||||||
|
return newLineMsg(line)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- update -------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg := msg.(type) {
|
||||||
|
|
||||||
|
case tea.WindowSizeMsg:
|
||||||
|
m.width = msg.Width
|
||||||
|
m.height = msg.Height
|
||||||
|
|
||||||
|
case tea.KeyMsg:
|
||||||
|
if msg.String() == "q" || msg.String() == "ctrl+c" {
|
||||||
|
close(m.doneCh)
|
||||||
|
return m, tea.Quit
|
||||||
|
}
|
||||||
|
|
||||||
|
case tickMsg:
|
||||||
|
return m, tea.Batch(tick(), drainLines(m.lineCh), fetchSysStats(m.client))
|
||||||
|
|
||||||
|
case newLineMsg:
|
||||||
|
if e := parser.ParseLine(string(msg)); e != nil {
|
||||||
|
m.liveStats.Add(e)
|
||||||
|
m.liveEntries = append(m.liveEntries, e)
|
||||||
|
if len(m.liveEntries) > 10000 {
|
||||||
|
m.liveEntries = m.liveEntries[len(m.liveEntries)-10000:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, drainLines(m.lineCh)
|
||||||
|
|
||||||
|
case sysStatsMsg:
|
||||||
|
m.cpu = msg.cpu
|
||||||
|
m.memUsed = msg.memUsed
|
||||||
|
m.memTotal = msg.memTotal
|
||||||
|
|
||||||
|
case errMsg:
|
||||||
|
m.err = msg.err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- view ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
func (m model) View() string {
|
||||||
|
if m.err != nil {
|
||||||
|
return styleBad.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err))
|
||||||
|
}
|
||||||
|
|
||||||
|
header := m.renderHeader()
|
||||||
|
stats := m.renderStats()
|
||||||
|
hosts := m.renderTopHosts()
|
||||||
|
ips := m.renderTopIPs()
|
||||||
|
status := m.renderStatusCodes()
|
||||||
|
footer := styleDim.Render(" q quit")
|
||||||
|
|
||||||
|
top := lipgloss.JoinHorizontal(lipgloss.Top, stats, hosts, ips, status)
|
||||||
|
return lipgloss.JoinVertical(lipgloss.Left, header, top, footer)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderHeader() string {
|
||||||
|
elapsed := time.Since(m.liveStart).Round(time.Second)
|
||||||
|
cpuColor := styleGood
|
||||||
|
if m.cpu > 80 {
|
||||||
|
cpuColor = styleBad
|
||||||
|
} else if m.cpu > 50 {
|
||||||
|
cpuColor = styleWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
memPct := 0.0
|
||||||
|
if m.memTotal > 0 {
|
||||||
|
memPct = float64(m.memUsed) / float64(m.memTotal) * 100
|
||||||
|
}
|
||||||
|
memColor := styleGood
|
||||||
|
if memPct > 80 {
|
||||||
|
memColor = styleBad
|
||||||
|
} else if memPct > 60 {
|
||||||
|
memColor = styleWarn
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := []string{
|
||||||
|
styleTitle.Render("caddyview"),
|
||||||
|
styleDim.Render(" "),
|
||||||
|
styleHeader.Render("host: ") + m.client.Host(),
|
||||||
|
styleDim.Render(" "),
|
||||||
|
styleHeader.Render("log: ") + styleDim.Render(m.logPath),
|
||||||
|
styleDim.Render(" "),
|
||||||
|
styleHeader.Render("uptime: ") + elapsed.String(),
|
||||||
|
styleDim.Render(" "),
|
||||||
|
styleHeader.Render("CPU: ") + cpuColor.Render(fmt.Sprintf("%.1f%%", m.cpu)),
|
||||||
|
styleDim.Render(" "),
|
||||||
|
styleHeader.Render("MEM: ") + memColor.Render(fmt.Sprintf("%d/%d MB (%.0f%%)", m.memUsed, m.memTotal, memPct)),
|
||||||
|
}
|
||||||
|
|
||||||
|
return lipgloss.NewStyle().
|
||||||
|
BorderBottom(true).
|
||||||
|
BorderStyle(lipgloss.NormalBorder()).
|
||||||
|
BorderForeground(lipgloss.Color("62")).
|
||||||
|
Width(m.width).
|
||||||
|
Render(strings.Join(parts, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderStats() string {
|
||||||
|
elapsed := time.Since(m.liveStart).Seconds()
|
||||||
|
rps := float64(m.liveStats.TotalRequests)
|
||||||
|
if elapsed > 0 {
|
||||||
|
rps = float64(m.liveStats.TotalRequests) / elapsed
|
||||||
|
}
|
||||||
|
bps := m.liveStats.BytesPerSec(elapsed)
|
||||||
|
|
||||||
|
content := strings.Join([]string{
|
||||||
|
styleHeader.Render("Live Stats"),
|
||||||
|
"",
|
||||||
|
fmt.Sprintf("Requests : %s", styleGood.Render(fmt.Sprintf("%d", m.liveStats.TotalRequests))),
|
||||||
|
fmt.Sprintf("Req/sec : %s", styleGood.Render(fmt.Sprintf("%.2f", rps))),
|
||||||
|
fmt.Sprintf("Transfer : %s", formatBytes(m.liveStats.TotalBytes)),
|
||||||
|
fmt.Sprintf("Speed : %s", formatBytes(int64(bps))+"/s"),
|
||||||
|
fmt.Sprintf("Avg resp : %.1fms", m.liveStats.AvgDurationMs),
|
||||||
|
}, "\n")
|
||||||
|
|
||||||
|
return styleBorder.Render(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderTopHosts() string {
|
||||||
|
lines := []string{styleHeader.Render("Top Hosts"), ""}
|
||||||
|
type kv struct {
|
||||||
|
k string
|
||||||
|
v int64
|
||||||
|
}
|
||||||
|
pairs := make([]kv, 0, len(m.liveStats.TopHosts))
|
||||||
|
for k, v := range m.liveStats.TopHosts {
|
||||||
|
pairs = append(pairs, kv{k, v})
|
||||||
|
}
|
||||||
|
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v })
|
||||||
|
if len(pairs) > 8 {
|
||||||
|
pairs = pairs[:8]
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
host := p.k
|
||||||
|
if len(host) > 30 {
|
||||||
|
host = host[:27] + "..."
|
||||||
|
}
|
||||||
|
lines = append(lines, fmt.Sprintf("%-30s %s", host, styleGood.Render(fmt.Sprintf("%d", p.v))))
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
lines = append(lines, styleDim.Render("waiting for requests..."))
|
||||||
|
}
|
||||||
|
return styleBorder.Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderTopIPs() string {
|
||||||
|
lines := []string{styleHeader.Render("Top IPs"), ""}
|
||||||
|
type kv struct {
|
||||||
|
k string
|
||||||
|
v int64
|
||||||
|
}
|
||||||
|
pairs := make([]kv, 0, len(m.liveStats.TopIPs))
|
||||||
|
for k, v := range m.liveStats.TopIPs {
|
||||||
|
pairs = append(pairs, kv{k, v})
|
||||||
|
}
|
||||||
|
sort.Slice(pairs, func(i, j int) bool { return pairs[i].v > pairs[j].v })
|
||||||
|
if len(pairs) > 8 {
|
||||||
|
pairs = pairs[:8]
|
||||||
|
}
|
||||||
|
for _, p := range pairs {
|
||||||
|
lines = append(lines, fmt.Sprintf("%-20s %s", p.k, styleGood.Render(fmt.Sprintf("%d", p.v))))
|
||||||
|
}
|
||||||
|
if len(pairs) == 0 {
|
||||||
|
lines = append(lines, styleDim.Render("waiting for requests..."))
|
||||||
|
}
|
||||||
|
return styleBorder.Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m model) renderStatusCodes() string {
|
||||||
|
lines := []string{styleHeader.Render("Status Codes"), ""}
|
||||||
|
|
||||||
|
groups := map[string]int64{
|
||||||
|
"2xx": 0, "3xx": 0, "4xx": 0, "5xx": 0,
|
||||||
|
}
|
||||||
|
for code, count := range m.liveStats.StatusCodes {
|
||||||
|
switch {
|
||||||
|
case code >= 200 && code < 300:
|
||||||
|
groups["2xx"] += count
|
||||||
|
case code >= 300 && code < 400:
|
||||||
|
groups["3xx"] += count
|
||||||
|
case code >= 400 && code < 500:
|
||||||
|
groups["4xx"] += count
|
||||||
|
case code >= 500:
|
||||||
|
groups["5xx"] += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
colorFor := func(g string, v int64) string {
|
||||||
|
s := fmt.Sprintf("%d", v)
|
||||||
|
switch g {
|
||||||
|
case "2xx":
|
||||||
|
return styleGood.Render(s)
|
||||||
|
case "3xx":
|
||||||
|
return styleDim.Render(s)
|
||||||
|
case "4xx":
|
||||||
|
return styleWarn.Render(s)
|
||||||
|
case "5xx":
|
||||||
|
return styleBad.Render(s)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} {
|
||||||
|
lines = append(lines, fmt.Sprintf("%s : %s", g, colorFor(g, groups[g])))
|
||||||
|
}
|
||||||
|
return styleBorder.Render(strings.Join(lines, "\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- helpers ------------------------------------------------------------------
|
||||||
|
|
||||||
|
func formatBytes(b int64) string {
|
||||||
|
switch {
|
||||||
|
case b >= 1<<30:
|
||||||
|
return fmt.Sprintf("%.2f GB", float64(b)/(1<<30))
|
||||||
|
case b >= 1<<20:
|
||||||
|
return fmt.Sprintf("%.2f MB", float64(b)/(1<<20))
|
||||||
|
case b >= 1<<10:
|
||||||
|
return fmt.Sprintf("%.2f KB", float64(b)/(1<<10))
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%d B", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- entry point --------------------------------------------------------------
|
||||||
|
|
||||||
|
func Run(client *sshclient.Client, logPath string) error {
|
||||||
|
p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen())
|
||||||
|
_, err := p.Run()
|
||||||
|
return err
|
||||||
|
}
|
||||||
7
main.go
Normal file
7
main.go
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import "github.com/janvete/caddyview/cmd"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cmd.Execute()
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue