Repository: Avaiga/taipy
Pull request: job details
resolves #1251

```
import datetime as dt

import taipy as tp
from taipy import Config, Frequency
from taipy.gui import State


def on_change(state: State, var: str, val):
    print(f"on_change(state: State, var: {var}, val type: {type(val)}, val: {val})")

# Function to run a Dataiku scenario
def run_something(input_1, input_2):
    datetime = dt.datetime.now()
    date = dt.date(2018, 1, 1)
    int_var = 10
    string_var = "String"
    # raise Exception("Fred's exception")
    return datetime, date, int_var, string_var


data = {
    "toto": list(range(10_000)),
    "titi": [2 * i for i in range(10_000)],
    "tata": [4 * i for i in range(10_000)],
}


input_1_cfg = Config.configure_data_node(
    id="input_1_data_node",
    default_data=data,
)


input_2_cfg = Config.configure_data_node(
    id="input_2_data_node",
    default_data=data,
)

datetime_cfg = Config.configure_data_node(id="datetime_data_node")
date_cfg = Config.configure_data_node(id="date_data_node")
int_cfg = Config.configure_data_node(id="int_data_node")
string_cfg = Config.configure_data_node(id="string_data_node")


# Scenario and task configuration in Taipy
scenario_task_cfg = Config.configure_task(
    id="scenario_task",
    function=run_something,
    input=[input_1_cfg, input_2_cfg],
    output=[datetime_cfg, date_cfg, int_cfg, string_cfg],
)

scenario_cfg = Config.configure_scenario(id="scenario", task_configs=[scenario_task_cfg], frequency=Frequency.DAILY)

data_node = None

# GUI Markdown content
scenario_md = """
<|1 1|layout|
<|{scenario}|scenario_selector|>

<|{scenario}|scenario|>
|>

<|{scenario}|>





<|1 1|layout|
Input 1 Dataset:
<|{scenario.input_1_data_node if scenario else None}|data_node|>

Input 2 Dataset:
<|{scenario.input_2_data_node if scenario else None}|data_node|>
|>


<|1 1|layout|
<|{data_node}|data_node_selector|>

<|{data_node}|data_node|>
|>
"""

# Main execution block with GUI setup
if __name__ == "__main__":
    tp.Orchestrator().run()
    scenario = tp.create_scenario(scenario_cfg)
    scenario.add_tag("test")
    scenario1 = tp.create_scenario(scenario_cfg)
    scenario1.add_tag("test")
    # Run the GUI
    tp.Gui(scenario_md).run(title="1251 Job details")

```

`<|job_selector|on_details={False}|>`
`<|job_selector|on_details=on_details_callback|>

on_details_callback(state: State, id: str, payload: {'action': 'on_details_callback', 'args': ['JOB_scenario_task_e2e9329d-2d2c-45fd-b2e0-64921b88a666']})
`



Problem statement:
Show stack trace in job selector when job fails 
**What would that feature address**
When a job is failing, it is helpful to show the logs in the job selector to understand why the data is incorrect.

***Description of the ideal solution***
Display the job logs in a modal accessible from a job in the list

**Acceptance Criteria**
- [ ] Ensure new code is unit tested, and check code coverage is at least 90%
- [ ] Create related issue in taipy-doc for documentation and Release Notes
- [ ] Check if a new demo could be provided based on this, or if legacy demos could be benefit from it
- [ ] Ensure any change is well documented

Hints from issue discussion:
It will be really useful; you are correct! Would having a visual element called *job* be the solution for you, or should it be totally integrated within the *job selector*? 
Integrated with the job selector is better, so all the job handling functionalities are supported with a single visual element. It is much easier to design a page when controls are integrated.
Hi @FlorianJacta, if this issue is still needed, then I would like to work on this, could you please assign this to me.
Also, could you please give some information on how to reproduce this issue.
This is not an issue, but a new functionality asked.

When a job is failing, I would like to have a button in the job selector to display the job's logs and stacktrace.
Here is the proposal: 

