Compare commits

..

23 commits
v0.1.0 ... main

Author SHA1 Message Date
SW
68911b3af4 chore: rename module path to git.janvete.com/seth/caddyview
All checks were successful
Release / release (push) Successful in 1m4s
The repo lives on Forgejo; the github.com module path was a leftover
identifier. No functional change.
2026-10-17 22:42:21 +02:00
SW
b229cfe264 feat: support aes256-gcm@openssh.com encrypted private keys
x/crypto/ssh only supports aes256-ctr/cbc for private key encryption,
so keys exported from 1Password (aes256-gcm) could not be decrypted even
with the correct passphrase. Decrypt the openssh-key-v1 blob ourselves
(bcrypt_pbkdf ported from x/crypto's internal package, AES-256-GCM via
stdlib, auth tag stored as raw bytes after the encrypted block) and
re-marshal it as an unencrypted key for ssh.ParsePrivateKey.
2026-10-17 22:42:21 +02:00
SW
b3ba967353 fix: prompt for passphrase on encrypted SSH keys
All checks were successful
Release / release (push) Successful in 1m20s
ParsePrivateKey failures were silently skipped, so a passphrase-protected
~/.ssh/id_ed25519 was reported as 'no key files found' on hosts without
an SSH agent. Detect PassphraseMissingError and ask for the passphrase
interactively (up to 3 attempts) before the TUI starts; make the final
error distinguish missing files from unloadable ones.
2026-10-17 21:56:11 +02:00
SW
4acdfeaa87 feat: mouse wheel scrolling in errors tab
All checks were successful
Release / release (push) Successful in 47s
2026-09-10 13:52:55 +02:00
SW
9991f887b0 fix: parse error timestamps as float
All checks were successful
Release / release (push) Successful in 47s
2026-09-10 13:45:28 +02:00
088024ad9c ci: mount deploy key from /opt/runner/keys instead of /root/.ssh
All checks were successful
Release / release (push) Successful in 56s
Runner mel valid_volumes: [], takze mount /root/.ssh se tise zahazoval
a job container klic nikdy nedostal. Klic presunut do vyhrazeneho
adresare /opt/runner/keys, ktery je jako jediny povoleny v runner configu.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 13:26:42 +02:00
SW
3643430798 ci: mount runner .ssh into container
Some checks failed
Release / release (push) Failing after 6s
2026-09-10 13:10:22 +02:00
SW
383d14e155 ci: add SSH debug, use HOME path for key
Some checks failed
Release / release (push) Failing after 15s
2026-09-10 12:53:47 +02:00
SW
30d6cd26d6 ci: run directly on runner using its SSH key
Some checks failed
Release / release (push) Failing after 13s
2026-09-10 12:34:29 +02:00
SW
71f752c46e ci: use SSH key for tap and apt push
Some checks failed
Release / release (push) Failing after 5s
2026-09-10 11:27:04 +02:00
SW
5abee4c3bb ci: use username seth in HTTPS git URLs
Some checks failed
Release / release (push) Failing after 45s
2026-09-10 11:14:37 +02:00
SW
164c562f9f ci: handle existing release in workflow
Some checks failed
Release / release (push) Failing after 45s
2026-09-10 10:29:20 +02:00
SW
6d01888c85 ci: build .deb, update Forgejo tap and APT repo\n\n- Add .deb build for linux/amd64 and upload as release asset.\n- Update Homebrew tap on Forgejo (seth/homebrew-tools).\n- Update APT repository on Forgejo (seth/apt).\n- Update README with new install instructions.
Some checks failed
Release / release (push) Failing after 44s
2026-09-10 10:06:17 +02:00
SW
06b90d94a4 ci: fix GitHub PAT URL format for tap update 2026-09-10 09:20:06 +02:00
SW
8d60e9c745 feat: incremental history cache, errors tab, Forgejo migration
Some checks failed
Release / release (push) Failing after 53s
- Cache current log file incrementally by line count/inode to avoid
  re-processing the whole file every refresh.
- Collect 4xx/5xx errors during aggregation and add [4] ERRORS tab.
- Update README URLs and brew tap to point to git.janvete.com.
- Replace GitHub Actions workflow with Forgejo Actions release workflow.
2026-09-10 09:10:14 +02:00
edc48c2953
Add README.md for Caddyview installation and usage 2026-06-13 19:48:54 +02:00
378ccc1a28 ci: push formula to shared homebrew-tools tap
Tap was renamed from homebrew-caddyview to homebrew-tools and now
hosts formulae for multiple tools. Workflow URL updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-13 16:20:02 +02:00
SW
7c6adbc17e fix: rework history aggregation, live streaming and visual refresh
History was effectively frozen: the find pattern 'access.log*' never
matched Caddy's native rotation naming (access-<ts>.log.gz), so rotated
files were invisible and the server-side cache - invalidated only by new
.gz files - never refreshed after the first load.

- match both Caddy native (access-*.log.gz) and logrotate (access.log.N)
  rotation naming
- cache aggregation per rotated file (immutable, scanned exactly once);
  always scan the current log fresh, so history never goes stale
- always bucket hourly on the server, re-bucket client-side so all tabs
  share the same caches
- cap IPs at top 50 per file so output stays small on busy servers
- aggregate request duration so history shows real avg response time
- tail -F instead of -f: Caddy rotates by rename, which silently killed
  the live stream at every rotation on high-traffic servers
- process live lines in batches instead of one Update cycle per line
- fetch CPU/MEM stats every 5s instead of every second
- stable sort in topN: equal counts no longer flicker and swap rows
- drop the 1 Month tab (Live / 1 Day / 1 Week remain)
- retro green-phosphor CRT look: double borders, ▓░ bars, amber accents

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 21:30:38 +02:00
SW
fb09f6e028 perf: server-side result caching for history aggregation
Cache awk output in /tmp on the remote server, keyed by log path and
bucket size. Cache is invalidated only when a new rotated .gz file
appears, making repeated loads instant (0.001s vs 4s cold run).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 21:09:12 +02:00
SW
2be12c24cd perf: server-side awk aggregation for history views
High-traffic servers (100+ req/s) generated millions of log lines
per day. Transferring raw lines over SSH was impractical (GB of data).

Instead, run an awk script on the server that aggregates all matching
log files (including .gz rotated ones) into time buckets and returns
only a ~50-line summary. For a server doing 180 req/s this reduces
SSH transfer from ~7GB to <1KB per history request.

The awk script filters by timestamp, groups by bucket, and outputs:
- per-bucket request counts, bytes, and status code groups
- top hosts and IPs with counts
- overall totals

Files are pre-filtered with find -mtime to skip archives older than
the requested window.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:50:51 +02:00
SW
da6a128882 fix: auto-refresh history tabs every 5 minutes
History tabs were loaded once at first visit and never updated,
causing 1 Day/Week/Month to show fewer requests than the live view.

- Silent background refresh every 5 min for the active history tab
- Re-check on tab switch: reload if data is older than 5 min
- Spinner only on first load; subsequent refreshes keep old data visible
- Chart title shows "updated X ago" so freshness is always visible

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 14:26:08 +02:00
SW
1501b15a9d fix: use SSH agent for authentication
Try SSH_AUTH_SOCK agent first (handles macOS Keychain, ssh-add, and
custom key paths configured in the shell). Fall back to common key
file names (id_ed25519, id_ecdsa, id_rsa) if no agent is available.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:16:28 +02:00
SW
c6657daa96 ci: add automated release workflow
On git tag v*, builds darwin-arm64/amd64 and linux-amd64 binaries,
creates GitHub Release, and auto-updates homebrew-caddyview formula.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:10:04 +02:00
12 changed files with 1309 additions and 195 deletions

View file

