chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 13:32:57 +08:00
commit cd420f9332
4811 changed files with 884702 additions and 0 deletions
+545
View File
@@ -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>
);
}
+222
View File
@@ -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>
);
}
+206
View File
@@ -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 its had a big impact in dev iteration velocity already.",
person: "André Neves, ZBD",
},
{
quote:
"Weve 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>
);
}
+32
View File
@@ -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>
);
}
+190
View File
@@ -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>
);
}
+221
View File
@@ -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)} />
);
}
+26
View File
@@ -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 />
</>
);
}
+59
View File
@@ -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: 0360 (hue — position on the color wheel: 0=red, 120=green, 240=blue)
* s: 0100 (saturation — 0 is gray, 100 is full color)
* l: 0100 (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: 0360, s: 0100, l: 0100) 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