From 09898d7ba0ba81bdfe19cd14546648b658385aa9 Mon Sep 17 00:00:00 2001 From: Oliver Kieselbach Date: Fri, 24 Jul 2026 15:03:34 +0200 Subject: [PATCH] feat(web): consistent truncate-with-tooltip + tap-to-expand across dashboard cards Add reusable TruncatedLabel component: a single-line label that truncates with an ellipsis, shows the full text as a native hover tooltip, and on click/Enter/Space expands to two wrapped lines (touch-friendly). An interactive={false} mode provides tooltip-only behaviour for use inside already-clickable rows/links where an inner onClick would hijack navigation. Apply it consistently to the long, dynamic label columns across: - fleet-health: failure reasons, model + app ranked lists - sla: top failing apps, violators failure reason - geographic-performance: fastest/slowest location cards - vulnerability exposure panel: CVE sample software - agent metrics: top exception types - progress + download/install: app-name labels - admin software tabs: software names - session reports (comment/adminNote) + ops events (message): tooltip-only Co-Authored-By: Claude Opus 4.8 (1M context) --- .../admin/components/IgnoredSoftwareTab.tsx | 5 +- .../app/admin/components/OpsEventsSection.tsx | 5 +- .../components/SessionReportsSection.tsx | 9 ++- .../admin/components/UnmappedSoftwareTab.tsx | 5 +- .../metrics/sections/SectionAgentMetrics.tsx | 3 +- .../app/fleet-health/page.tsx | 36 +++++----- .../app/geographic-performance/page.tsx | 9 +-- .../app/progress/page.tsx | 26 ++++---- .../autopilot-monitor-web/app/sla/page.tsx | 7 +- .../components/DownloadProgress.tsx | 3 +- .../components/InstallProgress.tsx | 5 +- .../components/TruncatedLabel.tsx | 66 +++++++++++++++++++ .../components/VulnerabilityExposurePanel.tsx | 3 +- 13 files changed, 130 insertions(+), 52 deletions(-) create mode 100644 src/Web/autopilot-monitor-web/components/TruncatedLabel.tsx diff --git a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx index d92aa191..3a336af0 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/IgnoredSoftwareTab.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch } from "@/lib/authenticatedFetch"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { trackEvent } from "@/lib/appInsights"; import type { AutoResolveResult, IgnoredSoftwareEntry } from "./SoftwareMappingTypes"; @@ -211,8 +212,8 @@ export function IgnoredSoftwareTab({ return ( - - {entry.softwareName} + + {entry.publisher || unknown} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx index 9735bf43..6e94890e 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/OpsEventsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { api } from "@/lib/api"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import { extractContinuation } from "@/lib/paginationLink"; import { extractSessionId, buildAutoReason } from "./opsEventSessionHelpers"; @@ -445,8 +446,8 @@ export function OpsEventsSection({ {evt.eventType} - - {evt.message} + + {evt.tenantId ? (evt.tenantId.length > 8 ? `${evt.tenantId.slice(0, 8)}...` : evt.tenantId) : -} diff --git a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx index 3416fe2f..70dd6a68 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/SessionReportsSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { extractContinuation } from "@/lib/paginationLink"; import { isGuid } from "@/utils/inputValidation"; import { trackEvent } from "@/lib/appInsights"; @@ -355,8 +356,10 @@ export function SessionReportsSection({ {r.submittedBy} - - {r.comment || no comment} + + {r.comment + ? + : no comment} {r.adminNote ? ( @@ -364,7 +367,7 @@ export function SessionReportsSection({ - {r.adminNote} + ) : ( — diff --git a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx index 2f4d1065..d3dd0184 100644 --- a/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/components/UnmappedSoftwareTab.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from "react"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { trackEvent } from "@/lib/appInsights"; import type { UnmatchedSoftwareEntry, AutoResolveResult } from "./SoftwareMappingTypes"; @@ -564,9 +565,7 @@ export function UnmappedSoftwareTab({ /> )} -
- {entry.softwareName} -
+
{entry.publisher || unknown}
diff --git a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx index a279a352..d7c4e47e 100644 --- a/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx +++ b/src/Web/autopilot-monitor-web/app/admin/metrics/sections/SectionAgentMetrics.tsx @@ -3,6 +3,7 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { api } from '@/lib/api'; +import TruncatedLabel from '@/components/TruncatedLabel'; import { useAuth } from '../../../../contexts/AuthContext'; import { useNotifications } from '../../../../contexts/NotificationContext'; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -705,7 +706,7 @@ export function SectionAgentMetrics() {

Top exceptions:

{crashRate.topExceptions.map((e) => (
- {e.exceptionType} + {e.count}x
))} diff --git a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx index a3a871d6..1bb293f5 100644 --- a/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx +++ b/src/Web/autopilot-monitor-web/app/fleet-health/page.tsx @@ -10,6 +10,7 @@ import { useNotifications } from "../../contexts/NotificationContext"; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; import FleetStatCard from "./components/FleetStatCard"; +import TruncatedLabel from "../../components/TruncatedLabel"; import { useFleetHealth } from "./hooks/useFleetHealth"; import { useAggregatedAdminScope } from "@/hooks"; import { GlobalAdminBanner, globalAdminSubtitle } from "@/components/GlobalAdminBanner"; @@ -404,9 +405,10 @@ export default function FleetHealthPage() {
- - {fr.reason} - + ({fr.count}) @@ -444,9 +446,10 @@ export default function FleetHealthPage() {
- - {m.model} - + {m.avgMinutes} min @@ -479,9 +482,10 @@ export default function FleetHealthPage() {
- - {m.model} - + {m.failed} failed @@ -545,9 +549,10 @@ export default function FleetHealthPage() {
- - {app.appName} - + {avgLabel} avg @@ -595,9 +600,10 @@ export default function FleetHealthPage() {
- - {app.appName} - + {app.failed} failed diff --git a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx index b59fb760..991dd3b0 100644 --- a/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx +++ b/src/Web/autopilot-monitor-web/app/geographic-performance/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useRef, useMemo, useCallback } from "react"; import { useRouter } from "next/navigation"; import dynamic from "next/dynamic"; import { ProtectedRoute } from "../../components/ProtectedRoute"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { api } from "@/lib/api"; @@ -323,18 +324,14 @@ export default function GeographicPerformancePage() {
Fastest Location
-
- {stats.fastest?.locationKey || "—"} -
+
{stats.fastest ? `${Math.round(stats.fastest.avgDurationMinutes)} min avg` : "No data"}
Slowest Location
-
- {stats.slowest?.locationKey || "—"} -
+
{stats.slowest ? `${Math.round(stats.slowest.avgDurationMinutes)} min avg` : "No data"}
diff --git a/src/Web/autopilot-monitor-web/app/progress/page.tsx b/src/Web/autopilot-monitor-web/app/progress/page.tsx index daec1e40..8ab8772f 100644 --- a/src/Web/autopilot-monitor-web/app/progress/page.tsx +++ b/src/Web/autopilot-monitor-web/app/progress/page.tsx @@ -3,6 +3,7 @@ import { useTenant } from "../../contexts/TenantContext"; import { useAuth } from "../../contexts/AuthContext"; import { useNotifications } from "../../contexts/NotificationContext"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { useSignalR } from "../../contexts/SignalRContext"; import { ProtectedRoute } from "../../components/ProtectedRoute"; import { useProgressSearch } from "./hooks/useProgressSearch"; @@ -374,13 +375,16 @@ export default function ProgressPortalPage() { {isCurrent && (step.id === 3 || step.id === 5) && (
- - {currentDownload?.active && currentDownload.appName - ? `Downloading ${currentDownload.appName}...` - : currentInstall?.active && currentInstall.appName - ? `Installing ${currentInstall.appName}...` - : "Processing..."} - +
)}
@@ -398,9 +402,7 @@ export default function ProgressPortalPage() {

Downloading

- - {currentDownload.appName} - + {currentDownload.downloadRateBps > 0 && ( {currentDownload.downloadRateBps >= 1024 * 1024 @@ -443,9 +445,7 @@ export default function ProgressPortalPage() {
- - {currentInstall.appName} - +
{installElapsedMs != null && installElapsedMs > 0 && ( diff --git a/src/Web/autopilot-monitor-web/app/sla/page.tsx b/src/Web/autopilot-monitor-web/app/sla/page.tsx index 9846f626..6f2e164d 100644 --- a/src/Web/autopilot-monitor-web/app/sla/page.tsx +++ b/src/Web/autopilot-monitor-web/app/sla/page.tsx @@ -4,6 +4,7 @@ import { useEffect, useState, useCallback } from 'react'; import { useTenant } from '../../contexts/TenantContext'; import { useAuth } from '../../contexts/AuthContext'; import { useNotifications } from '../../contexts/NotificationContext'; +import TruncatedLabel from '@/components/TruncatedLabel'; import { ProtectedRoute } from '../../components/ProtectedRoute'; import { api } from "@/lib/api"; import { authenticatedFetch, TokenExpiredError } from "@/lib/authenticatedFetch"; @@ -333,7 +334,7 @@ export default function SlaPage() { {i + 1}
- {app.appName} + {app.failCount} failed
@@ -429,7 +430,9 @@ export default function SlaPage() { {v.violationType} - {v.failureReason || "-"} + + + ))} diff --git a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx index 37dbdc0a..e43e8d26 100644 --- a/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/DownloadProgress.tsx @@ -2,6 +2,7 @@ import { useMemo, useState } from "react"; import { partitionHistoricReplayEvents } from "@/lib/historicReplay"; +import TruncatedLabel from "@/components/TruncatedLabel"; import { shouldSkipLowBytesTotal, shouldSkipNoActivity } from "@/lib/downloadProgressFilters"; import { formatBytes, formatThroughput, formatDuration } from "@/lib/formatting"; import DoBreakdownBar from "./DoBreakdownBar"; @@ -353,7 +354,7 @@ function DownloadItem({ download: dl, progressPercent }: { download: DownloadIte )} - {dl.appName} + {dl.isSkipped && ( Skipped )} diff --git a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx index a06a6437..434dcebc 100644 --- a/src/Web/autopilot-monitor-web/components/InstallProgress.tsx +++ b/src/Web/autopilot-monitor-web/components/InstallProgress.tsx @@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { getErrorCodeEntry, formatErrorCode } from "@/utils/errorCodeMap"; import { partitionHistoricReplayEvents } from "@/lib/historicReplay"; +import TruncatedLabel from "@/components/TruncatedLabel"; interface InstallEvent { timestamp: string; @@ -465,9 +466,7 @@ function InstallItemRow({ item }: { item: InstallItem }) { )} - - {item.appName} - + {item.state === "Skipped" && ( Skipped )} diff --git a/src/Web/autopilot-monitor-web/components/TruncatedLabel.tsx b/src/Web/autopilot-monitor-web/components/TruncatedLabel.tsx new file mode 100644 index 00000000..4f295d84 --- /dev/null +++ b/src/Web/autopilot-monitor-web/components/TruncatedLabel.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { useState } from "react"; + +interface TruncatedLabelProps { + /** The full text to display. Also used verbatim as the hover tooltip. */ + text: string; + /** Extra classes for the text styling (font size, colour, padding). */ + className?: string; + /** + * When false, only the hover tooltip is provided — no click-to-expand and no + * button semantics. Use this inside an already-clickable row/link/button, + * where an inner onClick would hijack the outer navigation. + */ + interactive?: boolean; +} + +/** + * A single-line label that truncates with an ellipsis by default. + * + * - Hovering always shows the full text in a native browser tooltip (standard + * tooltip, no special cursor). + * - When `interactive` (the default), clicking — or pressing Enter/Space when + * focused — toggles a two-line expanded view. This keeps long values readable + * on touch devices where hover is unavailable. + * - When `interactive={false}`, it is a plain truncated span with only the + * tooltip, safe to nest inside a clickable row or link. + */ +export default function TruncatedLabel({ + text, + className = "", + interactive = true, +}: TruncatedLabelProps) { + const [expanded, setExpanded] = useState(false); + + if (!interactive) { + return ( + + {text} + + ); + } + + const toggle = () => setExpanded((v) => !v); + + return ( + { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + toggle(); + } + }} + className={`${className} min-w-0 cursor-pointer ${ + expanded ? "line-clamp-2 break-words" : "truncate" + }`} + > + {text} + + ); +} diff --git a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx index 6b233c7a..a341b754 100644 --- a/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx +++ b/src/Web/autopilot-monitor-web/components/VulnerabilityExposurePanel.tsx @@ -1,6 +1,7 @@ "use client"; import { severityChipClass, KEV_BADGE_CLASS } from "@/lib/vulnerabilitySeverity"; +import TruncatedLabel from "@/components/TruncatedLabel"; export interface VulnerabilityTopCve { cveId: string; @@ -136,7 +137,7 @@ export default function VulnerabilityExposurePanel({ )}
{c.sampleSoftware.length > 0 && ( -
{c.sampleSoftware.join(", ")}
+ )}