@ -0,0 +1,214 @@
name: Release
on:
push:
tags:
- 'v[0-9]*'
jobs:
release:
runs-on: ubuntu-latest
container:
image: golang:1.27
volumes:
- /opt/runner/keys:/keys:ro
steps:
- name: Install packaging tools
run: |
apt-get update
apt-get install -y dpkg-dev git curl openssh-client
- name: Setup SSH
run: |
test -r /keys/forgejo_runner || { echo "Deploy key /keys/forgejo_runner chybi"; exit 1; }
install -m 600 /keys/forgejo_runner /tmp/forgejo_runner
echo "GIT_SSH_COMMAND=ssh -p 554 -i /tmp/forgejo_runner -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR" >> "$GITHUB_ENV"
- name: Checkout source
run: |
git clone ssh://git@git-ssh.janvete.com:554/seth/Caddyview.git src
cd src
git checkout "${{ github.ref_name }}"
- name: Build binaries, archives and .deb
working-directory: src
run: |
VERSION=${{ github.ref_name }}
VER=${VERSION#v}
mkdir -p dist
for PAIR in darwin/arm64 darwin/amd64 linux/amd64; do
OS=${PAIR%/*}
ARCH=${PAIR#*/}
GOOS=$OS GOARCH=$ARCH go build -ldflags="-s -w" -o dist/caddyview-${OS}-${ARCH} .
STAGING=$(mktemp -d)
cp dist/caddyview-${OS}-${ARCH} ${STAGING}/caddyview
tar -czf dist/caddyview-${VERSION}-${OS}-${ARCH}.tar.gz -C ${STAGING} caddyview
rm -rf ${STAGING}
done
# Build .deb for linux/amd64
DEB_DIR="caddyview_${VER}_amd64"
mkdir -p "${DEB_DIR}/DEBIAN"
mkdir -p "${DEB_DIR}/usr/local/bin"
cp dist/caddyview-linux-amd64 "${DEB_DIR}/usr/local/bin/caddyview"
chmod 755 "${DEB_DIR}/usr/local/bin/caddyview"
cat > "${DEB_DIR}/DEBIAN/control" << CONTROL
Package: caddyview
Version: ${VER}
Section: utils
Priority: optional
Architecture: amd64
Maintainer: seth <seth@janvete.com>
Description: TUI SSH monitor for Caddy web server logs
CONTROL
dpkg-deb --build "${DEB_DIR}" "dist/caddyview_${VER}_amd64.deb"
mv "${DEB_DIR}" "dist/"
echo "=== SHA256 checksums ==="
sha256sum dist/*.tar.gz dist/*.deb
- name: Create Forgejo Release
working-directory: src
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
FORGEJO_URL: https://git.janvete.com
run: |
VERSION=${{ github.ref_name }}
API="${FORGEJO_URL}/api/v1/repos/seth/Caddyview"
# Try to create release; if it already exists, fetch the existing one.
RELEASE_JSON=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: application/json" \
-X POST \
-d "{\"tag_name\":\"${VERSION}\",\"name\":\"${VERSION}\",\"draft\":false,\"prerelease\":false}" \
"${API}/releases")
echo "Create release response: ${RELEASE_JSON}"
RELEASE_ID=$(echo "${RELEASE_JSON}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
if [ -z "${RELEASE_ID}" ]; then
echo "Release may already exist, fetching existing release..."
EXISTING=$(curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
"${API}/releases/tags/${VERSION}")
echo "Existing release response: ${EXISTING}"
RELEASE_ID=$(echo "${EXISTING}" | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2)
fi
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to create or fetch release"
exit 1
fi
echo "release_id=${RELEASE_ID}" >> "$GITHUB_ENV"
for ARCHIVE in dist/*.tar.gz dist/*.deb; do
[ -e "${ARCHIVE}" ] || continue
NAME=$(basename "${ARCHIVE}")
CONTENT_TYPE="application/gzip"
case "${NAME}" in *.deb) CONTENT_TYPE="application/vnd.debian.binary-package" ;; esac
curl -sS -H "Authorization: token ${FORGEJO_TOKEN}" \
-H "Content-Type: ${CONTENT_TYPE}" \
--data-binary "@${ARCHIVE}" \
"${API}/releases/${RELEASE_ID}/assets?name=${NAME}"
echo "Uploaded ${NAME}"
done
- name: Update Homebrew tap
working-directory: src
run: |
VERSION=${{ github.ref_name }}
VER=${VERSION#v}
SHA_ARM64=$(sha256sum dist/caddyview-${VERSION}-darwin-arm64.tar.gz | awk '{print $1}')
SHA_AMD64=$(sha256sum dist/caddyview-${VERSION}-darwin-amd64.tar.gz | awk '{print $1}')
SHA_LINUX=$(sha256sum dist/caddyview-${VERSION}-linux-amd64.tar.gz | awk '{print $1}')
git clone ssh://git@git-ssh.janvete.com:554/seth/homebrew-tools.git tap
cat > tap/Formula/caddyview.rb << FORMULA
class Caddyview < Formula
desc "TUI SSH monitor for Caddy web server logs"
homepage "https://git.janvete.com/seth/Caddyview"
version "${VER}"
license "MIT"
on_macos do
if Hardware::CPU.arm?
url "https://git.janvete.com/seth/Caddyview/releases/download/${VERSION}/caddyview-${VERSION}-darwin-arm64.tar.gz"
sha256 "${SHA_ARM64}"
else
url "https://git.janvete.com/seth/Caddyview/releases/download/${VERSION}/caddyview-${VERSION}-darwin-amd64.tar.gz"
sha256 "${SHA_AMD64}"
end
end
on_linux do
if Hardware::CPU.intel?
url "https://git.janvete.com/seth/Caddyview/releases/download/${VERSION}/caddyview-${VERSION}-linux-amd64.tar.gz"
sha256 "${SHA_LINUX}"
end
end
def install
bin.install "caddyview"
end
test do
assert_match "TUI monitor", shell_output("#{bin}/caddyview --help")
end
end
FORMULA
cd tap
git config user.email "forgejo-actions[bot]@janvete.com"
git config user.name "forgejo-actions[bot]"
git add Formula/caddyview.rb
git commit -m "chore: bump caddyview to ${VERSION}"
git push
- name: Update APT repository
working-directory: src
run: |
VERSION=${{ github.ref_name }}
VER=${VERSION#v}
git clone ssh://git@git-ssh.janvete.com:554/seth/apt.git apt-repo
cp "dist/caddyview_${VER}_amd64.deb" apt-repo/
cd apt-repo
# repo muze byt uplne prazdny (bez commitu a bez branche)
if ! git rev-parse --verify HEAD >/dev/null 2>&1; then
git checkout -b main
fi
dpkg-scanpackages . > Packages
gzip -k -f Packages
cat > Release << RELEASE
Origin: janvete
Label: janvete
Suite: stable
Codename: stable
Version: 1.0
Architectures: amd64
Components: main
Description: janvete tools APT repository
Date: $(date -Ru)
RELEASE
# Append hashes to Release
{
echo "SHA256:"
for f in Packages Packages.gz *.deb; do
[ -e "$f" ] || continue
printf " %s %16d %s\n" "$(sha256sum "$f" | cut -d' ' -f1)" "$(stat -c%s "$f")" "$f"
done
} >> Release
git config user.email "forgejo-actions[bot]@janvete.com"
git config user.name "forgejo-actions[bot]"
git add -A
git commit -m "chore: add caddyview ${VERSION}"
git push -u origin HEAD

2
.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
dist/
caddyview

92
README.md Normal file
View file

@ -0,0 +1,92 @@
# Caddyview
Remote Caddy log viewer over SSH.
## Installation
### macOS (Homebrew)
```bash
brew tap janvete/tools https://git.janvete.com/seth/homebrew-tools.git
brew install caddyview
```
### Linux (APT)
```bash
echo "deb [trusted=yes] https://git.janvete.com/seth/apt/raw/branch/main ./" | sudo tee /etc/apt/sources.list.d/janvete.list
sudo apt update
sudo apt install caddyview
```
### Linux (tarball)
Download the latest release from the [releases page](https://git.janvete.com/seth/Caddyview/releases) and extract it:
```bash
VERSION=v0.7.0
curl -L "https://git.janvete.com/seth/Caddyview/releases/download/${VERSION}/caddyview-${VERSION}-linux-amd64.tar.gz" | tar xz
sudo mv caddyview /usr/local/bin/
```
## Usage
```bash
caddyview ssh root@192.168.x.x
caddyview ssh -p 2222 admin@server.com
```
Press `[1]` for live traffic, `[2]` for last 24 hours, `[3]` for last 7 days, and `[4]` to view recent 4xx/5xx errors with URL and origin.
## Required Caddyfile Changes
### 1. Add a log block to your Caddyfile
Add the logging configuration to a [snippet](https://caddyserver.com/docs/caddyfile/concepts#snippets) or to each individual site block:
```caddyfile
(hsts) {
log {
output file /var/log/caddy/access.log {
roll_size 100mb
roll_keep 10
}
format json
}
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
-Server
}
}
some.dns.com {
import hsts
reverse_proxy 192.168.71.6:1378
}
```
### 2. Create the log file with correct permissions (BEFORE reloading!)
```bash
mkdir -p /var/log/caddy
touch /var/log/caddy/access.log
chown caddy:caddy /var/log/caddy/access.log
```
### 3. Reload Caddy
```bash
systemctl reload caddy
```
## Development
The source repository is hosted at `https://git.janvete.com/seth/Caddyview`.
To clone via SSH use the dedicated SSH endpoint:
```bash
git clone ssh://git@git-ssh.janvete.com:554/seth/Caddyview.git
```

View file

@ -4,8 +4,8 @@ import (
"fmt"
"os"
"github.com/janvete/caddyview/internal/sshclient"
"github.com/janvete/caddyview/internal/tui"
"git.janvete.com/seth/caddyview/internal/sshclient"
"git.janvete.com/seth/caddyview/internal/tui"
"github.com/spf13/cobra"
)

16
go.mod
View file

@ -1,12 +1,18 @@
module github.com/janvete/caddyview
module git.janvete.com/seth/caddyview
go 1.26.4
require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.10.2
golang.org/x/crypto v0.53.0
golang.org/x/term v0.47.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbletea v1.3.10 // indirect
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
github.com/charmbracelet/lipgloss v1.1.0 // indirect
github.com/charmbracelet/x/ansi v0.10.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
@ -20,10 +26,8 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/crypto v0.53.0 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/sys v0.49.0 // indirect
golang.org/x/text v0.38.0 // indirect
)

12
go.sum
View file

@ -44,14 +44,14 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJu
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=
golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/sys v0.49.0 h1:XbzkgYJHdqh/8m2Uu0W/dQv8nktxx4BFHp1M0gROTXA=
golang.org/x/sys v0.49.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.47.0 h1:cpyOwQkr2Nzxe2p6KnrTG7Ei2wtzjsuSjhpXXepZ/BM=
golang.org/x/term v0.47.0/go.mod h1:efAfAuG6RKABvg1ArGebRkvX6LXul14SM67o2e4mUCY=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View file

@ -2,6 +2,9 @@ package parser
import (
"encoding/json"
"fmt"
"sort"
"strconv"
"strings"
"time"
)
@ -39,24 +42,14 @@ func ParseLine(line string) *LogEntry {
if err := json.Unmarshal([]byte(line), &entry); err != nil {
return nil
}
if entry.Request.Host == "" {
if entry.Request.Host == "" || entry.Status == 0 {
return nil
}
return &entry
}
// ParseLines parses multiple lines and returns valid entries.
func ParseLines(lines []string) []*LogEntry {
entries := make([]*LogEntry, 0, len(lines))
for _, l := range lines {
if e := ParseLine(l); e != nil {
entries = append(entries, e)
}
}
return entries
}
// -- Stats (used for live view and derived from AggregatedHistory for history) --
// Stats holds aggregated metrics for a time window.
type Stats struct {
TotalRequests int64
TotalBytes int64
@ -90,7 +83,6 @@ func (s *Stats) Add(e *LogEntry) {
}
}
// BytesPerSec calculates transfer rate given a window duration in seconds.
func (s *Stats) BytesPerSec(windowSecs float64) float64 {
if windowSecs <= 0 {
return 0
@ -98,42 +90,258 @@ func (s *Stats) BytesPerSec(windowSecs float64) float64 {
return float64(s.TotalBytes) / windowSecs
}
// Bucket aggregates entries into fixed time buckets for the history view.
// -- Bucket (time-series bar chart data) --------------------------------------
type Bucket struct {
Time time.Time
Requests int64
Bytes int64
}
// ComputeStats builds a Stats object from a slice of log entries.
func ComputeStats(entries []*LogEntry) *Stats {
// -- ErrorEntry (4xx/5xx detail) ----------------------------------------------
// ErrorEntry holds one failed request for the errors panel.
type ErrorEntry struct {
Time time.Time
Status int
Method string
URI string
Host string
RemoteIP string
}
// -- AggregatedHistory (server-side aggregation result) -----------------------
// AggregatedBucket is one time bucket returned by server-side awk aggregation.
type AggregatedBucket struct {
Time time.Time
Requests, Bytes int64
Status2xx, Status3xx, Status4xx, Status5xx int64
}
// AggregatedHistory holds the merged result of per-file server-side aggregations.
type AggregatedHistory struct {
Buckets []AggregatedBucket
TopHosts map[string]int64
TopIPs map[string]int64
TotalReqs int64
TotalBytes int64
DurSumSecs float64
Errors []ErrorEntry
}
// FilterSince drops buckets and errors older than the window and recomputes
// totals from the remaining buckets, so the chart and totals always agree.
// TopHosts and TopIPs come from whole rotated files and may slightly overcount
// at the oldest edge of the window — acceptable for a monitoring view.
func (h *AggregatedHistory) FilterSince(since int64) {
avgDur := 0.0
if h.TotalReqs > 0 {
avgDur = h.DurSumSecs / float64(h.TotalReqs)
}
kept := h.Buckets[:0]
var reqs, bytes int64
for _, b := range h.Buckets {
if b.Time.Unix() >= since {
kept = append(kept, b)
reqs += b.Requests
bytes += b.Bytes
}
}
h.Buckets = kept
h.TotalReqs = reqs
h.TotalBytes = bytes
h.DurSumSecs = avgDur * float64(reqs)
keptErrs := h.Errors[:0]
for _, e := range h.Errors {
if e.Time.Unix() >= since {
keptErrs = append(keptErrs, e)
}
}
h.Errors = keptErrs
}
// ToStats converts aggregated history to a Stats object for use in the TUI panels.
func (h *AggregatedHistory) ToStats() *Stats {
s := NewStats()
for _, e := range entries {
s.Add(e)
s.TotalRequests = h.TotalReqs
s.TotalBytes = h.TotalBytes
s.TopHosts = h.TopHosts
s.TopIPs = h.TopIPs
if h.TotalReqs > 0 {
s.AvgDurationMs = h.DurSumSecs / float64(h.TotalReqs) * 1000
}
for _, b := range h.Buckets {
s.StatusCodes[200] += b.Status2xx
s.StatusCodes[301] += b.Status3xx
s.StatusCodes[404] += b.Status4xx
s.StatusCodes[500] += b.Status5xx
}
return s
}
// Bucketize groups entries into buckets of the given duration.
func Bucketize(entries []*LogEntry, bucketSize time.Duration) []Bucket {
if len(entries) == 0 {
// ToBuckets re-buckets the hourly server-side buckets into the granularity the
// chart wants (e.g. 6h for the week view) and returns them sorted by time.
func (h *AggregatedHistory) ToBuckets(size time.Duration) []Bucket {
secs := int64(size.Seconds())
if secs < 1 {
secs = 1
}
merged := map[int64]*Bucket{}
for _, ab := range h.Buckets {
ts := ab.Time.Unix() / secs * secs
b, ok := merged[ts]
if !ok {
b = &Bucket{Time: time.Unix(ts, 0)}
merged[ts] = b
}
b.Requests += ab.Requests
b.Bytes += ab.Bytes
}
out := make([]Bucket, 0, len(merged))
for _, b := range merged {
out = append(out, *b)
}
sort.Slice(out, func(i, j int) bool { return out[i].Time.Before(out[j].Time) })
return out
}
// RecentErrors returns the last n errors sorted by time (newest first).
func (h *AggregatedHistory) RecentErrors(n int) []ErrorEntry {
if n <= 0 {
return nil
}
buckets := map[int64]*Bucket{}
for _, e := range entries {
t := e.Time().Truncate(bucketSize)
key := t.Unix()
if _, ok := buckets[key]; !ok {
buckets[key] = &Bucket{Time: t}
sorted := make([]ErrorEntry, len(h.Errors))
copy(sorted, h.Errors)
sort.Slice(sorted, func(i, j int) bool {
return sorted[i].Time.After(sorted[j].Time)
})
if len(sorted) > n {
return sorted[:n]
}
buckets[key].Requests++
buckets[key].Bytes += e.Size
return sorted
}
result := make([]Bucket, 0, len(buckets))
for _, b := range buckets {
result = append(result, *b)
// ParseAggregatedOutput parses the concatenated output of per-file server-side
// awk aggregations. Sections may repeat (one set per log file) and are merged:
// bucket counts sum by timestamp, host/IP counts sum by key, totals sum.
//
// Per-file format:
//
// BUCKETS
// <unix_ts> <count> <bytes> <2xx> <3xx> <4xx> <5xx>
// HOSTS
// <count> <host>
// IPS
// <count> <ip>
// ERRORS
// <unix_ts>\t<status>\t<method>\t<uri>\t<host>\t<remote_ip>
// TOTALS
// <total_reqs> <total_bytes> <duration_sum_secs>
func ParseAggregatedOutput(raw string) (*AggregatedHistory, error) {
h := &AggregatedHistory{
TopHosts: make(map[string]int64),
TopIPs: make(map[string]int64),
}
return result
bucketsByTS := map[int64]*AggregatedBucket{}
section := ""
sawSection := false
for _, line := range strings.Split(strings.TrimSpace(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
switch line {
case "BUCKETS", "HOSTS", "IPS", "ERRORS", "TOTALS":
section = line
sawSection = true
continue
}
fields := strings.Fields(line)
switch section {
case "BUCKETS":
if len(fields) < 7 {
continue
}
ts, err := strconv.ParseInt(fields[0], 10, 64)
if err != nil {
continue
}
ab, ok := bucketsByTS[ts]
if !ok {
ab = &AggregatedBucket{Time: time.Unix(ts, 0)}
bucketsByTS[ts] = ab
}
n := func(i int) int64 { v, _ := strconv.ParseInt(fields[i], 10, 64); return v }
ab.Requests += n(1)
ab.Bytes += n(2)
ab.Status2xx += n(3)
ab.Status3xx += n(4)
ab.Status4xx += n(5)
ab.Status5xx += n(6)
case "HOSTS":
if len(fields) < 2 {
continue
}
count, _ := strconv.ParseInt(fields[0], 10, 64)
host := strings.Join(fields[1:], " ")
h.TopHosts[host] += count
case "IPS":
if len(fields) < 2 {
continue
}
count, _ := strconv.ParseInt(fields[0], 10, 64)
h.TopIPs[fields[1]] += count
case "ERRORS":
// Tab-separated: ts status method uri host remote_ip
parts := strings.Split(line, "\t")
if len(parts) < 6 {
continue
}
// Caddy logs ts as a float (unix seconds with fractional part).
tsFloat, err := strconv.ParseFloat(parts[0], 64)
if err != nil {
continue
}
status, _ := strconv.Atoi(parts[1])
h.Errors = append(h.Errors, ErrorEntry{
Time: time.Unix(int64(tsFloat), 0),
Status: status,
Method: parts[2],
URI: parts[3],
Host: parts[4],
RemoteIP: parts[5],
})
case "TOTALS":
if len(fields) < 2 {
continue
}
reqs, _ := strconv.ParseInt(fields[0], 10, 64)
bytes, _ := strconv.ParseInt(fields[1], 10, 64)
h.TotalReqs += reqs
h.TotalBytes += bytes
if len(fields) >= 3 {
dur, _ := strconv.ParseFloat(fields[2], 64)
h.DurSumSecs += dur
}
}
}
if !sawSection {
return nil, fmt.Errorf("no aggregation output received from server")
}
h.Buckets = make([]AggregatedBucket, 0, len(bucketsByTS))
for _, ab := range bucketsByTS {
h.Buckets = append(h.Buckets, *ab)
}
sort.Slice(h.Buckets, func(i, j int) bool { return h.Buckets[i].Time.Before(h.Buckets[j].Time) })
return h, nil
}

View file

@ -0,0 +1,95 @@
// Copyright 2014 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found at https://go.dev/LICENSE.
//
// Adapted from golang.org/x/crypto/ssh/internal/bcrypt_pbkdf, which is not
// importable (internal package). Implements bcrypt_pbkdf(3) from OpenBSD.
//
// See https://flak.tedunangst.com/post/bcrypt-pbkdf and
// https://cvsweb.openbsd.org/cgi-bin/cvsweb/src/lib/libutil/bcrypt_pbkdf.c.
package sshclient
import (
"crypto/sha512"
"errors"
"golang.org/x/crypto/blowfish"
)
const bcryptBlockSize = 32
// bcryptPBKDF derives a key from the password, salt and rounds count,
// returning a []byte of length keyLen that can be used as cryptographic key.
func bcryptPBKDF(password, salt []byte, rounds, keyLen int) ([]byte, error) {
if rounds < 1 {
return nil, errors.New("bcrypt_pbkdf: number of rounds is too small")
}
if len(password) == 0 {
return nil, errors.New("bcrypt_pbkdf: empty password")
}
if len(salt) == 0 || len(salt) > 1<<20 {
return nil, errors.New("bcrypt_pbkdf: bad salt length")
}
if keyLen > 1024 {
return nil, errors.New("bcrypt_pbkdf: keyLen is too large")
}
numBlocks := (keyLen + bcryptBlockSize - 1) / bcryptBlockSize
key := make([]byte, numBlocks*bcryptBlockSize)
h := sha512.New()
h.Write(password)
shapass := h.Sum(nil)
shasalt := make([]byte, 0, sha512.Size)
cnt, tmp := make([]byte, 4), make([]byte, bcryptBlockSize)
for block := 1; block <= numBlocks; block++ {
h.Reset()
h.Write(salt)
cnt[0] = byte(block >> 24)
cnt[1] = byte(block >> 16)
cnt[2] = byte(block >> 8)
cnt[3] = byte(block)
h.Write(cnt)
bcryptHash(tmp, shapass, h.Sum(shasalt))
out := make([]byte, bcryptBlockSize)
copy(out, tmp)
for i := 2; i <= rounds; i++ {
h.Reset()
h.Write(tmp)
bcryptHash(tmp, shapass, h.Sum(shasalt))
for j := 0; j < len(out); j++ {
out[j] ^= tmp[j]
}
}
for i, v := range out {
key[i*numBlocks+(block-1)] = v
}
}
return key[:keyLen], nil
}
var bcryptMagic = []byte("OxychromaticBlowfishSwatDynamite")
func bcryptHash(out, shapass, shasalt []byte) {
c, err := blowfish.NewSaltedCipher(shapass, shasalt)
if err != nil {
panic(err)
}
for i := 0; i < 64; i++ {
blowfish.ExpandKey(shasalt, c)
blowfish.ExpandKey(shapass, c)
}
copy(out, bcryptMagic)
for i := 0; i < 32; i += 8 {
for j := 0; j < 64; j++ {
c.Encrypt(out[i:i+8], out[i:i+8])
}
}
// Swap bytes due to different endianness.
for i := 0; i < 32; i += 4 {
out[i+3], out[i+2], out[i+1], out[i] = out[i], out[i+1], out[i+2], out[i+3]
}
}

View file

@ -2,12 +2,17 @@ package sshclient
import (
"bufio"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"git.janvete.com/seth/caddyview/internal/parser"
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/agent"
"golang.org/x/term"
)
type Client struct {
@ -61,20 +66,7 @@ func (c *Client) RunCommand(cmd string) (string, error) {
return string(out), err
}
// TailLines reads the last n lines of a remote file.
func (c *Client) TailLines(path string, n int) ([]string, error) {
out, err := c.RunCommand(fmt.Sprintf("tail -n %d %s 2>/dev/null", n, path))
if err != nil {
return nil, fmt.Errorf("cannot read %s: %w", path, err)
}
lines := strings.Split(strings.TrimSpace(out), "\n")
if len(lines) == 1 && lines[0] == "" {
return nil, nil
}
return lines, nil
}
// StreamLines streams new lines from a remote file (like tail -f) into the channel.
// StreamLines streams new lines from a remote file (like tail -F) into the channel.
func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struct{}) error {
session, err := c.conn.NewSession()
if err != nil {
@ -83,11 +75,12 @@ func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struc
stdout, err := session.StdoutPipe()
if err != nil {
session.Close()
return err
}
if err := session.Start(fmt.Sprintf("tail -n 0 -f %s 2>/dev/null", path)); err != nil {
// -F follows by name: Caddy rotates by renaming the file, which would
// silently kill a plain -f stream at every rotation.
if err := session.Start(fmt.Sprintf("tail -n 0 -F %s 2>/dev/null", path)); err != nil {
session.Close()
return err
}
@ -95,6 +88,7 @@ func (c *Client) StreamLines(path string, lines chan<- string, done <-chan struc
go func() {
defer session.Close()
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
for scanner.Scan() {
select {
case <-done:
@ -140,17 +134,24 @@ func parseTarget(target string) (user, host string, err error) {
}
func buildAuthMethods(keyPath string) ([]ssh.AuthMethod, error) {
candidates := []string{}
var methods []ssh.AuthMethod
// SSH agent — uses whatever keys are loaded in the agent (macOS Keychain, ssh-add, etc.)
if m := agentAuth(); m != nil {
methods = append(methods, m)
}
// Explicit key file or common defaults
var candidates []string
if keyPath != "" {
candidates = append(candidates, keyPath)
} else {
home, _ := os.UserHomeDir()
candidates = append(candidates,
filepath.Join(home, ".ssh", "id_ed25519"),
filepath.Join(home, ".ssh", "id_rsa"),
)
for _, name := range []string{"id_ed25519", "id_ecdsa", "id_rsa"} {
candidates = append(candidates, filepath.Join(home, ".ssh", name))
}
}
var problems []string
for _, p := range candidates {
data, err := os.ReadFile(p)
if err != nil {
@ -158,12 +159,69 @@ func buildAuthMethods(keyPath string) ([]ssh.AuthMethod, error) {
}
signer, err := ssh.ParsePrivateKey(data)
if err != nil {
var passErr *ssh.PassphraseMissingError
if errors.As(err, &passErr) {
signer, err = unlockWithPassphrase(p, data)
}
if err != nil {
problems = append(problems, fmt.Sprintf("%s (%v)", p, err))
continue
}
return []ssh.AuthMethod{ssh.PublicKeys(signer)}, nil
}
methods = append(methods, ssh.PublicKeys(signer))
}
return nil, fmt.Errorf("no usable SSH private key found (tried: %v)", candidates)
if len(methods) == 0 {
if len(problems) > 0 {
return nil, fmt.Errorf("key files found but could not be loaded: %s", strings.Join(problems, "; "))
}
return nil, fmt.Errorf("no SSH auth method available — no agent (SSH_AUTH_SOCK) and no key files found in %v", candidates)
}
return methods, nil
}
// unlockWithPassphrase prompts for the passphrase of an encrypted private key.
// It runs before the TUI starts, so stdin is still a normal terminal.
func unlockWithPassphrase(path string, data []byte) (ssh.Signer, error) {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return nil, fmt.Errorf("key is passphrase-protected and stdin is not a terminal")
}
for attempt := 0; attempt < 3; attempt++ {
fmt.Fprintf(os.Stderr, "Enter passphrase for %s: ", path)
pass, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return nil, err
}
signer, err := parsePrivateKeyWithPassphrase(data, pass)
if err == nil {
return signer, nil
}
fmt.Fprintf(os.Stderr, "caddyview: cannot decrypt key: %v\n", err)
}
return nil, fmt.Errorf("could not decrypt key in 3 attempts")
}
// parsePrivateKeyWithPassphrase handles ciphers beyond what x/crypto/ssh
// supports (aes256-gcm@openssh.com, e.g. 1Password key exports).
func parsePrivateKeyWithPassphrase(data, pass []byte) (ssh.Signer, error) {
if isGCMEncryptedKey(data) {
return parseGCMKey(data, pass)
}
return ssh.ParsePrivateKeyWithPassphrase(data, pass)
}
// agentAuth returns an auth method backed by the SSH agent if SSH_AUTH_SOCK is set.
func agentAuth() ssh.AuthMethod {
sock := os.Getenv("SSH_AUTH_SOCK")
if sock == "" {
return nil
}
conn, err := net.Dial("unix", sock)
if err != nil {
return nil
}
return ssh.PublicKeysCallback(agent.NewClient(conn).Signers)
}
func parseMeminfo(raw string) (totalMB, usedMB int64) {
@ -216,9 +274,88 @@ func parseCPUStat(raw string) float64 {
return (totalDiff - idleDiff) / totalDiff * 100
}
// LoadHistoricalLines reads log lines newer than `since` from the main log file
// and any rotated versions (access.log.1, access.log.2.gz, etc.).
func (c *Client) LoadHistoricalLines(logPath string, since int64) ([]string, error) {
// historyAwkProg aggregates Caddy JSON log lines into hourly summary buckets
// and collects the last 100 4xx/5xx errors per file. It runs entirely on the
// server and uses only POSIX awk constructs (works with gawk, mawk and busybox
// awk). IPs are capped to the top 50 per file so the output stays small even on
// servers with millions of unique clients.
const historyAwkProg = `
BEGIN { OFS=" " }
{
if (index($0,"\"msg\":\"handled request\"") == 0) next
p = index($0,"\"ts\":")
if (p == 0) next
ts = substr($0,p+5,20)+0
if (ts < cutoff) next
b = int(ts/bucket)*bucket
p = index($0,"\"status\":")
st = (p>0) ? substr($0,p+9,5)+0 : 0
p = index($0,"\"size\":")
sz = (p>0) ? substr($0,p+7,15)+0 : 0
p = index($0,"\"duration\":")
du = (p>0) ? substr($0,p+11,14)+0 : 0
p = index($0,"\"host\":\"")
if (p>0) { rest=substr($0,p+8); q=index(rest,"\""); ho=(q>0)?substr(rest,1,q-1):"?" } else ho="?"
p = index($0,"\"remote_ip\":\"")
if (p>0) { rest=substr($0,p+13); q=index(rest,"\""); ip=(q>0)?substr(rest,1,q-1):"?" } else ip="?"
p = index($0,"\"uri\":\"")
if (p>0) { rest=substr($0,p+7); q=index(rest,"\""); uri=(q>0)?substr(rest,1,q-1):"?" } else uri="?"
p = index($0,"\"method\":\"")
if (p>0) { rest=substr($0,p+10); q=index(rest,"\""); meth=(q>0)?substr(rest,1,q-1):"?" } else meth="?"
bc[b]++; bb[b]+=sz
if (st>=500) bs5[b]++
else if (st>=400) bs4[b]++
else if (st>=300) bs3[b]++
else if (st>=200) bs2[b]++
hc[ho]++; ic[ip]++
tot++; totb+=sz; totd+=du
if (st >= 400) {
err[++errN] = ts "\t" st "\t" meth "\t" uri "\t" ho "\t" ip
if (errN > 100) {
for (i=2; i<=errN; i++) err[i-1] = err[i]
errN--
}
}
}
END {
print "BUCKETS"
for (b in bc) print b, bc[b], bb[b], bs2[b]+0, bs3[b]+0, bs4[b]+0, bs5[b]+0
print "HOSTS"
for (h in hc) print hc[h], h
print "IPS"
k = 50
for (i in ic) n++
while (k > 0 && n > 0) {
max = -1; mi = ""
for (i in ic) if (ic[i] > max) { max = ic[i]; mi = i }
print ic[mi], mi
delete ic[mi]
k--; n--
}
print "ERRORS"
for (i=1; i<=errN; i++) print err[i]
print "TOTALS"
print tot+0, totb+0, totd+0
}`
// LoadAggregatedHistory aggregates log data on the remote server using awk,
// returning only summary buckets instead of raw lines. This is critical for
// high-traffic servers where raw line transfer would be impractical.
//
// Rotated log files are immutable, so each one is aggregated exactly once and
// the result is cached on the server (/tmp/caddyview_*.cache); after a
// rotation only the single new file is scanned. The current log file is cached
// incrementally: only newly appended lines are processed on refresh, so even a
// 100 MB active log refreshes in seconds. Buckets are always hourly; the caller
// re-buckets client-side, which lets all tabs share the same caches.
//
// since: unix cutoff used client-side after aggregation (ignored server-side so
// the cache can be reused across different windows).
// maxAgeDays: only look at files modified within this many days
func (c *Client) LoadAggregatedHistory(logPath string, since int64, maxAgeDays int) (*parser.AggregatedHistory, error) {
lastSlash := strings.LastIndex(logPath, "/")
dir, base := "/", logPath
if lastSlash >= 0 {
@ -229,22 +366,78 @@ func (c *Client) LoadHistoricalLines(logPath string, since int64) ([]string, err
base = logPath[lastSlash+1:]
}
// Find all rotated log files, cat/zcat them, filter JSON lines by ts field.
// awk: find offset of "ts": then parse the numeric value after it.
cmd := fmt.Sprintf(
`find %s -name '%s*' -type f 2>/dev/null | sort -r | `+
`while read f; do case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac; done 2>/dev/null | `+
`awk -v c=%d '{p=index($0,"\"ts\":"); if(p>0){v=substr($0,p+5,20)+0; if(v>=c) print}}'`,
dir, base, since,
// Match both Caddy's native rotation naming (access-2026-06-12T10-30-00.000.log
// and .log.gz next to access.log) and classic logrotate naming (access.log.1.gz).
stem, ext := base, ""
if dot := strings.LastIndex(base, "."); dot > 0 {
stem, ext = base[:dot], base[dot:]
}
namePattern := fmt.Sprintf(
`\( -name '%s' -o -name '%s.*' -o -name '%s-*%s' -o -name '%s-*%s.gz' \)`,
base, base, stem, ext, stem, ext,
)
out, err := c.RunCommand(cmd)
cacheKey := func(path string) string {
return strings.Map(func(r rune) rune {
if (r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
return r
}
return '_'
}, path)
}
key := cacheKey(logPath)
// Shell script that aggregates each file. The current log is handled
// incrementally via a line-count/inode marker; rotated files use immutable caches.
script := fmt.Sprintf(
`set -e
find /tmp -maxdepth 1 -name 'caddyview_*' -mtime +8 -delete 2>/dev/null || true
find %s %s -type f -mtime -%d 2>/dev/null | sort | while read -r f; do
if [ "$f" = "%s" ]; then
c="/tmp/caddyview_%s.cache"
m="/tmp/caddyview_%s.marker"
read inode < <(stat -c '%%i' "$f" 2>/dev/null || echo "0")
read lines < <(wc -l < "$f" 2>/dev/null || echo "0")
use_old=false
if [ -s "$c" ] && [ -s "$m" ]; then
read old_inode old_lines < "$m"
if [ "$inode" = "$old_inode" ] && [ "$lines" -ge "$old_lines" ]; then
use_old=true
fi
fi
if [ "$use_old" = true ]; then
cat "$c"
if [ "$lines" -gt "$old_lines" ]; then
tail -n +$((old_lines + 1)) "$f" 2>/dev/null | awk -v cutoff=0 -v bucket=3600 '%s'
fi
else
awk -v cutoff=0 -v bucket=3600 '%s' < "$f" > "$c"
fi
echo "$inode $lines" > "$m"
if [ "$use_old" != "true" ]; then cat "$c"; fi
else
key=$(printf '%%s' "$f" | tr -c 'A-Za-z0-9' '_')
c="/tmp/caddyview_${key}.cache"
if [ -s "$c" ] && [ "$c" -nt "$f" ]; then
cat "$c"
else
t=$(mktemp /tmp/caddyview.XXXXXX 2>/dev/null) || t="/tmp/caddyview_tmp.$$"
case "$f" in *.gz) zcat "$f" ;; *) cat "$f" ;; esac 2>/dev/null \
| awk -v cutoff=0 -v bucket=3600 '%s' > "$t" && mv "$t" "$c" && cat "$c" || cat "$t" 2>/dev/null
fi
fi
done`,
dir, namePattern, maxAgeDays+1,
logPath,
key, key,
historyAwkProg,
historyAwkProg,
historyAwkProg,
)
out, err := c.RunCommand(script)
if err != nil && strings.TrimSpace(out) == "" {
return nil, fmt.Errorf("failed to read historical logs: %w", err)
return nil, fmt.Errorf("aggregation failed: %w", err)
}
out = strings.TrimSpace(out)
if out == "" {
return nil, nil
}
return strings.Split(out, "\n"), nil
return parser.ParseAggregatedOutput(out)
}

View file

@ -0,0 +1,122 @@
package sshclient
import (
"crypto/aes"
"crypto/cipher"
"encoding/pem"
"errors"
"fmt"
"golang.org/x/crypto/ssh"
)
// aes256-gcm@openssh.com is a cipher OpenSSH (and 1Password key exports) may
// use for private key files. golang.org/x/crypto/ssh only supports aes256-ctr
// and aes256-cbc, so these keys are decrypted here and re-marshalled as
// unencrypted openssh-key-v1 blobs that ssh.ParsePrivateKey accepts.
const (
opensshKeyMagic = "openssh-key-v1\x00"
opensshGCMCipher = "aes256-gcm@openssh.com"
gcmTagSize = 16
)
type opensshPrivateKeyHeader struct {
CipherName string
KdfName string
KdfOpts string
NumKeys uint32
PubKey []byte
PrivKeyBlock []byte
// For AEAD ciphers (aes256-gcm@openssh.com) OpenSSH stores the 16-byte
// auth tag as raw bytes after the encrypted block, not inside it.
Rest []byte `ssh:"rest"`
}
// isGCMEncryptedKey reports whether data is an OpenSSH private key encrypted
// with aes256-gcm@openssh.com.
func isGCMEncryptedKey(data []byte) bool {
block, _ := pem.Decode(data)
if block == nil || block.Type != "OPENSSH PRIVATE KEY" {
return false
}
if len(block.Bytes) < len(opensshKeyMagic) || string(block.Bytes[:len(opensshKeyMagic)]) != opensshKeyMagic {
return false
}
var header struct {
CipherName string
Rest []byte `ssh:"rest"`
}
if err := ssh.Unmarshal(block.Bytes[len(opensshKeyMagic):], &header); err != nil {
return false
}
return header.CipherName == opensshGCMCipher
}
// parseGCMKey decrypts an aes256-gcm@openssh.com encrypted OpenSSH private
// key with passphrase and returns the signer.
func parseGCMKey(data, passphrase []byte) (ssh.Signer, error) {
block, _ := pem.Decode(data)
if block == nil || len(block.Bytes) < len(opensshKeyMagic) {
return nil, errors.New("invalid OpenSSH private key PEM")
}
var w opensshPrivateKeyHeader
if err := ssh.Unmarshal(block.Bytes[len(opensshKeyMagic):], &w); err != nil {
return nil, err
}
if w.NumKeys != 1 {
return nil, errors.New("multi-key files are not supported")
}
if w.KdfName != "bcrypt" {
return nil, fmt.Errorf("unknown KDF %q, only supports %q", w.KdfName, "bcrypt")
}
var kdfOpts struct {
Salt string
Rounds uint32
}
if err := ssh.Unmarshal([]byte(w.KdfOpts), &kdfOpts); err != nil {
return nil, err
}
// Same cap as x/crypto/ssh so a hostile file cannot tie up the caller.
const maxRounds = 1 << 11
if kdfOpts.Rounds > maxRounds {
return nil, fmt.Errorf("bcrypt KDF rounds %d exceed maximum %d", kdfOpts.Rounds, maxRounds)
}
// AES-256-GCM: 32-byte key + 12-byte nonce derived by bcrypt_pbkdf.
// The 16-byte auth tag follows the encrypted block as raw bytes;
// gcm.Open expects it appended to the ciphertext.
if len(w.Rest) != gcmTagSize {
return nil, fmt.Errorf("unexpected GCM tag length %d", len(w.Rest))
}
k, err := bcryptPBKDF(passphrase, []byte(kdfOpts.Salt), int(kdfOpts.Rounds), 32+12)
if err != nil {
return nil, err
}
aesCipher, err := aes.NewCipher(k[:32])
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(aesCipher)
if err != nil {
return nil, err
}
sealed := make([]byte, 0, len(w.PrivKeyBlock)+gcmTagSize)
sealed = append(sealed, w.PrivKeyBlock...)
sealed = append(sealed, w.Rest...)
plain, err := gcm.Open(nil, k[32:], sealed, nil)
if err != nil {
return nil, err
}
// Re-marshal as an unencrypted key (tag dropped). The plaintext keeps
// its original OpenSSH padding, which ssh.ParsePrivateKey validates.
w.CipherName = "none"
w.KdfName = "none"
w.KdfOpts = ""
w.PrivKeyBlock = plain
w.Rest = nil
raw := make([]byte, 0, len(opensshKeyMagic)+len(plain)+128)
raw = append(raw, opensshKeyMagic...)
raw = append(raw, ssh.Marshal(&w)...)
return ssh.ParsePrivateKey(pem.EncodeToMemory(&pem.Block{Type: "OPENSSH PRIVATE KEY", Bytes: raw}))
}

View file

@ -9,8 +9,8 @@ import (
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/janvete/caddyview/internal/parser"
"github.com/janvete/caddyview/internal/sshclient"
"git.janvete.com/seth/caddyview/internal/parser"
"git.janvete.com/seth/caddyview/internal/sshclient"
)
// -- tabs ---------------------------------------------------------------------
@ -21,81 +21,83 @@ const (
tabLive viewTab = 0
tabDay viewTab = 1
tabWeek viewTab = 2
tabMonth viewTab = 3
tabErrors viewTab = 3
)
var tabLabels = []string{"[1] Live", "[2] 1 Day", "[3] 1 Week", "[4] 1 Month"}
var tabLabels = []string{"[1] LIVE", "[2] 1 DAY", "[3] 1 WEEK", "[4] ERRORS"}
// tabWindow returns how far back each history tab reaches.
func tabWindow(t viewTab) time.Duration {
switch t {
case tabDay:
return 24 * time.Hour
case tabWeek:
if t == tabWeek {
return 7 * 24 * time.Hour
case tabMonth:
return 30 * 24 * time.Hour
}
return 0
return 24 * time.Hour
}
// tabBucketSize returns the bucket granularity for the bar chart.
func tabBucketSize(t viewTab) time.Duration {
switch t {
case tabDay:
return time.Hour
case tabWeek:
if t == tabWeek {
return 6 * time.Hour
case tabMonth:
return 24 * time.Hour
}
return time.Hour
}
// -- styles -------------------------------------------------------------------
// -- styles (green phosphor / amber CRT palette) --------------------------------
var (
colGreen = lipgloss.Color("40") // primary phosphor green
colBright = lipgloss.Color("46") // highlights
colDark = lipgloss.Color("28") // borders
colFaint = lipgloss.Color("22") // empty bar fill
colDim = lipgloss.Color("65") // secondary text
colAmber = lipgloss.Color("214") // warnings
colRed = lipgloss.Color("160") // errors
styleBorder = lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("62")).
Border(lipgloss.DoubleBorder()).
BorderForeground(colDark).
Padding(0, 1)
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("212"))
styleTitle = lipgloss.NewStyle().Bold(true).Foreground(colBright)
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("39"))
styleHeader = lipgloss.NewStyle().Bold(true).Foreground(colGreen)
styleTabActive = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("16")).
Background(lipgloss.Color("62")).
Background(colGreen).
Padding(0, 1)
styleTabInactive = lipgloss.NewStyle().
Foreground(lipgloss.Color("240")).
Foreground(colDim).
Padding(0, 1)
styleGood = lipgloss.NewStyle().Foreground(lipgloss.Color("42"))
styleWarn = lipgloss.NewStyle().Foreground(lipgloss.Color("214"))
styleBad = lipgloss.NewStyle().Foreground(lipgloss.Color("196"))
styleDim = lipgloss.NewStyle().Foreground(lipgloss.Color("240"))
styleBar = lipgloss.NewStyle().Foreground(lipgloss.Color("62"))
styleGood = lipgloss.NewStyle().Foreground(colGreen)
styleWarn = lipgloss.NewStyle().Foreground(colAmber)
styleBad = lipgloss.NewStyle().Foreground(colRed)
styleDim = lipgloss.NewStyle().Foreground(colDim)
styleBar = lipgloss.NewStyle().Foreground(colGreen)
styleBarEmpty = lipgloss.NewStyle().Foreground(colFaint)
)
// -- history state ------------------------------------------------------------
const historyRefreshInterval = 5 * time.Minute
type historyState struct {
entries []*parser.LogEntry
stats *parser.Stats
buckets []parser.Bucket
errors []parser.ErrorEntry
loaded bool
loading bool
loadedAt time.Time
err error
}
// -- messages -----------------------------------------------------------------
type tickMsg time.Time
type newLineMsg string
type newLinesMsg []string
type sysStatsMsg struct {
cpu float64
memUsed int64
@ -103,7 +105,7 @@ type sysStatsMsg struct {
}
type historyLoadedMsg struct {
tab viewTab
entries []*parser.LogEntry
agg *parser.AggregatedHistory
err error
}
type errMsg struct{ err error }
@ -116,27 +118,29 @@ type model struct {
lineCh chan string
doneCh chan struct{}
liveEntries []*parser.LogEntry
liveStats *parser.Stats
liveStart time.Time
activeTab viewTab
history [3]*historyState
history [2]*historyState
cpu float64
memUsed int64
memTotal int64
ticks int
width int
height int
err error
errorsScrollOffset int
}
func initialModel(client *sshclient.Client, logPath string) model {
m := model{
client: client,
logPath: logPath,
lineCh: make(chan string, 256),
lineCh: make(chan string, 8192),
doneCh: make(chan struct{}),
liveStats: parser.NewStats(),
liveStart: time.Now(),
@ -174,6 +178,8 @@ func tick() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
}
const sysStatsEvery = 5 // seconds; each fetch opens SSH sessions and sleeps 0.5s server-side
func fetchSysStats(client *sshclient.Client) tea.Cmd {
return func() tea.Msg {
cpu, used, total, err := client.SysStats()
@ -184,25 +190,39 @@ func fetchSysStats(client *sshclient.Client) tea.Cmd {
}
}
// drainLines grabs everything currently buffered in the channel as one batch,
// so a traffic burst costs one Update/render cycle instead of one per line.
func drainLines(ch chan string) tea.Cmd {
return func() tea.Msg {
var batch []string
for {
select {
case line := <-ch:
return newLineMsg(line)
batch = append(batch, line)
if len(batch) >= 10000 {
return newLinesMsg(batch)
}
default:
if len(batch) == 0 {
return nil
}
return newLinesMsg(batch)
}
}
}
}
func loadHistory(client *sshclient.Client, logPath string, tab viewTab) tea.Cmd {
since := time.Now().Add(-tabWindow(tab)).Unix()
window := tabWindow(tab)
since := time.Now().Add(-window).Unix()
maxAgeDays := int(window.Hours()/24) + 1
return func() tea.Msg {
lines, err := client.LoadHistoricalLines(logPath, since)
agg, err := client.LoadAggregatedHistory(logPath, since, maxAgeDays)
if err != nil {
return historyLoadedMsg{tab: tab, err: err}
}
return historyLoadedMsg{tab: tab, entries: parser.ParseLines(lines)}
agg.FilterSince(since)
return historyLoadedMsg{tab: tab, agg: agg}
}
}
@ -215,6 +235,18 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width
m.height = msg.Height
case tea.MouseMsg:
if m.activeTab == tabErrors {
switch msg.Button {
case tea.MouseButtonWheelUp:
if m.errorsScrollOffset > 0 {
m.errorsScrollOffset--
}
case tea.MouseButtonWheelDown:
m.errorsScrollOffset++
}
}
case tea.KeyMsg:
switch msg.String() {
case "q", "ctrl+c":
@ -222,26 +254,42 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, tea.Quit
case "1":
m.activeTab = tabLive
m.errorsScrollOffset = 0
case "2":
m.activeTab = tabDay
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabDay)
case "3":
m.activeTab = tabWeek
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabWeek)
case "4":
m.activeTab = tabMonth
return m, m.ensureHistory(tabMonth)
m.activeTab = tabErrors
m.errorsScrollOffset = 0
return m, m.ensureHistory(tabDay)
}
case tickMsg:
return m, tea.Batch(tick(), drainLines(m.lineCh), fetchSysStats(m.client))
m.ticks++
cmds := []tea.Cmd{tick(), drainLines(m.lineCh)}
if m.ticks%sysStatsEvery == 0 {
cmds = append(cmds, fetchSysStats(m.client))
}
if m.activeTab == tabErrors {
if cmd := m.maybeRefreshHistory(tabDay); cmd != nil {
cmds = append(cmds, cmd)
}
} else if m.activeTab != tabLive {
if cmd := m.maybeRefreshHistory(m.activeTab); cmd != nil {
cmds = append(cmds, cmd)
}
}
return m, tea.Batch(cmds...)
case newLineMsg:
if e := parser.ParseLine(string(msg)); e != nil {
case newLinesMsg:
for _, line := range msg {
if e := parser.ParseLine(line); e != nil {
m.liveStats.Add(e)
m.liveEntries = append(m.liveEntries, e)
if len(m.liveEntries) > 10000 {
m.liveEntries = m.liveEntries[len(m.liveEntries)-10000:]
}
}
return m, drainLines(m.lineCh)
@ -254,11 +302,13 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
h := m.history[idx]
h.loading = false
h.loaded = true
h.loadedAt = time.Now()
h.err = msg.err
if msg.err == nil {
h.entries = msg.entries
h.stats = parser.ComputeStats(msg.entries)
h.buckets = parser.Bucketize(msg.entries, tabBucketSize(msg.tab))
if msg.err == nil && msg.agg != nil {
h.stats = msg.agg.ToStats()
h.buckets = msg.agg.ToBuckets(tabBucketSize(msg.tab))
h.errors = msg.agg.RecentErrors(100)
m.errorsScrollOffset = 0
}
case errMsg:
@ -270,10 +320,30 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *model) ensureHistory(tab viewTab) tea.Cmd {
h := m.history[int(tab)-1]
if h.loaded || h.loading {
if h.loading {
return nil
}
if !h.loaded {
h.loading = true // first load: show spinner
return loadHistory(m.client, m.logPath, tab)
}
if time.Since(h.loadedAt) > historyRefreshInterval {
// silent refresh: keep showing old data while new data loads
return loadHistory(m.client, m.logPath, tab)
}
return nil
}
// maybeRefreshHistory triggers a silent background reload for the active history
// tab if its data is older than historyRefreshInterval.
func (m *model) maybeRefreshHistory(tab viewTab) tea.Cmd {
h := m.history[int(tab)-1]
if h.loading || !h.loaded {
return nil
}
if time.Since(h.loadedAt) < historyRefreshInterval {
return nil
}
h.loading = true
return loadHistory(m.client, m.logPath, tab)
}
@ -281,7 +351,7 @@ func (m *model) ensureHistory(tab viewTab) tea.Cmd {
func (m model) View() string {
if m.err != nil {
return styleBad.Render(fmt.Sprintf("Error: %v\n\nPress q to quit.", m.err))
return styleBad.Render(fmt.Sprintf("ERROR: %v\n\nPress q to quit.", m.err))
}
parts := []string{
m.renderHeader(),
@ -291,13 +361,13 @@ func (m model) View() string {
case tabLive:
parts = append(parts, m.renderLive())
case tabDay:
parts = append(parts, m.renderHistory(0, "last 24 hours"))
parts = append(parts, m.renderHistory(0, "LAST 24 HOURS"))
case tabWeek:
parts = append(parts, m.renderHistory(1, "last 7 days"))
case tabMonth:
parts = append(parts, m.renderHistory(2, "last 30 days"))
parts = append(parts, m.renderHistory(1, "LAST 7 DAYS"))
case tabErrors:
parts = append(parts, m.renderErrors())
}
parts = append(parts, styleDim.Render(" 1 live 2 day 3 week 4 month q quit"))
parts = append(parts, styleDim.Render(" [1] LIVE [2] 1 DAY [3] 1 WEEK [4] ERRORS [Q] QUIT"))
return lipgloss.JoinVertical(lipgloss.Left, parts...)
}
@ -319,21 +389,21 @@ func (m model) renderHeader() string {
memColor = styleWarn
}
row := strings.Join([]string{
styleTitle.Render("caddyview"),
styleTitle.Render("░▒▓ CADDYVIEW ▓▒░"),
" ",
styleHeader.Render("host: ") + m.client.Host(),
styleHeader.Render("HOST: ") + m.client.Host(),
" ",
styleHeader.Render("log: ") + styleDim.Render(m.logPath),
styleHeader.Render("LOG: ") + styleDim.Render(m.logPath),
" ",
styleHeader.Render("up: ") + time.Since(m.liveStart).Round(time.Second).String(),
styleHeader.Render("UP: ") + time.Since(m.liveStart).Round(time.Second).String(),
" ",
styleHeader.Render("CPU: ") + cpuColor.Render(fmt.Sprintf("%.1f%%", m.cpu)),
" ",
styleHeader.Render("MEM: ") + memColor.Render(fmt.Sprintf("%d/%d MB (%.0f%%)", m.memUsed, m.memTotal, memPct)),
}, "")
return lipgloss.NewStyle().
BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("62")).Width(m.width).
BorderBottom(true).BorderStyle(lipgloss.DoubleBorder()).
BorderForeground(colDark).Width(m.width).
Render(row)
}
@ -348,7 +418,7 @@ func (m model) renderTabBar() string {
}
return lipgloss.NewStyle().
BorderBottom(true).BorderStyle(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("62")).Width(m.width).
BorderForeground(colDark).Width(m.width).
Render(strings.Join(tabs, " "))
}
@ -361,7 +431,7 @@ func (m model) renderLive() string {
rps = float64(m.liveStats.TotalRequests) / elapsed
}
statsBox := styleBorder.Render(strings.Join([]string{
styleHeader.Render("Live Stats"),
styleHeader.Render("LIVE STATS"),
"",
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(m.liveStats.TotalRequests))),
fmt.Sprintf("Req/sec : %s", styleGood.Render(fmt.Sprintf("%.2f", rps))),
@ -383,22 +453,26 @@ func (m model) renderHistory(idx int, label string) string {
h := m.history[idx]
if h.loading {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleWarn.Render("⏳ Loading historical data…"))
Render(styleWarn.Render("▒▒ LOADING HISTORY ▒▒"))
}
if !h.loaded {
return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded."))
}
if h.err != nil {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleBad.Render(fmt.Sprintf("Error: %v", h.err)))
Render(styleBad.Render(fmt.Sprintf("ERROR: %v", h.err)))
}
if len(h.entries) == 0 {
if h.stats == nil || h.stats.TotalRequests == 0 {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleDim.Render("No log entries found for this period."))
}
// Append "updated X ago" to label so the chart title reflects freshness
age := time.Since(h.loadedAt).Round(time.Second)
labelWithAge := fmt.Sprintf("%s — updated %s ago", label, age)
tab := viewTab(idx + 1)
chart := m.renderBarChart(h.buckets, tabBucketSize(tab), label, h.stats)
chart := m.renderBarChart(h.buckets, tabBucketSize(tab), labelWithAge, h.stats)
bottom := lipgloss.JoinHorizontal(lipgloss.Top,
m.renderPeriodStats(h.stats),
m.renderTopHosts(h.stats),
@ -408,6 +482,98 @@ func (m model) renderHistory(idx int, label string) string {
return lipgloss.JoinVertical(lipgloss.Left, chart, bottom)
}
// -- errors view --------------------------------------------------------------
func (m model) renderErrors() string {
h := m.history[0] // errors are loaded together with the 1 DAY history
if h.loading {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleWarn.Render("▒▒ LOADING ERRORS ▒▒"))
}
if !h.loaded {
return lipgloss.NewStyle().Padding(2, 4).Render(styleDim.Render("No data loaded."))
}
if h.err != nil {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleBad.Render(fmt.Sprintf("ERROR: %v", h.err)))
}
errs := h.errors
if len(errs) == 0 {
return lipgloss.NewStyle().Padding(2, 4).
Render(styleDim.Render("No 4xx/5xx requests found in the last 24 hours."))
}
age := time.Since(h.loadedAt).Round(time.Second)
title := fmt.Sprintf("RECENT 4xx/5xx ERRORS — %d entries — updated %s ago", len(errs), age)
// Column widths (responsive, but fixed for simplicity)
timeW := 19
statusW := 7
methodW := 7
hostW := 28
ipW := 16
uriW := m.width - timeW - statusW - methodW - hostW - ipW - 14
if uriW < 20 {
uriW = 20
}
header := fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %s",
timeW, "TIME",
statusW, "STATUS",
methodW, "METHOD",
hostW, "HOST",
ipW, "REMOTE IP",
"URI",
)
// Fixed-height overhead: header + tab bar + title/header rows + footer/borders.
const overhead = 10
maxRows := m.height - overhead
if maxRows < 3 {
maxRows = 3
}
// Clamp scroll offset.
if m.errorsScrollOffset > len(errs)-maxRows {
if len(errs) > maxRows {
m.errorsScrollOffset = len(errs) - maxRows
} else {
m.errorsScrollOffset = 0
}
}
if m.errorsScrollOffset < 0 {
m.errorsScrollOffset = 0
}
end := m.errorsScrollOffset + maxRows
if end > len(errs) {
end = len(errs)
}
visible := errs[m.errorsScrollOffset:end]
rows := []string{styleHeader.Render(title), "", styleHeader.Render(header)}
for _, e := range visible {
statusStr := fmt.Sprintf("%d", e.Status)
var statusStyled string
if e.Status >= 500 {
statusStyled = styleBad.Render(statusStr)
} else {
statusStyled = styleWarn.Render(statusStr)
}
host := truncate(e.Host, hostW-1)
uri := truncate(e.URI, uriW-1)
rows = append(rows, fmt.Sprintf("%-*s %-*s %-*s %-*s %-*s %s",
timeW, e.Time.Format("2006-01-02 15:04:05"),
statusW, statusStyled,
methodW, e.Method,
hostW, host,
ipW, e.RemoteIP,
uri,
))
}
return styleBorder.Width(m.width - 4).Render(strings.Join(rows, "\n"))
}
// -- bar chart ----------------------------------------------------------------
func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration, label string, stats *parser.Stats) string {
@ -442,7 +608,7 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration,
barMax = 10
}
title := fmt.Sprintf("Requests / %s — %s — total: %s req, %s",
title := fmt.Sprintf("REQUESTS / %s — %s — TOTAL: %s req, %s",
bucketSizeLabel(bucketSize), label,
formatComma(stats.TotalRequests), formatBytes(stats.TotalBytes),
)
@ -453,10 +619,11 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration,
if maxReqs > 0 {
barLen = int(math.Round(float64(b.Requests) / float64(maxReqs) * float64(barMax)))
}
bar := strings.Repeat("█", barLen)
bar := styleBar.Render(strings.Repeat("▓", barLen)) +
styleBarEmpty.Render(strings.Repeat("░", barMax-barLen))
rows = append(rows, fmt.Sprintf("%-*s %s %s",
labelW, b.Time.Format(timeFmt),
styleBar.Render(fmt.Sprintf("%-*s", barMax, bar)),
bar,
styleDim.Render(formatComma(b.Requests)),
))
}
@ -468,7 +635,7 @@ func (m model) renderBarChart(buckets []parser.Bucket, bucketSize time.Duration,
func (m model) renderPeriodStats(stats *parser.Stats) string {
return styleBorder.Render(strings.Join([]string{
styleHeader.Render("Period Stats"),
styleHeader.Render("PERIOD STATS"),
"",
fmt.Sprintf("Requests : %s", styleGood.Render(formatComma(stats.TotalRequests))),
fmt.Sprintf("Transfer : %s", formatBytes(stats.TotalBytes)),
@ -477,7 +644,7 @@ func (m model) renderPeriodStats(stats *parser.Stats) string {
}
func (m model) renderTopHosts(stats *parser.Stats) string {
rows := []string{styleHeader.Render("Top Hosts"), ""}
rows := []string{styleHeader.Render("TOP HOSTS"), ""}
for _, p := range topN(stats.TopHosts, 8) {
host := p.key
if len(host) > 30 {
@ -492,7 +659,7 @@ func (m model) renderTopHosts(stats *parser.Stats) string {
}
func (m model) renderTopIPs(stats *parser.Stats) string {
rows := []string{styleHeader.Render("Top IPs"), ""}
rows := []string{styleHeader.Render("TOP IPS"), ""}
for _, p := range topN(stats.TopIPs, 8) {
rows = append(rows, fmt.Sprintf("%-20s %s", p.key, styleGood.Render(formatComma(p.val))))
}
@ -516,7 +683,7 @@ func (m model) renderStatusCodes(stats *parser.Stats) string {
groups["5xx"] += n
}
}
rows := []string{styleHeader.Render("Status Codes"), ""}
rows := []string{styleHeader.Render("STATUS CODES"), ""}
for _, g := range []string{"2xx", "3xx", "4xx", "5xx"} {
s := formatComma(groups[g])
var colored string
@ -567,33 +734,50 @@ type kv struct {
val int64
}
// topN returns the n highest entries. Ties are broken alphabetically so rows
// with equal counts keep a stable order between renders instead of flickering.
func topN(m map[string]int64, n int) []kv {
pairs := make([]kv, 0, len(m))
for k, v := range m {
pairs = append(pairs, kv{k, v})
}
sort.Slice(pairs, func(i, j int) bool { return pairs[i].val > pairs[j].val })
sort.Slice(pairs, func(i, j int) bool {
if pairs[i].val != pairs[j].val {
return pairs[i].val > pairs[j].val
}
return pairs[i].key < pairs[j].key
})
if len(pairs) > n {
return pairs[:n]
}
return pairs
}
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
if len(s) <= max {
return s
}
return s[:max-1] + "…"
}
func bucketSizeLabel(d time.Duration) string {
switch {
case d >= 24*time.Hour:
return "day"
return "DAY"
case d >= time.Hour:
return fmt.Sprintf("%dh", int(d.Hours()))
return fmt.Sprintf("%dH", int(d.Hours()))
default:
return fmt.Sprintf("%dm", int(d.Minutes()))
return fmt.Sprintf("%dM", int(d.Minutes()))
}
}
// -- entry point --------------------------------------------------------------
func Run(client *sshclient.Client, logPath string) error {
p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen())
p := tea.NewProgram(initialModel(client, logPath), tea.WithAltScreen(), tea.WithMouseCellMotion())
_, err := p.Run()
return err
}

View file

@ -1,6 +1,6 @@
package main
import "github.com/janvete/caddyview/cmd"
import "git.janvete.com/seth/caddyview/cmd"
func main() {
cmd.Execute()