diff --git a/guarddog_nexus/api/scans.py b/guarddog_nexus/api/scans.py
index a2816d2..a6b0f0d 100644
--- a/guarddog_nexus/api/scans.py
+++ b/guarddog_nexus/api/scans.py
@@ -36,18 +36,22 @@ async def list_scans(
session: AsyncSession = Depends(get_session),
):
q = select(Scan)
+ count_q = select(func.count(Scan.id))
if flagged is not None:
q = q.where(Scan.flagged == flagged)
+ count_q = count_q.where(Scan.flagged == flagged)
if status:
q = q.where(Scan.status == status)
+ count_q = count_q.where(Scan.status == status)
if repository:
q = q.where(Scan.repository == repository)
+ count_q = count_q.where(Scan.repository == repository)
if search:
pattern = f"%{search}%"
- q = q.where(
- Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
- )
+ condition = Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
+ q = q.where(condition)
+ count_q = count_q.where(condition)
sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at)
sort_dir = "asc" if sort_dir.lower() == "asc" else "desc"
@@ -55,7 +59,7 @@ async def list_scans(
q = q.offset(offset).limit(limit)
- total = await session.scalar(select(func.count(Scan.id)))
+ total = await session.scalar(count_q)
scans = (await session.execute(q)).scalars().all()
return {
diff --git a/guarddog_nexus/config.py b/guarddog_nexus/config.py
index cb7bbe2..c7b6c73 100644
--- a/guarddog_nexus/config.py
+++ b/guarddog_nexus/config.py
@@ -1,7 +1,7 @@
"""Configuration via environment variables."""
import os
-from dataclasses import dataclass, field
+from dataclasses import dataclass
@dataclass
@@ -9,7 +9,6 @@ class Config:
nexus_url: str = os.getenv("NEXUS_URL", "http://localhost:8081")
nexus_username: str = os.getenv("NEXUS_USERNAME", "admin")
nexus_password: str = os.getenv("NEXUS_PASSWORD", "admin123")
- nexus_repositories: list[str] = field(default_factory=lambda: _parse_repos())
database_path: str = os.getenv("DATABASE_PATH", "data/guarddog.db")
@@ -26,9 +25,4 @@ class Config:
temp_dir: str = os.getenv("TEMP_DIR", "/tmp/guarddog-nexus")
-def _parse_repos() -> list[str]:
- raw = os.getenv("NEXUS_REPOSITORIES", "")
- return [r.strip() for r in raw.split(",") if r.strip()]
-
-
config = Config()
diff --git a/guarddog_nexus/main.py b/guarddog_nexus/main.py
index 7bbe78a..a818227 100644
--- a/guarddog_nexus/main.py
+++ b/guarddog_nexus/main.py
@@ -14,7 +14,7 @@ from guarddog_nexus.logging_setup import log
from guarddog_nexus.web.routes import router as web_router
from guarddog_nexus.webhooks import router as webhook_router
-STATIC_DIR = os.path.join(os.path.dirname(__file__), "static")
+STATIC_DIR = os.path.join(os.path.dirname(__file__), "web", "static")
@asynccontextmanager
diff --git a/guarddog_nexus/static/style.css b/guarddog_nexus/static/style.css
deleted file mode 100644
index 28213ba..0000000
--- a/guarddog_nexus/static/style.css
+++ /dev/null
@@ -1 +0,0 @@
-/* static/style.css - minimal overrides for Pico.css dark theme */
diff --git a/guarddog_nexus/web/routes.py b/guarddog_nexus/web/routes.py
index bf08770..91378cf 100644
--- a/guarddog_nexus/web/routes.py
+++ b/guarddog_nexus/web/routes.py
@@ -4,6 +4,7 @@ import datetime
from fastapi import APIRouter, Depends, Request
from fastapi.responses import HTMLResponse
+from jinja2 import Environment, PackageLoader, select_autoescape
from sqlalchemy import Integer, cast, func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
@@ -12,7 +13,12 @@ from guarddog_nexus.models import Finding, Scan
router = APIRouter(tags=["web"])
-VALID_SORT_FIELDS = {
+_jinja_env = Environment(
+ loader=PackageLoader("guarddog_nexus", "web/templates"),
+ autoescape=select_autoescape(),
+)
+
+SCAN_SORT_FIELDS = {
"id": Scan.id,
"package_name": Scan.package_name,
"started_at": Scan.started_at,
@@ -20,15 +26,16 @@ VALID_SORT_FIELDS = {
"total_findings": Scan.total_findings,
}
+PACKAGE_SORT_FIELDS = {
+ "name": Scan.package_name,
+ "last_scanned_at": Scan.started_at,
+ "total_findings": Scan.total_findings,
+ "flagged": Scan.flagged,
+}
+
def _render(name: str, **context) -> HTMLResponse:
- from jinja2 import Environment, PackageLoader, select_autoescape
-
- env = Environment(
- loader=PackageLoader("guarddog_nexus", "web/templates"),
- autoescape=select_autoescape(),
- )
- template = env.get_template(name)
+ template = _jinja_env.get_template(name)
return HTMLResponse(template.render(**context))
@@ -53,10 +60,6 @@ async def _dashboard_data(session: AsyncSession) -> dict:
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)))
warnings_count = await session.scalar(
@@ -115,7 +118,6 @@ async def _dashboard_data(session: AsyncSession) -> dict:
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(
@@ -133,8 +135,6 @@ async def _dashboard_data(session: AsyncSession) -> dict:
"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,
@@ -162,23 +162,27 @@ async def scans_list(
per_page = 50
offset = (page - 1) * per_page
+ count_q = select(func.count(Scan.id))
q = select(Scan)
+
if flagged == "1":
q = q.where(Scan.flagged == True)
+ count_q = count_q.where(Scan.flagged == True)
if status:
q = q.where(Scan.status == status)
+ count_q = count_q.where(Scan.status == status)
if search:
pattern = f"%{search}%"
- q = q.where(
- Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
- )
+ condition = Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
+ q = q.where(condition)
+ count_q = count_q.where(condition)
- sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at)
+ sort_field = SCAN_SORT_FIELDS.get(sort_by, Scan.started_at)
q = q.order_by(sort_field.desc() if sort_dir == "desc" else sort_field.asc())
q = q.offset(offset).limit(per_page)
scans = (await session.execute(q)).scalars().all()
- total = await session.scalar(select(func.count(Scan.id)))
+ total = await session.scalar(count_q)
return _render(
"scans_list.html",
@@ -240,17 +244,17 @@ async def packages_list(
Scan.package_name.ilike(pattern) | Scan.package_version.ilike(pattern)
)
- sort_field = VALID_SORT_FIELDS.get(sort_by, Scan.started_at)
+ sort_field = PACKAGE_SORT_FIELDS.get(sort_by, Scan.started_at)
sort_col = func.max(sort_field)
subq = subq.order_by(
sort_col.desc() if sort_dir == "desc" else sort_col.asc()
)
- subq = subq.subquery()
- total = await session.scalar(select(func.count()).select_from(subq))
+ sq = subq.subquery()
+ total = await session.scalar(select(func.count()).select_from(sq))
rows = (
await session.execute(
- select(subq).offset(offset).limit(per_page)
+ select(sq).offset(offset).limit(per_page)
)
).all()
diff --git a/guarddog_nexus/web/static/app.js b/guarddog_nexus/web/static/app.js
new file mode 100644
index 0000000..8cee215
--- /dev/null
+++ b/guarddog_nexus/web/static/app.js
@@ -0,0 +1,25 @@
+// GuardDog Nexus — shared UI utilities
+
+function toggleFindings() {
+ var container = document.getElementById('findings-container');
+ if (!container) return;
+ var details = container.querySelectorAll('details');
+ if (details.length === 0) return;
+ var isOpen = details[0].open;
+ details.forEach(function (d) { d.open = !isOpen; });
+ var btn = document.querySelector('.toggle-all-btn');
+ if (btn) btn.textContent = isOpen ? 'Expand All' : 'Collapse All';
+}
+
+function copyCode(btn, codeId) {
+ var el = document.getElementById(codeId);
+ if (!el) return;
+ navigator.clipboard.writeText(el.textContent).then(function () {
+ btn.textContent = 'Copied!';
+ btn.classList.add('copied');
+ setTimeout(function () {
+ btn.textContent = 'Copy';
+ btn.classList.remove('copied');
+ }, 2000);
+ });
+}
diff --git a/guarddog_nexus/web/static/style.css b/guarddog_nexus/web/static/style.css
index e0ee52a..900ed52 100644
--- a/guarddog_nexus/web/static/style.css
+++ b/guarddog_nexus/web/static/style.css
@@ -238,19 +238,9 @@ nav.sticky {
/* Empty states */
.empty-state {
text-align: center;
- padding: 3rem 1rem;
- opacity: 0.6;
-}
-
-.empty-state svg {
- width: 64px;
- height: 64px;
- margin-bottom: 1rem;
- opacity: 0.4;
-}
-
-.empty-state h3 {
- margin-bottom: 0.5rem;
+ padding: 2rem 1rem;
+ opacity: 0.5;
+ font-style: italic;
}
/* Filter bar */
@@ -352,7 +342,37 @@ th.sortable.active .sort-icon {
}
}
-/* Expand/Collapse all button */
+/* Finding header row */
+.finding-header-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.5rem;
+}
+
+/* Finding summary */
+.finding-summary {
+ cursor: pointer;
+ list-style: none;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.25rem 0;
+}
+
+/* Finding summary hint */
+.finding-summary-hint {
+ margin-left: auto;
+ font-size: 0.8rem;
+ opacity: 0.5;
+}
+
+/* Code block toolbar */
+.code-toolbar {
+ display: flex;
+ justify-content: flex-end;
+ margin-bottom: 0.25rem;
+}
.toggle-all-btn {
font-size: 0.8rem;
margin-bottom: 0.5rem;
diff --git a/guarddog_nexus/web/templates/base.html b/guarddog_nexus/web/templates/base.html
index 988d106..4171ffd 100644
--- a/guarddog_nexus/web/templates/base.html
+++ b/guarddog_nexus/web/templates/base.html
@@ -7,6 +7,7 @@
+
diff --git a/guarddog_nexus/web/templates/dashboard_stats.html b/guarddog_nexus/web/templates/dashboard_stats.html
index 99eaffa..31efe4a 100644
--- a/guarddog_nexus/web/templates/dashboard_stats.html
+++ b/guarddog_nexus/web/templates/dashboard_stats.html
@@ -40,11 +40,7 @@
{% else %}
-
-
-
No findings yet
-
Scan results will appear here once packages are processed.
-
+No findings yet — scan results will appear here once packages are processed.
{% endif %}
{% if days %}
diff --git a/guarddog_nexus/web/templates/package_detail.html b/guarddog_nexus/web/templates/package_detail.html
index 36f5cc5..2da0b63 100644
--- a/guarddog_nexus/web/templates/package_detail.html
+++ b/guarddog_nexus/web/templates/package_detail.html
@@ -32,7 +32,7 @@
-
+