feat/show scheduled flow status (#6330)

* show scheduled flow status

* enhance ScheduleStatusBadge to display loading state and update tooltip text. Refactor schedule status retrieval in Agentflows to handle errors and improve state management.
This commit is contained in:
Henry Heng
2026-05-05 12:17:24 +01:00
committed by GitHub
parent e19105c5f3
commit 433b97d3af
4 changed files with 252 additions and 81 deletions
+76 -60
View File
@@ -8,6 +8,7 @@ import { Box, Grid, Tooltip, Typography, useTheme } from '@mui/material'
// project imports
import MainCard from '@/ui-component/cards/MainCard'
import MoreItemsTooltip from '../tooltip/MoreItemsTooltip'
import ScheduleStatusBadge from '@/ui-component/extended/ScheduleStatusBadge'
const CardWrapper = styled(MainCard)(({ theme }) => ({
background: theme.palette.card.main,
@@ -30,7 +31,7 @@ const CardWrapper = styled(MainCard)(({ theme }) => ({
// ===========================|| CONTRACT CARD ||=========================== //
const ItemCard = ({ data, images, icons, onClick }) => {
const ItemCard = ({ data, images, icons, scheduleStatus, onClick }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
@@ -107,74 +108,88 @@ const ItemCard = ({ data, images, icons, onClick }) => {
</span>
)}
</Box>
{(images?.length > 0 || icons?.length > 0) && (
{(images?.length > 0 || icons?.length > 0 || scheduleStatus?.isScheduled) && (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'start',
justifyContent: 'space-between',
gap: 1
}}
>
{[
...(images || []).map((img) => ({ type: 'image', src: img.imageSrc, label: img.label })),
...(icons || []).map((ic) => ({ type: 'icon', icon: ic.icon, color: ic.color, label: ic.name }))
]
.slice(0, 3)
.map((item, index) => (
<Tooltip key={item.src || index} title={item.label} placement='top'>
{item.type === 'image' ? (
<Box
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
alt=''
src={item.src}
/>
</Box>
) : (
<div
style={{
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<item.icon size={25} color={item.color} />
</div>
)}
</Tooltip>
))}
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 1,
flex: 1,
minWidth: 0,
overflow: 'hidden'
}}
>
{[
...(images || []).map((img) => ({ type: 'image', src: img.imageSrc, label: img.label })),
...(icons || []).map((ic) => ({ type: 'icon', icon: ic.icon, color: ic.color, label: ic.name }))
]
.slice(0, 3)
.map((item, index) => (
<Tooltip key={item.src || index} title={item.label} placement='top'>
{item.type === 'image' ? (
<Box
sx={{
width: 30,
height: 30,
borderRadius: '50%',
backgroundColor: customization.isDarkMode
? theme.palette.common.white
: theme.palette.grey[300] + 75
}}
>
<img
style={{ width: '100%', height: '100%', padding: 5, objectFit: 'contain' }}
alt=''
src={item.src}
/>
</Box>
) : (
<div
style={{
width: 30,
height: 30,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<item.icon size={25} color={item.color} />
</div>
)}
</Tooltip>
))}
{(images?.length || 0) + (icons?.length || 0) > 3 && (
<MoreItemsTooltip
images={[
...(images?.slice(3) || []),
...(icons?.slice(Math.max(0, 3 - (images?.length || 0))) || []).map((ic) => ({ label: ic.name }))
]}
>
<Typography
sx={{
alignItems: 'center',
display: 'flex',
fontSize: '.9rem',
fontWeight: 200
}}
{(images?.length || 0) + (icons?.length || 0) > 3 && (
<MoreItemsTooltip
images={[
...(images?.slice(3) || []),
...(icons?.slice(Math.max(0, 3 - (images?.length || 0))) || []).map((ic) => ({
label: ic.name
}))
]}
>
+ {(images?.length || 0) + (icons?.length || 0) - 3} More
</Typography>
</MoreItemsTooltip>
)}
<Typography
sx={{
alignItems: 'center',
display: 'flex',
fontSize: '.9rem',
fontWeight: 200
}}
>
+ {(images?.length || 0) + (icons?.length || 0) - 3} More
</Typography>
</MoreItemsTooltip>
)}
</Box>
<ScheduleStatusBadge scheduleStatus={scheduleStatus} />
</Box>
)}
</Grid>
@@ -187,6 +202,7 @@ ItemCard.propTypes = {
data: PropTypes.object,
images: PropTypes.array,
icons: PropTypes.array,
scheduleStatus: PropTypes.object,
onClick: PropTypes.func
}
@@ -0,0 +1,105 @@
import PropTypes from 'prop-types'
import moment from 'moment'
import { useSelector } from 'react-redux'
import { Box, CircularProgress, Tooltip } from '@mui/material'
import { useTheme } from '@mui/material/styles'
import { IconClock } from '@tabler/icons-react'
const ACTIVE = {
light: { bg: '#dcfce7', text: '#15803d', border: '#86efac', dot: '#22c55e' },
dark: { bg: 'rgba(34, 197, 94, 0.16)', text: '#86efac', border: 'rgba(134, 239, 172, 0.35)', dot: '#22c55e' }
}
const PAUSED = {
light: { bg: 'rgba(0, 0, 0, 0.04)', text: 'rgba(0, 0, 0, 0.6)', border: 'rgba(0, 0, 0, 0.12)' },
dark: { bg: 'rgba(255, 255, 255, 0.06)', text: 'rgba(255, 255, 255, 0.65)', border: 'rgba(255, 255, 255, 0.18)' }
}
const ScheduleStatusBadge = ({ scheduleStatus, size = 'md' }) => {
const theme = useTheme()
const customization = useSelector((state) => state.customization)
if (!scheduleStatus?.isScheduled) return null
const isLoading = scheduleStatus.loading === true
const isActive = !isLoading && scheduleStatus.enabled === true
const palette = customization.isDarkMode ? 'dark' : 'light'
const colors = isActive ? ACTIVE[palette] : PAUSED[palette]
const tooltipText = isLoading
? 'Checking schedule status…'
: isActive
? scheduleStatus.nextRunAt
? `Schedule active — next run ${moment(scheduleStatus.nextRunAt).format('MMM D, YYYY h:mm A')}`
: 'Schedule active'
: 'Schedule configured but turned off'
const dims =
size === 'sm'
? { px: 0.875, py: 0.25, fontSize: '0.65rem', dot: 5, icon: 10, gap: 0.625 }
: { px: 1, py: 0.375, fontSize: '0.7rem', dot: 6, icon: 11, gap: 0.75 }
return (
<Tooltip title={tooltipText} placement='top' arrow>
<Box
onClick={(e) => e.stopPropagation()}
sx={{
display: 'inline-flex',
alignItems: 'center',
gap: dims.gap,
px: dims.px,
py: dims.py,
borderRadius: '999px',
fontSize: dims.fontSize,
fontWeight: 600,
letterSpacing: '0.02em',
lineHeight: 1,
color: colors.text,
backgroundColor: colors.bg,
border: `1px solid ${colors.border}`,
cursor: 'default',
userSelect: 'none',
flexShrink: 0,
transition: theme.transitions.create(['background-color', 'border-color'])
}}
>
{isLoading ? (
<CircularProgress size={dims.icon} thickness={5} sx={{ color: colors.text }} />
) : isActive ? (
<Box
component='span'
sx={{
width: dims.dot,
height: dims.dot,
borderRadius: '50%',
backgroundColor: colors.dot,
boxShadow: `0 0 0 0 ${colors.dot}`,
animation: 'scheduleStatusPulse 1.8s ease-out infinite',
'@keyframes scheduleStatusPulse': {
'0%': { boxShadow: `0 0 0 0 ${colors.dot}99` },
'70%': { boxShadow: `0 0 0 6px ${colors.dot}00` },
'100%': { boxShadow: `0 0 0 0 ${colors.dot}00` }
}
}}
/>
) : (
<IconClock size={dims.icon} stroke={2} />
)}
{isLoading ? 'Loading…' : isActive ? 'Scheduled' : 'Paused'}
</Box>
</Tooltip>
)
}
ScheduleStatusBadge.propTypes = {
scheduleStatus: PropTypes.shape({
isScheduled: PropTypes.bool,
enabled: PropTypes.bool,
nextRunAt: PropTypes.string,
cronExpression: PropTypes.string,
loading: PropTypes.bool
}),
size: PropTypes.oneOf(['sm', 'md'])
}
export default ScheduleStatusBadge
@@ -26,6 +26,7 @@ import { Link } from 'react-router-dom'
import { useAuth } from '@/hooks/useAuth'
import MoreItemsTooltip from '../tooltip/MoreItemsTooltip'
import ScheduleStatusBadge from '@/ui-component/extended/ScheduleStatusBadge'
const StyledTableCell = styled(TableCell)(({ theme }) => ({
borderColor: theme.palette.grey[900] + 25,
@@ -54,6 +55,7 @@ export const FlowListTable = ({
data,
images = {},
icons = {},
scheduleStatuses = {},
isLoading,
filterFunction,
updateFlowsApi,
@@ -191,23 +193,27 @@ export const FlowListTable = ({
{sortedData.filter(filterFunction).map((row, index) => (
<StyledTableRow key={index}>
<StyledTableCell key='0'>
<Tooltip title={row.templateName || row.name}>
<Typography
sx={{
display: '-webkit-box',
fontSize: 14,
fontWeight: 500,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden'
}}
>
<Link to={onFlowClick(row)} style={{ color: '#2196f3', textDecoration: 'none' }}>
{row.templateName || row.name}
</Link>
</Typography>
</Tooltip>
<Stack direction='row' spacing={1} alignItems='center' sx={{ minWidth: 0 }}>
<Tooltip title={row.templateName || row.name}>
<Typography
sx={{
display: '-webkit-box',
fontSize: 14,
fontWeight: 500,
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
textOverflow: 'ellipsis',
overflow: 'hidden',
minWidth: 0
}}
>
<Link to={onFlowClick(row)} style={{ color: '#2196f3', textDecoration: 'none' }}>
{row.templateName || row.name}
</Link>
</Typography>
</Tooltip>
<ScheduleStatusBadge scheduleStatus={scheduleStatuses[row.id]} size='sm' />
</Stack>
</StyledTableCell>
<StyledTableCell key='1'>
<div
@@ -354,6 +360,7 @@ FlowListTable.propTypes = {
data: PropTypes.array,
images: PropTypes.object,
icons: PropTypes.object,
scheduleStatuses: PropTypes.object,
isLoading: PropTypes.bool,
filterFunction: PropTypes.func,
updateFlowsApi: PropTypes.object,
+47 -4
View File
@@ -41,6 +41,7 @@ const Agentflows = () => {
const [isLoading, setLoading] = useState(true)
const [images, setImages] = useState({})
const [icons, setIcons] = useState({})
const [scheduleStatuses, setScheduleStatuses] = useState({})
const [search, setSearch] = useState('')
const { error, setError } = useError()
@@ -139,30 +140,70 @@ const Agentflows = () => {
setTotal(getAllAgentflows.data?.total)
const images = {}
const icons = {}
const scheduleConfiguredIds = []
for (let i = 0; i < agentflows.length; i += 1) {
const flowDataStr = agentflows[i].flowData
const flowData = JSON.parse(flowDataStr)
const nodes = flowData.nodes || []
images[agentflows[i].id] = []
icons[agentflows[i].id] = []
let isScheduleFlow = false
for (let j = 0; j < nodes.length; j += 1) {
if (nodes[j].data.name === 'stickyNote' || nodes[j].data.name === 'stickyNoteAgentflow') continue
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === nodes[j].data.name)
const node = nodes[j]
if (node.data?.name === 'startAgentflow' && node.data?.inputs?.startInputType === 'scheduleInput') {
isScheduleFlow = true
}
if (node.data.name === 'stickyNote' || node.data.name === 'stickyNoteAgentflow') continue
const foundIcon = AGENTFLOW_ICONS.find((icon) => icon.name === node.data.name)
if (foundIcon) {
icons[agentflows[i].id].push(foundIcon)
} else {
const imageSrc = `${baseURL}/api/v1/node-icon/${nodes[j].data.name}`
const imageSrc = `${baseURL}/api/v1/node-icon/${node.data.name}`
if (!images[agentflows[i].id].some((img) => img.imageSrc === imageSrc)) {
images[agentflows[i].id].push({
imageSrc,
label: nodes[j].data.label
label: node.data.label
})
}
}
}
if (isScheduleFlow) scheduleConfiguredIds.push(agentflows[i].id)
}
setImages(images)
setIcons(icons)
const initialStatuses = {}
scheduleConfiguredIds.forEach((id) => {
initialStatuses[id] = { isScheduled: true, enabled: false, loading: true }
})
setScheduleStatuses(initialStatuses)
if (scheduleConfiguredIds.length > 0) {
Promise.all(
scheduleConfiguredIds.map((id) =>
chatflowsApi
.getScheduleStatus(id)
.then((res) => ({ id, data: res.data }))
.catch(() => ({ id, error: true }))
)
).then((results) => {
setScheduleStatuses((prev) => {
const next = { ...prev }
results.forEach(({ id, data }) => {
if (next[id]) {
next[id] = {
...next[id],
enabled: data?.enabled === true,
nextRunAt: data?.record?.nextRunAt || null,
cronExpression: data?.record?.cronExpression || null,
loading: false
}
}
})
return next
})
})
}
} catch (e) {
console.error(e)
}
@@ -312,6 +353,7 @@ const Agentflows = () => {
data={data}
images={images[data.id]}
icons={icons[data.id]}
scheduleStatus={scheduleStatuses[data.id]}
/>
))}
</Box>
@@ -322,6 +364,7 @@ const Agentflows = () => {
data={getAllAgentflows.data?.data}
images={images}
icons={icons}
scheduleStatuses={scheduleStatuses}
isLoading={isLoading}
filterFunction={filterFlows}
updateFlowsApi={getAllAgentflows}