secure-web/backend/templates/scan_detail.html

497 lines
24 KiB
HTML

{% extends "base.html" %}
{% block title %}Scan Results - Website Analyzer{% endblock %}
{% block content %}
<div x-data="scanDetailApp('{{ scan_id }}')" x-init="init()" x-cloak>
<!-- Loading State -->
<div x-show="loading" class="flex items-center justify-center py-20">
<div class="text-center">
<svg class="animate-spin h-12 w-12 text-blue-600 mx-auto mb-4" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<p class="text-gray-600">Loading scan results...</p>
</div>
</div>
<!-- Scan In Progress -->
<div x-show="!loading && scan && (scan.status === 'pending' || scan.status === 'running')" class="py-12">
<div class="max-w-2xl mx-auto text-center">
<div class="bg-white rounded-xl shadow-lg p-8">
<div class="animate-pulse-slow">
<svg class="h-16 w-16 text-blue-600 mx-auto mb-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-gray-900 mb-4">Scan in Progress</h2>
<p class="text-gray-600 mb-6">
Analyzing <span class="font-medium text-gray-900" x-text="scan.website_url"></span>
</p>
<div class="space-y-3 text-sm text-gray-500">
<p>⏱️ This typically takes 1-3 minutes</p>
<p>🔍 Running Lighthouse, OWASP ZAP, and browser analysis</p>
</div>
<div class="mt-6">
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-blue-600 h-2 rounded-full animate-pulse" style="width: 45%"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Error State -->
<div x-show="error && !loading" class="py-12">
<div class="max-w-2xl mx-auto">
<div class="bg-red-50 border border-red-200 rounded-xl p-8 text-center">
<svg class="h-12 w-12 text-red-500 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<h2 class="text-xl font-bold text-red-800 mb-2">Error Loading Scan</h2>
<p class="text-red-600" x-text="error"></p>
<a href="/" class="inline-block mt-6 px-6 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">
Start New Scan
</a>
</div>
</div>
</div>
<!-- Scan Results -->
<div x-show="!loading && scan && scan.status !== 'pending' && scan.status !== 'running'" class="space-y-8">
<!-- Header -->
<div class="bg-white rounded-xl shadow p-6">
<div class="flex flex-col md:flex-row md:items-center md:justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-2">
Scan Results
</h1>
<p class="text-gray-600">
<a :href="scan.website_url" target="_blank" class="text-blue-600 hover:underline" x-text="scan.website_url"></a>
</p>
<p class="text-sm text-gray-500 mt-1">
Scanned <span x-text="new Date(scan.completed_at || scan.created_at).toLocaleString()"></span>
</p>
</div>
<div class="mt-4 md:mt-0">
<span
class="px-4 py-2 rounded-full text-sm font-medium"
:class="{
'bg-green-100 text-green-800': scan.status === 'done',
'bg-yellow-100 text-yellow-800': scan.status === 'partial',
'bg-red-100 text-red-800': scan.status === 'failed'
}"
x-text="scan.status_display"
></span>
</div>
</div>
</div>
<!-- Scores Grid -->
<div class="grid grid-cols-2 md:grid-cols-5 gap-4">
<!-- Overall Score -->
<div class="bg-white rounded-xl shadow p-6 text-center col-span-2 md:col-span-1">
<div class="relative inline-flex items-center justify-center w-24 h-24 mb-3">
<svg class="w-24 h-24 transform -rotate-90">
<circle cx="48" cy="48" r="44" stroke="#e5e7eb" stroke-width="8" fill="none"/>
<circle
cx="48" cy="48" r="44"
:stroke="getScoreColor(scan.overall_score)"
stroke-width="8"
fill="none"
stroke-linecap="round"
:stroke-dasharray="276.46"
:stroke-dashoffset="276.46 - (276.46 * (scan.overall_score || 0) / 100)"
/>
</svg>
<span class="absolute text-2xl font-bold" :class="getScoreTextClass(scan.overall_score)" x-text="scan.overall_score || '-'"></span>
</div>
<p class="text-sm font-medium text-gray-600">Overall Score</p>
</div>
<!-- Performance -->
<div class="bg-white rounded-xl shadow p-4 text-center">
<div class="text-3xl font-bold mb-1" :class="getScoreTextClass(scan.performance_score)" x-text="scan.performance_score || '-'"></div>
<p class="text-sm text-gray-600">Performance</p>
</div>
<!-- Security -->
<div class="bg-white rounded-xl shadow p-4 text-center">
<div class="text-3xl font-bold mb-1" :class="getScoreTextClass(scan.security_score)" x-text="scan.security_score || '-'"></div>
<p class="text-sm text-gray-600">Security</p>
</div>
<!-- Accessibility -->
<div class="bg-white rounded-xl shadow p-4 text-center">
<div class="text-3xl font-bold mb-1" :class="getScoreTextClass(scan.accessibility_score)" x-text="scan.accessibility_score || '-'"></div>
<p class="text-sm text-gray-600">Accessibility</p>
</div>
<!-- SEO -->
<div class="bg-white rounded-xl shadow p-4 text-center">
<div class="text-3xl font-bold mb-1" :class="getScoreTextClass(scan.seo_score)" x-text="scan.seo_score || '-'"></div>
<p class="text-sm text-gray-600">SEO</p>
</div>
</div>
<!-- Key Metrics -->
<div class="bg-white rounded-xl shadow p-6" x-show="scan.metrics && scan.metrics.length > 0">
<h2 class="text-xl font-bold text-gray-900 mb-4">Key Metrics</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<template x-for="metric in getKeyMetrics()" :key="metric.name">
<div class="bg-gray-50 rounded-lg p-4">
<div class="text-2xl font-bold text-gray-900" x-text="metric.formatted_value || formatMetric(metric)"></div>
<p class="text-sm text-gray-600" x-text="metric.display_name"></p>
</div>
</template>
</div>
</div>
<!-- Issues Section -->
<div class="bg-white rounded-xl shadow overflow-hidden">
<div class="p-6 border-b border-gray-200">
<div class="flex items-center justify-between">
<h2 class="text-xl font-bold text-gray-900">
Issues Found
<span class="ml-2 text-sm font-normal text-gray-500" x-text="'(' + (scan.issues?.length || 0) + ')'"></span>
</h2>
<!-- Filter buttons -->
<div class="flex space-x-2">
<button
@click="issueFilter = 'all'"
:class="issueFilter === 'all' ? 'bg-gray-900 text-white' : 'bg-gray-100 text-gray-700'"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors"
>All</button>
<button
@click="issueFilter = 'critical'"
:class="issueFilter === 'critical' ? 'bg-red-600 text-white' : 'bg-red-50 text-red-700'"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors"
>Critical</button>
<button
@click="issueFilter = 'high'"
:class="issueFilter === 'high' ? 'bg-orange-600 text-white' : 'bg-orange-50 text-orange-700'"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors"
>High</button>
<button
@click="issueFilter = 'medium'"
:class="issueFilter === 'medium' ? 'bg-yellow-600 text-white' : 'bg-yellow-50 text-yellow-700'"
class="px-3 py-1 rounded-full text-sm font-medium transition-colors"
>Medium</button>
</div>
</div>
</div>
<!-- Issues List -->
<div class="divide-y divide-gray-200 max-h-96 overflow-y-auto">
<template x-for="issue in getFilteredIssues()" :key="issue.id">
<div class="p-4 hover:bg-gray-50 cursor-pointer" @click="issue.expanded = !issue.expanded">
<div class="flex items-start space-x-4">
<!-- Severity Badge -->
<span
class="flex-shrink-0 w-20 px-2 py-1 text-xs font-medium rounded-full text-center"
:class="{
'bg-red-100 text-red-800': issue.severity === 'critical',
'bg-orange-100 text-orange-800': issue.severity === 'high',
'bg-yellow-100 text-yellow-800': issue.severity === 'medium',
'bg-blue-100 text-blue-800': issue.severity === 'low',
'bg-gray-100 text-gray-800': issue.severity === 'info'
}"
x-text="issue.severity_display"
></span>
<!-- Issue Content -->
<div class="flex-1 min-w-0">
<div class="flex items-center space-x-2">
<span
class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600"
x-text="issue.category_display"
></span>
<span
class="text-xs text-gray-400"
x-text="issue.tool_display"
></span>
</div>
<h4 class="mt-1 font-medium text-gray-900" x-text="issue.title"></h4>
<!-- Expanded Details -->
<div x-show="issue.expanded" x-transition class="mt-3 space-y-2">
<p class="text-sm text-gray-600" x-text="issue.description"></p>
<div x-show="issue.affected_url" class="text-sm">
<span class="font-medium text-gray-700">Affected URL:</span>
<a :href="issue.affected_url" target="_blank" class="text-blue-600 hover:underline break-all" x-text="issue.affected_url"></a>
</div>
<div x-show="issue.remediation" class="bg-green-50 p-3 rounded-lg">
<span class="font-medium text-green-800">Remediation:</span>
<p class="text-sm text-green-700 mt-1" x-text="issue.remediation"></p>
</div>
</div>
</div>
<!-- Expand Icon -->
<svg
class="w-5 h-5 text-gray-400 transform transition-transform"
:class="{'rotate-180': issue.expanded}"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</div>
</div>
</template>
<!-- No Issues -->
<div x-show="getFilteredIssues().length === 0" class="p-8 text-center text-gray-500">
<svg class="w-12 h-12 mx-auto mb-3 text-gray-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p x-show="issueFilter === 'all'">No issues found!</p>
<p x-show="issueFilter !== 'all'">No <span x-text="issueFilter"></span> severity issues found.</p>
</div>
</div>
</div>
<!-- Issues by Category Chart -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="bg-white rounded-xl shadow p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Issues by Category</h3>
<canvas id="categoryChart"></canvas>
</div>
<div class="bg-white rounded-xl shadow p-6">
<h3 class="text-lg font-bold text-gray-900 mb-4">Issues by Severity</h3>
<canvas id="severityChart"></canvas>
</div>
</div>
<!-- Error Message (for failed/partial scans) -->
<div x-show="scan.error_message" class="bg-yellow-50 border border-yellow-200 rounded-xl p-6">
<div class="flex items-start">
<svg class="w-6 h-6 text-yellow-500 mr-3 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<div>
<h4 class="font-medium text-yellow-800">Scan Warning</h4>
<p class="mt-1 text-sm text-yellow-700" x-text="scan.error_message"></p>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex justify-center space-x-4">
<a href="/" class="px-6 py-3 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-colors">
New Scan
</a>
<button
@click="rescan()"
class="px-6 py-3 bg-gray-200 text-gray-700 font-semibold rounded-lg hover:bg-gray-300 transition-colors"
>
Rescan This URL
</button>
</div>
</div>
</div>
<script>
function scanDetailApp(scanId) {
return {
scanId: scanId,
scan: null,
loading: true,
error: null,
pollInterval: null,
issueFilter: 'all',
categoryChart: null,
severityChart: null,
async init() {
await this.loadScan();
// Poll if scan is in progress
if (this.scan && (this.scan.status === 'pending' || this.scan.status === 'running')) {
this.pollInterval = setInterval(() => this.loadScan(), 3000);
}
},
async loadScan() {
try {
const response = await fetch(`/api/scans/${this.scanId}/`);
if (!response.ok) {
throw new Error('Scan not found');
}
this.scan = await response.json();
// Add expanded property to issues
if (this.scan.issues) {
this.scan.issues = this.scan.issues.map(issue => ({...issue, expanded: false}));
}
// Stop polling if done
if (this.scan.status !== 'pending' && this.scan.status !== 'running') {
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
// Draw charts after a short delay
this.$nextTick(() => {
setTimeout(() => this.drawCharts(), 100);
});
}
} catch (err) {
this.error = err.message;
if (this.pollInterval) {
clearInterval(this.pollInterval);
}
} finally {
this.loading = false;
}
},
getScoreColor(score) {
if (score === null || score === undefined) return '#9ca3af';
if (score >= 80) return '#16a34a';
if (score >= 50) return '#ca8a04';
return '#dc2626';
},
getScoreTextClass(score) {
if (score === null || score === undefined) return 'text-gray-400';
if (score >= 80) return 'text-green-600';
if (score >= 50) return 'text-yellow-600';
return 'text-red-600';
},
getKeyMetrics() {
if (!this.scan?.metrics) return [];
const keyNames = [
'first_contentful_paint',
'largest_contentful_paint',
'time_to_interactive',
'total_blocking_time',
'cumulative_layout_shift',
'num_requests',
'total_byte_weight',
'page_load_time'
];
return this.scan.metrics.filter(m => keyNames.includes(m.name)).slice(0, 8);
},
formatMetric(metric) {
const value = metric.value;
const unit = metric.unit;
if (unit === 'ms') {
if (value >= 1000) return (value / 1000).toFixed(2) + 's';
return Math.round(value) + 'ms';
}
if (unit === 'bytes') {
if (value >= 1024 * 1024) return (value / (1024 * 1024)).toFixed(2) + ' MB';
if (value >= 1024) return (value / 1024).toFixed(1) + ' KB';
return value + ' B';
}
if (unit === 'score') return value.toFixed(3);
if (unit === 'count') return value.toString();
return value.toFixed(2);
},
getFilteredIssues() {
if (!this.scan?.issues) return [];
if (this.issueFilter === 'all') return this.scan.issues;
return this.scan.issues.filter(i => i.severity === this.issueFilter);
},
drawCharts() {
if (!this.scan?.issues_by_category) return;
// Category Chart
const categoryCtx = document.getElementById('categoryChart');
if (categoryCtx && this.scan.issues_by_category) {
if (this.categoryChart) this.categoryChart.destroy();
const categories = Object.keys(this.scan.issues_by_category);
const counts = Object.values(this.scan.issues_by_category);
this.categoryChart = new Chart(categoryCtx, {
type: 'doughnut',
data: {
labels: categories.map(c => c.replace('_', ' ').replace(/\b\w/g, l => l.toUpperCase())),
datasets: [{
data: counts,
backgroundColor: [
'#3b82f6', '#ef4444', '#10b981', '#f59e0b',
'#6366f1', '#8b5cf6', '#ec4899', '#14b8a6'
]
}]
},
options: {
responsive: true,
plugins: {
legend: {
position: 'bottom'
}
}
}
});
}
// Severity Chart
const severityCtx = document.getElementById('severityChart');
if (severityCtx && this.scan.issues_by_severity) {
if (this.severityChart) this.severityChart.destroy();
const severities = ['critical', 'high', 'medium', 'low', 'info'];
const severityLabels = ['Critical', 'High', 'Medium', 'Low', 'Info'];
const severityCounts = severities.map(s => this.scan.issues_by_severity[s] || 0);
this.severityChart = new Chart(severityCtx, {
type: 'bar',
data: {
labels: severityLabels,
datasets: [{
label: 'Issues',
data: severityCounts,
backgroundColor: ['#dc2626', '#f97316', '#eab308', '#3b82f6', '#9ca3af']
}]
},
options: {
responsive: true,
plugins: {
legend: {
display: false
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
stepSize: 1
}
}
}
}
});
}
},
async rescan() {
try {
const response = await fetch('/api/scans/', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ url: this.scan.website_url })
});
if (response.ok) {
const newScan = await response.json();
window.location.href = `/scan/${newScan.id}/`;
}
} catch (err) {
alert('Failed to start rescan: ' + err.message);
}
}
}
}
</script>
{% endblock %}