All files / src/components/features/admin/monitoring/TraceExplorer index.tsx

96.52% Statements 222/230
78.12% Branches 25/32
100% Functions 6/6
96.52% Lines 222/230

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 2311x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 24x 24x 24x 12x 24x 12x 24x   24x 24x 1x 1x 1x 1x 24x 24x 24x 12x     1x 1x 1x 1x 24x 24x 24x 24x 24x 24x 24x 24x 24x           1x 1x 1x 1x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 15x 1x 1x 15x 15x 15x 15x 15x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 5x 5x 5x 5x 1x 1x 1x 1x 1x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 14x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 42x 14x 14x 14x 14x 14x 14x 15x 2x 2x 2x 1x 1x 1x 2x 2x 15x 12x 12x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 12x 12x 12x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 24x 12x 12x 15x 15x 15x 15x  
'use client';
 
import React, { useState, useCallback } from 'react';
import { formatDuration } from '@/lib/monitoring/percentiles';
 
export interface TraceListItem {
  id: string;
  name: string;
  serviceName: string;
  startTime: string;
  endTime: string;
  duration: number;
  status: 'ok' | 'error';
  spanCount: number;
  userId?: number | null;
  requestId?: string | null;
}
 
export interface TraceExplorerProps {
  /** List of traces to display */
  traces: TraceListItem[];
  /** Currently selected trace ID */
  selectedTraceId?: string;
  /** Callback when a trace is selected */
  onSelectTrace?: (traceId: string) => void;
  /** Loading state */
  isLoading?: boolean;
  /** Search/filter value */
  searchValue?: string;
  /** Callback when search changes */
  onSearchChange?: (value: string) => void;
  /** Status filter */
  statusFilter?: 'all' | 'ok' | 'error';
  /** Callback when status filter changes */
  onStatusFilterChange?: (status: 'all' | 'ok' | 'error') => void;
  /** Additional CSS classes */
  className?: string;
}
 
/**
 * Get status badge color
 */
function getStatusColor(status: string): string {
  switch (status) {
    case 'error':
      return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
    case 'ok':
      return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
    default:
      return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300';
  }
}
 
/**
 * Get duration color based on value
 */
function getDurationColor(duration: number): string {
  if (duration < 100) return 'text-green-600 dark:text-green-400';
  if (duration < 300) return 'text-yellow-600 dark:text-yellow-400';
  if (duration < 1000) return 'text-orange-600 dark:text-orange-400';
  return 'text-red-600 dark:text-red-400';
}
 
/**
 * Format relative time
 */
function formatRelativeTime(dateString: string): string {
  const date = new Date(dateString);
  const now = new Date();
  const diffMs = now.getTime() - date.getTime();
  const diffMins = Math.floor(diffMs / 60000);
  const diffHours = Math.floor(diffMins / 60);
  const diffDays = Math.floor(diffHours / 24);
 
  if (diffMins < 1) return 'Just now';
  if (diffMins < 60) return `${diffMins}m ago`;
  if (diffHours < 24) return `${diffHours}h ago`;
  if (diffDays < 7) return `${diffDays}d ago`;
  return date.toLocaleDateString();
}
 
/**
 * TraceExplorer - List view for browsing and searching traces
 */
