From fd4dba7140d0f42bc5e8d214fae092aace6355bd Mon Sep 17 00:00:00 2001 From: Dunemask Date: Sat, 22 Oct 2022 08:35:12 -0400 Subject: [PATCH] Job Menu available on pipeline display --- src/job-core/JobCore.jsx | 6 +- src/job-core/{JobExtra.jsx => JobExtras.jsx} | 2 +- src/job-core/PipelineCore.jsx | 55 ++++---- src/views/catalog/CatalogItemDetails.jsx | 14 +- src/views/jobs/JobActionMenu.jsx | 128 ++++++++++++++++++ src/views/jobs/JobBox.jsx | 3 +- src/views/jobs/JobView.jsx | 2 +- src/views/jobs/Jobs.jsx | 4 +- .../jobs/{ => pipeline}/JobPipelineBox.jsx | 15 +- .../{ => pipeline}/JobPipelineDisplay.jsx | 86 +++++++++--- .../{ => pipeline}/JobPiplinePendingView.jsx | 0 .../jobs/pipeline/PipelineTriggerDialog.jsx | 91 +++++++++++++ .../jobs/pipeline/PipelineTriggerWidget.jsx | 124 +++++++++++++++++ 13 files changed, 460 insertions(+), 70 deletions(-) rename src/job-core/{JobExtra.jsx => JobExtras.jsx} (98%) create mode 100644 src/views/jobs/JobActionMenu.jsx rename src/views/jobs/{ => pipeline}/JobPipelineBox.jsx (62%) rename src/views/jobs/{ => pipeline}/JobPipelineDisplay.jsx (70%) rename src/views/jobs/{ => pipeline}/JobPiplinePendingView.jsx (100%) create mode 100644 src/views/jobs/pipeline/PipelineTriggerDialog.jsx create mode 100644 src/views/jobs/pipeline/PipelineTriggerWidget.jsx diff --git a/src/job-core/JobCore.jsx b/src/job-core/JobCore.jsx index 88d0026..8c5e1e0 100644 --- a/src/job-core/JobCore.jsx +++ b/src/job-core/JobCore.jsx @@ -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, }; } diff --git a/src/job-core/JobExtra.jsx b/src/job-core/JobExtras.jsx similarity index 98% rename from src/job-core/JobExtra.jsx rename to src/job-core/JobExtras.jsx index ea3980a..d9916cd 100644 --- a/src/job-core/JobExtra.jsx +++ b/src/job-core/JobExtras.jsx @@ -33,7 +33,7 @@ function statusIcon(status) { } } -export function useJobExtra() { +export function useJobExtras() { const { state, jobUpdate, jobCreate, jobDelete } = useContext(JobContext); const navigate = useNavigate(); diff --git a/src/job-core/PipelineCore.jsx b/src/job-core/PipelineCore.jsx index 9e3d99f..29dc2ce 100644 --- a/src/job-core/PipelineCore.jsx +++ b/src/job-core/PipelineCore.jsx @@ -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 }; } diff --git a/src/views/catalog/CatalogItemDetails.jsx b/src/views/catalog/CatalogItemDetails.jsx index 4eee6c4..ef8f771 100644 --- a/src/views/catalog/CatalogItemDetails.jsx +++ b/src/views/catalog/CatalogItemDetails.jsx @@ -17,23 +17,23 @@ export default function CatalogItemDetails(props) { return ( -
+
Image: {image}
-
+
Env: {env.join(", ")}
-
+
Regions: - {regions.join(", ")} + {(regions ?? []).join(", ")}
-
+
Crons: - {JSON.stringify(crons)} + {(crons ?? []).join(", ")}
-
+
Projects: {projects.join(", ")}
diff --git a/src/views/jobs/JobActionMenu.jsx b/src/views/jobs/JobActionMenu.jsx new file mode 100644 index 0000000..28ec714 --- /dev/null +++ b/src/views/jobs/JobActionMenu.jsx @@ -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 ( + + + + + + Download Log + + {!job.isPipeline && ( + + + {job.status === jobStatus.OK || job.status === jobStatus.ERROR ? ( + + ) : ( + + )} + + + {job.status === jobStatus.ERROR ? "Retry" : "Duplicate"} + + + )} + {job.status === jobStatus.OK || + job.status === jobStatus.ERROR || + job.status === jobStatus.CANCELED ? null : ( + + + + + Cancel + + )} + {!job.isPipeline && ( + + + + + Delete + + )} + + ); +} diff --git a/src/views/jobs/JobBox.jsx b/src/views/jobs/JobBox.jsx index 253dd0a..39e3b2e 100644 --- a/src/views/jobs/JobBox.jsx +++ b/src/views/jobs/JobBox.jsx @@ -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 ( + {job.isPipeline && ( - + )} diff --git a/src/views/jobs/Jobs.jsx b/src/views/jobs/Jobs.jsx index d6fcbd6..5ed8f6e 100644 --- a/src/views/jobs/Jobs.jsx +++ b/src/views/jobs/Jobs.jsx @@ -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"; diff --git a/src/views/jobs/JobPipelineBox.jsx b/src/views/jobs/pipeline/JobPipelineBox.jsx similarity index 62% rename from src/views/jobs/JobPipelineBox.jsx rename to src/views/jobs/pipeline/JobPipelineBox.jsx index a51fd6d..e4940a1 100644 --- a/src/views/jobs/JobPipelineBox.jsx +++ b/src/views/jobs/pipeline/JobPipelineBox.jsx @@ -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) { diff --git a/src/views/jobs/JobPipelineDisplay.jsx b/src/views/jobs/pipeline/JobPipelineDisplay.jsx similarity index 70% rename from src/views/jobs/JobPipelineDisplay.jsx rename to src/views/jobs/pipeline/JobPipelineDisplay.jsx index 9d2f9c6..ee9488d 100644 --- a/src/views/jobs/JobPipelineDisplay.jsx +++ b/src/views/jobs/pipeline/JobPipelineDisplay.jsx @@ -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 ( + + {timerDisplay(test.name)} - - + + {boxIcon(test.name)} + + +
diff --git a/src/views/jobs/JobPiplinePendingView.jsx b/src/views/jobs/pipeline/JobPiplinePendingView.jsx similarity index 100% rename from src/views/jobs/JobPiplinePendingView.jsx rename to src/views/jobs/pipeline/JobPiplinePendingView.jsx diff --git a/src/views/jobs/pipeline/PipelineTriggerDialog.jsx b/src/views/jobs/pipeline/PipelineTriggerDialog.jsx new file mode 100644 index 0000000..553880a --- /dev/null +++ b/src/views/jobs/pipeline/PipelineTriggerDialog.jsx @@ -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 ( + + + Pipeline Timeout + + + +
+ Test: + {trigger.testName} +
+
+ +
+ +
+ + + +
+ ); +} diff --git a/src/views/jobs/pipeline/PipelineTriggerWidget.jsx b/src/views/jobs/pipeline/PipelineTriggerWidget.jsx new file mode 100644 index 0000000..64fc382 --- /dev/null +++ b/src/views/jobs/pipeline/PipelineTriggerWidget.jsx @@ -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 ( + + + + + + + + {!hovering && triggerMomentRemaining()} + {hovering && ( + + + + {triggerMomentRemaining()} + + + )} + + + + + ); +}