All checks were successful
ci/woodpecker/tag/release Pipeline was successful
- 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
428 lines
14 KiB
HTML
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>
|
|
|