feat: dashboard stats

This commit is contained in:
2026-05-23 19:24:01 +00:00
parent a8bcb9bd1c
commit e16a667d60
6 changed files with 349 additions and 46 deletions
+49 -10
View File
@@ -2718,18 +2718,57 @@ def api_roles():
def api_dashboard():
with get_db_connection(current_app) as conn:
cursor = conn.cursor(dictionary=True)
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY site, name')
subnets = cursor.fetchall()
utils = get_all_subnet_utilizations(cursor)
sites = {}
for s in subnets:
site = s['site'] or 'Unassigned'
util = utils.get(s['id'], {'percent': 0})
sites.setdefault(site, []).append({
'id': s['id'], 'name': s['name'], 'cidr': s['cidr'],
'vlan_id': s['vlan_id'], 'utilization': util['percent'],
cursor.execute('SELECT COUNT(*) AS n FROM Device')
device_count = cursor.fetchone()['n']
cursor.execute('SELECT COUNT(*) AS n FROM Subnet')
subnet_count = cursor.fetchone()['n']
total_ips = sum(u['total'] for u in utils.values())
used_ips = sum(u['used'] for u in utils.values())
available_ips = max(total_ips - used_ips, 0)
utilization_percent = round((used_ips / total_ips * 100) if total_ips > 0 else 0, 1)
alerting_subnets = sum(1 for u in utils.values() if u['percent'] >= 90)
cursor.execute('SELECT id, name, cidr, site, vlan_id FROM Subnet ORDER BY name')
subnet_overview = []
for s in cursor.fetchall():
u = utils.get(s['id'], {'total': 0, 'used': 0, 'percent': 0})
pct = u['percent']
subnet_overview.append({
'id': s['id'],
'name': s['name'],
'cidr': s['cidr'],
'site': s['site'] or 'Unassigned',
'vlan_id': s['vlan_id'],
'utilization': pct,
'available': u['total'] - u['used'],
'status': 'alerting' if pct >= 90 else 'active',
})
return jsonify({'sites': sites})
cursor.execute('''
SELECT HOUR(timestamp) AS hour, COUNT(*) AS count
FROM AuditLog
WHERE timestamp >= DATE_SUB(NOW(), INTERVAL 24 HOUR)
GROUP BY HOUR(timestamp)
ORDER BY hour
''')
activity_by_hour = {row['hour']: row['count'] for row in cursor.fetchall()}
activity = [{'hour': h, 'count': activity_by_hour.get(h, 0)} for h in range(24)]
return jsonify({
'stats': {
'total_ips': total_ips,
'used_ips': used_ips,
'available_ips': available_ips,
'utilization_percent': utilization_percent,
'subnet_count': subnet_count,
'alerting_subnets': alerting_subnets,
'device_count': device_count,
},
'subnet_overview': subnet_overview,
'activity': activity,
})
@app.route('/api/v2/search', methods=['GET'])
+22 -1
View File
@@ -178,7 +178,28 @@ export const api = {
return handle(await fetchApi("/api/v2/auth/logout", { method: "POST" }));
},
async dashboard() {
return handle<{ sites: Record<string, Subnet[]> }>(await fetchApi("/api/v2/dashboard"));
return handle<{
stats: {
total_ips: number;
used_ips: number;
available_ips: number;
utilization_percent: number;
subnet_count: number;
alerting_subnets: number;
device_count: number;
};
subnet_overview: {
id: number;
name: string;
cidr: string;
site: string;
vlan_id?: number;
utilization: number;
available: number;
status: "active" | "alerting";
}[];
activity: { hour: number; count: number }[];
}>(await fetchApi("/api/v2/dashboard"));
},
async search(q: string) {
return handle<Record<string, unknown[]>>(await fetchApi(`/api/v2/search?q=${encodeURIComponent(q)}`));
+3 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from "vue";
import { RouterLink, RouterView, useRoute, useRouter } from "vue-router";
import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User } from "lucide-vue-next";
import { Menu, Search, X, Home, Server, Grid3x3, Settings, Users, Tag, Layers, FileText, User, Network } from "lucide-vue-next";
import { useAuthStore } from "@/stores/auth";
import { api } from "@/api";
@@ -18,6 +18,7 @@ const searchLoading = ref(false);
const nav = computed(() =>
[
{ to: "/", label: "Home", icon: Home, perm: "view_index" },
{ to: "/subnets", label: "Subnets", icon: Network, perm: "view_subnet", match: (path: string) => path === "/subnets" || /^\/subnets\/\d+/.test(path) },
{ to: "/devices", label: "Devices", icon: Server, perm: "view_devices" },
{ to: "/racks", label: "Racks", icon: Grid3x3, perm: "view_racks" },
{ to: "/tags", label: "Tags", icon: Tag, perm: "view_tags" },
@@ -112,7 +113,7 @@ onUnmounted(() => {
:key="item.to"
:to="item.to"
class="mb-0.5 flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition"
:class="route.path === item.to || route.path.startsWith(item.to + '/')
:class="(item.match ? item.match(route.path) : route.path === item.to || route.path.startsWith(item.to + '/'))
? 'bg-accent/15 text-accent font-medium'
: 'text-slate-600 hover:bg-surface-overlay dark:text-slate-400'"
@click="sidebarOpen = false"
+1
View File
@@ -12,6 +12,7 @@ const router = createRouter({
component: () => import("@/components/AppLayout.vue"),
children: [
{ path: "", name: "dashboard", component: () => import("@/views/DashboardView.vue") },
{ path: "subnets", name: "subnets", component: () => import("@/views/SubnetsBrowseView.vue") },
{ path: "devices", name: "devices", component: () => import("@/views/DevicesView.vue") },
{ path: "devices/:id", name: "device", component: () => import("@/views/DeviceDetailView.vue") },
{ path: "subnets/:id", name: "subnet", component: () => import("@/views/SubnetDetailView.vue") },
+196 -33
View File
@@ -1,52 +1,215 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
import { Network, Wifi, Layers, Server } from "lucide-vue-next";
import { api } from "@/api";
interface DashboardStats {
total_ips: number;
used_ips: number;
available_ips: number;
utilization_percent: number;
subnet_count: number;
alerting_subnets: number;
device_count: number;
}
interface SubnetOverviewRow {
id: number;
name: string;
cidr: string;
site: string;
vlan_id?: number;
utilization: number;
available: number;
status: "active" | "alerting";
}
interface ActivityPoint {
hour: number;
count: number;
}
const sites = ref<Record<string, Subnet[]>>({});
const loading = ref(true);
const error = ref("");
const stats = ref<DashboardStats | null>(null);
const subnetOverview = ref<SubnetOverviewRow[]>([]);
const activity = ref<ActivityPoint[]>([]);
const donutStyle = computed(() => {
const pct = stats.value?.utilization_percent ?? 0;
return { background: `conic-gradient(rgb(6 182 212) ${pct}%, rgb(var(--surface-overlay)) ${pct}%)` };
});
const maxActivity = computed(() => Math.max(1, ...activity.value.map((a) => a.count)));
onMounted(async () => {
try {
const d = await api.dashboard();
sites.value = d.sites;
stats.value = d.stats;
subnetOverview.value = d.subnet_overview;
activity.value = d.activity;
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load dashboard";
} finally {
loading.value = false;
}
});
function formatHour(h: number) {
if (h === 0) return "12 AM";
if (h === 12) return "12 PM";
return h < 12 ? `${h} AM` : `${h - 12} PM`;
}
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Dashboard</h1>
<p class="mt-1 text-slate-500">Subnets grouped by site</p>
<div v-if="loading" class="mt-8 text-slate-500">Loading</div>
<div v-else class="mt-6 space-y-8">
<section v-for="(subnets, site) in sites" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in subnets"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div class="h-full rounded-full bg-accent transition-all" :style="{ width: `${s.utilization ?? 0}%` }" />
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
<p class="mt-1 text-slate-500">Network overview</p>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<template v-else-if="stats">
<div class="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-accent/15 p-3 text-accent"><Network class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Total IPv4 addresses</div>
<div class="mt-1 text-2xl font-bold text-accent">{{ stats.total_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ stats.utilization_percent }}% utilized</div>
</div>
</div>
</section>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Wifi class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Available IPs</div>
<div class="mt-1 text-2xl font-bold">{{ stats.available_ips.toLocaleString() }}</div>
<div class="text-sm text-slate-500">{{ 100 - stats.utilization_percent }}% free</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Layers class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Subnets</div>
<div class="mt-1 text-2xl font-bold">{{ stats.subnet_count }}</div>
<div class="text-sm text-slate-500">
Total
<span v-if="stats.alerting_subnets" class="text-red-500">/ {{ stats.alerting_subnets }} alerting</span>
</div>
</div>
</div>
<div class="card flex items-start gap-4">
<div class="rounded-lg bg-surface-overlay p-3 text-slate-500"><Server class="h-6 w-6" /></div>
<div>
<div class="text-xs font-medium uppercase tracking-wide text-slate-500">Devices</div>
<div class="mt-1 text-2xl font-bold">{{ stats.device_count.toLocaleString() }}</div>
<div class="text-sm text-slate-500">Managed</div>
</div>
</div>
</div>
<div class="mt-6 grid gap-4 lg:grid-cols-2">
<div class="card">
<h2 class="font-semibold">IPv4 usage distribution</h2>
<div class="mt-6 flex flex-col items-center gap-6 sm:flex-row sm:justify-center">
<div class="relative h-44 w-44 shrink-0 rounded-full" :style="donutStyle">
<div class="absolute inset-5 flex flex-col items-center justify-center rounded-full bg-surface-raised text-center">
<span class="text-2xl font-bold">{{ stats.total_ips.toLocaleString() }}</span>
<span class="text-xs uppercase tracking-wide text-slate-500">Total</span>
</div>
</div>
<div class="space-y-3 text-sm">
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-accent" />
<span>{{ stats.utilization_percent }}% Used ({{ stats.used_ips.toLocaleString() }})</span>
</div>
<div class="flex items-center gap-2">
<span class="h-3 w-3 rounded-full bg-surface-overlay ring-1 ring-slate-300 dark:ring-slate-600" />
<span>{{ 100 - stats.utilization_percent }}% Free ({{ stats.available_ips.toLocaleString() }})</span>
</div>
</div>
</div>
</div>
<div class="card">
<h2 class="font-semibold">Activity last 24 hours</h2>
<p class="mt-1 text-xs text-slate-500">Audit log entries by hour</p>
<div class="mt-4 flex h-40 items-end gap-0.5">
<div
v-for="point in activity"
:key="point.hour"
class="flex-1 rounded-t bg-accent/80 transition-all hover:bg-accent"
:style="{ height: `${Math.max(4, (point.count / maxActivity) * 100)}%` }"
:title="`${formatHour(point.hour)}: ${point.count}`"
/>
</div>
<div class="mt-2 flex justify-between text-[10px] text-slate-500">
<span>12 AM</span>
<span>6 AM</span>
<span>12 PM</span>
<span>6 PM</span>
</div>
</div>
</div>
<div class="card mt-6 overflow-x-auto">
<div class="mb-4 flex items-center justify-between gap-3">
<h2 class="font-semibold">Subnet overview</h2>
<RouterLink to="/subnets" class="text-sm text-accent hover:underline">View all subnets</RouterLink>
</div>
<table class="w-full min-w-[640px] text-left text-sm">
<thead>
<tr class="border-b border-slate-200 text-xs font-medium uppercase tracking-wide text-slate-500 dark:border-slate-700">
<th class="p-2">Subnet</th>
<th class="p-2">Name</th>
<th class="p-2">Utilized</th>
<th class="p-2">Available</th>
<th class="p-2">Site</th>
<th class="p-2">Status</th>
</tr>
</thead>
<tbody>
<tr
v-for="s in subnetOverview"
:key="s.id"
class="border-b border-slate-100 dark:border-slate-800"
:class="s.status === 'alerting' ? 'bg-red-500/5' : ''"
>
<td class="p-2">
<RouterLink :to="`/subnets/${s.id}`" class="font-mono text-accent hover:underline">{{ s.cidr }}</RouterLink>
</td>
<td class="p-2">{{ s.name }}</td>
<td class="p-2">
<div class="flex items-center gap-2">
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full"
:class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization}%` }"
/>
</div>
<span>{{ s.utilization }}%</span>
</div>
</td>
<td class="p-2">{{ s.available }}</td>
<td class="p-2">{{ s.site }}</td>
<td class="p-2">
<span
class="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium"
:class="s.status === 'alerting' ? 'bg-red-500/15 text-red-500' : 'bg-accent/15 text-accent'"
>
<span class="h-1.5 w-1.5 rounded-full" :class="s.status === 'alerting' ? 'bg-red-500' : 'bg-accent'" />
{{ s.status === 'alerting' ? 'Alerting' : 'Active' }}
</span>
</td>
</tr>
<tr v-if="!subnetOverview.length">
<td colspan="6" class="p-4 text-center text-slate-500">No subnets configured.</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
</template>
+78
View File
@@ -0,0 +1,78 @@
<script setup lang="ts">
import { ref, onMounted, computed } from "vue";
import { RouterLink } from "vue-router";
import { api, type Subnet } from "@/api";
const subnets = ref<Subnet[]>([]);
const loading = ref(true);
const error = ref("");
const bySite = computed(() => {
const m: Record<string, Subnet[]> = {};
for (const s of subnets.value) {
const site = s.site || "Unassigned";
if (!m[site]) m[site] = [];
m[site].push(s);
}
return m;
});
const siteOrder = computed(() =>
Object.keys(bySite.value).sort((a, b) => {
if (a === "Unassigned") return -1;
if (b === "Unassigned") return 1;
return a.localeCompare(b);
}),
);
onMounted(async () => {
try {
subnets.value = await api.subnets(true);
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load subnets";
} finally {
loading.value = false;
}
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold">Subnets</h1>
<p class="mt-1 text-slate-500">Browse subnets grouped by site</p>
<p v-if="loading" class="mt-8 text-slate-500">Loading</p>
<p v-else-if="error" class="mt-8 text-red-500">{{ error }}</p>
<p v-else-if="!subnets.length" class="mt-8 text-slate-500">No subnets yet.</p>
<div v-else class="mt-6 space-y-8">
<section v-for="site in siteOrder" :key="site">
<h2 class="mb-3 text-lg font-semibold text-accent">{{ site }}</h2>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<RouterLink
v-for="s in bySite[site]"
:key="s.id"
:to="`/subnets/${s.id}`"
class="card block transition hover:border-accent/50"
>
<div class="font-medium">{{ s.name }}</div>
<div class="mt-1 flex flex-wrap items-center gap-2">
<span class="font-mono text-sm text-slate-500">{{ s.cidr }}</span>
<span
v-if="s.vlan_id"
class="rounded-full bg-surface-overlay px-2 py-0.5 text-xs font-semibold text-slate-600 dark:text-slate-300"
>VLAN {{ s.vlan_id }}</span>
</div>
<div class="mt-3">
<div class="h-2 overflow-hidden rounded-full bg-surface-overlay">
<div
class="h-full rounded-full transition-all"
:class="(s.utilization ?? 0) >= 90 ? 'bg-red-500' : 'bg-accent'"
:style="{ width: `${s.utilization ?? 0}%` }"
/>
</div>
<div class="mt-1 text-xs text-slate-500">{{ s.utilization ?? 0 }}% used</div>
</div>
</RouterLink>
</div>
</section>
</div>
</div>
</template>