Watch
1
0
Fork
You've already forked RedFlag
0

v0.2.2.0: enforce package state machine across all transitions, vuln dashboard

Route every current_package_state status change through one transitionStatus
path: read the observed status, validate against PackageStatusTransitions,
run a status-guarded UPDATE, record terminal history. Replaces ten raw-SQL
transition functions whose WHERE guards validated nothing and silently
no-op'd on an illegal state. ApproveUpdate, the Reject/Install/Set* family,
BulkApprove and UpdatePackageStatus now share the core; illegal moves return
a named from->to error instead of a silent miss, and concurrent callers are
caught by the guarded row count.

Migration 047 renames the terminal success state updated -> installed in
current_package_state and update_version_history, realigning both CHECK
constraints with the Go PackageStatus/HistoryStatus constants.
UpdateStats updated_updates -> installed_updates to match.

UpdateCurrentStateInTx documents its reconcile CASE as the SQL twin of
models.ReconcileFromScan so the two stay in lockstep.

Dashboard: vulnerable-package count surfaced in AttentionPanel, plus a
Vulnerable quick-filter on the Updates view.
This commit is contained in:
Fimeg 2026-05-31 16:39:58 -04:00
commit 2a0c800659
16 changed files with 495 additions and 204 deletions

View file

@ -21,7 +21,7 @@ services:
context: .
dockerfile: ./server/Dockerfile
args:
BUILD_VERSION: ${BUILD_VERSION:-0.2.1.3}
BUILD_VERSION: ${BUILD_VERSION:-0.2.2.0}
container_name: redflag-server
volumes:
- server-config:/app/config

View file

@ -372,7 +372,7 @@ func (h *DockerHandler) RejectUpdate(c *gin.Context) {
return
}
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, "ignored", nil, nil); err != nil {
if err := h.updateQueries.UpdatePackageStatus(update.AgentID, "docker", update.PackageName, models.StatusIgnored, nil, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to reject Docker update"})
return
}

View file

@ -24,16 +24,17 @@ func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.
// DashboardStats represents dashboard statistics
type DashboardStats struct {
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
TotalAgents int `json:"total_agents"`
OnlineAgents int `json:"online_agents"`
OfflineAgents int `json:"offline_agents"`
PendingUpdates int `json:"pending_updates"`
FailedUpdates int `json:"failed_updates"`
VulnerablePackages int `json:"vulnerable_packages"`
CriticalUpdates int `json:"critical_updates"`
ImportantUpdates int `json:"high_updates"`
ModerateUpdates int `json:"medium_updates"`
LowUpdates int `json:"low_updates"`
UpdatesByType map[string]int `json:"updates_by_type"`
}
// GetDashboardStats returns dashboard statistics using aggregate queries (F-B1-6 fix)
@ -70,5 +71,11 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
stats.LowUpdates = updateStats.LowUpdates
}
// Vulnerable packages (OSV.dev findings on non-terminal updates)
vulnCount, err := h.updateQueries.GetVulnerablePackageCount()
if err == nil {
stats.VulnerablePackages = vulnCount
}
c.JSON(http.StatusOK, stats)
}

View file

