497 lines
24 KiB
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 %}
|