All files / src/persistence dashboard.ts

100% Statements 7/7
100% Branches 3/3
100% Functions 1/1
100% Lines 7/7

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166                      1x 12x     12x   12x         12x                                                                                                                     12x                                                                                                                                                                     12x  
/**
 * Generate a self-contained HTML dashboard from session data.
 * Uses the OpenSIP Espresso theme. Zero external dependencies.
 *
 * Security note: The HTML is a static report generated from our own
 * check results data (not user input). It is written to a local file
 * and opened in the user's browser — not served as a web application.
 */
 
import type { StoredSession } from './store.js';
 
export function generateDashboardHtml(sessions: StoredSession[]): string {
  const latest = sessions[0];
  // Data is consumed by JavaScript via textContent (safe DOM methods), not innerHTML.
  // Only need to prevent the JSON from breaking out of the <script> tag.
  const safeDataJson = JSON.stringify(sessions).replace(/<\//g, '<\\/');
 
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>OpenSIP Tools${latest ? ` \u2014 Score: ${latest.score}%` : ''}</title>
<style>
  :root {
    --bg: #1a1210; --bg-surface: #231a16; --bg-card: #231a16;
    --bg-hover: #3a2e27; --text: #f4ede5; --text-secondary: #e6ddd2;
    --text-muted: #c0b2a2; --text-dim: #958474; --accent: #c49a6c;
    --accent-fitness: #7ca068; --success: #8fbc8f;
    --success-light: rgba(143,188,143,0.2); --warning: #d4a574;
    --warning-light: rgba(212,165,116,0.2); --error: #c75b4a;
    --error-light: rgba(199,91,74,0.2); --border: #3a2e27;
    --border-light: #483a31;
    --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
    --radius: 8px; --radius-sm: 4px;
  }
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body { background: var(--bg); color: var(--text); font-family: var(--font); font-size: 14px; line-height: 1.6; padding: 24px; max-width: 1200px; margin: 0 auto; }
  h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
  h3 { font-size: 14px; font-weight: 600; margin-bottom: 8px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; }
  .header { display: flex; align-items: center; gap: 16px; margin-bottom: 32px; }
  .header-brand { color: var(--accent); font-size: 13px; font-weight: 500; }
  .header-sub { color: var(--text-dim); font-size: 13px; margin-bottom: 12px; }
  .stat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
  .stat-card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; }
  .stat-label { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px; }
  .stat-value { font-size: 28px; font-weight: 700; }
  .score-good { color: var(--success); } .score-warn { color: var(--warning); } .score-bad { color: var(--error); }
  .card { background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 16px; }
  .trend-chart { display: flex; align-items: flex-end; gap: 4px; height: 80px; margin-bottom: 24px; padding: 16px; background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--radius); }
  .trend-bar { flex: 1; border-radius: 2px 2px 0 0; min-width: 8px; max-width: 40px; position: relative; cursor: pointer; }
  .trend-bar:hover::after { content: attr(data-tooltip); position: absolute; bottom: calc(100% + 4px); left: 50%; transform: translateX(-50%); background: var(--bg-hover); color: var(--text); padding: 4px 8px; border-radius: var(--radius-sm); font-size: 11px; white-space: nowrap; border: 1px solid var(--border); }
  .check-row { display: flex; align-items: center; gap: 12px; padding: 8px 0; border-bottom: 1px solid var(--border); }
  .check-row:last-child { border-bottom: none; }
  .check-icon { width: 20px; text-align: center; font-size: 14px; }
  .check-icon.pass { color: var(--success); } .check-icon.fail { color: var(--error); }
  .check-slug { font-weight: 500; flex: 1; }
  .check-duration { color: var(--text-dim); font-size: 12px; min-width: 60px; text-align: right; }
  .findings-toggle { background: none; border: none; color: var(--accent); cursor: pointer; font-size: 12px; padding: 2px 8px; border-radius: var(--radius-sm); }
  .findings-toggle:hover { background: var(--bg-hover); }
  .findings-list { display: none; padding: 8px 0 8px 32px; }
  .findings-list.open { display: block; }
  .finding-item { padding: 4px 0; font-size: 13px; color: var(--text-muted); border-left: 2px solid var(--border); padding-left: 12px; margin-bottom: 4px; }
  .finding-file { color: var(--text-dim); font-size: 11px; }
  .finding-sev { font-size: 11px; padding: 1px 6px; border-radius: 3px; font-weight: 500; }
  .finding-sev.error { background: var(--error-light); color: var(--error); }
  .finding-sev.warning { background: var(--warning-light); color: var(--warning); }
  .badge { font-size: 11px; padding: 2px 8px; border-radius: 3px; font-weight: 500; }
  .badge-pass { background: var(--success-light); color: var(--success); }
  .badge-fail { background: var(--error-light); color: var(--error); }
  .section { margin-bottom: 32px; }
  .empty { color: var(--text-dim); font-style: italic; padding: 24px; text-align: center; }
  .footer { color: var(--text-dim); font-size: 12px; text-align: center; padding: 24px 0; border-top: 1px solid var(--border); margin-top: 32px; }
  .footer a { color: var(--accent); text-decoration: none; }
</style>
</head>
<body>
<div class="header"><div><h1>OpenSIP Tools</h1><div class="header-brand">Fitness Report</div></div></div>
<div id="app"></div>
<div class="footer">Generated by <strong>opensip-tools</strong> &mdash; <a href="https://opensip.ai">opensip.ai</a></div>
<script>
const sessions = ${safeDataJson};
const app = document.getElementById('app');
 
