Skip to main content

scripts-submodule-dashboard-html

#!/usr/bin/env python3 """

title: "Submodule Health Dashboard HTML Generator" component_type: script version: 1.0.0 audience: contributor status: stable summary: "Generate self-contained HTML dashboard for CODITECT submodule health monitoring" keywords: ['devops', 'git', 'health', 'monitoring', 'dashboard', 'html'] tokens: ~800 created: 2026-02-02 updated: 2026-02-02 script_name: "submodule-dashboard-html.py" language: python executable: true usage: "python3 scripts/submodule-dashboard-html.py [--output path] [--open]" python_version: "3.10+" dependencies: [] modifies_files: false network_access: false requires_auth: false

Submodule Health Dashboard HTML Generator

Generates a self-contained HTML dashboard showing the health of all CODITECT submodules. Reuses data collection functions from submodule-health-check.py.

Features:

  • Self-contained HTML (inline CSS + vanilla JS, no external dependencies)
  • Dark theme with responsive layout
  • Sortable columns, category filters, text search
  • Color-coded health scores (green/yellow/red)
  • Summary cards with ecosystem-wide statistics

Usage: python3 scripts/submodule-dashboard-html.py --output dashboard.html python3 scripts/submodule-dashboard-html.py --output dashboard.html --open python3 scripts/submodule-dashboard-html.py > dashboard.html

