diff --git a/dot_config/private_k9s/plugins/executable_victoria-logs b/dot_config/private_k9s/plugins/executable_victoria-logs new file mode 100644 index 0000000..4957451 --- /dev/null +++ b/dot_config/private_k9s/plugins/executable_victoria-logs @@ -0,0 +1,276 @@ +#!/bin/sh +set -eu + +usage() { + echo "usage: victoria-logs <--container|--pod|--deployment|--node|--namespace> " >&2 + exit 2 +} + +context="${1:-}" +selector="${2:-}" +[ -n "$selector" ] || usage +shift 2 + +quote_value() { + jq -Rn --arg value "$1" '$value' +} + +regex_escape() { + printf '%s' "$1" | sed 's/[][(){}.^$*+?|\\]/\\&/g' +} + +table_scope=query +case "$selector" in + --container) + [ "$#" -ge 3 ] || usage + namespace="$1" pod="$2" container="$3" + shift 3 + query="namespace:=$(quote_value "$namespace") and pod:=$(quote_value "$pod") and container:=$(quote_value "$container")" + table_scope=container + ;; + --pod) + [ "$#" -ge 2 ] || usage + namespace="$1" pod="$2" + shift 2 + query="namespace:=$(quote_value "$namespace") and pod:=$(quote_value "$pod")" + table_scope=pod + ;; + --deployment) + [ "$#" -ge 2 ] || usage + namespace="$1" deployment="$2" + shift 2 + # Deployment pod names retain this prefix across ReplicaSet rollouts. + pod_pattern="^$(regex_escape "$deployment")-[a-z0-9]+-[a-z0-9]+$" + query="namespace:=$(quote_value "$namespace") and pod:~$(quote_value "$pod_pattern")" + table_scope=deployment + ;; + --node) + [ "$#" -ge 1 ] || usage + node="$1" + shift + query="node:=$(quote_value "$node")" + table_scope=node + ;; + --namespace) + [ "$#" -ge 1 ] || usage + namespace="$1" + shift + query="namespace:=$(quote_value "$namespace")" + table_scope=namespace + ;; + *) usage ;; +esac + +columns_json='[]' +mode="${1:-}" +[ "$#" -eq 1 ] || usage +case "$mode" in raw|table) ;; *) usage ;; esac + +addr=http://localhost:9428 +if [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ]; then + runtime_dir="$XDG_RUNTIME_DIR" +elif [ -d /dev/shm ]; then + runtime_dir=/dev/shm +else + runtime_dir=/tmp +fi + +state_dir="$runtime_dir/k9s-victoria-logs-port-forward" +session_dir="$state_dir/sessions" +pid_file="$state_dir/pid" +janitor_pid_file="$state_dir/janitor.pid" +port_forward_log="$state_dir/kubectl.log" +tail_log="$state_dir/tail.log" +session_file="$session_dir/$$" +mkdir -p "$session_dir" + +is_alive() { + [ -n "${1:-}" ] && kill -0 "$1" 2>/dev/null +} + +kill_tree() { + pid="${1:-}" + [ -n "$pid" ] || return 0 + for child in $(pgrep -P "$pid" 2>/dev/null || true); do + kill_tree "$child" + done + kill "$pid" 2>/dev/null || true +} + +is_victoria_logs_up() { + curl -fsS --max-time 1 "$addr/health" >/dev/null 2>&1 +} + +k9s_running() { + pgrep -x k9s >/dev/null 2>&1 +} + +release_session() { + rm -f "$session_file" +} + +start_janitor() { + k9s_running || return 0 + if [ -f "$janitor_pid_file" ]; then + janitor_pid="$(sed -n '1p' "$janitor_pid_file" 2>/dev/null || true)" + is_alive "$janitor_pid" && return 0 + fi + ( + while pgrep -x k9s >/dev/null 2>&1; do sleep 5; done + if [ -f "$pid_file" ]; then + pid="$(sed -n '1p' "$pid_file" 2>/dev/null || true)" + is_alive "$pid" && kill "$pid" 2>/dev/null || true + rm -f "$pid_file" + fi + rm -f "$janitor_pid_file" + ) >/dev/null 2>&1 & + printf '%s\n' "$!" >"$janitor_pid_file" +} + +start_port_forward() { + if [ -f "$pid_file" ]; then + pid="$(sed -n '1p' "$pid_file" 2>/dev/null || true)" + if is_alive "$pid" && is_victoria_logs_up; then return 0; fi + fi + is_victoria_logs_up && return 0 + : >"$port_forward_log" + if [ -n "$context" ]; then + kubectl --context "$context" port-forward -n victoria-metrics service/vlsingle-logs 9428:9428 --address localhost >>"$port_forward_log" 2>&1 & + else + kubectl port-forward -n victoria-metrics service/vlsingle-logs 9428:9428 --address localhost >>"$port_forward_log" 2>&1 & + fi + pf_pid="$!" + printf '%s\n' "$pf_pid" >"$pid_file" + i=0 + while [ "$i" -lt 50 ]; do + is_victoria_logs_up && return 0 + if ! is_alive "$pf_pid"; then + sed -n '1,200p' "$port_forward_log" >&2 + exit 1 + fi + i=$((i + 1)) + sleep 0.1 + done + sed -n '1,200p' "$port_forward_log" >&2 + exit 1 +} + +query_logs() { + curl -fsS --no-buffer --get "$addr/select/logsql/query" \ + --data-urlencode "query=$query" \ + --data-urlencode 'limit=10' \ + --data-urlencode 'start=24h' +} + +tail_logs() { + curl -fsS --no-buffer --get "$addr/select/logsql/tail" \ + --data-urlencode "query=$query" +} + +tail_logs_with_history() { + curl -fsS --no-buffer --get "$addr/select/logsql/tail" \ + --data-urlencode "query=$query" \ + --data-urlencode 'start_offset=1m' +} + +format_table() { + jq --unbuffered -r --argjson columns "$columns_json" ' + def clean: tostring | gsub("[|\\t\\r\\n]"; " "); + [$columns[] as $key + | .[$key] + | if . == null then "" + elif type == "object" or type == "array" then tojson + else tostring end + | clean] + | join(" | ") + ' +} + +format_raw() { + jq --unbuffered -c ' + with_entries(select(.key as $key + | ($key | startswith("kubernetes.") | not) + and (["_stream", "_stream_id", "_time", "app", "collector", "container", "namespace", "node", "pod", "source_type", "timestamp"] | index($key) | not) + )) + | .message = (._msg // .message // "") + | del(._msg) + | if .level then .level = (.level | tostring | ascii_upcase) else . end + ' +} + +discover_table_columns() { + columns_json="$(jq -sc --arg scope "$table_scope" ' + map(keys[]) | unique as $all + | ["_stream", "_stream_id", "_time", "app", "collector", "container", "namespace", "node", "pod", "source_type", "timestamp"] as $excluded + | (if $scope == "pod" then ["container"] + elif $scope == "deployment" or $scope == "namespace" then ["pod", "container"] + elif $scope == "node" then ["namespace", "pod", "container"] + else [] end) as $context + | [$context[] | select(. as $key | $all | index($key))] + + ([$all[] + | select(. as $key + | ($excluded | index($key) | not) + and ($context | index($key) | not) + and ($key | startswith("kubernetes.") | not))] + | sort_by(if . == "_msg" then "message" else . end)) + ' "$1")" +} + +table_header() { + jq -nr --argjson columns "$columns_json" ' + def clean: gsub("[|\\t\\r\\n]"; " "); + def header: + if . == "_time" then "TIME" + elif . == "_msg" then "MESSAGE" + else ltrimstr("_") | ascii_upcase end; + $columns | map(header | clean) | join(" | ") + ' +} + +run_table() { + tmp_file="$state_dir/logs.$$" + query_file="$state_dir/query.$$" + producer_pid="" + cleanup_table() { + if [ -n "${producer_pid:-}" ]; then + kill_tree "$producer_pid" + wait "$producer_pid" 2>/dev/null || true + producer_pid="" + fi + rm -f "$tmp_file" "$query_file" + } + trap 'cleanup_table; release_session' EXIT INT TERM + query_logs >"$query_file" + discover_table_columns "$query_file" + table_header >"$tmp_file" + format_table <"$query_file" >>"$tmp_file" + : >"$tail_log" + (tail_logs 2>>"$tail_log" | format_table >>"$tmp_file") 2>>"$tail_log" & + producer_pid="$!" + if command -v ov >/dev/null 2>&1; then + ov --follow-mode --align --column-mode --column-rainbow --column-delimiter '|' --header 1 --multi-color 'ERROR,FATAL,PANIC,WARN,WARNING,INFO,DEBUG,TRACE,UNKNOWN' "$tmp_file" + elif command -v less >/dev/null 2>&1; then + less -R +F "$tmp_file" + else + more "$tmp_file" + fi + cleanup_table +} + +run_raw() { + if command -v less >/dev/null 2>&1; then + tail_logs_with_history | format_raw | less -R +F + else + tail_logs_with_history | format_raw | more + fi +} + +: >"$session_file" +trap release_session EXIT INT TERM +start_port_forward +start_janitor + +case "$mode" in + raw) run_raw ;; + table) run_table ;; +esac diff --git a/dot_config/private_k9s/plugins/executable_victoria-tui-launcher b/dot_config/private_k9s/plugins/executable_victoria-tui-launcher new file mode 100644 index 0000000..54a2f79 --- /dev/null +++ b/dot_config/private_k9s/plugins/executable_victoria-tui-launcher @@ -0,0 +1,17 @@ +#!/bin/sh +set -eu + +config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins" +source_dir="$config_dir/victoria-tui" +binary="$config_dir/victoria-tui-bin" + +if [ ! -x "$binary" ] || find "$source_dir" -type f \( -name '*.go' -o -name 'go.mod' -o -name 'go.sum' \) -newer "$binary" -print -quit | grep -q .; then + temporary="$binary.$$" + trap 'rm -f "$temporary"' EXIT INT TERM + printf 'Building VictoriaLogs TUI...\n' >&2 + (cd "$source_dir" && go build -o "$temporary" .) + mv "$temporary" "$binary" + trap - EXIT INT TERM +fi + +exec "$binary" "$@" diff --git a/dot_config/private_k9s/plugins/log-victoria.yaml b/dot_config/private_k9s/plugins/log-victoria.yaml new file mode 100644 index 0000000..adffe3d --- /dev/null +++ b/dot_config/private_k9s/plugins/log-victoria.yaml @@ -0,0 +1,149 @@ +plugins: + victoria-container-raw: + shortCut: Shift-L + description: "VictoriaLogs raw" + scopes: [containers] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --container + - $NAMESPACE + - $POD + - $NAME + - raw + victoria-container-table: + shortCut: k + description: "VictoriaLogs table" + scopes: [containers] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --container + - $NAMESPACE + - $POD + - $NAME + - table + victoria-pods-raw: + shortCut: Shift-L + description: "VictoriaLogs raw" + scopes: [po] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --pod + - $NAMESPACE + - $NAME + - raw + victoria-pods-table: + shortCut: k + description: "VictoriaLogs table" + scopes: [po] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --pod + - $NAMESPACE + - $NAME + - table + victoria-deployments-raw: + shortCut: Shift-L + description: "VictoriaLogs raw" + scopes: [deployments] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --deployment + - $NAMESPACE + - $NAME + - raw + victoria-deployments-table: + shortCut: k + description: "VictoriaLogs table" + scopes: [deployments] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --deployment + - $NAMESPACE + - $NAME + - table + victoria-node-raw: + shortCut: Shift-L + description: "VictoriaLogs raw" + scopes: [node] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --node + - $NAME + - raw + victoria-node-table: + shortCut: k + description: "VictoriaLogs table" + scopes: [node] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --node + - $NAME + - table + victoria-ns-raw: + shortCut: Shift-L + description: "VictoriaLogs raw" + scopes: [namespace] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --namespace + - $NAME + - raw + victoria-ns-table: + shortCut: k + description: "VictoriaLogs table" + scopes: [namespace] + command: sh + background: false + args: + - -c + - exec "${XDG_CONFIG_HOME:-$HOME/.config}/k9s/plugins/victoria-tui-launcher" "$@" + - -- + - $CONTEXT + - --namespace + - $NAME + - table diff --git a/dot_config/private_k9s/plugins/victoria-tui/backend.go b/dot_config/private_k9s/plugins/victoria-tui/backend.go new file mode 100644 index 0000000..8d5854a --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/backend.go @@ -0,0 +1,100 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "net/http" + "os/exec" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +type backend struct { + context string + port int + baseURL string + mu sync.Mutex + cmd *exec.Cmd + done chan error +} + +func newBackend(kubeContext string) (*backend, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, err + } + port := listener.Addr().(*net.TCPAddr).Port + if err := listener.Close(); err != nil { + return nil, err + } + return &backend{context: kubeContext, port: port, baseURL: "http://127.0.0.1:" + strconv.Itoa(port)}, nil +} + +func (b *backend) ensure(ctx context.Context) error { + b.mu.Lock() + defer b.mu.Unlock() + if backendHealthy(b.baseURL) { + return nil + } + b.stopLocked() + args := []string{} + if b.context != "" { + args = append(args, "--context", b.context) + } + args = append(args, "port-forward", "-n", "victoria-metrics", "service/vlsingle-logs", fmt.Sprintf("%d:9428", b.port), "--address", "127.0.0.1") + cmd := exec.CommandContext(ctx, "kubectl", args...) + var stderr bytes.Buffer + cmd.Stdout, cmd.Stderr = io.Discard, &stderr + if err := cmd.Start(); err != nil { + return err + } + b.cmd, b.done = cmd, make(chan error, 1) + go func(done chan<- error) { done <- cmd.Wait() }(b.done) + for range 50 { + if backendHealthy(b.baseURL) { + return nil + } + select { + case err := <-b.done: + b.cmd, b.done = nil, nil + return fmt.Errorf("kubectl port-forward exited: %v: %s", err, strings.TrimSpace(stderr.String())) + case <-ctx.Done(): + b.stopLocked() + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + b.stopLocked() + return fmt.Errorf("VictoriaLogs port-forward did not become ready: %s", strings.TrimSpace(stderr.String())) +} + +func (b *backend) close() { b.mu.Lock(); defer b.mu.Unlock(); b.stopLocked() } +func (b *backend) stopLocked() { + if b.cmd == nil { + return + } + _ = b.cmd.Process.Signal(syscall.SIGTERM) + select { + case <-b.done: + case <-time.After(2 * time.Second): + _ = b.cmd.Process.Kill() + <-b.done + } + b.cmd, b.done = nil, nil +} + +func backendHealthy(baseURL string) bool { + client := http.Client{Timeout: 300 * time.Millisecond} + resp, err := client.Get(baseURL + "/health") + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode == http.StatusOK +} diff --git a/dot_config/private_k9s/plugins/victoria-tui/client.go b/dot_config/private_k9s/plugins/victoria-tui/client.go new file mode 100644 index 0000000..de8764a --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/client.go @@ -0,0 +1,100 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" +) + +const ( + initialSize = 10 + pageSize = 10 +) + +type entry map[string]any + +func (e entry) id() string { + return stringValue(e["_stream_id"]) + "\x00" + stringValue(e["_time"]) + "\x00" + stringValue(e["_msg"]) +} +func (e entry) timestamp() string { return stringValue(e["_time"]) } + +type client struct { + http *http.Client + baseURL string + query string +} + +func (c *client) page(ctx context.Context, end string, limit int) ([]entry, error) { + values := url.Values{"query": {c.query}, "limit": {strconv.Itoa(limit)}} + if end != "" { + values.Set("end", end) + } else { + values.Set("start", "1h") + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/select/logsql/query?"+values.Encode(), nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return nil, fmt.Errorf("VictoriaLogs query: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + return decodeEntries(resp.Body) +} + +func (c *client) tail(ctx context.Context, onEntry func(entry)) error { + values := url.Values{"query": {c.query}} + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/select/logsql/tail?"+values.Encode(), nil) + if err != nil { + return err + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return fmt.Errorf("VictoriaLogs tail: %s: %s", resp.Status, strings.TrimSpace(string(body))) + } + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + for scanner.Scan() { + var item entry + if json.Unmarshal(scanner.Bytes(), &item) == nil { + onEntry(item) + } + } + if err := scanner.Err(); err != nil { + return err + } + if ctx.Err() != nil { + return ctx.Err() + } + return io.ErrUnexpectedEOF +} + +func decodeEntries(r io.Reader) ([]entry, error) { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 64*1024), 4*1024*1024) + var entries []entry + for scanner.Scan() { + var item entry + if err := json.Unmarshal(scanner.Bytes(), &item); err != nil { + return nil, err + } + entries = append(entries, item) + } + return entries, scanner.Err() +} diff --git a/dot_config/private_k9s/plugins/victoria-tui/config.go b/dot_config/private_k9s/plugins/victoria-tui/config.go new file mode 100644 index 0000000..4a5c3d4 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/config.go @@ -0,0 +1,64 @@ +package main + +import ( + "errors" + "fmt" + "regexp" + "strconv" +) + +type config struct { + context string + scope string + query string + mode string +} + +func parseConfig(args []string) (config, error) { + if len(args) < 4 { + return config{}, errors.New("usage: victoria-tui <--container|--pod|--deployment|--node|--namespace> ") + } + cfg := config{context: args[0]} + selector := args[1] + values := args[2:] + quote := strconv.Quote + switch selector { + case "--container": + if len(values) != 4 { + return config{}, errors.New("container requires namespace, pod, container and mode") + } + cfg.scope, cfg.mode = "container", values[3] + cfg.query = "namespace:=" + quote(values[0]) + " and pod:=" + quote(values[1]) + " and container:=" + quote(values[2]) + case "--pod": + if len(values) != 3 { + return config{}, errors.New("pod requires namespace, pod and mode") + } + cfg.scope, cfg.mode = "pod", values[2] + cfg.query = "namespace:=" + quote(values[0]) + " and pod:=" + quote(values[1]) + case "--deployment": + if len(values) != 3 { + return config{}, errors.New("deployment requires namespace, name and mode") + } + cfg.scope, cfg.mode = "deployment", values[2] + pattern := "^" + regexp.QuoteMeta(values[1]) + "-[a-z0-9]+-[a-z0-9]+$" + cfg.query = "namespace:=" + quote(values[0]) + " and pod:~" + quote(pattern) + case "--node": + if len(values) != 2 { + return config{}, errors.New("node requires name and mode") + } + cfg.scope, cfg.mode = "node", values[1] + cfg.query = "node:=" + quote(values[0]) + case "--namespace": + if len(values) != 2 { + return config{}, errors.New("namespace requires name and mode") + } + cfg.scope, cfg.mode = "namespace", values[1] + cfg.query = "namespace:=" + quote(values[0]) + default: + return config{}, fmt.Errorf("unknown selector %q", selector) + } + if cfg.mode != "raw" && cfg.mode != "table" { + return config{}, fmt.Errorf("unknown mode %q", cfg.mode) + } + return cfg, nil +} diff --git a/dot_config/private_k9s/plugins/victoria-tui/format.go b/dot_config/private_k9s/plugins/victoria-tui/format.go new file mode 100644 index 0000000..355d075 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/format.go @@ -0,0 +1,182 @@ +package main + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +const ( + columnMarkerVisible = " [X] " + columnMarkerHidden = " [ ] " +) + +var metadataFields = map[string]bool{ + "_stream": true, "_stream_id": true, "_time": true, + "app": true, "collector": true, "container": true, + "namespace": true, "node": true, "pod": true, + "source_type": true, "timestamp": true, +} + +func columnMarker(hidden bool) string { + if hidden { + return tview.Escape(columnMarkerHidden) + } + return tview.Escape(columnMarkerVisible) +} + +// toggleAllColumns shows every discovered column if one is hidden; otherwise it hides all. +func toggleAllColumns(columns []string, hidden map[string]bool) { + for _, column := range columns { + if hidden[column] { + clear(hidden) + return + } + } + for _, column := range columns { + hidden[column] = true + } +} + +func discoverColumns(entries []entry, scope string) []string { + all := make(map[string]bool) + for _, item := range entries { + for key := range item { + all[key] = true + } + } + var contextColumns []string + switch scope { + case "pod": + contextColumns = []string{"container"} + case "deployment", "namespace": + contextColumns = []string{"pod", "container"} + case "node": + contextColumns = []string{"namespace", "pod", "container"} + } + columns := make([]string, 0, len(all)) + for _, key := range contextColumns { + if all[key] { + columns = append(columns, key) + } + } + var application []string + for key := range all { + if metadataFields[key] || strings.HasPrefix(key, "kubernetes.") || contains(contextColumns, key) { + continue + } + application = append(application, key) + } + sort.Slice(application, func(i, j int) bool { + left, right := application[i], application[j] + if left == "_msg" { + left = "message" + } + if right == "_msg" { + right = "message" + } + return left < right + }) + return append(columns, application...) +} + +func mergeColumns(current, discovered []string) []string { + merged := append([]string(nil), current...) + for _, column := range discovered { + if !contains(merged, column) { + merged = append(merged, column) + } + } + return merged +} +func visibleColumns(columns []string, hidden map[string]bool) []string { + visible := make([]string, 0, len(columns)) + for _, column := range columns { + if !hidden[column] { + visible = append(visible, column) + } + } + return visible +} + +func sourceJSON(item entry) string { + clean := make(entry) + for key, value := range item { + if metadataFields[key] || strings.HasPrefix(key, "kubernetes.") { + continue + } + if key == "_msg" { + clean["message"] = value + continue + } + if key == "level" { + clean[key] = strings.ToUpper(stringValue(value)) + continue + } + clean[key] = value + } + data, _ := json.Marshal(clean) + return string(data) +} + +func displayValue(value any) string { + if value == nil { + return "" + } + switch typed := value.(type) { + case map[string]any, []any: + data, _ := json.Marshal(typed) + return strings.ReplaceAll(string(data), "|", " ") + default: + return strings.NewReplacer("|", " ", "\n", " ", "\r", " ", "\t", " ").Replace(fmt.Sprint(value)) + } +} +func header(name string) string { + if name == "_msg" { + return "MESSAGE" + } + return strings.ToUpper(strings.TrimLeft(name, "_")) +} + +func levelColor(level string) tcell.Color { + switch strings.ToLower(level) { + case "panic", "fatal": + return tcell.NewRGBColor(255, 85, 85) + case "error": + return tcell.NewRGBColor(255, 140, 100) + case "warn", "warning": + return tcell.NewRGBColor(255, 215, 80) + case "info": + return tcell.NewRGBColor(100, 220, 255) + case "debug", "trace": + return tcell.NewRGBColor(175, 185, 205) + default: + return tcell.NewRGBColor(205, 180, 255) + } +} + +func contains(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} +func stringValue(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} +func onOff(enabled bool) string { + if enabled { + return "[green]On[-]" + } + return "[gray]Off[-]" +} +func shouldPrefetch(offset, visibleRows int) bool { return offset <= visibleRows } diff --git a/dot_config/private_k9s/plugins/victoria-tui/go.mod b/dot_config/private_k9s/plugins/victoria-tui/go.mod new file mode 100644 index 0000000..2348712 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/go.mod @@ -0,0 +1,18 @@ +module victoria-tui + +go 1.26 + +require ( + github.com/gdamore/tcell/v2 v2.8.1 + github.com/rivo/tview v0.42.0 +) + +require ( + github.com/gdamore/encoding v1.0.1 // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sys v0.29.0 // indirect + golang.org/x/term v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect +) diff --git a/dot_config/private_k9s/plugins/victoria-tui/go.sum b/dot_config/private_k9s/plugins/victoria-tui/go.sum new file mode 100644 index 0000000..8afde65 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/go.sum @@ -0,0 +1,80 @@ +github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= +github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= +github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= +github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= +golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= +golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/dot_config/private_k9s/plugins/victoria-tui/main.go b/dot_config/private_k9s/plugins/victoria-tui/main.go new file mode 100644 index 0000000..c07e6af --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/main.go @@ -0,0 +1,37 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + "os/signal" + "syscall" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func main() { + tview.Styles.PrimitiveBackgroundColor = tcell.ColorDefault + cfg, err := parseConfig(os.Args[1:]) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(2) + } + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + backend, err := newBackend(cfg.context) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + defer backend.close() + defer cancel() + httpClient := &http.Client{Transport: &http.Transport{MaxIdleConns: 4, MaxIdleConnsPerHost: 4}} + v := newViewer(cfg, &client{http: httpClient, baseURL: backend.baseURL, query: cfg.query}, backend, cancel) + if err := v.run(ctx); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + cancel() +} diff --git a/dot_config/private_k9s/plugins/victoria-tui/main_test.go b/dot_config/private_k9s/plugins/victoria-tui/main_test.go new file mode 100644 index 0000000..539bac7 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/main_test.go @@ -0,0 +1,272 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +func TestTailStreamsOnlyNewEntries(t *testing.T) { + var startOffset string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + startOffset = r.URL.Query().Get("start_offset") + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + c := &client{http: server.Client(), baseURL: server.URL, query: "*"} + _ = c.tail(context.Background(), func(entry) {}) + if startOffset != "" { + t.Fatalf("tail unexpectedly requested history: %q", startOffset) + } +} + +func TestParseDeployment(t *testing.T) { + cfg, err := parseConfig([]string{"cluster", "--deployment", "habits", "habits-api", "table"}) + if err != nil { + t.Fatal(err) + } + if cfg.scope != "deployment" || cfg.mode != "table" { + t.Fatalf("unexpected config: %#v", cfg) + } + if !strings.Contains(cfg.query, `pod:~"^habits-api-[a-z0-9]+-[a-z0-9]+$"`) { + t.Fatalf("query is not rollout-safe: %s", cfg.query) + } +} + +func TestSourceJSONExcludesMetadata(t *testing.T) { + item := entry{ + "_msg": "api request slow", "_time": "now", "_stream": "stream", + "namespace": "habits", "pod": "habits-api-x", "container": "habits-api", + "kubernetes.pod_node_name": "odin", "duration_ms": "157", "level": "warn", + "method": "GET", "path": "/api/punishments/status", "status": "200", + } + got := sourceJSON(item) + for _, excluded := range []string{"_time", "_stream", "namespace", "pod", "container", "kubernetes"} { + if strings.Contains(got, excluded) { + t.Fatalf("output contains %q: %s", excluded, got) + } + } + for _, expected := range []string{`"message":"api request slow"`, `"level":"WARN"`, `"method":"GET"`} { + if !strings.Contains(got, expected) { + t.Fatalf("output lacks %q: %s", expected, got) + } + } +} + +func TestColumnsKeepScopeContext(t *testing.T) { + items := []entry{{"pod": "p", "container": "c", "_msg": "hello", "level": "info", "kubernetes.pod_name": "p"}} + columns := discoverColumns(items, "deployment") + got := strings.Join(columns, ",") + if got != "pod,container,level,_msg" { + t.Fatalf("unexpected columns: %s", got) + } +} + +func TestNewFieldsAppendWithoutReorderingColumns(t *testing.T) { + current := []string{"pod", "container", "level", "_msg"} + entries := []entry{{"pod": "p", "container": "c", "level": "info", "_msg": "hello", "trace_id": "abc"}} + got := strings.Join(mergeColumns(current, discoverColumns(entries, "deployment")), ",") + if got != "pod,container,level,_msg,trace_id" { + t.Fatalf("new field did not append stably: %s", got) + } +} + +func TestPausedViewAppendsWithoutMovingSelection(t *testing.T) { + v := newViewer(config{scope: "deployment", mode: "table"}, nil, nil, func() {}) + v.following = false + v.addEntries([]entry{ + {"_stream_id": "1", "_time": "2026-01-01T00:00:00Z", "_msg": "first"}, + {"_stream_id": "2", "_time": "2026-01-01T00:00:01Z", "_msg": "second"}, + }, false) + oldStart := v.entryStartRow() + v.table.Select(oldStart, 0) + v.table.SetOffset(1, 0) + v.addEntries([]entry{{"_stream_id": "3", "_time": "2026-01-01T00:00:02Z", "_msg": "third"}}, false) + row, _ := v.table.GetSelection() + if row != v.entryStartRow() { + t.Fatalf("selection jumped from paused row: %d", row) + } + if len(v.entries) != 3 { + t.Fatalf("live entry was not appended: %d entries", len(v.entries)) + } + offset, _ := v.table.GetOffset() + expectedOffset := 1 + v.entryStartRow() - oldStart + if offset != expectedOffset { + t.Fatalf("viewport shifted while paused: %d", offset) + } +} + +func TestLiveEntriesAlwaysAppend(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "raw"}, nil, nil, func() {}) + v.addEntries([]entry{{"_stream_id": "1", "_time": "2026-01-01T00:00:10Z", "_msg": "existing"}}, true) + v.addEntries([]entry{{"_stream_id": "2", "_time": "2026-01-01T00:00:01Z", "_msg": "delayed live"}}, false) + if got := stringValue(v.entries[len(v.entries)-1]["_msg"]); got != "delayed live" { + t.Fatalf("live entry was not appended: %s", got) + } +} + +func TestVisibleColumns(t *testing.T) { + got := strings.Join(visibleColumns([]string{"pod", "container", "level"}, map[string]bool{"container": true}), ",") + if got != "pod,level" { + t.Fatalf("unexpected visible columns: %s", got) + } +} + +func TestToggleAllColumns(t *testing.T) { + columns := []string{"pod", "container", "level"} + hidden := map[string]bool{"container": true} + toggleAllColumns(columns, hidden) + if len(hidden) != 0 { + t.Fatalf("hidden columns were not shown: %#v", hidden) + } + toggleAllColumns(columns, hidden) + for _, column := range columns { + if !hidden[column] { + t.Fatalf("column %q was not hidden: %#v", column, hidden) + } + } +} + +func TestColumnMarkersHaveFixedWidth(t *testing.T) { + if len(columnMarkerVisible) != len(columnMarkerHidden) { + t.Fatalf("marker widths differ: %q and %q", columnMarkerVisible, columnMarkerHidden) + } + visible, hidden := tview.TaggedStringWidth(columnMarker(false)), tview.TaggedStringWidth(columnMarker(true)) + if visible != hidden || visible != 5 { + t.Fatalf("rendered marker widths differ: %d and %d", visible, hidden) + } +} + +func TestNewLiveIndicatorClearsWhenBottomIsVisible(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "raw"}, nil, nil, func() {}) + items := make([]entry, 12) + for i := range items { + items[i] = entry{"_stream_id": fmt.Sprint(i), "_time": fmt.Sprintf("2026-01-01T00:00:%02dZ", i), "_msg": fmt.Sprint(i)} + } + v.addEntries(items, true) + v.following = false + v.table.Select(0, 0) + v.table.SetOffset(0, 0) + v.addEntries([]entry{{"_stream_id": "live", "_time": "2026-01-01T00:01:00Z", "_msg": "live"}}, false) + if !strings.Contains(v.status.GetText(true), "↓ new logs") { + t.Fatal("new-live indicator is missing") + } + v.table.SetOffset(3, 0) + v.updateStatus("") + if strings.Contains(v.status.GetText(true), "↓ new logs") { + t.Fatal("new-live indicator remained after the last row became visible") + } +} + +func TestPausedStatusIsNotShown(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "raw"}, nil, nil, func() {}) + v.loading = false + v.connection = "connected" + v.following = false + v.updateStatus("") + if strings.Contains(v.status.GetText(true), "paused") { + t.Fatal("paused status is visible") + } +} + +func TestWrapModeIsRawOnlyAndOffByDefault(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "raw"}, nil, nil, func() {}) + v.loading = false + v.connection = "connected" + v.wrapWidth = 20 + v.entries = []entry{{"_stream_id": "1", "_msg": "a message long enough to wrap across several display rows"}} + v.rebuild() + v.updateStatus("") + if v.wrap || !strings.Contains(v.topBar.GetText(true), "Wrap:Off") || !strings.Contains(v.status.GetText(true), "s/c/t/w") || strings.Contains(v.status.GetText(true), "h columns") { + t.Fatal("raw wrap control is not shown as off by default") + } + if event := v.handleKey(tcell.NewEventKey(tcell.KeyRune, 'h', tcell.ModNone)); event == nil { + t.Fatal("column menu key was handled in raw mode") + } + v.handleKey(tcell.NewEventKey(tcell.KeyRune, 'w', tcell.ModNone)) + if !v.wrap || v.renderedRowCount() <= len(v.entries) { + t.Fatal("wrap mode did not create additional display rows") + } + v.columnsOn = true + v.updateStatus("") + if strings.Contains(v.topBar.GetText(true), "Wrap:") || strings.Contains(v.status.GetText(true), "/w") || !strings.Contains(v.status.GetText(true), "h columns") { + t.Fatal("wrap controls are visible in column mode") + } + if event := v.handleKey(tcell.NewEventKey(tcell.KeyRune, 'w', tcell.ModNone)); event == nil || !v.wrap { + t.Fatal("wrap key was handled in column mode") + } +} + +func TestWrappedSelectionCoversEntryAndArrowsJumpEntries(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "raw"}, nil, nil, func() {}) + v.wrap = true + v.wrapWidth = 24 + v.entries = []entry{ + {"_stream_id": "1", "_msg": "first message long enough to occupy multiple rows"}, + {"_stream_id": "2", "_msg": "second message long enough to occupy multiple rows"}, + } + v.rebuild() + firstRow := v.rowForEntry(0) + v.table.Select(firstRow, 0) + _, _, selectedAttrs := v.selected.Decompose() + for row := firstRow; row < v.rowForEntry(1); row++ { + _, _, attrs := v.table.GetCell(row, 0).Style.Decompose() + if attrs != selectedAttrs { + t.Fatalf("wrapped row %d is not selected", row) + } + } + v.handleKey(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)) + row, _ := v.table.GetSelection() + want := v.rowForEntry(1) + v.entryRowCount(1) - 1 + if row != want { + t.Fatalf("down did not reveal the complete wrapped entry: got %d, want %d", row, want) + } +} + +func TestBackendUsesRandomLocalPort(t *testing.T) { + backend, err := newBackend("test") + if err != nil { + t.Fatal(err) + } + if backend.port <= 1024 || !strings.HasPrefix(backend.baseURL, "http://127.0.0.1:") { + t.Fatalf("unexpected backend address: %s", backend.baseURL) + } +} + +func TestMouseOffsetPrefetchesBeforeTop(t *testing.T) { + if !shouldPrefetch(20, 24) { + t.Fatal("expected prefetch within one viewport of the top") + } + if shouldPrefetch(25, 24) { + t.Fatal("prefetched too early") + } +} + +func TestFooterOrderAndConnectionStatus(t *testing.T) { + v := newViewer(config{scope: "pod", mode: "table"}, nil, nil, func() {}) + v.loading = false + v.connection = "connected :45678" + v.entries = make([]entry, 12) + v.updateStatus("") + text := v.status.GetText(true) + keys := strings.Index(text, "q quit") + count := strings.Index(text, "12 logs") + status := strings.Index(text, "following") + if keys < 0 || count <= keys || status <= count { + t.Fatalf("unexpected footer order: %s", text) + } + if strings.Contains(text, "connected") { + t.Fatalf("normal connection state leaked into footer: %s", text) + } + v.connection = "reconnecting" + v.updateStatus("") + if !strings.Contains(v.status.GetText(true), "reconnecting") { + t.Fatal("reconnecting state missing from footer") + } +} diff --git a/dot_config/private_k9s/plugins/victoria-tui/viewer.go b/dot_config/private_k9s/plugins/victoria-tui/viewer.go new file mode 100644 index 0000000..5f168a5 --- /dev/null +++ b/dot_config/private_k9s/plugins/victoria-tui/viewer.go @@ -0,0 +1,623 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sort" + "strings" + "time" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" +) + +type viewer struct { + app *tview.Application + table *tview.Table + topBar *tview.TextView + status *tview.TextView + root *tview.Flex + pages *tview.Pages + client *client + backend *backend + config config + entries []entry + seen map[string]bool + columns []string + hidden map[string]bool + loading bool + hasMore bool + following bool + columnsOn bool + timestamps bool + wrap bool + ready bool + rebuilding bool + ctx context.Context + cancel context.CancelFunc + connection string + newBelow bool + wrapWidth int + selected tcell.Style +} + +func newViewer(cfg config, c *client, backend *backend, cancel context.CancelFunc) *viewer { + selected := tcell.StyleDefault.Reverse(true).Bold(true) + v := &viewer{app: tview.NewApplication(), table: tview.NewTable().SetSelectable(true, true).SetSelectedStyle(selected), topBar: tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter), status: tview.NewTextView().SetDynamicColors(true), client: c, backend: backend, config: cfg, seen: make(map[string]bool), hidden: make(map[string]bool), loading: true, hasMore: true, following: true, columnsOn: cfg.mode == "table", cancel: cancel, selected: selected} + v.table.SetBorder(false) + v.table.SetSelectionChangedFunc(func(row, _ int) { + if v.rebuilding { + return + } + v.styleRawSelection(v.entryIndexAtRow(row)) + v.following = row >= v.lastRow() + if v.ready && row <= v.prefetchRow() { + v.loadOlder(v.ctx) + } + v.updateStatus("") + }) + v.table.SetInputCapture(v.handleKey) + v.table.SetMouseCapture(func(action tview.MouseAction, event *tcell.EventMouse) (tview.MouseAction, *tcell.EventMouse) { + if action == tview.MouseScrollUp || action == tview.MouseScrollDown { + if action == tview.MouseScrollUp { + v.following = false + } + go v.app.QueueUpdateDraw(func() { + v.prefetchFromOffset() + v.updateStatus("") + }) + } + return action, event + }) + v.root = tview.NewFlex().SetDirection(tview.FlexRow).AddItem(v.topBar, 1, 0, false).AddItem(v.table, 0, 1, true).AddItem(v.status, 1, 0, false) + v.table.SetBackgroundColor(tcell.ColorDefault) + v.topBar.SetBackgroundColor(tcell.ColorDefault) + v.status.SetBackgroundColor(tcell.ColorDefault) + v.root.SetBackgroundColor(tcell.ColorDefault) + v.pages = tview.NewPages().AddPage("logs", v.root, true, true) + v.pages.SetBackgroundColor(tcell.ColorDefault) + v.app.SetRoot(v.pages, true).EnableMouse(true) + v.app.SetBeforeDrawFunc(func(screen tcell.Screen) bool { + width, _ := screen.Size() + if v.wrap && !v.columnsOn && width != v.wrapWidth { + row, _ := v.table.GetSelection() + index := v.entryIndexAtRow(row) + v.wrapWidth = width + v.rebuildKeepingSelection(index) + } + return false + }) + v.connection = "waiting for port-forward" + v.updateStatus("loading recent logs") + return v +} + +func (v *viewer) run(ctx context.Context) error { v.ctx = ctx; go v.connect(ctx); return v.app.Run() } +func (v *viewer) connect(ctx context.Context) { + initialized := false + for ctx.Err() == nil { + if initialized { + v.setConnection("reconnecting") + } else { + v.setConnection(fmt.Sprintf("opening port-forward :%d", v.backend.port)) + } + if err := v.backend.ensure(ctx); err != nil { + if ctx.Err() != nil { + return + } + v.setConnection("reconnecting: " + err.Error()) + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + continue + } + } + v.setConnection(fmt.Sprintf("connected :%d", v.backend.port)) + if !initialized { + initialized = true + go v.loadInitial(ctx) + } + err := v.client.tail(ctx, func(item entry) { v.app.QueueUpdateDraw(func() { v.addEntries([]entry{item}, false) }) }) + if ctx.Err() != nil || errors.Is(err, context.Canceled) { + return + } + } +} +func (v *viewer) setConnection(status string) { + v.app.QueueUpdateDraw(func() { v.connection = status; v.updateStatus("") }) +} +func (v *viewer) loadInitial(ctx context.Context) { + items, err := v.client.page(ctx, "", initialSize) + v.app.QueueUpdateDraw(func() { + v.loading = false + if err != nil { + v.updateStatus(err.Error()) + return + } + v.hasMore = true + v.addEntries(items, true) + v.following = true + v.table.Select(v.lastRow(), 0) + v.ready = true + v.updateStatus("") + if v.renderedRowCount() < v.visibleDataRows() { + v.loadOlder(ctx) + } + }) +} +func (v *viewer) loadOlder(ctx context.Context) { + if v.loading || !v.hasMore { + return + } + v.loading = true + end := time.Now().UTC().Format(time.RFC3339Nano) + if len(v.entries) > 0 { + end = v.entries[0].timestamp() + } + v.updateStatus("loading older logs") + go func() { + items, err := v.client.page(ctx, end, pageSize) + v.app.QueueUpdateDraw(func() { + v.loading = false + if err != nil { + v.updateStatus(err.Error()) + return + } + v.hasMore = len(items) == pageSize + v.addEntries(items, true) + v.updateStatus("") + if v.hasMore && v.renderedRowCount() < v.visibleDataRows() { + v.loadOlder(ctx) + } + }) + }() +} + +func (v *viewer) addEntries(items []entry, older bool) { + selectedID := "" + selectedLine := 0 + row, _ := v.table.GetSelection() + offsetRow, offsetColumn := v.table.GetOffset() + oldEntryStart := v.entryStartRow() + oldRenderedRows := v.renderedRowCount() + if index := v.entryIndexAtRow(row); index >= 0 { + selectedID = v.entries[index].id() + selectedLine = row - v.rowForEntry(index) + } + fresh := make([]entry, 0, len(items)) + for _, item := range items { + id := item.id() + if v.seen[id] { + continue + } + v.seen[id] = true + fresh = append(fresh, item) + } + added := len(fresh) + if added == 0 { + return + } + if older { + sort.SliceStable(fresh, func(i, j int) bool { return fresh[i].timestamp() < fresh[j].timestamp() }) + v.entries = append(fresh, v.entries...) + } else { + v.entries = append(v.entries, fresh...) + } + v.rebuild() + if v.following && !older { + v.newBelow = false + v.table.Select(v.lastRow(), 0) + v.styleRawSelection(v.entryIndexAtRow(v.lastRow())) + v.updateStatus("") + return + } + if selectedID != "" { + for i, item := range v.entries { + if item.id() == selectedID { + lines := v.entryRowCount(i) + if selectedLine >= lines { + selectedLine = lines - 1 + } + v.table.Select(v.rowForEntry(i)+selectedLine, 0) + break + } + } + } + offsetRow += v.entryStartRow() - oldEntryStart + if older { + offsetRow += v.renderedRowCount() - oldRenderedRows + } + if offsetRow < 0 { + offsetRow = 0 + } + v.table.SetOffset(offsetRow, offsetColumn) + if !older && !v.following { + v.newBelow = !v.bottomVisible() + } + selectedRow, _ := v.table.GetSelection() + v.styleRawSelection(v.entryIndexAtRow(selectedRow)) + v.updateStatus("") +} + +func (v *viewer) rebuild() { + v.rebuilding = true + defer func() { v.rebuilding = false }() + v.table.Clear() + v.table.SetFixed(v.headerRows(), 0) + entryStart := v.entryStartRow() + for row := v.headerRows(); row < entryStart; row++ { + v.table.SetCell(row, 0, tview.NewTableCell(" ").SetSelectable(false)) + } + if !v.columnsOn { + row := entryStart + for _, item := range v.entries { + for _, line := range v.rawLines(item) { + v.table.SetCell(row, 0, tview.NewTableCell(line).SetExpansion(1)) + row++ + } + } + return + } + v.columns = mergeColumns(v.columns, discoverColumns(v.entries, v.config.scope)) + renderColumns := visibleColumns(v.columns, v.hidden) + if v.timestamps { + renderColumns = append([]string{"_time"}, renderColumns...) + } + for column, name := range renderColumns { + v.table.SetCell(0, column, tview.NewTableCell(header(name)).SetSelectable(false).SetAttributes(tcell.AttrBold)) + } + for row, item := range v.entries { + for column, name := range renderColumns { + cell := tview.NewTableCell(displayValue(item[name])) + if name == "level" { + cell.SetTextColor(levelColor(stringValue(item[name]))) + } + v.table.SetCell(row+entryStart, column, cell) + } + } +} + +func (v *viewer) handleKey(event *tcell.EventKey) *tcell.EventKey { + row, column := v.table.GetSelection() + entryIndex := v.entryIndexAtRow(row) + switch event.Key() { + case tcell.KeyCtrlC: + v.cancel() + v.app.Stop() + return nil + case tcell.KeyHome: + v.following = false + v.table.Select(v.entryStartRow(), column) + v.loadOlder(v.ctx) + return nil + case tcell.KeyEnd: + v.following = true + v.newBelow = false + v.table.Select(v.lastRow(), column) + v.updateStatus("") + return nil + case tcell.KeyUp, tcell.KeyPgUp: + v.following = false + if row <= v.prefetchRow() { + v.loadOlder(v.ctx) + } + if event.Key() == tcell.KeyUp && v.wrap && !v.columnsOn { + if entryIndex > 0 { + v.table.Select(v.rowForEntry(entryIndex-1), column) + } + return nil + } + case tcell.KeyDown: + if v.wrap && !v.columnsOn { + if entryIndex >= 0 && entryIndex+1 < len(v.entries) { + next := entryIndex + 1 + v.table.Select(v.rowForEntry(next)+v.entryRowCount(next)-1, column) + } + return nil + } + } + switch event.Rune() { + case 'q': + v.cancel() + v.app.Stop() + return nil + case 'g': + v.following = false + v.table.Select(v.entryStartRow(), column) + v.loadOlder(v.ctx) + return nil + case 'G': + v.following = true + v.newBelow = false + v.table.Select(v.lastRow(), column) + v.updateStatus("") + return nil + case 's': + v.following = !v.following + if v.following { + v.newBelow = false + v.table.Select(v.lastRow(), column) + } + v.updateStatus("") + return nil + case 'c': + v.columnsOn = !v.columnsOn + v.rebuildKeepingSelection(entryIndex) + v.updateStatus("") + return nil + case 't': + v.timestamps = !v.timestamps + v.rebuildKeepingSelection(entryIndex) + v.updateStatus("") + return nil + case 'w': + if v.columnsOn { + return event + } + v.wrap = !v.wrap + v.rebuildKeepingSelection(entryIndex) + v.updateStatus("") + return nil + case 'h': + if !v.columnsOn { + return event + } + v.openColumnMenu() + return nil + } + return event +} +func (v *viewer) headerRows() int { + if v.columnsOn { + return 1 + } + return 0 +} +func (v *viewer) lastRow() int { + last := v.renderedRowCount() - 1 + v.entryStartRow() + if last < 0 { + return 0 + } + return last +} +func (v *viewer) updateStatus(message string) { + if v.newBelow && (v.following || v.bottomVisible()) { + v.newBelow = false + } + state := "" + if v.following { + state = "following" + } + if v.loading || v.connection == "waiting for port-forward" || strings.HasPrefix(v.connection, "opening port-forward") { + state = "loading" + } + if strings.HasPrefix(v.connection, "reconnecting") { + state = "reconnecting" + } + if message != "" && state != "reconnecting" { + state = message + } + wrapState := "" + wrapKey := "" + columnHelp := "" + if !v.columnsOn { + wrapState = " [::b]Wrap[::-]:" + onOff(v.wrap) + wrapKey = "/w" + } else { + columnHelp = " [::b]h[::-] columns" + } + v.topBar.SetText(fmt.Sprintf("[mediumorchid::b]Logs(%s)[tail][-::-] [::b]Autoscroll[::-]:%s [::b]ColumnMode[::-]:%s [::b]Timestamps[::-]:%s%s", v.config.scope, onOff(v.following), onOff(v.columnsOn), onOff(v.timestamps), wrapState)) + indicator := "" + if v.newBelow { + indicator = " · [green::b]↓ new logs[-::-]" + } + status := "" + if state != "" { + status = " · [yellow]" + tview.Escape(state) + "[-]" + } + v.status.SetText(fmt.Sprintf(" [::b]q[::-] quit [::b]↑/↓[::-] scroll [::b]g/G[::-] ends [::b]s/c/t%s[::-] toggle%s · [::b]%d logs[::-]%s%s", wrapKey, columnHelp, len(v.entries), indicator, status)) +} + +func (v *viewer) openColumnMenu() { + v.columns = mergeColumns(v.columns, discoverColumns(v.entries, v.config.scope)) + menu := tview.NewTable().SetSelectable(true, false).SetSelectedStyle(tcell.StyleDefault.Reverse(true).Bold(true)) + menu.SetBorder(true).SetTitle(" Columns ").SetBorderColor(tcell.ColorMediumPurple) + menu.SetBackgroundColor(tcell.ColorDefault) + menu.SetFixed(0, 1) + menu.SetEvaluateAllRows(true) + refresh := func() { + menu.Clear() + for row, column := range v.columns { + menu.SetCell(row, 0, tview.NewTableCell(columnMarker(v.hidden[column])).SetTextColor(tcell.ColorMediumPurple)) + menu.SetCell(row, 1, tview.NewTableCell(header(column)).SetExpansion(1)) + } + } + closeMenu := func() { v.pages.RemovePage("columns"); v.app.SetFocus(v.table) } + toggleRow := func() { + row, _ := menu.GetSelection() + if row >= 0 && row < len(v.columns) { + v.hidden[v.columns[row]] = !v.hidden[v.columns[row]] + refresh() + v.rebuild() + menu.Select(row, 0) + } + } + menu.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey { + switch event.Key() { + case tcell.KeyEscape: + closeMenu() + return nil + case tcell.KeyEnter: + toggleRow() + return nil + } + switch event.Rune() { + case ' ', 'x': + toggleRow() + return nil + case 'a': + toggleAllColumns(v.columns, v.hidden) + refresh() + v.rebuild() + return nil + case 'q', 'h': + closeMenu() + return nil + } + return event + }) + refresh() + menu.Select(0, 0) + help := tview.NewTextView().SetDynamicColors(true).SetTextAlign(tview.AlignCenter) + help.SetBackgroundColor(tcell.ColorDefault) + help.SetText("[::b]Space/Enter[::-] toggle [::b]a[::-] show/hide all [::b]Esc/q/h[::-] close") + panel := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(menu, 0, 1, true).AddItem(help, 1, 0, false) + panel.SetBackgroundColor(tcell.ColorDefault) + width, height := 44, len(v.columns)+3 + if height > 24 { + height = 24 + } + modal := tview.NewGrid().SetRows(0, height, 0).SetColumns(0, width, 0).AddItem(panel, 1, 1, 1, 1, 0, 0, true) + modal.SetBackgroundColor(tcell.ColorDefault) + v.pages.AddPage("columns", modal, true, true) + v.app.SetFocus(menu) +} + +func (v *viewer) prefetchRow() int { return v.entryStartRow() + v.visibleRows() } +func (v *viewer) prefetchFromOffset() { + row, _ := v.table.GetOffset() + if v.ready && shouldPrefetch(row, v.visibleRows()) { + v.loadOlder(v.ctx) + } +} +func (v *viewer) visibleRows() int { + _, _, _, height := v.table.GetInnerRect() + if height < 10 { + height = 10 + } + return height +} +func (v *viewer) visibleDataRows() int { + rows := v.visibleRows() - v.headerRows() + if rows < 1 { + return 1 + } + return rows +} +func (v *viewer) rawLines(item entry) []string { + text := sourceJSON(item) + if v.timestamps { + text = item.timestamp() + " " + text + } + if !v.wrap { + return []string{text} + } + width := v.wrapWidth + if width <= 0 { + _, _, width, _ = v.table.GetInnerRect() + } + if width <= 0 { + width = 80 + } + lines := tview.WordWrap(text, width) + if len(lines) == 0 { + return []string{""} + } + return lines +} +func (v *viewer) renderedRowCount() int { + if v.columnsOn { + return len(v.entries) + } + rows := 0 + for _, item := range v.entries { + rows += len(v.rawLines(item)) + } + return rows +} +func (v *viewer) entryRowCount(index int) int { + if index < 0 || index >= len(v.entries) { + return 0 + } + if v.columnsOn { + return 1 + } + return len(v.rawLines(v.entries[index])) +} +func (v *viewer) entryIndexAtRow(row int) int { + logicalRow := row - v.entryStartRow() + if logicalRow < 0 { + return -1 + } + if v.columnsOn { + if logicalRow < len(v.entries) { + return logicalRow + } + return -1 + } + for index, item := range v.entries { + lines := len(v.rawLines(item)) + if logicalRow < lines { + return index + } + logicalRow -= lines + } + return -1 +} +func (v *viewer) rowForEntry(index int) int { + if index < 0 { + index = 0 + } + if index > len(v.entries) { + index = len(v.entries) + } + row := v.entryStartRow() + if v.columnsOn { + return row + index + } + for _, item := range v.entries[:index] { + row += len(v.rawLines(item)) + } + return row +} +func (v *viewer) styleRawSelection(selectedIndex int) { + if v.columnsOn || !v.wrap { + return + } + normal := tcell.StyleDefault.Foreground(tview.Styles.PrimaryTextColor).Background(tview.Styles.PrimitiveBackgroundColor) + row := v.entryStartRow() + for index, item := range v.entries { + isSelected := index == selectedIndex + for range v.rawLines(item) { + cell := v.table.GetCell(row, 0) + if isSelected { + cell.SetStyle(v.selected).SetSelectedStyle(v.selected).SetTransparency(false) + } else { + cell.SetStyle(normal).SetSelectedStyle(v.selected).SetTransparency(true) + } + row++ + } + } +} +func (v *viewer) bottomVisible() bool { + offset, _ := v.table.GetOffset() + first := v.headerRows() + offset + return v.lastRow() < first+v.visibleDataRows() +} +func (v *viewer) entryStartRow() int { + padding := v.visibleDataRows() - v.renderedRowCount() + if padding < 0 { + padding = 0 + } + return v.headerRows() + padding +} +func (v *viewer) rebuildKeepingSelection(index int) { + if index < 0 { + index = 0 + } + v.rebuild() + v.table.Select(v.rowForEntry(index), 0) + v.styleRawSelection(index) +}