feat(web): merge App Health into a tabbed "Software" hub

/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) <noreply@anthropic.com>
This commit is contained in:
Oliver Kieselbach
2026-06-07 17:51:59 +02:00
parent fed162946e
commit e4c41e03ee
15 changed files with 1098 additions and 555 deletions
@@ -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({
</div>
{/* Fleet Vulnerability Exposure (read-only cross-tenant overview) */}
<div className="bg-emerald-50 dark:bg-gray-700 border border-emerald-200 dark:border-emerald-600 rounded-lg p-4">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="text-sm font-medium text-emerald-900 dark:text-emerald-100">Fleet Vulnerability Exposure</h3>
<div className="flex items-center gap-2">
<select
value={summaryDays}
onChange={(e) => setSummaryDays(Number(e.target.value))}
className="text-xs rounded-md border border-emerald-300 dark:border-emerald-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-emerald-500"
aria-label="Time window in days"
>
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
<option value={365}>Last 365 days</option>
</select>
<button
type="button"
onClick={() => void refreshSummary()}
disabled={loadingSummary}
className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md text-emerald-700 dark:text-emerald-300 border border-emerald-400 dark:border-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
title="Refresh exposure overview"
>
{loadingSummary ? "Refreshing..." : "Refresh"}
</button>
</div>
</div>
{loadingSummary && !summary ? (
<div className="text-center py-6">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-emerald-600 dark:border-emerald-400 mx-auto"></div>
</div>
) : summary && summary.distinctCves > 0 ? (
<div className="space-y-4">
{summary.truncated && (
<div className="p-2 rounded text-xs bg-amber-50 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300 border border-amber-200 dark:border-amber-800">
Showing partial results — the index scan hit its cap, so counts are a lower bound.
</div>
)}
{/* KPI tiles */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-900 dark:text-emerald-100">{summary.totalAffectedSessions.toLocaleString()}</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">Affected devices</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-900 dark:text-emerald-100">{summary.distinctCves.toLocaleString()}</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">Distinct CVEs</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3">
<div className="text-2xl font-bold text-red-700 dark:text-red-400">{summary.kevCves.toLocaleString()}</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">KEV CVEs (exploited)</div>
</div>
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-900 dark:text-emerald-100">
{summary.totalAffectedTenants !== null ? summary.totalAffectedTenants.toLocaleString() : "—"}
</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">Affected tenants</div>
</div>
</div>
{/* Severity breakdown */}
<div className="flex flex-wrap gap-2">
{([
["Critical", summary.severityBreakdown.critical],
["High", summary.severityBreakdown.high],
["Medium", summary.severityBreakdown.medium],
["Low", summary.severityBreakdown.low],
] as Array<[string, number]>).map(([label, count]) => (
<span
key={label}
className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full border text-xs font-medium ${severityChipClass(label)}`}
>
{label}: {count.toLocaleString()}
</span>
))}
</div>
{/* Top CVEs */}
<div>
<div className="text-xs font-medium text-emerald-800 dark:text-emerald-200 mb-2">Top CVEs by affected devices</div>
<div className="space-y-1.5">
{summary.topCves.map((c) => (
<div
key={c.cveId}
className="flex items-center justify-between gap-3 bg-white dark:bg-gray-800 border border-emerald-100 dark:border-emerald-800 rounded-md px-3 py-2"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<a
href={`https://nvd.nist.gov/vuln/detail/${encodeURIComponent(c.cveId)}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs text-emerald-700 dark:text-emerald-300 underline hover:text-emerald-900 dark:hover:text-emerald-100"
>
{c.cveId}
</a>
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium ${severityChipClass(c.cvssSeverity)}`}>
{c.cvssSeverity || "Unknown"}{c.cvssScore > 0 ? ` ${c.cvssScore.toFixed(1)}` : ""}
</span>
{c.isKev && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-semibold bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300 border-red-300 dark:border-red-700">
KEV
</span>
)}
</div>
{c.sampleSoftware.length > 0 && (
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate mt-0.5">
{c.sampleSoftware.join(", ")}
</div>
)}
</div>
<div className="text-right flex-shrink-0">
<div className="text-sm font-semibold text-emerald-900 dark:text-emerald-100">{c.affectedSessions.toLocaleString()}</div>
<div className="text-[10px] text-emerald-700 dark:text-emerald-300">devices</div>
</div>
</div>
))}
</div>
</div>
</div>
) : (
<p className="text-sm text-emerald-800 dark:text-gray-300 py-2">
No vulnerability findings in the last {summary?.windowDays ?? summaryDays} days.
{!vulnerabilityCorrelationEnabled && " Correlation is currently disabled."}
</p>
)}
</div>
<VulnerabilityExposurePanel
title="Fleet Vulnerability Exposure"
summary={summary}
loading={loadingSummary && !summary}
showTenantCount
days={summaryDays}
onDaysChange={setSummaryDays}
onRefresh={() => void refreshSummary()}
refreshing={loadingSummary}
/>
{/* NVD Attribution Notice */}
<div className="bg-gray-100 dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-lg p-3">
@@ -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<AppsListResponse | null>(null);
const [doRollup, setDoRollup] = useState<DeliveryOptimizationRollup | null>(null);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("failureRate");
const [sortDir, setSortDir] = useState<SortDir>("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<AppRow[]>(() => {
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<string, number> = { 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 <span className="ml-1 text-xs">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
function trendBadge(row: AppRow) {
if (row.trend === "improving") {
return <span className="inline-flex items-center text-emerald-700 text-xs">↓ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp</span>;
}
if (row.trend === "worsening") {
return <span className="inline-flex items-center text-red-700 text-xs">↑ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp</span>;
}
return <span className="text-xs text-gray-500">—</span>;
}
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 <span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-xs ${color}`}>{type}</span>;
}
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 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Total Apps</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{stats.totalApps}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Total Installs</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{stats.totalInstalls}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Avg Failure Rate</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{stats.avgFailureRate.toFixed(1)}%</div>
</div>
</div>
{/* Delivery Optimization rollup */}
<DoCard rollup={doRollup} days={rangeToDays(timeRange)} />
{/* Search */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<input
type="text"
placeholder="Search apps by name…"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading…</div>
) : filteredAndSorted.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{search ? "No apps match your search." : "No app install data in this window."}
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3 cursor-pointer select-none" onClick={() => toggleSort("appName")}>App {sortIndicator("appName")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("totalInstalls")}>Installs {sortIndicator("totalInstalls")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("succeeded")}>Succeeded {sortIndicator("succeeded")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failed")}>Failed {sortIndicator("failed")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("failureRate")}>Failure Rate {sortIndicator("failureRate")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("avgDurationSeconds")}>Avg Duration {sortIndicator("avgDurationSeconds")}</th>
<th className="px-4 py-3 cursor-pointer select-none text-right" onClick={() => toggleSort("trend")}>Trend {sortIndicator("trend")}</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredAndSorted.map((row) => (
<tr key={row.appName} onClick={() => openApp(row.appName)} className="hover:bg-gray-50 cursor-pointer text-sm">
<td className="px-4 py-3 text-gray-900">
<span className="font-medium">{row.appName}</span>
{appTypeBadge(row.appType)}
</td>
<td className="px-4 py-3 text-right text-gray-700">{row.totalInstalls}</td>
<td className="px-4 py-3 text-right text-emerald-700">{row.succeeded}</td>
<td className="px-4 py-3 text-right text-red-700">{row.failed}</td>
<td className="px-4 py-3 text-right">
<span className={row.failureRate >= 20 ? "text-red-700 font-semibold" : row.failureRate >= 5 ? "text-amber-700" : "text-gray-700"}>
{row.failureRate.toFixed(1)}%
</span>
</td>
<td className="px-4 py-3 text-right text-gray-700">{formatDuration(row.avgDurationSeconds, "—")}</td>
<td className="px-4 py-3 text-right">{trendBadge(row)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</>
);
}
/** 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 (
<div className="bg-white rounded-lg shadow p-4 mb-6">
<div className="flex items-center justify-between mb-3">
<h2 className="text-sm font-medium text-gray-700">Delivery Optimization</h2>
<span className="text-xs text-gray-400">peer / Connected Cache offload · last {days}d</span>
</div>
<div className="flex flex-wrap items-end gap-x-8 gap-y-2 mb-3">
<div>
<div className="text-xs text-gray-500">Peer offload</div>
<div className={`text-2xl font-semibold ${offloadClass}`}>{offload.toFixed(1)}%</div>
</div>
<div>
<div className="text-xs text-gray-500">Saved from peers + MCC</div>
<div className="text-2xl font-semibold text-gray-900">{formatBytes(rollup.fromPeers + rollup.fromCacheServer)}</div>
</div>
<div>
<div className="text-xs text-gray-500">Total downloaded</div>
<div className="text-2xl font-semibold text-gray-900">{formatBytes(total)}</div>
</div>
</div>
<DoBreakdownBar peers={rollup.fromPeers} cacheServer={rollup.fromCacheServer} http={pureCdn} total={total} showLegend />
</div>
);
}
@@ -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<InventoryResponse | null>(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<InventoryItem[]>(() => {
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 (
<div className="bg-white rounded-lg shadow p-8 text-center text-gray-500">
Select a tenant from the scope selector above to view its installed-software inventory.
</div>
);
}
return (
<>
{resp && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Distinct titles</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{resp.total}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">CPE-mapped</div>
<div className="text-2xl font-semibold text-emerald-700 mt-1">{resp.matched}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Unmatched</div>
<div className="text-2xl font-semibold text-amber-700 mt-1">{resp.unmatched}</div>
</div>
</div>
)}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<input
type="text"
placeholder="Search software by name or publisher…"
value={search}
onChange={(e) => { 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"
/>
</div>
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading…</div>
) : filtered.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{search ? "No software matches your search." : "No software inventory collected for this tenant yet."}
</div>
) : (
<>
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th className="px-4 py-3">Software</th>
<th className="px-4 py-3">Version</th>
<th className="px-4 py-3">Publisher</th>
<th className="px-4 py-3 text-right">Sessions</th>
<th className="px-4 py-3 text-right">Last seen</th>
<th className="px-4 py-3 text-right">CPE</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{pageRows.map((row, idx) => (
<tr key={`${row.normalizedVendor}|${row.normalizedName}|${row.normalizedVersion}|${idx}`} className="text-sm">
<td className="px-4 py-3 text-gray-900 font-medium">{row.displayName || row.normalizedName}</td>
<td className="px-4 py-3 text-gray-700">{row.normalizedVersion || "—"}</td>
<td className="px-4 py-3 text-gray-700">{row.publisher || "—"}</td>
<td className="px-4 py-3 text-right text-gray-700">{row.sessionCount}</td>
<td className="px-4 py-3 text-right text-gray-500">{formatDate(row.lastSeenAt)}</td>
<td className="px-4 py-3 text-right">
{row.cpeUri ? (
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-emerald-100 text-emerald-800">mapped</span>
) : (
<span className="inline-block px-1.5 py-0.5 rounded text-xs bg-amber-100 text-amber-800">unmatched</span>
)}
</td>
</tr>
))}
</tbody>
</table>
{pageCount > 1 && (
<div className="flex items-center justify-between px-4 py-3 text-sm text-gray-600 border-t border-gray-200">
<span>{filtered.length} titles · page {page + 1} of {pageCount}</span>
<div className="flex gap-2">
<button onClick={() => setPage((p) => Math.max(0, p - 1))} disabled={page === 0}
className="px-3 py-1 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed">Previous</button>
<button onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))} disabled={page >= pageCount - 1}
className="px-3 py-1 rounded-md bg-gray-100 hover:bg-gray-200 disabled:opacity-50 disabled:cursor-not-allowed">Next</button>
</div>
</div>
)}
</>
)}
</div>
</>
);
}
@@ -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<VulnerabilitySummary | null>(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 <VulnerabilityExposurePanel summary={summary} loading={loading} showTenantCount={showTenantCount} />;
}
@@ -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<typeof useAggregatedAdminScope>;
export type TimeRange = "7d" | "30d" | "90d";
export function rangeToDays(range: TimeRange): number {
return range === "7d" ? 7 : range === "30d" ? 30 : 90;
}
+83 -361
View File
@@ -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<AppsListResponse | null>(null);
const [loading, setLoading] = useState(true);
const [timeRange, setTimeRange] = useState<"7d" | "30d" | "90d">("30d");
const [search, setSearch] = useState("");
const [sortKey, setSortKey] = useState<SortKey>("failureRate");
const [sortDir, setSortDir] = useState<SortDir>("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<TimeRange>("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<AppRow[]>(() => {
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<string, number> = { 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 <span className="ml-1 text-xs">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
function trendBadge(row: AppRow) {
if (row.trend === "improving") {
return (
<span className="inline-flex items-center text-emerald-700 text-xs">
↓ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp
</span>
);
}
if (row.trend === "worsening") {
return (
<span className="inline-flex items-center text-red-700 text-xs">
↑ {Math.abs(row.trendDelta ?? 0).toFixed(1)} pp
</span>
);
}
return <span className="text-xs text-gray-500">—</span>;
}
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 (
<span className={`ml-2 inline-block px-1.5 py-0.5 rounded text-xs ${color}`}>{type}</span>
);
}
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 (
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto pt-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-normal text-gray-900">Software</h1>
<p className="text-sm text-gray-500 mt-1">
App installs, the installed-software inventory, and vulnerability exposure across enrollments.
</p>
</div>
<div className="flex items-center gap-3">
<TenantScopeSelector scope={scope} allowAggregated />
{(["7d", "30d", "90d"] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
timeRange === range ? "bg-blue-600 text-white" : "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
</button>
))}
</div>
</div>
{/* Lens tabs — installs / inventory / vulnerabilities, synced to ?tab= */}
<nav className="mt-4 flex gap-6 border-b border-gray-200">
{TABS.map((t) => (
<button
key={t.id}
onClick={() => selectTab(t.id)}
className={`-mb-px border-b-2 px-1 py-3 text-sm font-medium transition-colors ${
activeTab === t.id
? "border-blue-600 text-blue-700"
: "border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300"
}`}
>
{t.label}
</button>
))}
</nav>
</div>
</header>
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{activeTab === "installs" && <InstallsTab scope={scope} timeRange={timeRange} />}
{activeTab === "inventory" && <InventoryTab scope={scope} />}
{activeTab === "vulnerabilities" && <VulnerabilitiesTab scope={scope} timeRange={timeRange} />}
</main>
</div>
);
}
export default function SoftwarePage() {
return (
<ProtectedRoute>
<div className="min-h-screen bg-gray-50">
<GlobalAdminBanner show={scope.isGlobalAdmin} subtitle={globalAdminSubtitle(scope)} />
<header className="bg-white shadow">
<div className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-normal text-gray-900">App Health</h1>
<p className="text-sm text-gray-500 mt-1">
All apps observed across enrollments, sortable by failure rate, duration, and trend.
</p>
</div>
<div className="flex items-center gap-3">
<TenantScopeSelector scope={scope} allowAggregated />
{(["7d", "30d", "90d"] as const).map((range) => (
<button
key={range}
onClick={() => setTimeRange(range)}
className={`px-4 py-2 text-sm rounded-md transition-colors ${
timeRange === range
? "bg-blue-600 text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
{range === "7d" ? "7 Days" : range === "30d" ? "30 Days" : "90 Days"}
</button>
))}
</div>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
{/* Stat cards */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 mb-6">
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Total Apps</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{stats.totalApps}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Total Installs</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">{stats.totalInstalls}</div>
</div>
<div className="bg-white rounded-lg shadow p-4">
<div className="text-sm text-gray-500">Avg Failure Rate</div>
<div className="text-2xl font-semibold text-gray-900 mt-1">
{stats.avgFailureRate.toFixed(1)}%
</div>
</div>
</div>
{/* Search */}
<div className="bg-white rounded-lg shadow mb-4 p-4">
<input
type="text"
placeholder="Search apps by name…"
value={search}
onChange={(e) => 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"
/>
</div>
{/* Table */}
<div className="bg-white rounded-lg shadow overflow-hidden">
{loading ? (
<div className="p-8 text-center text-gray-500">Loading…</div>
) : filteredAndSorted.length === 0 ? (
<div className="p-8 text-center text-gray-500">
{search ? "No apps match your search." : "No app install data in this window."}
</div>
) : (
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr className="text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
<th
className="px-4 py-3 cursor-pointer select-none"
onClick={() => toggleSort("appName")}
>
App {sortIndicator("appName")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("totalInstalls")}
>
Installs {sortIndicator("totalInstalls")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("succeeded")}
>
Succeeded {sortIndicator("succeeded")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("failed")}
>
Failed {sortIndicator("failed")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("failureRate")}
>
Failure Rate {sortIndicator("failureRate")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("avgDurationSeconds")}
>
Avg Duration {sortIndicator("avgDurationSeconds")}
</th>
<th
className="px-4 py-3 cursor-pointer select-none text-right"
onClick={() => toggleSort("trend")}
>
Trend {sortIndicator("trend")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredAndSorted.map((row) => (
<tr
key={row.appName}
onClick={() => {
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"
>
<td className="px-4 py-3 text-gray-900">
<span className="font-medium">{row.appName}</span>
{appTypeBadge(row.appType)}
</td>
<td className="px-4 py-3 text-right text-gray-700">{row.totalInstalls}</td>
<td className="px-4 py-3 text-right text-emerald-700">{row.succeeded}</td>
<td className="px-4 py-3 text-right text-red-700">{row.failed}</td>
<td className="px-4 py-3 text-right">
<span
className={
row.failureRate >= 20
? "text-red-700 font-semibold"
: row.failureRate >= 5
? "text-amber-700"
: "text-gray-700"
}
>
{row.failureRate.toFixed(1)}%
</span>
</td>
<td className="px-4 py-3 text-right text-gray-700">
{formatDuration(row.avgDurationSeconds)}
</td>
<td className="px-4 py-3 text-right">{trendBadge(row)}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</main>
</div>
{/* useSearchParams requires a Suspense boundary under the App Router. */}
<Suspense fallback={<div className="min-h-screen bg-gray-50" />}>
<SoftwareHub />
</Suspense>
</ProtectedRoute>
);
}
@@ -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 (
<div className={className}>
<div className="w-full h-1.5 bg-gray-200 rounded-full overflow-hidden flex">
<div
className="h-full bg-green-400"
style={{ width: `${peerPct}%` }}
title={`${peerPct.toFixed(1)}% from peers`}
/>
<div
className="h-full bg-purple-400"
style={{ width: `${cachePct}%` }}
title={`${cachePct.toFixed(1)}% from Connected Cache${cacheHost ? ` (${cacheHost})` : ""}`}
/>
<div
className="h-full bg-blue-400"
style={{ width: `${httpPct}%` }}
title={`${httpPct.toFixed(1)}% from HTTP/CDN`}
/>
</div>
{showLegend && (
<div className="mt-1.5 flex flex-wrap gap-x-4 gap-y-0.5 text-xs text-gray-600">
<span><span className="inline-block w-2 h-2 rounded-sm bg-green-400 mr-1 align-middle" />Peers {formatBytes(peers)}</span>
<span><span className="inline-block w-2 h-2 rounded-sm bg-purple-400 mr-1 align-middle" />MCC {formatBytes(cacheServer)}</span>
<span><span className="inline-block w-2 h-2 rounded-sm bg-blue-400 mr-1 align-middle" />CDN {formatBytes(http)}</span>
</div>
)}
</div>
);
}
@@ -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 && (
<span className="text-xs text-gray-400">
— Total: {formatDuration(totalDuration)}
— Total: {formatDuration(Math.floor(totalDuration / 1000))}
</span>
)}
{totalP2P != null && (
@@ -376,7 +351,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
</div>
<div className="flex items-center space-x-3 text-xs text-gray-500 flex-shrink-0 ml-2">
{!dl.isComplete && !dl.isSkipped && dl.downloadRateBps > 0 && (
<span className="font-medium text-blue-600">{formatSpeed(dl.downloadRateBps)}</span>
<span className="font-medium text-blue-600">{formatThroughput(dl.downloadRateBps, "0 B/s")}</span>
)}
{dl.eventData && Object.keys(dl.eventData).length > 0 && (
<button
@@ -417,7 +392,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
{!dl.isSkipped && !dl.isComplete && dl.bytesTotal === 0 && dl.bytesDownloaded > 0 && (
<div className="mt-1 text-xs text-gray-500">
Downloaded: {formatBytes(dl.bytesDownloaded)}
{dl.downloadRateBps > 0 && ` at ${formatSpeed(dl.downloadRateBps)}`}
{dl.downloadRateBps > 0 && ` at ${formatThroughput(dl.downloadRateBps, "0 B/s")}`}
</div>
)}
@@ -448,9 +423,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
const peerBytes = dl.doStats.bytesFromPeers;
const cacheBytes = dl.doStats.bytesFromCacheServer;
const httpBytes = Math.max(0, dl.doStats.bytesFromHttp - cacheBytes);
const peerPct = (peerBytes / total) * 100;
const cachePct = (cacheBytes / total) * 100;
const httpPct = Math.max(0, 100 - peerPct - cachePct);
const cachePct = total > 0 ? (cacheBytes / total) * 100 : 0;
// LAN + LinkLocal combined for breakdown line — both are "local network"
const localPeers = dl.doStats.bytesFromLanPeers + dl.doStats.bytesFromLinkLocalPeers;
const cachePctRounded = cachePct >= 1 ? Math.round(cachePct) : Math.round(cachePct * 10) / 10;
@@ -501,24 +474,14 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte
</div>
)}
</div>
{/* Peers (green) | MCC (purple) | HTTP (blue) */}
<div className="mt-1.5 w-full h-1.5 bg-gray-200 rounded-full overflow-hidden flex">
<div
className="h-full bg-green-400"
style={{ width: `${peerPct}%` }}
title={`${peerPct.toFixed(1)}% from peers`}
/>
<div
className="h-full bg-purple-400"
style={{ width: `${cachePct}%` }}
title={`${cachePct.toFixed(1)}% from Connected Cache${dl.doStats.cacheHost ? ` (${dl.doStats.cacheHost})` : ""}`}
/>
<div
className="h-full bg-blue-400"
style={{ width: `${httpPct}%` }}
title={`${httpPct.toFixed(1)}% from HTTP/CDN`}
/>
</div>
<DoBreakdownBar
className="mt-1.5"
peers={peerBytes}
cacheServer={cacheBytes}
http={httpBytes}
total={total}
cacheHost={dl.doStats.cacheHost}
/>
</div>
);
})()}
@@ -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 (
<div className="bg-emerald-50 dark:bg-gray-700 border border-emerald-200 dark:border-emerald-600 rounded-lg p-4">
<div className="flex flex-wrap items-center justify-between gap-2 mb-3">
<h3 className="text-sm font-medium text-emerald-900 dark:text-emerald-100">{title}</h3>
{(onDaysChange || onRefresh) && (
<div className="flex items-center gap-2">
{onDaysChange && (
<select
value={days}
onChange={(e) => onDaysChange(Number(e.target.value))}
className="text-xs rounded-md border border-emerald-300 dark:border-emerald-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 px-2 py-1 focus:outline-none focus:ring-2 focus:ring-emerald-500"
aria-label="Time window in days"
>
<option value={7}>Last 7 days</option>
<option value={30}>Last 30 days</option>
<option value={90}>Last 90 days</option>
<option value={365}>Last 365 days</option>
</select>
)}
{onRefresh && (
<button
type="button"
onClick={onRefresh}
disabled={refreshing}
className="inline-flex items-center px-2.5 py-1 text-xs font-medium rounded-md text-emerald-700 dark:text-emerald-300 border border-emerald-400 dark:border-emerald-600 hover:bg-emerald-100 dark:hover:bg-emerald-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{refreshing ? "Refreshing..." : "Refresh"}
</button>
)}
</div>
)}
</div>
{loading ? (
<div className="py-6 text-center text-sm text-emerald-800 dark:text-emerald-200">Loading…</div>
) : !hasFindings ? (
<div className="py-6 text-center text-sm text-emerald-800 dark:text-emerald-200">
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).
</div>
) : (
<div className="space-y-4">
{/* KPI tiles */}
<div className={`grid grid-cols-2 ${showTenantCount ? "sm:grid-cols-4" : "sm:grid-cols-3"} gap-3`}>
<Kpi value={summary!.totalAffectedSessions} label="Affected devices" />
<Kpi value={summary!.distinctCves} label="Distinct CVEs" />
<Kpi value={summary!.kevCves} label="Known-Exploited (KEV)" />
{showTenantCount && <Kpi value={summary!.totalAffectedTenants ?? 0} label="Affected tenants" />}
</div>
{/* Severity breakdown */}
<div className="flex flex-wrap gap-2">
{([
["Critical", summary!.severityBreakdown.critical],
["High", summary!.severityBreakdown.high],
["Medium", summary!.severityBreakdown.medium],
["Low", summary!.severityBreakdown.low],
] as Array<[string, number]>).map(([label, count]) => (
<span key={label} className={`inline-flex items-center gap-1 px-2.5 py-1 rounded-full border text-xs font-medium ${severityChipClass(label)}`}>
{label}: {count.toLocaleString()}
</span>
))}
</div>
{/* Top CVEs */}
<div>
<div className="text-xs font-medium text-emerald-800 dark:text-emerald-200 mb-2">Top CVEs by affected devices</div>
<div className="space-y-1.5">
{summary!.topCves.map((c) => (
<div key={c.cveId} className="flex items-center justify-between gap-3 bg-white dark:bg-gray-800 border border-emerald-100 dark:border-emerald-800 rounded-md px-3 py-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<a href={`https://nvd.nist.gov/vuln/detail/${encodeURIComponent(c.cveId)}`} target="_blank" rel="noopener noreferrer"
className="font-mono text-xs text-emerald-700 dark:text-emerald-300 underline hover:text-emerald-900 dark:hover:text-emerald-100">
{c.cveId}
</a>
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-medium ${severityChipClass(c.cvssSeverity)}`}>
{c.cvssSeverity || "Unknown"}{c.cvssScore > 0 ? ` ${c.cvssScore.toFixed(1)}` : ""}
</span>
{c.isKev && (
<span className={`inline-flex items-center px-1.5 py-0.5 rounded border text-[10px] font-semibold ${KEV_BADGE_CLASS}`}>KEV</span>
)}
</div>
{c.sampleSoftware.length > 0 && (
<div className="text-[11px] text-gray-500 dark:text-gray-400 truncate mt-0.5">{c.sampleSoftware.join(", ")}</div>
)}
</div>
<div className="text-right flex-shrink-0">
<div className="text-sm font-semibold text-emerald-900 dark:text-emerald-100">{c.affectedSessions.toLocaleString()}</div>
<div className="text-[10px] text-emerald-700 dark:text-emerald-300">devices</div>
</div>
</div>
))}
</div>
</div>
{summary!.truncated && (
<div className="text-[11px] text-amber-700 dark:text-amber-400">
Counts are a lower bound — the underlying scan hit its cap. Narrow the window for exact figures.
</div>
)}
</div>
)}
</div>
);
}
function Kpi({ value, label }: { value: number; label: string }) {
return (
<div className="bg-white dark:bg-gray-800 border border-emerald-200 dark:border-emerald-700 rounded-lg p-3">
<div className="text-2xl font-bold text-emerald-900 dark:text-emerald-100">{value.toLocaleString()}</div>
<div className="text-xs text-emerald-700 dark:text-emerald-300 mt-0.5">{label}</div>
</div>
);
}
@@ -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("—");
});
});
@@ -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");
});
});
+8
View File
@@ -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 ─────────────────────────────────────────────────────────────
@@ -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)}%`;
}
@@ -138,7 +138,7 @@ export const NAV_GROUPS: NavGroup[] = [
visibility: "adminOrOperator",
items: [
{ id: "fleet-health", label: "Fleet Health", href: "/fleet-health", icon: <ChartBarIcon /> },
{ id: "apps", label: "App Health", href: "/apps", icon: <CubeIcon /> },
{ id: "apps", label: "Software", href: "/apps", icon: <CubeIcon /> },
{ id: "usage-metrics", label: "Usage Metrics", href: "/usage-metrics", icon: <TrendingUpIcon /> },
{ id: "geographic-performance", label: "Geographic Perf.", href: "/geographic-performance", icon: <GlobeAltIcon /> },
{ id: "sla", label: "SLA Compliance", href: "/sla", icon: <ShieldCheckIcon /> },
@@ -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";