dharma/pkg/reporter/templates/report.html
Felipe M. 48a0dffb2b
All checks were successful
ci/woodpecker/tag/release Pipeline was successful
Add HTML report format with filtering capabilities
- Add HTMLReporter that generates interactive HTML reports
- Use html/template with go:embed for template files
- Implement client-side filtering by status code, path, type, and internal/external
- Display internal URLs as relative paths (without base URL)
- Add modern, responsive CSS styling with color-coded status codes
- Include summary statistics and filterable table of all URLs
2026-01-17 17:25:10 +01:00

428 lines
14 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dharma Report - {{.BaseURL}}</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f5f5f5;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
padding: 30px;
}
h1 {
color: #2c3e50;
margin-bottom: 10px;
font-size: 28px;
}
.meta {
color: #7f8c8d;
margin-bottom: 30px;
font-size: 14px;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 30px;
}
.stat-card {
background: #f8f9fa;
padding: 15px;
border-radius: 6px;
border-left: 4px solid #3498db;
}
.stat-card.success {
border-left-color: #27ae60;
}
.stat-card.error {
border-left-color: #e74c3c;
}
.stat-label {
font-size: 12px;
color: #7f8c8d;
text-transform: uppercase;
margin-bottom: 5px;
}
.stat-value {
font-size: 24px;
font-weight: bold;
color: #2c3e50;
}
.filters {
background: #f8f9fa;
padding: 20px;
border-radius: 6px;
margin-bottom: 20px;
}
.filters h2 {
font-size: 18px;
margin-bottom: 15px;
color: #2c3e50;
}
.filter-group {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 15px;
margin-bottom: 15px;
}
.filter-item {
display: flex;
flex-direction: column;
}
.filter-item label {
font-size: 12px;
color: #7f8c8d;
margin-bottom: 5px;
font-weight: 500;
}
.filter-item input,
.filter-item select {
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.filter-item input:focus,
.filter-item select:focus {
outline: none;
border-color: #3498db;
}
.filter-checkboxes {
display: flex;
flex-wrap: wrap;
gap: 15px;
margin-top: 10px;
}
.filter-checkbox {
display: flex;
align-items: center;
gap: 5px;
}
.filter-checkbox input[type="checkbox"] {
width: auto;
}
.result-count {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #ddd;
font-size: 14px;
color: #7f8c8d;
}
.result-count strong {
color: #2c3e50;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
thead {
background: #34495e;
color: white;
}
th {
padding: 12px;
text-align: left;
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
tbody tr {
border-bottom: 1px solid #e0e0e0;
}
tbody tr:hover {
background: #f8f9fa;
}
tbody tr.hidden {
display: none;
}
td {
padding: 12px;
font-size: 14px;
}
.status {
display: inline-block;
padding: 4px 8px;
border-radius: 4px;
font-weight: 600;
font-size: 12px;
}
.status-2xx {
background: #d4edda;
color: #155724;
}
.status-3xx {
background: #fff3cd;
color: #856404;
}
.status-4xx {
background: #f8d7da;
color: #721c24;
}
.status-5xx {
background: #f8d7da;
color: #721c24;
}
.status-error {
background: #e9ecef;
color: #495057;
}
.type-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: #e9ecef;
color: #495057;
}
.external-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: #cfe2ff;
color: #084298;
}
.internal-badge {
display: inline-block;
padding: 3px 8px;
border-radius: 3px;
font-size: 11px;
font-weight: 500;
background: #d1e7dd;
color: #0f5132;
}
a {
color: #3498db;
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
.error-msg {
color: #e74c3c;
font-size: 13px;
}
@media (max-width: 768px) {
.container {
padding: 15px;
}
.filter-group {
grid-template-columns: 1fr;
}
table {
font-size: 12px;
}
th, td {
padding: 8px;
}
}
</style>
</head>
<body>
<div class="container">
<h1>Website Scan Report</h1>
<div class="meta">
<strong>URL:</strong> {{.BaseURL}}<br>
<strong>Scanned at:</strong> {{.ScannedAt}}
</div>
<div class="stats">
<div class="stat-card">
<div class="stat-label">Total Resources</div>
<div class="stat-value">{{.Total}}</div>
</div>
<div class="stat-card success">
<div class="stat-label">Success</div>
<div class="stat-value">{{.SuccessCount}}</div>
</div>
<div class="stat-card error">
<div class="stat-label">Errors</div>
<div class="stat-value">{{.ErrorCount}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Internal Success</div>
<div class="stat-value">{{.InternalSuccess}}</div>
</div>
<div class="stat-card">
<div class="stat-label">Internal Errors</div>
<div class="stat-value">{{.InternalErrors}}</div>
</div>
<div class="stat-card">
<div class="stat-label">External Success</div>
<div class="stat-value">{{.ExternalSuccess}}</div>
</div>
<div class="stat-card">
<div class="stat-label">External Errors</div>
<div class="stat-value">{{.ExternalErrors}}</div>
</div>
</div>
<div class="filters">
<h2>Filters</h2>
<div class="filter-group">
<div class="filter-item">
<label for="statusFilter">Status Code</label>
<input type="text" id="statusFilter" placeholder="e.g., 200, 404, or leave empty">
</div>
<div class="filter-item">
<label for="pathFilter">URL Path</label>
<input type="text" id="pathFilter" placeholder="Filter by URL path">
</div>
<div class="filter-item">
<label for="externalFilter">Internal/External</label>
<select id="externalFilter">
<option value="">All</option>
<option value="internal">Internal Only</option>
<option value="external">External Only</option>
</select>
</div>
</div>
<div class="filter-checkboxes">
<div class="filter-checkbox">
<input type="checkbox" id="errorOnly">
<label for="errorOnly">Errors Only</label>
</div>
{{range .Types}}
<div class="filter-checkbox">
<input type="checkbox" id="type-{{.}}" class="type-filter" value="{{.}}" checked>
<label for="type-{{.}}">{{.}}</label>
</div>
{{end}}
</div>
<div class="result-count">
Showing <strong id="resultCount">{{.Total}}</strong> of {{.Total}} results
</div>
</div>
<table>
<thead>
<tr>
<th>Status</th>
<th>Type</th>
<th>URL</th>
<th>Source URL</th>
<th>Location</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{{range .Results}}
<tr data-status="{{.StatusText}}" data-type="{{.Type}}" data-path="{{.URL}}" data-external="{{.IsExternal}}" data-has-error="{{.HasError}}">
<td><span class="status {{.StatusClass}}">{{.StatusText}}</span></td>
<td><span class="type-badge">{{.Type}}</span></td>
<td><a href="{{.URL}}" target="_blank">{{.DisplayURL}}</a></td>
<td><a href="{{.SourceURL}}" target="_blank">{{.DisplaySource}}</a></td>
<td>
{{if .IsExternal}}
<span class="external-badge">External</span>
{{else}}
<span class="internal-badge">Internal</span>
{{end}}
</td>
<td class="{{.ErrorClass}}">{{.Error}}</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<script>
function updateFilters() {
const statusFilter = document.getElementById('statusFilter').value.trim();
const pathFilter = document.getElementById('pathFilter').value.trim().toLowerCase();
const externalFilter = document.getElementById('externalFilter').value;
const errorOnly = document.getElementById('errorOnly').checked;
const typeCheckboxes = document.querySelectorAll('.type-filter:checked');
const selectedTypes = Array.from(typeCheckboxes).map(cb => cb.value);
const rows = document.querySelectorAll('tbody tr');
let visibleCount = 0;
rows.forEach(row => {
let visible = true;
// Status filter
if (statusFilter) {
const rowStatus = row.getAttribute('data-status');
if (rowStatus !== statusFilter) {
visible = false;
}
}
// Path filter
if (pathFilter && visible) {
const rowPath = row.getAttribute('data-path').toLowerCase();
if (!rowPath.includes(pathFilter)) {
visible = false;
}
}
// External/Internal filter
if (externalFilter && visible) {
const isExternal = row.getAttribute('data-external') === 'true';
if (externalFilter === 'internal' && isExternal) {
visible = false;
} else if (externalFilter === 'external' && !isExternal) {
visible = false;
}
}
// Error only filter
if (errorOnly && visible) {
const hasError = row.getAttribute('data-has-error') === 'true';
if (!hasError) {
visible = false;
}
}
// Type filter
if (selectedTypes.length > 0 && visible) {
const rowType = row.getAttribute('data-type');
if (!selectedTypes.includes(rowType)) {
visible = false;
}
}
if (visible) {
row.classList.remove('hidden');
visibleCount++;
} else {
row.classList.add('hidden');
}
});
document.getElementById('resultCount').textContent = visibleCount;
}
// Attach event listeners
document.getElementById('statusFilter').addEventListener('input', updateFilters);
document.getElementById('pathFilter').addEventListener('input', updateFilters);
document.getElementById('externalFilter').addEventListener('change', updateFilters);
document.getElementById('errorOnly').addEventListener('change', updateFilters);
document.querySelectorAll('.type-filter').forEach(cb => {
cb.addEventListener('change', updateFilters);
});
</script>
</body>
</html>