Bladeren bron

feat: add filters and exact timestamps to executions panel

- Add date range filter (from/to dates)
- Add execution duration filter (min/max seconds)
- Show exact timestamp (YYYY-MM-DD HH:MM:SS) below relative time
- Add filter toggle button with active indicator
- Show "X of Y executions" when filters are active
- Add empty state for when filters match no results
fszontagh 6 maanden geleden
bovenliggende
commit
59b50a0f7c
1 gewijzigde bestanden met toevoegingen van 187 en 13 verwijderingen
  1. 187 13
      webui/src/components/workflow/ExecutionListPanel.tsx

+ 187 - 13
webui/src/components/workflow/ExecutionListPanel.tsx

@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useState, useMemo } from 'react'
 import { useQuery } from '@tanstack/react-query'
 import {
   X,
@@ -12,6 +12,7 @@ import {
   Loader2,
   ChevronLeft,
   ChevronRight,
+  Filter,
 } from 'lucide-react'
 import { executionsApi, type ExecutionListItem, type ExecutionDetail } from '../../api/workflows'
 import { useExecutionListUpdates } from '../../hooks/useExecutionListUpdates'
@@ -24,6 +25,13 @@ interface ExecutionListPanelProps {
   onPinExecution: (execution: ExecutionDetail) => void
 }
 
+interface FilterState {
+  dateFrom: string
+  dateTo: string
+  durationMin: string
+  durationMax: string
+}
+
 const PAGE_SIZE = 20
 
 function formatDuration(startedAt: number, finishedAt: number): string {
@@ -33,7 +41,11 @@ function formatDuration(startedAt: number, finishedAt: number): string {
   return `${(durationMs / 60000).toFixed(1)}m`
 }
 
-function formatTimestamp(timestamp: number): string {
+function getDurationMs(startedAt: number, finishedAt: number): number {
+  return finishedAt - startedAt
+}
+
+function formatRelativeTimestamp(timestamp: number): string {
   const date = new Date(timestamp)
   const now = new Date()
   const diffMs = now.getTime() - date.getTime()
@@ -48,6 +60,25 @@ function formatTimestamp(timestamp: number): string {
   return date.toLocaleDateString()
 }
 
+function formatExactTimestamp(timestamp: number): string {
+  const date = new Date(timestamp)
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  const hours = String(date.getHours()).padStart(2, '0')
+  const minutes = String(date.getMinutes()).padStart(2, '0')
+  const seconds = String(date.getSeconds()).padStart(2, '0')
+  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
+}
+
+function parseDurationToMs(value: string): number | null {
+  if (!value.trim()) return null
+  const num = parseFloat(value)
+  if (isNaN(num)) return null
+  // Assume seconds if no unit, convert to ms
+  return num * 1000
+}
+
 function StatusBadge({ status }: { status: string }) {
   const config: Record<string, { icon: typeof CheckCircle; color: string; bg: string }> = {
     completed: { icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-100 dark:bg-green-900/30' },
@@ -76,6 +107,13 @@ export function ExecutionListPanel({
 }: ExecutionListPanelProps) {
   const [loadingId, setLoadingId] = useState<string | null>(null)
   const [page, setPage] = useState(1)
+  const [showFilters, setShowFilters] = useState(false)
+  const [filters, setFilters] = useState<FilterState>({
+    dateFrom: '',
+    dateTo: '',
+    durationMin: '',
+    durationMax: '',
+  })
 
   const { data, isLoading, error } = useQuery({
     queryKey: ['executions', workflowId, page],
@@ -86,6 +124,46 @@ export function ExecutionListPanel({
   // Subscribe to WebSocket for real-time updates instead of polling
   useExecutionListUpdates({ workflowId, enabled: isOpen && !!workflowId })
 
+  // Apply client-side filters
+  const filteredExecutions = useMemo(() => {
+    if (!data?.executions) return []
+
+    return data.executions.filter((exec: ExecutionListItem) => {
+      // Date range filter
+      if (filters.dateFrom) {
+        const fromDate = new Date(filters.dateFrom).getTime()
+        if (exec.startedAt < fromDate) return false
+      }
+      if (filters.dateTo) {
+        const toDate = new Date(filters.dateTo).getTime() + 86400000 // Include the entire day
+        if (exec.startedAt > toDate) return false
+      }
+
+      // Duration filter (only for finished executions)
+      if (exec.finishedAt) {
+        const durationMs = getDurationMs(exec.startedAt, exec.finishedAt)
+        const minMs = parseDurationToMs(filters.durationMin)
+        const maxMs = parseDurationToMs(filters.durationMax)
+
+        if (minMs !== null && durationMs < minMs) return false
+        if (maxMs !== null && durationMs > maxMs) return false
+      }
+
+      return true
+    })
+  }, [data?.executions, filters])
+
+  const hasActiveFilters = filters.dateFrom || filters.dateTo || filters.durationMin || filters.durationMax
+
+  const clearFilters = () => {
+    setFilters({
+      dateFrom: '',
+      dateTo: '',
+      durationMin: '',
+      durationMax: '',
+    })
+  }
+
   const handleAction = async (
     executionId: string,
     action: 'view' | 'pin'
@@ -119,14 +197,99 @@ export function ExecutionListPanel({
           <Play className="w-5 h-5 text-gray-600 dark:text-gray-400" />
           <h2 className="font-semibold text-gray-800 dark:text-gray-100">Executions</h2>
         </div>
-        <button
-          onClick={onClose}
-          className="p-1 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
-        >
-          <X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
-        </button>
+        <div className="flex items-center gap-1">
+          <button
+            onClick={() => setShowFilters(!showFilters)}
+            className={`p-1.5 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg transition-colors relative ${
+              hasActiveFilters ? 'text-primary-600 dark:text-primary-400' : 'text-gray-600 dark:text-gray-400'
+            }`}
+            title="Toggle filters"
+          >
+            <Filter className="w-4 h-4" />
+            {hasActiveFilters && (
+              <span className="absolute -top-0.5 -right-0.5 w-2 h-2 bg-primary-500 rounded-full" />
+            )}
+          </button>
+          <button
+            onClick={onClose}
+            className="p-1 hover:bg-gray-200 dark:hover:bg-slate-700 rounded-lg transition-colors"
+          >
+            <X className="w-5 h-5 text-gray-600 dark:text-gray-400" />
+          </button>
+        </div>
       </div>
 
+      {/* Filters Panel */}
+      {showFilters && (
+        <div className="px-4 py-3 border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50 space-y-3 shrink-0">
+          <div className="flex items-center justify-between">
+            <span className="text-xs font-medium text-gray-600 dark:text-gray-400 uppercase tracking-wide">
+              Filters
+            </span>
+            {hasActiveFilters && (
+              <button
+                onClick={clearFilters}
+                className="text-xs text-primary-600 dark:text-primary-400 hover:underline"
+              >
+                Clear all
+              </button>
+            )}
+          </div>
+
+          {/* Date Range */}
+          <div className="space-y-1.5">
+            <label className="text-xs text-gray-500 dark:text-gray-400">Date Range</label>
+            <div className="flex gap-2">
+              <input
+                type="date"
+                value={filters.dateFrom}
+                onChange={(e) => setFilters(f => ({ ...f, dateFrom: e.target.value }))}
+                className="flex-1 px-2 py-1.5 text-xs bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
+                placeholder="From"
+              />
+              <input
+                type="date"
+                value={filters.dateTo}
+                onChange={(e) => setFilters(f => ({ ...f, dateTo: e.target.value }))}
+                className="flex-1 px-2 py-1.5 text-xs bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
+                placeholder="To"
+              />
+            </div>
+          </div>
+
+          {/* Duration Range */}
+          <div className="space-y-1.5">
+            <label className="text-xs text-gray-500 dark:text-gray-400">Duration (seconds)</label>
+            <div className="flex gap-2">
+              <input
+                type="number"
+                value={filters.durationMin}
+                onChange={(e) => setFilters(f => ({ ...f, durationMin: e.target.value }))}
+                className="flex-1 px-2 py-1.5 text-xs bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
+                placeholder="Min"
+                min="0"
+                step="0.1"
+              />
+              <input
+                type="number"
+                value={filters.durationMax}
+                onChange={(e) => setFilters(f => ({ ...f, durationMax: e.target.value }))}
+                className="flex-1 px-2 py-1.5 text-xs bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600 rounded-md focus:ring-1 focus:ring-primary-500 focus:border-primary-500"
+                placeholder="Max"
+                min="0"
+                step="0.1"
+              />
+            </div>
+          </div>
+
+          {hasActiveFilters && filteredExecutions.length !== data?.executions?.length && (
+            <div className="text-xs text-gray-500 dark:text-gray-400">
+              Showing {filteredExecutions.length} of {data?.executions?.length} executions
+            </div>
+          )}
+        </div>
+      )}
+
       {/* Content */}
       <div className="flex-1 overflow-y-auto">
         {isLoading ? (
@@ -144,18 +307,29 @@ export function ExecutionListPanel({
             <p className="text-sm">No executions yet</p>
             <p className="text-xs mt-1">Run the workflow to see executions here</p>
           </div>
+        ) : filteredExecutions.length === 0 ? (
+          <div className="p-8 text-center text-gray-500 dark:text-gray-400">
+            <Filter className="w-10 h-10 mx-auto mb-3 text-gray-300 dark:text-gray-600" />
+            <p className="text-sm">No matching executions</p>
+            <p className="text-xs mt-1">Try adjusting your filters</p>
+          </div>
         ) : (
           <div className="divide-y divide-gray-200 dark:divide-slate-700">
-            {data.executions.map((exec: ExecutionListItem) => (
+            {filteredExecutions.map((exec: ExecutionListItem) => (
               <div
                 key={exec.id}
                 className="p-4 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
               >
-                <div className="flex items-start justify-between mb-2">
+                <div className="flex items-start justify-between mb-1">
                   <StatusBadge status={exec.status} />
-                  <span className="text-xs text-gray-500 dark:text-gray-400">
-                    {formatTimestamp(exec.startedAt)}
-                  </span>
+                  <div className="text-right">
+                    <div className="text-xs text-gray-500 dark:text-gray-400">
+                      {formatRelativeTimestamp(exec.startedAt)}
+                    </div>
+                    <div className="text-[10px] text-gray-400 dark:text-gray-500 font-mono">
+                      {formatExactTimestamp(exec.startedAt)}
+                    </div>
+                  </div>
                 </div>
 
                 <div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 mb-3">