Last active
October 27, 2025 23:30
-
-
Save ceesco53/e42761dde6abf96824d46d7cdfc7bb3a to your computer and use it in GitHub Desktop.
maestro_dashboard_watch.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| # -*- coding: utf-8 -*- | |
| """ | |
| Maestro Certificate Rotation Dashboard | |
| - Chains: chip layout; scrollable; per-card Sort Mode (Urgency / Name / Issuer depth exact) | |
| - Exact issuer-depth calculation per node | |
| - Hover tooltips show full issuer-chain trace | |
| - Breadcrumbs above each card | |
| - Dependency violation highlight (child expires before parent) | |
| - Timeline / Table / Insights | |
| - Watch folder + auto-refresh; CSV/JSON/Runbook; optional PDF export (WeasyPrint) | |
| """ | |
| import os, io, csv, glob, hashlib, datetime as dt | |
| from typing import Any, Dict, List, Optional, Tuple | |
| from flask import Flask, request, render_template_string, jsonify, Response | |
| import yaml | |
| # Optional PDF engine | |
| try: | |
| from weasyprint import HTML # type: ignore | |
| HAS_WEASYPRINT = True | |
| except Exception: | |
| HAS_WEASYPRINT = False | |
| app = Flask(__name__) | |
| SLA_COLORS = {"<=30":"#e11d48","<=60":"#f97316","<=90":"#ca8a04",">90":"#16a34a","no-date":"#6b7280"} | |
| HTML_PAGE = r""" | |
| <!doctype html> | |
| <html> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <title>Maestro Certificate Rotation Dashboard</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <style> | |
| body { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif; margin: 24px; background:#f8fafc; color:#0f172a;} | |
| .row { display:flex; gap:12px; flex-wrap:wrap; align-items:center; } | |
| .card { background:#fff; border:1px solid #e5e7eb; border-radius:12px; padding:16px; } | |
| .muted { color:#64748b; font-size:13px; } | |
| .legend { display:flex; gap:8px; align-items:center; } | |
| .pill { border-radius:8px; padding:2px 8px; color:#fff; font-size:12px; } | |
| .tabs { display:flex; gap:8px; border-bottom:1px solid #e5e7eb; margin-top:8px; } | |
| .tab { padding:8px 12px; border:1px solid #e5e7eb; border-bottom:none; border-radius:8px 8px 0 0; background:#f1f5f9; cursor:pointer; } | |
| .tab.active { background:#fff; font-weight:600; } | |
| .tab-panel { display:none; } | |
| .tab-panel.active { display:block; } | |
| table { border-collapse:collapse; width:100%; font-size:14px; } | |
| th,td { border-top:1px solid #e5e7eb; padding:8px 10px; text-align:left; vertical-align:top; } | |
| thead th { position:sticky; top:0; background:#f1f5f9; } | |
| code { background:#f1f5f9; padding:2px 6px; border-radius:6px; } | |
| .badge { color:#fff; border-radius:999px; padding:2px 8px; font-size:12px; background:#334155;} | |
| .grid { display:grid; gap:12px; grid-template-columns: repeat(auto-fit, minmax(420px, 1fr)); } | |
| .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } | |
| .btn { padding:6px 10px; border:1px solid #e5e7eb; border-radius:8px; background:#fff; cursor:pointer; } | |
| .btn:hover { background:#f8fafc; } | |
| /* Chains chip layout */ | |
| .chain-viewport { overflow: auto; border: 1px solid #e5e7eb; border-radius: 8px; padding: 8px; background: #fff; } | |
| .tier { margin: 6px 0 12px 0; } | |
| .tier h5 { margin: 0 0 6px 0; color: #475569; font-size: 13px; } | |
| .chips { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } | |
| .chip { display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; border-radius: 8px; background: #f1f5f9; border: 1px solid #e5e7eb; } | |
| .chip .line1 { display: flex; justify-content: space-between; font-size: 12px; } | |
| .chip .line2 { display: flex; justify-content: space-between; font-size: 11px; color: #475569; } | |
| .chip .badges { display: flex; gap: 6px; } | |
| .chip .badge { font-size:10px; padding:1px 6px; border-radius:999px; } | |
| .chip .sla { width: 10px; height: 10px; border-radius: 50%; align-self: flex-end; } | |
| .chip.violation { border:2px solid #ef4444; box-shadow: 0 0 0 2px rgba(239,68,68,0.15) inset; } | |
| .crumbs { font-size:12px; color:#475569; } | |
| .ins-grid { display:grid; gap:12px; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); } | |
| .mini { border:1px solid #e5e7eb; border-radius:8px; padding:8px; background:#fff; } | |
| .mini h5 { margin:0 0 6px 0; font-size:13px; } | |
| </style> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/4.4.1/chart.umd.min.js"></script> | |
| </head> | |
| <body> | |
| <h2>Maestro Certificate Rotation Dashboard</h2> | |
| <p class="muted">Auto-refresh on change (watch folder). Chains show tiered chips; Sort Mode per-card; dependency violations highlighted.</p> | |
| <div class="card row" style="align-items:center;"> | |
| <label>Watch Folder: <input id="watchDir" style="min-width:340px;" placeholder="/path/to/dir"/></label> | |
| <button class="btn" onclick="loadData()">Load</button> | |
| <button class="btn" onclick="manualRefresh()">Refresh</button> | |
| <button class="btn" onclick="exportCSV()">Export CSV</button> | |
| <button class="btn" onclick="exportJSON()">Export JSON</button> | |
| <button class="btn" onclick="downloadRunbook()">Runbook.md</button> | |
| <button id="pdfBtn" class="btn" onclick="exportPDF()" disabled>Export PDF</button> | |
| <span class="muted" id="status"></span> | |
| <div class="legend" style="margin-left:auto;"> | |
| <span class="pill" style="background:#e11d48">≤30d</span> | |
| <span class="pill" style="background:#f97316">≤60d</span> | |
| <span class="pill" style="background:#ca8a04">≤90d</span> | |
| <span class="pill" style="background:#16a34a">>90d</span> | |
| <span class="pill" style="background:#6b7280">No date</span> | |
| </div> | |
| </div> | |
| <div class="card" style="margin-top:12px;"> | |
| <div class="tabs"> | |
| <div class="tab active" data-tab="timeline">Timeline</div> | |
| <div class="tab" data-tab="table">Table</div> | |
| <div class="tab" data-tab="chains">Chains</div> | |
| <div class="tab" data-tab="insights">Insights</div> | |
| </div> | |
| <div id="panel-timeline" class="tab-panel active"> | |
| <div class="row" style="margin:8px 0;"> | |
| <label class="muted">Foundation</label> | |
| <select id="fnd" onchange="applyFilters()"><option value="all">All</option></select> | |
| <label class="muted">SLA</label> | |
| <select id="sla" onchange="applyFilters()"> | |
| <option value="all">All</option> | |
| <option value="<=30">≤30</option> | |
| <option value="<=60">≤60</option> | |
| <option value="<=90">≤90</option> | |
| <option value=">90">>90</option> | |
| <option value="no-date">No date</option> | |
| </select> | |
| <label style="display:flex; align-items:center; gap:6px;"><input type="checkbox" id="onlyActive" onchange="applyFilters()"/>Only active</label> | |
| <label style="display:flex; align-items:center; gap:6px;"><input type="checkbox" id="onlyCA" onchange="applyFilters()"/>Only CA</label> | |
| <label style="display:flex; align-items:center; gap:6px;"><input type="checkbox" id="onlyTrans" onchange="applyFilters()"/>Only transitional</label> | |
| </div> | |
| <canvas id="chart" height="520"></canvas> | |
| </div> | |
| <div id="panel-table" class="tab-panel"> | |
| <div style="max-height:520px; overflow:auto;"> | |
| <table id="tbl"> | |
| <thead> | |
| <tr> | |
| <th>Foundation</th><th>Cert</th><th>Version</th><th>Issuer</th><th>Active</th><th>CA</th><th>T</th><th>Deployments</th><th>Valid Until</th><th>Days</th><th>SLA</th> | |
| </tr> | |
| </thead> | |
| <tbody></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <div id="panel-chains" class="tab-panel"> | |
| <div class="row" style="margin:8px 0; gap:16px;"> | |
| <label style="display:flex; align-items:center; gap:6px;"> | |
| <input type="checkbox" id="chainsUseFilters" checked onchange="applyFilters()"/> | |
| Chains use current filters | |
| </label> | |
| <label style="display:flex; align-items:center; gap:6px;"> | |
| <input type="checkbox" id="groupByFoundation" onchange="applyFilters()"/> | |
| Group by foundation | |
| </label> | |
| <label>Zoom <input type="range" min="50" max="200" value="100" id="chainsZoom" oninput="applyFilters()" /> <span id="chainsZoomVal">100%</span></label> | |
| <label>Card height <input type="number" id="chainsMaxH" value="420" style="width:80px;" oninput="applyFilters()" /> px</label> | |
| </div> | |
| <div id="chains" class="grid"></div> | |
| </div> | |
| <div id="panel-insights" class="tab-panel"> | |
| <div class="row" style="margin-bottom:8px;"> | |
| <label class="muted">What‑if target date: </label> | |
| <input id="whatIfDate" type="date" onchange="renderInsights(filteredRows())" /> | |
| <label class="muted" style="margin-left:12px;">Heatmap view</label> | |
| <select id="insightsView" onchange="renderInsights(filteredRows())"> | |
| <option value="calendar">Calendar strip</option> | |
| <option value="monthly">Monthly panels</option> | |
| <option value="hist">Weekly histogram</option> | |
| </select> | |
| <label style="display:flex; align-items:center; gap:6px; margin-left:12px;"> | |
| <input type="checkbox" id="groupHeatByFoundation" onchange="renderInsights(filteredRows())"/> | |
| Group heatmap by foundation | |
| </label> | |
| </div> | |
| <div id="insights" class="grid"></div> | |
| </div> | |
| </div> | |
| <script> | |
| const COLORS = {"<=30":"#e11d48","<=60":"#f97316","<=90":"#ca8a04",">90":"#16a34a","no-date":"#6b7280"}; | |
| let POLL_HANDLE = null, SIG_HANDLE = null; | |
| let RAW_ROWS = []; | |
| let chart = null; | |
| let CUR_DIR = ""; | |
| let CUR_SIG = ""; | |
| let CARD_DATA = {}; | |
| function toBucket(n) { | |
| if (n === null || n === undefined || n === "") return 'no-date'; | |
| n = Number(n); | |
| if (n <= 30) return '<=30'; | |
| if (n <= 60) return '<=60'; | |
| if (n <= 90) return '<=90'; | |
| return '>90'; | |
| } | |
| // Tabs | |
| document.querySelectorAll('.tab').forEach(el => { | |
| el.addEventListener('click', () => { | |
| document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active')); | |
| document.querySelectorAll('.tab-panel').forEach(p=>p.classList.remove('active')); | |
| el.classList.add('active'); | |
| document.getElementById('panel-' + el.dataset.tab).classList.add('active'); | |
| if (el.dataset.tab === 'timeline') renderChart(filteredRows()); | |
| if (el.dataset.tab === 'table') renderTable(filteredRows()); | |
| if (el.dataset.tab === 'chains') renderChains(document.getElementById('chainsUseFilters')?.checked ? filteredRows() : RAW_ROWS); | |
| if (el.dataset.tab === 'insights') renderInsights(filteredRows()); | |
| }); | |
| }); | |
| function status(msg) { document.getElementById('status').textContent = msg; } | |
| async function checkPDFAvailability() { | |
| try { const res = await fetch('/api/pdf_available'); const data = await res.json(); return !!data.available; } | |
| catch { return false; } | |
| } | |
| async function updatePDFButton() { | |
| const btn = document.getElementById('pdfBtn'); if (!btn) return; | |
| const available = await checkPDFAvailability(); | |
| const fndSel = document.getElementById('fnd'); | |
| const oneFoundation = (fndSel && fndSel.value && fndSel.value !== 'all'); | |
| btn.disabled = !(available && oneFoundation); | |
| if (!available) document.getElementById('status').textContent = 'PDF export unavailable — install WeasyPrint'; | |
| } | |
| function loadData() { | |
| const dir = document.getElementById('watchDir').value.trim(); | |
| if (!dir) { alert('Enter a watch folder path'); return; } | |
| CUR_DIR = dir; | |
| fetchRows(dir); | |
| if (POLL_HANDLE) clearInterval(POLL_HANDLE); | |
| POLL_HANDLE = setInterval(()=>fetchRows(dir), 30000); | |
| if (SIG_HANDLE) clearInterval(SIG_HANDLE); | |
| SIG_HANDLE = setInterval(()=>checkSig(dir), 10000); | |
| } | |
| function manualRefresh() { if (CUR_DIR) fetchRows(CUR_DIR); } | |
| async function checkSig(dir) { | |
| try { const res = await fetch('/api/version?dir=' + encodeURIComponent(dir)); | |
| if (!res.ok) return; const data = await res.json(); | |
| if (data.sig && data.sig !== CUR_SIG) { CUR_SIG = data.sig; fetchRows(dir); } | |
| } catch (e) {} | |
| } | |
| async function fetchRows(dir) { | |
| try { | |
| status('Loading...'); | |
| const res = await fetch('/api/rows?dir=' + encodeURIComponent(dir)); | |
| if (!res.ok) throw new Error('HTTP ' + res.status); | |
| RAW_ROWS = await res.json(); | |
| populateFoundationSelect(); | |
| applyFilters(); | |
| status(`Loaded ${RAW_ROWS.length} rows @ ` + new Date().toLocaleTimeString()); | |
| } catch (e) { console.error(e); status('Error: ' + e.message); } | |
| } | |
| function populateFoundationSelect() { | |
| const fndSel = document.getElementById('fnd'); | |
| const prev = fndSel.value; | |
| const fnds = Array.from(new Set(RAW_ROWS.map(r => r.foundation))).sort(); | |
| fndSel.innerHTML = '<option value="all">All</option>' + fnds.map(f=>`<option value="${f}">${f}</option>`).join(''); | |
| if (fnds.includes(prev)) fndSel.value = prev; | |
| updatePDFButton(); | |
| } | |
| function filteredRows() { | |
| const fnd = document.getElementById('fnd').value; | |
| const onlyActive = document.getElementById('onlyActive').checked; | |
| const onlyCA = document.getElementById('onlyCA').checked; | |
| const onlyTrans = document.getElementById('onlyTrans').checked; | |
| const sla = document.getElementById('sla').value; | |
| let data = RAW_ROWS.slice(); | |
| if (fnd !== 'all') data = data.filter(r => r.foundation === fnd); | |
| if (onlyActive) data = data.filter(r => r.active); | |
| if (onlyCA) data = data.filter(r => r.certificate_authority); | |
| if (onlyTrans) data = data.filter(r => r.transitional); | |
| if (sla !== 'all') data = data.filter(r => toBucket(r.days_remaining) === sla); | |
| data.sort((a,b)=> (a.days_remaining ?? 9e9) - (b.days_remaining ?? 9e9) || a.foundation.localeCompare(b.foundation) || a.cert_name.localeCompare(b.cert_name)); | |
| return data; | |
| } | |
| function applyFilters() { | |
| const z = parseInt(document.getElementById('chainsZoom')?.value || '100',10); | |
| const zv = document.getElementById('chainsZoomVal'); if (zv) zv.textContent = z + '%'; | |
| const data = filteredRows(); | |
| renderChart(data); | |
| renderTable(data); | |
| const useFilters = document.getElementById('chainsUseFilters')?.checked !== false; | |
| renderChains(useFilters ? data : RAW_ROWS); | |
| renderInsights(data); | |
| updatePDFButton(); | |
| } | |
| function ensureChart() { | |
| const ctx = document.getElementById('chart'); | |
| if (!chart) { | |
| chart = new Chart(ctx, { | |
| type: 'bar', | |
| data: { labels: [], datasets: [{ label: 'Days Remaining', data: [], backgroundColor: [] }] }, | |
| options: { indexAxis: 'y', responsive:true, plugins:{ legend:{ display:false } }, scales:{ x:{ title:{ display:true, text:'Days Remaining' } } } } | |
| }); | |
| } | |
| return chart; | |
| } | |
| function renderChart(rows) { | |
| const c = ensureChart(); | |
| const labels = rows.map(r => `${r.foundation} • ${r.cert_name} • ${r.version_id_short}${r.active ? ' • ACTIVE' : ''}`); | |
| c.data.labels = labels; | |
| c.data.datasets[0].data = rows.map(r => (r.days_remaining ?? 0)); | |
| c.data.datasets[0].backgroundColor = rows.map(r => COLORS[toBucket(r.days_remaining)]); | |
| c.update(); | |
| } | |
| function renderTable(rows) { | |
| const tbody = document.querySelector('#tbl tbody'); | |
| tbody.innerHTML = ''; | |
| rows.forEach(r => { | |
| const vu = r.valid_until || r.valid_until_raw; | |
| const bucket = toBucket(r.days_remaining); | |
| const tr = document.createElement('tr'); | |
| tr.innerHTML = ` | |
| <td>${r.foundation}</td> | |
| <td>${r.cert_name}</td> | |
| <td><code>${r.version_id_short}</code></td> | |
| <td>${r.issuer || ''}</td> | |
| <td>${r.active ? '<span class="badge">ACTIVE</span>' : ''}</td> | |
| <td>${r.certificate_authority ? '<span class="badge">CA</span>' : ''}</td> | |
| <td>${r.transitional ? '<span class="badge">T</span>' : ''}</td> | |
| <td>${r.deployments || ''}</td> | |
| <td>${vu || ''}</td> | |
| <td style="text-align:right;">${r.days_remaining ?? ''}</td> | |
| <td><span class="pill" style="background:${COLORS[bucket]}">${bucket}</span></td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| // ------- Chains (chip layout + exact depth + tooltips + violations) ------- | |
| function renderChains(rows) { | |
| const el = document.getElementById('chains'); | |
| el.innerHTML = ''; | |
| CARD_DATA = {}; | |
| const byFoundationOnly = document.getElementById('groupByFoundation')?.checked === true; | |
| const groups = {}; | |
| rows.forEach(r => { | |
| const key = byFoundationOnly ? r.foundation : (r.foundation + '::' + r.cert_name); | |
| (groups[key] = groups[key] || []).push(r); | |
| }); | |
| const keys = Object.keys(groups).sort(); | |
| if (keys.length === 0) { | |
| const fSel = document.getElementById('fnd')?.value || 'all'; | |
| const slaSel = document.getElementById('sla')?.value || 'all'; | |
| const onlyA = document.getElementById('onlyActive')?.checked ? 'ON' : 'OFF'; | |
| const onlyCA = document.getElementById('onlyCA')?.checked ? 'ON' : 'OFF'; | |
| const onlyT = document.getElementById('onlyTrans')?.checked ? 'ON' : 'OFF'; | |
| const useFilters = document.getElementById('chainsUseFilters')?.checked ? 'ON' : 'OFF'; | |
| const byF = document.getElementById('groupByFoundation')?.checked ? 'ON' : 'OFF'; | |
| const card = document.createElement('div'); card.className = 'card'; | |
| card.innerHTML = ` | |
| <h4 style="margin:0 0 6px 0;">No chains to display</h4> | |
| <div class="muted">Try: unchecking "Only CA"/"Only transitional", switching foundation to "All", turning OFF "Chains use current filters", or enabling "Group by foundation".</div> | |
| <div style="margin-top:8px; font-size:13px;"><strong>Current filters</strong>: foundation=<code>${fSel}</code>, SLA=<code>${slaSel}</code>, active=<code>${onlyA}</code>, CA=<code>${onlyCA}</code>, T=<code>${onlyT}</code>, ChainsUseFilters=<code>${useFilters}</code>, GroupByFoundation=<code>${byF}</code></div> | |
| `; | |
| el.appendChild(card); | |
| return; | |
| } | |
| let cardIdx = 0; | |
| keys.forEach(key => { | |
| const [foundation, ...rest] = key.split('::'); | |
| const cert = byFoundationOnly ? '(all certificates)' : rest.join('::'); | |
| const gr = groups[key].slice(); | |
| const byVid = {}; gr.forEach(x => byVid[x.version_id] = x); | |
| // exact depth + chain trace | |
| function computeDepth(n, guard=0){ | |
| if (!n) return 0; | |
| if (!n.issuer_version) return n.certificate_authority ? 1 : 99; // root CA depth 1; leaves default deep | |
| if (guard>64) return 99; | |
| const p = byVid[n.issuer_version]; | |
| if (!p) return n.certificate_authority ? 2 : 99; | |
| const parentIsRoot = (p.certificate_authority && !p.issuer_version); | |
| const base = parentIsRoot ? 1 : (computeDepth(p, guard+1)); | |
| return base + 1; | |
| } | |
| function chainTrace(n){ | |
| const out = []; | |
| let cur = n; let guard=0; | |
| while (cur && guard<64){ | |
| out.push(`${cur.cert_name}(${cur.version_id_short})`); | |
| if (!cur.issuer_version) break; | |
| cur = byVid[cur.issuer_version]; | |
| guard++; | |
| } | |
| return out.reverse(); // root -> node | |
| } | |
| // derive depth & parent lookup for violation detection | |
| gr.forEach(n => { n.__depth = computeDepth(n); n.__parent = n.issuer_version ? byVid[n.issuer_version] : null; n.__trace = chainTrace(n).join(' → '); }); | |
| const roots = gr.filter(n => n.certificate_authority && !n.issuer_version); | |
| const transCAs = gr.filter(n => n.certificate_authority && n.transitional && n.issuer_version); | |
| const interCAsAll = gr.filter(n => n.certificate_authority && !n.transitional && n.issuer_version); | |
| const leaves = gr.filter(n => !n.certificate_authority); | |
| const interLevels = {}; | |
| interCAsAll.forEach(n => { const d = n.__depth; (interLevels[d] = interLevels[d] || []).push(n); }); | |
| const interDepths = Object.keys(interLevels).map(k=>parseInt(k,10)); | |
| const maxInterDepth = interDepths.length ? Math.max(...interDepths) : 0; | |
| // cache raw arrays for resorting | |
| CARD_DATA[cardIdx] = { roots, transCAs, interLevels, maxInterDepth, leaves }; | |
| const card = document.createElement('div'); | |
| card.className = 'card'; | |
| const heightPx = (document.getElementById('chainsMaxH')?.value||420); | |
| const zoom = (parseInt(document.getElementById('chainsZoom')?.value||'100',10))/100; | |
| card.innerHTML = ` | |
| <div style="display:flex; align-items:center; gap:12px; margin-bottom:6px; flex-wrap:wrap;"> | |
| <div> | |
| <strong>${foundation} • ${cert}</strong><br/> | |
| <span class="crumbs">Hierarchy: ROOT → TRANSITIONAL → INTERMEDIATE d1..dn → LEAF</span> | |
| </div> | |
| <span style="margin-left:auto;" class="legend"> | |
| <span class="pill" style="background:#e11d48">≤30</span> | |
| <span class="pill" style="background:#f97316">≤60</span> | |
| <span class="pill" style="background:#ca8a04">≤90</span> | |
| <span class="pill" style="background:#16a34a">>90</span> | |
| <span class="pill" style="background:#6b7280">no-date</span> | |
| </span> | |
| <label>Sort mode: | |
| <select onchange="resortCard(${cardIdx}, this.value)"> | |
| <option value="urgency">Urgency (days)</option> | |
| <option value="name">Name</option> | |
| <option value="depth">Issuer depth</option> | |
| </select> | |
| </label> | |
| </div> | |
| <div class="chain-viewport" style="max-height:${heightPx}px;"> | |
| <div class="chain-canvas" style="transform: scale(${zoom}); transform-origin: top left;"></div> | |
| </div> | |
| `; | |
| el.appendChild(card); | |
| // initial render with default 'urgency' | |
| resortCard(cardIdx, 'urgency'); | |
| cardIdx += 1; | |
| }); | |
| } | |
| // Resort & rebuild a single card by index | |
| function resortCard(idx, mode) { | |
| const data = CARD_DATA[idx]; | |
| if (!data) return; | |
| const { roots, transCAs, interLevels, maxInterDepth, leaves } = data; | |
| function byUrg(a,b){ return (a.days_remaining??9e9)-(b.days_remaining??9e9) || (a.version_id_short||'').localeCompare(b.version_id_short||''); } | |
| function byName(a,b){ return (a.version_id_short||'').localeCompare(b.version_id_short||''); } | |
| function byDepth(a,b){ return (a.__depth||99)-(b.__depth||99) || byUrg(a,b); } | |
| const sortFn = (mode==='name') ? byName : (mode==='depth' ? byDepth : byUrg); | |
| const cardEls = document.querySelectorAll('#chains .card'); | |
| const cardEl = cardEls[idx]; | |
| const canvas = cardEl.querySelector('.chain-canvas'); | |
| canvas.innerHTML = ''; | |
| function chipHTML(n) { | |
| const bucket = toBucket(n.days_remaining); | |
| const color = COLORS[bucket]; | |
| const badges = [n.certificate_authority?'CA':'', n.transitional?'T':'', n.active?'ACTIVE':''] | |
| .filter(Boolean).map(b=>`<span class="badge">${b}</span>`).join(''); | |
| // dependency violation: child expires before parent | |
| const p = n.__parent; | |
| const viol = p && (p.days_remaining!=null) && (n.days_remaining!=null) && (n.days_remaining < p.days_remaining); | |
| const cls = 'chip' + (viol ? ' violation' : ''); | |
| const ttip = `${n.cert_name} • ${n.version_id}\nvalid_until: ${n.valid_until || n.valid_until_raw || ''}\ndays_remaining: ${n.days_remaining ?? 'NA'}\nissuer_chain: ${n.__trace}`; | |
| return `<div class="${cls}" title="${ttip.replace(/"/g,'"')}"> | |
| <div class="line1"><strong class="mono">${n.version_id_short}</strong><span class="sla" style="background:${color}"></span></div> | |
| <div class="line2"><span>${n.days_remaining??'NA'}d</span><span>${n.valid_until || n.valid_until_raw || ''}</span></div> | |
| <div class="line2"><span>${n.issuer_version_short?('issuer '+n.issuer_version_short):''} ${n.__depth?('• d'+n.__depth):''}</span><div class="badges">${badges}</div></div> | |
| </div>`; | |
| } | |
| function buildTier(title, arr) { | |
| const div = document.createElement('div'); | |
| div.className = 'tier'; | |
| div.innerHTML = `<h5>${title}</h5><div class="chips"></div>`; | |
| const grid = div.querySelector('.chips'); | |
| arr.slice().sort(sortFn).forEach(n => grid.insertAdjacentHTML('beforeend', chipHTML(n))); | |
| canvas.appendChild(div); | |
| } | |
| buildTier('ROOT CAs', roots); | |
| buildTier('TRANSITIONAL CAs', transCAs); | |
| for (let d = 1; d <= maxInterDepth; d++) buildTier(`INTERMEDIATE d${d}`, (interLevels[d]||[])); | |
| buildTier('LEAVES', leaves); | |
| } | |
| // ------- Insights (heatmap, grouped toggle, multiple views) ------- | |
| function renderInsights(rows) { | |
| const root = document.getElementById('insights'); | |
| if (!root) return; | |
| root.innerHTML = ''; | |
| const groupByFnd = document.getElementById('groupHeatByFoundation')?.checked === true; | |
| const view = document.getElementById('insightsView')?.value || 'calendar'; | |
| function parseEvents(inRows){ | |
| function parseISO(s){ try { return s ? new Date(s) : null; } catch { return null; } } | |
| const evts = []; | |
| inRows.forEach(r => { | |
| const d = parseISO(r.valid_until) || parseISO(r.valid_until_raw); | |
| if (!d || isNaN(d.getTime())) return; | |
| evts.push({ date:d, foundation:r.foundation, cert:r.cert_name, version:r.version_id_short }); | |
| }); | |
| return evts; | |
| } | |
| function startOfWeek(d){ const x = new Date(d); const day=x.getDay(); const diff=(day+6)%7; x.setDate(x.getDate()-diff); x.setHours(0,0,0,0); return x; } | |
| function fmt(d){ return d.toISOString().slice(0,10); } | |
| function buildWeekMap(events){ | |
| const weekMap = new Map(); | |
| events.forEach(e => { const wk = startOfWeek(e.date); const k = fmt(wk); | |
| if (!weekMap.has(k)) weekMap.set(k,{count:0, items:[]}); | |
| const rec = weekMap.get(k); rec.count++; rec.items.push(e); | |
| }); | |
| return weekMap; | |
| } | |
| function palette(val, max){ if (val<=0) return '#e5e7eb'; const t=Math.max(0,Math.min(1,val/max)); const a=0.25+0.75*t; return `rgba(22,163,74,${a})`; } | |
| function appendCalendarStrip(title, events){ | |
| const weekMap = buildWeekMap(events); | |
| if (!events.length) return; | |
| const minDt = new Date(Math.min(...events.map(e=>e.date.getTime()))); | |
| const maxDtEvent = new Date(Math.max(...events.map(e=>e.date.getTime()))); | |
| const horizon = new Date(); horizon.setMonth(horizon.getMonth()+12); | |
| const endDt = maxDtEvent > horizon ? maxDtEvent : horizon; | |
| const minWeek = startOfWeek(minDt); | |
| const weeks = []; for (let d=new Date(minWeek); d<=endDt; d=new Date(d.getTime()+7*86400000)) weeks.push(new Date(d)); | |
| const counts = weeks.map(w => (weekMap.get(fmt(w))||{count:0}).count); | |
| const maxCount = Math.max(1, ...counts); | |
| const cell=14, gap=2, padL=60, padT=18; | |
| const cols = weeks.length; | |
| const vbW = padL + cols*(cell+gap) + 10; | |
| const vbH = padT + 7*(cell+gap) + 18; | |
| let svg = `<svg viewBox="0 0 ${vbW} ${vbH}" width="100%" height="${vbH}" xmlns="http://www.w3.org/2000/svg">`; | |
| const dnames = ['Mon','Tue','Wed','Thu','Fri','Sat','Sun']; | |
| for (let r=0; r<7; r++){ svg += `<text x="8" y="${padT + r*(cell+gap) + cell - 2}" font-size="10" fill="#475569">${dnames[r]}</text>`; } | |
| function monthName(d){ return d.toLocaleString(undefined,{month:'short'}); } | |
| let lastMonth=-1; | |
| weeks.forEach((wk, idx)=>{ const m=wk.getMonth(); if (m!==lastMonth){ svg += `<text x="${padL + idx*(cell+gap)}" y="12" font-size="11" fill="#0f172a">${monthName(wk)}</text>`; lastMonth=m; } }); | |
| weeks.forEach((wk,c)=>{ | |
| for (let r=0; r<7; r++){ | |
| const k=fmt(startOfWeek(new Date(wk.getTime()+r*86400000))); | |
| const val=(weekMap.get(k)||{count:0}).count; | |
| const x=padL + c*(cell+gap), y=padT + r*(cell+gap); | |
| svg += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="${palette(val,maxCount)}"><title>${k} | |
| ${val} expiration(s)</title></rect>`; | |
| } | |
| }); | |
| svg += `</svg>`; | |
| const card = document.createElement('div'); card.className='card'; card.innerHTML=`<h4 style="margin:0 0 6px 0;">${title}</h4>${svg}`; | |
| root.appendChild(card); | |
| appendTopWeeks(title, weekMap); | |
| } | |
| function appendMonthlyPanels(title, events){ | |
| const weekMap = buildWeekMap(events); | |
| if (!events.length) return; | |
| const minDt = new Date(Math.min(...events.map(e=>e.date.getTime()))); | |
| const maxDtEvent = new Date(Math.max(...events.map(e=>e.date.getTime()))); | |
| const start = new Date(minDt.getFullYear(), minDt.getMonth(), 1); | |
| const horizon = new Date(); horizon.setMonth(horizon.getMonth()+12); | |
| const endDt = maxDtEvent > horizon ? maxDtEvent : horizon; | |
| const end = new Date(endDt.getFullYear(), endDt.getMonth(), 1); | |
| // produce months inclusive | |
| const months = []; | |
| for (let d=new Date(start); d<=end; d=new Date(d.getFullYear(), d.getMonth()+1, 1)) months.push(new Date(d)); | |
| const wrap = document.createElement('div'); wrap.className='card'; wrap.innerHTML = `<h4 style="margin:0 0 6px 0;">${title}</h4><div class="ins-grid"></div>`; | |
| const grid = wrap.querySelector('.ins-grid'); | |
| months.forEach(md => { | |
| const first = new Date(md.getFullYear(), md.getMonth(), 1); | |
| const last = new Date(md.getFullYear(), md.getMonth()+1, 0); | |
| const weeks = []; | |
| // compute Monday-aligned start | |
| const s = new Date(first); const sdiff=(s.getDay()+6)%7; s.setDate(s.getDate()-sdiff); | |
| for (let d=new Date(s); d<=last || d.getDay()!==1; d=new Date(d.getTime()+7*86400000)) weeks.push(new Date(d)); | |
| // max count for palette scaling within the month | |
| const counts = weeks.map(w => (weekMap.get(fmt(w))||{count:0}).count); | |
| const maxCount = Math.max(1, ...counts); | |
| const cell=14, gap=2, padL=26, padT=18; | |
| const cols = weeks.length; | |
| const vbW = padL + cols*(cell+gap) + 8; | |
| const vbH = padT + 7*(cell+gap) + 12; | |
| let svg = `<svg viewBox="0 0 ${vbW} ${vbH}" width="100%" height="${vbH}" xmlns="http://www.w3.org/2000/svg">`; | |
| const dnames=['M','T','W','T','F','S','S']; | |
| for (let r=0; r<7; r++){ svg += `<text x="6" y="${padT + r*(cell+gap) + cell - 2}" font-size="9" fill="#475569">${dnames[r]}</text>`; } | |
| weeks.forEach((wk,c)=>{ | |
| for (let r=0; r<7; r++){ | |
| const k=fmt(startOfWeek(new Date(wk.getTime()+r*86400000))); | |
| const val=(weekMap.get(k)||{count:0}).count; | |
| const x=padL + c*(cell+gap), y=padT + r*(cell+gap); | |
| svg += `<rect x="${x}" y="${y}" width="${cell}" height="${cell}" fill="${palette(val,maxCount)}"><title>${k} | |
| ${val} expiration(s)</title></rect>`; | |
| } | |
| }); | |
| svg += `</svg>`; | |
| const mini = document.createElement('div'); mini.className='mini'; | |
| mini.innerHTML = `<h5>${md.toLocaleString(undefined,{month:'long', year:'numeric'})}</h5>${svg}`; | |
| grid.appendChild(mini); | |
| }); | |
| root.appendChild(wrap); | |
| appendTopWeeks(title, weekMap); | |
| } | |
| function appendTopWeeks(title, weekMap){ | |
| const sortedWeeks = Array.from(weekMap.entries()) | |
| .sort((a,b)=> b[1].count - a[1].count) | |
| .slice(0, 8); | |
| const top = document.createElement('div'); top.className = 'card'; | |
| let rowsHtml = ''; | |
| sortedWeeks.forEach(([k,rec])=>{ | |
| const list = rec.items | |
| .slice(0,5) | |
| .map(e => `${e.foundation} • ${e.cert} \`${e.version}\``) | |
| .join('<br/>'); | |
| rowsHtml += `<tr><td><code>${k}</code></td><td style="text-align:right;"><strong>${rec.count}</strong></td><td>${list}${rec.items.length>5?'…':''}</td></tr>`; | |
| }); | |
| if (!rowsHtml) rowsHtml = '<tr><td colspan="3" class="muted">No upcoming expirations found.</td></tr>'; | |
| top.innerHTML = `<h4 style="margin:0 0 6px 0;">Busiest Weeks — ${title}</h4> | |
| <table style="width:100%; border-collapse:collapse;"> | |
| <thead><tr><th>Week (Mon)</th><th style="text-align:right;">Count</th><th>Examples</th></tr></thead> | |
| <tbody>${rowsHtml}</tbody> | |
| </table>`; | |
| root.appendChild(top); | |
| } | |
| function appendWeeklyHistogram(title, events){ | |
| const weekMap = buildWeekMap(events); | |
| const weeks = Array.from(weekMap.keys()).sort(); | |
| const data = weeks.map(k => weekMap.get(k).count); | |
| const card = document.createElement('div'); card.className='card'; | |
| const id = 'hist_' + Math.random().toString(36).slice(2); | |
| card.innerHTML = `<h4 style="margin:0 0 6px 0;">${title} — Weekly histogram</h4><canvas id="${id}" height="180"></canvas>`; | |
| root.appendChild(card); | |
| const ctx = document.getElementById(id).getContext('2d'); | |
| new Chart(ctx, { type:'bar', data:{ labels: weeks, datasets:[{ label:'Expirations', data, backgroundColor:'#16a34a' }]}, | |
| options:{ responsive:true, plugins:{legend:{display:false}}, scales:{ x:{ ticks:{ maxRotation:0, autoSkip:true, maxTicksLimit:12 }}}}); | |
| appendTopWeeks(title, weekMap); | |
| } | |
| if (!rows || !rows.length) { | |
| const card = document.createElement('div'); card.className = 'card'; | |
| card.innerHTML = '<div class="muted">No data in scope for insights.</div>'; | |
| root.appendChild(card); return; | |
| } | |
| const events = parseEvents(rows); | |
| if (!events.length) { | |
| const card = document.createElement('div'); card.className = 'card'; | |
| card.innerHTML = '<div class="muted">No valid expiration dates in scope — try widening filters.</div>'; | |
| root.appendChild(card); return; | |
| } | |
| if (!groupByFnd){ | |
| if (view==='monthly') appendMonthlyPanels('Rotation Splash (All Foundations)', events); | |
| else if (view==='hist') appendWeeklyHistogram('All Foundations', events); | |
| else appendCalendarStrip('Rotation Splash (All Foundations)', events); | |
| } else { | |
| const byF = {}; events.forEach(e => { (byF[e.foundation]=byF[e.foundation]||[]).push(e); }); | |
| Object.keys(byF).sort().forEach(f => { | |
| if (view==='monthly') appendMonthlyPanels(`Rotation Splash — ${f}`, byF[f]); | |
| else if (view==='hist') appendWeeklyHistogram(f, byF[f]); | |
| else appendCalendarStrip(`Rotation Splash — ${f}`, byF[f]); | |
| }); | |
| } | |
| } | |
| // Exports & Runbook | |
| function queryFromFilters() { | |
| const params = new URLSearchParams(); | |
| params.set('dir', CUR_DIR || ''); | |
| params.set('foundation', document.getElementById('fnd').value); | |
| params.set('sla', document.getElementById('sla').value); | |
| params.set('onlyActive', document.getElementById('onlyActive').checked ? '1' : '0'); | |
| params.set('onlyCA', document.getElementById('onlyCA').checked ? '1' : '0'); | |
| params.set('onlyTrans', document.getElementById('onlyTrans').checked ? '1' : '0'); | |
| return params.toString(); | |
| } | |
| function exportCSV() { const qs = queryFromFilters(); window.open('/api/export/csv?' + qs, '_blank'); } | |
| function exportJSON() { const qs = queryFromFilters(); window.open('/api/export/json?' + qs, '_blank'); } | |
| function downloadRunbook() { const qs = queryFromFilters(); window.open('/api/runbook?' + qs, '_blank'); } | |
| function exportPDF() { const qs = queryFromFilters(); window.open('/api/export/pdf?' + qs, '_blank'); } | |
| </script> | |
| </body> | |
| </html> | |
| """ | |
| # ----------------- Python helpers ----------------- | |
| def _parse_dt(s: Optional[str]) -> Optional[dt.datetime]: | |
| if not s: return None | |
| s = str(s).strip().replace(" ", "T") | |
| if s.endswith("Z"): s = s[:-1] + "+00:00" | |
| try: | |
| return dt.datetime.fromisoformat(s) | |
| except Exception: | |
| return None | |
| def _days_remaining(d: Optional[dt.datetime]) -> Optional[int]: | |
| if not isinstance(d, dt.datetime): return None | |
| if d.tzinfo is None: | |
| d = d.replace(tzinfo=dt.timezone.utc) | |
| now = dt.datetime.now(dt.timezone.utc) | |
| return (d - now).days | |
| def _short(s: Optional[str]) -> str: | |
| if not s: return "" | |
| return s[:8] + "…" if len(s) > 9 else s | |
| def _flatten_doc(doc: Dict[str, Any], foundation: str) -> List[Dict[str, Any]]: | |
| topo = (doc or {}).get("output", {}).get("topology", []) | |
| index = {} | |
| for cert in topo: | |
| for v in (cert.get("versions") or []): | |
| vid = v.get("version_id") | |
| if vid: | |
| index[vid] = cert.get("name") or "UNKNOWN_CERT" | |
| rows = [] | |
| for cert in topo: | |
| cname = cert.get("name") or "UNKNOWN_CERT" | |
| for v in (cert.get("versions") or []): | |
| vu = _parse_dt(v.get("valid_until")) | |
| issuer_vid = v.get("signed_by_version") or "" | |
| issuer = f"{index.get(issuer_vid, issuer_vid)}({_short(issuer_vid)})" if issuer_vid else "" | |
| rows.append({ | |
| "foundation": foundation, | |
| "cert_name": cname, | |
| "version_id": v.get("version_id") or "", | |
| "version_id_short": _short(v.get("version_id") or ""), | |
| "issuer_version": issuer_vid, | |
| "issuer_version_short": _short(issuer_vid) if issuer_vid else "", | |
| "issuer": issuer, | |
| "active": bool(v.get("active", False)), | |
| "certificate_authority": bool(v.get("certificate_authority", False)), | |
| "transitional": bool(v.get("transitional", False)), | |
| "deployments": ", ".join(v.get("deployment_names") or []) if isinstance(v.get("deployment_names"), list) else (v.get("deployment_names") or ""), | |
| "valid_until": (vu.isoformat() if vu else (v.get("valid_until") or "")), | |
| "valid_until_raw": v.get("valid_until") or "", | |
| "days_remaining": _days_remaining(vu), | |
| }) | |
| return rows | |
| def _load_dir(dirpath: str) -> List[Dict[str, Any]]: | |
| out: List[Dict[str, Any]] = [] | |
| ymls = [] | |
| ymls.extend(glob.glob(os.path.join(dirpath, "*.yml"))) | |
| ymls.extend(glob.glob(os.path.join(dirpath, "*.yaml"))) | |
| for path in sorted(ymls): | |
| foundation = os.path.splitext(os.path.basename(path))[0] | |
| try: | |
| with open(path, "r", encoding="utf-8") as f: | |
| doc = yaml.safe_load(f) | |
| out.extend(_flatten_doc(doc, foundation)) | |
| except Exception as e: | |
| print("Failed to parse", path, ":", e) | |
| return out | |
| def _dir_signature(dirpath: str) -> str: | |
| files = [] | |
| files.extend(glob.glob(os.path.join(dirpath, "*.yml"))) | |
| files.extend(glob.glob(os.path.join(dirpath, "*.yaml"))) | |
| sig_items = [] | |
| for p in sorted(files): | |
| try: | |
| st = os.stat(p) | |
| sig_items.append(f"{os.path.basename(p)}:{int(st.st_mtime)}:{st.st_size}") | |
| except Exception: | |
| continue | |
| return hashlib.sha256("|".join(sig_items).encode("utf-8")).hexdigest() | |
| def _dir_file_hashes(dirpath: str): | |
| files = [] | |
| files.extend(glob.glob(os.path.join(dirpath, '*.yml'))) | |
| files.extend(glob.glob(os.path.join(dirpath, '*.yaml'))) | |
| out = [] | |
| for p in sorted(files): | |
| try: | |
| with open(p, 'rb') as f: | |
| h = hashlib.sha256(f.read()).hexdigest() | |
| st = os.stat(p) | |
| out.append({'file': os.path.basename(p), 'sha256': h, 'mtime': int(st.st_mtime), 'size': st.st_size}) | |
| except Exception: | |
| continue | |
| return out | |
| # ----------------- Routes ----------------- | |
| @app.route("/", methods=["GET"]) | |
| def page(): | |
| return render_template_string(HTML_PAGE) | |
| @app.route("/api/rows", methods=["GET"]) | |
| def api_rows(): | |
| dirpath = request.args.get("dir", "").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return jsonify([]) | |
| rows = _load_dir(dirpath) | |
| return jsonify(rows) | |
| @app.route("/api/version", methods=["GET"]) | |
| def api_version(): | |
| dirpath = request.args.get("dir", "").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return jsonify({"sig": ""}) | |
| return jsonify({"sig": _dir_signature(dirpath)}) | |
| @app.route("/api/export/csv", methods=["GET"]) | |
| def api_export_csv(): | |
| dirpath = request.args.get("dir", "").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return Response("", mimetype="text/csv") | |
| rows = _apply_filters(_load_dir(dirpath), request.args) | |
| out = io.StringIO() | |
| w = csv.writer(out) | |
| w.writerow(["foundation","cert_name","version_id_short","issuer","active","certificate_authority","transitional","deployments","valid_until","days_remaining"]) | |
| for r in rows: | |
| w.writerow([r["foundation"], r["cert_name"], r["version_id_short"], r["issuer"], r["active"], | |
| r["certificate_authority"], r["transitional"], r["deployments"], r["valid_until"], r["days_remaining"]]) | |
| return Response(out.getvalue(), mimetype="text/csv", | |
| headers={"Content-Disposition":"attachment; filename=cert_rotation_plan.csv"}) | |
| @app.route("/api/export/json", methods=["GET"]) | |
| def api_export_json(): | |
| dirpath = request.args.get("dir", "").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return jsonify([]) | |
| rows = _apply_filters(_load_dir(dirpath), request.args) | |
| return jsonify(rows) | |
| @app.route("/api/runbook", methods=["GET"]) | |
| def api_runbook(): | |
| dirpath = request.args.get("dir", "").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return Response("# Runbook\n\n_No directory provided._\n", mimetype="text/markdown") | |
| rows = _apply_filters(_load_dir(dirpath), request.args) | |
| def md_escape(s: str) -> str: | |
| return s.replace("|","\\|") | |
| cas = [r for r in rows if r["certificate_authority"]] | |
| leaves = [r for r in rows if not r["certificate_authority"]] | |
| cas.sort(key=lambda r: (r["days_remaining"] if r["days_remaining"] is not None else 9_000_000)) | |
| leaves.sort(key=lambda r: (r["days_remaining"] if r["days_remaining"] is not None else 9_000_000)) | |
| earliest: Dict[Tuple[str,str], Dict[str, Any]] = {} | |
| for r in rows: | |
| key = (r["foundation"], r["cert_name"]) | |
| if key not in earliest or (r["days_remaining"] or 9_000_000) < (earliest[key]["days_remaining"] or 9_000_000): | |
| earliest[key] = r | |
| out = io.StringIO() | |
| out.write(f"# Certificate Rotation Runbook\n\n") | |
| out.write("## Phase 1 — Rotate Certificate Authorities (CA-first)\n\n") | |
| if not cas: | |
| out.write("_No CA expirations in scope._\n\n") | |
| else: | |
| for r in cas: | |
| out.write(f"- **{md_escape(r['foundation'])}** • **{md_escape(r['cert_name'])}** • `{r['version_id_short']}` — **{r['days_remaining']}d** (issuer: {md_escape(r.get('issuer',''))})\n") | |
| out.write("\n") | |
| out.write("## Phase 2 — Rotate Dependent Leaves\n\n") | |
| if not leaves: | |
| out.write("_No leaf expirations in scope._\n\n") | |
| else: | |
| for r in leaves: | |
| out.write(f"- {md_escape(r['foundation'])} • {md_escape(r['cert_name'])} • `{r['version_id_short']}` — {r['days_remaining']}d (issuer: {md_escape(r.get('issuer',''))})\n") | |
| out.write("\n") | |
| out.write("## Earliest Expiring per Certificate (by foundation)\n\n") | |
| for (f, c), r in sorted(earliest.items(), key=lambda kv: (kv[1]['days_remaining'] or 9_000_000)): | |
| out.write(f"- {md_escape(f)} • {md_escape(c)} → `{r['version_id_short']}` in {r['days_remaining']}d\n") | |
| out.write("\n") | |
| return Response(out.getvalue(), mimetype="text/markdown", | |
| headers={"Content-Disposition":"attachment; filename=Runbook.md"}) | |
| @app.route("/api/pdf_available", methods=["GET"]) | |
| def api_pdf_available(): | |
| return jsonify({"available": bool(HAS_WEASYPRINT)}) | |
| @app.route("/api/export/pdf", methods=["GET"]) | |
| def api_export_pdf(): | |
| if not HAS_WEASYPRINT: | |
| return Response("WeasyPrint not installed", status=409) | |
| dirpath = request.args.get("dir","").strip() | |
| if not dirpath or not os.path.isdir(dirpath): | |
| return Response("Invalid dir", status=400) | |
| rows = _apply_filters(_load_dir(dirpath), request.args) | |
| foundation = request.args.get("foundation","all") | |
| fnds = sorted(set([r["foundation"] for r in rows])) if foundation=="all" else [foundation] | |
| if len(fnds) != 1 or fnds[0] == "all": | |
| return Response("Select exactly one foundation for PDF export", status=412) | |
| fnd = fnds[0] | |
| scoped = [r for r in rows if r["foundation"]==fnd] | |
| now = dt.datetime.now() | |
| stamp = now.strftime("%Y-%m-%d %H:%M") | |
| def _bucket(n): | |
| if n is None: return 'no-date' | |
| if n <= 30: return '<=30' | |
| if n <= 60: return '<=60' | |
| if n <= 90: return '<=90' | |
| return '>90' | |
| # simple timeline SVG | |
| rows_sorted = sorted(scoped, key=lambda r: (r['days_remaining'] if r['days_remaining'] is not None else 9_000_000)) | |
| h = 24; pad = 10; width = 900 | |
| height = pad + len(rows_sorted)*(h+8) + pad + 20 | |
| max_days = max([r.get('days_remaining') or 0 for r in rows_sorted] + [90]) | |
| parts = [f"<svg width='{width}' height='{height}' xmlns='http://www.w3.org/2000/svg'>"] | |
| y = pad + 10 | |
| for r in rows_sorted: | |
| days = r.get('days_remaining') or 0 | |
| b = _bucket(r.get('days_remaining')) | |
| color = SLA_COLORS.get(b, '#6b7280') | |
| barw = int((days/max_days) * (width - 260)) if max_days else 0 | |
| label = f"{r['cert_name']} • {r['version_id_short']}" | |
| parts.append(f"<text x='6' y='{y+16}' font-size='11' fill='#0f172a'>{label}</text>") | |
| parts.append(f"<rect x='260' y='{y}' width='{barw}' height='{h}' fill='{color}' rx='4' ry='4'/>") | |
| parts.append(f"<text x='{260+barw+8}' y='{y+16}' font-size='11' fill='#334155'>{days}d</text>") | |
| y += h + 8 | |
| parts.append('</svg>') | |
| svg_timeline = ''.join(parts) | |
| # Runbook (brief) | |
| from io import StringIO | |
| sio = StringIO() | |
| sio.write(f"Certificate Rotation Runbook — {fnd}\nGenerated: {stamp}\n\n") | |
| cas = [r for r in scoped if r['certificate_authority']] | |
| leaves = [r for r in scoped if not r['certificate_authority']] | |
| cas.sort(key=lambda r: (r['days_remaining'] if r['days_remaining'] is not None else 9_000_000)) | |
| leaves.sort(key=lambda r: (r['days_remaining'] if r['days_remaining'] is not None else 9_000_000)) | |
| sio.write("Phase 1 — CAs\n") | |
| for r in cas: sio.write(f"- {r['cert_name']} `{r['version_id_short']}` — {r['days_remaining']}d (issuer: {r.get('issuer','')})\n") | |
| sio.write("\nPhase 2 — Leaves\n") | |
| for r in leaves: sio.write(f"- {r['cert_name']} `{r['version_id_short']}` — {r['days_remaining']}d (issuer: {r.get('issuer','')})\n") | |
| runbook_pre = f"<pre style='white-space:pre-wrap; font-size:12px; background:#f8fafc; padding:12px; border:1px solid #e5e7eb; border-radius:8px;'>{sio.getvalue()}</pre>" | |
| # Traceability appendix | |
| files = _dir_file_hashes(dirpath) | |
| trace_rows = ''.join([f"<tr><td>{f['file']}</td><td>{f['sha256']}</td><td>{f['size']}</td><td>{dt.datetime.fromtimestamp(f['mtime']).isoformat(sep=' ', timespec='minutes')}</td></tr>" for f in files]) | |
| trace_html = f""" | |
| <table style='width:100%; border-collapse:collapse; font-size:12px;'> | |
| <thead> | |
| <tr><th align='left'>File</th><th align='left'>SHA-256</th><th align='right'>Bytes</th><th align='left'>Modified</th></tr> | |
| </thead> | |
| <tbody>{trace_rows}</tbody> | |
| </table> | |
| """ | |
| style = """ | |
| <style> | |
| @page { size: A4; margin: 20mm; @bottom-center { content: counter(page); font-size: 10px; } } | |
| body { font-family: -apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif; color:#0f172a; } | |
| h1,h2,h3,h4 { margin: 0 0 8px 0; } | |
| .section { page-break-inside: avoid; margin: 8px 0 18px 0; } | |
| .cover { text-align:center; margin-top: 120px; } | |
| .muted { color:#475569; } | |
| .divider { height:1px; background:#e5e7eb; margin: 12px 0; } | |
| table, th, td { border: 1px solid #e5e7eb; } | |
| th, td { padding: 6px 8px; } | |
| </style> | |
| """ | |
| body = f""" | |
| <html><head>{style}</head><body> | |
| <div class='cover'> | |
| <h1>Certificate Rotation Report</h1> | |
| <h2>{fnd}</h2> | |
| <div class='muted'>Generated {stamp}</div> | |
| </div> | |
| <div class='divider'></div> | |
| <div class='section'> | |
| <h2>Timeline</h2> | |
| {svg_timeline} | |
| </div> | |
| <div class='section'> | |
| <h2>Runbook</h2> | |
| {runbook_pre} | |
| </div> | |
| <div class='section'> | |
| <h2>Traceability Appendix</h2> | |
| <div class='muted'>Hash of input YAMLs at generation time</div> | |
| {trace_html} | |
| </div> | |
| </body></html> | |
| """ | |
| pdf = HTML(string=body).write_pdf() | |
| fname = f"{fnd}-{now.strftime('%Y%m%d-%H%M')}.pdf" | |
| return Response(pdf, mimetype='application/pdf', headers={'Content-Disposition': f'attachment; filename={fname}'}) | |
| def _apply_filters(rows: List[Dict[str, Any]], args) -> List[Dict[str, Any]]: | |
| fnd = args.get("foundation", "all") | |
| sla = args.get("sla", "all") | |
| onlyActive = args.get("onlyActive") in ("1","true","True") | |
| onlyCA = args.get("onlyCA") in ("1","true","True") | |
| onlyTrans = args.get("onlyTrans") in ("1","true","True") | |
| out = rows[:] | |
| if fnd != "all": | |
| out = [r for r in out if r["foundation"] == fnd] | |
| if onlyActive: | |
| out = [r for r in out if r["active"]] | |
| if onlyCA: | |
| out = [r for r in out if r["certificate_authority"]] | |
| if onlyTrans: | |
| out = [r for r in out if r["transitional"]] | |
| if sla != "all": | |
| def to_bucket(n): | |
| if n is None: return "no-date" | |
| if n <= 30: return "<=30" | |
| if n <= 60: return "<=60" | |
| if n <= 90: return "<=90" | |
| return ">90" | |
| out = [r for r in out if to_bucket(r["days_remaining"]) == sla] | |
| out.sort(key=lambda r: ((r["days_remaining"] if r["days_remaining"] is not None else 9_000_000), | |
| r["foundation"], r["cert_name"])) | |
| return out | |
| if __name__ == "__main__": | |
| app.run(host="127.0.0.1", port=5000, debug=False) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment