{"version":3,"file":"index.mjs","names":[],"sources":["../src/escape.ts","../src/dashboard.ts","../src/server.ts","../src/hub-pages.ts"],"sourcesContent":["// Shared HTML escaping + page shell for server-rendered pages.\n\nexport function escapeHtml(text: string): string {\n  return text\n    .replaceAll(\"&\", \"&amp;\")\n    .replaceAll(\"<\", \"&lt;\")\n    .replaceAll(\">\", \"&gt;\")\n    .replaceAll('\"', \"&quot;\")\n    .replaceAll(\"'\", \"&#39;\");\n}\n\n/** Shared dark theme — same palette as the SARIF dashboard. */\nexport const PAGE_CSS = String.raw`\n:root { color-scheme: dark; }\n* { box-sizing: border-box; }\nbody {\n  margin: 0;\n  padding: 0;\n  background: #0d1117;\n  color: #c9d1d9;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n  line-height: 1.6;\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n}\nheader {\n  padding: 1.5rem 2rem;\n  background: #161b22;\n  border-bottom: 1px solid #30363d;\n}\nheader h1 { margin: 0 0 0.5rem 0; font-size: 1.5rem; color: #f0f6fc; }\nheader nav { font-size: 0.875rem; }\nheader nav a { color: #58a6ff; text-decoration: none; margin-right: 1rem; }\nheader nav a:hover { text-decoration: underline; }\n.path { font-size: 0.875rem; color: #8b949e; margin: 0; }\nmain { flex: 1; padding: 2rem; }\nh2 { font-size: 1.25rem; color: #f0f6fc; margin: 0 0 1rem 0; }\ntable { border-collapse: collapse; width: 100%; font-size: 0.9rem; }\nth, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #21262d; }\nth { color: #8b949e; font-weight: 600; }\ntr:hover td { background: #161b22; }\na { color: #58a6ff; text-decoration: none; }\na:hover { text-decoration: underline; }\ncode { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 3px; }\n.empty { color: #8b949e; padding: 2rem; text-align: center; }\n.ok { color: #3fb950; }\n.fail { color: #f85149; }\n.warn { color: #d29922; }\n.muted { color: #8b949e; }\nfooter { padding: 1rem 2rem; border-top: 1px solid #21262d; text-align: center; }\nfooter a { color: #58a6ff; text-decoration: none; }\niframe.findings {\n  width: 100%;\n  min-height: 32rem;\n  border: 1px solid #30363d;\n  border-radius: 6px;\n  background: #fff;\n}\n`;\n\n/** Wrap page body in the shared shell (doctype, head, header, footer). */\nexport function pageShell(opts: {\n  title: string;\n  subtitle?: string;\n  nav?: readonly { href: string; label: string }[];\n  body: string;\n}): string {\n  const nav = (opts.nav ?? [])\n    .map((n) => `<a href=\"${escapeHtml(n.href)}\">${escapeHtml(n.label)}</a>`)\n    .join(\"\");\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>${escapeHtml(opts.title)}</title>\n  <style>${PAGE_CSS}</style>\n</head>\n<body>\n  <header>\n    <h1>${escapeHtml(opts.title)}</h1>\n    ${opts.subtitle !== undefined ? `<p class=\"path\">${opts.subtitle}</p>` : \"\"}\n    ${nav === \"\" ? \"\" : `<nav>${nav}</nav>`}\n  </header>\n  <main>\n    ${opts.body}\n  </main>\n  <footer>\n    <a href=\"https://sverka.dev\">sverka.dev</a>\n  </footer>\n</body>\n</html>`;\n}\n","// @sverka/ui — dashboard HTML generator.\n\nimport { escapeHtml } from \"./escape.js\";\n\n/** Render the dashboard page listing available SARIF files. */\nexport function renderDashboard(\n  artifactsDir: string,\n  files: readonly string[],\n): string {\n  const fileList =\n    files.length === 0\n      ? '<p class=\"empty\">No SARIF files found. Run <code>sverka run --format sarif</code> to generate findings.</p>'\n      : `<ul class=\"file-list\">\n      ${files.map((f) => `<li><a href=\"/report/${encodeURIComponent(f)}\">${escapeHtml(f)}</a></li>`).join(\"\\n      \")}\n    </ul>`;\n\n  return `<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>Sverka Dashboard</title>\n  <style>${CSS}</style>\n</head>\n<body>\n  <header>\n    <h1>Sverka Dashboard</h1>\n    <p class=\"path\">Artifacts: <code>${escapeHtml(artifactsDir)}</code></p>\n  </header>\n  <main>\n    <h2>SARIF Reports</h2>\n    ${fileList}\n  </main>\n  <footer>\n    <a href=\"https://sverka.dev\">sverka.dev</a>\n  </footer>\n</body>\n</html>`;\n}\n\nconst CSS = String.raw`\n:root { color-scheme: dark; }\n* { box-sizing: border-box; }\nbody {\n  margin: 0;\n  padding: 0;\n  background: #0d1117;\n  color: #c9d1d9;\n  font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Helvetica, Arial, sans-serif;\n  line-height: 1.6;\n  min-height: 100vh;\n  display: flex;\n  flex-direction: column;\n}\nheader {\n  padding: 1.5rem 2rem;\n  background: #161b22;\n  border-bottom: 1px solid #30363d;\n}\nheader h1 { margin: 0 0 0.5rem 0; font-size: 1.5rem; color: #f0f6fc; }\n.path { font-size: 0.875rem; color: #8b949e; margin: 0; }\npath code { color: #58a6ff; }\nmain { flex: 1; padding: 2rem; }\nh2 { font-size: 1.25rem; color: #f0f6fc; margin: 0 0 1rem 0; }\n.file-list { list-style: none; padding: 0; }\n.file-list li { margin-bottom: 0.5rem; }\n.file-list a {\n  display: block;\n  padding: 0.75rem 1rem;\n  background: #161b22;\n  border: 1px solid #30363d;\n  border-radius: 6px;\n  color: #58a6ff;\n  text-decoration: none;\n  font-family: monospace;\n  font-size: 0.9rem;\n}\n.file-list a:hover { border-color: #58a6ff; background: #1c2330; }\n.empty { color: #8b949e; padding: 2rem; text-align: center; }\ncode { background: #21262d; padding: 0.1rem 0.3rem; border-radius: 3px; }\nfooter { padding: 1rem 2rem; border-top: 1px solid #21262d; text-align: center; }\nfooter a { color: #58a6ff; text-decoration: none; }\n`;\n","// @sverka/ui — local web dashboard server.\n\nimport {\n  createServer,\n  type Server,\n  type IncomingMessage,\n  type ServerResponse,\n} from \"node:http\";\nimport { createHash } from \"node:crypto\";\nimport {\n  readdirSync,\n  readFileSync,\n  existsSync,\n  realpathSync,\n  type Dirent,\n} from \"node:fs\";\nimport { join, dirname, relative, resolve, sep } from \"node:path\";\nimport {\n  normalizeSarif,\n  type Finding,\n  type SarifLog,\n} from \"@sverka/verification\";\nimport { generateSarifHtml } from \"@sverka/sarif-viewer-web\";\nimport { renderDashboard } from \"./dashboard.js\";\n\nexport interface UiServerOptions {\n  /** Directory to scan for SARIF files (default: .sverka/artifacts). */\n  readonly artifactsDir: string;\n  /** Port to listen on (default: 3000). */\n  readonly port?: number;\n  /** Host to bind (default: localhost). */\n  readonly host?: string;\n}\n\nexport interface UiServer {\n  readonly port: number;\n  readonly url: string;\n  close(): void;\n}\n\n/** Security headers for all HTML responses — defense-in-depth against XSS.\n * The CSP blocks inline scripts (script-src 'none') even if escaping fails.\n * generateSarifHtml escapes all user-controlled data via escapeHtml(). */\nconst HTML_HEADERS = {\n  \"content-type\": \"text/html; charset=utf-8\",\n  \"content-security-policy\":\n    \"default-src 'self'; script-src 'none'; style-src 'unsafe-inline'\",\n  \"x-content-type-options\": \"nosniff\",\n} as const;\n\n/**\n * Start a local web dashboard server that lists SARIF files and\n * renders individual findings reports. Returns when the server is\n * listening.\n */\nexport function startUiServer(options: UiServerOptions): Promise<UiServer> {\n  const port = options.port ?? 3000;\n  const host = options.host ?? \"localhost\";\n  const artifactsDir = resolve(options.artifactsDir);\n\n  const server: Server = createServer(\n    (req: IncomingMessage, res: ServerResponse) => {\n      handleRequest(req, res, artifactsDir);\n    },\n  );\n\n  return new Promise((resolve, reject) => {\n    const onError = (err: Error): void => reject(err);\n    server.on(\"error\", onError);\n\n    server.listen(port, host, () => {\n      // Get the actual port (supports port 0 = ephemeral).\n      const address = server.address();\n      const actualPort =\n        typeof address === \"object\" && address ? address.port : port;\n\n      // Keep the error listener active for errors after listen (e.g. ECONNRESET).\n      server.removeListener(\"error\", onError);\n      server.on(\"error\", () => {\n        // Swallow post-listen errors — the server is already running.\n      });\n\n      resolve({\n        port: actualPort,\n        url: `http://${host}:${actualPort}`,\n        close: () => {\n          server.close((err) => {\n            if (\n              err &&\n              (err as NodeJS.ErrnoException).code !== \"ERR_SERVER_NOT_RUNNING\"\n            ) {\n              // Ignore — server may already be closed.\n            }\n          });\n        },\n      });\n    });\n  });\n}\n\nfunction handleRequest(\n  req: IncomingMessage,\n  res: ServerResponse,\n  artifactsDir: string,\n): void {\n  const url = req.url ?? \"/\";\n\n  if (url === \"/\" || url === \"/dashboard\") {\n    serveDashboard(res, artifactsDir);\n    return;\n  }\n\n  if (url.startsWith(\"/report/\")) {\n    let filename: string;\n    try {\n      filename = decodeURIComponent(url.slice(\"/report/\".length));\n    } catch {\n      res.writeHead(400, { \"content-type\": \"text/plain\" });\n      res.end(\"Invalid URL encoding\");\n      return;\n    }\n    serveReport(res, artifactsDir, filename);\n    return;\n  }\n\n  if (url === \"/health\") {\n    res.writeHead(200, { \"content-type\": \"application/json\" });\n    res.end(JSON.stringify({ status: \"ok\" }));\n    return;\n  }\n\n  res.writeHead(404, { \"content-type\": \"text/plain\" });\n  res.end(\"Not found\");\n}\n\n/** List SARIF files and render the dashboard page.\n *  All user-controlled data (artifactsDir, filenames) is escaped via\n *  escapeHtml() in renderDashboard; CSP blocks inline scripts as\n *  defense-in-depth. The escaper is local — the alert is dismissed. */\nfunction serveDashboard(res: ServerResponse, artifactsDir: string): void {\n  const files = listSarifFiles(artifactsDir);\n  const html = renderDashboard(artifactsDir, files);\n  res.writeHead(200, HTML_HEADERS);\n  res.end(html); // CodeQL: stored XSS — escapeHtml + CSP mitigate\n}\n\n/** Engine run ids are `randomUUID()` values — a leading path segment in\n *  this shape is the per-run artifact dir, not a step/pipeline name. */\nconst RUN_ID_SEGMENT =\n  /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;\n\n/** Read a SARIF file, normalize findings, and render the HTML report. */\nfunction serveReport(\n  res: ServerResponse,\n  artifactsDir: string,\n  filename: string,\n): void {\n  // Validate the relative path — nested step directories are allowed\n  // (artifacts are stored as <pipeline>/<step>/<file>.sarif) but\n  // traversal, backslashes, and absolute paths are not.\n  if (\n    filename === \"\" ||\n    filename.includes(\"..\") ||\n    filename.includes(\"\\\\\") ||\n    filename.startsWith(\"/\")\n  ) {\n    res.writeHead(400, { \"content-type\": \"text/plain\" });\n    res.end(\"Invalid filename\");\n    return;\n  }\n\n  const filePath = join(artifactsDir, filename);\n\n  // Resolve real paths and verify the file is inside artifactsDir.\n  // This also prevents symlink-based escapes.\n  try {\n    const realArtifacts = realpathSync(artifactsDir);\n    const realFile = realpathSync(filePath);\n    if (\n      !realFile.startsWith(realArtifacts + sep) &&\n      realFile !== realArtifacts\n    ) {\n      res.writeHead(400, { \"content-type\": \"text/plain\" });\n      res.end(\"Invalid filename\");\n      return;\n    }\n  } catch {\n    res.writeHead(404, { \"content-type\": \"text/plain\" });\n    res.end(\"File not found\");\n    return;\n  }\n\n  try {\n    const raw = readFileSync(filePath, \"utf8\");\n    const sarif = JSON.parse(raw) as SarifLog;\n    // Mirror collectFindings: checkId is prefixed with the step directory\n    // relative to the artifacts root (\"\" for top-level files). Engine\n    // artifacts nest under <runId> (a UUID — collectFindings scans the\n    // run dir as its root), so drop a leading run segment.\n    const dir = dirname(filename).split(sep).join(\"/\");\n    const dirSegments = dir.split(\"/\");\n    const checkIdPrefix =\n      dir === \".\"\n        ? \"\"\n        : dirSegments[0] !== undefined && RUN_ID_SEGMENT.test(dirSegments[0])\n          ? dirSegments.slice(1).join(\"/\")\n          : dir;\n    const findings = normalizeSarif(sarif, {\n      root: artifactsDir,\n      checkIdPrefix,\n      defaultConfidence: 0.5,\n    });\n    const html = generateSarifHtml(findings);\n    res.writeHead(200, reportHeaders(html));\n    res.end(html); // CodeQL: stored XSS — escapeHtml + hash-locked CSP mitigate\n  } catch (e) {\n    res.writeHead(500, { \"content-type\": \"text/plain\" });\n    res.end(\n      `Error rendering report: ${e instanceof Error ? e.message : String(e)}`,\n    );\n  }\n}\n\n/** Response headers for report pages. The generated report ships inline\n *  <script> blocks for filter/search/sort — allow exactly those scripts by\n *  SHA-256 hash instead of disabling them with script-src 'none'. */\nfunction reportHeaders(html: string): Record<string, string> {\n  return {\n    ...HTML_HEADERS,\n    \"content-security-policy\": `default-src 'self'; script-src ${inlineScriptHashes(html)}; style-src 'unsafe-inline'`,\n  };\n}\n\n/** Compute a CSP script-src source list of SHA-256 hashes for every inline\n *  <script> block in the HTML. Falls back to 'none' when there are none. */\nfunction inlineScriptHashes(html: string): string {\n  const hashes: string[] = [];\n  for (const match of html.matchAll(\n    /<script\\b[^>]*>([\\s\\S]*?)<\\/script[^>]*>/gi,\n  )) {\n    const digest = createHash(\"sha256\")\n      .update(match[1] ?? \"\")\n      .digest(\"base64\");\n    hashes.push(`'sha256-${digest}'`);\n  }\n  return hashes.length > 0 ? hashes.join(\" \") : \"'none'\";\n}\n\n/** List .sarif and .sarif.json files under the artifacts directory,\n *  recursively. Artifacts are stored as <pipeline>/<step>/<file>.sarif —\n *  returned paths are relative to `dir` with forward slashes. */\nfunction listSarifFiles(dir: string): string[] {\n  if (!existsSync(dir)) return [];\n  const files: string[] = [];\n  collectSarifFiles(dir, dir, files);\n  return files.sort((a, b) => a.localeCompare(b));\n}\n\nfunction collectSarifFiles(root: string, dir: string, out: string[]): void {\n  let entries: Dirent[];\n  try {\n    entries = readdirSync(dir, { withFileTypes: true });\n  } catch {\n    return;\n  }\n  for (const entry of entries) {\n    const entryPath = join(dir, entry.name);\n    // Prevent traversal outside the artifacts root via ../ or links.\n    const resolvedEntry = resolve(entryPath);\n    if (resolvedEntry !== root && !resolvedEntry.startsWith(root + sep)) {\n      continue;\n    }\n    if (entry.isDirectory()) {\n      collectSarifFiles(root, entryPath, out);\n    } else if (\n      entry.name.endsWith(\".sarif\") ||\n      entry.name.endsWith(\".sarif.json\")\n    ) {\n      out.push(relative(root, entryPath).split(sep).join(\"/\"));\n    }\n  }\n}\n","// @sverka/ui — hub dashboard pages (Spec 55): run list, run detail,\n// findings trend, flaky-step view. Server-rendered static HTML reusing\n// the dashboard's dark theme; findings render via\n// @sverka/sarif-viewer-web's generateSarifHtml (served as a sub-document\n// and embedded via <iframe>).\n\nimport { escapeHtml, pageShell } from \"./escape.js\";\n\n/** A run summary row as the hub serves it (mirrors HubRunSummary). */\nexport interface HubRunRow {\n  readonly runId: string;\n  readonly project: string;\n  readonly entry: string;\n  readonly status: string;\n  readonly startedAt: number | null;\n  readonly durationMs: number | null;\n  readonly findingCounts: {\n    readonly total: number;\n    readonly bySeverity: Record<string, number>;\n  };\n  readonly policyVerdict?: string | null;\n}\n\n/** A stored run detail (report payload + findings). */\nexport interface HubRunView extends HubRunRow {\n  readonly report: Record<string, unknown>;\n  readonly findings: readonly unknown[];\n  readonly uploadedAt: number;\n}\n\n/** A flaky-step aggregate row (GET /v1/flaky). */\nexport interface HubFlakyRow {\n  readonly stepId: string;\n  readonly successRate: number;\n  readonly runs: number;\n}\n\nfunction formatTs(epochMs: number | null): string {\n  if (epochMs === null) return \"—\";\n  return new Date(epochMs).toISOString().replace(\"T\", \" \").slice(0, 19) + \"Z\";\n}\n\nfunction formatDuration(ms: number | null): string {\n  if (ms === null) return \"—\";\n  if (ms < 1000) return `${ms}ms`;\n  return `${(ms / 1000).toFixed(1)}s`;\n}\n\nfunction statusCell(status: string): string {\n  const cls =\n    status === \"success\" ? \"ok\" : status === \"failure\" ? \"fail\" : \"warn\";\n  return `<span class=\"${cls}\">${escapeHtml(status)}</span>`;\n}\n\nfunction runUrl(project: string, runId: string): string {\n  return `/runs/${encodeURIComponent(project)}/${encodeURIComponent(runId)}`;\n}\n\nfunction severityBadges(counts: Record<string, number>): string {\n  return ([\"critical\", \"high\", \"medium\", \"low\", \"info\"] as const)\n    .filter((sev) => (counts[sev] ?? 0) > 0)\n    .map(\n      (sev) =>\n        `<span class=\"${sev === \"critical\" || sev === \"high\" ? \"fail\" : sev === \"medium\" ? \"warn\" : \"muted\"}\">${sev}:${counts[sev]}</span>`,\n    )\n    .join(\" \");\n}\n\n/**\n * Run list page — one row per stored run for a project, newest first.\n */\nexport function renderHubRunList(opts: {\n  readonly project: string;\n  readonly runs: readonly HubRunRow[];\n}): string {\n  const rows =\n    opts.runs.length === 0\n      ? `<p class=\"empty\">No runs recorded for <code>${escapeHtml(opts.project)}</code>. Run <code>sverka run --remote</code> to upload one.</p>`\n      : `<table>\n  <thead><tr><th>Run</th><th>Entry</th><th>Status</th><th>Started</th><th>Duration</th><th>Findings</th><th>Verdict</th></tr></thead>\n  <tbody>\n    ${opts.runs\n      .map(\n        (r) => `<tr>\n      <td><a href=\"${runUrl(r.project, r.runId)}\"><code>${escapeHtml(r.runId)}</code></a></td>\n      <td>${escapeHtml(r.entry)}</td>\n      <td>${statusCell(r.status)}</td>\n      <td class=\"muted\">${formatTs(r.startedAt)}</td>\n      <td class=\"muted\">${formatDuration(r.durationMs)}</td>\n      <td>${r.findingCounts.total} ${severityBadges(r.findingCounts.bySeverity)}</td>\n      <td>${r.policyVerdict != null ? statusCell(r.policyVerdict) : '<span class=\"muted\">—</span>'}</td>\n    </tr>`,\n      )\n      .join(\"\\n    \")}\n  </tbody>\n</table>`;\n  return pageShell({\n    title: `Runs — ${opts.project}`,\n    subtitle: `<code>${escapeHtml(opts.project)}</code>`,\n    nav: [\n      { href: \"/\", label: \"projects\" },\n      {\n        href: `/flaky/${encodeURIComponent(opts.project)}`,\n        label: \"flaky steps\",\n      },\n    ],\n    body: `<h2>Recent runs</h2>${rows}`,\n  });\n}\n\ninterface ReportStep {\n  readonly stepId?: unknown;\n  readonly status?: unknown;\n  readonly durationMs?: unknown;\n  readonly error?: unknown;\n  readonly cacheKey?: unknown;\n}\n\n/** Steps table from a sverka.run/v1 report payload. */\nfunction stepsTable(report: Record<string, unknown>): string {\n  const data = report[\"data\"];\n  const steps =\n    typeof data === \"object\" && data !== null\n      ? (data as { steps?: readonly ReportStep[] }).steps\n      : undefined;\n  if (!Array.isArray(steps) || steps.length === 0) return \"\";\n  const rows = steps\n    .map((s) => {\n      const stepId = typeof s.stepId === \"string\" ? s.stepId : \"?\";\n      const status = typeof s.status === \"string\" ? s.status : \"?\";\n      const duration =\n        typeof s.durationMs === \"number\" ? formatDuration(s.durationMs) : \"—\";\n      const detail =\n        typeof s.error === \"string\"\n          ? s.error\n          : typeof s.cacheKey === \"string\"\n            ? `cache: ${s.cacheKey}`\n            : \"\";\n      return `<tr><td><code>${escapeHtml(stepId)}</code></td><td>${statusCell(status)}</td><td class=\"muted\">${duration}</td><td class=\"muted\">${escapeHtml(detail)}</td></tr>`;\n    })\n    .join(\"\\n    \");\n  return `<h2>Steps</h2><table>\n  <thead><tr><th>Step</th><th>Status</th><th>Duration</th><th>Detail</th></tr></thead>\n  <tbody>\n    ${rows}\n  </tbody>\n</table>`;\n}\n\n/**\n * Run detail page — run metadata + per-step table + findings embedded\n * via the SARIF report renderer (iframe to the findings sub-document).\n */\nexport function renderHubRunDetail(opts: { readonly run: HubRunView }): string {\n  const { run } = opts;\n  const findingsBlock =\n    run.findings.length === 0\n      ? `<p class=\"empty\">No findings in this run.</p>`\n      : `<iframe class=\"findings\" src=\"${runUrl(run.project, run.runId)}/findings\" title=\"Findings\"></iframe>`;\n  return pageShell({\n    title: `Run ${run.runId}`,\n    subtitle: `<code>${escapeHtml(run.project)}</code> · ${escapeHtml(run.entry)} · ${formatTs(run.startedAt)}`,\n    nav: [\n      { href: `/?project=${encodeURIComponent(run.project)}`, label: \"runs\" },\n      {\n        href: `/flaky/${encodeURIComponent(run.project)}`,\n        label: \"flaky steps\",\n      },\n    ],\n    body: `\n<h2>Summary</h2>\n<table>\n  <tbody>\n    <tr><th>Status</th><td>${statusCell(run.status)}</td></tr>\n    <tr><th>Duration</th><td>${formatDuration(run.durationMs)}</td></tr>\n    <tr><th>Findings</th><td>${run.findingCounts.total} ${severityBadges(run.findingCounts.bySeverity)}</td></tr>\n    ${run.policyVerdict != null ? `<tr><th>Verdict</th><td>${statusCell(run.policyVerdict)}</td></tr>` : \"\"}\n    <tr><th>Uploaded</th><td class=\"muted\">${formatTs(run.uploadedAt)}</td></tr>\n  </tbody>\n</table>\n${stepsTable(run.report)}\n<h2>Findings</h2>\n${findingsBlock}`,\n  });\n}\n\n/**\n * Flaky-step view — per-step success rate across the last N runs,\n * worst first. Rows come from `GET /v1/flaky/{project}`.\n */\nexport function renderHubFlaky(opts: {\n  readonly project: string;\n  readonly rows: readonly HubFlakyRow[];\n  readonly window: number;\n}): string {\n  const body =\n    opts.rows.length === 0\n      ? `<p class=\"empty\">No step data over the last ${opts.window} runs for <code>${escapeHtml(opts.project)}</code>.</p>`\n      : `<table>\n  <thead><tr><th>Step</th><th>Success rate</th><th>Runs</th></tr></thead>\n  <tbody>\n    ${opts.rows\n      .map(\n        (r) => `<tr>\n      <td><code>${escapeHtml(r.stepId)}</code></td>\n      <td class=\"${r.successRate < 0.9 ? \"fail\" : r.successRate < 1 ? \"warn\" : \"ok\"}\">${(r.successRate * 100).toFixed(1)}%</td>\n      <td class=\"muted\">${r.runs}</td>\n    </tr>`,\n      )\n      .join(\"\\n    \")}\n  </tbody>\n</table>`;\n  return pageShell({\n    title: `Flaky steps — ${opts.project}`,\n    subtitle: `last ${opts.window} runs`,\n    nav: [\n      { href: `/?project=${encodeURIComponent(opts.project)}`, label: \"runs\" },\n    ],\n    body,\n  });\n}\n\n/**\n * Projects index — links to each project's run list.\n */\nexport function renderHubIndex(opts: {\n  readonly projects: readonly string[];\n}): string {\n  const body =\n    opts.projects.length === 0\n      ? `<p class=\"empty\">No runs recorded yet. Point a checkout at this hub and run <code>sverka run --remote</code>.</p>`\n      : `<table>\n  <thead><tr><th>Project</th></tr></thead>\n  <tbody>\n    ${opts.projects\n      .map(\n        (p) =>\n          `<tr><td><a href=\"/?project=${encodeURIComponent(p)}\"><code>${escapeHtml(p)}</code></a></td></tr>`,\n      )\n      .join(\"\\n    \")}\n  </tbody>\n</table>`;\n  return pageShell({\n    title: \"Sverka Hub\",\n    subtitle: \"Remote run hub — cache, history, findings\",\n    body: `<h2>Projects</h2>${body}`,\n  });\n}\n"],"mappings":"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"}