We want to add to the job selector a detail button for each job similarly to the following mockup:
![image](https://github.com/user-attachments/assets/d147d360-7dc7-41d3-a116-e8f9d51282c0)
This button should be controlled by a `on_detail` property deactivable or replacable by a gui callback.

By default clicking on a detail button should open a dialogue similar to the following mockup:
![image](https://github.com/user-attachments/assets/1b4777cc-9464-4b06-a406-2896d8db5925)

Note that we want to take the opportunity of this issue to improve the UX of the job selector control.

In particular we want to use the label as the man information in each column instead of the id. The id would be the second level information and could be displayed like an ellipse.
added a property `on_details`
- not set : adds a icon to the selector list that shows a dialog with details
- set to False: hide the icon
- set to a callback: calls the callback with the job id
on_details_callback(state: State, id: str, payload: {'action': 'on_details_callback', 'args': ['JOB_scenario_task_e2e9329d-2d2c-45fd-b2e0-64921b88a666']})
Note that for now, only the stack trace is collected when the job fails. The logs are not collected. It may come later.

Commit message: Merge branch 'develop' into feature/#1251-job-details

Patch to review:
diff --git a/frontend/taipy/src/JobSelector.tsx b/frontend/taipy/src/JobSelector.tsx
index c7663fd9a3..b79374c147 100644
--- a/frontend/taipy/src/JobSelector.tsx
+++ b/frontend/taipy/src/JobSelector.tsx
@@ -11,8 +11,13 @@
  * specific language governing permissions and limitations under the License.
  */
 
-import React, { useEffect, useState, useCallback, useMemo, MouseEvent } from "react";
-import { DeleteOutline, StopCircleOutlined, Add, FilterList } from "@mui/icons-material";
+import React, { useEffect, useState, useCallback, useMemo, MouseEvent, useRef } from "react";
+import Add from "@mui/icons-material/Add";
+import CloseIcon from "@mui/icons-material/Close";
+import DeleteOutline from "@mui/icons-material/DeleteOutline";
+import DescriptionOutlinedIcon from "@mui/icons-material/DescriptionOutlined";
+import FilterList from "@mui/icons-material/FilterList";
+import StopCircleOutlined from "@mui/icons-material/StopCircleOutlined";
 import Box from "@mui/material/Box";
 import Button from "@mui/material/Button";
 import Checkbox from "@mui/material/Checkbox";
@@ -47,8 +52,26 @@ import {
     useModule,
 } from "taipy-gui";
 
-import { disableColor, popoverOrigin, useClassNames } from "./utils";
+import {
+    disableColor,
+    getUpdateVarNames,
+    popoverOrigin,
+    useClassNames,
+    EllipsisSx,
+    SecondaryEllipsisProps,
+} from "./utils";
 import StatusChip, { Status } from "./StatusChip";
+import JobViewer, { JobDetail } from "./JobViewer";
+import { Dialog, DialogActions, DialogContent, DialogTitle, Theme } from "@mui/material";
+
+const CloseDialogSx = {
+    position: "absolute",
+    right: 8,
+    top: 8,
+    color: (theme: Theme) => theme.palette.grey[500],
+};
+
+const RightButtonSx = { marginLeft: "auto ! important" };
 
 interface JobSelectorProps {
     updateVarName?: string;
@@ -75,6 +98,8 @@ interface JobSelectorProps {
     defaultValue?: string;
     propagate?: boolean;
     updateJbVars?: string;
+    details?: JobDetail;
+    onDetails?: string | boolean;
 }
 
 // job id, job name, empty list, entity id, entity name, submit id, creation date, status, not deletable, not readable, not editable
@@ -92,7 +117,7 @@ enum JobProps {
     status,
     not_deletable,
     not_readable,
-    not_editable
+    not_editable,
 }
 const JobLength = Object.keys(JobProps).length / 2;
 
@@ -359,6 +384,7 @@ interface JobSelectedTableRowProps {
     handleCheckboxClick: (event: React.MouseEvent<HTMLElement>) => void;
     handleCancelJobs: (event: React.MouseEvent<HTMLElement>) => void;
     handleDeleteJobs: (event: React.MouseEvent<HTMLElement>) => void;
+    handleShowDetails: false | ((event: React.MouseEvent<HTMLElement>) => void);
     showId?: boolean;
     showSubmittedLabel?: boolean;
     showSubmittedId?: boolean;
@@ -375,13 +401,14 @@ const JobSelectedTableRow = ({
     handleCheckboxClick,
     handleCancelJobs,
     handleDeleteJobs,
+    handleShowDetails,
     showId,
     showSubmittedLabel,
     showSubmittedId,
     showSubmissionId,
     showDate,
     showCancel,
-    showDelete
+    showDelete,
 }: JobSelectedTableRowProps) => {
     const [id, jobName, , entityId, entityName, submitId, creationDate, status] = row;
 
@@ -400,18 +427,22 @@ const JobSelectedTableRow = ({
             </TableCell>
             {showId ? (
                 <TableCell component="th" scope="row" padding="none">
-                    <ListItemText primary={jobName} secondary={id} />
+                    <ListItemText primary={jobName} secondary={id} secondaryTypographyProps={SecondaryEllipsisProps} />
                 </TableCell>
             ) : null}
             {showSubmissionId ? <TableCell>{submitId}</TableCell> : null}
             {showSubmittedLabel || showSubmittedId ? (
                 <TableCell>
                     {!showSubmittedLabel && showSubmittedId ? (
-                        entityId
+                        <Typography sx={EllipsisSx}>{entityId}</Typography>
                     ) : !showSubmittedId && showSubmittedLabel ? (
-                        entityName
+                        <Typography>{entityName}</Typography>
                     ) : (
-                        <ListItemText primary={entityName} secondary={entityId} />
+                        <ListItemText
+                            primary={entityName}
+                            secondary={entityId}
+                            secondaryTypographyProps={SecondaryEllipsisProps}
+                        />
                     )}
                 </TableCell>
             ) : null}
@@ -419,8 +450,15 @@ const JobSelectedTableRow = ({
             <TableCell>
                 <StatusChip status={status} />
             </TableCell>
-            {showCancel || showDelete ? (
+            {showCancel || showDelete || handleShowDetails ? (
                 <TableCell>
+                    {handleShowDetails ? (
+                        <Tooltip title="Show details">
+                            <IconButton data-id={id} onClick={handleShowDetails}>
+                                <DescriptionOutlinedIcon />
+                            </IconButton>
+                        </Tooltip>
+                    ) : null}
                     {status === Status.RUNNING ? null : status === Status.BLOCKED ||
                       status === Status.PENDING ||
                       status === Status.SUBMITTED ? (
@@ -455,13 +493,15 @@ const JobSelector = (props: JobSelectorProps) => {
         showCancel = true,
         showDelete = true,
         propagate = true,
-        updateJbVars = ""
+        updateJbVars = "",
     } = props;
     const [checked, setChecked] = useState<string[]>([]);
     const [selected, setSelected] = useState<string[]>([]);
     const [jobRows, setJobRows] = useState<Jobs>([]);
     const [filters, setFilters] = useState<FilterData[]>();
-    const [anchorEl, setAnchorEl] = React.useState<HTMLButtonElement | null>(null);
+    const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
+    const [showDetails, setShowDetails] = useState(false);
+    const detailId = useRef<string>();
 
     const dispatch = useDispatch();
     const module = useModule();
@@ -586,7 +626,7 @@ const JobSelector = (props: JobSelectorProps) => {
                     createSendActionNameAction(props.id, module, props.onJobAction, {
                         id: multiple === false ? [id] : JSON.parse(id),
                         action: "cancel",
-                        error_id: getUpdateVar(updateJbVars, "error_id")
+                        error_id: getUpdateVar(updateJbVars, "error_id"),
                     })
                 );
             } catch (e) {
@@ -605,7 +645,7 @@ const JobSelector = (props: JobSelectorProps) => {
                     createSendActionNameAction(props.id, module, props.onJobAction, {
                         id: multiple === false ? [id] : JSON.parse(id),
                         action: "delete",
-                        error_id: getUpdateVar(updateJbVars, "error_id")
+                        error_id: getUpdateVar(updateJbVars, "error_id"),
                     })
                 );
             } catch (e) {
@@ -615,6 +655,39 @@ const JobSelector = (props: JobSelectorProps) => {
         [dispatch, module, props.id, props.onJobAction, updateJbVars]
     );
 
+    const deleteJob = useCallback(
+        (event: React.MouseEvent<HTMLElement>) => {
+            handleDeleteJobs(event);
+            setShowDetails(false);
+        },
+        [handleDeleteJobs]
+    );
+
+    const handleShowDetails = useCallback(
+        (event: React.MouseEvent<HTMLElement>) => {
+            event.stopPropagation();
+            const { id = "" } = event.currentTarget?.dataset || {};
+            if (props.onDetails) {
+                dispatch(createSendActionNameAction(props.id, module, props.onDetails, id));
+            } else {
+                const idVar = getUpdateVar(updateJbVars, "detail_id");
+                detailId.current = id;
+                dispatch(
+                    createRequestUpdateAction(
+                        id,
+                        module,
+                        getUpdateVarNames(props.updateVars, "details"),
+                        true,
+                        idVar ? { [idVar]: id } : undefined
+                    )
+                );
+            }
+        },
+        [dispatch, module, props.id, props.onDetails, props.updateVars, updateJbVars]
+    );
+
+    const closeDetails = useCallback(() => setShowDetails(false), []);
+
     const allowCancelJobs = useMemo(
         () =>
             !!checked.length &&
@@ -653,6 +726,13 @@ const JobSelector = (props: JobSelectorProps) => {
         setAnchorEl(null);
     }, []);
 
+    useEffect(() => {
+        if (props.details && props.details[0] == detailId.current) {
+            // show Dialog
+            setShowDetails(true);
+        }
+    }, [props.details]);
+
     useEffect(() => {
         let filteredJobRows = [...(props.jobs || [])];
         filteredJobRows.length &&
@@ -708,6 +788,25 @@ const JobSelector = (props: JobSelectorProps) => {
 
     return (
         <Box className={className}>
+            {showDetails && props.details ? (
+                <Dialog open={true} onClose={closeDetails} scroll="paper" fullWidth>
+                    <DialogTitle>{props.details[1]}</DialogTitle>
+                    <IconButton aria-label="close" onClick={closeDetails} sx={CloseDialogSx}>
+                        <CloseIcon />
+                    </IconButton>
+                    <DialogContent dividers>
+                        <JobViewer job={props.details} inDialog={true}></JobViewer>
+                    </DialogContent>
+                    <DialogActions>
+                        <Button variant="outlined" color="primary" onClick={deleteJob} data-id={props.details[0]}>
+                            Delete
+                        </Button>
+                        <Button variant="outlined" color="secondary" onClick={closeDetails} sx={RightButtonSx}>
+                            Close
+                        </Button>
+                    </DialogActions>
+                </Dialog>
+            ) : null}
             <Paper sx={containerSx}>
                 <Toolbar sx={headerToolbarSx}>
                     <Grid container spacing={2} alignItems="center">
@@ -792,6 +891,7 @@ const JobSelector = (props: JobSelectorProps) => {
                                     key={row[JobProps.id]}
                                     handleDeleteJobs={handleDeleteJobs}
                                     handleCancelJobs={handleCancelJobs}
+                                    handleShowDetails={props.onDetails === false ? false : handleShowDetails}
                                     showSubmissionId={showSubmissionId}
                                     showId={showId}
                                     showSubmittedLabel={showSubmittedLabel}
diff --git a/frontend/taipy/src/JobViewer.tsx b/frontend/taipy/src/JobViewer.tsx
new file mode 100644
index 0000000000..ea2ac79046
--- /dev/null
+++ b/frontend/taipy/src/JobViewer.tsx
@@ -0,0 +1,186 @@
+/*
+ * 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/Grid";
+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 } from "./utils";
+import StatusChip from "./StatusChip";
+
+interface JobViewerProps {
+    updateVarName?: string;
+    coreChanged?: Record<string, unknown>;
+    error?: string;
+    job: JobDetail;
+    onDelete?: string;
+    id?: string;
+    libClassName?: string;
+    className?: string;
+    dynamicClassName?: string;
+    updateJbVars?: string;
+    inDialog?: boolean;
+    width?: string;
+}
+
+// job id, job name, entity id, entity name, submit id, creation date, status, not deletable, execution time, logs
+export type JobDetail = [string, string, string, string, string, string, number, string, string, string[]];
+const invalidJob: JobDetail = ["", "", "", "", "", "", 0, "", "", []];
+
+const JobViewer = (props: JobViewerProps) => {
+    const { updateVarName = "", id = "", updateJbVars = "", inDialog = false, width = "50vw" } = props;
+
+    const [
+        jobId,
+        jobName,
+        entityId,
+        entityName,
+        submissionId,
+        creationDate,
+        status,
+        notDeleteable,
+        executionTime,
+        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<HTMLElement>) => {
+            event.stopPropagation();
+            try {
+                dispatch(
+                    createSendActionNameAction(props.id, module, props.onDelete, {
+                        id: jobId,
+                        action: "delete",
+                        error_id: getUpdateVar(updateJbVars, "error_id"),
+                    })
+                );
+            } catch (e) {
+                console.warn("Error parsing ids for delete.", e);
+            }
+        },
+        [jobId, dispatch, module, props.id, props.onDelete, updateJbVars]
+    );
+
+    useEffect(() => {
+        if (props.coreChanged?.job == jobId) {
+            updateVarName && dispatch(createRequestUpdateAction(id, module, [updateVarName], true));
+        }
+    }, [props.coreChanged, updateVarName, jobId, module, dispatch, id]);
+
+    return (
+        <Grid container className={className} sx={{ maxWidth: width }}>
+            {inDialog ? null : (
+                <>
+                    <Grid item xs={4}>
+                        <Typography>Job Name</Typography>
+                    </Grid>
+                    <Grid item xs={8}>
+                        <Typography>{jobName}</Typography>
+                    </Grid>
+                    <Divider />
+                </>
+            )}
+            <Grid item xs={4}>
+                <Typography>Job Id</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <Tooltip title={jobId}>
+                    <Typography sx={EllipsisSx}>{jobId}</Typography>
+                </Tooltip>
+            </Grid>
+            <Grid item xs={4}>
+                <Typography>Submission Id</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <Tooltip title={submissionId}>
+                    <Typography sx={EllipsisSx}>{submissionId}</Typography>
+                </Tooltip>
+            </Grid>
+            <Grid item xs={4}>
+                <Typography>Submitted entity</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <Tooltip title={entityId}>
+                    <ListItemText
+                        primary={entityName}
+                        secondary={entityId}
+                        secondaryTypographyProps={SecondaryEllipsisProps}
+                    />
+                </Tooltip>
+            </Grid>
+            <Grid item xs={4}>
+                <Typography>Execution time</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <Typography>{executionTime}</Typography>
+            </Grid>
+            <Grid item xs={4}>
+                <Typography>Status</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <StatusChip status={status} />
+            </Grid>
+            <Grid item xs={4}>
+                <Typography>Creation date</Typography>
+            </Grid>
+            <Grid item xs={8}>
+                <Typography>{creationDate ? new Date(creationDate).toLocaleString() : ""}</Typography>
+            </Grid>
+            <Divider />
+            <Grid item xs={12}>
+                <Typography>Stack Trace</Typography>
+            </Grid>
+            <Grid item xs={12}>
+                <Typography variant="caption" component="pre" overflow="auto" maxHeight="50vh">
+                    {stacktrace.join("<br/>")}
+                </Typography>
+            </Grid>
+            {props.onDelete ? (
+                <>
+                    <Divider />
+                    <Grid item xs={6}>
+                        <Tooltip title={notDeleteable}>
+                            <span>
+                                <Button variant="outlined" onClick={handleDeleteJob} disabled={!!notDeleteable}>
+                                    Delete
+                                </Button>
+                            </span>
+                        </Tooltip>
+                    </Grid>
+                </>
+            ) : null}
+        </Grid>
+    );
+};
+
+export default JobViewer;
diff --git a/frontend/taipy/src/utils.ts b/frontend/taipy/src/utils.ts
index 2fe2860acf..30c576b0cd 100644
--- a/frontend/taipy/src/utils.ts
+++ b/frontend/taipy/src/utils.ts
@@ -16,22 +16,22 @@ import { PopoverOrigin } from "@mui/material/Popover";
 import { getUpdateVar, useDynamicProperty } from "taipy-gui";
 
 export type ScenarioFull = [
-    string,     // id
-    boolean,    // is_primary
-    string,     // config_id
-    string,     // creation_date
-    string,     // cycle label
-    string,     // label
-    string[],   // tags
-    Array<[string, string]>,    // properties
-    Array<[string, string[], string, string]>,   // sequences (label, task ids, notSubmittableReason, notEditableReason)
+    string, // id
+    boolean, // is_primary
+    string, // config_id
+    string, // creation_date
+    string, // cycle label
+    string, // label
+    string[], // tags
+    Array<[string, string]>, // properties
+    Array<[string, string[], string, string]>, // sequences (label, task ids, notSubmittableReason, notEditableReason)
     Record<string, string>, // tasks (id: label)
-    string[],   // authorized_tags
-    string,    // notDeletableReason
-    string,    // notPromotableReason
-    string,     // notSubmittableReason
-    string,     // notReadableReason
-    string      // notEditableReason
+    string[], // authorized_tags
+    string, // notDeletableReason
+    string, // notPromotableReason
+    string, // notSubmittableReason
+    string, // notReadableReason
+    string // notEditableReason
 ];
 
 export enum ScFProps {
@@ -218,4 +218,8 @@ export const DeleteIconSx = { height: 50, width: 50, p: 0 };
 
 export const EmptyArray = [];
 
-export const getUpdateVarNames = (updateVars: string, ...vars: string[]) => vars.map((v) => getUpdateVar(updateVars, v) || "").filter(v => v);
+export const getUpdateVarNames = (updateVars: string, ...vars: string[]) =>
+    vars.map((v) => getUpdateVar(updateVars, v) || "").filter((v) => v);
+
+export const EllipsisSx = { textOverflow: "ellipsis", overflow: "hidden", whiteSpace: "nowrap" };
+export const SecondaryEllipsisProps = { sx: EllipsisSx };
diff --git a/taipy/gui/_renderers/builder.py b/taipy/gui/_renderers/builder.py
index 179523d86c..b1221fae68 100644
--- a/taipy/gui/_renderers/builder.py
+++ b/taipy/gui/_renderers/builder.py
@@ -357,7 +357,7 @@ def __set_function_attribute(
             if strattr is None:
                 return self
         elif _is_boolean(strattr) and not _is_true(strattr):
-            return self
+            return self.__set_react_attribute(_to_camel_case(name), False)
         elif strattr:
             strattr = str(strattr)
             func = self.__gui._get_user_function(strattr)
diff --git a/taipy/gui_core/_GuiCoreLib.py b/taipy/gui_core/_GuiCoreLib.py
index 501cbbdcc9..57f4c33447 100644
--- a/taipy/gui_core/_GuiCoreLib.py
+++ b/taipy/gui_core/_GuiCoreLib.py
@@ -50,6 +50,7 @@ class _GuiCore(ElementLibrary):
     __SCENARIO_SELECTOR_SORT_VAR = "__tpgc_sc_sort"
     __SCENARIO_VIZ_ERROR_VAR = "__tpgc_sv_error"
     __JOB_SELECTOR_ERROR_VAR = "__tpgc_js_error"
+    __JOB_DETAIL_ID_VAR = "__tpgc_jd_id"
     __DATANODE_VIZ_ERROR_VAR = "__tpgc_dv_error"
     __DATANODE_VIZ_OWNER_ID_VAR = "__tpgc_dv_owner_id"
     __DATANODE_VIZ_HISTORY_ID_VAR = "__tpgc_dv_history_id"
@@ -283,6 +284,7 @@ class _GuiCore(ElementLibrary):
                 "show_cancel": ElementProperty(PropertyType.boolean, True),
                 "show_delete": ElementProperty(PropertyType.boolean, True),
                 "on_change": ElementProperty(PropertyType.function),
+                "on_details": ElementProperty(PropertyType.function),
                 "height": ElementProperty(PropertyType.string, "50vh"),
             },
             inner_properties={
@@ -291,8 +293,14 @@ class _GuiCore(ElementLibrary):
                 "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}<tp:uniq:jb>}}"),
+                "details": ElementProperty(
+                    PropertyType.react,
+                    f"{{{__CTX_VAR_NAME}.get_job_details(" + f"{__JOB_DETAIL_ID_VAR}<tp:uniq:jb>)}}",
+                ),
                 "update_jb_vars": ElementProperty(
-                    PropertyType.string, f"error_id={__JOB_SELECTOR_ERROR_VAR}<tp:uniq:jb>"
+                    PropertyType.string,
+                    f"error_id={__JOB_SELECTOR_ERROR_VAR}<tp:uniq:jb>;"
+                    + f"detail_id={__JOB_DETAIL_ID_VAR}<tp:uniq:jb>;",
                 ),
             },
         ),
diff --git a/taipy/gui_core/_context.py b/taipy/gui_core/_context.py
index 42082e2aca..44f67afcdc 100644
--- a/taipy/gui_core/_context.py
+++ b/taipy/gui_core/_context.py
@@ -9,6 +9,7 @@
 # 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 datetime
 import json
 import typing as t
 from collections import defaultdict
@@ -29,6 +30,7 @@
     DataNode,
     DataNodeId,
     Job,
+    JobId,
     Scenario,
     ScenarioId,
     Sequence,
@@ -806,8 +808,8 @@ def job_adapter(self, job):
                         job.id,
                         job.get_simple_label(),
                         [],
-                        entity.get_simple_label() if entity else "",
                         entity.id if entity else "",
+                        entity.get_simple_label() if entity else "",
                         job.submit_id,
                         job.creation_date,
                         job.status.value,
@@ -855,6 +857,29 @@ def act_on_jobs(self, state: State, id: str, payload: t.Dict[str, str]):
                         errs.append(f"Error canceling job. {e}")
             _GuiCoreContext.__assign_var(state, payload.get("error_id"), "<br/>".join(errs) if errs else "")
 
+    def get_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.get_simple_label(),
+                        entity.id if entity else "",
+                        entity.get_simple_label() if entity else "",
+                        job.submit_id,
+                        job.creation_date,
+                        job.status.value,
+                        _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 if hasattr(job, 'id') else 'No_id'}) failed", e)
+        return None
+
     def edit_data_node(self, state: State, id: str, payload: t.Dict[str, str]):
         self.__lazy_start()
         args = payload.get("args")
diff --git a/taipy/gui_core/viselements.json b/taipy/gui_core/viselements.json
index 9e3cc9f953..e70394a4bf 100644
--- a/taipy/gui_core/viselements.json
+++ b/taipy/gui_core/viselements.json
@@ -65,7 +65,7 @@
                     {
                         "name": "on_creation",
                         "type": "Callback",
-                        "doc": "The name of the function that is triggered when a scenario is about to be created.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the scenario selector.</li>\n<li>payload (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>config: the name of the selected scenario configuration.</li>\n<li>date: the creation date for the new scenario.</li>\n<li>label: the user-specified label.</li>\n<li>properties: a dictionary containing all the user-defined custom properties.</li>\n</ul>\n</li>\n<li>The callback function can return a scenario, a string containing an error message (a scenario will not be created), or None (then a new scenario is created with the user parameters).</li>\n</ul>",
+                        "doc": "The name of the function that is triggered when a scenario is about to be created.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the identifier of the scenario selector.</li>\n<li>payload (dict): the details on this Callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>config: the name of the selected scenario configuration.</li>\n<li>date: the creation date for the new scenario.</li>\n<li>label: the user-specified label.</li>\n<li>properties: a dictionary containing all the user-defined custom properties.</li>\n</ul>\n</li>\n<li>The Callback function can return a scenario, a string containing an error message (a scenario will not be created), or None (then a new scenario is created with the user parameters).</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -207,7 +207,7 @@
                     {
                         "name": "on_submission_change",
                         "type": "Callback",
-                        "doc": "The name of the function that is triggered when a submission status is changed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>submission (Submission): the submission entity containing submission information.</li>\n<li>details (dict): the details on this callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>submission_status (str): the new status of the submission (possible values: SUBMITTED, COMPLETED, CANCELED, FAILED, BLOCKED, WAITING, RUNNING).</li>\n<li>job: the Job (if any) that is at the origin of the submission status change.</li>\n<li>submittable_entity: submittable (Submittable): the entity (usually a Scenario) that was submitted.</li>\n</ul>",
+                        "doc": "The name of the function that is triggered when a submission status is changed.<br/><br/>All the parameters of that function are optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>submission (Submission): the submission entity containing submission information.</li>\n<li>details (dict): the details on this Callback's invocation.<br/>\nThis dictionary has the following keys:\n<ul>\n<li>submission_status (str): the new status of the submission (possible values: SUBMITTED, COMPLETED, CANCELED, FAILED, BLOCKED, WAITING, RUNNING).</li>\n<li>job: the Job (if any) that is at the origin of the submission status change.</li>\n<li>submittable_entity: submittable (Submittable): the entity (usually a Scenario) that was submitted.</li>\n</ul>",
                         "signature": [
                             [
                                 "state",
@@ -308,7 +308,7 @@
                     },
                     {
                         "name": "on_change",
-                        "type": "callback",
+                        "type": "Callback",
                         "doc": "The name of a function that is triggered when a data node is selected.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>var_name (str): the variable name.</li>\n<li>value (<code>DataNode^</code>): the selected data node.</li>\n</ul>",
                         "signature": [
                             [
@@ -517,7 +517,7 @@
                     },
                     {
                         "name": "on_change",
-                        "type": "callback",
+                        "type": "Callback",
                         "doc": "The name of a function that is triggered when the selection is updated.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>var_name (str): the variable name.</li>\n<li>value (<code>Job^</code>): the selected job.</li>\n</ul>",
                         "signature": [
                             [
@@ -539,6 +539,26 @@
                         "type": "str",
                         "default_value": "\"50vh\"",
                         "doc": "The maximum height, in CSS units, of the control."
+                    },
+                    {
+                        "name": "on_details",
+                        "type": "Union[Callback, bool]",
+                        "doc": "The name of a function that is triggered when the details icon is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the component id.</li>\n<li>payload (<code>dict^</code>): a dict that contains a value for args with the Job Id.</li>\n</ul></br>If False, the icon is not shown.",
+                        "signature": [
+                            [
+                                "state",
+                                "State"
+                            ],
+                            [
+                                "id",
+                                "str"
+                            ],
+                            [
+                                "payload",
+                                "dict"
+                            ]
+                        ]
+
                     }
                 ]
             }


Code review comments:
diff_hunk: @@ -539,6 +539,26 @@
                         "type": "str",
                         "default_value": "\"50vh\"",
                         "doc": "The maximum height, in CSS units, of the control."
+                    },
+                    {
+                        "name": "on_details",
+                        "type": "Union[Callback, bool]",
+                        "doc": "The name of a function that is triggered when the details icon is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the component id.</li>\n<li>payload (<code>dict^</code>): a dict that contains a value for args with the Job Id.</li>\n</ul></br>If False, the icon is not shown.",
path: taipy/gui_core/viselements.json
text: @user1:
```suggestion
                        "doc": "The name of a function that is triggered when the details icon is pressed.<br/>The parameters of that function are all optional:\n<ul>\n<li>state (<code>State^</code>): the state instance.</li>\n<li>id (str): the id of the control.</li>\n<li>payload (<code>dict^</code>): a dictionary that contains the Job Id in the value for key <i>args<i>.</li>\n</ul></br>If False, the icon is not shown.",
```