Waterfall

A hierarchical log display component for showing time-based log data with interactive controls and custom actions.

interface WaterfallProps  {
  logData: LogItemType[];
  temporalCursor?: number;
  panelWidth?: number;
  onTemporalCursorChange?: (time: number) => void;
  getIcon: (item: LogItemType) => ReactNode;
  hoveredId?: string | null;
  onItemHover?: (id: string | null) => void;
  minWindow?: number;
  maxWindow?: number;
  zoomFactor?: number;
  enabled?: boolean;
  children?: React.ReactNode;
  className?: string;
}

Types

type LogItemType = TreeDataItemV2 & {
  type: "task" | "attempt" | "info" | "step";
  icon?: "history" | "file-code" | "bot" | "check-circle" | "pause-circle";
  createTime?: number;
  startTime?: number;
  duration?: number;
  time?: number;
  color?: "blue" | "green" | "orange" | "gray-light" | "gray-medium" | "purple";
  isCollapsible?: boolean;
  hasStripes?: boolean;
  isHaltedStep?: boolean;
};

type TreeDataItemV2 = {
  id: string;
  parentId: string | null;
  label: string;
  isCollapsible?: boolean;
  actions?: ReactNode;
  disable?: boolean;
  [key: string]: unknown;
};

Basic Usage

Job registered in queue
generate-report
Attempt 1
Fetch database records
Job halted, waiting for resources...
Waiting for image renderer...
Render charts
Assemble PDF
Fetch database records
Memory checkpoint
data-validation
Schema validation
Data integrity check
Generate validation report
Cache cleared
email-notification
Prepare email template
Attach report files
Send via SMTP
Webhook triggered
System health check
cleanup-process
Remove temporary files
Update job status
All tasks completed
-10s
-5s
0ms
5s
10s
15s
20s
25s
30s
33.000s
import { Waterfall, LogItemType } from "@vuer-ai/vuer-uikit";
import React, { useState, useMemo } from "react";
import {
  Bot,
  CheckCircle2,
  FileCode,
  History,
  Info,
  PauseCircle,
  Eye,
  EyeClosed,
  Trash2,
} from "lucide-react";
import { cn } from "@vuer-ai/vuer-uikit";

const logData: LogItemType[] = [
  {
    id: "0",
    parentId: null,
    indent: 0,
    etype: "info",
    label: "Job registered in queue",
    icon: "history",
    time: 0,
    color: "purple",
  },
  {
    id: "1",
    parentId: null,
    indent: 0,
    etype: "task",
    label: "generate-report",
    icon: "file-code",
    createTime: 0,
    startTime: 0,
    duration: 20,
    color: "blue",
    isCollapsible: true,
    hasStripes: true,
  },
  {
    id: "2",
    parentId: "1",
    indent: 1,
    etype: "attempt",
    label: "Attempt 1",
    icon: "bot",
    createTime: 0.1,
    startTime: 0.1,
    duration: 19.9,
    color: "blue",
    isCollapsible: true,
  },
  {
    id: "3",
    parentId: "2",
    indent: 2,
    etype: "step",
    label: "Fetch database records",
    icon: "check-circle",
    createTime: 0.2,
    startTime: 0.5,
    duration: 3,
    color: "green",
  },
  {
    id: "4",
    parentId: "2",
    indent: 2,
    etype: "step",
    label: "Job halted, waiting for resources...",
    icon: "pause-circle",
    startTime: 4,
    duration: 2,
    color: "orange",
    isHaltedStep: true,
  },
  // ... more log items
];

const getIcon = (item: LogItemType) => {
  const iconColor = (item: LogItemType) => {
    if (item.label === "generate-report" || item.label === "Assemble PDF")
      return "text-blue-500";
    if (item.icon === "file-code") return "text-muted-foreground";
    return "";
  };

  switch (item.icon) {
    case "history":
      return <History className="size-4 text-purple-500 shrink-0" />;
    case "file-code":
      return <FileCode className={cn("size-4 shrink-0", iconColor(item))} />;
    case "bot":
      return <Bot className="size-4 text-muted-foreground shrink-0" />;
    case "check-circle":
      return <CheckCircle2 className="size-4 text-green-500 shrink-0" />;
    case "pause-circle":
      return <PauseCircle className="size-4 text-orange-500 shrink-0" />;
    default:
      return <Info className="size-4 text-muted-foreground shrink-0" />;
  }
};