Exit Codes: 0: Success - Dashboard generated 1: Error - Generation failed """

import sys import os import argparse import logging from pathlib import Path from datetime import datetime from typing import List

Import health check functions from sibling script

script_dir = Path(file).resolve().parent sys.path.insert(0, str(script_dir))

Import with explicit error handling

try: import importlib.util spec = importlib.util.spec_from_file_location( "submodule_health_check", script_dir / "submodule-health-check.py" ) health_check_module = importlib.util.module_from_spec(spec) spec.loader.exec_module(health_check_module)

find_rollout_master_root = health_check_module.find_rollout_master_root
check_submodule_health = health_check_module.check_submodule_health
HealthStatus = health_check_module.HealthStatus

except Exception as e: logger.error(f"Failed to import submodule-health-check.py: {e}") logger.error("Make sure submodule-health-check.py exists in the same directory") sys.exit(1)

logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s') logger = logging.getLogger(name)

def get_category_display_name(category: str) -> str: """Convert category directory name to display name.""" category_map = { 'cloud': 'Cloud', 'core': 'Core', 'dev': 'Dev', 'docs': 'Docs', 'gtm': 'GTM', 'integrations': 'Integrations', 'investors': 'Investors', 'labs': 'Labs', 'ops': 'Ops', 'products': 'Products', 'enterprise-processes': 'Enterprise' } return category_map.get(category, category.title())

def generate_html_dashboard(health_statuses: List[HealthStatus]) -> str: """Generate self-contained HTML dashboard."""

# Calculate summary statistics
total = len(health_statuses)
if total == 0:
avg_score = 0
clean = dirty = stale = unpushed = 0
else:
avg_score = sum(s.score for s in health_statuses) / total
clean = sum(1 for s in health_statuses if s.git_status['uncommitted'] == 0)
dirty = sum(1 for s in health_statuses if s.git_status['uncommitted'] > 0)
stale = sum(1 for s in health_statuses if s.git_status['behind'] > 0)
unpushed = sum(1 for s in health_statuses if s.git_status['unpushed'] > 0)

# Get unique categories
categories = sorted(set(s.category for s in health_statuses))

# Generate timestamp
timestamp = datetime.now().strftime('%Y-%m-%d %H:%M:%S')

# Build table rows
table_rows = []
for status in sorted(health_statuses, key=lambda s: s.name):
# Determine status badge
if status.git_status['uncommitted'] == 0:
status_badge = '<span class="badge badge-clean">Clean</span>'
else:
status_badge = '<span class="badge badge-dirty">Dirty</span>'

# Symlink checkmarks
symlink_checks = ''
if status.symlink_status['coditect_exists']:
symlink_checks += '✓ '
if status.symlink_status['framework_accessible']:
symlink_checks += '✓'
if not symlink_checks:
symlink_checks = '✗'

# Health score color class
if status.score >= 80:
score_class = 'score-green'
elif status.score >= 50:
score_class = 'score-yellow'
else:
score_class = 'score-red'

row = f'''
<tr data-category="{status.category}">
<td><strong>{status.name}</strong></td>
<td>{get_category_display_name(status.category)}</td>
<td>{status.git_status['branch']}</td>
<td>{status_badge}</td>
<td>{status.git_status['uncommitted']}</td>
<td>{status.git_status['unpushed']}</td>
<td>{status.git_status['behind']}</td>
<td>{status.git_status['ahead']}</td>
<td>{status.git_status['last_commit'] or 'N/A'}</td>
<td>
<div class="health-score-cell">
<div class="health-bar">
<div class="health-bar-fill {score_class}" style="width: {status.score}%"></div>
</div>
<span class="health-score-value">{status.score}</span>
</div>
</td>
<td class="symlinks">{symlink_checks}</td>
</tr>'''
table_rows.append(row)

# Build category tabs
category_tabs = '<button class="tab-button active" data-category="all">All</button>\n'
for cat in categories:
display_name = get_category_display_name(cat)
category_tabs += f' <button class="tab-button" data-category="{cat}">{display_name}</button>\n'

# HTML template with inline CSS and JS
html = f'''<!DOCTYPE html>
CODITECT Submodule Health Dashboard

CODITECT Submodule Health Dashboard

Generated: {timestamp}
    <div class="summary-cards">
<div class="card">
<h3>Total Repositories</h3>
<div class="value">{total}</div>
</div>
<div class="card card-green">
<h3>Clean</h3>
<div class="value">{clean}</div>
</div>
<div class="card card-yellow">
<h3>Dirty</h3>
<div class="value">{dirty}</div>
</div>
<div class="card card-red">
<h3>Stale</h3>
<div class="value">{stale}</div>
</div>
<div class="card card-orange">
<h3>Unpushed</h3>
<div class="value">{unpushed}</div>
</div>
<div class="card">
<h3>Avg Health Score</h3>
<div class="value">{avg_score:.1f}</div>
</div>
</div>

<div class="controls">
<input type="text" class="search-box" id="searchBox" placeholder="Search submodules by name...">
<div class="tabs" id="categoryTabs">

{category_tabs}

    <div class="table-container">
<table id="submoduleTable">
<thead>
<tr>
<th class="sortable" data-column="name">Name</th>
<th class="sortable" data-column="category">Category</th>
<th class="sortable" data-column="branch">Branch</th>
<th class="sortable" data-column="status">Status</th>
<th class="sortable" data-column="uncommitted">Uncommitted</th>
<th class="sortable" data-column="unpushed">Unpushed</th>
<th class="sortable" data-column="behind">Behind</th>
<th class="sortable" data-column="ahead">Ahead</th>
<th class="sortable" data-column="last_commit">Last Commit</th>
<th class="sortable" data-column="score">Health Score</th>
<th>Symlinks</th>
</tr>
</thead>
<tbody id="tableBody">

{''.join(table_rows)}

<script>
// State
let currentSort = {{ column: 'name', direction: 'asc' }};
let currentCategory = 'all';

// Get elements
const searchBox = document.getElementById('searchBox');
const tableBody = document.getElementById('tableBody');
const tabButtons = document.querySelectorAll('.tab-button');
const sortableHeaders = document.querySelectorAll('th.sortable');

// Search functionality
searchBox.addEventListener('input', (e) => {{
const searchTerm = e.target.value.toLowerCase();
const rows = tableBody.querySelectorAll('tr');

rows.forEach(row => {{
const name = row.querySelector('td:first-child').textContent.toLowerCase();
const matchesSearch = name.includes(searchTerm);
const matchesCategory = currentCategory === 'all' || row.dataset.category === currentCategory;

if (matchesSearch && matchesCategory) {{
row.classList.remove('hidden');
}} else {{
row.classList.add('hidden');
}}
}});
}});

// Category tab filtering
tabButtons.forEach(button => {{
button.addEventListener('click', () => {{
// Update active tab
tabButtons.forEach(btn => btn.classList.remove('active'));
button.classList.add('active');

// Update filter
currentCategory = button.dataset.category;
const searchTerm = searchBox.value.toLowerCase();
const rows = tableBody.querySelectorAll('tr');

rows.forEach(row => {{
const name = row.querySelector('td:first-child').textContent.toLowerCase();
const matchesSearch = !searchTerm || name.includes(searchTerm);
const matchesCategory = currentCategory === 'all' || row.dataset.category === currentCategory;

if (matchesSearch && matchesCategory) {{
row.classList.remove('hidden');
}} else {{
row.classList.add('hidden');
}}
}});
}});
}});

// Column sorting
sortableHeaders.forEach(header => {{
header.addEventListener('click', () => {{
const column = header.dataset.column;
const rows = Array.from(tableBody.querySelectorAll('tr'));

// Toggle direction if same column
if (currentSort.column === column) {{
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
}} else {{
currentSort.column = column;
currentSort.direction = 'asc';
}}

// Update header classes
sortableHeaders.forEach(h => {{
h.classList.remove('sorted-asc', 'sorted-desc');
}});
header.classList.add(currentSort.direction === 'asc' ? 'sorted-asc' : 'sorted-desc');

// Sort rows
rows.sort((a, b) => {{
let aVal, bVal;

switch(column) {{
case 'name':
aVal = a.querySelector('td:nth-child(1)').textContent;
bVal = b.querySelector('td:nth-child(1)').textContent;
break;
case 'category':
aVal = a.querySelector('td:nth-child(2)').textContent;
bVal = b.querySelector('td:nth-child(2)').textContent;
break;
case 'branch':
aVal = a.querySelector('td:nth-child(3)').textContent;
bVal = b.querySelector('td:nth-child(3)').textContent;
break;
case 'status':
aVal = a.querySelector('td:nth-child(4)').textContent;
bVal = b.querySelector('td:nth-child(4)').textContent;
break;
case 'uncommitted':
aVal = parseInt(a.querySelector('td:nth-child(5)').textContent);
bVal = parseInt(b.querySelector('td:nth-child(5)').textContent);
break;
case 'unpushed':
aVal = parseInt(a.querySelector('td:nth-child(6)').textContent);
bVal = parseInt(b.querySelector('td:nth-child(6)').textContent);
break;
case 'behind':
aVal = parseInt(a.querySelector('td:nth-child(7)').textContent);
bVal = parseInt(b.querySelector('td:nth-child(7)').textContent);
break;
case 'ahead':
aVal = parseInt(a.querySelector('td:nth-child(8)').textContent);
bVal = parseInt(b.querySelector('td:nth-child(8)').textContent);
break;
case 'last_commit':
aVal = a.querySelector('td:nth-child(9)').textContent;
bVal = b.querySelector('td:nth-child(9)').textContent;
break;
case 'score':
aVal = parseInt(a.querySelector('.health-score-value').textContent);
bVal = parseInt(b.querySelector('.health-score-value').textContent);
break;
}}

if (typeof aVal === 'number') {{
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
}}

const comparison = aVal.localeCompare(bVal);
return currentSort.direction === 'asc' ? comparison : -comparison;
}});

// Re-append sorted rows
rows.forEach(row => tableBody.appendChild(row));
}});
}});

// Animate health bars on load
window.addEventListener('load', () => {{
const fills = document.querySelectorAll('.health-bar-fill');
fills.forEach(fill => {{
const width = fill.style.width;
fill.style.width = '0%';
setTimeout(() => {{
fill.style.width = width;
}}, 100);
}});
}});
</script>
'''
return html

def main() -> int: """Main entry point.""" parser = argparse.ArgumentParser( description='Generate HTML dashboard for CODITECT submodule health', formatter_class=argparse.RawDescriptionHelpFormatter ) parser.add_argument( '--output', '-o', help='Output file path (default: stdout)' ) parser.add_argument( '--open', action='store_true', help='Open dashboard in browser after generation' ) parser.add_argument( '--verbose', '-v', action='store_true', help='Verbose output' )

args = parser.parse_args()

if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)

try:
# Find rollout-master root
rollout_root = find_rollout_master_root()
logger.info(f"Scanning submodules in: {rollout_root}")

# Collect all submodules
submodules_dir = rollout_root / 'submodules'
if not submodules_dir.exists():
logger.error(f"Submodules directory not found: {submodules_dir}")
return 1

submodule_paths = []
for category_dir in submodules_dir.iterdir():
if category_dir.is_dir():
for submodule_dir in category_dir.iterdir():
if submodule_dir.is_dir() and (submodule_dir / '.git').exists():
submodule_paths.append(submodule_dir)

logger.info(f"Found {len(submodule_paths)} submodules")

# Perform health checks
health_statuses = []
for i, path in enumerate(submodule_paths, 1):
try:
logger.debug(f"Checking [{i}/{len(submodule_paths)}]: {path.name}")
status = check_submodule_health(path)
health_statuses.append(status)
except Exception as e:
logger.warning(f"Failed to check {path}: {e}")

# Generate HTML dashboard
logger.info("Generating HTML dashboard...")
html_content = generate_html_dashboard(health_statuses)

# Output
if args.output:
output_path = Path(args.output)
output_path.write_text(html_content)
logger.info(f"Dashboard saved to: {output_path.resolve()}")

# Open in browser if requested
if args.open:
import webbrowser
webbrowser.open(f'file://{output_path.resolve()}')
logger.info("Opening dashboard in browser...")
else:
# Print to stdout
print(html_content)

return 0

except Exception as e:
logger.exception("Dashboard generation failed")
print(f"Error: {e}", file=sys.stderr)
return 1

if name == "main": sys.exit(main())