feat: add dashboard view with real-time finding statistics and charts
This commit is contained in:
Generated
+31
-1
@@ -8,7 +8,9 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"vue": "^3.5.39"
|
||||
"chart.js": "^4.5.1",
|
||||
"vue": "^3.5.39",
|
||||
"vue-chartjs": "^5.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
@@ -149,6 +151,12 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
|
||||
@@ -952,6 +960,18 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.5.1",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
@@ -1652,6 +1672,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vue-chartjs": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
|
||||
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"chart.js": "^4.1.1",
|
||||
"vue": "^3.0.0-0 || ^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.6.4",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.39"
|
||||
"chart.js": "^4.5.1",
|
||||
"vue": "^3.5.39",
|
||||
"vue-chartjs": "^5.3.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<h1 class="text-xl font-bold text-heading">Nucleus</h1>
|
||||
</div>
|
||||
<nav class="flex space-x-4 items-center" v-if="$route.path !== '/login'">
|
||||
<router-link to="/" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Targets</router-link>
|
||||
<router-link to="/" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner" exact>Dashboard</router-link>
|
||||
<router-link to="/targets" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Targets</router-link>
|
||||
<router-link to="/scans" class="px-3 py-2 rounded-md text-sm font-medium transition-colors hover:bg-border-subtle hover:text-heading" active-class="bg-bg text-accent shadow-inner">Scans History</router-link>
|
||||
<button @click="logout" class="ml-4 px-3 py-1.5 border border-border-subtle rounded-md text-sm font-medium text-body hover:text-red-400 hover:border-red-400/50 transition-colors hover:cursor-pointer">Logout</button>
|
||||
</nav>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<!-- High Level Metrics -->
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-border-subtle flex flex-col justify-center">
|
||||
<h3 class="text-sm font-medium text-body uppercase tracking-wider mb-2">Total Targets</h3>
|
||||
<p class="text-4xl font-bold text-heading">{{ stats.total_targets || 0 }}</p>
|
||||
</div>
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-border-subtle flex flex-col justify-center">
|
||||
<h3 class="text-sm font-medium text-body uppercase tracking-wider mb-2">Total Scans</h3>
|
||||
<p class="text-4xl font-bold text-heading">{{ stats.total_scans || 0 }}</p>
|
||||
</div>
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-red-500/30 flex flex-col justify-center">
|
||||
<h3 class="text-sm font-medium text-red-400 uppercase tracking-wider mb-2">Critical Findings</h3>
|
||||
<p class="text-4xl font-bold text-red-500">{{ stats.severity?.critical || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- Severity Distribution Pie Chart -->
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-border-subtle">
|
||||
<h3 class="text-lg font-semibold text-heading mb-4">Severity Distribution</h3>
|
||||
<div class="h-64 flex justify-center">
|
||||
<Pie v-if="chartData.labels.length" :data="chartData" :options="chartOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-body">No data available</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Vulnerable Hosts -->
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-border-subtle flex flex-col">
|
||||
<h3 class="text-lg font-semibold text-heading mb-4">Top Vulnerable Hosts</h3>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<ul v-if="stats.top_hosts?.length > 0" class="divide-y divide-border-subtle">
|
||||
<li v-for="h in stats.top_hosts" :key="h.host" class="py-3 flex justify-between items-center">
|
||||
<span class="text-heading font-mono text-sm">{{ h.host }}</span>
|
||||
<span class="px-2.5 py-1 bg-red-500/20 text-red-400 border border-red-500/50 rounded-md text-xs font-bold">{{ h.count }} findings</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="flex items-center justify-center h-full text-body">
|
||||
No hosts with findings.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Trends -->
|
||||
<div class="bg-surface rounded-xl p-6 shadow-lg border border-border-subtle">
|
||||
<h3 class="text-lg font-semibold text-heading mb-4">Recent Discovery Trends (Last 7 Days)</h3>
|
||||
<div class="h-64">
|
||||
<Bar v-if="trendData.labels.length" :data="trendData" :options="trendOptions" />
|
||||
<div v-else class="flex items-center justify-center h-full text-body">No trends available</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title } from 'chart.js'
|
||||
import { Pie, Bar } from 'vue-chartjs'
|
||||
|
||||
ChartJS.register(ArcElement, Tooltip, Legend, CategoryScale, LinearScale, BarElement, Title)
|
||||
|
||||
const stats = ref({})
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/dashboard/stats')
|
||||
if (res.status === 401) { window.location.href = '/login'; return }
|
||||
stats.value = await res.json()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
if (!stats.value.severity) return { labels: [], datasets: [] }
|
||||
|
||||
const sev = stats.value.severity
|
||||
const values = [sev.critical, sev.high, sev.medium, sev.low, sev.info]
|
||||
|
||||
if (values.every(v => v === 0)) return { labels: [], datasets: [] }
|
||||
|
||||
return {
|
||||
labels: ['Critical', 'High', 'Medium', 'Low', 'Info'],
|
||||
datasets: [
|
||||
{
|
||||
backgroundColor: ['#ef4444', '#f97316', '#eab308', '#3b82f6', '#4b5563'],
|
||||
data: values,
|
||||
borderWidth: 1,
|
||||
borderColor: '#161b22'
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'right',
|
||||
labels: { color: '#8b949e' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trendData = computed(() => {
|
||||
if (!stats.value.recent_trends || stats.value.recent_trends.length === 0) return { labels: [], datasets: [] }
|
||||
|
||||
return {
|
||||
labels: stats.value.recent_trends.map(t => t.date),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Findings Discovered',
|
||||
backgroundColor: '#1ebe8a',
|
||||
data: stats.value.recent_trends.map(t => t.count),
|
||||
borderRadius: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const trendOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
grid: { color: '#30363d' },
|
||||
ticks: { color: '#8b949e' }
|
||||
},
|
||||
x: {
|
||||
grid: { display: false },
|
||||
ticks: { color: '#8b949e' }
|
||||
}
|
||||
},
|
||||
plugins: {
|
||||
legend: { display: false }
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
@@ -3,10 +3,12 @@ import TargetManager from './components/TargetManager.vue'
|
||||
import ScansHistory from './components/ScansHistory.vue'
|
||||
import FindingsInspector from './components/FindingsInspector.vue'
|
||||
import Login from './components/Login.vue'
|
||||
import Dashboard from './components/Dashboard.vue'
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: Login },
|
||||
{ path: '/', component: TargetManager, meta: { requiresAuth: true } },
|
||||
{ path: '/', component: Dashboard, meta: { requiresAuth: true } },
|
||||
{ path: '/targets', component: TargetManager, meta: { requiresAuth: true } },
|
||||
{ path: '/scans', component: ScansHistory, meta: { requiresAuth: true } },
|
||||
{ path: '/scans/:id', component: FindingsInspector, props: true, meta: { requiresAuth: true } }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user