From 2800f9bbe49e6f93d22f221767a459a8bed2e4ee Mon Sep 17 00:00:00 2001 From: Joe Corall Date: Fri, 20 Mar 2026 14:02:42 -0400 Subject: [PATCH] Add more metrics to the TUI also work on docs --- docs/commands/compose.mdx | 8 +- docs/context.mdx | 3 +- docs/install.mdx | 235 +++++++++++++++- docs/quickstart.mdx | 24 +- docs/snippets/compose-tooltip.mdx | 17 ++ index.mdx | 38 ++- pkg/docker/docker.go | 2 +- pkg/docker/summary.go | 176 +++++++++++- pkg/docker/summary_test.go | 25 ++ pkg/tui/dashboard.go | 442 ++++++++++++++++++++++++------ 10 files changed, 857 insertions(+), 113 deletions(-) create mode 100644 docs/snippets/compose-tooltip.mdx diff --git a/docs/commands/compose.mdx b/docs/commands/compose.mdx index 7a54633..e275719 100644 --- a/docs/commands/compose.mdx +++ b/docs/commands/compose.mdx @@ -3,11 +3,13 @@ title: Compose command description: Run Docker Compose commands through the active sitectl context. --- -# Compose command +import { Compose } from "/docs/snippets/compose-tooltip.mdx"; -The `compose` command runs Docker Compose commands against the active `sitectl` context. +# command -Use it when you want the same Compose operation to respect the site and environment wiring already stored in the context. +The `compose` command runs commands against the active `sitectl` context. + +Use it when you want the same operation to respect the site and environment wiring already stored in the context. Examples: diff --git a/docs/context.mdx b/docs/context.mdx index 822fe8a..e4099a2 100644 --- a/docs/context.mdx +++ b/docs/context.mdx @@ -3,6 +3,7 @@ title: Context description: How sitectl models sites, environments, and the saved context that connects to each one. --- +import { Compose } from "/docs/snippets/compose-tooltip.mdx"; `sitectl` organizes around a **site** and its **environments** into what it calls a context. @@ -15,4 +16,4 @@ Examples: - `museum-local` - `museum-prod` -Contexts tell `sitectl` where Docker Compose lives and how to reach it. +Contexts tell `sitectl` where lives and how to reach it. diff --git a/docs/install.mdx b/docs/install.mdx index 0ad9130..d47d5ca 100644 --- a/docs/install.mdx +++ b/docs/install.mdx @@ -1,8 +1,14 @@ --- -title: Install -description: Install sitectl with Homebrew, native Linux packages, or direct binaries. +title: Install and Upgrade +description: Install or upgrade sitectl with Homebrew, native Linux packages, or direct binaries. --- +Select a tab below for the core `sitectl` command or one of its plugins to see install and upgrade instructions. +Installing a `sitectl` plugin automatically will install `sitectl` on your machine if using homebrew or linux packages to install. + + + + ## Homebrew You can install `sitectl` using Homebrew: @@ -12,6 +18,13 @@ brew tap libops/homebrew https://github.com/libops/homebrew brew install libops/homebrew/sitectl ``` +To upgrade later: + +```bash +brew update +brew upgrade libops/homebrew/sitectl +``` + ## Linux Packages Releases publish native Linux packages through the libops package repository. @@ -42,9 +55,24 @@ sudo dnf install sitectl +To upgrade later: + + + +```bash Debian / Ubuntu +sudo apt update +sudo apt install --only-upgrade sitectl +``` + +```bash Fedora / Rocky / RHEL +sudo dnf upgrade sitectl +``` + + + ## Binary Install -You can install sitectl by either downloading or building the `sitectl` binary +You can install `sitectl` by either downloading or building the `sitectl` binary. @@ -62,8 +90,205 @@ cd sitectl make build ./sitectl --help ``` - + + + +To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. + +Once `sitectl` is on your system, put the binary in a directory that is in your `$PATH`. + + + + +## Homebrew + +You can install `sitectl-drupal` using Homebrew: + +```bash +brew tap libops/homebrew https://github.com/libops/homebrew +brew install libops/homebrew/sitectl-drupal +``` + +Homebrew will also install `sitectl` as a dependency. + +To upgrade later: + +```bash +brew update +brew upgrade libops/homebrew/sitectl-drupal +``` + +## Linux Packages + +Releases publish native Linux packages through the libops package repository. + + + +```bash Debian / Ubuntu +curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" | sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null +sudo apt update +sudo apt install sitectl-drupal +``` + +```bash Fedora / Rocky / RHEL +sudo tee /etc/yum.repos.d/sitectl.repo >/dev/null <<'EOF' +[sitectl] +name=sitectl +baseurl=https://packages.libops.io/sitectl/rpm +enabled=1 +gpgcheck=0 +repo_gpgcheck=1 +gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc +EOF + +sudo dnf makecache +sudo dnf install sitectl-drupal +``` + + + +The package manager will also install `sitectl` as a dependency. + +To upgrade later: + + + +```bash Debian / Ubuntu +sudo apt update +sudo apt install --only-upgrade sitectl-drupal +``` + +```bash Fedora / Rocky / RHEL +sudo dnf upgrade sitectl-drupal +``` + + + +## Binary Install + +You can install `sitectl-drupal` by either downloading or building the `sitectl-drupal` binary. + +You also need the base `sitectl` binary on your system. + + + + +You can download binaries for your system from [the latest release of sitectl](https://github.com/libops/sitectl/releases/latest) and [the latest release of sitectl-drupal](https://github.com/libops/sitectl-drupal/releases/latest). + + + + +Requires `go` and `make` + +```bash +git clone https://github.com/libops/sitectl-drupal +cd sitectl-drupal +make build +./sitectl-drupal --help +``` + + + +To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. + +Once `sitectl-drupal` is on your system, put the binary in a directory that is in your `$PATH`. + + + + +## Homebrew + +You can install `sitectl-isle` using Homebrew: + +```bash +brew tap libops/homebrew https://github.com/libops/homebrew +brew install libops/homebrew/sitectl-isle +``` + +Homebrew will also install `sitectl` and `sitectl-drupal` as dependencies. + +To upgrade later: + +```bash +brew update +brew upgrade libops/homebrew/sitectl-isle +``` + +## Linux Packages + +Releases publish native Linux packages through the libops package repository. + + + +```bash Debian / Ubuntu +curl -fsSL https://packages.libops.io/sitectl/sitectl-archive-keyring.asc | sudo gpg --dearmor -o /usr/share/keyrings/sitectl-archive-keyring.gpg +echo "deb [signed-by=/usr/share/keyrings/sitectl-archive-keyring.gpg] https://packages.libops.io/sitectl ./" | sudo tee /etc/apt/sources.list.d/sitectl.list >/dev/null +sudo apt update +sudo apt install sitectl-isle +``` + +```bash Fedora / Rocky / RHEL +sudo tee /etc/yum.repos.d/sitectl.repo >/dev/null <<'EOF' +[sitectl] +name=sitectl +baseurl=https://packages.libops.io/sitectl/rpm +enabled=1 +gpgcheck=0 +repo_gpgcheck=1 +gpgkey=https://packages.libops.io/sitectl/sitectl-archive-keyring.asc +EOF + +sudo dnf makecache +sudo dnf install sitectl-isle +``` + + + +The package manager will also install `sitectl` and `sitectl-drupal` as dependencies. + +To upgrade later: + + + +```bash Debian / Ubuntu +sudo apt update +sudo apt install --only-upgrade sitectl-isle +``` + +```bash Fedora / Rocky / RHEL +sudo dnf upgrade sitectl-isle +``` + + + +## Binary Install + +You can install `sitectl-isle` by either downloading or building the `sitectl-isle` binary. + +You also need the base `sitectl` binary on your system. + + + + +You can download binaries for your system from [the latest release of sitectl](https://github.com/libops/sitectl/releases/latest) and [the latest release of sitectl-isle](https://github.com/libops/sitectl-isle/releases/latest). + + + + +Requires `go` and `make` + +```bash +git clone https://github.com/libops/sitectl-isle +cd sitectl-isle +make build +./sitectl-isle --help +``` + +To upgrade a binary install, download the newest release or rebuild from the latest source, then replace the existing binary in your `$PATH`. -Once `sitectl` is on your system the you can put the binary in a directory that is in your `$PATH`. +Once `sitectl-isle` is on your system, put the binary in a directory that is in your `$PATH`. + + diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 32ce7f4..73f1036 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -3,6 +3,7 @@ title: Quickstart description: Learn the sitectl command shape and the core areas to explore after installation. --- +import { Compose } from "/docs/snippets/compose-tooltip.mdx"; import { TUI } from "/docs/snippets/tui-tooltip.mdx"; ## Setup @@ -17,7 +18,7 @@ Running `sitectl` with no arguments opens the , where you can: - Connect sitectl to a Compose project you already run locally or remotely. + Connect sitectl to a project you already run locally or remotely. Use the guided flow to start a new site with the available stack-specific tooling. @@ -43,7 +44,7 @@ The main areas to learn from there are: Describe a site and its environments so sitectl knows how to connect and operate. - Run Docker Compose commands through sitectl against local and remote contexts. + Run commands through sitectl against local and remote contexts. Add stack-specific behavior for the technologies your sites use. @@ -61,8 +62,17 @@ sitectl [subcommand] [flags] Common top-level commands: -- `compose` -- `config` -- `make` -- `port-forward` -- `sequelace` + + + Manage your sitectl contexts. + + + Run `docker compose` commands on any `sitectl` context + + + Use SSH port forwarding for one of your remote services to your local machine. + + + For macOS open a connection to a mariadb/mysql database using SequelAce + + diff --git a/docs/snippets/compose-tooltip.mdx b/docs/snippets/compose-tooltip.mdx new file mode 100644 index 0000000..e4d4dc9 --- /dev/null +++ b/docs/snippets/compose-tooltip.mdx @@ -0,0 +1,17 @@ +export const Compose = () => ( + + Docker Compose is Docker's tool for defining and running multi-container applications.{" "} + https://docs.docker.com/compose/. + + } + > + <> + + {" "} + Compose + + +); diff --git a/index.mdx b/index.mdx index 6bc030f..09d1d4a 100644 --- a/index.mdx +++ b/index.mdx @@ -3,23 +3,25 @@ title: sitectl description: Command line utility to interact with your local and remote Docker Compose sites. --- +import { Compose } from "/docs/snippets/compose-tooltip.mdx"; import { TUI } from "/docs/snippets/tui-tooltip.mdx"; ## Overview -Many open source applications can run well on Docker Compose. +`sitectl` was made with LAC-GLAM institutions at front of mind. `sitectl` is a command line utility to operate your local and remote sites. -Compose also offers a strong developer experience because the same workload can often run in development and production with only modest environment-specific differences. -## The gap +## Scaling Human Operators -The friction starts when a team is running several Compose projects across local and remote environments. +The philosophy behind `sitectl` is not to help scale operations in the traditional technological sense of the word, but rather to scale *human operators*. +The more institutions running their own instances of OSS means those projects will benefit from more contributors and will more likely enter into +a virtuious cycle. -There is usually enough toil around contexts, repeated operational commands, and stack-specific behavior to justify better tooling, but not enough complexity to justify Kubernetes. +By making the operation of a particular set of docker containers needed to run an application well-defined through common, repeatable patterns using 's spec, `sitectl` is designed to: +* **Empower institutions:** Giving organizations the capability and confidence to reliably host the software they depend on without relying soley on a dedicated DevOps team. +* **Empower individual contributors:** Providing teams with solid, standardized tooling that eliminates environmental toil and lets them focus on the work that matters. -## Why sitectl exists - -`sitectl` is a tool for technoligists running a handful of sites using docker compose. Using sitectl is a value add to Docker Compose; the Compose-first workflow remains intact while making it easier to operate that stack day to day. +## `sitectl` Features @@ -29,16 +31,16 @@ There is usually enough toil around contexts, repeated operational commands, and Track local and remote environments so sitectl can understand where a site lives and how to reach it. - Add stack-specific behavior for common technologies without abandoning the core Compose workflow. + Add stack-specific behavior for common technologies without abandoning the core workflow. - Model reviewed stack defaults and operator choices in a more structured way than ad hoc Compose notes. + Model reviewed stack defaults and operator choices in a more structured way than ad hoc notes. -## Why sitectl vs Docker Context? +## Why not just use Docker Contexts? -While [Docker's native context feature](https://docs.docker.com/engine/manage-resources/contexts/) handles basic docker daemon connections, `sitectl` is purpose-built for Docker Compose projects and adds: +While [Docker's native context feature](https://docs.docker.com/engine/manage-resources/contexts/) handles basic docker daemon connections, `sitectl` is purpose-built for projects and adds: @@ -47,7 +49,17 @@ While [Docker's native context feature](https://docs.docker.com/engine/manage-re General helpers to do things like resolve service names to containers, extract secrets and env vars for `exec` commands, and inspect container network details. - + -first design} icon="code"> Automatically set the equivalent of `DOCKER_HOST`, `COMPOSE_PROJECT_NAME`, `COMPOSE_FILE`, and `COMPOSE_ENV_FILES` from the active sitectl context. + +## Why not make kube operators? + +While doesn't scale well in the traditional technological sense, the applications most LAC-GLAM institutions host rarely need to scale beyond typical vertical or modest horizontal limits. +What affords applications that adopt it as the orchestration layer is a strong developer experience: the exact same workload orchestration can run in development and production with only minor, environment-specific tweaks. +This means all the nuances in production can be mimicked on your laptop so you can have some push safety even before the CI exercises its first test. + +Instead of dedicating resources to build `sitectl` we could have spent that time to build kube operators for different LAC-GLAM stacks. +`sitectl` was chosen so institutions can adopt open source projects without first needing a k8s admin on staff or the operation overhead to manage a kubernetes cluster. + diff --git a/pkg/docker/docker.go b/pkg/docker/docker.go index b276aad..a052341 100644 --- a/pkg/docker/docker.go +++ b/pkg/docker/docker.go @@ -6,8 +6,8 @@ import ( "io" "log/slog" "net" - "net/url" "net/http" + "net/url" "os" "path/filepath" "sort" diff --git a/pkg/docker/summary.go b/pkg/docker/summary.go index fc835e5..c47d634 100644 --- a/pkg/docker/summary.go +++ b/pkg/docker/summary.go @@ -8,6 +8,7 @@ import ( "sort" "strconv" "strings" + "time" dockercontainer "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" @@ -17,11 +18,14 @@ import ( ) type ServiceSummary struct { - Service string - Name string - State string - Status string - Healthy bool + Service string + Name string + State string + Status string + Healthy bool + CPUPercent float64 + MemoryBytes uint64 + MemoryLimitBytes uint64 } type ProjectSummary struct { @@ -32,6 +36,13 @@ type ProjectSummary struct { CPUPercent float64 MemoryBytes uint64 MemoryLimitBytes uint64 + HostLoad1 float64 + HostCPUCount int + DiskAvailable uint64 + DiskTotal uint64 + NetworkRXBytes uint64 + NetworkTXBytes uint64 + CollectedAt time.Time Services []ServiceSummary Status string } @@ -50,6 +61,9 @@ func SummarizeProject(ctxCfg *config.Context) (ProjectSummary, error) { if statsOutput, statsErr := runDockerStats(ctxCfg); statsErr == nil { applyDockerStats(&summary, statsOutput) } + if hostOutput, hostErr := runHostMetrics(ctxCfg); hostErr == nil { + applyHostMetrics(&summary, hostOutput) + } return summary, nil } @@ -63,6 +77,9 @@ func SummarizeProject(ctxCfg *config.Context) (ProjectSummary, error) { if fallbackErr != nil { return ProjectSummary{}, err } + if hostOutput, hostErr := runHostMetrics(ctxCfg); hostErr == nil { + applyHostMetrics(&summary, hostOutput) + } return summary, nil } @@ -185,6 +202,92 @@ func runDockerStats(ctxCfg *config.Context) (string, error) { return string(output), nil } +func runHostMetrics(ctxCfg *config.Context) (string, error) { + script := ` +load1="" +if [ -r /proc/loadavg ]; then + load1=$(awk '{print $1}' /proc/loadavg 2>/dev/null) +fi +if [ -z "$load1" ] && command -v uptime >/dev/null 2>&1; then + load1=$(uptime 2>/dev/null | sed -E 's/.*load averages?: ([0-9.]+).*/\1/' | awk '{print $1}') + load1=${load1%%,*} +fi + +cpu_count="" +if command -v getconf >/dev/null 2>&1; then + cpu_count=$(getconf _NPROCESSORS_ONLN 2>/dev/null || true) +fi +if [ -z "$cpu_count" ] && command -v nproc >/dev/null 2>&1; then + cpu_count=$(nproc 2>/dev/null || true) +fi + +disk_total_kb="" +disk_avail_kb="" +if command -v df >/dev/null 2>&1; then + set -- $(df -kP . 2>/dev/null | awk 'NR==2 {print $2, $4}') + disk_total_kb=$1 + disk_avail_kb=$2 +fi + +net_rx_bytes=0 +net_tx_bytes=0 +if [ -r /proc/net/dev ]; then + while IFS= read -r line; do + case "$line" in + *:*) + iface=$(printf '%s\n' "$line" | cut -d: -f1 | tr -d ' ') + case "$iface" in + lo|docker*|veth*|br-*|virbr*|tailscale*|tun*|tap*) + continue + ;; + esac + data=$(printf '%s\n' "$line" | cut -d: -f2) + set -- $data + net_rx_bytes=$((net_rx_bytes + $1)) + net_tx_bytes=$((net_tx_bytes + $9)) + ;; + esac + done < /proc/net/dev +fi + +printf '{"load1":"%s","cpu_count":"%s","disk_total_kb":"%s","disk_avail_kb":"%s","net_rx_bytes":"%s","net_tx_bytes":"%s"}\n' \ + "$load1" "$cpu_count" "$disk_total_kb" "$disk_avail_kb" "$net_rx_bytes" "$net_tx_bytes" +` + if ctxCfg.DockerHostType == config.ContextLocal { + cmd := exec.Command("sh", "-lc", script) + cmd.Dir = ctxCfg.ProjectDir + output, err := cmd.CombinedOutput() + return string(output), err + } + + client, err := ctxCfg.DialSSH() + if err != nil { + return "", err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctxCfg.ProjectDir)) + if ctxCfg.RunSudo { + remoteCmd += "sudo " + } + remoteCmd += shellquote.Join("sh", "-lc", script) + + output, err := session.CombinedOutput(remoteCmd) + if err != nil { + if _, ok := err.(*ssh.ExitError); ok && len(output) > 0 { + return string(output), nil + } + return string(output), err + } + return string(output), nil +} + func composePSArgs(ctxCfg config.Context) []string { args := []string{"compose"} for _, file := range ctxCfg.ComposeFile { @@ -269,14 +372,18 @@ func applyDockerStats(summary *ProjectSummary, output string) { if summary == nil { return } + serviceIndex := map[string]int{} + containerIndex := map[string]int{} serviceNames := map[string]struct{}{} containerNames := map[string]struct{}{} - for _, service := range summary.Services { + for i, service := range summary.Services { if strings.TrimSpace(service.Service) != "" { serviceNames[service.Service] = struct{}{} + serviceIndex[service.Service] = i } if strings.TrimSpace(service.Name) != "" { containerNames[service.Name] = struct{}{} + containerIndex[service.Name] = i } } @@ -301,10 +408,67 @@ func applyDockerStats(summary *ProjectSummary, output string) { if limit > maxLimit { maxLimit = limit } + if idx, ok := containerIndex[row.Name]; ok { + summary.Services[idx].CPUPercent = parsePercent(row.CPUPerc) + summary.Services[idx].MemoryBytes = used + summary.Services[idx].MemoryLimitBytes = limit + } else if idx, ok := serviceIndex[row.Name]; ok { + summary.Services[idx].CPUPercent = parsePercent(row.CPUPerc) + summary.Services[idx].MemoryBytes = used + summary.Services[idx].MemoryLimitBytes = limit + } } summary.MemoryLimitBytes = maxLimit } +type hostMetricsPayload struct { + Load1 string `json:"load1"` + CPUCount string `json:"cpu_count"` + DiskTotalKB string `json:"disk_total_kb"` + DiskAvailKB string `json:"disk_avail_kb"` + NetRXBytes string `json:"net_rx_bytes"` + NetTXBytes string `json:"net_tx_bytes"` +} + +func applyHostMetrics(summary *ProjectSummary, output string) { + if summary == nil { + return + } + load1, cpuCount, diskAvailable, diskTotal, netRX, netTX := parseHostMetricsOutput(output) + summary.HostLoad1 = load1 + summary.HostCPUCount = cpuCount + summary.DiskAvailable = diskAvailable + summary.DiskTotal = diskTotal + summary.NetworkRXBytes = netRX + summary.NetworkTXBytes = netTX + summary.CollectedAt = time.Now() +} + +func parseHostMetricsOutput(output string) (float64, int, uint64, uint64, uint64, uint64) { + var payload hostMetricsPayload + if err := json.Unmarshal([]byte(strings.TrimSpace(output)), &payload); err != nil { + return 0, 0, 0, 0, 0, 0 + } + load1, _ := strconv.ParseFloat(strings.TrimSpace(payload.Load1), 64) + cpuCount, _ := strconv.Atoi(strings.TrimSpace(payload.CPUCount)) + diskTotal := parseUint(strings.TrimSpace(payload.DiskTotalKB)) * 1000 + diskAvailable := parseUint(strings.TrimSpace(payload.DiskAvailKB)) * 1000 + netRX := parseUint(strings.TrimSpace(payload.NetRXBytes)) + netTX := parseUint(strings.TrimSpace(payload.NetTXBytes)) + return load1, cpuCount, diskAvailable, diskTotal, netRX, netTX +} + +func parseUint(value string) uint64 { + if value == "" { + return 0 + } + parsed, err := strconv.ParseUint(value, 10, 64) + if err != nil { + return 0 + } + return parsed +} + func parsePercent(value string) float64 { value = strings.TrimSpace(strings.TrimSuffix(value, "%")) if value == "" { diff --git a/pkg/docker/summary_test.go b/pkg/docker/summary_test.go index a1db859..fe466ce 100644 --- a/pkg/docker/summary_test.go +++ b/pkg/docker/summary_test.go @@ -108,3 +108,28 @@ func TestApplyDockerStatsUsesSingleEffectiveMemoryLimit(t *testing.T) { t.Fatalf("expected effective memory limit near host total, got %d", summary.MemoryLimitBytes) } } + +func TestParseHostMetricsOutput(t *testing.T) { + output := `{"load1":"1.25","cpu_count":"8","disk_total_kb":"1000000","disk_avail_kb":"250000","net_rx_bytes":"123456","net_tx_bytes":"654321"}` + + load1, cpuCount, diskAvailable, diskTotal, netRX, netTX := parseHostMetricsOutput(output) + + if load1 != 1.25 { + t.Fatalf("expected load1 1.25, got %v", load1) + } + if cpuCount != 8 { + t.Fatalf("expected cpu count 8, got %d", cpuCount) + } + if diskAvailable != 250000000 { + t.Fatalf("expected disk available 250000000, got %d", diskAvailable) + } + if diskTotal != 1000000000 { + t.Fatalf("expected disk total 1000000000, got %d", diskTotal) + } + if netRX != 123456 { + t.Fatalf("expected network rx 123456, got %d", netRX) + } + if netTX != 654321 { + t.Fatalf("expected network tx 654321, got %d", netTX) + } +} diff --git a/pkg/tui/dashboard.go b/pkg/tui/dashboard.go index 9128e61..5ae0ef3 100644 --- a/pkg/tui/dashboard.go +++ b/pkg/tui/dashboard.go @@ -106,7 +106,6 @@ type keyMap struct { Command key.Binding Palette key.Binding Terminal key.Binding - Logs key.Binding Refresh key.Binding Enter key.Binding Back key.Binding @@ -125,7 +124,6 @@ func defaultKeyMap() keyMap { Command: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "command bar")), Palette: key.NewBinding(key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "palette")), Terminal: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("ctrl+x", "run in terminal")), - Logs: key.NewBinding(key.WithKeys("ctrl+g"), key.WithHelp("ctrl+g", "logs")), Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("ctrl+r", "refresh")), Enter: key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "select")), Back: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "back")), @@ -134,11 +132,11 @@ func defaultKeyMap() keyMap { } func (k keyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Left, k.Up, k.Command, k.Palette, k.Logs, k.Refresh, k.Terminal, k.Back, k.Quit} + return []key.Binding{k.Left, k.Up, k.Command, k.Palette, k.Refresh, k.Terminal, k.Back, k.Quit} } func (k keyMap) FullHelp() [][]key.Binding { - return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Command, k.Palette, k.Logs, k.Refresh}, {k.Actions, k.Settings, k.NewApp, k.Terminal, k.Enter, k.Back, k.Quit}} + return [][]key.Binding{{k.Left, k.Right, k.Up, k.Down}, {k.Command, k.Palette, k.Refresh}, {k.Actions, k.Settings, k.NewApp, k.Terminal, k.Enter, k.Back, k.Quit}} } type dashboardModel struct { @@ -166,9 +164,14 @@ type dashboardModel struct { infoTitle string infoBody string logsTitle string + logTarget string + detailBody string + logsBody string historyCPU map[string][]float64 historyMemory map[string][]float64 + historyNet map[string][]float64 + lastNetSample map[string]networkSample help help.Model keys keyMap @@ -186,6 +189,11 @@ type dashboardModel struct { commandQuitArmed bool } +type networkSample struct { + totalBytes uint64 + at time.Time +} + func Run() error { cfg, err := config.Load() if err != nil { @@ -224,15 +232,19 @@ func newDashboardModel(cfg *config.Config, plugins []plugin.InstalledPlugin) *da spin: spinner.New(spinner.WithSpinner(spinner.MiniDot), spinner.WithStyle(spinnerStyle)), historyCPU: map[string][]float64{}, historyMemory: map[string][]float64{}, + historyNet: map[string][]float64{}, + lastNetSample: map[string]networkSample{}, } m.help.Styles = helpStyles() m.siteIndex, m.envIndex = defaultSelection(m.sites, current) m.detail = viewport.New(viewport.WithWidth(40), viewport.WithHeight(10)) m.detail.MouseWheelEnabled = true - m.detail.SetContent("Loading...") + m.detailBody = "Loading..." + m.detail.SetContent(m.detailBody) m.logs = viewport.New(viewport.WithWidth(40), viewport.WithHeight(10)) m.logs.MouseWheelEnabled = true - m.logs.SetContent("No logs loaded.") + m.logsBody = "No logs loaded." + m.logs.SetContent(m.logsBody) m.logsTitle = "Logs" m.actions = newMenuModel("Actions", []menuItem{ {title: "Refresh", desc: "Reload summary for the selected environment", action: "refresh"}, @@ -282,6 +294,8 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, loadSummaryCmd(ctx)) if m.screen == screenLogs && strings.HasPrefix(m.logsTitle, "Logs") { cmds = append(cmds, loadLogsCmd(ctx)) + } else if m.screen == screenLogs && strings.TrimSpace(m.logTarget) != "" { + cmds = append(cmds, loadContainerLogsCmd(ctx, m.logTarget)) } } return m, tea.Batch(cmds...) @@ -294,8 +308,7 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.Err == nil { m.pushHistory( msg.ContextName, - msg.Summary.CPUPercent, - memoryPercent(msg.Summary), + msg.Summary, ) } m.syncDetailContent() @@ -313,6 +326,7 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.TrimSpace(content) == "" { content = "No logs returned." } + m.logsBody = content m.logs.SetContent(content) m.logs.GotoBottom() } @@ -334,6 +348,7 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if strings.TrimSpace(content) == "" { content = "Command completed with no output." } + m.logsBody = content m.logs.SetContent(content) m.logs.GotoTop() m.syncLayout() @@ -385,14 +400,15 @@ func (m *dashboardModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.overlay != overlayNone { return m.updateOverlay(msg) } + if release, ok := msg.(tea.MouseReleaseMsg); ok { + return m.handleMouseRelease(release) + } if m.screen == screenLogs { var cmd tea.Cmd m.logs, cmd = m.logs.Update(msg) return m, cmd } switch msg := msg.(type) { - case tea.MouseReleaseMsg: - return m.handleMouseRelease(msg) case tea.MouseWheelMsg: var cmd tea.Cmd m.detail, cmd = m.detail.Update(msg) @@ -504,10 +520,6 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { if ctx, ok := m.selectedContext(); ok && strings.HasPrefix(m.logsTitle, "Logs") { return m, loadLogsCmd(ctx) } - case key.Matches(msg, m.keys.Logs): - m.screen = screenDashboard - m.syncLayout() - return m, nil case key.Matches(msg, m.keys.Terminal): return m.runCommand(true) case msg.String() == "enter": @@ -562,8 +574,6 @@ func (m *dashboardModel) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.commands = newMenuModel("Commands", commandPaletteItems("", m.selectedContextName(), m.selectedSiteName(), m.selectedPluginName())) m.overlay = overlayCommands return m, nil - case key.Matches(msg, m.keys.Logs): - return m.openLogs() case key.Matches(msg, m.keys.Refresh): if ctx, ok := m.selectedContext(); ok { m.loading = true @@ -587,6 +597,17 @@ func (m *dashboardModel) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, if msg.Mouse().Button != tea.MouseLeft { return m, nil } + if m.screen == screenLogs { + if z := zone.Get("logs:back"); z != nil && z.InBounds(msg) { + m.screen = screenDashboard + m.logTarget = "" + m.syncLayout() + return m, nil + } + var cmd tea.Cmd + m.logs, cmd = m.logs.Update(msg) + return m, cmd + } for _, targetSite := range m.sites { if z := zone.Get("tab:" + targetSite.Name); z != nil && z.InBounds(msg) { @@ -606,6 +627,11 @@ func (m *dashboardModel) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, return m.reloadSelected() } } + for _, service := range m.summary.Services { + if z := zone.Get(containerZoneID(service.Name)); z != nil && z.InBounds(msg) { + return m.openContainerLogs(service.Name) + } + } if z := zone.Get("chip:actions"); z != nil && z.InBounds(msg) { m.overlay = overlayActions @@ -616,9 +642,6 @@ func (m *dashboardModel) handleMouseRelease(msg tea.MouseReleaseMsg) (tea.Model, if z := zone.Get("chip:new"); z != nil && z.InBounds(msg) { m.overlay = overlayChooser } - if z := zone.Get("chip:logs"); z != nil && z.InBounds(msg) { - return m.openLogs() - } if z := zone.Get("chip:refresh"); z != nil && z.InBounds(msg) { if ctx, ok := m.selectedContext(); ok { m.loading = true @@ -699,7 +722,8 @@ func (m *dashboardModel) handleOverlaySelection() (tea.Model, tea.Cmd) { if ctx, ok := m.selectedContext(); ok { m.infoTitle = "Context Details" m.infoBody = renderContextInfo(ctx) - m.detail.SetContent(m.infoBody) + m.detailBody = m.infoBody + m.detail.SetContent(m.detailBody) m.detail.GotoTop() m.overlay = overlayInfo return m, nil @@ -741,6 +765,7 @@ func (m *dashboardModel) openLogs() (tea.Model, tea.Cmd) { if !ok { return m, nil } + m.logTarget = "" m.screen = screenLogs m.loadingLog = true m.logsTitle = "Logs | tail 20 | auto-refresh" @@ -748,6 +773,19 @@ func (m *dashboardModel) openLogs() (tea.Model, tea.Cmd) { return m, loadLogsCmd(ctx) } +func (m *dashboardModel) openContainerLogs(containerName string) (tea.Model, tea.Cmd) { + ctx, ok := m.selectedContext() + if !ok { + return m, nil + } + m.logTarget = containerName + m.screen = screenLogs + m.loadingLog = true + m.logsTitle = "Container Logs" + m.syncLayout() + return m, loadContainerLogsCmd(ctx, containerName) +} + func (m *dashboardModel) reloadSelected() (tea.Model, tea.Cmd) { m.summary = docker.ProjectSummary{} m.summaryErr = nil @@ -825,7 +863,6 @@ func (m *dashboardModel) renderHeaderChips() string { zone.Mark("chip:new", chipStyle.Render("[ctrl+n] Choose App")), zone.Mark("chip:command", chipStyle.Render("[/] Command")), zone.Mark("chip:palette", chipStyle.Render("[ctrl+p] Palette")), - zone.Mark("chip:logs", chipStyle.Render("[ctrl+g] Logs")), zone.Mark("chip:refresh", chipStyle.Render("[ctrl+r] Refresh")), } return lipgloss.JoinHorizontal(lipgloss.Left, chips...) @@ -845,17 +882,31 @@ func (m *dashboardModel) renderTitle() string { func (m *dashboardModel) renderResourceHeader() string { ctx, _ := m.selectedContext() historyKey := ctx.Name - widths := splitWidth(max(m.width-8, 60), 2) + widths := splitWidth(max(m.width-8, 100), 5) cpuDetail := fmt.Sprintf("%.1f%% total across %d containers", m.summary.CPUPercent, m.summary.Total) memDetail := fmt.Sprintf("%s / %s", humanBytes(m.summary.MemoryBytes), humanBytes(m.summary.MemoryLimitBytes)) + netDetail := networkDetail(m.historyNet[historyKey]) + loadValue, loadDetail, loadColor := loadDisplay(m.summary) + diskValue, diskDetail, diskPercent, diskColor := diskDisplay(m.summary) if m.loading { cpuDetail = "Refreshing docker stats..." memDetail = "Refreshing docker stats..." + netDetail = "Refreshing host stats..." + loadValue = "..." + loadDetail = "Refreshing host stats..." + loadColor = "#7F8C8D" + diskValue = "..." + diskDetail = "Refreshing host stats..." + diskPercent = 0 + diskColor = "#7F8C8D" } return lipgloss.JoinHorizontal( lipgloss.Top, renderChartBox("CPU", m.historyCPU[historyKey], cpuDetail, "#F4A261", widths[0]), renderChartBox("Memory", m.historyMemory[historyKey], memDetail, "#98C1D9", widths[1]), + renderStatusBox("Load", loadValue, loadDetail, loadColor, widths[2]), + renderGaugeBox("Disk Free", diskValue, diskDetail, diskPercent, diskColor, widths[3]), + renderChartBox("Network", m.historyNet[historyKey], netDetail, "#5DADE2", widths[4]), ) } @@ -881,16 +932,25 @@ func (m *dashboardModel) renderDashboardArea() string { func (m *dashboardModel) renderLogsArea() string { ctx, _ := m.selectedContext() - hint := "Auto-refreshing the latest 20 log lines. Scroll with mouse wheel or j/k. Press esc or ctrl+g to return." + hint := "Auto-refreshing the latest 20 log lines. Scroll with mouse wheel or j/k. Press esc to return." if m.logsTitle == "Command Output" { hint = "Command output. Press esc to return to the dashboard and keep using the footer command bar." + } else if strings.TrimSpace(m.logTarget) != "" { + hint = "Auto-refreshing the latest 20 log lines for the selected container. Press esc to return." } - header := panelStyle.Width(max(40, m.width-6)).Render(strings.Join([]string{ - m.logsTitle, + back := zone.Mark("logs:back", chipStyle.Render("[Back]")) + headerLines := []string{ + sectionTitleStyle.MarginBottom(0).Render(m.logsTitle), fmt.Sprintf("Context: %s", ctx.Name), - hint, - }, "\n")) - body := panelStyle.Width(max(40, m.width-6)).Height(max(10, m.height-14)).Render(renderViewportWithScrollbar(m.logs)) + } + if strings.TrimSpace(m.logTarget) != "" { + headerLines = append(headerLines, renderContainerHeader(m.summary, m.logTarget)) + } + headerLines = append(headerLines, hint) + header := panelStyle.Width(max(40, m.width-6)).Render(strings.Join(headerLines, "\n")) + body := panelStyle.Width(max(40, m.width-6)).Height(max(10, m.height-14)).Render( + back + "\n" + renderViewportWithScrollbar(m.logs, m.logsBody, max(34, m.width-12)), + ) if m.loadingLog { header = panelStyle.Width(max(40, m.width-6)).Render(m.spin.View() + " Loading logs...\nContext: " + ctx.Name) } @@ -928,16 +988,22 @@ func (m *dashboardModel) renderEnvironmentCards(width int) string { lines := []string{strings.ToUpper(envLabel(ctx)), ctx.Name} if selected { cardWidth = selectedWidth - lines = append(lines, - fmt.Sprintf("plugin: %s", firstNonEmpty(ctx.Plugin, "core")), - fmt.Sprintf("compose: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), - fmt.Sprintf("network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), + statusText := strings.ToUpper(firstNonEmpty(m.summary.Status, "unknown")) + containersText := fmt.Sprintf( + "containers: %d total, %d running, %d stopped", + m.summary.Total, + m.summary.Running, + m.summary.Stopped, ) - if ctx.DockerHostType == config.ContextRemote { - lines = append(lines, fmt.Sprintf("host: %s", firstNonEmpty(ctx.SSHHostname, "-"))) - } else { - lines = append(lines, fmt.Sprintf("dir: %s", firstNonEmpty(ctx.ProjectDir, "-"))) + if m.loading { + statusText = "REFRESHING" + containersText = "containers: loading..." } + lines = append(lines, + fmt.Sprintf("status: %s", statusText), + containersText, + fmt.Sprintf("healthy: %d", m.summary.Healthy), + ) } else { lines = append(lines, firstNonEmpty(ctx.Plugin, "core")) } @@ -956,13 +1022,14 @@ func (m *dashboardModel) renderEnvironmentCards(width int) string { } func (m *dashboardModel) renderDetailsPanel(width int) string { - content := renderViewportWithScrollbar(m.detail) + panelWidth := max(40, width-2) + content := renderViewportWithScrollbar(m.detail, m.detailBody, panelWidth-6) if m.loading { content = m.spin.View() + " Loading Docker Compose status..." } panelHeight := min(max(10, m.height-30), 16) - return panelStyle.Width(max(32, width-4)).Height(panelHeight).Render( + return panelStyle.Width(panelWidth).Height(panelHeight).Render( sectionTitleStyle.MarginBottom(0).Render("Selected Environment Status") + "\n" + content, ) } @@ -982,7 +1049,8 @@ func (m *dashboardModel) renderOverlay() string { content = m.chooser.View() case overlayInfo: title = m.infoTitle - content = renderViewportWithScrollbar(m.detail) + overlayWidth := min(72, max(48, m.width-12)) + content = renderViewportWithScrollbar(m.detail, m.detailBody, overlayWidth-6) case overlayCommands: title = commandPaletteTitle(m.commandParent) content = m.commands.View() @@ -1018,7 +1086,7 @@ func (m *dashboardModel) renderTourArea() string { fmt.Sprintf("Pane %d of %d", m.currentTourIndex()+1, len(m.tourPanes)), "left/right: next section esc: back to setup/create", }, "\n")) - body := panelStyle.Width(width).Height(max(12, m.height-12)).Render(renderViewportWithScrollbar(m.detail)) + body := panelStyle.Width(width).Height(max(12, m.height-12)).Render(renderViewportWithScrollbar(m.detail, m.detailBody, width-6)) return lipgloss.JoinVertical(lipgloss.Left, header, body) } @@ -1043,7 +1111,7 @@ func (m *dashboardModel) syncLayout() { if m.screen == screenTour { detailHeight = max(12, m.height-16) } - m.detail.SetWidth(max(40, m.width-hpad-8)) + m.detail.SetWidth(max(48, m.width-hpad-6)) m.detail.SetHeight(detailHeight) logHeight := max(10, m.height-14) @@ -1066,47 +1134,63 @@ func (m *dashboardModel) syncDetailContent() { if m.screen == screenTour { return } - ctx, ok := m.selectedContext() + _, ok := m.selectedContext() if !ok { - m.detail.SetContent("No context selected.") + m.detailBody = "No context selected." + m.detail.SetContent(m.detailBody) return } if m.overlay == overlayInfo && strings.TrimSpace(m.infoBody) != "" { - m.detail.SetContent(m.infoBody) + m.detailBody = m.infoBody + m.detail.SetContent(m.detailBody) return } if m.summaryErr != nil { - m.detail.SetContent(m.summaryErr.Error()) + m.detailBody = m.summaryErr.Error() + m.detail.SetContent(m.detailBody) return } lines := []string{ - fmt.Sprintf("Status: %s", strings.ToUpper(firstNonEmpty(m.summary.Status, "unknown"))), - fmt.Sprintf("CPU: %.1f%%", m.summary.CPUPercent), - fmt.Sprintf("Memory: %s / %s", humanBytes(m.summary.MemoryBytes), humanBytes(m.summary.MemoryLimitBytes)), - fmt.Sprintf("Containers: %d total, %d running, %d healthy, %d stopped", m.summary.Total, m.summary.Running, m.summary.Healthy, m.summary.Stopped), + "Containers", + "Click a container to view its logs.", "", - "Services:", + fmt.Sprintf(" %-36s %7s %-22s %s", "NAME", "CPU", "MEMORY", "STATUS"), + " " + strings.Repeat("─", 36) + " " + strings.Repeat("─", 7) + " " + strings.Repeat("─", 22) + " " + strings.Repeat("─", 12), } if len(m.summary.Services) == 0 { lines = append(lines, " No Compose containers found for this context.") } else { for _, service := range m.summary.Services { - lines = append(lines, fmt.Sprintf(" %s %s", service.Service, firstNonEmpty(service.Status, service.State))) + line := fmt.Sprintf( + " %-36s %6.1f%% %-22s %s", + truncateMetricText(firstNonEmpty(service.Name, service.Service), 36), + service.CPUPercent, + truncateMetricText(containerMemorySummary(service), 22), + truncateMetricText(firstNonEmpty(service.Status, service.State), 12), + ) + lines = append(lines, zone.Mark(containerZoneID(service.Name), line)) } } - lines = append(lines, - "", - fmt.Sprintf("Context directory: %s", firstNonEmpty(ctx.ProjectDir, "-")), - fmt.Sprintf("Compose project: %s", firstNonEmpty(ctx.EffectiveComposeProjectName(), "-")), - fmt.Sprintf("Compose network: %s", firstNonEmpty(ctx.EffectiveComposeNetwork(), "-")), - ) - m.detail.SetContent(strings.Join(lines, "\n")) + m.detailBody = strings.Join(lines, "\n") + m.detail.SetContent(m.detailBody) } -func (m *dashboardModel) pushHistory(contextName string, cpu, memory float64) { - m.historyCPU[contextName] = appendLimited(m.historyCPU[contextName], cpu, 24) - m.historyMemory[contextName] = appendLimited(m.historyMemory[contextName], memory, 24) +func (m *dashboardModel) pushHistory(contextName string, summary docker.ProjectSummary) { + m.historyCPU[contextName] = appendLimited(m.historyCPU[contextName], summary.CPUPercent, 24) + m.historyMemory[contextName] = appendLimited(m.historyMemory[contextName], memoryPercent(summary), 24) + rate := 0.0 + if !summary.CollectedAt.IsZero() { + currentTotal := summary.NetworkRXBytes + summary.NetworkTXBytes + if previous, ok := m.lastNetSample[contextName]; ok && !previous.at.IsZero() && currentTotal >= previous.totalBytes { + seconds := summary.CollectedAt.Sub(previous.at).Seconds() + if seconds > 0 { + rate = float64(currentTotal-previous.totalBytes) / seconds + } + } + m.lastNetSample[contextName] = networkSample{totalBytes: currentTotal, at: summary.CollectedAt} + } + m.historyNet[contextName] = appendLimited(m.historyNet[contextName], rate, 24) } func (m *dashboardModel) selectedSiteContexts() []config.Context { @@ -1176,6 +1260,13 @@ func loadSummaryCmd(ctx config.Context) tea.Cmd { } } +func loadContainerLogsCmd(ctx config.Context, containerName string) tea.Cmd { + return func() tea.Msg { + logs, err := fetchContainerLogs(ctx, containerName) + return logsLoadedMsg{ContextName: ctx.Name, Logs: logs, Err: err} + } +} + func loadLogsCmd(ctx config.Context) tea.Cmd { return func() tea.Msg { logs, err := fetchComposeLogs(ctx) @@ -1542,7 +1633,8 @@ func (m *dashboardModel) runCommand(interactive bool) (tea.Model, tea.Cmd) { m.commandRunning = true m.logsTitle = "Command Output" - m.logs.SetContent("Running " + display + "...") + m.logsBody = "Running " + display + "..." + m.logs.SetContent(m.logsBody) m.screen = screenLogs m.commandInput.SetValue("") return m, runSitectlCaptureCmd(display, args) @@ -1622,52 +1714,75 @@ func (m *dashboardModel) currentTourTitle() string { func (m *dashboardModel) syncTourContent() { if len(m.tourPanes) == 0 { - m.detail.SetContent("No embedded tour content found.") + m.detailBody = "No embedded tour content found." + m.detail.SetContent(m.detailBody) return } rendered, err := glamour.Render(m.tourPanes[m.currentTourIndex()].Markdown, "dark") if err != nil { - m.detail.SetContent(err.Error()) + m.detailBody = err.Error() + m.detail.SetContent(m.detailBody) return } - m.detail.SetContent(rendered) + m.detailBody = rendered + m.detail.SetContent(m.detailBody) m.detail.GotoTop() } -func renderViewportWithScrollbar(v viewport.Model) string { - body := v.View() +// renderViewportWithScrollbar renders the viewport content with a scrollbar on +// the right side. availWidth is the available panel content width (panel outer +// width minus its horizontal border and padding frame size); the scrollbar +// occupies the last 2 columns (space + character) so content uses availWidth-2. +func renderViewportWithScrollbar(v viewport.Model, raw string, availWidth int) string { total := v.TotalLineCount() height := v.Height() if total <= height || height <= 0 { - return body + return raw } - lines := strings.Split(body, "\n") - if len(lines) < height { - lines = append(lines, make([]string, height-len(lines))...) - } else if len(lines) > height { - lines = lines[:height] + allLines := strings.Split(raw, "\n") + offset := min(max(v.YOffset(), 0), max(len(allLines)-height, 0)) + lines := make([]string, 0, height) + for i := 0; i < height; i++ { + idx := offset + i + if idx >= 0 && idx < len(allLines) { + lines = append(lines, allLines[idx]) + } else { + lines = append(lines, "") + } } thumbHeight := max(1, (height*height)/max(total, 1)) maxOffset := max(total-height, 1) - offset := min(max(v.YOffset(), 0), maxOffset) thumbTop := 0 if height > thumbHeight { thumbTop = (offset * (height - thumbHeight)) / maxOffset } rows := make([]string, height) + contentWidth := max(1, availWidth-2) for i := 0; i < height; i++ { bar := subtleStyle.Render("│") if i >= thumbTop && i < thumbTop+thumbHeight { bar = accentStyle.Render("█") } - rows[i] = lipgloss.JoinHorizontal(lipgloss.Top, lines[i], " ", bar) + padded := lipgloss.NewStyle().Width(contentWidth).Render(clipLine(lines[i], contentWidth)) + rows[i] = padded + " " + bar } return strings.Join(rows, "\n") } +func clipLine(value string, width int) string { + if width <= 0 { + return "" + } + runes := []rune(value) + if len(runes) <= width { + return value + } + return string(runes[:width]) +} + func reloadStateCmd() tea.Cmd { return func() tea.Msg { cfg, err := config.Load() @@ -1810,10 +1925,183 @@ func renderChartBox(title string, values []float64, detail, border string, width chart := sparkline.New(innerWidth, 4) chart.PushAll(values) chart.DrawBraille() - content := sectionTitleStyle.MarginBottom(0).Render(title) + "\n" + chart.View() + "\n" + detail - style := panelStyle.Width(width) + content := sectionTitleStyle.MarginBottom(0).Render(truncateMetricText(title, innerWidth)) + "\n" + chart.View() + "\n" + truncateMetricText(detail, innerWidth) + style := panelStyle.Width(width).Height(10).MaxHeight(10) + if strings.TrimSpace(border) != "" { + style = style.BorderForeground(lipgloss.Color(border)) + } + return style.Render(content) +} + +func renderStatusBox(title, value, detail, border string, width int) string { + innerWidth := max(8, width-6) + content := sectionTitleStyle.MarginBottom(0).Render(truncateMetricText(title, innerWidth)) + "\n" + + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(border)).Render(truncateMetricText(value, innerWidth)) + "\n" + + "\n" + "\n" + "\n" + "\n" + + truncateMetricText(detail, innerWidth) + style := panelStyle.Width(width).Height(10).MaxHeight(10) if strings.TrimSpace(border) != "" { style = style.BorderForeground(lipgloss.Color(border)) } return style.Render(content) } + +func renderGaugeBox(title, value, detail string, percent float64, border string, width int) string { + innerWidth := max(8, width-6) + barWidth := max(8, innerWidth-2) + filled := int((clamp(percent, 0, 100) / 100) * float64(barWidth)) + bar := lipgloss.NewStyle().Foreground(lipgloss.Color(border)).Render(strings.Repeat("█", filled)) + + subtleStyle.Render(strings.Repeat("░", max(0, barWidth-filled))) + content := sectionTitleStyle.MarginBottom(0).Render(truncateMetricText(title, innerWidth)) + "\n" + + lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(border)).Render(truncateMetricText(value, innerWidth)) + "\n" + + "\n" + "\n" + + bar + "\n" + truncateMetricText(detail, innerWidth) + style := panelStyle.Width(width).Height(10).MaxHeight(10) + if strings.TrimSpace(border) != "" { + style = style.BorderForeground(lipgloss.Color(border)) + } + return style.Render(content) +} + +func networkDetail(values []float64) string { + if len(values) == 0 { + return "Sampling host bandwidth..." + } + latest := values[len(values)-1] + if latest <= 0 { + return "Sampling host bandwidth..." + } + return fmt.Sprintf("%s/s total host traffic", humanRate(latest)) +} + +func loadDisplay(summary docker.ProjectSummary) (string, string, string) { + if summary.HostLoad1 <= 0 { + return "n/a", "Host load unavailable", "#7F8C8D" + } + cpuCount := max(summary.HostCPUCount, 1) + ratio := summary.HostLoad1 / float64(cpuCount) + return fmt.Sprintf("%.2f", summary.HostLoad1), fmt.Sprintf("1m avg across %d cores", cpuCount), severityColor(ratio, 0.7, 1.0) +} + +func diskDisplay(summary docker.ProjectSummary) (string, string, float64, string) { + if summary.DiskTotal == 0 { + return "n/a", "Filesystem availability unavailable", 0, "#7F8C8D" + } + percent := (float64(summary.DiskAvailable) / float64(summary.DiskTotal)) * 100 + return humanBytes(summary.DiskAvailable), fmt.Sprintf("%s free of %s", humanBytes(summary.DiskAvailable), humanBytes(summary.DiskTotal)), percent, reverseSeverityColor(percent, 25, 10) +} + +func humanRate(value float64) string { + if value <= 0 { + return "0B" + } + return humanBytes(uint64(value)) +} + +func severityColor(value, yellow, red float64) string { + switch { + case value >= red: + return "#E76F51" + case value >= yellow: + return "#E9C46A" + default: + return "#2A9D8F" + } +} + +func reverseSeverityColor(value, green, yellow float64) string { + switch { + case value <= yellow: + return "#E76F51" + case value <= green: + return "#E9C46A" + default: + return "#2A9D8F" + } +} + +func clamp(value, low, high float64) float64 { + if value < low { + return low + } + if value > high { + return high + } + return value +} + +func truncateMetricText(value string, width int) string { + value = strings.TrimSpace(value) + if width <= 0 || lipgloss.Width(value) <= width { + return value + } + if width <= 1 { + return "…" + } + runes := []rune(value) + if len(runes) > width-1 { + runes = runes[:width-1] + } + return string(runes) + "…" +} + +func containerZoneID(name string) string { + if strings.TrimSpace(name) == "" { + return "container:-" + } + return "container:" + name +} + +func containerMemorySummary(service docker.ServiceSummary) string { + if service.MemoryLimitBytes == 0 { + return humanBytes(service.MemoryBytes) + } + return fmt.Sprintf("%s/%s", humanBytes(service.MemoryBytes), humanBytes(service.MemoryLimitBytes)) +} + +func renderContainerHeader(summary docker.ProjectSummary, containerName string) string { + for _, service := range summary.Services { + if service.Name != containerName { + continue + } + return fmt.Sprintf( + "Container: %s | CPU %.1f%% | Mem %s | %s", + firstNonEmpty(service.Name, service.Service), + service.CPUPercent, + containerMemorySummary(service), + firstNonEmpty(service.Status, service.State), + ) + } + return "Container: " + containerName +} + +func fetchContainerLogs(ctx config.Context, containerName string) (string, error) { + args := []string{"logs", "--tail", "20", containerName} + if ctx.DockerHostType == config.ContextLocal { + cmd := exec.Command("docker", args...) + cmd.Dir = ctx.ProjectDir + output, err := cmd.CombinedOutput() + return string(output), err + } + + remoteCmd := fmt.Sprintf("cd %s && ", shellquote.Join(ctx.ProjectDir)) + if ctx.RunSudo { + remoteCmd += "sudo " + } + remoteCmd += shellquote.Join(append([]string{"docker"}, args...)...) + + client, err := ctx.DialSSH() + if err != nil { + return "", err + } + defer client.Close() + + session, err := client.NewSession() + if err != nil { + return "", err + } + defer session.Close() + + output, err := session.CombinedOutput(remoteCmd) + return string(output), err +}