diff --git a/commands/logs.command.sh b/commands/logs.command.sh index 7957c63..90daaa1 100644 --- a/commands/logs.command.sh +++ b/commands/logs.command.sh @@ -22,19 +22,21 @@ function cmd::logs::on_load() { flag::register --event flag::register --ascending flag::register --descending + flag::register --resolved } function cmd::logs::help() { cat < Filter by client name --type Filter by device type @@ -49,7 +51,14 @@ Options for show: --detailed Show all deduplicated events (bypass hourly collapse) --follow, -f Follow logs in real time --raw Show raw IPs without service annotation - + --resolved Show only resolved names, hide raw IPs + --ascending Sort oldest first + --descending Sort newest first (default) + +Options for clean: + --wg Clean only WireGuard events (default: handshakes only) + --force Skip confirmation + Options for remove: --name Remove entries for specific peer --all Remove all log entries @@ -57,11 +66,11 @@ Options for remove: --wg Remove only WireGuard events --before Remove entries older than N days --force Skip confirmation - + Options for rotate: --days Days to keep (default: 7) --force Skip confirmation - + Examples: wgctl logs wgctl logs --since 2h @@ -73,8 +82,11 @@ Examples: wgctl logs --wg --event attempt wgctl logs --wg --event handshake --since 24h wgctl logs --detailed + wgctl logs --resolved wgctl logs --merged wgctl logs --follow + wgctl logs clean + wgctl logs clean --force wgctl logs remove --name phone-nuno wgctl logs rotate --days 30 EOF @@ -92,6 +104,7 @@ function cmd::logs::run() { show) cmd::logs::show "$@" ;; remove|rm|del) cmd::logs::remove "$@" ;; rotate) cmd::logs::rotate "$@" ;; + clean) cmd::logs::clean "$@" ;; help) cmd::logs::help ;; *) log::error "Unknown subcommand: '${subcmd}'" @@ -107,6 +120,7 @@ function cmd::logs::show() { local raw=false detailed=false local filter_service="" filter_event="" local sort_order="desc" + local resolved=false while [[ $# -gt 0 ]]; do case "$1" in @@ -121,6 +135,7 @@ function cmd::logs::show() { --merged) merged=true; shift ;; --follow|-f) follow=true; shift ;; --raw) raw=true; shift ;; + --resolved) resolved=true; shift ;; --detailed) detailed=true; shift ;; --help) cmd::logs::help; return ;; *) @@ -184,11 +199,11 @@ function cmd::logs::show() { $wg_only || fw_output=$(cmd::logs::show_fw_events \ "$filter_ip" "$name" "$type" "$limit" "$net_file" \ - "$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order") + "$collapse" "$since" "$filter_dest_ip" "$filter_dest_port" "$sort_order" "$resolved") $fw_only || wg_output=$(cmd::logs::show_wg_events \ "$filter_ip" "$name" "$type" "$limit" \ - "$collapse" "$since" "$filter_event" "$sort_order") + "$collapse" "$since" "$filter_event" "$sort_order" "$resolved") if [[ -z "$(echo "$fw_output" | tr -d '[:space:]')" && \ -z "$(echo "$wg_output" | tr -d '[:space:]')" ]]; then @@ -213,7 +228,7 @@ function cmd::logs::show_fw_events() { local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ limit="${4:-50}" net_file="${5:-}" collapse="${6:-1}" \ since="${7:-}" filter_dest_ip="${8:-}" filter_dest_port="${9:-}" \ - sort_order="${10:-desc}" + sort_order="${10:-desc}" resolved_only="${11:-false}" [[ ! -f "$FW_EVENTS_LOG" ]] && return 0 @@ -228,17 +243,29 @@ function cmd::logs::show_fw_events() { [[ -z "$data" ]] && return 0 - # ── Pass 1: resolve endpoints and measure widths ── + # ── Collect unique endpoints for batch resolution ── + local -a ep_list=() + while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do + [[ -z "$ts" || -z "$src_endpoint" ]] && continue + ep_list+=("$src_endpoint") + done <<< "$data" + + declare -A resolve_cache=() + if [[ ${#ep_list[@]} -gt 0 ]]; then + while IFS='|' read -r ip name; do + [[ -n "$ip" ]] && resolve_cache["$ip"]="$name" + done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null) + fi + + # ── Pass 1: measure widths ── local w_client=16 w_dest=20 w_endpoint=0 local resolved_data="" while IFS='|' read -r ts client dest_ip dest_port proto svc count src_endpoint; do [[ -z "$ts" ]] && continue - # Measure client (( ${#client} > w_client )) && w_client=${#client} - # Build svc_display (for w_dest measurement) local svc_display="" if [[ -n "$svc" ]]; then [[ -n "$dest_port" ]] && svc_display="${svc}/${proto}" \ @@ -248,32 +275,39 @@ function cmd::logs::show_fw_events() { || svc_display="${dest_ip} (${proto})" fi - # Build raw_suffix plain (no ANSI) for w_dest measurement - local raw_plain="" - if [[ -n "$svc" ]]; then - [[ -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" \ - || raw_plain=" (${dest_ip})" + local measure_len + if $resolved_only; then + measure_len=${#svc_display} + else + local raw_plain="" + [[ -n "$svc" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" + [[ -n "$svc" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})" + measure_len=$(( ${#svc_display} + ${#raw_plain} )) fi + (( measure_len > w_dest )) && w_dest=$measure_len - local full_dest_len=$(( ${#svc_display} + ${#raw_plain} )) - (( full_dest_len > w_dest )) && w_dest=$full_dest_len - - # Resolve endpoint once local src_resolved="" if [[ -n "$src_endpoint" ]]; then - src_resolved=$(resolve::ip "$src_endpoint" 2>/dev/null || true) + src_resolved="${resolve_cache[$src_endpoint]:-}" [[ "$src_resolved" == "$src_endpoint" ]] && src_resolved="" - # Measure endpoint column: raw IP + " → resolved" - local ep_display_len=${#src_endpoint} - [[ -n "$src_resolved" ]] && ep_display_len=$(( ep_display_len + 4 + ${#src_resolved} )) - (( ep_display_len > w_endpoint )) && w_endpoint=$ep_display_len + + local ep_measure_len + if $resolved_only; then + ep_measure_len=${#src_resolved} + [[ -z "$src_resolved" ]] && ep_measure_len=${#src_endpoint} + else + ep_measure_len=${#src_endpoint} + [[ -n "$src_resolved" ]] && \ + ep_measure_len=$(( ${#src_endpoint} + 4 + ${#src_resolved} )) + fi + (( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len fi resolved_data+="${ts}|${client}|${dest_ip}|${dest_port}|${proto}|${svc}|${count}|${src_endpoint}|${src_resolved}"$'\n' done <<< "$data" - (( w_client += 2 )) - (( w_dest += 2 )) + (( w_client += 2 )) + (( w_dest += 2 )) [[ "$w_endpoint" -gt 0 ]] && (( w_endpoint += 2 )) # ── Pass 2: render ── @@ -282,7 +316,7 @@ function cmd::logs::show_fw_events() { [[ -z "$ts" ]] && continue ui::logs::fw_row "$ts" "$client" "$dest_ip" "$dest_port" \ "$proto" "$svc" "$count" "$w_client" "$w_dest" \ - "$src_endpoint" "$src_resolved" "$w_endpoint" + "$src_endpoint" "$src_resolved" "$w_endpoint" "$resolved_only" done <<< "$resolved_data" printf "\n" } @@ -290,7 +324,8 @@ function cmd::logs::show_fw_events() { function cmd::logs::show_wg_events() { local filter_ip="${1:-}" filter_name="${2:-}" filter_type="${3:-}" \ limit="${4:-50}" collapse="${5:-1}" \ - since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" + since="${6:-}" filter_event="${7:-}" sort_order="${8:-desc}" \ + resolved_only="${9:-false}" [[ ! -f "$WG_EVENTS_LOG" ]] && return 0 @@ -300,34 +335,69 @@ function cmd::logs::show_wg_events() { "$limit" "$collapse" "$since" "$filter_event" \ "$(ctx::endpoint_cache)" "$sort_order" \ 2>/dev/null) - + [[ -z "$data" ]] && return 0 - - # Resolve endpoints and measure column widths + + # ── Collect unique endpoints for batch resolution ── + local -a ep_list=() + while IFS='|' read -r ts client endpoint event count gap_seconds; do + [[ -z "$ts" || -z "$endpoint" ]] && continue + ep_list+=("$endpoint") + done <<< "$data" + + declare -A resolve_cache=() + if [[ ${#ep_list[@]} -gt 0 ]]; then + while IFS='|' read -r ip name; do + [[ -n "$ip" ]] && resolve_cache["$ip"]="$name" + done < <(json::batch_resolve "${ep_list[@]}" 2>/dev/null) + fi + + # ── Measure widths ── local w_client=16 w_endpoint=16 local resolved_data="" + while IFS='|' read -r ts client endpoint event count gap_seconds; do [[ -z "$ts" ]] && continue (( ${#client} > w_client )) && w_client=${#client} - local ep_len=${#endpoint} - [[ -z "$endpoint" ]] && ep_len=1 - (( ep_len > w_endpoint )) && w_endpoint=$ep_len - - # Resolve endpoint + local resolved="" - [[ -n "$endpoint" ]] && resolved=$(resolve::ip "$endpoint" 2>/dev/null || true) - [[ "$resolved" == "$endpoint" ]] && resolved="" - + if [[ -n "$endpoint" ]]; then + resolved="${resolve_cache[$endpoint]:-}" + [[ "$resolved" == "$endpoint" ]] && resolved="" + fi + + local ep_raw="${endpoint:--}" + local ep_measure_len + if $resolved_only; then + local ep_display="${resolved:-$endpoint}" + [[ -z "$ep_display" ]] && ep_display="-" + ep_measure_len=${#ep_display} + else + ep_measure_len=${#ep_raw} + [[ -n "$resolved" && -n "$endpoint" ]] && \ + ep_measure_len=$(( ${#endpoint} + 4 + ${#resolved} )) + fi + (( ep_measure_len > w_endpoint )) && w_endpoint=$ep_measure_len + resolved_data+="${ts}|${client}|${endpoint}|${event}|${count}|${gap_seconds}|${resolved}"$'\n' done <<< "$data" + (( w_client += 2 )) - (( w_endpoint += 18 )) - + (( w_endpoint += 2 )) + + # ── Render ── ui::logs::wg_section_header while IFS='|' read -r ts client endpoint event count gap_seconds resolved; do [[ -z "$ts" ]] && continue - ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \ - "$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved" + if $resolved_only; then + local ep_display="${resolved:-$endpoint}" + [[ -z "$ep_display" ]] && ep_display="-" + ui::logs::wg_row "$ts" "$client" "$ep_display" "$event" \ + "$count" "$w_client" "$w_endpoint" "$gap_seconds" "" + else + ui::logs::wg_row "$ts" "$client" "$endpoint" "$event" \ + "$count" "$w_client" "$w_endpoint" "$gap_seconds" "$resolved" + fi done <<< "$resolved_data" printf "\n" } @@ -520,4 +590,31 @@ function cmd::logs::rotate() { fi log::wg_success "Rotated ${total} entries older than ${days} days (wg: ${removed_wg}, fw: ${removed_fw})" +} + +function cmd::logs::clean() { + local force=false wg_only=false + + while [[ $# -gt 0 ]]; do + case "$1" in + --force) force=true; shift ;; + --wg) wg_only=true; shift ;; + --help) cmd::logs::help; return ;; + *) log::error "Unknown flag: $1"; return 1 ;; + esac + done + + if ! $force; then + read -r -p "Remove keepalive handshakes from events.log? [y/N] " confirm + case "$confirm" in [yY]*) ;; *) log::info "Aborted"; return 0 ;; esac + fi + + local removed + removed=$(json::clean_handshakes "$WG_EVENTS_LOG" "${WG_HANDSHAKE_CHECK_TIME_SEC:-300}") + + if [[ "$removed" -eq 0 ]]; then + log::wg_warning "No keepalive handshakes found to remove" + else + log::wg_success "Removed ${removed} keepalive handshake entries" + fi } \ No newline at end of file diff --git a/core/json.sh b/core/json.sh index af26da4..2ae6b61 100644 --- a/core/json.sh +++ b/core/json.sh @@ -118,6 +118,9 @@ function json::hosts_remove() { python3 "$JSON_HELPER" hosts_remove "$@" = threshold: + kept.append(line) + last_ts[client] = ts + else: + removed += 1 + continue + except Exception: + pass + kept.append(line) + with open(file, 'w') as f: + f.writelines(kept) + print(removed) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + +def batch_resolve(hosts_file, net_file, *ips): + """ + Resolve multiple IPs at once. + Output: ip|resolved_name per line (resolved_name=ip if no match) + """ + from lib.util import load_hosts_data, load_net_data, hosts_lookup, reverse_lookup + hosts_data = load_hosts_data(hosts_file) + net_data = load_net_data(net_file) + seen = set() + for ip in ips: + if not ip or ip in seen: + continue + seen.add(ip) + name = hosts_lookup(hosts_data, ip) + if not name: + name = reverse_lookup(net_data, ip) + print(f"{ip}|{name or ''}") + # ── Rules (kept inline — candidates for lib/rules.py) ──────────────────── def find_rule_file(rules_dir, rule_name): @@ -1836,6 +1892,8 @@ commands = { 'hosts_remove': lambda args: hosts_remove(args[0], args[1], args[2]), 'hosts_exists': lambda args: hosts_exists(args[0], args[1], args[2]), 'hosts_lookup': lambda args: hosts_lookup(args[0], args[1]), + 'clean_handshakes': lambda args: clean_handshakes(args[0], args[1] if len(args) > 1 else '300'), + 'batch_resolve': lambda args: batch_resolve(args[0], args[1], *args[2:]), } # ── Main ───────────────────────────────────────────────────────────────────── diff --git a/modules/ui/logs.module.sh b/modules/ui/logs.module.sh index c2d479e..8c3d72a 100644 --- a/modules/ui/logs.module.sh +++ b/modules/ui/logs.module.sh @@ -38,52 +38,71 @@ function ui::logs::fw_row() { local ts="${1:-}" client="${2:-}" dest_ip="${3:-}" dest_port="${4:-}" \ proto="${5:-}" svc_name="${6:-}" count="${7:-1}" \ w_client="${8:-20}" w_dest="${9:-30}" \ - src_endpoint="${10:-}" src_resolved="${11:-}" w_endpoint="${12:-0}" + src_endpoint="${10:-}" src_resolved="${11:-}" \ + w_endpoint="${12:-0}" resolved_only="${13:-false}" local ts_pad client_pad ts_pad=$(printf "%-11s" "$ts") client_pad=$(printf "%-${w_client}s" "$client") - # ── Source endpoint — always render at w_endpoint width ── + # ── Source endpoint ── + # " → " = 5 bytes, 3 visible chars → overcount by _UI_ARROW_EXTRA=2 local src_padded="" if [[ "$w_endpoint" -gt 0 ]]; then + local src_colored src_visible_len pad_n + if [[ -n "$src_endpoint" ]]; then - local src_colored="$src_endpoint" - [[ -n "$src_resolved" ]] && \ - src_colored="${src_endpoint} \033[2m→ ${src_resolved}\033[0m" - src_padded=$(ui::pad_mb "$src_colored" "$w_endpoint") + if $resolved_only; then + src_colored="${src_resolved:-$src_endpoint}" + src_visible_len=${#src_colored} + else + if [[ -n "$src_resolved" ]]; then + src_colored="${src_endpoint} \033[2m→ ${src_resolved}\033[0m" + # bytes: endpoint + " → " (5 bytes, 3 visible) + resolved + # visible: endpoint + 3 + resolved + src_visible_len=$(( ${#src_endpoint} + 3 + ${#src_resolved} )) + else + src_colored="${src_endpoint}" + src_visible_len=${#src_endpoint} + fi + fi else - # No endpoint — use dim dash padded to w_endpoint - src_padded=$(ui::pad_mb "\033[2m—\033[0m" "$w_endpoint") + # — is 3 bytes, 1 visible char + src_colored="\033[2m—\033[0m" + src_visible_len=1 fi + + pad_n=$(( w_endpoint - src_visible_len )) + [[ $pad_n -lt 0 ]] && pad_n=0 + src_padded=$(printf "%b%*s" "$src_colored" "$pad_n" "") fi # ── Destination ── - local svc_display="" - local raw_suffix="" + local svc_display="" raw_suffix="" if [[ -n "$svc_name" ]]; then [[ -n "$dest_port" ]] && svc_display="${svc_name}/${proto}" \ || svc_display="${svc_name} (${proto})" - [[ -n "$dest_port" ]] && raw_suffix=" \033[2m(${dest_ip}:${dest_port})\033[0m" \ - || raw_suffix=" \033[2m(${dest_ip})\033[0m" + if ! $resolved_only; then + [[ -n "$dest_port" ]] && raw_suffix=" \033[2m(${dest_ip}:${dest_port})\033[0m" \ + || raw_suffix=" \033[2m(${dest_ip})\033[0m" + fi else [[ -n "$dest_port" ]] && svc_display="${dest_ip}:${dest_port}/${proto}" \ || svc_display="${dest_ip} (${proto})" fi - # Pad so count aligns — based on full dest (svc + raw_suffix plain length) local raw_plain="" - [[ -n "$svc_name" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" - [[ -n "$svc_name" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})" + if ! $resolved_only; then + [[ -n "$svc_name" && -n "$dest_port" ]] && raw_plain=" (${dest_ip}:${dest_port})" + [[ -n "$svc_name" && -z "$dest_port" ]] && raw_plain=" (${dest_ip})" + fi local full_dest_len=$(( ${#svc_display} + ${#raw_plain} )) local dest_pad_n=$(( w_dest - full_dest_len )) [[ $dest_pad_n -lt 0 ]] && dest_pad_n=0 - # ── Count ── local count_suffix="" [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m" - # ── Render ── if [[ "$w_endpoint" -gt 0 ]]; then printf " %s %s %b \033[1;31m→\033[0m %s%b%*s%b\n" \ "$ts_pad" "$client_pad" \ @@ -111,17 +130,17 @@ function ui::logs::wg_row() { local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \ count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \ gap_seconds="${8:-}" resolved="${9:-}" - + local event_color case "$event" in handshake) event_color="\033[1;32m" ;; attempt) event_color="\033[1;31m" ;; *) event_color="\033[0;37m" ;; esac - + local count_suffix="" [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m" - + # Gap suffix — offline label only when gap > threshold * 2 local gap_suffix="" if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then @@ -135,23 +154,28 @@ function ui::logs::wg_row() { gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m" fi fi - - # Build endpoint display: raw_ip [dim → resolved] - # Use ui::pad_mb so ANSI annotation doesn't affect column alignment + + # ── Endpoint — native padding, no ui::pad_mb ── + local endpoint_colored endpoint_visible_len local endpoint_raw="${endpoint:--}" - local endpoint_colored + if [[ -n "$resolved" && -n "$endpoint" ]]; then endpoint_colored="${endpoint} \033[2m→ ${resolved}\033[0m" + endpoint_visible_len=$(( ${#endpoint} + 4 + ${#resolved} - _UI_ARROW_EXTRA )) else endpoint_colored="$endpoint_raw" + # "-" is 1 char; endpoint may be empty + [[ -n "$endpoint" ]] && endpoint_visible_len=${#endpoint} \ + || endpoint_visible_len=1 fi - local endpoint_padded - endpoint_padded=$(ui::pad_mb "$endpoint_colored" "$w_endpoint") - + + local ep_pad_n=$(( w_endpoint - endpoint_visible_len )) + [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0 + local endpoint_padded=$(printf "%b%*s" "$endpoint_colored" "$ep_pad_n" "") local ts_pad client_pad ts_pad=$(printf "%-11s" "$ts") client_pad=$(printf "%-${w_client}s" "$client") - + printf " %s %s %b %b%s\033[0m%b%b\n" \ "$ts_pad" "$client_pad" \ "$endpoint_padded" \ @@ -191,9 +215,10 @@ function ui::watch::fw_row() { "$ts_pad" "$src" "$client_pad" "$dest_display" "$dest_pad_n" "" } -function ui::watch::wg_row() { +function ui::logs::wg_row() { local ts="${1:-}" client="${2:-}" endpoint="${3:-}" event="${4:-}" \ - w_client="${5:-20}" w_endpoint="${6:-18}" + count="${5:-1}" w_client="${6:-20}" w_endpoint="${7:-20}" \ + gap_seconds="${8:-}" resolved="${9:-}" local event_color case "$event" in @@ -202,23 +227,50 @@ function ui::watch::wg_row() { *) event_color="\033[0;37m" ;; esac - # "wg" is always 2 visible chars — no padding needed - local src="${_UI_WATCH_WG_COLOR}wg\033[0m" + local count_suffix="" + [[ "$count" -gt 1 ]] && count_suffix=" \033[2m(x${count})\033[0m" - # Use "-" not "—" to avoid multi-byte padding issues - local endpoint_display="${endpoint:--}" + local gap_suffix="" + if [[ "$event" == "handshake" && -n "$gap_seconds" && "$gap_seconds" -gt 0 ]]; then + local gap_int="$gap_seconds" + local threshold="${WG_HANDSHAKE_CHECK_TIME_SEC:-300}" + local offline_label="" + [[ "$gap_int" -gt $(( threshold * 2 )) ]] && offline_label=" offline" + if (( gap_int >= 3600 )); then + gap_suffix=" \033[2m↑ $(( gap_int / 3600 ))h${offline_label}\033[0m" + elif (( gap_int >= 60 )); then + gap_suffix=" \033[2m↑ $(( gap_int / 60 ))m${offline_label}\033[0m" + fi + fi - local ts_pad client_pad endpoint_pad_n + # ── Endpoint padding ── + # " → " = 5 bytes, 3 visible → visible = endpoint + 3 + resolved + local endpoint_colored endpoint_visible_len + if [[ -n "$resolved" && -n "$endpoint" ]]; then + endpoint_colored="${endpoint} \033[2m→ ${resolved}\033[0m" + endpoint_visible_len=$(( ${#endpoint} + 3 + ${#resolved} )) + elif [[ -n "$endpoint" ]]; then + endpoint_colored="${endpoint}" + endpoint_visible_len=${#endpoint} + else + endpoint_colored="-" + endpoint_visible_len=1 + fi + + local ep_pad_n=$(( w_endpoint - endpoint_visible_len )) + [[ $ep_pad_n -lt 0 ]] && ep_pad_n=0 + local endpoint_padded + endpoint_padded=$(printf "%b%*s" "$endpoint_colored" "$ep_pad_n" "") + + local ts_pad client_pad ts_pad=$(printf "%-11s" "$ts") client_pad=$(printf "%-${w_client}s" "$client") - endpoint_pad_n=$(( w_endpoint - ${#endpoint_display} )) - [[ $endpoint_pad_n -lt 0 ]] && endpoint_pad_n=0 - printf " %s %b %s %s%*s %b%s\033[0m\n" \ - "$ts_pad" "$src" "$client_pad" "$endpoint_display" "$endpoint_pad_n" "" \ - "$event_color" "$event" + printf " %s %s %b %b%s\033[0m%b%b\n" \ + "$ts_pad" "$client_pad" \ + "$endpoint_padded" \ + "$event_color" "$event" "$count_suffix" "$gap_suffix" } - function ui::watch::header_table() { printf "\n %-20s %-8s %-22s %-28s %-14s %s\n" \