chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,545 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUpIcon,
|
||||
HandThumbDownIcon,
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { motion } from "framer-motion";
|
||||
import { marked } from "marked";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type loader } from "~/root";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Callout } from "./primitives/Callout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
|
||||
import { Header2 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Spinner } from "./primitives/Spinner";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./primitives/Tooltip";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
|
||||
function useKapaWebsiteId() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
if (!isManagedCloud || !websiteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const openAskAI = useCallback((question?: string) => {
|
||||
if (question) {
|
||||
setInitialQuery(question);
|
||||
} else {
|
||||
setInitialQuery(undefined);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAskAI = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setInitialQuery(undefined);
|
||||
}, []);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const aiHelp = searchParams.get("aiHelp");
|
||||
if (aiHelp) {
|
||||
// Delay to avoid hCaptcha bot detection
|
||||
window.setTimeout(() => openAskAI(aiHelp), 1000);
|
||||
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("aiHelp");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams, openAskAI]);
|
||||
|
||||
return (
|
||||
<KapaProvider
|
||||
integrationId={websiteId}
|
||||
callbacks={{
|
||||
askAI: {
|
||||
onQuerySubmit: () => openAskAI(),
|
||||
onAnswerGenerationCompleted: () => openAskAI(),
|
||||
},
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
fullWidth={isCollapsed}
|
||||
className={cn("h-full", isCollapsed && "justify-center")}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8} className="flex items-center gap-2 text-xs">
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
closeAskAI={closeAskAI}
|
||||
/>
|
||||
</KapaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIDialogProps = {
|
||||
initialQuery?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
closeAskAI: () => void;
|
||||
};
|
||||
|
||||
function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
closeAskAI();
|
||||
} else {
|
||||
onOpenChange(open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="animated-gradient-glow flex max-h-[90vh] min-h-fit w-full flex-col justify-between gap-0 px-0 pb-0 pt-0 sm:max-w-prose">
|
||||
<DialogHeader className="flex h-11 items-start justify-center rounded-t-md bg-background-bright pl-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<AISparkleIcon className="size-5" />
|
||||
<DialogTitle className="text-sm font-medium text-text-bright">Ask AI</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<ChatInterface initialQuery={initialQuery} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessages({
|
||||
conversation,
|
||||
isPreparingAnswer,
|
||||
isGeneratingAnswer,
|
||||
onReset,
|
||||
onExampleClick,
|
||||
error,
|
||||
addFeedback,
|
||||
}: {
|
||||
conversation: QA[];
|
||||
isPreparingAnswer: boolean;
|
||||
isGeneratingAnswer: boolean;
|
||||
onReset: () => void;
|
||||
onExampleClick: (question: string) => void;
|
||||
error: string | null;
|
||||
addFeedback: (
|
||||
questionAnswerId: string,
|
||||
reaction: "upvote" | "downvote",
|
||||
comment?: FeedbackComment
|
||||
) => void;
|
||||
}) {
|
||||
const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset feedback state when conversation is reset
|
||||
useEffect(() => {
|
||||
if (conversation.length === 0) {
|
||||
setFeedbackGivenForQAs(new Set());
|
||||
}
|
||||
}, [conversation.length]);
|
||||
|
||||
// Check if feedback has been given for the latest QA
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false;
|
||||
|
||||
const exampleQuestions = [
|
||||
"How do I increase my concurrency limit?",
|
||||
"How do I debug errors in my task?",
|
||||
"How do I deploy my task?",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
{conversation.length === 0 ? (
|
||||
<motion.div
|
||||
className="flex flex-col gap-2 pb-2"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Paragraph className="mb-3 mt-1.5 pl-1">
|
||||
I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev.
|
||||
</Paragraph>
|
||||
{exampleQuestions.map((question, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-border-bright px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
|
||||
onClick={() => onExampleClick(question)}
|
||||
variants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
opacity: {
|
||||
duration: 0.5,
|
||||
ease: "linear",
|
||||
},
|
||||
x: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SparkleListIcon className="size-4 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph variant="small" className="transition group-hover:text-text-bright">
|
||||
{question}
|
||||
</Paragraph>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
conversation.map((qa) => (
|
||||
<div key={qa.id || `temp-${qa.question}`} className="mb-4">
|
||||
<Header2 spacing>{qa.question}</Header2>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-text-dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(qa.answer)) }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{conversation.length > 0 &&
|
||||
!isPreparingAnswer &&
|
||||
!isGeneratingAnswer &&
|
||||
!error &&
|
||||
!latestQA?.id && (
|
||||
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Answer generation was stopped
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{conversation.length > 0 &&
|
||||
!isPreparingAnswer &&
|
||||
!isGeneratingAnswer &&
|
||||
!error &&
|
||||
latestQA?.id && (
|
||||
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
|
||||
{hasFeedbackForLatestQA ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Thanks for your feedback!
|
||||
</Paragraph>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Was this helpful?
|
||||
</Paragraph>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
if (latestQA?.id) {
|
||||
addFeedback(latestQA.id, "upvote");
|
||||
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
|
||||
}
|
||||
}}
|
||||
className="size-8 px-1.5"
|
||||
>
|
||||
<HandThumbUpIcon className="size-4 text-text-dimmed transition group-hover/button:text-success" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
if (latestQA?.id) {
|
||||
addFeedback(latestQA.id, "downvote");
|
||||
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
|
||||
}
|
||||
}}
|
||||
className="size-8 px-1.5"
|
||||
>
|
||||
<HandThumbDownIcon className="size-4 text-text-dimmed transition group-hover/button:text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPreparingAnswer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 1)",
|
||||
foreground: "rgba(217, 70, 239, 1)",
|
||||
}}
|
||||
className="size-4"
|
||||
/>
|
||||
<Paragraph className="text-text-dimmed">Preparing answer…</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col">
|
||||
<Callout variant="error" className="mb-4">
|
||||
<Paragraph className="font-semibold text-error">Error generating answer:</Paragraph>
|
||||
<Paragraph className="text-rose-300">
|
||||
{error} If the problem persists after retrying, please contact support.
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInterface({ initialQuery }: { initialQuery?: string }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasSubmittedInitialQuery = useRef(false);
|
||||
const {
|
||||
conversation,
|
||||
submitQuery,
|
||||
isGeneratingAnswer,
|
||||
isPreparingAnswer,
|
||||
resetConversation,
|
||||
stopGeneration,
|
||||
error,
|
||||
addFeedback,
|
||||
} = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery && !hasSubmittedInitialQuery.current) {
|
||||
hasSubmittedInitialQuery.current = true;
|
||||
setIsExpanded(true);
|
||||
submitQuery(initialQuery);
|
||||
}
|
||||
}, [initialQuery, submitQuery]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
setIsExpanded(true);
|
||||
submitQuery(message);
|
||||
setMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExampleClick = (question: string) => {
|
||||
setIsExpanded(true);
|
||||
submitQuery(question);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetConversation();
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full max-h-[90vh] grow flex-col overflow-y-auto rounded-b-md bg-background-bright"
|
||||
animate={{ height: isExpanded ? "90vh" : "auto" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
initial={{ height: "auto" }}
|
||||
>
|
||||
<ChatMessages
|
||||
conversation={conversation}
|
||||
isPreparingAnswer={isPreparingAnswer}
|
||||
isGeneratingAnswer={isGeneratingAnswer}
|
||||
onReset={handleReset}
|
||||
onExampleClick={handleExampleClick}
|
||||
error={error}
|
||||
addFeedback={addFeedback}
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="shrink-0 border-t border-grid-bright p-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Ask a question..."
|
||||
disabled={isGeneratingAnswer}
|
||||
autoFocus
|
||||
className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom"
|
||||
/>
|
||||
{isGeneratingAnswer ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
onClick={() => stopGeneration()}
|
||||
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<StopIcon className="z-10 size-5 text-indigo-500 transition group-hover:text-indigo-400" />
|
||||
<GradientSpinnerBackground
|
||||
className="absolute inset-0 animate-spin"
|
||||
hoverEffect
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content="Stop generating"
|
||||
/>
|
||||
) : isPreparingAnswer ? (
|
||||
<GradientSpinnerBackground className="flex size-10 min-w-10 items-center justify-center">
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 1)",
|
||||
foreground: "rgba(217, 70, 239, 1)",
|
||||
}}
|
||||
className="size-5"
|
||||
/>
|
||||
</GradientSpinnerBackground>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!message.trim()}
|
||||
LeadingIcon={<ArrowUpIcon className="size-5 text-text-bright" />}
|
||||
variant="primary/large"
|
||||
className="size-10 min-w-10 rounded-full group-disabled/button:border-border-brighter group-disabled/button:bg-surface-control"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GradientSpinnerBackground({
|
||||
children,
|
||||
className,
|
||||
hoverEffect = false,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
hoverEffect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex rounded-full bg-linear-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px ${className}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center rounded-full bg-surface-control ${
|
||||
hoverEffect ? "transition group-hover:bg-surface-control-hover" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { type ReactNode } from "react";
|
||||
import blurredDashboardBackgroundMenuTop from "~/assets/images/blurred-dashboard-background-menu-top.jpg";
|
||||
import blurredDashboardBackgroundMenuBottom from "~/assets/images/blurred-dashboard-background-menu-bottom.jpg";
|
||||
import blurredDashboardBackgroundTable from "~/assets/images/blurred-dashboard-background-table.jpg";
|
||||
|
||||
export function BackgroundWrapper({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden bg-background-dimmed lg:bg-transparent">
|
||||
<div
|
||||
className="absolute left-0 top-0 hidden w-[260px] bg-contain bg-top-left bg-no-repeat lg:block"
|
||||
style={{
|
||||
backgroundImage: `url(${blurredDashboardBackgroundMenuTop})`,
|
||||
aspectRatio: "auto",
|
||||
height: "100vh",
|
||||
backgroundSize: "260px auto",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute bottom-0 left-0 hidden w-[260px] bg-contain bg-bottom-left bg-no-repeat lg:block"
|
||||
style={{
|
||||
backgroundImage: `url(${blurredDashboardBackgroundMenuBottom})`,
|
||||
aspectRatio: "auto",
|
||||
height: "100vh",
|
||||
backgroundSize: "260px auto",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="absolute top-0 hidden bg-top-left bg-no-repeat lg:block"
|
||||
style={{
|
||||
left: "260px",
|
||||
backgroundImage: `url(${blurredDashboardBackgroundTable})`,
|
||||
width: "100%",
|
||||
height: "100vh",
|
||||
backgroundSize: "1200px auto",
|
||||
backgroundColor: "#101214",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="relative z-10 h-full w-full">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import {
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ClockIcon,
|
||||
PlusIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
RectangleGroupIcon,
|
||||
RectangleStackIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useLocation } from "react-use";
|
||||
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
|
||||
import openBulkActionsPanel from "~/assets/images/open-bulk-actions-panel.png";
|
||||
import selectRunsIndividually from "~/assets/images/select-runs-individually.png";
|
||||
import selectRunsUsingFilters from "~/assets/images/select-runs-using-filters.png";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { type BranchableEnvironmentToken } from "~/utils/branchableEnvironment";
|
||||
import { NewBranchPanel } from "~/routes/resources.branches.create";
|
||||
import { GitHubSettingsPanel } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.github";
|
||||
import {
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3CreateBulkActionPath,
|
||||
v3EnvironmentPath,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewSchedulePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "./AskAI";
|
||||
import { CodeBlock } from "./code/CodeBlock";
|
||||
import { InlineCode } from "./code/InlineCode";
|
||||
import { environmentFullTitle, EnvironmentIcon } from "./environments/EnvironmentLabel";
|
||||
import { Feedback } from "./Feedback";
|
||||
import { EnvironmentSelector } from "./navigation/EnvironmentSelector";
|
||||
import { Button, LinkButton } from "./primitives/Buttons";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { InfoPanel } from "./primitives/InfoPanel";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { StepNumber } from "./primitives/StepNumber";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
import {
|
||||
InitCommandV3,
|
||||
PackageManagerProvider,
|
||||
TriggerDeployStep,
|
||||
TriggerDevStepV3,
|
||||
} from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { V4Badge } from "./V4Badge";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You'll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We've added a few simple example tasks
|
||||
in there to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Create your first scheduled task"
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-schedules"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
How to schedule tasks
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no scheduled tasks in your project. Before you can schedule a task you need to
|
||||
create a <InlineCode>schedules.task</InlineCode>.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchedulesNoneAttached() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Attach your first schedule"
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-schedules"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
|
||||
this in the dashboard or using the SDK.
|
||||
</Paragraph>
|
||||
<div className="flex gap-2">
|
||||
<LinkButton
|
||||
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
|
||||
variant="secondary/medium"
|
||||
LeadingIcon={RectangleGroupIcon}
|
||||
className="inline-flex"
|
||||
leadingIconClassName="text-blue-500"
|
||||
>
|
||||
Use the dashboard
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Use the SDK
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchesNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Triggering batches"
|
||||
icon={Squares2X2Icon}
|
||||
iconClassName="text-batches"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton to={docsPath("triggering")} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
How to trigger batches
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no batches in this environment. You can trigger batches from your backend or from
|
||||
inside other tasks.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionsNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Sessions"
|
||||
icon={AIChatIcon}
|
||||
iconClassName="text-sessions"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("ai-chat/sessions")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Sessions docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
A session is a stateful execution of an agent, with two-way streaming and durable compute. A
|
||||
single session can have multiple runs associated with it, so one conversation can span many
|
||||
task triggers. The input stream carries incoming user messages, and the output stream
|
||||
carries everything the agent produces, including AI generation parts (text, reasoning, tool
|
||||
calls, etc.) and any custom data parts your task emits.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
The easiest way to create one is to trigger a <InlineCode>chat.agent</InlineCode> task,
|
||||
which is built on sessions and handles the chat turn loop for you. You can also call{" "}
|
||||
<InlineCode>sessions.start()</InlineCode> directly for non-chat patterns like agent inboxes,
|
||||
approval flows, or server-to-server streaming.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestHasNoTasks() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
return (
|
||||
<InfoPanel
|
||||
title="You don't have any tasks to test"
|
||||
icon={BeakerIcon}
|
||||
iconClassName="text-tests"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
>
|
||||
Create a task
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Before testing a task, you must first create one. Follow the instructions on the{" "}
|
||||
<TextLink to={v3EnvironmentPath(organization, project, environment)}>Tasks page</TextLink>{" "}
|
||||
to create a task, then return here to test it.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
return <DeploymentOnboardingSteps />;
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="→" title="Switch to a deployed environment" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-fit border border-border-bright bg-secondary hover:border-border-brighter hover:bg-surface-control"
|
||||
/>
|
||||
</StepContentContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsNoneDev() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={BellAlertIcon}
|
||||
iconClassName="text-alerts"
|
||||
title="Adding alerts"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You can get alerted when deployed runs fail.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
We don't support alerts in the Development environment. Switch to a deployed environment
|
||||
to setup alerts.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("troubleshooting-alerts")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
How to setup alerts
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsNoneDeployed() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={BellAlertIcon}
|
||||
iconClassName="text-alerts"
|
||||
title="Adding alerts"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You can get alerted when deployed runs fail. We currently support sending Slack, Email,
|
||||
and webhooks.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("troubleshooting-alerts")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Alerts docs
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function QueuesHasNoTasks() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="You don't have any queues"
|
||||
icon={RectangleStackIcon}
|
||||
iconClassName="text-queues"
|
||||
panelClassName="max-w-md"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
>
|
||||
Create a task
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Queues will appear here when you have created a task in this environment. Follow the
|
||||
instructions on the{" "}
|
||||
<TextLink to={v3EnvironmentPath(organization, project, environment)}>Tasks page</TextLink>{" "}
|
||||
to create a task, then return here to see its queue.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function NoWaitpointTokens() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="You don't have any waitpoint tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
iconClassName="text-sky-500"
|
||||
panelClassName="max-w-md"
|
||||
accessory={
|
||||
<LinkButton to={docsPath("wait-for-token")} variant="docs/small" LeadingIcon={BookOpenIcon}>
|
||||
Waitpoint docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Waitpoint tokens pause task runs until you complete the token. They're commonly used for
|
||||
approval workflows and other scenarios where you need to wait for external confirmation,
|
||||
such as human-in-the-loop processes.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchesNoBranchableEnvironment({ showSelfServe }: { showSelfServe: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const organization = useOrganization();
|
||||
|
||||
if (!isManagedCloud) {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Create a preview environment"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
iconClassName="text-preview"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
To add branches you need to have a <InlineCode>RuntimeEnvironment</InlineCode> where{" "}
|
||||
<InlineCode>isBranchableEnvironment</InlineCode> is true. We recommend creating a
|
||||
dedicated one using the "PREVIEW" type.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Upgrade to get preview branches"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
iconClassName="text-preview"
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
showSelfServe ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={<Button variant="secondary/small">Request more</Button>}
|
||||
defaultValue="enterprise"
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Preview branches in Trigger.dev create isolated environments for testing new features before
|
||||
production.
|
||||
</Paragraph>
|
||||
<Paragraph variant="small">
|
||||
You must be on <V4Badge inline /> to access preview branches. Read our{" "}
|
||||
<TextLink to={docsPath("upgrade-to-v4")}>upgrade to v4 guide</TextLink> to learn more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function BranchesNoBranches({
|
||||
env,
|
||||
limits,
|
||||
canUpgrade,
|
||||
showSelfServe,
|
||||
}: {
|
||||
env: BranchableEnvironmentToken;
|
||||
limits: { used: number; limit: number };
|
||||
canUpgrade: boolean;
|
||||
showSelfServe: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
|
||||
const envTextClassName = env === "preview" ? "text-preview" : "text-dev";
|
||||
const branchesLabel = env === "preview" ? "preview branches" : "dev branches";
|
||||
|
||||
if (limits.used >= limits.limit) {
|
||||
return (
|
||||
<InfoPanel
|
||||
title={`Upgrade to get ${branchesLabel}`}
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
iconClassName={envTextClassName}
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
showSelfServe && canUpgrade ? (
|
||||
<LinkButton variant="primary/small" to={v3BillingPath(organization)}>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant={showSelfServe ? "primary/small" : "secondary/small"}>
|
||||
Request more
|
||||
</Button>
|
||||
}
|
||||
defaultValue={showSelfServe ? "help" : "enterprise"}
|
||||
/>
|
||||
)
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You've reached the limit ({limits.used}/{limits.limit}) of branches for your plan. Upgrade
|
||||
to get branches.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Create your first branch"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
iconClassName={envTextClassName}
|
||||
panelClassName="max-w-full"
|
||||
accessory={
|
||||
<NewBranchPanel
|
||||
button={
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
leadingIconClassName="text-white"
|
||||
>
|
||||
New branch
|
||||
</Button>
|
||||
}
|
||||
env={env}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Branches are a way to test new features in isolation before merging them into the main
|
||||
environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Branches are only available when using <V4Badge inline /> or above. Read our{" "}
|
||||
<TextLink to={docsPath("upgrade-to-v4")}>v4 upgrade guide</TextLink> to learn more.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SwitcherPanel({ title = "Switch to a deployed environment" }: { title?: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-md border border-grid-bright bg-background-bright p-3">
|
||||
<Paragraph variant="small" className="grow">
|
||||
{title}
|
||||
</Paragraph>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-auto grow-0 rounded-sm bg-grid-bright"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BulkActionsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b pb-0.5">
|
||||
<Header1 spacing>Create a bulk action</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
to={v3CreateBulkActionPath(organization, project, environment)}
|
||||
>
|
||||
New bulk action
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Select runs individually" />
|
||||
<StepContentContainer className="mb-4 flex flex-col gap-4">
|
||||
<Paragraph>Select runs from the runs page individually.</Paragraph>
|
||||
<div>
|
||||
<img src={selectRunsIndividually} alt="Select runs individually" />
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
<div className="mb-5 ml-9 flex items-center gap-2">
|
||||
<div className="h-px w-full bg-grid-bright" />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
OR
|
||||
</Paragraph>
|
||||
<div className="h-px w-full bg-grid-bright" />
|
||||
</div>
|
||||
<StepNumber stepNumber="2" title="Select runs using filters" />
|
||||
<StepContentContainer className="flex flex-col gap-4">
|
||||
<Paragraph>
|
||||
Use the filter menu on the runs page to select just the runs you want to bulk action.
|
||||
</Paragraph>
|
||||
<div>
|
||||
<img src={selectRunsUsingFilters} alt="Select runs using filters" />
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Open the bulk action panel" />
|
||||
<StepContentContainer className="flex flex-col gap-4">
|
||||
<Paragraph>Click the “Bulk actions” button in the top right of the runs page.</Paragraph>
|
||||
<div>
|
||||
<img src={openBulkActionsPanel} alt="Open the bulk action panel" />
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentOnboardingSteps() {
|
||||
const environment = useEnvironment();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
|
||||
<Header1 className="truncate">
|
||||
Deploy your tasks to {environmentFullTitle(environment)}
|
||||
</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("deployment/overview")}
|
||||
/>
|
||||
}
|
||||
content="Deploy docs"
|
||||
/>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={QuestionMarkCircleIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
to={docsPath("troubleshooting#deployment")}
|
||||
/>
|
||||
}
|
||||
content="Troubleshooting docs"
|
||||
/>
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
<ClientTabs defaultValue="github">
|
||||
<ClientTabsList variant="segmented" className="mb-6">
|
||||
<ClientTabsTrigger value={"github"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"cli"} variant="segmented" layoutId="deploy-tabs">
|
||||
Manual
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"github-actions"} variant="segmented" layoutId="deploy-tabs">
|
||||
GitHub Actions
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"github"}>
|
||||
<StepNumber stepNumber="1" title="Connect your GitHub repository" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Deploy automatically with every push. Read the{" "}
|
||||
<TextLink to={docsPath("github-integration")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<div className="w-fit">
|
||||
<GitHubSettingsPanel
|
||||
organizationSlug={organization.slug}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
billingPath={v3BillingPath({ slug: organization.slug })}
|
||||
/>
|
||||
</div>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"cli"}>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'deploy' command" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
This will deploy your tasks to the {environmentFullTitle(environment)} environment.
|
||||
Read the <TextLink to={docsPath("deployment/overview")}>full guide</TextLink>.
|
||||
</Paragraph>
|
||||
<TriggerDeployStep environment={environment} />
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"github-actions"}>
|
||||
<StepNumber stepNumber="1" title="Deploy using GitHub Actions" />
|
||||
<StepContentContainer>
|
||||
<Paragraph spacing>
|
||||
Read the <TextLink to={docsPath("github-actions")}>GitHub Actions guide</TextLink> to
|
||||
get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
|
||||
<StepNumber stepNumber="2" title="Waiting for tasks to deploy" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh when your tasks are deployed.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function PromptsNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Define your first prompt"
|
||||
icon={AIChatIcon}
|
||||
iconClassName="text-aiPrompts"
|
||||
panelClassName="max-w-lg"
|
||||
accessory={
|
||||
<LinkButton
|
||||
to={docsPath("prompt-management")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Prompts docs
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Managed prompts let you define AI prompts in code with typesafe variables, then edit and
|
||||
version them from the dashboard without redeploying.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
Add a prompt to your project using <InlineCode variant="small">prompts.define()</InlineCode>
|
||||
:
|
||||
</Paragraph>
|
||||
<CodeBlock
|
||||
code={`import { prompts } from "@trigger.dev/sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
export const myPrompt = prompts.define({
|
||||
id: "my-prompt",
|
||||
variables: z.object({
|
||||
name: z.string(),
|
||||
}),
|
||||
content: \`Hello {{name}}!\`,
|
||||
});`}
|
||||
showLineNumbers={false}
|
||||
showOpenInModal={false}
|
||||
/>
|
||||
<Paragraph variant="small" className="mt-2">
|
||||
Deploy your project and your prompts will appear here with version history and a live
|
||||
editor.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
filterIcon,
|
||||
filterTitle,
|
||||
type TaskRunListSearchFilterKey,
|
||||
type TaskRunListSearchFilters,
|
||||
} from "./runs/v3/RunFilters";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import simplur from "simplur";
|
||||
import { appliedSummary, dateFromString, timeFilterRenderValues } from "./runs/v3/SharedFilters";
|
||||
import { formatNumber } from "~/utils/numberFormatter";
|
||||
import { SpinnerWhite } from "./primitives/Spinner";
|
||||
import { ArrowPathIcon, CheckIcon, XCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { XCircleIcon as XCircleIconOutline } from "@heroicons/react/24/outline";
|
||||
import assertNever from "assert-never";
|
||||
import { AppliedFilter } from "./primitives/AppliedFilter";
|
||||
import { runStatusTitle } from "./runs/v3/TaskRunStatus";
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
|
||||
export const BulkActionMode = z.union([z.literal("selected"), z.literal("filter")]);
|
||||
export type BulkActionMode = z.infer<typeof BulkActionMode>;
|
||||
export const BulkActionAction = z.union([z.literal("cancel"), z.literal("replay")]);
|
||||
export type BulkActionAction = z.infer<typeof BulkActionAction>;
|
||||
|
||||
export function BulkActionFilterSummary({
|
||||
selected,
|
||||
final = false,
|
||||
mode,
|
||||
action,
|
||||
filters,
|
||||
}: {
|
||||
selected?: number;
|
||||
final?: boolean;
|
||||
mode: BulkActionMode;
|
||||
action: BulkActionAction;
|
||||
filters: TaskRunListSearchFilters;
|
||||
}) {
|
||||
switch (mode) {
|
||||
case "selected":
|
||||
return (
|
||||
<Paragraph variant="small">
|
||||
You {!final ? "have " : " "}individually selected {simplur`${selected} run[|s]`} to be{" "}
|
||||
<Action action={action} />.
|
||||
</Paragraph>
|
||||
);
|
||||
case "filter": {
|
||||
const {
|
||||
label,
|
||||
valueLabel,
|
||||
rangeType: _rangeType,
|
||||
} = timeFilterRenderValues({
|
||||
from: filters.from ? dateFromString(`${filters.from}`) : undefined,
|
||||
to: filters.to ? dateFromString(`${filters.to}`) : undefined,
|
||||
period: filters.period,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Paragraph variant="small">
|
||||
You {!final ? "have " : " "}selected{" "}
|
||||
<span className="text-text-bright">
|
||||
{final ? selected : <EstimatedCount count={selected} />}
|
||||
</span>{" "}
|
||||
runs to be <Action action={action} /> using these filters:
|
||||
</Paragraph>
|
||||
<div className="flex flex-col gap-2">
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
label={label}
|
||||
icon={filterIcon("period")}
|
||||
value={valueLabel}
|
||||
removable={false}
|
||||
/>
|
||||
{Object.entries(filters).map(([key, value]) => {
|
||||
if (!value && key !== "period") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const typedKey = key as TaskRunListSearchFilterKey;
|
||||
|
||||
switch (typedKey) {
|
||||
case "cursor":
|
||||
case "direction":
|
||||
case "environments":
|
||||
//We need to handle time differently because we have a default
|
||||
case "period":
|
||||
case "from":
|
||||
case "to": {
|
||||
return null;
|
||||
}
|
||||
case "tasks": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "versions": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "statuses": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values.map((v) => runStatusTitle(v as TaskRunStatus)))}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "tags": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "bulkId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "rootOnly": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Root only"}
|
||||
icon={filterIcon(key)}
|
||||
value={
|
||||
value ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<XCircleIcon className="size-4" />
|
||||
)
|
||||
}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "runId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Run ID"}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "batchId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Batch ID"}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "scheduleId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Schedule ID"}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "queues": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values.map((v) => v.replace("task/", "")))}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "regions": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "machines": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "errorId": {
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={"Error ID"}
|
||||
icon={filterIcon(key)}
|
||||
value={value}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "sources": {
|
||||
const values = Array.isArray(value) ? value : [`${value}`];
|
||||
return (
|
||||
<AppliedFilter
|
||||
variant="minimal/medium"
|
||||
key={key}
|
||||
label={filterTitle(key)}
|
||||
icon={filterIcon(key)}
|
||||
value={appliedSummary(values)}
|
||||
removable={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
assertNever(typedKey);
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Action({ action }: { action: BulkActionAction }) {
|
||||
switch (action) {
|
||||
case "cancel":
|
||||
return (
|
||||
<span>
|
||||
<XCircleIconOutline className="mb-0.5 inline-block size-4 text-error" />
|
||||
<span className="ml-0.5 text-text-bright">Canceled</span>
|
||||
</span>
|
||||
);
|
||||
case "replay":
|
||||
return (
|
||||
<span>
|
||||
<ArrowPathIcon className="mb-0.5 inline-block size-4 text-blue-400" />
|
||||
<span className="ml-0.5 text-text-bright">Replayed</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function EstimatedCount({ count }: { count?: number }) {
|
||||
if (typeof count === "number") {
|
||||
return <>~{formatNumber(count)}</>;
|
||||
}
|
||||
|
||||
return <SpinnerWhite className="mx-0.5 -mt-0.5 inline size-3" />;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function cloudProviderTitle(provider: "aws" | "digitalocean" | (string & {})) {
|
||||
switch (provider) {
|
||||
case "aws":
|
||||
return "Amazon Web Services";
|
||||
case "digitalocean":
|
||||
return "Digital Ocean";
|
||||
default:
|
||||
return provider;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
|
||||
|
||||
export function DefinitionTip({
|
||||
content,
|
||||
children,
|
||||
title,
|
||||
disableHoverableContent = true,
|
||||
}: {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
disableHoverableContent?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent={disableHoverableContent}>
|
||||
<TooltipTrigger className="text-left">
|
||||
<span className="cursor-default underline decoration-text-faint decoration-dashed underline-offset-4 transition hover:decoration-text-dimmed">
|
||||
{children}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="end" side="right" className="w-64 min-w-64">
|
||||
<Header3 className="mb-1">{title}</Header3>
|
||||
{typeof content === "string" ? (
|
||||
<Paragraph variant="small">{content}</Paragraph>
|
||||
) : (
|
||||
<div>{content}</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
CheckingConnectionIcon,
|
||||
ConnectedIcon,
|
||||
DisconnectedIcon,
|
||||
} from "~/assets/icons/ConnectionIcons";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
import connectedImage from "../assets/images/cli-connected.png";
|
||||
import disconnectedImage from "../assets/images/cli-disconnected.png";
|
||||
import { InlineCode } from "./code/InlineCode";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
|
||||
|
||||
// Define Context types
|
||||
type DevPresenceContextType = {
|
||||
isConnected: boolean | undefined;
|
||||
};
|
||||
|
||||
// Create Context with default values
|
||||
const DevPresenceContext = createContext<DevPresenceContextType>({
|
||||
isConnected: undefined,
|
||||
});
|
||||
|
||||
// Provider component with enabled prop
|
||||
interface DevPresenceProviderProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function DevPresenceProvider({ children, enabled = true }: DevPresenceProviderProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Only subscribe to event source if enabled is true
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/presence`,
|
||||
{
|
||||
event: "presence",
|
||||
disabled: !enabled,
|
||||
}
|
||||
);
|
||||
|
||||
const [isConnected, setIsConnected] = useState<boolean | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
// If disabled or no events
|
||||
if (!enabled || streamedEvents === null) {
|
||||
setIsConnected(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(streamedEvents) as any;
|
||||
if ("isConnected" in data && data.isConnected) {
|
||||
try {
|
||||
setIsConnected(true);
|
||||
} catch (error) {
|
||||
console.log("DevPresence: Failed to parse lastSeen timestamp", { error });
|
||||
setIsConnected(false);
|
||||
}
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("DevPresence: Failed to parse presence message", { error });
|
||||
setIsConnected(false);
|
||||
}
|
||||
}, [streamedEvents, enabled]);
|
||||
|
||||
// Calculate isConnected and memoize the context value
|
||||
const contextValue = useMemo(() => {
|
||||
return { isConnected };
|
||||
}, [isConnected, enabled]);
|
||||
|
||||
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
|
||||
}
|
||||
|
||||
// Custom hook to use the context
|
||||
export function useDevPresence() {
|
||||
const context = useContext(DevPresenceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDevPresence must be used within a DevPresenceProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* We need this for the legacy v1 engine, where we show the banner after a delay if there are no events.
|
||||
*/
|
||||
export function useCrossEngineIsConnected({
|
||||
isCompleted,
|
||||
logCount,
|
||||
}: {
|
||||
isCompleted: boolean;
|
||||
logCount: number;
|
||||
}) {
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { isConnected } = useDevPresence();
|
||||
const [crossEngineIsConnected, setCrossEngineIsConnected] = useState<boolean | undefined>(
|
||||
undefined
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (project.engine === "V2") {
|
||||
setCrossEngineIsConnected(isConnected);
|
||||
return;
|
||||
}
|
||||
|
||||
if (project.engine === "V1") {
|
||||
if (isCompleted) {
|
||||
setCrossEngineIsConnected(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (logCount <= 1) {
|
||||
const timer = setTimeout(() => {
|
||||
setCrossEngineIsConnected(false);
|
||||
}, 5000);
|
||||
return () => clearTimeout(timer);
|
||||
} else {
|
||||
setCrossEngineIsConnected(true);
|
||||
}
|
||||
}
|
||||
}, [environment.type, project.engine, logCount, isConnected, isCompleted]);
|
||||
|
||||
return crossEngineIsConnected;
|
||||
}
|
||||
|
||||
export function ConnectionIcon({ isConnected }: { isConnected: boolean | undefined }) {
|
||||
if (isConnected === undefined) {
|
||||
return <CheckingConnectionIcon className="size-5" />;
|
||||
}
|
||||
return isConnected ? (
|
||||
<ConnectedIcon className="size-5" />
|
||||
) : (
|
||||
<DisconnectedIcon className="size-5" />
|
||||
);
|
||||
}
|
||||
|
||||
export function DevPresencePanel({ isConnected }: { isConnected: boolean | undefined }) {
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your dev server is connected"
|
||||
: "Your dev server is not connected"}
|
||||
</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-3 px-2">
|
||||
<div className="flex flex-col items-center justify-center gap-6 px-6 py-10">
|
||||
<img
|
||||
src={isConnected === true ? connectedImage : disconnectedImage}
|
||||
alt={isConnected === true ? "Connected" : "Disconnected"}
|
||||
width={282}
|
||||
height={45}
|
||||
/>
|
||||
<Paragraph variant="small" className={isConnected ? "text-success" : "text-error"}>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your local dev server is connected to Trigger.dev"
|
||||
: "Your local dev server is not connected to Trigger.dev"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{isConnected ? null : (
|
||||
<div className="space-y-3">
|
||||
<PackageManagerProvider>
|
||||
<TriggerDevStepV3 title="Run this command to connect" />
|
||||
</PackageManagerProvider>
|
||||
<Paragraph variant="small">
|
||||
Run this CLI <InlineCode variant="extra-small">dev</InlineCode> command to connect to
|
||||
the Trigger.dev servers to start developing locally. Keep it running while you develop
|
||||
to stay connected. Learn more in the{" "}
|
||||
<TextLink to={docsPath("cli-dev")}>CLI docs</TextLink>.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
export function DevDisconnectedBanner({ isConnected }: { isConnected: boolean | undefined }) {
|
||||
return (
|
||||
<Dialog>
|
||||
<AnimatePresence>
|
||||
{isConnected === false && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="flex"
|
||||
>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="border border-error/20 bg-error/10 py-1 pl-1 pr-2 group-hover/button:border-error/30 group-hover/button:bg-error/20"
|
||||
iconSpacing="gap-1"
|
||||
LeadingIcon={<ConnectionIcon isConnected={false} />}
|
||||
>
|
||||
Your local dev server is not connected to Trigger.dev
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<DevPresencePanel isConnected={isConnected} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { HomeIcon } from "@heroicons/react/20/solid";
|
||||
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
|
||||
import { friendlyErrorDisplay } from "~/utils/httpErrors";
|
||||
import { permissionDeniedMessage } from "~/utils/permissionDenied";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { PermissionDenied } from "./PermissionDenied";
|
||||
import { TriggerRotatingLogo } from "./TriggerRotatingLogo";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
button?: {
|
||||
title: string;
|
||||
to: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function RouteErrorDisplay(options?: ErrorDisplayOptions) {
|
||||
const error = useRouteError();
|
||||
|
||||
// A failed `authorization` check (or `throwPermissionDenied`) throws a 403
|
||||
// that bubbles to the nearest route ErrorBoundary. Every layout boundary
|
||||
// renders through here, so handling it once means a gated route only has to
|
||||
// declare `authorization` to get the permission panel: no per-route boundary.
|
||||
const permission = isRouteErrorResponse(error) ? permissionDeniedMessage(error.data) : null;
|
||||
if (permission) {
|
||||
return (
|
||||
<div className="flex min-h-screen w-full items-center justify-center p-4">
|
||||
<div className="w-full max-w-md">
|
||||
<PermissionDenied message={permission} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isRouteErrorResponse(error) ? (
|
||||
<ErrorDisplay
|
||||
title={friendlyErrorDisplay(error.status, error.statusText).title}
|
||||
message={
|
||||
error.data.message ?? friendlyErrorDisplay(error.status, error.statusText).message
|
||||
}
|
||||
{...options}
|
||||
/>
|
||||
) : error instanceof Error ? (
|
||||
<ErrorDisplay title={error.name} message={error.message} {...options} />
|
||||
) : (
|
||||
<ErrorDisplay title="Oops" message={JSON.stringify(error)} {...options} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type DisplayOptionsProps = {
|
||||
title: string;
|
||||
message?: ReactNode;
|
||||
} & ErrorDisplayOptions;
|
||||
|
||||
export function ErrorDisplay({ title, message, button }: DisplayOptionsProps) {
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center overflow-y-auto bg-[#16181C]">
|
||||
<div className="z-10 mt-[30vh] flex shrink-0 flex-col items-center gap-8">
|
||||
<Header1>{title}</Header1>
|
||||
{message && <Paragraph>{message}</Paragraph>}
|
||||
<LinkButton
|
||||
to={button ? button.to : "/"}
|
||||
shortcut={{ key: "enter" }}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={HomeIcon}
|
||||
>
|
||||
{button ? button.title : "Go to homepage"}
|
||||
</LinkButton>
|
||||
</div>
|
||||
<TriggerRotatingLogo />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./primitives/Badge";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
|
||||
export function AlphaBadge({
|
||||
inline = false,
|
||||
className,
|
||||
}: {
|
||||
inline?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Alpha
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Alpha"
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlphaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<AlphaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaBadge({ inline = false, className }: { inline?: boolean; className?: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
Beta
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is in Beta"
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BetaTitle({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<BetaBadge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function NewBadge({ inline = false, className }: { inline?: boolean; className?: string }) {
|
||||
return (
|
||||
<Badge
|
||||
variant="extra-small"
|
||||
className={cn("text-success", inline ? "inline-grid" : "", className)}
|
||||
>
|
||||
New
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import {
|
||||
getFormProps,
|
||||
getSelectProps,
|
||||
getInputProps,
|
||||
getTextareaProps,
|
||||
useForm,
|
||||
} from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { InformationCircleIcon, ArrowUpCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { EnvelopeIcon, ShieldCheckIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation, useSearchParams } from "@remix-run/react";
|
||||
import { type ReactNode, useEffect, useState } from "react";
|
||||
import { type FeedbackType, feedbackTypes, schema } from "~/routes/resources.feedback";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
|
||||
import { Fieldset } from "./primitives/Fieldset";
|
||||
import { FormButtons } from "./primitives/FormButtons";
|
||||
import { FormError } from "./primitives/FormError";
|
||||
import { Icon } from "./primitives/Icon";
|
||||
import { InfoPanel } from "./primitives/InfoPanel";
|
||||
import { InputGroup } from "./primitives/InputGroup";
|
||||
import { Label } from "./primitives/Label";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Select, SelectItem } from "./primitives/Select";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function Feedback({ button, defaultValue = "bug", onOpenChange }: FeedbackProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const [type, setType] = useState<FeedbackType>(defaultValue);
|
||||
|
||||
const [form, fields] = useForm({
|
||||
id: "accept-invite",
|
||||
lastResult: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onInput",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
navigation.formAction === "/resources/feedback" &&
|
||||
navigation.state === "loading" &&
|
||||
Object.keys(form.allErrors).length === 0
|
||||
) {
|
||||
setOpen(false);
|
||||
}
|
||||
}, [navigation.formAction, navigation.state, form.allErrors]);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const open = searchParams.get("feedbackPanel");
|
||||
if (open) {
|
||||
setType(open as FeedbackType);
|
||||
setOpen(true);
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("feedbackPanel");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const handleOpenChange = (value: boolean) => {
|
||||
setOpen(value);
|
||||
onOpenChange?.(value);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogTrigger asChild>{button}</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>Contact us</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<Icon icon={EnvelopeIcon} className="size-10 min-w-10 text-blue-500" />
|
||||
<Paragraph variant="base/bright">
|
||||
How can we help? We read every message and will respond as quickly as we can.
|
||||
</Paragraph>
|
||||
</div>
|
||||
{!(
|
||||
type === "feature" ||
|
||||
type === "help" ||
|
||||
type === "concurrency" ||
|
||||
type === "hipaa"
|
||||
) && <hr className="border-grid-dimmed" />}
|
||||
<Form
|
||||
method="post"
|
||||
action="/resources/feedback"
|
||||
{...getFormProps(form)}
|
||||
className="w-full"
|
||||
>
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<input
|
||||
value={location.pathname}
|
||||
{...getInputProps(fields.path, { type: "hidden" })}
|
||||
/>
|
||||
<InputGroup className="max-w-full">
|
||||
{type === "feature" && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
All our feature requests are public and voted on by the community. The best
|
||||
way to submit your feature request is to{" "}
|
||||
<TextLink to="https://feedback.trigger.dev">
|
||||
post it to our feedback forum
|
||||
</TextLink>
|
||||
.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
{type === "help" && (
|
||||
<InfoPanel
|
||||
icon={InformationCircleIcon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
The quickest way to get answers from the Trigger.dev team and community is to{" "}
|
||||
<TextLink to="https://trigger.dev/discord">ask in our Discord</TextLink>.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
{type === "concurrency" && (
|
||||
<InfoPanel
|
||||
icon={ArrowUpCircleIcon}
|
||||
iconClassName="text-indigo-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
How much extra concurrency do you need? You can add bundles of 50 for
|
||||
$50/month each. To help us advise you, please let us know what your tasks do,
|
||||
your typical run volume, and if your workload is spiky (many runs at once).
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
{type === "hipaa" && (
|
||||
<InfoPanel
|
||||
icon={ShieldCheckIcon}
|
||||
iconClassName="text-green-500"
|
||||
panelClassName="w-full mb-2"
|
||||
>
|
||||
<Paragraph variant="small">
|
||||
We offer a signed Business Associate Agreement (BAA) as a paid add-on on any
|
||||
paid plan. To help us get back to you quickly, please include your company
|
||||
name, and a brief description of the PHI workload you plan to run.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
)}
|
||||
<Select
|
||||
{...getSelectProps(fields.feedbackType)}
|
||||
variant="tertiary/medium"
|
||||
value={type}
|
||||
defaultValue={type}
|
||||
setValue={(v) => setType(v as FeedbackType)}
|
||||
placeholder="Select type"
|
||||
text={(value) => feedbackTypes[value as FeedbackType].label}
|
||||
dropdownIcon
|
||||
>
|
||||
{Object.entries(feedbackTypes).map(([name, { label }]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormError id={fields.feedbackType.errorId}>{fields.feedbackType.errors}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
<Label>Message</Label>
|
||||
<TextArea {...getTextareaProps(fields.message)} />
|
||||
<FormError id={fields.message.errorId}>{fields.message.errors}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.errors}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium">
|
||||
Send message
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<DialogClose asChild>
|
||||
<Button variant="tertiary/medium">Cancel</Button>
|
||||
</DialogClose>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type GitHubLoginButtonProps = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export function GitHubLoginButton({
|
||||
label = "Continue with GitHub",
|
||||
className,
|
||||
onClick,
|
||||
}: GitHubLoginButtonProps) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
"flex items-center justify-center gap-2 rounded-md bg-indigo-800 py-3 pl-5 pr-6 text-lg font-medium text-white transition hover:bg-indigo-700",
|
||||
className
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<OctoKitty className="h-5 w-5" />
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function OctoKitty({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
version="1.2"
|
||||
baseProfile="tiny"
|
||||
id="Layer_1"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 2350 2314.8"
|
||||
xmlSpace="preserve"
|
||||
fill="currentColor"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
d="M1175,0C525.8,0,0,525.8,0,1175c0,552.2,378.9,1010.5,890.1,1139.7c-5.9-14.7-8.8-35.3-8.8-55.8v-199.8H734.4
|
||||
c-79.3,0-152.8-35.2-185.1-99.9c-38.2-70.5-44.1-179.2-141-246.8c-29.4-23.5-5.9-47,26.4-44.1c61.7,17.6,111.6,58.8,158.6,120.4
|
||||
c47,61.7,67.6,76.4,155.7,76.4c41.1,0,105.7-2.9,164.5-11.8c32.3-82.3,88.1-155.7,155.7-190.9c-393.6-47-581.6-240.9-581.6-505.3
|
||||
c0-114.6,49.9-223.3,132.2-317.3c-26.4-91.1-61.7-279.1,11.8-352.5c176.3,0,282,114.6,308.4,143.9c88.1-29.4,185.1-47,284.9-47
|
||||
c102.8,0,196.8,17.6,284.9,47c26.4-29.4,132.2-143.9,308.4-143.9c70.5,70.5,38.2,261.4,8.8,352.5c82.3,91.1,129.3,202.7,129.3,317.3
|
||||
c0,264.4-185.1,458.3-575.7,499.4c108.7,55.8,185.1,214.4,185.1,331.9V2256c0,8.8-2.9,17.6-2.9,26.4
|
||||
C2021,2123.8,2350,1689.1,2350,1175C2350,525.8,1824.2,0,1175,0L1175,0z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { GitPullRequestIcon, GitCommitIcon, GitBranchIcon } from "lucide-react";
|
||||
import { type GitMetaLinks } from "~/presenters/v3/BranchesPresenter.server";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
|
||||
export function GitMetadata({ git }: { git?: GitMetaLinks | null }) {
|
||||
if (!git) return null;
|
||||
return (
|
||||
<>
|
||||
{git.pullRequestUrl && git.pullRequestNumber && <GitMetadataPullRequest git={git} />}
|
||||
{git.branchUrl && <GitMetadataBranch git={git} />}
|
||||
{git.shortSha && <GitMetadataCommit git={git} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitMetadataBranch({
|
||||
git,
|
||||
}: {
|
||||
git: Pick<GitMetaLinks, "branchUrl" | "branchName">;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<GitBranchIcon className="size-4" />}
|
||||
leadingIconClassName="group-hover/table-row:text-text-bright"
|
||||
iconSpacing="gap-x-1"
|
||||
to={git.branchUrl}
|
||||
className="pl-1 duration-0 [&_span]:duration-0 group-hover/table-row:[&_span]:text-text-bright"
|
||||
>
|
||||
{git.branchName}
|
||||
</LinkButton>
|
||||
}
|
||||
content="Jump to GitHub branch"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitMetadataCommit({
|
||||
git,
|
||||
}: {
|
||||
git: Pick<GitMetaLinks, "commitUrl" | "shortSha" | "commitMessage">;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={git.commitUrl}
|
||||
LeadingIcon={<GitCommitIcon className="size-4" />}
|
||||
leadingIconClassName="group-hover/table-row:text-text-bright"
|
||||
iconSpacing="gap-x-1"
|
||||
className="pl-1 duration-0 [&_span]:duration-0 group-hover/table-row:[&_span]:text-text-bright"
|
||||
>
|
||||
{`${git.shortSha} / ${git.commitMessage}`}
|
||||
</LinkButton>
|
||||
}
|
||||
content="Jump to GitHub commit"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitMetadataPullRequest({
|
||||
git,
|
||||
}: {
|
||||
git: Pick<GitMetaLinks, "pullRequestUrl" | "pullRequestNumber" | "pullRequestTitle">;
|
||||
}) {
|
||||
if (!git.pullRequestUrl || !git.pullRequestNumber) return null;
|
||||
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
to={git.pullRequestUrl}
|
||||
LeadingIcon={<GitPullRequestIcon className="size-4" />}
|
||||
leadingIconClassName="group-hover/table-row:text-text-bright"
|
||||
iconSpacing="gap-x-1"
|
||||
className="pl-1 duration-0 [&_span]:duration-0 group-hover/table-row:[&_span]:text-text-bright"
|
||||
>
|
||||
#{git.pullRequestNumber} {git.pullRequestTitle}
|
||||
</LinkButton>
|
||||
}
|
||||
content="Jump to GitHub pull request"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { UserCrossIcon } from "~/assets/icons/UserCrossIcon";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./primitives/Tooltip";
|
||||
|
||||
export function ImpersonationBanner() {
|
||||
return (
|
||||
<div>
|
||||
<Form action="/resources/impersonation" method="delete" reloadDocument>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={UserCrossIcon}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="text-amber-400"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Stop impersonating
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { z } from "zod";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type List = {
|
||||
pagination: {
|
||||
next?: string | undefined;
|
||||
previous?: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export const DirectionSchema = z.union([z.literal("forward"), z.literal("backward")]);
|
||||
export type Direction = z.infer<typeof DirectionSchema>;
|
||||
|
||||
export function ListPagination({ list, className }: { list: List; className?: string }) {
|
||||
const bothDisabled = !list.pagination.previous && !list.pagination.next;
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center", className)}>
|
||||
<PreviousButton cursor={list.pagination.previous} />
|
||||
<NextButton cursor={list.pagination.next} />
|
||||
<div
|
||||
className={cn(
|
||||
"order-2 h-6 w-px bg-surface-control transition-colors peer-hover/next:bg-surface-control-hover peer-hover/prev:bg-surface-control-hover",
|
||||
bothDisabled && "opacity-30"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PreviousButton({ cursor }: { cursor?: string }) {
|
||||
const path = useCursorPath(cursor, "backward");
|
||||
|
||||
return (
|
||||
<div className={cn("peer/prev order-1", !path && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"secondary/small"}
|
||||
LeadingIcon={ChevronLeftIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-r-none border-r-0 pl-2 pr-2.25",
|
||||
!path && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
shortcut={{ key: "j" }}
|
||||
tooltip="Previous"
|
||||
disabled={!path}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NextButton({ cursor }: { cursor?: string }) {
|
||||
const path = useCursorPath(cursor, "forward");
|
||||
|
||||
return (
|
||||
<div className={cn("peer/next order-3", !path && "pointer-events-none")}>
|
||||
<LinkButton
|
||||
to={path ?? "#"}
|
||||
variant={"secondary/small"}
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
className={cn(
|
||||
"flex items-center rounded-l-none border-l-0 pl-2.25 pr-2",
|
||||
!path && "cursor-not-allowed opacity-50"
|
||||
)}
|
||||
onClick={(e) => !path && e.preventDefault()}
|
||||
shortcut={{ key: "k" }}
|
||||
tooltip="Next"
|
||||
disabled={!path}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function useCursorPath(cursor: string | undefined, direction: Direction) {
|
||||
const location = useLocation();
|
||||
|
||||
if (!cursor) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const search = new URLSearchParams(location.search);
|
||||
search.set("cursor", cursor);
|
||||
search.set("direction", direction);
|
||||
return location.pathname + "?" + search.toString();
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { LogLevel } from "./logs/LogLevel";
|
||||
|
||||
export function LogLevelTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<Header3>Log Levels</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Structured logging helps you debug and monitor your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="TRACE" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Traces and spans representing the execution flow of your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="INFO" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
General informational messages about task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="WARN" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Warning messages indicating potential issues that don't prevent execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="ERROR" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Error messages for failures and exceptions during task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1">
|
||||
<LogLevel level="DEBUG" />
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Detailed diagnostic information for development and debugging.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { AppsmithLogo } from "~/assets/logos/AppsmithLogo";
|
||||
import { CalComLogo } from "~/assets/logos/CalComLogo";
|
||||
import { LyftLogo } from "~/assets/logos/LyftLogo";
|
||||
import { MiddayLogo } from "~/assets/logos/MiddayLogo";
|
||||
import { TldrawLogo } from "~/assets/logos/TldrawLogo";
|
||||
import { UnkeyLogo } from "~/assets/logos/UnkeyLogo";
|
||||
import { LogoType } from "./LogoType";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
|
||||
interface QuoteType {
|
||||
quote: string;
|
||||
person: string;
|
||||
}
|
||||
|
||||
const quotes: QuoteType[] = [
|
||||
{
|
||||
quote: "Trigger.dev is redefining background jobs for modern developers.",
|
||||
person: "Paul Copplestone, Supabase",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"Trigger.dev is a great way to automate email campaigns with Resend, and we've heard nothing but good things from our mutual customers.",
|
||||
person: "Zeno Rocha, Resend",
|
||||
},
|
||||
{
|
||||
quote: "We love Trigger.dev and it’s had a big impact in dev iteration velocity already.",
|
||||
person: "André Neves, ZBD",
|
||||
},
|
||||
{
|
||||
quote:
|
||||
"We’ve been looking for a product like Trigger.dev for a really long time - automation that's simple and developer-focused.",
|
||||
person: "Han Wang, Mintlify",
|
||||
},
|
||||
];
|
||||
|
||||
export function LoginPageLayout({
|
||||
children,
|
||||
rightContent,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/** Replaces the default testimonials panel on the right (e.g. a promo highlight). */
|
||||
rightContent?: React.ReactNode;
|
||||
}) {
|
||||
const [randomQuote, setRandomQuote] = useState<QuoteType | null>(null);
|
||||
useEffect(() => {
|
||||
const randomIndex = Math.floor(Math.random() * quotes.length);
|
||||
setRandomQuote(quotes[randomIndex]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<main className="grid h-full grid-cols-1 lg:grid-cols-2">
|
||||
<div className="bg-background-dimmed lg:border-r lg:border-grid-bright lg:bg-background-bright">
|
||||
<div className="flex h-full flex-col items-center justify-center p-6 lg:justify-between">
|
||||
<div className="hidden w-full items-center justify-between lg:flex">
|
||||
<a href="https://trigger.dev">
|
||||
<LogoType className="w-36" />
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex h-full w-full max-w-xs items-center justify-center">
|
||||
<div className="w-full">{children}</div>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-center">
|
||||
Having login issues? <TextLink href="https://trigger.dev/contact">Email us</TextLink> or{" "}
|
||||
<TextLink href="https://trigger.dev/discord">ask us in Discord</TextLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden grid-rows-[1fr_auto] pb-6 lg:grid">
|
||||
{rightContent ?? (
|
||||
<>
|
||||
<div className="flex h-full flex-col items-center justify-center px-16">
|
||||
<Header3 className="relative text-center text-2xl font-normal leading-8 text-text-dimmed transition before:relative before:right-1 before:top-0 before:text-6xl before:text-charcoal-750 before:content-['❝'] lg-height:text-xl md-height:text-lg">
|
||||
{randomQuote?.quote}
|
||||
</Header3>
|
||||
<Paragraph className="mt-4 text-text-dimmed/60">{randomQuote?.person}</Paragraph>
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-4 px-8">
|
||||
<Paragraph>Trusted by developers at</Paragraph>
|
||||
<div className="flex w-full flex-wrap items-center justify-center gap-x-6 gap-y-3 text-text-faint xl:justify-between xl:gap-0">
|
||||
<LyftLogo className="w-11" />
|
||||
<UnkeyLogo />
|
||||
<MiddayLogo />
|
||||
<AppsmithLogo />
|
||||
<CalComLogo />
|
||||
<TldrawLogo />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export function LogoIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
width="321"
|
||||
height="282"
|
||||
viewBox="0 0 321 282"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M96.1017 113.4L160.679 4.57764e-05L320.718 281.045H0.638916L65.2159 167.642L110.896 194.382L92.0035 227.561H229.354L160.679 106.965L141.786 140.144L96.1017 113.4Z"
|
||||
fill="url(#paint0_linear_465_1663)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_465_1663"
|
||||
x1="320.718"
|
||||
y1="140.687"
|
||||
x2="0.638918"
|
||||
y2="140.687"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
export function LogoType({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 751 130" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path
|
||||
d="M195.022 16.2676H135.445H137.799V32.5096H157.858V102.4H174.84V32.5096H195.022V16.2676Z"
|
||||
fill="url(#paint0_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M211.265 51.4587V40.8767H195.391V102.4H211.265V72.9917C211.265 60.0719 221.725 56.3805 229.97 57.3648V39.6463C222.218 39.6463 214.465 43.0916 211.265 51.4587Z"
|
||||
fill="url(#paint1_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M246.954 33.494C252.368 33.494 256.799 29.0644 256.799 23.7734C256.799 18.4824 252.368 13.9297 246.954 13.9297C241.662 13.9297 237.232 18.4824 237.232 23.7734C237.232 29.0644 241.662 33.494 246.954 33.494ZM239.078 102.4H254.953V40.8767H239.078V102.4Z"
|
||||
fill="url(#paint2_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M315.253 40.8768V48.5056C310.946 42.7224 304.301 39.1542 295.563 39.1542C278.089 39.1542 264.921 53.4275 264.921 70.6539C264.921 88.0033 278.089 102.154 295.563 102.154C304.301 102.154 310.946 98.5853 315.253 92.8021V99.4466C315.253 109.167 309.1 114.581 299.132 114.581C289.656 114.581 285.596 110.767 283.011 105.968L269.475 113.72C274.889 123.687 285.472 128.731 298.64 128.731C314.884 128.731 330.758 119.626 330.758 99.4466V40.8768H315.253ZM298.025 87.5112C288.057 87.5112 280.796 80.4975 280.796 70.6539C280.796 60.9332 288.057 53.9196 298.025 53.9196C307.992 53.9196 315.253 60.9332 315.253 70.6539C315.253 80.4975 307.992 87.5112 298.025 87.5112Z"
|
||||
fill="url(#paint3_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M390.936 40.8768V48.5056C386.629 42.7224 379.983 39.1542 371.246 39.1542C353.772 39.1542 340.604 53.4275 340.604 70.6539C340.604 88.0033 353.772 102.154 371.246 102.154C379.983 102.154 386.629 98.5853 390.936 92.8021V99.4466C390.936 109.167 384.783 114.581 374.815 114.581C365.339 114.581 361.278 110.767 358.694 105.968L345.157 113.72C350.572 123.687 361.155 128.731 374.322 128.731C390.566 128.731 406.441 119.626 406.441 99.4466V40.8768H390.936ZM373.707 87.5112C363.739 87.5112 356.479 80.4975 356.479 70.6539C356.479 60.9332 363.739 53.9196 373.707 53.9196C383.675 53.9196 390.936 60.9332 390.936 70.6539C390.936 80.4975 383.675 87.5112 373.707 87.5112Z"
|
||||
fill="url(#paint4_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M432.9 78.1597H479.293C479.663 76.0679 479.909 73.9761 479.909 71.6383C479.909 53.5505 466.987 39.1542 448.775 39.1542C429.454 39.1542 416.287 53.3044 416.287 71.6383C416.287 89.9721 429.331 104.122 450.005 104.122C461.819 104.122 471.048 99.3236 476.832 90.9564L464.034 83.5737C461.327 87.142 456.404 89.726 450.251 89.726C441.883 89.726 435.115 86.2807 432.9 78.1597ZM432.654 65.8551C434.5 57.9802 440.284 53.4274 448.775 53.4274C455.42 53.4274 462.065 56.9958 464.034 65.8551H432.654Z"
|
||||
fill="url(#paint5_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M505.199 51.4587V40.8767H489.324V102.4H505.199V72.9917C505.199 60.0719 515.659 56.3805 523.904 57.3648V39.6463C516.151 39.6463 508.398 43.0916 505.199 51.4587Z"
|
||||
fill="url(#paint6_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M529.934 103.999C535.717 103.999 540.394 99.3235 540.394 93.5404C540.394 87.7572 535.717 83.0815 529.934 83.0815C524.15 83.0815 519.473 87.7572 519.473 93.5404C519.473 99.3235 524.15 103.999 529.934 103.999Z"
|
||||
fill="url(#paint7_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M596.632 16.2676V48.1364C592.202 42.4763 585.679 39.1541 576.696 39.1541C560.206 39.1541 546.67 53.3044 546.67 71.6382C546.67 89.972 560.206 104.122 576.696 104.122C585.679 104.122 592.202 100.8 596.632 95.1399V102.4H612.506V16.2676L596.632 16.2676ZM579.65 88.9876C569.805 88.9876 562.544 81.9741 562.544 71.6382C562.544 61.3024 569.805 54.2887 579.65 54.2887C589.371 54.2887 596.632 61.3024 596.632 71.6382C596.632 81.9741 589.371 88.9876 579.65 88.9876Z"
|
||||
fill="url(#paint8_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M637.98 78.1597H684.373C684.742 76.0679 684.989 73.9761 684.989 71.6383C684.989 53.5505 672.067 39.1542 653.855 39.1542C634.534 39.1542 621.367 53.3044 621.367 71.6383C621.367 89.9721 634.411 104.122 655.085 104.122C666.899 104.122 676.128 99.3236 681.912 90.9564L669.114 83.5737C666.407 87.142 661.484 89.726 655.331 89.726C646.963 89.726 640.195 86.2807 637.98 78.1597ZM637.734 65.8551C639.58 57.9802 645.363 53.4274 653.855 53.4274C660.5 53.4274 667.145 56.9958 669.114 65.8551H637.734Z"
|
||||
fill="url(#paint9_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
d="M732.859 40.8768L717.846 83.9428L702.955 40.8768H685.481L708.862 102.4H726.952L750.333 40.8768H732.859Z"
|
||||
fill="url(#paint10_linear_228_1439)"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M35.664 42.3949L59.4114 1.26865L118.264 103.194H0.558823L24.3062 62.0665L41.1046 71.7643L34.157 83.7971H84.6657L59.4114 40.0612L52.4637 52.094L35.664 42.3949Z"
|
||||
fill="url(#paint11_linear_228_1439)"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient
|
||||
id="paint0_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint1_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint2_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint3_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint4_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint5_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint6_linear_228_1439"
|
||||
x1="329.674"
|
||||
y1="150.079"
|
||||
x2="329.674"
|
||||
y2="13.9297"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint7_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint8_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint9_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint10_linear_228_1439"
|
||||
x1="634.903"
|
||||
y1="139.717"
|
||||
x2="651.436"
|
||||
y2="25.9719"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#2563EB" />
|
||||
<stop offset="1" stopColor="#A855F7" />
|
||||
</linearGradient>
|
||||
<linearGradient
|
||||
id="paint11_linear_228_1439"
|
||||
x1="95.8593"
|
||||
y1="103.194"
|
||||
x2="94.7607"
|
||||
y2="31.2381"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop stopColor="#41FF54" />
|
||||
<stop offset="1" stopColor="#E7FF52" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { MachineIcon } from "~/assets/icons/MachineIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const machines = Object.values(MachinePresetName.enum);
|
||||
|
||||
export function MachineLabelCombo({
|
||||
preset,
|
||||
className,
|
||||
iconClassName,
|
||||
labelClassName,
|
||||
}: {
|
||||
preset?: MachinePresetName | null;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
labelClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1", className)}>
|
||||
<MachineIcon preset={preset ?? undefined} className={cn("size-5", iconClassName)} />
|
||||
<MachineLabel preset={preset} className={labelClassName} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MachineLabel({
|
||||
preset,
|
||||
className,
|
||||
}: {
|
||||
preset?: MachinePresetName | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("text-text-dimmed group-hover/table-row:text-text-bright", className)}>
|
||||
{formatMachinePresetName(preset)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMachinePresetName(preset?: MachinePresetName | null): string {
|
||||
if (!preset) {
|
||||
return "No machine yet";
|
||||
}
|
||||
|
||||
switch (preset) {
|
||||
case "micro":
|
||||
return "Micro";
|
||||
case "small-1x":
|
||||
return "Small 1x";
|
||||
case "small-2x":
|
||||
return "Small 2x";
|
||||
case "medium-1x":
|
||||
return "Medium 1x";
|
||||
case "medium-2x":
|
||||
return "Medium 2x";
|
||||
case "large-1x":
|
||||
return "Large 1x";
|
||||
case "large-2x":
|
||||
return "Large 2x";
|
||||
default:
|
||||
return preset;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { MachineIcon } from "~/assets/icons/MachineIcon";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function MachineTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<MachineIcon preset="no-machine" />
|
||||
<Header3>No machine yet</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
The machine is set at the moment the run is dequeued.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<MachineIcon preset="micro" />
|
||||
<Header3>Micro</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
The smallest and cheapest machine available.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<MachineIcon preset="small-1x" /> <Header3>Small 1x & 2x</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Smaller machines for basic workloads. Small 1x is the default machine.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<MachineIcon preset="medium-1x" /> <Header3>Medium 1x & 2x</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Medium machines for more demanding workloads.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5 flex items-center gap-1.5">
|
||||
<MachineIcon preset="large-1x" /> <Header3>Large 1x & 2x</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Larger machines for the most demanding workloads such as video processing. The larger the
|
||||
machine, the more expensive it is.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
to={docsPath("machines#machine-configurations")}
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { NoSymbolIcon } from "@heroicons/react/20/solid";
|
||||
import React from "react";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { organizationRolesPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { InfoPanel } from "./primitives/InfoPanel";
|
||||
|
||||
export function PermissionDenied({ message }: { message: React.ReactNode }) {
|
||||
const organization = useOptionalOrganization();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={NoSymbolIcon}
|
||||
iconClassName="text-text-dimmed"
|
||||
title="Permission denied"
|
||||
accessory={
|
||||
organization ? (
|
||||
<LinkButton to={organizationRolesPath(organization)} variant="secondary/small">
|
||||
View roles
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{message}
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import productHuntLogo from "../assets/images/producthunt.png";
|
||||
import { ArrowRightIcon } from "@heroicons/react/20/solid";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
|
||||
export function ProductHuntBanner() {
|
||||
return (
|
||||
<div className="flex h-8 items-center justify-center gap-2 bg-[#ff6154]">
|
||||
<Paragraph variant="small" className="text-white">
|
||||
We're live on{" "}
|
||||
</Paragraph>
|
||||
<img src={productHuntLogo} alt="Product Hunt" className="h-6 w-[122px]" />
|
||||
<ArrowRightIcon className="h-4 w-4 text-white" />
|
||||
<LinkButton
|
||||
to="https://www.producthunt.com/posts/trigger-dev"
|
||||
target="_blank"
|
||||
className="text-white! underline underline-offset-2 transition hover:decoration-text-bright hover:decoration-2"
|
||||
variant="tertiary/small"
|
||||
>
|
||||
Vote for us today only!
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { BunLogoIcon } from "~/assets/icons/BunLogoIcon";
|
||||
import { NodejsLogoIcon } from "~/assets/icons/NodejsLogoIcon";
|
||||
import { parseRuntime, formatRuntimeWithVersion, type NormalizedRuntime } from "~/utils/runtime";
|
||||
|
||||
interface RuntimeIconProps {
|
||||
runtime?: string | null;
|
||||
runtimeVersion?: string | null;
|
||||
className?: string;
|
||||
withLabel?: boolean;
|
||||
}
|
||||
|
||||
const getIcon = (runtime: NormalizedRuntime, className: string) => {
|
||||
switch (runtime) {
|
||||
case "bun":
|
||||
return <BunLogoIcon className={className} />;
|
||||
case "node":
|
||||
return <NodejsLogoIcon className={className} />;
|
||||
default:
|
||||
return <span className="text-text-dimmed">–</span>;
|
||||
}
|
||||
};
|
||||
|
||||
export function RuntimeIcon({
|
||||
runtime,
|
||||
runtimeVersion,
|
||||
className = "h-4 w-4",
|
||||
withLabel = false,
|
||||
}: RuntimeIconProps) {
|
||||
const parsedRuntime = parseRuntime(runtime);
|
||||
|
||||
// Default to Node.js if no runtime is specified
|
||||
const effectiveRuntime = parsedRuntime || {
|
||||
runtime: "node" as const,
|
||||
originalRuntime: "node",
|
||||
displayName: "Node.js",
|
||||
};
|
||||
|
||||
const icon = getIcon(effectiveRuntime.runtime, className);
|
||||
const formattedText = formatRuntimeWithVersion(effectiveRuntime.originalRuntime, runtimeVersion);
|
||||
|
||||
if (withLabel) {
|
||||
return (
|
||||
<span className="flex items-center gap-1">
|
||||
{icon}
|
||||
<span>{formattedText}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof icon === "object" && "type" in icon) {
|
||||
return (
|
||||
<SimpleTooltip button={icon} content={formattedText} side="top" disableHoverableContent />
|
||||
);
|
||||
}
|
||||
|
||||
return icon;
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
import { createContext, useContext, useState } from "react";
|
||||
import { useAppOrigin } from "~/hooks/useAppOrigin";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTriggerCliTag } from "~/hooks/useTriggerCliTag";
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsContent,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "./primitives/ClientTabs";
|
||||
import { ClipboardField } from "./primitives/ClipboardField";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
|
||||
type PackageManagerContextType = {
|
||||
activePackageManager: string;
|
||||
setActivePackageManager: (value: string) => void;
|
||||
};
|
||||
|
||||
const PackageManagerContext = createContext<PackageManagerContextType | undefined>(undefined);
|
||||
|
||||
export function PackageManagerProvider({ children }: { children: React.ReactNode }) {
|
||||
const [activePackageManager, setActivePackageManager] = useState("npm");
|
||||
|
||||
return (
|
||||
<PackageManagerContext.Provider value={{ activePackageManager, setActivePackageManager }}>
|
||||
{children}
|
||||
</PackageManagerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function usePackageManager() {
|
||||
const context = useContext(PackageManagerContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("usePackageManager must be used within a PackageManagerProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
function getApiUrlArg() {
|
||||
const appOrigin = useAppOrigin();
|
||||
|
||||
let apiUrl: string | undefined = undefined;
|
||||
|
||||
switch (appOrigin) {
|
||||
case "https://cloud.trigger.dev":
|
||||
// don't display the arg, use the CLI default
|
||||
break;
|
||||
case "https://test-cloud.trigger.dev":
|
||||
apiUrl = "https://test-api.trigger.dev";
|
||||
break;
|
||||
case "https://internal.trigger.dev":
|
||||
apiUrl = "https://internal-api.trigger.dev";
|
||||
break;
|
||||
default:
|
||||
apiUrl = appOrigin;
|
||||
break;
|
||||
}
|
||||
|
||||
return apiUrl ? `-a ${apiUrl}` : undefined;
|
||||
}
|
||||
|
||||
// Add title prop to the component interfaces
|
||||
type TabsProps = {
|
||||
title?: string;
|
||||
};
|
||||
|
||||
export function InitCommandV3({ title }: TabsProps) {
|
||||
const project = useProject();
|
||||
const projectRef = project.externalRef;
|
||||
const apiUrlArg = getApiUrlArg();
|
||||
const triggerCliTag = useTriggerCliTag();
|
||||
|
||||
const initCommandParts = [`trigger.dev@${triggerCliTag}`, "init", `-p ${projectRef}`, apiUrlArg];
|
||||
const initCommand = initCommandParts.filter(Boolean).join(" ");
|
||||
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{title && <span>{title}</span>}
|
||||
<ClientTabsList className={title ? "ml-auto" : ""}>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx ${initCommand}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerDevStepV3({ title }: TabsProps) {
|
||||
const triggerCliTag = useTriggerCliTag();
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{title && <Header3>{title}</Header3>}
|
||||
<ClientTabsList className={title ? "ml-auto" : ""}>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx trigger.dev@${triggerCliTag} dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx trigger.dev@${triggerCliTag} dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx trigger.dev@${triggerCliTag} dev`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerLoginStepV3({ title }: TabsProps) {
|
||||
const triggerCliTag = useTriggerCliTag();
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
return (
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{title && <span>{title}</span>}
|
||||
<ClientTabsList className={title ? "ml-auto" : ""}>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx trigger.dev@${triggerCliTag} login`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx trigger.dev@${triggerCliTag} login`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx trigger.dev@${triggerCliTag} login`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
|
||||
export function TriggerDeployStep({
|
||||
title,
|
||||
environment,
|
||||
}: TabsProps & { environment: { type: string } }) {
|
||||
const triggerCliTag = useTriggerCliTag();
|
||||
const { activePackageManager, setActivePackageManager } = usePackageManager();
|
||||
|
||||
// Generate the environment flag based on environment type
|
||||
const getEnvironmentFlag = () => {
|
||||
switch (environment.type) {
|
||||
case "STAGING":
|
||||
return " --env staging";
|
||||
case "PREVIEW":
|
||||
return " --env preview";
|
||||
case "PRODUCTION":
|
||||
default:
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const environmentFlag = getEnvironmentFlag();
|
||||
|
||||
return (
|
||||
<ClientTabs
|
||||
defaultValue="npm"
|
||||
value={activePackageManager}
|
||||
onValueChange={setActivePackageManager}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{title && <Header3>{title}</Header3>}
|
||||
<ClientTabsList className={title ? "ml-auto" : ""}>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`npx trigger.dev@${triggerCliTag} deploy${environmentFlag}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`pnpm dlx trigger.dev@${triggerCliTag} deploy${environmentFlag}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="secondary/medium"
|
||||
iconButton
|
||||
className="mb-4"
|
||||
value={`yarn dlx trigger.dev@${triggerCliTag} deploy${environmentFlag}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { KeyboardIcon } from "~/assets/icons/KeyboardIcon";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={KeyboardIcon}
|
||||
leadingIconClassName="text-text-dimmed group-hover/button:text-text-bright"
|
||||
data-action="shortcuts"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
|
||||
className="gap-x-0 pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<ShortcutContent />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
export function ShortcutsAutoOpen() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: { modifiers: ["shift"], key: "?" },
|
||||
action: () => {
|
||||
setIsOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={setIsOpen}>
|
||||
<ShortcutContent />
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function ShortcutContent() {
|
||||
return (
|
||||
<SheetContent>
|
||||
<SheetHeader>
|
||||
<SheetTitle>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<KeyboardIcon className="size-5 text-text-bright" />
|
||||
<span className="font-sans text-base font-medium text-text-bright">
|
||||
Keyboard shortcuts
|
||||
</span>
|
||||
</div>
|
||||
</SheetTitle>
|
||||
<div className="space-y-6 px-4 pb-4 pt-2">
|
||||
<div className="space-y-3">
|
||||
<Header3>General</Header3>
|
||||
<Shortcut name="Close">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Confirm">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Ask AI">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle side menu">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Previous page">
|
||||
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Next page">
|
||||
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Help & Feedback">
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Runs page</Header3>
|
||||
<Shortcut name="Bulk action: Cancel runs">
|
||||
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Bulk action: Replay runs">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Bulk action: Clear selection">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Run page</Header3>
|
||||
<Shortcut name="Replay run">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Overview">
|
||||
<ShortcutKey shortcut={{ key: "o" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Context">
|
||||
<ShortcutKey shortcut={{ key: "x" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Metadata">
|
||||
<ShortcutKey shortcut={{ key: "m" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Navigate">
|
||||
<ShortcutKey shortcut={{ key: "arrowup" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowdown" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowleft" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "arrowright" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to next/previous run">
|
||||
<ShortcutKey shortcut={{ key: "j" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "k" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Expand all">
|
||||
<ShortcutKey shortcut={{ key: "e" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Collapse all">
|
||||
<ShortcutKey shortcut={{ key: "w" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle level">
|
||||
<ShortcutKey shortcut={{ key: "0" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "9" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to root run">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Jump to parent run">
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Logs page</Header3>
|
||||
<Shortcut name="Filter by task">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by run ID">
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by level">
|
||||
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select log level">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Close detail panel">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details tab">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Run tab">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="View full run">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Metrics page</Header3>
|
||||
<Shortcut name="Toggle fullscreen chart">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Alerts page</Header3>
|
||||
<Shortcut name="New alert">
|
||||
<ShortcutKey shortcut={{ key: "n" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
);
|
||||
}
|
||||
|
||||
function Shortcut({ children, name }: { children: React.ReactNode; name: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-x-2">
|
||||
<span className="text-sm text-text-dimmed">{name}</span>
|
||||
<span className="flex items-center gap-x-0.5">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function StepContentContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("mb-6 ml-9 mt-1", className)}>{children}</div>;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import type { loader } from "~/root";
|
||||
|
||||
export function TimezoneSetter() {
|
||||
const { timezone: storedTimezone } = useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher();
|
||||
const hasSetTimezone = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (hasSetTimezone.current) return;
|
||||
|
||||
const browserTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
if (browserTimezone && browserTimezone !== storedTimezone) {
|
||||
hasSetTimezone.current = true;
|
||||
fetcher.submit(
|
||||
{ timezone: browserTimezone },
|
||||
{
|
||||
method: "POST",
|
||||
action: "/resources/timezone",
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [storedTimezone, fetcher]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
interface IntrinsicElements {
|
||||
"spline-viewer": React.DetailedHTMLProps<
|
||||
React.HTMLAttributes<HTMLElement> & {
|
||||
url?: string;
|
||||
"loading-anim-type"?: string;
|
||||
},
|
||||
HTMLElement
|
||||
>;
|
||||
}
|
||||
}
|
||||
|
||||
interface Window {
|
||||
__splineLoader?: Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
export function TriggerRotatingLogo() {
|
||||
const [isSplineReady, setIsSplineReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Already registered from a previous render
|
||||
if (customElements.get("spline-viewer")) {
|
||||
setIsSplineReady(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Another mount already started loading - share the same promise
|
||||
if (window.__splineLoader) {
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
return;
|
||||
}
|
||||
|
||||
// First mount: create script and shared loader promise
|
||||
const script = document.createElement("script");
|
||||
script.type = "module";
|
||||
// Version pinned; SRI hash omitted as unpkg doesn't guarantee hash stability across deploys
|
||||
script.src = "https://unpkg.com/@splinetool/viewer@1.12.29/build/spline-viewer.js";
|
||||
|
||||
window.__splineLoader = new Promise<void>((resolve, reject) => {
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject();
|
||||
});
|
||||
|
||||
window.__splineLoader.then(() => setIsSplineReady(true)).catch(() => setIsSplineReady(false));
|
||||
|
||||
document.head.appendChild(script);
|
||||
|
||||
// Intentionally no cleanup: once the custom element is registered globally,
|
||||
// removing the script would break re-mounts while providing no benefit
|
||||
}, []);
|
||||
|
||||
if (!isSplineReady) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="pointer-events-none absolute inset-0 overflow-hidden"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.5, duration: 2, ease: "easeOut" }}
|
||||
>
|
||||
<spline-viewer
|
||||
loading-anim-type="spinner-small-light"
|
||||
url="https://prod.spline.design/wRly8TZN-e0Twb8W/scene.splinecode"
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
/>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { UserCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useOptionalUser } from "~/hooks/useUser";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function UserProfilePhoto({ className }: { className?: string }) {
|
||||
const user = useOptionalUser();
|
||||
return <UserAvatar avatarUrl={user?.avatarUrl} name={user?.name} className={className} />;
|
||||
}
|
||||
|
||||
export function UserAvatar({
|
||||
avatarUrl,
|
||||
name,
|
||||
className,
|
||||
}: {
|
||||
avatarUrl?: string | null;
|
||||
name?: string | null;
|
||||
className?: string;
|
||||
}) {
|
||||
return avatarUrl ? (
|
||||
<div className={cn("grid aspect-square place-items-center", className)}>
|
||||
<img
|
||||
className={cn("aspect-square rounded-full p-[7%]")}
|
||||
src={avatarUrl}
|
||||
alt={name ?? "User"}
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<UserCircleIcon className={cn("aspect-square text-text-dimmed", className)} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Badge } from "./primitives/Badge";
|
||||
import { SimpleTooltip } from "./primitives/Tooltip";
|
||||
|
||||
export function V4Badge({ inline = false, className }: { inline?: boolean; className?: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Badge variant="extra-small" className={cn(inline ? "inline-grid" : "", className)}>
|
||||
V4
|
||||
</Badge>
|
||||
}
|
||||
content="This feature is only available in V4 and above."
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function V4Title({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<span>{children}</span>
|
||||
<V4Badge />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { WarmStartIcon } from "~/assets/icons/WarmStartIcon";
|
||||
import { InfoIconTooltip, SimpleTooltip } from "./primitives/Tooltip";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
|
||||
export function WarmStartCombo({
|
||||
isWarmStart,
|
||||
showTooltip = false,
|
||||
className,
|
||||
}: {
|
||||
isWarmStart: boolean;
|
||||
showTooltip?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-1 text-sm text-text-dimmed", className)}>
|
||||
<WarmStartIcon isWarmStart={isWarmStart} className="size-5" />
|
||||
<span>{isWarmStart ? "Warm Start" : "Cold Start"}</span>
|
||||
{showTooltip && <InfoIconTooltip content={<WarmStartTooltipContent />} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WarmStartIconWithTooltip({
|
||||
isWarmStart,
|
||||
className,
|
||||
}: {
|
||||
isWarmStart: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
className="relative z-9999"
|
||||
button={<WarmStartIcon isWarmStart={isWarmStart} className={className} />}
|
||||
content={<WarmStartTooltipContent />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function WarmStartTooltipContent() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1">
|
||||
<div>
|
||||
<WarmStartCombo isWarmStart={false} className="mb-0.5 text-text-bright" />
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
A cold start happens when we need to boot up a new machine for your run to execute. This
|
||||
takes longer than a warm start.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<WarmStartCombo isWarmStart={true} className="mb-0.5 text-text-bright" />
|
||||
<Paragraph variant="small" className="text-wrap! text-text-dimmed">
|
||||
A warm start happens when we can reuse a machine from a run that recently finished. This
|
||||
takes less time than a cold start.
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import stableStringify from "json-stable-stringify";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "~/components/primitives/Dialog";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { FEATURE_FLAG, ORG_LOCKED_FLAGS, type FlagControlType } from "~/v3/featureFlags";
|
||||
import {
|
||||
UNSET_VALUE,
|
||||
BooleanControl,
|
||||
EnumControl,
|
||||
NumberControl,
|
||||
StringControl,
|
||||
WorkerGroupControl,
|
||||
type WorkerGroup,
|
||||
} from "./FlagControls";
|
||||
|
||||
type LoaderData = {
|
||||
org: { id: string; title: string; slug: string };
|
||||
orgFlags: Record<string, unknown>;
|
||||
globalFlags: Record<string, unknown>;
|
||||
controlTypes: Record<string, FlagControlType>;
|
||||
workerGroupName?: string;
|
||||
workerGroups?: WorkerGroup[];
|
||||
isManagedCloud?: boolean;
|
||||
};
|
||||
|
||||
type ActionData = {
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type FeatureFlagsDialogProps = {
|
||||
orgId: string | null;
|
||||
orgTitle: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export function FeatureFlagsDialog({
|
||||
orgId,
|
||||
orgTitle,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FeatureFlagsDialogProps) {
|
||||
const loadFetcher = useFetcher<LoaderData>();
|
||||
const saveFetcher = useFetcher<ActionData>();
|
||||
|
||||
const [overrides, setOverrides] = useState<Record<string, unknown>>({});
|
||||
const [initialOverrides, setInitialOverrides] = useState<Record<string, unknown>>({});
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [unlocked, setUnlocked] = useState(false);
|
||||
|
||||
const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && orgId) {
|
||||
setSaveError(null);
|
||||
setOverrides({});
|
||||
setInitialOverrides({});
|
||||
loadFetcher.load(`/admin/api/v2/orgs/${orgId}/feature-flags`);
|
||||
}
|
||||
}, [open, orgId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loadFetcher.data) {
|
||||
const loaded = loadFetcher.data.orgFlags ?? {};
|
||||
setOverrides({ ...loaded });
|
||||
setInitialOverrides({ ...loaded });
|
||||
}
|
||||
}, [loadFetcher.data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveFetcher.data?.success) {
|
||||
onOpenChange(false);
|
||||
} else if (saveFetcher.data?.error) {
|
||||
setSaveError(saveFetcher.data.error);
|
||||
}
|
||||
}, [saveFetcher.data]);
|
||||
|
||||
const isDirty = stableStringify(overrides) !== stableStringify(initialOverrides);
|
||||
|
||||
const setFlagValue = (key: string, value: unknown) => {
|
||||
setOverrides((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const unsetFlag = (key: string) => {
|
||||
setOverrides((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (!orgId) return;
|
||||
const body = Object.keys(overrides).length === 0 ? null : overrides;
|
||||
saveFetcher.submit(JSON.stringify(body), {
|
||||
method: "POST",
|
||||
action: `/admin/api/v2/orgs/${orgId}/feature-flags`,
|
||||
encType: "application/json",
|
||||
});
|
||||
};
|
||||
|
||||
const data = loadFetcher.data;
|
||||
const isLoading = loadFetcher.state === "loading";
|
||||
const isSaving = saveFetcher.state === "submitting";
|
||||
|
||||
const jsonPreview =
|
||||
Object.keys(overrides).length === 0 ? "null" : JSON.stringify(overrides, null, 2);
|
||||
|
||||
const sortedFlagKeys = data ? Object.keys(data.controlTypes).sort() : [];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-lg">
|
||||
<DialogHeader>Feature flags - {orgTitle}</DialogHeader>
|
||||
<DialogDescription>
|
||||
Org-level overrides. Unset flags inherit from global defaults.
|
||||
</DialogDescription>
|
||||
|
||||
{data && (
|
||||
<div className={data.isManagedCloud ? "cursor-not-allowed" : undefined}>
|
||||
<CheckboxWithLabel
|
||||
variant="simple/small"
|
||||
label={
|
||||
data.isManagedCloud
|
||||
? "Unlock read-only flags (only in unmanaged cloud)"
|
||||
: "Unlock read-only flags"
|
||||
}
|
||||
defaultChecked={unlocked}
|
||||
onChange={setUnlocked}
|
||||
disabled={data.isManagedCloud}
|
||||
className={data.isManagedCloud ? "pointer-events-none" : undefined}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="max-h-[60vh] overflow-y-auto">
|
||||
{isLoading ? (
|
||||
<div className="py-8 text-center text-sm text-text-dimmed">Loading flags...</div>
|
||||
) : data ? (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{sortedFlagKeys.map((key) => {
|
||||
const control = data.controlTypes[key];
|
||||
const locked = isLocked(key);
|
||||
const globalValue = data.globalFlags[key as keyof typeof data.globalFlags];
|
||||
const isWorkerGroup = key === FEATURE_FLAG.defaultWorkerInstanceGroupId;
|
||||
const globalDisplay =
|
||||
isWorkerGroup && data.workerGroupName && globalValue !== undefined
|
||||
? `${data.workerGroupName} (${String(globalValue).slice(0, 8)}...)`
|
||||
: globalValue !== undefined
|
||||
? String(globalValue)
|
||||
: "unset";
|
||||
|
||||
if (locked) {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between rounded-md border border-transparent bg-background-hover px-3 py-2.5"
|
||||
title="Global-level setting - not editable per org"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm text-text-dimmed">{key}</div>
|
||||
<div className="text-xs text-text-dimmed">global: {globalDisplay}</div>
|
||||
</div>
|
||||
<LockClosedIcon className="size-4 text-text-faint" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isOverridden = key in overrides;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={cn(
|
||||
"flex items-center justify-between rounded-md border px-3 py-2.5",
|
||||
isOverridden
|
||||
? "border-indigo-500/20 bg-indigo-500/5"
|
||||
: "border-transparent bg-background-hover"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
isOverridden ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
{key}
|
||||
</div>
|
||||
<div className="text-xs text-text-dimmed">global: {globalDisplay}</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => unsetFlag(key)}
|
||||
className={cn(!isOverridden && "invisible")}
|
||||
>
|
||||
unset
|
||||
</Button>
|
||||
|
||||
{isWorkerGroup && data.workerGroups ? (
|
||||
<WorkerGroupControl
|
||||
value={isOverridden ? (overrides[key] as string) : undefined}
|
||||
workerGroups={data.workerGroups as WorkerGroup[]}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "boolean" ? (
|
||||
<BooleanControl
|
||||
value={isOverridden ? (overrides[key] as boolean) : undefined}
|
||||
onChange={(val) => setFlagValue(key, val)}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "enum" ? (
|
||||
<EnumControl
|
||||
value={isOverridden ? (overrides[key] as string) : undefined}
|
||||
options={control.options}
|
||||
onChange={(val) => {
|
||||
if (val === UNSET_VALUE) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "number" ? (
|
||||
<NumberControl
|
||||
value={isOverridden ? (overrides[key] as number) : undefined}
|
||||
min={control.min}
|
||||
max={control.max}
|
||||
onChange={(val) => {
|
||||
if (val === undefined) {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : control.type === "string" ? (
|
||||
<StringControl
|
||||
value={isOverridden ? (overrides[key] as string) : ""}
|
||||
onChange={(val) => {
|
||||
if (val === "") {
|
||||
unsetFlag(key);
|
||||
} else {
|
||||
setFlagValue(key, val);
|
||||
}
|
||||
}}
|
||||
dimmed={!isOverridden}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{data && (
|
||||
<details className="mt-2">
|
||||
<summary className="cursor-pointer text-xs text-text-dimmed hover:text-text-bright">
|
||||
Preview JSON
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-40 overflow-auto rounded bg-background-bright p-2 text-xs text-text-dimmed">
|
||||
{jsonPreview}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{saveError && <Callout variant="error">{saveError}</Callout>}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="tertiary/small" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="primary/small" onClick={handleSave} disabled={!isDirty || isSaving}>
|
||||
{isSaving ? "Saving..." : "Save changes"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const UNSET_VALUE = "__unset__";
|
||||
|
||||
export function BooleanControl({
|
||||
value,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: boolean | undefined;
|
||||
onChange: (val: boolean) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={value ?? false}
|
||||
onCheckedChange={onChange}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnumControl({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
options: string[];
|
||||
onChange: (val: string) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
const items = [UNSET_VALUE, ...options];
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant="tertiary/small"
|
||||
value={value ?? UNSET_VALUE}
|
||||
setValue={onChange}
|
||||
items={items}
|
||||
text={(val) => (val === UNSET_VALUE ? "unset" : val)}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === UNSET_VALUE ? "unset" : item}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export type WorkerGroup = { id: string; name: string };
|
||||
|
||||
export function WorkerGroupControl({
|
||||
value,
|
||||
workerGroups,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: string | undefined;
|
||||
workerGroups: WorkerGroup[];
|
||||
onChange: (val: string) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
const items = [UNSET_VALUE, ...workerGroups.map((wg) => wg.id)];
|
||||
|
||||
return (
|
||||
<Select
|
||||
variant="tertiary/small"
|
||||
value={value ?? UNSET_VALUE}
|
||||
setValue={onChange}
|
||||
items={items}
|
||||
text={(val) => {
|
||||
if (val === UNSET_VALUE) return "unset";
|
||||
const wg = workerGroups.find((w) => w.id === val);
|
||||
return wg ? wg.name : val;
|
||||
}}
|
||||
className={cn(dimmed && "opacity-50")}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => {
|
||||
const wg = workerGroups.find((w) => w.id === item);
|
||||
return (
|
||||
<SelectItem key={item} value={item}>
|
||||
{item === UNSET_VALUE ? "unset" : wg ? wg.name : item}
|
||||
</SelectItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export function NumberControl({
|
||||
value,
|
||||
onChange,
|
||||
min,
|
||||
max,
|
||||
dimmed,
|
||||
}: {
|
||||
value: number | undefined;
|
||||
onChange: (val: number | undefined) => void;
|
||||
min?: number;
|
||||
max?: number;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
// Number and string fields share the same input shape; surface the range
|
||||
// (or "number") as the placeholder so an unset field still signals its type.
|
||||
const placeholder = min !== undefined && max !== undefined ? `${min}-${max}` : "number";
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
variant="small"
|
||||
// Empty string when unset so the placeholder shows instead of "0".
|
||||
value={value ?? ""}
|
||||
min={min}
|
||||
max={max}
|
||||
step={1}
|
||||
onChange={(e) => {
|
||||
const next = e.target.valueAsNumber;
|
||||
onChange(Number.isNaN(next) ? undefined : next);
|
||||
}}
|
||||
placeholder={placeholder}
|
||||
className={cn("w-40", dimmed && "opacity-50")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function StringControl({
|
||||
value,
|
||||
onChange,
|
||||
dimmed,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
dimmed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Input
|
||||
variant="small"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder="unset"
|
||||
className={cn("w-40", dimmed && "opacity-50")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { type Duration } from "~/services/rateLimiter.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { API_RATE_LIMIT_INTENT } from "./ApiRateLimitSection";
|
||||
import {
|
||||
handleRateLimitAction,
|
||||
resolveEffectiveRateLimit,
|
||||
type RateLimitActionResult,
|
||||
type RateLimitDomain,
|
||||
} from "./RateLimitSection.server";
|
||||
import type { EffectiveRateLimit } from "./RateLimitSection";
|
||||
|
||||
export const apiRateLimitDomain: RateLimitDomain = {
|
||||
intent: API_RATE_LIMIT_INTENT,
|
||||
systemDefault: () => ({
|
||||
type: "tokenBucket",
|
||||
refillRate: env.API_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.API_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.API_RATE_LIMIT_MAX,
|
||||
}),
|
||||
apply: async (orgId, next, adminUserId) => {
|
||||
const existing = await prisma.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: { apiRateLimiterConfig: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
await prisma.organization.update({
|
||||
where: { id: orgId },
|
||||
data: { apiRateLimiterConfig: next as any },
|
||||
});
|
||||
// apiRateLimiterConfig is embedded in every env of the org; drop all its cached env rows.
|
||||
controlPlaneResolver.invalidateOrganization(orgId);
|
||||
logger.info("admin.backOffice.apiRateLimit", {
|
||||
adminUserId,
|
||||
orgId,
|
||||
previous: existing.apiRateLimiterConfig,
|
||||
next,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveEffectiveApiRateLimit(override: unknown): EffectiveRateLimit {
|
||||
return resolveEffectiveRateLimit(override, apiRateLimitDomain);
|
||||
}
|
||||
|
||||
export function handleApiRateLimitAction(
|
||||
formData: FormData,
|
||||
orgId: string,
|
||||
adminUserId: string
|
||||
): Promise<RateLimitActionResult> {
|
||||
return handleRateLimitAction(formData, orgId, adminUserId, apiRateLimitDomain);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RateLimitSection, type RateLimitWrapperProps } from "./RateLimitSection";
|
||||
|
||||
export const API_RATE_LIMIT_INTENT = "set-rate-limit";
|
||||
export const API_RATE_LIMIT_SAVED_VALUE = "rate-limit";
|
||||
|
||||
export function ApiRateLimitSection(props: RateLimitWrapperProps) {
|
||||
return <RateLimitSection title="API rate limit" intent={API_RATE_LIMIT_INTENT} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { type Duration } from "~/services/rateLimiter.server";
|
||||
import { controlPlaneResolver } from "~/v3/runOpsMigration/controlPlaneResolver.server";
|
||||
import { BATCH_RATE_LIMIT_INTENT } from "./BatchRateLimitSection";
|
||||
import {
|
||||
handleRateLimitAction,
|
||||
resolveEffectiveRateLimit,
|
||||
type RateLimitActionResult,
|
||||
type RateLimitDomain,
|
||||
} from "./RateLimitSection.server";
|
||||
import type { EffectiveRateLimit } from "./RateLimitSection";
|
||||
|
||||
export const batchRateLimitDomain: RateLimitDomain = {
|
||||
intent: BATCH_RATE_LIMIT_INTENT,
|
||||
systemDefault: () => ({
|
||||
type: "tokenBucket",
|
||||
refillRate: env.BATCH_RATE_LIMIT_REFILL_RATE,
|
||||
interval: env.BATCH_RATE_LIMIT_REFILL_INTERVAL as Duration,
|
||||
maxTokens: env.BATCH_RATE_LIMIT_MAX,
|
||||
}),
|
||||
apply: async (orgId, next, adminUserId) => {
|
||||
const existing = await prisma.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: { batchRateLimitConfig: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
await prisma.organization.update({
|
||||
where: { id: orgId },
|
||||
data: { batchRateLimitConfig: next as any },
|
||||
});
|
||||
// batchRateLimitConfig is embedded in every env of the org; drop all its cached env rows.
|
||||
controlPlaneResolver.invalidateOrganization(orgId);
|
||||
logger.info("admin.backOffice.batchRateLimit", {
|
||||
adminUserId,
|
||||
orgId,
|
||||
previous: existing.batchRateLimitConfig,
|
||||
next,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export function resolveEffectiveBatchRateLimit(override: unknown): EffectiveRateLimit {
|
||||
return resolveEffectiveRateLimit(override, batchRateLimitDomain);
|
||||
}
|
||||
|
||||
export function handleBatchRateLimitAction(
|
||||
formData: FormData,
|
||||
orgId: string,
|
||||
adminUserId: string
|
||||
): Promise<RateLimitActionResult> {
|
||||
return handleRateLimitAction(formData, orgId, adminUserId, batchRateLimitDomain);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { RateLimitSection, type RateLimitWrapperProps } from "./RateLimitSection";
|
||||
|
||||
export const BATCH_RATE_LIMIT_INTENT = "set-batch-rate-limit";
|
||||
export const BATCH_RATE_LIMIT_SAVED_VALUE = "batch-rate-limit";
|
||||
|
||||
export function BatchRateLimitSection(props: RateLimitWrapperProps) {
|
||||
return <RateLimitSection title="Batch rate limit" intent={BATCH_RATE_LIMIT_INTENT} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { MAX_PROJECTS_INTENT } from "./MaxProjectsSection";
|
||||
|
||||
const SetMaxProjectsSchema = z.object({
|
||||
intent: z.literal(MAX_PROJECTS_INTENT),
|
||||
// Capped at PostgreSQL INTEGER max (Prisma Int) so oversized input fails
|
||||
// validation cleanly instead of crashing the update.
|
||||
maximumProjectCount: z.coerce.number().int().min(1).max(2_147_483_647),
|
||||
});
|
||||
|
||||
export type MaxProjectsActionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; errors: Record<string, string[] | undefined> };
|
||||
|
||||
export async function handleMaxProjectsAction(
|
||||
formData: FormData,
|
||||
orgId: string,
|
||||
adminUserId: string
|
||||
): Promise<MaxProjectsActionResult> {
|
||||
const submission = SetMaxProjectsSchema.safeParse(Object.fromEntries(formData));
|
||||
if (!submission.success) {
|
||||
return { ok: false, errors: submission.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const existing = await prisma.organization.findFirst({
|
||||
where: { id: orgId },
|
||||
select: { maximumProjectCount: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new Response(null, { status: 404 });
|
||||
}
|
||||
|
||||
await prisma.organization.update({
|
||||
where: { id: orgId },
|
||||
data: { maximumProjectCount: submission.data.maximumProjectCount },
|
||||
});
|
||||
|
||||
logger.info("admin.backOffice.maxProjects", {
|
||||
adminUserId,
|
||||
orgId,
|
||||
previous: existing.maximumProjectCount,
|
||||
next: submission.data.maximumProjectCount,
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
|
||||
export const MAX_PROJECTS_INTENT = "set-max-projects";
|
||||
export const MAX_PROJECTS_SAVED_VALUE = "max-projects";
|
||||
|
||||
type FieldErrors = Record<string, string[] | undefined> | null;
|
||||
|
||||
type Props = {
|
||||
maximumProjectCount: number;
|
||||
errors: FieldErrors;
|
||||
savedJustNow: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
export function MaxProjectsSection({
|
||||
maximumProjectCount,
|
||||
errors,
|
||||
savedJustNow,
|
||||
isSubmitting,
|
||||
}: Props) {
|
||||
const hasFieldErrors = !!errors && Object.keys(errors).length > 0;
|
||||
const fieldError = (field: string) =>
|
||||
errors && field in errors ? errors[field]?.[0] : undefined;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(hasFieldErrors);
|
||||
const [value, setValue] = useState(String(maximumProjectCount));
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFieldErrors) setIsEditing(true);
|
||||
}, [hasFieldErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savedJustNow && !hasFieldErrors) setIsEditing(false);
|
||||
}, [savedJustNow, hasFieldErrors]);
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-md border border-grid-bright bg-background-bright p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2>Maximum projects</Header2>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{savedJustNow && (
|
||||
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
|
||||
<Paragraph variant="small" className="text-green-500">
|
||||
Saved.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isEditing ? (
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Limit</Property.Label>
|
||||
<Property.Value>{maximumProjectCount.toLocaleString()}</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
) : (
|
||||
<Form method="post" className="flex flex-col gap-3 pt-2">
|
||||
<input type="hidden" name="intent" value={MAX_PROJECTS_INTENT} />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Maximum projects</Label>
|
||||
<Input
|
||||
name="maximumProjectCount"
|
||||
type="number"
|
||||
min={1}
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormError>{fieldError("maximumProjectCount")}</FormError>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" variant="primary/medium" disabled={isSubmitting || !value.trim()}>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/medium"
|
||||
onClick={() => {
|
||||
setValue(String(maximumProjectCount));
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { z } from "zod";
|
||||
import {
|
||||
RateLimitTokenBucketConfig,
|
||||
RateLimiterConfig,
|
||||
} from "~/services/authorizationRateLimitMiddleware.server";
|
||||
import { parseDurationToMs, type EffectiveRateLimit } from "./RateLimitSection";
|
||||
|
||||
export type RateLimitDomain = {
|
||||
intent: string;
|
||||
systemDefault: () => RateLimiterConfig;
|
||||
apply: (orgId: string, next: RateLimitTokenBucketConfig, adminUserId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export function resolveEffectiveRateLimit(
|
||||
override: unknown,
|
||||
domain: RateLimitDomain
|
||||
): EffectiveRateLimit {
|
||||
if (override == null) {
|
||||
return { source: "default", config: domain.systemDefault() };
|
||||
}
|
||||
const parsed = RateLimiterConfig.safeParse(override);
|
||||
if (parsed.success) {
|
||||
return { source: "override", config: parsed.data };
|
||||
}
|
||||
// Column holds malformed JSON — fall back silently. Admin must investigate
|
||||
// at the DB level; this UI can't recover it.
|
||||
return { source: "default", config: domain.systemDefault() };
|
||||
}
|
||||
|
||||
export type RateLimitActionResult =
|
||||
| { ok: true }
|
||||
| { ok: false; errors: Record<string, string[] | undefined> };
|
||||
|
||||
export async function handleRateLimitAction(
|
||||
formData: FormData,
|
||||
orgId: string,
|
||||
adminUserId: string,
|
||||
domain: RateLimitDomain
|
||||
): Promise<RateLimitActionResult> {
|
||||
const schema = z.object({
|
||||
intent: z.literal(domain.intent),
|
||||
refillRate: z.coerce.number().int().min(1),
|
||||
interval: z
|
||||
.string()
|
||||
.trim()
|
||||
.refine((v) => parseDurationToMs(v) > 0, {
|
||||
message: "Must be a duration like 10s, 1m, 500ms.",
|
||||
}),
|
||||
maxTokens: z.coerce.number().int().min(1),
|
||||
});
|
||||
|
||||
const submission = schema.safeParse(Object.fromEntries(formData));
|
||||
if (!submission.success) {
|
||||
return { ok: false, errors: submission.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
const built = RateLimitTokenBucketConfig.safeParse({
|
||||
type: "tokenBucket",
|
||||
refillRate: submission.data.refillRate,
|
||||
interval: submission.data.interval,
|
||||
maxTokens: submission.data.maxTokens,
|
||||
});
|
||||
if (!built.success) {
|
||||
return { ok: false, errors: built.error.flatten().fieldErrors };
|
||||
}
|
||||
|
||||
await domain.apply(orgId, built.data, adminUserId);
|
||||
return { ok: true };
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
import { Form } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
|
||||
// Local shape mirrors the server-side discriminated union just enough for this
|
||||
// view. Decoupled from the .server module so the component stays client-safe.
|
||||
// Duration fields are always suffixed strings — the server's DurationSchema
|
||||
// rejects anything else, so non-string overrides fall back to the default.
|
||||
export type RateLimitConfig =
|
||||
| {
|
||||
type: "tokenBucket";
|
||||
refillRate: number;
|
||||
interval: string;
|
||||
maxTokens: number;
|
||||
}
|
||||
| {
|
||||
type: "fixedWindow" | "slidingWindow";
|
||||
window: string;
|
||||
tokens: number;
|
||||
};
|
||||
|
||||
export type EffectiveRateLimit = {
|
||||
source: "override" | "default";
|
||||
config: RateLimitConfig;
|
||||
};
|
||||
|
||||
export type FieldErrors = Record<string, string[] | undefined> | null;
|
||||
|
||||
// Props shared by every per-domain wrapper (Api / Batch / future ones).
|
||||
export type RateLimitWrapperProps = {
|
||||
effective: EffectiveRateLimit;
|
||||
errors: FieldErrors;
|
||||
savedJustNow: boolean;
|
||||
isSubmitting: boolean;
|
||||
};
|
||||
|
||||
type Props = RateLimitWrapperProps & {
|
||||
title: string;
|
||||
intent: string;
|
||||
};
|
||||
|
||||
export function RateLimitSection({
|
||||
title,
|
||||
intent,
|
||||
effective,
|
||||
errors,
|
||||
savedJustNow,
|
||||
isSubmitting,
|
||||
}: Props) {
|
||||
const hasFieldErrors = !!errors && Object.keys(errors).length > 0;
|
||||
const fieldError = (field: string) =>
|
||||
errors && field in errors ? errors[field]?.[0] : undefined;
|
||||
|
||||
const current = effective.config.type === "tokenBucket" ? effective.config : null;
|
||||
|
||||
const [isEditing, setIsEditing] = useState(hasFieldErrors);
|
||||
const [refillRate, setRefillRate] = useState(current ? String(current.refillRate) : "");
|
||||
const [intervalStr, setIntervalStr] = useState(current ? String(current.interval) : "");
|
||||
const [maxTokens, setMaxTokens] = useState(current ? String(current.maxTokens) : "");
|
||||
|
||||
useEffect(() => {
|
||||
if (hasFieldErrors) setIsEditing(true);
|
||||
}, [hasFieldErrors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (savedJustNow && !hasFieldErrors) setIsEditing(false);
|
||||
}, [savedJustNow, hasFieldErrors]);
|
||||
|
||||
const currentDescription = current
|
||||
? describeRateLimit(
|
||||
current.refillRate,
|
||||
parseDurationToMs(String(current.interval)),
|
||||
current.maxTokens
|
||||
)
|
||||
: null;
|
||||
|
||||
const previewDescription = describeRateLimit(
|
||||
Number(refillRate) || 0,
|
||||
parseDurationToMs(intervalStr),
|
||||
Number(maxTokens) || 0
|
||||
);
|
||||
|
||||
const cancelEdit = () => {
|
||||
setRefillRate(current ? String(current.refillRate) : "");
|
||||
setIntervalStr(current ? String(current.interval) : "");
|
||||
setMaxTokens(current ? String(current.maxTokens) : "");
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="flex flex-col gap-3 rounded-md border border-grid-bright bg-background-bright p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Header2>{title}</Header2>
|
||||
{!isEditing && (
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={() => setIsEditing(true)}
|
||||
disabled={isSubmitting || effective.config.type !== "tokenBucket"}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{savedJustNow && (
|
||||
<div className="rounded-md border border-green-600/40 bg-green-600/10 px-3 py-2">
|
||||
<Paragraph variant="small" className="text-green-500">
|
||||
Saved.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Paragraph variant="small">
|
||||
Status:{" "}
|
||||
{effective.source === "override" ? "Custom override active." : "Using system default."}
|
||||
</Paragraph>
|
||||
|
||||
{!isEditing ? (
|
||||
<>
|
||||
<Property.Table>
|
||||
{effective.config.type === "tokenBucket" ? (
|
||||
currentDescription ? (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Sustained rate</Property.Label>
|
||||
<Property.Value>{currentDescription.sustained}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Burst allowance</Property.Label>
|
||||
<Property.Value>{currentDescription.burst}</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
) : (
|
||||
<Property.Item>
|
||||
<Property.Value>Invalid interval on the stored config.</Property.Value>
|
||||
</Property.Item>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Type</Property.Label>
|
||||
<Property.Value>{effective.config.type}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Window</Property.Label>
|
||||
<Property.Value>{String(effective.config.window)}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Tokens</Property.Label>
|
||||
<Property.Value>{effective.config.tokens.toLocaleString()}</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
)}
|
||||
</Property.Table>
|
||||
{effective.config.type !== "tokenBucket" && (
|
||||
<Paragraph variant="small" className="text-amber-500">
|
||||
This override is a {effective.config.type} limit and can't be edited from this form.
|
||||
Change it in the database directly.
|
||||
</Paragraph>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Form method="post" className="flex flex-col gap-3 pt-2">
|
||||
<input type="hidden" name="intent" value={intent} />
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Refill rate (tokens per interval)</Label>
|
||||
<Input
|
||||
name="refillRate"
|
||||
type="number"
|
||||
min={1}
|
||||
value={refillRate}
|
||||
onChange={(e) => setRefillRate(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormError>{fieldError("refillRate")}</FormError>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Interval (e.g. 10s, 1m)</Label>
|
||||
<Input
|
||||
name="interval"
|
||||
type="text"
|
||||
value={intervalStr}
|
||||
onChange={(e) => setIntervalStr(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormError>{fieldError("interval")}</FormError>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label>Max tokens (burst allowance)</Label>
|
||||
<Input
|
||||
name="maxTokens"
|
||||
type="number"
|
||||
min={1}
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<FormError>{fieldError("maxTokens")}</FormError>
|
||||
</div>
|
||||
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
{previewDescription
|
||||
? `Preview: ${previewDescription.sustained} · ${previewDescription.burst}.`
|
||||
: "Preview: enter valid values to see the effective limit."}
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary/medium"
|
||||
disabled={
|
||||
isSubmitting || !refillRate.trim() || !intervalStr.trim() || !maxTokens.trim()
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/medium"
|
||||
onClick={cancelEdit}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function parseDurationToMs(duration: string): number {
|
||||
const match = duration.trim().match(/^(\d+)\s*(ms|s|m|h|d)$/);
|
||||
if (!match) return 0;
|
||||
const value = parseInt(match[1], 10);
|
||||
switch (match[2]) {
|
||||
case "ms":
|
||||
return value;
|
||||
case "s":
|
||||
return value * 1_000;
|
||||
case "m":
|
||||
return value * 60_000;
|
||||
case "h":
|
||||
return value * 3_600_000;
|
||||
case "d":
|
||||
return value * 86_400_000;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function describeRateLimit(
|
||||
refillRate: number,
|
||||
intervalMs: number,
|
||||
maxTokens: number
|
||||
): { sustained: string; burst: string } | null {
|
||||
if (refillRate <= 0 || intervalMs <= 0 || maxTokens <= 0) return null;
|
||||
const perMin = (refillRate * 60_000) / intervalMs;
|
||||
let sustained: string;
|
||||
if (perMin >= 1) {
|
||||
sustained = `${formatRateValue(perMin)} requests per minute`;
|
||||
} else {
|
||||
const perHour = perMin * 60;
|
||||
if (perHour >= 1) {
|
||||
sustained = `${formatRateValue(perHour)} requests per hour`;
|
||||
} else {
|
||||
const perDay = perHour * 24;
|
||||
sustained = `${formatRateValue(perDay)} requests per day`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
sustained,
|
||||
burst: `${maxTokens.toLocaleString()} request burst allowance`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatRateValue(value: number): string {
|
||||
return value >= 10 ? Math.round(value).toLocaleString() : value.toFixed(1);
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
import { useIsImpersonating } from "~/hooks/useOrganizations";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Cog6ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { type loader } from "~/routes/resources.taskruns.$runParam.debug";
|
||||
import type { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useEffect } from "react";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { MarQSShortKeyProducer } from "~/v3/marqs/marqsKeyProducer";
|
||||
|
||||
export function AdminDebugRun({ friendlyId }: { friendlyId: string }) {
|
||||
const hasAdminAccess = useHasAdminAccess();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
if (!hasAdminAccess && !isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog key={`debug-${friendlyId}`}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="tertiary/small" LeadingIcon={Cog6ToothIcon}>
|
||||
Debug run
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DebugRunDialog friendlyId={friendlyId} />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function DebugRunDialog({ friendlyId }: { friendlyId: string }) {
|
||||
return (
|
||||
<DialogContent
|
||||
key={`debug`}
|
||||
className="overflow-y-auto sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[50vw]"
|
||||
>
|
||||
<DebugRunContent friendlyId={friendlyId} />
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugRunContent({ friendlyId }: { friendlyId: string }) {
|
||||
const fetcher = useTypedFetcher<typeof loader>();
|
||||
const isLoading = fetcher.state === "loading";
|
||||
|
||||
useEffect(() => {
|
||||
fetcher.load(`/resources/taskruns/${friendlyId}/debug`);
|
||||
}, [friendlyId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DialogHeader>Debugging run</DialogHeader>
|
||||
{isLoading ? (
|
||||
<div className="grid place-items-center p-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
) : fetcher.data ? (
|
||||
<DebugRunData {...fetcher.data} />
|
||||
) : (
|
||||
<>Failed to get run debug data</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugRunData(props: UseDataFunctionReturn<typeof loader>) {
|
||||
if (props.engine === "V1") {
|
||||
return <DebugRunDataEngineV1 {...props} />;
|
||||
}
|
||||
|
||||
return <DebugRunDataEngineV2 {...props} />;
|
||||
}
|
||||
|
||||
function DebugRunDataEngineV1({
|
||||
run,
|
||||
environment,
|
||||
queueConcurrencyLimit,
|
||||
queueCurrentConcurrency,
|
||||
envConcurrencyLimit,
|
||||
envCurrentConcurrency,
|
||||
queueReserveConcurrency,
|
||||
envReserveConcurrency,
|
||||
}: UseDataFunctionReturn<typeof loader>) {
|
||||
const keys = new MarQSShortKeyProducer("marqs:");
|
||||
|
||||
const withPrefix = (key: string) => `marqs:${key}`;
|
||||
|
||||
return (
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField value={run.id} variant="tertiary/small" iconButton />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Message key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.messageKey(run.id))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET message</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.messageKey(run.id))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue set</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`ZRANGE ${withPrefix(
|
||||
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)} 0 -1`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue current concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueCurrentConcurrencyKey(
|
||||
environment,
|
||||
run.queue,
|
||||
run.concurrencyKey ?? undefined
|
||||
)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(
|
||||
keys.queueCurrentConcurrencyKey(
|
||||
environment,
|
||||
run.queue,
|
||||
run.concurrencyKey ?? undefined
|
||||
)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue reserve concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(
|
||||
keys.queueReserveConcurrencyKeyFromQueue(
|
||||
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)
|
||||
)}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Get queue reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(
|
||||
keys.queueReserveConcurrencyKeyFromQueue(
|
||||
keys.queueKey(environment, run.queue, run.concurrencyKey ?? undefined)
|
||||
)
|
||||
)}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueReserveConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue concurrency limit key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.queueConcurrencyLimitKey(environment, run.queue))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET queue concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.queueConcurrencyLimitKey(environment, run.queue))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env current concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envCurrentConcurrencyKey(environment))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get env current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(keys.envCurrentConcurrencyKey(environment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env reserve concurrency key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envReserveConcurrencyKey(environment.id))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get env reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`SMEMBERS ${withPrefix(keys.envReserveConcurrencyKey(environment.id))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env reserve concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envReserveConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env concurrency limit key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={withPrefix(keys.envConcurrencyLimitKey(environment))}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>GET env concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.envConcurrencyLimitKey(environment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Shared queue key</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`GET ${withPrefix(keys.envSharedQueueKey(environment))}`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Get shared queue set</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField
|
||||
value={`ZRANGEBYSCORE ${withPrefix(
|
||||
keys.envSharedQueueKey(environment)
|
||||
)} -inf ${Date.now()} WITHSCORES`}
|
||||
variant="tertiary/small"
|
||||
iconButton
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
);
|
||||
}
|
||||
|
||||
function DebugRunDataEngineV2({
|
||||
run,
|
||||
queueConcurrencyLimit,
|
||||
queueCurrentConcurrency,
|
||||
envConcurrencyLimit,
|
||||
envCurrentConcurrency,
|
||||
keys,
|
||||
}: UseDataFunctionReturn<typeof loader>) {
|
||||
return (
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>ID</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField value={run.id} variant="tertiary/small" iconButton />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Queue concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{queueConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env current concurrency</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envCurrentConcurrency ?? "0"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Env concurrency limit</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<span>{envConcurrencyLimit ?? "Not set"}</span>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{keys.map((key) => (
|
||||
<Property.Item key={key.key}>
|
||||
<Property.Label>{key.label}</Property.Label>
|
||||
<Property.Value className="flex items-center gap-2">
|
||||
<ClipboardField value={key.key} variant="tertiary/small" iconButton />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useIsImpersonating, useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
|
||||
|
||||
export function AdminDebugTooltip({ children }: { children?: React.ReactNode }) {
|
||||
const hasAdminAccess = useHasAdminAccess();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
if (!hasAdminAccess && !isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ShieldCheckIcon className="size-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-h-[90vh] overflow-y-auto">
|
||||
<Content>{children}</Content>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function Content({ children }: { children: React.ReactNode }) {
|
||||
const organization = useOptionalOrganization();
|
||||
const project = useOptionalProject();
|
||||
const environment = useOptionalEnvironment();
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 divide-y divide-slate-700">
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>User ID</Property.Label>
|
||||
<Property.Value>{user.id}</Property.Value>
|
||||
</Property.Item>
|
||||
{organization && (
|
||||
<Property.Item>
|
||||
<Property.Label>Org ID</Property.Label>
|
||||
<Property.Value>{organization.id}</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
{project && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Project ID</Property.Label>
|
||||
<Property.Value>{project.id}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Project ref</Property.Label>
|
||||
<Property.Value>{project.externalRef}</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
)}
|
||||
{environment && (
|
||||
<>
|
||||
<Property.Item>
|
||||
<Property.Label>Environment ID</Property.Label>
|
||||
<Property.Value>{environment.id}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Environment type</Property.Label>
|
||||
<Property.Value>{environment.type}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Environment paused</Property.Label>
|
||||
<Property.Value>{environment.paused ? "Yes" : "No"}</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
)}
|
||||
</Property.Table>
|
||||
<div className="pt-2">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ExclamationCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { Icon } from "~/components/primitives/Icon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type AnimatedOrgBannerBarProps = {
|
||||
show: boolean;
|
||||
variant: "warning" | "error";
|
||||
children: React.ReactNode;
|
||||
action?: React.ReactNode;
|
||||
};
|
||||
|
||||
export function AnimatedOrgBannerBar({
|
||||
show,
|
||||
variant,
|
||||
children,
|
||||
action,
|
||||
}: AnimatedOrgBannerBarProps) {
|
||||
return (
|
||||
<AnimatePresence initial={false}>
|
||||
{show ? (
|
||||
<motion.div
|
||||
className={cn(
|
||||
"flex h-10 items-center justify-between overflow-hidden py-0 pl-3 pr-2",
|
||||
variant === "warning"
|
||||
? "border-y border-amber-400/20 bg-warning/20"
|
||||
: "border border-error bg-repeat"
|
||||
)}
|
||||
style={
|
||||
variant === "error"
|
||||
? { backgroundImage: `url(${tileBgPath})`, backgroundSize: "8px 8px" }
|
||||
: undefined
|
||||
}
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "2.5rem" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon
|
||||
icon={ExclamationCircleIcon}
|
||||
className={cn("h-5 w-5", variant === "warning" ? "text-amber-400" : "text-error")}
|
||||
/>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className={variant === "warning" ? "text-amber-200" : "text-error"}
|
||||
>
|
||||
{children}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{action}
|
||||
</motion.div>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
import { getFormProps, getInputProps, useForm, type SubmissionResult } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useSearchParams } from "@remix-run/react";
|
||||
import { Fragment, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
emailsMatchSaved,
|
||||
getAlertPreviewLimitCents,
|
||||
getBillingLimitMode,
|
||||
getEffectiveLimitCents,
|
||||
hasLegacySpikeAlertLevels,
|
||||
isPercentageAlertMode,
|
||||
MAX_ABSOLUTE_ALERTS,
|
||||
MAX_PERCENTAGE_ALERTS,
|
||||
MAX_PERCENTAGE_THRESHOLD,
|
||||
previewDollarAmountForPercent,
|
||||
storedAlertsToThresholds,
|
||||
thresholdsMatchSaved,
|
||||
thresholdValuesAreUnique,
|
||||
type BillingAlertsFormData,
|
||||
} from "~/components/billing/billingAlertsFormat";
|
||||
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const billingAlertsSchema = z.object({
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const emails = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (emails.length === 0) {
|
||||
return [""];
|
||||
}
|
||||
return emails;
|
||||
}
|
||||
|
||||
return [""];
|
||||
}, z.string().email().array().nonempty("At least one email is required")),
|
||||
alertLevels: z.preprocess(
|
||||
(i) => {
|
||||
const values = typeof i === "string" ? [i] : Array.isArray(i) ? i : [];
|
||||
return values.filter((v) => v !== "").map((v) => Number(v));
|
||||
},
|
||||
z.number().array().refine(thresholdValuesAreUnique, "Each alert must be unique")
|
||||
),
|
||||
});
|
||||
|
||||
export type { BillingAlertsFormData } from "~/components/billing/billingAlertsFormat";
|
||||
|
||||
type BillingAlertsActionData = {
|
||||
formIntent: "billing-alerts";
|
||||
submission: SubmissionResult;
|
||||
};
|
||||
|
||||
type BillingAlertsSectionProps = {
|
||||
alerts: BillingAlertsFormData;
|
||||
billingLimit: BillingLimitResult;
|
||||
planLimitCents: number;
|
||||
alertsResetRequested?: boolean;
|
||||
};
|
||||
|
||||
type ThresholdRow = {
|
||||
id: number;
|
||||
value: string;
|
||||
};
|
||||
|
||||
function isEmptyThresholdRow(value: string): boolean {
|
||||
const parsed = Number(value);
|
||||
return value === "" || !Number.isFinite(parsed) || parsed <= 0;
|
||||
}
|
||||
|
||||
function toThresholdRows(values: number[]): ThresholdRow[] {
|
||||
return values.map((value, index) => ({ id: index, value: String(value) }));
|
||||
}
|
||||
|
||||
function parseThresholdValues(rows: ThresholdRow[]): number[] {
|
||||
return rows
|
||||
.map((row) => Number(row.value))
|
||||
.filter((value) => Number.isFinite(value) && value > 0);
|
||||
}
|
||||
|
||||
function isDuplicateThresholdRow(rows: ThresholdRow[], index: number): boolean {
|
||||
const value = rows[index]?.value;
|
||||
if (!value || isEmptyThresholdRow(value)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parsed = Number(value);
|
||||
return rows.some(
|
||||
(row, rowIndex) =>
|
||||
rowIndex !== index && !isEmptyThresholdRow(row.value) && Number(row.value) === parsed
|
||||
);
|
||||
}
|
||||
|
||||
export function BillingAlertsSection({
|
||||
alerts,
|
||||
billingLimit,
|
||||
planLimitCents,
|
||||
alertsResetRequested = false,
|
||||
}: BillingAlertsSectionProps) {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [showResetBanner, setShowResetBanner] = useState(alertsResetRequested);
|
||||
|
||||
useEffect(() => {
|
||||
if (!alertsResetRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
setShowResetBanner(true);
|
||||
|
||||
if (searchParams.get("alertsReset") !== "1") {
|
||||
return;
|
||||
}
|
||||
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("alertsReset");
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [alertsResetRequested, searchParams, setSearchParams]);
|
||||
|
||||
const billingLimitMode = getBillingLimitMode(billingLimit);
|
||||
const isPercentageMode = isPercentageAlertMode(billingLimitMode);
|
||||
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
|
||||
const alertPreviewLimitCents = getAlertPreviewLimitCents(
|
||||
alerts,
|
||||
effectiveLimitCents,
|
||||
planLimitCents
|
||||
);
|
||||
const maxAlerts = isPercentageMode ? MAX_PERCENTAGE_ALERTS : MAX_ABSOLUTE_ALERTS;
|
||||
|
||||
const savedThresholds = useMemo(
|
||||
() => storedAlertsToThresholds(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
|
||||
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
|
||||
);
|
||||
const savedEmails = useMemo(() => alerts.emails, [alerts.emails]);
|
||||
const hasLegacySpikes = useMemo(
|
||||
() => hasLegacySpikeAlertLevels(alerts, billingLimitMode, effectiveLimitCents, planLimitCents),
|
||||
[alerts, billingLimitMode, effectiveLimitCents, planLimitCents]
|
||||
);
|
||||
|
||||
const nextThresholdIdRef = useRef(savedThresholds.length);
|
||||
const [thresholdRows, setThresholdRows] = useState<ThresholdRow[]>(() =>
|
||||
toThresholdRows(savedThresholds)
|
||||
);
|
||||
const [emailValues, setEmailValues] = useState<string[]>(
|
||||
savedEmails.length > 0 ? [...savedEmails, ""] : [""]
|
||||
);
|
||||
const actionData = useActionData<BillingAlertsActionData>();
|
||||
const alertsSubmission =
|
||||
actionData?.formIntent === "billing-alerts" ? actionData.submission : undefined;
|
||||
|
||||
const currentThresholds = parseThresholdValues(thresholdRows);
|
||||
const isDirty =
|
||||
hasLegacySpikes ||
|
||||
!thresholdsMatchSaved(currentThresholds, savedThresholds) ||
|
||||
!emailsMatchSaved(emailValues, savedEmails);
|
||||
const lastSubmission = isDirty ? alertsSubmission : undefined;
|
||||
|
||||
const [form, { emails, alertLevels }] = useForm<z.infer<typeof billingAlertsSchema>>({
|
||||
id: "billing-alerts",
|
||||
lastResult: lastSubmission as any,
|
||||
shouldRevalidate: "onInput",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: billingAlertsSchema });
|
||||
},
|
||||
defaultValue: {
|
||||
emails: emailValues,
|
||||
alertLevels: savedThresholds.map(String),
|
||||
},
|
||||
});
|
||||
|
||||
const emailFields = emails.getFieldList();
|
||||
|
||||
useEffect(() => {
|
||||
nextThresholdIdRef.current = savedThresholds.length;
|
||||
setThresholdRows(toThresholdRows(savedThresholds));
|
||||
setEmailValues(savedEmails.length > 0 ? [...savedEmails, ""] : [""]);
|
||||
}, [savedThresholds, savedEmails]);
|
||||
|
||||
function updateThresholdRow(index: number, rawValue: string) {
|
||||
setThresholdRows((current) =>
|
||||
current.map((row, rowIndex) => (rowIndex === index ? { ...row, value: rawValue } : row))
|
||||
);
|
||||
}
|
||||
|
||||
function removeThreshold(index: number) {
|
||||
setThresholdRows((current) => current.filter((_, rowIndex) => rowIndex !== index));
|
||||
}
|
||||
|
||||
function addThreshold() {
|
||||
if (thresholdRows.length >= maxAlerts) return;
|
||||
if (thresholdRows.some((row) => isEmptyThresholdRow(row.value))) return;
|
||||
|
||||
nextThresholdIdRef.current += 1;
|
||||
setThresholdRows((current) => [...current, { id: nextThresholdIdRef.current, value: "" }]);
|
||||
}
|
||||
|
||||
const hasEmptyThreshold = thresholdRows.some((row) => isEmptyThresholdRow(row.value));
|
||||
const hasDuplicateThresholds = !thresholdValuesAreUnique(currentThresholds);
|
||||
const canAddThreshold = thresholdRows.length < maxAlerts && !hasEmptyThreshold;
|
||||
const showAlertsSave = isDirty && !hasDuplicateThresholds;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 border-b border-grid-dimmed pb-3">
|
||||
<Header2 spacing>Billing alerts</Header2>
|
||||
<Paragraph variant="small">
|
||||
Receive an email when your compute spend crosses different thresholds. You can also learn
|
||||
how to{" "}
|
||||
<TextLink to={docsPath("how-to-reduce-your-spend")}>reduce your compute spend</TextLink>.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Form method="post" {...getFormProps(form)}>
|
||||
<input type="hidden" name="intent" value="billing-alerts" />
|
||||
<Fieldset>
|
||||
<AnimatedCallout
|
||||
show={showResetBanner}
|
||||
variant="warning"
|
||||
className="mb-4"
|
||||
autoHideMs={5_000}
|
||||
onAutoHide={() => setShowResetBanner(false)}
|
||||
>
|
||||
Billing alerts were reset because they no longer match the selected billing limit
|
||||
configuration.
|
||||
</AnimatedCallout>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={alertLevels.id}>
|
||||
{isPercentageMode ? "Alert me when I reach" : "Monthly spend alerts"}
|
||||
</Label>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{thresholdRows.map((row, index) => {
|
||||
const parsed = Number(row.value);
|
||||
const isDuplicate = isDuplicateThresholdRow(thresholdRows, index);
|
||||
|
||||
return (
|
||||
<div key={row.id} className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{isPercentageMode ? (
|
||||
<>
|
||||
<Input
|
||||
name={`${alertLevels.name}[${index}]`}
|
||||
id={`${alertLevels.id}-${index}`}
|
||||
type="number"
|
||||
value={row.value}
|
||||
onChange={(e) => updateThresholdRow(index, e.target.value)}
|
||||
onBlur={(e) => {
|
||||
if (e.target.value === "") return;
|
||||
const clamped = Math.min(
|
||||
MAX_PERCENTAGE_THRESHOLD,
|
||||
Math.max(1, Number(e.target.value))
|
||||
);
|
||||
if (String(clamped) !== e.target.value) {
|
||||
updateThresholdRow(index, String(clamped));
|
||||
}
|
||||
}}
|
||||
min={1}
|
||||
max={MAX_PERCENTAGE_THRESHOLD}
|
||||
step={1}
|
||||
placeholder="75"
|
||||
className="w-24"
|
||||
fullWidth={false}
|
||||
/>
|
||||
<span className="text-sm text-text-dimmed">%</span>
|
||||
<span className="text-sm text-text-dimmed">
|
||||
(
|
||||
{formatCurrency(
|
||||
previewDollarAmountForPercent(
|
||||
Number.isFinite(parsed) ? parsed : 0,
|
||||
alertPreviewLimitCents
|
||||
),
|
||||
false
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<Input
|
||||
name={`${alertLevels.name}[${index}]`}
|
||||
id={`${alertLevels.id}-${index}`}
|
||||
type="number"
|
||||
value={row.value}
|
||||
onChange={(e) => updateThresholdRow(index, e.target.value)}
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
placeholder="100"
|
||||
icon={
|
||||
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
|
||||
$
|
||||
</span>
|
||||
}
|
||||
className="max-w-xs pl-px"
|
||||
fullWidth={false}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={TrashIcon}
|
||||
className="shrink-0"
|
||||
onClick={() => removeThreshold(index)}
|
||||
aria-label="Remove alert"
|
||||
/>
|
||||
</div>
|
||||
{isDuplicate && <FormError>This alert threshold is already in use</FormError>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<FormError id={alertLevels.errorId}>{alertLevels.errors}</FormError>
|
||||
|
||||
{canAddThreshold && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
className="mt-2 w-fit"
|
||||
onClick={addThreshold}
|
||||
>
|
||||
Add alert
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<InputGroup fullWidth className="mt-4">
|
||||
<Label htmlFor={emails.id}>Email addresses</Label>
|
||||
{emailFields.map((email, index) => {
|
||||
const { defaultValue: _emailDefaultValue, ...emailInputProps } = getInputProps(
|
||||
email,
|
||||
{ type: "email" }
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment key={email.key}>
|
||||
<Input
|
||||
{...emailInputProps}
|
||||
value={emailValues[index] ?? ""}
|
||||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||||
onChange={(e) => {
|
||||
const nextValue = e.target.value;
|
||||
setEmailValues((current) => {
|
||||
const next = [...current];
|
||||
next[index] = nextValue;
|
||||
if (
|
||||
emailFields.length === next.length &&
|
||||
next.every((value) => value !== "")
|
||||
) {
|
||||
form.insert({ name: emails.name });
|
||||
return [...next, ""];
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
fullWidth
|
||||
/>
|
||||
<FormError id={email.errorId}>{email.errors}</FormError>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</InputGroup>
|
||||
|
||||
<FormButtons
|
||||
className={showAlertsSave ? undefined : "invisible"}
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/small" disabled={!showAlertsSave}>
|
||||
Update alerts
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
|
||||
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { Form, useActionData } from "@remix-run/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { getBillingLimitMode } from "~/components/billing/billingAlertsFormat";
|
||||
import { formatGracePeriodMs } from "~/components/billing/billingLimitFormat";
|
||||
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
|
||||
export const billingLimitFormSchema = z.discriminatedUnion("mode", [
|
||||
z.object({
|
||||
mode: z.literal("none"),
|
||||
cancelInProgressRuns: z
|
||||
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("plan"),
|
||||
cancelInProgressRuns: z
|
||||
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
z.object({
|
||||
mode: z.literal("custom"),
|
||||
amount: z.coerce
|
||||
.number({ invalid_type_error: "Not a valid amount" })
|
||||
.positive("Amount must be greater than 0"),
|
||||
cancelInProgressRuns: z
|
||||
.preprocess((v) => v === "on" || v === true || v === "true", z.boolean())
|
||||
.optional(),
|
||||
}),
|
||||
]);
|
||||
|
||||
type BillingLimitActionData = {
|
||||
formIntent: "billing-limit";
|
||||
submission: SubmissionResult;
|
||||
};
|
||||
|
||||
export function isBillingLimitFormDirty(input: {
|
||||
billingLimit: BillingLimitResult;
|
||||
mode: "none" | "plan" | "custom";
|
||||
customAmount: string;
|
||||
cancelInProgressRuns: boolean;
|
||||
}): boolean {
|
||||
const needsInitialSave = !input.billingLimit.isConfigured;
|
||||
const savedMode = getBillingLimitMode(input.billingLimit);
|
||||
const savedCustomAmount =
|
||||
input.billingLimit.isConfigured && input.billingLimit.mode === "custom"
|
||||
? (input.billingLimit.amountCents / 100).toFixed(2)
|
||||
: "";
|
||||
const savedCancelInProgressRuns =
|
||||
input.billingLimit.isConfigured && input.billingLimit.cancelInProgressRuns;
|
||||
|
||||
const isLimitDirty =
|
||||
input.mode !== savedMode ||
|
||||
(input.mode === "custom" && input.customAmount !== savedCustomAmount);
|
||||
|
||||
return (
|
||||
needsInitialSave || isLimitDirty || input.cancelInProgressRuns !== savedCancelInProgressRuns
|
||||
);
|
||||
}
|
||||
|
||||
export function getBillingLimitFormLastSubmission(
|
||||
submission: BillingLimitActionData["submission"] | undefined,
|
||||
mode: "none" | "plan" | "custom",
|
||||
isDirty: boolean
|
||||
) {
|
||||
if (!isDirty || !submission) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (mode !== "custom" && submission.error?.amount) {
|
||||
const { amount: _amount, ...remainingErrors } = submission.error;
|
||||
return {
|
||||
...submission,
|
||||
error: remainingErrors,
|
||||
};
|
||||
}
|
||||
|
||||
return submission;
|
||||
}
|
||||
|
||||
type BillingLimitConfigSectionProps = {
|
||||
billingLimit: BillingLimitResult;
|
||||
planLimitCents: number;
|
||||
};
|
||||
|
||||
export function BillingLimitConfigSection({
|
||||
billingLimit,
|
||||
planLimitCents,
|
||||
}: BillingLimitConfigSectionProps) {
|
||||
const gracePeriodLabel = formatGracePeriodMs(billingLimit.gracePeriodMs);
|
||||
|
||||
const savedMode = getBillingLimitMode(billingLimit);
|
||||
const savedCustomAmount =
|
||||
billingLimit.isConfigured && billingLimit.mode === "custom"
|
||||
? (billingLimit.amountCents / 100).toFixed(2)
|
||||
: "";
|
||||
const savedCancelInProgressRuns = billingLimit.isConfigured && billingLimit.cancelInProgressRuns;
|
||||
|
||||
const [mode, setMode] = useState<"none" | "plan" | "custom">(savedMode);
|
||||
const [customAmount, setCustomAmount] = useState(savedCustomAmount);
|
||||
const [cancelInProgressRuns, setCancelInProgressRuns] = useState(savedCancelInProgressRuns);
|
||||
const customAmountInputRef = useRef<HTMLInputElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMode(savedMode);
|
||||
setCustomAmount(savedCustomAmount);
|
||||
setCancelInProgressRuns(savedCancelInProgressRuns);
|
||||
}, [savedMode, savedCustomAmount, savedCancelInProgressRuns]);
|
||||
|
||||
function handleModeChange(value: string) {
|
||||
const nextMode = value as typeof mode;
|
||||
if (mode === "custom" && nextMode !== "custom") {
|
||||
setCustomAmount(savedCustomAmount);
|
||||
}
|
||||
setMode(nextMode);
|
||||
if (nextMode === "custom") {
|
||||
window.setTimeout(() => customAmountInputRef.current?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const actionData = useActionData<BillingLimitActionData>();
|
||||
const limitSubmission =
|
||||
actionData?.formIntent === "billing-limit" ? actionData.submission : undefined;
|
||||
|
||||
const _needsInitialSave = !billingLimit.isConfigured;
|
||||
const _isLimitDirty =
|
||||
mode !== savedMode || (mode === "custom" && customAmount !== savedCustomAmount);
|
||||
const isDirty = isBillingLimitFormDirty({
|
||||
billingLimit,
|
||||
mode,
|
||||
customAmount,
|
||||
cancelInProgressRuns,
|
||||
});
|
||||
const lastSubmission = useMemo(
|
||||
() => getBillingLimitFormLastSubmission(limitSubmission, mode, isDirty),
|
||||
[limitSubmission, mode, isDirty]
|
||||
);
|
||||
|
||||
const [form, fields] = useForm({
|
||||
id: "billing-limit",
|
||||
lastResult: lastSubmission as any,
|
||||
shouldRevalidate: "onInput",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: billingLimitFormSchema });
|
||||
},
|
||||
defaultValue: {
|
||||
mode: savedMode,
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [customAmount, mode]);
|
||||
|
||||
const planLimitLabel = formatCurrency(planLimitCents / 100, false);
|
||||
const showPlanInfoCallout = mode === "plan";
|
||||
const showCustomInfoCallout = mode === "custom";
|
||||
const showNoneWarningCallout = mode === "none";
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 border-b border-grid-dimmed pb-3">
|
||||
<Header2 spacing>Billing limit</Header2>
|
||||
<Paragraph variant="small">
|
||||
Set a monthly compute spend limit for your organization. When the limit is reached,
|
||||
billable environments enter a grace period before new triggers are rejected.
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<Form method="post" {...getFormProps(form)} ref={formRef}>
|
||||
<input type="hidden" name="intent" value="billing-limit" />
|
||||
<Fieldset>
|
||||
<input type="hidden" name="mode" value={mode} />
|
||||
|
||||
<RadioGroup value={mode} onValueChange={handleModeChange} className="flex flex-col gap-2">
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
|
||||
<RadioGroupItem
|
||||
id="limit_mode_plan"
|
||||
value="plan"
|
||||
variant="description"
|
||||
label={
|
||||
<span className="inline-flex items-center">{`My plan limit (${planLimitLabel})`}</span>
|
||||
}
|
||||
description={`Pause billable environments when monthly spend reaches ${planLimitLabel}.`}
|
||||
/>
|
||||
<div className="relative">
|
||||
<AnimatedCallout
|
||||
show={showPlanInfoCallout}
|
||||
variant="info"
|
||||
className="absolute w-full text-sm"
|
||||
>
|
||||
<LimitReachedCalloutContent
|
||||
gracePeriodLabel={gracePeriodLabel}
|
||||
cancelInProgressRuns={cancelInProgressRuns}
|
||||
/>
|
||||
</AnimatedCallout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
|
||||
<div className="flex flex-col gap-2">
|
||||
<RadioGroupItem
|
||||
id="limit_mode_custom"
|
||||
value="custom"
|
||||
variant="description"
|
||||
label="Custom limit"
|
||||
description="Set your own monthly spend threshold."
|
||||
/>
|
||||
{mode === "custom" && (
|
||||
<InputGroup fullWidth className="mb-1 mt-0.5">
|
||||
<Input
|
||||
ref={customAmountInputRef}
|
||||
id="custom-amount"
|
||||
name="amount"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="Custom limit amount"
|
||||
icon={
|
||||
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
|
||||
$
|
||||
</span>
|
||||
}
|
||||
className="pl-px"
|
||||
fullWidth
|
||||
/>
|
||||
{fields.amount && (
|
||||
<FormError id={fields.amount.errorId}>{fields.amount.errors}</FormError>
|
||||
)}
|
||||
</InputGroup>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<AnimatedCallout
|
||||
show={showCustomInfoCallout}
|
||||
variant="info"
|
||||
className="absolute w-full text-sm"
|
||||
>
|
||||
<LimitReachedCalloutContent
|
||||
gracePeriodLabel={gracePeriodLabel}
|
||||
cancelInProgressRuns={cancelInProgressRuns}
|
||||
/>
|
||||
</AnimatedCallout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 lg:grid-cols-2 lg:items-start lg:gap-x-4 lg:gap-y-2">
|
||||
<RadioGroupItem
|
||||
id="limit_mode_none"
|
||||
value="none"
|
||||
variant="description"
|
||||
label="I don't want a billing limit"
|
||||
description="Runs continue without protection against runaway usage."
|
||||
/>
|
||||
<div className="relative">
|
||||
<AnimatedCallout
|
||||
show={showNoneWarningCallout}
|
||||
variant="warning"
|
||||
className="absolute w-full"
|
||||
>
|
||||
Without a billing limit, runs will continue even if usage spikes unexpectedly. You
|
||||
may have to pay higher fees before you notice.
|
||||
</AnimatedCallout>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{mode !== "none" && (
|
||||
<CheckboxWithLabel
|
||||
className="mt-4"
|
||||
name="cancelInProgressRuns"
|
||||
id="cancel_in_progress_runs"
|
||||
value="on"
|
||||
variant="simple/small"
|
||||
label="Cancel in-progress runs when this limit is reached"
|
||||
defaultChecked={cancelInProgressRuns}
|
||||
onChange={setCancelInProgressRuns}
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
className={isDirty ? undefined : "invisible"}
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/small" disabled={!isDirty}>
|
||||
Save billing limit
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LimitReachedCalloutContent({
|
||||
gracePeriodLabel,
|
||||
cancelInProgressRuns,
|
||||
}: {
|
||||
gracePeriodLabel: string;
|
||||
cancelInProgressRuns: boolean;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
When this limit is reached, queued runs will be held for {gracePeriodLabel}, then new triggers
|
||||
will be rejected until you increase or remove the limit. Limits are enforced with a short
|
||||
delay, so spend may briefly exceed the limit before grace begins. See our{" "}
|
||||
<a href="https://trigger.dev/terms" className="underline">
|
||||
terms
|
||||
</a>{" "}
|
||||
for refund policy details.
|
||||
{cancelInProgressRuns ? (
|
||||
<> In-progress runs will be cancelled when the limit is hit.</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { getFormProps, useForm, type SubmissionResult } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { RadioGroup, RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
|
||||
export const billingLimitRecoveryFormSchema = z
|
||||
.object({
|
||||
action: z.enum(["increase", "remove"]),
|
||||
newAmount: z.coerce
|
||||
.number({ invalid_type_error: "Not a valid amount" })
|
||||
.positive("Amount must be greater than 0")
|
||||
.optional(),
|
||||
resumeMode: z.enum(["queue", "new_only"]),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.action === "increase" && (value.newAmount === undefined || value.newAmount <= 0)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Amount must be greater than 0",
|
||||
path: ["newAmount"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
type BillingLimitRecoveryActionData = {
|
||||
formIntent: "billing-limit-resolve";
|
||||
submission: SubmissionResult;
|
||||
};
|
||||
|
||||
export function BillingLimitRecoveryPanel({
|
||||
billingLimit,
|
||||
currentSpendCents,
|
||||
queuedRunCount,
|
||||
suggestedNewLimitDollars,
|
||||
}: {
|
||||
billingLimit: BillingLimitResult & { isConfigured: true };
|
||||
currentSpendCents: number;
|
||||
queuedRunCount: number;
|
||||
suggestedNewLimitDollars: number;
|
||||
}) {
|
||||
const { limitState } = billingLimit;
|
||||
const isGrace = limitState.status === "grace";
|
||||
const isRejected = limitState.status === "rejected";
|
||||
|
||||
const [action, setAction] = useState<"increase" | "remove">("increase");
|
||||
const [newAmount, setNewAmount] = useState(String(suggestedNewLimitDollars));
|
||||
const [resumeMode, setResumeMode] = useState<"queue" | "new_only">("queue");
|
||||
const newAmountInputRef = useRef<HTMLInputElement>(null);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setNewAmount(String(suggestedNewLimitDollars));
|
||||
}, [suggestedNewLimitDollars]);
|
||||
|
||||
function handleActionChange(value: string) {
|
||||
const nextAction = value as typeof action;
|
||||
setAction(nextAction);
|
||||
if (nextAction === "increase") {
|
||||
window.setTimeout(() => newAmountInputRef.current?.focus(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
const actionData = useActionData<BillingLimitRecoveryActionData>();
|
||||
const recoverySubmission =
|
||||
actionData?.formIntent === "billing-limit-resolve" ? actionData.submission : undefined;
|
||||
|
||||
const [form, fields] = useForm({
|
||||
id: "billing-limit-resolve",
|
||||
lastResult: recoverySubmission as any,
|
||||
shouldRevalidate: "onInput",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: billingLimitRecoveryFormSchema });
|
||||
},
|
||||
defaultValue: {
|
||||
action: "increase",
|
||||
newAmount: suggestedNewLimitDollars,
|
||||
resumeMode: "queue",
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
formRef.current?.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
}, [action, newAmount, resumeMode]);
|
||||
|
||||
const navigation = useNavigation();
|
||||
const isSubmitting = navigation.state === "submitting";
|
||||
|
||||
const queuedRunsLabel = useMemo(() => {
|
||||
if (queuedRunCount === 0) {
|
||||
return null;
|
||||
}
|
||||
return `~${queuedRunCount.toLocaleString()} run${
|
||||
queuedRunCount === 1 ? "" : "s"
|
||||
} waiting in queue`;
|
||||
}, [queuedRunCount]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="border-b border-grid-dimmed pb-3">
|
||||
<Header2 spacing>Action required</Header2>
|
||||
<Callout variant="warning">
|
||||
<Paragraph variant="small" className="text-yellow-200">
|
||||
{isGrace ? (
|
||||
<>
|
||||
Your organization has reached its billing limit. Processing is paused and new runs
|
||||
are queuing. Without action, new triggers block on{" "}
|
||||
<DateTime date={limitState.graceEndsAt} includeTime />.
|
||||
</>
|
||||
) : (
|
||||
"Your organization has exceeded its billing limit. New triggers are blocked until you resolve this."
|
||||
)}
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Paragraph variant="small">
|
||||
Current usage: {formatCurrency(currentSpendCents / 100, false)}
|
||||
</Paragraph>
|
||||
{billingLimit.effectiveAmountCents !== null && (
|
||||
<Paragraph variant="small">
|
||||
Current limit: {formatCurrency(billingLimit.effectiveAmountCents / 100, false)}
|
||||
</Paragraph>
|
||||
)}
|
||||
{queuedRunsLabel && <Paragraph variant="small">{queuedRunsLabel}</Paragraph>}
|
||||
|
||||
{isRejected && queuedRunsLabel && (
|
||||
<Paragraph variant="small">New triggers are currently blocked.</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Form method="post" {...getFormProps(form)} ref={formRef}>
|
||||
<input type="hidden" name="intent" value="billing-limit-resolve" />
|
||||
<input type="hidden" name="action" value={action} />
|
||||
<input type="hidden" name="resumeMode" value={resumeMode} />
|
||||
|
||||
<Fieldset className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
<Header2 spacing className="text-base">
|
||||
How would you like to resolve this?
|
||||
</Header2>
|
||||
<RadioGroup
|
||||
value={action}
|
||||
onValueChange={handleActionChange}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<RadioGroupItem
|
||||
id="resolve_action_increase"
|
||||
value="increase"
|
||||
variant="description"
|
||||
label="Increase billing limit"
|
||||
description="Continue running with a higher monthly cap."
|
||||
/>
|
||||
{action === "increase" && (
|
||||
<InputGroup fullWidth>
|
||||
<Input
|
||||
ref={newAmountInputRef}
|
||||
id="new-amount"
|
||||
name="newAmount"
|
||||
type="number"
|
||||
step={0.01}
|
||||
min={0.01}
|
||||
value={newAmount}
|
||||
onChange={(e) => setNewAmount(e.target.value)}
|
||||
placeholder="New limit amount"
|
||||
icon={
|
||||
<span className="-mt-0.5 block pl-1.5 pr-2 text-sm font-semibold text-text-dimmed">
|
||||
$
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
</InputGroup>
|
||||
)}
|
||||
{action === "increase" && fields.newAmount?.errors && (
|
||||
<FormError id={fields.newAmount?.errorId}>{fields.newAmount.errors}</FormError>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<RadioGroupItem
|
||||
id="resolve_action_remove"
|
||||
value="remove"
|
||||
variant="description"
|
||||
label="Remove billing limit"
|
||||
description="Keep going without a ceiling on usage."
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 border-t border-grid-dimmed pt-6">
|
||||
<Header2 spacing className="text-base">
|
||||
What should happen to queued runs?
|
||||
</Header2>
|
||||
<RadioGroup
|
||||
value={resumeMode}
|
||||
onValueChange={(value) => setResumeMode(value as typeof resumeMode)}
|
||||
className="flex flex-col gap-2"
|
||||
>
|
||||
<RadioGroupItem
|
||||
id="resume_mode_queue"
|
||||
value="queue"
|
||||
variant="description"
|
||||
label="Resume queued runs"
|
||||
description="Everything built up during the pause will run in order."
|
||||
/>
|
||||
<RadioGroupItem
|
||||
id="resume_mode_new_only"
|
||||
value="new_only"
|
||||
variant="description"
|
||||
label="Cancel queued runs"
|
||||
description="Nothing from the pause is kept — only new triggers going forward."
|
||||
/>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button type="submit" variant="primary/medium" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Resolving…" : "Resolve"}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { AnimatedCallout } from "~/components/primitives/AnimatedCallout";
|
||||
|
||||
export function BillingLimitResolveProgress({
|
||||
show,
|
||||
cancellingQueuedRuns,
|
||||
}: {
|
||||
show: boolean;
|
||||
cancellingQueuedRuns: boolean;
|
||||
}) {
|
||||
if (!show) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<AnimatedCallout show variant="success">
|
||||
Billing limit resolved. Environments are being unpaused — this usually takes a few seconds.
|
||||
</AnimatedCallout>
|
||||
{cancellingQueuedRuns && (
|
||||
<AnimatedCallout show variant="info">
|
||||
Cancelling queued runs across billable environments…
|
||||
</AnimatedCallout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useThemeColor } from "~/hooks/useThemeColor";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function FreePlanUsage({ to, percentage }: { to: string; percentage: number }) {
|
||||
const cappedPercentage = Math.min(percentage, 1);
|
||||
const widthProgress = useMotionValue(cappedPercentage * 100);
|
||||
// Resolved to concrete colors - framer-motion can't interpolate var() strings
|
||||
const successColor = useThemeColor("--color-success", "#28bf5c");
|
||||
const warningColor = useThemeColor("--color-warning", "#f59e0b");
|
||||
const errorColor = useThemeColor("--color-error", "#e11d48");
|
||||
const color = useTransform(
|
||||
widthProgress,
|
||||
[0, 74, 75, 95, 100],
|
||||
[successColor, successColor, warningColor, errorColor, errorColor]
|
||||
);
|
||||
|
||||
const hasHitLimit = cappedPercentage >= 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"rounded border border-grid-bright bg-background-hover p-2.5",
|
||||
hasHitLimit && "border-error/40"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
|
||||
<span className="text-2sm text-text-bright">Free Plan</span>
|
||||
</div>
|
||||
<Link to={to} className="text-2sm text-text-link focus-custom">
|
||||
Upgrade
|
||||
</Link>
|
||||
</div>
|
||||
<div className="relative mt-3 h-1 rounded-full bg-background-dimmed">
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: cappedPercentage * 100 + "%" }}
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
}}
|
||||
transition={{ duration: 1, type: "spring" }}
|
||||
className={cn("absolute left-0 top-0 h-full rounded-full")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { environmentFullTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AnimatedOrgBannerBar } from "~/components/billing/AnimatedOrgBannerBar";
|
||||
import { OrgBannerKind, selectOrgBanner } from "~/components/billing/selectOrgBanner";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { useEnvironment, useOptionalEnvironment } from "~/hooks/useEnvironment";
|
||||
import {
|
||||
useOptionalOrganization,
|
||||
useOrganization,
|
||||
useBillingLimit,
|
||||
useCanManageBillingLimits,
|
||||
} from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject, useProject } from "~/hooks/useProject";
|
||||
import { useShowSelfServe } from "~/hooks/useShowSelfServe";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { v3BillingLimitsPath, v3BillingPath, v3QueuesPath } from "~/utils/pathBuilder";
|
||||
import { ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT } from "~/utils/environmentPauseSource";
|
||||
|
||||
function getUpgradeResetDate(): Date {
|
||||
const nextMonth = new Date();
|
||||
nextMonth.setUTCDate(1);
|
||||
nextMonth.setUTCHours(0, 0, 0, 0);
|
||||
nextMonth.setUTCMonth(nextMonth.getUTCMonth() + 1);
|
||||
return nextMonth;
|
||||
}
|
||||
|
||||
export function OrgBanner() {
|
||||
const organization = useOptionalOrganization();
|
||||
const project = useOptionalProject();
|
||||
const environment = useOptionalEnvironment();
|
||||
const billingLimit = useBillingLimit();
|
||||
const currentPlan = useCurrentPlan();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const location = useLocation();
|
||||
|
||||
// Billing-limit pauses are surfaced by the dedicated limit banners (grace/rejected).
|
||||
// Don't also raise the generic "environment paused" warning for them — that would
|
||||
// alarm the user during the async resolve→ok window, when billing is already `ok`
|
||||
// but converge hasn't finished unpausing envs yet. Manual pauses still warn.
|
||||
const isPaused = !!(
|
||||
organization &&
|
||||
project &&
|
||||
environment &&
|
||||
environment.paused &&
|
||||
environment.pauseSource !== ENVIRONMENT_PAUSE_SOURCE_BILLING_LIMIT
|
||||
);
|
||||
const isArchived = !!(organization && project && environment && environment.archivedAt);
|
||||
|
||||
const bannerKind = selectOrgBanner({
|
||||
billingLimit,
|
||||
hasExceededFreeTier: currentPlan?.v3Usage.hasExceededFreeTier === true,
|
||||
showEnvironmentWarning: isPaused || isArchived,
|
||||
showSelfServe,
|
||||
});
|
||||
|
||||
const hideQueuesButton = location.pathname.endsWith("/queues");
|
||||
const hideBillingLimitBanner = location.pathname.endsWith("/settings/billing-limits");
|
||||
|
||||
switch (bannerKind) {
|
||||
case OrgBannerKind.LimitRejected:
|
||||
return hideBillingLimitBanner ? null : <LimitRejectedBanner />;
|
||||
case OrgBannerKind.LimitGrace:
|
||||
return hideBillingLimitBanner ? null : <LimitGraceBanner />;
|
||||
case OrgBannerKind.NoLimitConfigured:
|
||||
return hideBillingLimitBanner ? null : <NoLimitConfiguredBanner />;
|
||||
case OrgBannerKind.Upgrade:
|
||||
return organization ? <UpgradeBanner /> : null;
|
||||
case OrgBannerKind.EnvironmentWarning:
|
||||
return isArchived ? (
|
||||
<ArchivedEnvironmentBanner />
|
||||
) : (
|
||||
<PausedEnvironmentBanner hideButton={hideQueuesButton} />
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function LimitRejectedBanner() {
|
||||
const organization = useOrganization();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const canManageBillingLimits = useCanManageBillingLimits();
|
||||
const canResolve = showSelfServe && canManageBillingLimits;
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar
|
||||
show
|
||||
variant="error"
|
||||
action={
|
||||
canResolve ? (
|
||||
<LinkButton
|
||||
variant="danger/small"
|
||||
leadingIconClassName="px-0"
|
||||
to={v3BillingLimitsPath(organization)}
|
||||
>
|
||||
Resolve
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="font-medium">Billing limit exceeded</span> — New triggers are currently
|
||||
blocked.
|
||||
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
|
||||
function LimitGraceBanner() {
|
||||
const organization = useOrganization();
|
||||
const billingLimit = useBillingLimit();
|
||||
const showSelfServe = useShowSelfServe();
|
||||
const canManageBillingLimits = useCanManageBillingLimits();
|
||||
const canResolve = showSelfServe && canManageBillingLimits;
|
||||
|
||||
const graceEndsAt =
|
||||
billingLimit?.isConfigured && billingLimit.limitState.status === "grace"
|
||||
? billingLimit.limitState.graceEndsAt
|
||||
: null;
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar
|
||||
show={graceEndsAt !== null}
|
||||
variant="error"
|
||||
action={
|
||||
canResolve ? (
|
||||
<LinkButton
|
||||
variant="danger/small"
|
||||
leadingIconClassName="px-0"
|
||||
to={v3BillingLimitsPath(organization)}
|
||||
>
|
||||
Resolve
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<span className="font-medium">Billing limit reached</span> — Queues have been paused. New runs
|
||||
will continue to queue until <DateTime date={graceEndsAt ?? new Date()} includeTime />.
|
||||
{!canResolve ? " Contact your organization administrator to resolve this issue." : null}
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
|
||||
function NoLimitConfiguredBanner() {
|
||||
const organization = useOrganization();
|
||||
const canManageBillingLimits = useCanManageBillingLimits();
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar
|
||||
show
|
||||
variant="warning"
|
||||
action={
|
||||
canManageBillingLimits ? (
|
||||
<LinkButton variant="tertiary/small" to={v3BillingLimitsPath(organization)}>
|
||||
Configure billing limit
|
||||
</LinkButton>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{canManageBillingLimits
|
||||
? "Protect your organization from unexpected usage spikes."
|
||||
: "Billing limits are not configured for this organization. Contact an organization administrator to configure them."}
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
|
||||
function UpgradeBanner() {
|
||||
const organization = useOrganization();
|
||||
const plan = useCurrentPlan();
|
||||
const freeCreditsDollars = (plan?.v3Subscription?.plan?.limits.includedUsage ?? 500) / 100;
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar
|
||||
show={plan?.v3Usage.hasExceededFreeTier === true}
|
||||
variant="error"
|
||||
action={
|
||||
<LinkButton
|
||||
variant="danger/small"
|
||||
leadingIconClassName="px-0"
|
||||
to={v3BillingPath(organization)}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
>
|
||||
You have exceeded the monthly ${freeCreditsDollars} free credits. Existing runs will be queued
|
||||
and new runs won't be created until{" "}
|
||||
<DateTime date={getUpgradeResetDate()} includeTime={false} timeZone="utc" />, or you upgrade.
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
|
||||
function PausedEnvironmentBanner({ hideButton }: { hideButton: boolean }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar
|
||||
show
|
||||
variant="warning"
|
||||
action={
|
||||
hideButton ? undefined : (
|
||||
<LinkButton
|
||||
variant="tertiary/small"
|
||||
to={v3QueuesPath(organization, project, environment)}
|
||||
>
|
||||
Manage
|
||||
</LinkButton>
|
||||
)
|
||||
}
|
||||
>
|
||||
{environmentFullTitle(environment)} environment paused. No new runs will be dequeued and
|
||||
executed.
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchivedEnvironmentBanner() {
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<AnimatedOrgBannerBar show variant="warning">
|
||||
"{environment.branchName}" branch is archived and is read-only. No new runs will be dequeued
|
||||
and executed.
|
||||
</AnimatedOrgBannerBar>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { formatCurrency } from "~/utils/numberFormatter";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { motion } from "framer-motion";
|
||||
|
||||
type UsageBarProps = {
|
||||
current: number;
|
||||
billingLimit?: number;
|
||||
tierLimit?: number;
|
||||
isPaying: boolean;
|
||||
};
|
||||
|
||||
const startFactor = 4;
|
||||
|
||||
export function UsageBar({ current, billingLimit, tierLimit, isPaying }: UsageBarProps) {
|
||||
const getLargestNumber = Math.max(current, tierLimit ?? -Infinity, billingLimit ?? -Infinity, 5);
|
||||
//creates a maximum range for the progress bar, add 10% to the largest number so the bar doesn't reach the end
|
||||
const maxRange = Math.round(getLargestNumber * 1.1);
|
||||
const tierRunLimitPercentage = tierLimit ? Math.round((tierLimit / maxRange) * 100) : 0;
|
||||
const billingLimitPercentage =
|
||||
billingLimit !== undefined ? Math.round((billingLimit / maxRange) * 100) : 0;
|
||||
const usagePercentage = Math.round((current / maxRange) * 100);
|
||||
|
||||
//cap the usagePercentage to the freeRunLimitPercentage
|
||||
const usageCappedToLimitPercentage = Math.min(usagePercentage, tierRunLimitPercentage);
|
||||
|
||||
return (
|
||||
<div className="h-fit w-full py-6">
|
||||
<div className="relative h-3 w-full rounded-sm bg-background-bright">
|
||||
{billingLimit !== undefined && (
|
||||
<motion.div
|
||||
initial={{ width: billingLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: billingLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${billingLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm"
|
||||
>
|
||||
<Legend
|
||||
text="Billing limit:"
|
||||
value={formatCurrency(billingLimit, false)}
|
||||
position="bottomRow2"
|
||||
percentage={billingLimitPercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ width: usagePercentage / startFactor + "%" }}
|
||||
animate={{ width: usagePercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usagePercentage}%` }}
|
||||
className={cn(
|
||||
"absolute h-3 rounded-l-sm",
|
||||
tierLimit && current > tierLimit ? "bg-green-700" : "bg-green-600"
|
||||
)}
|
||||
>
|
||||
<Legend
|
||||
text="Used:"
|
||||
value={formatCurrency(current, false)}
|
||||
position="topRow1"
|
||||
percentage={usagePercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
{tierLimit !== undefined && (
|
||||
<motion.div
|
||||
initial={{ width: tierRunLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: tierRunLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${tierRunLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-900/50"
|
||||
>
|
||||
<Legend
|
||||
text={isPaying ? `Included usage:` : `Tier limit:`}
|
||||
value={formatCurrency(tierLimit, false)}
|
||||
position="bottomRow1"
|
||||
percentage={tierRunLimitPercentage}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
<motion.div
|
||||
initial={{ width: usageCappedToLimitPercentage / startFactor + "%" }}
|
||||
animate={{ width: usageCappedToLimitPercentage + "%" }}
|
||||
transition={{ duration: 1.5, type: "spring" }}
|
||||
style={{ width: `${usageCappedToLimitPercentage}%` }}
|
||||
className="absolute h-3 rounded-l-sm bg-green-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const positions = {
|
||||
topRow1: "bottom-0 h-9",
|
||||
topRow2: "bottom-0 h-14",
|
||||
bottomRow1: "top-0 h-9 items-end",
|
||||
bottomRow2: "top-0 h-14 items-end",
|
||||
};
|
||||
|
||||
type LegendProps = {
|
||||
text: string;
|
||||
value: number | string;
|
||||
percentage: number;
|
||||
position: keyof typeof positions;
|
||||
tooltipContent?: string;
|
||||
};
|
||||
|
||||
function Legend({ text, value, position, percentage, tooltipContent }: LegendProps) {
|
||||
const flipLegendPositionValue = 80;
|
||||
const flipLegendPosition = percentage > flipLegendPositionValue ? true : false;
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute left-full z-10 flex border-border-brightest",
|
||||
positions[position],
|
||||
flipLegendPosition === true ? "-translate-x-full border-r" : "border-l"
|
||||
)}
|
||||
>
|
||||
{tooltipContent ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
|
||||
{text}
|
||||
<span className="ml-1 text-text-dimmed">{value}</span>
|
||||
</Paragraph>
|
||||
}
|
||||
side="top"
|
||||
content={tooltipContent}
|
||||
className="z-50 h-fit"
|
||||
/>
|
||||
) : (
|
||||
<Paragraph className="mr-px h-fit whitespace-nowrap bg-background-bright px-1.5 text-xs text-text-bright">
|
||||
{text}
|
||||
<span className="ml-1 text-text-dimmed">{value}</span>
|
||||
</Paragraph>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
|
||||
export type BillingAlertsFormData = {
|
||||
/** Stored base amount in dollars (from API cents / 100). Used when converting legacy alerts. */
|
||||
amount: number;
|
||||
emails: string[];
|
||||
alertLevels: number[];
|
||||
};
|
||||
|
||||
/** $1 base (in cents) for absolute spend alerts when billing limit mode is none. */
|
||||
export const ABSOLUTE_ALERT_BASE_CENTS = 100;
|
||||
|
||||
export const MAX_PERCENTAGE_ALERTS = 5;
|
||||
export const MAX_ABSOLUTE_ALERTS = 10;
|
||||
export const MAX_PERCENTAGE_THRESHOLD = 100;
|
||||
|
||||
export type BillingLimitMode = "plan" | "custom" | "none";
|
||||
|
||||
export function getBillingLimitMode(billingLimit: BillingLimitResult): BillingLimitMode {
|
||||
if (!billingLimit.isConfigured) {
|
||||
return "none";
|
||||
}
|
||||
return billingLimit.mode;
|
||||
}
|
||||
|
||||
export function isPercentageAlertMode(mode: BillingLimitMode): boolean {
|
||||
return mode === "plan" || mode === "custom";
|
||||
}
|
||||
|
||||
export function shouldClearAlertsOnLimitChange(
|
||||
previousMode: BillingLimitMode,
|
||||
nextMode: BillingLimitMode
|
||||
): boolean {
|
||||
return shouldResetAlertsOnLimitChange(previousMode, nextMode);
|
||||
}
|
||||
|
||||
/** Alert format changes when crossing between percentage (plan/custom) and dollar (none) modes. */
|
||||
export function shouldResetAlertsOnLimitChange(
|
||||
previousMode: BillingLimitMode | null,
|
||||
nextMode: BillingLimitMode
|
||||
): boolean {
|
||||
if (previousMode === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isPercentageAlertMode(previousMode) !== isPercentageAlertMode(nextMode);
|
||||
}
|
||||
|
||||
/** Configured billing limit mode before a save; null when billing limit was never configured. */
|
||||
export function getPreviousBillingLimitModeForAlertSync(
|
||||
billingLimit: BillingLimitResult
|
||||
): BillingLimitMode | null {
|
||||
if (!billingLimit.isConfigured) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return billingLimit.mode;
|
||||
}
|
||||
|
||||
export function hasConfiguredAlerts(
|
||||
alerts: BillingAlertsFormData,
|
||||
billingLimit: BillingLimitResult,
|
||||
planLimitCents: number
|
||||
): boolean {
|
||||
const mode = getBillingLimitMode(billingLimit);
|
||||
const effectiveLimitCents = getEffectiveLimitCents(billingLimit, planLimitCents);
|
||||
return storedAlertsToThresholds(alerts, mode, effectiveLimitCents, planLimitCents).length > 0;
|
||||
}
|
||||
|
||||
export function hasSavedAlertThresholds(alerts: BillingAlertsFormData): boolean {
|
||||
return alerts.alertLevels.length > 0;
|
||||
}
|
||||
|
||||
/** Saved thresholds that would be cleared when the billing limit alert format changes. */
|
||||
export function hadSavedAlertsToClearOnLimitChange(
|
||||
alerts: BillingAlertsFormData,
|
||||
billingLimit: BillingLimitResult,
|
||||
planLimitCents: number
|
||||
): boolean {
|
||||
return hasConfiguredAlerts(alerts, billingLimit, planLimitCents);
|
||||
}
|
||||
|
||||
export function normalizeThresholdValues(values: number[]): number[] {
|
||||
return [...values].sort((a, b) => a - b);
|
||||
}
|
||||
|
||||
export function thresholdValuesAreUnique(values: number[]): boolean {
|
||||
const normalized = normalizeThresholdValues(values);
|
||||
return new Set(normalized).size === normalized.length;
|
||||
}
|
||||
|
||||
export function normalizeEmailValues(values: string[]): string[] {
|
||||
return values.map((value) => value.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export function thresholdsMatchSaved(current: number[], saved: number[]): boolean {
|
||||
return (
|
||||
JSON.stringify(normalizeThresholdValues(current)) ===
|
||||
JSON.stringify(normalizeThresholdValues(saved))
|
||||
);
|
||||
}
|
||||
|
||||
export function emailsMatchSaved(current: string[], saved: string[]): boolean {
|
||||
return (
|
||||
JSON.stringify(normalizeEmailValues(current)) === JSON.stringify(normalizeEmailValues(saved))
|
||||
);
|
||||
}
|
||||
|
||||
export function clearedAlertsPayload(emails: string[] = []): {
|
||||
amount: number;
|
||||
alertLevels: number[];
|
||||
emails: string[];
|
||||
} {
|
||||
return {
|
||||
amount: ABSOLUTE_ALERT_BASE_CENTS,
|
||||
alertLevels: [],
|
||||
emails,
|
||||
};
|
||||
}
|
||||
|
||||
export function resetAlertsPayloadForLimitMode(
|
||||
nextMode: BillingLimitMode,
|
||||
effectiveLimitCents: number,
|
||||
emails: string[] = []
|
||||
): { amount: number; alertLevels: number[]; emails: string[] } {
|
||||
if (nextMode === "none") {
|
||||
return clearedAlertsPayload(emails);
|
||||
}
|
||||
|
||||
return {
|
||||
amount: effectiveLimitCents,
|
||||
alertLevels: [],
|
||||
emails,
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectiveLimitCents(
|
||||
billingLimit: BillingLimitResult,
|
||||
planLimitCents: number
|
||||
): number {
|
||||
if (!billingLimit.isConfigured) {
|
||||
return planLimitCents;
|
||||
}
|
||||
if (billingLimit.mode === "custom") {
|
||||
return billingLimit.amountCents;
|
||||
}
|
||||
if (billingLimit.mode === "plan") {
|
||||
return billingLimit.effectiveAmountCents ?? planLimitCents;
|
||||
}
|
||||
return planLimitCents;
|
||||
}
|
||||
|
||||
/** Billing limit in cents when configured (plan/custom); undefined for none or unconfigured. */
|
||||
export function getConfiguredBillingLimitCents(
|
||||
billingLimit: BillingLimitResult | undefined,
|
||||
planLimitCents: number
|
||||
): number | undefined {
|
||||
if (!billingLimit?.isConfigured || billingLimit.mode === "none") {
|
||||
return undefined;
|
||||
}
|
||||
return getEffectiveLimitCents(billingLimit, planLimitCents);
|
||||
}
|
||||
|
||||
/** Dollars for UsageBar marker; omitted when no limit or same as plan included usage. */
|
||||
export function getUsageBarBillingLimitDollars(
|
||||
billingLimit: BillingLimitResult | undefined,
|
||||
planLimitCents: number
|
||||
): number | undefined {
|
||||
const limitCents = getConfiguredBillingLimitCents(billingLimit, planLimitCents);
|
||||
if (limitCents === undefined || limitCents === planLimitCents) {
|
||||
return undefined;
|
||||
}
|
||||
return limitCents / 100;
|
||||
}
|
||||
|
||||
function getSavedAlertAmountCents(alerts: BillingAlertsFormData): number {
|
||||
return Math.round(alerts.amount * 100);
|
||||
}
|
||||
|
||||
function usesFractionAlertLevelFormat(levels: number[]): boolean {
|
||||
return levels.some((level) => level > 0 && level <= 1);
|
||||
}
|
||||
|
||||
export type NormalizeBillingAlertsOptions = {
|
||||
planLimitCents: number;
|
||||
effectiveLimitCents: number;
|
||||
};
|
||||
|
||||
function isAbsoluteDollarAlertAmount(rawAmount: number, alertLevels: number[]): boolean {
|
||||
if (rawAmount !== ABSOLUTE_ALERT_BASE_CENTS || alertLevels.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// None-mode absolute alerts use the $1 base (100 cents) with dollar thresholds (e.g. 100, 250).
|
||||
// Legacy percentage alerts at amount=100 use UI percent values, with at least one below 100.
|
||||
const hasTypicalPercentLevel = alertLevels.some((level) => level > 0 && level < 100);
|
||||
|
||||
return !hasTypicalPercentLevel;
|
||||
}
|
||||
|
||||
/** Detect legacy alerts that stored the limit base in dollars with whole-number percents. */
|
||||
export function isLegacyDollarAmountField(
|
||||
rawAmount: number,
|
||||
alertLevels: number[],
|
||||
options: NormalizeBillingAlertsOptions
|
||||
): boolean {
|
||||
if (isAbsoluteDollarAlertAmount(rawAmount, alertLevels)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(rawAmount) || rawAmount < 10) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (alertLevels.length === 0 || usesFractionAlertLevelFormat(alertLevels)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Platform migrated $10+ limits to cents (e.g. 10_000 for $100).
|
||||
if (rawAmount >= 1000) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rawAmount === options.planLimitCents || rawAmount === options.effectiveLimitCents) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const planDollars = Math.round(options.planLimitCents / 100);
|
||||
const effectiveDollars = Math.round(options.effectiveLimitCents / 100);
|
||||
|
||||
return rawAmount === planDollars || rawAmount === effectiveDollars;
|
||||
}
|
||||
export function isAbsoluteSavedAlerts(alerts: BillingAlertsFormData): boolean {
|
||||
return getSavedAlertAmountCents(alerts) === ABSOLUTE_ALERT_BASE_CENTS;
|
||||
}
|
||||
|
||||
/** Build a cleaned alerts payload when saving billing limits in the same alert format. */
|
||||
export function buildCleanedAlertsPayloadForLimitSave(
|
||||
alerts: BillingAlertsFormData,
|
||||
nextMode: BillingLimitMode,
|
||||
effectiveLimitCents: number,
|
||||
planLimitCents: number
|
||||
): { amount: number; alertLevels: number[]; emails: string[] } | null {
|
||||
if (alerts.alertLevels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const thresholds = storedAlertsToThresholds(
|
||||
alerts,
|
||||
nextMode,
|
||||
effectiveLimitCents,
|
||||
planLimitCents
|
||||
);
|
||||
|
||||
return {
|
||||
emails: alerts.emails,
|
||||
...thresholdsToAlertPayload(thresholds, nextMode, effectiveLimitCents),
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert stored percentage alert levels to UI percent values (10, 50, 80). */
|
||||
export function percentageAlertLevelsToUiThresholds(levels: number[]): number[] {
|
||||
const normalized = levels.filter((level) => Number.isFinite(level) && level > 0);
|
||||
if (normalized.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (usesFractionAlertLevelFormat(normalized)) {
|
||||
return normalized.filter((level) => level <= 1).map((level) => Math.round(level * 100));
|
||||
}
|
||||
|
||||
return normalized
|
||||
.filter((level) => level <= MAX_PERCENTAGE_THRESHOLD)
|
||||
.map((level) => Math.round(level));
|
||||
}
|
||||
|
||||
export function normalizeBillingAlertsFromApi(
|
||||
apiAlerts: {
|
||||
amount: number;
|
||||
emails?: string[];
|
||||
alertLevels?: number[];
|
||||
},
|
||||
options: NormalizeBillingAlertsOptions
|
||||
): BillingAlertsFormData {
|
||||
const rawAmount = Number(apiAlerts.amount);
|
||||
const alertLevels = (apiAlerts.alertLevels ?? []).map(Number).filter(Number.isFinite);
|
||||
|
||||
// Platform API stores amount in cents.
|
||||
let amountDollars = rawAmount / 100;
|
||||
|
||||
if (isLegacyDollarAmountField(rawAmount, alertLevels, options)) {
|
||||
amountDollars = rawAmount;
|
||||
}
|
||||
|
||||
return {
|
||||
amount: amountDollars,
|
||||
emails: apiAlerts.emails ?? [],
|
||||
alertLevels,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy alerts used plan included usage; new alerts use the billing limit amount. */
|
||||
function percentageAlertAmountMatches(
|
||||
amountCents: number,
|
||||
effectiveLimitCents: number,
|
||||
planLimitCents: number
|
||||
): boolean {
|
||||
return amountCents === effectiveLimitCents || amountCents === planLimitCents;
|
||||
}
|
||||
|
||||
/** Cents base for dollar preview when displaying saved percentage alerts. */
|
||||
export function getAlertPreviewLimitCents(
|
||||
alerts: BillingAlertsFormData,
|
||||
effectiveLimitCents: number,
|
||||
planLimitCents: number
|
||||
): number {
|
||||
const amountCents = getSavedAlertAmountCents(alerts);
|
||||
if (amountCents > 0 && percentageAlertLevelsToUiThresholds(alerts.alertLevels).length > 0) {
|
||||
return amountCents;
|
||||
}
|
||||
if (percentageAlertAmountMatches(amountCents, effectiveLimitCents, planLimitCents)) {
|
||||
return amountCents;
|
||||
}
|
||||
return effectiveLimitCents;
|
||||
}
|
||||
|
||||
/** Convert stored API alerts to UI threshold values (percent or dollars). */
|
||||
export function storedAlertsToThresholds(
|
||||
alerts: BillingAlertsFormData,
|
||||
mode: BillingLimitMode,
|
||||
effectiveLimitCents: number,
|
||||
planLimitCents: number
|
||||
): number[] {
|
||||
const amountCents = getSavedAlertAmountCents(alerts);
|
||||
|
||||
if (mode === "none") {
|
||||
if (alerts.alertLevels.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Absolute dollar alerts: API amount is the $1 base marker (100 cents).
|
||||
if (
|
||||
amountCents === ABSOLUTE_ALERT_BASE_CENTS ||
|
||||
alerts.amount === ABSOLUTE_ALERT_BASE_CENTS / 100
|
||||
) {
|
||||
return alerts.alertLevels.slice(0, MAX_ABSOLUTE_ALERTS);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const uiThresholds = percentageAlertLevelsToUiThresholds(alerts.alertLevels);
|
||||
if (uiThresholds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Saved percentage alerts keep their thresholds whenever a positive base amount is stored.
|
||||
if (amountCents > 0) {
|
||||
return uiThresholds.slice(0, MAX_PERCENTAGE_ALERTS);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
export function thresholdsToAlertPayload(
|
||||
thresholds: number[],
|
||||
mode: BillingLimitMode,
|
||||
effectiveLimitCents: number
|
||||
): { amount: number; alertLevels: number[] } {
|
||||
if (mode === "none") {
|
||||
return {
|
||||
amount: ABSOLUTE_ALERT_BASE_CENTS,
|
||||
alertLevels: thresholds,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
amount: effectiveLimitCents,
|
||||
alertLevels: thresholds.map((percent) => percent / 100),
|
||||
};
|
||||
}
|
||||
|
||||
export function isEmptyThreshold(value: number): boolean {
|
||||
return !Number.isFinite(value) || value <= 0;
|
||||
}
|
||||
|
||||
export function previewDollarAmountForPercent(
|
||||
percent: number,
|
||||
effectiveLimitCents: number
|
||||
): number {
|
||||
if (!Number.isFinite(percent) || percent <= 0) {
|
||||
return 0;
|
||||
}
|
||||
return (effectiveLimitCents * percent) / 100 / 100;
|
||||
}
|
||||
|
||||
/** Legacy percentage alerts may include spike multipliers above 100%. */
|
||||
export function hasLegacySpikeAlertLevels(
|
||||
alerts: BillingAlertsFormData,
|
||||
mode: BillingLimitMode,
|
||||
effectiveLimitCents: number,
|
||||
planLimitCents: number
|
||||
): boolean {
|
||||
if (!isPercentageAlertMode(mode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (usesFractionAlertLevelFormat(alerts.alertLevels)) {
|
||||
return alerts.alertLevels.some((level) => level > 1);
|
||||
}
|
||||
|
||||
return alerts.alertLevels.some((level) => level > MAX_PERCENTAGE_THRESHOLD);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
|
||||
/** Format billing grace period from API `gracePeriodMs` (e.g. 24 hours, not 1 day). */
|
||||
export function formatGracePeriodMs(ms: number): string {
|
||||
return formatDurationMilliseconds(ms, {
|
||||
style: "long",
|
||||
units: ["h", "m", "s"],
|
||||
maxUnits: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function getSuggestedRecoveryLimitDollars(
|
||||
effectiveAmountCents: number | null,
|
||||
currentSpendCents: number
|
||||
): number {
|
||||
const candidates = [Math.ceil(currentSpendCents * 1.25)];
|
||||
if (effectiveAmountCents != null) {
|
||||
candidates.push(effectiveAmountCents + 5_000, Math.ceil(effectiveAmountCents * 1.25));
|
||||
}
|
||||
|
||||
const rawAmount = Math.max(...candidates) / 100;
|
||||
return Math.ceil(rawAmount / 50) * 50;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { BillingLimitResult } from "~/services/billingLimit.schemas";
|
||||
|
||||
export enum OrgBannerKind {
|
||||
LimitRejected = "limit-rejected",
|
||||
LimitGrace = "limit-grace",
|
||||
NoLimitConfigured = "no-limit",
|
||||
Upgrade = "upgrade",
|
||||
EnvironmentWarning = "env-warning",
|
||||
None = "none",
|
||||
}
|
||||
|
||||
export function selectOrgBanner(input: {
|
||||
billingLimit?: BillingLimitResult;
|
||||
hasExceededFreeTier?: boolean;
|
||||
showEnvironmentWarning?: boolean;
|
||||
/** Self-serve billing UI — hide configure-limit prompt for managed customers. */
|
||||
showSelfServe?: boolean;
|
||||
}): OrgBannerKind {
|
||||
const { billingLimit, hasExceededFreeTier, showEnvironmentWarning, showSelfServe = true } = input;
|
||||
|
||||
if (billingLimit?.isConfigured) {
|
||||
const status = billingLimit.limitState.status;
|
||||
if (status === "rejected") {
|
||||
return OrgBannerKind.LimitRejected;
|
||||
}
|
||||
if (status === "grace") {
|
||||
return OrgBannerKind.LimitGrace;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasExceededFreeTier) {
|
||||
return OrgBannerKind.Upgrade;
|
||||
}
|
||||
|
||||
if (billingLimit && !billingLimit.isConfigured && showSelfServe) {
|
||||
return OrgBannerKind.NoLimitConfigured;
|
||||
}
|
||||
|
||||
if (showEnvironmentWarning) {
|
||||
return OrgBannerKind.EnvironmentWarning;
|
||||
}
|
||||
|
||||
return OrgBannerKind.None;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import { CheckIcon, PencilSquareIcon, PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { AITimeFilter } from "~/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query/types";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type StreamEventType =
|
||||
| { type: "thinking"; content: string }
|
||||
| { type: "tool_call"; tool: string; args: unknown }
|
||||
| { type: "time_filter"; filter: AITimeFilter }
|
||||
| { type: "result"; success: true; query: string; timeFilter?: AITimeFilter }
|
||||
| { type: "result"; success: false; error: string };
|
||||
|
||||
export type AIQueryMode = "new" | "edit";
|
||||
|
||||
interface AIQueryInputProps {
|
||||
onQueryGenerated: (query: string) => void;
|
||||
/** Called when the AI sets a time filter - updates URL search params */
|
||||
onTimeFilterChange?: (filter: AITimeFilter) => void;
|
||||
/** Set this to a prompt to auto-populate and immediately submit */
|
||||
autoSubmitPrompt?: string;
|
||||
/** Change this to force re-submission even if prompt is the same */
|
||||
autoSubmitKey?: number;
|
||||
/** Get the current query in the editor (used for edit mode) */
|
||||
getCurrentQuery?: () => string;
|
||||
}
|
||||
|
||||
export function AIQueryInput({
|
||||
onQueryGenerated,
|
||||
onTimeFilterChange,
|
||||
autoSubmitPrompt,
|
||||
autoSubmitKey,
|
||||
getCurrentQuery,
|
||||
}: AIQueryInputProps) {
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [mode, setMode] = useState<AIQueryMode>("new");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showThinking, setShowThinking] = useState(false);
|
||||
const [lastResult, setLastResult] = useState<"success" | "error" | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const lastAutoSubmitRef = useRef<{ prompt: string; key?: number } | null>(null);
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const resourcePath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-generate`;
|
||||
|
||||
// Can only use edit mode if there's a current query
|
||||
const canEdit = Boolean(getCurrentQuery?.()?.trim());
|
||||
|
||||
// If mode is edit but there's no current query, switch to new
|
||||
useEffect(() => {
|
||||
if (mode === "edit" && !canEdit) {
|
||||
setMode("new");
|
||||
}
|
||||
}, [mode, canEdit]);
|
||||
|
||||
const submitQuery = useCallback(
|
||||
async (queryPrompt: string, submitMode: AIQueryMode = mode) => {
|
||||
if (!queryPrompt.trim() || isLoading) return;
|
||||
const currentQuery = getCurrentQuery?.();
|
||||
if (submitMode === "edit" && !currentQuery?.trim()) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setThinking("");
|
||||
setError(null);
|
||||
setShowThinking(true);
|
||||
setLastResult(null);
|
||||
|
||||
// Abort any existing request
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("prompt", queryPrompt);
|
||||
formData.append("mode", submitMode);
|
||||
if (submitMode === "edit" && currentQuery) {
|
||||
formData.append("currentQuery", currentQuery);
|
||||
}
|
||||
|
||||
const response = await fetch(resourcePath, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
signal: abortControllerRef.current.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json()) as { error?: string };
|
||||
setError(errorData.error || "Failed to generate query");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) {
|
||||
setError("No response stream");
|
||||
setIsLoading(false);
|
||||
setLastResult("error");
|
||||
return;
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
// Process complete events from buffer
|
||||
const lines = buffer.split("\n\n");
|
||||
buffer = lines.pop() || ""; // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(line.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process any remaining data
|
||||
if (buffer.startsWith("data: ")) {
|
||||
try {
|
||||
const event = JSON.parse(buffer.slice(6)) as StreamEventType;
|
||||
processStreamEvent(event);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
// Request was aborted, ignore
|
||||
return;
|
||||
}
|
||||
setError(err instanceof Error ? err.message : "An error occurred");
|
||||
setLastResult("error");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[isLoading, resourcePath, mode, getCurrentQuery]
|
||||
);
|
||||
|
||||
const processStreamEvent = useCallback(
|
||||
(event: StreamEventType) => {
|
||||
switch (event.type) {
|
||||
case "thinking":
|
||||
setThinking((prev) => prev + event.content);
|
||||
break;
|
||||
case "tool_call":
|
||||
// Tool calls are handled silently — no UI text needed
|
||||
break;
|
||||
case "time_filter":
|
||||
// Apply time filter immediately when the AI sets it
|
||||
onTimeFilterChange?.(event.filter);
|
||||
break;
|
||||
case "result":
|
||||
if (event.success) {
|
||||
// Apply time filter if included in result (backup in case time_filter event was missed)
|
||||
if (event.timeFilter) {
|
||||
onTimeFilterChange?.(event.timeFilter);
|
||||
}
|
||||
onQueryGenerated(event.query);
|
||||
setPrompt("");
|
||||
setLastResult("success");
|
||||
// Keep thinking visible to show what happened
|
||||
} else {
|
||||
setError(event.error);
|
||||
setLastResult("error");
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
[onQueryGenerated, onTimeFilterChange]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
submitQuery(prompt);
|
||||
},
|
||||
[prompt, submitQuery]
|
||||
);
|
||||
|
||||
// Auto-submit when autoSubmitPrompt or autoSubmitKey changes
|
||||
useEffect(() => {
|
||||
if (!autoSubmitPrompt || !autoSubmitPrompt.trim() || isLoading) {
|
||||
return;
|
||||
}
|
||||
|
||||
const last = lastAutoSubmitRef.current;
|
||||
const isDifferent =
|
||||
last === null || autoSubmitPrompt !== last.prompt || autoSubmitKey !== last.key;
|
||||
|
||||
if (isDifferent) {
|
||||
lastAutoSubmitRef.current = { prompt: autoSubmitPrompt, key: autoSubmitKey };
|
||||
setPrompt(autoSubmitPrompt);
|
||||
submitQuery(autoSubmitPrompt);
|
||||
}
|
||||
}, [autoSubmitPrompt, autoSubmitKey, isLoading, submitQuery]);
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Auto-hide error after delay
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
const timer = setTimeout(() => setError(null), 15000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* Gradient border wrapper like the schedules AI input */}
|
||||
<div
|
||||
className="overflow-hidden rounded-md p-px"
|
||||
style={{ background: "linear-gradient(to bottom right, #E543FF, #286399)" }}
|
||||
>
|
||||
<div className="overflow-hidden rounded-md bg-background-bright">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
name="prompt"
|
||||
placeholder={
|
||||
mode === "edit"
|
||||
? "e.g. add a filter for failed runs, change the limit to 50"
|
||||
: "e.g. show me failed runs from the last 7 days"
|
||||
}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
disabled={isLoading}
|
||||
rows={8}
|
||||
className="m-0 min-h-10 w-full resize-none border-0 bg-background-bright px-3 py-2.5 text-sm text-text-bright scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control file:border-0 file:bg-transparent file:text-base file:font-medium placeholder:text-text-dimmed focus:border-0 focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && prompt.trim() && !isLoading) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-end gap-2 px-2 pb-2">
|
||||
{isLoading ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={true}
|
||||
LeadingIcon={Spinner}
|
||||
className="pl-2"
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
{mode === "edit" ? "Editing…" : "Generating…"}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim()}
|
||||
LeadingIcon={PlusIcon}
|
||||
iconSpacing="gap-1.5"
|
||||
onClick={() => {
|
||||
setMode("new");
|
||||
submitQuery(prompt, "new");
|
||||
}}
|
||||
>
|
||||
New query
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="tertiary/small"
|
||||
disabled={!prompt.trim() || !canEdit}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
className={cn(!canEdit && "opacity-50")}
|
||||
iconSpacing="gap-2"
|
||||
tooltip={!canEdit ? "Write a query first to enable editing" : undefined}
|
||||
onClick={() => {
|
||||
setMode("edit");
|
||||
submitQuery(prompt, "edit");
|
||||
}}
|
||||
>
|
||||
Edit query
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="rounded-md border border-error/30 bg-error/10 px-3 py-2 text-sm text-error">
|
||||
{error}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Thinking panel - stays visible after completion */}
|
||||
<AnimatePresence>
|
||||
{showThinking && thinking && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-1">
|
||||
<div className="rounded-b-lg border-x border-b border-grid-dimmed bg-background-dimmed p-3 pb-1">
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
{isLoading ? (
|
||||
<Spinner className="size-4" />
|
||||
) : lastResult === "success" ? (
|
||||
<CheckIcon className="size-4 text-success" />
|
||||
) : lastResult === "error" ? (
|
||||
<XMarkIcon className="size-4 text-error" />
|
||||
) : null}
|
||||
<span className="text-xs font-medium text-text-dimmed">
|
||||
{isLoading
|
||||
? "AI is thinking…"
|
||||
: lastResult === "success"
|
||||
? "Query generated"
|
||||
: lastResult === "error"
|
||||
? "Generation failed"
|
||||
: "AI response"}
|
||||
</span>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsLoading(false);
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
setShowThinking(false);
|
||||
setThinking("");
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
Dismiss
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="streamdown-container max-h-96 overflow-y-auto text-xs text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Suspense fallback={<p className="whitespace-pre-wrap">{thinking}</p>}>
|
||||
<StreamdownRenderer isAnimating={isLoading}>{thinking}</StreamdownRenderer>
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,623 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { IconSortAscending, IconSortDescending } from "@tabler/icons-react";
|
||||
import { BarChart, CheckIcon, LineChart, Plus, XIcon } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
type AggregationType,
|
||||
type ChartConfiguration,
|
||||
type SortDirection,
|
||||
} from "../metrics/QueryWidget";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import SegmentedControl from "../primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "../primitives/Select";
|
||||
import { Switch } from "../primitives/Switch";
|
||||
import { CHART_COLORS_BY_HUE, getSeriesColor } from "./chartColors";
|
||||
|
||||
export const defaultChartConfig: ChartConfiguration = {
|
||||
chartType: "bar",
|
||||
xAxisColumn: null,
|
||||
yAxisColumns: [],
|
||||
groupByColumn: null,
|
||||
stacked: false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "asc",
|
||||
aggregation: "sum",
|
||||
seriesColors: {},
|
||||
};
|
||||
|
||||
interface ChartConfigPanelProps {
|
||||
columns: OutputColumnMetadata[];
|
||||
config: ChartConfiguration;
|
||||
onChange: (config: ChartConfiguration) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Type detection helpers
|
||||
function isNumericType(type: string): boolean {
|
||||
return (
|
||||
type.startsWith("Int") ||
|
||||
type.startsWith("UInt") ||
|
||||
type.startsWith("Float") ||
|
||||
type.startsWith("Decimal") ||
|
||||
type.startsWith("Nullable(Int") ||
|
||||
type.startsWith("Nullable(UInt") ||
|
||||
type.startsWith("Nullable(Float") ||
|
||||
type.startsWith("Nullable(Decimal")
|
||||
);
|
||||
}
|
||||
|
||||
function isDateTimeType(type: string): boolean {
|
||||
return (
|
||||
type === "DateTime" ||
|
||||
type === "DateTime64" ||
|
||||
type === "Date" ||
|
||||
type === "Date32" ||
|
||||
type.startsWith("DateTime64(") ||
|
||||
type.startsWith("Nullable(DateTime") ||
|
||||
type.startsWith("Nullable(Date")
|
||||
);
|
||||
}
|
||||
|
||||
function isStringType(type: string): boolean {
|
||||
return (
|
||||
type === "String" ||
|
||||
type === "LowCardinality(String)" ||
|
||||
type === "Nullable(String)" ||
|
||||
type.startsWith("Enum") ||
|
||||
type.startsWith("FixedString")
|
||||
);
|
||||
}
|
||||
|
||||
export function ChartConfigPanel({ columns, config, onChange, className }: ChartConfigPanelProps) {
|
||||
// Categorize columns by type
|
||||
const { numericColumns, dateTimeColumns, categoricalColumns, allColumns } = useMemo(() => {
|
||||
const numeric: OutputColumnMetadata[] = [];
|
||||
const dateTime: OutputColumnMetadata[] = [];
|
||||
const categorical: OutputColumnMetadata[] = [];
|
||||
|
||||
for (const col of columns) {
|
||||
if (isNumericType(col.type)) {
|
||||
numeric.push(col);
|
||||
}
|
||||
if (isDateTimeType(col.type)) {
|
||||
dateTime.push(col);
|
||||
}
|
||||
if (isStringType(col.type) || isDateTimeType(col.type)) {
|
||||
categorical.push(col);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
numericColumns: numeric,
|
||||
dateTimeColumns: dateTime,
|
||||
categoricalColumns: categorical,
|
||||
allColumns: columns,
|
||||
};
|
||||
}, [columns]);
|
||||
|
||||
// Create a stable key from column names and types to detect actual changes
|
||||
const columnsKey = useMemo(() => columns.map((c) => `${c.name}:${c.type}`).join(","), [columns]);
|
||||
|
||||
// Use refs to access current config/onChange without adding them as dependencies
|
||||
const configRef = useRef(config);
|
||||
const onChangeRef = useRef(onChange);
|
||||
useEffect(() => {
|
||||
configRef.current = config;
|
||||
onChangeRef.current = onChange;
|
||||
});
|
||||
|
||||
// Auto-select defaults when columns change
|
||||
useEffect(() => {
|
||||
if (columns.length === 0) return;
|
||||
|
||||
const currentConfig = configRef.current;
|
||||
let needsUpdate = false;
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
|
||||
// Auto-select X-axis (prefer datetime, then first categorical)
|
||||
if (!currentConfig.xAxisColumn) {
|
||||
const defaultX = dateTimeColumns[0] ?? categoricalColumns[0] ?? columns[0];
|
||||
if (defaultX) {
|
||||
updates.xAxisColumn = defaultX.name;
|
||||
needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-select Y-axis (first numeric column)
|
||||
if (currentConfig.yAxisColumns.length === 0 && numericColumns.length > 0) {
|
||||
updates.yAxisColumns = [numericColumns[0].name];
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
// Determine the effective x-axis column (either existing or newly selected)
|
||||
const effectiveXAxis = updates.xAxisColumn ?? currentConfig.xAxisColumn;
|
||||
|
||||
// Auto-set sort to x-axis ASC if it's a datetime column and no sort is configured
|
||||
if (
|
||||
effectiveXAxis &&
|
||||
!currentConfig.sortByColumn &&
|
||||
dateTimeColumns.some((col) => col.name === effectiveXAxis)
|
||||
) {
|
||||
updates.sortByColumn = effectiveXAxis;
|
||||
updates.sortDirection = "asc";
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
onChangeRef.current({ ...currentConfig, ...updates });
|
||||
}
|
||||
// Only re-run when the actual column structure changes, not on every config change.
|
||||
// columnsKey (a string) is stable when columns match, so this won't re-fire
|
||||
// unnecessarily when the same query is re-run with identical columns.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [columnsKey]);
|
||||
|
||||
const updateConfig = useCallback(
|
||||
(updates: Partial<ChartConfiguration>) => {
|
||||
onChange({ ...config, ...updates });
|
||||
},
|
||||
[config, onChange]
|
||||
);
|
||||
|
||||
// X-axis options: prefer datetime and string columns at the top
|
||||
const xAxisOptions = useMemo(() => {
|
||||
const preferred = [
|
||||
...dateTimeColumns,
|
||||
...categoricalColumns.filter((c) => !isDateTimeType(c.type)),
|
||||
];
|
||||
const preferredNames = new Set(preferred.map((c) => c.name));
|
||||
const other = allColumns.filter((c) => !preferredNames.has(c.name));
|
||||
|
||||
const options: Array<{ value: string; label: string; type: string }> = [];
|
||||
|
||||
for (const col of preferred) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
for (const col of other) {
|
||||
options.push({ value: col.name, label: col.name, type: col.type });
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [allColumns, dateTimeColumns, categoricalColumns]);
|
||||
|
||||
// Y-axis options: numeric columns only
|
||||
const yAxisOptions = useMemo(() => {
|
||||
return numericColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
}, [numericColumns]);
|
||||
|
||||
// Aggregation options
|
||||
const aggregationOptions = [
|
||||
{ value: "sum", label: "Sum" },
|
||||
{ value: "avg", label: "Average" },
|
||||
{ value: "count", label: "Count" },
|
||||
{ value: "min", label: "Min" },
|
||||
{ value: "max", label: "Max" },
|
||||
];
|
||||
|
||||
// Group by options: categorical columns (excluding selected X axis)
|
||||
const groupByOptions = useMemo(() => {
|
||||
const options = categoricalColumns
|
||||
.filter((col) => col.name !== config.xAxisColumn)
|
||||
.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [categoricalColumns, config.xAxisColumn]);
|
||||
|
||||
// Sort by options: all columns
|
||||
const sortByOptions = useMemo(() => {
|
||||
const options = allColumns.map((col) => ({
|
||||
value: col.name,
|
||||
label: col.name,
|
||||
type: col.type,
|
||||
}));
|
||||
|
||||
return [{ value: "__none__", label: "None", type: "" }, ...options];
|
||||
}, [allColumns]);
|
||||
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<div className={cn("flex items-center justify-center p-4", className)}>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Run a query to configure the chart
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-3 p-2", className)}>
|
||||
{/* Chart Type */}
|
||||
<div className="flex flex-col gap-3">
|
||||
<ConfigField label="Type">
|
||||
<SegmentedControl
|
||||
name="chartType"
|
||||
value={config.chartType}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<BarChart className="size-3" /> Bar
|
||||
</span>
|
||||
),
|
||||
value: "bar",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<LineChart className="size-3" /> Line
|
||||
</span>
|
||||
),
|
||||
value: "line",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ chartType: value as "bar" | "line" })}
|
||||
/>
|
||||
</ConfigField>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3">
|
||||
{/* X-Axis */}
|
||||
<ConfigField label="X-Axis">
|
||||
<Select
|
||||
value={config.xAxisColumn ?? ""}
|
||||
setValue={(value) => {
|
||||
const updates: Partial<ChartConfiguration> = { xAxisColumn: value || null };
|
||||
// Auto-set sort to x-axis ASC if selecting a datetime column
|
||||
if (value) {
|
||||
const selectedCol = columns.find((c) => c.name === value);
|
||||
if (selectedCol && isDateTimeType(selectedCol.type)) {
|
||||
updates.sortByColumn = value;
|
||||
updates.sortDirection = "asc";
|
||||
}
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={xAxisOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Y-Axis / Series */}
|
||||
<ConfigField label={config.yAxisColumns.length > 1 ? "Series" : "Y-Axis"}>
|
||||
{yAxisOptions.length === 0 ? (
|
||||
<span className="text-xs text-text-dimmed">No numeric columns</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{/* Always show at least one dropdown, even if yAxisColumns is empty */}
|
||||
{(config.yAxisColumns.length === 0 ? [""] : config.yAxisColumns).map((col, index) => (
|
||||
<div key={index} className="flex items-center gap-1">
|
||||
{col && !config.groupByColumn && (
|
||||
<SeriesColorPicker
|
||||
color={config.seriesColors?.[col] ?? getSeriesColor(index)}
|
||||
onColorChange={(color) => {
|
||||
updateConfig({
|
||||
seriesColors: { ...config.seriesColors, [col]: color },
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select
|
||||
value={col}
|
||||
setValue={(value) => {
|
||||
const newColumns = [...config.yAxisColumns];
|
||||
const updates: Partial<ChartConfiguration> = {};
|
||||
if (value) {
|
||||
// If this is a new slot (empty string), add it
|
||||
if (index >= config.yAxisColumns.length) {
|
||||
newColumns.push(value);
|
||||
} else {
|
||||
// If the column name changed, migrate the color
|
||||
const oldCol = newColumns[index];
|
||||
if (oldCol && oldCol !== value && config.seriesColors?.[oldCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
newSeriesColors[value] = newSeriesColors[oldCol];
|
||||
delete newSeriesColors[oldCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
newColumns[index] = value;
|
||||
}
|
||||
} else if (index < config.yAxisColumns.length) {
|
||||
newColumns.splice(index, 1);
|
||||
}
|
||||
updateConfig({ ...updates, yAxisColumns: newColumns });
|
||||
}}
|
||||
variant="tertiary/small"
|
||||
placeholder="Select column"
|
||||
items={yAxisOptions.filter(
|
||||
(opt) => opt.value === col || !config.yAxisColumns.includes(opt.value)
|
||||
)}
|
||||
dropdownIcon
|
||||
className="min-w-[140px] flex-1"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
<TypeBadge type={item.type} />
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
|
||||
{index > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const removedCol = config.yAxisColumns[index];
|
||||
const newColumns = config.yAxisColumns.filter((_, i) => i !== index);
|
||||
const updates: Partial<ChartConfiguration> = { yAxisColumns: newColumns };
|
||||
// Clean up the color entry for the removed series
|
||||
if (removedCol && config.seriesColors?.[removedCol]) {
|
||||
const newSeriesColors = { ...config.seriesColors };
|
||||
delete newSeriesColors[removedCol];
|
||||
updates.seriesColors = newSeriesColors;
|
||||
}
|
||||
updateConfig(updates);
|
||||
}}
|
||||
className="rounded p-1 text-text-dimmed hover:bg-background-raised hover:text-text-bright"
|
||||
title="Remove series"
|
||||
>
|
||||
<XIcon className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Add another series button - only show when we have at least one series and not grouped */}
|
||||
{config.yAxisColumns.length > 0 &&
|
||||
config.yAxisColumns.length < yAxisOptions.length &&
|
||||
!config.groupByColumn && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const availableColumns = yAxisOptions.filter(
|
||||
(opt) => !config.yAxisColumns.includes(opt.value)
|
||||
);
|
||||
if (availableColumns.length > 0) {
|
||||
updateConfig({
|
||||
yAxisColumns: [...config.yAxisColumns, availableColumns[0].value],
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex items-center gap-1 self-start rounded px-1 py-0.5 text-xs text-text-dimmed hover:bg-background-raised hover:text-text-bright"
|
||||
>
|
||||
<Plus className="h-3 w-3" />
|
||||
Add series
|
||||
</button>
|
||||
)}
|
||||
|
||||
{config.groupByColumn && config.yAxisColumns.length === 1 && (
|
||||
<span className="text-xxs text-text-dimmed">
|
||||
Remove group by to add multiple series
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Aggregation */}
|
||||
<ConfigField label="Aggregation">
|
||||
<Select
|
||||
value={config.aggregation}
|
||||
setValue={(value) => updateConfig({ aggregation: value as AggregationType })}
|
||||
variant="tertiary/small"
|
||||
items={aggregationOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[100px]"
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Group By - disabled when multiple series are selected */}
|
||||
<ConfigField label="Group by">
|
||||
{config.yAxisColumns.length > 1 ? (
|
||||
<span className="text-xs text-text-dimmed">Not available with multiple series</span>
|
||||
) : (
|
||||
<Select
|
||||
value={config.groupByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ groupByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={groupByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
)}
|
||||
</ConfigField>
|
||||
|
||||
{/* Stacked toggle (when grouped or multiple series) */}
|
||||
{(config.groupByColumn || config.yAxisColumns.length > 1) && (
|
||||
<ConfigField label={config.groupByColumn ? "Stack groups" : "Stack series"}>
|
||||
<Switch
|
||||
variant="medium"
|
||||
checked={config.stacked}
|
||||
onCheckedChange={(checked) => updateConfig({ stacked: checked })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
|
||||
{/* Order By */}
|
||||
<ConfigField label="Order by">
|
||||
<Select
|
||||
value={config.sortByColumn ?? "__none__"}
|
||||
setValue={(value) =>
|
||||
updateConfig({ sortByColumn: value === "__none__" ? null : value })
|
||||
}
|
||||
variant="tertiary/small"
|
||||
placeholder="None"
|
||||
items={sortByOptions}
|
||||
dropdownIcon
|
||||
className="min-w-[140px]"
|
||||
text={(t) => (t === "__none__" ? "None" : t)}
|
||||
>
|
||||
{(items) =>
|
||||
items.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span>{item.label}</span>
|
||||
{item.type && <TypeBadge type={item.type} />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
</Select>
|
||||
</ConfigField>
|
||||
|
||||
{/* Sort Direction (only when sorting) */}
|
||||
{config.sortByColumn && (
|
||||
<ConfigField label="Sort direction">
|
||||
<SegmentedControl
|
||||
name="sortDirection"
|
||||
value={config.sortDirection}
|
||||
variant="secondary/small"
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortAscending className="size-3" /> Asc
|
||||
</span>
|
||||
),
|
||||
value: "asc",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span className="flex items-center gap-1">
|
||||
<IconSortDescending className="size-3" /> Desc
|
||||
</span>
|
||||
),
|
||||
value: "desc",
|
||||
},
|
||||
]}
|
||||
onChange={(value) => updateConfig({ sortDirection: value as SortDirection })}
|
||||
/>
|
||||
</ConfigField>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
{label && <span className="text-xs text-text-bright">{label}</span>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SeriesColorPicker({
|
||||
color,
|
||||
onColorChange,
|
||||
}: {
|
||||
color: string;
|
||||
onColorChange: (color: string) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded p-0.5 hover:bg-background-raised"
|
||||
title="Change series color"
|
||||
>
|
||||
<span
|
||||
className="block h-4 w-4 rounded-full border border-white/30"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-auto p-2">
|
||||
<div className="grid grid-cols-6 gap-1.5">
|
||||
{CHART_COLORS_BY_HUE.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onColorChange(c);
|
||||
setOpen(false);
|
||||
}}
|
||||
className="group/swatch flex h-6 w-6 items-center justify-center rounded-full border border-white/30"
|
||||
style={{ backgroundColor: c }}
|
||||
title={c}
|
||||
>
|
||||
{c === color && <CheckIcon className="h-3.5 w-3.5 text-white drop-shadow-md" />}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function TypeBadge({ type }: { type: string }) {
|
||||
// Simplify type for display
|
||||
let displayType = type;
|
||||
if (type.startsWith("Nullable(")) {
|
||||
displayType = type.slice(9, -1) + "?";
|
||||
}
|
||||
if (type.startsWith("LowCardinality(")) {
|
||||
displayType = type.slice(15, -1);
|
||||
}
|
||||
|
||||
// Shorten long type names
|
||||
if (displayType.length > 12) {
|
||||
displayType = displayType.slice(0, 10) + "…";
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="rounded bg-background-hover px-1 py-0.5 font-mono text-xxs text-text-dimmed">
|
||||
{displayType}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { ArrowsPointingOutIcon } from "@heroicons/react/20/solid";
|
||||
import { Clipboard, ClipboardCheck } from "lucide-react";
|
||||
import type { Language, PrismTheme } from "prism-react-renderer";
|
||||
import { Highlight, Prism } from "prism-react-renderer";
|
||||
import type { ReactNode } from "react";
|
||||
import { forwardRef, useCallback, useEffect, useState } from "react";
|
||||
import { TextWrapIcon } from "~/assets/icons/TextWrapIcon";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { highlightSearchText } from "~/utils/logUtils";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { TextInlineIcon } from "~/assets/icons/TextInlineIcon";
|
||||
|
||||
//This is a fork of https://github.com/mantinedev/mantine/blob/master/src/mantine-prism/src/Prism/Prism.tsx
|
||||
//it didn't support highlighting lines by dimming the rest of the code, or animations on the highlighting
|
||||
|
||||
async function setup() {
|
||||
(typeof global !== "undefined" ? global : window).Prism = Prism;
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-json");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-typescript");
|
||||
//@ts-ignore
|
||||
await import("prismjs/components/prism-sql.js");
|
||||
}
|
||||
setup();
|
||||
|
||||
type CodeBlockProps = {
|
||||
/** Code which will be highlighted */
|
||||
code: string;
|
||||
|
||||
/** Programming language that should be highlighted */
|
||||
language?: Language;
|
||||
|
||||
/** Show copy to clipboard button */
|
||||
showCopyButton?: boolean;
|
||||
|
||||
/** Show text wrapping button */
|
||||
showTextWrapping?: boolean;
|
||||
|
||||
/** Display line numbers */
|
||||
showLineNumbers?: boolean;
|
||||
|
||||
/** Highlight line at given line number with color from theme.colors */
|
||||
highlightedRanges?: [number, number][];
|
||||
|
||||
/** Add/override classes on the overall element */
|
||||
className?: string;
|
||||
|
||||
/** Add/override code theme */
|
||||
theme?: PrismTheme;
|
||||
|
||||
/** Max lines */
|
||||
maxLines?: number;
|
||||
|
||||
/** Whether to show the chrome, if you provide a string it will be used as the title, */
|
||||
showChrome?: boolean;
|
||||
|
||||
/** filename */
|
||||
fileName?: string;
|
||||
|
||||
/** title text for the Title row */
|
||||
rowTitle?: ReactNode;
|
||||
|
||||
/** Whether to show the open in modal button */
|
||||
showOpenInModal?: boolean;
|
||||
|
||||
/** Search term to highlight in the code */
|
||||
searchTerm?: string;
|
||||
|
||||
/** Whether to wrap the code */
|
||||
wrap?: boolean;
|
||||
};
|
||||
|
||||
const dimAmount = 0.5;
|
||||
const extraLinesWhenClipping = 0.35;
|
||||
|
||||
const defaultTheme: PrismTheme = {
|
||||
plain: {
|
||||
color: "var(--color-code-constant)",
|
||||
backgroundColor: "rgba(0, 0, 0, 0)",
|
||||
},
|
||||
styles: [
|
||||
{
|
||||
types: ["comment", "prolog", "doctype", "cdata"],
|
||||
style: {
|
||||
color: "var(--color-code-muted)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["punctuation"],
|
||||
style: {
|
||||
color: "var(--color-code-foreground)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["property", "tag", "constant", "symbol", "deleted"],
|
||||
style: {
|
||||
color: "var(--color-code-language)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["boolean", "number"],
|
||||
style: {
|
||||
color: "var(--color-code-builtin)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["selector", "attr-name", "string", "char", "builtin", "inserted"],
|
||||
style: {
|
||||
color: "var(--color-code-string)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["operator", "entity", "url"],
|
||||
style: {
|
||||
color: "var(--color-code-plain)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["variable"],
|
||||
style: {
|
||||
color: "var(--color-code-variable)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["atrule", "attr-value", "keyword"],
|
||||
style: {
|
||||
color: "var(--color-code-keyword)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["function", "class-name"],
|
||||
style: {
|
||||
color: "var(--color-code-function)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["regex"],
|
||||
style: {
|
||||
color: "var(--color-code-regexp)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["important", "bold"],
|
||||
style: {
|
||||
fontWeight: "bold",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["italic"],
|
||||
style: {
|
||||
fontStyle: "italic",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["namespace"],
|
||||
style: {
|
||||
opacity: 0.7,
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["deleted"],
|
||||
style: {
|
||||
color: "var(--color-code-deleted)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["char"],
|
||||
style: {
|
||||
color: "var(--color-code-number)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["tag"],
|
||||
style: {
|
||||
color: "var(--color-code-escape)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["keyword.operator"],
|
||||
style: {
|
||||
color: "var(--color-code-storage)",
|
||||
},
|
||||
},
|
||||
{
|
||||
types: ["meta.template.expression"],
|
||||
style: {
|
||||
color: "var(--color-code-plain)",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export const CodeBlock = forwardRef<HTMLDivElement, CodeBlockProps>(
|
||||
(
|
||||
{
|
||||
showCopyButton = true,
|
||||
showTextWrapping = false,
|
||||
showLineNumbers = true,
|
||||
showOpenInModal = true,
|
||||
highlightedRanges,
|
||||
code,
|
||||
className,
|
||||
language = "typescript",
|
||||
theme = defaultTheme,
|
||||
maxLines,
|
||||
showChrome = false,
|
||||
fileName,
|
||||
rowTitle,
|
||||
searchTerm,
|
||||
wrap = false,
|
||||
...props
|
||||
}: CodeBlockProps,
|
||||
ref
|
||||
) => {
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [modalCopied, setModalCopied] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isWrapped, setIsWrapped] = useState(wrap);
|
||||
|
||||
const onCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[code]
|
||||
);
|
||||
|
||||
const onModalCopied = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
navigator.clipboard.writeText(code);
|
||||
setModalCopied(true);
|
||||
setTimeout(() => {
|
||||
setModalCopied(false);
|
||||
}, 1500);
|
||||
},
|
||||
[code]
|
||||
);
|
||||
|
||||
code = code?.trim() ?? "";
|
||||
const lineCount = code.split("\n").length;
|
||||
const maxLineWidth = lineCount.toString().length;
|
||||
let maxHeight: string | undefined = undefined;
|
||||
if (maxLines && lineCount > maxLines) {
|
||||
maxHeight = `calc(${(maxLines + extraLinesWhenClipping) * 0.75 * 1.625}rem + 1.5rem )`;
|
||||
}
|
||||
|
||||
const highlightLines = highlightedRanges?.flatMap(([start, end]) =>
|
||||
Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
);
|
||||
|
||||
// if there are more than 1000 lines, don't highlight
|
||||
const shouldHighlight = lineCount <= 1000;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"relative flex flex-col overflow-hidden rounded-md border border-grid-bright",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: theme.plain.backgroundColor,
|
||||
}}
|
||||
ref={ref}
|
||||
{...props}
|
||||
translate="no"
|
||||
>
|
||||
{showChrome && <Chrome title={fileName} />}
|
||||
{rowTitle && <TitleRow title={rowTitle} />}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-3 top-2.5 z-50 flex gap-3",
|
||||
showChrome ? "right-1.5 top-1.5" : "top-2.5"
|
||||
)}
|
||||
>
|
||||
{showTextWrapping && (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={() => setIsWrapped(!isWrapped)}
|
||||
className="transition-colors focus-custom hover:cursor-pointer hover:text-text-bright"
|
||||
>
|
||||
{isWrapped ? (
|
||||
<TextInlineIcon className="size-4" />
|
||||
) : (
|
||||
<TextWrapIcon className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{isWrapped ? "Unwrap" : "Wrap"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<TooltipProvider>
|
||||
<Tooltip open={copied || mouseOver} disableHoverableContent>
|
||||
<TooltipTrigger
|
||||
onClick={onCopied}
|
||||
onMouseEnter={() => setMouseOver(true)}
|
||||
onMouseLeave={() => setMouseOver(false)}
|
||||
className={cn(
|
||||
"transition-colors duration-100 focus-custom hover:cursor-pointer",
|
||||
copied ? "text-success" : "text-text-dimmed hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
{copied ? (
|
||||
<ClipboardCheck className="size-4" />
|
||||
) : (
|
||||
<Clipboard className="size-4" />
|
||||
)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{showOpenInModal && (
|
||||
<TooltipProvider>
|
||||
<Tooltip disableHoverableContent>
|
||||
<TooltipTrigger onClick={() => setIsModalOpen(true)}>
|
||||
<ArrowsPointingOutIcon className="size-4 transition-colors hover:text-text-bright" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="left" className="text-xs">
|
||||
Expand
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="px-2 py-3"
|
||||
preClassName="text-xs"
|
||||
isWrapped={isWrapped}
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className={cn(
|
||||
"min-h-0 flex-1 px-2 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
"overflow-auto"
|
||||
)}
|
||||
style={{
|
||||
maxHeight,
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
"relative mr-2 p-2 font-mono text-xs leading-relaxed",
|
||||
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
|
||||
)}
|
||||
dir="ltr"
|
||||
>
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
|
||||
<DialogContent className="flex flex-col gap-0 p-0 pt-[2.9rem] sm:h-[80vh] sm:max-h-[80vh] sm:max-w-[80vw]">
|
||||
<DialogHeader className="h-fit">
|
||||
<DialogTitle className="absolute left-3.5 top-2.5">
|
||||
{fileName && fileName}
|
||||
{rowTitle && rowTitle}
|
||||
</DialogTitle>
|
||||
<Button
|
||||
variant="tertiary/small"
|
||||
onClick={onModalCopied}
|
||||
className="absolute right-4 top-16 z-50"
|
||||
LeadingIcon={modalCopied ? undefined : Clipboard}
|
||||
leadingIconClassName="size-3 -ml-1"
|
||||
>
|
||||
{modalCopied ? "Copied" : "Copy"}
|
||||
</Button>
|
||||
</DialogHeader>
|
||||
|
||||
{shouldHighlight ? (
|
||||
<HighlightCode
|
||||
theme={theme}
|
||||
code={code}
|
||||
language={language}
|
||||
showLineNumbers={showLineNumbers}
|
||||
highlightLines={highlightLines}
|
||||
maxLineWidth={maxLineWidth}
|
||||
className="min-h-full"
|
||||
preClassName="text-sm"
|
||||
isWrapped={isWrapped}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
dir="ltr"
|
||||
className="overflow-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
>
|
||||
<pre className="relative mr-2 p-2 font-mono text-base leading-relaxed" dir="ltr">
|
||||
{highlightSearchText(code, searchTerm)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CodeBlock.displayName = "CodeBlock";
|
||||
|
||||
function Chrome({ title }: { title?: string }) {
|
||||
return (
|
||||
<div className="grid h-7 grid-cols-[100px_auto_100px] border-b border-background-bright bg-background-deep">
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
<div className="h-3 w-3 rounded-full bg-background-raised" />
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<div className={cn("rounded-sm px-3 py-0.5 text-xs text-text-faint")}>{title}</div>
|
||||
</div>
|
||||
<div></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TitleRow({ title }: { title: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between px-3">
|
||||
<Paragraph variant="small/bright" className="w-full border-b border-grid-dimmed py-2">
|
||||
{title}
|
||||
</Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type HighlightCodeProps = {
|
||||
theme: PrismTheme;
|
||||
code: string;
|
||||
language: Language;
|
||||
showLineNumbers: boolean;
|
||||
highlightLines?: number[];
|
||||
maxLineWidth?: number;
|
||||
className?: string;
|
||||
preClassName?: string;
|
||||
isWrapped: boolean;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
function HighlightCode({
|
||||
theme,
|
||||
code,
|
||||
language,
|
||||
showLineNumbers,
|
||||
highlightLines,
|
||||
maxLineWidth,
|
||||
className,
|
||||
preClassName,
|
||||
isWrapped,
|
||||
searchTerm,
|
||||
}: HighlightCodeProps) {
|
||||
const [isLoaded, setIsLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-json"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-typescript"),
|
||||
//@ts-ignore
|
||||
import("prismjs/components/prism-sql.js"),
|
||||
]).then(() => setIsLoaded(true));
|
||||
}, []);
|
||||
|
||||
const containerClasses = cn(
|
||||
"min-h-0 flex-1 px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
!isWrapped && "overflow-auto",
|
||||
isWrapped && "overflow-auto",
|
||||
className
|
||||
);
|
||||
|
||||
const preClasses = cn(
|
||||
"relative mr-2 font-mono leading-relaxed",
|
||||
preClassName,
|
||||
isWrapped && "[&_span]:whitespace-pre-wrap [&_span]:wrap-break-word"
|
||||
);
|
||||
|
||||
if (!isLoaded) {
|
||||
return (
|
||||
<div dir="ltr" className={containerClasses}>
|
||||
<pre className={preClasses}>{code}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Highlight theme={theme} code={code} language={language}>
|
||||
{({
|
||||
className: inheritedClassName,
|
||||
style: inheritedStyle,
|
||||
tokens,
|
||||
getLineProps,
|
||||
getTokenProps,
|
||||
}) => (
|
||||
<div dir="ltr" className={containerClasses}>
|
||||
<pre className={cn(preClasses, inheritedClassName)} style={inheritedStyle} dir="ltr">
|
||||
{tokens
|
||||
.map((line, index) => {
|
||||
if (index === tokens.length - 1 && line.length === 1 && line[0].content === "\n") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
const lineProps = getLineProps({ line });
|
||||
|
||||
let hasAnyHighlights = highlightLines ? highlightLines.length > 0 : false;
|
||||
|
||||
let shouldDim = hasAnyHighlights;
|
||||
if (hasAnyHighlights && highlightLines?.includes(lineNumber)) {
|
||||
shouldDim = false;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={lineNumber}
|
||||
{...lineProps}
|
||||
className={cn(
|
||||
"flex w-full justify-start transition-opacity duration-500",
|
||||
lineProps.className,
|
||||
isWrapped && "flex-wrap"
|
||||
)}
|
||||
style={{
|
||||
opacity: shouldDim ? dimAmount : undefined,
|
||||
...lineProps.style,
|
||||
}}
|
||||
>
|
||||
{showLineNumbers && (
|
||||
<div
|
||||
className={cn(
|
||||
"mr-2 flex-none select-none text-right text-text-faint transition-opacity duration-500",
|
||||
isWrapped && "sticky left-0"
|
||||
)}
|
||||
style={{
|
||||
width: `calc(8 * ${(maxLineWidth as number) / 16}rem)`,
|
||||
}}
|
||||
>
|
||||
{lineNumber}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1">
|
||||
{line.map((token, key) => {
|
||||
const tokenProps = getTokenProps({ token });
|
||||
|
||||
// Highlight search term matches in token
|
||||
const content = highlightSearchText(token.content, searchTerm);
|
||||
|
||||
return (
|
||||
<span
|
||||
key={key}
|
||||
{...tokenProps}
|
||||
style={{
|
||||
color: tokenProps?.style?.color as string,
|
||||
...tokenProps.style,
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="w-4 flex-none" />
|
||||
</div>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</Highlight>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const inlineCode =
|
||||
"px-1 py-0.5 rounded border border-grid-bright bg-background-bright text-text-bright font-mono text-wrap";
|
||||
|
||||
const variants = {
|
||||
"extra-extra-small": "text-xxs",
|
||||
"extra-small": "text-xs",
|
||||
small: "text-sm",
|
||||
base: "text-base",
|
||||
};
|
||||
|
||||
export type InlineCodeVariant = keyof typeof variants;
|
||||
|
||||
type InlineCodeProps = {
|
||||
children: React.ReactNode;
|
||||
variant?: InlineCodeVariant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function InlineCode({ variant = "small", children, className }: InlineCodeProps) {
|
||||
return <code className={cn(inlineCode, variants[variant], className)}>{children}</code>;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import {
|
||||
ClientTabs,
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
ClientTabsContent,
|
||||
} from "../primitives/ClientTabs";
|
||||
import { ClipboardField } from "../primitives/ClipboardField";
|
||||
|
||||
type InstallPackagesProps = {
|
||||
packages: string[];
|
||||
};
|
||||
|
||||
export function InstallPackages({ packages }: InstallPackagesProps) {
|
||||
return (
|
||||
<ClientTabs defaultValue="npm">
|
||||
<ClientTabsList>
|
||||
<ClientTabsTrigger value={"npm"}>npm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"pnpm"}>pnpm</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value={"yarn"}>yarn</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<ClientTabsContent value={"npm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`npm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"pnpm"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`pnpm install ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent value={"yarn"}>
|
||||
<ClipboardField
|
||||
variant="primary/medium"
|
||||
className="mb-4"
|
||||
value={`yarn add ${packages.join(" ")}`}
|
||||
/>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { json as jsonLang, jsonParseLinter } from "@codemirror/lang-json";
|
||||
import type { EditorView, ViewUpdate } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { linter, lintGutter, type Diagnostic } from "@codemirror/lint";
|
||||
|
||||
export interface JSONEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
language?: "json";
|
||||
readOnly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
onBlur?: (code: string) => void;
|
||||
showCopyButton?: boolean;
|
||||
showClearButton?: boolean;
|
||||
linterEnabled?: boolean;
|
||||
allowEmpty?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
const languages = {
|
||||
json: jsonLang,
|
||||
};
|
||||
|
||||
function emptyAwareJsonLinter() {
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// return no errors if content is empty
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return jsonParseLinter()(view);
|
||||
};
|
||||
}
|
||||
|
||||
type JSONEditorDefaultProps = Partial<JSONEditorProps>;
|
||||
|
||||
const defaultProps: JSONEditorDefaultProps = {
|
||||
language: "json",
|
||||
readOnly: true,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
allowEmpty: true,
|
||||
};
|
||||
|
||||
export function JSONEditor(opts: JSONEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
language,
|
||||
readOnly,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = true,
|
||||
linterEnabled,
|
||||
allowEmpty,
|
||||
additionalActions,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
const extensions = getEditorSetup();
|
||||
|
||||
if (!language) throw new Error("language is required");
|
||||
const languageExtension = languages[language];
|
||||
|
||||
extensions.push(languageExtension());
|
||||
|
||||
if (linterEnabled) {
|
||||
extensions.push(lintGutter());
|
||||
|
||||
switch (language) {
|
||||
case "json": {
|
||||
extensions.push(allowEmpty ? linter(emptyAwareJsonLinter()) : linter(jsonParseLinter()));
|
||||
break;
|
||||
}
|
||||
default:
|
||||
language satisfies never;
|
||||
}
|
||||
}
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
//if the defaultValue changes update the editor
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
showButtons ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
|
||||
opts.className
|
||||
)}
|
||||
>
|
||||
{showButtons && (
|
||||
<div className="mx-3 flex items-center justify-end gap-2 border-b border-grid-dimmed">
|
||||
{additionalActions && additionalActions}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="w-full overflow-auto"
|
||||
ref={editor}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
onBlur(editor.current?.textContent ?? "");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
import { lazy } from "react";
|
||||
import type { CodeHighlighterPlugin } from "streamdown";
|
||||
|
||||
export const StreamdownRenderer = lazy(() =>
|
||||
Promise.all([import("streamdown"), import("@streamdown/code"), import("./shikiTheme")]).then(
|
||||
([{ Streamdown }, { createCodePlugin }, { triggerDarkTheme }]) => {
|
||||
// Type assertion needed: @streamdown/code and streamdown resolve different shiki
|
||||
// versions under pnpm, causing structurally-identical CodeHighlighterPlugin types
|
||||
// to be considered incompatible (different BundledLanguage string unions).
|
||||
const codePlugin = createCodePlugin({
|
||||
themes: [triggerDarkTheme, triggerDarkTheme],
|
||||
}) as unknown as CodeHighlighterPlugin;
|
||||
|
||||
return {
|
||||
default: ({
|
||||
children,
|
||||
isAnimating = false,
|
||||
}: {
|
||||
children: string;
|
||||
isAnimating?: boolean;
|
||||
}) => (
|
||||
<Streamdown
|
||||
isAnimating={isAnimating}
|
||||
plugins={{ code: codePlugin }}
|
||||
controls={{ code: { copy: false, download: false } }}
|
||||
linkSafety={{ enabled: false }}
|
||||
>
|
||||
{children}
|
||||
</Streamdown>
|
||||
),
|
||||
};
|
||||
}
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,387 @@
|
||||
import { autocompletion, startCompletion } from "@codemirror/autocomplete";
|
||||
import { sql, StandardSQL } from "@codemirror/lang-sql";
|
||||
import { linter, lintGutter } from "@codemirror/lint";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, keymap } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import {
|
||||
type ReactCodeMirrorProps,
|
||||
type UseCodeMirror,
|
||||
useCodeMirror,
|
||||
} from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { format as formatSQL } from "sql-formatter";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
import { createTSQLCompletion } from "./tsql/tsqlCompletion";
|
||||
import { createTSQLLinter } from "./tsql/tsqlLinter";
|
||||
|
||||
export interface TSQLEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
/** Initial value for the editor */
|
||||
defaultValue?: string;
|
||||
/** Whether the editor is read-only */
|
||||
readOnly?: boolean;
|
||||
/** Called when the editor content changes */
|
||||
onChange?: (value: string) => void;
|
||||
/** Called when the editor state updates */
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
/** Called when the editor loses focus */
|
||||
onBlur?: (code: string) => void;
|
||||
/** Schema for table/column autocompletion */
|
||||
schema?: TableSchema[];
|
||||
/** Show copy button */
|
||||
showCopyButton?: boolean;
|
||||
/** Show clear button */
|
||||
showClearButton?: boolean;
|
||||
/** Show format button */
|
||||
showFormatButton?: boolean;
|
||||
/** Enable linting (syntax checking) */
|
||||
linterEnabled?: boolean;
|
||||
/** Placeholder text when empty */
|
||||
placeholder?: string;
|
||||
/** Additional actions to show in the toolbar */
|
||||
additionalActions?: React.ReactNode;
|
||||
/** Minimum height of the editor */
|
||||
minHeight?: string;
|
||||
}
|
||||
|
||||
type TSQLEditorDefaultProps = Partial<TSQLEditorProps>;
|
||||
|
||||
const defaultProps: TSQLEditorDefaultProps = {
|
||||
readOnly: false,
|
||||
basicSetup: false,
|
||||
linterEnabled: true,
|
||||
showCopyButton: true,
|
||||
showClearButton: false,
|
||||
showFormatButton: true,
|
||||
schema: [],
|
||||
};
|
||||
|
||||
// Toggle comment on current line or selected lines with -- comment symbol
|
||||
const toggleLineComment = (view: EditorView): boolean => {
|
||||
const { from, to } = view.state.selection.main;
|
||||
const startLine = view.state.doc.lineAt(from);
|
||||
// When `to` is exactly at the start of a line and there's an actual selection,
|
||||
// the caret sits before that line — so exclude it by stepping back one position.
|
||||
const adjustedTo = to > from && view.state.doc.lineAt(to).from === to ? to - 1 : to;
|
||||
const endLine = view.state.doc.lineAt(adjustedTo);
|
||||
|
||||
// Collect all lines in the selection
|
||||
const lines: { from: number; to: number; text: string }[] = [];
|
||||
for (let i = startLine.number; i <= endLine.number; i++) {
|
||||
const line = view.state.doc.line(i);
|
||||
lines.push({ from: line.from, to: line.to, text: line.text });
|
||||
}
|
||||
|
||||
// Determine action: if all non-empty lines are commented, uncomment; otherwise comment
|
||||
const allCommented = lines.every((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
return trimmed.length === 0 || trimmed.startsWith("--");
|
||||
});
|
||||
|
||||
const changes = lines
|
||||
.map((line) => {
|
||||
const trimmed = line.text.trimStart();
|
||||
if (trimmed.length === 0) return null; // skip empty lines
|
||||
const indent = line.text.length - trimmed.length;
|
||||
|
||||
if (allCommented) {
|
||||
// Remove comment: strip "-- " or just "--"
|
||||
const afterComment = trimmed.slice(2);
|
||||
const newText = line.text.slice(0, indent) + afterComment.replace(/^\s/, "");
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
} else {
|
||||
// Add comment: prepend "-- " to the line content
|
||||
const newText = line.text.slice(0, indent) + "-- " + trimmed;
|
||||
return { from: line.from, to: line.to, insert: newText };
|
||||
}
|
||||
})
|
||||
.filter((c): c is { from: number; to: number; insert: string } => c !== null);
|
||||
|
||||
if (changes.length > 0) {
|
||||
view.dispatch({ changes });
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export function TSQLEditor(opts: TSQLEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
onBlur,
|
||||
basicSetup = false,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
showClearButton = false,
|
||||
showFormatButton = true,
|
||||
linterEnabled = true,
|
||||
schema = [],
|
||||
placeholder = "",
|
||||
additionalActions,
|
||||
minHeight = undefined,
|
||||
} = {
|
||||
...defaultProps,
|
||||
...opts,
|
||||
};
|
||||
|
||||
// Create extensions - memoize to avoid recreating on every render
|
||||
const extensions = useMemo(() => {
|
||||
const exts = getEditorSetup();
|
||||
|
||||
// Add SQL language support with StandardSQL dialect
|
||||
// This provides syntax highlighting
|
||||
exts.push(
|
||||
sql({
|
||||
dialect: StandardSQL,
|
||||
upperCaseKeywords: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Add custom TSQL completion
|
||||
if (schema && schema.length > 0) {
|
||||
exts.push(
|
||||
autocompletion({
|
||||
override: [createTSQLCompletion(schema)],
|
||||
activateOnTyping: true,
|
||||
maxRenderedOptions: 50,
|
||||
})
|
||||
);
|
||||
|
||||
// Trigger autocomplete when ' is typed in value context
|
||||
// CodeMirror's activateOnTyping only triggers on alphanumeric characters,
|
||||
// so we manually trigger for quotes after comparison operators
|
||||
exts.push(
|
||||
EditorView.domEventHandlers({
|
||||
keyup: (event, view) => {
|
||||
// Trigger on quote key (both ' and shift+' on some keyboards)
|
||||
if (event.key === "'" || event.key === '"' || event.code === "Quote") {
|
||||
setTimeout(() => {
|
||||
startCompletion(view);
|
||||
}, 50);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add TSQL linter
|
||||
if (linterEnabled) {
|
||||
exts.push(lintGutter());
|
||||
exts.push(
|
||||
linter(createTSQLLinter({ schema }), {
|
||||
delay: 300, // Debounce linting for better performance
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add keyboard shortcut for toggling comments
|
||||
exts.push(
|
||||
keymap.of([
|
||||
{ key: "Cmd-/", run: toggleLineComment },
|
||||
{ key: "Ctrl-/", run: toggleLineComment },
|
||||
])
|
||||
);
|
||||
|
||||
return exts;
|
||||
}, [schema, linterEnabled]);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup,
|
||||
onChange,
|
||||
onUpdate,
|
||||
placeholder,
|
||||
};
|
||||
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
// Update editor when defaultValue changes
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const clear = () => {
|
||||
if (view === undefined) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: undefined },
|
||||
});
|
||||
onChange?.("");
|
||||
};
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 1500);
|
||||
}, [view]);
|
||||
|
||||
const format = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
const currentContent = view.state.doc.toString();
|
||||
if (!currentContent.trim()) return;
|
||||
|
||||
try {
|
||||
const formatted = autoFormatSQL(currentContent);
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: formatted },
|
||||
});
|
||||
onChange?.(formatted);
|
||||
} catch {
|
||||
// If formatting fails (e.g., invalid SQL), silently ignore
|
||||
}
|
||||
}, [view, onChange]);
|
||||
|
||||
const showButtons = showClearButton || showCopyButton || showFormatButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative flex h-full flex-col", opts.className)}
|
||||
style={minHeight ? { minHeight } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
)}
|
||||
ref={editor}
|
||||
onClick={() => {
|
||||
view?.focus();
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!onBlur) return;
|
||||
if (!view) return;
|
||||
onBlur(view.state.doc.toString());
|
||||
}}
|
||||
/>
|
||||
{showButtons && (
|
||||
<div className="absolute right-0 top-0 z-10 flex items-center justify-end bg-background-deep/80 p-1.5">
|
||||
{additionalActions && additionalActions}
|
||||
{showFormatButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
className="flex-none"
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
format();
|
||||
}}
|
||||
shortcut={{ key: "f", modifiers: ["shift", "alt"], enabledOnInputElements: true }}
|
||||
>
|
||||
Format
|
||||
</Button>
|
||||
)}
|
||||
{showClearButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={TrashIcon}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
clear();
|
||||
}}
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// SQL keywords that legitimately appear before parentheses with a space
|
||||
const SQL_KEYWORDS_BEFORE_PAREN = new Set([
|
||||
"IN",
|
||||
"NOT",
|
||||
"EXISTS",
|
||||
"OVER",
|
||||
"USING",
|
||||
"VALUES",
|
||||
"BETWEEN",
|
||||
"LIKE",
|
||||
"AND",
|
||||
"OR",
|
||||
"ON",
|
||||
"SET",
|
||||
"INTO",
|
||||
"TABLE",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"AS",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"HAVING",
|
||||
"JOIN",
|
||||
"SELECT",
|
||||
]);
|
||||
|
||||
export function autoFormatSQL(sql: string) {
|
||||
let formatted = formatSQL(sql, {
|
||||
language: "sql",
|
||||
keywordCase: "upper",
|
||||
indentStyle: "standard",
|
||||
linesBetweenQueries: 2,
|
||||
});
|
||||
|
||||
// sql-formatter adds a space before ( for unknown/custom functions (e.g. timeBucket ())
|
||||
// Remove that space for anything that isn't a SQL keyword
|
||||
formatted = formatted.replace(/(\b\w+)\s+\(/g, (match, name) => {
|
||||
if (SQL_KEYWORDS_BEFORE_PAREN.has(name.toUpperCase())) {
|
||||
return match;
|
||||
}
|
||||
return `${name}(`;
|
||||
});
|
||||
|
||||
return formatted;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,125 @@
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { EditorView, lineNumbers } from "@codemirror/view";
|
||||
import { CheckIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { ReactCodeMirrorProps, UseCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCodeMirror } from "@uiw/react-codemirror";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { getEditorSetup } from "./codeMirrorSetup";
|
||||
import { darkTheme } from "./codeMirrorTheme";
|
||||
|
||||
export interface TextEditorProps extends Omit<ReactCodeMirrorProps, "onBlur"> {
|
||||
defaultValue?: string;
|
||||
readOnly?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
onUpdate?: (update: ViewUpdate) => void;
|
||||
showCopyButton?: boolean;
|
||||
additionalActions?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function TextEditor(opts: TextEditorProps) {
|
||||
const {
|
||||
defaultValue = "",
|
||||
readOnly = false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
autoFocus,
|
||||
showCopyButton = true,
|
||||
additionalActions,
|
||||
} = opts;
|
||||
|
||||
// Don't use default line numbers from setup — add our own with proper sizing
|
||||
const extensions = getEditorSetup(false);
|
||||
extensions.push(EditorView.lineWrapping);
|
||||
extensions.push(
|
||||
lineNumbers({
|
||||
formatNumber: (n) => String(n),
|
||||
})
|
||||
);
|
||||
extensions.push(
|
||||
EditorView.theme({
|
||||
".cm-lineNumbers": {
|
||||
minWidth: "40px",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const editor = useRef<HTMLDivElement>(null);
|
||||
const settings: Omit<UseCodeMirror, "onBlur"> = {
|
||||
...opts,
|
||||
container: editor.current,
|
||||
extensions,
|
||||
editable: !readOnly,
|
||||
contentEditable: !readOnly,
|
||||
value: defaultValue,
|
||||
autoFocus,
|
||||
theme: darkTheme(),
|
||||
indentWithTab: false,
|
||||
basicSetup: false,
|
||||
onChange,
|
||||
onUpdate,
|
||||
};
|
||||
const { setContainer, view } = useCodeMirror(settings);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editor.current) {
|
||||
setContainer(editor.current);
|
||||
}
|
||||
}, [setContainer]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== undefined) {
|
||||
if (view.state.doc.toString() === defaultValue) return;
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: defaultValue },
|
||||
});
|
||||
}
|
||||
}, [defaultValue, view]);
|
||||
|
||||
const copy = useCallback(() => {
|
||||
if (view === undefined) return;
|
||||
navigator.clipboard.writeText(view.state.doc.toString());
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
}, [view]);
|
||||
|
||||
const showToolbar = showCopyButton || additionalActions;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid",
|
||||
showToolbar ? "grid-rows-[2.5rem_1fr]" : "grid-rows-[1fr]",
|
||||
opts.className
|
||||
)}
|
||||
>
|
||||
{showToolbar && (
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<div className="flex items-center">{additionalActions}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="minimal/small"
|
||||
TrailingIcon={copied ? CheckIcon : ClipboardIcon}
|
||||
trailingIconClassName={
|
||||
copied ? "text-green-500 group-hover:text-green-500" : undefined
|
||||
}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
copy();
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="min-h-0 min-w-0 overflow-auto" ref={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* Chart color palette defined in HSL (Hue, Saturation, Lightness).
|
||||
*
|
||||
* HSL is a human-friendly color model:
|
||||
* h: 0–360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
|
||||
* s: 0–100 (saturation — 0 is gray, 100 is full color)
|
||||
* l: 0–100 (lightness — 0 is black, 50 is pure color, 100 is white)
|
||||
*/
|
||||
|
||||
interface HSLColor {
|
||||
h: number;
|
||||
s: number;
|
||||
l: number;
|
||||
}
|
||||
|
||||
interface ChartColorDef {
|
||||
name: string;
|
||||
hsl: HSLColor;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Palette — 30 distinct colors for chart series, defined in HSL
|
||||
// ---------------------------------------------------------------------------
|
||||
const CHART_COLOR_DEFS: ChartColorDef[] = [
|
||||
// Primary colors (high contrast, spread across hue wheel)
|
||||
{ name: "Purple", hsl: { h: 252, s: 98, l: 66 } },
|
||||
{ name: "Green", hsl: { h: 142, s: 71, l: 45 } },
|
||||
{ name: "Amber", hsl: { h: 38, s: 92, l: 50 } },
|
||||
{ name: "Red", hsl: { h: 0, s: 84, l: 60 } },
|
||||
{ name: "Cyan", hsl: { h: 189, s: 95, l: 43 } },
|
||||
{ name: "Pink", hsl: { h: 330, s: 81, l: 60 } },
|
||||
{ name: "Violet", hsl: { h: 258, s: 90, l: 66 } },
|
||||
{ name: "Teal", hsl: { h: 173, s: 80, l: 40 } },
|
||||
{ name: "Orange", hsl: { h: 25, s: 95, l: 53 } },
|
||||
{ name: "Indigo", hsl: { h: 239, s: 84, l: 67 } },
|
||||
// Extended palette
|
||||
{ name: "Lime", hsl: { h: 84, s: 81, l: 44 } },
|
||||
{ name: "Sky", hsl: { h: 199, s: 89, l: 48 } },
|
||||
{ name: "Rose", hsl: { h: 350, s: 89, l: 60 } },
|
||||
{ name: "Fuchsia", hsl: { h: 271, s: 91, l: 65 } },
|
||||
{ name: "Yellow", hsl: { h: 45, s: 93, l: 47 } },
|
||||
{ name: "Emerald", hsl: { h: 160, s: 84, l: 39 } },
|
||||
{ name: "Blue", hsl: { h: 217, s: 91, l: 60 } },
|
||||
{ name: "Magenta", hsl: { h: 292, s: 84, l: 61 } },
|
||||
{ name: "Stone", hsl: { h: 25, s: 5, l: 45 } },
|
||||
{ name: "Gold", hsl: { h: 48, s: 96, l: 53 } },
|
||||
// Additional distinct colors (lighter variants)
|
||||
{ name: "Turquoise", hsl: { h: 173, s: 66, l: 50 } },
|
||||
{ name: "Light Orange", hsl: { h: 27, s: 96, l: 61 } },
|
||||
{ name: "Yellow-Green", hsl: { h: 83, s: 78, l: 55 } },
|
||||
{ name: "Light Blue", hsl: { h: 198, s: 93, l: 60 } },
|
||||
{ name: "Light Purple", hsl: { h: 270, s: 95, l: 75 } },
|
||||
{ name: "Light Green", hsl: { h: 142, s: 69, l: 58 } },
|
||||
{ name: "Light Amber", hsl: { h: 43, s: 96, l: 56 } },
|
||||
{ name: "Light Pink", hsl: { h: 329, s: 86, l: 70 } },
|
||||
{ name: "Light Cyan", hsl: { h: 187, s: 92, l: 69 } },
|
||||
{ name: "Light Indigo", hsl: { h: 235, s: 89, l: 74 } },
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HSL ↔ Hex conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Convert an HSL color (h: 0–360, s: 0–100, l: 0–100) to a hex string */
|
||||
function hslToHex({ h, s, l }: HSLColor): string {
|
||||
const sNorm = s / 100;
|
||||
const lNorm = l / 100;
|
||||
|
||||
const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
|
||||
const hPrime = h / 60;
|
||||
const x = c * (1 - Math.abs((hPrime % 2) - 1));
|
||||
const m = lNorm - c / 2;
|
||||
|
||||
let r1: number, g1: number, b1: number;
|
||||
|
||||
if (hPrime < 1) {
|
||||
r1 = c;
|
||||
g1 = x;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 2) {
|
||||
r1 = x;
|
||||
g1 = c;
|
||||
b1 = 0;
|
||||
} else if (hPrime < 3) {
|
||||
r1 = 0;
|
||||
g1 = c;
|
||||
b1 = x;
|
||||
} else if (hPrime < 4) {
|
||||
r1 = 0;
|
||||
g1 = x;
|
||||
b1 = c;
|
||||
} else if (hPrime < 5) {
|
||||
r1 = x;
|
||||
g1 = 0;
|
||||
b1 = c;
|
||||
} else {
|
||||
r1 = c;
|
||||
g1 = 0;
|
||||
b1 = x;
|
||||
}
|
||||
|
||||
const toHex = (v: number) =>
|
||||
Math.round((v + m) * 255)
|
||||
.toString(16)
|
||||
.padStart(2, "0");
|
||||
|
||||
return `#${toHex(r1)}${toHex(g1)}${toHex(b1)}`;
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Derived hex palette (for consumers that need plain hex strings)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Color palette for chart series — 30 distinct hex colors derived from HSL definitions */
|
||||
const CHART_COLORS: string[] = CHART_COLOR_DEFS.map((def) => hslToHex(def.hsl));
|
||||
|
||||
/** Get the hex color for a series by its index (wraps around) */
|
||||
export function getSeriesColor(index: number): string {
|
||||
return CHART_COLORS[index % CHART_COLORS.length];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hue-sorted palette (rainbow order for color pickers)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SATURATION_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Chart colors sorted by perceived hue — the natural rainbow order
|
||||
* that humans expect: red -> orange -> yellow -> green -> cyan -> blue -> purple -> pink.
|
||||
*
|
||||
* Very desaturated colors (like grays) are placed at the end since they don't
|
||||
* have a strong hue.
|
||||
*/
|
||||
export const CHART_COLORS_BY_HUE: string[] = [...CHART_COLOR_DEFS]
|
||||
.sort((a, b) => {
|
||||
const aIsGray = a.hsl.s < SATURATION_THRESHOLD;
|
||||
const bIsGray = b.hsl.s < SATURATION_THRESHOLD;
|
||||
|
||||
// Push desaturated colors to the end
|
||||
if (aIsGray && !bIsGray) return 1;
|
||||
if (!aIsGray && bIsGray) return -1;
|
||||
if (aIsGray && bIsGray) return a.hsl.l - b.hsl.l;
|
||||
|
||||
// Sort by hue, then by saturation (more vivid first), then by lightness
|
||||
if (a.hsl.h !== b.hsl.h) return a.hsl.h - b.hsl.h;
|
||||
if (a.hsl.s !== b.hsl.s) return b.hsl.s - a.hsl.s;
|
||||
return a.hsl.l - b.hsl.l;
|
||||
})
|
||||
.map((def) => hslToHex(def.hsl));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { closeBrackets } from "@codemirror/autocomplete";
|
||||
import { indentWithTab, history, historyKeymap, undo, redo } from "@codemirror/commands";
|
||||
import { bracketMatching } from "@codemirror/language";
|
||||
import { lintKeymap } from "@codemirror/lint";
|
||||
import { highlightSelectionMatches } from "@codemirror/search";
|
||||
import { Prec, type Extension } from "@codemirror/state";
|
||||
import {
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from "@codemirror/view";
|
||||
|
||||
export function getEditorSetup(showLineNumbers = true, showHighlights = true): Array<Extension> {
|
||||
const options = [
|
||||
drawSelection(),
|
||||
dropCursor(),
|
||||
history(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
{
|
||||
key: "Mod-Enter",
|
||||
run: () => {
|
||||
return true;
|
||||
},
|
||||
preventDefault: false,
|
||||
},
|
||||
])
|
||||
),
|
||||
// Explicit undo/redo keybindings with high precedence
|
||||
Prec.high(
|
||||
keymap.of([
|
||||
{ key: "Mod-z", run: undo },
|
||||
{ key: "Mod-Shift-z", run: redo },
|
||||
{ key: "Mod-y", run: redo },
|
||||
])
|
||||
),
|
||||
keymap.of([indentWithTab, ...historyKeymap, ...lintKeymap]),
|
||||
];
|
||||
|
||||
if (showLineNumbers) {
|
||||
options.push(lineNumbers());
|
||||
}
|
||||
|
||||
if (showHighlights) {
|
||||
options.push([
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
highlightActiveLine(),
|
||||
highlightSelectionMatches(),
|
||||
]);
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
export function darkTheme(): Extension {
|
||||
// Values come from the --color-editor-* theme palette in tailwind.css.
|
||||
const chalky = "var(--color-editor-type)",
|
||||
coral = "var(--color-editor-heading)",
|
||||
cyan = "var(--color-editor-operator)",
|
||||
invalid = "var(--color-editor-invalid)",
|
||||
ivory = "var(--color-editor-foreground)",
|
||||
stone = "var(--color-editor-comment)",
|
||||
malibu = "var(--color-editor-function)",
|
||||
sage = "var(--color-editor-string)",
|
||||
whiskey = "var(--color-editor-constant)",
|
||||
violet = "var(--color-editor-keyword)",
|
||||
lilac = "var(--color-editor-name)",
|
||||
darkBackground = "var(--color-editor-panel-background)",
|
||||
highlightBackground = "var(--color-editor-highlight-background)",
|
||||
background = "var(--color-editor-background)",
|
||||
tooltipBackground = "var(--color-editor-tooltip-background)",
|
||||
selection = "var(--color-editor-selection)",
|
||||
cursor = "var(--color-editor-cursor)",
|
||||
scrollbarTrack = "rgba(0,0,0,0)",
|
||||
scrollbarTrackActive = "var(--color-editor-scrollbar-track-active)",
|
||||
scrollbarThumb = "var(--color-editor-scrollbar-thumb)",
|
||||
scrollbarThumbActive = "var(--color-editor-scrollbar-thumb-active)",
|
||||
scrollbarBg = "rgba(0,0,0,0)";
|
||||
|
||||
const jsonHeroEditorTheme = EditorView.theme(
|
||||
{
|
||||
"&": {
|
||||
color: ivory,
|
||||
backgroundColor: background,
|
||||
},
|
||||
|
||||
".cm-content": {
|
||||
caretColor: cursor,
|
||||
fontFamily: "Geist Mono Variable",
|
||||
fontSize: "14px",
|
||||
},
|
||||
|
||||
".cm-tooltip.cm-tooltip-lint": {
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
|
||||
".cm-diagnostic": {
|
||||
padding: "4px 8px",
|
||||
color: ivory,
|
||||
fontFamily: "Geist Mono Variable",
|
||||
fontSize: "12px",
|
||||
},
|
||||
|
||||
".cm-diagnostic-error": {
|
||||
borderLeft: "2px solid var(--color-error)",
|
||||
},
|
||||
|
||||
".cm-lint-marker-error": {
|
||||
content: "none",
|
||||
backgroundColor: "var(--color-error)",
|
||||
height: "100%",
|
||||
width: "2px",
|
||||
},
|
||||
|
||||
".cm-lintPoint:after": {
|
||||
borderBottom: "4px solid var(--color-error)",
|
||||
},
|
||||
|
||||
".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor },
|
||||
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
|
||||
backgroundColor: selection,
|
||||
},
|
||||
|
||||
".cm-panels": { backgroundColor: darkBackground, color: ivory },
|
||||
".cm-panels.cm-panels-top": { borderBottom: "2px solid black" },
|
||||
".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" },
|
||||
|
||||
".cm-searchMatch": {
|
||||
backgroundColor: "var(--color-editor-search-match)",
|
||||
outline: "1px solid var(--color-editor-search-match-outline)",
|
||||
},
|
||||
".cm-searchMatch.cm-searchMatch-selected": {
|
||||
backgroundColor: "var(--color-editor-search-match-selected)",
|
||||
},
|
||||
|
||||
".cm-activeLine": { backgroundColor: highlightBackground },
|
||||
".cm-selectionMatch": { backgroundColor: "var(--color-editor-selection-match)" },
|
||||
|
||||
"&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": {
|
||||
backgroundColor: "var(--color-editor-matching-bracket)",
|
||||
outline: "1px solid var(--color-editor-matching-bracket-outline)",
|
||||
},
|
||||
|
||||
".cm-gutters": {
|
||||
backgroundColor: background,
|
||||
color: stone,
|
||||
border: "none",
|
||||
},
|
||||
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: highlightBackground,
|
||||
},
|
||||
|
||||
".cm-foldPlaceholder": {
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
color: "var(--color-editor-fold-placeholder)",
|
||||
},
|
||||
|
||||
".cm-tooltip": {
|
||||
border: "none",
|
||||
marginTop: "6px",
|
||||
backgroundColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:before": {
|
||||
borderTopColor: "transparent",
|
||||
borderBottomColor: "transparent",
|
||||
},
|
||||
".cm-tooltip .cm-tooltip-arrow:after": {
|
||||
borderTopColor: tooltipBackground,
|
||||
borderBottomColor: tooltipBackground,
|
||||
},
|
||||
".cm-tooltip-autocomplete": {
|
||||
"& > ul > li[aria-selected]": {
|
||||
backgroundColor: highlightBackground,
|
||||
color: ivory,
|
||||
},
|
||||
},
|
||||
".cm-scroller": {
|
||||
scrollbarWidth: "thin",
|
||||
scrollbarColor: `${scrollbarThumb} ${scrollbarTrack}`,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar": {
|
||||
display: "block",
|
||||
width: "8px",
|
||||
height: "8px",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track": {
|
||||
backgroundColor: scrollbarTrack,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:hover": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-track:active": {
|
||||
backgroundColor: scrollbarTrackActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb": {
|
||||
backgroundColor: scrollbarThumb,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:hover": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-thumb:active": {
|
||||
backgroundColor: scrollbarThumbActive,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner": {
|
||||
backgroundColor: scrollbarBg,
|
||||
borderRadius: "0",
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:hover": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
".cm-scroller::-webkit-scrollbar-corner:active": {
|
||||
backgroundColor: scrollbarBg,
|
||||
},
|
||||
},
|
||||
{ dark: true }
|
||||
);
|
||||
|
||||
/// The highlighting style for code in the JSON Hero theme.
|
||||
const jsonHeroHighlightStyle = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: violet },
|
||||
{
|
||||
tag: [tags.name, tags.deleted, tags.character, tags.propertyName, tags.macroName],
|
||||
color: lilac,
|
||||
},
|
||||
{ tag: [tags.function(tags.variableName), tags.labelName], color: malibu },
|
||||
{
|
||||
tag: [tags.color, tags.constant(tags.name), tags.standard(tags.name)],
|
||||
color: whiskey,
|
||||
},
|
||||
{ tag: [tags.definition(tags.name), tags.separator], color: ivory },
|
||||
{
|
||||
tag: [
|
||||
tags.typeName,
|
||||
tags.className,
|
||||
tags.number,
|
||||
tags.bool,
|
||||
tags.changed,
|
||||
tags.annotation,
|
||||
tags.modifier,
|
||||
tags.self,
|
||||
tags.namespace,
|
||||
],
|
||||
color: chalky,
|
||||
},
|
||||
{
|
||||
tag: [
|
||||
tags.operator,
|
||||
tags.operatorKeyword,
|
||||
tags.url,
|
||||
tags.escape,
|
||||
tags.regexp,
|
||||
tags.link,
|
||||
tags.special(tags.string),
|
||||
],
|
||||
color: cyan,
|
||||
},
|
||||
{ tag: [tags.meta, tags.comment], color: stone },
|
||||
{ tag: tags.strong, fontWeight: "bold" },
|
||||
{ tag: tags.emphasis, fontStyle: "italic" },
|
||||
{ tag: tags.strikethrough, textDecoration: "line-through" },
|
||||
{ tag: tags.link, color: stone, textDecoration: "underline" },
|
||||
{ tag: tags.heading, fontWeight: "bold", color: coral },
|
||||
{
|
||||
tag: [tags.atom, tags.special(tags.variableName)],
|
||||
color: whiskey,
|
||||
},
|
||||
{
|
||||
tag: [tags.processingInstruction, tags.string, tags.inserted],
|
||||
color: sage,
|
||||
},
|
||||
{ tag: tags.invalid, color: invalid },
|
||||
]);
|
||||
|
||||
return [jsonHeroEditorTheme, syntaxHighlighting(jsonHeroHighlightStyle)];
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import type { ThemeRegistrationAny } from "streamdown";
|
||||
|
||||
// Custom Shiki theme matching the Trigger.dev VS Code dark theme.
|
||||
// Colors taken directly from the VS Code extension's tokenColors.
|
||||
export const triggerDarkTheme: ThemeRegistrationAny = {
|
||||
name: "trigger-dark",
|
||||
type: "dark",
|
||||
colors: {
|
||||
"editor.background": "var(--color-code-background)",
|
||||
"editor.foreground": "var(--color-code-foreground)",
|
||||
"editorLineNumber.foreground": "var(--color-code-line-number)",
|
||||
},
|
||||
tokenColors: [
|
||||
// Control flow keywords: pink-purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.control",
|
||||
"keyword.operator.delete",
|
||||
"keyword.other.using",
|
||||
"keyword.other.operator",
|
||||
"entity.name.operator",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-keyword)" },
|
||||
},
|
||||
// Storage type (const, let, var, function, class): purple
|
||||
{
|
||||
scope: "storage.type",
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Storage modifiers (async, export, etc.): purple
|
||||
{
|
||||
scope: ["storage.modifier", "keyword.operator.noexcept"],
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Keyword operator expressions (new, typeof, instanceof, etc.): purple
|
||||
{
|
||||
scope: [
|
||||
"keyword.operator.new",
|
||||
"keyword.operator.expression",
|
||||
"keyword.operator.cast",
|
||||
"keyword.operator.sizeof",
|
||||
"keyword.operator.instanceof",
|
||||
"keyword.operator.logical.python",
|
||||
"keyword.operator.wordlike",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-storage)" },
|
||||
},
|
||||
// Types and namespaces: hot pink
|
||||
{
|
||||
scope: [
|
||||
"support.class",
|
||||
"support.type",
|
||||
"entity.name.type",
|
||||
"entity.name.namespace",
|
||||
"entity.name.scope-resolution",
|
||||
"entity.name.class",
|
||||
"entity.other.inherited-class",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-type)" },
|
||||
},
|
||||
// Functions: lime/yellow-green
|
||||
{
|
||||
scope: ["entity.name.function", "support.function"],
|
||||
settings: { foreground: "var(--color-code-function)" },
|
||||
},
|
||||
// Variables and parameters: light lavender
|
||||
{
|
||||
scope: [
|
||||
"variable",
|
||||
"meta.definition.variable.name",
|
||||
"support.variable",
|
||||
"entity.name.variable",
|
||||
"constant.other.placeholder",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-variable)" },
|
||||
},
|
||||
// Constants and enums: medium purple
|
||||
{
|
||||
scope: ["variable.other.constant", "variable.other.enummember"],
|
||||
settings: { foreground: "var(--color-code-constant)" },
|
||||
},
|
||||
// this/self: purple-blue
|
||||
{
|
||||
scope: "variable.language",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Object literal keys: medium purple-blue
|
||||
{
|
||||
scope: "meta.object-literal.key",
|
||||
settings: { foreground: "var(--color-code-object-key)" },
|
||||
},
|
||||
// Strings: sage green
|
||||
{
|
||||
scope: ["string", "meta.embedded.assembly"],
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// String interpolation punctuation: blue-purple
|
||||
{
|
||||
scope: [
|
||||
"punctuation.definition.template-expression.begin",
|
||||
"punctuation.definition.template-expression.end",
|
||||
"punctuation.section.embedded",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-template-punctuation)" },
|
||||
},
|
||||
// Template expression reset
|
||||
{
|
||||
scope: "meta.template.expression",
|
||||
settings: { foreground: "var(--color-code-plain)" },
|
||||
},
|
||||
// Operators: gray (same as foreground)
|
||||
{
|
||||
scope: "keyword.operator",
|
||||
settings: { foreground: "var(--color-code-foreground)" },
|
||||
},
|
||||
// Comments: olive gray
|
||||
{
|
||||
scope: "comment",
|
||||
settings: { foreground: "var(--color-code-comment)" },
|
||||
},
|
||||
// Language constants (true, false, null, undefined): purple-blue
|
||||
{
|
||||
scope: "constant.language",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Numeric constants: light green
|
||||
{
|
||||
scope: [
|
||||
"constant.numeric",
|
||||
"keyword.operator.plus.exponent",
|
||||
"keyword.operator.minus.exponent",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-number)" },
|
||||
},
|
||||
// Regex: dark red
|
||||
{
|
||||
scope: "constant.regexp",
|
||||
settings: { foreground: "var(--color-code-regexp-constant)" },
|
||||
},
|
||||
// HTML/JSX tags: purple-blue
|
||||
{
|
||||
scope: "entity.name.tag",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Tag brackets: dark gray
|
||||
{
|
||||
scope: "punctuation.definition.tag",
|
||||
settings: { foreground: "var(--color-code-muted)" },
|
||||
},
|
||||
// HTML/JSX attributes: light purple
|
||||
{
|
||||
scope: "entity.other.attribute-name",
|
||||
settings: { foreground: "var(--color-code-attribute)" },
|
||||
},
|
||||
// Escape characters: gold
|
||||
{
|
||||
scope: "constant.character.escape",
|
||||
settings: { foreground: "var(--color-code-escape)" },
|
||||
},
|
||||
// Regex string: dark red
|
||||
{
|
||||
scope: "string.regexp",
|
||||
settings: { foreground: "var(--color-code-regexp)" },
|
||||
},
|
||||
// Storage: purple-blue
|
||||
{
|
||||
scope: "storage",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// TS-specific: type casts, math/dom/json constants
|
||||
{
|
||||
scope: [
|
||||
"meta.type.cast.expr",
|
||||
"meta.type.new.expr",
|
||||
"support.constant.math",
|
||||
"support.constant.dom",
|
||||
"support.constant.json",
|
||||
],
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Markdown headings: purple-blue bold
|
||||
{
|
||||
scope: "markup.heading",
|
||||
settings: { foreground: "var(--color-code-language)", fontStyle: "bold" },
|
||||
},
|
||||
// Markup bold: purple-blue
|
||||
{
|
||||
scope: "markup.bold",
|
||||
settings: { foreground: "var(--color-code-language)", fontStyle: "bold" },
|
||||
},
|
||||
// Markup inline raw: sage green
|
||||
{
|
||||
scope: "markup.inline.raw",
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// Markup inserted: light green
|
||||
{
|
||||
scope: "markup.inserted",
|
||||
settings: { foreground: "var(--color-code-number)" },
|
||||
},
|
||||
// Markup deleted: sage green
|
||||
{
|
||||
scope: "markup.deleted",
|
||||
settings: { foreground: "var(--color-code-string)" },
|
||||
},
|
||||
// Markup changed: purple-blue
|
||||
{
|
||||
scope: "markup.changed",
|
||||
settings: { foreground: "var(--color-code-language)" },
|
||||
},
|
||||
// Invalid: red
|
||||
{
|
||||
scope: "invalid",
|
||||
settings: { foreground: "var(--color-code-invalid)" },
|
||||
},
|
||||
// JSX text content
|
||||
{
|
||||
scope: ["meta.jsx.children"],
|
||||
settings: { foreground: "var(--color-code-jsx-text)" },
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
// TSQL CodeMirror support
|
||||
// Provides syntax highlighting, autocompletion, and linting for TSQL queries
|
||||
|
||||
export { createTSQLCompletion } from "./tsqlCompletion";
|
||||
export {
|
||||
createTSQLLinter,
|
||||
isValidTSQLQuery,
|
||||
getTSQLError,
|
||||
type TSQLLinterConfig,
|
||||
} from "./tsqlLinter";
|
||||
@@ -0,0 +1,490 @@
|
||||
import type { CompletionContext, CompletionResult, Completion } from "@codemirror/autocomplete";
|
||||
import {
|
||||
type TableSchema,
|
||||
type ColumnSchema,
|
||||
TSQL_CLICKHOUSE_FUNCTIONS,
|
||||
TSQL_AGGREGATIONS,
|
||||
} from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* SQL keywords for autocomplete
|
||||
*/
|
||||
const SQL_KEYWORDS = [
|
||||
"SELECT",
|
||||
"FROM",
|
||||
"WHERE",
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"TRUE",
|
||||
"FALSE",
|
||||
"AS",
|
||||
"ORDER",
|
||||
"BY",
|
||||
"ASC",
|
||||
"DESC",
|
||||
"LIMIT",
|
||||
"OFFSET",
|
||||
"GROUP",
|
||||
"HAVING",
|
||||
"DISTINCT",
|
||||
"JOIN",
|
||||
"LEFT",
|
||||
"RIGHT",
|
||||
"INNER",
|
||||
"OUTER",
|
||||
"FULL",
|
||||
"CROSS",
|
||||
"ON",
|
||||
"UNION",
|
||||
"INTERSECT",
|
||||
"EXCEPT",
|
||||
"ALL",
|
||||
"WITH",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
"OVER",
|
||||
"PARTITION",
|
||||
"ROWS",
|
||||
"RANGE",
|
||||
"UNBOUNDED",
|
||||
"PRECEDING",
|
||||
"FOLLOWING",
|
||||
"CURRENT",
|
||||
"ROW",
|
||||
"NULLS",
|
||||
"FIRST",
|
||||
"LAST",
|
||||
];
|
||||
|
||||
/**
|
||||
* Create keyword completions from the SQL keywords list
|
||||
*/
|
||||
function createKeywordCompletions(): Completion[] {
|
||||
return SQL_KEYWORDS.map((keyword) => ({
|
||||
label: keyword,
|
||||
type: "keyword",
|
||||
boost: -1, // Keywords should have lower priority than schema items
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create function completions from TSQL function definitions
|
||||
*/
|
||||
function createFunctionCompletions(): Completion[] {
|
||||
const functions: Completion[] = [];
|
||||
|
||||
// Add regular functions
|
||||
for (const [name, meta] of Object.entries(TSQL_CLICKHOUSE_FUNCTIONS)) {
|
||||
// Skip internal functions starting with _
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: argsHint,
|
||||
apply: `${name}()`,
|
||||
});
|
||||
}
|
||||
|
||||
// Add aggregate functions with slightly higher boost
|
||||
for (const [name, meta] of Object.entries(TSQL_AGGREGATIONS)) {
|
||||
if (name.startsWith("_")) continue;
|
||||
|
||||
const argsHint =
|
||||
meta.maxArgs === 0
|
||||
? "()"
|
||||
: meta.minArgs === meta.maxArgs
|
||||
? `(${meta.minArgs} args)`
|
||||
: `(${meta.minArgs}${meta.maxArgs ? `-${meta.maxArgs}` : "+"} args)`;
|
||||
|
||||
functions.push({
|
||||
label: name,
|
||||
type: "function",
|
||||
detail: `aggregate ${argsHint}`,
|
||||
apply: `${name}()`,
|
||||
boost: 0.5,
|
||||
});
|
||||
}
|
||||
|
||||
// Add special TSQL functions not in the ClickHouse function registry
|
||||
functions.push({
|
||||
label: "timeBucket",
|
||||
type: "function",
|
||||
detail: "auto time bucket (0 args)",
|
||||
apply: "timeBucket()",
|
||||
boost: 1.5,
|
||||
info: "Automatically bucket by time using the table's time column. Interval is chosen based on the query's time range.",
|
||||
});
|
||||
|
||||
return functions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create table completions from schema
|
||||
*/
|
||||
function createTableCompletions(schema: TableSchema[]): Completion[] {
|
||||
return schema.map((table) => ({
|
||||
label: table.name,
|
||||
type: "class", // Using "class" type for tables gives them a nice icon
|
||||
detail: table.description || "table",
|
||||
boost: 1, // Tables should have higher priority
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create column completions for a specific table
|
||||
*/
|
||||
function createColumnCompletions(table: TableSchema, prefix?: string): Completion[] {
|
||||
const columns: Completion[] = [];
|
||||
|
||||
for (const [name, column] of Object.entries(table.columns)) {
|
||||
columns.push({
|
||||
label: prefix ? `${prefix}.${name}` : name,
|
||||
type: "property", // Using "property" type for columns
|
||||
detail: `${column.type}${column.description ? ` - ${column.description}` : ""}`,
|
||||
boost: 2, // Columns should have highest priority
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract table names/aliases from the current query context
|
||||
* This is a simplified parser that looks for FROM and JOIN clauses
|
||||
*/
|
||||
function extractTablesFromQuery(doc: string, schema: TableSchema[]): Map<string, TableSchema> {
|
||||
const tableMap = new Map<string, TableSchema>();
|
||||
const _tableNames = schema.map((t) => t.name);
|
||||
|
||||
// Simple regex to find table references in FROM and JOIN clauses
|
||||
// Handles: FROM table_name, FROM table_name AS alias, FROM table_name alias
|
||||
const tablePattern = /(?:FROM|JOIN)\s+(\w+)(?:\s+(?:AS\s+)?(\w+))?/gi;
|
||||
|
||||
let match;
|
||||
while ((match = tablePattern.exec(doc)) !== null) {
|
||||
const tableName = match[1];
|
||||
const alias = match[2] || tableName;
|
||||
|
||||
// Find the table schema if it exists
|
||||
const tableSchema = schema.find((t) => t.name.toLowerCase() === tableName.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
tableMap.set(alias.toLowerCase(), tableSchema);
|
||||
}
|
||||
}
|
||||
|
||||
return tableMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what context we're in based on cursor position
|
||||
*/
|
||||
type CompletionContextType =
|
||||
| "table" // After FROM or JOIN
|
||||
| "column" // After SELECT, WHERE, ORDER BY, GROUP BY, etc.
|
||||
| "alias" // After table_name.
|
||||
| "value" // After comparison operator (=, !=, IN, etc.)
|
||||
| "general"; // Anywhere else
|
||||
|
||||
/**
|
||||
* Result of context detection
|
||||
*/
|
||||
interface ContextResult {
|
||||
type: CompletionContextType;
|
||||
tablePrefix?: string;
|
||||
/** Column being compared (for value context) */
|
||||
columnName?: string;
|
||||
/** Table alias for the column (for value context) */
|
||||
columnTableAlias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract column name from text before a comparison operator
|
||||
* Handles: "column =", "table.column =", "column IN", "column = 'partial", etc.
|
||||
*/
|
||||
function extractColumnBeforeOperator(
|
||||
textBefore: string
|
||||
): { columnName: string; tableAlias?: string } | null {
|
||||
// Match patterns like: column =, column !=, column IN, table.column =, etc.
|
||||
// We need to capture the column (and optional table prefix) before the operator
|
||||
// Also match when user is typing a partial string value like: column = 'val
|
||||
const patterns = [
|
||||
// column = or column != or column <> (with optional whitespace and optional partial string value)
|
||||
/(\w+)\.(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s*(?:=|!=|<>)\s*(?:'[^']*)?$/i,
|
||||
// column IN ( or column NOT IN ( (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\(\s*(?:'[^']*)?$/i,
|
||||
// After a comma in IN clause (with optional partial string value)
|
||||
/(\w+)\.(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
/(\w+)\s+(?:NOT\s+)?IN\s*\([^)]*,\s*(?:'[^']*)?$/i,
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = textBefore.match(pattern);
|
||||
if (match) {
|
||||
if (match.length === 3) {
|
||||
// table.column pattern
|
||||
return { tableAlias: match[1], columnName: match[2] };
|
||||
} else {
|
||||
// just column pattern
|
||||
return { columnName: match[1] };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function determineContext(doc: string, pos: number): ContextResult {
|
||||
// Get text before cursor
|
||||
const textBefore = doc.slice(0, pos);
|
||||
|
||||
// Check if we're in a value context (after comparison operator)
|
||||
// This should be checked before other contexts
|
||||
const columnInfo = extractColumnBeforeOperator(textBefore);
|
||||
if (columnInfo) {
|
||||
return {
|
||||
type: "value",
|
||||
columnName: columnInfo.columnName,
|
||||
columnTableAlias: columnInfo.tableAlias,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if we're completing after a dot (table.column)
|
||||
const dotMatch = textBefore.match(/(\w+)\.\s*$/);
|
||||
if (dotMatch) {
|
||||
return { type: "alias", tablePrefix: dotMatch[1] };
|
||||
}
|
||||
|
||||
// Find the LAST significant keyword before cursor
|
||||
// We match all keywords and take the last one
|
||||
const keywordPattern = /\b(SELECT|FROM|JOIN|WHERE|AND|OR|ORDER\s+BY|GROUP\s+BY|HAVING|ON)\b/gi;
|
||||
let lastMatch: RegExpExecArray | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
while ((match = keywordPattern.exec(textBefore)) !== null) {
|
||||
lastMatch = match;
|
||||
}
|
||||
|
||||
if (lastMatch) {
|
||||
const keyword = lastMatch[1].toUpperCase().replace(/\s+/g, " ");
|
||||
|
||||
if (keyword === "FROM" || keyword === "JOIN") {
|
||||
return { type: "table" };
|
||||
}
|
||||
|
||||
if (
|
||||
keyword === "SELECT" ||
|
||||
keyword === "WHERE" ||
|
||||
keyword === "AND" ||
|
||||
keyword === "OR" ||
|
||||
keyword === "ORDER BY" ||
|
||||
keyword === "GROUP BY" ||
|
||||
keyword === "HAVING" ||
|
||||
keyword === "ON"
|
||||
) {
|
||||
return { type: "column" };
|
||||
}
|
||||
}
|
||||
|
||||
return { type: "general" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a column schema by name in the tables map
|
||||
*/
|
||||
function findColumnSchema(
|
||||
columnName: string,
|
||||
tableAlias: string | undefined,
|
||||
tables: Map<string, TableSchema>
|
||||
): ColumnSchema | null {
|
||||
if (tableAlias) {
|
||||
// Look in specific table
|
||||
const tableSchema = tables.get(tableAlias.toLowerCase());
|
||||
if (tableSchema) {
|
||||
return tableSchema.columns[columnName] || null;
|
||||
}
|
||||
} else {
|
||||
// Look in all tables
|
||||
for (const tableSchema of tables.values()) {
|
||||
const col = tableSchema.columns[columnName];
|
||||
if (col) {
|
||||
return col;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create completions for enum values from allowedValues
|
||||
*/
|
||||
function createEnumValueCompletions(columnSchema: ColumnSchema): Completion[] {
|
||||
if (!columnSchema.allowedValues || columnSchema.allowedValues.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return columnSchema.allowedValues.map((value) => ({
|
||||
label: `'${value}'`,
|
||||
type: "enum",
|
||||
detail: columnSchema.description || "allowed value",
|
||||
boost: 3, // Highest priority for enum values in value context
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL-aware autocompletion source
|
||||
*
|
||||
* @param schema - Array of table schemas to use for completions
|
||||
* @returns A CodeMirror completion source function
|
||||
*/
|
||||
export function createTSQLCompletion(
|
||||
schema: TableSchema[]
|
||||
): (context: CompletionContext) => CompletionResult | null {
|
||||
// Pre-compute static completions
|
||||
const keywordCompletions = createKeywordCompletions();
|
||||
const functionCompletions = createFunctionCompletions();
|
||||
const tableCompletions = createTableCompletions(schema);
|
||||
|
||||
return (context: CompletionContext): CompletionResult | null => {
|
||||
// Get the word being typed - include single quotes for value completion
|
||||
const word = context.matchBefore(/[\w.']+/);
|
||||
|
||||
// Don't show completions if no word is being typed and not explicitly triggered
|
||||
if (!word && !context.explicit) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const from = word ? word.from : context.pos;
|
||||
const doc = context.state.doc.toString();
|
||||
const queryContext = determineContext(doc, context.pos);
|
||||
|
||||
let options: Completion[] = [];
|
||||
// Track if we need to extend replacement range (e.g., to consume auto-paired closing quote)
|
||||
let to: number | undefined = undefined;
|
||||
|
||||
switch (queryContext.type) {
|
||||
case "table":
|
||||
// After FROM or JOIN, show only tables
|
||||
options = tableCompletions;
|
||||
break;
|
||||
|
||||
case "alias":
|
||||
// After table., show columns for that table
|
||||
if (queryContext.tablePrefix) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const tableSchema = tables.get(queryContext.tablePrefix.toLowerCase());
|
||||
|
||||
if (tableSchema) {
|
||||
options = createColumnCompletions(tableSchema);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "value":
|
||||
// After comparison operator, show enum values if available
|
||||
if (queryContext.columnName) {
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
const columnSchema = findColumnSchema(
|
||||
queryContext.columnName,
|
||||
queryContext.columnTableAlias,
|
||||
tables
|
||||
);
|
||||
|
||||
if (columnSchema) {
|
||||
options = createEnumValueCompletions(columnSchema);
|
||||
// Check if there's a closing quote right after cursor (from auto-pairing)
|
||||
// If so, extend replacement range to include it to avoid 'Completed''
|
||||
const charAfterCursor = context.state.doc.sliceString(context.pos, context.pos + 1);
|
||||
if (charAfterCursor === "'") {
|
||||
to = context.pos + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case "column":
|
||||
// After SELECT, WHERE, etc., show columns, functions, and some keywords
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
|
||||
// Add columns from all tables in the query
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
// If multiple tables, prefix with alias
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
|
||||
// Also add functions and relevant keywords
|
||||
options.push(...functionCompletions);
|
||||
options.push(
|
||||
...keywordCompletions.filter((k) =>
|
||||
[
|
||||
"AND",
|
||||
"OR",
|
||||
"NOT",
|
||||
"IN",
|
||||
"LIKE",
|
||||
"ILIKE",
|
||||
"BETWEEN",
|
||||
"IS",
|
||||
"NULL",
|
||||
"AS",
|
||||
"CASE",
|
||||
"WHEN",
|
||||
"THEN",
|
||||
"ELSE",
|
||||
"END",
|
||||
].includes(k.label as string)
|
||||
)
|
||||
);
|
||||
}
|
||||
break;
|
||||
|
||||
case "general":
|
||||
default:
|
||||
// Show everything
|
||||
options = [...tableCompletions, ...functionCompletions, ...keywordCompletions];
|
||||
|
||||
// Also add columns from tables in query
|
||||
{
|
||||
const tables = extractTablesFromQuery(doc, schema);
|
||||
tables.forEach((tableSchema, alias) => {
|
||||
const prefix = tables.size > 1 ? alias : undefined;
|
||||
options.push(...createColumnCompletions(tableSchema, prefix));
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const result: CompletionResult = {
|
||||
from,
|
||||
options,
|
||||
validFor: /^[\w.']*$/,
|
||||
};
|
||||
// Only set 'to' if we need to extend the replacement range
|
||||
if (to !== undefined) {
|
||||
result.to = to;
|
||||
}
|
||||
return result;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { isValidTSQLQuery, getTSQLError } from "./tsqlLinter";
|
||||
|
||||
describe("tsqlLinter", () => {
|
||||
describe("isValidTSQLQuery", () => {
|
||||
it("should return true for empty queries", () => {
|
||||
expect(isValidTSQLQuery("")).toBe(true);
|
||||
expect(isValidTSQLQuery(" ")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for valid SELECT queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT id, name FROM users WHERE status = 'active'")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT count(*) FROM users GROUP BY status")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with ORDER BY", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users ORDER BY created_at DESC")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with LIMIT", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10")).toBe(true);
|
||||
expect(isValidTSQLQuery("SELECT * FROM users LIMIT 10 OFFSET 20")).toBe(true);
|
||||
});
|
||||
|
||||
it("should return true for queries with JOINs", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM users JOIN orders ON users.id = orders.user_id")).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
isValidTSQLQuery("SELECT * FROM users LEFT JOIN orders ON users.id = orders.user_id")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false for invalid syntax", () => {
|
||||
expect(isValidTSQLQuery("SELEC * FROM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT * FORM users")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT FROM users")).toBe(false);
|
||||
});
|
||||
|
||||
it("should return false for incomplete queries", () => {
|
||||
expect(isValidTSQLQuery("SELECT * FROM")).toBe(false);
|
||||
expect(isValidTSQLQuery("SELECT")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getTSQLError", () => {
|
||||
it("should return null for empty queries", () => {
|
||||
expect(getTSQLError("")).toBeNull();
|
||||
expect(getTSQLError(" ")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null for valid queries", () => {
|
||||
expect(getTSQLError("SELECT * FROM users")).toBeNull();
|
||||
expect(getTSQLError("SELECT id, name FROM users WHERE id = 1")).toBeNull();
|
||||
});
|
||||
|
||||
it("should return error message for invalid queries", () => {
|
||||
const error = getTSQLError("SELEC * FROM users");
|
||||
expect(error).not.toBeNull();
|
||||
expect(typeof error).toBe("string");
|
||||
});
|
||||
|
||||
it("should include position information in error", () => {
|
||||
const error = getTSQLError("SELECT * FORM users");
|
||||
expect(error).not.toBeNull();
|
||||
// Error message should contain line/column info
|
||||
expect(error).toContain("line");
|
||||
});
|
||||
|
||||
it("should handle missing FROM clause", () => {
|
||||
const error = getTSQLError("SELECT * WHERE id = 1");
|
||||
expect(error).not.toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,208 @@
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import type { Diagnostic } from "@codemirror/lint";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import { parseTSQLSelect, SyntaxError, QueryError, validateQuery } from "@internal/tsql";
|
||||
|
||||
/**
|
||||
* Configuration for the TSQL linter
|
||||
*/
|
||||
export interface TSQLLinterConfig {
|
||||
/** Optional schema for validating table/column names */
|
||||
schema?: TableSchema[];
|
||||
/** Delay in milliseconds before running the linter (debouncing) */
|
||||
delay?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract line and column from a TSQL error message
|
||||
* Error format: "Syntax error at line X:Y: message"
|
||||
*/
|
||||
function parseErrorPosition(message: string): { line: number; column: number } | null {
|
||||
const match = message.match(/at line (\d+):(\d+)/);
|
||||
if (match) {
|
||||
return {
|
||||
line: parseInt(match[1], 10),
|
||||
column: parseInt(match[2], 10),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert line/column to a document position
|
||||
*/
|
||||
function positionToOffset(doc: string, line: number, column: number): number {
|
||||
const lines = doc.split("\n");
|
||||
|
||||
// line is 1-indexed
|
||||
let offset = 0;
|
||||
for (let i = 0; i < line - 1 && i < lines.length; i++) {
|
||||
offset += lines[i].length + 1; // +1 for newline
|
||||
}
|
||||
|
||||
return offset + column;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the end of a word/token at the given position
|
||||
*/
|
||||
function findTokenEnd(doc: string, start: number): number {
|
||||
let end = start;
|
||||
|
||||
// Scan forward until we hit whitespace or end of string
|
||||
while (end < doc.length && /\S/.test(doc[end])) {
|
||||
end++;
|
||||
}
|
||||
|
||||
// If we didn't move, include at least one character
|
||||
if (end === start) {
|
||||
end = Math.min(start + 1, doc.length);
|
||||
}
|
||||
|
||||
return end;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a TSQL linter function for CodeMirror
|
||||
*
|
||||
* This linter uses the TSQL ANTLR parser to detect syntax errors
|
||||
* and optionally validates against a schema.
|
||||
*
|
||||
* @param config - Linter configuration
|
||||
* @returns A linter function for use with CodeMirror's linter extension
|
||||
*/
|
||||
export function createTSQLLinter(
|
||||
config: TSQLLinterConfig = {}
|
||||
): (view: EditorView) => Diagnostic[] {
|
||||
const { schema = [] } = config;
|
||||
|
||||
return (view: EditorView): Diagnostic[] => {
|
||||
const content = view.state.doc.toString().trim();
|
||||
|
||||
// Return no errors for empty content
|
||||
if (!content) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
try {
|
||||
// Try to parse the query
|
||||
const ast = parseTSQLSelect(content);
|
||||
|
||||
// If parsing succeeds and we have a schema, run schema validation
|
||||
if (schema.length > 0) {
|
||||
const validationResult = validateQuery(ast, schema);
|
||||
|
||||
for (const issue of validationResult.issues) {
|
||||
// Map validation severity to CodeMirror diagnostic severity
|
||||
const severity: "error" | "warning" | "info" =
|
||||
issue.severity === "error"
|
||||
? "error"
|
||||
: issue.severity === "warning"
|
||||
? "warning"
|
||||
: "info";
|
||||
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity,
|
||||
message: issue.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
const position = parseErrorPosition(error.message);
|
||||
|
||||
let from: number;
|
||||
let to: number;
|
||||
|
||||
if (position) {
|
||||
from = positionToOffset(content, position.line, position.column);
|
||||
to = findTokenEnd(content, from);
|
||||
} else {
|
||||
// If we can't parse the position, highlight the whole query
|
||||
from = 0;
|
||||
to = content.length;
|
||||
}
|
||||
|
||||
// Clean up the error message
|
||||
let message = error.message;
|
||||
// Remove the "Syntax error at line X:Y: " prefix if present
|
||||
message = message.replace(/^Syntax error at line \d+:\d+:\s*/, "");
|
||||
|
||||
diagnostics.push({
|
||||
from,
|
||||
to,
|
||||
severity: "error",
|
||||
message: message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof QueryError) {
|
||||
// Schema validation errors don't have position info,
|
||||
// so highlight the whole query
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "warning",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
} else if (error instanceof Error) {
|
||||
// Unknown error
|
||||
diagnostics.push({
|
||||
from: 0,
|
||||
to: content.length,
|
||||
severity: "error",
|
||||
message: error.message,
|
||||
source: "tsql",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a TSQL query is valid
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns true if the query is valid, false otherwise
|
||||
*/
|
||||
export function isValidTSQLQuery(query: string): boolean {
|
||||
if (!query.trim()) {
|
||||
return true; // Empty queries are considered valid
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get error message for a TSQL query, if any
|
||||
*
|
||||
* @param query - The query to validate
|
||||
* @returns Error message if invalid, null if valid
|
||||
*/
|
||||
export function getTSQLError(query: string): string | null {
|
||||
if (!query.trim()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
parseTSQLSelect(query);
|
||||
return null;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "Unknown error";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import type { ChartBlock } from "@internal/dashboard-agent";
|
||||
import { useEffect, useState } from "react";
|
||||
import { QueryResultsChart } from "~/components/code/QueryResultsChart";
|
||||
import type { ChartConfiguration } from "~/components/metrics/QueryWidget";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
|
||||
// Render an agent "chart" block by running its TRQL query through the dashboard's
|
||||
// own /resources/metric endpoint (session-authed, returns rows + real column
|
||||
// metadata) and feeding the result into QueryResultsChart. So the chart is live
|
||||
// and matches the Query page exactly: the agent only emits the query + chart
|
||||
// config, never the rows. Runs against the project/env the panel is open in.
|
||||
|
||||
type MetricResponse =
|
||||
| { success: false; error: string }
|
||||
| {
|
||||
success: true;
|
||||
data: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
timeRange: { from: string; to: string };
|
||||
};
|
||||
};
|
||||
|
||||
type ChartState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| {
|
||||
status: "ready";
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
timeRange?: { from: string; to: string };
|
||||
};
|
||||
|
||||
export function AgentChart({ block }: { block: ChartBlock }) {
|
||||
const organization = useOptionalOrganization();
|
||||
const project = useOptionalProject();
|
||||
const environment = useOptionalEnvironment();
|
||||
const [state, setState] = useState<ChartState>({ status: "loading" });
|
||||
|
||||
const organizationId = organization?.id;
|
||||
const projectId = project?.id;
|
||||
const environmentId = environment?.id;
|
||||
|
||||
useEffect(() => {
|
||||
// The block can render before its `query` has finished streaming in; wait
|
||||
// for it rather than POST an empty query (which 400s).
|
||||
if (!block.query) return;
|
||||
if (!organizationId || !projectId || !environmentId) {
|
||||
setState({ status: "error", error: "No environment context to run the query." });
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setState({ status: "loading" });
|
||||
fetch("/resources/metric", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: block.query,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
scope: "environment",
|
||||
period: block.period ?? null,
|
||||
from: null,
|
||||
to: null,
|
||||
}),
|
||||
signal: controller.signal,
|
||||
})
|
||||
.then(async (res) => (await res.json()) as MetricResponse)
|
||||
.then((data) => {
|
||||
if (controller.signal.aborted) return;
|
||||
if (!data.success) {
|
||||
setState({ status: "error", error: data.error });
|
||||
} else {
|
||||
setState({
|
||||
status: "ready",
|
||||
rows: data.data.rows,
|
||||
columns: data.data.columns,
|
||||
timeRange: data.data.timeRange,
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (controller.signal.aborted) return;
|
||||
setState({ status: "error", error: err?.message ?? "The query failed to run." });
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [block.query, block.period, organizationId, projectId, environmentId]);
|
||||
|
||||
const config: ChartConfiguration = {
|
||||
chartType: block.chartType,
|
||||
xAxisColumn: block.xAxisColumn,
|
||||
yAxisColumns: block.yAxisColumns ?? [],
|
||||
groupByColumn: block.groupByColumn ?? null,
|
||||
stacked: block.stacked ?? false,
|
||||
sortByColumn: null,
|
||||
sortDirection: "desc",
|
||||
aggregation: block.aggregation ?? "sum",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
|
||||
{block.title ? (
|
||||
<div className="border-b border-grid-bright bg-background-bright px-3 py-2 text-xs font-medium text-text-dimmed">
|
||||
{block.title}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="h-64 w-full p-2">
|
||||
{state.status === "loading" ? (
|
||||
<div className="flex h-full items-center justify-center gap-2 text-xs text-text-dimmed">
|
||||
<Spinner className="size-3" />
|
||||
Running query…
|
||||
</div>
|
||||
) : state.status === "error" ? (
|
||||
<div className="flex h-full items-center justify-center px-3 text-center text-xs text-error">
|
||||
{state.error}
|
||||
</div>
|
||||
) : (
|
||||
<QueryResultsChart
|
||||
rows={state.rows}
|
||||
columns={state.columns}
|
||||
config={config}
|
||||
timeRange={state.timeRange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { DashboardAgentPanel } from "./DashboardAgentPanel";
|
||||
import { DashboardAgentProvider } from "./dashboardAgentLauncher";
|
||||
|
||||
/**
|
||||
* Mounts the dashboard agent in the env layout. Renders the page content
|
||||
* (`children` = the route Outlet) and shares the open/close state via context so
|
||||
* the page-header launcher (`DashboardAgentLauncher`) can toggle it. When open it
|
||||
* splits the layout into a resizable content + agent panel, `autosaveId` persists
|
||||
* the width.
|
||||
*
|
||||
* `hasAccess` is resolved server-side in the env layout loader
|
||||
* (`canAccessDashboardAgent`); when false we render the content untouched and
|
||||
* never expose the context, so the launcher stays hidden. The resource routes
|
||||
* enforce the same check server-side.
|
||||
*/
|
||||
export function DashboardAgent({
|
||||
children,
|
||||
hasAccess = false,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
hasAccess?: boolean;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!hasAccess) {
|
||||
return <div className="h-full min-h-0">{children}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DashboardAgentProvider value={{ open, setOpen }}>
|
||||
{open ? (
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal"
|
||||
autosaveId="dashboard-agent-split"
|
||||
className="h-full min-h-0"
|
||||
>
|
||||
<ResizablePanel id="dashboard-content" min="320px">
|
||||
<div className="h-full overflow-hidden">{children}</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="dashboard-agent-handle" />
|
||||
<ResizablePanel id="dashboard-agent-panel" default="380px" min="320px" max="720px">
|
||||
<DashboardAgentPanel onClose={() => setOpen(false)} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
) : (
|
||||
<div className="h-full min-h-0 overflow-hidden">{children}</div>
|
||||
)}
|
||||
</DashboardAgentProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import type { dashboardAgent } from "@internal/dashboard-agent";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { DashboardAgentComposer } from "./DashboardAgentComposer";
|
||||
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
|
||||
import { DashboardAgentMessages } from "./DashboardAgentMessages";
|
||||
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
|
||||
|
||||
// The persisted session for a chat: the session-scoped token plus the stream
|
||||
// cursor. Resuming with `lastEventId` is what stops the agent's `.out` stream
|
||||
// from replaying the previous turn.
|
||||
export type DashboardAgentSession = {
|
||||
publicAccessToken: string;
|
||||
lastEventId?: string;
|
||||
};
|
||||
|
||||
// Per-turn context for the agent. Matches the agent's clientDataSchema input.
|
||||
export type DashboardAgentClientData = {
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
currentPage?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A single conversation. The panel mounts this with `key={chatId}`, so each
|
||||
* chat gets its own transport constructed with its persisted session — the
|
||||
* resume cursor flows in declaratively via the `sessions` option rather than
|
||||
* an imperative setSession after the fact. A fresh chat passes no session and
|
||||
* starts a new run on first send.
|
||||
*/
|
||||
export function DashboardAgentChat({
|
||||
chatId,
|
||||
initialMessages,
|
||||
session,
|
||||
clientData,
|
||||
apiOrigin,
|
||||
actionPath,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
currentPage,
|
||||
pendingFirstMessage,
|
||||
streaming,
|
||||
onTurnSettled,
|
||||
}: {
|
||||
chatId: string;
|
||||
initialMessages: UIMessage[];
|
||||
session: DashboardAgentSession | null;
|
||||
clientData: DashboardAgentClientData;
|
||||
apiOrigin: string;
|
||||
actionPath: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
// Cold start: send this first message through the transport once on mount to
|
||||
// trigger the turn. Undefined for head-started and resumed chats.
|
||||
pendingFirstMessage?: string;
|
||||
// Head start: the turn is already in flight, so hydrate the session as
|
||||
// streaming so the transport resumes `session.out` instead of treating it as
|
||||
// a settled session with nothing to reconnect to.
|
||||
streaming?: boolean;
|
||||
onTurnSettled: () => void;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const transport = useTriggerChatTransport<typeof dashboardAgent>({
|
||||
task: "dashboard-agent",
|
||||
baseURL: apiOrigin,
|
||||
// New chats are created server-side (the `create` action owns the id and
|
||||
// runs head start), so there's no client-driven head-start route here.
|
||||
// Redirect only the `in`/append to the same-origin proxy, which mints +
|
||||
// injects the delegated user token server-side. `baseURL` stays a string so
|
||||
// `out` (the long-lived SSE) keeps the SDK's realtime-host routing — we
|
||||
// never override it. The proxy forwards the same path on to the API.
|
||||
fetch: (url, init, ctx) => {
|
||||
if (ctx.endpoint !== "in") return globalThis.fetch(url, init);
|
||||
const { pathname, search } = new URL(url);
|
||||
return globalThis.fetch(`${actionPath}/in${pathname}${search}`, init);
|
||||
},
|
||||
clientData,
|
||||
sessions: session
|
||||
? {
|
||||
[chatId]: {
|
||||
publicAccessToken: session.publicAccessToken,
|
||||
lastEventId: session.lastEventId,
|
||||
// Head-started chats are mid-turn, so mark the session streaming to
|
||||
// make the transport resume `session.out`. A settled session
|
||||
// (history) stays false — its transcript loads from the store.
|
||||
isStreaming: streaming ?? false,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
startSession: async ({ chatId }) => {
|
||||
const body = new FormData();
|
||||
body.set("intent", "start");
|
||||
body.set("chatId", chatId);
|
||||
body.set("clientData", JSON.stringify(clientData));
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as { publicAccessToken?: string; error?: string };
|
||||
if (!res.ok || !data.publicAccessToken) {
|
||||
throw new Error(data.error ?? "The chat couldn't start.");
|
||||
}
|
||||
return { publicAccessToken: data.publicAccessToken };
|
||||
},
|
||||
accessToken: async ({ chatId }) => {
|
||||
const body = new FormData();
|
||||
body.set("intent", "token");
|
||||
body.set("chatId", chatId);
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as { token?: string; error?: string };
|
||||
if (!res.ok || !data.token) {
|
||||
throw new Error(data.error ?? "Couldn't refresh the chat token.");
|
||||
}
|
||||
return data.token;
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop: aiStop,
|
||||
error,
|
||||
} = useChat({
|
||||
id: chatId,
|
||||
messages: initialMessages,
|
||||
transport,
|
||||
// Resume an existing/head-started session's stream. A cold-start chat has a
|
||||
// session but nothing to resume yet — it sends its first message instead.
|
||||
resume: !!session && !pendingFirstMessage,
|
||||
});
|
||||
|
||||
const isStreaming = status === "streaming";
|
||||
const isThinking = status === "submitted";
|
||||
|
||||
// Cold start: trigger the first turn by sending the pending message once.
|
||||
const sentFirst = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pendingFirstMessage && !sentFirst.current) {
|
||||
sentFirst.current = true;
|
||||
void sendMessage({ text: pendingFirstMessage });
|
||||
}
|
||||
}, [pendingFirstMessage, sendMessage]);
|
||||
|
||||
const submit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed || isStreaming) return;
|
||||
setInput("");
|
||||
void sendMessage({ text: trimmed });
|
||||
},
|
||||
[isStreaming, sendMessage]
|
||||
);
|
||||
|
||||
const stop = useCallback(() => {
|
||||
transport.stopGeneration(chatId);
|
||||
aiStop();
|
||||
}, [transport, chatId, aiStop]);
|
||||
|
||||
// Tell the panel to refresh its history list once a turn settles, so the new
|
||||
// chat appears and titles/timestamps stay current.
|
||||
const prevStatus = useRef(status);
|
||||
useEffect(() => {
|
||||
const wasInFlight = prevStatus.current === "streaming" || prevStatus.current === "submitted";
|
||||
const nowSettled = status === "ready" || status === "error";
|
||||
if (wasInFlight && nowSettled) onTurnSettled();
|
||||
prevStatus.current = status;
|
||||
}, [status, onTurnSettled]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
{messages.length === 0 ? (
|
||||
<DashboardAgentSuggestedPrompts onSelect={submit} />
|
||||
) : (
|
||||
<DashboardAgentMessages messages={messages} isThinking={isThinking} error={error} />
|
||||
)}
|
||||
<DashboardAgentComposer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={stop}
|
||||
isStreaming={isStreaming}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { PaperAirplaneIcon, StopIcon } from "@heroicons/react/20/solid";
|
||||
import { useRef } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function DashboardAgentComposer({
|
||||
value,
|
||||
onChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
isStreaming,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
isStreaming: boolean;
|
||||
}) {
|
||||
const ref = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
return (
|
||||
<div className="border-t border-grid-bright p-3">
|
||||
<div className="rounded-2xl border border-border-bright bg-background-bright p-2 transition focus-within:border-border-brighter">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={ref}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||||
e.preventDefault();
|
||||
onSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message…"
|
||||
className={cn(
|
||||
"max-h-[40vh] min-h-[40px] flex-1 resize-none border-0 bg-transparent px-2 py-1.5 text-sm text-text-bright placeholder-text-dimmed outline-hidden ring-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control field-sizing-content focus:outline-hidden focus:ring-0"
|
||||
)}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<Button variant="danger/small" LeadingIcon={StopIcon} onClick={onStop}>
|
||||
Stop
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="primary/small"
|
||||
LeadingIcon={PaperAirplaneIcon}
|
||||
onClick={onSubmit}
|
||||
disabled={!value.trim()}
|
||||
>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function DashboardAgentContextBanner({
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
currentPage,
|
||||
}: {
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-grid-bright bg-background-bright/30 px-3 py-1.5 text-xs text-text-dimmed">
|
||||
<span className="shrink-0">Context:</span>
|
||||
<span className="truncate font-medium text-text-bright">{projectSlug}</span>
|
||||
<span>/</span>
|
||||
<span className="truncate">{environmentSlug}</span>
|
||||
<span>/</span>
|
||||
<span className="truncate capitalize">{currentPage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { DashboardAgentComposer } from "./DashboardAgentComposer";
|
||||
import { DashboardAgentContextBanner } from "./DashboardAgentContextBanner";
|
||||
import { DashboardAgentSuggestedPrompts } from "./DashboardAgentSuggestedPrompts";
|
||||
|
||||
/**
|
||||
* The new-chat "draft" state: suggested prompts + composer with no transport
|
||||
* mounted and no chat id yet. The chat id is server-owned, so the first send
|
||||
* goes to the panel's `create` call, which generates the id and returns it;
|
||||
* only then does the real `DashboardAgentChat` mount. The client never invents
|
||||
* a chat id.
|
||||
*/
|
||||
export function DashboardAgentDraft({
|
||||
onSubmit,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
currentPage,
|
||||
}: {
|
||||
onSubmit: (text: string) => void;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
}) {
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const submit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
setInput("");
|
||||
onSubmit(trimmed);
|
||||
},
|
||||
[onSubmit]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardAgentContextBanner
|
||||
projectSlug={projectSlug}
|
||||
environmentSlug={environmentSlug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
<DashboardAgentSuggestedPrompts onSelect={submit} />
|
||||
<DashboardAgentComposer
|
||||
value={input}
|
||||
onChange={setInput}
|
||||
onSubmit={() => submit(input)}
|
||||
onStop={() => {}}
|
||||
isStreaming={false}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ClockIcon, PencilSquareIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export function DashboardAgentHeader({
|
||||
view,
|
||||
onNewChat,
|
||||
onToggleHistory,
|
||||
onClose,
|
||||
}: {
|
||||
view: "chat" | "history";
|
||||
onNewChat: () => void;
|
||||
onToggleHistory: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<span className="text-sm font-medium text-text-bright">Chat</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<IconButton label="New chat" icon={PencilSquareIcon} onClick={onNewChat} />
|
||||
<IconButton
|
||||
label="History"
|
||||
icon={ClockIcon}
|
||||
onClick={onToggleHistory}
|
||||
active={view === "history"}
|
||||
/>
|
||||
<IconButton label="Close" icon={XMarkIcon} onClick={onClose} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IconButton({
|
||||
label,
|
||||
icon: Icon,
|
||||
onClick,
|
||||
active,
|
||||
}: {
|
||||
label: string;
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={label}
|
||||
aria-label={label}
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
"rounded p-1.5 text-text-dimmed transition hover:bg-background-raised hover:text-text-bright",
|
||||
active && "bg-background-raised text-text-bright"
|
||||
)}
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { PlusIcon, TrashIcon } from "@heroicons/react/20/solid";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
// Date fields arrive as strings over the loader's JSON.
|
||||
export type DashboardAgentChat = {
|
||||
id: string;
|
||||
title: string;
|
||||
lastMessageAt: string | null;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export function DashboardAgentHistory({
|
||||
chats,
|
||||
currentChatId,
|
||||
onSelect,
|
||||
onNewChat,
|
||||
onDelete,
|
||||
}: {
|
||||
chats: DashboardAgentChat[];
|
||||
currentChatId: string;
|
||||
onSelect: (chatId: string) => void;
|
||||
onNewChat: () => void;
|
||||
onDelete: (chatId: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div className="p-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onNewChat}
|
||||
className="mb-1 flex w-full items-center gap-2 rounded-md px-2 py-2 text-left text-sm text-text-bright transition hover:bg-background-bright"
|
||||
>
|
||||
<PlusIcon className="size-4 text-green-500" />
|
||||
New chat
|
||||
</button>
|
||||
|
||||
{chats.length === 0 ? (
|
||||
<Paragraph variant="small" className="p-2 text-text-dimmed">
|
||||
No previous chats yet.
|
||||
</Paragraph>
|
||||
) : (
|
||||
<ol className="space-y-0.5">
|
||||
{chats.map((chat) => (
|
||||
<li key={chat.id}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-sm px-2 py-1.5 transition-colors hover:bg-background-bright",
|
||||
chat.id === currentChatId && "bg-background-hover hover:bg-background-hover"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className="flex min-w-0 flex-1 flex-col items-start gap-0.5 text-left outline-hidden focus-custom"
|
||||
>
|
||||
<span className="line-clamp-1 text-sm text-text-bright">{chat.title}</span>
|
||||
{chat.lastMessageAt && (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
<DateTime date={chat.lastMessageAt} showTooltip={false} />
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDelete(chat.id)}
|
||||
aria-label="Delete chat"
|
||||
className="shrink-0 rounded p-1 text-text-dimmed opacity-0 transition-opacity hover:text-error group-hover:opacity-100 group-focus-within:opacity-100 focus-visible:opacity-100 focus-custom"
|
||||
>
|
||||
<TrashIcon className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { memo } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { MessageBubble, renderPart } from "~/components/runs/v3/agent/AgentMessageView";
|
||||
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
|
||||
import { ViewBlocks } from "./view-catalog";
|
||||
|
||||
// The shared MessageBubble renders `step-start` parts as a dashed "step"
|
||||
// separator — useful in the run inspector / playground, just noise in this
|
||||
// simple chat. Drop them before rendering (reference preserved when there are
|
||||
// none, so memoization still holds for those messages).
|
||||
function stripStepParts(message: UIMessage): UIMessage {
|
||||
if (!message.parts?.some((p) => p.type === "step-start")) return message;
|
||||
return { ...message, parts: message.parts.filter((p) => p.type !== "step-start") };
|
||||
}
|
||||
|
||||
// A completed render_view tool part carries a `{ blocks }` view spec the agent
|
||||
// composed (see the dashboard-agent view catalog). We render those blocks as
|
||||
// rich cards instead of the generic tool row.
|
||||
function viewSpecFor(part: UIMessage["parts"][number]): { blocks: unknown[] } | null {
|
||||
const p = part as { type: string; output?: { blocks?: unknown[] } };
|
||||
if (p.type !== "tool-render_view") return null;
|
||||
return Array.isArray(p.output?.blocks) ? { blocks: p.output!.blocks! } : null;
|
||||
}
|
||||
|
||||
// Renders one message. Assistant messages that include a completed render_view
|
||||
// part get the catalog cards (plus the gather tool rows / lead-in text for
|
||||
// transparency); everything else uses the shared MessageBubble unchanged, so
|
||||
// its streaming memoization is preserved for the common case.
|
||||
const DashboardAgentMessageBubble = memo(function DashboardAgentMessageBubble({
|
||||
message,
|
||||
}: {
|
||||
message: UIMessage;
|
||||
}) {
|
||||
if (message.role !== "assistant" || !message.parts?.some((p) => viewSpecFor(p))) {
|
||||
return <MessageBubble message={message} />;
|
||||
}
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{message.parts.map((part, i) => {
|
||||
const spec = viewSpecFor(part);
|
||||
if (spec) return <ViewBlocks key={i} blocks={spec.blocks as never} />;
|
||||
return renderPart(part, i);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
// Renders the conversation with the shared agent message renderer — the same
|
||||
// MessageBubble the run inspector and playground use, so agent output looks
|
||||
// identical everywhere — except where the agent emits a view-catalog block,
|
||||
// which renders as a rich card.
|
||||
export function DashboardAgentMessages({
|
||||
messages,
|
||||
isThinking,
|
||||
error,
|
||||
}: {
|
||||
messages: UIMessage[];
|
||||
isThinking: boolean;
|
||||
error?: Error;
|
||||
}) {
|
||||
const rootRef = useAutoScrollToBottom([messages, isThinking]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div ref={rootRef} className="space-y-4 p-4">
|
||||
{messages.map((message) => (
|
||||
<DashboardAgentMessageBubble key={message.id} message={stripStepParts(message)} />
|
||||
))}
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-2 text-sm text-text-dimmed">
|
||||
<Spinner className="size-3" />
|
||||
Thinking…
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded border border-error/30 bg-error/10 px-3 py-2">
|
||||
<span className="text-xs text-error">{error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import type { UIMessage } from "@ai-sdk/react";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { generateFriendlyId } from "@trigger.dev/core/v3/isomorphic";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { useApiOrigin } from "~/hooks/useApiOrigin";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import {
|
||||
DashboardAgentChat,
|
||||
type DashboardAgentClientData,
|
||||
type DashboardAgentSession,
|
||||
} from "./DashboardAgentChat";
|
||||
import { DashboardAgentDraft } from "./DashboardAgentDraft";
|
||||
import { DashboardAgentHeader } from "./DashboardAgentHeader";
|
||||
import {
|
||||
DashboardAgentHistory,
|
||||
type DashboardAgentChat as DashboardAgentChatListItem,
|
||||
} from "./DashboardAgentHistory";
|
||||
|
||||
// Restore the last open chat across panel re-opens and page reloads. Scoped by
|
||||
// org because chats are org-scoped. localStorage (not a cookie) since the panel
|
||||
// only mounts client-side — the server never needs this.
|
||||
const lastChatStorageKey = (organizationId: string) =>
|
||||
`tdev:dashboard-agent:last-chat:${organizationId}`;
|
||||
|
||||
type ActiveChat = {
|
||||
chatId: string;
|
||||
messages: UIMessage[];
|
||||
session: DashboardAgentSession | null;
|
||||
// Cold start only: the agent run has no warm step-1, so the mounted chat sends
|
||||
// this first message through the transport to trigger the turn. Undefined for
|
||||
// head-started and resumed chats — their stream is resumed, not re-sent.
|
||||
pendingFirstMessage?: string;
|
||||
// True for a head-started chat: the turn is already in flight server-side, so
|
||||
// the transport must hydrate the session as streaming to resume `session.out`.
|
||||
streaming?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The dashboard agent side panel. Owns history, the active chat, and last-chat
|
||||
* persistence. New chats start in a draft state with no id; the server
|
||||
* generates the chat id on the first send (`create`) and owns the chat record,
|
||||
* so the client never invents an id. Existing chats resolve their stored
|
||||
* transcript + session before mounting `DashboardAgentChat` (keyed by chatId).
|
||||
*/
|
||||
export function DashboardAgentPanel({ onClose }: { onClose: () => void }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const user = useUser();
|
||||
const apiOrigin = useApiOrigin();
|
||||
const location = useLocation();
|
||||
|
||||
const [view, setView] = useState<"chat" | "history">("chat");
|
||||
const [chats, setChats] = useState<DashboardAgentChatListItem[]>([]);
|
||||
const [active, setActive] = useState<ActiveChat | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const actionPath = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dashboard-agent`;
|
||||
const storageKey = lastChatStorageKey(organization.id);
|
||||
|
||||
const currentPage = location.pathname.split("/").filter(Boolean).pop() ?? "overview";
|
||||
|
||||
const clientData = useMemo<DashboardAgentClientData>(
|
||||
() => ({
|
||||
userId: user.id,
|
||||
organizationId: organization.id,
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
currentPage: location.pathname,
|
||||
}),
|
||||
[user.id, organization.id, project.id, environment.id, location.pathname]
|
||||
);
|
||||
|
||||
const loadHistory = useCallback(async () => {
|
||||
const res = await fetch(actionPath);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { chats?: DashboardAgentChatListItem[] };
|
||||
setChats(data.chats ?? []);
|
||||
}
|
||||
}, [actionPath]);
|
||||
|
||||
// Bumped on each open so a slower earlier open can't overwrite a newer one
|
||||
// when chats are switched rapidly.
|
||||
const openChatRequestSeq = useRef(0);
|
||||
|
||||
// Open an existing chat: fetch its stored transcript + session so resume flows
|
||||
// in through the transport at mount. A stored id that's gone (deleted / never
|
||||
// sent) drops back to the draft state.
|
||||
const openChat = useCallback(
|
||||
async (id: string) => {
|
||||
setView("chat");
|
||||
const seq = ++openChatRequestSeq.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${actionPath}?chatId=${encodeURIComponent(id)}`);
|
||||
const data = res.ok
|
||||
? ((await res.json()) as {
|
||||
messages?: UIMessage[];
|
||||
session?: { publicAccessToken: string; lastEventId: string | null } | null;
|
||||
})
|
||||
: { messages: [], session: null };
|
||||
if (seq !== openChatRequestSeq.current) return;
|
||||
if (data.messages && data.messages.length > 0) {
|
||||
setActive({
|
||||
chatId: id,
|
||||
messages: data.messages,
|
||||
session: data.session?.publicAccessToken
|
||||
? {
|
||||
publicAccessToken: data.session.publicAccessToken,
|
||||
lastEventId: data.session.lastEventId ?? undefined,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
} else {
|
||||
// Nothing stored under this id — drop to a fresh draft.
|
||||
setActive(null);
|
||||
}
|
||||
} finally {
|
||||
if (seq === openChatRequestSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[actionPath]
|
||||
);
|
||||
|
||||
// Start a new chat by sending its first message. The server generates the id,
|
||||
// creates the chat record, and kicks off the first turn (head start when
|
||||
// configured, else a cold session). We then mount the real chat on the server
|
||||
// id and either resume its stream (head start) or send the message through
|
||||
// the transport (cold start).
|
||||
const createChat = useCallback(
|
||||
async (text: string) => {
|
||||
setView("chat");
|
||||
const seq = ++openChatRequestSeq.current;
|
||||
setLoading(true);
|
||||
try {
|
||||
const userMessage: UIMessage = {
|
||||
id: generateFriendlyId("msg"),
|
||||
role: "user",
|
||||
parts: [{ type: "text", text }],
|
||||
};
|
||||
const body = new FormData();
|
||||
body.set("intent", "create");
|
||||
body.set("message", JSON.stringify(userMessage));
|
||||
body.set("clientData", JSON.stringify(clientData));
|
||||
const res = await fetch(actionPath, { method: "POST", body });
|
||||
const data = (await res.json()) as {
|
||||
chatId?: string;
|
||||
publicAccessToken?: string;
|
||||
headStarted?: boolean;
|
||||
error?: string;
|
||||
};
|
||||
// A newer open/create (or New chat) superseded this one — drop the result.
|
||||
if (seq !== openChatRequestSeq.current) return;
|
||||
if (!res.ok || !data.chatId || !data.publicAccessToken) {
|
||||
setActive(null);
|
||||
return;
|
||||
}
|
||||
setActive({
|
||||
chatId: data.chatId,
|
||||
messages: data.headStarted ? [userMessage] : [],
|
||||
session: { publicAccessToken: data.publicAccessToken },
|
||||
pendingFirstMessage: data.headStarted ? undefined : text,
|
||||
streaming: data.headStarted,
|
||||
});
|
||||
} finally {
|
||||
if (seq === openChatRequestSeq.current) setLoading(false);
|
||||
}
|
||||
},
|
||||
[actionPath, clientData]
|
||||
);
|
||||
|
||||
// On open, restore the last chat if there is one; otherwise stay in the draft
|
||||
// state (active = null). Runs once per mount.
|
||||
const restored = useRef(false);
|
||||
useEffect(() => {
|
||||
if (restored.current) return;
|
||||
restored.current = true;
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = window.localStorage.getItem(storageKey);
|
||||
} catch {
|
||||
/* localStorage unavailable — start fresh */
|
||||
}
|
||||
if (stored) void openChat(stored);
|
||||
}, [openChat, storageKey]);
|
||||
|
||||
// Persist the active chat as the one to restore next time.
|
||||
useEffect(() => {
|
||||
if (!active?.chatId) return;
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, active.chatId);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [active?.chatId, storageKey]);
|
||||
|
||||
const newChat = useCallback(() => {
|
||||
// Invalidate any in-flight open/create so its result can't replace the draft.
|
||||
openChatRequestSeq.current += 1;
|
||||
setLoading(false);
|
||||
setView("chat");
|
||||
setActive(null);
|
||||
}, []);
|
||||
|
||||
const switchChat = useCallback(
|
||||
(id: string) => {
|
||||
void openChat(id);
|
||||
},
|
||||
[openChat]
|
||||
);
|
||||
|
||||
const deleteChat = useCallback(
|
||||
async (id: string) => {
|
||||
const body = new FormData();
|
||||
body.set("intent", "delete");
|
||||
body.set("chatId", id);
|
||||
await fetch(actionPath, { method: "POST", body });
|
||||
if (id === active?.chatId) newChat();
|
||||
void loadHistory();
|
||||
},
|
||||
[actionPath, active?.chatId, newChat, loadHistory]
|
||||
);
|
||||
|
||||
const toggleHistory = useCallback(() => {
|
||||
setView((v) => {
|
||||
if (v === "chat") void loadHistory();
|
||||
return v === "chat" ? "history" : "chat";
|
||||
});
|
||||
}, [loadHistory]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col bg-background-bright animate-in slide-in-from-right-2 duration-150">
|
||||
<DashboardAgentHeader
|
||||
view={view}
|
||||
onNewChat={newChat}
|
||||
onToggleHistory={toggleHistory}
|
||||
onClose={onClose}
|
||||
/>
|
||||
|
||||
{view === "history" ? (
|
||||
<DashboardAgentHistory
|
||||
chats={chats}
|
||||
currentChatId={active?.chatId ?? ""}
|
||||
onSelect={switchChat}
|
||||
onNewChat={newChat}
|
||||
onDelete={deleteChat}
|
||||
/>
|
||||
) : loading ? (
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner className="size-5" />
|
||||
</div>
|
||||
) : active ? (
|
||||
<DashboardAgentChat
|
||||
key={active.chatId}
|
||||
chatId={active.chatId}
|
||||
initialMessages={active.messages}
|
||||
session={active.session}
|
||||
pendingFirstMessage={active.pendingFirstMessage}
|
||||
streaming={active.streaming}
|
||||
clientData={clientData}
|
||||
apiOrigin={apiOrigin}
|
||||
actionPath={actionPath}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
currentPage={currentPage}
|
||||
onTurnSettled={loadHistory}
|
||||
/>
|
||||
) : (
|
||||
<DashboardAgentDraft
|
||||
onSubmit={createChat}
|
||||
projectSlug={project.slug}
|
||||
environmentSlug={environment.slug}
|
||||
currentPage={currentPage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { SparklesIcon } from "@heroicons/react/20/solid";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
|
||||
// Static for now; later these can be page-aware (per currentPage) or server-driven.
|
||||
const SUGGESTED_PROMPTS = [
|
||||
"What can you help me with?",
|
||||
"How do retries work in Trigger.dev?",
|
||||
"Where do I set environment variables?",
|
||||
"Explain what this page shows.",
|
||||
];
|
||||
|
||||
export function DashboardAgentSuggestedPrompts({
|
||||
onSelect,
|
||||
}: {
|
||||
onSelect: (prompt: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4 px-4">
|
||||
<div className="flex flex-col items-center gap-1.5 text-center">
|
||||
<SparklesIcon className="size-6 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Ask about your runs, errors, or how Trigger.dev works.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="flex w-full flex-col gap-1.5">
|
||||
{SUGGESTED_PROMPTS.map((prompt) => (
|
||||
<button
|
||||
key={prompt}
|
||||
type="button"
|
||||
onClick={() => onSelect(prompt)}
|
||||
className="rounded-md border border-grid-bright bg-background-bright/40 px-3 py-2 text-left text-sm text-text-dimmed transition hover:border-border-bright hover:text-text-bright"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import type { DiagnosisBlock } from "@internal/dashboard-agent";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { toSafeUrl } from "~/components/runs/v3/agent/AgentMessageView";
|
||||
import { useOptionalEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useOptionalProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { v3RunPath } from "~/utils/pathBuilder";
|
||||
|
||||
// The "why did this run fail?" failure card — the first block in the dashboard
|
||||
// agent's view catalog. Rendered from a `diagnosis` block the agent emits via
|
||||
// the render_view tool (see internal-packages/dashboard-agent tool-schemas).
|
||||
// Everything here is plain presentation of validated fields; no markup comes
|
||||
// from the model, so there's nothing to sanitize beyond outbound URLs.
|
||||
|
||||
const CATEGORY_LABELS: Record<DiagnosisBlock["category"], string> = {
|
||||
user_code_error: "Code error",
|
||||
configuration: "Configuration",
|
||||
dependency: "Dependency",
|
||||
timeout: "Timeout",
|
||||
out_of_memory: "Out of memory",
|
||||
rate_limit: "Rate limit",
|
||||
external_service: "External service",
|
||||
infrastructure: "Infrastructure",
|
||||
cancellation: "Cancelled",
|
||||
unknown: "Unknown",
|
||||
};
|
||||
|
||||
const CONFIDENCE_STYLES: Record<DiagnosisBlock["confidence"], string> = {
|
||||
high: "border-emerald-500/40 text-emerald-400",
|
||||
medium: "border-amber-500/40 text-amber-400",
|
||||
low: "border-border-bright text-text-dimmed",
|
||||
};
|
||||
|
||||
const EVIDENCE_LABELS: Record<DiagnosisBlock["evidence"][number]["type"], string> = {
|
||||
error: "Error",
|
||||
failed_span: "Failed span",
|
||||
child_run: "Child run",
|
||||
logs: "Logs",
|
||||
deploy: "Deploy",
|
||||
source: "Source",
|
||||
historical_match: "History",
|
||||
};
|
||||
|
||||
// Build a run-page path in the current org/project/env, or null when that route
|
||||
// context is absent (e.g. the storybook page) so the card degrades to plain
|
||||
// text rather than throwing.
|
||||
function useRunPath(runId: string): string | null {
|
||||
const organization = useOptionalOrganization();
|
||||
const project = useOptionalProject();
|
||||
const environment = useOptionalEnvironment();
|
||||
if (!organization || !project || !environment) return null;
|
||||
return v3RunPath(organization, project, environment, { friendlyId: runId });
|
||||
}
|
||||
|
||||
// Internal link to a run page, built from the canonical path builder so it stays
|
||||
// correct if the route shape changes. Falls back to plain text off-context.
|
||||
function RunLink({ runId, className }: { runId: string; className?: string }) {
|
||||
const to = useRunPath(runId);
|
||||
if (!to) return <span className={cn("font-mono text-text-dimmed", className)}>{runId}</span>;
|
||||
return (
|
||||
<Link to={to} className={cn("text-indigo-400 underline hover:text-indigo-300", className)}>
|
||||
{runId}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
// Render an evidence `reference`: a run id links to its run page, an https URL
|
||||
// becomes an external link, everything else (error id, file:line, version) is
|
||||
// shown as monospace text.
|
||||
function EvidenceReference({ reference }: { reference: string }) {
|
||||
if (/^run_[a-z0-9]+$/i.test(reference)) {
|
||||
return <RunLink runId={reference} className="font-mono text-xs" />;
|
||||
}
|
||||
const safeUrl = toSafeUrl(reference);
|
||||
if (safeUrl) {
|
||||
return (
|
||||
<a
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="font-mono text-xs text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
{reference}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-xs text-text-dimmed">{reference}</span>;
|
||||
}
|
||||
|
||||
function DiagnosisActions({ actions }: { actions: NonNullable<DiagnosisBlock["actions"]> }) {
|
||||
const buttonClass =
|
||||
"inline-flex items-center rounded border border-border-bright bg-background-bright px-2.5 py-1 text-xs text-text-bright transition-colors hover:border-border-brightest hover:bg-background-hover";
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 pt-1">
|
||||
{actions.map((action, i) => {
|
||||
if (action.kind === "view_run" && /^run_[a-z0-9]+$/i.test(action.target)) {
|
||||
return (
|
||||
<RunActionButton
|
||||
key={i}
|
||||
runId={action.target}
|
||||
label={action.label}
|
||||
className={buttonClass}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (action.kind === "docs") {
|
||||
const safeUrl = toSafeUrl(action.target);
|
||||
if (!safeUrl) return null;
|
||||
return (
|
||||
<a
|
||||
key={i}
|
||||
href={safeUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={buttonClass}
|
||||
>
|
||||
{action.label}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RunActionButton({
|
||||
runId,
|
||||
label,
|
||||
className,
|
||||
}: {
|
||||
runId: string;
|
||||
label: string;
|
||||
className: string;
|
||||
}) {
|
||||
const to = useRunPath(runId);
|
||||
if (!to) return <span className={className}>{label}</span>;
|
||||
return (
|
||||
<Link to={to} className={className}>
|
||||
{label}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunDiagnosisCard({ block }: { block: DiagnosisBlock }) {
|
||||
const evidence = block.evidence ?? [];
|
||||
const nextSteps = block.nextSteps ?? [];
|
||||
const actions = block.actions ?? [];
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border-bright bg-background-dimmed">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-grid-bright bg-background-bright px-3 py-2">
|
||||
<span className="text-xs font-medium text-text-dimmed">Run diagnosis</span>
|
||||
<Badge variant="small" className="border-rose-500/40 text-rose-400">
|
||||
{CATEGORY_LABELS[block.category] ?? block.category}
|
||||
</Badge>
|
||||
<Badge variant="small" className={cn("uppercase", CONFIDENCE_STYLES[block.confidence])}>
|
||||
{block.confidence} confidence
|
||||
</Badge>
|
||||
{block.runId ? <RunLink runId={block.runId} className="ml-auto font-mono text-xs" /> : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 px-3 py-3">
|
||||
<p className="text-sm text-text-bright">{block.summary}</p>
|
||||
|
||||
<Section title="Likely cause">
|
||||
<p className="text-sm text-text-dimmed">{block.likelyCause}</p>
|
||||
</Section>
|
||||
|
||||
{evidence.length > 0 ? (
|
||||
<Section title="Evidence">
|
||||
<ul className="space-y-1.5">
|
||||
{evidence.map((item, i) => (
|
||||
<li key={i} className="text-xs text-text-dimmed">
|
||||
<span className="mr-1.5 rounded-sm bg-background-raised px-1 py-0.5 text-[10px] uppercase tracking-wide text-text-dimmed">
|
||||
{EVIDENCE_LABELS[item.type] ?? item.type}
|
||||
</span>
|
||||
<span className="text-text-bright">{item.detail}</span>
|
||||
{item.reference ? (
|
||||
<span className="ml-1.5">
|
||||
<EvidenceReference reference={item.reference} />
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{block.impact ? (
|
||||
<Section title="Impact">
|
||||
<p className="text-sm text-text-dimmed">{block.impact}</p>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{nextSteps.length > 0 ? (
|
||||
<Section title="Next steps">
|
||||
<ol className="list-decimal space-y-1 pl-4">
|
||||
{nextSteps.map((step, i) => (
|
||||
<li key={i} className="text-sm text-text-dimmed">
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{actions.length > 0 ? <DiagnosisActions actions={actions} /> : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-xs font-medium uppercase tracking-wide text-text-dimmed">{title}</h4>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChatBubbleLeftRightIcon, ChevronDoubleRightIcon } from "@heroicons/react/20/solid";
|
||||
import { createContext, useContext } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
type DashboardAgentContextValue = {
|
||||
open: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const DashboardAgentContext = createContext<DashboardAgentContextValue | null>(null);
|
||||
|
||||
export const DashboardAgentProvider = DashboardAgentContext.Provider;
|
||||
|
||||
// Null outside the env layout (no provider) or when the agent is gated off, so
|
||||
// the launcher self-hides everywhere it can't open.
|
||||
export function useDashboardAgent() {
|
||||
return useContext(DashboardAgentContext);
|
||||
}
|
||||
|
||||
export function DashboardAgentLauncher() {
|
||||
const agent = useDashboardAgent();
|
||||
if (!agent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { open, setOpen } = agent;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={open ? "Collapse chat" : "Open chat"}
|
||||
onClick={() => setOpen(!open)}
|
||||
className={cn(
|
||||
"flex shrink-0 items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs text-text-bright transition",
|
||||
open
|
||||
? "border-border-brighter bg-background-hover"
|
||||
: "border-border-bright bg-background-bright hover:border-border-brighter"
|
||||
)}
|
||||
>
|
||||
{open ? (
|
||||
<ChevronDoubleRightIcon className="size-3.5 text-text-dimmed" />
|
||||
) : (
|
||||
<ChatBubbleLeftRightIcon className="size-3.5 text-indigo-500" />
|
||||
)}
|
||||
{open ? "Collapse" : "Chat"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ViewBlock } from "@internal/dashboard-agent";
|
||||
import { AgentChart } from "./AgentChart";
|
||||
import { RunDiagnosisCard } from "./RunDiagnosisCard";
|
||||
|
||||
// The render registry for the dashboard agent's view catalog — our small
|
||||
// "generative UI" layer. The agent emits a `render_view` tool call whose output
|
||||
// is `{ blocks: ViewBlock[] }` (a spec drawn from the catalog defined in
|
||||
// internal-packages/dashboard-agent). Here we map each block `type` to its
|
||||
// component. Unknown types are skipped, so an older/newer agent can never
|
||||
// render arbitrary content — same guarantee a generative-UI framework gives,
|
||||
// without the dependency. Add a block by adding a `case` here and a union
|
||||
// member in the package's `viewBlockSchema`.
|
||||
export function ViewBlocks({ blocks }: { blocks: ViewBlock[] }) {
|
||||
if (!Array.isArray(blocks)) return null;
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{blocks.map((block, i) => {
|
||||
switch (block.type) {
|
||||
case "diagnosis":
|
||||
return <RunDiagnosisCard key={i} block={block} />;
|
||||
case "chart":
|
||||
return <AgentChart key={i} block={block} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
BranchEnvironmentIconSmall,
|
||||
DeployedEnvironmentIconSmall,
|
||||
DevEnvironmentIconSmall,
|
||||
ProdEnvironmentIconSmall,
|
||||
} from "~/assets/icons/EnvironmentIcons";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type"> & { branchName?: string | null };
|
||||
|
||||
export function EnvironmentIcon({
|
||||
environment,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
}) {
|
||||
if (environment.branchName) {
|
||||
return (
|
||||
<BranchEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "DEVELOPMENT":
|
||||
return (
|
||||
<DevEnvironmentIconSmall className={cn(environmentTextClassName(environment), className)} />
|
||||
);
|
||||
case "PRODUCTION":
|
||||
return (
|
||||
<ProdEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
case "STAGING":
|
||||
case "PREVIEW":
|
||||
return (
|
||||
<DeployedEnvironmentIconSmall
|
||||
className={cn(environmentTextClassName(environment), className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function EnvironmentCombo({
|
||||
environment,
|
||||
className,
|
||||
iconClassName,
|
||||
tooltipSideOffset,
|
||||
tooltipSide,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
<EnvironmentIcon
|
||||
environment={environment}
|
||||
className={cn("size-4.5 shrink-0", iconClassName)}
|
||||
/>
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
tooltipSideOffset={tooltipSideOffset}
|
||||
tooltipSide={tooltipSide}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
const text = environment.branchName ? environment.branchName : environmentFullTitle(environment);
|
||||
|
||||
useEffect(() => {
|
||||
const checkTruncation = () => {
|
||||
if (spanRef.current) {
|
||||
const isTruncated = spanRef.current.scrollWidth > spanRef.current.clientWidth;
|
||||
setIsTruncated(isTruncated);
|
||||
}
|
||||
};
|
||||
|
||||
checkTruncation();
|
||||
// Add resize observer to recheck on window resize
|
||||
const resizeObserver = new ResizeObserver(checkTruncation);
|
||||
if (spanRef.current) {
|
||||
resizeObserver.observe(spanRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, [text]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={spanRef}
|
||||
className={cn("truncate text-left", environmentTextClassName(environment), className)}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
button={content}
|
||||
content={
|
||||
<span ref={spanRef} className={cn("text-left", environmentTextClassName(environment))}>
|
||||
{text}
|
||||
</span>
|
||||
}
|
||||
side={tooltipSide}
|
||||
variant="dark"
|
||||
sideOffset={tooltipSideOffset}
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
export function EnvironmentSlug({ environment }: { environment: Environment & { slug: string } }) {
|
||||
return <span className={environmentTextClassName(environment)}>{environment.slug}</span>;
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
if (environment.branchName) {
|
||||
return environment.branchName;
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Prod";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return username ? `Dev: ${username}` : "Dev: You";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentFullTitle(environment: Environment) {
|
||||
if (environment.branchName) {
|
||||
return environment.branchName;
|
||||
}
|
||||
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Production";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Development";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTextClassName(environment: { type: Environment["type"] }) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "text-prod";
|
||||
case "STAGING":
|
||||
return "text-staging";
|
||||
case "DEVELOPMENT":
|
||||
return "text-dev";
|
||||
case "PREVIEW":
|
||||
return "text-preview";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { ArrowPathIcon } from "@heroicons/react/20/solid";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { generateTwoRandomWords } from "~/utils/randomWords";
|
||||
import { Button } from "../primitives/Buttons";
|
||||
import { Callout } from "../primitives/Callout";
|
||||
import { Fieldset } from "../primitives/Fieldset";
|
||||
import { FormButtons } from "../primitives/FormButtons";
|
||||
import { Input } from "../primitives/Input";
|
||||
import { InputGroup } from "../primitives/InputGroup";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { CheckboxWithLabel } from "../primitives/Checkbox";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
|
||||
type ModalProps = {
|
||||
id: string;
|
||||
title: string;
|
||||
hasVercelIntegration: boolean;
|
||||
isDevelopment: boolean;
|
||||
};
|
||||
|
||||
type ModalContentProps = ModalProps & {
|
||||
randomWord: string;
|
||||
closeModal: () => void;
|
||||
};
|
||||
|
||||
export function RegenerateApiKeyModal({
|
||||
id,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
}: ModalProps) {
|
||||
const randomWord = generateTwoRandomWords();
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="minimal/small" textAlignLeft LeadingIcon={ArrowPathIcon}>
|
||||
Regenerate…
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>{`Regenerate ${title} environment key`}</DialogHeader>
|
||||
<RegenerateApiKeyModalContent
|
||||
id={id}
|
||||
title={title}
|
||||
hasVercelIntegration={hasVercelIntegration}
|
||||
isDevelopment={isDevelopment}
|
||||
randomWord={randomWord}
|
||||
closeModal={() => setOpen(false)}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const RegenerateApiKeyModalContent = ({
|
||||
id,
|
||||
randomWord,
|
||||
title,
|
||||
hasVercelIntegration,
|
||||
isDevelopment,
|
||||
closeModal,
|
||||
}: ModalContentProps) => {
|
||||
const [confirmationText, setConfirmationText] = useState("");
|
||||
const fetcher = useFetcher();
|
||||
const isSubmitting = fetcher.state === "submitting";
|
||||
|
||||
// form submission completed
|
||||
if (fetcher.state === "loading") {
|
||||
closeModal();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-y-4 pt-4">
|
||||
<Callout variant="warning">
|
||||
{`A new API key will be issued for the ${title} environment. The previous key stays valid
|
||||
for 24 hours so you can roll out the new key in your environment variables without downtime.
|
||||
After 24 hours, the previous key stops working.`}
|
||||
</Callout>
|
||||
<fetcher.Form
|
||||
method="post"
|
||||
action={`/resources/environments/${id}/regenerate-api-key`}
|
||||
className="mt-2 w-full"
|
||||
>
|
||||
<Fieldset className="w-full">
|
||||
<InputGroup className="max-w-full">
|
||||
<Paragraph variant="small/bright">Enter this text below to confirm:</Paragraph>
|
||||
<Paragraph
|
||||
variant="small"
|
||||
className="select-all rounded-md border border-grid-bright bg-background-deep px-2 py-1 font-mono"
|
||||
>
|
||||
{randomWord}
|
||||
</Paragraph>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Confirmation text"
|
||||
fullWidth
|
||||
value={confirmationText}
|
||||
onChange={(e) => setConfirmationText(e.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
{hasVercelIntegration && !isDevelopment && (
|
||||
<CheckboxWithLabel
|
||||
name="syncToVercel"
|
||||
variant="simple/small"
|
||||
label="Also update TRIGGER_SECRET_KEY in Vercel"
|
||||
defaultChecked={true}
|
||||
value="on"
|
||||
/>
|
||||
)}
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
type="submit"
|
||||
variant={"primary/medium"}
|
||||
LeadingIcon={isSubmitting ? Spinner : undefined}
|
||||
disabled={confirmationText !== randomWord}
|
||||
>
|
||||
Regenerate
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<Button variant={"tertiary/medium"} type="button" onClick={closeModal}>
|
||||
Cancel
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</Fieldset>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,369 @@
|
||||
import { getFormProps, getInputProps, useForm } from "@conform-to/react";
|
||||
import { parseWithZod } from "@conform-to/zod";
|
||||
import {
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
HashtagIcon,
|
||||
LockClosedIcon,
|
||||
XMarkIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BellAlertIcon } from "@heroicons/react/24/solid";
|
||||
import { useFetcher, useNavigate } from "@remix-run/react";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useToast } from "~/components/primitives/Toast";
|
||||
import { UnorderedList } from "~/components/primitives/UnorderedList";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import type { ErrorAlertChannelData } from "~/presenters/v3/ErrorAlertChannelPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { organizationSlackIntegrationPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const ErrorAlertsFormSchema = z.object({
|
||||
emails: z.preprocess((i) => {
|
||||
if (typeof i === "string") return i === "" ? [] : [i];
|
||||
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
||||
return [];
|
||||
}, z.string().email().array()),
|
||||
slackChannel: z.string().optional(),
|
||||
slackIntegrationId: z.string().optional(),
|
||||
webhooks: z.preprocess((i) => {
|
||||
if (typeof i === "string") return i === "" ? [] : [i];
|
||||
if (Array.isArray(i)) return i.filter((v) => typeof v === "string" && v !== "");
|
||||
return [];
|
||||
}, z.string().url().array()),
|
||||
});
|
||||
|
||||
type ConfigureErrorAlertsProps = ErrorAlertChannelData & {
|
||||
connectToSlackHref?: string;
|
||||
formAction: string;
|
||||
};
|
||||
|
||||
export function ConfigureErrorAlerts({
|
||||
emails: existingEmails,
|
||||
webhooks: existingWebhooks,
|
||||
slackChannel: existingSlackChannel,
|
||||
slack,
|
||||
emailAlertsEnabled,
|
||||
connectToSlackHref,
|
||||
formAction,
|
||||
}: ConfigureErrorAlertsProps) {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useFetcher<{ ok?: boolean }>();
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const location = useOptimisticLocation();
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
|
||||
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>(
|
||||
existingSlackChannel
|
||||
? `${existingSlackChannel.channelId}/${existingSlackChannel.channelName}`
|
||||
: undefined
|
||||
);
|
||||
|
||||
const selectedSlackChannel =
|
||||
slack.status === "READY"
|
||||
? slack.channels?.find((s) => selectedSlackChannelValue === `${s.id}/${s.name}`)
|
||||
: undefined;
|
||||
|
||||
const closeHref = (() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
params.delete("alerts");
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : location.pathname;
|
||||
})();
|
||||
|
||||
const hasHandledSuccess = useRef(false);
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data?.ok && !hasHandledSuccess.current) {
|
||||
hasHandledSuccess.current = true;
|
||||
toast.success("Alert settings saved");
|
||||
navigate(closeHref, { replace: true });
|
||||
}
|
||||
}, [fetcher.state, fetcher.data, closeHref, navigate, toast]);
|
||||
|
||||
const emailFieldValues = useRef<string[]>(
|
||||
existingEmails.length > 0 ? [...existingEmails.map((e) => e.email), ""] : [""]
|
||||
);
|
||||
|
||||
const webhookFieldValues = useRef<string[]>(
|
||||
existingWebhooks.length > 0 ? [...existingWebhooks.map((w) => w.url), ""] : [""]
|
||||
);
|
||||
|
||||
const [form, fields] = useForm<z.infer<typeof ErrorAlertsFormSchema>>({
|
||||
id: "configure-error-alerts",
|
||||
onValidate({ formData }) {
|
||||
return parseWithZod(formData, { schema: ErrorAlertsFormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
defaultValue: {
|
||||
emails: emailFieldValues.current,
|
||||
webhooks: webhookFieldValues.current,
|
||||
},
|
||||
});
|
||||
const { emails, webhooks, slackChannel, slackIntegrationId } = fields;
|
||||
|
||||
const emailFields = emails.getFieldList();
|
||||
const webhookFields = webhooks.getFieldList();
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr_auto] overflow-hidden">
|
||||
<div className="flex items-center justify-between border-b border-grid-bright px-3 py-2">
|
||||
<Header2 className="flex items-center gap-2">
|
||||
<BellAlertIcon className="size-5 text-alerts" /> Configure alerts
|
||||
</Header2>
|
||||
<LinkButton
|
||||
to={closeHref}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<fetcher.Form method="post" action={formAction} {...getFormProps(form)} className="contents">
|
||||
<div className="flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<Fieldset className="flex flex-col gap-4 p-4">
|
||||
<div className="flex flex-col">
|
||||
<Header3>Receive alerts when</Header3>
|
||||
<UnorderedList variant="small/dimmed" className="mt-1">
|
||||
<li>An error is seen for the first time</li>
|
||||
<li>A resolved error re-occurs</li>
|
||||
<li>An ignored error re-occurs based on settings you configured</li>
|
||||
</UnorderedList>
|
||||
</div>
|
||||
|
||||
{/* Email section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Email</Header3>
|
||||
{emailAlertsEnabled ? (
|
||||
<InputGroup>
|
||||
{emailFields.map((emailField, index) => (
|
||||
<Fragment key={emailField.key}>
|
||||
<Input
|
||||
{...getInputProps(emailField, { type: "email" })}
|
||||
placeholder={index === 0 ? "Enter an email address" : "Add another email"}
|
||||
icon={EnvelopeIcon}
|
||||
onChange={(e) => {
|
||||
emailFieldValues.current[index] = e.target.value;
|
||||
if (
|
||||
emailFields.length === emailFieldValues.current.length &&
|
||||
emailFieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
form.insert({ name: emails.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormError id={emailField.errorId}>{emailField.errors}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
</InputGroup>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Email integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Slack section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Slack</Header3>
|
||||
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
<>
|
||||
<Select
|
||||
name={slackChannel.name}
|
||||
placeholder={<span className="text-text-dimmed">Select a Slack channel</span>}
|
||||
heading="Filter channels…"
|
||||
value={selectedSlackChannelValue ?? ""}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
setValue={(value) => {
|
||||
typeof value === "string" && setSelectedSlackChannelValue(value);
|
||||
}}
|
||||
filter={(channel, search) =>
|
||||
channel.name?.toLowerCase().includes(search.toLowerCase()) ?? false
|
||||
}
|
||||
text={(value) => {
|
||||
const channel = slack.channels.find((s) => value === `${s.id}/${s.name}`);
|
||||
if (!channel) return;
|
||||
return (
|
||||
<span className="text-text-bright">
|
||||
<SlackChannelTitle {...channel} />
|
||||
</span>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
<SelectItem
|
||||
value=""
|
||||
className="border-b border-grid-bright text-text-dimmed"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<XMarkIcon className="size-4" />
|
||||
<span>No channel</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
{matches?.map((channel) => (
|
||||
<SelectItem
|
||||
key={channel.id}
|
||||
value={`${channel.id}/${channel.name}`}
|
||||
className="text-text-bright"
|
||||
>
|
||||
<SlackChannelTitle {...channel} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
{selectedSlackChannel && selectedSlackChannel.is_private && (
|
||||
<Callout
|
||||
variant="warning"
|
||||
className={cn("text-sm", variantClasses.warning.textColor)}
|
||||
>
|
||||
To receive alerts in the{" "}
|
||||
<InlineCode variant="extra-small">{selectedSlackChannel.name}</InlineCode>{" "}
|
||||
channel, you need to invite the @Trigger.dev Slack Bot. Go to the channel in
|
||||
Slack and type:{" "}
|
||||
<InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>.
|
||||
</Callout>
|
||||
)}
|
||||
<Hint>
|
||||
<TextLink to={organizationSlackIntegrationPath(organization)}>
|
||||
Manage Slack connection
|
||||
</TextLink>
|
||||
</Hint>
|
||||
<input
|
||||
type="hidden"
|
||||
name={slackIntegrationId.name}
|
||||
value={slack.integrationId}
|
||||
/>
|
||||
</>
|
||||
) : slack.status === "NOT_CONFIGURED" ? (
|
||||
connectToSlackHref ? (
|
||||
<LinkButton variant="tertiary/medium" to={connectToSlackHref} fullWidth>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Callout variant="info">
|
||||
Slack is not connected. Connect Slack from the{" "}
|
||||
<span className="font-medium text-text-bright">Alerts</span> page to enable
|
||||
Slack notifications.
|
||||
</Callout>
|
||||
)
|
||||
) : slack.status === "TOKEN_REVOKED" || slack.status === "TOKEN_EXPIRED" ? (
|
||||
connectToSlackHref ? (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Callout variant="info">
|
||||
The Slack integration in your workspace has been revoked or has expired.
|
||||
Please re-connect your Slack workspace.
|
||||
</Callout>
|
||||
<LinkButton
|
||||
variant="tertiary/large"
|
||||
to={`${connectToSlackHref}?reinstall=true`}
|
||||
fullWidth
|
||||
>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
) : (
|
||||
<Callout variant="info">
|
||||
The Slack integration in your workspace has been revoked or expired. Please
|
||||
re-connect from the{" "}
|
||||
<span className="font-medium text-text-bright">Alerts</span> page.
|
||||
</Callout>
|
||||
)
|
||||
) : slack.status === "FAILED_FETCHING_CHANNELS" ? (
|
||||
<Callout variant="warning">
|
||||
Failed loading channels from Slack. Please try again later.
|
||||
</Callout>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Slack integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
{/* Webhook section */}
|
||||
<div>
|
||||
<Header3 className="mb-1">Webhook</Header3>
|
||||
<InputGroup>
|
||||
{webhookFields.map((webhookField, index) => (
|
||||
<Fragment key={webhookField.key}>
|
||||
<Input
|
||||
{...getInputProps(webhookField, { type: "url" })}
|
||||
placeholder={
|
||||
index === 0 ? "https://example.com/webhook" : "Add another webhook URL"
|
||||
}
|
||||
icon={GlobeAltIcon}
|
||||
onChange={(e) => {
|
||||
webhookFieldValues.current[index] = e.target.value;
|
||||
if (
|
||||
webhookFields.length === webhookFieldValues.current.length &&
|
||||
webhookFieldValues.current.every((v) => v !== "")
|
||||
) {
|
||||
form.insert({ name: webhooks.name });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<FormError id={webhookField.errorId}>{webhookField.errors}</FormError>
|
||||
</Fragment>
|
||||
))}
|
||||
<Hint>We'll issue POST requests to these URLs with a JSON payload.</Hint>
|
||||
</InputGroup>
|
||||
</div>
|
||||
|
||||
<FormError>{form.errors}</FormError>
|
||||
</Fieldset>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between border-t border-grid-bright px-3 py-3">
|
||||
<LinkButton variant="secondary/medium" to={closeHref}>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
isLoading={isSubmitting}
|
||||
>
|
||||
{isSubmitting ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</fetcher.Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SlackChannelTitle({ name, is_private }: { name?: string; is_private?: boolean }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{is_private ? <LockClosedIcon className="size-4" /> : <HashtagIcon className="size-4" />}
|
||||
<span>{name}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { type ErrorGroupStatus } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const styles: Record<ErrorGroupStatus, string> = {
|
||||
UNRESOLVED: "bg-error/10 text-error",
|
||||
RESOLVED: "bg-success/10 text-success",
|
||||
IGNORED: "bg-blue-500/10 text-blue-400",
|
||||
};
|
||||
|
||||
const labels: Record<ErrorGroupStatus, string> = {
|
||||
UNRESOLVED: "Unresolved",
|
||||
RESOLVED: "Resolved",
|
||||
IGNORED: "Ignored",
|
||||
};
|
||||
|
||||
export function ErrorStatusBadge({
|
||||
status,
|
||||
className,
|
||||
}: {
|
||||
status: ErrorGroupStatus;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded px-2 py-0.5 text-xs font-medium",
|
||||
styles[status],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{labels[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import {
|
||||
EnvironmentIcon,
|
||||
environmentFullTitle,
|
||||
environmentTextClassName,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { envSlugToType, type EnvSlug } from "~/v3/vercel/vercelProjectIntegrationSchema";
|
||||
|
||||
type BuildSettingsFieldsProps = {
|
||||
availableEnvSlugs: EnvSlug[];
|
||||
pullEnvVarsBeforeBuild: EnvSlug[];
|
||||
onPullEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
discoverEnvVars: EnvSlug[];
|
||||
onDiscoverEnvVarsChange: (slugs: EnvSlug[]) => void;
|
||||
atomicBuilds: EnvSlug[];
|
||||
onAtomicBuildsChange: (slugs: EnvSlug[]) => void;
|
||||
envVarsConfigLink?: string;
|
||||
/** Slugs that should be forced off and disabled, with tooltip reason. */
|
||||
disabledEnvSlugs?: Partial<Record<EnvSlug, string>>;
|
||||
autoPromote?: boolean;
|
||||
onAutoPromoteChange?: (value: boolean) => void;
|
||||
/** The currently pinned TRIGGER_VERSION on Vercel production, if any. Shown under the
|
||||
* Atomic deployments toggle so the user knows what version is set on Vercel right now. */
|
||||
currentTriggerVersion?: string | null;
|
||||
/** True when the Vercel lookup for TRIGGER_VERSION failed. We show this so the user knows
|
||||
* the pin status is unknown — distinct from "not set". */
|
||||
currentTriggerVersionFetchFailed?: boolean;
|
||||
/** Hide the section-level master toggles for "Pull env vars" and "Discover new env vars". */
|
||||
hideSectionToggles?: boolean;
|
||||
};
|
||||
|
||||
export function BuildSettingsFields({
|
||||
availableEnvSlugs,
|
||||
pullEnvVarsBeforeBuild,
|
||||
onPullEnvVarsChange,
|
||||
discoverEnvVars,
|
||||
onDiscoverEnvVarsChange,
|
||||
atomicBuilds,
|
||||
onAtomicBuildsChange,
|
||||
envVarsConfigLink,
|
||||
disabledEnvSlugs,
|
||||
autoPromote,
|
||||
onAutoPromoteChange,
|
||||
currentTriggerVersion,
|
||||
currentTriggerVersionFetchFailed,
|
||||
hideSectionToggles,
|
||||
}: BuildSettingsFieldsProps) {
|
||||
const isSlugDisabled = (slug: EnvSlug) => !!disabledEnvSlugs?.[slug];
|
||||
const enabledSlugs = availableEnvSlugs.filter((s) => !isSlugDisabled(s));
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Pull env vars before build */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Pull env vars before build</Label>
|
||||
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every((s) => pullEnvVarsBeforeBuild.includes(s))
|
||||
}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(checked ? [...enabledSlugs] : []);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
Select which environments should pull environment variables from Vercel before each
|
||||
build.{" "}
|
||||
{envVarsConfigLink && (
|
||||
<>
|
||||
<TextLink to={envVarsConfigLink}>Configure which variables to pull</TextLink>.
|
||||
</>
|
||||
)}
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${disabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={disabled ? false : pullEnvVarsBeforeBuild.includes(slug)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onPullEnvVarsChange(
|
||||
checked
|
||||
? [...pullEnvVarsBeforeBuild, slug]
|
||||
: pullEnvVarsBeforeBuild.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return <SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />;
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Discover new env vars */}
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Discover new env vars</Label>
|
||||
{!hideSectionToggles && availableEnvSlugs.length > 1 && (
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={
|
||||
enabledSlugs.length > 0 &&
|
||||
enabledSlugs.every(
|
||||
(s) => discoverEnvVars.includes(s) || !pullEnvVarsBeforeBuild.includes(s)
|
||||
) &&
|
||||
enabledSlugs.some((s) => discoverEnvVars.includes(s))
|
||||
}
|
||||
disabled={!enabledSlugs.some((s) => pullEnvVarsBeforeBuild.includes(s))}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked ? enabledSlugs.filter((s) => pullEnvVarsBeforeBuild.includes(s)) : []
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
Select which environments should automatically discover and create new environment
|
||||
variables from Vercel during builds.
|
||||
</Hint>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 rounded border bg-background-bright p-3">
|
||||
{availableEnvSlugs.map((slug) => {
|
||||
const envType = envSlugToType(slug);
|
||||
const disabled = isSlugDisabled(slug);
|
||||
const disabledReason = disabledEnvSlugs?.[slug];
|
||||
const isPullDisabled = !pullEnvVarsBeforeBuild.includes(slug);
|
||||
const row = (
|
||||
<div
|
||||
key={slug}
|
||||
className={`flex items-center justify-between ${disabled || isPullDisabled ? "opacity-50" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<EnvironmentIcon environment={{ type: envType }} className="size-4" />
|
||||
<span className={`text-sm ${environmentTextClassName({ type: envType })}`}>
|
||||
{environmentFullTitle({ type: envType })}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={disabled ? false : discoverEnvVars.includes(slug)}
|
||||
disabled={disabled || isPullDisabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onDiscoverEnvVarsChange(
|
||||
checked
|
||||
? [...discoverEnvVars, slug]
|
||||
: discoverEnvVars.filter((s) => s !== slug)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
if (disabled && disabledReason) {
|
||||
return <SimpleTooltip key={slug} button={row} content={disabledReason} side="left" />;
|
||||
}
|
||||
return row;
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Atomic deployments */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Atomic deployments</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={atomicBuilds.includes("prod")}
|
||||
onCheckedChange={(checked) => {
|
||||
onAtomicBuildsChange(checked ? ["prod"] : []);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
When enabled, production deployments wait for Vercel deployment to complete before
|
||||
promoting the Trigger.dev deployment. This will disable the "Auto-assign Custom Production
|
||||
Domains" option in your Vercel project settings to perform staged deployments.{" "}
|
||||
<TextLink
|
||||
href="https://trigger.dev/docs/vercel-integration#atomic-deployments"
|
||||
target="_blank"
|
||||
>
|
||||
Learn more
|
||||
</TextLink>
|
||||
.
|
||||
</Hint>
|
||||
{currentTriggerVersion && (
|
||||
<Hint className="pr-6">
|
||||
Currently pinned to{" "}
|
||||
<span className="font-mono text-text-bright">{currentTriggerVersion}</span> in Vercel
|
||||
production.
|
||||
</Hint>
|
||||
)}
|
||||
{!currentTriggerVersion && currentTriggerVersionFetchFailed && (
|
||||
<Hint className="pr-6 text-warning">
|
||||
Couldn't read <span className="font-mono text-text-bright">TRIGGER_VERSION</span> from
|
||||
Vercel — check the Vercel dashboard to confirm the production pin.
|
||||
</Hint>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Auto promotion — only visible when atomic deployments are on */}
|
||||
{atomicBuilds.includes("prod") && onAutoPromoteChange !== undefined && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Auto promotion</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
checked={autoPromote ?? true}
|
||||
onCheckedChange={onAutoPromoteChange}
|
||||
/>
|
||||
</div>
|
||||
<Hint className="pr-6">
|
||||
When enabled, the integration automatically promotes the Vercel deployment after the
|
||||
Trigger.dev build completes. Turn off to manually promote from your Vercel dashboard —
|
||||
Trigger.dev will then promote automatically once you do.
|
||||
</Hint>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { VercelLogo } from "./VercelLogo";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
|
||||
export function VercelLink({ vercelDeploymentUrl }: { vercelDeploymentUrl: string }) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<LinkButton
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<VercelLogo className="size-3.5" />}
|
||||
iconSpacing="gap-x-1"
|
||||
to={vercelDeploymentUrl}
|
||||
className="pl-1"
|
||||
>
|
||||
Vercel
|
||||
</LinkButton>
|
||||
}
|
||||
content="View on Vercel"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export function VercelLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg viewBox="0 0 76 65" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,109 @@
|
||||
import { forwardRef } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
/** This container is used to surround the entire app, it correctly places the nav bar */
|
||||
export function AppContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid h-full w-full grid-rows-1 overflow-hidden", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainBody({ children }: { children: React.ReactNode }) {
|
||||
return <div className={cn("grid grid-rows-1 overflow-hidden")}>{children}</div>;
|
||||
}
|
||||
|
||||
/** This container should be placed around the content on a page */
|
||||
export function PageContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("grid h-full grid-rows-[auto_1fr] overflow-hidden", className)}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const PageBody = forwardRef<
|
||||
HTMLDivElement,
|
||||
{
|
||||
children: React.ReactNode;
|
||||
scrollable?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
>(function PageBody({ children, scrollable = true, className }, ref) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control"
|
||||
: "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export function MainCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
variant = "default",
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
variant?: "default" | "onboarding";
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control",
|
||||
variant === "onboarding" && "flex flex-col p-4 lg:p-0"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto max-w-xs p-1",
|
||||
variant === "onboarding" ? "m-auto lg:mx-auto lg:mb-0 lg:mt-[22vh]" : "mt-6 md:mt-[22vh]",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainHorizontallyCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control md:mt-14",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import type { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CopyableText } from "~/components/primitives/CopyableText";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import * as Property from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { PacketDisplay } from "~/components/runs/v3/PacketDisplay";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
descriptionForTaskRunStatus,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { v3RunSpanPath } from "~/utils/pathBuilder";
|
||||
import { LogLevel } from "./LogLevel";
|
||||
type LogDetailViewProps = {
|
||||
logId: string;
|
||||
// If we have the log entry from the list, we can display it immediately
|
||||
initialLog?: LogEntry;
|
||||
onClose: () => void;
|
||||
searchTerm?: string;
|
||||
};
|
||||
|
||||
type LogAttributes = Record<string, unknown> & {
|
||||
error?: {
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
function getDisplayMessage(log: {
|
||||
message: string;
|
||||
level: string;
|
||||
attributes?: LogAttributes;
|
||||
}): string {
|
||||
let message = log.message ?? "";
|
||||
if (log.level === "ERROR") {
|
||||
const maybeErrorMessage = log.attributes?.error?.message;
|
||||
if (typeof maybeErrorMessage === "string" && maybeErrorMessage.length > 0) {
|
||||
message = maybeErrorMessage;
|
||||
}
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
function formatStringJSON(str: string): string {
|
||||
return str
|
||||
.replace(/\\n/g, "\n") // Converts literal "\n" to newline
|
||||
.replace(/\\t/g, "\t"); // Converts literal "\t" to tab
|
||||
}
|
||||
|
||||
export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDetailViewProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const fetcher = useTypedFetcher<typeof logDetailLoader>();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch full log details when logId changes
|
||||
useEffect(() => {
|
||||
if (!logId) return;
|
||||
|
||||
setError(null);
|
||||
fetcher.load(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${
|
||||
environment.slug
|
||||
}/logs/${encodeURIComponent(logId)}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [organization.slug, project.slug, environment.slug, logId]);
|
||||
|
||||
// Handle fetch errors
|
||||
useEffect(() => {
|
||||
if (fetcher.data && typeof fetcher.data === "object" && "error" in fetcher.data) {
|
||||
setError(fetcher.data.error as string);
|
||||
} else if (fetcher.state === "idle" && fetcher.data === null && !initialLog) {
|
||||
setError("Failed to load log details");
|
||||
} else {
|
||||
setError(null);
|
||||
}
|
||||
}, [fetcher.data, initialLog, fetcher.state]);
|
||||
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const log = fetcher.data ?? initialLog;
|
||||
const runStatus = fetcher.data?.runStatus;
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log?.runId ?? "" },
|
||||
{ spanId: log?.spanId ?? "" }
|
||||
);
|
||||
|
||||
if (isLoading && !log) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!log) {
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2>Log Details</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Paragraph className="text-text-dimmed">{error ?? "Log not found"}</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between overflow-hidden border-b border-grid-dimmed py-2 pl-3 pr-2">
|
||||
<Header2 className="truncate">{getDisplayMessage(log)}</Header2>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
|
||||
<DetailsTab log={log} runPath={runPath} runStatus={runStatus} searchTerm={searchTerm} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailsTab({
|
||||
log,
|
||||
runPath,
|
||||
runStatus,
|
||||
searchTerm,
|
||||
}: {
|
||||
log: LogEntry & {
|
||||
attributes?: LogAttributes;
|
||||
};
|
||||
runPath: string;
|
||||
runStatus?: TaskRunStatus;
|
||||
searchTerm?: string;
|
||||
}) {
|
||||
let beautifiedAttributes: string | null = null;
|
||||
|
||||
if (log.attributes) {
|
||||
beautifiedAttributes = JSON.stringify(log.attributes, null, 2);
|
||||
beautifiedAttributes = formatStringJSON(beautifiedAttributes);
|
||||
}
|
||||
|
||||
const showAttributes = beautifiedAttributes && beautifiedAttributes !== "{}";
|
||||
|
||||
const message = getDisplayMessage(log);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Property.Table>
|
||||
<Property.Item>
|
||||
<Property.Label>Run ID</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.runId} copyValue={log.runId} asChild />
|
||||
<LinkButton
|
||||
to={runPath}
|
||||
variant="secondary/small"
|
||||
shortcut={{ key: "v" }}
|
||||
className="mt-2"
|
||||
>
|
||||
View full run
|
||||
</LinkButton>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
{runStatus && (
|
||||
<Property.Item>
|
||||
<Property.Label>Status</Property.Label>
|
||||
<Property.Value>
|
||||
<SimpleTooltip
|
||||
button={<TaskRunStatusCombo status={runStatus} />}
|
||||
content={descriptionForTaskRunStatus(runStatus)}
|
||||
disableHoverableContent
|
||||
className="mt-1"
|
||||
/>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
)}
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Task</Property.Label>
|
||||
<Property.Value>
|
||||
<CopyableText value={log.taskIdentifier} copyValue={log.taskIdentifier} asChild />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Level</Property.Label>
|
||||
<Property.Value>
|
||||
<LogLevel level={log.level} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
|
||||
<Property.Item>
|
||||
<Property.Label>Timestamp</Property.Label>
|
||||
<Property.Value>
|
||||
<DateTimeAccurate date={log.triggeredTimestamp} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
|
||||
{/* Message */}
|
||||
<div className="mb-6 mt-3">
|
||||
<PacketDisplay
|
||||
data={message}
|
||||
dataType="application/json"
|
||||
title="Message"
|
||||
searchTerm={searchTerm}
|
||||
wrap={true}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Attributes - only available in full log detail */}
|
||||
{showAttributes && beautifiedAttributes && (
|
||||
<div className="mb-6">
|
||||
<PacketDisplay
|
||||
data={beautifiedAttributes}
|
||||
dataType="application/json"
|
||||
title="Attributes"
|
||||
searchTerm={searchTerm}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { cn } from "~/utils/cn";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
|
||||
export function LogLevel({ level }: { level: LogEntry["level"] }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1 py-0.5 text-xxs font-medium uppercase tracking-wider",
|
||||
getLevelColor(level)
|
||||
)}
|
||||
>
|
||||
{level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { IconListTree } from "@tabler/icons-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "TRACE", label: "Trace", color: "text-purple-400" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-text-dimmed" },
|
||||
];
|
||||
|
||||
// In the future we might add other levels or change which are available
|
||||
function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "text-error bg-error/10 border-error/20";
|
||||
case "WARN":
|
||||
return "text-warning bg-warning/10 border-warning/20";
|
||||
case "TRACE":
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
case "DEBUG":
|
||||
return "text-text-dimmed bg-background-raised border-border-bright";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
default:
|
||||
return "text-text-dimmed bg-background-hover border-grid-bright";
|
||||
}
|
||||
}
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter() {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter />;
|
||||
}
|
||||
|
||||
return (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<IconListTree className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
className="pl-1.5"
|
||||
>
|
||||
<span className="ml-1">Level</span>
|
||||
</SelectTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({ trigger }: { trigger: ReactNode }) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels();
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
<SelectList>
|
||||
{availableLevels.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.level}
|
||||
value={item.level}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelBadgeColor(item.level)
|
||||
)}
|
||||
>
|
||||
{item.level}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedLevelFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
if (levels.length === 0 || levels.every((v) => v === "")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<IconListTree className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user