fix: dashboard populates Updates-by-Type and gives honest severity bars
The stats handler initialized updates_by_type but never filled it, so the "Updates by Type" card always rendered empty (Codeberg #10). Add UpdateQueries.GetUpdatesByType (grouped by package_type, non-terminal scope) and wire it into GetDashboardStats. Severity bars were sized against total_updates (all statuses) while the severity counts are scoped to non-terminal rows — a scope mismatch. Bars now size against the sum of the scoped severity values, so they form a true breakdown that always sums to 100% and never overflows. Also closed the silent error-swallows in GetDashboardStats: each sub-count failure is now logged [ERROR] [server] [stats] instead of vanishing.
This commit is contained in:
parent
b8cdb91a21
commit
9be8aba073
3 changed files with 53 additions and 1 deletions
|
|
@ -1,6 +1,7 @@
|
|||
package handlers
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
|
|
@ -79,19 +80,34 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
|
|||
stats.ImportantUpdates = updateStats.ImportantUpdates
|
||||
stats.ModerateUpdates = updateStats.ModerateUpdates
|
||||
stats.LowUpdates = updateStats.LowUpdates
|
||||
} else {
|
||||
log.Printf("[ERROR] [server] [stats] all_update_stats_failed: %v", err)
|
||||
}
|
||||
|
||||
// Update counts grouped by package type — drives the "Updates by Type" card.
|
||||
if byType, err := h.updateQueries.GetUpdatesByType(); err == nil {
|
||||
stats.UpdatesByType = byType
|
||||
} else {
|
||||
log.Printf("[ERROR] [server] [stats] updates_by_type_failed: %v", err)
|
||||
}
|
||||
|
||||
// Remediation count — distinct advisories on available version.
|
||||
if n, err := h.updateQueries.GetAvailableFixCount(); err == nil {
|
||||
stats.AvailableFixCount = n
|
||||
} else {
|
||||
log.Printf("[ERROR] [server] [stats] available_fix_count_failed: %v", err)
|
||||
}
|
||||
// Threat count — distinct advisories on installed version.
|
||||
if n, err := h.updateQueries.GetOpenThreatCount(); err == nil {
|
||||
stats.OpenThreatCount = n
|
||||
} else {
|
||||
log.Printf("[ERROR] [server] [stats] open_threat_count_failed: %v", err)
|
||||
}
|
||||
if stats.OpenThreatCount > 0 {
|
||||
if top, err := h.updateQueries.GetTopOpenThreats(3); err == nil {
|
||||
stats.TopThreats = top
|
||||
} else {
|
||||
log.Printf("[ERROR] [server] [stats] top_open_threats_failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1316,6 +1316,36 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
|||
return stats, nil
|
||||
}
|
||||
|
||||
// typeCount is one row of GetUpdatesByType.
|
||||
type typeCount struct {
|
||||
PackageType string `db:"package_type"`
|
||||
Count int `db:"count"`
|
||||
}
|
||||
|
||||
// GetUpdatesByType returns a count of actionable updates grouped by package type.
|
||||
// Scoped to non-terminal statuses to match the severity breakdown: installed,
|
||||
// failed, and ignored packages are not actionable workload. The Dashboard's
|
||||
// "Updates by Type" card reads this — without it the card always renders empty
|
||||
// because the handler never populated the map. UI-DASHBOARD-AUDIT.
|
||||
func (q *UpdateQueries) GetUpdatesByType() (map[string]int, error) {
|
||||
var rows []typeCount
|
||||
query := `
|
||||
SELECT package_type, COUNT(*) as count
|
||||
FROM current_package_state
|
||||
WHERE status NOT IN ('installed', 'failed', 'ignored')
|
||||
GROUP BY package_type
|
||||
`
|
||||
if err := q.db.Select(&rows, query); err != nil {
|
||||
return nil, fmt.Errorf("failed to get updates by type: %w", err)
|
||||
}
|
||||
|
||||
out := make(map[string]int, len(rows))
|
||||
for _, r := range rows {
|
||||
out[r.PackageType] = r.Count
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetAvailableFixCount returns the number of distinct OSV advisory IDs present
|
||||
// in supply_chain_vulns across all non-terminal package rows. Deduplicates by
|
||||
// advisory ID so sub-packages sharing one advisory count as one, not many.
|
||||
|
|
|
|||
|
|
@ -61,6 +61,12 @@ const Dashboard: React.FC = () => {
|
|||
{ label: 'Low', value: stats?.low_updates ?? 0, color: 'bg-gray-600' },
|
||||
];
|
||||
|
||||
// Bars show each severity's share of the actionable (non-terminal) severity
|
||||
// counts the server returns. Sizing against this sum — not total_updates,
|
||||
// which also counts installed/failed — keeps numerator and denominator in the
|
||||
// same scope so bars always sum to 100% and never overflow. UI-DASHBOARD-AUDIT #2.
|
||||
const severityTotal = severityBreakdown.reduce((sum, s) => sum + s.value, 0);
|
||||
|
||||
const updateTypeBreakdown = Object.entries(stats?.updates_by_type ?? {}).map(([type, count]) => ({
|
||||
type: type.charAt(0).toUpperCase() + type.slice(1),
|
||||
value: count,
|
||||
|
|
@ -151,7 +157,7 @@ const Dashboard: React.FC = () => {
|
|||
<div
|
||||
className={`h-2 rounded-full ${severity.color}`}
|
||||
style={{
|
||||
width: `${(stats?.total_updates ?? 0) > 0 ? (severity.value / (stats?.total_updates ?? 1)) * 100 : 0}%`
|
||||
width: `${severityTotal > 0 ? (severity.value / severityTotal) * 100 : 0}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in a new issue