Attach robustness + interactive report UI#6
Conversation
Self-contained interactive HTML report: client-side column sort (numeric aware), live search, value heat-shading (higher/lower-better), stat cards, light/dark toggle, and a client CSV export of the current view. No external resources — inlined CSS/JS, safe to share as one file. </script> in embedded data is escaped to prevent breakout. exportReport gains an interactive opt.
Add browser_attach_control to the search category; add WUNDERLAND_ATTACH_DRYRUN and WUNDERLAND_ATTACH_DEADLINE_MS env knobs for the flexibility surface.
Reviewer's GuideAdds a self-contained interactive HTML export variant for reports, introduces environment-configurable attach dry-run and deadline options, and classifies the browser_attach_control tool under the search category, with accompanying tests. Sequence diagram for interactive report export pipelinesequenceDiagram
actor Caller
participant exportReport
participant toCsv
participant toHtml
participant toInteractiveHtml
participant renderPdf
participant FS as node_fs
Caller->>exportReport: exportReport(ds, outDir, opts)
exportReport->>FS: mkdirSync(outDir)
exportReport->>toCsv: toCsv(ds)
toCsv-->>exportReport: csvContent
exportReport->>FS: writeFileSync(report.csv, csvContent)
exportReport->>toHtml: toHtml(ds)
toHtml-->>exportReport: htmlDoc
exportReport->>FS: writeFileSync(report.html, htmlDoc)
alt opts.interactive is truthy
exportReport->>toInteractiveHtml: toInteractiveHtml(ds, interactiveOpts)
toInteractiveHtml-->>exportReport: interactiveHtml
exportReport->>FS: writeFileSync(report.interactive.html, interactiveHtml)
end
alt opts.pdf !== false
exportReport->>renderPdf: renderPdf(htmlDoc, report.pdf, opts)
renderPdf-->>exportReport: pdfPath
end
exportReport-->>Caller: { csv, html, pdf?, interactive? }
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
There was a problem hiding this comment.
Hey - I've found 2 issues, and left some high level feedback:
- The large inline HTML/CSS/JS template in
toInteractiveHtmlis quite dense; consider factoring the CSS and script generation into helper functions or a tagged template to improve readability and make future changes to the interactive report UI easier to maintain.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- The large inline HTML/CSS/JS template in `toInteractiveHtml` is quite dense; consider factoring the CSS and script generation into helper functions or a tagged template to improve readability and make future changes to the interactive report UI easier to maintain.
## Individual Comments
### Comment 1
<location path="src/cli/export/report-export.ts" line_range="109-111" />
<code_context>
+ */
+export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {
+ const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
+ const colMeta = specs.map((s) => ({
+ key: s.key,
+ label: s.label ?? s.key,
+ numeric: !!s.numeric,
+ heat: s.heat ?? null,
</code_context>
<issue_to_address>
**🚨 issue (security):** Column labels are injected into HTML without escaping, which can lead to broken markup or XSS in the interactive report.
In the interactive table header, `c.label` is written directly into `innerHTML` and can come from `InteractiveOptions.columns`, so unescaped characters like `<`, `&`, or quotes may corrupt the HTML or enable XSS in the exported report. Please ensure labels are HTML-escaped before insertion (e.g., by storing both raw and escaped values in `colMeta`, or escaping at header rendering time), consistent with how `ds.title` and `stats` are handled.
</issue_to_address>
### Comment 2
<location path="src/cli/export/report-export.ts" line_range="107" />
<code_context>
+ * value heat-shading, and a light/dark toggle. No external resources — all CSS
+ * and JS are inlined, so it opens offline and is safe to share as one file.
+ */
+export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string {
+ const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
+ const colMeta = specs.map((s) => ({
</code_context>
<issue_to_address>
**issue (complexity):** Consider extracting the interactive report’s CSS/JS and interactive file-writing logic into small helper functions so `toInteractiveHtml` and `exportReport` stay focused on data wiring.
You can keep all functionality while reducing complexity by factoring out CSS/JS and a bit of orchestration logic. This keeps `toInteractiveHtml` readable without changing behavior.
### 1. Split CSS and JS into named constants
Move the inline CSS and JS into separate template helpers, so `toInteractiveHtml` is mostly about data wiring.
```ts
function buildInteractiveCss(): string {
return `
:root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
:root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
*{box-sizing:border-box}
body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
/* ... keep the rest unchanged, just formatted more readably ... */
`;
}
function buildInteractiveScript(dataJson: string, theme: InteractiveOptions['theme']): string {
const themeInit =
theme && theme !== 'auto'
? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});`
: '';
return `
const DATA = ${dataJson};
const state = { sortKey: null, dir: 1, q: '' };
const num = v => {
const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,''));
return isNaN(n) ? null : n;
};
function heatRange(key) {
let lo = Infinity, hi = -Infinity;
for (const r of DATA.rows) {
const n = num(r[key]);
if (n == null) continue;
lo = Math.min(lo, n);
hi = Math.max(hi, n);
}
return { lo, hi };
}
const heats = {};
for (const c of DATA.cols) if (c.heat) heats[c.key] = heatRange(c.key);
function shade(c, v) {
const h = heats[c.key];
if (!h) return '';
const n = num(v);
if (n == null || h.hi === h.lo) return '';
let t = (n - h.lo) / (h.hi - h.lo);
if (c.heat === 'lower-better') t = 1 - t;
const a = (0.06 + 0.20 * t).toFixed(3);
return 'background:color-mix(in srgb, var(--heat) ' + (a * 100).toFixed(1) + '%, transparent)';
}
function getRows() {
let rs = DATA.rows;
if (state.q) {
const q = state.q.toLowerCase();
rs = rs.filter(r =>
DATA.cols.some(c => String(r[c.key] ?? '').toLowerCase().includes(q)),
);
}
if (state.sortKey) {
const c = DATA.cols.find(x => x.key === state.sortKey);
rs = rs.slice().sort((a, b) => {
let av = a[state.sortKey], bv = b[state.sortKey];
if (c && c.numeric) {
av = num(av) ?? -Infinity;
bv = num(bv) ?? -Infinity;
return (av - bv) * state.dir;
}
return String(av ?? '').localeCompare(String(bv ?? '')) * state.dir;
});
}
return rs;
}
function render() {
const th = document.getElementById('thead');
const tb = document.getElementById('tbody');
th.innerHTML = '<tr>' + DATA.cols.map(c =>
'<th class="' + (c.numeric ? 'num ' : '') +
(state.sortKey === c.key ? 'sorted' : '') +
'" data-k="' + c.key + '">' +
c.label +
'<span class="ar">' +
(state.sortKey === c.key ? (state.dir > 0 ? '▲' : '▼') : '⇅') +
'</span></th>',
).join('') + '</tr>';
const rs = getRows();
tb.innerHTML = rs.length
? rs.map(r =>
'<tr>' + DATA.cols.map(c => {
const v = r[c.key] ?? '';
return '<td class="' + (c.numeric ? 'num' : 'wrapcell') +
'" style="' + shade(c, v) + '">' +
String(v).replace(/&/g,'&').replace(/</g,'<') +
'</td>';
}).join('') + '</tr>',
).join('')
: '<tr><td class="empty" colspan="' + DATA.cols.length +
'">No matching rows</td></tr>';
document.getElementById('count').textContent =
rs.length + ' / ' + DATA.rows.length + ' rows';
th.querySelectorAll('th').forEach(h => {
h.onclick = () => {
const k = h.dataset.k;
if (state.sortKey === k) state.dir *= -1;
else { state.sortKey = k; state.dir = 1; }
render();
};
});
}
document.getElementById('q').addEventListener('input', e => {
state.q = e.target.value;
render();
});
document.getElementById('themeBtn').onclick = () => {
const r = document.documentElement;
const cur = r.getAttribute('data-theme') ||
(matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
r.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
};
document.getElementById('csvBtn').onclick = () => {
const esc = v => {
const s = String(v ?? '');
return /[",\\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
const cols = DATA.cols.map(c => c.key);
const csv = [cols.map(esc).join(',')]
.concat(getRows().map(r => cols.map(c => esc(r[c])).join(',')))
.join('\\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'report.csv';
a.click();
};
${themeInit}
render();
`;
}
```
Then `toInteractiveHtml` becomes shorter and easier to scan:
```ts
export function toInteractiveHtml(
ds: ReportDataset,
opts: InteractiveOptions = {},
): string {
const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
const colMeta = specs.map((s) => ({
key: s.key,
label: s.label ?? s.key,
numeric: !!s.numeric,
heat: s.heat ?? null,
}));
const stats = opts.stats ?? [];
const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
const statsHtml = stats
.map((s) =>
`<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`,
)
.join('');
const theme = opts.theme ?? 'auto';
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>${buildInteractiveCss()}</style></head><body>
<header>
<div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
<button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
<input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
<button class="btn" id="csvBtn" type="button">⬇ CSV</button>
<span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>${buildInteractiveScript(dataJson, theme)}</script>
</body></html>`;
}
```
This keeps the “single-file, self-contained” property but isolates behavior and styling from the data mapping.
### 2. Reduce responsibility in `exportReport` with a helper
The interactive export branch is simple but you can make `exportReport` more declarative:
```ts
function writeInteractiveReport(
ds: ReportDataset,
outDir: string,
base: string,
interactive: boolean | InteractiveOptions | undefined,
writeFileSync: typeof import('node:fs')['writeFileSync'],
): string | undefined {
if (!interactive) return;
const iv = join(outDir, `${base}.interactive.html`);
const opts = typeof interactive === 'object' ? interactive : {};
writeFileSync(iv, toInteractiveHtml(ds, opts));
return iv;
}
```
Then inside `exportReport`:
```ts
const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };
result.interactive = writeInteractiveReport(ds, outDir, base, opts.interactive, writeFileSync);
```
These small extra helpers significantly lower cognitive load while preserving all current features and behavior.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| const colMeta = specs.map((s) => ({ | ||
| key: s.key, | ||
| label: s.label ?? s.key, |
There was a problem hiding this comment.
🚨 issue (security): Column labels are injected into HTML without escaping, which can lead to broken markup or XSS in the interactive report.
In the interactive table header, c.label is written directly into innerHTML and can come from InteractiveOptions.columns, so unescaped characters like <, &, or quotes may corrupt the HTML or enable XSS in the exported report. Please ensure labels are HTML-escaped before insertion (e.g., by storing both raw and escaped values in colMeta, or escaping at header rendering time), consistent with how ds.title and stats are handled.
| * value heat-shading, and a light/dark toggle. No external resources — all CSS | ||
| * and JS are inlined, so it opens offline and is safe to share as one file. | ||
| */ | ||
| export function toInteractiveHtml(ds: ReportDataset, opts: InteractiveOptions = {}): string { |
There was a problem hiding this comment.
issue (complexity): Consider extracting the interactive report’s CSS/JS and interactive file-writing logic into small helper functions so toInteractiveHtml and exportReport stay focused on data wiring.
You can keep all functionality while reducing complexity by factoring out CSS/JS and a bit of orchestration logic. This keeps toInteractiveHtml readable without changing behavior.
1. Split CSS and JS into named constants
Move the inline CSS and JS into separate template helpers, so toInteractiveHtml is mostly about data wiring.
function buildInteractiveCss(): string {
return `
:root{--bg:#fff;--fg:#16181d;--muted:#5b616e;--line:#d8dbe2;--head:#f0f2f6;--accent:#2f6df6;--heat:#2f6df6}
:root[data-theme=dark]{--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}
@media (prefers-color-scheme:dark){:root:not([data-theme=light]){--bg:#14161b;--fg:#e8eaf0;--muted:#9aa2b1;--line:#2a2e37;--head:#1c1f27;--accent:#6ea8ff;--heat:#6ea8ff}}
*{box-sizing:border-box}
body{font-family:Inter,-apple-system,'Helvetica Neue',Arial,sans-serif;background:var(--bg);color:var(--fg);margin:0;padding:24px 28px;font-size:13px;line-height:1.45}
/* ... keep the rest unchanged, just formatted more readably ... */
`;
}
function buildInteractiveScript(dataJson: string, theme: InteractiveOptions['theme']): string {
const themeInit =
theme && theme !== 'auto'
? `document.documentElement.setAttribute('data-theme', ${JSON.stringify(theme)});`
: '';
return `
const DATA = ${dataJson};
const state = { sortKey: null, dir: 1, q: '' };
const num = v => {
const n = parseFloat(String(v).replace(/[^0-9.\\-]/g,''));
return isNaN(n) ? null : n;
};
function heatRange(key) {
let lo = Infinity, hi = -Infinity;
for (const r of DATA.rows) {
const n = num(r[key]);
if (n == null) continue;
lo = Math.min(lo, n);
hi = Math.max(hi, n);
}
return { lo, hi };
}
const heats = {};
for (const c of DATA.cols) if (c.heat) heats[c.key] = heatRange(c.key);
function shade(c, v) {
const h = heats[c.key];
if (!h) return '';
const n = num(v);
if (n == null || h.hi === h.lo) return '';
let t = (n - h.lo) / (h.hi - h.lo);
if (c.heat === 'lower-better') t = 1 - t;
const a = (0.06 + 0.20 * t).toFixed(3);
return 'background:color-mix(in srgb, var(--heat) ' + (a * 100).toFixed(1) + '%, transparent)';
}
function getRows() {
let rs = DATA.rows;
if (state.q) {
const q = state.q.toLowerCase();
rs = rs.filter(r =>
DATA.cols.some(c => String(r[c.key] ?? '').toLowerCase().includes(q)),
);
}
if (state.sortKey) {
const c = DATA.cols.find(x => x.key === state.sortKey);
rs = rs.slice().sort((a, b) => {
let av = a[state.sortKey], bv = b[state.sortKey];
if (c && c.numeric) {
av = num(av) ?? -Infinity;
bv = num(bv) ?? -Infinity;
return (av - bv) * state.dir;
}
return String(av ?? '').localeCompare(String(bv ?? '')) * state.dir;
});
}
return rs;
}
function render() {
const th = document.getElementById('thead');
const tb = document.getElementById('tbody');
th.innerHTML = '<tr>' + DATA.cols.map(c =>
'<th class="' + (c.numeric ? 'num ' : '') +
(state.sortKey === c.key ? 'sorted' : '') +
'" data-k="' + c.key + '">' +
c.label +
'<span class="ar">' +
(state.sortKey === c.key ? (state.dir > 0 ? '▲' : '▼') : '⇅') +
'</span></th>',
).join('') + '</tr>';
const rs = getRows();
tb.innerHTML = rs.length
? rs.map(r =>
'<tr>' + DATA.cols.map(c => {
const v = r[c.key] ?? '';
return '<td class="' + (c.numeric ? 'num' : 'wrapcell') +
'" style="' + shade(c, v) + '">' +
String(v).replace(/&/g,'&').replace(/</g,'<') +
'</td>';
}).join('') + '</tr>',
).join('')
: '<tr><td class="empty" colspan="' + DATA.cols.length +
'">No matching rows</td></tr>';
document.getElementById('count').textContent =
rs.length + ' / ' + DATA.rows.length + ' rows';
th.querySelectorAll('th').forEach(h => {
h.onclick = () => {
const k = h.dataset.k;
if (state.sortKey === k) state.dir *= -1;
else { state.sortKey = k; state.dir = 1; }
render();
};
});
}
document.getElementById('q').addEventListener('input', e => {
state.q = e.target.value;
render();
});
document.getElementById('themeBtn').onclick = () => {
const r = document.documentElement;
const cur = r.getAttribute('data-theme') ||
(matchMedia('(prefers-color-scheme:dark)').matches ? 'dark' : 'light');
r.setAttribute('data-theme', cur === 'dark' ? 'light' : 'dark');
};
document.getElementById('csvBtn').onclick = () => {
const esc = v => {
const s = String(v ?? '');
return /[",\\n]/.test(s) ? '"' + s.replace(/"/g,'""') + '"' : s;
};
const cols = DATA.cols.map(c => c.key);
const csv = [cols.map(esc).join(',')]
.concat(getRows().map(r => cols.map(c => esc(r[c])).join(',')))
.join('\\n');
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }));
a.download = 'report.csv';
a.click();
};
${themeInit}
render();
`;
}Then toInteractiveHtml becomes shorter and easier to scan:
export function toInteractiveHtml(
ds: ReportDataset,
opts: InteractiveOptions = {},
): string {
const specs: ColumnSpec[] = opts.columns ?? ds.columns.map((k) => ({ key: k }));
const colMeta = specs.map((s) => ({
key: s.key,
label: s.label ?? s.key,
numeric: !!s.numeric,
heat: s.heat ?? null,
}));
const stats = opts.stats ?? [];
const dataJson = JSON.stringify({ rows: ds.rows, cols: colMeta }).replace(/</g, '\\u003c');
const statsHtml = stats
.map((s) =>
`<div class="stat"><b>${htmlEscape(s.value)}</b><span>${htmlEscape(s.label)}</span></div>`,
)
.join('');
const theme = opts.theme ?? 'auto';
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${htmlEscape(ds.title)}</title>
<style>${buildInteractiveCss()}</style></head><body>
<header>
<div><h1>${htmlEscape(ds.title)}</h1>${ds.summary ? `<p class="summary">${htmlEscape(ds.summary)}</p>` : ''}</div>
<button class="btn" id="themeBtn" type="button">◐ theme</button>
</header>
<div class="stats">${statsHtml}</div>
<div class="toolbar">
<input type="search" id="q" placeholder="Search all columns…" autocomplete="off">
<button class="btn" id="csvBtn" type="button">⬇ CSV</button>
<span class="count" id="count"></span>
</div>
<div class="wrap"><table><thead id="thead"></thead><tbody id="tbody"></tbody></table></div>
<script>${buildInteractiveScript(dataJson, theme)}</script>
</body></html>`;
}This keeps the “single-file, self-contained” property but isolates behavior and styling from the data mapping.
2. Reduce responsibility in exportReport with a helper
The interactive export branch is simple but you can make exportReport more declarative:
function writeInteractiveReport(
ds: ReportDataset,
outDir: string,
base: string,
interactive: boolean | InteractiveOptions | undefined,
writeFileSync: typeof import('node:fs')['writeFileSync'],
): string | undefined {
if (!interactive) return;
const iv = join(outDir, `${base}.interactive.html`);
const opts = typeof interactive === 'object' ? interactive : {};
writeFileSync(iv, toInteractiveHtml(ds, opts));
return iv;
}Then inside exportReport:
const result: { csv: string; html: string; pdf?: string; interactive?: string } = { csv, html };
result.interactive = writeInteractiveReport(ds, outDir, base, opts.interactive, writeFileSync);These small extra helpers significantly lower cognitive load while preserving all current features and behavior.
Wunderland-side of the robustness/UX wave (pairs with framerslab/agentos-extensions#36):
WUNDERLAND_ATTACH_DRYRUN,WUNDERLAND_ATTACH_DEADLINE_MS; classifybrowser_attach_controlunder search.All unit-tested.
Summary by Sourcery
Introduce a self-contained interactive HTML report export alongside existing static report output and extend attach runtime configuration for robustness.
New Features:
Enhancements:
Tests: