feat: rich dashboard with severity bars, heatmap, most flagged, live poll

This commit is contained in:
Marker689
2026-05-09 05:35:36 +03:00
parent d776d037e7
commit e83167a938
3 changed files with 181 additions and 25 deletions

View File

@@ -4,7 +4,7 @@ import datetime
from fastapi import APIRouter, Depends, Request from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from sqlalchemy import func, select from sqlalchemy import Integer, cast, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from guarddog_nexus.database import get_session from guarddog_nexus.database import get_session
@@ -27,6 +27,17 @@ def _render(name: str, **context) -> HTMLResponse:
@router.get("/", response_class=HTMLResponse) @router.get("/", response_class=HTMLResponse)
async def dashboard(request: Request, session: AsyncSession = Depends(get_session)): async def dashboard(request: Request, session: AsyncSession = Depends(get_session)):
ctx = await _dashboard_data(session)
return _render("dashboard.html", **ctx, request=request)
@router.get("/dashboard/stats", response_class=HTMLResponse)
async def dashboard_stats_fragment(session: AsyncSession = Depends(get_session)):
ctx = await _dashboard_data(session)
return _render("dashboard_stats.html", **ctx)
async def _dashboard_data(session: AsyncSession) -> dict:
total_scans = await session.scalar(select(func.count(Scan.id))) total_scans = await session.scalar(select(func.count(Scan.id)))
flagged_scans = await session.scalar(select(func.count(Scan.id)).where(Scan.flagged == True)) flagged_scans = await session.scalar(select(func.count(Scan.id)).where(Scan.flagged == True))
recent_flagged = await session.scalar( recent_flagged = await session.scalar(
@@ -35,7 +46,29 @@ async def dashboard(request: Request, session: AsyncSession = Depends(get_sessio
Scan.started_at >= func.datetime("now", "-7 days"), Scan.started_at >= func.datetime("now", "-7 days"),
) )
) )
completed_scans = await session.scalar(
select(func.count(Scan.id)).where(Scan.status == "completed")
)
failed_scans = await session.scalar(select(func.count(Scan.id)).where(Scan.status == "failed"))
total_findings = await session.scalar(select(func.count(Finding.id))) total_findings = await session.scalar(select(func.count(Finding.id)))
warnings_count = await session.scalar(
select(func.count(Finding.id)).where(Finding.severity == "WARNING")
)
errors_count = await session.scalar(
select(func.count(Finding.id)).where(Finding.severity == "ERROR")
)
latest_flagged = (
(
await session.execute(
select(Scan).where(Scan.flagged == True).order_by(Scan.started_at.desc()).limit(8)
)
)
.scalars()
.all()
)
latest_scans = ( latest_scans = (
(await session.execute(select(Scan).order_by(Scan.started_at.desc()).limit(10))) (await session.execute(select(Scan).order_by(Scan.started_at.desc()).limit(10)))
.scalars() .scalars()
@@ -51,17 +84,54 @@ async def dashboard(request: Request, session: AsyncSession = Depends(get_sessio
) )
).all() ).all()
return _render( most_flagged = (
"dashboard.html", await session.execute(
total_scans=total_scans, select(
flagged_scans=flagged_scans, Scan.package_name,
recent_flagged=recent_flagged, Scan.package_version,
total_findings=total_findings, func.sum(Scan.total_findings).label("total"),
latest_scans=latest_scans, func.max(Scan.started_at).label("last_scan"),
top_rules=[(r.rule, r.cnt) for r in top_rules], )
now=datetime.datetime.now(datetime.timezone.utc), .where(Scan.flagged == True)
request=request, .group_by(Scan.package_name, Scan.package_version)
) .order_by(func.sum(Scan.total_findings).desc())
.limit(8)
)
).all()
max_findings = max((r.total for r in most_flagged), default=1)
# Heatmap: scans per day for last 14 days
days_raw = (
await session.execute(
select(
func.date(Scan.started_at).label("day"),
func.count(Scan.id).label("cnt"),
func.sum(cast(Scan.flagged, Integer)).label("flagged_cnt"),
)
.where(Scan.started_at >= func.datetime("now", "-14 days"))
.group_by("day")
.order_by("day")
)
).all()
return {
"total_scans": total_scans or 0,
"flagged_scans": flagged_scans or 0,
"recent_flagged": recent_flagged or 0,
"completed_scans": completed_scans or 0,
"failed_scans": failed_scans or 0,
"total_findings": total_findings or 0,
"warnings_count": warnings_count or 0,
"errors_count": errors_count or 0,
"latest_flagged": latest_flagged,
"latest_scans": latest_scans,
"top_rules": [(r.rule, r.cnt) for r in top_rules],
"most_flagged": most_flagged,
"max_findings": max_findings,
"days": [(d.day, d.cnt, d.flagged_cnt) for d in days_raw],
"now": datetime.datetime.now(datetime.timezone.utc),
}
@router.get("/scans", response_class=HTMLResponse) @router.get("/scans", response_class=HTMLResponse)

