|
@@ -1,10 +1,11 @@
|
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
import { Link, useNavigate, useSearchParams } from 'react-router-dom'
|
|
|
-import { workflowsApi, Workflow } from '../api/workflows'
|
|
|
|
|
|
|
+import { workflowsApi, nodesApi, Workflow } from '../api/workflows'
|
|
|
|
|
+import { usersApi } from '../api/users'
|
|
|
import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
|
|
import { workflowGroupsApi, WorkflowGroup } from '../api/workflowGroups'
|
|
|
-import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput } from 'lucide-react'
|
|
|
|
|
|
|
+import { Plus, Play, Pause, Trash2, MoreVertical, AlertTriangle, X, FolderPlus, FolderInput, Clock, User, Zap } from 'lucide-react'
|
|
|
import { useState } from 'react'
|
|
import { useState } from 'react'
|
|
|
-import { formatDistanceToNow } from 'date-fns'
|
|
|
|
|
|
|
+import { formatDistanceToNow, format } from 'date-fns'
|
|
|
import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
|
|
import { GroupBreadcrumb } from '../components/GroupBreadcrumb'
|
|
|
import { GroupCard } from '../components/GroupCard'
|
|
import { GroupCard } from '../components/GroupCard'
|
|
|
import {
|
|
import {
|
|
@@ -14,10 +15,14 @@ import {
|
|
|
MoveItemModal,
|
|
MoveItemModal,
|
|
|
} from '../components/GroupModals'
|
|
} from '../components/GroupModals'
|
|
|
|
|
|
|
|
-// Helper to safely format timestamps that might be invalid or too large
|
|
|
|
|
-function safeFormatDistanceToNow(timestamp: number): string {
|
|
|
|
|
- // Check for invalid timestamps (too large, negative, or zero)
|
|
|
|
|
- if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) {
|
|
|
|
|
|
|
+// Timestamps arrive already normalised to milliseconds by the API layer, which
|
|
|
|
|
+// is where the nanosecond and snake_case handling lives. A null here means the
|
|
|
|
|
+// document genuinely carries no usable timestamp, not that the unit was wrong.
|
|
|
|
|
+function safeFormatDistanceToNow(timestamp: number | null | undefined): string {
|
|
|
|
|
+ if (timestamp === null || timestamp === undefined) {
|
|
|
|
|
+ return 'unknown'
|
|
|
|
|
+ }
|
|
|
|
|
+ if (timestamp <= 0 || timestamp > 8640000000000000) {
|
|
|
return 'unknown'
|
|
return 'unknown'
|
|
|
}
|
|
}
|
|
|
try {
|
|
try {
|
|
@@ -27,6 +32,45 @@ function safeFormatDistanceToNow(timestamp: number): string {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+function formatExact(timestamp: number | null | undefined): string {
|
|
|
|
|
+ if (timestamp === null || timestamp === undefined || timestamp <= 0) {
|
|
|
|
|
+ return 'unknown'
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ return format(timestamp, 'yyyy-MM-dd HH:mm')
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ return 'unknown'
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface TriggerSummary {
|
|
|
|
|
+ type: string
|
|
|
|
|
+ label: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Which trigger nodes a workflow contains. A trigger is a node whose definition
|
|
|
|
|
+ * carries isTrigger, so this needs the definitions the page already loads -
|
|
|
|
|
+ * the workflow itself only stores each node's type string.
|
|
|
|
|
+ */
|
|
|
|
|
+function findTriggers(
|
|
|
|
|
+ workflow: Workflow,
|
|
|
|
|
+ triggerTypes: Map<string, string>
|
|
|
|
|
+): TriggerSummary[] {
|
|
|
|
|
+ const found: TriggerSummary[] = []
|
|
|
|
|
+ const seen = new Set<string>()
|
|
|
|
|
+
|
|
|
|
|
+ for (const node of workflow.nodes || []) {
|
|
|
|
|
+ const label = triggerTypes.get(node.type)
|
|
|
|
|
+ if (label === undefined || seen.has(node.type)) {
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+ seen.add(node.type)
|
|
|
|
|
+ found.push({ type: node.type, label })
|
|
|
|
|
+ }
|
|
|
|
|
+ return found
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export default function WorkflowsPage() {
|
|
export default function WorkflowsPage() {
|
|
|
const queryClient = useQueryClient()
|
|
const queryClient = useQueryClient()
|
|
|
const navigate = useNavigate()
|
|
const navigate = useNavigate()
|
|
@@ -66,6 +110,34 @@ export default function WorkflowsPage() {
|
|
|
})
|
|
})
|
|
|
const workflows: Workflow[] = workflowsData?.workflows || []
|
|
const workflows: Workflow[] = workflowsData?.workflows || []
|
|
|
|
|
|
|
|
|
|
+ // Node definitions, purely to learn which node types are triggers. A workflow
|
|
|
|
|
+ // stores only a node's type string, so the definitions are the only place
|
|
|
|
|
+ // that says whether it starts a run.
|
|
|
|
|
+ const { data: nodesData } = useQuery({
|
|
|
|
|
+ queryKey: ['node-definitions'],
|
|
|
|
|
+ queryFn: () => nodesApi.list(),
|
|
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
|
|
+ })
|
|
|
|
|
+ const triggerTypes = new Map<string, string>()
|
|
|
|
|
+ for (const definition of nodesData?.nodes || []) {
|
|
|
|
|
+ if (definition.isTrigger) {
|
|
|
|
|
+ triggerTypes.set(definition.id, definition.name || definition.id)
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Owner names. The endpoint is admin-only, so a non-admin simply gets no
|
|
|
|
|
+ // names and the card falls back to showing nothing rather than erroring.
|
|
|
|
|
+ const { data: usersData } = useQuery({
|
|
|
|
|
+ queryKey: ['users'],
|
|
|
|
|
+ queryFn: () => usersApi.list(),
|
|
|
|
|
+ staleTime: 5 * 60 * 1000,
|
|
|
|
|
+ retry: false,
|
|
|
|
|
+ })
|
|
|
|
|
+ const userNames = new Map<string, string>()
|
|
|
|
|
+ for (const user of usersData || []) {
|
|
|
|
|
+ userNames.set(user.id, user.username)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
const isLoading = isLoadingGroups || isLoadingWorkflows
|
|
const isLoading = isLoadingGroups || isLoadingWorkflows
|
|
|
|
|
|
|
|
// Navigate to a group
|
|
// Navigate to a group
|
|
@@ -265,6 +337,10 @@ export default function WorkflowsPage() {
|
|
|
key={workflow.id || Math.random()}
|
|
key={workflow.id || Math.random()}
|
|
|
workflow={workflow}
|
|
workflow={workflow}
|
|
|
groupId={currentGroupId}
|
|
groupId={currentGroupId}
|
|
|
|
|
+ ownerName={
|
|
|
|
|
+ workflow.ownerId ? userNames.get(workflow.ownerId) : undefined
|
|
|
|
|
+ }
|
|
|
|
|
+ triggers={findTriggers(workflow, triggerTypes)}
|
|
|
onToggleActive={() => {
|
|
onToggleActive={() => {
|
|
|
if (workflow.id) {
|
|
if (workflow.id) {
|
|
|
toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
|
|
toggleActiveMutation.mutate({ id: workflow.id, active: workflow.active })
|
|
@@ -393,12 +469,16 @@ export default function WorkflowsPage() {
|
|
|
function WorkflowCard({
|
|
function WorkflowCard({
|
|
|
workflow,
|
|
workflow,
|
|
|
groupId,
|
|
groupId,
|
|
|
|
|
+ ownerName,
|
|
|
|
|
+ triggers,
|
|
|
onToggleActive,
|
|
onToggleActive,
|
|
|
onMove,
|
|
onMove,
|
|
|
onDelete,
|
|
onDelete,
|
|
|
}: {
|
|
}: {
|
|
|
workflow: Workflow
|
|
workflow: Workflow
|
|
|
groupId: string | null
|
|
groupId: string | null
|
|
|
|
|
+ ownerName?: string
|
|
|
|
|
+ triggers: TriggerSummary[]
|
|
|
onToggleActive: () => void
|
|
onToggleActive: () => void
|
|
|
onMove: () => void
|
|
onMove: () => void
|
|
|
onDelete: () => void
|
|
onDelete: () => void
|
|
@@ -478,6 +558,45 @@ function WorkflowCard({
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|
|
|
|
|
|
|
|
|
|
+ {triggers.length > 0 && (
|
|
|
|
|
+ <div className="flex flex-wrap items-center gap-1.5 mb-3">
|
|
|
|
|
+ <Zap className="w-3.5 h-3.5 text-amber-500 dark:text-amber-400 shrink-0" />
|
|
|
|
|
+ {triggers.map((trigger) => (
|
|
|
|
|
+ <span
|
|
|
|
|
+ key={trigger.type}
|
|
|
|
|
+ className="px-2 py-0.5 rounded-full text-xs font-medium bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"
|
|
|
|
|
+ title={trigger.type}
|
|
|
|
|
+ >
|
|
|
|
|
+ {trigger.label}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ <div className="space-y-1 mb-3 text-xs text-gray-500 dark:text-gray-400">
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <Clock className="w-3.5 h-3.5 shrink-0" />
|
|
|
|
|
+ {/* The exact date sits in the tooltip because "3 months ago" is the
|
|
|
|
|
+ useful form at a glance, but the precise stamp is what someone
|
|
|
|
|
+ needs when they are actually comparing two workflows. */}
|
|
|
|
|
+ <span title={formatExact(workflow.createdAt)}>
|
|
|
|
|
+ Created {safeFormatDistanceToNow(workflow.createdAt)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <Clock className="w-3.5 h-3.5 shrink-0" />
|
|
|
|
|
+ <span title={formatExact(workflow.updatedAt)}>
|
|
|
|
|
+ Modified {safeFormatDistanceToNow(workflow.updatedAt)}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {(ownerName || workflow.createdBy) && (
|
|
|
|
|
+ <div className="flex items-center gap-1.5">
|
|
|
|
|
+ <User className="w-3.5 h-3.5 shrink-0" />
|
|
|
|
|
+ <span>{ownerName || workflow.createdBy}</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
<div className="flex items-center justify-between text-sm">
|
|
<div className="flex items-center justify-between text-sm">
|
|
|
<span
|
|
<span
|
|
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
className={`px-2 py-0.5 rounded-full text-xs font-medium ${
|
|
@@ -489,7 +608,7 @@ function WorkflowCard({
|
|
|
{workflow.active ? 'Active' : 'Inactive'}
|
|
{workflow.active ? 'Active' : 'Inactive'}
|
|
|
</span>
|
|
</span>
|
|
|
<span className="text-gray-400 dark:text-gray-500">
|
|
<span className="text-gray-400 dark:text-gray-500">
|
|
|
- {safeFormatDistanceToNow(workflow.updatedAt)}
|
|
|
|
|
|
|
+ {workflow.nodes?.length || 0} node{workflow.nodes?.length === 1 ? '' : 's'}
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</div>
|
|
|
</div>
|
|
</div>
|