diff --git a/frontend/taipy/src/JobDetailsViewer.tsx b/frontend/taipy/src/JobDetailsViewer.tsx new file mode 100644 index 000000000..26597e546 --- /dev/null +++ b/frontend/taipy/src/JobDetailsViewer.tsx @@ -0,0 +1,280 @@ +/* + * Copyright 2021-2024 Avaiga Private Limited + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the + * specific language governing permissions and limitations under the License. + */ + +import React, { useEffect, useCallback } from "react"; +import Button from "@mui/material/Button"; +import Divider from "@mui/material/Divider"; +import Grid from "@mui/material/Grid2"; +import ListItemText from "@mui/material/ListItemText"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +import { + createRequestUpdateAction, + createSendActionNameAction, + getUpdateVar, + useDispatch, + useDispatchRequestUpdateOnFirstRender, + useModule, +} from "taipy-gui"; + +import { useClassNames, EllipsisSx, SecondaryEllipsisProps, CoreProps, calculateTimeDifference } from "./utils"; +import StatusChip from "./StatusChip"; + +interface JobDetailsViewerProps extends CoreProps { + job: JobDetail; + showSubmitId?: boolean, + showTaskLabel?: boolean, + showSubmittedLabel?: boolean, + showExecutionDuration?: boolean, + showPendingDuration?: boolean, + showBlockedDuration?: boolean, + showSubmissionDuration?: boolean, + showCancel?: boolean; + showDelete?: boolean; + showStackTrace?: boolean, + onJobAction?: string; + updateJbVars?: string; + height?: string, + width?: string; +} + +// job id, creation date, status, submit id, job name, entity id, entity name, pending duration, blocked duration, finished at, not cancellable, not deletable, execution time, logs, +export type JobDetail = [string, string, number, string, string, string, string, string, string, string, string, string, string, string[]]; +const invalidJob: JobDetail = ["", "", 0, "", "", "", "", "", "" , "", "", "", "", []]; + +const JobDetailsViewer = (props: JobDetailsViewerProps) => { + const { + updateVarName = "", + id = "", + updateJbVars = "", + height = "50vh", + width = "50vw", + coreChanged + } = props; + + const [ + jobId, + creationDate, + status, + submitId, + jobName, + entityId, + entityName, + pendingDuration, + blockedDuration, + finishedAt, + notCancellable, + notDeletable, + executionDuration, + stacktrace, + ] = props.job || invalidJob; + + const dispatch = useDispatch(); + const module = useModule(); + + const className = useClassNames(props.libClassName, props.dynamicClassName, props.className); + + useDispatchRequestUpdateOnFirstRender(dispatch, id, module, undefined, updateVarName); + + const handleDeleteJob = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + try { + dispatch( + createSendActionNameAction(props.id, module, props.onJobAction, { + id: jobId, + action: "delete", + error_id: getUpdateVar(updateJbVars, "error_id"), + }) + ); + } catch (e) { + console.warn("Error parsing id for delete.", e); + } + }, + [jobId, dispatch, module, props.id, props.onJobAction, updateJbVars] + ); + + const handleCancelJob = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + try { + dispatch( + createSendActionNameAction(props.id, module, props.onJobAction, { + id: jobId, + action: "cancel", + error_id: getUpdateVar(updateJbVars, "error_id"), + }) + ); + } catch (e) { + console.warn("Error parsing id for cancel.", e); + } + }, + [jobId, dispatch, module, props.id, props.onJobAction, updateJbVars] + ) + + useEffect(() => { + if (coreChanged?.job == jobId) { + updateVarName && dispatch(createRequestUpdateAction(id, module, [updateVarName], true)); + } + }, [coreChanged, updateVarName, jobId, module, dispatch, id]); + + return ( + + + Job Id + + + + {jobId} + + + + Creation date + + + {creationDate ? new Date(creationDate).toLocaleString() : ""} + + + Status + + + + + {props.showSubmitId && ( + + + Submit Id + + + + {submitId} + + + + + )} + {props.showTaskLabel && ( + + + Task label + + + + {jobName} + + + + )} + {props.showSubmittedLabel && ( + + + Submitted entity + + + + + + + + )} + {props.showExecutionDuration && ( + + + Execution Duration + + + {executionDuration} + + + )} + {props.showPendingDuration && ( + + + Pending time + + + {pendingDuration} + + + )} + {props.showBlockedDuration && ( + + + Blocked time + + + {blockedDuration} + + + )} + {props.showSubmissionDuration && ( + + + Submission duration + + + {(creationDate && finishedAt) ? calculateTimeDifference(new Date(creationDate), new Date(finishedAt)) : ""} + + + )} + + {props.showStackTrace && ( + + + Stack Trace + + + + {stacktrace.join("
")} +
+
+
+ )} + {props.showCancel ? ( + <> + + + + + + + + + + ) : null} + {props.showDelete ? ( + <> + + + + + + + + + + ) : null} +
+ ); +}; + +export default JobDetailsViewer; diff --git a/frontend/taipy/src/index.ts b/frontend/taipy/src/index.ts index 4b12fb22a..0ae262094 100644 --- a/frontend/taipy/src/index.ts +++ b/frontend/taipy/src/index.ts @@ -3,6 +3,7 @@ import ScenarioViewer from "./ScenarioViewer"; import ScenarioDag from "./ScenarioDag"; import NodeSelector from "./NodeSelector"; import JobSelector from "./JobSelector"; +import JobDetailsViewer from "./JobDetailsViewer"; import DataNodeViewer from "./DataNodeViewer"; -export { ScenarioSelector, ScenarioDag, ScenarioViewer as Scenario, NodeSelector as DataNodeSelector, JobSelector, DataNodeViewer as DataNode }; +export { ScenarioSelector, ScenarioDag, ScenarioViewer as Scenario, NodeSelector as DataNodeSelector, JobSelector, DataNodeViewer as DataNode, JobDetailsViewer as JobViewer }; diff --git a/frontend/taipy/src/utils.ts b/frontend/taipy/src/utils.ts index 8400d765a..7e69990aa 100644 --- a/frontend/taipy/src/utils.ts +++ b/frontend/taipy/src/utils.ts @@ -237,3 +237,19 @@ export const getUpdateVarNames = (updateVars: string, ...vars: string[]) => export const EllipsisSx = { textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" }; export const SecondaryEllipsisProps = { sx: EllipsisSx }; + +export const calculateTimeDifference = (date1: Date, date2: Date) => { + const milliseconds1 = date1.getTime(); + const milliseconds2 = date2.getTime(); + + const differenceInMilliseconds = milliseconds2 - milliseconds1; + const seconds = Math.floor(differenceInMilliseconds / 1000); + + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secondsRemaining = seconds % 60; + + const formattedTime = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secondsRemaining.toString().padStart(2, '0')}`; + + return formattedTime; + } \ No newline at end of file diff --git a/taipy/core/job/job.py b/taipy/core/job/job.py index 43343df6a..b7e5ed808 100644 --- a/taipy/core/job/job.py +++ b/taipy/core/job/job.py @@ -460,6 +460,16 @@ def is_deletable(self) -> ReasonCollection: from ... import core as tp return tp.is_deletable(self) + + def is_cancellable(self) -> bool: + """Indicate if the job can be cancelled. + + Returns: + A Boolean value, which is True if the job can be cancelled. False otherwise. + """ + + can_be_cancelled = self.is_pending or self.is_blocked or self.is_submitted + return bool(can_be_cancelled) def get_event_context(self): return {"task_config_id": self._task.config_id} diff --git a/taipy/gui_core/_GuiCoreLib.py b/taipy/gui_core/_GuiCoreLib.py index 45540efab..720503cb2 100644 --- a/taipy/gui_core/_GuiCoreLib.py +++ b/taipy/gui_core/_GuiCoreLib.py @@ -307,6 +307,41 @@ class _GuiCore(ElementLibrary): ), }, ), + "job_viewer": Element( + "value", + { + "id": ElementProperty(PropertyType.string), + "class_name": ElementProperty(PropertyType.dynamic_string), + "value": ElementProperty(PropertyType.lov_value), + "show_submit_id": ElementProperty(PropertyType.boolean, False), + "show_task_label": ElementProperty(PropertyType.boolean, True), + "show_submitted_label": ElementProperty(PropertyType.boolean, True), + "show_execution_duration": ElementProperty(PropertyType.boolean, True), + "show_pending_duration": ElementProperty(PropertyType.boolean, False), + "show_blocked_duration": ElementProperty(PropertyType.boolean, False), + "show_submission_duration": ElementProperty(PropertyType.boolean, True), + "show_cancel": ElementProperty(PropertyType.boolean, True), + "show_delete": ElementProperty(PropertyType.boolean, True), + "show_stack_trace": ElementProperty(PropertyType.boolean, True), + "height": ElementProperty(PropertyType.string, "50vh"), + "width": ElementProperty(PropertyType.string, "50vw"), + }, + inner_properties={ + "job": ElementProperty( + PropertyType.react, + f"{{{__CTX_VAR_NAME}.get_complete_job_details(" + f"{__JOB_DETAIL_ID_VAR})}}", + ), + "core_changed": ElementProperty(PropertyType.broadcast, _GuiCoreContext._CORE_CHANGED_NAME), + "type": ElementProperty(PropertyType.inner, __JOB_ADAPTER), + "on_job_action": ElementProperty(PropertyType.function, f"{{{__CTX_VAR_NAME}.act_on_jobs}}"), + "error": ElementProperty(PropertyType.dynamic_string, f"{{{__JOB_SELECTOR_ERROR_VAR}}}"), + "update_jb_vars": ElementProperty( + PropertyType.string, + f"error_id={__JOB_SELECTOR_ERROR_VAR};" + + f"detail_id={__JOB_DETAIL_ID_VAR};", + ), + }, + ), } def get_name(self) -> str: diff --git a/taipy/gui_core/_context.py b/taipy/gui_core/_context.py index 97a1e6a2a..c6f217591 100644 --- a/taipy/gui_core/_context.py +++ b/taipy/gui_core/_context.py @@ -885,6 +885,33 @@ def get_job_details(self, job_id: t.Optional[JobId]): except Exception as e: _warn(f"Access to job ({job_id}) failed", e) return None + + def get_complete_job_details(self, job_id: t.Optional[JobId]): + try: + if job_id and is_readable(job_id) and (job := core_get(job_id)) is not None: + if isinstance(job, Job): + entity = core_get(job.owner_id) + return ( + job.id, + job.creation_date, + job.status.value, + job.submit_id, + job.get_simple_label(), + entity.id if entity else "", + entity.get_simple_label() if entity else "", + job.pending_duration, + job.blocked_duration, + job.finished_at, + job.is_cancellable, + _get_reason(is_deletable(job)), + "" + if job.execution_duration is None + else str(datetime.timedelta(seconds=job.execution_duration)), + [] if job.stacktrace is None else job.stacktrace, + ) + except Exception as e: + _warn(f"Access to job ({job_id}) failed", e) + return None def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]): self.__lazy_start() diff --git a/taipy/gui_core/viselements.json b/taipy/gui_core/viselements.json index 7142008ac..a2b93acc1 100644 --- a/taipy/gui_core/viselements.json +++ b/taipy/gui_core/viselements.json @@ -568,7 +568,105 @@ } ] } - ] + ], + [ + "job_viewer", + { + "inherits": [ + "core_gui_shared" + ], + "properties": [ + { + "name": "value", + "default_property": true, + "type": "dynamic(Job)", + "doc": "Bound to the selected Job^, or None if there is none." + }, + { + "name": "show_submit_id", + "type": "bool", + "default_value": "False", + "doc": "If True, the submit id is shown in the viewer." + }, + { + "name": "show_task_label", + "type": "bool", + "default_value": "True", + "doc": "If False, the task label is not shown in the viewer." + }, + { + "name": "show_submitted_label", + "type": "bool", + "default_value": "True", + "doc": "If False, the submitted label is not shown in the viewer." + }, + { + "name": "show_execution_duration", + "type": "bool", + "default_value": "True", + "doc": "If False, the execution duration is not shown in the viewer." + }, + { + "name": "show_pending_duration", + "type": "bool", + "default_value": "False", + "doc": "If True, the pending duration is shown in the viewer." + }, + { + "name": "show_blocked_duration", + "type": "bool", + "default_value": "False", + "doc": "If True, the blocked duration is shown in the viewer." + }, + { + "name": "show_submission_duration", + "type": "bool", + "default_value": "True", + "doc": "If False, the submission duration is not shown in the viewer." + }, + { + "name": "show_cancel", + "type": "bool", + "default_value": "True", + "doc": "If False, the Cancel button is not shown in the viewer." + }, + { + "name": "show_delete", + "type": "bool", + "default_value": "True", + "doc": "If False, the Delete button is not shown in the viewer." + }, + { + "name": "show_stack_trace", + "type": "bool", + "default_value": "True", + "doc": "If False, the stack trace is not shown in the viewer." + }, + { + "name": "height", + "type": "str", + "default_value": "\"50vh\"", + "doc": "The maximum height, in CSS units, of the viewer." + }, + { + "name": "width", + "type": "str", + "default_value": "\"50vw\"", + "doc": "The maximum width, in CSS units, of the viewer." + }, + { + "name": "on_job_action", + "type": "Union[str, Callable]", + "doc": "A function or the name of a function that is triggered for job actions." + }, + { + "name": "error", + "type": "str", + "doc": "The error message related to the job viewer." + } + ] + } + ] ], "undocumented": [ [