From e4545a400bb6cd96a112548baa93409bcb667a2c Mon Sep 17 00:00:00 2001 From: Nuno Duque Nunes Date: Fri, 29 May 2026 23:34:13 +0000 Subject: [PATCH] feat: --exclude-service/--include-service, --ports flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - activity: --exclude-service (repeatable), --include-service override - activity: --ports flag shows dim raw IP:port on accept and drop rows - activity_aggregate: dst_ip/dst_port/proto in service row output - activity_aggregate: exclude_services filtering for drop rows - ui::activity::_visible_len: ANSI-aware padding for --ports alignment - service_row/accept_dest_row: correct padding with ANSI suffixes - accept_dest_row: fix ↓/↑ swap (bytes_reply=download, bytes_orig=upload) - command defaults: activity defaults with pihole exclusions --- commands/activity.command.sh | 61 ++++++++-- core/command.sh | 2 +- core/json_helper.py | 8 +- .../__pycache__/accept_events.cpython-311.pyc | Bin 13201 -> 14439 bytes core/lib/__pycache__/activity.cpython-311.pyc | Bin 7451 -> 8624 bytes core/lib/accept_events.py | 38 ++++++- core/lib/activity.py | 68 +++++++---- modules/ui/activity.module.sh | 106 ++++++++++-------- 8 files changed, 201 insertions(+), 82 deletions(-) diff --git a/commands/activity.command.sh b/commands/activity.command.sh index 2ff0c1d..dcf90a9 100644 --- a/commands/activity.command.sh +++ b/commands/activity.command.sh @@ -18,6 +18,9 @@ function cmd::activity::on_load() { flag::register --accept flag::register --drop flag::register --external + flag::register --ports + flag::register --exclude-service + flag::register --include-service flag::exclusive --accept --drop } @@ -59,7 +62,8 @@ EOF function cmd::activity::run() { local filter_peer="" filter_service="" filter_ip="" filter_type="" local hours=24 - local accept_only=false drop_only=false external_only=false + local accept_only=false drop_only=false external_only=false show_ports=false + local -a exclude_services=() include_services=() while [[ $# -gt 0 ]]; do case "$1" in @@ -71,6 +75,9 @@ function cmd::activity::run() { --accept) accept_only=true; shift ;; --drop) drop_only=true; shift ;; --external) external_only=true; shift ;; + --ports) show_ports=true; shift ;; + --exclude-service) exclude_services+=("$2"); shift 2 ;; + --include-service) include_services+=("$2"); shift 2 ;; --help) cmd::activity::help; return ;; *) log::error "Unknown flag: $1" @@ -96,6 +103,21 @@ function cmd::activity::run() { fi [[ -n "$filter_ip" ]] && service_ip="$filter_ip" + # Build final exclusion list — remove any --include-service entries + local -a final_excludes=() + for svc in "${exclude_services[@]:-}"; do + local included=false + for inc in "${include_services[@]:-}"; do + [[ "$svc" == "$inc" ]] && included=true && break + done + $included || final_excludes+=("$svc") + done + + # Build exclude string for Python (space-separated) + local exclude_str="" + [[ ${#final_excludes[@]} -gt 0 ]] && \ + exclude_str=$(IFS=' '; echo "${final_excludes[*]}") + # ── Fetch data ── local data="" if ! $accept_only; then @@ -103,7 +125,7 @@ function cmd::activity::run() { "$(ctx::fw_events_log)" "$(ctx::events_log)" \ "$(config::interface)" "$(ctx::net)" \ "$(ctx::clients)" "$(ctx::meta)" \ - "$hours" "$filter_peer" "$service_ip" 2>/dev/null) + "$hours" "$filter_peer" "$service_ip" "$exclude_str" 2>/dev/null) fi local accept_data="" @@ -114,7 +136,7 @@ function cmd::activity::run() { [[ -f "$(ctx::accept_events_log)" ]] && \ accept_data=$(json::accept_aggregate \ "$(ctx::accept_events_log)" "$(ctx::net)" "$(ctx::clients)" \ - "$since_arg" "$filter_peer" "$ext_flag" 2>/dev/null) + "$since_arg" "$filter_peer" "$ext_flag" "$exclude_str" 2>/dev/null) fi [[ -z "$data" && -z "$accept_data" ]] && \ @@ -204,7 +226,17 @@ function cmd::activity::run() { local d_port="${pp%%:*}" local d_proto="${pp##*:}" local spec="${d_ip}:${d_port}:${d_proto}" - local dest_display="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}" + local dest_display + local raw_suffix="" + local resolved="${_DEST_RESOLVE_CACHE[$spec]:-${d_ip}:${d_port}/${d_proto}}" + local dest_display="$resolved" + if [[ "$show_ports" == "true" && "$resolved" != "${d_ip}:"* && "$resolved" != "${d_ip} "* ]]; then + if [[ -n "$d_port" && "$d_port" != "0" ]]; then + dest_display=$(printf "%s \033[2m(%s:%s)\033[0m" "$resolved" "$d_ip" "$d_port") + else + dest_display=$(printf "%s \033[2m(%s)\033[0m" "$resolved" "$d_ip") + fi + fi ui::activity::accept_dest_row \ "$dest_display" "$d_bytes_orig" "$d_bytes_reply" \ "$d_count" "$drops_col" "$w_count" @@ -289,14 +321,23 @@ function cmd::activity::run() { ;; service) - $skip_peer && continue - $accept_only && continue - local peer dest_display drop_count - IFS='|' read -r peer dest_display drop_count <<< "$rest" + local peer dest_display dst_ip dst_port proto drop_count + IFS='|' read -r peer dest_display dst_ip dst_port proto drop_count <<< "$rest" + # Build dim suffix if --ports + local svc_display="$dest_display" + if [[ "$show_ports" == "true" && -n "$dst_ip" ]]; then + if [[ -n "$dst_port" ]]; then + svc_display=$(printf "%s \033[2m(%s:%s)\033[0m" \ + "$dest_display" "$dst_ip" "$dst_port") + else + svc_display=$(printf "%s \033[2m(%s)\033[0m" \ + "$dest_display" "$dst_ip") + fi + fi local svc_drop_word="drops" [[ "$drop_count" -eq 1 ]] && svc_drop_word="drop" - ui::activity::service_row \ - "$dest_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count" + $accept_only || ui::activity::service_row \ + "$svc_display" "$drop_count" "$svc_drop_word" "$drops_col" "$w_count" ;; esac done <<< "$data" diff --git a/core/command.sh b/core/command.sh index 4d3c182..fd422aa 100644 --- a/core/command.sh +++ b/core/command.sh @@ -89,7 +89,7 @@ function command::run() { # Preprocess mixin flags (--json, --no-color etc) command::_preprocess_flags args - echo "DEBUG cmd=$cmd groups='$groups' defs='${default_args[*]}' user='${user_args[*]:-}'" >&2 + local fn fn=$(command::fn "$cmd" run) core::call_function "$fn" ${args[@]+"${args[@]}"} diff --git a/core/json_helper.py b/core/json_helper.py index 7381c49..3884483 100644 --- a/core/json_helper.py +++ b/core/json_helper.py @@ -2148,7 +2148,8 @@ commands = { args[0], args[1], args[2], args[3], args[4], args[5], args[6] if len(args) > 6 else '24', args[7] if len(args) > 7 else '', - args[8] if len(args) > 8 else ''), + args[8] if len(args) > 8 else '', + args[9] if len(args) > 9 else ''), # Rules 'rule_resolve': lambda args: rule_resolve(args[0], args[1]), 'rule_resolve_field':lambda args: rule_resolve_field(args[0], args[1], args[2]), @@ -2302,13 +2303,14 @@ commands = { args[5] if len(args) > 5 else '1', args[6] if len(args) > 6 else '', args[7] if len(args) > 7 else '0', - args[8] if len(args) > 8 else 'desc'), + args[8] if len(args) > 8 else 'desc'), 'accept_aggregate': lambda args: __import__('lib.accept_events', fromlist=['accept_aggregate']).accept_aggregate( args[0], args[1], args[2], args[3] if len(args) > 3 else '', args[4] if len(args) > 4 else '', - args[5] if len(args) > 5 else '0'), + args[5] if len(args) > 5 else '0', + args[6] if len(args) > 6 else ''), 'batch_resolve_dest': lambda args: batch_resolve_dest(args[0], args[1], *args[2:]), } diff --git a/core/lib/__pycache__/accept_events.cpython-311.pyc b/core/lib/__pycache__/accept_events.cpython-311.pyc index fcd4f3dc94af570c088a2cd1dfbd16b412194432..5a26ab61ce484c87f0f8c0f00e4116517b5a19d4 100644 GIT binary patch delta 3149 zcmZ`*eQaA-6~Fg=_}hN|wiDa2<0MX;v`L#LYdX4gQ$y5}z&1LGwY0L@TyIL_q}}sN z*N^+;N@SAhf^c|D%NSL?39?D$kD~ljA&_7lrCRfcgCb#!5dCLDAcQ=Pp|XGA+~*{Y zopSx&dB1nQ?m73JdwiE({p&@?2X?y!!MOL!mh(g1OO7C>OWo(9c4Aq;r<`VFM#Mp^ zfL+A4fKIsycLF7)pT9p4{<@LbZ4M7SdAL76WKERl#Psy4-=?TLiGVwmBWXVuOjY9z)5w1N@34R>v*4xMk> z#g`PH_L?PVQJvHvk5WU?s;!&6+AXWYGKS71RTp&@ZHy&QSJA#D({A&QIgA#2DVFWj zDODhw61&JxrM2>VQPu@QJ?lg z+^;{;@UM$NMV(+Icm`e#7!*Pi(-Ti*BE}{*<}~0iL|zuhi}`& zUjrQ7#FqfaYB)y%1UF^ZP94yBKv9o5%*$NHesi-zivVu#g?7>F$4Q`>@?K4>R02+a9xo^ssCL>V(gRE)7j(% zcX2|yIIX)lVeOysc4=oYQazX)Xmj~foHotb z*&&M1#}lg~oqxivBrmgGF0(%Z*$hPUBv$b|+V2u8_}1!N_Zf`;uC4X_3jag9k?g@? zZ8aJHYQ>b9KG#35OlO*mWe0P^HdTesrj?4Ay_lI+D*RbOh< zKpy{6*AKg{Pn3;;D&oXs+0*d{PxAMk-dk3 zzccaUiFI4ghOMWhUyu3@l%&bKRv>vbuP(p5E+sakL`h0Kh+jWe6%LB_Kige}&hMUD zJXJ+Q=9cCCWmmAJiVoOYK4YXHRJlQI$roP@@8P)6r-6>*_=m^tr0;(FLC<>N#75u* zgyC!dV=$(*^}qFU8iP%h2S>U$oqWdp{)>@1?@8$K*&Nj;bSuf{ISKvuv^ETR)+WLxL;s+P{#*?fPZTn8s~)jh;i;? z44nI%Wn41ew{Z+RFvHTA4_-d$>1V-6ER6{#p2D7fAB zD$0i_553_j)c{i)fWHsUHrodPq*Y9|97n{)CZ#taqiUi?D%Q?ZvJ09P#7qn<{+m28 zvbUSuD;&7w;d}-uEFNT8CQaWM{}Mm_lFUvmavviBAXf!4m)#2ugSCm;CO63X22bY9 z6>j=sMYwoDR^(nLzQM>MBX0t!2yBB^OpK=3_B88=sb`tD*w&B}%w%DUd=4P{2*`C* zw)&y2MPFI+=I0+-Y$am^uDqf@bCE*2#wdnL#yxPAB~M;?ba<>}nfzY=a`D|gPB(=j;vt(>hjFr97DiX{wo#e;LlDk0i z*YelOiQW%p-k-VCap$%5#G#E`;?TOYqhMNwwh&s*{@_UANb$;Du{1mm*RO@z^MT{R zJlu}b(6>t6>?((%%R|dUkIPnPepv2;G?RiX!K1eDE{5DSuKp!-qH`HadMd_Lid>jV zr7GHg_XkAzI&ihv;DNoY!_^kClOg6=U)V2Ahi5O4`Am9LE&`9$YPMCoHu&i?$0w@% W494HYRrI9CY2JlhRkWMvzW)ctD!oqt delta 1932 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 delta 2855 zcmZ`)O>7&-6`tAU&ypfVF3IITDN-awQT~w=S!!iDvK^~Ylh|^dHZ4-bU{o$eP}ZM! z*S6yBLOKYr73g4RdPr-uGxN=xZ{B+|d*kTU1MTm+-A)80bTT;kXZ}ii7%yJQzv&WfOX$af$b26a z?7zFwfjdhMirOk|P_yuTGXQfh+AroVc3sSqM|c-@kTg?e9g3S2*cj_nJha}`rts!e zgj9AFBXkYEdU-2%DK~*d>k_BA6hHZt&9PnedNl7qieTapnxyL*MYM@_l@Ym$MRbUc z>&{hRbB!`Gisn|`lPdm&8xQ6c(TC>r@YN(HNWbYQp;Vc zf1a6Vw#};i1vf$^w!|ESrIzzM#(htYuaFsU1}RzcYij~izhw_-0ma`SiNy9L)UvRk zbZw7M228ysP?}_)&1=WfH{kR=WYm`M1l8aOx{ObKU^vWh;{mG5cqIy;UUND`CT!|z zd&`m`)geu)A;S}a?};AKyUIYY;Hwuc45k{P15*uusNoh<5j8RyHRmm+)JQD`yaQy^ z9?J1Mv8v;=0haK}TMbvmEhd+w=-a_1szzaRzc(RS(Z2%{QwB{65d5uuImLhpgiHw9 zw75C4TNFgLWyc9+XxqeO<7g??${?-C$DGHSRGswCw*F5{J#m+ca# z`j_pe4HrtZ?^Tt9zL?5A(N|=U>Vm$wnrt~M9m;dtWaTd)Nsj23~ZpH$|Rhqb_d4#e^vS9QY5nSSyyGGC|n7l^MV$g=sHX;v*5FqkTM+j`7mWJD4=(+F` zr>0GiNl!~F-!XNFc)B?faG!jr`Io!uaSHi&1e;&Z|KBm68mNyQ-#M>S8Qor0jHv#r zNQ|nyA$^AIyo!FwHM4p>ltCa11Z=GYR+1~XjQ>XR!mo(nzDz!H{}boQ2klvMlz*1_8z$%Z;}LqR7Z`~q zt z9@q^$ZU+vLT+( zoLm?7My#?TRp)f4JU3NUrm8b#S+^QG`AT>R?;`txaSJP#74mX0hr7v{;PYAfQ;@z5 zLg%-*%4O+XwNwTd>bBBZWp-kM{5_b!F~WyV_3x(ex@YMe$DA~)%obw}UDjP9gr+D~Vfk_LZYa@dEzO>tQ6zd3NY7Bw4?^w+k*b$B2o5KD4j(>j>|FPa zmz3(cs`6%Wyi$?Mm2vRnIY84tlg!&77{|h6%mO~f;f^33lB4e_wv8p^P4^I_g?S4wQy_Ue&)nR=EQ@{iA{tZ00gn&>bP|5o!mng zf8TfDogaXJw|1;v-{`N`8?K=TuA%$JyUEh29&;i2E_m{@o}-Z$EarhclKlGy3>!L>&}4lCDwiyF`(aNFxA-@)sqx+ zx8r#M8u#`y(6~3iQGJ-|Um&V~!5O;qh!q;^9@<#vonsyL^%zU_SHK7~I*dlf1#H%P zEn|K5^*)yB1DNXHGV~$m36A}P9fK?cb$hWW&X$Tr=|$L^bR2|ksZ7oOL^@2osqq5M tALEE_-6+uf7k6-aRy;dZ9+rLzO?vd?|042>)L%Qj(-?!RpDqmx{{thfa*_Z5 diff --git a/core/lib/accept_events.py b/core/lib/accept_events.py index 2e31e0a..9da9dc0 100644 --- a/core/lib/accept_events.py +++ b/core/lib/accept_events.py @@ -131,7 +131,7 @@ def accept_events(file, filter_peer, filter_type, net_file, def accept_aggregate(file, net_file, clients_dir, since='', - filter_peer='', external_only='0'): + filter_peer='', external_only='0', exclude_services=''): """ Aggregate accept events per peer — total bytes, packets, top destinations. Used by wgctl activity to show accepted traffic alongside drops. @@ -145,6 +145,7 @@ def accept_aggregate(file, net_file, clients_dir, since='', """ from collections import defaultdict from itertools import groupby + from lib.util import load_net_data, hosts_lookup, reverse_lookup since_dt = parse_since(since) if since else None show_external = str(external_only) == '1' @@ -157,6 +158,14 @@ def accept_aggregate(file, net_file, clients_dir, since='', # dest_stats = defaultdict(lambda: {'bytes': 0, 'count': 0}) dest_stats = defaultdict(lambda: {'bytes_orig': 0, 'bytes_reply': 0, 'count': 0}) + # Build exclusion set — supports service names and ip:port:proto + exclude_set = set() + if exclude_services: + for svc in exclude_services.split(): + exclude_set.add(svc.strip()) + + net_data = load_net_data(net_file) if (net_file and exclude_set) else {} + try: with open(file) as f: for line in f: @@ -200,7 +209,10 @@ def accept_aggregate(file, net_file, clients_dir, since='', ps['packets_out'] += p_orig ps['packets_in'] += p_reply ps['conn_count'] += 1 - + + if _is_excluded(dst_ip, dst_port, proto, exclude_set, net_data): + continue + dest_key = (peer, dst_ip, dst_port, proto) dest_stats[dest_key]['bytes_orig'] += b_orig dest_stats[dest_key]['bytes_reply'] += b_reply @@ -225,4 +237,24 @@ def accept_aggregate(file, net_file, clients_dir, since='', top = list(group)[:20] for (p, dst_ip, dst_port, proto), stats in top: print(f"dest|{p}|{dst_ip}|{dst_port}|{proto}|" - f"{stats['bytes_orig']}|{stats['bytes_reply']}|{stats['count']}") \ No newline at end of file + f"{stats['bytes_orig']}|{stats['bytes_reply']}|{stats['count']}") + + +def _is_excluded(ip, port, proto, exclude_set, net_data): + if not exclude_set: + return False + # Check raw ip:port:proto + if f"{ip}:{port}:{proto}" in exclude_set: + return True + # Check service name + svc = reverse_lookup(net_data, ip, str(port), proto) if net_data else '' + if svc and svc in exclude_set: + return True + # Check service:proto format (e.g. "pihole:dns-udp" -> "pihole" + "dns-udp") + if svc: + for excl in exclude_set: + if ':' in excl: + excl_svc, excl_port = excl.rsplit(':', 1) + if excl_svc == svc and excl_port in (f"{proto}-{port}", f"dns-{proto}"): + return True + return False \ No newline at end of file diff --git a/core/lib/activity.py b/core/lib/activity.py index a0d2a9c..24daf36 100644 --- a/core/lib/activity.py +++ b/core/lib/activity.py @@ -19,26 +19,49 @@ from lib.util import ( def activity_aggregate(fw_file, wg_file, wg_interface, net_file, clients_dir, meta_dir, hours, filter_peer, - filter_service_ip): + filter_service_ip, exclude_services=''): """ Aggregate activity data for wgctl activity. Output: peer|name|rx_bytes|tx_bytes|drop_count - service|peer_name|dest_display|drop_count + service|peer_name|dest_display|dst_ip|dst_port|proto|drop_count """ hours = int(hours) if hours else 24 cutoff = None if hours > 0: cutoff = datetime.now(timezone.utc) - timedelta(hours=hours) - + + # Build exclusion set + exclude_set = set() + if exclude_services: + for svc in exclude_services.split(): + exclude_set.add(svc.strip()) + # Preload lookups once ip_to_peer = build_ip_to_name(clients_dir) pubkey_to_peer = build_pubkey_to_name(clients_dir) net_data = load_net_data(net_file) - + def _reverse(dest_ip, dest_port, proto): return reverse_lookup(net_data, dest_ip, dest_port, proto) - + + def _is_excluded(ip, port, proto, svc_name): + if not exclude_set: + return False + if f"{ip}:{port}:{proto}" in exclude_set: + return True + if svc_name and svc_name in exclude_set: + return True + if svc_name: + for excl in exclude_set: + if ':' in excl: + excl_svc, excl_port = excl.rsplit(':', 1) + if excl_svc == svc_name and excl_port in ( + f"{proto}-{port}", f"dns-{proto}", f"dns-udp", f"dns-tcp" + ): + return True + return False + # WireGuard transfer totals peer_rx = defaultdict(int) peer_tx = defaultdict(int) @@ -57,11 +80,12 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file, peer_tx[peer] += tx except Exception: pass - + # Parse fw_events for drops + # service_drops[peer][(dest_display, dst_ip, dst_port, proto)] = count peer_drops = defaultdict(int) service_drops = defaultdict(lambda: defaultdict(int)) - + if os.path.exists(fw_file): try: with open(fw_file) as f: @@ -81,16 +105,16 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file, continue except Exception: pass - + src_ip = ev.get('src_ip', '') if not src_ip: continue - + dest_ip = ev.get('dest_ip', '') dest_port = str(ev.get('dest_port', '')) proto_num = ev.get('ip.protocol', 0) proto = PROTO_MAP.get(int(proto_num), str(proto_num)) - + peer = ip_to_peer.get(src_ip) if not peer: continue @@ -98,18 +122,23 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file, continue if filter_service_ip and dest_ip != filter_service_ip: continue - + svc_name = _reverse(dest_ip, dest_port, proto) dest_display = make_dest_display(dest_ip, dest_port, proto, svc_name) - + + if _is_excluded(dest_ip, dest_port, proto, svc_name): + continue + peer_drops[peer] += 1 - service_drops[peer][dest_display] += 1 - + # Key includes raw ip:port:proto for --ports support + svc_key = (dest_display, dest_ip, dest_port, proto) + service_drops[peer][svc_key] += 1 + except Exception: continue except Exception: pass - + # Collect peers with any activity all_peers = set() all_peers.update(k for k in peer_rx if peer_rx[k] > 0) @@ -117,13 +146,14 @@ def activity_aggregate(fw_file, wg_file, wg_interface, net_file, all_peers.update(peer_drops.keys()) if filter_peer: all_peers = {p for p in all_peers if p == filter_peer} - + for peer in sorted(all_peers): rx = peer_rx.get(peer, 0) tx = peer_tx.get(peer, 0) drops = peer_drops.get(peer, 0) print(f"peer|{peer}|{rx}|{tx}|{drops}") - + svc_map = service_drops.get(peer, {}) - for dest_display, count in sorted(svc_map.items(), key=lambda x: -x[1]): - print(f"service|{peer}|{dest_display}|{count}") \ No newline at end of file + for (dest_display, dst_ip, dst_port, proto), count in \ + sorted(svc_map.items(), key=lambda x: -x[1]): + print(f"service|{peer}|{dest_display}|{dst_ip}|{dst_port}|{proto}|{count}") \ No newline at end of file diff --git a/modules/ui/activity.module.sh b/modules/ui/activity.module.sh index 5169ca8..32785b3 100644 --- a/modules/ui/activity.module.sh +++ b/modules/ui/activity.module.sh @@ -1,7 +1,6 @@ #!/usr/bin/env bash # ui/activity.module.sh — rendering for wgctl activity -# ui::activity::peer_row function ui::activity::peer_row() { local name_pad="${1:-}" rx_pad="${2:-}" tx_pad="${3:-}" \ drops="${4:-0}" drop_word="${5:-drops}" w_drops="${6:-1}" @@ -10,22 +9,78 @@ function ui::activity::peer_row() { "$name_pad" "$rx_pad" "$tx_pad" "$drops" "$drop_word" } -# ui::activity::service_row +# ── _strip_ansi → visible string +# Used for measuring visible length of strings that may contain ANSI codes +function ui::activity::_visible_len() { + local s="$1" + printf "%b" "$s" | sed 's/\x1b\[[0-9;]*m//g' | wc -m | tr -d ' ' +} + +# ui::activity::service_row +# dest_display may contain ANSI (when --ports passes dim suffix) function ui::activity::service_row() { local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" \ drops_col="${4:-30}" w_count="${5:-1}" local arrow_prefix=" → " local prefix_bytes=${#arrow_prefix} - local prefix_len=$(( prefix_bytes + ${#dest_display} )) + # Measure visible length of dest (strip ANSI for correct padding) + local dest_visible_len + dest_visible_len=$(ui::activity::_visible_len "$dest_display") + local prefix_len=$(( prefix_bytes + dest_visible_len )) local pad_n=$(( drops_col - prefix_len )) [[ $pad_n -lt 1 ]] && pad_n=1 - printf " \033[0;31m→ %s%*s %${w_count}s %s\033[0m\n" \ + printf " \033[0;31m→\033[0m \033[0;31m%b\033[0m%*s \033[0;31m%${w_count}s %s\033[0m\n" \ "$dest_display" "$pad_n" "" "$drop_count" "$drop_word" } -# Table versions (kept for future display config) +function ui::activity::accept_row() { + local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \ + conns="${4:-0}" w_count="${5:-4}" + + local conn_word="conns" + [[ "$conns" -eq 1 ]] && conn_word="conn" + + local spaces + spaces=$(printf '%*s' "${#name_pad}" '') + + printf " \033[0;32m%s ↓%-10s ↑%-10s %${w_count}s %s\033[0m\n" \ + "$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word" +} + +function ui::activity::accept_dest_row() { + local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \ + count="${4:-0}" drops_col="${5:-40}" w_count="${6:-4}" + + local conn_word="conns" + [[ "$count" -eq 1 ]] && conn_word="conn" + + local arrow_prefix=" → " + local prefix_bytes=${#arrow_prefix} + # Measure visible length of dest (strip ANSI for correct padding) + local dest_visible_len + dest_visible_len=$(ui::activity::_visible_len "$dest") + local prefix_len=$(( prefix_bytes + dest_visible_len )) + local pad_n=$(( drops_col - prefix_len )) + [[ $pad_n -lt 1 ]] && pad_n=1 + + # Build bytes display + local bytes_display="" + if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then + bytes_display=" " + [[ "$bytes_reply" -gt 0 ]] && bytes_display+="↓$(fmt::bytes "$bytes_reply") " + [[ "$bytes_orig" -gt 0 ]] && bytes_display+="↑$(fmt::bytes "$bytes_orig")" + bytes_display="${bytes_display% }" + fi + + # Use %b for dest to interpret ANSI, keep rest as %s/%d + printf " \033[0;32m→\033[0m \033[0;32m%b\033[0m%*s \033[0;32m%${w_count}s %-5s\033[0m%s\n" \ + "$dest" "$pad_n" "" "$count" "$conn_word" "$bytes_display" +} + +# ── Table versions ────────────────────────────────────── + function ui::activity::header_table() { printf "\n %-24s %-14s %-14s %s\n" "PEER" "↓ RX" "↑ TX" "DROPS" printf " %s\n" "$(printf '─%.0s' {1..65})" @@ -41,45 +96,4 @@ function ui::activity::peer_row_table() { function ui::activity::service_row_table() { local dest_display="${1:-}" drop_count="${2:-0}" drop_word="${3:-drops}" printf " → %-30s %s %s\n" "$dest_display" "$drop_count" "$drop_word" -} - -function ui::activity::accept_row() { - local name_pad="${1:-}" bytes_in="${2:-}" bytes_out="${3:-}" \ - conns="${4:-0}" w_count="${5:-4}" - - local conn_word="conns" - [[ "$conns" -eq 1 ]] && conn_word="conn" - - local spaces - spaces=$(printf '%*s' "${#name_pad}" '') - - printf " \033[0;32m%s ↓%-10s ↑%-10s %${w_count}s %s\033[0m\n" \ - "$spaces" "$bytes_in" "$bytes_out" "$conns" "$conn_word" -} - - -function ui::activity::accept_dest_row() { - local dest="${1:-}" bytes_orig="${2:-0}" bytes_reply="${3:-0}" \ - count="${4:-0}" drops_col="${5:-40}" w_count="${6:-4}" - - local conn_word="conns" - [[ "$count" -eq 1 ]] && conn_word="conn" - - local arrow_prefix=" → " - local prefix_bytes=${#arrow_prefix} - local prefix_len=$(( prefix_bytes + ${#dest} )) - local pad_n=$(( drops_col - prefix_len )) - [[ $pad_n -lt 1 ]] && pad_n=1 - - # Only show bytes if non-zero - local bytes_display="" - if [[ "$bytes_orig" -gt 0 || "$bytes_reply" -gt 0 ]]; then - local bytes_display=" " - [[ "$bytes_orig" -gt 0 ]] && bytes_display+="↓$(fmt::bytes "$bytes_orig") " - [[ "$bytes_reply" -gt 0 ]] && bytes_display+="↑$(fmt::bytes "$bytes_reply")" - bytes_display="${bytes_display% }" # trim trailing space - fi - - printf " \033[0;32m→\033[0m \033[0;32m%s%*s %${w_count}s %-5s%s\033[0m\n" \ - "$dest" "$pad_n" "" "$count" "$conn_word" "$bytes_display" } \ No newline at end of file