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
+}