Skip to main content

scripts-distribute-dashboard-json

#!/usr/bin/env python3 """ distribute_dashboard_json.py - Auto-distribute and normalize project-dashboard-data.json

Part of ADR-170: Multi-Project Executive Dashboard

  1. Normalizes JSON schema to match dashboard JSX expectations
  2. Detects active Vite dev servers via lsof
  3. Copies normalized JSON to each server's public/ and dist/ directories

Usage: python3 distribute_dashboard_json.py [--dry-run] [--verbose] [--normalize-only] """

import json import os import re import subprocess import sys from pathlib import Path

def normalize_dashboard_json(data: dict) -> dict: """Normalize JSON fields to match dashboard JSX expectations.

The JSX dashboard (61-project-status-dashboard.jsx) expects specific field names
and types. This function maps the Phase 1 output to the JSX schema.
"""
s = data.get("summary", {})
tracks = data.get("tracks", [])

# Fix track progress: JSX expects {percent: N} not just N
for t in tracks:
if isinstance(t.get("progress"), (int, float)):
t["progress"] = {"percent": t["progress"]}

# Fix status values: JSX STATUS_COLORS expects specific values
STATUS_MAP = {
"completed": "complete",
"deferred": "planned",
"in_progress": "in-progress",
}
for t in tracks:
st = t.get("status", "pending")
t["status"] = STATUS_MAP.get(st, st)

# Count sections across all tracks
all_sections = []
status_counts = {}
for t in tracks:
secs = t.get("sections", [])
all_sections.extend(secs)
st = t.get("status", "pending")
status_counts[st] = status_counts.get(st, 0) + 1

# Compute section status counts
section_status_counts = {}
for sec in all_sections:
p = sec.get("progress", 0)
if p >= 100:
st = "complete"
elif p > 0:
st = "in-progress"
else:
st = "pending"
section_status_counts[st] = section_status_counts.get(st, 0) + 1

# Map summary fields to JSX-expected names
if "overallPercent" not in s and "overallProgress" in s:
s["overallPercent"] = s["overallProgress"]
if "doneTasks" not in s and "completedTasks" in s:
s["doneTasks"] = s["completedTasks"]
if "totalSections" not in s:
s["totalSections"] = len(all_sections)
if "sectionStatusCounts" not in s:
s["sectionStatusCounts"] = section_status_counts
if "statusCounts" not in s:
s["statusCounts"] = status_counts

# Fix velocity: JSX expects {tasksPerDay: N, trend: 'string'} not a bare string
vel = s.get("velocity")
if isinstance(vel, str):
s["velocity"] = {"tasksPerDay": 0, "trend": vel}
elif vel is None:
s["velocity"] = {"tasksPerDay": 0, "trend": "No data"}

# Add agentCounts if missing
if "agentCounts" not in s:
agents = {}
for t in tracks:
a = t.get("agent", "")
if a:
agents[a] = agents.get(a, 0) + 1
s["agentCounts"] = agents

data["summary"] = s
return data

def find_active_vite_servers() -> dict: """Detect active Vite dev servers and return {project_dir: port} mapping.""" servers = {}

try:
result = subprocess.run(
["lsof", "-i", "-P", "-n"],
capture_output=True, text=True, timeout=5
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return servers

# Find listening node processes
node_pids = set()
for line in result.stdout.splitlines():
if "LISTEN" in line and "node" in line.lower():
parts = line.split()
if len(parts) >= 2:
node_pids.add(parts[1])

for pid in node_pids:
try:
# Get command line
cmd_result = subprocess.run(
["ps", "-p", pid, "-o", "command="],
capture_output=True, text=True, timeout=3
)
cmd = cmd_result.stdout.strip()

# Only care about Vite processes
if "vite" not in cmd.lower():
continue

# Get working directory
cwd_result = subprocess.run(
["lsof", "-p", pid],
capture_output=True, text=True, timeout=5
)
cwd = ""
for line in cwd_result.stdout.splitlines():
if " cwd " in line:
cwd = line.split()[-1]
break

if not cwd or not os.path.isdir(os.path.join(cwd, "public")):
continue

# Get port
port_result = subprocess.run(
["lsof", "-p", pid, "-i", "-P", "-n"],
capture_output=True, text=True, timeout=3
)
port = ""
for line in port_result.stdout.splitlines():
if "LISTEN" in line:
match = re.search(r":(\d+)\s", line)
if match:
port = match.group(1)
break

servers[cwd] = port

except (subprocess.TimeoutExpired, Exception):
continue

return servers

def distribute(source_json: str, dry_run: bool = False, verbose: bool = False, normalize_only: bool = False, project_id: str = None) -> dict: """Main distribution function. Returns result dict.

J.18.5.1: When project_id is provided, distributes to both:
- project-dashboard-data.json (backward compat default)
- project-dashboard-data-{project_id}.json (per-project)
"""
result = {"distributed": 0, "errors": 0, "targets": []}

# Read and normalize
with open(source_json, "r") as f:
data = json.load(f)

data = normalize_dashboard_json(data)

# Write back normalized version to source
with open(source_json, "w") as f:
json.dump(data, f, indent=2)

if verbose:
print(f"[distribute] Normalized: {source_json}")

if normalize_only:
result["normalized"] = True
return result

# Build list of filenames to distribute
filenames = ["project-dashboard-data.json"]
if project_id:
filenames.append(f"project-dashboard-data-{project_id}.json")

# Find active Vite servers
servers = find_active_vite_servers()

if not servers:
if verbose:
print("[distribute] No active Vite dev servers found")
return result

if verbose:
print(f"[distribute] Found {len(servers)} active Vite project(s)")

# Distribute to each project
for project_dir, port in servers.items():
project_name = os.path.basename(project_dir)

for subdir in ["public", "dist"]:
target_dir = os.path.join(project_dir, subdir)

if not os.path.isdir(target_dir):
continue

for fname in filenames:
target = os.path.join(target_dir, fname)

if dry_run:
print(f"[distribute] [DRY RUN] Would copy {fname} to: {project_name}/{subdir}/ (port {port})")
result["targets"].append(target)
else:
try:
with open(target, "w") as f:
json.dump(data, f, indent=2)
if verbose:
print(f"[distribute] Copied {fname} to: {project_name}/{subdir}/ (port {port})")
result["distributed"] += 1
result["targets"].append(target)
except Exception as e:
print(f"[distribute] Error copying to {target}: {e}", file=sys.stderr)
result["errors"] += 1

return result

def main(): import argparse parser = argparse.ArgumentParser(description="Distribute dashboard JSON to active dev servers") parser.add_argument("source_json", help="Path to source JSON file") parser.add_argument("--dry-run", action="store_true", help="Preview without copying") parser.add_argument("--verbose", action="store_true", help="Detailed output") parser.add_argument("--normalize-only", action="store_true", help="Only normalize JSON, don't distribute") parser.add_argument("--project-id", help="Project ID for per-project filename (J.18.5.1)") args = parser.parse_args()

result = distribute(args.source_json, args.dry_run, args.verbose, args.normalize_only, args.project_id)

if args.dry_run:
print(f"\n[distribute] DRY RUN: {len(result['targets'])} targets would be updated")
elif not args.normalize_only:
if result["errors"] == 0:
print(f"[distribute] Distributed to {result['distributed']} target(s)")
else:
print(f"[distribute] Distributed to {result['distributed']} target(s), {result['errors']} error(s)")

if name == "main": main()