cli: print service statistics using new table framework

Replace manual f-string formatting with SimpleTable/Column classes.

This new "framework" handles ANSI colors and padding. Removing the
hassle of manually calculating padding. You simply specify the header
with max number of chars the data can be and if you want left/right
padding and the "framework" calculates the padding for you.

We use this new "framework" to pretty print the newly added services
statistics.

Signed-off-by: Richard Alpe <richard@bit42.se>
This commit is contained in:
Richard Alpe
2025-12-17 15:34:10 +01:00
parent f653cacd1a
commit b826bcb9d7
+116 -18
View File
@@ -190,13 +190,6 @@ class PadNtpSource:
poll = 14
class PadService:
name = 16
status = 8
pid = 8
description = 40
class PadWifiScan:
ssid = 40
encryption = 30
@@ -218,6 +211,34 @@ class PadDiskUsage:
avail = 12
percent = 6
def format_memory_bytes(bytes_val):
"""Convert bytes to human-readable format"""
if bytes_val == 0:
return " "
elif bytes_val < 1024:
return f"{bytes_val}B"
elif bytes_val < 1024 * 1024:
return f"{bytes_val // 1024}K"
elif bytes_val < 1024 * 1024 * 1024:
return f"{bytes_val // (1024 * 1024):.1f}M"
else:
return f"{bytes_val // (1024 * 1024 * 1024):.1f}G"
def format_uptime_seconds(seconds):
"""Convert seconds to compact time format"""
if seconds == 0:
return " "
elif seconds < 60:
return f"{seconds}s"
elif seconds < 3600:
return f"{seconds // 60}m"
elif seconds < 86400:
return f"{seconds // 3600}h"
else:
return f"{seconds // 86400}d"
@classmethod
def table_width(cls):
"""Total width of disk usage table"""
@@ -260,6 +281,69 @@ class PadFirewall:
return cls.zone_locked + cls.zone_name + cls.zone_type + cls.zone_data \
+ cls.zone_services
class Column:
"""Column definition for SimpleTable"""
def __init__(self, name, length, align='left', formatter=None):
self.name = name # Header text
self.width = length # Max visible data length (excluding ANSI codes)
self.align = align # 'left' or 'right' (defaults to 'left')
self.formatter = formatter # Optional function to format values
class SimpleTable:
"""Simple table formatter that handles ANSI colors correctly"""
def __init__(self, columns):
self.columns = columns
@staticmethod
def visible_width(text):
"""Return visible character count, excluding ANSI escape sequences"""
ansi_pattern = r'\x1b\[[0-9;]*m'
clean_text = re.sub(ansi_pattern, '', str(text))
return len(clean_text)
def _format_column(self, value, column):
"""Format a single column value with proper alignment
The column length specifies max expected data length.
Framework automatically adds 1 space for column separation.
"""
if column.formatter:
value = column.formatter(value)
value_str = str(value)
visible_len = self.visible_width(value_str)
if column.align == 'right':
padding = column.width - visible_len
return ' ' * max(0, padding) + value_str + ' '
else: # left alignment (default)
padding = column.width - visible_len
return value_str + ' ' * max(0, padding) + ' '
def header(self, styled=True):
"""Generate formatted header row"""
header_parts = []
for column in self.columns:
if column.align == 'right':
header_parts.append(f"{column.name:>{column.width}} ")
else: # left alignment (default)
header_parts.append(f"{column.name:{column.width}} ")
header_str = ''.join(header_parts)
return Decore.invert(header_str) if styled else header_str
def row(self, *values):
"""Generate formatted data row"""
if len(values) != len(self.columns):
raise ValueError(f"Expected {len(self.columns)} values, got {len(values)}")
row_parts = []
for value, column in zip(values, self.columns):
row_parts.append(self._format_column(value, column))
return ''.join(row_parts).rstrip()
class Decore():
@staticmethod
@@ -1693,17 +1777,27 @@ def show_services(json):
services_data = get_json_data({}, json, 'ietf-system:system-state', 'infix-system:services')
services = services_data.get("service", [])
hdr = (f"{'NAME':<{PadService.name}}"
f"{'STATUS':<{PadService.status}}"
f"{'PID':>{PadService.pid -1}}"
f" {'DESCRIPTION'}")
print(Decore.invert(hdr))
# This is the first usage of simple table. I assume this will be
# copied so I left a lot of comments. If you copy it feel free
# to be less verbose..
service_table = SimpleTable([
Column('NAME', 15, 'left'), # Max service name length
Column('STATUS', 10, 'left'), # Max status text length (e.g., "running")
Column('PID', 7, 'right'), # Max PID digits
Column('MEM', 6, 'right'), # Max memory string (e.g., "123.4M")
Column('UP', 4, 'right'), # Max uptime string (e.g., "3d")
Column('RST', 3, 'right'), # Max restart count digits
Column('DESCRIPTION', 30) # Last column needs no padding
])
print(service_table.header())
for svc in services:
name = svc.get('name', '')
status = svc.get('status', '')
pid = svc.get('pid', 0)
description = svc.get('description', '')
stats = svc.get('statistics', {})
if status in ('running', 'active', 'done'):
status_str = Decore.green(status)
@@ -1712,13 +1806,17 @@ def show_services(json):
else:
status_str = Decore.yellow(status)
pid_str = str(pid) if pid > 0 else '-'
pid_str = str(pid) if pid > 0 else ' '
row = f"{name:<{PadService.name}}"
row += f"{status_str:<{PadService.status + 9}}"
row += f"{pid_str:>{PadService.pid}}"
row += f" {description}"
print(row)
memory_bytes = int(stats.get('memory-usage', 0))
uptime_secs = int(stats.get('uptime', 0))
restart_count = stats.get('restart-count', 0)
memory_str = format_memory_bytes(memory_bytes)
uptime_str = format_uptime_seconds(uptime_secs)
print(service_table.row(name, status_str, pid_str, memory_str,
uptime_str, restart_count, description))
def show_hardware(json):