Skip to content

Commit 1f8a539

Browse files
committed
feat: per-phase log capture, clickable execution pipeline, and broader results tabs
- Add goroutine-local phase key tracking (scancontext.go) so we know which module every log line belongs to. - Create phase_logs.go: global logrus hook that captures all log output during a workflow phase and persists it to results/<scanID>/phase-logs/<module>.jsonl. - Wire phase log capture into RunWorkflowPhase (start before fn, flush and clear after fn completes or times out). - Add GET /api/scans/:id/logs?module= endpoint to serve captured logs. - Redesign Execution Pipeline card: rows are now clickable. Clicking opens an inline log drawer that fetches and displays per-module logs with timestamps, levels, and structured fields. - Fix overly aggressive module tab exclusions in scan-detail.js: removed hardcoded exclusions that were hiding tech-detect, nuclei, js-analysis, js-endpoints, katana, ffuf, reflection, xss-detection, and github-scan from the results tabs. Also removed 'tech' from HIDDEN_KINDS.
1 parent faf1ccb commit 1f8a539

7 files changed

Lines changed: 372 additions & 12 deletions

File tree

internal/api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -500,6 +500,7 @@ func SetupAPI() *gin.Engine {
500500
apiGroup.GET("/scans/:id/results/urls", apiScanURLs)
501501
apiGroup.GET("/scans/:id/artifacts", apiListScanArtifacts)
502502
apiGroup.GET("/scans/:id/manifest", apiGetScanManifest)
503+
apiGroup.GET("/scans/:id/logs", apiGetScanPhaseLogs)
503504
apiGroup.GET("/scans/:id", apiGetScan)
504505
apiGroup.GET("/scans/:id/report", apiGetScanReport)
505506
apiGroup.GET("/scans/:id/logs/stream", apiStreamScanLogs)

internal/api/scan_results_api.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,46 @@ func apiGetScanManifest(c *gin.Context) {
110110
c.JSON(http.StatusOK, gin.H{"scan_id": scanID, "manifest": manifest, "generated": true})
111111
}
112112

113+
// GET /api/scans/:id/logs?module= — per-module phase logs for a scan.
114+
func apiGetScanPhaseLogs(c *gin.Context) {
115+
_ = db.Init()
116+
_ = db.EnsureSchema()
117+
scanID := strings.TrimSpace(c.Param("id"))
118+
if scanID == "" {
119+
c.JSON(http.StatusBadRequest, gin.H{"error": "scan id required"})
120+
return
121+
}
122+
module := strings.TrimSpace(c.Query("module"))
123+
if module == "" {
124+
c.JSON(http.StatusBadRequest, gin.H{"error": "module query param required"})
125+
return
126+
}
127+
128+
// Try in-memory buffer first (for live / recent scans).
129+
entries := utils.ReadPhaseLogBuffer(scanID, module)
130+
if len(entries) == 0 {
131+
// Fallback to persisted JSONL file.
132+
var err error
133+
entries, err = utils.ReadPhaseLogFile(scanID, module)
134+
if err != nil {
135+
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
136+
return
137+
}
138+
}
139+
140+
// Convert to simple lines for the frontend.
141+
lines := make([]map[string]interface{}, 0, len(entries))
142+
for _, e := range entries {
143+
lines = append(lines, map[string]interface{}{
144+
"timestamp": e.Timestamp,
145+
"level": e.Level,
146+
"message": e.Message,
147+
"fields": e.Fields,
148+
})
149+
}
150+
c.JSON(http.StatusOK, gin.H{"scan_id": scanID, "module": module, "lines": lines, "count": len(lines)})
151+
}
152+
113153
type fileEntry struct {
114154
FileName string `json:"file_name"`
115155
LocalPath string `json:"local_path"`

internal/api/ui/pages/scan-detail-manifest.js

Lines changed: 74 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
// scan-detail-manifest.js — Execution pipeline / manifest card rendering.
2-
// Extracted from scan-detail.js for maintainability.
32
// Exposes: window.ScanDetailManifest
43
(() => {
54
const esc = (...args) => (typeof window.esc === 'function' ? window.esc(...args) : String(args[0] ?? ''));
@@ -49,35 +48,99 @@
4948
return d.toLocaleTimeString();
5049
}
5150

52-
function _moduleRow(m) {
51+
function _moduleRow(m, scanId) {
52+
const mod = esc(m.module || m.name || 'unknown');
5353
return `
54-
<tr>
55-
<td style="font-weight:600;font-size:13px">${esc(m.module || m.name || 'unknown')}</td>
54+
<tr class="manifest-row" data-module="${mod}" data-scan-id="${esc(scanId)}" style="cursor:pointer">
55+
<td style="font-weight:600;font-size:13px">${mod}</td>
5656
<td><span class="badge ${manifestStatusBadge(m.status)}">${esc(m.status)}</span></td>
5757
<td style="font-family:monospace;font-size:12px;color:var(--text-muted)">${manifestArtifactLabel(m)}</td>
5858
<td style="font-size:11px;color:var(--text-muted)">${manifestStartedLabel(m)}</td>
5959
<td style="font-family:monospace;font-size:12px">${formatManifestDuration(m.duration_ms)}</td>
60+
</tr>
61+
<tr class="manifest-log-row" id="manifest-log-row-${mod}" style="display:none;background:rgba(0,0,0,.25)">
62+
<td colspan="5" style="padding:0;border:none">
63+
<div class="manifest-log-panel" id="manifest-log-panel-${mod}" style="padding:12px 16px;max-height:400px;overflow:auto;font-family:var(--font-mono,monospace);font-size:12px">
64+
<div style="color:var(--text-muted)">Click to load logs…</div>
65+
</div>
66+
</td>
6067
</tr>`;
6168
}
6269

70+
async function loadModuleLogs(scanId, module, container) {
71+
container.innerHTML = '<div style="color:var(--text-muted);padding:8px 0">Loading logs…</div>';
72+
try {
73+
const resp = await apiFetch(`/api/scans/${encodeURIComponent(scanId)}/logs?module=${encodeURIComponent(module)}`);
74+
const lines = Array.isArray(resp?.lines) ? resp.lines : [];
75+
if (!lines.length) {
76+
container.innerHTML = '<div style="color:var(--text-muted);padding:8px 0">No logs captured for this phase yet.</div>';
77+
return;
78+
}
79+
const html = lines.map((ln) => {
80+
const ts = ln.timestamp ? new Date(ln.timestamp).toLocaleTimeString() : '';
81+
const level = String(ln.level || 'info').toLowerCase();
82+
let color = 'var(--text-secondary)';
83+
if (level === 'error' || level === 'fatal' || level === 'panic') color = '#ef4444';
84+
else if (level === 'warn' || level === 'warning') color = '#f59e0b';
85+
else if (level === 'debug') color = '#94a3b8';
86+
else if (level === 'info') color = '#22c55e';
87+
const msg = esc(ln.message || '');
88+
const fields = ln.fields && Object.keys(ln.fields).length
89+
? ' <span style="color:var(--text-muted);font-size:11px">' + esc(JSON.stringify(ln.fields)) + '</span>'
90+
: '';
91+
return `<div style="padding:3px 0;border-bottom:1px solid rgba(255,255,255,.04)"><span style="color:var(--text-muted);font-size:11px;margin-right:8px">${esc(ts)}</span><span style="color:${color};font-weight:600;margin-right:8px">${esc(level.toUpperCase())}</span><span style="color:var(--text-primary)">${msg}</span>${fields}</div>`;
92+
}).join('');
93+
container.innerHTML = html;
94+
} catch (e) {
95+
container.innerHTML = `<div style="color:var(--accent-red);padding:8px 0">Failed to load logs: ${esc(e.message || String(e))}</div>`;
96+
}
97+
}
98+
99+
function wireManifestRowClicks(root) {
100+
if (!root) return;
101+
root.querySelectorAll('.manifest-row').forEach((row) => {
102+
row.addEventListener('click', async () => {
103+
const mod = row.getAttribute('data-module');
104+
const scanId = row.getAttribute('data-scan-id');
105+
const logRow = document.getElementById(`manifest-log-row-${mod}`);
106+
const panel = document.getElementById(`manifest-log-panel-${mod}`);
107+
if (!logRow || !panel) return;
108+
109+
const isOpen = logRow.style.display !== 'none';
110+
// Close any open log rows first
111+
root.querySelectorAll('.manifest-log-row').forEach((r) => { r.style.display = 'none'; });
112+
113+
if (!isOpen) {
114+
logRow.style.display = 'table-row';
115+
// Load logs only on first open
116+
if (panel.dataset.loaded !== '1') {
117+
panel.dataset.loaded = '1';
118+
await loadModuleLogs(scanId, mod, panel);
119+
}
120+
}
121+
});
122+
});
123+
}
124+
63125
function renderScanManifestCard(manifest, scan) {
64126
const modules = Array.isArray(manifest?.modules) ? manifest.modules : [];
65127
const scanStatus = scan?.status || scan?.Status || '';
66128
const isActive = /running|starting|paused|cancelling/i.test(scanStatus);
129+
const scanId = scan?.scan_id || scan?.ScanID || '';
67130

68131
if (!modules.length && !isActive) return '';
69132

70133
return `
71134
<div class="modern-card" style="margin-bottom:20px">
72135
<div class="card-header" style="cursor:pointer" onclick="const b=this.nextElementSibling; b.style.display=b.style.display==='none'?'block':'none'">
73136
<div class="card-title"><span class="card-title-icon">⚙</span>Execution Pipeline</div>
74-
<div style="font-size:11px;color:var(--text-muted)">${modules.length} phases documented</div>
137+
<div style="font-size:11px;color:var(--text-muted)">${modules.length} phases documented · click any row for logs</div>
75138
</div>
76-
<div class="card-body" style="padding:0">
139+
<div class="card-body" style="padding:0;display:block">
77140
<table class="dashboard-table" style="width:100%">
78141
<thead><tr><th>Phase</th><th>Status</th><th>Artifacts</th><th>Started</th><th>Duration</th></tr></thead>
79142
<tbody id="scan-manifest-tbody">
80-
${modules.map(_moduleRow).join('')}
143+
${modules.map((m) => _moduleRow(m, scanId)).join('')}
81144
</tbody>
82145
</table>
83146
</div>
@@ -96,9 +159,11 @@
96159
const resp = await fetchScanManifest(scanId);
97160
if (!resp || !resp.manifest) return;
98161
const modules = Array.isArray(resp.manifest.modules) ? resp.manifest.modules : [];
162+
const scanIdVal = scan?.scan_id || scan?.ScanID || '';
99163
const tbody = document.getElementById('scan-manifest-tbody');
100164
if (tbody) {
101-
tbody.innerHTML = modules.map(_moduleRow).join('');
165+
tbody.innerHTML = modules.map((m) => _moduleRow(m, scanIdVal)).join('');
166+
wireManifestRowClicks(tbody.closest('.modern-card'));
102167
}
103168
}
104169

@@ -110,5 +175,6 @@
110175
renderScanManifestCard,
111176
fetchScanManifest,
112177
refreshScanManifestCard,
178+
wireManifestRowClicks,
113179
};
114180
})();

internal/api/ui/pages/scan-detail.js

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@
196196

197197
container.innerHTML = html;
198198

199+
// Wire manifest pipeline row clicks after DOM insertion.
200+
const manifestCardEl = container.querySelector('.modern-card');
201+
if (manifestCardEl) {
202+
window.ScanDetailManifest.wireManifestRowClicks(manifestCardEl);
203+
}
204+
199205
window.wireScanFileRows(container, scanId);
200206
window.wireScanDetailFilters(scanId, files);
201207
loadReconUnifiedTable(scanId, files, 'unified-parsed-results', scan);
@@ -459,7 +465,7 @@
459465

460466
const _kindCounts = {};
461467
for (const r of allRows) _kindCounts[r.kind || 'other'] = (_kindCounts[r.kind || 'other'] || 0) + 1;
462-
const HIDDEN_KINDS = new Set(['logs', 'log', 'tech', 'js_urls']);
468+
const HIDDEN_KINDS = new Set(['logs', 'log']);
463469
const TAB_LABELS = {
464470
assets: '🏠 Assets',
465471
urls: '🔗 Links',
@@ -523,7 +529,7 @@
523529
if (bi !== -1) return 1;
524530
return a.localeCompare(b);
525531
});
526-
const excludedModuleTabs = new Set(['autoar', 'unknown', 'tech-detect', 'ffuf-fuzzing', 'js-analysis', 'js-endpoints', 'katana-crawler', 'xss-detection', 'github-scan', 'nuclei', 'ffuf', 'reflection']);
532+
const excludedModuleTabs = new Set(['autoar', 'unknown']);
527533
const hasUrlsDatasetTab = UNIQUE_TABS.some((t) => t[0] === 'urls');
528534
if (hasUrlsDatasetTab) excludedModuleTabs.add('url-collection');
529535
const hasApkxDatasetTab = UNIQUE_TABS.some((t) => t[0] === 'apkx');

0 commit comments

Comments
 (0)