feat: add dashboard view with real-time finding statistics and charts

This commit is contained in:
2026-07-17 09:23:01 +01:00
parent ca2d6d05d4
commit 94dbd610e1
7 changed files with 259 additions and 4 deletions
+31 -1
View File
@@ -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",
+3 -1
View File
@@ -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",
+2 -1
View File
@@ -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>
+144
View File
@@ -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 -1
View File
@@ -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 } }
]
+58
View File
@@ -16,6 +16,8 @@ func RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/auth/logout", LogoutHandler)
mux.HandleFunc("GET /api/auth/status", StatusHandler)
mux.HandleFunc("GET /api/dashboard/stats", AuthMiddleware(getDashboardStats))
mux.HandleFunc("GET /api/targets", AuthMiddleware(getTargets))
mux.HandleFunc("POST /api/targets", AuthMiddleware(createTarget))
mux.HandleFunc("PUT /api/targets/{id}", AuthMiddleware(updateTarget))
@@ -195,3 +197,59 @@ func getFindings(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(findings)
}
func getDashboardStats(w http.ResponseWriter, r *http.Request) {
var stats models.DashboardStats
stats.Severity = map[string]int{"critical": 0, "high": 0, "medium": 0, "low": 0, "info": 0}
stats.TopHosts = []models.HostCount{}
stats.RecentTrends = []models.TrendPoint{}
// Total Targets
db.DB.QueryRow("SELECT COUNT(*) FROM targets").Scan(&stats.TotalTargets)
// Total Scans
db.DB.QueryRow("SELECT COUNT(*) FROM scans").Scan(&stats.TotalScans)
// Severity Counts
rows, err := db.DB.Query("SELECT severity, COUNT(*) FROM findings GROUP BY severity")
if err == nil {
for rows.Next() {
var sev string
var count int
rows.Scan(&sev, &count)
stats.Severity[sev] = count
}
rows.Close()
}
// Top Hosts
hRows, err := db.DB.Query("SELECT host, COUNT(*) as c FROM findings GROUP BY host ORDER BY c DESC LIMIT 5")
if err == nil {
for hRows.Next() {
var h models.HostCount
hRows.Scan(&h.Host, &h.Count)
stats.TopHosts = append(stats.TopHosts, h)
}
hRows.Close()
}
// Recent Trends (Last 7 days)
tRows, err := db.DB.Query(`
SELECT DATE(detected_at) as d, COUNT(*)
FROM findings
WHERE detected_at >= date('now', '-7 days')
GROUP BY d
ORDER BY d ASC
`)
if err == nil {
for tRows.Next() {
var t models.TrendPoint
tRows.Scan(&t.Date, &t.Count)
stats.RecentTrends = append(stats.RecentTrends, t)
}
tRows.Close()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(stats)
}
+18
View File
@@ -45,3 +45,21 @@ type NucleiFinding struct {
MatchedAt string `json:"matched-at"`
Timestamp string `json:"timestamp"`
}
type TrendPoint struct {
Date string `json:"date"`
Count int `json:"count"`
}
type HostCount struct {
Host string `json:"host"`
Count int `json:"count"`
}
type DashboardStats struct {
TotalTargets int `json:"total_targets"`
TotalScans int `json:"total_scans"`
Severity map[string]int `json:"severity"`
TopHosts []HostCount `json:"top_hosts"`
RecentTrends []TrendPoint `json:"recent_trends"`
}