Compare commits

...

4 Commits

Author SHA1 Message Date
5cc1460a3e Hide unnecessary text in summary table 2025-12-04 18:47:21 -08:00
c604da12c7 Show completed parts 2025-12-04 18:46:44 -08:00
dde52bb9db Collapse bytes output table 2025-12-04 18:44:28 -08:00
171fdb4329 Show bytes output counters 2025-12-04 17:23:44 -08:00

View File

@ -37,6 +37,7 @@ class PerformanceResult:
part: int part: int
time_ns: int # Runner time in nanoseconds time_ns: int # Runner time in nanoseconds
generator_time_ns: int = 0 # Generator time in nanoseconds (optional) generator_time_ns: int = 0 # Generator time in nanoseconds (optional)
output_bytes: int = 0 # Number of bytes in the output/answer
git_rev: str = "" # Git revision (short hash) git_rev: str = "" # Git revision (short hash)
repo_url: str = "" # Repository URL repo_url: str = "" # Repository URL
timestamp: str = "" timestamp: str = ""
@ -121,6 +122,7 @@ class Database:
part INTEGER NOT NULL, part INTEGER NOT NULL,
time_ns INTEGER NOT NULL, time_ns INTEGER NOT NULL,
generator_time_ns INTEGER NOT NULL DEFAULT 0, generator_time_ns INTEGER NOT NULL DEFAULT 0,
output_bytes INTEGER NOT NULL DEFAULT 0,
git_rev TEXT NOT NULL DEFAULT '', git_rev TEXT NOT NULL DEFAULT '',
repo_url TEXT NOT NULL DEFAULT '', repo_url TEXT NOT NULL DEFAULT '',
timestamp TEXT NOT NULL, timestamp TEXT NOT NULL,
@ -131,6 +133,7 @@ class Database:
# Add new columns if they don't exist (for existing databases) # Add new columns if they don't exist (for existing databases)
for column, col_type in [ for column, col_type in [
('generator_time_ns', 'INTEGER NOT NULL DEFAULT 0'), ('generator_time_ns', 'INTEGER NOT NULL DEFAULT 0'),
('output_bytes', 'INTEGER NOT NULL DEFAULT 0'),
('git_rev', 'TEXT NOT NULL DEFAULT \'\''), ('git_rev', 'TEXT NOT NULL DEFAULT \'\''),
('repo_url', 'TEXT NOT NULL DEFAULT \'\'') ('repo_url', 'TEXT NOT NULL DEFAULT \'\'')
]: ]:
@ -156,10 +159,10 @@ class Database:
try: try:
cursor.execute(''' cursor.execute('''
INSERT OR REPLACE INTO results INSERT OR REPLACE INTO results
(user, year, day, part, time_ns, generator_time_ns, git_rev, repo_url, timestamp) (user, year, day, part, time_ns, generator_time_ns, output_bytes, git_rev, repo_url, timestamp)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (result.user, result.year, result.day, result.part, ''', (result.user, result.year, result.day, result.part,
result.time_ns, result.generator_time_ns, result.git_rev, result.time_ns, result.generator_time_ns, result.output_bytes, result.git_rev,
result.repo_url, result.timestamp)) result.repo_url, result.timestamp))
conn.commit() conn.commit()
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
@ -178,7 +181,7 @@ class Database:
cursor = conn.cursor() cursor = conn.cursor()
query = ''' query = '''
SELECT user, year, day, part, time_ns, generator_time_ns, git_rev, repo_url, timestamp SELECT user, year, day, part, time_ns, generator_time_ns, output_bytes, git_rev, repo_url, timestamp
FROM results r1 FROM results r1
WHERE timestamp = ( WHERE timestamp = (
SELECT MAX(timestamp) SELECT MAX(timestamp)
@ -220,9 +223,10 @@ class Database:
'part': row[3], 'part': row[3],
'time_ns': row[4], 'time_ns': row[4],
'generator_time_ns': row[5] if len(row) > 5 else 0, 'generator_time_ns': row[5] if len(row) > 5 else 0,
'git_rev': row[6] if len(row) > 6 else '', 'output_bytes': row[6] if len(row) > 6 else 0,
'repo_url': row[7] if len(row) > 7 else '', 'git_rev': row[7] if len(row) > 7 else '',
'timestamp': row[8] if len(row) > 8 else (row[6] if len(row) > 6 else '') 'repo_url': row[8] if len(row) > 8 else '',
'timestamp': row[9] if len(row) > 9 else (row[7] if len(row) > 7 else '')
} }
for row in rows for row in rows
] ]
@ -785,9 +789,12 @@ class CargoAOCRunner:
stdout_clean = CargoAOCRunner._strip_ansi_codes(result.stdout or "") stdout_clean = CargoAOCRunner._strip_ansi_codes(result.stdout or "")
stderr_clean = CargoAOCRunner._strip_ansi_codes(result.stderr or "") stderr_clean = CargoAOCRunner._strip_ansi_codes(result.stderr or "")
# Count bytes in stdout output (original, before ANSI stripping)
output_bytes = len(result.stdout.encode('utf-8')) if result.stdout else 0
# Parse output for runtime information # Parse output for runtime information
day_results = CargoAOCRunner._parse_runtime_output( day_results = CargoAOCRunner._parse_runtime_output(
stdout_clean, stderr_clean, day, year, user, git_rev, repo_url stdout_clean, stderr_clean, day, year, user, git_rev, repo_url, output_bytes
) )
if day_results: if day_results:
logger.info(f"Parsed {len(day_results)} runtime result(s) for {user} year {year} day {day}") logger.info(f"Parsed {len(day_results)} runtime result(s) for {user} year {year} day {day}")
@ -816,7 +823,7 @@ class CargoAOCRunner:
@staticmethod @staticmethod
def _parse_runtime_output(stdout: str, stderr: str, day: int, year: int, def _parse_runtime_output(stdout: str, stderr: str, day: int, year: int,
user: str, git_rev: str = "", repo_url: str = "") -> List[PerformanceResult]: user: str, git_rev: str = "", repo_url: str = "", output_bytes: int = 0) -> List[PerformanceResult]:
"""Parse cargo-aoc runtime output """Parse cargo-aoc runtime output
cargo aoc typically outputs timing information like: cargo aoc typically outputs timing information like:
@ -869,6 +876,7 @@ class CargoAOCRunner:
# First, try to parse the generator/runner format which is most common # First, try to parse the generator/runner format which is most common
# Look for "Day X - Part Y" lines and extract both generator and runner times # Look for "Day X - Part Y" lines and extract both generator and runner times
# output_bytes parameter contains the total stdout bytes for this day run
lines = output.split('\n') lines = output.split('\n')
current_day = None current_day = None
current_part = None current_part = None
@ -889,6 +897,7 @@ class CargoAOCRunner:
part=current_part, part=current_part,
time_ns=runner_time_ns, time_ns=runner_time_ns,
generator_time_ns=generator_time_ns, generator_time_ns=generator_time_ns,
output_bytes=output_bytes,
git_rev=git_rev, git_rev=git_rev,
repo_url=repo_url, repo_url=repo_url,
timestamp=timestamp timestamp=timestamp
@ -933,6 +942,7 @@ class CargoAOCRunner:
part=current_part, part=current_part,
time_ns=runner_time_ns, time_ns=runner_time_ns,
generator_time_ns=generator_time_ns, generator_time_ns=generator_time_ns,
output_bytes=output_bytes,
git_rev=git_rev, git_rev=git_rev,
repo_url=repo_url, repo_url=repo_url,
timestamp=timestamp timestamp=timestamp
@ -994,6 +1004,7 @@ class CargoAOCRunner:
day=actual_day, day=actual_day,
part=part_num, part=part_num,
time_ns=time_ns, time_ns=time_ns,
output_bytes=output_bytes,
timestamp=timestamp timestamp=timestamp
)) ))
except ValueError: except ValueError:
@ -1023,6 +1034,7 @@ class CargoAOCRunner:
day=day, day=day,
part=1, part=1,
time_ns=time_ns, time_ns=time_ns,
output_bytes=output_bytes,
timestamp=timestamp timestamp=timestamp
)) ))
elif len(matches) == 2: elif len(matches) == 2:
@ -1036,6 +1048,7 @@ class CargoAOCRunner:
day=day, day=day,
part=idx, part=idx,
time_ns=time_ns, time_ns=time_ns,
output_bytes=output_bytes,
timestamp=timestamp timestamp=timestamp
)) ))
break break
@ -1100,6 +1113,7 @@ class HTMLGenerator:
user = result['user'] user = result['user']
runner_time_ns = result['time_ns'] runner_time_ns = result['time_ns']
generator_time_ns = result.get('generator_time_ns', 0) generator_time_ns = result.get('generator_time_ns', 0)
output_bytes = result.get('output_bytes', 0)
git_rev = result.get('git_rev', '') git_rev = result.get('git_rev', '')
repo_url = result.get('repo_url', '') repo_url = result.get('repo_url', '')
@ -1111,11 +1125,12 @@ class HTMLGenerator:
'total': total_time_ns, 'total': total_time_ns,
'runner': runner_time_ns, 'runner': runner_time_ns,
'generator': generator_time_ns, 'generator': generator_time_ns,
'output_bytes': output_bytes,
'git_rev': git_rev, 'git_rev': git_rev,
'repo_url': repo_url 'repo_url': repo_url
} }
html = self._generate_html(data, years, users, db, config) html = self._generate_html(data, years, users, db, config, results)
output_file = self.output_dir / 'index.html' output_file = self.output_dir / 'index.html'
with open(output_file, 'w') as f: with open(output_file, 'w') as f:
@ -1286,7 +1301,7 @@ class HTMLGenerator:
html += '</div>' html += '</div>'
return html return html
def _generate_html(self, data: dict, years: List[int], users: List[str], db: Database, config: Config) -> str: def _generate_html(self, data: dict, years: List[int], users: List[str], db: Database, config: Config, results: List[dict]) -> str:
"""Generate HTML content""" """Generate HTML content"""
# Get refresh interval from config (default 5 minutes = 300 seconds) # Get refresh interval from config (default 5 minutes = 300 seconds)
refresh_interval = config.config.get('html_refresh_interval', 300) refresh_interval = config.config.get('html_refresh_interval', 300)
@ -1303,6 +1318,19 @@ class HTMLGenerator:
for part in day.values(): for part in day.values():
users_with_data.update(part.keys()) users_with_data.update(part.keys())
# Calculate stars (completed parts) per user per year
stars_by_user_year = defaultdict(lambda: defaultdict(int))
for year in data:
for day in data[year].values():
for part in day.values():
for user, time_data in part.items():
if isinstance(time_data, dict):
total_time = time_data.get('total', 0)
else:
total_time = time_data if time_data > 0 else 0
if total_time > 0:
stars_by_user_year[user][year] += 1
# Check if log file exists # Check if log file exists
log_file_path = Path(config.output_dir) / 'cargo-aoc.log' log_file_path = Path(config.output_dir) / 'cargo-aoc.log'
log_file_exists = log_file_path.exists() log_file_exists = log_file_path.exists()
@ -1673,6 +1701,37 @@ class HTMLGenerator:
font-weight: bold; font-weight: bold;
color: #333; color: #333;
}} }}
.collapsible-header {{
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 8px;
}}
.collapsible-header:hover {{
opacity: 0.8;
}}
.collapsible-arrow {{
display: inline-block;
transition: transform 0.2s ease;
font-size: 0.9em;
}}
.collapsible-arrow.expanded {{
transform: rotate(90deg);
}}
.collapsible-content {{
display: none;
margin-top: 10px;
}}
.collapsible-content.expanded {{
display: block;
}}
</style> </style>
<script> <script>
function showHistory(user, year, day, part) {{ function showHistory(user, year, day, part) {{
@ -1691,6 +1750,17 @@ class HTMLGenerator:
}} }}
}} }}
function toggleCollapsible(element) {{
const content = element.nextElementSibling;
const arrow = element.querySelector('.collapsible-arrow');
if (content && content.classList.contains('collapsible-content')) {{
content.classList.toggle('expanded');
if (arrow) {{
arrow.classList.toggle('expanded');
}}
}}
}}
// Close modal when clicking outside of it // Close modal when clicking outside of it
window.onclick = function(event) {{ window.onclick = function(event) {{
if (event.target.classList.contains('modal')) {{ if (event.target.classList.contains('modal')) {{
@ -1720,6 +1790,43 @@ class HTMLGenerator:
<p><strong>Users:</strong> """ + ', '.join(sorted(users)) + """</p> <p><strong>Users:</strong> """ + ', '.join(sorted(users)) + """</p>
""" + (f'<p><a href="cargo-aoc.log" target="_blank">📋 View Cargo AOC Logs</a></p>' if log_file_exists else '') + """ """ + (f'<p><a href="cargo-aoc.log" target="_blank">📋 View Cargo AOC Logs</a></p>' if log_file_exists else '') + """
</div> </div>
<!-- Stars Summary Table -->
<div class="summary" style="margin-top: 20px; margin-bottom: 20px;">
<h3> Stars Summary</h3>
<!--<p style="font-size: 0.9em; color: #666; margin-bottom: 10px;">Number of completed parts (stars) per user per year</p>-->
<table>
<thead>
<tr>
<th>User</th>
"""
# Add year columns
for year in sorted_years:
html += f" <th>{year}</th>\n"
html += """ <th>Total</th>
</tr>
</thead>
<tbody>
"""
# Add rows for each user
for user in sorted(users):
html += f""" <tr>
<td><strong>{user}</strong></td>
"""
total_stars = 0
for year in sorted_years:
stars = stars_by_user_year[user][year]
total_stars += stars
if stars > 0:
html += f" <td>{stars} ⭐</td>\n"
else:
html += " <td class=\"no-data\">-</td>\n"
html += f" <td><strong>{total_stars} ⭐</strong></td>\n"
html += " </tr>\n"
html += """ </tbody>
</table>
</div>
""" """
# Generate content for each year (sorted descending) # Generate content for each year (sorted descending)
@ -1944,6 +2051,65 @@ class HTMLGenerator:
</div> </div>
""" """
# Add output bytes summary table
# Use the results passed to the method
html += """
<div class="summary">
<h3 class="collapsible-header" onclick="toggleCollapsible(this)">
<span class="collapsible-arrow"></span>
Output Bytes Summary
</h3>
<div class="collapsible-content">
<p style="font-size: 0.9em; color: #666; margin-bottom: 10px;">Number of bytes written to stdout for each day/part/user combination</p>
<table>
<thead>
<tr>
<th>Year</th>
<th>Day</th>
<th>Part</th>
"""
for user in sorted(users):
html += f" <th>{user}</th>\n"
html += """ </tr>
</thead>
<tbody>
"""
# Organize output bytes by year/day/part/user
output_bytes_data = defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))
for result in results: # results is passed as parameter to _generate_html
year = result['year']
day = result['day']
part = result['part']
user = result['user']
output_bytes = result.get('output_bytes', 0)
# Store output_bytes even if 0, but we'll show "-" for 0 in the table
output_bytes_data[year][day][part][user] = output_bytes
# Generate table rows - use the same structure as main data tables
for year in sorted_years:
if year not in data:
continue
for day in sorted(data[year].keys()):
for part in sorted(data[year][day].keys()):
html += f""" <tr>
<td>{year}</td>
<td>{day}</td>
<td>{part}</td>
"""
for user in sorted(users):
bytes_val = output_bytes_data[year][day][part].get(user, None)
if bytes_val is not None and bytes_val > 0:
html += f" <td>{bytes_val:,}</td>\n"
else:
html += " <td>-</td>\n"
html += " </tr>\n"
html += """ </tbody>
</table>
</div>
</div>
"""
# Add summary statistics at the bottom # Add summary statistics at the bottom
html += f""" html += f"""
<div class="summary"> <div class="summary">