251 lines
8.1 KiB
TypeScript
251 lines
8.1 KiB
TypeScript
import { CheckIcon } from "@heroicons/react/20/solid";
|
|
import {
|
|
IconAlarmSnooze as IconAlarmSnoozeBase,
|
|
IconArrowBackUp as IconArrowBackUpBase,
|
|
IconBugOff as IconBugOffBase,
|
|
} from "@tabler/icons-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
|
import { useFetcher } from "@remix-run/react";
|
|
import { Button } from "~/components/primitives/Buttons";
|
|
import { useToast } from "~/components/primitives/Toast";
|
|
import { FormError } from "~/components/primitives/FormError";
|
|
import { Input } from "~/components/primitives/Input";
|
|
import { InputGroup } from "~/components/primitives/InputGroup";
|
|
import { Label } from "~/components/primitives/Label";
|
|
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "~/components/primitives/Dialog";
|
|
|
|
const AlarmSnoozeIcon = ({ className }: { className?: string }) => (
|
|
<IconAlarmSnoozeBase className={className} size={18} />
|
|
);
|
|
const ArrowBackUpIcon = ({ className }: { className?: string }) => (
|
|
<IconArrowBackUpBase className={className} size={18} />
|
|
);
|
|
const BugOffIcon = ({ className }: { className?: string }) => (
|
|
<IconBugOffBase className={className} size={18} />
|
|
);
|
|
|
|
export function statusActionToastMessage(data: Record<string, string>): string {
|
|
switch (data.action) {
|
|
case "resolve":
|
|
return "Error marked as resolved";
|
|
case "unresolve":
|
|
return "Error marked as unresolved";
|
|
case "ignore": {
|
|
const duration = data.duration ? Number(data.duration) : undefined;
|
|
if (!duration) return "Error ignored indefinitely";
|
|
const hours = duration / (60 * 60 * 1000);
|
|
if (hours < 24) return `Error ignored for ${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
const days = hours / 24;
|
|
return `Error ignored for ${days} ${days === 1 ? "day" : "days"}`;
|
|
}
|
|
default:
|
|
return "Error status updated";
|
|
}
|
|
}
|
|
|
|
export function ErrorStatusMenuItems({
|
|
status,
|
|
taskIdentifier,
|
|
onAction,
|
|
onCustomIgnore,
|
|
}: {
|
|
status: ErrorGroupStatus;
|
|
taskIdentifier: string;
|
|
onAction: (data: Record<string, string>) => void;
|
|
onCustomIgnore: () => void;
|
|
}) {
|
|
return (
|
|
<>
|
|
{status === "UNRESOLVED" && (
|
|
<>
|
|
<PopoverMenuItem
|
|
icon={CheckIcon}
|
|
leadingIconClassName="text-success"
|
|
title="Resolved"
|
|
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
|
|
/>
|
|
<PopoverMenuItem
|
|
icon={AlarmSnoozeIcon}
|
|
leadingIconClassName="text-blue-500"
|
|
title="Ignored for 1 hour"
|
|
onClick={() =>
|
|
onAction({
|
|
taskIdentifier,
|
|
action: "ignore",
|
|
duration: String(60 * 60 * 1000),
|
|
})
|
|
}
|
|
/>
|
|
<PopoverMenuItem
|
|
icon={AlarmSnoozeIcon}
|
|
leadingIconClassName="text-blue-500"
|
|
title="Ignored for 24 hours"
|
|
onClick={() =>
|
|
onAction({
|
|
taskIdentifier,
|
|
action: "ignore",
|
|
duration: String(24 * 60 * 60 * 1000),
|
|
})
|
|
}
|
|
/>
|
|
<PopoverMenuItem
|
|
icon={BugOffIcon}
|
|
leadingIconClassName="text-blue-500"
|
|
title="Ignored forever"
|
|
onClick={() => onAction({ taskIdentifier, action: "ignore" })}
|
|
/>
|
|
<PopoverMenuItem
|
|
icon={AlarmSnoozeIcon}
|
|
leadingIconClassName="text-blue-500"
|
|
title="Ignored with custom condition…"
|
|
onClick={onCustomIgnore}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{status === "IGNORED" && (
|
|
<>
|
|
<PopoverMenuItem
|
|
icon={CheckIcon}
|
|
leadingIconClassName="text-success"
|
|
title="Resolved"
|
|
onClick={() => onAction({ taskIdentifier, action: "resolve" })}
|
|
/>
|
|
<PopoverMenuItem
|
|
icon={ArrowBackUpIcon}
|
|
leadingIconClassName="text-error"
|
|
title="Unresolved"
|
|
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{status === "RESOLVED" && (
|
|
<PopoverMenuItem
|
|
icon={ArrowBackUpIcon}
|
|
leadingIconClassName="text-error"
|
|
title="Unresolved"
|
|
onClick={() => onAction({ taskIdentifier, action: "unresolve" })}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function CustomIgnoreDialog({
|
|
open,
|
|
onOpenChange,
|
|
taskIdentifier,
|
|
formAction,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
taskIdentifier: string;
|
|
formAction?: string;
|
|
}) {
|
|
const fetcher = useFetcher<{ ok?: boolean }>();
|
|
const isSubmitting = fetcher.state !== "idle";
|
|
const [conditionError, setConditionError] = useState<string | null>(null);
|
|
const toast = useToast();
|
|
const hasHandledSuccess = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
|
|
hasHandledSuccess.current = true;
|
|
toast.success("Error ignored with custom condition");
|
|
onOpenChange(false);
|
|
}
|
|
}, [fetcher.state, fetcher.data, onOpenChange, toast]);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-md">
|
|
<DialogHeader>
|
|
<DialogTitle className="flex items-center gap-1.5">
|
|
<IconAlarmSnoozeBase className="-ml-1.5 size-6 text-blue-500" />
|
|
Custom ignore condition
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<fetcher.Form
|
|
method="post"
|
|
action={formAction}
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const formData = new FormData(e.currentTarget);
|
|
const rate = formData.get("occurrenceRate")?.toString().trim();
|
|
const total = formData.get("totalOccurrences")?.toString().trim();
|
|
|
|
if (!rate && !total) {
|
|
setConditionError("At least one unignore condition is required");
|
|
return;
|
|
}
|
|
|
|
setConditionError(null);
|
|
hasHandledSuccess.current = false;
|
|
fetcher.submit(e.currentTarget, { method: "post", action: formAction });
|
|
}}
|
|
>
|
|
<input type="hidden" name="action" value="ignore" />
|
|
<input type="hidden" name="taskIdentifier" value={taskIdentifier} />
|
|
|
|
<div className="flex flex-col gap-4 py-4">
|
|
<InputGroup fullWidth>
|
|
<Label htmlFor="occurrenceRate" variant="small">
|
|
Unignore when occurrence rate exceeds (per minute)
|
|
</Label>
|
|
<Input
|
|
id="occurrenceRate"
|
|
name="occurrenceRate"
|
|
type="number"
|
|
min={1}
|
|
placeholder="e.g. 10"
|
|
onChange={() => conditionError && setConditionError(null)}
|
|
/>
|
|
</InputGroup>
|
|
|
|
<InputGroup fullWidth>
|
|
<Label htmlFor="totalOccurrences" variant="small">
|
|
Unignore when total occurrences exceed
|
|
</Label>
|
|
<Input
|
|
id="totalOccurrences"
|
|
name="totalOccurrences"
|
|
type="number"
|
|
min={1}
|
|
placeholder="e.g. 100"
|
|
onChange={() => conditionError && setConditionError(null)}
|
|
/>
|
|
</InputGroup>
|
|
|
|
{conditionError && <FormError>{conditionError}</FormError>}
|
|
|
|
<InputGroup fullWidth>
|
|
<Label htmlFor="reason" variant="small" required={false}>
|
|
Reason
|
|
</Label>
|
|
<Input id="reason" name="reason" type="text" placeholder="e.g. Known flaky test" />
|
|
</InputGroup>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="tertiary/medium" type="button" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="primary/medium" type="submit" disabled={isSubmitting}>
|
|
{isSubmitting ? "Ignoring…" : "Ignore error"}
|
|
</Button>
|
|
</DialogFooter>
|
|
</fetcher.Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|