Link K8S deps properly

This commit is contained in:
Elijah Dunemask 2022-10-08 17:47:46 +00:00
parent 0ac77cdb15
commit f0260fc819
64 changed files with 4282 additions and 3069 deletions

View file

@ -18,7 +18,7 @@ const ACTIONS = {
PIPELINE: "p",
};
const url = "https://qualiteer.elijahparker3.repl.co/";
const url = "/";
const initialState = {
jobs: [],
@ -134,7 +134,7 @@ export const JobProvider = ({ children }) => {
request,
onLog,
onClose,
null,
() => {},
onPipelineTrigger
);
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
@ -146,6 +146,7 @@ export const JobProvider = ({ children }) => {
const pipelineReq = {
image: "node",
pipeline: { __test, triggers: { ...tree[__test] } },
isTriage: builderCache.triageFailing,
};
const id = `pij${Date.now()}`;
const pipeline = { id, branches, pendingTriggers: [], selectedBranches };
@ -232,6 +233,7 @@ export const JobProvider = ({ children }) => {
image: "node",
type: "single",
name: jobId,
isTriage: builderCache.isTriage,
};
jobCreate(job);
@ -250,7 +252,7 @@ export const JobProvider = ({ children }) => {
jobUpdate({ ...job }, jobId);
};
const started = i.newJob(request, onLog, onClose);
const started = i.newJob(request, onLog, onClose, () => {});
started.then(() => jobUpdate({ status: jobStatus.ACTIVE }, jobId));
return jobId;

View file

@ -5,7 +5,7 @@ const ACTIONS = {
UPDATE: "u",
};
const localStorage = { setItem: () => {}, getItem: () => {} };
// const localStorage = { setItem: () => {}, getItem: () => {} };
const localSettings = localStorage.getItem("settings");
const defaultSettings = {
@ -14,6 +14,7 @@ const defaultSettings = {
logAppDetails: true,
defaultRegion: "us",
defaultPage: "failing",
triageFailing: true,
};
const settings = localSettings ? JSON.parse(localSettings) : defaultSettings;
@ -58,7 +59,7 @@ export const StoreProvider = ({ children }) => {
name: silenceInfo.name ?? "*",
class: silenceInfo.class ?? "*",
method: silenceInfo.method ?? "*",
silencedUntil: silenceInfo.silencedUntil,
expires: silenceInfo.expires,
};
console.log("Would upsert silence", req);
}

View file

@ -1,11 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { silencedMock } from "@qltr/mocks/alerting-mock.js";
import { failingMock } from "@qltr/mocks/results-mock.js";
import { testsMock, mappingsMock } from "@qltr/mocks/catalog-mock.js";
const QUALITEER_URL = "https://qualiteer.elijahparker3.repl.co/api";
const QUALITEER_URL = "/api";
const useMock = true;
const useMock = false;
const asMock = (data) => ({ data });
@ -31,3 +31,44 @@ export const useCurrentlyFailing = () =>
useMock
? asMock(failingMock())
: useQuery(["failing"], fetchApi("/results/failing"));
export const useUpsertAlert = () => {
const qc = useQueryClient();
return async function upsertAlert(silenceRequest) {
var { id, name, class: className, method, expires } = silenceRequest;
const keepExpires = typeof expires === "string";
name = name ? name : "*";
className = className ? className : "*";
method = method ? method : "*";
const res = await fetch(`${QUALITEER_URL}/alerting/silence`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
id,
name,
class: className,
method,
expires,
keepExpires,
}),
});
qc.invalidateQueries(["silenced"]);
};
};
export const useIgnoreResult = () => {
const qc = useQueryClient();
return async function ignoreResult(result) {
const { id } = result;
const res = await fetch(`${QUALITEER_URL}/results/ignore`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ id }),
});
qc.invalidateQueries(["failing"]);
};
};

View file

