Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions ui/src/components/ActivityExpandedDetails.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
}
Expand Down Expand Up @@ -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 (
<TooltipProvider>
<div className="mt-4 pt-4 border-t border-border">
<div className={compact ? 'p-4 bg-muted/30' : 'mt-4 pt-4 border-t border-border'}>
{/* Field Changes - Most actionable, shown first */}
{changes && changes.length > 0 && (
<div className="mb-3">
Expand Down
9 changes: 7 additions & 2 deletions ui/src/components/ActivityFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand Down Expand Up @@ -82,6 +84,7 @@ export function ActivityFeed({
tenantRenderer,
onActivityClick,
compact = false,
variant = 'feed',
resourceUid,
showFilters = true,
hiddenFilters = [],
Expand Down Expand Up @@ -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 (
<Card className={containerClasses}>
Expand Down Expand Up @@ -396,6 +399,8 @@ export function ActivityFeed({
onActivityClick={onActivityClick}
compact={compact}
isNew={enableStreaming && index < newActivitiesCount}
variant={variant}
isLast={index === activities.length - 1}
/>
))}

Expand Down
80 changes: 55 additions & 25 deletions ui/src/components/ActivityFeedFilters.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<FilterId[]>(() => {
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)
Expand All @@ -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
Expand Down Expand Up @@ -330,20 +333,39 @@ export function ActivityFeedFilters({
return (value as string[] | undefined) || [];
};

// Handle search input change with debouncing
const handleSearchChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
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<ReturnType<typeof setTimeout> | 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<HTMLInputElement>) => {
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 (
<div className={`mb-3 pb-3 border-b border-border pr-2 ${className}`}>
<div className={`mb-3 pb-3 border-b border-border p-4 ${className}`}>
<div className="flex flex-wrap gap-2 items-center">
{/* Change Source Toggle */}
{!hiddenFilters.includes('changeSource') && (
Expand All @@ -360,11 +382,19 @@ export function ActivityFeedFilters({
<Input
type="text"
placeholder="Search activities..."
value={filters.search || ''}
value={searchInputValue}
onChange={handleSearchChange}
disabled={disabled}
className="pl-8 h-7 text-xs"
className="pl-8 h-7 text-xs pr-6"
/>
{searchInputValue && (
<button
onClick={handleSearchClear}
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors"
aria-label="Clear search"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>

{/* Active Filter Chips */}
Expand Down
Loading
Loading