瀏覽代碼

job details (#1717)

* job details
resolves #1251

* doc

* Fab's comment

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* Fab's comment

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

* Fab's comment

Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>

---------

Co-authored-by: Fred Lefévère-Laoide <Fred.Lefevere-Laoide@Taipy.io>
Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com>
Fred Lefévère-Laoide 9 月之前
父節點
當前提交
9e31ffd35a

+ 114 - 14
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}

+ 186 - 0
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;

+ 20 - 16
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 };

+ 1 - 1
taipy/gui/_renderers/builder.py

@@ -357,7 +357,7 @@ class _Builder:
             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)

+ 9 - 1
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>;",
                 ),
             },
         ),

+ 26 - 1
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 @@ from taipy.core import (
     DataNode,
     DataNodeId,
     Job,
+    JobId,
     Scenario,
     ScenarioId,
     Sequence,
@@ -806,8 +808,8 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         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 @@ class _GuiCoreContext(CoreEventConsumerBase):
                         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")

+ 24 - 4
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 this 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 (str): the name of the selected scenario configuration.</li>\n<li>date (datetime): the creation date for the new scenario.</li>\n<li>label (str): the user-specified label.</li>\n<li>properties (dic): 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 are: "SUBMITTED", "COMPLETED", "CANCELED", "FAILED", "BLOCKED", "WAITING", or "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): 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 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.",
+                        "signature": [
+                            [
+                                "state",
+                                "State"
+                            ],
+                            [
+                                "id",
+                                "str"
+                            ],
+                            [
+                                "payload",
+                                "dict"
+                            ]
+                        ]
+
                     }
                 ]
             }