mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-24 12:42:38 +00:00
fix(web): hooks-warnings batch 1 — static-components, purity, immutability to zero (#117)
First burn-down batch of the React-Compiler hooks warnings (163 -> 141, CI ratchet lowered to match). static-components (14, real remount bugs): SortIcon (geographic- performance), SortIndicator (VulnerabilityReportSection) and SearchIcon/ ResultsDropdown (GlobalSearch) were defined inside render — a new component type every render, so React unmounted and rebuilt their subtrees each time (GlobalSearch tore down its dropdown on every keystroke). All hoisted to module level with explicit props. purity (6): Date.now() during render replaced with mount-time clocks (useState lazy init) for the date-input caps (Maintenance sections) and trial-expiry checks (TenantManagement, SectionTenantConfigReport); FeedbackSection refreshes its clock per fetch so "Xm ago" labels track the data; DownloadProgress falls back to NaN instead of wall-clock for unparseable event timestamps (rate calc already guards via Number.isFinite). immutability (5): fetch functions were declared below the effects that call them — moved above (diagnosis, fleet-health, DistressReports). This unblocked deeper compiler analysis, surfacing 3 latent set-state-in-effect findings in those files (93 -> 96); they belong to the set-state category and stay for its own batch. Verified: eslint 0 errors / 141 warnings (gate exits 0), tsc clean, vitest 611/611, static export builds. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
72392f6f4c
commit
5312216f6b
@@ -82,7 +82,7 @@ jobs:
|
||||
# in eslint.config.mjs + exhaustive-deps) — when you fix warnings, lower
|
||||
# the cap to match; never raise it.
|
||||
- name: Lint
|
||||
run: npm run lint -- --max-warnings 163
|
||||
run: npm run lint -- --max-warnings 141
|
||||
|
||||
- name: Tests
|
||||
run: npm test
|
||||
|
||||
@@ -146,11 +146,6 @@ export function DistressReportsSection({
|
||||
return sortDir === "asc" ? cmp : -cmp;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const fetchReports = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
@@ -172,6 +167,11 @@ export function DistressReportsSection({
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchReports();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Summary stats
|
||||
const errorTypeCounts = reports.reduce<Record<string, number>>((acc, r) => {
|
||||
acc[r.errorType] = (acc[r.errorType] ?? 0) + 1;
|
||||
|
||||
@@ -35,6 +35,10 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
const [activeTab, setActiveTab] = useState<FeedbackTab>("InApp");
|
||||
const [inAppFilter, setInAppFilter] = useState<InAppFilter>("all");
|
||||
const [currentPage, setCurrentPage] = useState(0);
|
||||
// Reference clock for the "Xm ago" labels — render must stay pure
|
||||
// (react-hooks/purity), so it's captured at mount and refreshed per fetch,
|
||||
// keeping the labels in step with the data they describe.
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
const entriesPerPage = 5;
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
@@ -56,6 +60,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
return dateB - dateA;
|
||||
});
|
||||
setEntries(sorted);
|
||||
setNowMs(Date.now());
|
||||
} catch (err) {
|
||||
if (err instanceof TokenExpiredError) {
|
||||
console.error("Session expired while fetching feedback");
|
||||
@@ -114,7 +119,7 @@ export function FeedbackSection({ getAccessToken, setError }: FeedbackSectionPro
|
||||
|
||||
const formatTimeAgo = (dateStr: string | null): string => {
|
||||
if (!dateStr) return "unknown";
|
||||
const diff = Date.now() - new Date(dateStr).getTime();
|
||||
const diff = nowMs - new Date(dateStr).getTime();
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceSection({
|
||||
}: MaintenanceSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [reseedingRules, setReseedingRules] = useState(false);
|
||||
const [reseedingGatherRules, setReseedingGatherRules] = useState(false);
|
||||
const [reseedingImePatterns, setReseedingImePatterns] = useState(false);
|
||||
@@ -244,7 +247,7 @@ export function MaintenanceSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -17,6 +17,9 @@ export function MaintenanceTriggerSection({
|
||||
}: MaintenanceTriggerSectionProps) {
|
||||
const [triggeringMaintenance, setTriggeringMaintenance] = useState(false);
|
||||
const [maintenanceDate, setMaintenanceDate] = useState<string>("");
|
||||
// Latest selectable day (yesterday), fixed at mount — render must stay pure
|
||||
// (react-hooks/purity) and the cap doesn't need a live midnight rollover.
|
||||
const [maxMaintenanceDate] = useState(() => new Date(Date.now() - 86400000).toISOString().split('T')[0]);
|
||||
const [refreshingVersions, setRefreshingVersions] = useState(false);
|
||||
|
||||
const handleRefreshLatestVersions = async () => {
|
||||
@@ -143,7 +146,7 @@ export function MaintenanceTriggerSection({
|
||||
type="date"
|
||||
value={maintenanceDate}
|
||||
onChange={(e) => setMaintenanceDate(e.target.value)}
|
||||
max={new Date(Date.now() - 86400000).toISOString().split('T')[0]}
|
||||
max={maxMaintenanceDate}
|
||||
className="w-full max-w-xs px-3 py-2 border border-purple-300 dark:border-purple-600 rounded-lg text-sm bg-white dark:bg-gray-600 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-purple-500 focus:border-purple-500"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
|
||||
@@ -74,6 +74,9 @@ function TenantManagementSectionInner({
|
||||
// Read-only Global Readers may view tenants (incl. config report) but not edit them.
|
||||
const canMutate = useCanMutatePlatform();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
|
||||
// Deep link from GA notifications: ?tenantId=… seeds the search box once, so the
|
||||
// list opens filtered to the tenant the notification is about.
|
||||
@@ -597,7 +600,7 @@ function TenantManagementSectionInner({
|
||||
{(() => {
|
||||
const isEnterpriseTier = editingTenant.planTier === "enterprise";
|
||||
const trialActive = !!editingTenant.trialExpiresUtc &&
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > Date.now();
|
||||
new Date(editingTenant.trialExpiresUtc).getTime() > nowMs;
|
||||
const effective = isEnterpriseTier || trialActive ? "Enterprise" : "Community";
|
||||
return (
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
|
||||
+4
-1
@@ -335,6 +335,9 @@ export function SectionTenantConfigReport() {
|
||||
|
||||
const [tenants, setTenants] = useState<TenantInfo[]>([]);
|
||||
const [selectedTenantId, setSelectedTenantId] = useState('');
|
||||
// Mount-time clock for trial-expiry checks — render must stay pure
|
||||
// (react-hooks/purity); day-granularity expiry doesn't need a live clock.
|
||||
const [nowMs] = useState(() => Date.now());
|
||||
const [config, setConfig] = useState<TenantConfig | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingTenants, setLoadingTenants] = useState(true);
|
||||
@@ -531,7 +534,7 @@ export function SectionTenantConfigReport() {
|
||||
<ConfigRow label="Onboarded At" value={config.onboardedAt} isDate />
|
||||
<ConfigRow
|
||||
label="Plan Tier"
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > Date.now() ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
value={`${config.planTier || 'free'}${config.trialExpiresUtc && new Date(config.trialExpiresUtc).getTime() > nowMs ? ` (trial until ${formatDate(config.trialExpiresUtc)})` : ''}`}
|
||||
configKey="planTier"
|
||||
defaults={DEFAULTS}
|
||||
/>
|
||||
|
||||
@@ -64,87 +64,8 @@ function DiagnosisContent() {
|
||||
|
||||
const { globalAdminMode } = useAdminMode();
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
}, [sessionId, tenantId, globalAdminMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
}
|
||||
}, [sessionTenantId, sessionId]);
|
||||
|
||||
// SignalR groups - subscribe-then-fetch pattern
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
if (!hasJoinedGroups.current) {
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = true;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
};
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId]);
|
||||
|
||||
// Real-time analysis updates
|
||||
useEffect(() => {
|
||||
const handleEventStream = (data: {
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
newRuleResults?: RuleResult[];
|
||||
}) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
if (data.session) setSession(data.session);
|
||||
if (data.newRuleResults?.length) {
|
||||
setAnalysisResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(
|
||||
(r) => !existingIds.has(r.ruleId)
|
||||
);
|
||||
return [...prev, ...newResults].sort(
|
||||
(a, b) => b.confidenceScore - a.confidenceScore
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
on("eventStream", handleEventStream);
|
||||
return () => {
|
||||
off("eventStream", handleEventStream);
|
||||
};
|
||||
}, [on, off]);
|
||||
|
||||
// Declared ahead of the effects that call them (react-hooks/immutability:
|
||||
// a later declaration would freeze the effect's view of the binding).
|
||||
const fetchSessionDetails = async () => {
|
||||
try {
|
||||
// Fetch the single session directly instead of pulling the entire session
|
||||
@@ -234,6 +155,87 @@ function DiagnosisContent() {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
if (!globalAdminMode && !tenantId) return; // wait for real tenant ID
|
||||
sessionIdRef.current = sessionId;
|
||||
if (lastFetchedSessionId.current !== sessionId) {
|
||||
hasInitialFetch.current = false;
|
||||
lastFetchedSessionId.current = sessionId;
|
||||
sessionFetchDone.current = false;
|
||||
analysisFetchDone.current = false;
|
||||
setSessionTenantId(null);
|
||||
setLoading(true);
|
||||
}
|
||||
if (hasInitialFetch.current) return;
|
||||
hasInitialFetch.current = true;
|
||||
|
||||
// Performance: eager-set sessionTenantId from TenantContext when known, so
|
||||
// the events/analysis effect fires in parallel with fetchSessionDetails
|
||||
// instead of waiting for its roundtrip — eliminates the fetch waterfall.
|
||||
// Global Admins in all-tenant view fall through with null (backend resolves
|
||||
// the tenant from the session itself).
|
||||
if (!globalAdminMode && isGuid(tenantId)) {
|
||||
setSessionTenantId(tenantId);
|
||||
}
|
||||
|
||||
fetchSessionDetails();
|
||||
}, [sessionId, tenantId, globalAdminMode]);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionTenantId && sessionId) {
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
}
|
||||
}, [sessionTenantId, sessionId]);
|
||||
|
||||
// SignalR groups - subscribe-then-fetch pattern
|
||||
useEffect(() => {
|
||||
const effectiveTenantId = sessionTenantId || tenantId;
|
||||
if (!sessionId || !isConnected || !effectiveTenantId) return;
|
||||
if (!hasJoinedGroups.current) {
|
||||
const joinAndCatchUp = async () => {
|
||||
await joinGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = true;
|
||||
// Re-fetch after group join to catch any missed during join
|
||||
Promise.all([fetchEvents(), fetchAnalysisResults()]);
|
||||
};
|
||||
joinAndCatchUp();
|
||||
}
|
||||
return () => {
|
||||
if (hasJoinedGroups.current && effectiveTenantId) {
|
||||
leaveGroup(`session-${effectiveTenantId}-${sessionId}`);
|
||||
hasJoinedGroups.current = false;
|
||||
}
|
||||
};
|
||||
}, [sessionId, isConnected, sessionTenantId, tenantId]);
|
||||
|
||||
// Real-time analysis updates
|
||||
useEffect(() => {
|
||||
const handleEventStream = (data: {
|
||||
sessionId: string;
|
||||
session: Session;
|
||||
newRuleResults?: RuleResult[];
|
||||
}) => {
|
||||
if (data.sessionId !== sessionIdRef.current) return;
|
||||
if (data.session) setSession(data.session);
|
||||
if (data.newRuleResults?.length) {
|
||||
setAnalysisResults((prev) => {
|
||||
const existingIds = new Set(prev.map((r) => r.ruleId));
|
||||
const newResults = data.newRuleResults!.filter(
|
||||
(r) => !existingIds.has(r.ruleId)
|
||||
);
|
||||
return [...prev, ...newResults].sort(
|
||||
(a, b) => b.confidenceScore - a.confidenceScore
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
on("eventStream", handleEventStream);
|
||||
return () => {
|
||||
off("eventStream", handleEventStream);
|
||||
};
|
||||
}, [on, off]);
|
||||
|
||||
const copyRemediation = (result: RuleResult) => {
|
||||
const text = result.remediation
|
||||
.map((r) => {
|
||||
|
||||
@@ -77,6 +77,29 @@ export default function FleetHealthPage() {
|
||||
signalR: { on, off, isConnected },
|
||||
});
|
||||
|
||||
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
|
||||
try {
|
||||
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalApp(d, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, d);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
setAppMetrics(json);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
|
||||
} else {
|
||||
console.error("Failed to fetch app metrics:", error);
|
||||
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// App install metrics are already backend-aggregated; fetch alongside fleet data.
|
||||
useEffect(() => {
|
||||
if (!scopeInitialized) return;
|
||||
@@ -147,29 +170,6 @@ export default function FleetHealthPage() {
|
||||
};
|
||||
}, [isConnected, effectiveTenantId]);
|
||||
|
||||
const fetchAppMetrics = async (range: "7d" | "30d" | "90d" = timeRange) => {
|
||||
try {
|
||||
const d = range === "7d" ? 7 : range === "30d" ? 30 : 90;
|
||||
const endpoint = routeGlobal
|
||||
? api.metrics.globalApp(d, selectedTenantId || undefined)
|
||||
: api.metrics.app(tenantId, d);
|
||||
const response = await authenticatedFetch(endpoint, getAccessToken);
|
||||
if (response.ok) {
|
||||
const json = await response.json();
|
||||
setAppMetrics(json);
|
||||
} else {
|
||||
addNotification('error', 'Backend Error', `Failed to load app metrics: ${response.statusText}`, 'fleet-health-metrics-error');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TokenExpiredError) {
|
||||
addNotification('error', 'Session Expired', error.message, 'session-expired-error');
|
||||
} else {
|
||||
console.error("Failed to fetch app metrics:", error);
|
||||
addNotification('error', 'Backend Not Reachable', 'Unable to load app metrics. Please check your connection.', 'fleet-health-metrics-error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Server payload, with presentation-friendly defaults so the JSX renders
|
||||
// without null guards once loading clears. `avgDuration` keeps the card's
|
||||
// original field name (server sends avgDurationMinutes).
|
||||
|
||||
@@ -82,8 +82,16 @@ interface GeographicMetricsResponse {
|
||||
|
||||
type GroupBy = "city" | "region" | "country";
|
||||
type SortBy = "sessionCount" | "avgDurationMinutes" | "appLoadScore" | "avgThroughputBytesPerSec" | "avgApiLatencyMs" | "avgDoPercentPeerCaching";
|
||||
|
||||
type TimeRange = "7d" | "30d" | "90d";
|
||||
|
||||
// Module-level so React keeps a stable component identity across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every render).
|
||||
function SortIcon({ col, sortBy, sortDesc }: { col: SortBy; sortBy: SortBy; sortDesc: boolean }) {
|
||||
if (sortBy !== col) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
|
||||
}
|
||||
|
||||
const durationColor = (value: number, globalAvg: number) => {
|
||||
if (globalAvg <= 0) return "text-gray-700";
|
||||
const ratio = value / globalAvg;
|
||||
@@ -220,11 +228,6 @@ export default function GeographicPerformancePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ col }: { col: SortBy }) => {
|
||||
if (sortBy !== col) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="text-blue-600 ml-1">{sortDesc ? "▼" : "▲"}</span>;
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<CalculatingCard
|
||||
@@ -434,7 +437,7 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("sessionCount")}
|
||||
>
|
||||
Sessions <SortIcon col="sessionCount" />
|
||||
Sessions <SortIcon col="sessionCount" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
Success
|
||||
@@ -443,7 +446,7 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDurationMinutes")}
|
||||
>
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" />
|
||||
Avg Duration <SortIcon col="avgDurationMinutes" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
P95
|
||||
@@ -452,26 +455,26 @@ export default function GeographicPerformancePage() {
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("appLoadScore")}
|
||||
>
|
||||
App-Load-Score <SortIcon col="appLoadScore" />
|
||||
App-Load-Score <SortIcon col="appLoadScore" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgThroughputBytesPerSec")}
|
||||
>
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" />
|
||||
Throughput <SortIcon col="avgThroughputBytesPerSec" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgApiLatencyMs")}
|
||||
title="Agent→backend HTTP round-trip measured on the devices — high values indicate network distance to the backend region"
|
||||
>
|
||||
API Latency <SortIcon col="avgApiLatencyMs" />
|
||||
API Latency <SortIcon col="avgApiLatencyMs" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th
|
||||
className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-700"
|
||||
onClick={() => handleSort("avgDoPercentPeerCaching")}
|
||||
>
|
||||
P2P % <SortIcon col="avgDoPercentPeerCaching" />
|
||||
P2P % <SortIcon col="avgDoPercentPeerCaching" sortBy={sortBy} sortDesc={sortDesc} />
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
vs Global
|
||||
|
||||
+14
-10
@@ -94,6 +94,15 @@ function confidenceTooltip(confidence?: string): string {
|
||||
return "Unknown identification confidence";
|
||||
}
|
||||
|
||||
type FindingsSortKey = "severity" | "software" | "cves" | "cvss";
|
||||
|
||||
// Module-level so React keeps a stable component identity across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every render).
|
||||
function SortIndicator({ column, sortKey, sortAsc }: { column: FindingsSortKey; sortKey: FindingsSortKey; sortAsc: boolean }) {
|
||||
if (sortKey !== column) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="ml-1">{sortAsc ? "▲" : "▼"}</span>;
|
||||
}
|
||||
|
||||
export default function VulnerabilityReportSection({
|
||||
events,
|
||||
vulnerabilityReport,
|
||||
@@ -104,7 +113,7 @@ export default function VulnerabilityReportSection({
|
||||
const [activeTab, setActiveTab] = useState<TabKey>("findings");
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [findingsSearch, setFindingsSearch] = useState("");
|
||||
const [findingsSortKey, setFindingsSortKey] = useState<"severity" | "software" | "cves" | "cvss">("severity");
|
||||
const [findingsSortKey, setFindingsSortKey] = useState<FindingsSortKey>("severity");
|
||||
const [findingsSortAsc, setFindingsSortAsc] = useState(false);
|
||||
const [expandedRows, setExpandedRows] = useState<Set<string>>(new Set());
|
||||
const [inventorySearch, setInventorySearch] = useState("");
|
||||
@@ -397,11 +406,6 @@ export default function VulnerabilityReportSection({
|
||||
});
|
||||
};
|
||||
|
||||
const SortIndicator = ({ column }: { column: typeof findingsSortKey }) => {
|
||||
if (findingsSortKey !== column) return <span className="text-gray-300 ml-1">↕</span>;
|
||||
return <span className="ml-1">{findingsSortAsc ? "\u25B2" : "\u25BC"}</span>;
|
||||
};
|
||||
|
||||
// Guard: no relevant data → render nothing (placed after all hooks to satisfy Rules of Hooks)
|
||||
if (vulnReportData.length === 0 && inventoryEvents.length === 0) return null;
|
||||
|
||||
@@ -573,18 +577,18 @@ export default function VulnerabilityReportSection({
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("severity")}>
|
||||
Risk <SortIndicator column="severity" />
|
||||
Risk <SortIndicator column="severity" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("software")}>
|
||||
Software <SortIndicator column="software" />
|
||||
Software <SortIndicator column="software" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3">Version</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cves")}>
|
||||
CVEs <SortIndicator column="cves" />
|
||||
CVEs <SortIndicator column="cves" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
<th className="py-2 px-3">KEV</th>
|
||||
<th className="py-2 px-3 cursor-pointer select-none" onClick={() => toggleFindingsSort("cvss")}>
|
||||
Max CVSS <SortIndicator column="cvss" />
|
||||
Max CVSS <SortIndicator column="cvss" sortKey={findingsSortKey} sortAsc={findingsSortAsc} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -214,7 +214,11 @@ export default function DownloadProgress({ events, summaryStats }: DownloadProgr
|
||||
bytesTotal: isNaN(bytesTotal) ? 0 : bytesTotal,
|
||||
downloadRateBps: effectiveRateBps,
|
||||
lastUpdated: evt.timestamp,
|
||||
lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? Date.now()),
|
||||
// NaN when no event ever carried a parseable timestamp: the rate
|
||||
// fallback above guards with Number.isFinite, and wall-clock time at
|
||||
// memo-recompute would be an arbitrary base for event-time deltas
|
||||
// anyway (render must also stay pure — react-hooks/purity).
|
||||
lastUpdatedMs: Number.isFinite(eventTs) ? eventTs : (existing?.lastUpdatedMs ?? NaN),
|
||||
isComplete,
|
||||
isSkipped: isSkippedEvent || (existing?.isSkipped ?? false),
|
||||
firstSeenIndex: existing?.firstSeenIndex ?? insertionIndex++,
|
||||
|
||||
@@ -17,6 +17,93 @@ interface QuickSearchResult {
|
||||
matchedField: 'sessionId' | 'serialNumber' | 'deviceName';
|
||||
}
|
||||
|
||||
const fieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case 'sessionId': return 'Session ID';
|
||||
case 'serialNumber': return 'Serial';
|
||||
case 'deviceName': return 'Device';
|
||||
default: return field;
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Succeeded': return 'text-green-600';
|
||||
case 'Failed': return 'text-red-600';
|
||||
case 'InProgress': return 'text-blue-600';
|
||||
case 'Pending': return 'text-amber-600';
|
||||
case 'Stalled': return 'text-orange-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
// Module-level so React keeps stable component identities across renders
|
||||
// (react-hooks/static-components: an inline component type remounts on every
|
||||
// keystroke — the dropdown subtree would be torn down and rebuilt mid-typing).
|
||||
const SearchIcon = () => (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
function ResultsDropdown({ mobile, visible, loading, results, query, selectedIndex, onNavigate }: {
|
||||
mobile?: boolean;
|
||||
visible: boolean;
|
||||
loading: boolean;
|
||||
results: QuickSearchResult[];
|
||||
query: string;
|
||||
selectedIndex: number;
|
||||
onNavigate: (result: QuickSearchResult) => void;
|
||||
}) {
|
||||
if (!visible) return null;
|
||||
return (
|
||||
<div className={`absolute ${mobile ? 'left-0 right-0 mx-3' : 'left-0 w-full'} top-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto`}>
|
||||
{loading ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Searching...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">
|
||||
{query.length >= 2 ? 'No results found' : 'Type at least 2 characters'}
|
||||
</div>
|
||||
) : (
|
||||
results.map((result, idx) => (
|
||||
<button
|
||||
key={result.sessionId}
|
||||
onClick={() => onNavigate(result)}
|
||||
className={`w-full text-left px-4 py-2.5 flex items-center gap-3 transition-colors ${idx === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="text-gray-400 flex-shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{result.deviceName || result.serialNumber || result.sessionId}
|
||||
</p>
|
||||
<span className={`text-[10px] font-semibold ${statusColor(result.status)}`}>
|
||||
{result.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
<span className="text-gray-400">Matched: </span>
|
||||
{fieldLabel(result.matchedField)}
|
||||
{result.serialNumber && ` · ${result.serialNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GlobalSearch() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
@@ -138,82 +225,6 @@ export default function GlobalSearch() {
|
||||
}
|
||||
};
|
||||
|
||||
const fieldLabel = (field: string) => {
|
||||
switch (field) {
|
||||
case 'sessionId': return 'Session ID';
|
||||
case 'serialNumber': return 'Serial';
|
||||
case 'deviceName': return 'Device';
|
||||
default: return field;
|
||||
}
|
||||
};
|
||||
|
||||
const statusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Succeeded': return 'text-green-600';
|
||||
case 'Failed': return 'text-red-600';
|
||||
case 'InProgress': return 'text-blue-600';
|
||||
case 'Pending': return 'text-amber-600';
|
||||
case 'Stalled': return 'text-orange-600';
|
||||
default: return 'text-gray-500';
|
||||
}
|
||||
};
|
||||
|
||||
const SearchIcon = () => (
|
||||
<svg className="w-4 h-4 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
const ResultsDropdown = ({ mobile }: { mobile?: boolean }) => {
|
||||
if (!showDropdown) return null;
|
||||
return (
|
||||
<div className={`absolute ${mobile ? 'left-0 right-0 mx-3' : 'left-0 w-full'} top-full mt-1 bg-white rounded-lg shadow-lg border border-gray-200 z-50 max-h-80 overflow-y-auto`}>
|
||||
{loading ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500 flex items-center gap-2">
|
||||
<svg className="w-4 h-4 animate-spin" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
||||
</svg>
|
||||
Searching...
|
||||
</div>
|
||||
) : results.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-gray-500">
|
||||
{query.length >= 2 ? 'No results found' : 'Type at least 2 characters'}
|
||||
</div>
|
||||
) : (
|
||||
results.map((result, idx) => (
|
||||
<button
|
||||
key={result.sessionId}
|
||||
onClick={() => navigateToResult(result)}
|
||||
className={`w-full text-left px-4 py-2.5 flex items-center gap-3 transition-colors ${idx === selectedIndex ? 'bg-blue-50' : 'hover:bg-gray-50'}`}
|
||||
>
|
||||
<span className="text-gray-400 flex-shrink-0">
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
|
||||
</svg>
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{result.deviceName || result.serialNumber || result.sessionId}
|
||||
</p>
|
||||
<span className={`text-[10px] font-semibold ${statusColor(result.status)}`}>
|
||||
{result.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
<span className="text-gray-400">Matched: </span>
|
||||
{fieldLabel(result.matchedField)}
|
||||
{result.serialNumber && ` \u00B7 ${result.serialNumber}`}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex items-center px-3">
|
||||
{/* Desktop: inline search field centered (visible md+) */}
|
||||
@@ -244,7 +255,7 @@ export default function GlobalSearch() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<ResultsDropdown />
|
||||
<ResultsDropdown visible={showDropdown} loading={loading} results={results} query={query} selectedIndex={selectedIndex} onNavigate={navigateToResult} />
|
||||
</div>
|
||||
|
||||
{/* Mobile: magnifying glass button pushed right (visible <md) */}
|
||||
@@ -299,7 +310,7 @@ export default function GlobalSearch() {
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ResultsDropdown mobile />
|
||||
<ResultsDropdown mobile visible={showDropdown} loading={loading} results={results} query={query} selectedIndex={selectedIndex} onNavigate={navigateToResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user