function el(tag, attrs, children) {
  const e = document.createElement(tag);
  if (attrs) Object.entries(attrs).forEach(([k,v]) => { if (k === 'text') e.textContent = v; else if (k === 'class') e.className = v; else if (k.startsWith('on')) e.addEventListener(k.slice(2), v); else e.setAttribute(k, v); });
  if (children) children.forEach(c => { if (typeof c === 'string') e.appendChild(document.createTextNode(c)); else if (c) e.appendChild(c); });
  return e;
}
 
function render() {
  if (!sessions.length) { app.appendChild(el('div', {class:'empty', text:'No sessions yet. Run opensip-tools fit to generate data.'})); return; }
  const latest = sessions[0];
  const sc = latest.score >= 90 ? 'score-good' : latest.score >= 70 ? 'score-warn' : 'score-bad';
 
  // Stats
  const grid = el('div', {class:'stat-grid'});
  [['Score', latest.score+'%', sc], ['Checks', ''+latest.summary.total, ''], ['Passed', ''+latest.summary.passed, 'score-good'], ['Failed', ''+latest.summary.failed, latest.summary.failed > 0 ? 'score-bad' : ''], ['Findings', ''+latest.summary.errors, ''], ['Duration', (latest.durationMs/1000).toFixed(1)+'s', '']].forEach(([label,val,cls]) => {
    const card = el('div', {class:'stat-card'}, [el('div', {class:'stat-label', text:label}), el('div', {class:'stat-value ' + cls, text:val})]);
    grid.appendChild(card);
  });
  app.appendChild(grid);
 
  // Trend
  if (sessions.length > 1) {
    const sec = el('div', {class:'section'}, [el('h3', {text:'Score Trend'})]);
    const chart = el('div', {class:'trend-chart'});
    sessions.slice(0,20).reverse().forEach(s => {
      const h = Math.max(4, (s.score/100)*60);
      const color = s.score >= 90 ? 'var(--success)' : s.score >= 70 ? 'var(--warning)' : 'var(--error)';
      const bar = el('div', {class:'trend-bar', style:'height:'+h+'px;background:'+color, 'data-tooltip': s.score+'% \\u2014 '+new Date(s.timestamp).toLocaleDateString()});
      chart.appendChild(bar);
    });
    sec.appendChild(chart); app.appendChild(sec);
  }
 
  // Checks
  const csec = el('div', {class:'section'}, [el('h3', {text:'Latest Run' + (latest.recipe ? ' \\u2014 ' + latest.recipe : '')}), el('div', {class:'header-sub', text: new Date(latest.timestamp).toLocaleString() + ' \\u2014 ' + latest.cwd})]);
  const ccard = el('div', {class:'card'});
  latest.checks.forEach((check, i) => {
    const row = el('div', {class:'check-row'}, [
      el('div', {class:'check-icon ' + (check.passed ? 'pass' : 'fail'), text: check.passed ? '\\u2713' : '\\u2717'}),
      el('div', {class:'check-slug', text: check.checkSlug}),
    ]);
    if (check.findings.length > 0) {
      row.appendChild(el('button', {class:'findings-toggle', text: check.findings.length + ' finding' + (check.findings.length > 1 ? 's' : ''), onclick: () => { const fl = document.getElementById('fl-'+i); if (fl) fl.classList.toggle('open'); }}));
    }
    if (check.durationMs > 0) row.appendChild(el('div', {class:'check-duration', text: check.durationMs + 'ms'}));
    ccard.appendChild(row);
    if (check.findings.length > 0) {
      const fl = el('div', {class:'findings-list', id:'fl-'+i});
      check.findings.forEach(f => {
        const item = el('div', {class:'finding-item'}, [el('span', {class:'finding-sev ' + f.severity, text: f.severity}), document.createTextNode(' ' + f.message)]);
        if (f.filePath) item.appendChild(el('div', {class:'finding-file', text: f.filePath + (f.line ? ':' + f.line : '')}));
        if (f.suggestion) item.appendChild(el('div', {class:'finding-file', text: f.suggestion, style:'color:var(--accent)'}));
        fl.appendChild(item);
      });
      ccard.appendChild(fl);
    }
  });
  csec.appendChild(ccard); app.appendChild(csec);
 
  // History
  if (sessions.length > 1) {
    const hsec = el('div', {class:'section'}, [el('h3', {text:'History (' + sessions.length + ' runs)'})]);
    const hcard = el('div', {class:'card'});
    sessions.slice(0,20).forEach(s => {
      const sc2 = s.score >= 90 ? 'color:var(--success)' : s.score >= 70 ? 'color:var(--warning)' : 'color:var(--error)';
      hcard.appendChild(el('div', {class:'check-row'}, [
        el('div', {style:'min-width:140px;color:var(--text-dim);font-size:12px', text: new Date(s.timestamp).toLocaleString()}),
        el('div', {style:'min-width:60px;font-weight:600;'+sc2, text: s.score+'%'}),
        el('span', {class:'badge ' + (s.passed ? 'badge-pass' : 'badge-fail'), text: s.passed ? 'PASS' : 'FAIL'}),
        el('div', {style:'flex:1;color:var(--text-dim);font-size:12px', text: s.summary.passed+'/'+s.summary.total + ' checks' + (s.recipe ? ' ('+s.recipe+')' : '')}),
        el('div', {class:'check-duration', text: (s.durationMs/1000).toFixed(1)+'s'}),
      ]));
    });
    hsec.appendChild(hcard); app.appendChild(hsec);
  }
}
render();
</script>
</body>
</html>`;
}