View File

@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<h1>Dashboard</h1> <h1>Dashboard</h1>
<div hx-get="/api/v1/scans/stats" hx-trigger="every 30s" hx-swap="innerHTML"> <div hx-get="/dashboard/stats" hx-trigger="every 30s" hx-swap="innerHTML">
{% include "dashboard_stats.html" %} {% include "dashboard_stats.html" %}
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -1,31 +1,114 @@
<div class="stats-grid"> <div class="stats-grid">
<article class="stat-card"> <article class="stat-card">
<h5>{{ total_scans }}</h5> <h3>{{ total_scans }}</h3>
<small>Total Scans</small> <small>Total Scans</small>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<h5 class="flagged">{{ flagged_scans }}</h5> <h3 class="flagged">{{ flagged_scans }}</h3>
<small>Flagged</small> <small>Flagged</small>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<h5 class="flagged">{{ recent_flagged }}</h5> <h3>{{ recent_flagged }}</h3>
<small>Flagged (7 days)</small> <small>Flagged (7 days)</small>
</article> </article>
<article class="stat-card"> <article class="stat-card">
<h5>{{ total_findings }}</h5> <h3>{{ total_findings }}</h3>
<small>Total Findings</small> <small>Total Findings</small>
</article> </article>
<article class="stat-card">
<h3 class="severity-ERROR">{{ errors_count }}</h3>
<small>Errors</small>
</article>
<article class="stat-card">
<h3 class="severity-WARNING">{{ warnings_count }}</h3>
<small>Warnings</small>
</article>
</div> </div>
<h2>Latest Scans</h2> {% if total_findings > 0 %}
<div style="margin-bottom: 2rem;">
<small>Severity ratio</small>
<div style="display: flex; height: 8px; border-radius: 4px; overflow: hidden; margin-top: 4px;">
{% set err_pct = (errors_count / total_findings * 100) | int %}
{% set warn_pct = 100 - err_pct %}
<div style="width: {{ err_pct }}%; background: var(--pico-color-red-400);" title="ERROR: {{ errors_count }}"></div>
<div style="width: {{ warn_pct }}%; background: var(--pico-color-yellow-400);" title="WARNING: {{ warnings_count }}"></div>
</div>
<div style="display: flex; justify-content: space-between; font-size: 0.75rem; margin-top: 2px;">
<span class="severity-ERROR">ERROR {{ errors_count }}</span>
<span class="severity-WARNING">WARNING {{ warnings_count }}</span>
</div>
</div>
{% endif %}
{% if days %}
<div style="margin-bottom: 2rem;">
<small>Scan activity (14 days)</small>
<div style="display: flex; align-items: flex-end; gap: 2px; height: 40px; margin-top: 4px;">
{% set max_cnt = days | map(attribute=1) | max %}
{% for day, cnt, fl in days %}
<div style="flex: 1; display: flex; flex-direction: column; justify-content: flex-end;" title="{{ day }}: {{ cnt }} scans, {{ fl }} flagged">
{% set h = (cnt / max_cnt * 38) | int if max_cnt > 0 else 0 %}
<div style="height: {{ h }}px; background: {% if fl > 0 %}var(--pico-color-red-500){% else %}var(--pico-color-zinc-500){% endif %}; border-radius: 2px 2px 0 0; opacity: 0.8;"></div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if most_flagged %}
<div style="margin-bottom: 2rem;">
<h3>⚠ Most Flagged Packages</h3>
<table>
<thead>
<tr><th>Package</th><th>Version</th><th>Findings</th></tr>
</thead>
<tbody>
{% for p in most_flagged %}
<tr>
<td><a href="/packages/{{ p.package_name }}/{{ p.package_version }}"><strong>{{ p.package_name }}</strong></a></td>
<td>{{ p.package_version }}</td>
<td>
<span class="flagged">{{ p.total }}</span>
<progress value="{{ p.total }}" max="{{ max_findings }}" style="width: 80px; height: 6px; vertical-align: middle; margin-left: 6px;"></progress>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% if latest_flagged %}
<div style="margin-bottom: 2rem;">
<h3>🔴 Latest Flagged</h3>
<table>
<thead>
<tr><th>Package</th><th>Version</th><th>Findings</th><th>Time</th></tr>
</thead>
<tbody>
{% for s in latest_flagged %}
<tr>
<td><a href="/scans/{{ s.id }}"><strong class="flagged">{{ s.package_name }}</strong></a></td>
<td>{{ s.package_version }}</td>
<td><span class="flagged">{{ s.total_findings }}</span></td>
<td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<h3>Latest Scans</h3>
<table> <table>
<thead> <thead>
<tr> <tr>
<th>Package</th> <th>Package</th>
<th>Version</th> <th>Version</th>
<th>Ecosystem</th> <th>Repo</th>
<th>Status</th> <th>Status</th>
<th>Findings</th> <th></th>
<th>Time</th> <th>Time</th>
</tr> </tr>
</thead> </thead>
@@ -34,17 +117,18 @@
<tr> <tr>
<td><a href="/packages/{{ s.package_name }}/{{ s.package_version }}">{{ s.package_name }}</a></td> <td><a href="/packages/{{ s.package_name }}/{{ s.package_version }}">{{ s.package_name }}</a></td>
<td>{{ s.package_version }}</td> <td>{{ s.package_version }}</td>
<td>{{ s.ecosystem }}</td> <td><small>{{ s.repository }}</small></td>
<td><span class="status-{{ s.status }}">{{ s.status }}</span></td> <td><span class="status-{{ s.status }}">{{ s.status }}</span></td>
<td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% else %}<span class="clean">{{ s.total_findings }}</span>{% endif %}</td> <td>{% if s.flagged %}<span class="flagged">{{ s.total_findings }}</span>{% elif s.status == 'completed' %}<span class="clean"></span>{% else %}<span>-</span>{% endif %}</td>
<td>{{ s.started_at.strftime('%Y-%m-%d %H:%M') if s.started_at }}</td> <td>{{ s.started_at.strftime('%m-%d %H:%M') if s.started_at }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<small><a href="/scans">View all scans →</a></small>
{% if top_rules %} {% if top_rules %}
<h2>Top Rules Triggered</h2> <h3>Top Rules Triggered</h3>
<table> <table>
<thead><tr><th>Rule</th><th>Count</th></tr></thead> <thead><tr><th>Rule</th><th>Count</th></tr></thead>
<tbody> <tbody>
@@ -54,3 +138,5 @@
</tbody> </tbody>
</table> </table>
{% endif %} {% endif %}
<small style="opacity: 0.5;">Last refresh: {{ now.strftime('%H:%M:%S') }} (auto every 30s)</small>