@ -17,7 +17,7 @@ export default function About() {
</Typography>
<Typography variant="body1">
Qualiteer was designed to solve the issue of "on call". A state of
being in which QA tests will fail, stiring everyone into a frenzy of
being in which QA tests will fail, stirring everyone into a frenzy of
what is broken in production! 🤯 Qualiteer gives users power to
resolve and reattempt failing tests, run a particular suite of tests,
and mute pesky alerts reminding you the navbar's color changed... 🤦

View file

@ -1,8 +1,8 @@
import React, { useState, useContext } from "react";
import { useSilencedAlerts } from "@qltr/queries";
import { useSilencedAlerts, useUpsertAlert } from "@qltr/queries";
import StoreContext from "@qltr/store";
import SilencedBox from "./SilencedBox.jsx";
import SilenceDialog from "./SilenceDialog.jsx";
import SilenceDialog, { useSilenceDialog } from "./SilenceDialog.jsx";
import SpeedDial from "@mui/material/SpeedDial";
import SpeedDialAction from "@mui/material/SpeedDialAction";
@ -18,41 +18,38 @@ import Typography from "@mui/material/Typography";
import Box from "@mui/material/Box";
export default function Alerting() {
const [deleteOpen, setDeleteOpen] = useState(false);
const { updateStore, silenceRequest } = useContext(StoreContext);
const { isLoading, data: silenced } = useSilencedAlerts();
const upsertAlert = useUpsertAlert();
const [sdOpen, sdToggle, silence, setSilence, sdClose] = useSilenceDialog();
const toggleDelete = () => setDeleteOpen(!deleteOpen);
const [silenceEntry, setSilenceEntry] = useState({
open: false,
deleteOpen: false,
});
const closeSilence = () =>
setSilenceEntry({ ...silenceEntry, open: false, deleteOpen: false });
const handleDeleteClose = (makeRequest) => () => {
const silenceReq = { ...silenceEntry };
closeSilence();
if (!makeRequest) return;
silenceRequest({ ...silenceReq, silencedUntil: null });
const handleDeleteClose = (confirmed) => () => {
toggleDelete();
if (!confirmed) return;
const silenceReq = { ...silence };
upsertAlert({ ...silenceReq, expires: null });
};
const handleClose = (silenceReq) => {
closeSilence();
if (!silenceReq) return;
silenceRequest(silenceReq);
upsertAlert(silenceReq);
};
const quickAlertClick = () => {
setSilenceEntry({ open: true, deleteOpen: false });
setSilence({});
sdToggle();
};
const editSilence = (silence) => () => {
setSilenceEntry({ ...silence, open: true, deleteOpen: false });
sdToggle();
setSilence(silence);
};
const removeSilence = (silence) => () => {
setSilenceEntry({ ...silence, deleteOpen: true, open: false });
setSilence(silence);
toggleDelete();
};
return (
@ -93,7 +90,7 @@ export default function Alerting() {
) : null}
<Dialog
open={silenceEntry.deleteOpen}
open={deleteOpen}
onClose={handleDeleteClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
@ -110,9 +107,9 @@ export default function Alerting() {
<SilenceDialog
keepMounted
open={silenceEntry.open}
onClose={handleClose}
silence={silenceEntry}
open={sdOpen}
onClose={sdClose}
silence={silence}
/>
<SpeedDial

View file

@ -1,6 +1,5 @@
import { useState, useContext, useEffect } from "react";
import StoreContext from "@qltr/store";
import { useUpsertAlert } from "@qltr/queries";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Button from "@mui/material/Button";
@ -11,25 +10,53 @@ import Dialog from "@mui/material/Dialog";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import SilenceSelector from "./SilenceSelector.jsx";
export function useSilenceDialog(isOpen = false) {
const [open, setOpen] = useState(isOpen);
const [silence, setSilence] = useState({});
const upsertAlert = useUpsertAlert();
const dialogToggle = () => setOpen(!open);
const dialogClose = (confirmedSilence) => {
setOpen(false);
if (!confirmedSilence) return;
upsertAlert(confirmedSilence);
setSilence({});
};
return [open, dialogToggle, silence, setSilence, dialogClose];
}
export default function SilenceDialog(props) {
const { silence, open, onClose } = props;
const [duration, setDuration] = useState({ h: 0, m: 0 });
const theme = useTheme();
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
const [silenceEntry, setSilenceEntry] = useState(silence);
const durationModified = duration.h !== 0 || duration.m !== 0;
const silenceModified =
silence.name !== silenceEntry.name ||
silence.method !== silenceEntry.method ||
silence.class !== silenceEntry.class;
const modified =
(Object.keys(silence).length > 0 && durationModified) || silenceModified;
useEffect(() => {
setSilenceEntry(silence);
setDuration({ h: 0, m: 0 });
}, [silence, open]);
const { state: store, updateStore } = useContext(StoreContext);
const handleCancel = () => onClose();
const handleOk = () => onClose(silenceEntry);
const updateSilence = (silenceType) => (e) => {
silenceEntry[silenceType] = e.target.value;
setSilenceEntry({ ...silenceEntry });
const s = { ...silenceEntry };
s[silenceType] = e.target.value;
setSilenceEntry({ ...s, expires: duration });
};
const updateDuration = (dur) => {
setDuration(dur);
setSilenceEntry({ ...silenceEntry, expires: dur });
};
return (
@ -37,7 +64,7 @@ export default function SilenceDialog(props) {
sx={
fullScreen
? {}
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 525 } }
}
maxWidth="xs"
open={open}
@ -70,12 +97,19 @@ export default function SilenceDialog(props) {
onChange={updateSilence("class")}
margin="normal"
/>
<SilenceSelector
formerExpires={silence.expires}
duration={duration}
setDuration={updateDuration}
/>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleOk}>Ok</Button>
<Button onClick={handleOk} disabled={!modified}>
Ok
</Button>
</DialogActions>
</Dialog>
);

View file

@ -0,0 +1,70 @@
import { useState } from "react";
import moment from "moment";
import Box from "@mui/material/Box";
import TextField from "@mui/material/TextField";
const MAX_DURATION = { h: 72, m: 59 };
const MIN_DURATION = 0;
export default function SilenceSelector(props) {
const [expires, setExpires] = useState(moment());
const { formerExpires, duration, setDuration } = props;
const formerExpirey =
formerExpires && duration.h === 0 && duration.m === 0
? moment(formerExpires)
: null;
const updateDuration = (t) => (e) => {
const d = { ...duration };
d[t] = Math.abs(parseInt(e.target.value));
if (isNaN(d[t])) d[t] = 0;
d[t] = d[t] <= MAX_DURATION[t] ? d[t] : MAX_DURATION[t];
setDuration(d);
const now = moment().add(d.h, "hours").add(d.m, "minutes");
setExpires(now);
};
const onNumberInput = (e) =>
(e.target.value =
!!e.target.value && Math.abs(e.target.value) >= MIN_DURATION
? Math.abs(e.target.value)
: null);
return (
<Box>
<Box style={{ display: "flex" }}>
<TextField
margin="normal"
label="Hours"
type="number"
onChange={updateDuration("h")}
inputProps={{
min: MIN_DURATION,
max: MAX_DURATION.h,
onInput: onNumberInput,
}}
value={duration.h}
autoComplete="off"
/>
<TextField
margin="normal"
sx={{ marginLeft: 1 }}
label="Minutes"
type="number"
onChange={updateDuration("m")}
inputProps={{
min: MIN_DURATION,
max: MAX_DURATION.m,
onInput: onNumberInput,
}}
value={duration.m}
autoComplete="off"
/>
</Box>
<TextField
margin="normal"
label="Expires"
value={`${(formerExpirey ?? expires).format("L")} ${(
formerExpirey ?? expires
).format("LT")}`}
/>
</Box>
);
}

View file

@ -1,4 +1,5 @@
import React, { useState, useContext } from "react";
import moment from "moment";
import StoreContext from "@qltr/store";
import Accordion from "@mui/material/Accordion";
@ -18,7 +19,7 @@ export default function SilencingBox(props) {
method: testMethod,
class: testClass,
id: silenceId,
silencedUntil,
expires,
} = silenceEntry;
const { state: store, updateStore } = useContext(StoreContext);
@ -61,11 +62,19 @@ export default function SilencingBox(props) {
<br />
{`Test Class: ${testClass}`}
<br />
{`Silenced Until: ${silencedUntil} Remaining Time: 2:50`}
{`Silenced Until: ${expires} Remaining Time: ${moment(
moment(expires).diff(moment())
).format("HH:mm")}`}
</Typography>
<Stack
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
sx={{
ml: "auto",
mb: "auto",
mt: "auto",
whiteSpace: "nowrap",
display: { md: "none", lg: "none", xl: "none" },
}}
>
<Actions />
</Stack>

View file

@ -9,7 +9,7 @@ import TextField from "@mui/material/TextField";
export default function Catalog() {
const { state: store, updateStore } = useContext(StoreContext);
const { state: jobState } = useContext(JobContext);
const { isLoading, data: tests } = useCatalogTests();
const { isLoading, data: rawTests } = useCatalogTests();
const handleSearchChange = (e) =>
updateStore({ catalogSearch: e.target.value });
@ -22,6 +22,8 @@ export default function Catalog() {
}, []);
const catalogWithJobs = () => {
const tests = rawTests.map((t) => ({ ...t, isPipeline: t.pipeline }));
for (var t of tests) delete t.pipeline;
for (var test of tests) {
if (test.isPipeline) {
const pipeline = jobState.pipelines.find((p) =>

View file

@ -9,6 +9,8 @@ import {
useJobNav,
} from "@qltr/util/JobTools";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
@ -40,8 +42,9 @@ export default function CatalogBox(props) {
const { jobFactory } = useContext(JobContext);
const jobNav = useJobNav();
const [open, setOpen] = useState(false);
const toggleOpen = () => setOpen(!open);
const theme = useTheme();
const minifyActions = useMediaQuery(theme.breakpoints.down("sm"));
const navigateToJob = () => {
if (pipeline) return jobNav.toPipeline(pipeline.id);
@ -50,7 +53,10 @@ export default function CatalogBox(props) {
const runTest = () => {
if (isPipeline) return runPipelineTest();
const jobId = jobFactory({ testNames: [testName], isTriage: true });
const jobId = jobFactory({
testNames: [testName],
isTriage: store.triageFailing,
});
if (store.focusJob) jobNav.toJob(jobId);
};
@ -86,21 +92,6 @@ export default function CatalogBox(props) {
return useJobIconState(job);
}
function Actions() {
return (
<React.Fragment>
<IconButton
color="success"
aria-label="play"
component="span"
onClick={jobOnClick}
>
{jobIcon()}
</IconButton>
</React.Fragment>
);
}
return (
<Accordion
expanded={open}
@ -129,20 +120,18 @@ export default function CatalogBox(props) {
</Box>
<br />
</Typography>
<Stack
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
direction={minifyActions ? "column" : "row"}
sx={{ ml: "auto", mb: "auto", mt: "auto", whiteSpace: "nowrap" }}
>
<Actions />
</Stack>
<Stack
direction="row"
sx={{
ml: "auto",
display: { xs: "none", sm: "none", md: "flex", lg: "flex" },
}}
>
<Actions />
<IconButton
color="success"
aria-label="play"
component="span"
onClick={jobOnClick}
>
{jobIcon()}
</IconButton>
</Stack>
</AccordionSummary>
<AccordionDetails>

View file

@ -1,135 +1,103 @@
import { useState, useContext } from "react";
import { useCurrentlyFailing, useSilencedAlerts } from "@qltr/queries";
import StoreContext from "@qltr/store";
import JobContext from "@qltr/jobs";
import { useJobNav } from "@qltr/util/JobTools";
import SilenceDialog from "../alerting/SilenceDialog.jsx";
import SilenceDialog, { useSilenceDialog } from "../alerting/SilenceDialog.jsx";
import FailingBox from "./FailingBox.jsx";
import QuickSilence, { useQuickSilence } from "./QuickSilence.jsx";
import FailingRetry from "./FailingRetry.jsx";
import SpeedDial from "@mui/material/SpeedDial";
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import ReplayIcon from "@mui/icons-material/Replay";
// MaterialUI
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
export default function Failing() {
const { state: jobState, retryAll } = useContext(JobContext);
const { state: store, silenceRequest } = useContext(StoreContext);
const { isLoading, data: failing } = useCurrentlyFailing();
const { isSilencedLoading, data: silencedAlerts } = useSilencedAlerts();
const jobNav = useJobNav();
const [silenceEntry, setSilenceEntry] = useState({ open: false });
const { state: jobState } = useContext(JobContext);
const { isLoading: failsLoading, data: failing } = useCurrentlyFailing();
const { isLoading: silencesLoading, data: silences } = useSilencedAlerts();
const [qsAnchor, qsTest, openQs, closeQs] = useQuickSilence();
const [sdOpen, sdToggle, silence, setSilence, sdClose] = useSilenceDialog();
const closeSilence = () => setSilenceEntry({ ...silenceEntry, open: false });
const handleSilenceClose = (silenceReq) => {
closeSilence();
if (!silenceReq) return;
silenceRequest(silenceReq);
const joinSilence = (result) => {
if (!silences) return;
const silence = silences.find((s) => s.name === result.name);
if (silence) result.silence = silence;
};
const editSilence = (silence) => () => {
setSilenceEntry({ ...silence, open: true });
const joinJob = (result) => {
if (result.isPipeline) return;
const job = jobState.jobs.find(
(j) => !j.isPipeline && j.builderCache.testNames.includes(result.name)
);
if (job) result.job = job;
};
const [retryAllOpen, setRetryAllOpen] = useState(false);
const retryAllClick = () => setRetryAllOpen(!retryAllOpen);
const handleClose = (confirmed) => () => {
retryAllClick();
if (!confirmed) return;
const jobId = retryAll(store.failing);
if (!store.focusJob) return;
jobNav.toJob(jobId);
const joinPipeline = (result) => {
if (!result.isPipeline) return;
const pipeline = jobState.pipelines.find((p) =>
p.selectedBranches.find((b) => b.name === result.name)
);
if (!pipeline) return;
const pipelineJob = jobState.jobs.find(
(j) =>
j.isPipeline &&
j.pipelineId === pipeline.id &&
j.branchId === result.name
);
if (!pipelineJob) result.pipeline = pipeline;
else result.job = pipelineJob;
};
const failingTestsWithJobs = () => {
if (isLoading) return [];
const silences = silencedAlerts ?? [];
for (var test of failing) {
const silence = silences.find(
(s) => s.name === test.name || s.class === test.class
);
if (silence) test.silencedUntil = silence;
if (test.isPipeline) {
const pipeline = jobState.pipelines.find((p) =>
p.selectedBranches.find((b) => b.name === test.name)
);
if (!pipeline) continue;
const pipelineJob = jobState.jobs.find(
(j) =>
j.isPipeline &&
j.pipelineId === pipeline.id &&
j.branchId === test.name
);
if (!pipelineJob) test.pipeline = pipeline;
test.job = pipelineJob;
continue;
}
const job = jobState.jobs.find(
(j) => !j.isPipeline && j.builderCache.testNames.includes(test.name)
);
if (job) test.job = job;
const resultsCompiled = () => {
if (failsLoading) return [];
const compiled = [];
for (var r of failing) {
const rc = { ...r };
joinSilence(rc);
// Find associated job and skip pipeline
joinJob(rc);
// Find associated pipeline
joinPipeline(rc);
compiled.push(rc);
}
return failing;
return compiled;
};
const alertClick = (result) => (e) => {
if (e) e.preventDefault();
if (e && e.type === "contextmenu" && result.silence)
return openQs(e, result);
const { name, class: className, method } = result;
const testInfo = { name, class: className, method };
const silenceInfo = result.silence ?? testInfo;
sdToggle();
setSilence(silenceInfo);
};
return (
<div className="failing">
{isLoading
? null
: failingTestsWithJobs().map((v, i) => (
<FailingBox key={i} failingTest={v} silenceClick={editSilence(v)} />
))}
<Dialog
open={retryAllOpen}
onClose={handleClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
>
<DialogTitle>Retry all failing tests?</DialogTitle>
<DialogContent>
<DialogContentText>
This will create x jobs and run y tests
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose()}>Cancel</Button>
<Button onClick={handleClose(true)} autoFocus>
Yes
</Button>
</DialogActions>
</Dialog>
<SilenceDialog
keepMounted
open={silenceEntry.open}
onClose={handleSilenceClose}
silence={silenceEntry}
open={sdOpen}
onClose={sdClose}
silence={silence}
/>
{(failing ?? []).length === 0 ? (
<QuickSilence
anchorEl={qsAnchor}
handleClose={closeQs}
test={qsTest}
editSilence={alertClick(qsTest)}
/>
<FailingRetry failing={failsLoading ? [] : failing} />
{!failsLoading && failing.length === 0 && (
<Box display="flex" alignItems="center" justifyContent="center">
<Typography variant="h4">No tests failing!</Typography>
</Box>
) : null}
{(failing ?? []).length === 0 ? null : (
<SpeedDial
ariaLabel="Retry All"
sx={{ position: "fixed", bottom: 16, right: 16 }}
icon={<ReplayIcon />}
onClick={retryAllClick}
open={false}
/>
)}
{!failsLoading &&
resultsCompiled().map((v, i) => (
<FailingBox key={i} failingTest={v} alertClick={alertClick(v)} />
))}
</div>
);
}

View file

@ -1,5 +1,5 @@
import React, { useState, useContext } from "react";
import { usePipelineMappings } from "@qltr/queries";
import { usePipelineMappings, useIgnoreResult } from "@qltr/queries";
import StoreContext from "@qltr/store";
import JobContext, { jobStatus } from "@qltr/jobs";
import {
@ -7,7 +7,8 @@ import {
usePipelineIconState,
useJobNav,
} from "@qltr/util/JobTools";
import useMediaQuery from "@mui/material/useMediaQuery";
import { useTheme } from "@mui/material/styles";
import Accordion from "@mui/material/Accordion";
import AccordionDetails from "@mui/material/AccordionDetails";
import AccordionSummary from "@mui/material/AccordionSummary";
@ -42,30 +43,33 @@ import { asTree, asBranches, as1d } from "@qltr/util/pipelines.js";
const stopPropagation = (e) => e.stopPropagation() && e.preventDefault();
export default function FailingBox(props) {
const { failingTest, silenceClick } = props;
const { failingTest, alertClick } = props;
const {
class: testClass,
name: testName,
timestamp,
silencedUntil,
silence,
type,
dailyFails,
screenshot: screenshotUrl,
console: cs,
recentResults,
failedMessage,
message,
isPipeline,
job,
pipeline,
} = failingTest;
const ignoreResult = useIgnoreResult();
const runHistory = recentResults ? [...recentResults].reverse() : null;
const { data: pipelineMappings, isLoading } = usePipelineMappings();
const { jobFactory } = useContext(JobContext);
const { state: store, updateStore, removeFailure } = useContext(StoreContext);
const jobNav = useJobNav();
const theme = useTheme();
const minifyActions = useMediaQuery(theme.breakpoints.down("sm"));
const [open, setOpen] = useState(false);
const toggleOpen = () => setOpen(!open);
const [removeOpen, setRemoveOpen] = useState(false);
const removeClick = () => setRemoveOpen(!removeOpen);
@ -73,7 +77,7 @@ export default function FailingBox(props) {
stopPropagation(e);
setRemoveOpen(false);
if (!confirmed) return;
removeFailure(failingTest);
ignoreResult(failingTest);
};
function badgeColor() {
@ -91,7 +95,7 @@ export default function FailingBox(props) {
branches: asBranches(primaries),
tree: asTree(primaries),
selectedBranches: as1d(primaries),
isTriage: true,
isTriage: store.triageFailing,
};
const pipeline = jobFactory(builderCache);
if (store.focusJob) jobNav.toPipeline(pipeline.id);
@ -99,7 +103,10 @@ export default function FailingBox(props) {
const retryTest = () => {
if (isPipeline) return retryPipelineTest();
const jobId = jobFactory({ testNames: [testName], isTriage: true });
const jobId = jobFactory({
testNames: [testName],
isTriage: store.triageFailing,
});
if (store.focusJob) jobNav.toJob(jobId);
};
@ -120,39 +127,6 @@ export default function FailingBox(props) {
return useJobIconState(job);
}
function Actions() {
return (
<React.Fragment>
<a href={screenshotUrl}>
<IconButton aria-label="photo" component="span">
<PhotoCameraIcon />
</IconButton>
</a>
<IconButton aria-label="retry" component="span" onClick={jobOnClick}>
{jobIcon()}
</IconButton>
<IconButton
aria-label="silence"
component="span"
color={silencedUntil ? "primary" : "default"}
onClick={silenceClick}
>
<NotificationsIcon />
</IconButton>
<IconButton
color="error"
aria-label="delete"
component="span"
onClick={removeClick}
>
<DeleteIcon />
</IconButton>
</React.Fragment>
);
}
return (
<Accordion
expanded={open}
@ -212,12 +186,23 @@ export default function FailingBox(props) {
<br />
<div>
<span className="recent-results">
{recentResults.map(
(v, i) =>
(v && <CheckIcon key={i} color="success" />) || (
<ClearIcon key={i} color="error" />
)
)}
{runHistory &&
runHistory.map(
(v, i) =>
(!v.failed && (
<CheckIcon
key={i}
color="success"
titleAccess={v.timestamp}
/>
)) || (
<ClearIcon
key={i}
color="error"
titleAccess={v.timestamp}
/>
)
)}
</span>
{isPipeline && <ViewColumnIcon />}
</div>
@ -225,27 +210,41 @@ export default function FailingBox(props) {
<Stack
onClick={stopPropagation}
sx={{ ml: "auto", display: { md: "none", lg: "none", xl: "none" } }}
direction={minifyActions ? "column" : "row"}
sx={{ ml: "auto", mb: "auto", mt: "auto", whiteSpace: "nowrap" }}
>
<Actions />
</Stack>
<Stack
onClick={stopPropagation}
direction="row"
sx={{
ml: "auto",
mb: "auto",
mt: "auto",
whiteSpace: "nowrap",
display: { xs: "none", sm: "none", md: "block", lg: "block" },
}}
>
<Actions />
<a href={screenshotUrl}>
<IconButton aria-label="photo" component="span">
<PhotoCameraIcon />
</IconButton>
</a>
<IconButton aria-label="retry" component="span" onClick={jobOnClick}>
{jobIcon()}
</IconButton>
<IconButton
aria-label="silence"
component="span"
color={silence && silence.expires ? "primary" : "default"}
onClick={alertClick}
onContextMenu={alertClick}
>
<NotificationsIcon titleAccess={silence && silence.expires} />
</IconButton>
<IconButton
color="error"
aria-label="delete"
component="span"
onClick={removeClick}
>
<DeleteIcon />
</IconButton>
</Stack>
</AccordionSummary>
<AccordionDetails>
<Typography component={"span"} style={{ wordBreak: "break-word" }}>
{failedMessage}
{message}
</Typography>
</AccordionDetails>
</Accordion>

View file

@ -0,0 +1,64 @@
// React
import React, { useState, useContext } from "react";
import JobContext from "@qltr/jobs";
import StoreContext from "@qltr/store";
import { useJobNav } from "@qltr/util/JobTools";
// Components
import Button from "@mui/material/Button";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import SpeedDial from "@mui/material/SpeedDial";
// Icons
import ReplayIcon from "@mui/icons-material/Replay";
export default function FailingRetry(props) {
const { failing } = props;
const { state: jobState, retryAll } = useContext(JobContext);
const { state: store } = useContext(StoreContext);
const [open, setOpen] = useState(false);
const jobNav = useJobNav();
const toggleOpen = () => setOpen(!open);
const dialogClose = (confirmed) => () => {
toggleOpen();
if (!confirmed) return;
const jobId = retryAll(failing);
if (!store.focusJob) return;
jobNav.toJob(jobId);
};
if (!failing || failing.length === 0) return;
return (
<React.Fragment>
<Dialog
open={open}
onClose={dialogClose()}
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
maxWidth="xs"
>
<DialogTitle>Retry all failing tests?</DialogTitle>
<DialogContent>
<DialogContentText>
This will create x jobs and run y tests
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={dialogClose()}>Cancel</Button>
<Button onClick={dialogClose(true)} autoFocus>
Yes
</Button>
</DialogActions>
</Dialog>
<SpeedDial
ariaLabel="Retry All"
sx={{ position: "fixed", bottom: 16, right: 16 }}
icon={<ReplayIcon />}
onClick={toggleOpen}
open={false}
/>
</React.Fragment>
);
}

View file

@ -0,0 +1,58 @@
// React
import { useState, useContext } from "react";
import { useUpsertAlert } from "@qltr/queries";
// Components
import Popover from "@mui/material/Popover";
import IconButton from "@mui/material/IconButton";
//Icons
import DeleteIcon from "@mui/icons-material/Delete";
import EditIcon from "@mui/icons-material/Edit";
export function useQuickSilence() {
const [anchorEl, setAnchorEl] = useState(null);
const [test, setTest] = useState(null);
const openMenuAt = (e, t) => {
e.preventDefault();
setTest(t);
setAnchorEl(e.currentTarget);
};
const closeMenu = () => setAnchorEl(null);
return [anchorEl, test, openMenuAt, closeMenu];
}
export default function QuickSilence(props) {
const { anchorEl, test, handleClose, editSilence } = props;
const upsertAlert = useUpsertAlert();
const open = Boolean(anchorEl);
async function deleteClick() {
await upsertAlert({ id: test.silence.id, expires: null });
handleClose();
}
async function editClick() {
editSilence();
handleClose();
}
return (
<Popover
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
>
<IconButton color="primary" onClick={editClick}>
<EditIcon />
</IconButton>
<IconButton color="error" onClick={deleteClick}>
<DeleteIcon />
</IconButton>
</Popover>
);
}

View file

@ -1,4 +1,5 @@
import React, { useContext } from "react";
import { useNavigate } from "react-router-dom";
import JobContext, { jobStatus } from "@qltr/jobs";
import {
selectedPipelineBranches,
@ -37,6 +38,7 @@ function JobPipelineDisplay(props) {
} = useContext(JobContext);
const jobNav = useJobNav();
const nav = useNavigate();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
@ -92,7 +94,7 @@ function JobPipelineDisplay(props) {
<Toolbar disableGutters />
<Box sx={{ flexGrow: 1, margin: "0 10px" }}>
<Toolbar disableGutters>
<IconButton onClick={jobNav.toJobs}>
<IconButton onClick={() => nav(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" sx={{ ml: "auto", mr: "auto" }}>

View file

@ -1,5 +1,6 @@
import React, { useContext, useState, useEffect } from "react";
import { useJobNav } from "@qltr/util/JobTools";
import { useNavigate } from "react-router-dom";
import JobContext, { jobStatus } from "@qltr/jobs";
import StoreContext from "@qltr/store";
import Box from "@mui/material/Box";
@ -27,6 +28,7 @@ export default function JobView(props) {
const { jobFactory, jobCancel, jobDestroy } = useContext(JobContext);
const { state: store } = useContext(StoreContext);
const jobNav = useJobNav();
const nav = useNavigate();
const [anchorEl, setAnchorEl] = React.useState(null);
const open = Boolean(anchorEl);
const handleClick = (event) => {
@ -91,7 +93,7 @@ export default function JobView(props) {
<Toolbar disableGutters />
<Box sx={{ flexGrow: 1, margin: "0 10px" }}>
<Toolbar disableGutters>
<IconButton onClick={navigateToJobs}>
<IconButton onClick={() => nav(-1)}>
<ArrowBackIcon />
</IconButton>
<Typography variant="h6" component="span" sx={{ ml: "auto" }}>

View file

@ -52,7 +52,7 @@ export default function JobBuilder() {
const handleClose = (confirmed) => () => {
setJobDialogOpen(false);
if (!confirmed) return;
const jobId = jobFactory(cache);
const jobId = jobFactory({ ...cache, isTriage: store.triageFailing });
if (store.focusJob) jobNav.toJob(jobId);
};

View file

@ -115,6 +115,11 @@ export default function Settings(props) {
<Switch edge="end" checked={store.focusJob} />
</ListItem>
<ListItem button divider onClick={handleToggle("triageFailing")}>
<ListItemText primary="Triage Failing" />
<Switch edge="end" checked={store.triageFailing} />
</ListItem>
<ListItem button divider onClick={handleToggle("logAppDetails")}>
<ListItemText primary="Log App Details" />
<Switch edge="end" checked={store.logAppDetails} />