refactor: vulnerability → security advisory terminology
Rename CVE/vulnerability language to advisory/threat/fix across the stack: - Dashboard: installed_cve_count→open_threat_count, security_update_count→available_fix_count - Update detail: Known Vulnerabilities→Security Advisories - AdvisoryType() helper for human-readable advisory ID prefixes - clearVulnsOnInstall on installed transition with per-advisory security event logging - StatsHandler takes checkInInterval for online/offline threshold - AttentionPanel re-keyed on open-threats / available-fixes
This commit is contained in:
parent
5683bc15a4
commit
c71fc093db
10 changed files with 393 additions and 51 deletions
|
|
@ -10,15 +10,20 @@ import (
|
|||
|
||||
// StatsHandler handles statistics for the dashboard
|
||||
type StatsHandler struct {
|
||||
agentQueries *queries.AgentQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
agentQueries *queries.AgentQueries
|
||||
updateQueries *queries.UpdateQueries
|
||||
checkInInterval int // seconds; used for online/offline threshold
|
||||
}
|
||||
|
||||
// NewStatsHandler creates a new stats handler
|
||||
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries) *StatsHandler {
|
||||
func NewStatsHandler(agentQueries *queries.AgentQueries, updateQueries *queries.UpdateQueries, checkInInterval int) *StatsHandler {
|
||||
if checkInInterval <= 0 {
|
||||
checkInInterval = 300 // default 5 minutes
|
||||
}
|
||||
return &StatsHandler{
|
||||
agentQueries: agentQueries,
|
||||
updateQueries: updateQueries,
|
||||
agentQueries: agentQueries,
|
||||
updateQueries: updateQueries,
|
||||
checkInInterval: checkInInterval,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,10 +32,11 @@ type DashboardStats struct {
|
|||
TotalAgents int `json:"total_agents"`
|
||||
OnlineAgents int `json:"online_agents"`
|
||||
OfflineAgents int `json:"offline_agents"`
|
||||
TotalUpdates int `json:"total_updates"`
|
||||
PendingUpdates int `json:"pending_updates"`
|
||||
FailedUpdates int `json:"failed_updates"`
|
||||
SecurityUpdateCount int `json:"security_update_count"` // distinct advisories in available-version OSV check
|
||||
InstalledCVECount int `json:"installed_cve_count"` // distinct advisories in installed-version OSV check
|
||||
AvailableFixCount int `json:"available_fix_count"` // distinct advisories on available version (remediation)
|
||||
OpenThreatCount int `json:"open_threat_count"` // distinct advisories on installed version (threat)
|
||||
CriticalUpdates int `json:"critical_updates"`
|
||||
ImportantUpdates int `json:"high_updates"`
|
||||
ModerateUpdates int `json:"medium_updates"`
|
||||
|
|
@ -52,9 +58,10 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
|
|||
UpdatesByType: make(map[string]int),
|
||||
}
|
||||
|
||||
// Count online/offline agents
|
||||
// Count online/offline agents using check-in interval (2x for threshold).
|
||||
threshold := time.Duration(h.checkInInterval*2) * time.Second
|
||||
for _, agent := range agents {
|
||||
if time.Since(agent.LastSeen) <= 10*time.Minute {
|
||||
if time.Since(agent.LastSeen) <= threshold {
|
||||
stats.OnlineAgents++
|
||||
} else {
|
||||
stats.OfflineAgents++
|
||||
|
|
@ -64,6 +71,7 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
|
|||
// Single aggregate query for all update stats (replaces N+1 per-agent loop)
|
||||
updateStats, err := h.updateQueries.GetAllUpdateStats()
|
||||
if err == nil {
|
||||
stats.TotalUpdates = updateStats.TotalUpdates
|
||||
stats.PendingUpdates = updateStats.PendingUpdates
|
||||
stats.FailedUpdates = updateStats.FailedUpdates
|
||||
stats.CriticalUpdates = updateStats.CriticalUpdates
|
||||
|
|
@ -72,13 +80,13 @@ func (h *StatsHandler) GetDashboardStats(c *gin.Context) {
|
|||
stats.LowUpdates = updateStats.LowUpdates
|
||||
}
|
||||
|
||||
// Remediation count — distinct advisories in available-version OSV check.
|
||||
if n, err := h.updateQueries.GetSecurityUpdateCount(); err == nil {
|
||||
stats.SecurityUpdateCount = n
|
||||
// Remediation count — distinct advisories on available version.
|
||||
if n, err := h.updateQueries.GetAvailableFixCount(); err == nil {
|
||||
stats.AvailableFixCount = n
|
||||
}
|
||||
// Threat count — distinct advisories in installed-version OSV check.
|
||||
if n, err := h.updateQueries.GetInstalledCVECount(); err == nil {
|
||||
stats.InstalledCVECount = n
|
||||
// Threat count — distinct advisories on installed version.
|
||||
if n, err := h.updateQueries.GetOpenThreatCount(); err == nil {
|
||||
stats.OpenThreatCount = n
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, stats)
|
||||
|
|
|
|||
|
|
@ -587,6 +587,17 @@ func (q *UpdateQueries) transitionStatus(tx *sqlx.Tx, sel statusSelector, to mod
|
|||
if err := q.recordTerminalHistory(tx, cur, to, completedAt, meta); err != nil {
|
||||
return cur, err
|
||||
}
|
||||
|
||||
// Clear vulnerability metadata on installed transition — the threat is
|
||||
// remediated. Log each cleared advisory to security_events for later
|
||||
// grouping and audit.
|
||||
if to == models.StatusInstalled {
|
||||
if err := q.clearVulnsOnInstall(tx, cur); err != nil {
|
||||
log.Printf("[WARNING] [server] [updates] vuln_clearance_failed pkg=%s/%s error=%v",
|
||||
cur.PackageType, cur.PackageName, err)
|
||||
// Non-fatal: transition already committed, clearance is best-effort.
|
||||
}
|
||||
}
|
||||
}
|
||||
return cur, nil
|
||||
}
|
||||
|
|
@ -607,6 +618,78 @@ func (q *UpdateQueries) recordTerminalHistory(tx *sqlx.Tx, cur models.UpdateStat
|
|||
return nil
|
||||
}
|
||||
|
||||
// clearVulnsOnInstall clears vulnerability metadata when a package transitions
|
||||
// to installed (threat remediated). Logs each cleared advisory to security_events
|
||||
// for later grouping and audit.
|
||||
func (q *UpdateQueries) clearVulnsOnInstall(tx *sqlx.Tx, cur models.UpdateState) error {
|
||||
if cur.Metadata == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Collect advisories before clearing.
|
||||
var cleared []string
|
||||
for _, key := range []string{"supply_chain_vulns", "installed_vulns"} {
|
||||
raw, ok := cur.Metadata[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
var vulns []map[string]interface{}
|
||||
switch v := raw.(type) {
|
||||
case string:
|
||||
if err := json.Unmarshal([]byte(v), &vulns); err != nil {
|
||||
continue
|
||||
}
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
vulns = append(vulns, m)
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, vuln := range vulns {
|
||||
if id, ok := vuln["id"].(string); ok && id != "" {
|
||||
cleared = append(cleared, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(cleared) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Clear the vuln metadata keys.
|
||||
_, err := tx.Exec(`
|
||||
UPDATE current_package_state
|
||||
SET metadata = metadata - 'supply_chain_vulns' - 'installed_vulns'
|
||||
- 'supply_chain_checked_at' - 'installed_checked_at'
|
||||
- 'supply_chain_checked_version' - 'installed_checked_version'
|
||||
WHERE id = $1`, cur.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("clear vuln metadata: %w", err)
|
||||
}
|
||||
|
||||
// Log each cleared advisory to security_events.
|
||||
for _, advisoryID := range cleared {
|
||||
_, err := tx.Exec(`
|
||||
INSERT INTO security_events (timestamp, level, event_type, agent_id, message, trace_id, ip_address, details, metadata)
|
||||
VALUES (NOW(), 'INFO', 'SECURITY_ADVISORY_CLEARED', $1, $2, '', '', $3, '{}'::jsonb)`,
|
||||
cur.AgentID,
|
||||
fmt.Sprintf("Advisory %s cleared — %s/%s updated to %s",
|
||||
advisoryID, cur.PackageType, cur.PackageName, cur.AvailableVersion),
|
||||
fmt.Sprintf(`{"advisory_id":"%s","package_type":"%s","package_name":"%s","version_to":"%s"}`,
|
||||
advisoryID, cur.PackageType, cur.PackageName, cur.AvailableVersion))
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [server] [updates] advisory_clearance_log_failed advisory=%s error=%v",
|
||||
advisoryID, err)
|
||||
// Non-fatal: clearance already happened, logging is best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("[INFO] [server] [updates] vulns_cleared pkg=%s/%s count=%d advisories=%v",
|
||||
cur.PackageType, cur.PackageName, len(cleared), cleared)
|
||||
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 {
|
||||
|
|
@ -1215,10 +1298,10 @@ func (q *UpdateQueries) GetAllUpdateStats() (*models.UpdateStats, error) {
|
|||
return stats, nil
|
||||
}
|
||||
|
||||
// GetSecurityUpdateCount returns the number of distinct OSV advisory IDs present
|
||||
// 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.
|
||||
func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
|
||||
func (q *UpdateQueries) GetAvailableFixCount() (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT vuln->>'id')
|
||||
FROM current_package_state,
|
||||
|
|
@ -1240,10 +1323,10 @@ func (q *UpdateQueries) GetSecurityUpdateCount() (int, error) {
|
|||
return count, nil
|
||||
}
|
||||
|
||||
// GetInstalledCVECount returns the number of distinct OSV advisory IDs found
|
||||
// GetOpenThreatCount returns the number of distinct OSV advisory IDs found
|
||||
// in installed_vulns — the threat check against the currently-installed version.
|
||||
// Only counts rows in active states (not installed/ignored/failed).
|
||||
func (q *UpdateQueries) GetInstalledCVECount() (int, error) {
|
||||
func (q *UpdateQueries) GetOpenThreatCount() (int, error) {
|
||||
query := `
|
||||
SELECT COUNT(DISTINCT vuln->>'id')
|
||||
FROM current_package_state,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ var SecurityEventTypes = struct {
|
|||
UnauthorizedAccessAttempt string
|
||||
ConfigTamperingDetected string
|
||||
AnomalousBehavior string
|
||||
SecurityAdvisoryCleared string
|
||||
}{
|
||||
CmdSigned: "CMD_SIGNED",
|
||||
CmdSignatureVerificationFailed: "CMD_SIGNATURE_VERIFICATION_FAILED",
|
||||
|
|
@ -48,6 +49,7 @@ var SecurityEventTypes = struct {
|
|||
UnauthorizedAccessAttempt: "UNAUTHORIZED_ACCESS_ATTEMPT",
|
||||
ConfigTamperingDetected: "CONFIG_TAMPERING_DETECTED",
|
||||
AnomalousBehavior: "ANOMALOUS_BEHAVIOR",
|
||||
SecurityAdvisoryCleared: "SECURITY_ADVISORY_CLEARED",
|
||||
}
|
||||
|
||||
// IsCritical returns true if the event is of critical severity
|
||||
|
|
|
|||
|
|
@ -87,6 +87,25 @@ type VulnerabilityInfo struct {
|
|||
CVSSVector string `json:"cvss_vector,omitempty"`
|
||||
FixedVersion string `json:"fixed_version,omitempty"`
|
||||
Published string `json:"published,omitempty"`
|
||||
AdvisoryType string `json:"advisory_type,omitempty"` // "AlmaLinux advisory", "CVE", etc.
|
||||
}
|
||||
|
||||
// AdvisoryType returns a human-readable label for an advisory ID prefix.
|
||||
func AdvisoryType(id string) string {
|
||||
switch {
|
||||
case strings.HasPrefix(id, "ALSA-"):
|
||||
return "AlmaLinux advisory"
|
||||
case strings.HasPrefix(id, "RHSA-"):
|
||||
return "Red Hat advisory"
|
||||
case strings.HasPrefix(id, "USN-"):
|
||||
return "Ubuntu advisory"
|
||||
case strings.HasPrefix(id, "GHSA-"):
|
||||
return "GitHub advisory"
|
||||
case strings.HasPrefix(id, "CVE-"):
|
||||
return "CVE"
|
||||
default:
|
||||
return "security advisory"
|
||||
}
|
||||
}
|
||||
|
||||
// toVulnerabilityInfo maps a raw OSV record into the display struct, pulling
|
||||
|
|
@ -94,10 +113,11 @@ type VulnerabilityInfo struct {
|
|||
// publish date out of the OSV schema's various nesting points.
|
||||
func toVulnerabilityInfo(v OSVVuln) VulnerabilityInfo {
|
||||
info := VulnerabilityInfo{
|
||||
ID: v.ID,
|
||||
Summary: v.Summary,
|
||||
Aliases: v.Aliases,
|
||||
Published: v.Published,
|
||||
ID: v.ID,
|
||||
Summary: v.Summary,
|
||||
Aliases: v.Aliases,
|
||||
Published: v.Published,
|
||||
AdvisoryType: AdvisoryType(v.ID),
|
||||
}
|
||||
|
||||
// Qualitative severity: GHSA puts it in database_specific.severity.
|
||||
|
|
@ -309,7 +329,9 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
|
|||
}
|
||||
|
||||
if len(result.Vulns) > 0 {
|
||||
vulnJSON, err := json.Marshal(result.Vulns)
|
||||
// Enrich ALSA/RHSA/USN advisories with full details from OSV.
|
||||
enriched := enrichAdvisoryVulns(result.Vulns)
|
||||
vulnJSON, err := json.Marshal(enriched)
|
||||
if err != nil {
|
||||
log.Printf("[WARNING] [supply_chain] vuln_marshal_failed pkg=%s error=%v", r.PkgName, err)
|
||||
continue
|
||||
|
|
@ -328,6 +350,55 @@ func osvBatchRun(reqs []OSVCheckRequest, store OSVStoreFunc) {
|
|||
}
|
||||
}
|
||||
|
||||
// enrichAdvisoryVulns does a second OSV pass for ALSA/RHSA/USN advisories to
|
||||
// resolve their aliases and summaries. OSV's batch endpoint returns advisory IDs
|
||||
// but often without full details; the single-vuln endpoint (/v1/vulns/{id})
|
||||
// returns the complete record including constituent CVEs.
|
||||
func enrichAdvisoryVulns(vulns []OSVVuln) []OSVVuln {
|
||||
enriched := make([]OSVVuln, 0, len(vulns))
|
||||
for _, v := range vulns {
|
||||
if needsEnrichment(v) {
|
||||
if resolved, err := fetchVulnDetail(v.ID); err == nil {
|
||||
enriched = append(enriched, resolved)
|
||||
continue
|
||||
}
|
||||
// On failure, keep original — partial data is better than none.
|
||||
log.Printf("[WARNING] [supply_chain] enrich_failed id=%s", v.ID)
|
||||
}
|
||||
enriched = append(enriched, v)
|
||||
}
|
||||
return enriched
|
||||
}
|
||||
|
||||
// needsEnrichment returns true if a vuln record looks sparse — missing
|
||||
// summary and aliases — and its ID is a known advisory prefix.
|
||||
func needsEnrichment(v OSVVuln) bool {
|
||||
if v.Summary != "" && len(v.Aliases) > 0 {
|
||||
return false
|
||||
}
|
||||
return strings.HasPrefix(v.ID, "ALSA-") ||
|
||||
strings.HasPrefix(v.ID, "RHSA-") ||
|
||||
strings.HasPrefix(v.ID, "USN-")
|
||||
}
|
||||
|
||||
// fetchVulnDetail queries OSV.dev for a single vulnerability ID.
|
||||
func fetchVulnDetail(id string) (OSVVuln, error) {
|
||||
url := fmt.Sprintf("https://api.osv.dev/v1/vulns/%s", id)
|
||||
resp, err := osvHTTPClient.Get(url)
|
||||
if err != nil {
|
||||
return OSVVuln{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return OSVVuln{}, fmt.Errorf("osv status %d", resp.StatusCode)
|
||||
}
|
||||
var v OSVVuln
|
||||
if err := json.NewDecoder(resp.Body).Decode(&v); err != nil {
|
||||
return OSVVuln{}, err
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// osvMetaKeys returns the metadata key names for a given check namespace.
|
||||
func osvMetaKeys(namespace string) (checkedAt, checkedVersion, vulns, checkError string) {
|
||||
if namespace == "installed" {
|
||||
|
|
|
|||
|
|
@ -50,26 +50,26 @@ const AttentionPanel: React.FC = () => {
|
|||
});
|
||||
}
|
||||
|
||||
// 1b. Installed version has known CVEs — this is the threat number.
|
||||
if (stats && stats.installed_cve_count > 0) {
|
||||
const n = stats.installed_cve_count;
|
||||
// 1b. Open threats — advisories affecting installed versions.
|
||||
if (stats && stats.open_threat_count > 0) {
|
||||
const n = stats.open_threat_count;
|
||||
alerts.push({
|
||||
key: 'installed-cves',
|
||||
key: 'open-threats',
|
||||
severity: 'vuln',
|
||||
title: `${n} known CVE${n === 1 ? '' : 's'} in installed packages`,
|
||||
detail: 'Installed versions have active advisories — patch to remediate',
|
||||
title: `${n} open threat${n === 1 ? '' : 's'} in installed packages`,
|
||||
detail: 'Installed versions have active security advisories — patch to remediate',
|
||||
href: '/updates',
|
||||
icon: ShieldAlert,
|
||||
});
|
||||
}
|
||||
|
||||
// 1c. Security updates available — remediation framing, not threat.
|
||||
if (stats && stats.security_update_count > 0) {
|
||||
const n = stats.security_update_count;
|
||||
// 1c. Available fixes — advisories on available versions (remediation).
|
||||
if (stats && stats.available_fix_count > 0) {
|
||||
const n = stats.available_fix_count;
|
||||
alerts.push({
|
||||
key: 'security-updates',
|
||||
key: 'available-fixes',
|
||||
severity: 'patch',
|
||||
title: `${n} security update${n === 1 ? '' : 's'} available`,
|
||||
title: `${n} security fix${n === 1 ? '' : 'es'} available to apply`,
|
||||
detail: 'Updates carry security advisories — review and approve to apply',
|
||||
href: '/updates?vuln=true',
|
||||
icon: ShieldAlert,
|
||||
|
|
|
|||
105
web/src/hooks/useColumnSort.tsx
Normal file
105
web/src/hooks/useColumnSort.tsx
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { useState, useCallback } from 'react';
|
||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
export interface SortConfig {
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface UseColumnSortOptions {
|
||||
/** Sync sort state to URL search params (default false). */
|
||||
syncUrl?: boolean;
|
||||
/** Default column to sort by. */
|
||||
defaultSortBy?: string;
|
||||
/** Default sort direction (default 'desc'). */
|
||||
defaultOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable column-sort state hook. Extracted from Updates.tsx; use anywhere a
|
||||
* table needs click-to-sort headers with URL persistence.
|
||||
*
|
||||
* Returns the sort config, a header click handler, a sort-icon renderer, and a
|
||||
* generic sort-applier for arrays.
|
||||
*/
|
||||
export function useColumnSort(opts: UseColumnSortOptions = {}) {
|
||||
const { syncUrl, defaultSortBy = '', defaultOrder = 'desc' } = opts;
|
||||
const [searchParams, setSearchParams] = syncUrl ? useSearchParams() : [null, null] as any;
|
||||
|
||||
const [sortBy, setSortBy] = useState<string>(
|
||||
syncUrl ? (searchParams.get('sort_by') || defaultSortBy) : defaultSortBy,
|
||||
);
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(
|
||||
syncUrl
|
||||
? ((searchParams.get('sort_order') as 'asc' | 'desc') || defaultOrder)
|
||||
: defaultOrder,
|
||||
);
|
||||
|
||||
const handleSort = useCallback(
|
||||
(column: string) => {
|
||||
setSortBy((prev) => {
|
||||
if (prev === column) {
|
||||
const next = sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortOrder(next);
|
||||
if (syncUrl) {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
p.set('sort_by', column);
|
||||
p.set('sort_order', next);
|
||||
setSearchParams(p, { replace: true });
|
||||
}
|
||||
return column;
|
||||
}
|
||||
setSortOrder('desc');
|
||||
if (syncUrl) {
|
||||
const p = new URLSearchParams(searchParams);
|
||||
p.set('sort_by', column);
|
||||
p.set('sort_order', 'desc');
|
||||
setSearchParams(p, { replace: true });
|
||||
}
|
||||
return column;
|
||||
});
|
||||
},
|
||||
[sortOrder, syncUrl, searchParams, setSearchParams],
|
||||
);
|
||||
|
||||
const renderSortIcon = useCallback(
|
||||
(column: string) => {
|
||||
if (sortBy !== column) {
|
||||
return <ArrowUpDown className="h-4 w-4 ml-1 text-gray-400" />;
|
||||
}
|
||||
return sortOrder === 'asc' ? (
|
||||
<ArrowUp className="h-4 w-4 ml-1 text-primary-600" />
|
||||
) : (
|
||||
<ArrowDown className="h-4 w-4 ml-1 text-primary-600" />
|
||||
);
|
||||
},
|
||||
[sortBy, sortOrder],
|
||||
);
|
||||
|
||||
/**
|
||||
* Sort an array of items by a column, given a value extractor. Handles
|
||||
* strings (localeCompare), numbers, and dates; nulls sort last regardless
|
||||
* of direction.
|
||||
*/
|
||||
const applySort = useCallback(
|
||||
<T,>(items: T[], extract: (item: T) => string | number | Date | null | undefined): T[] => {
|
||||
if (!sortBy) return items;
|
||||
const dir = sortOrder === 'asc' ? 1 : -1;
|
||||
return [...items].sort((a, b) => {
|
||||
const va = extract(a);
|
||||
const vb = extract(b);
|
||||
// Nulls last
|
||||
if (va == null && vb == null) return 0;
|
||||
if (va == null) return 1;
|
||||
if (vb == null) return -1;
|
||||
if (va instanceof Date && vb instanceof Date) return (va.getTime() - vb.getTime()) * dir;
|
||||
if (typeof va === 'number' && typeof vb === 'number') return (va - vb) * dir;
|
||||
return String(va).localeCompare(String(vb), undefined, { numeric: true }) * dir;
|
||||
});
|
||||
},
|
||||
[sortBy, sortOrder],
|
||||
);
|
||||
|
||||
return { sortBy, sortOrder, handleSort, renderSortIcon, applySort } as const;
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ import {
|
|||
CommandCard,
|
||||
} from '@/components/primitives';
|
||||
import { useDebounce } from '@/hooks/useDebounce';
|
||||
import { useColumnSort } from '@/hooks/useColumnSort';
|
||||
import { useAgents, useAgent, useScanMultipleAgents, useUnregisterAgent } from '@/hooks/useAgents';
|
||||
import { useActiveCommands, useCancelCommand, useCaptureScreenshot, useCommand } from '@/hooks/useCommands';
|
||||
import { useHeartbeatStatus } from '@/hooks/useHeartbeat';
|
||||
|
|
@ -66,6 +67,10 @@ const Agents: React.FC = () => {
|
|||
const debouncedSearchQuery = useDebounce(searchQuery, 300);
|
||||
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || '');
|
||||
const [osFilter, setOsFilter] = useState<string>('');
|
||||
const { sortBy, handleSort, renderSortIcon, applySort } = useColumnSort({
|
||||
defaultSortBy: 'last_seen',
|
||||
defaultOrder: 'asc',
|
||||
});
|
||||
const [selectedAgents, setSelectedAgents] = useState<string[]>([]);
|
||||
const activeTab = parseAgentDetailTab(searchParams.get('tab'));
|
||||
const [heartbeatDuration, setHeartbeatDuration] = useState<number>(10); // Default 10 minutes
|
||||
|
|
@ -259,6 +264,19 @@ const Agents: React.FC = () => {
|
|||
return agent.os_type.toLowerCase().includes(osFilter.toLowerCase());
|
||||
});
|
||||
|
||||
// Sort agents client-side (fleet size doesn't warrant server-side pagination yet)
|
||||
const sortedAgents = applySort(filteredAgents, (agent) => {
|
||||
switch (sortBy) {
|
||||
case 'hostname': return agent.hostname;
|
||||
case 'status': return isOnline(agent.last_seen) ? 0 : 1; // online first when asc
|
||||
case 'version': return agent.current_version || agent.agent_version || '';
|
||||
case 'os': return agent.os_type;
|
||||
case 'last_seen': return agent.last_seen ? new Date(agent.last_seen) : null;
|
||||
case 'last_scan': return agent.last_scan ? new Date(agent.last_scan) : null;
|
||||
default: return null;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle agent selection
|
||||
const handleSelectAgent = (agentId: string, checked: boolean) => {
|
||||
if (checked) {
|
||||
|
|
@ -270,7 +288,7 @@ const Agents: React.FC = () => {
|
|||
|
||||
const handleSelectAll = (checked: boolean) => {
|
||||
if (checked) {
|
||||
setSelectedAgents(filteredAgents.map(agent => agent.id));
|
||||
setSelectedAgents(sortedAgents.map(agent => agent.id));
|
||||
} else {
|
||||
setSelectedAgents([]);
|
||||
}
|
||||
|
|
@ -1012,7 +1030,7 @@ const Agents: React.FC = () => {
|
|||
<PageState
|
||||
loading={isPending}
|
||||
error={error ? 'Failed to load agents' : null}
|
||||
empty={filteredAgents.length === 0}
|
||||
empty={sortedAgents.length === 0}
|
||||
emptyTitle="No agents found"
|
||||
emptyMessage={
|
||||
debouncedSearchQuery || statusFilter || osFilter
|
||||
|
|
@ -1028,22 +1046,70 @@ const Agents: React.FC = () => {
|
|||
<th className="table-header">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedAgents.length === filteredAgents.length}
|
||||
checked={selectedAgents.length === sortedAgents.length && sortedAgents.length > 0}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
className="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header">Agent</th>
|
||||
<th className="table-header">Status</th>
|
||||
<th className="table-header">Version</th>
|
||||
<th className="table-header">OS</th>
|
||||
<th className="table-header">Last Check-in</th>
|
||||
<th className="table-header">Last Scan</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('hostname')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Agent
|
||||
{renderSortIcon('hostname')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('status')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Status
|
||||
{renderSortIcon('status')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('version')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Version
|
||||
{renderSortIcon('version')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('os')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
OS
|
||||
{renderSortIcon('os')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('last_seen')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Last Check-in
|
||||
{renderSortIcon('last_seen')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">
|
||||
<button
|
||||
onClick={() => handleSort('last_scan')}
|
||||
className="flex items-center hover:text-primary-600 font-medium"
|
||||
>
|
||||
Last Scan
|
||||
{renderSortIcon('last_scan')}
|
||||
</button>
|
||||
</th>
|
||||
<th className="table-header">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredAgents.map((agent) => (
|
||||
{sortedAgents.map((agent) => (
|
||||
<tr key={agent.id} className="hover:bg-gray-50 group">
|
||||
<td className="table-cell">
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ const Dashboard: React.FC = () => {
|
|||
<div
|
||||
className={`h-2 rounded-full ${severity.color}`}
|
||||
style={{
|
||||
width: `${(stats?.pending_updates ?? 0) > 0 ? (severity.value / (stats?.pending_updates ?? 1)) * 100 : 0}%`
|
||||
width: `${(stats?.total_updates ?? 0) > 0 ? (severity.value / (stats?.total_updates ?? 1)) * 100 : 0}%`
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -397,6 +397,7 @@ const Updates: React.FC = () => {
|
|||
cvss_vector?: string;
|
||||
fixed_version?: string;
|
||||
published?: string;
|
||||
advisory_type?: string;
|
||||
}> = [];
|
||||
const rawVulns = selectedUpdate.metadata?.supply_chain_vulns;
|
||||
if (rawVulns) {
|
||||
|
|
@ -510,7 +511,7 @@ const Updates: React.FC = () => {
|
|||
{vulns.length > 0 && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 bg-amber-50 border border-amber-200 rounded-full px-2.5 py-0.5">
|
||||
<Shield className="w-3.5 h-3.5" />
|
||||
{vulns.length} known {vulns.length === 1 ? 'vulnerability' : 'vulnerabilities'}
|
||||
{vulns.length} security {vulns.length === 1 ? 'advisory' : 'advisories'}
|
||||
</span>
|
||||
)}
|
||||
{/* Supply Chain Gate indicator: show when this update uses capability-token path */}
|
||||
|
|
@ -687,13 +688,13 @@ const Updates: React.FC = () => {
|
|||
/>
|
||||
)}
|
||||
|
||||
{/* Known Vulnerabilities */}
|
||||
{/* Security Advisories */}
|
||||
{vulns.length > 0 && (
|
||||
<div className="card border-amber-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h2 className="text-sm font-medium text-gray-700 inline-flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-amber-600" />
|
||||
Known Vulnerabilities
|
||||
Security Advisories
|
||||
<span className="text-xs text-gray-500 font-normal">({vulns.length})</span>
|
||||
</h2>
|
||||
{checkedAt && (
|
||||
|
|
@ -714,6 +715,11 @@ const Updates: React.FC = () => {
|
|||
<span className={cn('badge border text-[10px] font-bold px-1.5 py-0.5', sev.cls)}>
|
||||
{sev.label}
|
||||
</span>
|
||||
{v.advisory_type && (
|
||||
<span className="text-[10px] text-gray-500 font-medium">
|
||||
{v.advisory_type}
|
||||
</span>
|
||||
)}
|
||||
<a
|
||||
href={advisoryUrl(v.id)}
|
||||
target="_blank"
|
||||
|
|
|
|||
|
|
@ -254,12 +254,13 @@ export interface DashboardStats {
|
|||
total_agents: number;
|
||||
online_agents: number;
|
||||
offline_agents: number;
|
||||
total_updates: number;
|
||||
pending_updates: number;
|
||||
approved_updates: number;
|
||||
installed_updates: number;
|
||||
failed_updates: number;
|
||||
security_update_count: number; // distinct advisories on available version
|
||||
installed_cve_count: number; // distinct advisories on installed version (threat)
|
||||
available_fix_count: number; // distinct advisories on available version (remediation)
|
||||
open_threat_count: number; // distinct advisories on installed version (threat)
|
||||
critical_updates: number;
|
||||
high_updates: number;
|
||||
medium_updates: number;
|
||||
|
|
|
|||
Loading…
Reference in a new issue