From e4c41e03ee060d3dd62f2b39b9361dd065eaf795 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Sun, 7 Jun 2026 17:51:59 +0200 Subject: [PATCH] feat(web): merge App Health into a tabbed "Software" hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /apps becomes a Software hub with three lens tabs synced to ?tab= (installs | inventory | vulnerabilities) — client-side tabs to avoid a route collision with the existing /apps/[appName] detail page; drill-down preserved. Nav label "App Health" -> "Software". - Installs tab: existing app-health list + a Delivery Optimization card that renders the deliveryOptimization rollup from /api/metrics/app (peer/MCC offload %, bytes saved, Peers/MCC/CDN bar). - Inventory tab: tenant's installed-software inventory (GA picks a tenant via the scope selector; the inventory endpoint is per-tenant). - Vulnerabilities tab: tenant-facing CVE/KEV exposure (first time customers can see their own exposure; was GA-only). The three lenses key differently (IME app name / Registry identity / CPE-CVE) so they are merged at the navigation level, not row-joined. Shared groundwork (de-dupes existing copies): - lib/formatting.ts (canonical formatBytes/Duration/Throughput/Percent), lib/vulnerabilitySeverity.ts (severity chips + KEV). - components/DoBreakdownBar.tsx — DownloadProgress refactored onto it (behaviour-preserving via the total override). - components/VulnerabilityExposurePanel.tsx — the GA VulnerabilityDataSection refactored onto it (removes the inline exposure JSX + local severity map). - api.ts: metrics.vulnerability, metrics.softwareInventory, vulnerability.softwareInventory. Verified: tsc clean, 446 web tests, next build OK. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/VulnerabilityDataSection.tsx | 152 +----- .../app/apps/components/InstallsTab.tsx | 317 +++++++++++++ .../app/apps/components/InventoryTab.tsx | 200 ++++++++ .../apps/components/VulnerabilitiesTab.tsx | 62 +++ .../app/apps/components/types.ts | 10 + .../autopilot-monitor-web/app/apps/page.tsx | 444 ++++-------------- .../components/DoBreakdownBar.tsx | 73 +++ .../components/DownloadProgress.tsx | 67 +-- .../components/VulnerabilityExposurePanel.tsx | 169 +++++++ .../lib/__tests__/formatting.test.ts | 53 +++ .../__tests__/vulnerabilitySeverity.test.ts | 21 + src/Web/autopilot-monitor-web/lib/api.ts | 8 + .../autopilot-monitor-web/lib/formatting.ts | 52 ++ .../lib/globalNavConfig.tsx | 2 +- .../lib/vulnerabilitySeverity.ts | 23 + 15 files changed, 1098 insertions(+), 555 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx create mode 100644 src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx create mode 100644 src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx create mode 100644 src/Web/autopilot-monitor-web/app/apps/components/types.ts create mode 100644 src/Web/autopilot-monitor-web/components/DoBreakdownBar.tsx create mode 100644 src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/formatting.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/__tests__/vulnerabilitySeverity.test.ts create mode 100644 src/Web/autopilot-monitor-web/lib/formatting.ts create mode 100644 src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts diff --git a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx index a255adb3..21d02454 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/VulnerabilityDataSection.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import SaveResetBar from "../../settings/components/SaveResetBar"; +import VulnerabilityExposurePanel from "@/components/VulnerabilityExposurePanel"; import type { AdminConfiguration } from "@/types/adminConfig"; interface VulnerabilityTopCve { @@ -325,19 +326,6 @@ export function VulnerabilityDataSection({ } }, [getAccessToken, setError, onAdminConfigUpdated, refreshSyncStatus]); - const severityChipClass = (sev: string): string => { - switch (sev.toLowerCase()) { - case "critical": - return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700"; - case "high": - return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border-orange-300 dark:border-orange-700"; - case "medium": - return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 border-amber-300 dark:border-amber-700"; - default: - return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600"; - } - }; - const formatTimestamp = (ts: string | undefined): string => { if (!ts) return "Never"; try { @@ -587,134 +575,16 @@ export function VulnerabilityDataSection({ {/* Fleet Vulnerability Exposure (read-only cross-tenant overview) */} -
-
-

Fleet Vulnerability Exposure