@ -221,8 +221,15 @@ func enqueueOSVChecks(events []models.UpdateEvent, q *queries.UpdateQueries) {
// ListUpdates retrieves updates with filtering using the new state table
func (h *UpdateHandler) ListUpdates(c *gin.Context) {
// Parse and validate optional status filter
var statusFilter models.PackageStatus
if s := c.Query("status"); s != "" {
if parsed, err := models.StatusFromString(s); err == nil {
statusFilter = parsed
}
}
filters := &models.UpdateFilters{
Status: c.Query("status"),
Status: statusFilter,
Severity: c.Query("severity"),
PackageType: c.Query("package_type"),
}
@ -769,7 +776,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
}
// Update package status to 'updated' with actual completion timestamp
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "updated", nil, completionTime); err != nil {
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusInstalled, nil, completionTime); err != nil {
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
} else {
log.Printf("[INFO] [server] [updates] package_updated package=%s type=%s", packageName, packageType)
@ -788,7 +795,7 @@ func (h *UpdateHandler) ReportLog(c *gin.Context) {
if err == nil && command.CommandType == models.CommandTypeConfirmDependencies {
if packageName, ok := command.Params["package_name"].(string); ok {
if packageType, ok := command.Params["package_type"].(string); ok {
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, "failed", nil, nil); err != nil {
if err := h.updateQueries.UpdatePackageStatus(agentID, packageType, packageName, models.StatusFailed, nil, nil); err != nil {
log.Printf("Warning: Failed to update package status for %s/%s: %v", packageType, packageName, err)
} else {
log.Printf("[INFO] [server] [updates] package_failed package=%s type=%s", packageName, packageType)
@ -921,7 +928,7 @@ func (h *UpdateHandler) UpdatePackageStatus(c *gin.Context) {
var req struct {
PackageType string `json:"package_type" binding:"required"`
PackageName string `json:"package_name" binding:"required"`
Status string `json:"status" binding:"required"`
Status models.PackageStatus `json:"status" binding:"required"`
Metadata map[string]interface{} `json:"metadata"`
}
@ -1268,7 +1275,7 @@ func (h *UpdateHandler) ReportDependencies(c *gin.Context) {
if _, err := h.mintResolvedClosure(update); err != nil {
log.Printf("[SECURITY] [server] [capability] auto_mint_failed update_id=%s pkg=%s error=%v",
update.ID, update.PackageName, err)
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, "failed", models.JSONB{
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
"capability_authorization_error": err.Error(),
}, nil); statusErr != nil {
log.Printf("[ERROR] [server] [capability] auto_mint_failed_status_update_failed update_id=%s error=%v",
@ -1539,7 +1546,7 @@ func (h *UpdateHandler) ConfirmDependencies(c *gin.Context) {
if _, err := h.mintResolvedClosure(update); err != nil {
log.Printf("[SECURITY] [server] [capability] confirm_mint_failed update_id=%s pkg=%s error=%v",
update.ID, update.PackageName, err)
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, "failed", models.JSONB{
if statusErr := h.updateQueries.UpdatePackageStatus(update.AgentID, update.PackageType, update.PackageName, models.StatusFailed, models.JSONB{
"capability_authorization_error": err.Error(),
}, nil); statusErr != nil {
log.Printf("[ERROR] [server] [capability] confirm_mint_failed_status_update_failed update_id=%s error=%v",
@ -1981,9 +1988,9 @@ func (h *UpdateHandler) ReportCapabilityResult(c *gin.Context) {
log.Printf("[ERROR] [server] [capability] receipt_update_load_failed token_id=%s update_id=%s error=%v",
tokenID, updateID, err)
} else {
status := "failed"
status := models.StatusFailed
if body.Decision == "executed" && body.ExitCode == 0 {
status = "updated"
status = models.StatusInstalled
}
metadata := models.JSONB{
"capability_token_id": tokenID.String(),

View file

@ -0,0 +1,20 @@
-- Revert: rename 'installed' back to 'updated'.
UPDATE current_package_state SET status = 'updated' WHERE status = 'installed';
ALTER TABLE current_package_state
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
ALTER TABLE current_package_state
ADD CONSTRAINT current_package_state_status_check
CHECK (status IN ('pending', 'approved', 'checking_dependencies', 'pending_dependencies',
'installing', 'updated', 'failed', 'ignored'));
UPDATE update_version_history SET update_status = 'updated' WHERE update_status = 'installed';
ALTER TABLE update_version_history
DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
ALTER TABLE update_version_history
ADD CONSTRAINT update_version_history_update_status_check
CHECK (update_status IN ('updated', 'failed', 'rollback'));

View file

@ -0,0 +1,25 @@
-- LIFECYCLE-001: Rename terminal success state from 'updated' to 'installed'.
-- This matches the Go PackageStatus constant StatusInstalled and what the UI expects.
-- Step 1: Migrate existing rows.
UPDATE current_package_state SET status = 'installed' WHERE status = 'updated';
-- Step 2: Drop and recreate the CHECK constraint.
ALTER TABLE current_package_state
DROP CONSTRAINT IF EXISTS current_package_state_status_check;
ALTER TABLE current_package_state
ADD CONSTRAINT current_package_state_status_check
CHECK (status IN ('pending', 'approved', 'checking_dependencies', 'pending_dependencies',
'installing', 'installed', 'failed', 'ignored'));
-- Step 3: Migrate update_version_history rows.
UPDATE update_version_history SET update_status = 'installed' WHERE update_status = 'updated';
-- Step 4: Update the update_version_history constraint (added by migration 041).
ALTER TABLE update_version_history
DROP CONSTRAINT IF EXISTS update_version_history_update_status_check;
ALTER TABLE update_version_history
ADD CONSTRAINT update_version_history_update_status_check
CHECK (update_status IN ('installed', 'failed', 'rollback'));

View file

@ -312,36 +312,159 @@ func (q *UpdateQueries) GetPackageFleet(packageType, packageName string) ([]Pack
}
// ApproveUpdate marks an update as approved in the new event sourcing system
// --- State machine: the single transition path ---------------------------
//
// Every status change on current_package_state routes through transitionStatus.
// It reads the observed status, validates the move against models.PackageStatusTransitions,
// performs a status-guarded UPDATE (so a concurrent caller cannot double-consume the
// transition), and records version history on terminal moves. The raw-SQL WHERE guards
// that used to live in each function were a half-measure: no named error, no idempotent
// self-check, and silently opt-out for any future caller. This is the one path now.
// statusSelector identifies which current_package_state row to transition: either by
// primary key (id) or by the (agent_id, package_type, package_name) natural key.
type statusSelector struct {
byID bool
id uuid.UUID
agentID uuid.UUID
packageType string
packageName string
}
func selByID(id uuid.UUID) statusSelector { return statusSelector{byID: true, id: id} }
func selByPackage(agentID uuid.UUID, packageType, packageName string) statusSelector {
return statusSelector{agentID: agentID, packageType: packageType, packageName: packageName}
}
// where renders the row-matching predicate using positional placeholders starting at
// $start, returning the SQL fragment and its arguments in order.
func (s statusSelector) where(start int) (string, []interface{}) {
if s.byID {
return fmt.Sprintf("id = $%d", start), []interface{}{s.id}
}
return fmt.Sprintf("agent_id = $%d AND package_type = $%d AND package_name = $%d", start, start+1, start+2),
[]interface{}{s.agentID, s.packageType, s.packageName}
}
func (s statusSelector) String() string {
if s.byID {
return "id=" + s.id.String()
}
return s.packageType + "/" + s.packageName
}
// transitionOpts carries the per-call extras the gold path (UpdatePackageStatus) needs:
// a caller-supplied completion timestamp and the metadata to stamp into version history.
// Both are zero-valued for the routine transition functions.
type transitionOpts struct {
completedAt *time.Time
historyMeta models.JSONB // nil → fall back to the row's current metadata
}
// transitionStatus is the validated, race-guarded core. It loads the selected row,
// validates current -> to via the state machine, performs the guarded UPDATE, and
// records terminal history. Returns the pre-transition row so callers can attach
// follow-up writes (metadata, dependency closures) within the same transaction.
func (q *UpdateQueries) transitionStatus(tx *sqlx.Tx, sel statusSelector, to models.PackageStatus, opts transitionOpts) (models.UpdateState, error) {
var cur models.UpdateState
selWhere, selArgs := sel.where(1)
if err := tx.Get(&cur, `SELECT * FROM current_package_state WHERE `+selWhere, selArgs...); err != nil {
return cur, fmt.Errorf("transition (%s): load current state: %w", sel, err)
}
if err := models.ValidateTransition(cur.Status, to); err != nil {
return cur, fmt.Errorf("transition %s: %w", sel, err)
}
// Guarded UPDATE — $1 is the new status, the trailing placeholder re-checks the
// status we observed so a racing caller that already advanced the row yields rows == 0.
updWhere, updArgs := sel.where(2)
args := append([]interface{}{to}, updArgs...)
args = append(args, cur.Status)
updQuery := fmt.Sprintf(
`UPDATE current_package_state SET status = $1, last_updated_at = NOW() WHERE %s AND status = $%d`,
updWhere, len(args))
res, err := tx.Exec(updQuery, args...)
if err != nil {
return cur, fmt.Errorf("transition %s: update: %w", sel, err)
}
if n, _ := res.RowsAffected(); n == 0 {
return cur, fmt.Errorf("transition %s -> %q: row changed concurrently", sel, to)
}
// Record terminal history only on an actual change of status (idempotent re-entry
// at the same terminal state must not double-write history).
if (to == models.StatusInstalled || to == models.StatusFailed) && cur.Status != to {
completedAt := time.Now().UTC()
if opts.completedAt != nil {
completedAt = *opts.completedAt
}
meta := opts.historyMeta
if meta == nil {
meta = cur.Metadata
}
if err := q.recordTerminalHistory(tx, cur, to, completedAt, meta); err != nil {
return cur, err
}
}
return cur, nil
}
// recordTerminalHistory appends a row to update_version_history for a terminal transition.
func (q *UpdateQueries) recordTerminalHistory(tx *sqlx.Tx, cur models.UpdateState, to models.PackageStatus, completedAt time.Time, meta models.JSONB) error {
const historyQuery = `
INSERT INTO update_version_history (
agent_id, package_type, package_name, version_from, version_to,
severity, repository_source, metadata, update_completed_at, update_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`
_, err := tx.Exec(historyQuery,
cur.AgentID, cur.PackageType, cur.PackageName, cur.CurrentVersion,
cur.AvailableVersion, cur.Severity, cur.RepositorySource, meta, completedAt, to)
if err != nil {
return fmt.Errorf("record version history: %w", err)
}
return nil
}
// runTransition opens a transaction, applies a single transition, runs an optional
// follow-up write in the same transaction, and commits.
func (q *UpdateQueries) runTransition(sel statusSelector, to models.PackageStatus, after func(tx *sqlx.Tx, cur models.UpdateState) error) error {
tx, err := q.db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
cur, err := q.transitionStatus(tx, sel, to, transitionOpts{})
if err != nil {
return err
}
if after != nil {
if err := after(tx, cur); err != nil {
return err
}
}
return tx.Commit()
}
func (q *UpdateQueries) ApproveUpdate(id uuid.UUID, approvedBy string) error {
query := `
UPDATE current_package_state
SET status = 'approved', last_updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`
_, err := q.db.Exec(query, id)
return err
return q.runTransition(selByID(id), models.StatusApproved, nil)
}
// ApproveUpdateWithVulns approves an update and stores supply chain metadata.
func (q *UpdateQueries) ApproveUpdateWithVulns(id uuid.UUID, approvedBy string, metadata models.JSONB) error {
query := `
UPDATE current_package_state
SET status = 'approved', last_updated_at = NOW(), metadata = $2::jsonb
WHERE id = $1 AND status = 'pending'
`
_, err := q.db.Exec(query, id, metadata)
return err
return q.runTransition(selByID(id), models.StatusApproved, func(tx *sqlx.Tx, cur models.UpdateState) error {
if _, err := tx.Exec(`UPDATE current_package_state SET metadata = $2::jsonb WHERE id = $1`, id, metadata); err != nil {
return fmt.Errorf("store supply chain metadata: %w", err)
}
return nil
})
}
// ApproveUpdateByPackage approves an update by agent_id, package_type, and package_name
func (q *UpdateQueries) ApproveUpdateByPackage(agentID uuid.UUID, packageType, packageName, approvedBy string) error {
query := `
UPDATE current_package_state
SET status = 'approved', last_updated_at = NOW()
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status = 'pending'
`
_, err := q.db.Exec(query, agentID, packageType, packageName)
return err
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusApproved, nil)
}
// BulkApproveUpdates approves multiple updates by their IDs
@ -357,15 +480,8 @@ func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy str
}
defer tx.Rollback()
// Update each update
for _, id := range updateIDs {
query := `
UPDATE current_package_state
SET status = 'approved', last_updated_at = NOW()
WHERE id = $1 AND status = 'pending'
`
_, err := tx.Exec(query, id)
if err != nil {
if _, err := q.transitionStatus(tx, selByID(id), models.StatusApproved, transitionOpts{}); err != nil {
return fmt.Errorf("failed to approve update %s: %w", id, err)
}
}
@ -375,46 +491,22 @@ func (q *UpdateQueries) BulkApproveUpdates(updateIDs []uuid.UUID, approvedBy str
// RejectUpdate marks an update as rejected/ignored
func (q *UpdateQueries) RejectUpdate(id uuid.UUID, rejectedBy string) error {
query := `
UPDATE current_package_state
SET status = 'ignored', last_updated_at = NOW()
WHERE id = $1 AND status IN ('pending', 'approved')
`
_, err := q.db.Exec(query, id)
return err
return q.runTransition(selByID(id), models.StatusIgnored, nil)
}
// RejectUpdateByPackage rejects an update by agent_id, package_type, and package_name
func (q *UpdateQueries) RejectUpdateByPackage(agentID uuid.UUID, packageType, packageName, rejectedBy string) error {
query := `
UPDATE current_package_state
SET status = 'ignored', last_updated_at = NOW()
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3 AND status IN ('pending', 'approved')
`
_, err := q.db.Exec(query, agentID, packageType, packageName)
return err
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusIgnored, nil)
}
// InstallUpdate marks an update as ready for installation
func (q *UpdateQueries) InstallUpdate(id uuid.UUID) error {
query := `
UPDATE current_package_state
SET status = 'installing', last_updated_at = NOW()
WHERE id = $1 AND status IN ('approved', 'pending_dependencies')
`
_, err := q.db.Exec(query, id)
return err
return q.runTransition(selByID(id), models.StatusInstalling, nil)
}
// SetCheckingDependencies marks an update as being checked for dependencies
func (q *UpdateQueries) SetCheckingDependencies(id uuid.UUID) error {
query := `
UPDATE current_package_state
SET status = 'checking_dependencies', last_updated_at = NOW()
WHERE id = $1 AND status = 'approved'
`
_, err := q.db.Exec(query, id)
return err
return q.runTransition(selByID(id), models.StatusCheckingDependencies, nil)
}
// SetPendingDependencies stores dependency information and sets status based on whether dependencies exist
@ -431,20 +523,20 @@ func (q *UpdateQueries) SetPendingDependencies(agentID uuid.UUID, packageType, p
// Note: When dependencies array is empty, the handler should bypass this status change
// and proceed directly to installation. This function still records the empty array
// in metadata for audit purposes before the handler transitions to 'installing'.
query := `
UPDATE current_package_state
SET status = 'pending_dependencies',
metadata = jsonb_set(
jsonb_set(metadata, '{dependencies}', $4::jsonb),
'{dependencies_reported_at}',
to_jsonb(NOW())
),
last_updated_at = NOW()
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3
AND status IN ('checking_dependencies', 'installing')
`
_, err = q.db.Exec(query, agentID, packageType, packageName, depsJSON)
return err
return q.runTransition(selByPackage(agentID, packageType, packageName), models.StatusPendingDependencies,
func(tx *sqlx.Tx, cur models.UpdateState) error {
_, err := tx.Exec(`
UPDATE current_package_state
SET metadata = jsonb_set(
jsonb_set(metadata, '{dependencies}', $4::jsonb),
'{dependencies_reported_at}', to_jsonb(NOW()))
WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`,
agentID, packageType, packageName, depsJSON)
if err != nil {
return fmt.Errorf("store reported dependencies: %w", err)
}
return nil
})
}
// SetInstallingWithNoDependencies records zero dependencies and transitions directly to installing
@ -455,19 +547,18 @@ func (q *UpdateQueries) SetInstallingWithNoDependencies(id uuid.UUID, dependenci
return fmt.Errorf("failed to marshal dependencies: %w", err)
}
query := `
UPDATE current_package_state
SET status = 'installing',
metadata = jsonb_set(
jsonb_set(metadata, '{dependencies}', $2::jsonb),
'{dependencies_reported_at}',
to_jsonb(NOW())
),
last_updated_at = NOW()
WHERE id = $1 AND status = 'checking_dependencies'
`
_, err = q.db.Exec(query, id, depsJSON)
return err
return q.runTransition(selByID(id), models.StatusInstalling, func(tx *sqlx.Tx, cur models.UpdateState) error {
_, err := tx.Exec(`
UPDATE current_package_state
SET metadata = jsonb_set(
jsonb_set(metadata, '{dependencies}', $2::jsonb),
'{dependencies_reported_at}', to_jsonb(NOW()))
WHERE id = $1`, id, depsJSON)
if err != nil {
return fmt.Errorf("store dependency closure: %w", err)
}
return nil
})
}
// CreateUpdateLog inserts an update log entry
@ -605,8 +696,13 @@ func (q *UpdateQueries) UpdateCurrentStateInTx(tx *sqlx.Tx, event *models.Update
repository_source = EXCLUDED.repository_source,
metadata = EXCLUDED.metadata,
last_discovered_at = EXCLUDED.last_discovered_at,
-- Re-discovery reconciliation: this CASE is the SQL twin of
-- models.ReconcileFromScan terminal states (installed/ignored/failed)
-- are preserved, everything else resets to pending. It is an upsert that
-- may create the row, so it cannot route through transitionStatus; keep
-- the two in lockstep if the terminal set changes.
status = CASE
WHEN current_package_state.status IN ('updated', 'ignored')
WHEN current_package_state.status IN ('installed', 'ignored', 'failed')
THEN current_package_state.status
ELSE 'pending'
END
@ -656,14 +752,14 @@ func (q *UpdateQueries) ListUpdatesFromState(filters *models.UpdateFilters) ([]m
sd = sd.Where(goqu.Ex{"severity": filters.Severity})
}
if filters.Status != "" {
statuses := strings.Split(filters.Status, ",")
statuses := strings.Split(string(filters.Status), ",")
if len(statuses) == 1 {
sd = sd.Where(goqu.Ex{"status": statuses[0]})
} else {
sd = sd.Where(goqu.Ex{"status": statuses})
}
} else {
sd = sd.Where(goqu.C("status").NotIn("updated", "ignored"))
sd = sd.Where(goqu.C("status").NotIn("installed", "ignored"))
}
total, err := Paginated(q.db, sd, uint(filters.Page), uint(filters.PageSize),
@ -702,55 +798,20 @@ func (q *UpdateQueries) GetPackageHistory(agentID uuid.UUID, packageType, packag
return history, nil
}
// UpdatePackageStatus updates the status of a package and records history
// UpdatePackageStatus updates the status of a package and records history.
// Transition validation is performed via models.ValidateTransition; the SQL
// UPDATE is guarded on the current status to prevent races.
// completedAt is optional - if nil, uses time.Now().UTC(). Pass actual completion time for accurate audit trails.
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName, status string, metadata models.JSONB, completedAt *time.Time) error {
func (q *UpdateQueries) UpdatePackageStatus(agentID uuid.UUID, packageType, packageName string, status models.PackageStatus, metadata models.JSONB, completedAt *time.Time) error {
tx, err := q.db.Beginx()
if err != nil {
return fmt.Errorf("failed to begin transaction: %w", err)
}
defer tx.Rollback()
// Get current state
var currentState models.UpdateState
query := `SELECT * FROM current_package_state WHERE agent_id = $1 AND package_type = $2 AND package_name = $3`
err = tx.Get(&currentState, query, agentID, packageType, packageName)
if err != nil {
return fmt.Errorf("failed to get current state: %w", err)
}
// Use provided timestamp or fall back to server time
timestamp := time.Now().UTC()
if completedAt != nil {
timestamp = *completedAt
}
// Update status
updateQuery := `
UPDATE current_package_state
SET status = $1, last_updated_at = $2
WHERE agent_id = $3 AND package_type = $4 AND package_name = $5
`
_, err = tx.Exec(updateQuery, status, timestamp, agentID, packageType, packageName)
if err != nil {
return fmt.Errorf("failed to update package status: %w", err)
}
// Record in history if this is an update completion
if status == "updated" || status == "failed" {
historyQuery := `
INSERT INTO update_version_history (
agent_id, package_type, package_name, version_from, version_to,
severity, repository_source, metadata, update_completed_at, update_status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
`
_, err = tx.Exec(historyQuery,
agentID, packageType, packageName, currentState.CurrentVersion,
currentState.AvailableVersion, currentState.Severity,
currentState.RepositorySource, metadata, timestamp, status)
if err != nil {
return fmt.Errorf("failed to record version history: %w", err)
}
if _, err := q.transitionStatus(tx, selByPackage(agentID, packageType, packageName), status,
transitionOpts{completedAt: completedAt, historyMeta: metadata}); err != nil {
return err
}
return tx.Commit()
@ -798,7 +859,7 @@ func (q *UpdateQueries) GetUpdateStatsFromState(agentID uuid.UUID) (*models.Upda
SELECT
COUNT(*) as total_updates,
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
COUNT(*) FILTER (WHERE status = 'installed') as installed_updates,
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
COUNT(*) FILTER (WHERE severity = 'important') as important_updates,
@ -825,7 +886,7 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
COUNT(*) as total_updates,
COUNT(*) FILTER (WHERE status = 'pending') as pending_updates,
COUNT(*) FILTER (WHERE status = 'approved') as approved_updates,
COUNT(*) FILTER (WHERE status = 'updated') as updated_updates,
COUNT(*) FILTER (WHERE status = 'installed') as installed_updates,
COUNT(*) FILTER (WHERE status = 'failed') as failed_updates,
COUNT(*) FILTER (WHERE severity = 'critical') as critical_updates,
COUNT(*) FILTER (WHERE severity = 'important') as high_updates,
@ -842,6 +903,28 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
return stats, nil
}
// GetVulnerablePackageCount returns the number of distinct packages that have
// known CVEs from OSV.dev. A package is counted if its metadata contains a
// non-empty, non-null supply_chain_vulns field.
func (q *UpdateQueries) GetVulnerablePackageCount() (int, error) {
query := `
SELECT COUNT(*) FROM (
SELECT DISTINCT package_type, package_name
FROM current_package_state
WHERE metadata IS NOT NULL
AND metadata ? 'supply_chain_vulns'
AND metadata->>'supply_chain_vulns' != '[]'
AND metadata->>'supply_chain_vulns' != ''
AND status NOT IN ('installed', 'ignored', 'failed')
) vuln_packages
`
var count int
if err := q.db.Get(&count, query); err != nil {
return 0, fmt.Errorf("failed to get vulnerable package count: %w", err)
}
return count, nil
}
// GetUpdateLogs retrieves installation logs for a specific update
func (q *UpdateQueries) GetUpdateLogs(updateID uuid.UUID, limit int) ([]models.UpdateLog, error) {
var logs []models.UpdateLog

View file

@ -101,7 +101,7 @@ type InstallResult struct {
// UpdateFilters for querying updates
type UpdateFilters struct {
AgentID uuid.UUID
Status string
Status PackageStatus
Severity string
PackageType string
Page int
@ -127,19 +127,19 @@ type UpdateEvent struct {
// UpdateState represents the current state of a package (denormalized for queries)
type UpdateState struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status string `json:"status" db:"status"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
LastDiscoveredAt time.Time `json:"last_discovered_at" db:"last_discovered_at"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Status PackageStatus `json:"status" db:"status"`
ExpectedSHA256 *string `json:"expected_sha256" db:"expected_sha256"` // Layer 1: Hash Registry
}
// PackageVersion is one row of the version timeline catalog: a single
@ -163,19 +163,19 @@ type PackageVersion struct {
// UpdateHistory represents the version history of a package
type UpdateHistory struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
VersionFrom string `json:"version_from" db:"version_from"`
VersionTo string `json:"version_to" db:"version_to"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
UpdateInitiatedAt *time.Time `json:"update_initiated_at" db:"update_initiated_at"`
UpdateCompletedAt time.Time `json:"update_completed_at" db:"update_completed_at"`
UpdateStatus string `json:"update_status" db:"update_status"`
FailureReason string `json:"failure_reason" db:"failure_reason"`
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
VersionFrom string `json:"version_from" db:"version_from"`
VersionTo string `json:"version_to" db:"version_to"`
Severity string `json:"severity" db:"severity"`
RepositorySource string `json:"repository_source" db:"repository_source"`
Metadata JSONB `json:"metadata" db:"metadata"`
UpdateInitiatedAt *time.Time `json:"update_initiated_at" db:"update_initiated_at"`
UpdateCompletedAt time.Time `json:"update_completed_at" db:"update_completed_at"`
UpdateStatus HistoryStatus `json:"update_status" db:"update_status"`
FailureReason string `json:"failure_reason" db:"failure_reason"`
}
// UpdateBatch represents a batch of update events
@ -196,7 +196,7 @@ type UpdateStats struct {
TotalUpdates int `json:"total_updates" db:"total_updates"`
PendingUpdates int `json:"pending_updates" db:"pending_updates"`
ApprovedUpdates int `json:"approved_updates" db:"approved_updates"`
UpdatedUpdates int `json:"updated_updates" db:"updated_updates"`
InstalledUpdates int `json:"installed_updates" db:"installed_updates"`
FailedUpdates int `json:"failed_updates" db:"failed_updates"`
CriticalUpdates int `json:"critical_updates" db:"critical_updates"`
HighUpdates int `json:"high_updates" db:"high_updates"`
@ -217,14 +217,14 @@ type LogFilters struct {
// ActiveOperation represents a currently running operation
type ActiveOperation struct {
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
Status string `json:"status" db:"status"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Metadata JSONB `json:"metadata" db:"metadata"`
ID uuid.UUID `json:"id" db:"id"`
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
PackageType string `json:"package_type" db:"package_type"`
PackageName string `json:"package_name" db:"package_name"`
CurrentVersion string `json:"current_version" db:"current_version"`
AvailableVersion string `json:"available_version" db:"available_version"`
Severity string `json:"severity" db:"severity"`
Status PackageStatus `json:"status" db:"status"`
LastUpdatedAt time.Time `json:"last_updated_at" db:"last_updated_at"`
Metadata JSONB `json:"metadata" db:"metadata"`
}

View file

@ -0,0 +1,108 @@
package models
import (
"fmt"
)
// PackageStatus is the lifecycle status of a package update, backed by the
// current_package_state.status SQL column with a CHECK constraint.
type PackageStatus string
const (
StatusPending PackageStatus = "pending"
StatusApproved PackageStatus = "approved"
StatusCheckingDependencies PackageStatus = "checking_dependencies"
StatusPendingDependencies PackageStatus = "pending_dependencies"
StatusInstalling PackageStatus = "installing"
StatusInstalled PackageStatus = "installed"
StatusFailed PackageStatus = "failed"
StatusIgnored PackageStatus = "ignored"
)
// PackageStatusTransitions encodes every permitted (from -> to) move in the
// state machine. The map is read-only after init; access through ValidateTransition.
var PackageStatusTransitions = map[PackageStatus][]PackageStatus{
StatusPending: {StatusApproved, StatusIgnored},
StatusApproved: {StatusCheckingDependencies, StatusInstalling, StatusIgnored, StatusFailed},
StatusCheckingDependencies: {StatusPendingDependencies, StatusInstalling, StatusFailed},
StatusPendingDependencies: {StatusInstalling, StatusFailed},
StatusInstalling: {StatusInstalled, StatusFailed, StatusPendingDependencies},
StatusInstalled: {}, // terminal
StatusFailed: {}, // terminal
StatusIgnored: {}, // terminal
}
var allPackageStatuses = func() map[PackageStatus]struct{} {
m := make(map[PackageStatus]struct{}, len(PackageStatusTransitions))
for s := range PackageStatusTransitions {
m[s] = struct{}{}
}
return m
}()
// ValidateTransition checks whether moving from `from` to `to` is permitted
// by the state machine. It is idempotent (from == to always passes).
// Returns an error naming both states when the transition is invalid.
func ValidateTransition(from, to PackageStatus) error {
if from == to {
return nil
}
allowed, ok := PackageStatusTransitions[from]
if !ok {
return fmt.Errorf("unknown source state %q", from)
}
for _, a := range allowed {
if a == to {
return nil
}
}
return fmt.Errorf("transition %q -> %q is not permitted", from, to)
}
// IsTerminal returns true for states that cannot transition away from.
func (s PackageStatus) IsTerminal() bool {
switch s {
case StatusInstalled, StatusFailed, StatusIgnored:
return true
}
return false
}
// IsActive returns true for states that represent an in-flight operation.
func (s PackageStatus) IsActive() bool {
switch s {
case StatusCheckingDependencies, StatusPendingDependencies, StatusInstalling:
return true
}
return false
}
// StatusFromString parses a raw status string into a PackageStatus constant.
// Returns an error if the string is not a known state.
func StatusFromString(s string) (PackageStatus, error) {
st := PackageStatus(s)
if _, ok := allPackageStatuses[st]; ok {
return st, nil
}
return "", fmt.Errorf("unknown package status %q", s)
}
// ReconcileFromScan returns the status to set after an agent re-discovers a
// package that already has a row. Terminal states (installed, failed, ignored)
// are preserved; everything else resets to pending.
func ReconcileFromScan(current PackageStatus) PackageStatus {
if current.IsTerminal() {
return current
}
return StatusPending
}
// HistoryStatus are the valid values for update_version_history.update_status.
type HistoryStatus string
const (
HistoryInstalled = HistoryStatus("installed")
HistoryFailed = HistoryStatus("failed")
HistoryRollback = HistoryStatus("rollback")
)

View file

@ -235,7 +235,7 @@ func (ts *TimeoutService) updateRelatedPackageStatus(command *models.AgentComman
return ts.updateQueries.UpdatePackageStatus(command.AgentID,
command.Params["package_type"].(string),
command.Params["package_name"].(string),
"failed",
models.StatusFailed,
metadata,
nil) // nil = use time.Now().UTC() for timeout operations
}

View file

@ -12,8 +12,8 @@ import (
// Build-time injected version information (SERVER AUTHORITY)
var (
AgentVersion = "0.2.1.3"
ConfigVersion = "0.2.1.3"
AgentVersion = "0.2.2.0"
ConfigVersion = "0.2.2.0"
MinAgentVersion = "0.1.22"
)

View file

@ -43,7 +43,7 @@ interface LogResponse {
const TAB_GROUPS: { key: string; label: string; statuses: string }[] = [
{ key: 'needs-review', label: 'Needs Review', statuses: 'pending' },
{ key: 'in-progress', label: 'In Progress', statuses: 'approved,checking_dependencies,pending_dependencies,installing' },
{ key: 'installed', label: 'Installed', statuses: 'updated' },
{ key: 'installed', label: 'Installed', statuses: 'installed' },
{ key: 'failed-ignored', label: 'Failed / Ignored', statuses: 'failed,ignored' },
];
@ -53,7 +53,7 @@ const STATUS_META: Record<string, { label: string; icon: React.ReactNode; class:
checking_dependencies: { label: 'Checking Deps', icon: <Loader2 className="h-3 w-3 animate-spin" />, class: 'text-yellow-600 bg-yellow-100' },
pending_dependencies: { label: 'Deps Pending', icon: <AlertTriangle className="h-3 w-3" />, class: 'text-orange-600 bg-orange-100' },
installing: { label: 'Installing', icon: <RefreshCw className="h-3 w-3 animate-spin" />, class: 'text-purple-600 bg-purple-100' },
updated: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
installed: { label: 'Installed', icon: <CheckCircle className="h-3 w-3" />, class: 'text-green-600 bg-green-100' },
failed: { label: 'Failed', icon: <XCircle className="h-3 w-3" />, class: 'text-red-600 bg-red-100' },
ignored: { label: 'Ignored', icon: <X className="h-3 w-3" />, class: 'text-gray-500 bg-gray-50' },
};

View file

@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import {
AlertTriangle,
AlertOctagon,
ShieldAlert,
XCircle,
GitBranch,
ChevronRight,
@ -10,9 +11,10 @@ import {
import { useDashboardStats } from '@/hooks/useStats';
import { useDriftedSoftware, useRecentDriftEvents } from '@/hooks/useUpstream';
// Severity ranks: eol (100) > failed (80) > major drift (70) > minor drift (40) > patch (20) > metadata (10)
// Severity ranks: eol (100) > vuln (90) > failed (80) > major drift (70) > minor drift (40) > patch (20) > metadata (10)
const SEVERITY_RANK: Record<string, number> = {
eol: 100,
vuln: 90,
failed: 80,
major: 70,
minor: 40,
@ -48,6 +50,18 @@ const AttentionPanel: React.FC = () => {
});
}
// 1b. Packages with known CVEs (OSV.dev findings)
if (stats && stats.vulnerable_packages > 0) {
alerts.push({
key: 'vulnerable-packages',
severity: 'vuln',
title: `${stats.vulnerable_packages} package${stats.vulnerable_packages === 1 ? '' : 's'} with known CVEs`,
detail: 'Review vulnerabilities before approving updates',
href: '/updates?vuln=true',
icon: ShieldAlert,
});
}
// 2. EOL software (drifted + past EOL date) - highest priority
if (drifted) {
const eolPassed = drifted.filter((s) => s.eol_at && new Date(s.eol_at) < new Date());
@ -100,24 +114,26 @@ const AttentionPanel: React.FC = () => {
{top.map((a) => {
const Icon = a.icon;
const eol = a.severity === 'eol';
const vuln = a.severity === 'vuln';
const critical = eol || vuln;
return (
<li key={a.key}>
<Link
to={a.href ?? '#'}
className={`flex items-start gap-3 p-2 rounded hover:bg-amber-100 ${eol ? 'bg-red-50' : ''}`}
className={`flex items-start gap-3 p-2 rounded hover:bg-amber-100 ${eol ? 'bg-red-50' : vuln ? 'bg-orange-50' : ''}`}
>
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${eol ? 'text-red-600' : 'text-amber-700'}`} />
<Icon className={`w-5 h-5 flex-shrink-0 mt-0.5 ${eol ? 'text-red-600' : vuln ? 'text-orange-600' : 'text-amber-700'}`} />
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${eol ? 'text-red-900' : 'text-amber-900'}`}>
<p className={`text-sm font-medium ${eol ? 'text-red-900' : vuln ? 'text-orange-900' : 'text-amber-900'}`}>
{a.title}
</p>
{a.detail && (
<p className={`text-xs ${eol ? 'text-red-700' : 'text-amber-700'} truncate`}>
<p className={`text-xs ${eol ? 'text-red-700' : vuln ? 'text-orange-700' : 'text-amber-700'} truncate`}>
{a.detail}
</p>
)}
</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : 'text-amber-400'}`} />
<ChevronRight className={`w-4 h-4 flex-shrink-0 mt-1 ${eol ? 'text-red-400' : vuln ? 'text-orange-400' : 'text-amber-400'}`} />
</Link>
</li>
);

View file

@ -128,7 +128,6 @@ export const getStatusColor = (status: string): string => {
case 'pending_dependencies':
return 'text-orange-600 bg-orange-100';
case 'approved':
case 'scheduled':
return 'text-info-600 bg-info-100';
case 'installing':
return 'text-indigo-600 bg-indigo-100';
@ -136,6 +135,8 @@ export const getStatusColor = (status: string): string => {
return 'text-success-600 bg-success-100';
case 'failed':
return 'text-danger-600 bg-danger-100';
case 'ignored':
return 'text-gray-500 bg-gray-100';
default:
return 'text-gray-600 bg-gray-100';
}

View file

@ -19,6 +19,7 @@ import {
ArrowUp,
ArrowDown,
Shield,
ShieldAlert,
GitBranch,
Activity,
HardDrive,
@ -46,6 +47,7 @@ const Updates: React.FC = () => {
const [severityFilter, setSeverityFilter] = useState(searchParams.get('severity') || '');
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
const [agentFilter, setAgentFilter] = useState(searchParams.get('agent') || '');
const [vulnFilter, setVulnFilter] = useState(searchParams.get('vuln') === 'true');
const [sortBy, setSortBy] = useState(searchParams.get('sort_by') || '');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(searchParams.get('sort_order') as 'asc' | 'desc' || 'desc');
@ -80,6 +82,7 @@ const Updates: React.FC = () => {
if (severityFilter) params.set('severity', severityFilter);
if (typeFilter) params.set('type', typeFilter);
if (agentFilter) params.set('agent', agentFilter);
if (vulnFilter) params.set('vuln', 'true');
if (sortBy) params.set('sort_by', sortBy);
if (sortOrder) params.set('sort_order', sortOrder);
if (currentPage > 1) params.set('page', currentPage.toString());
@ -147,6 +150,7 @@ const Updates: React.FC = () => {
const packageTotal = packagesData?.total || 0;
const displayedPackages = packages.filter((p) => {
if (severityFilter && p.max_severity !== severityFilter) return false;
if (vulnFilter && !p.has_vulns) return false;
return true;
});
const packageTotalPages = Math.ceil(packageTotal / pageSize);
@ -316,12 +320,18 @@ const Updates: React.FC = () => {
setStatusFilter('pending_dependencies');
setSeverityFilter('');
break;
case 'vuln':
setVulnFilter(true);
setStatusFilter('');
setSeverityFilter('');
break;
default:
// Clear all filters
setStatusFilter('');
setSeverityFilter('');
setTypeFilter('');
setAgentFilter('');
setVulnFilter(false);
break;
}
setCurrentPage(1);
@ -438,7 +448,8 @@ const Updates: React.FC = () => {
if (selectedUpdate.status === 'installed') return 3;
if (selectedUpdate.status === 'installing') return 2;
if (selectedUpdate.status === 'failed') return 2; // failed during install
if (['approved', 'scheduled', 'checking_dependencies', 'pending_dependencies'].includes(selectedUpdate.status)) return 1;
if (['approved', 'checking_dependencies', 'pending_dependencies'].includes(selectedUpdate.status)) return 1;
if (selectedUpdate.status === 'ignored') return -1; // terminal, not highlighted
return 0;
})();
const isFailed = selectedUpdate.status === 'failed';
@ -1444,7 +1455,7 @@ const Updates: React.FC = () => {
onClick={() => handleQuickFilter('all')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
!statusFilter && !severityFilter && !typeFilter && !agentFilter
!statusFilter && !severityFilter && !typeFilter && !agentFilter && !vulnFilter
? "bg-primary-100 border-primary-300 text-primary-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
@ -1535,6 +1546,18 @@ const Updates: React.FC = () => {
<AlertTriangle className="h-4 w-4 mr-1 inline" />
Dependencies
</button>
<button
onClick={() => handleQuickFilter('vuln')}
className={cn(
"px-4 py-2 text-sm font-medium rounded-lg border transition-colors",
vulnFilter
? "bg-orange-100 border-orange-300 text-orange-700"
: "bg-white border-gray-300 text-gray-700 hover:bg-gray-50"
)}
>
<ShieldAlert className="h-4 w-4 mr-1 inline" />
Vulnerable
</button>
</div>
</div>

View file

@ -54,7 +54,7 @@ export interface UpdatePackage {
current_version: string;
available_version: string;
severity: 'low' | 'medium' | 'high' | 'critical';
status: 'pending' | 'approved' | 'scheduled' | 'installing' | 'installed' | 'failed' | 'checking_dependencies' | 'pending_dependencies';
status: 'pending' | 'approved' | 'checking_dependencies' | 'pending_dependencies' | 'installing' | 'installed' | 'failed' | 'ignored';
// Timestamp fields - matching backend API response
last_discovered_at: string; // When package was first discovered
last_updated_at: string; // When package status was last updated
@ -232,6 +232,7 @@ export interface DashboardStats {
approved_updates: number;
installed_updates: number;
failed_updates: number;
vulnerable_packages: number;
critical_updates: number;
high_updates: number;
medium_updates: number;