mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 05:00:07 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
Reference in New Issue
Block a user