From e923dd59e2aac52243a672c3385b2d25f905fe7e Mon Sep 17 00:00:00 2001 From: Will Date: Thu, 30 Jul 2026 16:20:51 +0800 Subject: [PATCH 1/2] fix(credentials): reveal auth file names on hover Reuse the Request Events portal tooltip for complete filename previews without adding labels or changing the credential row layout. --- .../components/ui/PortalTooltip.module.scss | 24 ++++ web/src/components/ui/PortalTooltip.tsx | 130 ++++++++++++++++++ .../usage/RequestEventsDetailsCard.tsx | 130 ++---------------- .../AuthFileCredentialsSection.tsx | 31 ++++- .../credentials/CredentialAliasEditor.tsx | 8 +- .../CredentialSections.module.scss | 10 ++ .../test/AuthFileFilenameTooltip.test.tsx | 112 +++++++++++++++ .../test/CredentialSections.styles.test.ts | 5 + web/src/pages/UsagePage.module.scss | 25 ---- 9 files changed, 325 insertions(+), 150 deletions(-) create mode 100644 web/src/components/ui/PortalTooltip.module.scss create mode 100644 web/src/components/ui/PortalTooltip.tsx create mode 100644 web/src/components/usage/credentials/test/AuthFileFilenameTooltip.test.tsx diff --git a/web/src/components/ui/PortalTooltip.module.scss b/web/src/components/ui/PortalTooltip.module.scss new file mode 100644 index 00000000..61357e30 --- /dev/null +++ b/web/src/components/ui/PortalTooltip.module.scss @@ -0,0 +1,24 @@ +.tooltip { + position: fixed; + z-index: 2100; + display: grid; + gap: 3px; + width: max-content; + max-width: min(280px, calc(100vw - 16px)); + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-primary); + color: var(--text-secondary); + box-shadow: 0 18px 44px rgba(0, 0, 0, 0.22); + padding: 10px 12px; + pointer-events: none; + text-align: left; + font-size: 11px; + line-height: 1.45; + + span { + display: block; + white-space: normal; + overflow-wrap: anywhere; + } +} diff --git a/web/src/components/ui/PortalTooltip.tsx b/web/src/components/ui/PortalTooltip.tsx new file mode 100644 index 00000000..7ccdd0b7 --- /dev/null +++ b/web/src/components/ui/PortalTooltip.tsx @@ -0,0 +1,130 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { createPortal } from 'react-dom' +import styles from './PortalTooltip.module.scss' + +const TOOLTIP_MAX_WIDTH = 280 +const TOOLTIP_ESTIMATED_HEIGHT = 72 +const TOOLTIP_OFFSET = 10 +const TOOLTIP_VIEWPORT_PADDING = 8 + +export type PortalTooltipState = { + lines: string[] + x: number + y: number + placement: 'above' | 'below' +} + +type PortalTooltipTarget = { + lines: string[] + anchor: HTMLElement +} + +export function usePortalTooltip() { + const [tooltip, setTooltip] = useState(null) + const hoverTargetRef = useRef(null) + const focusTargetRef = useRef(null) + + const positionTooltip = useCallback((target: PortalTooltipTarget | null) => { + if (!target?.anchor.isConnected) { + setTooltip(null) + return + } + + // 浮层挂到 body 后不受滚动容器裁剪,并围绕当前 hover/focus 锚点限制在视口内。 + const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth + const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight + const rect = target.anchor.getBoundingClientRect() + const tooltipWidth = Math.min( + TOOLTIP_MAX_WIDTH, + Math.max(viewportWidth - TOOLTIP_VIEWPORT_PADDING * 2, 0), + ) + const halfTooltipWidth = tooltipWidth / 2 + const minX = TOOLTIP_VIEWPORT_PADDING + halfTooltipWidth + const maxX = viewportWidth - TOOLTIP_VIEWPORT_PADDING - halfTooltipWidth + const anchorX = rect.left + rect.width / 2 + const x = maxX >= minX ? Math.max(minX, Math.min(anchorX, maxX)) : viewportWidth / 2 + const spaceBelow = viewportHeight - rect.bottom - TOOLTIP_OFFSET - TOOLTIP_VIEWPORT_PADDING + const spaceAbove = rect.top - TOOLTIP_OFFSET - TOOLTIP_VIEWPORT_PADDING + const placement = spaceBelow >= TOOLTIP_ESTIMATED_HEIGHT || spaceBelow >= spaceAbove ? 'below' : 'above' + const y = placement === 'above' ? rect.top - TOOLTIP_OFFSET : rect.bottom + TOOLTIP_OFFSET + + setTooltip({ lines: target.lines, x, y, placement }) + }, []) + + const syncTooltip = useCallback(() => { + if (hoverTargetRef.current && !hoverTargetRef.current.anchor.isConnected) { + hoverTargetRef.current = null + } + if (focusTargetRef.current && !focusTargetRef.current.anchor.isConnected) { + focusTargetRef.current = null + } + positionTooltip(hoverTargetRef.current ?? focusTargetRef.current) + }, [positionTooltip]) + + const showOnMouseEnter = useCallback((lines: string[], anchor: HTMLElement) => { + hoverTargetRef.current = { lines, anchor } + syncTooltip() + }, [syncTooltip]) + + const hideOnMouseLeave = useCallback((anchor: HTMLElement) => { + if (hoverTargetRef.current?.anchor === anchor) { + hoverTargetRef.current = null + } + syncTooltip() + }, [syncTooltip]) + + const showOnFocus = useCallback((lines: string[], anchor: HTMLElement) => { + focusTargetRef.current = { lines, anchor } + syncTooltip() + }, [syncTooltip]) + + const hideOnBlur = useCallback((anchor: HTMLElement) => { + if (focusTargetRef.current?.anchor === anchor) { + focusTargetRef.current = null + } + syncTooltip() + }, [syncTooltip]) + + useEffect(() => { + const repositionTooltip = () => { + if (hoverTargetRef.current || focusTargetRef.current) { + syncTooltip() + } + } + window.addEventListener('resize', repositionTooltip) + window.addEventListener('scroll', repositionTooltip, true) + return () => { + window.removeEventListener('resize', repositionTooltip) + window.removeEventListener('scroll', repositionTooltip, true) + } + }, [syncTooltip]) + + return { + tooltip, + showOnMouseEnter, + hideOnMouseLeave, + showOnFocus, + hideOnBlur, + } +} + +export function PortalTooltip({ tooltip }: { tooltip: PortalTooltipState | null }) { + if (!tooltip || typeof document === 'undefined') return null + + return createPortal( +
+ {tooltip.lines.map((line, index) => {line})} +
, + document.body, + ) +} diff --git a/web/src/components/usage/RequestEventsDetailsCard.tsx b/web/src/components/usage/RequestEventsDetailsCard.tsx index f89b8562..b005c416 100644 --- a/web/src/components/usage/RequestEventsDetailsCard.tsx +++ b/web/src/components/usage/RequestEventsDetailsCard.tsx @@ -7,7 +7,6 @@ import React, { useState, type ReactNode, } from 'react'; -import { createPortal } from 'react-dom'; import { useVirtualizer } from '@tanstack/react-virtual'; import { useTranslation } from 'react-i18next'; import { Button } from '@/components/ui/Button'; @@ -15,6 +14,7 @@ import { Card } from '@/components/ui/Card'; import { EmptyState } from '@/components/ui/EmptyState'; import { MainActionButton } from '@/components/ui/MainActionButton'; import { Modal } from '@/components/ui/Modal'; +import { PortalTooltip, usePortalTooltip } from '@/components/ui/PortalTooltip'; import { Select } from '@/components/ui/Select'; import { IconCheck, IconChevronDown, IconCopy, IconDownload, IconScrollText, IconSettings } from '@/components/ui/icons'; import type { UsageEvent, UsageEventRequestLogResponse, UsageSourceFilterOption } from '@/lib/types'; @@ -50,10 +50,6 @@ const REQUEST_LOG_VIRTUAL_PADDING_Y = 12; const REQUEST_LOG_VIRTUAL_CHUNK_CHARS = 2048; const REQUEST_LOG_VIRTUAL_BREAK_LOOKBACK = 256; const REQUEST_LOG_GRAPHEME_CONTEXT_CHARS = 64; -const REQUEST_EVENTS_TOOLTIP_MAX_WIDTH = 280; -const REQUEST_EVENTS_TOOLTIP_ESTIMATED_HEIGHT = 72; -const REQUEST_EVENTS_TOOLTIP_OFFSET = 10; -const REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING = 8; const REQUEST_EVENT_CLIENT_IP_DISPLAY_LENGTH = 39; const REQUEST_EVENT_X_FORWARDED_FOR_DISPLAY_LENGTH = 48; const REQUEST_EVENT_USER_AGENT_DISPLAY_LENGTH = 48; @@ -126,18 +122,6 @@ type RequestEventRow = { costAvailable: boolean; }; -type RequestEventTooltipState = { - lines: string[]; - x: number; - y: number; - placement: 'above' | 'below'; -}; - -type RequestEventTooltipTarget = { - lines: string[]; - anchor: HTMLTableCellElement; -}; - type RequestEventColumnDefinition = { id: RequestEventColumnId; label: string; @@ -661,11 +645,15 @@ export function RequestEventsDetailsCard({ requestLogDownloading = false, }: RequestEventsDetailsCardProps) { const { t } = useTranslation(); - const [requestEventsTooltip, setRequestEventsTooltip] = useState(null); + const { + tooltip: requestEventsTooltip, + showOnMouseEnter: handleRequestEventsTooltipMouseEnter, + hideOnMouseLeave: handleRequestEventsTooltipMouseLeave, + showOnFocus: handleRequestEventsTooltipFocus, + hideOnBlur: handleRequestEventsTooltipBlur, + } = usePortalTooltip(); const [columnSettingsOpen, setColumnSettingsOpen] = useState(false); const [columnSettingsSession, setColumnSettingsSession] = useState(0); - const requestEventsTooltipHoverTargetRef = useRef(null); - const requestEventsTooltipFocusTargetRef = useRef(null); const requestEventsTableWrapperRef = useRef(null); const resultLocale = t('usage_stats.success') === 'Success' ? 'en' : 'zh'; const latencyHint = t('usage_stats.latency_unit_hint', { @@ -675,89 +663,6 @@ export function RequestEventsDetailsCard({ const ttftHint = t('usage_stats.ttft_hint'); const speedHint = t('usage_stats.speed_hint'); - const positionRequestEventsTooltip = useCallback((target: RequestEventTooltipTarget | null) => { - if (!target) { - setRequestEventsTooltip(null); - return; - } - - // 浮层挂到 body 后不受表格滚动容器裁剪,并随当前 hover/focus 锚点保持在视口内。 - const viewportWidth = typeof window === 'undefined' ? 1024 : window.innerWidth; - const viewportHeight = typeof window === 'undefined' ? 768 : window.innerHeight; - const rect = target.anchor.getBoundingClientRect(); - const tooltipWidth = Math.min( - REQUEST_EVENTS_TOOLTIP_MAX_WIDTH, - Math.max(viewportWidth - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING * 2, 0), - ); - const halfTooltipWidth = tooltipWidth / 2; - const minX = REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING + halfTooltipWidth; - const maxX = viewportWidth - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING - halfTooltipWidth; - const anchorX = rect.left + rect.width / 2; - const x = maxX >= minX ? Math.max(minX, Math.min(anchorX, maxX)) : viewportWidth / 2; - const spaceBelow = viewportHeight - - rect.bottom - - REQUEST_EVENTS_TOOLTIP_OFFSET - - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING; - const spaceAbove = rect.top - - REQUEST_EVENTS_TOOLTIP_OFFSET - - REQUEST_EVENTS_TOOLTIP_VIEWPORT_PADDING; - const placement = spaceBelow >= REQUEST_EVENTS_TOOLTIP_ESTIMATED_HEIGHT || spaceBelow >= spaceAbove - ? 'below' - : 'above'; - const y = placement === 'above' - ? rect.top - REQUEST_EVENTS_TOOLTIP_OFFSET - : rect.bottom + REQUEST_EVENTS_TOOLTIP_OFFSET; - - setRequestEventsTooltip({ lines: target.lines, x, y, placement }); - }, []); - - const syncRequestEventsTooltip = useCallback(() => { - if (requestEventsTooltipHoverTargetRef.current && !requestEventsTooltipHoverTargetRef.current.anchor.isConnected) { - requestEventsTooltipHoverTargetRef.current = null; - } - if (requestEventsTooltipFocusTargetRef.current && !requestEventsTooltipFocusTargetRef.current.anchor.isConnected) { - requestEventsTooltipFocusTargetRef.current = null; - } - positionRequestEventsTooltip( - requestEventsTooltipHoverTargetRef.current ?? requestEventsTooltipFocusTargetRef.current, - ); - }, [positionRequestEventsTooltip]); - - const handleRequestEventsTooltipMouseEnter = useCallback((lines: string[], anchor: HTMLTableCellElement) => { - requestEventsTooltipHoverTargetRef.current = { lines, anchor }; - syncRequestEventsTooltip(); - }, [syncRequestEventsTooltip]); - const handleRequestEventsTooltipMouseLeave = useCallback((anchor: HTMLTableCellElement) => { - if (requestEventsTooltipHoverTargetRef.current?.anchor === anchor) { - requestEventsTooltipHoverTargetRef.current = null; - } - syncRequestEventsTooltip(); - }, [syncRequestEventsTooltip]); - const handleRequestEventsTooltipFocus = useCallback((lines: string[], anchor: HTMLTableCellElement) => { - requestEventsTooltipFocusTargetRef.current = { lines, anchor }; - syncRequestEventsTooltip(); - }, [syncRequestEventsTooltip]); - const handleRequestEventsTooltipBlur = useCallback((anchor: HTMLTableCellElement) => { - if (requestEventsTooltipFocusTargetRef.current?.anchor === anchor) { - requestEventsTooltipFocusTargetRef.current = null; - } - syncRequestEventsTooltip(); - }, [syncRequestEventsTooltip]); - - useEffect(() => { - const repositionRequestEventsTooltip = () => { - if (requestEventsTooltipHoverTargetRef.current || requestEventsTooltipFocusTargetRef.current) { - syncRequestEventsTooltip(); - } - }; - window.addEventListener('resize', repositionRequestEventsTooltip); - window.addEventListener('scroll', repositionRequestEventsTooltip, true); - return () => { - window.removeEventListener('resize', repositionRequestEventsTooltip); - window.removeEventListener('scroll', repositionRequestEventsTooltip, true); - }; - }, [syncRequestEventsTooltip]); - const rows = useMemo(() => { return events.map((event, index) => { const timestamp = event.timestamp; @@ -1389,24 +1294,7 @@ export function RequestEventsDetailsCard({ onApply={handleColumnSettingsApply} onClose={() => setColumnSettingsOpen(false)} /> - {requestEventsTooltip && typeof document !== 'undefined' - ? createPortal( -
- {requestEventsTooltip.lines.map((line) => {line})} -
, - document.body, - ) - : null} + ('current') const [displayMode, setDisplayModeState] = useState(() => readStoredAuthFileDisplayMode()) const [expiryTooltip, setExpiryTooltip] = useState(null) + const { + tooltip: filenameTooltip, + showOnMouseEnter: showFilenameTooltipOnMouseEnter, + hideOnMouseLeave: hideFilenameTooltipOnMouseLeave, + showOnFocus: showFilenameTooltipOnFocus, + hideOnBlur: hideFilenameTooltipOnBlur, + } = usePortalTooltip() const expiryTooltipHoverTargetRef = useRef(null) const expiryTooltipFocusTargetRef = useRef(null) const showHealthMode = displayMode === 'health' @@ -247,6 +255,25 @@ export function AuthFileCredentialsSection({ rows, total, page, totalPages, page const rowExpiryTooltipText = row.expiresAtLabel ? t('usage_stats.credentials_expiry_tooltip', { value: row.expiresAtLabel }) : '' + const fileName = row.identity.file_name?.trim() ?? '' + const filenameTooltipTargetProps = { + className: styles.credentialFileNameTooltipTarget, + 'data-auth-file-name-tooltip-target': true, + tabIndex: fileName ? 0 : undefined, + 'aria-label': fileName ? `${row.displayName}; ${fileName}` : undefined, + onMouseEnter: fileName + ? (event: React.MouseEvent) => showFilenameTooltipOnMouseEnter([fileName], event.currentTarget) + : undefined, + onMouseLeave: fileName + ? (event: React.MouseEvent) => hideFilenameTooltipOnMouseLeave(event.currentTarget) + : undefined, + onFocus: fileName + ? (event: React.FocusEvent) => showFilenameTooltipOnFocus([fileName], event.currentTarget) + : undefined, + onBlur: fileName + ? (event: React.FocusEvent) => hideFilenameTooltipOnBlur(event.currentTarget) + : undefined, + } return ( - ) : row.displayName} + ) : {row.displayName}} subtitle={( {row.typeLabel} @@ -362,6 +390,7 @@ export function AuthFileCredentialsSection({ rows, total, page, totalPages, page onSortChange={(nextSort) => onSortChange(nextSort as UsageIdentityPageSort)} /> + {expiryTooltip && activeExpiryTooltipText && typeof document !== 'undefined' ? createPortal(
onSaveAlias: (id: string, alias: string) => Promise } @@ -17,13 +18,14 @@ export function isCredentialAliasEditorDisabled(identityId: string, isDeleted?: return Boolean(isDeleted || (aliasSavingId && aliasSavingId !== identityId)) } -export function CredentialAliasEditor({ identityId, displayName, alias, saving, disabled = false, onSaveAlias }: CredentialAliasEditorProps) { +export function CredentialAliasEditor({ identityId, displayName, alias, saving, disabled = false, displayNameProps, onSaveAlias }: CredentialAliasEditorProps) { const { t } = useTranslation() const [editing, setEditing] = useState(false) const [draftAlias, setDraftAlias] = useState(alias ?? '') const currentAlias = alias ?? '' const canEdit = !disabled && identityId.trim() !== '' const canSave = !saving && draftAlias.trim() !== currentAlias.trim() + const displayNameClassName = `${styles.credentialAliasNameSlot} ${displayNameProps?.className ?? ''}`.trim() const startEditing = () => { if (!canEdit) return @@ -100,7 +102,7 @@ export function CredentialAliasEditor({ identityId, displayName, alias, saving, return ( - {displayName} + {displayName} {canEdit && (