This commit is contained in:
+7
-1
@@ -1,4 +1,10 @@
|
||||
# Optional local overrides for hp-viz
|
||||
# Optional local overrides for hp-viz (copy to .env or ~/.config/hp-viz/.env)
|
||||
HP_VIZ_URL=https://viz.honeypeer.com
|
||||
# HP_VIZ_BUFFER=1000
|
||||
# HP_VIZ_MOUSE=0
|
||||
# HP_VIZ_BELL=0
|
||||
# HP_VIZ_NO_UPDATE=0
|
||||
# HP_VIZ_REDUCED_MOTION=0
|
||||
# HP_VIZ_OPERATOR_TOKEN=
|
||||
# HP_VIZ_SUBSCRIPTION_ID=
|
||||
# HP_VIZ_SUBSCRIPTION_EMAIL=
|
||||
|
||||
@@ -18,8 +18,29 @@ jobs:
|
||||
run: bash scripts/ci-setup-go.sh
|
||||
env:
|
||||
GO_VERSION: ${{ env.GO_VERSION }}
|
||||
- run: go test ./...
|
||||
- run: go build -o bin/hp-viz ./cmd/hp-viz/
|
||||
- name: Format check
|
||||
run: |
|
||||
unformatted=$(gofmt -l $(find . -name '*.go' -not -path './.git/*'))
|
||||
if [ -n "$unformatted" ]; then
|
||||
echo "gofmt needed on:"
|
||||
echo "$unformatted"
|
||||
exit 1
|
||||
fi
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
- name: Test
|
||||
run: go test ./...
|
||||
- name: Build
|
||||
run: go build -trimpath -o bin/hp-viz ./cmd/hp-viz/
|
||||
- name: CLI smoke
|
||||
run: |
|
||||
./bin/hp-viz --help
|
||||
./bin/hp-viz --version
|
||||
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt --no-update --no-color &
|
||||
pid=$!
|
||||
sleep 1
|
||||
kill $pid 2>/dev/null || true
|
||||
wait $pid 2>/dev/null || true
|
||||
|
||||
release:
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
@@ -36,13 +57,14 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist
|
||||
BUILD_TIME=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
for spec in "linux amd64" "linux arm64" "darwin amd64" "darwin arm64"; do
|
||||
set -- $spec
|
||||
os=$1 arch=$2
|
||||
bin="hp-viz-${os}-${arch}"
|
||||
pkg="hp-viz-${os}-${arch}.tar.gz"
|
||||
GOOS=$os GOARCH=$arch CGO_ENABLED=0 go build \
|
||||
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA}" \
|
||||
-ldflags="-s -w -X github.com/honeypeer/cli-viz/internal/update.BuildCommit=${GITHUB_SHA} -X github.com/honeypeer/cli-viz/internal/update.BuildTime=${BUILD_TIME}" \
|
||||
-trimpath \
|
||||
-o "dist/${bin}" ./cmd/hp-viz/
|
||||
tar -czf "dist/${pkg}" -C dist "${bin}"
|
||||
|
||||
+26
@@ -1,3 +1,29 @@
|
||||
# Build outputs
|
||||
bin/
|
||||
dist/
|
||||
hp-viz
|
||||
hp-viz-*
|
||||
*.exe
|
||||
|
||||
# Local env / secrets
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
bin/.env*
|
||||
|
||||
# Recorded streams / scratch
|
||||
feed.jsonl
|
||||
*.jsonl
|
||||
!tests/fixtures/**
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*~
|
||||
.DS_Store
|
||||
|
||||
# Coverage / tooling
|
||||
coverage.out
|
||||
coverage.html
|
||||
*.test
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024–2026 HoneyPeer
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,19 +1,61 @@
|
||||
.PHONY: build test run install fixture install-script
|
||||
.PHONY: build test vet fmt check run install fixture install-script clean version release-build
|
||||
|
||||
MODULE := github.com/honeypeer/cli-viz
|
||||
VERSION ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo dev)
|
||||
COMMIT ?= $(shell git rev-parse HEAD 2>/dev/null || echo unknown)
|
||||
DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w \
|
||||
-X $(MODULE)/internal/update.BuildCommit=$(COMMIT) \
|
||||
-X $(MODULE)/internal/update.BuildTime=$(DATE)
|
||||
|
||||
build:
|
||||
go build -o bin/hp-viz ./cmd/hp-viz/
|
||||
go build -trimpath -ldflags "$(LDFLAGS)" -o bin/hp-viz ./cmd/hp-viz/
|
||||
|
||||
# Release-style multi-arch archives (mirrors CI).
|
||||
release-build:
|
||||
@mkdir -p dist
|
||||
@for spec in "linux amd64" "linux arm64" "darwin amd64" "darwin arm64"; do \
|
||||
set -- $$spec; os=$$1 arch=$$2; \
|
||||
bin="hp-viz-$${os}-$${arch}"; \
|
||||
GOOS=$$os GOARCH=$$arch CGO_ENABLED=0 go build -trimpath -ldflags "$(LDFLAGS)" -o "dist/$${bin}" ./cmd/hp-viz/; \
|
||||
tar -czf "dist/$${bin}.tar.gz" -C dist "$${bin}"; \
|
||||
sha256sum "dist/$${bin}.tar.gz" | awk '{print $$1}' > "dist/$${bin}.tar.gz.sha256"; \
|
||||
echo "built dist/$${bin}.tar.gz"; \
|
||||
done
|
||||
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w $$(find . -name '*.go' -not -path './.git/*')
|
||||
|
||||
# Fail if sources are not gofmt-clean (CI).
|
||||
fmt-check:
|
||||
@unformatted=$$(gofmt -l $$(find . -name '*.go' -not -path './.git/*')); \
|
||||
if [ -n "$$unformatted" ]; then \
|
||||
echo "gofmt needed on:"; echo "$$unformatted"; exit 1; \
|
||||
fi
|
||||
|
||||
check: fmt-check vet test
|
||||
go build -o /tmp/hp-viz-check ./cmd/hp-viz/
|
||||
|
||||
version: build
|
||||
./bin/hp-viz --version
|
||||
|
||||
install: build
|
||||
cp bin/hp-viz $(DESTDIR)/usr/local/bin/
|
||||
install -m 755 bin/hp-viz $(DESTDIR)/usr/local/bin/hp-viz
|
||||
|
||||
install-script:
|
||||
bash scripts/install.sh
|
||||
|
||||
fixture:
|
||||
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt
|
||||
fixture: build
|
||||
./bin/hp-viz --fixture tests/fixtures/sse-sample.txt --no-update
|
||||
|
||||
run:
|
||||
./bin/hp-viz --url $(URL)
|
||||
run: build
|
||||
./bin/hp-viz --url $(or $(URL),https://viz.honeypeer.com) --no-update
|
||||
|
||||
clean:
|
||||
rm -rf bin/ dist/ /tmp/hp-viz-check
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
# hp-viz
|
||||
|
||||
**hp-viz** is a terminal app for watching the [HoneyPeer](https://honeypeer.com) public attack visualizer in real time — live feed, incidents, peers, stats, and threat-intelligence blog posts, without opening a browser.
|
||||
**hp-viz** is a production-ready terminal app for watching the [HoneyPeer](https://honeypeer.com) public attack visualizer in real time — live feed, incidents, peers, stats, history, and threat-intelligence blog posts, without opening a browser.
|
||||
|
||||
Default data source: [viz.honeypeer.com](https://viz.honeypeer.com)
|
||||
|
||||

|
||||

|
||||
|
||||
## Install (recommended)
|
||||
|
||||
One command — auto-detects Linux/macOS and amd64/arm64:
|
||||
@@ -35,12 +38,13 @@ Archives: `hp-viz-linux-amd64`, `hp-viz-linux-arm64`, `hp-viz-darwin-amd64`, `hp
|
||||
|
||||
## Build from source
|
||||
|
||||
Requires Go 1.22+ and a truecolor terminal (`COLORTERM=truecolor` recommended).
|
||||
Requires Go 1.24+ and a truecolor terminal (`COLORTERM=truecolor` recommended).
|
||||
|
||||
```bash
|
||||
git clone https://git.ssh.surf/snxraven/honeypeer-viz-cli.git
|
||||
cd honeypeer-viz-cli
|
||||
go build -o hp-viz ./cmd/hp-viz/
|
||||
make build
|
||||
./bin/hp-viz --version
|
||||
```
|
||||
|
||||
## Quick start
|
||||
@@ -51,21 +55,30 @@ hp-viz
|
||||
|
||||
# Offline demo (bundled fixture)
|
||||
hp-viz --fixture tests/fixtures/sse-sample.txt
|
||||
|
||||
# Help / version
|
||||
hp-viz --help
|
||||
hp-viz --version
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Precedence: **flags → environment → config file → defaults**.
|
||||
|
||||
Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `HP_VIZ_URL` | Master base URL (default: `https://viz.honeypeer.com`) |
|
||||
| `HP_VIZ_BUFFER` | In-memory feed size (default `1000`) |
|
||||
| `HP_VIZ_BUFFER` | In-memory feed size (default `1000`, clamped 50–5000) |
|
||||
| `HP_VIZ_MOUSE` | Set `1` or `true` to enable mouse on startup |
|
||||
| `HP_VIZ_BELL` | Terminal bell on block-tier events |
|
||||
| `HP_VIZ_REDUCED_MOTION` | Static mesh and no synthesized sounds |
|
||||
| `HP_VIZ_OPERATOR_TOKEN` | Saved subscription operator token |
|
||||
| `HP_VIZ_SUBSCRIPTION_ID` | Subscription ID (`sub_…`) for startup sign-in |
|
||||
| `HP_VIZ_SUBSCRIPTION_EMAIL` | Billing email paired with subscription ID |
|
||||
|
||||
Optional file: `~/.config/hp-viz/config.yaml` (see `config.yaml.example`).
|
||||
Precedence: **flags → environment → config file → defaults**.
|
||||
| `HP_VIZ_NO_UPDATE` | Skip automatic update checks |
|
||||
| `NO_COLOR` / `HP_VIZ_NO_COLOR` | Disable colors |
|
||||
|
||||
| Flag | Description |
|
||||
|------|-------------|
|
||||
@@ -77,13 +90,18 @@ Precedence: **flags → environment → config file → defaults**.
|
||||
| `--bell` | Terminal bell on high-severity events |
|
||||
| `--subscription` | Subscription ID for operator sign-in at startup |
|
||||
| `--email` | Billing email for operator sign-in at startup |
|
||||
| `--no-update` | Skip automatic update checks |
|
||||
| `--version` | Print version and exit |
|
||||
| `--update` | Check and apply rolling update, then exit |
|
||||
| `--help` | Show help |
|
||||
|
||||
## Keyboard
|
||||
|
||||
| Key | Action |
|
||||
|-----|--------|
|
||||
| `j` / `k`, `↑` / `↓` | Move selection |
|
||||
| `Tab` | Feed → Incidents → Peers → Stats → **History** → Blog |
|
||||
| `Tab` / `Shift+Tab` | Next / previous view |
|
||||
| `1`–`6` | Jump to Feed · Incidents · Peers · Stats · History · Blog |
|
||||
| `Enter` | Open detail (or read blog post on Blog tab) |
|
||||
| `Esc` | Close detail / back from blog post / cancel filter or search |
|
||||
| `m` | Toggle mouse (off by default) |
|
||||
@@ -93,9 +111,11 @@ Precedence: **flags → environment → config file → defaults**.
|
||||
| `f` | Toggle follow mode (auto-scroll to newest) |
|
||||
| `s` | Search by attack or moderation ID (opens detail on match) |
|
||||
| `o` | Operator sign-in — subscription ID + billing email unlocks real IPs |
|
||||
| `/` | Filter — text or `service:ssh geo:cn peer:id since:1h` |
|
||||
| `PgUp` | Page up in list / load more blog posts |
|
||||
| `PgDn` | Page down in list |
|
||||
| `/` | Filter — text or `service:ssh geo:cn peer:id tier:block since:1h` |
|
||||
| `v` | Toggle peer mesh under the Feed tab |
|
||||
| `e` | Toggle event sounds |
|
||||
| `-` / `=` | Volume down / up (while sound is on) |
|
||||
| `PgUp` / `PgDn` | Page list / load more blog or history |
|
||||
| `]` / `[` | History sub-tab (attacks/reputation) / time range |
|
||||
| `r` | Reconnect stream |
|
||||
| `?` | Help overlay |
|
||||
@@ -114,33 +134,34 @@ HP_VIZ_NO_UPDATE=1 hp-viz # same via environment
|
||||
|
||||
Source builds (`go build` without release ldflags) are not auto-updated. Install via the curl installer to get a self-updating binary.
|
||||
|
||||
In the detail view, `j`/`k` or the mouse wheel scroll long content.
|
||||
|
||||
## What you see
|
||||
|
||||
- **Feed** — live attacks, blocks, moderation events (masked IPs on the public feed; **operator mode** shows real IPs)
|
||||
- **Incidents** — grouped activity with drill-down detail
|
||||
- **Peers** — animated hub-and-spoke mesh (like the web viz), live roster, connect events
|
||||
- **Stats** — aggregates and breakdowns
|
||||
- **History** — paginated attack and reputation database history (like the web viz sidebar), with time-range filters
|
||||
- **History** — paginated attack and reputation database history, with time-range filters
|
||||
- **Blog** — published HoneyPeer threat briefings (markdown, tables, charts)
|
||||
|
||||
Timestamps use your **local timezone**.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
make check # gofmt, vet, test, build
|
||||
make test
|
||||
make build
|
||||
make fixture # offline TUI demo
|
||||
```
|
||||
|
||||
## Releases (maintainers)
|
||||
|
||||
Every push to `main` runs tests, then rebuilds and **replaces** the rolling [`latest`](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest) release (binaries + `install.sh`). Requires the `RELEASE_TOKEN` Actions secret.
|
||||
Every push to `main` runs format/vet/tests, then rebuilds and **replaces** the rolling [`latest`](https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/tag/latest) release (binaries + `install.sh`). Requires the `RELEASE_TOKEN` Actions secret.
|
||||
|
||||
Point **https://viz-cli.honeypeer.com** at:
|
||||
|
||||
`https://git.ssh.surf/snxraven/honeypeer-viz-cli/releases/download/latest/install.sh`
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
go test ./...
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
See repository license file.
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
+23
-9
@@ -18,8 +18,12 @@ import (
|
||||
func main() {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
fmt.Fprintf(os.Stderr, "hp-viz: %v\n", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
if cfg.Help {
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
if cfg.Version {
|
||||
@@ -38,14 +42,18 @@ func main() {
|
||||
}
|
||||
|
||||
if cfg.NoColor {
|
||||
os.Setenv("NO_COLOR", "1")
|
||||
_ = os.Setenv("NO_COLOR", "1")
|
||||
lipgloss.SetColorProfile(termenv.Ascii)
|
||||
}
|
||||
|
||||
m := ui.NewModel(cfg)
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
p := tea.NewProgram(
|
||||
m,
|
||||
tea.WithAltScreen(),
|
||||
tea.WithReportFocus(),
|
||||
)
|
||||
if _, err := p.Run(); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
fmt.Fprintf(os.Stderr, "hp-viz: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -64,7 +72,7 @@ func runAutoUpdate() string {
|
||||
return fmt.Sprintf("hp-viz update check failed: %v", err)
|
||||
}
|
||||
if res.Updated {
|
||||
return fmt.Sprintf("hp-viz updated to %s", res.CommitShort)
|
||||
return fmt.Sprintf("hp-viz updated to %s — restart recommended", res.CommitShort)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -78,7 +86,7 @@ func exitUpdate(force bool) {
|
||||
|
||||
res, err := update.MaybeCheck(opts)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update failed: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "hp-viz: update failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
switch {
|
||||
@@ -87,9 +95,15 @@ func exitUpdate(force bool) {
|
||||
case res.Skipped == "already current":
|
||||
fmt.Printf("Already up to date (%s)\n", res.CommitShort)
|
||||
case res.Skipped == "source build":
|
||||
fmt.Fprintln(os.Stderr, "This is a source build (no embedded commit). Install from the latest release to enable updates.")
|
||||
fmt.Fprintln(os.Stderr, "hp-viz: this is a source build (no embedded commit).")
|
||||
fmt.Fprintln(os.Stderr, "Install from the latest release to enable updates:")
|
||||
fmt.Fprintln(os.Stderr, " curl -fsSL https://viz-cli.honeypeer.com | bash")
|
||||
os.Exit(1)
|
||||
default:
|
||||
fmt.Println(res.Skipped)
|
||||
if res.Skipped != "" {
|
||||
fmt.Println(res.Skipped)
|
||||
} else {
|
||||
fmt.Println("No update available")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -1,13 +1,19 @@
|
||||
# hp-viz configuration (~/.config/hp-viz/config.yaml)
|
||||
# Flags and environment variables override these values.
|
||||
# Precedence: flags → environment → this file → defaults.
|
||||
|
||||
url: https://viz.honeypeer.com
|
||||
buffer: 1000
|
||||
bell: false
|
||||
mouse: false
|
||||
# no_update: false
|
||||
|
||||
# Operator mode (optional — use `o` in the TUI to sign in interactively)
|
||||
# operator_token: "…" # saved automatically after subscription sign-in
|
||||
# subscription_id: sub_…
|
||||
# subscription_email: [email protected]
|
||||
# secret: … # raw HP_VIZ_OPERATOR_SECRET (admin override)
|
||||
|
||||
# Optional environment-only knobs (set in shell or .env):
|
||||
# HP_VIZ_REDUCED_MOTION=1 static mesh, disable sounds
|
||||
# HP_VIZ_UPDATE_INTERVAL_HOURS=6
|
||||
# HP_VIZ_RELEASE_BASE=…
|
||||
|
||||
+136
-20
@@ -1,8 +1,12 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
@@ -10,6 +14,12 @@ import (
|
||||
// DefaultPublicURL is the HoneyPeer public viz API (live attack feed).
|
||||
const DefaultPublicURL = "https://viz.honeypeer.com"
|
||||
|
||||
const (
|
||||
minBuffer = 50
|
||||
maxBuffer = 5000
|
||||
)
|
||||
|
||||
// Config holds runtime options resolved from flags, environment, and config files.
|
||||
type Config struct {
|
||||
URL string
|
||||
Secret string
|
||||
@@ -25,34 +35,89 @@ type Config struct {
|
||||
NoUpdate bool
|
||||
Version bool
|
||||
Update bool
|
||||
Help bool
|
||||
}
|
||||
|
||||
// usageText is printed for -h / --help.
|
||||
const usageText = `hp-viz — HoneyPeer terminal attack visualizer
|
||||
|
||||
Watch the public HoneyPeer attack feed, incidents, peers, stats, history, and
|
||||
threat-intelligence blog posts in your terminal.
|
||||
|
||||
Usage:
|
||||
hp-viz [flags]
|
||||
|
||||
Examples:
|
||||
hp-viz
|
||||
hp-viz --fixture tests/fixtures/sse-sample.txt
|
||||
hp-viz --url https://viz.honeypeer.com --bell
|
||||
hp-viz --subscription sub_… --email [email protected]
|
||||
hp-viz --version
|
||||
hp-viz --update
|
||||
|
||||
Configuration:
|
||||
Flags override environment variables, which override ~/.config/hp-viz/config.yaml.
|
||||
See config.yaml.example and README for the full list.
|
||||
|
||||
In-app keys:
|
||||
? help · Tab views · j/k navigate · Enter detail · / filter · s search
|
||||
o operator · m mouse · e sound · r reconnect · q quit
|
||||
|
||||
Flags:
|
||||
`
|
||||
|
||||
// Load resolves configuration from flags (os.Args), environment, and config files.
|
||||
func Load() (Config, error) {
|
||||
return LoadArgs(os.Args[1:])
|
||||
}
|
||||
|
||||
// LoadArgs is like Load but parses the given argument slice (useful for tests).
|
||||
func LoadArgs(args []string) (Config, error) {
|
||||
LoadDotEnv()
|
||||
loadYAMLConfig()
|
||||
|
||||
url := flag.String("url", envOr("HP_VIZ_URL", DefaultPublicURL), "HoneyPeer public viz base URL")
|
||||
secret := flag.String("secret", envOr("HP_VIZ_SECRET", envOr("HP_VIZ_OPERATOR_SECRET", "")), "Operator secret for real IPs + full detail")
|
||||
subscription := flag.String("subscription", envOr("HP_VIZ_SUBSCRIPTION_ID", ""), "Subscription ID (sub_…) to claim operator access")
|
||||
email := flag.String("email", envOr("HP_VIZ_SUBSCRIPTION_EMAIL", ""), "Billing email for subscription validation")
|
||||
buffer := flag.Int("buffer", envIntOr("HP_VIZ_BUFFER", 1000), "Feed ring buffer size")
|
||||
fixture := flag.String("fixture", "", "Replay SSE from fixture file (offline dev)")
|
||||
record := flag.String("record", "", "Append SSE events to JSONL file")
|
||||
noColor := flag.Bool("no-color", false, "Disable truecolor output")
|
||||
bell := flag.Bool("bell", false, "Terminal bell on block-tier events")
|
||||
noUpdate := flag.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
|
||||
showVersion := flag.Bool("version", false, "Print version and exit")
|
||||
forceUpdate := flag.Bool("update", false, "Check for updates, apply if available, and exit")
|
||||
flag.Parse()
|
||||
fs := flag.NewFlagSet("hp-viz", flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
fs.Usage = func() {
|
||||
fmt.Fprint(os.Stderr, usageText)
|
||||
fs.PrintDefaults()
|
||||
fmt.Fprintln(os.Stderr)
|
||||
}
|
||||
|
||||
urlFlag := fs.String("url", envOr("HP_VIZ_URL", DefaultPublicURL), "HoneyPeer public viz base URL")
|
||||
secret := fs.String("secret", envOr("HP_VIZ_SECRET", envOr("HP_VIZ_OPERATOR_SECRET", "")), "Operator secret for real IPs + full detail")
|
||||
subscription := fs.String("subscription", envOr("HP_VIZ_SUBSCRIPTION_ID", ""), "Subscription ID (sub_…) to claim operator access")
|
||||
email := fs.String("email", envOr("HP_VIZ_SUBSCRIPTION_EMAIL", ""), "Billing email for subscription validation")
|
||||
buffer := fs.Int("buffer", envIntOr("HP_VIZ_BUFFER", 1000), "Feed ring buffer size (50–5000)")
|
||||
fixture := fs.String("fixture", "", "Replay SSE from fixture file (offline / demo)")
|
||||
record := fs.String("record", "", "Append live SSE events to a JSONL file")
|
||||
noColor := fs.Bool("no-color", envPresentOrBool("NO_COLOR") || envBoolOr("HP_VIZ_NO_COLOR", false), "Disable truecolor output")
|
||||
bell := fs.Bool("bell", envBoolOr("HP_VIZ_BELL", false), "Terminal bell on block-tier events")
|
||||
noUpdate := fs.Bool("no-update", envBoolOr("HP_VIZ_NO_UPDATE", false), "Disable automatic update checks")
|
||||
showVersion := fs.Bool("version", false, "Print version and exit")
|
||||
forceUpdate := fs.Bool("update", false, "Check for updates, apply if available, and exit")
|
||||
showHelp := fs.Bool("help", false, "Show this help and exit")
|
||||
|
||||
if err := fs.Parse(args); err != nil {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
return Config{Help: true}, nil
|
||||
}
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if *showHelp {
|
||||
fs.Usage()
|
||||
return Config{Help: true}, nil
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
URL: trimSlash(*url),
|
||||
URL: trimSlash(*urlFlag),
|
||||
Secret: strings.TrimSpace(*secret),
|
||||
SubscriptionID: strings.TrimSpace(*subscription),
|
||||
Email: strings.TrimSpace(strings.ToLower(*email)),
|
||||
Buffer: *buffer,
|
||||
Fixture: *fixture,
|
||||
Record: *record,
|
||||
Fixture: strings.TrimSpace(*fixture),
|
||||
Record: strings.TrimSpace(*record),
|
||||
NoColor: *noColor,
|
||||
Bell: *bell,
|
||||
NoUpdate: *noUpdate,
|
||||
@@ -74,15 +139,61 @@ func Load() (Config, error) {
|
||||
if cfg.Fixture != "" {
|
||||
cfg.URL = ""
|
||||
}
|
||||
if cfg.Buffer < 50 {
|
||||
cfg.Buffer = 50
|
||||
if cfg.Buffer < minBuffer {
|
||||
cfg.Buffer = minBuffer
|
||||
}
|
||||
if cfg.Buffer > 5000 {
|
||||
cfg.Buffer = 5000
|
||||
if cfg.Buffer > maxBuffer {
|
||||
cfg.Buffer = maxBuffer
|
||||
}
|
||||
|
||||
// Version / update / help don't need full validation.
|
||||
if cfg.Version || cfg.Update || cfg.Help {
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
if err := cfg.validate(); err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (cfg Config) validate() error {
|
||||
if cfg.Fixture != "" {
|
||||
if _, err := os.Stat(cfg.Fixture); err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("fixture file not found: %s", cfg.Fixture)
|
||||
}
|
||||
return fmt.Errorf("fixture file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if cfg.URL == "" {
|
||||
return fmt.Errorf("url is required (or pass --fixture for offline demo)")
|
||||
}
|
||||
u, err := url.Parse(cfg.URL)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return fmt.Errorf("invalid url %q (expected https://…)", cfg.URL)
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return fmt.Errorf("url scheme must be http or https, got %q", u.Scheme)
|
||||
}
|
||||
if cfg.Record != "" {
|
||||
dir := filepath.Dir(cfg.Record)
|
||||
if dir != "" && dir != "." {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("record path: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (cfg.SubscriptionID != "") != (cfg.Email != "") {
|
||||
// Allow partial credentials only when a secret/token is already present.
|
||||
if cfg.Secret == "" {
|
||||
return fmt.Errorf("--subscription and --email must be provided together")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func envOr(key, fallback string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return trimSlash(v)
|
||||
@@ -116,6 +227,11 @@ func envBoolOr(key string, fallback bool) bool {
|
||||
return fallback
|
||||
}
|
||||
|
||||
// envPresentOrBool implements the NO_COLOR convention: any non-empty value means true.
|
||||
func envPresentOrBool(key string) bool {
|
||||
return strings.TrimSpace(os.Getenv(key)) != ""
|
||||
}
|
||||
|
||||
func trimSlash(s string) string {
|
||||
for len(s) > 0 && s[len(s)-1] == '/' {
|
||||
s = s[:len(s)-1]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
package config_test
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/honeypeer/cli-viz/internal/config"
|
||||
@@ -15,9 +15,9 @@ func TestDefaultPublicURL(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestLoadDefaultURL(t *testing.T) {
|
||||
t.Setenv("HP_VIZ_URL", "")
|
||||
os.Unsetenv("HP_VIZ_URL")
|
||||
flag.CommandLine = flag.NewFlagSet("test", flag.ContinueOnError)
|
||||
cfg, err := config.Load()
|
||||
cfg, err := config.LoadArgs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -25,3 +25,73 @@ func TestLoadDefaultURL(t *testing.T) {
|
||||
t.Fatalf("expected %q, got %q", config.DefaultPublicURL, cfg.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadVersionSkipsValidation(t *testing.T) {
|
||||
cfg, err := config.LoadArgs([]string{"--version"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Version {
|
||||
t.Fatal("expected Version=true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadInvalidURL(t *testing.T) {
|
||||
_, err := config.LoadArgs([]string{"--url", "not-a-url"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid url")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadMissingFixture(t *testing.T) {
|
||||
_, err := config.LoadArgs([]string{"--fixture", "/no/such/fixture.txt"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing fixture")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadFixture(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "sample.txt")
|
||||
if err := os.WriteFile(path, []byte("event: stats\ndata: {}\n\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := config.LoadArgs([]string{"--fixture", path, "--no-update"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Fixture != path {
|
||||
t.Fatalf("fixture = %q", cfg.Fixture)
|
||||
}
|
||||
if cfg.URL != "" {
|
||||
t.Fatalf("expected empty URL for fixture mode, got %q", cfg.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBellFromEnv(t *testing.T) {
|
||||
t.Setenv("HP_VIZ_BELL", "true")
|
||||
cfg, err := config.LoadArgs(nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !cfg.Bell {
|
||||
t.Fatal("expected Bell from HP_VIZ_BELL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadSubscriptionRequiresEmail(t *testing.T) {
|
||||
_, err := config.LoadArgs([]string{"--subscription", "sub_abc"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error when email missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadBufferClamped(t *testing.T) {
|
||||
cfg, err := config.LoadArgs([]string{"--buffer", "5"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.Buffer < 50 {
|
||||
t.Fatalf("buffer should clamp to min, got %d", cfg.Buffer)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,13 @@ type Point struct {
|
||||
// PeerNode extends a viz peer with orbital layout fields.
|
||||
type PeerNode struct {
|
||||
viz.Peer
|
||||
Angle float64
|
||||
Orbit float64
|
||||
Phase float64
|
||||
Size float64
|
||||
Angle float64
|
||||
Orbit float64
|
||||
Phase float64
|
||||
Size float64
|
||||
AttackCount int
|
||||
Fade float64
|
||||
FadeTarget float64
|
||||
Fade float64
|
||||
FadeTarget float64
|
||||
}
|
||||
|
||||
func BuildPeerLayout(peers []viz.Peer) []PeerNode {
|
||||
|
||||
+12
-12
@@ -27,19 +27,19 @@ type HitNode struct {
|
||||
|
||||
// State drives the animated peer mesh.
|
||||
type State struct {
|
||||
mu sync.Mutex
|
||||
peers []PeerNode
|
||||
pulses []Pulse
|
||||
time float64
|
||||
selectedID string
|
||||
mu sync.Mutex
|
||||
peers []PeerNode
|
||||
pulses []Pulse
|
||||
time float64
|
||||
selectedID string
|
||||
reducedMotion bool
|
||||
coordinator Point
|
||||
scale float64
|
||||
hits []HitNode
|
||||
pulseSeed int
|
||||
renderCX float64
|
||||
renderCY float64
|
||||
renderScale float64
|
||||
coordinator Point
|
||||
scale float64
|
||||
hits []HitNode
|
||||
pulseSeed int
|
||||
renderCX float64
|
||||
renderCY float64
|
||||
renderScale float64
|
||||
}
|
||||
|
||||
func NewState() *State {
|
||||
|
||||
@@ -12,8 +12,8 @@ const (
|
||||
defaultVolume = 0.3
|
||||
maxAttackSoundsPerSec = 4
|
||||
// VolumeStep is the adjustment per -/+ key press.
|
||||
VolumeStep = 0.05
|
||||
volumeStep = VolumeStep
|
||||
VolumeStep = 0.05
|
||||
volumeStep = VolumeStep
|
||||
)
|
||||
|
||||
// Engine plays HoneyPeer viz event sounds (ported from web useVizSound.ts).
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// Playback routes synthesized PCM through the desktop audio stack (PulseAudio /
|
||||
// PipeWire / CoreAudio) instead of opening ALSA devices directly.
|
||||
type player struct {
|
||||
name string
|
||||
name string
|
||||
newCmd func(wav, raw []byte) *exec.Cmd
|
||||
}
|
||||
|
||||
|
||||
+94
-25
@@ -2,12 +2,14 @@ package transport
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
@@ -35,7 +37,10 @@ type SseClient struct {
|
||||
state chan ConnState
|
||||
errors chan error
|
||||
done chan struct{}
|
||||
stopOnce sync.Once
|
||||
retryMs time.Duration
|
||||
cancelHTTP context.CancelFunc
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func NewSseClient(baseURL, secret, fixture, recordPath string) *SseClient {
|
||||
@@ -45,23 +50,58 @@ func NewSseClient(baseURL, secret, fixture, recordPath string) *SseClient {
|
||||
fixture: fixture,
|
||||
recordPath: recordPath,
|
||||
events: make(chan Event, 256),
|
||||
state: make(chan ConnState, 4),
|
||||
errors: make(chan error, 4),
|
||||
state: make(chan ConnState, 8),
|
||||
errors: make(chan error, 8),
|
||||
done: make(chan struct{}),
|
||||
retryMs: 2 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SseClient) Events() <-chan Event { return c.events }
|
||||
func (c *SseClient) Events() <-chan Event { return c.events }
|
||||
func (c *SseClient) State() <-chan ConnState { return c.state }
|
||||
func (c *SseClient) Errors() <-chan error { return c.errors }
|
||||
func (c *SseClient) Errors() <-chan error { return c.errors }
|
||||
|
||||
func (c *SseClient) Start() {
|
||||
go c.loop()
|
||||
}
|
||||
|
||||
// Stop is safe to call multiple times.
|
||||
func (c *SseClient) Stop() {
|
||||
close(c.done)
|
||||
c.stopOnce.Do(func() {
|
||||
close(c.done)
|
||||
c.mu.Lock()
|
||||
cancel := c.cancelHTTP
|
||||
c.mu.Unlock()
|
||||
if cancel != nil {
|
||||
cancel()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (c *SseClient) setHTTPCancel(cancel context.CancelFunc) {
|
||||
c.mu.Lock()
|
||||
c.cancelHTTP = cancel
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *SseClient) emitState(st ConnState) {
|
||||
select {
|
||||
case c.state <- st:
|
||||
case <-c.done:
|
||||
default:
|
||||
// Drop if full — latest reconnect/offline is advisory.
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SseClient) emitError(err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case c.errors <- err:
|
||||
case <-c.done:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SseClient) loop() {
|
||||
@@ -77,11 +117,8 @@ func (c *SseClient) loop() {
|
||||
} else {
|
||||
err = c.streamLive()
|
||||
}
|
||||
if err != nil {
|
||||
select {
|
||||
case c.errors <- err:
|
||||
default:
|
||||
}
|
||||
if err != nil && err != io.EOF && err != context.Canceled {
|
||||
c.emitError(err)
|
||||
}
|
||||
select {
|
||||
case <-c.done:
|
||||
@@ -89,21 +126,50 @@ func (c *SseClient) loop() {
|
||||
default:
|
||||
}
|
||||
if c.fixture != "" {
|
||||
// Loop fixture for dev
|
||||
time.Sleep(2 * time.Second)
|
||||
// Loop fixture for offline demo / dev.
|
||||
if !c.sleepInterruptible(2 * time.Second) {
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
c.state <- ConnReconnecting
|
||||
time.Sleep(c.retryMs)
|
||||
c.emitState(ConnReconnecting)
|
||||
if !c.sleepInterruptible(c.retryMs) {
|
||||
return
|
||||
}
|
||||
c.retryMs = minDuration(c.retryMs*3/2, 60*time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
// sleepInterruptible returns false if Stop was called during the wait.
|
||||
func (c *SseClient) sleepInterruptible(d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-c.done:
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (c *SseClient) streamLive() error {
|
||||
url := c.baseURL + "/api/public/viz/stream"
|
||||
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.setHTTPCancel(cancel)
|
||||
defer cancel()
|
||||
|
||||
// Cancel in-flight request when Stop is called.
|
||||
go func() {
|
||||
select {
|
||||
case <-c.done:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
return err
|
||||
}
|
||||
setSSEHeaders(req, c.secret)
|
||||
@@ -111,25 +177,28 @@ func (c *SseClient) streamLive() error {
|
||||
client := &http.Client{Timeout: 0}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
if ctx.Err() != nil {
|
||||
return context.Canceled
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode == http.StatusServiceUnavailable {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
return fmt.Errorf("public visualization disabled on server (503)")
|
||||
}
|
||||
if resp.StatusCode == http.StatusTooManyRequests {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
return fmt.Errorf("too many SSE connections (429)")
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
return fmt.Errorf("SSE connect failed: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
c.state <- ConnLive
|
||||
c.emitState(ConnLive)
|
||||
c.retryMs = 2 * time.Second
|
||||
return c.parseStream(resp.Body)
|
||||
}
|
||||
@@ -137,11 +206,11 @@ func (c *SseClient) streamLive() error {
|
||||
func (c *SseClient) readFixture(path string) error {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
c.state <- ConnOffline
|
||||
c.emitState(ConnOffline)
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
c.state <- ConnLive
|
||||
c.emitState(ConnLive)
|
||||
return c.parseStream(f)
|
||||
}
|
||||
|
||||
@@ -202,10 +271,10 @@ func (c *SseClient) parseStream(r io.Reader) error {
|
||||
return err
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.state <- ConnReconnecting
|
||||
c.emitState(ConnReconnecting)
|
||||
return err
|
||||
}
|
||||
c.state <- ConnReconnecting
|
||||
c.emitState(ConnReconnecting)
|
||||
return io.EOF
|
||||
}
|
||||
|
||||
|
||||
@@ -115,3 +115,9 @@ func trim(s string) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestSseClientStopIdempotent(t *testing.T) {
|
||||
c := transport.NewSseClient("", "", "", "")
|
||||
c.Stop()
|
||||
c.Stop() // must not panic
|
||||
}
|
||||
|
||||
+1
-1
@@ -5,8 +5,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/bubbles/viewport"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
|
||||
+76
-93
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/honeypeer/cli-viz/internal/update"
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
|
||||
@@ -350,127 +351,109 @@ func renderFooter(t Theme, filtering, searching, operatorSigning bool, filter st
|
||||
if mouseEnabled {
|
||||
hints = append(hints, "✕/outside close", "wheel scroll")
|
||||
}
|
||||
} else if mouseEnabled {
|
||||
hints = []string{"click open", "wheel scroll", "right-click open", "Tab view", "j/k nav", "Enter detail", "o operator", "s search", "/ filter", "? help", "q quit"}
|
||||
} else if filtering {
|
||||
hints = []string{"type filter…", "Enter apply", "Esc clear"}
|
||||
} else {
|
||||
hints = []string{"Tab view", "j/k nav", "Enter detail", "o operator", "s search", "/ filter", "? help", "q quit"}
|
||||
hints = []string{"1-6 views", "Tab/S-Tab", "j/k", "Enter", "o", "s", "/", "?", "q"}
|
||||
if mouseEnabled {
|
||||
hints = append([]string{"click", "wheel"}, hints...)
|
||||
}
|
||||
if tab == TabFeed {
|
||||
hints = append(hints, "v mesh")
|
||||
}
|
||||
if tab == TabHistory {
|
||||
hints = append(hints, "] sub", "[ range")
|
||||
}
|
||||
hints = append(hints, "e sound")
|
||||
if soundEnabled {
|
||||
hints = append(hints, "-/= vol")
|
||||
}
|
||||
hints = append(hints, "m mouse", "r reconnect")
|
||||
}
|
||||
if tab == TabFeed {
|
||||
hints = append(hints, "v mesh")
|
||||
}
|
||||
if tab == TabHistory {
|
||||
hints = append(hints, "] sub", "[ range", "scroll loads more")
|
||||
}
|
||||
hints = append(hints, "e sound")
|
||||
if soundEnabled {
|
||||
hints = append(hints, "-/= vol")
|
||||
}
|
||||
hints = append(hints, "m mouse")
|
||||
if filtering {
|
||||
hints = []string{"type filter…", "Enter apply", "Esc cancel"}
|
||||
}
|
||||
line := t.MutedStyle().Render(strings.Join(hints, " │ "))
|
||||
if follow {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[follow]")
|
||||
|
||||
line := t.MutedStyle().Render(strings.Join(hints, " │ "))
|
||||
|
||||
var badges []string
|
||||
if follow && !filtering && !searching && !operatorSigning {
|
||||
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("follow"))
|
||||
}
|
||||
if filter != "" {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Honey).Render("filter: "+filter)
|
||||
badges = append(badges, lipgloss.NewStyle().Foreground(t.Honey).Render("filter:"+filter))
|
||||
}
|
||||
if mouseEnabled {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mouse]")
|
||||
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("mouse"))
|
||||
}
|
||||
if tab == TabFeed && feedMeshVisible {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Teal).Render("[mesh]")
|
||||
badges = append(badges, lipgloss.NewStyle().Foreground(t.Teal).Render("mesh"))
|
||||
}
|
||||
if soundEnabled {
|
||||
line += " " + renderVolumeBar(t, soundVolume)
|
||||
badges = append(badges, renderVolumeBar(t, soundVolume))
|
||||
}
|
||||
if historyNote != "" {
|
||||
line += " " + lipgloss.NewStyle().Foreground(t.Muted).Render("[" + historyNote + "]")
|
||||
badges = append(badges, t.MutedStyle().Render(historyNote))
|
||||
}
|
||||
if len(badges) > 0 {
|
||||
line += " " + strings.Join(badges, " ")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func renderHelp(t Theme) string {
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◆ hp-viz help")
|
||||
ver := t.MutedStyle().Render(update.VersionString())
|
||||
body := `
|
||||
hp-viz — HoneyPeer terminal attack feed
|
||||
Navigation
|
||||
j / k ↑ / ↓ Move selection
|
||||
Tab / Shift+Tab Next / previous view
|
||||
1–6 Jump to Feed · Incidents · Peers · Stats · History · Blog
|
||||
Enter Open detail (or blog post)
|
||||
f Toggle follow (auto-scroll to newest)
|
||||
PgUp / PgDn Page list · load more history/blog
|
||||
|
||||
Navigation
|
||||
j / k, ↑ / ↓ Move selection
|
||||
Tab Cycle Feed → Incidents → Peers → Stats → History → Blog
|
||||
Enter Open detail modal (or blog post on Blog tab)
|
||||
f Toggle follow mode (auto-scroll to newest)
|
||||
PgUp Load older attacks (Feed) / moderation (Incidents) / more posts (Blog)
|
||||
Feed
|
||||
v Toggle peer mesh under the feed
|
||||
e Toggle event sounds
|
||||
- / = Volume down / up (while sound is on)
|
||||
|
||||
Feed tab
|
||||
v Toggle peer mesh animation below the feed
|
||||
e Toggle event sounds (attack, block, peer, moderation)
|
||||
Filter & search
|
||||
/ Filter — text or service:ssh geo:cn peer:id tier:block since:1h
|
||||
s Search by attack_… or mod_… ID
|
||||
Esc Cancel filter / search / close overlays
|
||||
|
||||
Blog tab
|
||||
Enter Read selected briefing
|
||||
Esc Back to post list (while reading)
|
||||
j / k Scroll post content (while reading)
|
||||
PgUp Load more posts (list view)
|
||||
Operator
|
||||
o Sign in (subscription ID + billing email → real IPs)
|
||||
Shift+O Sign out (while operator modal is open)
|
||||
|
||||
Peers tab
|
||||
Animated hub-and-spoke mesh (attacks pulse: edge → peer → coordinator → fanout)
|
||||
Click a mesh node to select a peer (when mouse is on)
|
||||
HP_VIZ_REDUCED_MOTION=1 — static mesh (no animation)
|
||||
History
|
||||
] Attacks ↔ Reputation
|
||||
[ Time range: all → 1h → 24h → 7d
|
||||
|
||||
Mouse (off by default — press m, or HP_VIZ_MOUSE=1)
|
||||
m Toggle mouse support
|
||||
Click row Open detail (Feed / Incidents / History)
|
||||
Double-click Open peer roster detail (Peers tab)
|
||||
Right-click row Open detail
|
||||
Wheel Scroll list / detail / blog reader
|
||||
Click ✕ Close Close detail modal
|
||||
Click outside Close detail / search / operator / help
|
||||
Mouse (off by default — m or HP_VIZ_MOUSE=1)
|
||||
Click row Open detail
|
||||
Wheel Scroll lists and modals
|
||||
Click ✕ / outside Close detail / search / operator / help
|
||||
|
||||
Detail modal
|
||||
Enter Open / refresh detail for selected row
|
||||
Esc Close modal
|
||||
✕ Close Click the close control (mouse enabled)
|
||||
Click outside Close modal (mouse enabled)
|
||||
Right-click Close modal (mouse enabled)
|
||||
Wheel Scroll long detail content
|
||||
General
|
||||
m Toggle mouse
|
||||
r Reconnect stream
|
||||
? Toggle this help
|
||||
q Ctrl+C Quit
|
||||
|
||||
Filter
|
||||
/ Start filter input
|
||||
Enter Apply filter
|
||||
Esc Clear filter (cancel input)
|
||||
|
||||
Search
|
||||
s Search by attack or moderation ID
|
||||
Enter Look up and open detail
|
||||
Esc Cancel search
|
||||
|
||||
Operator (real IPs + full detail)
|
||||
o Sign in with subscription ID + billing email
|
||||
Tab Move between fields (sign-in form)
|
||||
Enter Validate subscription and unlock operator mode
|
||||
Shift+O Sign out (while operator modal is open)
|
||||
Esc Close operator modal
|
||||
|
||||
History (database — like web viz sidebar)
|
||||
Tab to History view
|
||||
] Switch Attacks ↔ Reputation sub-tabs
|
||||
[ Cycle time range: all → 1h → 24h → 7d
|
||||
j / k, scroll Infinite scroll — loads more near the bottom
|
||||
/ filter service:ssh geo:cn peer:id tier:block (reputation)
|
||||
Enter Open attack or moderation detail
|
||||
|
||||
General
|
||||
e Toggle synthesized event sounds (matches web viz)
|
||||
- / = (+) Volume down / up (while sound is on)
|
||||
? Toggle this help
|
||||
q, Ctrl+C Quit
|
||||
Environment
|
||||
HP_VIZ_URL Master base URL
|
||||
HP_VIZ_MOUSE=1 Mouse on at startup
|
||||
HP_VIZ_REDUCED_MOTION=1 Static mesh, no sounds
|
||||
HP_VIZ_BELL=1 Terminal bell on block events
|
||||
HP_VIZ_NO_UPDATE=1 Skip auto-update checks
|
||||
`
|
||||
return t.BorderStyle().Render(
|
||||
lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("Help") + "\n" +
|
||||
t.MutedStyle().Render(body),
|
||||
return t.ModalStyle().Width(72).Render(
|
||||
title + " " + ver + "\n" + t.MutedStyle().Render(body),
|
||||
)
|
||||
}
|
||||
|
||||
func renderStatusErr(t Theme, msg string) string {
|
||||
return lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render("Error: " + msg)
|
||||
return lipgloss.NewStyle().
|
||||
Foreground(t.Attack).
|
||||
Bold(true).
|
||||
Render("⚠ " + msg)
|
||||
}
|
||||
|
||||
+34
-8
@@ -49,14 +49,22 @@ func tabLabel(tab ViewTab) string {
|
||||
func renderTabs(t Theme, active ViewTab) string {
|
||||
var parts []string
|
||||
for i := ViewTab(0); i <= TabBlog; i++ {
|
||||
// Numbered shortcuts (1–6) for power users.
|
||||
num := t.MutedStyle().Render(fmt.Sprintf("%d ", i+1))
|
||||
label := tabLabel(i)
|
||||
if i == active {
|
||||
parts = append(parts, lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Underline(true).Render(label))
|
||||
activeLbl := lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(t.Bg).
|
||||
Background(t.Honey).
|
||||
Padding(0, 1).
|
||||
Render(label)
|
||||
parts = append(parts, num+activeLbl)
|
||||
} else {
|
||||
parts = append(parts, t.MutedStyle().Render(label))
|
||||
parts = append(parts, num+t.MutedStyle().Render(label))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " │ ")
|
||||
return strings.Join(parts, t.MutedStyle().Render(" │ "))
|
||||
}
|
||||
|
||||
func feedItemSummary(item viz.FeedItem) string {
|
||||
@@ -154,7 +162,7 @@ func feedListHeader(t Theme, ipCol string) string {
|
||||
|
||||
func renderFeedList(t Theme, items []viz.FeedItem, cursor int, store *viz.Store, height, width int, operator bool) string {
|
||||
if len(items) == 0 {
|
||||
return t.MutedStyle().Render("Watching the mesh… attacks appear here in real time")
|
||||
return renderEmptyState(t, "Watching the mesh", "Live attacks, blocks, and moderation events appear here.", "Press / to filter · s to search by ID · ? for help")
|
||||
}
|
||||
ipCol := "IP (masked)"
|
||||
if operator {
|
||||
@@ -240,7 +248,7 @@ func incidentListHeader(t Theme, operator bool) string {
|
||||
|
||||
func renderIncidentList(t Theme, incidents []viz.Incident, cursor, height, width int, operator bool) string {
|
||||
if len(incidents) == 0 {
|
||||
return t.MutedStyle().Render("No incidents yet")
|
||||
return renderEmptyState(t, "No incidents yet", "Grouped escalations and block fanouts show up as they form.", "Switch to Feed to watch the raw stream")
|
||||
}
|
||||
var lines []string
|
||||
lines = append(lines, incidentListHeader(t, operator))
|
||||
@@ -294,9 +302,21 @@ func renderPeerEventRow(t Theme, p viz.FeedItem, width int) string {
|
||||
return fixed + colGap + padCellLeft(viz.TruncatePeer(p.PeerID), peerW)
|
||||
}
|
||||
|
||||
func renderEmptyState(t Theme, title, body, hint string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◇ "+title) + "\n\n")
|
||||
if body != "" {
|
||||
b.WriteString(t.MutedStyle().Render(body) + "\n")
|
||||
}
|
||||
if hint != "" {
|
||||
b.WriteString("\n" + t.MutedStyle().Render(hint))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func renderStatsView(t Theme, stats *viz.Stats, network *viz.Network, width int) string {
|
||||
if stats == nil {
|
||||
return t.MutedStyle().Render("No stats yet")
|
||||
return renderEmptyState(t, "No stats yet", "Aggregates arrive with the first snapshot or SSE stats event.", "Press r to reconnect if this stays empty")
|
||||
}
|
||||
if width < 40 {
|
||||
width = 80
|
||||
@@ -351,7 +371,10 @@ func renderStatsServiceBreakdown(t Theme, stats *viz.Stats, width int) string {
|
||||
if stats == nil || len(stats.ServiceBreakdown) == 0 {
|
||||
return t.MutedStyle().Render(" (none)")
|
||||
}
|
||||
type kv struct{ k string; v int }
|
||||
type kv struct {
|
||||
k string
|
||||
v int
|
||||
}
|
||||
var pairs []kv
|
||||
total := 0
|
||||
for k, v := range stats.ServiceBreakdown {
|
||||
@@ -393,7 +416,10 @@ func renderGeoBreakdown(t Theme, geo map[string]int, width int) string {
|
||||
if len(geo) == 0 {
|
||||
return t.MutedStyle().Render(" (none)")
|
||||
}
|
||||
type kv struct{ k string; v int }
|
||||
type kv struct {
|
||||
k string
|
||||
v int
|
||||
}
|
||||
var pairs []kv
|
||||
total := 0
|
||||
for k, v := range geo {
|
||||
|
||||
+34
-22
@@ -5,10 +5,11 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/honeypeer/cli-viz/internal/update"
|
||||
"github.com/honeypeer/cli-viz/internal/viz"
|
||||
)
|
||||
|
||||
func renderHeader(t Theme, conn ConnState, stats *viz.Stats, url string, operator bool) string {
|
||||
func renderHeader(t Theme, conn ConnState, url string, operator bool) string {
|
||||
pill := t.LivePill(false)
|
||||
switch conn {
|
||||
case ConnLive:
|
||||
@@ -19,38 +20,49 @@ func renderHeader(t Theme, conn ConnState, stats *viz.Stats, url string, operato
|
||||
pill = t.OfflinePill()
|
||||
}
|
||||
|
||||
title := t.HeaderStyle().Render("◆ HoneyPeer Feed")
|
||||
title := t.HeaderStyle().Render("◆ hp-viz")
|
||||
brand := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render(" HoneyPeer")
|
||||
if operator {
|
||||
title += " " + lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("[OPERATOR]")
|
||||
brand += " " + lipgloss.NewStyle().Bold(true).Foreground(t.Honey).
|
||||
Background(lipgloss.Color("#2a2010")).
|
||||
Padding(0, 1).
|
||||
Render("OPERATOR")
|
||||
}
|
||||
right := pill
|
||||
if stats != nil {
|
||||
right += " " + t.MutedStyle().Render("│")
|
||||
right += " " + lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render(viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render("/hr")
|
||||
right += " " + t.MutedStyle().Render("│")
|
||||
right += " " + lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total")
|
||||
right += " " + t.MutedStyle().Render("│")
|
||||
right += " " + lipgloss.NewStyle().Foreground(t.Teal).Render(fmt.Sprintf("%d peers", stats.ConnectedPeers))
|
||||
right += " " + t.MutedStyle().Render("│")
|
||||
right += " " + lipgloss.NewStyle().Render(viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked")
|
||||
}
|
||||
|
||||
line1 := lipgloss.JoinHorizontal(lipgloss.Top, title, lipgloss.NewStyle().Width(1).Render(""), right)
|
||||
host := t.MutedStyle().Render(viz.Truncate(url, 60))
|
||||
line1 := lipgloss.JoinHorizontal(lipgloss.Top, title, brand, lipgloss.NewStyle().Width(2).Render(""), right)
|
||||
host := t.MutedStyle().Render(viz.Truncate(url, 72))
|
||||
return line1 + "\n" + host
|
||||
}
|
||||
|
||||
func renderKPIStrip(t Theme, stats *viz.Stats) string {
|
||||
if stats == nil {
|
||||
return t.MutedStyle().Render("Waiting for stats…")
|
||||
return t.MutedStyle().Render("◈ connecting… waiting for network stats")
|
||||
}
|
||||
sep := t.MutedStyle().Render(" · ")
|
||||
parts := []string{
|
||||
lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render("◆ "+viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render(" /hr"),
|
||||
lipgloss.NewStyle().Bold(true).Render("◆ "+viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total"),
|
||||
lipgloss.NewStyle().Foreground(t.Teal).Render(fmt.Sprintf("◆ %d peers", stats.ConnectedPeers)),
|
||||
lipgloss.NewStyle().Render("◆ "+viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked"),
|
||||
lipgloss.NewStyle().Foreground(t.Attack).Bold(true).Render(viz.FormatCount(stats.AttacksLastHour)) + t.MutedStyle().Render("/hr"),
|
||||
lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.TotalAttacks)) + t.MutedStyle().Render(" total"),
|
||||
lipgloss.NewStyle().Foreground(t.Teal).Bold(true).Render(fmt.Sprintf("%d", stats.ConnectedPeers)) + t.MutedStyle().Render(" peers"),
|
||||
lipgloss.NewStyle().Bold(true).Render(viz.FormatCount(stats.BlockedIPs)) + t.MutedStyle().Render(" blocked"),
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
return "◈ " + strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// renderSplash is shown before the first WindowSizeMsg arrives.
|
||||
func renderSplash(t Theme, width, height int) string {
|
||||
if width < 1 {
|
||||
width = 80
|
||||
}
|
||||
if height < 1 {
|
||||
height = 24
|
||||
}
|
||||
title := lipgloss.NewStyle().Bold(true).Foreground(t.Honey).Render("◆ hp-viz")
|
||||
sub := t.MutedStyle().Render("HoneyPeer terminal attack visualizer")
|
||||
ver := t.MutedStyle().Render(update.VersionString())
|
||||
hint := t.MutedStyle().Render("connecting… press ? for help")
|
||||
body := title + "\n" + sub + "\n\n" + ver + "\n\n" + hint
|
||||
box := t.BorderStyle().Padding(1, 3).Render(body)
|
||||
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, box)
|
||||
}
|
||||
|
||||
func renderBreakdown(t Theme, stats *viz.Stats, width int) string {
|
||||
|
||||
@@ -241,7 +241,7 @@ func attacksToFeedItems(attacks []viz.PublicAttack) []viz.FeedItem {
|
||||
Port: a.Port, Summary: a.Summary,
|
||||
Interaction: a.Interaction, BlobCount: a.BlobCount,
|
||||
CoConspiratorCount: a.CoConspiratorCount,
|
||||
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
|
||||
ScoreAfter: a.ScoreAfter, ScoreDelta: a.ScoreDelta, TierAfter: a.TierAfter,
|
||||
BlockScore: a.BlockScore, GreylistScore: a.GreylistScore,
|
||||
})
|
||||
}
|
||||
@@ -255,11 +255,7 @@ func (m Model) visibleHistoryItems() []viz.FeedItem {
|
||||
} else {
|
||||
items = m.histAtkItems
|
||||
}
|
||||
f := viz.ParseFilterQuery(m.filterQuery)
|
||||
if f.Text == "" {
|
||||
return items
|
||||
}
|
||||
return viz.FilterFeed(items, f.Text)
|
||||
return viz.FilterFeedWith(items, viz.ParseFilterQuery(m.filterQuery))
|
||||
}
|
||||
|
||||
func (m Model) renderHistoryView(height, width int) string {
|
||||
|
||||
+7
-12
@@ -29,7 +29,7 @@ func (m Model) chromeTop() string {
|
||||
url = "fixture:" + m.cfg.Fixture
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteString(renderHeader(m.theme, m.conn, m.stats, url, m.cfg.Operator))
|
||||
b.WriteString(renderHeader(m.theme, m.conn, url, m.cfg.Operator))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(renderKPIStrip(m.theme, m.stats))
|
||||
b.WriteString("\n\n")
|
||||
@@ -149,21 +149,16 @@ type tabZone struct {
|
||||
}
|
||||
|
||||
func tabHitZones() []tabZone {
|
||||
sep := " │ "
|
||||
// Must match renderTabs cell widths: "N " + label (active has padding).
|
||||
sep := " │ "
|
||||
var zones []tabZone
|
||||
x := 0
|
||||
for i := ViewTab(0); i <= TabBlog; i++ {
|
||||
label := tabLabel(i)
|
||||
var rendered string
|
||||
if i == TabFeed {
|
||||
rendered = lipgloss.NewStyle().Bold(true).Underline(true).Render(label)
|
||||
} else {
|
||||
rendered = label
|
||||
}
|
||||
w := lipgloss.Width(rendered)
|
||||
if w < len(label) {
|
||||
w = len(label)
|
||||
}
|
||||
// "N " prefix (2 cells) + active pad or plain label.
|
||||
// Active label is padded (0,1) → +2 cells; inactive is plain.
|
||||
// Use the wider (active) size so both hit targets are generous.
|
||||
w := 2 + lipgloss.Width(label) + 2
|
||||
zones = append(zones, tabZone{Tab: i, StartX: x, EndX: x + w})
|
||||
x += w
|
||||
if i < TabBlog {
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestLayoutFillsTerminalHeight(t *testing.T) {
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(out, "\n"), "\n")
|
||||
footer := lines[len(lines)-1]
|
||||
if !strings.Contains(footer, "quit") {
|
||||
if !strings.Contains(footer, "q") || !strings.Contains(footer, "views") {
|
||||
t.Fatalf("expected footer on last line, got %q", footer)
|
||||
}
|
||||
}
|
||||
|
||||
+145
-109
@@ -53,14 +53,15 @@ type blogPostLoadedMsg struct {
|
||||
const blogPageSize = 20
|
||||
|
||||
type keyMap struct {
|
||||
Up, Down, Quit, Help, Tab, Follow, Filter, Search, Operator, Enter, Reconnect, Close, Mouse key.Binding
|
||||
PgUp, PgDn key.Binding
|
||||
MeshView key.Binding
|
||||
HistSub key.Binding
|
||||
HistRange key.Binding
|
||||
Sound key.Binding
|
||||
VolDown key.Binding
|
||||
VolUp key.Binding
|
||||
Up, Down, Quit, Help, Tab, PrevTab, Follow, Filter, Search, Operator, Enter, Reconnect, Close, Mouse key.Binding
|
||||
PgUp, PgDn key.Binding
|
||||
MeshView key.Binding
|
||||
HistSub key.Binding
|
||||
HistRange key.Binding
|
||||
Sound key.Binding
|
||||
VolDown key.Binding
|
||||
VolUp key.Binding
|
||||
Tab1, Tab2, Tab3, Tab4, Tab5, Tab6 key.Binding
|
||||
}
|
||||
|
||||
func defaultKeyMap() keyMap {
|
||||
@@ -70,6 +71,7 @@ func defaultKeyMap() keyMap {
|
||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Tab: key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "next view")),
|
||||
PrevTab: key.NewBinding(key.WithKeys("shift+tab"), key.WithHelp("S-Tab", "prev view")),
|
||||
Follow: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "follow")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Search: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "search")),
|
||||
@@ -78,82 +80,88 @@ func defaultKeyMap() keyMap {
|
||||
Close: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close")),
|
||||
Mouse: key.NewBinding(key.WithKeys("m"), key.WithHelp("m", "mouse")),
|
||||
Reconnect: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reconnect")),
|
||||
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "history")),
|
||||
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "down")),
|
||||
PgUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("pgup", "page up")),
|
||||
PgDn: key.NewBinding(key.WithKeys("pgdn"), key.WithHelp("pgdn", "page down")),
|
||||
HistSub: key.NewBinding(key.WithKeys("]"), key.WithHelp("]", "hist sub")),
|
||||
HistRange: key.NewBinding(key.WithKeys("["), key.WithHelp("[", "hist range")),
|
||||
MeshView: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "mesh")),
|
||||
Sound: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "sound")),
|
||||
VolDown: key.NewBinding(key.WithKeys("-", "_"), key.WithHelp("-", "quieter")),
|
||||
VolUp: key.NewBinding(key.WithKeys("=", "+"), key.WithHelp("=/+", "louder")),
|
||||
Tab1: key.NewBinding(key.WithKeys("1"), key.WithHelp("1", "Feed")),
|
||||
Tab2: key.NewBinding(key.WithKeys("2"), key.WithHelp("2", "Incidents")),
|
||||
Tab3: key.NewBinding(key.WithKeys("3"), key.WithHelp("3", "Peers")),
|
||||
Tab4: key.NewBinding(key.WithKeys("4"), key.WithHelp("4", "Stats")),
|
||||
Tab5: key.NewBinding(key.WithKeys("5"), key.WithHelp("5", "History")),
|
||||
Tab6: key.NewBinding(key.WithKeys("6"), key.WithHelp("6", "Blog")),
|
||||
}
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
cfg config.Config
|
||||
theme Theme
|
||||
keys keyMap
|
||||
sse *transport.SseClient
|
||||
api *transport.APIClient
|
||||
cfg config.Config
|
||||
theme Theme
|
||||
keys keyMap
|
||||
sse *transport.SseClient
|
||||
api *transport.APIClient
|
||||
|
||||
width, height int
|
||||
conn ConnState
|
||||
tab ViewTab
|
||||
cursor int
|
||||
follow bool
|
||||
showHelp bool
|
||||
mouseEnabled bool
|
||||
filtering bool
|
||||
filterQuery string
|
||||
filterInput textinput.Model
|
||||
searching bool
|
||||
searchInput textinput.Model
|
||||
operatorSigning bool
|
||||
operatorField int
|
||||
operatorSubInput textinput.Model
|
||||
operatorEmailInput textinput.Model
|
||||
width, height int
|
||||
conn ConnState
|
||||
tab ViewTab
|
||||
cursor int
|
||||
follow bool
|
||||
showHelp bool
|
||||
mouseEnabled bool
|
||||
filtering bool
|
||||
filterQuery string
|
||||
filterInput textinput.Model
|
||||
searching bool
|
||||
searchInput textinput.Model
|
||||
operatorSigning bool
|
||||
operatorField int
|
||||
operatorSubInput textinput.Model
|
||||
operatorEmailInput textinput.Model
|
||||
|
||||
store *viz.Store
|
||||
stats *viz.Stats
|
||||
network *viz.Network
|
||||
detailItem *viz.FeedItem
|
||||
detailIncident *viz.Incident
|
||||
detailAttack *viz.PublicAttack
|
||||
detailModeration *viz.ModerationEvent
|
||||
detailModAttacks []viz.PublicAttack
|
||||
store *viz.Store
|
||||
stats *viz.Stats
|
||||
network *viz.Network
|
||||
detailItem *viz.FeedItem
|
||||
detailIncident *viz.Incident
|
||||
detailAttack *viz.PublicAttack
|
||||
detailModeration *viz.ModerationEvent
|
||||
detailModAttacks []viz.PublicAttack
|
||||
detailAttacksLoading bool
|
||||
detailAttacksErr string
|
||||
detailViewport viewport.Model
|
||||
detailLoading bool
|
||||
detailErr string
|
||||
detailOpen bool
|
||||
statusErr string
|
||||
historySubTab HistorySubTab
|
||||
historyRange viz.HistoryRange
|
||||
histAtkItems []viz.FeedItem
|
||||
histAtkOffset int
|
||||
histAtkTotal int
|
||||
histAtkExhausted bool
|
||||
histModItems []viz.FeedItem
|
||||
histModOffset int
|
||||
histModTotal int
|
||||
histModExhausted bool
|
||||
histLoading bool
|
||||
blogPosts []viz.BlogPostSummary
|
||||
blogOffset int
|
||||
blogTotal int
|
||||
blogExhausted bool
|
||||
blogLoading bool
|
||||
blogErr string
|
||||
blogReading bool
|
||||
blogPost *viz.BlogPost
|
||||
blogViewport viewport.Model
|
||||
rateLimitUntil time.Time
|
||||
pendingSSE []transport.Event
|
||||
coalesceScheduled bool
|
||||
cachedModalBase string
|
||||
cachedModalKey string
|
||||
ready bool
|
||||
detailViewport viewport.Model
|
||||
detailLoading bool
|
||||
detailErr string
|
||||
detailOpen bool
|
||||
statusErr string
|
||||
historySubTab HistorySubTab
|
||||
historyRange viz.HistoryRange
|
||||
histAtkItems []viz.FeedItem
|
||||
histAtkOffset int
|
||||
histAtkTotal int
|
||||
histAtkExhausted bool
|
||||
histModItems []viz.FeedItem
|
||||
histModOffset int
|
||||
histModTotal int
|
||||
histModExhausted bool
|
||||
histLoading bool
|
||||
blogPosts []viz.BlogPostSummary
|
||||
blogOffset int
|
||||
blogTotal int
|
||||
blogExhausted bool
|
||||
blogLoading bool
|
||||
blogErr string
|
||||
blogReading bool
|
||||
blogPost *viz.BlogPost
|
||||
blogViewport viewport.Model
|
||||
rateLimitUntil time.Time
|
||||
pendingSSE []transport.Event
|
||||
coalesceScheduled bool
|
||||
cachedModalBase string
|
||||
cachedModalKey string
|
||||
ready bool
|
||||
|
||||
lastClickAt time.Time
|
||||
lastClickX int
|
||||
@@ -166,10 +174,10 @@ type Model struct {
|
||||
mousePressX int
|
||||
mousePressY int
|
||||
|
||||
peerMesh *mesh.State
|
||||
meshTickActive bool
|
||||
peerMesh *mesh.State
|
||||
meshTickActive bool
|
||||
feedMeshVisible bool
|
||||
sound *sound.Engine
|
||||
sound *sound.Engine
|
||||
}
|
||||
|
||||
func NewModel(cfg config.Config) Model {
|
||||
@@ -194,17 +202,17 @@ func NewModel(cfg config.Config) Model {
|
||||
opEmail.Width = 48
|
||||
|
||||
m := Model{
|
||||
cfg: cfg,
|
||||
theme: NewTheme(cfg.NoColor),
|
||||
keys: defaultKeyMap(),
|
||||
store: viz.NewStore(cfg.Buffer),
|
||||
follow: true,
|
||||
filterInput: ti,
|
||||
searchInput: si,
|
||||
cfg: cfg,
|
||||
theme: NewTheme(cfg.NoColor),
|
||||
keys: defaultKeyMap(),
|
||||
store: viz.NewStore(cfg.Buffer),
|
||||
follow: true,
|
||||
filterInput: ti,
|
||||
searchInput: si,
|
||||
operatorSubInput: opSub,
|
||||
operatorEmailInput: opEmail,
|
||||
conn: ConnReconnecting,
|
||||
mouseEnabled: envTruthy("HP_VIZ_MOUSE"),
|
||||
conn: ConnReconnecting,
|
||||
mouseEnabled: envTruthy("HP_VIZ_MOUSE"),
|
||||
}
|
||||
if cfg.URL != "" {
|
||||
m.api = transport.NewAPIClient(cfg.URL, cfg.Secret)
|
||||
@@ -346,17 +354,25 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case key.Matches(msg, m.keys.Help):
|
||||
m.showHelp = !m.showHelp
|
||||
case key.Matches(msg, m.keys.Tab):
|
||||
m.tab = (m.tab + 1) % (TabBlog + 1)
|
||||
m.cursor = 0
|
||||
m.closeDetail()
|
||||
m.closeBlogReader()
|
||||
if m.tab == TabBlog {
|
||||
cmds = append(cmds, m.requestBlogList())
|
||||
cmds = append(cmds, m.switchTab((m.tab+1)%(TabBlog+1))...)
|
||||
case key.Matches(msg, m.keys.PrevTab):
|
||||
prev := m.tab - 1
|
||||
if prev < 0 {
|
||||
prev = TabBlog
|
||||
}
|
||||
if m.tab == TabHistory {
|
||||
cmds = append(cmds, m.ensureHistoryTabLoaded())
|
||||
}
|
||||
cmds = append(cmds, m.onTabChanged())
|
||||
cmds = append(cmds, m.switchTab(prev)...)
|
||||
case key.Matches(msg, m.keys.Tab1):
|
||||
cmds = append(cmds, m.switchTab(TabFeed)...)
|
||||
case key.Matches(msg, m.keys.Tab2):
|
||||
cmds = append(cmds, m.switchTab(TabIncidents)...)
|
||||
case key.Matches(msg, m.keys.Tab3):
|
||||
cmds = append(cmds, m.switchTab(TabPeers)...)
|
||||
case key.Matches(msg, m.keys.Tab4):
|
||||
cmds = append(cmds, m.switchTab(TabStats)...)
|
||||
case key.Matches(msg, m.keys.Tab5):
|
||||
cmds = append(cmds, m.switchTab(TabHistory)...)
|
||||
case key.Matches(msg, m.keys.Tab6):
|
||||
cmds = append(cmds, m.switchTab(TabBlog)...)
|
||||
case key.Matches(msg, m.keys.Follow):
|
||||
m.follow = !m.follow
|
||||
if m.follow {
|
||||
@@ -405,10 +421,13 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, m.openDetail())
|
||||
}
|
||||
case key.Matches(msg, m.keys.Reconnect):
|
||||
m.sse.Stop()
|
||||
if m.sse != nil {
|
||||
m.sse.Stop()
|
||||
}
|
||||
m.sse = transport.NewSseClient(m.cfg.URL, m.cfg.Secret, m.cfg.Fixture, m.cfg.Record)
|
||||
m.sse.Start()
|
||||
m.conn = ConnReconnecting
|
||||
m.statusErr = ""
|
||||
cmds = append(cmds, waitSSE(m.sse), waitConnState(m.sse), waitSSEErr(m.sse))
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.cursor > 0 {
|
||||
@@ -636,8 +655,7 @@ func (m *Model) updateFilter(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
cmd := m.refreshHistoryAfterFilter()
|
||||
return *m, cmd
|
||||
case tea.KeyCtrlC:
|
||||
m.sse.Stop()
|
||||
return *m, tea.Quit
|
||||
return *m, m.quit()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.filterInput, cmd = m.filterInput.Update(msg)
|
||||
@@ -812,9 +830,34 @@ func (m *Model) adjustSoundVolume(delta float64) {
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) switchTab(tab ViewTab) []tea.Cmd {
|
||||
if tab == m.tab {
|
||||
return nil
|
||||
}
|
||||
m.tab = tab
|
||||
m.cursor = 0
|
||||
m.closeDetail()
|
||||
m.closeBlogReader()
|
||||
var cmds []tea.Cmd
|
||||
if m.tab == TabBlog {
|
||||
cmds = append(cmds, m.requestBlogList())
|
||||
}
|
||||
if m.tab == TabHistory {
|
||||
cmds = append(cmds, m.ensureHistoryTabLoaded())
|
||||
}
|
||||
if cmd := m.onTabChanged(); cmd != nil {
|
||||
cmds = append(cmds, cmd)
|
||||
}
|
||||
return cmds
|
||||
}
|
||||
|
||||
func (m *Model) quit() tea.Cmd {
|
||||
m.sse.Stop()
|
||||
m.sound.Close()
|
||||
if m.sse != nil {
|
||||
m.sse.Stop()
|
||||
}
|
||||
if m.sound != nil {
|
||||
m.sound.Close()
|
||||
}
|
||||
return tea.Quit
|
||||
}
|
||||
|
||||
@@ -857,23 +900,16 @@ func (m Model) selectedFeedItem() *viz.FeedItem {
|
||||
|
||||
func (m Model) visibleFeed() []viz.FeedItem {
|
||||
raw := viz.AttackFeedItems(m.store.Items())
|
||||
f := viz.ParseFilterQuery(m.filterQuery)
|
||||
q := f.Text
|
||||
if q == "" && f.Service == "" && f.Geo == "" && f.PeerID == "" && f.Since == "" {
|
||||
q = m.filterQuery
|
||||
}
|
||||
return viz.FilterFeed(raw, q)
|
||||
return viz.FilterFeedWith(raw, viz.ParseFilterQuery(m.filterQuery))
|
||||
}
|
||||
|
||||
func (m Model) visibleIncidents() []viz.Incident {
|
||||
return viz.FilterIncidents(viz.BuildIncidents(m.store.Items()), m.filterQuery)
|
||||
return viz.FilterIncidentsWith(viz.BuildIncidents(m.store.Items()), viz.ParseFilterQuery(m.filterQuery))
|
||||
}
|
||||
|
||||
func (m Model) visiblePeers() []viz.FeedItem {
|
||||
if m.filterQuery == "" {
|
||||
return viz.PeerItems(m.store.Items())
|
||||
}
|
||||
return viz.FilterFeed(viz.PeerItems(m.store.Items()), m.filterQuery)
|
||||
peers := viz.PeerItems(m.store.Items())
|
||||
return viz.FilterFeedWith(peers, viz.ParseFilterQuery(m.filterQuery))
|
||||
}
|
||||
|
||||
func (m Model) listLen() int {
|
||||
@@ -898,7 +934,7 @@ func (m Model) listLen() int {
|
||||
|
||||
func (m Model) View() string {
|
||||
if !m.ready {
|
||||
return "Initializing…"
|
||||
return renderSplash(m.theme, m.width, m.height)
|
||||
}
|
||||
if m.showHelp {
|
||||
return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, renderHelp(m.theme))
|
||||
|
||||
@@ -57,8 +57,7 @@ func (m *Model) updateOperatorSignIn(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
m.cancelOperatorSignIn()
|
||||
return *m, nil
|
||||
case tea.KeyCtrlC:
|
||||
m.sse.Stop()
|
||||
return *m, tea.Quit
|
||||
return *m, m.quit()
|
||||
}
|
||||
|
||||
if m.cfg.Operator {
|
||||
|
||||
@@ -38,8 +38,7 @@ func (m *Model) updateSearch(msg tea.KeyMsg) (Model, tea.Cmd) {
|
||||
case tea.KeyEnter:
|
||||
return *m, m.submitSearch()
|
||||
case tea.KeyCtrlC:
|
||||
m.sse.Stop()
|
||||
return *m, tea.Quit
|
||||
return *m, m.quit()
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.searchInput, cmd = m.searchInput.Update(msg)
|
||||
|
||||
@@ -17,13 +17,13 @@ type Theme struct {
|
||||
|
||||
func NewTheme(noColor bool) Theme {
|
||||
t := Theme{
|
||||
Honey: lipgloss.Color("#eab84d"),
|
||||
Teal: lipgloss.Color("#2fe8da"),
|
||||
Bg: lipgloss.Color("#0d0d0d"),
|
||||
BgElev: lipgloss.Color("#141414"),
|
||||
Muted: lipgloss.Color("#a8a29e"),
|
||||
Attack: lipgloss.Color("#f87171"),
|
||||
Border: lipgloss.Color("#3d3520"),
|
||||
Honey: lipgloss.Color("#eab84d"),
|
||||
Teal: lipgloss.Color("#2fe8da"),
|
||||
Bg: lipgloss.Color("#0d0d0d"),
|
||||
BgElev: lipgloss.Color("#141414"),
|
||||
Muted: lipgloss.Color("#a8a29e"),
|
||||
Attack: lipgloss.Color("#f87171"),
|
||||
Border: lipgloss.Color("#3d3520"),
|
||||
NoColor: noColor,
|
||||
}
|
||||
return t
|
||||
|
||||
@@ -27,6 +27,9 @@ const (
|
||||
// BuildCommit is set at link time for release binaries (-ldflags -X ...BuildCommit=...).
|
||||
var BuildCommit string
|
||||
|
||||
// BuildTime is an optional RFC3339 timestamp set at link time.
|
||||
var BuildTime string
|
||||
|
||||
type Options struct {
|
||||
ReleaseBase string
|
||||
APIURL string
|
||||
@@ -71,7 +74,11 @@ func VersionString() string {
|
||||
if commit == "" {
|
||||
return "hp-viz dev (source build)"
|
||||
}
|
||||
return "hp-viz " + shortCommit(commit)
|
||||
s := "hp-viz " + shortCommit(commit)
|
||||
if t := strings.TrimSpace(BuildTime); t != "" {
|
||||
s += " (" + t + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func CurrentCommit() string {
|
||||
|
||||
@@ -48,10 +48,10 @@ func FormatBlogDate(ts int64) string {
|
||||
}
|
||||
|
||||
var (
|
||||
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
mdLinkRe = regexp.MustCompile(`\[([^\]]+)\]\(([^)]+)\)`)
|
||||
mdBoldRe = regexp.MustCompile(`\*\*([^*]+)\*\*`)
|
||||
mdItalicRe = regexp.MustCompile(`\*([^*]+)\*`)
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
htmlTagRe = regexp.MustCompile(`<[^>]+>`)
|
||||
)
|
||||
|
||||
// BlogBody returns markdown/plain text for display (prefers content over HTML).
|
||||
|
||||
@@ -270,14 +270,19 @@ func AttackFeedItems(feed []FeedItem) []FeedItem {
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterFeed filters items by free-text query (substring match across fields).
|
||||
func FilterFeed(items []FeedItem, query string) []FeedItem {
|
||||
q := stringsToLower(query)
|
||||
if q == "" {
|
||||
return FilterFeedWith(items, ParseFilterQuery(query))
|
||||
}
|
||||
|
||||
// FilterFeedWith filters items using structured AttackFilters (service:, geo:, etc.).
|
||||
func FilterFeedWith(items []FeedItem, f AttackFilters) []FeedItem {
|
||||
if f.Empty() {
|
||||
return items
|
||||
}
|
||||
out := make([]FeedItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if feedMatches(item, q) {
|
||||
if f.MatchFeed(item) {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,127 @@ type AttackFilters struct {
|
||||
Text string
|
||||
}
|
||||
|
||||
// Empty reports whether the filter has no constraints.
|
||||
func (f AttackFilters) Empty() bool {
|
||||
return f.Service == "" && f.Geo == "" && f.PeerID == "" && f.Since == "" && f.Tier == "" && f.Text == ""
|
||||
}
|
||||
|
||||
// MatchFeed applies structured + free-text filter rules to a feed item.
|
||||
func (f AttackFilters) MatchFeed(item FeedItem) bool {
|
||||
if f.Empty() {
|
||||
return true
|
||||
}
|
||||
if f.Service != "" {
|
||||
svc := f.Service
|
||||
if !stringsContainsFold(item.Service, svc) && !stringsContainsFold(item.Label, svc) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Geo != "" && !stringsContainsFold(item.Geo, f.Geo) {
|
||||
return false
|
||||
}
|
||||
if f.PeerID != "" {
|
||||
if !stringsContainsFold(item.PeerID, f.PeerID) && !stringsContainsFold(item.PeerPublicKeyHex, f.PeerID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Tier != "" {
|
||||
if !stringsContainsFold(item.TierAfter, f.Tier) && !stringsContainsFold(item.TierBefore, f.Tier) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Since != "" {
|
||||
if sinceMS := ParseSinceToken(f.Since); sinceMS != "" {
|
||||
if cutoff, err := parseInt64(sinceMS); err == nil && item.Timestamp > 0 && item.Timestamp < cutoff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.Text != "" && !feedMatches(item, stringsToLower(f.Text)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// MatchIncident applies structured + free-text filter rules to an incident.
|
||||
func (f AttackFilters) MatchIncident(inc Incident) bool {
|
||||
if f.Empty() {
|
||||
return true
|
||||
}
|
||||
if f.Geo != "" && !stringsContainsFold(inc.Geo, f.Geo) {
|
||||
return false
|
||||
}
|
||||
if f.Tier != "" {
|
||||
kind := string(IncidentActionKindOf(inc))
|
||||
if !stringsContainsFold(kind, f.Tier) && !stringsContainsFold(IncidentHeadline(inc), f.Tier) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Service != "" {
|
||||
ok := false
|
||||
for _, a := range inc.Attacks {
|
||||
if stringsContainsFold(a.Service, f.Service) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok && inc.Standalone != nil {
|
||||
ok = stringsContainsFold(inc.Standalone.Service, f.Service) || stringsContainsFold(inc.Standalone.Label, f.Service)
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.PeerID != "" {
|
||||
ok := false
|
||||
for _, a := range inc.Attacks {
|
||||
if stringsContainsFold(a.PeerID, f.PeerID) {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok && inc.Standalone != nil {
|
||||
ok = stringsContainsFold(inc.Standalone.PeerID, f.PeerID) || stringsContainsFold(inc.Standalone.PeerPublicKeyHex, f.PeerID)
|
||||
}
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if f.Since != "" {
|
||||
if sinceMS := ParseSinceToken(f.Since); sinceMS != "" {
|
||||
ts := IncidentCardTimestamp(inc)
|
||||
if cutoff, err := parseInt64(sinceMS); err == nil && ts > 0 && ts < cutoff {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if f.Text != "" {
|
||||
q := stringsToLower(f.Text)
|
||||
if !incidentMatches(inc, q) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseInt64(s string) (int64, error) {
|
||||
var n int64
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errInvalidInt
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
type filterError string
|
||||
|
||||
func (e filterError) Error() string { return string(e) }
|
||||
|
||||
const errInvalidInt = filterError("invalid int")
|
||||
|
||||
func stringsTrimSpace(s string) string {
|
||||
start, end := 0, len(s)
|
||||
for start < end && (s[start] == ' ' || s[start] == '\t') {
|
||||
|
||||
@@ -41,3 +41,27 @@ func TestParseFilterQuery(t *testing.T) {
|
||||
t.Fatalf("unexpected filter: %+v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterFeedWithStructured(t *testing.T) {
|
||||
items := []FeedItem{
|
||||
{ID: "1", Service: "SSH", Geo: "CN", TierAfter: "block", Label: "SSH", Summary: "brute force"},
|
||||
{ID: "2", Service: "HTTP", Geo: "US", TierAfter: "watch", Label: "HTTP", Summary: "scan"},
|
||||
{ID: "3", Service: "SSH", Geo: "US", TierAfter: "greylist", Label: "SSH", Summary: "auth fail"},
|
||||
}
|
||||
got := FilterFeedWith(items, ParseFilterQuery("service:ssh geo:cn"))
|
||||
if len(got) != 1 || got[0].ID != "1" {
|
||||
t.Fatalf("service+geo filter: got %+v", got)
|
||||
}
|
||||
got = FilterFeedWith(items, ParseFilterQuery("tier:block"))
|
||||
if len(got) != 1 || got[0].ID != "1" {
|
||||
t.Fatalf("tier filter: got %+v", got)
|
||||
}
|
||||
got = FilterFeedWith(items, ParseFilterQuery("brute"))
|
||||
if len(got) != 1 || got[0].ID != "1" {
|
||||
t.Fatalf("text filter: got %+v", got)
|
||||
}
|
||||
got = FilterFeed(items, "service:http")
|
||||
if len(got) != 1 || got[0].ID != "2" {
|
||||
t.Fatalf("FilterFeed structured: got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,9 +8,9 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
detectionScoreMax = 200
|
||||
defaultBlockScore = 45
|
||||
defaultGreyScore = 20
|
||||
detectionScoreMax = 200
|
||||
defaultBlockScore = 45
|
||||
defaultGreyScore = 20
|
||||
)
|
||||
|
||||
func ClampScore(score float64) int {
|
||||
|
||||
@@ -238,14 +238,19 @@ func BuildIncidents(feed []FeedItem) []Incident {
|
||||
return incidents
|
||||
}
|
||||
|
||||
// FilterIncidents filters incidents by free-text or structured query tokens.
|
||||
func FilterIncidents(incidents []Incident, query string) []Incident {
|
||||
q := stringsToLower(query)
|
||||
if q == "" {
|
||||
return FilterIncidentsWith(incidents, ParseFilterQuery(query))
|
||||
}
|
||||
|
||||
// FilterIncidentsWith filters incidents using structured AttackFilters.
|
||||
func FilterIncidentsWith(incidents []Incident, f AttackFilters) []Incident {
|
||||
if f.Empty() {
|
||||
return incidents
|
||||
}
|
||||
out := make([]Incident, 0, len(incidents))
|
||||
for _, inc := range incidents {
|
||||
if incidentMatches(inc, q) {
|
||||
if f.MatchIncident(inc) {
|
||||
out = append(out, inc)
|
||||
}
|
||||
}
|
||||
@@ -300,11 +305,11 @@ func createEscalationIncident(ip, geo string, startedAt int64, pendingID string)
|
||||
func createStandaloneIncident(item FeedItem) Incident {
|
||||
cp := item
|
||||
return Incident{
|
||||
ID: "standalone_" + item.ID,
|
||||
Kind: "standalone",
|
||||
Resolved: true,
|
||||
StartedAt: item.Timestamp,
|
||||
UpdatedAt: item.Timestamp,
|
||||
ID: "standalone_" + item.ID,
|
||||
Kind: "standalone",
|
||||
Resolved: true,
|
||||
StartedAt: item.Timestamp,
|
||||
UpdatedAt: item.Timestamp,
|
||||
Standalone: &cp,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,13 +9,13 @@ import (
|
||||
|
||||
func TestFormatInteractionHTTP(t *testing.T) {
|
||||
ix := map[string]interface{}{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"url": "/wp-login.php",
|
||||
"path": "/wp-login.php",
|
||||
"remoteAddress": "203.0.113.1",
|
||||
"userAgent": "curl/8.0",
|
||||
"trapTitle": "WordPress admin",
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"url": "/wp-login.php",
|
||||
"path": "/wp-login.php",
|
||||
"remoteAddress": "203.0.113.1",
|
||||
"userAgent": "curl/8.0",
|
||||
"trapTitle": "WordPress admin",
|
||||
"authAttempts": []interface{}{
|
||||
map[string]interface{}{"username": "admin", "password": "[redacted]"},
|
||||
},
|
||||
|
||||
+36
-36
@@ -1,26 +1,26 @@
|
||||
package viz
|
||||
|
||||
type Stats struct {
|
||||
TotalAttacks int `json:"totalAttacks"`
|
||||
ConnectedPeers int `json:"connectedPeers"`
|
||||
BlockedIPs int `json:"blockedIPs"`
|
||||
BlockedSubnets int `json:"blockedSubnets,omitempty"`
|
||||
ServiceBreakdown map[string]int `json:"serviceBreakdown"`
|
||||
GeoBreakdown map[string]int `json:"geoBreakdown"`
|
||||
GeoBreakdownLastHour map[string]int `json:"geoBreakdownLastHour,omitempty"`
|
||||
AttacksLastHour int `json:"attacksLastHour"`
|
||||
RecentBlocksLastHour int `json:"recentBlocksLastHour,omitempty"`
|
||||
PeersByGeo map[string]int `json:"peersByGeo,omitempty"`
|
||||
TotalAttacks int `json:"totalAttacks"`
|
||||
ConnectedPeers int `json:"connectedPeers"`
|
||||
BlockedIPs int `json:"blockedIPs"`
|
||||
BlockedSubnets int `json:"blockedSubnets,omitempty"`
|
||||
ServiceBreakdown map[string]int `json:"serviceBreakdown"`
|
||||
GeoBreakdown map[string]int `json:"geoBreakdown"`
|
||||
GeoBreakdownLastHour map[string]int `json:"geoBreakdownLastHour,omitempty"`
|
||||
AttacksLastHour int `json:"attacksLastHour"`
|
||||
RecentBlocksLastHour int `json:"recentBlocksLastHour,omitempty"`
|
||||
PeersByGeo map[string]int `json:"peersByGeo,omitempty"`
|
||||
}
|
||||
|
||||
type Peer struct {
|
||||
ID string `json:"id"`
|
||||
PeerPublicKeyHex string `json:"peerPublicKeyHex,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ConnectedAt int64 `json:"connectedAt"`
|
||||
AttacksLastHour int `json:"attacksLastHour,omitempty"`
|
||||
TopServices map[string]int `json:"topServices,omitempty"`
|
||||
Geo string `json:"geo,omitempty"`
|
||||
ID string `json:"id"`
|
||||
PeerPublicKeyHex string `json:"peerPublicKeyHex,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ConnectedAt int64 `json:"connectedAt"`
|
||||
AttacksLastHour int `json:"attacksLastHour,omitempty"`
|
||||
TopServices map[string]int `json:"topServices,omitempty"`
|
||||
Geo string `json:"geo,omitempty"`
|
||||
}
|
||||
|
||||
type Network struct {
|
||||
@@ -69,27 +69,27 @@ type BlockEvent struct {
|
||||
}
|
||||
|
||||
type ModerationEvent struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
IPMasked string `json:"ipMasked"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ScoreBefore float64 `json:"scoreBefore"`
|
||||
ScoreAfter float64 `json:"scoreAfter"`
|
||||
ScoreDelta float64 `json:"scoreDelta"`
|
||||
DetectionScore float64 `json:"detectionScore,omitempty"`
|
||||
BlockScore float64 `json:"blockScore,omitempty"`
|
||||
GreylistScore float64 `json:"greylistScore,omitempty"`
|
||||
TierBefore string `json:"tierBefore"`
|
||||
TierAfter string `json:"tierAfter"`
|
||||
ID string `json:"id,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
IPMasked string `json:"ipMasked"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
ScoreBefore float64 `json:"scoreBefore"`
|
||||
ScoreAfter float64 `json:"scoreAfter"`
|
||||
ScoreDelta float64 `json:"scoreDelta"`
|
||||
DetectionScore float64 `json:"detectionScore,omitempty"`
|
||||
BlockScore float64 `json:"blockScore,omitempty"`
|
||||
GreylistScore float64 `json:"greylistScore,omitempty"`
|
||||
TierBefore string `json:"tierBefore"`
|
||||
TierAfter string `json:"tierAfter"`
|
||||
Action string `json:"action"`
|
||||
Source string `json:"source,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
Geo string `json:"geo,omitempty"`
|
||||
AttackID string `json:"attackId,omitempty"`
|
||||
AttackIds []string `json:"attackIds,omitempty"`
|
||||
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
|
||||
SubnetBlocked bool `json:"subnetBlocked,omitempty"`
|
||||
SubnetCidrMasked string `json:"subnetCidrMasked,omitempty"`
|
||||
CoConspiratorCount int `json:"coConspiratorCount,omitempty"`
|
||||
SubnetBlocked bool `json:"subnetBlocked,omitempty"`
|
||||
SubnetCidrMasked string `json:"subnetCidrMasked,omitempty"`
|
||||
}
|
||||
|
||||
type RecentEvent struct {
|
||||
@@ -147,10 +147,10 @@ type AttackHistoryResponse struct {
|
||||
type FeedType string
|
||||
|
||||
const (
|
||||
FeedAttack FeedType = "attack"
|
||||
FeedPeer FeedType = "peer"
|
||||
FeedBlock FeedType = "block"
|
||||
FeedModeration FeedType = "moderation"
|
||||
FeedAttack FeedType = "attack"
|
||||
FeedPeer FeedType = "peer"
|
||||
FeedBlock FeedType = "block"
|
||||
FeedModeration FeedType = "moderation"
|
||||
)
|
||||
|
||||
type FeedItem struct {
|
||||
|
||||
@@ -1,17 +1,23 @@
|
||||
class HpViz < Formula
|
||||
desc "Terminal attack feed visualizer for HoneyPeer"
|
||||
homepage "https://github.com/honeypeer/cli-viz"
|
||||
url "https://github.com/honeypeer/cli-viz/archive/refs/tags/vVERSION.tar.gz"
|
||||
homepage "https://honeypeer.com"
|
||||
url "https://git.ssh.surf/snxraven/honeypeer-viz-cli/archive/refs/tags/vVERSION.tar.gz"
|
||||
sha256 "SHA256_PLACEHOLDER"
|
||||
license "MIT"
|
||||
|
||||
depends_on "go" => :build
|
||||
|
||||
def install
|
||||
system "go", "build", "-o", bin/"hp-viz", "./cmd/hp-viz"
|
||||
ldflags = %W[
|
||||
-s -w
|
||||
-X github.com/honeypeer/cli-viz/internal/update.BuildCommit=#{Utils.git_head}
|
||||
-X github.com/honeypeer/cli-viz/internal/update.BuildTime=#{time.iso8601}
|
||||
]
|
||||
system "go", "build", *std_go_args(ldflags: ldflags), "./cmd/hp-viz"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match "hp-viz", shell_output("#{bin}/hp-viz --help 2>&1", 2)
|
||||
assert_match "hp-viz", shell_output("#{bin}/hp-viz --version")
|
||||
assert_match "HoneyPeer", shell_output("#{bin}/hp-viz --help")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user