-
- - -
-
- - {loadingSummary && !summary ? ( -
-
-
- ) : summary && summary.distinctCves > 0 ? ( -
- {summary.truncated && ( -
- Showing partial results — the index scan hit its cap, so counts are a lower bound. -
- )} - - {/* KPI tiles */} -
-
-
{summary.totalAffectedSessions.toLocaleString()}
-
Affected devices
-
-
-
{summary.distinctCves.toLocaleString()}
-
Distinct CVEs
-
-
-
{summary.kevCves.toLocaleString()}
-
KEV CVEs (exploited)
-
-
-
- {summary.totalAffectedTenants !== null ? summary.totalAffectedTenants.toLocaleString() : "—"} -
-
Affected tenants
-
-
- - {/* Severity breakdown */} -
- {([ - ["Critical", summary.severityBreakdown.critical], - ["High", summary.severityBreakdown.high], - ["Medium", summary.severityBreakdown.medium], - ["Low", summary.severityBreakdown.low], - ] as Array<[string, number]>).map(([label, count]) => ( - - {label}: {count.toLocaleString()} - - ))} -
- - {/* Top CVEs */} -
-
Top CVEs by affected devices
-
- {summary.topCves.map((c) => ( -
-
-
- - {c.cveId} - - - {c.cvssSeverity || "Unknown"}{c.cvssScore > 0 ? ` ${c.cvssScore.toFixed(1)}` : ""} - - {c.isKev && ( - - KEV - - )} -
- {c.sampleSoftware.length > 0 && ( -
- {c.sampleSoftware.join(", ")} -
- )} -
-
-
{c.affectedSessions.toLocaleString()}
-
devices
-
-
- ))} -
-
-
- ) : ( -

- No vulnerability findings in the last {summary?.windowDays ?? summaryDays} days. - {!vulnerabilityCorrelationEnabled && " Correlation is currently disabled."} -

- )} -
+ void refreshSummary()} + refreshing={loadingSummary} + /> {/* NVD Attribution Notice */}
diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx new file mode 100644 index 00000000..e32a4fd1 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/apps/components/InstallsTab.tsx @@ -0,0 +1,317 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTenant } from "../../../contexts/TenantContext"; +import { useAuth } from "../../../contexts/AuthContext"; +import { useNotifications } from "../../../contexts/NotificationContext"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import { formatBytes, formatDuration } from "@/lib/formatting"; +import DoBreakdownBar from "@/components/DoBreakdownBar"; +import type { SoftwareTabScope, TimeRange } from "./types"; +import { rangeToDays } from "./types"; + +interface AppRow { + appName: string; + appType: string; + totalInstalls: number; + succeeded: number; + failed: number; + failureRate: number; + avgDurationSeconds: number; + maxDurationSeconds: number; + avgDownloadBytes: number; + trend: "improving" | "worsening" | "stable"; + trendDelta: number | null; + lastSeenAt: string; +} + +interface AppsListResponse { + success: boolean; + totalApps: number; + totalInstalls: number; + windowDays: number; + apps: AppRow[]; +} + +/** Delivery Optimization rollup served by /api/metrics/app (and the global variant). */ +interface DeliveryOptimizationRollup { + totalBytesDownloaded: number; + fromPeers: number; + fromCacheServer: number; + fromHttp: number; + peerOffloadPercent: number; +} + +interface AppMetricsResponse { + success: boolean; + deliveryOptimization?: DeliveryOptimizationRollup; +} + +type SortKey = + | "appName" + | "totalInstalls" + | "succeeded" + | "failed" + | "failureRate" + | "avgDurationSeconds" + | "trend"; +type SortDir = "asc" | "desc"; + +interface InstallsTabProps { + scope: SoftwareTabScope; + timeRange: TimeRange; +} + +export default function InstallsTab({ scope, timeRange }: InstallsTabProps) { + const router = useRouter(); + const { tenantId } = useTenant(); + const { getAccessToken } = useAuth(); + const { addNotification } = useNotifications(); + const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; + + const [data, setData] = useState(null); + const [doRollup, setDoRollup] = useState(null); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [sortKey, setSortKey] = useState("failureRate"); + const [sortDir, setSortDir] = useState("desc"); + + useEffect(() => { + if (!scopeInitialized) return; + if (!isGlobalAdmin && !tenantId) return; + let cancelled = false; + const days = rangeToDays(timeRange); + + const run = async () => { + try { + setLoading(true); + const listUrl = isGlobalAdmin + ? api.apps.globalList(days, selectedTenantId || undefined) + : api.apps.list(tenantId, days); + const metricsUrl = isGlobalAdmin + ? api.metrics.globalApp(days, selectedTenantId || undefined) + : api.metrics.app(tenantId, days); + + const [listRes, metricsRes] = await Promise.all([ + authenticatedFetch(listUrl, getAccessToken), + authenticatedFetch(metricsUrl, getAccessToken), + ]); + + if (cancelled) return; + if (listRes.ok) { + setData((await listRes.json()) as AppsListResponse); + } else { + addNotification("error", "Backend Error", `Failed to load apps: ${listRes.statusText}`, "apps-list-error"); + } + if (metricsRes.ok) { + const m = (await metricsRes.json()) as AppMetricsResponse; + setDoRollup(m.deliveryOptimization ?? null); + } + } catch (err) { + if (cancelled) return; + if (err instanceof TokenExpiredError) { + addNotification("error", "Session Expired", err.message, "session-expired-error"); + } else { + console.error("Failed to fetch app installs", err); + addNotification("error", "Backend Not Reachable", "Unable to load app dashboard data.", "apps-list-error"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeInitialized, timeRange, scopeKey]); + + const filteredAndSorted = useMemo(() => { + if (!data?.apps) return []; + const q = search.trim().toLowerCase(); + const rows = q ? data.apps.filter((a) => a.appName.toLowerCase().includes(q)) : [...data.apps]; + + const dir = sortDir === "asc" ? 1 : -1; + rows.sort((a, b) => { + if (sortKey === "appName") return a.appName.localeCompare(b.appName) * dir; + if (sortKey === "trend") { + const order: Record = { worsening: 0, stable: 1, improving: 2 }; + return (order[a.trend] - order[b.trend]) * dir; + } + return ((a[sortKey] as number) - (b[sortKey] as number)) * dir; + }); + return rows; + }, [data, search, sortKey, sortDir]); + + const stats = useMemo(() => { + if (!data) return { totalApps: 0, totalInstalls: 0, avgFailureRate: 0 }; + const apps = data.apps ?? []; + const totalFailed = apps.reduce((acc, a) => acc + a.failed, 0); + const totalInstalls = data.totalInstalls; + return { + totalApps: data.totalApps, + totalInstalls, + avgFailureRate: totalInstalls > 0 ? Math.round((totalFailed / totalInstalls) * 1000) / 10 : 0, + }; + }, [data]); + + function toggleSort(key: SortKey) { + if (sortKey === key) { + setSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setSortKey(key); + setSortDir(key === "appName" ? "asc" : "desc"); + } + } + + function sortIndicator(key: SortKey) { + if (sortKey !== key) return null; + return {sortDir === "asc" ? "↑" : "↓"}; + } + + function trendBadge(row: AppRow) { + if (row.trend === "improving") { + return ↓ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp; + } + if (row.trend === "worsening") { + return ↑ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp; + } + return —; + } + + function appTypeBadge(type: string) { + if (!type) return null; + const color = + type === "WinGet" ? "bg-blue-100 text-blue-800" + : type === "MSI" ? "bg-purple-100 text-purple-800" + : type === "Win32" ? "bg-gray-100 text-gray-800" + : "bg-amber-100 text-amber-800"; + return {type}; + } + + function openApp(appName: string) { + const days = rangeToDays(timeRange); + const params = new URLSearchParams({ days: String(days) }); + // In Global Admin mode, propagate scope: aggregated → ?global=1; specific tenant → +tenantId. + if (isGlobalAdmin) { + params.set("global", "1"); + if (selectedTenantId) params.set("tenantId", selectedTenantId); + } + router.push(`/apps/${encodeURIComponent(appName)}?${params.toString()}`); + } + + return ( + <> + {/* Stat cards */} +
+
+
Total Apps
+
{stats.totalApps}
+
+
+
Total Installs
+
{stats.totalInstalls}
+
+
+
Avg Failure Rate
+
{stats.avgFailureRate.toFixed(1)}%
+
+
+ + {/* Delivery Optimization rollup */} + + + {/* Search */} +
+ setSearch(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+ + {/* Table */} +
+ {loading ? ( +
Loading…
+ ) : filteredAndSorted.length === 0 ? ( +
+ {search ? "No apps match your search." : "No app install data in this window."} +
+ ) : ( + + + + + + + + + + + + + + {filteredAndSorted.map((row) => ( + openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm"> + + + + + + + + + ))} + +
toggleSort("appName")}>App {sortIndicator("appName")} toggleSort("totalInstalls")}>Installs {sortIndicator("totalInstalls")} toggleSort("succeeded")}>Succeeded {sortIndicator("succeeded")} toggleSort("failed")}>Failed {sortIndicator("failed")} toggleSort("failureRate")}>Failure Rate {sortIndicator("failureRate")} toggleSort("avgDurationSeconds")}>Avg Duration {sortIndicator("avgDurationSeconds")} toggleSort("trend")}>Trend {sortIndicator("trend")}
+ {row.appName} + {appTypeBadge(row.appType)} + {row.totalInstalls}{row.succeeded}{row.failed} + = 20 ? "text-red-700 font-semibold" : row.failureRate >= 5 ? "text-amber-700" : "text-gray-700"}> + {row.failureRate.toFixed(1)}% + + {formatDuration(row.avgDurationSeconds, "—")}{trendBadge(row)}
+ )} +
+ + ); +} + +/** Delivery Optimization summary: peer/MCC offload % + bytes saved + sourcing bar. */ +function DoCard({ rollup, days }: { rollup: DeliveryOptimizationRollup | null; days: number }) { + if (!rollup || rollup.totalBytesDownloaded <= 0) return null; + const total = rollup.totalBytesDownloaded; + // Pure CDN = everything not served by peers or the Connected Cache. Keeps the bar's HTTP + // segment from double-counting MCC (which DO also reports inside BytesFromHttp). + const pureCdn = Math.max(0, total - rollup.fromPeers - rollup.fromCacheServer); + const offload = rollup.peerOffloadPercent; + const offloadClass = offload >= 30 ? "text-emerald-700" : offload > 0 ? "text-amber-700" : "text-gray-500"; + + return ( +
+
+

Delivery Optimization

+ peer / Connected Cache offload · last {days}d +
+
+
+
Peer offload
+
{offload.toFixed(1)}%
+
+
+
Saved from peers + MCC
+
{formatBytes(rollup.fromPeers + rollup.fromCacheServer)}
+
+
+
Total downloaded
+
{formatBytes(total)}
+
+
+ +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx new file mode 100644 index 00000000..162bc2c6 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/apps/components/InventoryTab.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTenant } from "../../../contexts/TenantContext"; +import { useAuth } from "../../../contexts/AuthContext"; +import { useNotifications } from "../../../contexts/NotificationContext"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import type { SoftwareTabScope } from "./types"; + +interface InventoryItem { + displayName: string; + normalizedName: string; + normalizedVendor: string; + normalizedVersion: string; + publisher: string; + registrySource: string; + normalizationConfidence: string; + cpeUri: string; + lastSeenAt: string; + sessionCount: number; +} + +interface InventoryResponse { + success: boolean; + tenantId: string; + total: number; + matched: number; + unmatched: number; + inventory: InventoryItem[]; +} + +const PAGE_SIZE = 20; + +export default function InventoryTab({ scope }: { scope: SoftwareTabScope }) { + const { tenantId } = useTenant(); + const { getAccessToken } = useAuth(); + const { addNotification } = useNotifications(); + const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; + + const [resp, setResp] = useState(null); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(""); + const [page, setPage] = useState(0); + + // A Global Admin must pick a concrete tenant — the inventory endpoint is per-tenant, + // there is no cross-tenant aggregate. + const needsTenantPick = isGlobalAdmin && !selectedTenantId; + + useEffect(() => { + if (!scopeInitialized) return; + if (!isGlobalAdmin && !tenantId) return; + if (needsTenantPick) { setResp(null); setLoading(false); return; } + let cancelled = false; + + const run = async () => { + try { + setLoading(true); + const url = isGlobalAdmin + ? api.vulnerability.softwareInventory(selectedTenantId) + : api.metrics.softwareInventory(); + const res = await authenticatedFetch(url, getAccessToken); + if (cancelled) return; + if (res.ok) { + setResp((await res.json()) as InventoryResponse); + setPage(0); + } else { + addNotification("error", "Backend Error", `Failed to load inventory: ${res.statusText}`, "inventory-error"); + } + } catch (err) { + if (cancelled) return; + if (err instanceof TokenExpiredError) { + addNotification("error", "Session Expired", err.message, "session-expired-error"); + } else { + console.error("Failed to fetch software inventory", err); + addNotification("error", "Backend Not Reachable", "Unable to load software inventory.", "inventory-error"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeInitialized, scopeKey, needsTenantPick]); + + const filtered = useMemo(() => { + const items = resp?.inventory ?? []; + const q = search.trim().toLowerCase(); + const rows = q + ? items.filter((i) => + (i.displayName || i.normalizedName).toLowerCase().includes(q) || + i.publisher.toLowerCase().includes(q)) + : items; + return [...rows].sort((a, b) => b.sessionCount - a.sessionCount); + }, [resp, search]); + + const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); + const pageRows = filtered.slice(page * PAGE_SIZE, page * PAGE_SIZE + PAGE_SIZE); + + function formatDate(iso: string) { + if (!iso) return "—"; + const d = new Date(iso); + return Number.isNaN(d.getTime()) ? "—" : d.toLocaleDateString(); + } + + if (needsTenantPick) { + return ( +
+ Select a tenant from the scope selector above to view its installed-software inventory. +
+ ); + } + + return ( + <> + {resp && ( +
+
+
Distinct titles
+
{resp.total}
+
+
+
CPE-mapped
+
{resp.matched}
+
+
+
Unmatched
+
{resp.unmatched}
+
+
+ )} + +
+ { setSearch(e.target.value); setPage(0); }} + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" + /> +
+ +
+ {loading ? ( +
Loading…
+ ) : filtered.length === 0 ? ( +
+ {search ? "No software matches your search." : "No software inventory collected for this tenant yet."} +
+ ) : ( + <> + + + + + + + + + + + + + {pageRows.map((row, idx) => ( + + + + + + + + + ))} + +
SoftwareVersionPublisherSessionsLast seenCPE
{row.displayName || row.normalizedName}{row.normalizedVersion || "—"}{row.publisher || "—"}{row.sessionCount}{formatDate(row.lastSeenAt)} + {row.cpeUri ? ( + mapped + ) : ( + unmatched + )} +
+ {pageCount > 1 && ( +
+ {filtered.length} titles · page {page + 1} of {pageCount} +
+ + +
+
+ )} + + )} +
+ + ); +} diff --git a/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx new file mode 100644 index 00000000..f5c14b75 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/apps/components/VulnerabilitiesTab.tsx @@ -0,0 +1,62 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useAuth } from "../../../contexts/AuthContext"; +import { useNotifications } from "../../../contexts/NotificationContext"; +import { api } from "@/lib/api"; +import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import VulnerabilityExposurePanel, { type VulnerabilitySummary } from "@/components/VulnerabilityExposurePanel"; +import type { SoftwareTabScope, TimeRange } from "./types"; +import { rangeToDays } from "./types"; + +const TOP_N = 20; + +export default function VulnerabilitiesTab({ scope, timeRange }: { scope: SoftwareTabScope; timeRange: TimeRange }) { + const { getAccessToken } = useAuth(); + const { addNotification } = useNotifications(); + const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; + + const [summary, setSummary] = useState(null); + const [loading, setLoading] = useState(true); + + // GA without a specific tenant selected sees the cross-tenant aggregate (incl. affected tenants). + const showTenantCount = isGlobalAdmin && !selectedTenantId; + + useEffect(() => { + if (!scopeInitialized) return; + let cancelled = false; + const days = rangeToDays(timeRange); + + const run = async () => { + try { + setLoading(true); + const url = isGlobalAdmin + ? api.metrics.globalVulnerability(days, TOP_N, selectedTenantId || undefined) + : api.metrics.vulnerability(days, TOP_N); + const res = await authenticatedFetch(url, getAccessToken); + if (cancelled) return; + if (res.ok) { + setSummary((await res.json()) as VulnerabilitySummary); + } else { + addNotification("error", "Backend Error", `Failed to load vulnerability exposure: ${res.statusText}`, "vuln-exposure-error"); + } + } catch (err) { + if (cancelled) return; + if (err instanceof TokenExpiredError) { + addNotification("error", "Session Expired", err.message, "session-expired-error"); + } else { + console.error("Failed to fetch vulnerability exposure", err); + addNotification("error", "Backend Not Reachable", "Unable to load vulnerability exposure.", "vuln-exposure-error"); + } + } finally { + if (!cancelled) setLoading(false); + } + }; + + void run(); + return () => { cancelled = true; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scopeInitialized, scopeKey, timeRange]); + + return ; +} diff --git a/src/Web/autopilot-monitor-web/app/apps/components/types.ts b/src/Web/autopilot-monitor-web/app/apps/components/types.ts new file mode 100644 index 00000000..30f15505 --- /dev/null +++ b/src/Web/autopilot-monitor-web/app/apps/components/types.ts @@ -0,0 +1,10 @@ +import type { useAggregatedAdminScope } from "@/hooks"; + +/** Global-admin tenant scope shared by every Software-hub tab (read values + setters). */ +export type SoftwareTabScope = ReturnType; + +export type TimeRange = "7d" | "30d" | "90d"; + +export function rangeToDays(range: TimeRange): number { + return range === "7d" ? 7 : range === "30d" ? 30 : 90; +} diff --git a/src/Web/autopilot-monitor-web/app/apps/page.tsx b/src/Web/autopilot-monitor-web/app/apps/page.tsx index b699c4e4..cae68d32 100644 --- a/src/Web/autopilot-monitor-web/app/apps/page.tsx +++ b/src/Web/autopilot-monitor-web/app/apps/page.tsx @@ -1,380 +1,102 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; -import { useRouter } from "next/navigation"; +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useState } from "react"; import { ProtectedRoute } from "../../components/ProtectedRoute"; -import { useTenant } from "../../contexts/TenantContext"; -import { useAuth } from "../../contexts/AuthContext"; -import { useNotifications } from "../../contexts/NotificationContext"; -import { api } from "@/lib/api"; -import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { useAggregatedAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; import { TenantScopeSelector } from "@/components/TenantScopeSelector"; +import InstallsTab from "./components/InstallsTab"; +import InventoryTab from "./components/InventoryTab"; +import VulnerabilitiesTab from "./components/VulnerabilitiesTab"; +import type { TimeRange } from "./components/types"; -interface AppRow { - appName: string; - appType: string; - totalInstalls: number; - succeeded: number; - failed: number; - failureRate: number; - avgDurationSeconds: number; - maxDurationSeconds: number; - avgDownloadBytes: number; - trend: "improving" | "worsening" | "stable"; - trendDelta: number | null; - lastSeenAt: string; -} +const TABS = [ + { id: "installs", label: "Installs" }, + { id: "inventory", label: "Inventory" }, + { id: "vulnerabilities", label: "Vulnerabilities" }, +] as const; +type TabId = (typeof TABS)[number]["id"]; -interface AppsListResponse { - success: boolean; - totalApps: number; - totalInstalls: number; - windowDays: number; - apps: AppRow[]; -} - -type SortKey = - | "appName" - | "totalInstalls" - | "succeeded" - | "failed" - | "failureRate" - | "avgDurationSeconds" - | "trend"; -type SortDir = "asc" | "desc"; - -export default function AppsPage() { +function SoftwareHub() { const router = useRouter(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d"); - const [search, setSearch] = useState(""); - const [sortKey, setSortKey] = useState("failureRate"); - const [sortDir, setSortDir] = useState("desc"); - - const { tenantId } = useTenant(); - const { getAccessToken } = useAuth(); - const { addNotification } = useNotifications(); - - // Global admin tenant scope (aggregated-capable): tenant list, selection ("" = all tenants), - // and scope flags. Default selection is the GA's own tenant; user can opt into aggregated. + const searchParams = useSearchParams(); const scope = useAggregatedAdminScope(); - const { isGlobalAdmin, selectedTenantId, scopeInitialized, scopeKey } = scope; + const [timeRange, setTimeRange] = useState("30d"); - const isTimeRangeMount = useRef(true); + const rawTab = searchParams.get("tab"); + const activeTab: TabId = TABS.some((t) => t.id === rawTab) ? (rawTab as TabId) : "installs"; - const fetchApps = async (range: "7d" | "30d" | "90d" = timeRange) => { - // Global admin can fetch aggregated view without selecting a tenant; - // regular users must wait for their own tenantId. - if (!isGlobalAdmin && !tenantId) return; - try { - setLoading(true); - const days = range === "7d" ? 7 : range === "30d" ? 30 : 90; - const url = isGlobalAdmin - ? api.apps.globalList(days, selectedTenantId || undefined) - : api.apps.list(tenantId, days); - const response = await authenticatedFetch(url, getAccessToken); - if (response.ok) { - const json = (await response.json()) as AppsListResponse; - setData(json); - } else { - addNotification( - "error", - "Backend Error", - `Failed to load apps: ${response.statusText}`, - "apps-list-error" - ); - } - } catch (err) { - if (err instanceof TokenExpiredError) { - addNotification("error", "Session Expired", err.message, "session-expired-error"); - } else { - console.error("Failed to fetch apps", err); - addNotification( - "error", - "Backend Not Reachable", - "Unable to load app dashboard data.", - "apps-list-error" - ); - } - } finally { - setLoading(false); - } - }; - - // Fetch whenever the effective scope or time range changes — gated on - // scopeInitialized so we don't do an initial aggregated-fetch before the - // GA default-to-own-tenant kicks in (would otherwise cause a wasted backend hit). - useEffect(() => { - if (!scopeInitialized) return; - if (isTimeRangeMount.current) { - isTimeRangeMount.current = false; - } - fetchApps(timeRange); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [scopeInitialized, timeRange, scopeKey]); - - const filteredAndSorted = useMemo(() => { - if (!data?.apps) return []; - const q = search.trim().toLowerCase(); - let rows = q - ? data.apps.filter((a) => a.appName.toLowerCase().includes(q)) - : [...data.apps]; - - const dir = sortDir === "asc" ? 1 : -1; - rows.sort((a, b) => { - if (sortKey === "appName") { - return a.appName.localeCompare(b.appName) * dir; - } - if (sortKey === "trend") { - const order: Record = { worsening: 0, stable: 1, improving: 2 }; - return (order[a.trend] - order[b.trend]) * dir; - } - const av = a[sortKey] as number; - const bv = b[sortKey] as number; - return (av - bv) * dir; - }); - return rows; - }, [data, search, sortKey, sortDir]); - - const stats = useMemo(() => { - if (!data) return { totalApps: 0, totalInstalls: 0, avgFailureRate: 0 }; - const apps = data.apps ?? []; - const totalFailed = apps.reduce((acc, a) => acc + a.failed, 0); - const totalInstalls = data.totalInstalls; - return { - totalApps: data.totalApps, - totalInstalls, - avgFailureRate: - totalInstalls > 0 ? Math.round((totalFailed / totalInstalls) * 1000) / 10 : 0, - }; - }, [data]); - - function toggleSort(key: SortKey) { - if (sortKey === key) { - setSortDir((d) => (d === "asc" ? "desc" : "asc")); - } else { - setSortKey(key); - setSortDir(key === "appName" ? "asc" : "desc"); - } - } - - function sortIndicator(key: SortKey) { - if (sortKey !== key) return null; - return {sortDir === "asc" ? "↑" : "↓"}; - } - - function trendBadge(row: AppRow) { - if (row.trend === "improving") { - return ( - - ↓ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp - - ); - } - if (row.trend === "worsening") { - return ( - - ↑ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp - - ); - } - return —; - } - - function appTypeBadge(type: string) { - if (!type) return null; - const color = - type === "WinGet" - ? "bg-blue-100 text-blue-800" - : type === "MSI" - ? "bg-purple-100 text-purple-800" - : type === "Win32" - ? "bg-gray-100 text-gray-800" - : "bg-amber-100 text-amber-800"; - return ( - {type} - ); - } - - function formatDuration(s: number) { - if (!s) return "—"; - if (s < 60) return `${Math.round(s)}s`; - if (s < 3600) return `${Math.round(s / 60)}m`; - return `${(s / 3600).toFixed(1)}h`; + function selectTab(id: TabId) { + const params = new URLSearchParams(searchParams.toString()); + params.set("tab", id); + router.replace(`/apps?${params.toString()}`); } + return ( +
+ +
+
+
+
+

Software

+

+ App installs, the installed-software inventory, and vulnerability exposure across enrollments. +

+
+
+ + {(["7d", "30d", "90d"] as const).map((range) => ( + + ))} +
+
+ + {/* Lens tabs — installs / inventory / vulnerabilities, synced to ?tab= */} + +
+
+ +
+ {activeTab === "installs" && } + {activeTab === "inventory" && } + {activeTab === "vulnerabilities" && } +
+
+ ); +} + +export default function SoftwarePage() { return ( -
- -
-
-
-
-

App Health

-

- All apps observed across enrollments, sortable by failure rate, duration, and trend. -

-
-
- - {(["7d", "30d", "90d"] as const).map((range) => ( - - ))} -
-
-
-
- -
- {/* Stat cards */} -
-
-
Total Apps
-
{stats.totalApps}
-
-
-
Total Installs
-
{stats.totalInstalls}
-
-
-
Avg Failure Rate
-
- {stats.avgFailureRate.toFixed(1)}% -
-
-
- - {/* Search */} -
- setSearch(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" - /> -
- - {/* Table */} -
- {loading ? ( -
Loading…
- ) : filteredAndSorted.length === 0 ? ( -
- {search ? "No apps match your search." : "No app install data in this window."} -
- ) : ( - - - - - - - - - - - - - - {filteredAndSorted.map((row) => ( - { - const days = timeRange === "7d" ? 7 : timeRange === "30d" ? 30 : 90; - const params = new URLSearchParams({ days: String(days) }); - // In Global Admin mode, propagate scope: - // - aggregated view → ?global=1 - // - specific tenant → ?global=1&tenantId=... - if (isGlobalAdmin) { - params.set("global", "1"); - if (selectedTenantId) params.set("tenantId", selectedTenantId); - } - router.push(`/apps/${encodeURIComponent(row.appName)}?${params.toString()}`); - }} - className="hover:bg-gray-50 cursor-pointer text-sm" - > - - - - - - - - - ))} - -
toggleSort("appName")} - > - App {sortIndicator("appName")} - toggleSort("totalInstalls")} - > - Installs {sortIndicator("totalInstalls")} - toggleSort("succeeded")} - > - Succeeded {sortIndicator("succeeded")} - toggleSort("failed")} - > - Failed {sortIndicator("failed")} - toggleSort("failureRate")} - > - Failure Rate {sortIndicator("failureRate")} - toggleSort("avgDurationSeconds")} - > - Avg Duration {sortIndicator("avgDurationSeconds")} - toggleSort("trend")} - > - Trend {sortIndicator("trend")} -
- {row.appName} - {appTypeBadge(row.appType)} - {row.totalInstalls}{row.succeeded}{row.failed} - = 20 - ? "text-red-700 font-semibold" - : row.failureRate >= 5 - ? "text-amber-700" - : "text-gray-700" - } - > - {row.failureRate.toFixed(1)}% - - - {formatDuration(row.avgDurationSeconds)} - {trendBadge(row)}
- )} -
-
-
+ {/* useSearchParams requires a Suspense boundary under the App Router. */} + }> + +
); } diff --git a/src/Web/autopilot-monitor-web/components/DoBreakdownBar.tsx b/src/Web/autopilot-monitor-web/components/DoBreakdownBar.tsx new file mode 100644 index 00000000..272b87a5 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/DoBreakdownBar.tsx @@ -0,0 +1,73 @@ +"use client"; + +import { formatBytes } from "@/lib/formatting"; + +interface DoBreakdownBarProps { + /** Bytes served from DO peers. */ + peers: number; + /** Bytes served from a Microsoft Connected Cache (MCC) node. */ + cacheServer: number; + /** Bytes served from HTTP/CDN (the "pure CDN" remainder; net of MCC). */ + http: number; + /** + * Denominator for the segment widths. Defaults to peers + cacheServer + http. + * DownloadProgress passes the DO-reported file size so any unaccounted bytes + * fall into the HTTP remainder — preserving its original per-app bar math. + */ + total?: number; + /** MCC node URI/IP, surfaced in the segment tooltip when present. */ + cacheHost?: string; + /** Render a compact "Peers · MCC · CDN" byte legend under the bar. */ + showLegend?: boolean; + className?: string; +} + +/** + * Three-segment bar showing how a download was sourced: peers (green) | MCC + * (purple) | HTTP/CDN (blue). HTTP is the remainder of the bar so any total not + * covered by peers/MCC reads as CDN. Shared by the per-app session view + * (DownloadProgress) and the aggregated Delivery Optimization card. + */ +export default function DoBreakdownBar({ + peers, + cacheServer, + http, + total, + cacheHost, + showLegend = false, + className, +}: DoBreakdownBarProps) { + const denom = total != null && total > 0 ? total : Math.max(1, peers + cacheServer + http); + const peerPct = (peers / denom) * 100; + const cachePct = (cacheServer / denom) * 100; + const httpPct = Math.max(0, 100 - peerPct - cachePct); + + return ( +
+
+
+
+
+
+ {showLegend && ( +
+ Peers {formatBytes(peers)} + MCC {formatBytes(cacheServer)} + CDN {formatBytes(http)} +
+ )} +
+ ); +} diff --git a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx index a764775f..aecaf000 100644 --- a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx @@ -2,6 +2,8 @@ import { useMemo, useState } from "react"; import { shouldSkipLowBytesTotal, shouldSkipNoActivity } from "@/lib/downloadProgressFilters"; +import { formatBytes, formatThroughput, formatDuration } from "@/lib/formatting"; +import DoBreakdownBar from "./DoBreakdownBar"; interface DownloadEvent { timestamp: string; @@ -53,22 +55,6 @@ interface DownloadItem { doStats?: DoStats | null; } -function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${(bytes / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; -} - -function formatSpeed(bps: number): string { - if (bps === 0) return "0 B/s"; - const k = 1024; - const sizes = ["B/s", "KB/s", "MB/s", "GB/s"]; - const i = Math.floor(Math.log(bps) / Math.log(k)); - return `${(bps / Math.pow(k, i)).toFixed(1)} ${sizes[i]}`; -} - function formatDoMode(mode: number): string { switch (mode) { case 0: return "Background"; @@ -80,17 +66,6 @@ function formatDoMode(mode: number): string { } } -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - if (seconds < 60) return `${seconds}s`; - const minutes = Math.floor(seconds / 60); - const remainingSeconds = seconds % 60; - if (minutes < 60) return `${minutes}m ${remainingSeconds}s`; - const hours = Math.floor(minutes / 60); - const remainingMinutes = minutes % 60; - return `${hours}h ${remainingMinutes}m`; -} - function parseDoDurationMs(duration: string): number { if (!duration) return 0; const match = duration.match(/^(\d+):(\d+):(\d+)\.?(\d+)?$/); @@ -105,7 +80,7 @@ function parseDoDurationMs(duration: string): number { function formatDoDuration(duration: string): string { const ms = parseDoDurationMs(duration); if (ms <= 0) return duration || "N/A"; - return formatDuration(ms); + return formatDuration(Math.floor(ms / 1000)); } export default function DownloadProgress({ events, summaryStats }: DownloadProgressProps) { @@ -288,7 +263,7 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr )} {totalDuration != null && ( - — Total: {formatDuration(totalDuration)} + — Total: {formatDuration(Math.floor(totalDuration / 1000))} )} {totalP2P != null && ( @@ -376,7 +351,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
{!dl.isComplete && !dl.isSkipped && dl.downloadRateBps > 0 && ( - {formatSpeed(dl.downloadRateBps)} + {formatThroughput(dl.downloadRateBps, "0 B/s")} )} {dl.eventData && Object.keys(dl.eventData).length > 0 && (
)}
- {/* Peers (green) | MCC (purple) | HTTP (blue) */} -
-
-
-
-
+
); })()} diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx new file mode 100644 index 00000000..6b233c7a --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { severityChipClass, KEV_BADGE_CLASS } from "@/lib/vulnerabilitySeverity"; + +export interface VulnerabilityTopCve { + cveId: string; + cvssSeverity: string; + cvssScore: number; + isKev: boolean; + affectedSessions: number; + sampleSoftware: string[]; +} + +export interface VulnerabilitySummary { + windowDays: number; + totalAffectedSessions: number; + totalAffectedTenants: number | null; + distinctCves: number; + kevCves: number; + severityBreakdown: { critical: number; high: number; medium: number; low: number }; + topCves: VulnerabilityTopCve[]; + truncated: boolean; +} + +interface Props { + summary: VulnerabilitySummary | null; + loading?: boolean; + /** Show the "Affected tenants" KPI (cross-tenant / Global Admin views only). */ + showTenantCount?: boolean; + /** Optional header controls (used by the GA admin section); hidden when omitted. */ + days?: number; + onDaysChange?: (days: number) => void; + onRefresh?: () => void; + refreshing?: boolean; + title?: string; +} + +/** + * Read-only fleet/tenant vulnerability exposure: affected-device + CVE/KEV KPIs, a severity + * breakdown, and the top CVEs ranked by affected devices. Shared by the GA admin section and the + * tenant-facing Software › Vulnerabilities tab so both render identical chips/CVE rows. + */ +export default function VulnerabilityExposurePanel({ + summary, + loading = false, + showTenantCount = false, + days, + onDaysChange, + onRefresh, + refreshing = false, + title = "Vulnerability Exposure", +}: Props) { + const hasFindings = summary != null && summary.distinctCves > 0; + + return ( +
+
+

{title}

+ {(onDaysChange || onRefresh) && ( +
+ {onDaysChange && ( + + )} + {onRefresh && ( + + )} +
+ )} +
+ + {loading ? ( +
Loading…
+ ) : !hasFindings ? ( +
+ No vulnerability findings in this window. An empty result means no detected CVEs — it does + not by itself confirm the fleet is unaffected (vulnerability scanning must be enabled). +
+ ) : ( +
+ {/* KPI tiles */} +
+ + + + {showTenantCount && } +
+ + {/* Severity breakdown */} +
+ {([ + ["Critical", summary!.severityBreakdown.critical], + ["High", summary!.severityBreakdown.high], + ["Medium", summary!.severityBreakdown.medium], + ["Low", summary!.severityBreakdown.low], + ] as Array<[string, number]>).map(([label, count]) => ( + + {label}: {count.toLocaleString()} + + ))} +
+ + {/* Top CVEs */} +
+
Top CVEs by affected devices
+
+ {summary!.topCves.map((c) => ( +
+
+
+ + {c.cveId} + + + {c.cvssSeverity || "Unknown"}{c.cvssScore > 0 ? ` ${c.cvssScore.toFixed(1)}` : ""} + + {c.isKev && ( + KEV + )} +
+ {c.sampleSoftware.length > 0 && ( +
{c.sampleSoftware.join(", ")}
+ )} +
+
+
{c.affectedSessions.toLocaleString()}
+
devices
+
+
+ ))} +
+
+ + {summary!.truncated && ( +
+ Counts are a lower bound — the underlying scan hit its cap. Narrow the window for exact figures. +
+ )} +
+ )} +
+ ); +} + +function Kpi({ value, label }: { value: number; label: string }) { + return ( +
+
{value.toLocaleString()}
+
{label}
+
+ ); +} diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/formatting.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/formatting.test.ts new file mode 100644 index 00000000..aeeaaf5f --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/formatting.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes, formatThroughput, formatDuration, formatPercent } from "../formatting"; + +describe("formatBytes", () => { + it("returns the zero sentinel for 0 / negative / non-finite", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(-5)).toBe("0 B"); + expect(formatBytes(NaN)).toBe("0 B"); + expect(formatBytes(0, "—")).toBe("—"); + }); + + it("uses 0 decimals for raw bytes, 1 decimal from KB up", () => { + expect(formatBytes(512)).toBe("512 B"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(2.3 * 1024 ** 3)).toBe("2.3 GB"); + }); + + it("caps the unit at TB", () => { + expect(formatBytes(5 * 1024 ** 4)).toBe("5.0 TB"); + expect(formatBytes(5 * 1024 ** 5)).toMatch(/TB$/); + }); +}); + +describe("formatThroughput", () => { + it("appends /s and dashes on no data", () => { + expect(formatThroughput(1.5 * 1024 * 1024)).toBe("1.5 MB/s"); + expect(formatThroughput(0)).toBe("—"); + }); +}); + +describe("formatDuration", () => { + it("dashes/zero on non-positive", () => { + expect(formatDuration(0)).toBe("0s"); + expect(formatDuration(-1, "—")).toBe("—"); + }); + + it("formats seconds, minutes, hours", () => { + expect(formatDuration(45)).toBe("45s"); + expect(formatDuration(150)).toBe("2m 30s"); + expect(formatDuration(3 * 3600 + 25 * 60)).toBe("3h 25m"); + }); +}); + +describe("formatPercent", () => { + it("computes a ratio percentage", () => { + expect(formatPercent(400, 1000)).toBe("40%"); + expect(formatPercent(1, 3, 1)).toBe("33.3%"); + }); + + it("dashes on zero denominator", () => { + expect(formatPercent(5, 0)).toBe("—"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/__tests__/vulnerabilitySeverity.test.ts b/src/Web/autopilot-monitor-web/lib/__tests__/vulnerabilitySeverity.test.ts new file mode 100644 index 00000000..5f48474b --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/__tests__/vulnerabilitySeverity.test.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from "vitest"; +import { severityChipClass, KEV_BADGE_CLASS } from "../vulnerabilitySeverity"; + +describe("severityChipClass", () => { + it("maps known severities case-insensitively", () => { + expect(severityChipClass("Critical")).toContain("red"); + expect(severityChipClass("high")).toContain("orange"); + expect(severityChipClass("MEDIUM")).toContain("amber"); + }); + + it("falls back to neutral for low/unknown", () => { + expect(severityChipClass("low")).toContain("gray"); + expect(severityChipClass("")).toContain("gray"); + }); +}); + +describe("KEV_BADGE_CLASS", () => { + it("is a red-toned chip", () => { + expect(KEV_BADGE_CLASS).toContain("red"); + }); +}); diff --git a/src/Web/autopilot-monitor-web/lib/api.ts b/src/Web/autopilot-monitor-web/lib/api.ts index f6da3855..76986a79 100644 --- a/src/Web/autopilot-monitor-web/lib/api.ts +++ b/src/Web/autopilot-monitor-web/lib/api.ts @@ -220,8 +220,13 @@ export const api = { `${API_BASE_URL}/api/metrics/rule-stats${qs({ startDate, endDate, ruleType })}`, globalRuleStats: (startDate?: string, endDate?: string, ruleType?: string, tenantId?: string) => `${API_BASE_URL}/api/global/metrics/rule-stats${qs({ startDate, endDate, ruleType, tenantId })}`, + vulnerability: (days?: number, topN?: number) => + `${API_BASE_URL}/api/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString() })}`, globalVulnerability: (days?: number, topN?: number, tenantId?: string) => `${API_BASE_URL}/api/global/metrics/vulnerability${qs({ days: days?.toString(), topN: topN?.toString(), tenantId })}`, + // Tenant-scoped installed-software inventory (JWT-scoped; MemberRead). + softwareInventory: () => + `${API_BASE_URL}/api/metrics/software-inventory`, sla: (tenantId?: string, months?: number, fresh?: boolean) => `${API_BASE_URL}/api/metrics/sla${qs({ tenantId, months: months?.toString(), fresh: fresh ? "1" : undefined })}`, globalSla: (tenantId: string, months?: number, fresh?: boolean) => @@ -471,6 +476,9 @@ export const api = { take: take !== undefined ? String(take) : undefined, })}`, ignoredSoftware: () => `${API_BASE_URL}/api/vulnerability/ignored-software`, + // Global-Admin per-tenant software inventory (cross-tenant; tenantId required). + softwareInventory: (tenantId: string) => + `${API_BASE_URL}/api/vulnerability/software-inventory${qs({ tenantId })}`, }, // ── MCP Users ───────────────────────────────────────────────────────────── diff --git a/src/Web/autopilot-monitor-web/lib/formatting.ts b/src/Web/autopilot-monitor-web/lib/formatting.ts new file mode 100644 index 00000000..c379ef94 --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/formatting.ts @@ -0,0 +1,52 @@ +/** + * Canonical formatting helpers for bytes, throughput, durations and percentages. + * + * Before this module these were copy-pasted ~10x across pages/components with + * subtle divergences (decimals, zero-handling, GB-vs-TB ceiling). Centralizing + * keeps the fleet's byte/duration strings consistent. Callers that want a dash + * for "no data" pass it via the `zero` argument. + */ + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; + +/** + * Human-readable byte size. 0 decimals for raw bytes, 1 decimal for KB and up + * (e.g. "0 B", "512 B", "1.5 KB", "2.3 GB"). Non-finite or <= 0 returns `zero`. + */ +export function formatBytes(bytes: number, zero = "0 B"): string { + if (!Number.isFinite(bytes) || bytes <= 0) return zero; + const i = Math.min(BYTE_UNITS.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const val = bytes / Math.pow(1024, i); + return `${val.toFixed(i === 0 ? 0 : 1)} ${BYTE_UNITS[i]}`; +} + +/** Transfer rate from a bytes-per-second value (e.g. "2.3 MB/s"). <= 0 returns `zero`. */ +export function formatThroughput(bytesPerSec: number, zero = "—"): string { + if (!Number.isFinite(bytesPerSec) || bytesPerSec <= 0) return zero; + return `${formatBytes(bytesPerSec)}/s`; +} + +/** + * Duration from a SECONDS value: "Xs" (< 1 min), "Xm Ys" (< 1 h), else "Xh Ym". + * Callers holding milliseconds should pass `ms / 1000`. <= 0 returns `zero`. + */ +export function formatDuration(seconds: number, zero = "0s"): string { + if (!Number.isFinite(seconds) || seconds <= 0) return zero; + const s = Math.floor(seconds); + if (s < 60) return `${s}s`; + const minutes = Math.floor(s / 60); + const remSeconds = s % 60; + if (minutes < 60) return `${minutes}m ${remSeconds}s`; + const hours = Math.floor(minutes / 60); + const remMinutes = minutes % 60; + return `${hours}h ${remMinutes}m`; +} + +/** + * Percentage string from a ratio's numerator/denominator (e.g. 400/1000 -> "40%"). + * Denominator <= 0 returns `zero`. `decimals` controls precision (default 0). + */ +export function formatPercent(numerator: number, denominator: number, decimals = 0, zero = "—"): string { + if (!Number.isFinite(numerator) || !Number.isFinite(denominator) || denominator <= 0) return zero; + return `${((numerator / denominator) * 100).toFixed(decimals)}%`; +} diff --git a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx index e2be7f46..b81ef71b 100644 --- a/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx +++ b/src/Web/autopilot-monitor-web/lib/globalNavConfig.tsx @@ -138,7 +138,7 @@ export const NAV_GROUPS: NavGroup[] = [ visibility: "adminOrOperator", items: [ { id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: }, - { id: "apps", label: "App Health", href: "/apps", icon: }, + { id: "apps", label: "Software", href: "/apps", icon: }, { id: "usage-metrics", label: "Usage Metrics", href: "/usage-metrics", icon: }, { id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: }, { id: "sla", label: "SLA Compliance", href: "/sla", icon: }, diff --git a/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts new file mode 100644 index 00000000..77be9d8c --- /dev/null +++ b/src/Web/autopilot-monitor-web/lib/vulnerabilitySeverity.ts @@ -0,0 +1,23 @@ +/** + * Shared CVSS severity + KEV badge styling. Extracted from VulnerabilityDataSection + * so the GA admin view and the tenant-facing Vulnerabilities tab render identical + * chips without drift. + */ + +/** Tailwind chip classes for a CVSS severity label (critical/high/medium/low; anything else = neutral). */ +export function severityChipClass(severity: string): string { + switch (severity.toLowerCase()) { + case "critical": + return "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700"; + case "high": + return "bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-300 border-orange-300 dark:border-orange-700"; + case "medium": + return "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300 border-amber-300 dark:border-amber-700"; + default: + return "bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300 border-gray-300 dark:border-gray-600"; + } +} + +/** Tailwind classes for the CISA Known-Exploited (KEV) badge. */ +export const KEV_BADGE_CLASS = + "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700";