From 54540809bd57ef8ea793b16bb371ca20c103fb60 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 25 Mar 2026 18:49:21 -0700 Subject: [PATCH] feat(ui): add timeline variant, debounced search, and UTC timestamp fix - Add `variant="timeline"` to ActivityFeedItem: flat bordered rows with colored action icon squares (blue=create, green=update, red=delete), dark mode variants included - Add expanded details panel to timeline variant via ActivityExpandedDetails with new `compact` prop (muted background, no redundant top border) - Fix formatTimestampFull to use UTC methods instead of format() with hardcoded 'UTC' label (affected both ActivityFeedItem and ActivityExpandedDetails) - Add debounced search (400ms) to ActivityFeedFilters with local state + refs pattern to avoid stale closures; add clear button and unmount cleanup - Remove `actions` from available filters (no backend facet support yet) - Fix filter-chip blue tint by replacing bg-secondary with bg-card/bg-accent (secondary has hue 240 in alpha theme causing blue appearance) - Remove compact card padding/border from ActivityFeed for cleaner embedding - Remove isFirst prop from ActivityFeedItem (no longer needed) --- ui/src/components/ActivityExpandedDetails.tsx | 12 +- ui/src/components/ActivityFeed.tsx | 9 +- ui/src/components/ActivityFeedFilters.tsx | 80 +++++--- ui/src/components/ActivityFeedItem.tsx | 175 ++++++++---------- ui/src/components/ResourceHistoryView.tsx | 1 - ui/src/components/ui/filter-chip.tsx | 26 +-- 6 files changed, 162 insertions(+), 141 deletions(-) diff --git a/ui/src/components/ActivityExpandedDetails.tsx b/ui/src/components/ActivityExpandedDetails.tsx index 90fc0e8a..14dc44ea 100644 --- a/ui/src/components/ActivityExpandedDetails.tsx +++ b/ui/src/components/ActivityExpandedDetails.tsx @@ -1,5 +1,4 @@ import { useState } from 'react'; -import { format } from 'date-fns'; import { Copy, Check } from 'lucide-react'; import type { Activity, TenantLinkResolver } from '../types/activity'; import { TenantBadge } from './TenantBadge'; @@ -15,15 +14,18 @@ export interface ActivityExpandedDetailsProps { activity: Activity; /** Optional resolver function to make tenant badges clickable */ tenantLinkResolver?: TenantLinkResolver; + /** When true, removes the top margin/border (caller handles the separator) */ + compact?: boolean; } /** - * Format timestamp for display (with timezone) + * Format timestamp for display (in UTC) */ function formatTimestampFull(timestamp?: string): string { if (!timestamp) return 'Unknown time'; try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + const date = new Date(timestamp); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; } catch { return timestamp; } @@ -80,14 +82,14 @@ function CopyButton({ value, label }: { value: string; label: string }) { * 5. Resource - what resource was affected * 6. Origin - correlation to audit logs */ -export function ActivityExpandedDetails({ activity, tenantLinkResolver }: ActivityExpandedDetailsProps) { +export function ActivityExpandedDetails({ activity, tenantLinkResolver, compact = false }: ActivityExpandedDetailsProps) { const { spec, metadata } = activity; const { actor, resource, origin, changes, tenant } = spec; const timestamp = metadata?.creationTimestamp; return ( -
+
{/* Field Changes - Most actionable, shown first */} {changes && changes.length > 0 && (
diff --git a/ui/src/components/ActivityFeed.tsx b/ui/src/components/ActivityFeed.tsx index 01bf649e..61b3aee2 100644 --- a/ui/src/components/ActivityFeed.tsx +++ b/ui/src/components/ActivityFeed.tsx @@ -36,6 +36,8 @@ export interface ActivityFeedProps { onActivityClick?: (activity: Activity) => void; /** Whether to show in compact mode (for resource detail tabs) */ compact?: boolean; + /** Layout variant for activity items: 'feed' (default) or 'timeline' */ + variant?: 'feed' | 'timeline'; /** Filter to a specific resource UID */ resourceUid?: string; /** Whether to show filters */ @@ -82,6 +84,7 @@ export function ActivityFeed({ tenantRenderer, onActivityClick, compact = false, + variant = 'feed', resourceUid, showFilters = true, hiddenFilters = [], @@ -231,13 +234,13 @@ export function ActivityFeed({ // Build container classes - use flex layout to properly fill available space // flex-1 min-h-0 allows the Card to fill parent flex container and enable child scrolling const containerClasses = compact - ? `flex-1 min-h-0 flex flex-col p-1 shadow-none border-border ${className}` + ? `flex-1 min-h-0 flex flex-col p-0 shadow-none border-none ${className}` : `flex-1 min-h-0 flex flex-col p-3 ${className}`; // Build list classes - use flex-1 min-h-0 for flex-based scrolling // Parent containers must have proper height constraints (h-screen/h-full + overflow-hidden) const effectiveMaxHeight = maxHeight === 'none' ? undefined : maxHeight; - const listClasses = 'flex-1 min-h-0 overflow-y-auto pr-2 flex flex-col'; + const listClasses = 'flex-1 min-h-0 overflow-y-auto flex flex-col'; return ( @@ -396,6 +399,8 @@ export function ActivityFeed({ onActivityClick={onActivityClick} compact={compact} isNew={enableStreaming && index < newActivitiesCount} + variant={variant} + isLast={index === activities.length - 1} /> ))} diff --git a/ui/src/components/ActivityFeedFilters.tsx b/ui/src/components/ActivityFeedFilters.tsx index d2348aa5..2f8d1045 100644 --- a/ui/src/components/ActivityFeedFilters.tsx +++ b/ui/src/components/ActivityFeedFilters.tsx @@ -1,6 +1,6 @@ -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef, useMemo } from 'react'; import { formatISO, subDays } from 'date-fns'; -import { Search } from 'lucide-react'; +import { Search, X } from 'lucide-react'; import type { ActivityFeedFilters as FilterState } from '../hooks/useActivityFeed'; import type { TimeRange } from '../hooks/useActivityFeed'; @@ -200,13 +200,16 @@ export function ActivityFeedFilters({ }; // Determine which filters are currently active (have values) and not hidden - const filtersWithValues: FilterId[] = []; - if (filters.resourceKinds && filters.resourceKinds.length > 0 && !hiddenFilters.includes('resourceKinds')) filtersWithValues.push('resourceKinds'); - if (filters.actorNames && filters.actorNames.length > 0 && !hiddenFilters.includes('actorNames')) filtersWithValues.push('actorNames'); - if (filters.apiGroups && filters.apiGroups.length > 0 && !hiddenFilters.includes('apiGroups')) filtersWithValues.push('apiGroups'); - if (filters.resourceNamespaces && filters.resourceNamespaces.length > 0 && !hiddenFilters.includes('resourceNamespaces')) filtersWithValues.push('resourceNamespaces'); - if (filters.resourceName && !hiddenFilters.includes('resourceName')) filtersWithValues.push('resourceName'); - if (filters.actions && filters.actions.length > 0) filtersWithValues.push('actions'); + const filtersWithValues = useMemo(() => { + const result: FilterId[] = []; + if (filters.resourceKinds && filters.resourceKinds.length > 0 && !hiddenFilters.includes('resourceKinds')) result.push('resourceKinds'); + if (filters.actorNames && filters.actorNames.length > 0 && !hiddenFilters.includes('actorNames')) result.push('actorNames'); + if (filters.apiGroups && filters.apiGroups.length > 0 && !hiddenFilters.includes('apiGroups')) result.push('apiGroups'); + if (filters.resourceNamespaces && filters.resourceNamespaces.length > 0 && !hiddenFilters.includes('resourceNamespaces')) result.push('resourceNamespaces'); + if (filters.resourceName && !hiddenFilters.includes('resourceName')) result.push('resourceName'); + if (filters.actions && filters.actions.length > 0) result.push('actions'); + return result; + }, [filters, hiddenFilters]); // Include pendingFilter (newly added filter awaiting value selection) in the displayed filters const activeFilterIds: FilterId[] = pendingFilter && !filtersWithValues.includes(pendingFilter) @@ -228,7 +231,7 @@ export function ActivityFeedFilters({ { id: 'apiGroups', label: 'API Group' }, { id: 'resourceNamespaces', label: 'Namespace' }, { id: 'resourceName', label: 'Resource Name' }, - { id: 'actions', label: 'Action' }, + // 'actions' hidden until backend facet support is available ].filter((filter) => !hiddenFilters.includes(filter.id as FilterId)); // Handle adding a filter @@ -330,20 +333,39 @@ export function ActivityFeedFilters({ return (value as string[] | undefined) || []; }; - // Handle search input change with debouncing - const handleSearchChange = useCallback( - (event: React.ChangeEvent) => { - const value = event.target.value; - onFiltersChange({ - ...filters, - search: value || undefined, - }); - }, - [filters, onFiltersChange] - ); + // Local search value for debouncing — keeps input responsive while query runs + const [searchInputValue, setSearchInputValue] = useState(filters.search || ''); + const searchDebounceRef = useRef | null>(null); + // Use refs so the debounced callback never closes over stale values + const filtersRef = useRef(filters); + filtersRef.current = filters; + const onFiltersChangeRef = useRef(onFiltersChange); + onFiltersChangeRef.current = onFiltersChange; + + // Cancel any pending debounce on unmount + useEffect(() => { + return () => { + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + }; + }, []); + + const handleSearchChange = useCallback((event: React.ChangeEvent) => { + const value = event.target.value; + setSearchInputValue(value); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + searchDebounceRef.current = setTimeout(() => { + onFiltersChangeRef.current({ ...filtersRef.current, search: value || undefined }); + }, 400); + }, []); + + const handleSearchClear = useCallback(() => { + setSearchInputValue(''); + if (searchDebounceRef.current) clearTimeout(searchDebounceRef.current); + onFiltersChangeRef.current({ ...filtersRef.current, search: undefined }); + }, []); return ( -
+
{/* Change Source Toggle */} {!hiddenFilters.includes('changeSource') && ( @@ -360,11 +382,19 @@ export function ActivityFeedFilters({ + {searchInputValue && ( + + )}
{/* Active Filter Chips */} diff --git a/ui/src/components/ActivityFeedItem.tsx b/ui/src/components/ActivityFeedItem.tsx index 27b2144c..397979f3 100644 --- a/ui/src/components/ActivityFeedItem.tsx +++ b/ui/src/components/ActivityFeedItem.tsx @@ -1,5 +1,5 @@ import { useState } from 'react'; -import { format, formatDistanceToNow } from 'date-fns'; +import { formatDistanceToNow } from 'date-fns'; import type { Activity, ResourceLinkResolver, TenantLinkResolver, TenantRenderer } from '../types/activity'; import { ActivityFeedSummary, ResourceLinkClickHandler } from './ActivityFeedSummary'; import { ActivityExpandedDetails } from './ActivityExpandedDetails'; @@ -7,6 +7,7 @@ import { TenantBadge } from './TenantBadge'; import { cn } from '../lib/utils'; import { Button } from './ui/button'; import { Card } from './ui/card'; +import { Plus, Pencil, Trash2, Activity as ActivityIcon } from 'lucide-react'; export interface ActivityFeedItemProps { /** The activity to render */ @@ -33,9 +34,7 @@ export interface ActivityFeedItemProps { isNew?: boolean; /** Layout variant: 'feed' (default) or 'timeline' */ variant?: 'feed' | 'timeline'; - /** Whether this is the first item in the list (hides timeline head, only used in timeline variant) */ - isFirst?: boolean; - /** Whether this is the last item in the list (hides timeline tail, only used in timeline variant) */ + /** Whether this is the last item in the list (hides bottom border, only used in timeline variant) */ isLast?: boolean; /** Whether the item starts expanded */ defaultExpanded?: boolean; @@ -55,12 +54,13 @@ function formatTimestamp(timestamp?: string): string { } /** - * Format timestamp for tooltip (with timezone) + * Format timestamp for tooltip (in UTC) */ function formatTimestampFull(timestamp?: string): string { if (!timestamp) return 'Unknown time'; try { - return format(new Date(timestamp), 'yyyy-MM-dd HH:mm:ss \'UTC\''); + const date = new Date(timestamp); + return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, '0')}-${String(date.getUTCDate()).padStart(2, '0')} ${String(date.getUTCHours()).padStart(2, '0')}:${String(date.getUTCMinutes()).padStart(2, '0')}:${String(date.getUTCSeconds()).padStart(2, '0')} UTC`; } catch { return timestamp; } @@ -119,19 +119,36 @@ function normalizeVerb(verb: string): 'create' | 'update' | 'delete' | 'other' { } /** - * Get timeline node classes based on verb + * Get icon container + icon color classes based on verb */ -function getTimelineNodeClasses(verb: string): string { +function getActionIconClasses(verb: string): { container: string; icon: string } { const normalizedVerb = normalizeVerb(verb); switch (normalizedVerb) { case 'create': - return 'bg-green-500'; + return { container: 'bg-blue-50 dark:bg-blue-950', icon: 'text-blue-500 dark:text-blue-400' }; case 'update': - return 'bg-amber-500'; + return { container: 'bg-green-50 dark:bg-green-950', icon: 'text-green-600 dark:text-green-400' }; case 'delete': - return 'bg-red-500'; + return { container: 'bg-red-50 dark:bg-red-950', icon: 'text-red-500 dark:text-red-400' }; default: - return 'bg-muted-foreground'; + return { container: 'bg-slate-100 dark:bg-slate-800', icon: 'text-slate-500 dark:text-slate-400' }; + } +} + +/** + * Get the Lucide icon component for the timeline node based on verb + */ +function getTimelineIcon(verb: string): React.ElementType { + const normalizedVerb = normalizeVerb(verb); + switch (normalizedVerb) { + case 'create': + return Plus; + case 'update': + return Pencil; + case 'delete': + return Trash2; + default: + return ActivityIcon; } } @@ -151,7 +168,6 @@ export function ActivityFeedItem({ compact = false, isNew = false, variant = 'feed', - isFirst = false, isLast = false, defaultExpanded = false, }: ActivityFeedItemProps) { @@ -180,104 +196,71 @@ export function ActivityFeedItem({ const verb = extractVerb(summary); const isTimeline = variant === 'timeline'; - // Timeline variant wrapper + // Timeline variant — flat list row with bottom border if (isTimeline) { + const { container: iconBg, icon: iconColor } = getActionIconClasses(verb); + const Icon = getTimelineIcon(verb); return ( -
- {/* Timeline column - contains line and dot */} +
- {/* Top line segment (connects to previous item) */} -
- - {/* Timeline node (dot) - centered */} + {/* Action icon square */}
+ > + +
- {/* Bottom line segment (connects to next item) */} -
-
+ {/* Summary */} +
+ +
- {/* Event content card */} -
- {/* Single row layout */} -
- {/* Summary - takes remaining space */} -
- + {/* Tenant badge */} + {tenant && ( +
+ {tenantRenderer ? tenantRenderer(tenant) : }
+ )} - {/* Tenant badge */} - {tenant && ( -
- {tenantRenderer ? tenantRenderer(tenant) : } -
- )} - - {/* Timestamp */} - - {formatTimestamp(timestamp)} - - - {/* Expand button */} - -
+ {/* Timestamp */} + + {formatTimestamp(timestamp)} + - {/* Expanded Details */} - {isExpanded && } + {/* Expand toggle */} +
+ + {/* Expanded Details */} + {isExpanded && ( + + )}
); } diff --git a/ui/src/components/ResourceHistoryView.tsx b/ui/src/components/ResourceHistoryView.tsx index e4ba1582..dc15a0bb 100644 --- a/ui/src/components/ResourceHistoryView.tsx +++ b/ui/src/components/ResourceHistoryView.tsx @@ -259,7 +259,6 @@ export function ResourceHistoryView({ activity={activity} variant="timeline" compact={compact} - isFirst={index === 0} isLast={index === activities.length - 1 && !hasMore} onActivityClick={onActivityClick} onResourceClick={onResourceClick} diff --git a/ui/src/components/ui/filter-chip.tsx b/ui/src/components/ui/filter-chip.tsx index f7cc8297..2cd6f77d 100644 --- a/ui/src/components/ui/filter-chip.tsx +++ b/ui/src/components/ui/filter-chip.tsx @@ -209,9 +209,8 @@ export function FilterChip({ type="button" disabled={disabled} className={cn( - 'flex h-7 items-center gap-2 rounded-l-md border border-r-0 border-border bg-secondary px-2 text-xs', - 'hover:bg-secondary/80 transition-colors', - 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'flex h-7 items-center gap-2 rounded-l-md border border-r-0 border-border bg-card px-2 text-xs outline-none', + 'hover:bg-accent/40 data-[state=open]:bg-accent/40 transition-colors', 'disabled:cursor-not-allowed disabled:opacity-50' )} > @@ -278,12 +277,16 @@ export function FilterChip({ 'hover:bg-accent hover:text-accent-foreground' )} > - {}} - className="mr-2 h-4 w-4" - /> +
+ {values.includes(option.value) && ( + + + + )} +
{option.label} {option.count !== undefined && ( @@ -315,9 +318,8 @@ export function FilterChip({ onClick={handleClearAll} disabled={disabled} className={cn( - 'flex h-7 items-center rounded-r-md border border-border bg-secondary px-2', - 'hover:bg-secondary/80 transition-colors', - 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', + 'flex h-7 items-center rounded-r-md border border-border bg-card px-2 outline-none', + 'hover:bg-accent/40 transition-colors', 'disabled:cursor-not-allowed disabled:opacity-50' )} aria-label={`Clear ${label} filter`}