Job Menu available on pipeline display

This commit is contained in:
Dunemask 2022-10-22 08:35:12 -04:00
parent bb934ee859
commit fd4dba7140
13 changed files with 460 additions and 70 deletions

View file

@ -4,14 +4,14 @@ import JobContext from "@qltr/jobctx";
import Initiator from "@qltr/initiator";
import { useOneshotCore } from "./OneshotCore.jsx";
import { usePipelineCore } from "./PipelineCore.jsx";
import { useJobExtra } from "./JobExtra.jsx";
import { useJobExtras } from "./JobExtras.jsx";
import { jobStatus, socketUrl } from "./job-config.js";
export function useJobCore() {
const { state, jobUpdate, jobCreate, jobDelete } = useContext(JobContext);
const { pipelineStart, pipelineCancel, pipelineDestroy } = usePipelineCore();
const { oneshotStart, oneshotCancel, oneshotDestroy } = useOneshotCore();
const jobExtra = useJobExtra();
const jobExtras = useJobExtras();
function retryAll(failing) {
console.log("Would retry all failing tests!");
@ -37,7 +37,7 @@ export function useJobCore() {
pipelineDestroy,
pipelineStart,
// Job Extra
...jobExtra,
...jobExtras,
};
}

View file

@ -33,7 +33,7 @@ function statusIcon(status) {
}
}
export function useJobExtra() {
export function useJobExtras() {
const { state, jobUpdate, jobCreate, jobDelete } = useContext(JobContext);
const navigate = useNavigate();

View file

@ -1,4 +1,5 @@
import { useContext } from "react";
import moment from "moment";
import { v4 as uuidv4 } from "uuid";
import JobContext from "@qltr/jobctx";
import Initiator from "@qltr/initiator";
@ -35,30 +36,7 @@ export function usePipelineCore() {
jobUpdate({ ...job }, jobId);
};
const onPipelineTrigger = (p) => {
const { triggers } = p;
for (const t in triggers) {
if (t === "__testDelay") continue;
const delay = triggers[t].__testDelay ?? 0;
delete triggers[t].__testDelay;
const plTrigger = { ...p, triggers: triggers[t], __test: t };
const jobReq = { ...plReq, pipeline: plTrigger };
const triggerAt = Date.now() + delay;
const testName = t;
function removeTrigger() {
const i = pl.pendingTriggers.findIndex((pt) => pt.testName === t);
if (i < 0) return;
pl.pendingTriggers.splice(i, 1);
}
function launchTrigger() {
pipelineJob(pl, jobReq);
removeTrigger();
}
const doTrigger = { removeTrigger, launchTrigger };
const timer = setTimeout(launchTrigger, delay);
pl.pendingTriggers.push({ testName, timer, triggerAt, ...doTrigger });
}
};
const onPipelineTrigger = applyOnPipelineTrigger(pl, plReq);
const started = initiator.newPipelineJob(
plReq,
onLog,
@ -117,5 +95,34 @@ export function usePipelineCore() {
state.pipelines.splice(pipelineIndex, 1);
}
function applyOnPipelineTrigger(pl, plReq) {
return function onPipelineTrigger(p) {
const { triggers } = p;
for (const t in triggers) {
if (t === "__testDelay") continue;
const delay = triggers[t].__testDelay ?? 0;
delete triggers[t].__testDelay;
const plTrigger = { ...p, triggers: triggers[t], __test: t };
const jobReq = { ...plReq, pipeline: plTrigger };
const triggerQueued = moment();
const triggerAt = moment().add(delay, "ms");
const testName = t;
function removeTrigger() {
const i = pl.pendingTriggers.findIndex((pt) => pt.testName === t);
if (i < 0) return;
pl.pendingTriggers.splice(i, 1);
}
function launchTrigger() {
pipelineJob(pl, jobReq);
removeTrigger();
}
const timer = setTimeout(launchTrigger, delay);
const triggerTimings = { timer, triggerQueued, triggerAt };
const doTrigger = { removeTrigger, launchTrigger, jobReq };
pl.pendingTriggers.push({ testName, ...triggerTimings, ...doTrigger });
}
};
}
return { pipelineStart, pipelineCancel, pipelineDestroy };
}

View file

@ -17,23 +17,23 @@ export default function CatalogItemDetails(props) {
return (
<Typography component={"div"} style={{ wordBreak: "break-word" }}>
<div sx={{ display: "inline-flex" }}>
<div>
<span style={{ fontWeight: "bold" }}>Image: </span>
{image}
</div>
<div sx={{ display: "inline-flex" }}>
<div>
<span style={{ fontWeight: "bold" }}>Env: </span>
{env.join(", ")}
</div>
<div sx={{ display: "inline-flex" }}>
<div>
<span style={{ fontWeight: "bold" }}>Regions: </span>
{regions.join(", ")}
{(regions ?? []).join(", ")}
</div>
<div sx={{ display: "inline-flex" }}>
<div>
<span style={{ fontWeight: "bold" }}>Crons: </span>
{JSON.stringify(crons)}
{(crons ?? []).join(", ")}
</div>
<div sx={{ display: "inline-flex" }}>
<div>
<span style={{ fontWeight: "bold" }}>Projects: </span>
{projects.join(", ")}
</div>

View file

@ -0,0 +1,128 @@
import React, { useContext, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useJobCore, jobStatus } from "@qltr/jobcore";
import StoreContext from "@qltr/store";
import Box from "@mui/material/Box";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
import IconButton from "@mui/material/IconButton";
import Typography from "@mui/material/Typography";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import JobLogView from "./JobLogView.jsx";
import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DownloadIcon from "@mui/icons-material/Download";
import ReplayIcon from "@mui/icons-material/Replay";
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import DoNotDisturbIcon from "@mui/icons-material/DoNotDisturb";
import DeleteIcon from "@mui/icons-material/Delete";
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
export function useJobActionMenu(baseAnchor = null) {
const [menuJob, setMenuJob] = useState({});
const [anchorEl, setAnchorEl] = useState(baseAnchor);
const menuOpen = (event) => setAnchorEl(event.currentTarget);
const menuClose = () => {
setAnchorEl(null);
setMenuJob(null);
};
return [anchorEl, menuJob, setMenuJob, menuOpen, menuClose];
}
export default function JobActionMenu(props) {
const { job, anchorEl, menuClose } = props;
const { jobCompose, oneshotCancel, oneshotDestroy, toPipeline, toJob } =
useJobCore();
const { state: store } = useContext(StoreContext);
const open = Boolean(anchorEl);
function download(filename, text) {
var element = document.createElement("a");
element.setAttribute(
"href",
"data:text/plain;charset=utf-8," + encodeURIComponent(text)
);
element.setAttribute("download", filename);
element.style.display = "none";
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
function retryJob() {
const jobId = jobCompose(job.builderCache);
if (store.focusJob) toJob(jobId);
}
function downloadLog() {
if (job.status === jobStatus.PENDING) return;
download(`${job.jobId}.txt`, job.log.join("\n"));
}
function cancelJob() {
oneshotCancel(job.jobId);
}
function deleteJob() {
oneshotDestroy(job.jobId);
navigateToJobs();
}
const menuSelect = (cb) => () => {
menuClose();
cb();
};
function navigateToJobs() {
if (job.isPipeline) return toPipeline(job.pipelineId);
toJobs();
}
if (!job) return;
return (
<Menu anchorEl={anchorEl} open={open} onClose={menuClose}>
<MenuItem onClick={menuSelect(downloadLog)}>
<ListItemIcon>
<DownloadIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Download Log</ListItemText>
</MenuItem>
{!job.isPipeline && (
<MenuItem onClick={menuSelect(retryJob)}>
<ListItemIcon>
{job.status === jobStatus.OK || job.status === jobStatus.ERROR ? (
<ReplayIcon fontSize="small" />
) : (
<PlayArrowIcon fontSize="small" />
)}
</ListItemIcon>
<ListItemText>
{job.status === jobStatus.ERROR ? "Retry" : "Duplicate"}
</ListItemText>
</MenuItem>
)}
{job.status === jobStatus.OK ||
job.status === jobStatus.ERROR ||
job.status === jobStatus.CANCELED ? null : (
<MenuItem onClick={menuSelect(cancelJob)}>
<ListItemIcon>
<DoNotDisturbIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Cancel</ListItemText>
</MenuItem>
)}
{!job.isPipeline && (
<MenuItem onClick={menuSelect(deleteJob)}>
<ListItemIcon>
<DeleteIcon fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
)}
</Menu>
);
}

View file

@ -6,8 +6,8 @@ import AccordionSummary from "@mui/material/AccordionSummary";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import Stack from "@mui/material/Stack";
import JobActionMenu from "./JobActionMenu";
export default function JobBox(props) {
const { jobIcon } = useJobCore();
@ -16,6 +16,7 @@ export default function JobBox(props) {
return (
<Accordion expanded={false} disableGutters={true} square>
<JobActionMenu job={job} />
<AccordionSummary
style={{
backgroundColor: "rgba(0, 0, 0, .03)",

View file

@ -105,7 +105,7 @@ export default function JobView(props) {
{job.jobId}
</Typography>
{job.isPipeline && (
<IconButton>
<IconButton onClick={navigateToJobs}>
<ViewColumnIcon />
</IconButton>
)}

View file

@ -3,9 +3,9 @@ import { useLocation, useNavigate } from "react-router-dom";
import JobContext from "@qltr/jobctx";
import JobBox from "./JobBox.jsx";
import JobPipelineBox from "./JobPipelineBox.jsx";
import JobPipelineBox from "./pipeline/JobPipelineBox.jsx";
import JobView from "./JobView.jsx";
import JobPipelineDisplay from "./JobPipelineDisplay.jsx";
import JobPipelineDisplay from "./pipeline/JobPipelineDisplay.jsx";
import JobBuilder from "./builder/JobBuilder.jsx";
import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";

View file

@ -1,21 +1,10 @@
import React, { useState, useContext } from "react";
import StoreContext from "@qltr/store";
import { useJobCore, jobStatus } from "@qltr/jobcore";
import React from "react";
import { useJobCore } from "@qltr/jobcore";
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
import Typography from "@mui/material/Typography";
import IconButton from "@mui/material/IconButton";
import CheckIcon from "@mui/icons-material/Check";
import ClearIcon from "@mui/icons-material/Clear";
import ViewColumnIcon from "@mui/icons-material/ViewColumn";
import PendingIcon from "@mui/icons-material/Pending";
import VisibilityIcon from "@mui/icons-material/Visibility";
import DoNotDisturbIcon from "@mui/icons-material/DoNotDisturb";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
export default function JobPipelineBox(props) {

View file

@ -2,7 +2,11 @@ import React, { useContext, useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import moment from "moment";
import { useJobCore, jobStatus } from "@qltr/jobcore";
import PipelineTriggerDialog, {
usePipelineTriggerDialog,
} from "./PipelineTriggerDialog";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import AppBar from "@mui/material/AppBar";
import Toolbar from "@mui/material/Toolbar";
@ -22,12 +26,13 @@ import ListItemText from "@mui/material/ListItemText";
import ListItemIcon from "@mui/material/ListItemIcon";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import DeleteIcon from "@mui/icons-material/Delete";
import JobActionMenu, { useJobActionMenu } from "../JobActionMenu";
const stopPropagation = (e) => e.stopPropagation() && e.preventDefault();
function JobPipelineDisplay(props) {
const { pipeline } = props;
const {
state: jobState,
pipelineCancel,
pipelineDestroy,
selectedPipelineBranches,
@ -37,8 +42,15 @@ function JobPipelineDisplay(props) {
jobIcon,
} = useJobCore();
const nav = useNavigate();
const [tdOpen, tdToggle, trigger, setTrigger, tdClose] =
usePipelineTriggerDialog();
const [jobAnchor, menuJob, setMenuJob, menuOpen, menuClose] =
useJobActionMenu();
const theme = useTheme();
const minifyActions = useMediaQuery(theme.breakpoints.down("sm"));
const nav = useNavigate();
const [time, setTime] = useState(Date.now());
const [anchorEl, setAnchorEl] = useState(null);
const open = Boolean(anchorEl);
@ -46,9 +58,6 @@ function JobPipelineDisplay(props) {
const handleClose = () => setAnchorEl(null);
const branches = selectedPipelineBranches(pipeline);
const pipelineTriggers = branches.map(({ name }) =>
pipeline.pendingTriggers.find(({ testName }) => testName === name)
);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 1000);
@ -57,7 +66,7 @@ function JobPipelineDisplay(props) {
const selectJob = (testName) => () => {
const pt = pipeline.pendingTriggers.find(
({ testName }) => testName === name
(ppt) => ppt.testName === testName
);
if (pt) return selectTimer(pt);
const job = findPipelineJobByTestName(pipeline, testName);
@ -65,17 +74,24 @@ function JobPipelineDisplay(props) {
toJob(job.jobId);
};
const selectTimer = (pt) => {
console.log("Selected timer:", pt);
const selectJobMenu = (testName) => (e) => {
const pt = pipeline.pendingTriggers.find(
(ppt) => ppt.testName === testName
);
if (pt) return selectTimer(pt);
const job = findPipelineJobByTestName(pipeline, testName);
if (!job) return;
menuOpen(e);
setMenuJob(job);
};
function cancelPipeline() {
pipelineCancel(pipeline.id);
}
const selectTimer = (pt) => {
setTrigger(pt);
tdToggle();
};
function deletePipeline() {
pipelineDestroy(pipeline.id);
}
const cancelPipeline = () => pipelineCancel(pipeline.id);
const deletePipeline = () => pipelineDestroy(pipeline.id);
const menuSelect = (cb) => () => {
handleClose();
@ -102,11 +118,26 @@ function JobPipelineDisplay(props) {
({ testName }) => testName === name
);
if (!pt) return;
return moment(moment(pt.triggerAt).diff(moment())).format("mm:ss");
const now = moment();
const diff = moment(pt.triggerAt.diff(now)).valueOf();
const timeString = new Date(diff).toISOString().substring(11, 19);
return timeString; // .split(":").filter((s) => s != "00").join(":");
}
return (
<Box>
<PipelineTriggerDialog
keepMounted
open={tdOpen}
onClose={tdClose}
trigger={trigger}
/>
<JobActionMenu
keepMounted
anchorEl={jobAnchor}
menuClose={menuClose}
job={menuJob}
/>
<AppBar
position="fixed"
sx={{
@ -173,10 +204,29 @@ function JobPipelineDisplay(props) {
>
{timerDisplay(test.name)}
</Typography>
<Stack sx={{ ml: "auto" }}>
<IconButton aria-label="retry" component="span">
<Stack
onClick={stopPropagation}
direction={minifyActions ? "column" : "row"}
sx={{
ml: "auto",
mb: "auto",
mt: "auto",
whiteSpace: "nowrap",
}}
>
<IconButton
aria-label="retry"
component="span"
onClick={selectJob(test.name)}
>
{boxIcon(test.name)}
</IconButton>
<IconButton
component="span"
onClick={selectJobMenu(test.name)}
>
<MoreVertIcon />
</IconButton>
</Stack>
</div>
</AccordionSummary>

View file

@ -0,0 +1,91 @@
import { useState, useEffect } from "react";
import PipelineTriggerWidget from "./PipelineTriggerWidget.jsx";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import DialogTitle from "@mui/material/DialogTitle";
import DialogContent from "@mui/material/DialogContent";
import DialogActions from "@mui/material/DialogActions";
import Dialog from "@mui/material/Dialog";
import Typography from "@mui/material/Typography";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
export function usePipelineTriggerDialog(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const [trigger, setTrigger] = useState({});
const dialogToggle = () => setOpen(!open);
const dialogClose = (confirmedTrigger) => {
setOpen(false);
if (!confirmedTrigger) return;
setTrigger({});
};
return [open, dialogToggle, trigger, setTrigger, dialogClose];
}
export default function PipelineTriggerDialog(props) {
const { trigger, open, onClose } = props;
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
useEffect(() => {
if (!trigger.triggerAt) return;
const expiredInterval = setInterval(() => {
if (Date.now() < trigger.triggerAt.valueOf()) return;
onClose();
clearInterval(expiredInterval);
}, 1000);
return () => clearInterval(expiredInterval);
}, [trigger, open]);
return (
<Dialog
sx={
fullScreen
? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
}
maxWidth="xs"
open={open}
fullScreen={fullScreen}
>
<Toolbar sx={{ display: { sm: "none" } }} />
<DialogTitle>Pipeline Timeout</DialogTitle>
<DialogContent>
<Box
style={{
display: "flex",
justifyContent: "center",
flexWrap: "wrap",
}}
>
<Typography
margin="normal"
component={"div"}
style={{
margin: "auto",
width: "100%",
wordBreak: "break-word",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
}}
>
<div>
<span style={{ fontWeight: "bold" }}>Test: </span>
{trigger.testName}
</div>
</Typography>
<TextField
margin="normal"
label="Expires"
value={JSON.stringify(trigger.jobReq)}
/>
</Box>
<PipelineTriggerWidget trigger={trigger} close={onClose} />
</DialogContent>
<DialogActions>
<Button onClick={onClose}>Close</Button>
</DialogActions>
</Dialog>
);
}

View file

@ -0,0 +1,124 @@
import React, { useEffect, useState } from "react";
import moment from "moment";
import CircularProgress from "@mui/material/CircularProgress";
import Typography from "@mui/material/Typography";
import Button from "@mui/material/Button";
import Box from "@mui/material/Box";
const happyGreen = "#64dd17";
const happyGrey = "#e0e0e0";
export default function PipelineTriggerWidget(props) {
const { trigger, close } = props;
const [time, setTime] = useState(Date.now());
const [hovering, setHovering] = useState(false);
const hoverStop = () => setHovering(false);
const hoverStart = () => setHovering(true);
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 200);
return () => clearInterval(interval);
}, []);
function runNow() {
close();
if (!trigger.launchTrigger) return;
trigger.launchTrigger();
}
function triggerMomentRemaining() {
if (!trigger || !trigger.triggerAt) return null;
const now = moment();
const diff = moment(trigger.triggerAt.diff(now)).valueOf();
const timeString = new Date(diff).toISOString().substring(11, 19);
return timeString; // .split(":").filter((s) => s != "00").join(":");
}
const normalizeValue = () => {
const { triggerQueued: start, triggerAt: end } = trigger;
const total = end - start;
// TODO The below line causes issues because close is called during a render.
// This causes MASSIVE issues, and needs to be resolved
//if (((trigger.triggerAt - Date.now()) / total) * 100 > 100) return close();
return ((total - (trigger.triggerAt - Date.now())) / total) * 100;
};
return (
<Box sx={{ display: "flex" }}>
<Box
sx={{
position: "relative",
display: "inline-flex",
backgroundColor: hovering ? happyGreen : "white",
borderRadius: "50%",
padding: 0.5,
margin: "auto",
}}
onMouseEnter={hoverStart}
onMouseLeave={hoverStop}
>
<CircularProgress
size="8rem"
variant="determinate"
sx={{ color: happyGrey, position: "absolute" }}
thickness={2.5}
value={100}
/>
<CircularProgress
size="8rem"
variant="determinate"
sx={{ color: hovering ? "gray" : happyGreen }}
thickness={2.5}
value={normalizeValue()}
/>
<Box
sx={{
top: 0,
left: 0,
bottom: 0,
right: 0,
position: "absolute",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
}}
>
<Typography
variant="caption"
component="div"
color="black"
sx={{
// fontWeight: "bold",
cursor: !hovering ? "inherit" : "pointer",
}}
>
{!hovering && triggerMomentRemaining()}
{hovering && (
<React.Fragment>
<Button
component="div"
sx={{ color: "white", fontWeight: "bold" }}
onClick={runNow}
>
Run Now
</Button>
<Typography
variant="caption"
component="div"
color="white"
sx={{
display: "flex",
alignItems: "center",
justifyContent: "center",
fontWeight: "bold",
}}
>
{triggerMomentRemaining()}
</Typography>
</React.Fragment>
)}
</Typography>
</Box>
</Box>
</Box>
);
}