Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 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
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@
"@types/react-syntax-highlighter": "^15.5.13",
"@types/remarkable": "^2.0.8",
"@types/styled-components": "^5.1.32",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"cross-env": "^7.0.3",
Expand Down Expand Up @@ -102,6 +103,7 @@
"serve": "^14.2.1",
"styled-components": "^6.1.1",
"typescript": "5.1.6",
"uuid": "11.1.0",
"zod": "3.24.1"
},
"devDependencies": {
Expand All @@ -121,6 +123,7 @@
"@types/react": "^18.3.11",
"@types/react-dom": "^18.3.1",
"eslint-config-react-app": "^7.0.1",
"fake-indexeddb": "6.0.1",
"globals": "15.11.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "29.7.0",
Expand Down
3 changes: 2 additions & 1 deletion client/src/preview/components/asset/AssetCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ function CardButtonsContainerExecute({
assetName,
setShowLog,
}: CardButtonsContainerExecuteProps) {
const [logButtonDisabled, setLogButtonDisabled] = useState(true);
const [logButtonDisabled, setLogButtonDisabled] = useState(false);
return (
<CardActions style={{ justifyContent: 'flex-end' }}>
<StartStopButton
Expand All @@ -137,6 +137,7 @@ function CardButtonsContainerExecute({
<LogButton
setShowLog={setShowLog}
logButtonDisabled={logButtonDisabled}
assetName={assetName}
/>
</CardActions>
);
Expand Down
41 changes: 31 additions & 10 deletions client/src/preview/components/asset/LogButton.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file can be placed in src/routes/digitaltwins/execution directory. Perhaps this code could be placed in HistoryButton.tsx.
The general guideline is that the react components used in multiple routes go to src/components while ones used in a single route go to their corresponding route directory.

Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import * as React from 'react';
import { Dispatch, SetStateAction } from 'react';
import { Button } from '@mui/material';
import { Button, Badge } from '@mui/material';
import { useSelector } from 'react-redux';
import { selectExecutionHistoryByDTName } from 'preview/store/executionHistory.slice';

interface LogButtonProps {
setShowLog: Dispatch<React.SetStateAction<boolean>>;
logButtonDisabled: boolean;
assetName: string;
}

export const handleToggleLog = (
Expand All @@ -13,17 +16,35 @@ export const handleToggleLog = (
setShowLog((prev) => !prev);
};

function LogButton({ setShowLog, logButtonDisabled }: LogButtonProps) {
function LogButton({
setShowLog,
logButtonDisabled,
assetName,
}: LogButtonProps) {
// Get execution history for this Digital Twin
const executions =
useSelector(selectExecutionHistoryByDTName(assetName)) || [];

// Count of executions with logs
const executionCount = executions.length;

return (
<Button
variant="contained"
size="small"
color="primary"
onClick={() => handleToggleLog(setShowLog)}
disabled={logButtonDisabled}
<Badge
badgeContent={executionCount > 0 ? executionCount : 0}
color="secondary"
overlap="circular"
invisible={executionCount === 0}
>
Log
</Button>
<Button
variant="contained"
size="small"
color="primary"
onClick={() => handleToggleLog(setShowLog)}
disabled={logButtonDisabled && executionCount === 0}
>
History
</Button>
</Badge>
);
}

Expand Down
58 changes: 37 additions & 21 deletions client/src/preview/components/asset/StartStopButton.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously one button was used for the start and stop actions but now they are separate. The stop is inside History button. Perhaps a HistoryButton.tsx component be placed in src/components/asset. The component responsibility could be:

StartButton.tsx --> manages only start
HistoryButton.tsx --> manages stop and logs

Original file line number Diff line number Diff line change
@@ -1,14 +1,11 @@
import * as React from 'react';
import { useState, Dispatch, SetStateAction } from 'react';
import { Button, CircularProgress } from '@mui/material';
import { handleButtonClick } from 'preview/route/digitaltwins/execute/pipelineHandler';
import { Dispatch, SetStateAction } from 'react';
import { Button, CircularProgress, Box } from '@mui/material';
import { handleStart } from 'preview/route/digitaltwins/execute/pipelineHandler';
import { useSelector, useDispatch } from 'react-redux';
import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice';

export interface JobLog {
jobName: string;
log: string;
}
import { selectExecutionHistoryByDTName } from 'preview/store/executionHistory.slice';
import { ExecutionStatus } from 'preview/model/executionHistory';

interface StartStopButtonProps {
assetName: string;
Expand All @@ -19,33 +16,52 @@ function StartStopButton({
assetName,
setLogButtonDisabled,
}: StartStopButtonProps) {
const [buttonText, setButtonText] = useState('Start');

const dispatch = useDispatch();
const digitalTwin = useSelector(selectDigitalTwinByName(assetName));
const executions =
useSelector(selectExecutionHistoryByDTName(assetName)) || [];

const runningExecutions = Array.isArray(executions)
? executions.filter(
(execution) => execution.status === ExecutionStatus.RUNNING,
)
: [];

const isLoading =
digitalTwin?.pipelineLoading || runningExecutions.length > 0;

const runningCount = runningExecutions.length;

return (
<>
{digitalTwin?.pipelineLoading ? (
<CircularProgress size={22} style={{ marginRight: '8px' }} />
) : null}
<Box display="flex" alignItems="center">
{isLoading && (
<Box display="flex" alignItems="center" mr={1}>
<CircularProgress size={22} data-testid="circular-progress" />
{runningCount > 0 && (
<Box component="span" ml={0.5} fontSize="0.75rem">
({runningCount})
</Box>
)}
</Box>
)}
<Button
variant="contained"
size="small"
color="primary"
onClick={() =>
handleButtonClick(
buttonText,
onClick={() => {
const setButtonText = () => {}; // Dummy function since we don't need to change button text
handleStart(
'Start',
Copy link

Copilot AI May 24, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The call to handleStart always passes 'Start' and you’ve removed any buttonText state, so the UI can never switch to 'Stop'. This breaks the stop functionality—consider managing a local buttonText state or passing the current executionId to toggle correctly.

Suggested change
const setButtonText = () => {}; // Dummy function since we don't need to change button text
handleStart(
'Start',
const newButtonText = buttonText === 'Start' ? 'Stop' : 'Start';
setButtonText(newButtonText);
handleStart(
newButtonText,

Copilot uses AI. Check for mistakes.
setButtonText,
digitalTwin,
setLogButtonDisabled,
dispatch,
)
}
);
}}
>
{buttonText}
Start
</Button>
</>
</Box>
);
}

Expand Down
206 changes: 206 additions & 0 deletions client/src/preview/components/execution/ExecutionHistoryList.tsx
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please move this to src/components/execution

Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import * as React from 'react';
import { useEffect } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
List,
ListItem,
ListItemText,
IconButton,
Typography,
Paper,
Box,
Tooltip,
CircularProgress,
Divider,
} from '@mui/material';
import {
Delete as DeleteIcon,
Visibility as VisibilityIcon,
CheckCircle as CheckCircleIcon,
Error as ErrorIcon,
Cancel as CancelIcon,
AccessTime as AccessTimeIcon,
HourglassEmpty as HourglassEmptyIcon,
Stop as StopIcon,
} from '@mui/icons-material';
import { ExecutionStatus } from 'preview/model/executionHistory';
import {
fetchExecutionHistory,
removeExecution,
selectExecutionHistoryByDTName,
selectExecutionHistoryLoading,
setSelectedExecutionId,
} from 'preview/store/executionHistory.slice';
import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice';
import { handleStop } from 'preview/route/digitaltwins/execute/pipelineHandler';
import { ThunkDispatch, Action } from '@reduxjs/toolkit';
import { RootState } from 'store/store';

interface ExecutionHistoryListProps {
dtName: string;
onViewLogs: (executionId: string) => void;
}

const formatTimestamp = (timestamp: number): string => {
const date = new Date(timestamp);
return date.toLocaleString();
};

const getStatusIcon = (status: ExecutionStatus) => {
switch (status) {
case ExecutionStatus.COMPLETED:
return <CheckCircleIcon color="success" />;
case ExecutionStatus.FAILED:
return <ErrorIcon color="error" />;
case ExecutionStatus.CANCELED:
return <CancelIcon color="warning" />;
case ExecutionStatus.TIMEOUT:
return <HourglassEmptyIcon color="warning" />;
case ExecutionStatus.RUNNING:
return <CircularProgress size={20} />;
default:
return <AccessTimeIcon />;

Check warning on line 62 in client/src/preview/components/execution/ExecutionHistoryList.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/preview/components/execution/ExecutionHistoryList.tsx#L61-L62

Added lines #L61 - L62 were not covered by tests
}
};

const getStatusText = (status: ExecutionStatus): string => {
switch (status) {
case ExecutionStatus.COMPLETED:
return 'Completed';
case ExecutionStatus.FAILED:
return 'Failed';
case ExecutionStatus.CANCELED:
return 'Canceled';
case ExecutionStatus.TIMEOUT:
return 'Timed out';
case ExecutionStatus.RUNNING:
return 'Running';
default:
return 'Unknown';

Check warning on line 79 in client/src/preview/components/execution/ExecutionHistoryList.tsx

View check run for this annotation

Codecov / codecov/patch

client/src/preview/components/execution/ExecutionHistoryList.tsx#L78-L79

Added lines #L78 - L79 were not covered by tests
}
};

const ExecutionHistoryList: React.FC<ExecutionHistoryListProps> = ({
dtName,
onViewLogs,
}) => {
// Use typed dispatch for thunk actions
const dispatch =
useDispatch<ThunkDispatch<RootState, unknown, Action<string>>>();
const executions = useSelector(selectExecutionHistoryByDTName(dtName));
const loading = useSelector(selectExecutionHistoryLoading);
const digitalTwin = useSelector(selectDigitalTwinByName(dtName));

useEffect(() => {
// Use the thunk action creator directly
dispatch(fetchExecutionHistory(dtName));
}, [dispatch, dtName]);

const handleDelete = (executionId: string) => {
// Use the thunk action creator directly
dispatch(removeExecution(executionId));
};

const handleViewLogs = (executionId: string) => {
dispatch(setSelectedExecutionId(executionId));
onViewLogs(executionId);
};

const handleStopExecution = (executionId: string) => {
if (digitalTwin) {
// Dummy function since we don't need to change button text
const setButtonText = () => {};
handleStop(digitalTwin, setButtonText, dispatch, executionId);
}
};

if (loading) {
return (
<Box display="flex" justifyContent="center" alignItems="center" p={2}>
<div data-testid="circular-progress">
<CircularProgress data-testid="progress-indicator" />
</div>
</Box>
);
}

if (!executions || executions.length === 0) {
return (
<Paper elevation={2} sx={{ p: 2, mt: 2 }}>
<Typography variant="body1" align="center">
No execution history found. Start an execution to see it here.
</Typography>
</Paper>
);
}

const sortedExecutions = [...executions].sort(
(a, b) => b.timestamp - a.timestamp,
);

return (
<Paper elevation={2} sx={{ mt: 2 }}>
<Box p={2}>
<Typography variant="h6" gutterBottom>
Execution History
</Typography>
<List>
{sortedExecutions.map((execution) => (
<React.Fragment key={execution.id}>
<ListItem
secondaryAction={
<Box display="flex">
<Tooltip title="View Logs">
<IconButton
edge="end"
aria-label="view"
onClick={() => handleViewLogs(execution.id)}
>
<VisibilityIcon />
</IconButton>
</Tooltip>
{execution.status === ExecutionStatus.RUNNING && (
<Tooltip title="Stop Execution">
<IconButton
edge="end"
aria-label="stop"
onClick={() => handleStopExecution(execution.id)}
>
<StopIcon />
</IconButton>
</Tooltip>
)}
<Tooltip title="Delete">
<IconButton
edge="end"
aria-label="delete"
onClick={() => handleDelete(execution.id)}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</Box>
}
>
<Box display="flex" alignItems="center" mr={2}>
{getStatusIcon(execution.status)}
</Box>
<ListItemText
primary={formatTimestamp(execution.timestamp)}
secondary={
<Typography variant="body2" color="textSecondary">
Status: {getStatusText(execution.status)}
</Typography>
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</React.Fragment>
))}
</List>
</Box>
</Paper>
);
};

export default ExecutionHistoryList;
Loading
Loading