// Helper functions
const getAllChildrenIds = (parentId: string, allItems: LogItemType[]): string[] => {
  const children: string[] = [];
  const directChildren = allItems.filter(item => item.parentId === parentId);

  for (const child of directChildren) {
    children.push(child.id);
    children.push(...getAllChildrenIds(child.id, allItems));
  }

  return children;
};

const isIndirectlyHidden = (itemId: string, hiddenItems: Set<string>, allItems: LogItemType[]): boolean => {
  const item = allItems.find(i => i.id === itemId);
  if (!item || !item.parentId) return false;

  if (hiddenItems.has(item.parentId)) {
    return true;
  }

  return isIndirectlyHidden(item.parentId, hiddenItems, allItems);
};

// State management
const [expandedItems, setExpandedItems] = useState(new Set(["1", "2"]));
const [hoveredId, setHoveredId] = useState<string | null>(null);
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set());
const [deletedItems, setDeletedItems] = useState<Set<string>>(new Set());

// Toggle visibility function
const toggleItemVisibility = (itemId: string) => {
  const wasHidden = hiddenItems.has(itemId);

  setHiddenItems(prev => {
    const newSet = new Set(prev);
    if (newSet.has(itemId)) {
      newSet.delete(itemId);
    } else {
      newSet.add(itemId);
    }
    return newSet;
  });

  // If hiding an expanded item, collapse it
  if (!wasHidden && expandedItems.has(itemId)) {
    setExpandedItems(prev => {
      const newSet = new Set(prev);
      newSet.delete(itemId);
      return newSet;
    });
  }
};

// Delete item function
const deleteItem = (itemId: string) => {
  const itemsToDelete = [itemId, ...getAllChildrenIds(itemId, logData)];

  setDeletedItems(prev => {
    const newSet = new Set(prev);
    itemsToDelete.forEach(id => newSet.add(id));
    return newSet;
  });

  setHiddenItems(prev => {
    const newSet = new Set(prev);
    itemsToDelete.forEach(id => newSet.delete(id));
    return newSet;
  });

  setExpandedItems(prev => {
    const newSet = new Set(prev);
    itemsToDelete.forEach(id => newSet.delete(id));
    return newSet;
  });
};

// Create actions for each item
const createActions = (itemId: string, isDirectlyHidden: boolean, isIndirectlyHiddenItem: boolean, isHovered: boolean) => {
  const visibilityButton = (
    <button
      onClick={(e) => {
        e.stopPropagation();
        toggleItemVisibility(itemId);
      }}
      className='hover:bg-shadow-secondary rounded p-1'
    >
      {isDirectlyHidden ? (
        <EyeClosed className='text-icon-tertiary size-3' />
      ) : isIndirectlyHiddenItem ? (
        <div className='flex size-4 items-center justify-center'>
          <div className='text-icon-tertiary size-[3px] bg-current rounded-full' />
        </div>
      ) : (
        <Eye className='text-icon-primary size-3' />
      )}
    </button>
  );

  const deleteButton = (
    <button
      onClick={(e) => {
        e.stopPropagation();
        deleteItem(itemId);
      }}
      className='hover:bg-shadow-secondary rounded p-1'
    >
      <Trash2 className={cn('size-3',
        (isDirectlyHidden || isIndirectlyHiddenItem) ? 'text-icon-tertiary' : 'text-icon-primary'
      )} />
    </button>
  );

  return (
    <div className='flex gap-1'>
      {isHovered && deleteButton}
      {visibilityButton}
    </div>
  );
};

// Process log data with actions and visibility states
const processedLogData = useMemo(() => {
  return logData
    .filter(item => !deletedItems.has(item.id))
    .map(item => {
      const isDirectlyHidden = hiddenItems.has(item.id);
      const isIndirectlyHiddenItem = isIndirectlyHidden(item.id, hiddenItems, logData);
      const isItemHidden = isDirectlyHidden || isIndirectlyHiddenItem;
      const isHovered = hoveredId === item.id;

      return {
        ...item,
        actions: createActions(item.id, isDirectlyHidden, isIndirectlyHiddenItem, isHovered),
        disable: isItemHidden,
        isSelectable: !isItemHidden,
      };
    });
}, [logData, deletedItems, hiddenItems, hoveredId]);

return (
  <div className="w-full">
    <Waterfall
      className="h-[300px]"
      logData={processedLogData}
      getIcon={getIcon}
      hoveredId={hoveredId}
      onItemHover={setHoveredId}
    />
  </div>
);