feat(ctl): add 'show all' command for process overview

Displays complete hierarchy: arbiter PID, web workers with their
PIDs/status, dirty arbiter PID, and dirty workers with their apps.
This commit is contained in:
Benoit Chesneau 2026-02-13 01:47:45 +01:00
parent 3963e850ea
commit 9f7000ff63
4 changed files with 185 additions and 3 deletions

View File

@ -168,6 +168,64 @@ def format_help(data: dict) -> str:
return "\n".join(lines)
def format_all(data: dict) -> str:
"""Format show all output for display."""
lines = []
# Arbiter
arbiter = data.get("arbiter", {})
lines.append("ARBITER (master)")
lines.append(f" PID: {arbiter.get('pid', '?')}")
lines.append("")
# Web workers
web_workers = data.get("web_workers", [])
lines.append(f"WEB WORKERS ({data.get('web_worker_count', 0)})")
if web_workers:
lines.append(f" {'PID':<10} {'AGE':<6} {'BOOTED':<8} {'LAST_BEAT'}")
lines.append(f" {'-' * 38}")
for w in web_workers:
pid = w.get("pid", "?")
age = w.get("age", "?")
booted = "yes" if w.get("booted") else "no"
hb = w.get("last_heartbeat")
hb_str = f"{hb}s ago" if hb is not None else "n/a"
lines.append(f" {pid:<10} {age:<6} {booted:<8} {hb_str}")
else:
lines.append(" (none)")
lines.append("")
# Dirty arbiter
dirty_arbiter = data.get("dirty_arbiter")
if dirty_arbiter:
lines.append("DIRTY ARBITER")
lines.append(f" PID: {dirty_arbiter.get('pid', '?')}")
lines.append("")
# Dirty workers
dirty_workers = data.get("dirty_workers", [])
lines.append(f"DIRTY WORKERS ({data.get('dirty_worker_count', 0)})")
if dirty_workers:
lines.append(f" {'PID':<10} {'AGE':<6} {'APPS':<30} {'LAST_BEAT'}")
lines.append(f" {'-' * 58}")
for w in dirty_workers:
pid = w.get("pid", "?")
age = w.get("age", "?")
apps = ", ".join(w.get("apps", []))
if len(apps) > 28:
apps = apps[:25] + "..."
hb = w.get("last_heartbeat")
hb_str = f"{hb}s ago" if hb is not None else "n/a"
lines.append(f" {pid:<10} {age:<6} {apps:<30} {hb_str}")
else:
lines.append(" (none)")
else:
lines.append("DIRTY ARBITER")
lines.append(" (not running)")
return "\n".join(lines)
def format_response(command: str, data: dict) -> str:
"""
Format response data based on command.
@ -182,7 +240,9 @@ def format_response(command: str, data: dict) -> str:
cmd_lower = command.lower().strip()
# Route to specific formatters
if cmd_lower == "show workers":
if cmd_lower == "show all":
return format_all(data)
elif cmd_lower == "show workers":
return format_workers(data)
elif cmd_lower == "show dirty":
return format_dirty(data)

View File

@ -405,6 +405,83 @@ class CommandHandlers:
return {"status": "shutting_down", "mode": mode}
def show_all(self) -> dict:
"""
Return overview of all processes (arbiter, web workers, dirty arbiter, dirty workers).
Returns:
Dictionary with complete process hierarchy
"""
now = time.monotonic()
# Arbiter info
arbiter_info = {
"pid": self.arbiter.pid,
"type": "arbiter",
"role": "master",
}
# Web workers (HTTP workers)
web_workers = []
for pid, worker in self.arbiter.WORKERS.items():
try:
last_update = worker.tmp.last_update()
last_heartbeat = round(now - last_update, 2)
except (OSError, ValueError):
last_heartbeat = None
web_workers.append({
"pid": pid,
"type": "web",
"age": worker.age,
"booted": worker.booted,
"last_heartbeat": last_heartbeat,
})
# Sort by age
web_workers.sort(key=lambda w: w["age"])
# Dirty arbiter and workers
dirty_arbiter_info = None
dirty_workers = []
if self.arbiter.dirty_arbiter_pid:
dirty_arbiter_info = {
"pid": self.arbiter.dirty_arbiter_pid,
"type": "dirty_arbiter",
"role": "dirty master",
}
# Get dirty workers if we have access
dirty_arbiter = getattr(self.arbiter, 'dirty_arbiter', None)
if dirty_arbiter and hasattr(dirty_arbiter, 'workers'):
for pid, worker in dirty_arbiter.workers.items():
try:
last_update = worker.tmp.last_update()
last_heartbeat = round(now - last_update, 2)
except (OSError, ValueError, AttributeError):
last_heartbeat = None
dirty_workers.append({
"pid": pid,
"type": "dirty",
"age": worker.age,
"apps": getattr(worker, 'app_paths', []),
"booted": getattr(worker, 'booted', False),
"last_heartbeat": last_heartbeat,
})
dirty_workers.sort(key=lambda w: w["age"])
return {
"arbiter": arbiter_info,
"web_workers": web_workers,
"web_worker_count": len(web_workers),
"dirty_arbiter": dirty_arbiter_info,
"dirty_workers": dirty_workers,
"dirty_worker_count": len(dirty_workers),
}
def help(self) -> dict:
"""
Return list of available commands.
@ -413,6 +490,7 @@ class CommandHandlers:
Dictionary with commands and descriptions
"""
commands = {
"show all": "Show all processes (arbiter, web workers, dirty workers)",
"show workers": "List HTTP workers with their status",
"show dirty": "List dirty workers and apps",
"show config": "Show current effective configuration",

View File

@ -242,11 +242,13 @@ class ControlSocketServer:
def _handle_show(self, args: list) -> dict:
"""Handle 'show' commands."""
if not args:
raise ValueError("Missing show target (workers|dirty|config|stats|listeners)")
raise ValueError("Missing show target (all|workers|dirty|config|stats|listeners)")
target = args[0].lower()
if target == "workers":
if target == "all":
return self.handlers.show_all()
elif target == "workers":
return self.handlers.show_workers()
elif target == "dirty":
return self.handlers.show_dirty()

View File

@ -356,6 +356,47 @@ class TestShutdown:
mock_kill.assert_called_once_with(12345, signal.SIGINT)
class TestShowAll:
"""Tests for show all command."""
def test_show_all_basic(self):
"""Test show all command."""
arbiter = MockArbiter()
arbiter.WORKERS = {
1001: MockWorker(1001, 1),
1002: MockWorker(1002, 2),
}
handlers = CommandHandlers(arbiter)
result = handlers.show_all()
assert "arbiter" in result
assert result["arbiter"]["pid"] == 12345
assert result["arbiter"]["type"] == "arbiter"
assert "web_workers" in result
assert result["web_worker_count"] == 2
assert len(result["web_workers"]) == 2
assert "dirty_arbiter" in result
assert result["dirty_arbiter"] is None
assert "dirty_workers" in result
assert result["dirty_worker_count"] == 0
def test_show_all_with_dirty(self):
"""Test show all with dirty arbiter running."""
arbiter = MockArbiter()
arbiter.dirty_arbiter_pid = 2000
handlers = CommandHandlers(arbiter)
result = handlers.show_all()
assert result["dirty_arbiter"] is not None
assert result["dirty_arbiter"]["pid"] == 2000
assert result["dirty_arbiter"]["type"] == "dirty_arbiter"
class TestHelp:
"""Tests for help command."""
@ -368,6 +409,7 @@ class TestHelp:
assert "commands" in result
commands = result["commands"]
assert "show all" in commands
assert "show workers" in commands
assert "worker add [N]" in commands
assert "reload" in commands