|
@@ -1,4 +1,4 @@
|
|
|
-import { useState } from 'react'
|
|
|
|
|
|
|
+import { useState, useMemo } from 'react'
|
|
|
import { useQuery } from '@tanstack/react-query'
|
|
import { useQuery } from '@tanstack/react-query'
|
|
|
import {
|
|
import {
|
|
|
X,
|
|
X,
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
Loader2,
|
|
Loader2,
|
|
|
ChevronLeft,
|
|
ChevronLeft,
|
|
|
ChevronRight,
|
|
ChevronRight,
|
|
|
|
|
+ Filter,
|
|
|
} from 'lucide-react'
|
|
} from 'lucide-react'
|
|
|
import { executionsApi, type ExecutionListItem, type ExecutionDetail } from '../../api/workflows'
|
|
import { executionsApi, type ExecutionListItem, type ExecutionDetail } from '../../api/workflows'
|
|
|
import { useExecutionListUpdates } from '../../hooks/useExecutionListUpdates'
|
|
import { useExecutionListUpdates } from '../../hooks/useExecutionListUpdates'
|
|
@@ -24,6 +25,13 @@ interface ExecutionListPanelProps {
|
|
|
onPinExecution: (execution: ExecutionDetail) => void
|
|
onPinExecution: (execution: ExecutionDetail) => void
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface FilterState {
|
|
|
|
|
+ dateFrom: string
|
|
|
|
|
+ dateTo: string
|
|
|
|
|
+ durationMin: string
|
|
|
|
|
+ durationMax: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
const PAGE_SIZE = 20
|
|
const PAGE_SIZE = 20
|
|
|
|
|
|
|
|
function formatDuration(startedAt: number, finishedAt: number): string {
|
|
function formatDuration(startedAt: number, finishedAt: number): string {
|
|
@@ -33,7 +41,11 @@ function formatDuration(startedAt: number, finishedAt: number): string {
|
|
|
return `${(durationMs / 60000).toFixed(1)}m`
|
|
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 date = new Date(timestamp)
|
|
|
const now = new Date()
|
|
const now = new Date()
|
|
|
const diffMs = now.getTime() - date.getTime()
|
|
const diffMs = now.getTime() - date.getTime()
|
|
@@ -48,6 +60,25 @@ function formatTimestamp(timestamp: number): string {
|
|
|
return date.toLocaleDateString()
|
|
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 }) {
|
|
function StatusBadge({ status }: { status: string }) {
|
|
|
const config: Record<string, { icon: typeof CheckCircle; color: string; bg: 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' },
|
|
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) {
|
|
}: ExecutionListPanelProps) {
|
|
|
const [loadingId, setLoadingId] = useState<string | null>(null)
|
|
const [loadingId, setLoadingId] = useState<string | null>(null)
|
|
|
const [page, setPage] = useState(1)
|
|
const [page, setPage] = useState(1)
|
|
|
|
|
+ const [showFilters, setShowFilters] = useState(false)
|
|
|
|
|
+ const [filters, setFilters] = useState<FilterState>({
|
|
|
|
|
+ dateFrom: '',
|
|
|
|
|
+ dateTo: '',
|
|
|
|
|
+ durationMin: '',
|
|
|
|
|
+ durationMax: '',
|
|
|
|
|
+ })
|
|
|
|
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
const { data, isLoading, error } = useQuery({
|
|
|
queryKey: ['executions', workflowId, page],
|
|
queryKey: ['executions', workflowId, page],
|
|
@@ -86,6 +124,46 @@ export function ExecutionListPanel({
|
|
|
// Subscribe to WebSocket for real-time updates instead of polling
|
|
// Subscribe to WebSocket for real-time updates instead of polling
|
|
|
useExecutionListUpdates({ workflowId, enabled: isOpen && !!workflowId })
|
|
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 (
|
|
const handleAction = async (
|
|
|
executionId: string,
|
|
executionId: string,
|
|
|
action: 'view' | 'pin'
|
|
action: 'view' | 'pin'
|
|
@@ -119,14 +197,99 @@ export function ExecutionListPanel({
|
|
|
<Play className="w-5 h-5 text-gray-600 dark:text-gray-400" />
|
|
<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>
|
|
<h2 className="font-semibold text-gray-800 dark:text-gray-100">Executions</h2>
|
|
|
</div>
|
|
</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>
|
|
</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 */}
|
|
{/* Content */}
|
|
|
<div className="flex-1 overflow-y-auto">
|
|
<div className="flex-1 overflow-y-auto">
|
|
|
{isLoading ? (
|
|
{isLoading ? (
|
|
@@ -144,18 +307,29 @@ export function ExecutionListPanel({
|
|
|
<p className="text-sm">No executions yet</p>
|
|
<p className="text-sm">No executions yet</p>
|
|
|
<p className="text-xs mt-1">Run the workflow to see executions here</p>
|
|
<p className="text-xs mt-1">Run the workflow to see executions here</p>
|
|
|
</div>
|
|
</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">
|
|
<div className="divide-y divide-gray-200 dark:divide-slate-700">
|
|
|
- {data.executions.map((exec: ExecutionListItem) => (
|
|
|
|
|
|
|
+ {filteredExecutions.map((exec: ExecutionListItem) => (
|
|
|
<div
|
|
<div
|
|
|
key={exec.id}
|
|
key={exec.id}
|
|
|
className="p-4 hover:bg-gray-50 dark:hover:bg-slate-700 transition-colors"
|
|
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} />
|
|
<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>
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 mb-3">
|
|
<div className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400 mb-3">
|