export function TraceExplorer({
  traces,
  selectedTraceId,
  onSelectTrace,
  isLoading = false,
  searchValue = '',
  onSearchChange,
  statusFilter = 'all',
  onStatusFilterChange,
  className = '',
}: TraceExplorerProps) {
  const [localSearch, setLocalSearch] = useState(searchValue);
 
  const handleSearchChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      setLocalSearch(e.target.value);
      onSearchChange?.(e.target.value);
    },
    [onSearchChange]
  );
 
  if (isLoading) {
    return (
      <div
        className={`bg-white dark:bg-gray-800 rounded-lg shadow ${className}`}
        data-testid="trace-explorer"
      >
        <div className="p-4 border-b border-gray-200 dark:border-gray-700">
          <div className="h-10 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
        </div>
        <div className="divide-y divide-gray-200 dark:divide-gray-700">
          {[1, 2, 3, 4, 5].map((i) => (
            <div key={i} className="p-4">
              <div className="h-5 w-3/4 bg-gray-200 dark:bg-gray-700 rounded animate-pulse mb-2" />
              <div className="h-4 w-1/2 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
            </div>
          ))}
        </div>
      </div>
    );
  }
 
  return (
    <div
      className={`bg-white dark:bg-gray-800 rounded-lg shadow ${className}`}
      data-testid="trace-explorer"
    >
      {/* Header with search and filters */}
      <div className="p-4 border-b border-gray-200 dark:border-gray-700">
        <div className="flex flex-col sm:flex-row gap-3">
          {/* Search input */}
          <div className="flex-1">
            <input
              type="text"
              placeholder="Search traces by name..."
              value={localSearch}
              onChange={handleSearchChange}
              className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-500 dark:placeholder-gray-400 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
              data-testid="trace-search-input"
            />
          </div>
 
          {/* Status filter */}
          <div className="flex gap-1">
            {(['all', 'ok', 'error'] as const).map((status) => (
              <button
                key={status}
                onClick={() => onStatusFilterChange?.(status)}
                className={`px-3 py-2 text-sm font-medium rounded-md transition-colors ${
                  statusFilter === status
                    ? 'bg-blue-600 text-white'
                    : 'bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-200 dark:hover:bg-gray-600'
                }`}
                data-testid={`filter-${status}`}
              >
                {status === 'all' ? 'All' : status.charAt(0).toUpperCase() + status.slice(1)}
              </button>
            ))}
          </div>
        </div>
      </div>
 
      {/* Trace list */}
      {traces.length === 0 ? (
        <div className="p-8 text-center">
          <p className="text-gray-500 dark:text-gray-400">No traces found</p>
          {searchValue && (
            <p className="text-sm text-gray-400 dark:text-gray-500 mt-1">
              Try adjusting your search filters
            </p>
          )}
        </div>
      ) : (
        <div className="divide-y divide-gray-200 dark:divide-gray-700 max-h-[600px] overflow-y-auto">
          {traces.map((trace) => (
            <button
              key={trace.id}
              onClick={() => onSelectTrace?.(trace.id)}
              className={`w-full text-left p-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors ${
                selectedTraceId === trace.id ? 'bg-blue-50 dark:bg-blue-900/20' : ''
              }`}
              data-testid={`trace-item-${trace.id}`}
            >
              <div className="flex items-start justify-between gap-4">
                <div className="flex-1 min-w-0">
                  {/* Trace name */}
                  <div className="flex items-center gap-2 mb-1">
                    <span
                      className={`px-2 py-0.5 text-xs font-medium rounded ${getStatusColor(trace.status)}`}
                    >
                      {trace.status}
                    </span>
                    <span className="font-medium text-gray-900 dark:text-white truncate">
                      {trace.name}
                    </span>
                  </div>
 
                  {/* Trace metadata */}
                  <div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
                    <span>{trace.serviceName}</span>
                    <span>{trace.spanCount} spans</span>
                    {trace.requestId && (
                      <span className="font-mono text-xs truncate max-w-[120px]">
                        {trace.requestId}
                      </span>
                    )}
                  </div>
                </div>
 
                {/* Timing info */}
                <div className="text-right flex-shrink-0">
                  <div className={`font-medium ${getDurationColor(trace.duration)}`}>
                    {formatDuration(trace.duration)}
                  </div>
                  <div className="text-xs text-gray-400 dark:text-gray-500">
                    {formatRelativeTime(trace.startTime)}
                  </div>
                </div>
              </div>
            </button>
          ))}
        </div>
      )}
    </div>
  );
}