feat: rule list tableless layout with inline extends and +all/-N indicators

This commit is contained in:
Nuno Duque Nunes 2026-05-22 23:12:27 +00:00
parent abf4cd7e1c
commit 57e08e88c4
5 changed files with 300 additions and 291 deletions

View file

@ -23,10 +23,10 @@ function cmd::rule::on_load() {
flag::register --peer flag::register --peer
flag::register --peers flag::register --peers
flag::register --dns-redirect flag::register --dns-redirect
flag::register --color
flag::register --base flag::register --base
flag::register --no-base flag::register --no-base
flag::register --tree flag::register --tree
flag::register --detailed
flag::register --resolved flag::register --resolved
flag::register --force flag::register --force
flag::register --type flag::register --type
@ -60,7 +60,7 @@ Options for list:
--base Show only base rules --base Show only base rules
--no-base Hide base rules section --no-base Hide base rules section
--group <name> Filter by group (case insensitive) --group <name> Filter by group (case insensitive)
--tree Show full inheritance tree inline --detailed Show rule entries inline
Options for add: Options for add:
--name <name> Rule name --name <name> Rule name
@ -72,8 +72,8 @@ Options for add:
--allow-port <ip:port:proto> Allow specific port (repeatable) --allow-port <ip:port:proto> Allow specific port (repeatable)
--block-ip <ip/cidr> Block IP or subnet (repeatable) --block-ip <ip/cidr> Block IP or subnet (repeatable)
--block-port <ip:port:proto> Block specific port (repeatable) --block-port <ip:port:proto> Block specific port (repeatable)
--block-service <name> Block named service — resolved to IP/port at creation (repeatable) --block-service <name> Block named service (repeatable)
--allow-service <name> Allow named service — resolved to IP/port at creation (repeatable) --allow-service <name> Allow named service (repeatable)
--dns-redirect Force DNS through Pi-hole --dns-redirect Force DNS through Pi-hole
Options for update: Options for update:
@ -85,7 +85,7 @@ Options for update:
--remove-block-ip <ip> Remove block IP entry --remove-block-ip <ip> Remove block IP entry
--remove-block-port <entry> Remove block port entry --remove-block-port <entry> Remove block port entry
Options for show/inspect: Options for show:
--name <name> Rule name --name <name> Rule name
--resolved Show resolved/merged entries --resolved Show resolved/merged entries
--no-peers Hide assigned peers --no-peers Hide assigned peers
@ -96,14 +96,12 @@ Options for reapply:
Examples: Examples:
wgctl rule list wgctl rule list
wgctl rule list --tree wgctl rule list --detailed
wgctl rule list --group "VM Rules" wgctl rule list --group "VM Rules"
wgctl rule show --name guest wgctl rule show --name guest
wgctl rule show --name moonlight-02 --resolved wgctl rule show --name moonlight-02 --resolved
wgctl rule add --name no-proxmox --base --block-service proxmox wgctl rule add --name no-proxmox --base --block-service proxmox
wgctl rule add --name dev-01 --desc "Dev access" --group "Dev" --extends no-lan wgctl rule add --name dev-01 --desc "Dev access" --extends no-lan
wgctl rule add --name restricted-dns --allow-service pihole:dns --block-service pihole
wgctl rule update --name user --add-extends no-nginx
wgctl rule assign --name dev-01 --peer laptop-nuno wgctl rule assign --name dev-01 --peer laptop-nuno
wgctl rule reapply --all wgctl rule reapply --all
EOF EOF
@ -120,7 +118,6 @@ function cmd::rule::run() {
case "$subcmd" in case "$subcmd" in
list|ls) cmd::rule::list "$@" ;; list|ls) cmd::rule::list "$@" ;;
show|inspect) cmd::rule::show "$@" ;; show|inspect) cmd::rule::show "$@" ;;
inspect) cmd::rule::inspect "$@" ;;
add|new|create) cmd::rule::add "$@" ;; add|new|create) cmd::rule::add "$@" ;;
update|edit) cmd::rule::update "$@" ;; update|edit) cmd::rule::update "$@" ;;
remove|rm|del|delete) cmd::rule::remove "$@" ;; remove|rm|del|delete) cmd::rule::remove "$@" ;;
@ -141,61 +138,19 @@ function cmd::rule::run() {
# List # List
# ============================================ # ============================================
function cmd::rule::_pad() {
local text="$1" width="$2"
local visible
visible=$(printf "%s" "$text" | sed 's/\x1b\[[0-9;]*m//g')
local visible_len=${#visible}
local byte_len=${#text}
local extra=$(( byte_len - visible_len ))
printf "%-$(( width + extra ))s" "$text"
}
function cmd::rule::_print_extends_tree() {
local extends="$1" indent="${2:-2}" rules_dir="$3"
[[ -z "$extends" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
local spaces
spaces=$(printf '%*s' "$indent" '')
printf " \033[0;37m%s↳ %s\033[0m\n" "$spaces" "$base"
if [[ "$indent" -lt 12 ]]; then
local sub_file=""
if sub_file=$(json::find_rule_file "$rules_dir" "$base" 2>/dev/null); then
local sub_extends=""
sub_extends=$(json::get "$sub_file" "extends" 2>/dev/null \
| tr '\n' ',' | sed 's/,$//' || true)
if [[ -n "$sub_extends" ]]; then
cmd::rule::_print_extends_tree \
"$sub_extends" $(( indent + 4 )) "$rules_dir"
fi
fi
fi
done
}
function cmd::rule::list() { function cmd::rule::list() {
local rules_dir local rules_dir
rules_dir="$(ctx::rules)" rules_dir="$(ctx::rules)"
local show_base_only=false local show_base_only=false show_base=true
local show_base=true local filter_group="" detailed=false
local filter_group=""
local show_tree=false
local found_any=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--base) show_base_only=true; shift ;; --base) show_base_only=true; shift ;;
--no-base) show_base=false; shift ;; --no-base) show_base=false; shift ;;
--group) util::require_flag "--group" "${2:-}" || return 1 --group) filter_group="${2,,}"; shift 2 ;;
filter_group="${2,,}"; shift 2 ;; --detailed) detailed=true; shift ;;
--tree) show_tree=true; shift ;;
--help) cmd::rule::help; return ;; --help) cmd::rule::help; return ;;
*) *)
log::error "Unknown flag: $1" log::error "Unknown flag: $1"
@ -203,110 +158,78 @@ function cmd::rule::list() {
esac esac
done done
local rules=("${rules_dir}"/*.rule) local data
if [[ ! -f "${rules[0]}" ]]; then data=$(json::rule_list_data "$rules_dir" "$(ctx::meta)")
log::wg "No rules configured" [[ -z "$data" ]] && log::wg "No rules configured" && return 0
return 0
fi
local header_printed=false # Measure max name width
local printing_base=false local w_name=12
local current_group="" while IFS='|' read -r name rest; do
[[ -z "$name" ]] && continue
(( ${#name} > w_name )) && w_name=${#name}
done <<< "$data"
(( w_name += 2 ))
log::section "Firewall Rules"
echo ""
local current_group="" printing_base=false found_any=false
while IFS="|" read -r name desc n_allows n_blocks \ while IFS="|" read -r name desc n_allows n_blocks \
peer_count extends is_base group; do peer_count extends is_base group; do
[[ -z "$name" ]] && continue [[ -z "$name" ]] && continue
# --base: show ONLY base rules $show_base_only && [[ "$is_base" == "False" ]] && continue
if $show_base_only && [[ "$is_base" == "False" ]]; then ! $show_base && [[ "$is_base" == "True" ]] && continue
continue [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]] && continue
fi
# --no-base: hide base rules found_any=true
if ! $show_base && [[ "$is_base" == "True" ]]; then
continue
fi
# --group filter (case insensitive)
if [[ -n "$filter_group" && "${group,,}" != "$filter_group" ]]; then
continue
fi
# Print header on first match
if ! $header_printed; then
log::section "Firewall Rules"
printf "\n %-20s %-40s %-8s %-8s %s\n" \
"NAME" "DESCRIPTION" \
"$(ui::center "ALLOWS" 8)" \
"$(ui::center "BLOCKS" 8)" \
"PEERS"
local divider
divider=$(printf '─%.0s' {1..88})
printf " %s\n" "$divider"
header_printed=true
fi
# Base rules section header # Base rules section header
if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then if [[ "$is_base" == "True" && "$printing_base" == "false" ]]; then
if ! $show_base_only; then if ! $show_base_only; then
local bdashes ui::rule::list_base_header
bdashes=$(printf '─%.0s' {1..74})
printf "\n \033[0;37m── Base Rules \033[0m%s\n" "$bdashes"
fi fi
printing_base=true printing_base=true
current_group="" current_group=""
fi fi
# Group header — only for non-base rules # Group header — non-base rules only
if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then if [[ "$is_base" == "False" && "${group,,}" != "${current_group,,}" ]]; then
if [[ -n "$group" ]]; then if [[ -n "$group" ]]; then
printf "\n \033[0;36m▸ %s\033[0m\n" "$group" ui::rule::list_group_header "$group"
elif [[ -n "$current_group" ]]; then elif [[ -n "$current_group" ]]; then
printf "\n" echo ""
fi fi
current_group="$group" current_group="$group"
fi fi
local short_desc="${desc:0:35}" # Rule row
[[ ${#desc} -gt 35 ]] && short_desc="${short_desc}..." # ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name"
local desc_col_width=40 # Extends
[[ "${short_desc:-}" == "—" ]] && desc_col_width=42 # Rule row — pass extends_csv for compact inline display
local compact_extends=""
found_any=true if [[ -z "$detailed" ]] || ! $detailed; then
compact_extends="$extends"
printf " %-20s %-${desc_col_width}s %-8s %-8s %s\n" \
"$name" "${short_desc:-}" \
"$(ui::center "$n_allows" 8)" \
"$(ui::center "$n_blocks" 8)" \
"${peer_count} peers"
# Print extends
if [[ -n "$extends" ]]; then
if $show_tree; then
cmd::rule::_print_extends_tree "$extends" 2 "$rules_dir"
else
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
done
fi fi
printf "\n" ui::rule::list_row "$name" "$n_allows" "$n_blocks" "$peer_count" "$w_name" "$compact_extends"
# Detailed mode — show expanded entries
if $detailed && [[ -n "$extends" ]]; then
ui::rule::list_extends_detailed "$extends" "$rules_dir"
echo ""
fi fi
done < <(json::rule_list_data "$rules_dir" "$(ctx::meta)") done <<< "$data"
if ! $found_any; then $found_any || {
if [[ -n "$filter_group" ]]; then [[ -n "$filter_group" ]] && \
log::wg_warning "No rules found in group: ${filter_group}" log::wg_warning "No rules found in group: ${filter_group}" || \
else
log::wg_warning "No rules found" log::wg_warning "No rules found"
fi }
fi
printf "\n" echo ""
} }
# ============================================ # ============================================
@ -318,8 +241,7 @@ function cmd::rule::show() {
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
--name) util::require_flag "--name" "${2:-}" || return 1 --name) name="$2"; shift 2 ;;
name="$2"; shift 2 ;;
--no-peers) show_peers=false; shift ;; --no-peers) show_peers=false; shift ;;
--resolved) show_resolved=true; shift ;; --resolved) show_resolved=true; shift ;;
--help) cmd::rule::help; return ;; --help) cmd::rule::help; return ;;
@ -333,7 +255,7 @@ function cmd::rule::show() {
local rule_file local rule_file
rule_file="$(rule::path "$name")" rule_file="$(rule::path "$name")"
# ── DNS display ─────────────────────────────── # DNS display
local dns_redirect resolved_dns dns_display local dns_redirect resolved_dns dns_display
dns_redirect=$(rule::get_own "$name" "dns_redirect") dns_redirect=$(rule::get_own "$name" "dns_redirect")
dns_redirect="${dns_redirect:-false}" dns_redirect="${dns_redirect:-false}"
@ -359,19 +281,16 @@ function cmd::rule::show() {
ui::row "DNS" "$dns_display" ui::row "DNS" "$dns_display"
printf "\n" printf "\n"
# ── Extends + own rules ──────────────────────── if ui::rule::tree "$name"; then
if rule::render_extends_tree "$name"; then
# Has inheritance — tree already rendered
: :
else else
# No inheritance — flat view ui::rule::flat "$name"
rule::render_flat "$name"
printf "\n" printf "\n"
fi fi
# ── Resolved ────────────────────────────────── # Resolved view
if $show_resolved; then if $show_resolved; then
cmd::rule::_show_section "Resolved (applied to peers)" ui::rule::section_header "Resolved (applied to peers)"
printf "\n" printf "\n"
local res_allow_ports res_allow_ips res_block_ips res_block_ports local res_allow_ports res_allow_ips res_block_ips res_block_ports
res_allow_ports=$(rule::get "$name" "allow_ports") res_allow_ports=$(rule::get "$name" "allow_ports")
@ -385,26 +304,24 @@ function cmd::rule::show() {
printf "\n" printf "\n"
fi fi
# ── Peers ───────────────────────────────────── # Peers
$show_peers || return 0
local peer_list=() local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name") mapfile -t peer_list < <(peers::with_rule "$name") || true
local peer_count=${#peer_list[@]} local peer_count=${#peer_list[@]}
ui::empty "$peer_count" && return 0 ui::empty "$peer_count" && return 0
printf "\n" local peer_word="peers"
printf "\033[0;37m── Peers (%s) \033[0m%s\n\n" \ [[ "$peer_count" -eq 1 ]] && peer_word="peer"
"$(color::gray "${peer_count}")" \ printf "\n \033[0;37m── Peers (%s %s) \033[0m%s\n\n" \
"$(printf '\033[0;37m─%.0s' {1..35})" "$peer_count" "$peer_word" "$(printf '\033[0;37m─%.0s' {1..30})"
if $show_peers && [[ $peer_count -gt 0 ]]; then
for peer_name in "${peer_list[@]}"; do for peer_name in "${peer_list[@]}"; do
local ip local ip
ip=$(peers::get_ip "$peer_name") ip=$(peers::get_ip "$peer_name")
printf " %-28s %s\n" "$peer_name" "$ip" printf " %-28s %s\n" "$peer_name" "$ip"
done done
fi
printf "\n" printf "\n"
return 0 return 0
} }
@ -418,8 +335,7 @@ function cmd::rule::add() {
local extends=() local extends=()
local allow_ips=() block_ips=() block_ports=() allow_ports=() local allow_ips=() block_ips=() block_ports=() allow_ports=()
local block_services=() allow_services=() local block_services=() allow_services=()
local dns_redirect=false local dns_redirect=false is_base=false
local is_base=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
case "$1" in case "$1" in
@ -452,12 +368,10 @@ function cmd::rule::add() {
return 1 return 1
fi fi
# Validate extends
for ext in "${extends[@]}"; do for ext in "${extends[@]}"; do
rule::require_exists "$ext" || return 1 rule::require_exists "$ext" || return 1
done done
# Determine target directory
local rule_dir local rule_dir
if $is_base; then if $is_base; then
rule_dir="$(ctx::rules)/base" rule_dir="$(ctx::rules)/base"
@ -487,7 +401,6 @@ function cmd::rule::add() {
done done
local rule_file="${rule_dir}/${name}.rule" local rule_file="${rule_dir}/${name}.rule"
local allow_str block_str port_str allow_port_str extends_str local allow_str block_str port_str allow_port_str extends_str
allow_str=$(IFS=','; echo "${allow_ips[*]}") allow_str=$(IFS=','; echo "${allow_ips[*]}")
block_str=$(IFS=','; echo "${block_ips[*]}") block_str=$(IFS=','; echo "${block_ips[*]}")
@ -502,7 +415,6 @@ function cmd::rule::add() {
local base_label="" local base_label=""
$is_base && base_label=" (base)" $is_base && base_label=" (base)"
log::wg_success "Rule created: ${name}${base_label}" log::wg_success "Rule created: ${name}${base_label}"
} }
@ -552,18 +464,15 @@ function cmd::rule::update() {
local rule_file local rule_file
rule_file="$(rule::path "$name")" rule_file="$(rule::path "$name")"
# Update simple fields
[[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\"" [[ -n "$desc" ]] && json::set "$rule_file" "desc" "\"$desc\""
[[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\"" [[ -n "$group" ]] && json::set "$rule_file" "group" "\"$group\""
[[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true" [[ -n "$dns_redirect" ]] && json::set "$rule_file" "dns_redirect" "true"
# Add entries
for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done for ip in "${allow_ips[@]}"; do json::append "$rule_file" "allow_ips" "$ip"; done
for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done for ip in "${block_ips[@]}"; do json::append "$rule_file" "block_ips" "$ip"; done
for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done for p in "${block_ports[@]}"; do json::append "$rule_file" "block_ports" "$p"; done
for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done for p in "${allow_ports[@]}"; do json::append "$rule_file" "allow_ports" "$p"; done
# Add/remove extends
for ext in "${add_extends[@]}"; do for ext in "${add_extends[@]}"; do
rule::require_exists "$ext" || return 1 rule::require_exists "$ext" || return 1
json::append "$rule_file" "extends" "$ext" json::append "$rule_file" "extends" "$ext"
@ -572,7 +481,6 @@ function cmd::rule::update() {
json::remove "$rule_file" "extends" "$ext" json::remove "$rule_file" "extends" "$ext"
done done
# Remove entries
for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done for ip in "${rm_allow_ips[@]}"; do json::remove "$rule_file" "allow_ips" "$ip"; done
for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done for ip in "${rm_block_ips[@]}"; do json::remove "$rule_file" "block_ips" "$ip"; done
for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done for p in "${rm_block_ports[@]}"; do json::remove "$rule_file" "block_ports" "$p"; done
@ -583,8 +491,7 @@ function cmd::rule::update() {
} }
# ============================================ # ============================================
# Remove / Assign / Unassign / Migrate / Reapply # Remove
# (unchanged from original)
# ============================================ # ============================================
function cmd::rule::remove() { function cmd::rule::remove() {
@ -603,7 +510,7 @@ function cmd::rule::remove() {
rule::require_exists "$name" || return 1 rule::require_exists "$name" || return 1
local peer_list=() local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$name") mapfile -t peer_list < <(peers::with_rule "$name") || true
local peer_count=${#peer_list[@]} local peer_count=${#peer_list[@]}
if [[ "$peer_count" -gt 0 ]]; then if [[ "$peer_count" -gt 0 ]]; then
@ -620,6 +527,10 @@ function cmd::rule::remove() {
log::wg_success "Rule removed: ${name}" log::wg_success "Rule removed: ${name}"
} }
# ============================================
# Assign / Unassign
# ============================================
function cmd::rule::assign() { function cmd::rule::assign() {
local name="" peer="" type="" local name="" peer="" type=""
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -640,10 +551,8 @@ function cmd::rule::assign() {
peer=$(peers::resolve_and_require "$peer" "$type") || return 1 peer=$(peers::resolve_and_require "$peer" "$type") || return 1
local existing_rule local existing_rule ip
existing_rule=$(peers::get_meta "$peer" "rule") existing_rule=$(peers::get_meta "$peer" "rule")
local ip
ip=$(peers::get_ip "$peer") ip=$(peers::get_ip "$peer")
[[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1 [[ -z "$ip" ]] && log::error "Could not resolve IP for: $peer" && return 1
@ -684,37 +593,41 @@ function cmd::rule::unassign() {
log::wg_success "Unassigned rule from: ${peer}" log::wg_success "Unassigned rule from: ${peer}"
} }
# ============================================
# Migrate
# ============================================
function cmd::rule::migrate() { function cmd::rule::migrate() {
log::section "Migrating peers to default rules" log::section "Migrating peers to default rules"
local tmp local count=0
tmp=$(mktemp)
while IFS= read -r peer_name; do while IFS= read -r peer_name; do
local existing local existing
existing=$(peers::get_meta "$peer_name" "rule") existing=$(peers::get_meta "$peer_name" "rule")
[[ -n "$existing" ]] && continue [[ -n "$existing" ]] && continue
local default_rule
default_rule=$(peers::default_rule "$peer_name") # Try to get default rule from subnet policy
local peer_type subnet_name default_rule
peer_type=$(peers::get_meta "$peer_name" "type")
subnet_name=$(peers::get_meta "$peer_name" "subnet")
default_rule=$(subnet::default_rule "$subnet_name" "$peer_type")
[[ -z "$default_rule" ]] && continue
rule::exists "$default_rule" || continue rule::exists "$default_rule" || continue
local ip local ip
ip=$(peers::get_ip "$peer_name") ip=$(peers::get_ip "$peer_name")
echo "${peer_name} ${default_rule} ${ip}" >> "$tmp" rule::apply "$default_rule" "$ip" "$peer_name"
done < <(peers::all)
local count=0
local lines=()
mapfile -t lines < "$tmp"
rm -f "$tmp"
for line in "${lines[@]}"; do
IFS=" " read -r peer_name default_rule ip <<< "$line"
rule::apply "$default_rule" "$ip" "$peer_name" </dev/null
(( count++ )) || true (( count++ )) || true
done done < <(peers::all)
log::wg_success "Migrated ${count} peers" log::wg_success "Migrated ${count} peers"
} }
# ============================================
# Reapply
# ============================================
function cmd::rule::reapply() { function cmd::rule::reapply() {
local name="" all=false local name="" all=false
while [[ $# -gt 0 ]]; do while [[ $# -gt 0 ]]; do
@ -731,9 +644,8 @@ function cmd::rule::reapply() {
while IFS= read -r rule_file; do while IFS= read -r rule_file; do
local rname local rname
rname=$(basename "$rule_file" .rule) rname=$(basename "$rule_file" .rule)
# Skip if no peers assigned
local peer_list=() local peer_list=()
mapfile -t peer_list < <(peers::with_rule "$rname") mapfile -t peer_list < <(peers::with_rule "$rname") || true
[[ ${#peer_list[@]} -eq 0 ]] && continue [[ ${#peer_list[@]} -eq 0 ]] && continue
rule::reapply_all "$rname" rule::reapply_all "$rname"
(( count++ )) || true (( count++ )) || true
@ -747,19 +659,3 @@ function cmd::rule::reapply() {
rule::reapply_all "$name" rule::reapply_all "$name"
log::wg_success "Rule '${name}' reapplied" log::wg_success "Rule '${name}' reapplied"
} }
# ============================================
# Show helpers
# ============================================
function cmd::rule::_show_section() {
local title="${1:-}" color="${2:-white}" use_color="${3:-false}"
local color_code=""
if $use_color; then
case "$color" in
green) color_code="\033[0;32m" ;;
red) color_code="\033[0;31m" ;;
esac
fi
printf "\n ${color_code}── %s ──────────────────────────────────\033[0m\n" "$title"
}

View file

@ -64,6 +64,29 @@ for s in sys.argv[1:]:
" "$@" " "$@"
} }
# ui::vis_len <string>
# Returns the visible (character) length of a string,
# stripping ANSI codes and accounting for multi-byte UTF-8.
function ui::vis_len() {
local str="${1:-}"
# Strip ANSI codes first
local clean
clean=$(echo "$str" | sed 's/\x1b\[[0-9;]*m//g')
# Use Python for accurate Unicode character count
python3 -c "import sys; print(len('${clean//\'/\'\\\'\'}'))" 2>/dev/null || echo "${#clean}"
}
# ui::pad_to_col <string_bytes_printed> <target_visible_col>
# Returns padding needed accounting for UTF-8 byte/char difference.
# extra_bytes = bytes_printed - visible_chars_printed
function ui::utf8_extra_bytes() {
local str="${1:-}"
local byte_len=${#str}
local vis_len
vis_len=$(ui::vis_len "$str")
echo $(( byte_len - vis_len ))
}
function ui::pad_status() { function ui::pad_status() {
ui::pad "${1:-}" "${2:-25}" ui::pad "${1:-}" "${2:-25}"

View file

@ -8,6 +8,6 @@
"desktop-roboclean": "46.189.215.231", "desktop-roboclean": "46.189.215.231",
"laptop-nuno": "94.63.0.129", "laptop-nuno": "94.63.0.129",
"phone-luis": "176.223.61.15", "phone-luis": "176.223.61.15",
"phone-helena-2": "148.69.192.130", "phone-helena-2": "148.69.202.127",
"desktop-zephyr": "86.120.152.74" "desktop-zephyr": "86.120.152.74"
} }

View file

@ -333,21 +333,14 @@ function rule::restore_all() {
# Rendering # Rendering
# ============================================ # ============================================
function rule::render_flat() { # ======================================================
ui::rule::flat "$1" # Aliases (for backward compat — remove in cleanup pass)
} # ======================================================
function rule::render_entries() { function rule::render_flat() { ui::rule::flat "$1"; }
ui::rule::entries "$1" function rule::render_entries() { ui::rule::entries "$1"; }
} function rule::render_own_entries() { ui::rule::own_entries "$1"; }
function rule::render_extends_tree() { ui::rule::tree "$1"; }
function rule::render_own_entries() {
ui::rule::own_entries "$1"
}
function rule::render_extends_tree() {
ui::rule::tree "$1"
}
# ============================================ # ============================================
# DNS Redirect # DNS Redirect

View file

@ -201,7 +201,6 @@ function ui::rule::_identity_rule_entry() {
mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true mapfile -t extends_raw < <(json::get "$rule_file" "extends" 2>/dev/null || true) || true
if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then if [[ ${#extends_raw[@]} -gt 0 && -n "${extends_raw[0]:-}" ]]; then
# Rule has extends — render one level deep using shared helper
ui::rule::_render_bases extends_raw 10 8 ui::rule::_render_bases extends_raw 10 8
local own_output local own_output
@ -211,7 +210,6 @@ function ui::rule::_identity_rule_entry() {
printf "%s\n" "$own_output" printf "%s\n" "$own_output"
fi fi
else else
# Leaf rule — show own entries or note full access
local own_output local own_output
own_output=$(ui::rule::own_entries "$rule_name" 8) own_output=$(ui::rule::own_entries "$rule_name" 8)
if [[ -n "$own_output" ]]; then if [[ -n "$own_output" ]]; then
@ -255,3 +253,102 @@ function ui::rule::_peer_rule_entry() {
fi fi
fi fi
} }
# ======================================================
# List Rendering
# ======================================================
# ui::rule::list_group_header <group_name>
function ui::rule::list_group_header() {
local group="${1:-}"
printf "\n \033[0;36m▸ %s\033[0m\n" "$group"
}
# ui::rule::list_base_header
function ui::rule::list_base_header() {
printf "\n \033[2m── Base Rules ──────────────────────\033[0m\n"
}
# ui::rule::list_row <name> <n_allows> <n_blocks> <peer_count> <w_name>
function ui::rule::list_row() {
local name="${1:-}" n_allows="${2:-0}" n_blocks="${3:-0}" \
peer_count="${4:-0}" w_name="${5:-16}" extends_csv="${6:-}"
local name_pad
name_pad=$(printf "%-${w_name}s" "$name")
local peer_word="peers"
[[ "$peer_count" -eq 1 ]] && peer_word="peer"
local peers_display
peers_display=$(printf "%s %s" "$peer_count" "$peer_word")
local peers_pad_n=$(( 10 - ${#peers_display} ))
[[ $peers_pad_n -lt 0 ]] && peers_pad_n=0
local extends_indicator=""
if [[ -n "$extends_csv" ]]; then
local extends_display="${extends_csv//,/, }"
extends_indicator=" \033[2m↳ ${extends_display}\033[0m"
fi
# Build allows and blocks — pad to fixed visible width of 5
local allows_str blocks_str
if [[ "$n_allows" -eq 0 && "$n_blocks" -eq 0 ]]; then
allows_str=$(ui::pad_mb "\033[1;32m+all\033[0m" 5)
blocks_str=$(printf "%5s" "")
else
if [[ "$n_allows" -gt 0 ]]; then
allows_str=$(ui::pad_mb "\033[1;32m+${n_allows}\033[0m" 5)
else
allows_str=$(printf "%5s" "")
fi
if [[ "$n_blocks" -gt 0 ]]; then
blocks_str=$(ui::pad_mb "\033[1;31m-${n_blocks}\033[0m" 5)
else
blocks_str=$(printf "%5s" "")
fi
fi
printf " %s %b%b %s%*s%b\n" \
"$name_pad" "$allows_str" "$blocks_str" \
"$peers_display" "$peers_pad_n" "" "$extends_indicator"
}
# ui::rule::list_extends <extends_csv>
# Renders the extends tree for a rule in list view (compact, one level)
function ui::rule::list_extends() {
local extends_csv="${1:-}"
[[ -z "$extends_csv" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends_csv"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
done
}
# ui::rule::list_extends_detailed <extends_csv> <rules_dir>
# Renders the extends tree with entries expanded (--detailed mode)
function ui::rule::list_extends_detailed() {
local extends_csv="${1:-}" rules_dir="${2:-}"
[[ -z "$extends_csv" ]] && return 0
local extend_list=()
IFS=',' read -ra extend_list <<< "$extends_csv"
for base in "${extend_list[@]}"; do
[[ -z "$base" ]] && continue
printf " \033[0;37m ↳ %s\033[0m\n" "$base"
ui::rule::entries "$base" 6
done
}
# ======================================================
# Show helpers
# ======================================================
function ui::rule::section_header() {
local title="${1:-}"
printf "\n \033[0;37m── %s ──────────────────────────────────\033[0m\n" "$title"
}