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
222 changes: 161 additions & 61 deletions frontend/src/pages/WorkflowList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useNavigate } from 'react-router-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import type { MouseEvent } from 'react';
import type { CSSProperties, MouseEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Expand All @@ -21,7 +21,7 @@ import {
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Skeleton } from '@/components/ui/skeleton';
import { Workflow, AlertCircle, Trash2, Loader2, Info } from 'lucide-react';
import { Workflow, AlertCircle, Trash2, Loader2, Info, GripVertical } from 'lucide-react';
import { api } from '@/services/api';
import { getStatusBadgeClassFromStatus } from '@/utils/statusBadgeStyles';
import { WorkflowMetadataSchema, type WorkflowMetadataNormalized } from '@/schemas/workflow';
Expand All @@ -30,6 +30,48 @@ import { hasAdminRole } from '@/utils/auth';
import { track, Events } from '@/features/analytics/events';
import { useAuth } from '@/auth/auth-context';
import { useRunStore } from '@/store/runStore';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
useSortable,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';

const WORKFLOW_ORDER_KEY = 'workflow-list-order';

function getSavedOrder(): string[] {
try {
const saved = localStorage.getItem(WORKFLOW_ORDER_KEY);
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}

function saveOrder(ids: string[]) {
localStorage.setItem(WORKFLOW_ORDER_KEY, JSON.stringify(ids));
}

function applyOrder<T extends { id: string }>(items: T[], savedOrder: string[]): T[] {
if (savedOrder.length === 0) return items;
const orderMap = new Map(savedOrder.map((id, idx) => [id, idx]));
return [...items].sort((a, b) => {
const aIdx = orderMap.get(a.id) ?? Infinity;
const bIdx = orderMap.get(b.id) ?? Infinity;
return aIdx - bIdx;
});
}

export function WorkflowList() {
const navigate = useNavigate();
Expand All @@ -50,6 +92,26 @@ export function WorkflowList() {
const token = useAuthStore((state) => state.token);
const adminUsername = useAuthStore((state) => state.adminUsername);

const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);

const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
setWorkflows((prev) => {
const oldIndex = prev.findIndex((w) => w.id === active.id);
const newIndex = prev.findIndex((w) => w.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
const reordered = arrayMove(prev, oldIndex, newIndex);
saveOrder(reordered.map((w) => w.id));
return reordered;
}
return prev;
});
}, []);

const MAX_RETRY_ATTEMPTS = 30; // Try for ~60 seconds (30 attempts × 2s)
const RETRY_INTERVAL_MS = 2000; // 2 seconds between retries

Expand Down Expand Up @@ -85,7 +147,7 @@ export function WorkflowList() {
try {
const data = await api.workflows.list();
const normalized = data.map((workflow) => WorkflowMetadataSchema.parse(workflow));
setWorkflows(normalized);
setWorkflows(applyOrder(normalized, getSavedOrder()));
setRetryCount(0);
retryCountRef.current = 0; // Reset on success
setIsLoading(false);
Expand Down Expand Up @@ -230,6 +292,7 @@ export function WorkflowList() {
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead>Name</TableHead>
<TableHead>Nodes</TableHead>
<TableHead>Status</TableHead>
Expand Down Expand Up @@ -269,6 +332,9 @@ export function WorkflowList() {
<TableBody>
{Array.from({ length: 5 }).map((_, idx) => (
<TableRow key={idx}>
<TableCell className="w-10 px-2">
<Skeleton className="h-4 w-4 bg-muted" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-[220px] bg-muted" />
</TableCell>
Expand Down Expand Up @@ -318,62 +384,74 @@ export function WorkflowList() {
) : (
<div className="border rounded-lg bg-card overflow-hidden">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-[150px]">Name</TableHead>
<TableHead className="min-w-[80px]">Nodes</TableHead>
<TableHead className="min-w-[100px]">Status</TableHead>
<TableHead className="min-w-[140px] hidden sm:table-cell">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
Last Run
<Info className="h-3 w-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Times shown in your local timezone</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableHead className="min-w-[140px] hidden md:table-cell">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
Last Updated
<Info className="h-3 w-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Times shown in your local timezone</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
{canManageWorkflows && (
<TableHead className="text-right min-w-[60px]">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{workflows.map((workflow) => (
<WorkflowRowItem
key={workflow.id}
workflow={workflow}
canManageWorkflows={canManageWorkflows}
isDeleting={isDeleting}
isLoading={isLoading}
formatDate={formatDate}
onRowClick={() => navigate(`/workflows/${workflow.id}`)}
onDeleteClick={handleDeleteClick}
/>
))}
</TableBody>
</Table>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10" />
<TableHead className="min-w-[150px]">Name</TableHead>
<TableHead className="min-w-[80px]">Nodes</TableHead>
<TableHead className="min-w-[100px]">Status</TableHead>
<TableHead className="min-w-[140px] hidden sm:table-cell">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
Last Run
<Info className="h-3 w-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Times shown in your local timezone</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
<TableHead className="min-w-[140px] hidden md:table-cell">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span className="flex items-center gap-1 cursor-help">
Last Updated
<Info className="h-3 w-3 text-muted-foreground" />
</span>
</TooltipTrigger>
<TooltipContent>
<p>Times shown in your local timezone</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</TableHead>
{canManageWorkflows && (
<TableHead className="text-right min-w-[60px]">Actions</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
<SortableContext
items={workflows.map((w) => w.id)}
strategy={verticalListSortingStrategy}
>
{workflows.map((workflow) => (
<WorkflowRowItem
key={workflow.id}
workflow={workflow}
canManageWorkflows={canManageWorkflows}
isDeleting={isDeleting}
isLoading={isLoading}
formatDate={formatDate}
onRowClick={() => navigate(`/workflows/${workflow.id}`)}
onDeleteClick={handleDeleteClick}
/>
))}
</SortableContext>
</TableBody>
</Table>
</DndContext>
</div>
</div>
)}
Expand Down Expand Up @@ -439,6 +517,17 @@ function WorkflowRowItem({
}: WorkflowRowItemProps) {
const fetchRuns = useRunStore((state) => state.fetchRuns);
const latestRun = useRunStore((state) => state.getLatestRun(workflow.id));
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: workflow.id,
});

const style: CSSProperties = {
transform: CSS.Transform.toString(transform),
transition,
position: 'relative',
zIndex: isDragging ? 50 : undefined,
opacity: isDragging ? 0.5 : 1,
};

useEffect(() => {
fetchRuns({ workflowId: workflow.id }).catch(() => undefined);
Expand All @@ -458,10 +547,21 @@ function WorkflowRowItem({

return (
<TableRow
key={workflow.id}
ref={setNodeRef}
style={style}
onClick={onRowClick}
className="cursor-pointer transition-colors duration-150 hover:bg-accent/50 dark:hover:bg-accent/30"
className={`cursor-pointer transition-colors duration-150 hover:bg-accent/50 dark:hover:bg-accent/30 ${isDragging ? 'bg-accent/50 shadow-lg' : ''}`}
>
<TableCell className="w-10 px-2">
<div
className="touch-none cursor-grab active:cursor-grabbing text-muted-foreground hover:text-foreground"
{...attributes}
{...listeners}
onClick={(e) => e.stopPropagation()}
>
<GripVertical className="h-4 w-4" />
</div>
</TableCell>
<TableCell className="font-medium">
<div className="max-w-[200px] truncate" title={workflow.name}>
{workflow.name}
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@
"undici": "^7.19.0"
},
"lint-staged": {
"frontend/**/*.{ts,tsx,js,jsx}": "bunx eslint --fix --config frontend/eslint.config.mjs",
"backend/**/*.{ts,js}": "bunx eslint --fix --config backend/eslint.config.mjs",
"worker/**/*.{ts,js}": "bunx eslint --fix --config worker/eslint.config.mjs",
"frontend/**/*.{ts,tsx,js,jsx}": "bunx eslint@9 --fix --config frontend/eslint.config.mjs",
"backend/**/*.{ts,js}": "bunx eslint@9 --fix --config backend/eslint.config.mjs",
"worker/**/*.{ts,js}": "bunx eslint@9 --fix --config worker/eslint.config.mjs",
"*.{json,md,yml,yaml}": "bunx prettier --write"
},
"dependencies": {
Expand Down