Compare commits
60 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb81d6c835 | |||
| 72748603ab | |||
| f96f1e91a2 | |||
| 21c8b847e5 | |||
| b39f79f0ab | |||
| 90b8797fbb | |||
| 107f2e7bd5 | |||
| b8477ea2b0 | |||
| 864498ea85 | |||
| 2e21bc1563 | |||
| 1477a2e309 | |||
| 2081cb15b8 | |||
| ff551c5cbd | |||
| 794b25a988 | |||
| 5fdb43ecd1 | |||
| 80997bcd6d | |||
| 194f336b84 | |||
| 2e02743170 | |||
| 1642fd7baf | |||
| c6449126b1 | |||
| 1ccf38e440 | |||
| 29b773cd36 | |||
| 73fb7cfeb3 | |||
| 5431638926 | |||
| 933a14b44c | |||
| 4ff07f8a94 | |||
| 0afdb4cc7c | |||
| af9957c085 | |||
| 5c4275619b | |||
| 892fd9f212 | |||
| cbe9317a86 | |||
| 6baf9e5294 | |||
| a823421324 | |||
| 0e919f56f2 | |||
| f90960fc13 | |||
| 2306217697 | |||
| cfe4b5409a | |||
| 223768a025 | |||
| 1fd26ff98b | |||
| 8d34c63c2d | |||
| ff04c986a1 | |||
| 7d6430dc46 | |||
| b289aa31da | |||
| 0508de6258 | |||
| 56a063e586 | |||
| d490bc5cbf | |||
| 6d9dfbc75d | |||
| 6406924b02 | |||
| 2156e15266 | |||
| 49b4d47726 | |||
| 2e4f5d893c | |||
| 203e002087 | |||
| 1670c4c41d | |||
| 9d6c424236 | |||
| cffc88aeac | |||
| ae13757703 | |||
| 0ee39e6101 | |||
| bce398b892 | |||
| e667028d4a | |||
| ae99807047 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue with scoped packages in additionalPackages option
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/shopify": patch
|
||||
---
|
||||
|
||||
improved error messages when a shopify webhook fails to register
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Remove "log" Log Level, unify log and info messages under the "info" log level
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Strip out server-only package from worker builds
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Better handle issues with resolving dependency versions during deploy
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add runs.retrieve management API method to get info about a run by run ID
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Increased the timeout when canceling a checkpoint to 31s (to match the timeout on the server)
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Adding some additional telemetry during deploy to help debug issues
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add the "log" level back in as an alias to "info"
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Better handle uncaught exceptions
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add configure function to be able to configure the SDK manually
|
||||
@@ -44,38 +44,49 @@
|
||||
"@trigger.dev/yalt": "2.3.18"
|
||||
},
|
||||
"changesets": [
|
||||
"afraid-sheep-joke",
|
||||
"angry-eagles-trade",
|
||||
"beige-pens-dance",
|
||||
"big-tomatoes-deliver",
|
||||
"blue-pumas-whisper",
|
||||
"breezy-gorillas-mate",
|
||||
"chilled-hornets-move",
|
||||
"clean-pianos-listen",
|
||||
"clever-apes-collect",
|
||||
"clever-carrots-travel",
|
||||
"clever-donkeys-hunt",
|
||||
"cool-glasses-bake",
|
||||
"cuddly-feet-approve",
|
||||
"dry-walls-check",
|
||||
"dull-mangos-press",
|
||||
"eight-pumas-float",
|
||||
"eleven-paws-join",
|
||||
"famous-boats-tease",
|
||||
"few-students-share",
|
||||
"funny-swans-destroy",
|
||||
"gorgeous-gorillas-compete",
|
||||
"green-bags-wink",
|
||||
"khaki-apricots-design",
|
||||
"khaki-poems-lay",
|
||||
"late-icons-lie",
|
||||
"late-steaks-behave",
|
||||
"lazy-files-lay",
|
||||
"lemon-jobs-repair",
|
||||
"light-bulldogs-press",
|
||||
"light-dragons-complain",
|
||||
"little-crabs-cross",
|
||||
"long-fireants-search",
|
||||
"loud-actors-remember",
|
||||
"lovely-drinks-flash",
|
||||
"many-ligers-pump",
|
||||
"mighty-camels-joke",
|
||||
"mighty-flowers-train",
|
||||
"nasty-jars-pump",
|
||||
"new-rivers-tell",
|
||||
"nice-bulldogs-turn",
|
||||
"ninety-pets-travel",
|
||||
"odd-poets-own",
|
||||
"pink-pumas-rhyme",
|
||||
"polite-ducks-switch",
|
||||
"polite-rockets-matter",
|
||||
"poor-flowers-cross",
|
||||
@@ -106,6 +117,7 @@
|
||||
"tame-guests-know",
|
||||
"tender-moose-tell",
|
||||
"tender-oranges-rhyme",
|
||||
"thin-parents-heal",
|
||||
"tidy-balloons-suffer",
|
||||
"tidy-dryers-sleep",
|
||||
"tiny-doors-type",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Ensure @trigger.dev/sdk and @trigger.dev/core are always in the list of deployed dependencies
|
||||
+4
-4
@@ -12,10 +12,10 @@ APP_ENV=development
|
||||
APP_ORIGIN=http://localhost:3030
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis is used for concurrency control
|
||||
# REDIS_HOST="localhost"
|
||||
# REDIS_PORT="6379"
|
||||
# REDIS_TLS_DISABLED="true"
|
||||
# Redis is used for the v3 queuing and v2 concurrency control
|
||||
REDIS_HOST="localhost"
|
||||
REDIS_PORT="6379"
|
||||
REDIS_TLS_DISABLED="true"
|
||||
|
||||
# OPTIONAL VARIABLES
|
||||
# This is used for validating emails that are allowed to log in. Every email that do not match this regex will be rejected.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<img alt="Trigger.dev logo" src="https://imagedelivery.net/3TbraffuDZ4aEf8KWOmI_w/a45d1fa2-0ae8-4a39-4409-f4f934bfae00/public">
|
||||
</picture>
|
||||
|
||||
### The open source background jobs framework
|
||||
### The open source background jobs platform
|
||||
|
||||
[Discord](https://trigger.dev/discord) | [Website](https://trigger.dev) | [Issues](https://github.com/triggerdotdev/trigger.dev/issues) | [Docs](https://trigger.dev/docs)
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
</div>
|
||||
|
||||
# About Trigger.dev
|
||||
> The Trigger.dev v3 developer preview is now open. For more information and to get early access, check out our [developer preview launch post](https://trigger.dev/blog/v3-developer-preview-launch/).
|
||||
|
||||
## About Trigger.dev
|
||||
|
||||
Create long-running jobs directly in your codebase with features like API integrations, webhooks, scheduling and delays.
|
||||
|
||||
@@ -48,30 +50,6 @@ View every Task in every Run so you can tell exactly what happened.
|
||||
|
||||
Easily integrate with hundreds of third-party APIs – including your own. Use API keys (which never leave your server) or let us handle OAuth for you. Install our integration packages and easily subscribe to webhooks and perform common tasks, or you can easily use your existing favorite Node.JS SDKs and get resumability and idempotency through our `runTask` function.
|
||||
|
||||
## Our progress
|
||||
|
||||
We’re building the most comprehensive and easy-to-use background jobs framework for developers.
|
||||
|
||||
Click the links to join the discussions about our upcoming features.
|
||||
|
||||
| Feature | What it does | Status |
|
||||
| ------------------------------------------------------------------------------------ | --------------------------------------------------- | ------ |
|
||||
| Integration kit | Official Trigger.dev integrations or build your own | ✅ |
|
||||
| Self-hosting | Host the platform yourself | ✅ |
|
||||
| Cloud | Just write code, no deployment required | ✅ |
|
||||
| Dashboard | View every Task in every Run | ✅ |
|
||||
| Serverless | Long-running Jobs on your serverless backend | ✅ |
|
||||
| React hooks | Easily update your UI with Job progress | ✅ |
|
||||
| React frameworks | Support for Remix, Astro, RedwoodJS & more | ✅ |
|
||||
| [Background tasks](https://github.com/triggerdotdev/trigger.dev/discussions/400) | Offload long or intense Tasks to our infrastructure | 🛠️ |
|
||||
| [Long-running servers](https://github.com/triggerdotdev/trigger.dev/discussions/430) | Run Jobs on your long-running backend | 🛠️ |
|
||||
| Polling Triggers | Subscribe to changes without webhooks | 🕝 |
|
||||
| Vercel integration | Easy deploy and preview environment support | 🕝 |
|
||||
| Streaming | Receive data from your Jobs in realtime | 🕝 |
|
||||
| 100+ integrations | Comprehensive support for popular APIs | 🕝 |
|
||||
| [Trigger.dev Connect](https://github.com/triggerdotdev/trigger.dev/discussions/441) | Use integrations signed in as your users | 🕝 |
|
||||
| File IO | Create Tasks that have file outputs | 🕝 |
|
||||
|
||||
# Getting started
|
||||
|
||||
Visit our docs [here](https://trigger.dev/docs).
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import { Machine, PostStartCauses, PreStopCauses, EnvironmentType } from "@trigger.dev/core/v3";
|
||||
import { randomUUID } from "crypto";
|
||||
import { TaskMonitor } from "./taskMonitor";
|
||||
import { PodCleaner } from "./podCleaner";
|
||||
|
||||
const RUNTIME_ENV = process.env.KUBERNETES_PORT ? "kubernetes" : "local";
|
||||
const NODE_NAME = process.env.NODE_NAME || "local";
|
||||
@@ -80,8 +81,8 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
],
|
||||
resources: {
|
||||
limits: {
|
||||
cpu: "250m",
|
||||
memory: "0.5G",
|
||||
cpu: "1",
|
||||
memory: "1G",
|
||||
"ephemeral-storage": "2Gi",
|
||||
},
|
||||
},
|
||||
@@ -543,3 +544,11 @@ const taskMonitor = new TaskMonitor({
|
||||
});
|
||||
|
||||
taskMonitor.start();
|
||||
|
||||
const podCleaner = new PodCleaner({
|
||||
runtimeEnv: RUNTIME_ENV,
|
||||
namespace: "default",
|
||||
intervalInSeconds: 300,
|
||||
});
|
||||
|
||||
podCleaner.start();
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
import * as k8s from "@kubernetes/client-node";
|
||||
import { SimpleLogger } from "@trigger.dev/core-apps";
|
||||
|
||||
type PodCleanerOptions = {
|
||||
runtimeEnv: "local" | "kubernetes";
|
||||
namespace?: string;
|
||||
intervalInSeconds?: number;
|
||||
};
|
||||
|
||||
export class PodCleaner {
|
||||
private enabled = false;
|
||||
private namespace = "default";
|
||||
private intervalInSeconds = 300;
|
||||
|
||||
private logger = new SimpleLogger("[PodCleaner]");
|
||||
private k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
};
|
||||
|
||||
constructor(private opts: PodCleanerOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
if (opts.intervalInSeconds) {
|
||||
this.intervalInSeconds = opts.intervalInSeconds;
|
||||
}
|
||||
|
||||
this.k8sClient = this.#createK8sClient();
|
||||
}
|
||||
|
||||
#createK8sClient() {
|
||||
const kubeConfig = new k8s.KubeConfig();
|
||||
|
||||
if (this.opts.runtimeEnv === "local") {
|
||||
kubeConfig.loadFromDefault();
|
||||
} else if (this.opts.runtimeEnv === "kubernetes") {
|
||||
kubeConfig.loadFromCluster();
|
||||
} else {
|
||||
throw new Error(`Unsupported runtime environment: ${this.opts.runtimeEnv}`);
|
||||
}
|
||||
|
||||
return {
|
||||
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
|
||||
kubeConfig: kubeConfig,
|
||||
};
|
||||
}
|
||||
|
||||
#isRecord(candidate: unknown): candidate is Record<string, unknown> {
|
||||
if (typeof candidate !== "object" || candidate === null) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
#logK8sError(err: unknown, debugOnly = false) {
|
||||
if (debugOnly) {
|
||||
this.logger.debug("K8s API Error", err);
|
||||
} else {
|
||||
this.logger.error("K8s API Error", err);
|
||||
}
|
||||
}
|
||||
|
||||
#handleK8sError(err: unknown) {
|
||||
if (!this.#isRecord(err) || !this.#isRecord(err.body)) {
|
||||
this.#logK8sError(err);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError(err, true);
|
||||
|
||||
if (typeof err.body.message === "string") {
|
||||
this.#logK8sError({ message: err.body.message });
|
||||
return;
|
||||
}
|
||||
|
||||
this.#logK8sError({ body: err.body });
|
||||
}
|
||||
|
||||
async #deletePods(opts: {
|
||||
namespace: string;
|
||||
dryRun?: boolean;
|
||||
fieldSelector?: string;
|
||||
labelSelector?: string;
|
||||
}) {
|
||||
return await this.k8sClient.core
|
||||
.deleteCollectionNamespacedPod(
|
||||
opts.namespace,
|
||||
undefined, // pretty
|
||||
undefined, // continue
|
||||
opts.dryRun ? "All" : undefined,
|
||||
opts.fieldSelector,
|
||||
undefined, // gracePeriodSeconds
|
||||
opts.labelSelector
|
||||
)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
}
|
||||
|
||||
async #deleteCompletedRuns() {
|
||||
this.logger.log("Deleting completed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Succeeded",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting completed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting completed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteFailedRuns() {
|
||||
this.logger.log("Deleting failed runs");
|
||||
|
||||
const start = Date.now();
|
||||
|
||||
const result = await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=Failed",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
|
||||
const elapsedMs = Date.now() - start;
|
||||
|
||||
if (!result) {
|
||||
this.logger.log("Deleting failed runs: No delete result", { elapsedMs });
|
||||
return;
|
||||
}
|
||||
|
||||
const total = (result.response as any)?.body?.items?.length ?? 0;
|
||||
|
||||
this.logger.log("Deleting failed runs: Done", { total, elapsedMs });
|
||||
}
|
||||
|
||||
async #deleteUnrecoverableRuns() {
|
||||
await this.#deletePods({
|
||||
namespace: this.namespace,
|
||||
fieldSelector: "status.phase=?",
|
||||
labelSelector: "app=task-run",
|
||||
});
|
||||
}
|
||||
|
||||
async start() {
|
||||
this.enabled = true;
|
||||
this.logger.log("Starting");
|
||||
|
||||
const completedInterval = setInterval(async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(completedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteCompletedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
}, this.intervalInSeconds * 1000);
|
||||
|
||||
const failedInterval = setInterval(
|
||||
async () => {
|
||||
if (!this.enabled) {
|
||||
clearInterval(failedInterval);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.#deleteFailedRuns();
|
||||
} catch (error) {
|
||||
this.logger.error("Error deleting completed runs", error);
|
||||
}
|
||||
},
|
||||
// Use a longer interval for failed runs. This is only a backup in case the task monitor fails.
|
||||
2 * this.intervalInSeconds * 1000
|
||||
);
|
||||
|
||||
// this.#launchTests();
|
||||
}
|
||||
|
||||
async stop() {
|
||||
if (!this.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.enabled = false;
|
||||
this.logger.log("Shutting down..");
|
||||
}
|
||||
|
||||
async #launchTests() {
|
||||
const createPod = async (
|
||||
container: k8s.V1Container,
|
||||
name: string,
|
||||
labels?: Record<string, string>
|
||||
) => {
|
||||
this.logger.log("Creating pod:", name);
|
||||
|
||||
const pod = {
|
||||
metadata: {
|
||||
name,
|
||||
labels,
|
||||
},
|
||||
spec: {
|
||||
restartPolicy: "Never",
|
||||
automountServiceAccountToken: false,
|
||||
terminationGracePeriodSeconds: 1,
|
||||
containers: [container],
|
||||
},
|
||||
} satisfies k8s.V1Pod;
|
||||
|
||||
await this.k8sClient.core
|
||||
.createNamespacedPod(this.namespace, pod)
|
||||
.catch(this.#handleK8sError.bind(this));
|
||||
};
|
||||
|
||||
const createIdlePod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "sleep infinity"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createCompletedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "true"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
const createFailedPod = async (name: string, labels?: Record<string, string>) => {
|
||||
const container = {
|
||||
name,
|
||||
image: "docker.io/library/busybox",
|
||||
command: ["sh"],
|
||||
args: ["-c", "false"],
|
||||
} satisfies k8s.V1Container;
|
||||
|
||||
await createPod(container, name, labels);
|
||||
};
|
||||
|
||||
await createIdlePod("test-idle-1", { app: "task-run" });
|
||||
await createFailedPod("test-failed-1", { app: "task-run" });
|
||||
await createCompletedPod("test-completed-1", { app: "task-run" });
|
||||
}
|
||||
}
|
||||
@@ -30,10 +30,12 @@ type TaskMonitorOptions = {
|
||||
|
||||
export class TaskMonitor {
|
||||
#enabled = false;
|
||||
|
||||
#logger = new SimpleLogger("[TaskMonitor]");
|
||||
#taskInformer: ReturnType<typeof k8s.makeInformer<k8s.V1Pod>>;
|
||||
#processedPods = new Map<string, number>();
|
||||
#queue = new PQueue({ concurrency: 10 });
|
||||
|
||||
#k8sClient: {
|
||||
core: k8s.CoreV1Api;
|
||||
kubeConfig: k8s.KubeConfig;
|
||||
@@ -44,6 +46,10 @@ export class TaskMonitor {
|
||||
private labelSelector = "app in (task-index, task-run)";
|
||||
|
||||
constructor(private opts: TaskMonitorOptions) {
|
||||
if (opts.namespace) {
|
||||
this.namespace = opts.namespace;
|
||||
}
|
||||
|
||||
this.#k8sClient = this.#createK8sClient();
|
||||
|
||||
this.#taskInformer = this.#createTaskInformer();
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ChevronRightIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, useActionData, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { DiscordIcon, GitHubLightIcon } from "@trigger.dev/companyicons";
|
||||
import { ActivityIcon } from "lucide-react";
|
||||
import { ReactNode, useState } from "react";
|
||||
import { FeedbackType, feedbackTypeLabel, schema } from "~/routes/resources.feedback";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
import { Button, LinkButton } from "./primitives/Buttons";
|
||||
import { Fieldset } from "./primitives/Fieldset";
|
||||
import { FormButtons } from "./primitives/FormButtons";
|
||||
@@ -13,20 +17,9 @@ import { Header1, Header2 } from "./primitives/Headers";
|
||||
import { InputGroup } from "./primitives/InputGroup";
|
||||
import { Label } from "./primitives/Label";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./primitives/Select";
|
||||
import { Select, SelectItem } from "./primitives/Select";
|
||||
import { Sheet, SheetBody, SheetContent, SheetTrigger } from "./primitives/Sheet";
|
||||
import { TextArea } from "./primitives/TextArea";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ActivityIcon, HeartPulseIcon } from "lucide-react";
|
||||
import { docsPath } from "~/utils/pathBuilder";
|
||||
|
||||
type FeedbackProps = {
|
||||
button: ReactNode;
|
||||
@@ -78,20 +71,20 @@ export function Feedback({ button, defaultValue = "bug" }: FeedbackProps) {
|
||||
<Fieldset className="max-w-full gap-y-3">
|
||||
<input value={location.pathname} {...conform.input(path, { type: "hidden" })} />
|
||||
<InputGroup className="max-w-full">
|
||||
<SelectGroup>
|
||||
<Select {...conform.input(feedbackType)} defaultValue={defaultValue}>
|
||||
<SelectTrigger size="medium" width="full">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{Object.entries(feedbackTypeLabel).map(([key, value]) => (
|
||||
<SelectItem key={key} value={key}>
|
||||
{value}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Select
|
||||
{...conform.select(feedbackType)}
|
||||
variant="tertiary/medium"
|
||||
defaultValue={defaultValue}
|
||||
placeholder="Select type"
|
||||
text={(value) => feedbackTypeLabel[value]}
|
||||
dropdownIcon
|
||||
>
|
||||
{Object.entries(feedbackTypeLabel).map(([name, title]) => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{title}
|
||||
</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
<FormError id={feedbackType.errorId}>{feedbackType.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup className="max-w-full">
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useIsImpersonating } from "~/hooks/useOrganizations";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
|
||||
export function AdminDebugTooltip({ children }: { children: React.ReactNode }) {
|
||||
const hasAdminAccess = useHasAdminAccess();
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
if (!hasAdminAccess) {
|
||||
if (!hasAdminAccess && !isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -290,6 +290,7 @@ export function TierPro({
|
||||
options={concurrencyTiers.map((c) => ({ label: `Up to ${c.upto}`, value: c.code }))}
|
||||
fullWidth
|
||||
value={concurrentBracketCode}
|
||||
variant="primary"
|
||||
onChange={(v) => setConcurrentBracketCode(v)}
|
||||
/>
|
||||
<div className="py-6">
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
const variants = {
|
||||
@@ -33,6 +35,74 @@ export function EnvironmentLabel({
|
||||
);
|
||||
}
|
||||
|
||||
type EnvironmentWithUsername = Environment & { userName?: string };
|
||||
|
||||
export function EnvironmentLabels({
|
||||
environments,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environments: EnvironmentWithUsername[];
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
const devEnvironments = sortEnvironments(
|
||||
environments.filter((env) => env.type === "DEVELOPMENT")
|
||||
);
|
||||
const firstDevEnvironment = devEnvironments[0];
|
||||
const otherDevEnvironments = devEnvironments.slice(1);
|
||||
const otherEnvironments = environments.filter((env) => env.type !== "DEVELOPMENT");
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-baseline gap-2", className)}>
|
||||
{firstDevEnvironment && (
|
||||
<EnvironmentLabel
|
||||
environment={firstDevEnvironment}
|
||||
userName={firstDevEnvironment.userName}
|
||||
size={size}
|
||||
/>
|
||||
)}
|
||||
{otherDevEnvironments.length > 0 ? (
|
||||
<SimpleTooltip
|
||||
disableHoverableContent
|
||||
button={
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName({ type: "DEVELOPMENT" }),
|
||||
environmentTextClassName({ type: "DEVELOPMENT" }),
|
||||
variants[size]
|
||||
)}
|
||||
>
|
||||
+{otherDevEnvironments.length}
|
||||
</span>
|
||||
}
|
||||
content={
|
||||
<div className="flex gap-1 py-1">
|
||||
{otherDevEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{otherEnvironments.map((environment, index) => (
|
||||
<EnvironmentLabel
|
||||
key={index}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size={size}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function environmentTitle(environment: Environment, username?: string) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
} from "../primitives/SimpleSelect";
|
||||
import { EventListSearchSchema } from "./EventStatuses";
|
||||
import { environmentKeys, FilterableEnvironment } from "~/components/runs/RunStatuses";
|
||||
import { TimeFrameFilter } from "../runs/TimeFrameFilter";
|
||||
|
||||
@@ -41,7 +41,7 @@ export function PageBody({
|
||||
<div
|
||||
className={cn(
|
||||
scrollable
|
||||
? "overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
? "overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
: "overflow-hidden",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
ArrowRightIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ClockIcon,
|
||||
CursorArrowRaysIcon,
|
||||
@@ -45,6 +46,7 @@ import {
|
||||
v3ApiKeysPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
v3ProjectSettingsPath,
|
||||
v3RunsPath,
|
||||
@@ -601,6 +603,13 @@ function V3ProjectSideMenu({
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
iconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon="settings"
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
"tertiary/small": {
|
||||
box: "h-6 bg-tertiary rounded pl-1.5 gap-1.5 text-xs divide-x divide-black/15 group-hover:bg-charcoal-600",
|
||||
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
|
||||
},
|
||||
"minimal/small": {
|
||||
box: "h-6 hover:bg-tertiary rounded pl-1.5 gap-1.5 text-xs",
|
||||
clear: "size-6 text-text-dimmed hover:text-text-bright transition-colors",
|
||||
},
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variants;
|
||||
|
||||
type AppliedFilterProps = {
|
||||
label: ReactNode;
|
||||
value: ReactNode;
|
||||
removable?: boolean;
|
||||
onRemove?: () => void;
|
||||
variant?: Variant;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AppliedFilter({
|
||||
label,
|
||||
value,
|
||||
removable = true,
|
||||
onRemove,
|
||||
variant = "tertiary/small",
|
||||
className,
|
||||
}: AppliedFilterProps) {
|
||||
const variantClassName = variants[variant];
|
||||
return (
|
||||
<div className={cn("flex items-center transition", variantClassName.box, className)}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="text-text-dimmed">
|
||||
<span>{label}</span>:
|
||||
</div>
|
||||
<div className="text-text-bright">
|
||||
<div>{value}</div>
|
||||
</div>
|
||||
</div>
|
||||
{removable && (
|
||||
<button
|
||||
className={cn("group flex size-6 items-center justify-center", variantClassName.clear)}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<XMarkIcon className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -48,11 +48,11 @@ const theme = {
|
||||
"border-black/40 text-charcoal-900 group-hover:border-black/60 group-hover:text-charcoal-900",
|
||||
},
|
||||
secondary: {
|
||||
textColor: "text-primary group-hover:text-apple-200 transition group-disabled:text-primary",
|
||||
textColor: "text-secondary group-hover:text-secondary transition group-disabled:text-secondary",
|
||||
button:
|
||||
"bg-transparent border border-primary group-hover:border-apple-200 group-hover:bg-apple-950 group-disabled:opacity-30 group-disabled:border-primary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
"bg-transparent border border-secondary group-hover:border-secondary group-hover:bg-secondary/10 group-disabled:opacity-30 group-disabled:border-secondary group-disabled:bg-transparent group-disabled:pointer-events-none",
|
||||
shortcut:
|
||||
"border-primary/30 text-apple-200 group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
"border-secondary/30 text-secondary group-hover:text-text-bright/80 group-hover:border-dimmed/60",
|
||||
},
|
||||
tertiary: {
|
||||
textColor: "text-text-bright transition group-disabled:text-text-dimmed/80",
|
||||
|
||||
@@ -49,12 +49,7 @@ export function DetailCell({
|
||||
const variation = variations[variant];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3 transition hover:bg-charcoal-900",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className={cn("group flex h-11 w-full items-center gap-3 rounded-md p-1 pr-3", className)}>
|
||||
<IconInBox
|
||||
icon={leadingIcon}
|
||||
className={cn("flex-none transition group-hover:border-charcoal-750", leadingIconClassName)}
|
||||
@@ -62,20 +57,14 @@ export function DetailCell({
|
||||
<div className="flex flex-1 flex-col">
|
||||
<Paragraph
|
||||
variant={variation.label.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left transition group-hover:text-text-bright",
|
||||
variation.label.className
|
||||
)}
|
||||
className={cn("flex-1 text-left", variation.label.className)}
|
||||
>
|
||||
{label}
|
||||
</Paragraph>
|
||||
{description && (
|
||||
<Paragraph
|
||||
variant={variation.description.variant}
|
||||
className={cn(
|
||||
"flex-1 text-left text-text-dimmed transition group-hover:text-text-bright",
|
||||
variation.description.className
|
||||
)}
|
||||
className={cn("flex-1 text-left text-text-dimmed", variation.description.className)}
|
||||
>
|
||||
{description}
|
||||
</Paragraph>
|
||||
|
||||
@@ -2,6 +2,19 @@ import { RadioGroup } from "@headlessui/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const variants = {
|
||||
primary: {
|
||||
base: "bg-charcoal-700",
|
||||
active: "text-text-bright hover:bg-charcoal-750/50",
|
||||
},
|
||||
secondary: {
|
||||
base: "bg-charcoal-700/50",
|
||||
active: "text-text-bright bg-charcoal-700 rounded-[2px] border border-charcoal-600/50",
|
||||
},
|
||||
};
|
||||
|
||||
type Variants = keyof typeof variants;
|
||||
|
||||
type Options = {
|
||||
label: string;
|
||||
value: string;
|
||||
@@ -12,6 +25,7 @@ type SegmentedControlProps = {
|
||||
value?: string;
|
||||
defaultValue?: string;
|
||||
options: Options[];
|
||||
variant?: Variants;
|
||||
fullWidth?: boolean;
|
||||
onChange?: (value: string) => void;
|
||||
};
|
||||
@@ -21,11 +35,18 @@ export default function SegmentedControl({
|
||||
value,
|
||||
defaultValue,
|
||||
options,
|
||||
variant = "secondary",
|
||||
fullWidth,
|
||||
onChange,
|
||||
}: SegmentedControlProps) {
|
||||
return (
|
||||
<div className={cn("flex h-10 rounded bg-charcoal-700", fullWidth ? "w-full" : "w-fit")}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-10 rounded text-text-bright",
|
||||
variants[variant].base,
|
||||
fullWidth ? "w-full" : "w-fit"
|
||||
)}
|
||||
>
|
||||
<RadioGroup
|
||||
value={value}
|
||||
defaultValue={defaultValue ?? options[0].value}
|
||||
@@ -46,11 +67,11 @@ export default function SegmentedControl({
|
||||
cn(
|
||||
"relative flex h-full grow cursor-pointer text-center font-normal focus:outline-none",
|
||||
active
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-primary focus-visible:ring-opacity-60"
|
||||
? "ring-offset-2 focus-visible:ring focus-visible:ring-secondary focus-visible:ring-opacity-60"
|
||||
: "",
|
||||
checked
|
||||
? "text-text-bright"
|
||||
: "rounded-[2px] text-text-dimmed transition hover:bg-charcoal-750/50 hover:text-text-bright"
|
||||
? variants[variant].active
|
||||
: "text-text-dimmed transition hover:text-text-bright"
|
||||
)
|
||||
}
|
||||
>
|
||||
@@ -60,12 +81,12 @@ export default function SegmentedControl({
|
||||
<div className="z-10 flex h-full w-full items-center justify-center text-sm">
|
||||
<RadioGroup.Label as="p">{option.label}</RadioGroup.Label>
|
||||
</div>
|
||||
{checked && (
|
||||
{checked && variant === "primary" && (
|
||||
<motion.div
|
||||
layoutId={`segmented-control-${name}`}
|
||||
transition={{ duration: 0.4, type: "spring" }}
|
||||
className="absolute inset-0 rounded-[2px] shadow-md outline outline-3 outline-primary"
|
||||
></motion.div>
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -1,143 +1,624 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { SelectProps as AriaSelectProps } from "@ariakit/react";
|
||||
import { SelectValue } from "@ariakit/react-core/select/select-value";
|
||||
import { Link } from "@remix-run/react";
|
||||
import * as React from "react";
|
||||
import { Fragment, useMemo, useState } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
|
||||
const sizes = {
|
||||
"secondary/small":
|
||||
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
|
||||
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
|
||||
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
|
||||
small: {
|
||||
button: "h-6 rounded text-xs px-2 ",
|
||||
},
|
||||
medium: {
|
||||
button: "h-8 rounded text-xs px-3 text-sm",
|
||||
},
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
size?: keyof typeof sizes;
|
||||
width?: "content" | "full";
|
||||
const style = {
|
||||
tertiary: {
|
||||
button:
|
||||
"bg-tertiary focus-within:ring-charcoal-500 border border-tertiary hover:text-text-bright hover:border-charcoal-600",
|
||||
},
|
||||
minimal: {
|
||||
button:
|
||||
"bg-transparent focus-within:ring-charcoal-500 hover:bg-tertiary disabled:bg-transparent disabled:pointer-events-none",
|
||||
},
|
||||
};
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
|
||||
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
|
||||
const sizeClassName = sizes[size];
|
||||
const variants = {
|
||||
"tertiary/small": {
|
||||
button: cn(sizes.small.button, style.tertiary.button),
|
||||
},
|
||||
"tertiary/medium": {
|
||||
button: cn(sizes.medium.button, style.tertiary.button),
|
||||
},
|
||||
"minimal/small": {
|
||||
button: cn(sizes.small.button, style.minimal.button),
|
||||
},
|
||||
"minimal/medium": {
|
||||
button: cn(sizes.medium.button, style.minimal.button),
|
||||
},
|
||||
};
|
||||
|
||||
type Variant = keyof typeof variants;
|
||||
|
||||
type Section<TItem> = {
|
||||
type: "section";
|
||||
title?: string;
|
||||
items: TItem[];
|
||||
};
|
||||
|
||||
function isSection<TItem>(data: TItem[] | Section<TItem>[]): data is Section<TItem>[] {
|
||||
const firstItem = data[0];
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
width === "full" ? "w-full" : "w-min",
|
||||
sizeClassName,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
(firstItem as Section<TItem>).type === "section" &&
|
||||
(firstItem as Section<TItem>).items !== undefined &&
|
||||
Array.isArray((firstItem as Section<TItem>).items)
|
||||
);
|
||||
});
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
}
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
type ItemFromSection<TItemOrSection> = TItemOrSection extends Section<infer U> ? U : TItemOrSection;
|
||||
export interface SelectProps<TValue extends string | string[], TItem>
|
||||
extends Omit<Ariakit.SelectProps, "children"> {
|
||||
icon?: React.ReactNode;
|
||||
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
|
||||
placeholder?: React.ReactNode;
|
||||
value?: Ariakit.SelectProviderProps<TValue>["value"];
|
||||
setValue?: Ariakit.SelectProviderProps<TValue>["setValue"];
|
||||
defaultValue?: Ariakit.SelectProviderProps<TValue>["defaultValue"];
|
||||
label?: string | Ariakit.SelectLabelProps["render"];
|
||||
heading?: string;
|
||||
showHeading?: boolean;
|
||||
items?: TItem[] | Section<TItem>[];
|
||||
empty?: React.ReactNode;
|
||||
filter?: (item: ItemFromSection<TItem>, search: string, title?: string) => boolean;
|
||||
children:
|
||||
| React.ReactNode
|
||||
| ((
|
||||
items: ItemFromSection<TItem>[],
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: {
|
||||
title?: string;
|
||||
startIndex: number;
|
||||
count: number;
|
||||
};
|
||||
}
|
||||
) => React.ReactNode);
|
||||
variant?: Variant;
|
||||
open?: boolean;
|
||||
setOpen?: (open: boolean) => void;
|
||||
shortcut?: ShortcutDefinition;
|
||||
allowItemShortcuts?: boolean;
|
||||
clearSearchOnSelection?: boolean;
|
||||
dropdownIcon?: boolean | React.ReactNode;
|
||||
}
|
||||
|
||||
export function Select<TValue extends string | string[], TItem>({
|
||||
children,
|
||||
icon,
|
||||
text,
|
||||
placeholder,
|
||||
value,
|
||||
setValue,
|
||||
defaultValue,
|
||||
label,
|
||||
heading,
|
||||
showHeading = false,
|
||||
items,
|
||||
filter,
|
||||
empty = null,
|
||||
variant = "tertiary/small",
|
||||
open,
|
||||
setOpen,
|
||||
shortcut,
|
||||
allowItemShortcuts = true,
|
||||
disabled,
|
||||
clearSearchOnSelection = true,
|
||||
dropdownIcon,
|
||||
...props
|
||||
}: SelectProps<TValue, TItem>) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const searchable = items !== undefined && filter !== undefined;
|
||||
|
||||
const matches = useMemo(() => {
|
||||
if (!items) return [];
|
||||
if (!searchValue || !filter) return items;
|
||||
|
||||
if (isSection(items)) {
|
||||
return items
|
||||
.map((section) => ({
|
||||
...section,
|
||||
items: section.items.filter((item) =>
|
||||
filter(item as ItemFromSection<TItem>, searchValue, section.title)
|
||||
),
|
||||
}))
|
||||
.filter((section) => section.items.length > 0);
|
||||
}
|
||||
|
||||
return items.filter((item) => filter(item as ItemFromSection<TItem>, searchValue));
|
||||
}, [searchValue, items]);
|
||||
|
||||
const enableItemShortcuts = allowItemShortcuts && matches.length === items?.length;
|
||||
|
||||
const select = (
|
||||
<SelectProvider
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
virtualFocus={searchable}
|
||||
value={value}
|
||||
setValue={(v) => {
|
||||
if (clearSearchOnSelection) {
|
||||
setSearchValue("");
|
||||
}
|
||||
|
||||
if (setValue) {
|
||||
setValue(v as any);
|
||||
}
|
||||
}}
|
||||
defaultValue={defaultValue}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"space-y-0.5 px-1 py-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
{label && <SelectLabel render={typeof label === "string" ? <div>{label}</div> : label} />}
|
||||
<SelectTrigger
|
||||
icon={icon}
|
||||
variant={variant}
|
||||
text={text}
|
||||
placeholder={placeholder}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={heading}
|
||||
disabled={disabled}
|
||||
dropdownIcon={dropdownIcon}
|
||||
{...props}
|
||||
/>
|
||||
<SelectPopover>
|
||||
{!searchable && showHeading && heading && <SelectHeading render={<>{heading}</>} />}
|
||||
{searchable && <ComboBox placeholder={heading} shortcut={shortcut} value={searchValue} />}
|
||||
|
||||
<SelectList>
|
||||
{typeof children === "function" ? (
|
||||
matches.length > 0 ? (
|
||||
isSection(matches) ? (
|
||||
<SelectGroupedRenderer
|
||||
items={matches}
|
||||
children={children}
|
||||
enableItemShortcuts={enableItemShortcuts}
|
||||
/>
|
||||
) : (
|
||||
children(matches as ItemFromSection<TItem>[], {
|
||||
shortcutsEnabled: enableItemShortcuts,
|
||||
})
|
||||
)
|
||||
) : (
|
||||
empty
|
||||
)
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
|
||||
if (searchable) {
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
React.startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
{select}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
return select;
|
||||
}
|
||||
|
||||
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
contentClassName?: string;
|
||||
};
|
||||
export interface SelectTriggerProps<TValue = any> extends AriaSelectProps {
|
||||
icon?: React.ReactNode;
|
||||
text?: React.ReactNode | ((value: TValue) => React.ReactNode);
|
||||
placeholder?: React.ReactNode;
|
||||
variant?: Variant;
|
||||
shortcut?: ShortcutDefinition;
|
||||
tooltipTitle?: string;
|
||||
dropdownIcon?: boolean | React.ReactNode;
|
||||
}
|
||||
|
||||
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
|
||||
({ className, children, contentClassName, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
export function SelectTrigger({
|
||||
icon,
|
||||
variant = "tertiary/small",
|
||||
text,
|
||||
shortcut,
|
||||
tooltipTitle,
|
||||
disabled,
|
||||
placeholder,
|
||||
dropdownIcon = false,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: SelectTriggerProps) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
},
|
||||
disabled,
|
||||
});
|
||||
|
||||
const showTooltip = tooltipTitle || shortcut;
|
||||
const variantClasses = variants[variant];
|
||||
|
||||
let content: React.ReactNode = "";
|
||||
if (children) {
|
||||
content = children;
|
||||
} else if (text !== undefined) {
|
||||
if (typeof text === "function") {
|
||||
content = <SelectValue>{(value) => <>{text(value) ?? placeholder}</>}</SelectValue>;
|
||||
} else {
|
||||
content = text;
|
||||
}
|
||||
} else {
|
||||
content = (
|
||||
<SelectValue>
|
||||
{(value) => (
|
||||
<>
|
||||
{typeof value === "string"
|
||||
? value ?? placeholder
|
||||
: value.length === 0
|
||||
? placeholder
|
||||
: value.join(", ")}
|
||||
</>
|
||||
)}
|
||||
</SelectValue>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Ariakit.TooltipProvider timeout={200}>
|
||||
<Ariakit.TooltipAnchor
|
||||
className="button"
|
||||
render={
|
||||
<Ariakit.Select
|
||||
className={cn(
|
||||
"group flex items-center gap-1 outline-offset-0 focus-within:outline-none focus-within:ring-1 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
variantClasses.button,
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex grow items-center gap-0.5">
|
||||
{icon && <div className="-ml-1 flex-none">{icon}</div>}
|
||||
<div className="truncate">{content}</div>
|
||||
</div>
|
||||
{dropdownIcon === true ? (
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 flex-none text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
) : !dropdownIcon ? null : (
|
||||
dropdownIcon
|
||||
)}
|
||||
</Ariakit.TooltipAnchor>
|
||||
{showTooltip && (
|
||||
<Ariakit.Tooltip
|
||||
disabled={shortcut === undefined}
|
||||
className="z-40 cursor-default rounded border border-charcoal-700 bg-background-bright px-2 py-1.5 text-xs"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{tooltipTitle ?? "Open menu"}</span>
|
||||
{shortcut && (
|
||||
<ShortcutKey
|
||||
className={cn("size-4 flex-none")}
|
||||
shortcut={shortcut}
|
||||
variant={"small"}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.Tooltip>
|
||||
)}
|
||||
</Ariakit.TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectProviderProps<TValue extends string | string[]>
|
||||
extends Ariakit.SelectProviderProps<TValue> {}
|
||||
export function SelectProvider<TValue extends string | string[]>(
|
||||
props: SelectProviderProps<TValue>
|
||||
) {
|
||||
return <Ariakit.SelectProvider {...props} />;
|
||||
}
|
||||
|
||||
export interface ComboboxProviderProps extends Ariakit.ComboboxProviderProps {}
|
||||
export function ComboboxProvider(props: ComboboxProviderProps) {
|
||||
return <Ariakit.ComboboxProvider {...props} />;
|
||||
}
|
||||
|
||||
function SelectGroupedRenderer<TItem>({
|
||||
items,
|
||||
children,
|
||||
enableItemShortcuts,
|
||||
}: {
|
||||
items: Section<TItem>[];
|
||||
children: (
|
||||
items: ItemFromSection<TItem>[],
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: { title?: string; startIndex: number; count: number };
|
||||
}
|
||||
) => React.ReactNode;
|
||||
enableItemShortcuts: boolean;
|
||||
}) {
|
||||
let count = 0;
|
||||
return (
|
||||
<>
|
||||
{items.map((section, index) => {
|
||||
const previousItem = items.at(index - 1);
|
||||
count += previousItem ? previousItem.items.length : 0;
|
||||
return (
|
||||
<Fragment key={index}>
|
||||
{children(section.items as ItemFromSection<TItem>[], {
|
||||
shortcutsEnabled: enableItemShortcuts,
|
||||
section: {
|
||||
title: section.title,
|
||||
startIndex: count - 1,
|
||||
count: section.items.length,
|
||||
},
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectListProps extends Omit<Ariakit.SelectListProps, "store"> {}
|
||||
export function SelectList(props: SelectListProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const Component = combobox ? Ariakit.ComboboxList : Ariakit.SelectList;
|
||||
|
||||
return (
|
||||
<Component
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
|
||||
"overflow-y-auto overscroll-contain outline-none scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
const selectItemClasses =
|
||||
"group cursor-pointer px-1 pt-1 text-xs text-text-dimmed outline-none last:pb-1";
|
||||
|
||||
export function SelectItem({
|
||||
icon,
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
shortcut,
|
||||
...props
|
||||
}: SelectItemProps) {
|
||||
const combobox = Ariakit.useComboboxContext();
|
||||
const render = combobox ? <Ariakit.ComboboxItem render={props.render} /> : undefined;
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (ref.current) {
|
||||
ref.current.click();
|
||||
}
|
||||
},
|
||||
disabled: props.disabled,
|
||||
enabledOnInputElements: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Ariakit.SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(
|
||||
selectItemClasses,
|
||||
"[--padding-block:0.5rem] sm:[--padding-block:0.25rem]",
|
||||
props.className
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
<div className="flex h-7 w-full items-center gap-1 rounded-sm px-2 group-data-[active-item=true]:bg-tertiary">
|
||||
{icon}
|
||||
<div className="grow truncate">{props.children || props.value}</div>
|
||||
{checkIcon}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</div>
|
||||
</Ariakit.SelectItem>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectLinkItemProps extends Ariakit.SelectItemProps {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
to: string;
|
||||
}
|
||||
|
||||
export function SelectLinkItem({
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
to,
|
||||
...props
|
||||
}: SelectLinkItemProps) {
|
||||
const render = <Link to={to} className={cn("block", selectItemClasses, props.className)} />;
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectButtonItemProps extends Omit<Ariakit.SelectItemProps, "onClick"> {
|
||||
icon?: React.ReactNode;
|
||||
checkIcon?: React.ReactNode;
|
||||
shortcut?: ShortcutDefinition;
|
||||
onClick: React.ComponentProps<"button">["onClick"];
|
||||
}
|
||||
|
||||
export function SelectButtonItem({
|
||||
checkIcon = <Ariakit.SelectItemCheck className="size-8 flex-none text-white" />,
|
||||
onClick,
|
||||
...props
|
||||
}: SelectButtonItemProps) {
|
||||
const render = (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn("block w-full text-left", selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectItem
|
||||
{...props}
|
||||
render={render}
|
||||
blurOnHoverEnd={false}
|
||||
className={cn(selectItemClasses, props.className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function shortcutFromIndex(
|
||||
index: number,
|
||||
meta: {
|
||||
shortcutsEnabled?: boolean;
|
||||
section?: { startIndex: number };
|
||||
}
|
||||
): ShortcutDefinition | undefined {
|
||||
if (!meta.shortcutsEnabled) return;
|
||||
|
||||
let adjustedIndex = index + (meta.section?.startIndex ?? 0);
|
||||
|
||||
if (adjustedIndex > 9) return;
|
||||
if (adjustedIndex === 9) {
|
||||
adjustedIndex = -1;
|
||||
}
|
||||
|
||||
return { key: String(adjustedIndex + 1) };
|
||||
}
|
||||
|
||||
export interface SelectSeparatorProps extends React.ComponentProps<"div"> {}
|
||||
|
||||
export function SelectSeparator(props: SelectSeparatorProps) {
|
||||
return <div {...props} className={cn("h-px bg-charcoal-700", props.className)} />;
|
||||
}
|
||||
|
||||
export interface SelectGroupProps extends Ariakit.SelectGroupProps {}
|
||||
|
||||
export function SelectGroup(props: SelectGroupProps) {
|
||||
return <Ariakit.SelectGroup {...props} />;
|
||||
}
|
||||
|
||||
export interface SelectGroupLabelProps extends Ariakit.SelectGroupLabelProps {}
|
||||
|
||||
export function SelectGroupLabel(props: SelectGroupLabelProps) {
|
||||
return (
|
||||
<Ariakit.SelectGroupLabel
|
||||
{...props}
|
||||
className={cn(
|
||||
"flex h-[1.375rem] items-center border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright",
|
||||
props.className
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectHeadingProps extends Ariakit.SelectHeadingProps {}
|
||||
export function SelectHeading({ render, ...props }: SelectHeadingProps) {
|
||||
return (
|
||||
<div className="flex h-[1.375rem] flex-none cursor-default items-center gap-2 border-b border-charcoal-700 bg-charcoal-750 px-2.5 text-xxs uppercase text-text-bright">
|
||||
<Ariakit.SelectHeading render={<>{render}</>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SelectPopoverProps extends Ariakit.SelectPopoverProps {}
|
||||
export function SelectPopover({
|
||||
gutter = 5,
|
||||
shift = 0,
|
||||
unmountOnHide = true,
|
||||
className,
|
||||
...props
|
||||
}: SelectPopoverProps) {
|
||||
return (
|
||||
<Ariakit.SelectPopover
|
||||
gutter={gutter}
|
||||
shift={shift}
|
||||
unmountOnHide={unmountOnHide}
|
||||
className={cn(
|
||||
"z-50 flex flex-col overflow-clip rounded border border-charcoal-700 bg-background-bright shadow-md outline-none animate-in fade-in-40",
|
||||
"min-w-[max(180px,calc(var(--popover-anchor-width)+0.5rem))]",
|
||||
"max-w-[min(480px,var(--popover-available-width))]",
|
||||
"max-h-[min(480px,var(--popover-available-height))]",
|
||||
"origin-[var(--popover-transform-origin)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
export interface SelectLabelProps extends Ariakit.SelectLabelProps {}
|
||||
//currently unstyled
|
||||
export function SelectLabel(props: SelectLabelProps) {
|
||||
return <Ariakit.SelectLabel {...props} />;
|
||||
}
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
export interface ComboBoxProps extends Ariakit.ComboboxProps {
|
||||
shortcut?: ShortcutDefinition;
|
||||
}
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
export function ComboBox({
|
||||
autoSelect = true,
|
||||
placeholder = "Filter options",
|
||||
shortcut,
|
||||
...props
|
||||
}: ComboBoxProps) {
|
||||
return (
|
||||
<div className="flex h-9 w-full flex-none items-center border-b border-grid-dimmed bg-transparent px-3 text-xs text-text-dimmed outline-none">
|
||||
<Ariakit.Combobox
|
||||
autoSelect={autoSelect}
|
||||
render={<input placeholder={placeholder} />}
|
||||
className="flex-1 bg-transparent text-xs text-text-dimmed outline-none"
|
||||
{...props}
|
||||
/>
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const sizes = {
|
||||
"secondary/small":
|
||||
"text-xs h-6 bg-tertiary border border-tertiary group-hover:text-text-bright hover:border-charcoal-600 pr-2 pl-1.5",
|
||||
medium: "text-sm h-8 bg-tertiary border border-tertiary hover:border-charcoal-600 px-2.5",
|
||||
minimal: "text-xs h-6 bg-transparent hover:bg-tertiary pl-1.5 pr-2",
|
||||
};
|
||||
|
||||
export type SelectProps = {
|
||||
size?: keyof typeof sizes;
|
||||
width?: "content" | "full";
|
||||
};
|
||||
|
||||
const Select = SelectPrimitive.Root;
|
||||
const SelectGroup = SelectPrimitive.Group;
|
||||
const SelectValue = SelectPrimitive.Value;
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & SelectProps
|
||||
>(({ className, children, width = "content", size = "secondary/small", ...props }, ref) => {
|
||||
const sizeClassName = sizes[size];
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"ring-offset-background focus-visible:ring-ring group flex items-center justify-between gap-x-1 rounded text-text-dimmed transition placeholder:text-text-dimmed hover:text-text-bright focus-visible:bg-tertiary focus-visible:text-text-bright focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-offset-0 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
width === "full" ? "w-full" : "w-min",
|
||||
sizeClassName,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"size-4 text-text-dimmed transition group-hover:text-text-bright group-focus:text-text-bright"
|
||||
)}
|
||||
/>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "popper", ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 min-w-max overflow-hidden rounded-md border border-charcoal-700 bg-background-dimmed text-text-bright shadow-md animate-in fade-in-40",
|
||||
position === "popper" && "translate-y-1",
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"space-y-0.5 px-1 py-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
));
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"-ml-1 -mr-1 mb-1 bg-charcoal-900 py-1.5 pl-2 pr-2 font-sans text-xxs font-normal uppercase leading-normal tracking-wider text-text-dimmed first-of-type:-mt-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectLabel.displayName = SelectPrimitive.Label.displayName;
|
||||
|
||||
type SelectItemProps = React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
|
||||
contentClassName?: string;
|
||||
};
|
||||
|
||||
const SelectItem = React.forwardRef<React.ElementRef<typeof SelectPrimitive.Item>, SelectItemProps>(
|
||||
({ className, children, contentClassName, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-12 text-sm outline-none transition data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:bg-charcoal-750 focus:bg-charcoal-750/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
)
|
||||
);
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Separator
|
||||
ref={ref}
|
||||
className={cn("bg-muted -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
SelectSeparator.displayName = SelectPrimitive.Separator.displayName;
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
};
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
} from "../primitives/SimpleSelect";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
FilterableEnvironment,
|
||||
|
||||
@@ -1,9 +1,25 @@
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import type { TaskRunStatus as TaskRunStatusType } from "@trigger.dev/database";
|
||||
import { RuntimeEnvironment, TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { useCallback } from "react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { CalendarIcon, CpuChipIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { RuntimeEnvironment, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { ListFilterIcon } from "lucide-react";
|
||||
import { ReactNode, startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,243 +27,656 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../primitives/Select";
|
||||
import { TimeFrameFilter } from "../TimeFrameFilter";
|
||||
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "./TaskRunStatus";
|
||||
|
||||
export const allTaskRunStatuses = [
|
||||
"PENDING",
|
||||
"WAITING_FOR_DEPLOY",
|
||||
"EXECUTING",
|
||||
"RETRYING_AFTER_FAILURE",
|
||||
"WAITING_TO_RESUME",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
"CRASHED",
|
||||
] as TaskRunStatusType[];
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
descriptionForTaskRunStatus,
|
||||
runStatusTitle,
|
||||
} from "./TaskRunStatus";
|
||||
import { TaskTriggerSourceIcon } from "./TaskTriggerSource";
|
||||
|
||||
export const TaskAttemptStatus = z.nativeEnum(TaskRunStatus);
|
||||
|
||||
export const TaskRunListSearchFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
environments: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
tasks: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
versions: z
|
||||
.string()
|
||||
.optional()
|
||||
.transform((value) => (value ? value.split(",") : undefined)),
|
||||
environments: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
tasks: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
versions: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? value.split(",") : undefined),
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
TaskAttemptStatus.array().optional()
|
||||
),
|
||||
period: z.preprocess((value) => (value === "all" ? undefined : value), z.string().optional()),
|
||||
from: z.coerce.number().optional(),
|
||||
to: z.coerce.number().optional(),
|
||||
});
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
|
||||
const All = "ALL";
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type RunFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
possibleTasks: string[];
|
||||
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
export function RunsFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
|
||||
const navigate = useNavigate();
|
||||
export function RunsFilters(props: RunFiltersProps) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const { environments, tasks, versions, statuses, from, to } = TaskRunListSearchFilters.parse(
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
} else {
|
||||
searchParams.delete(filterType);
|
||||
}
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const handleStatusChange = useCallback((value: TaskRunAttemptStatus | typeof All) => {
|
||||
handleFilterChange("statuses", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleTaskChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("tasks", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("environments", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleTimeFrameChange = useCallback((range: { from?: number; to?: number }) => {
|
||||
if (range.from) {
|
||||
searchParams.set("from", range.from.toString());
|
||||
} else {
|
||||
searchParams.delete("from");
|
||||
}
|
||||
|
||||
if (range.to) {
|
||||
searchParams.set("to", range.to.toString());
|
||||
} else {
|
||||
searchParams.delete("to");
|
||||
}
|
||||
|
||||
searchParams.delete("cursor");
|
||||
searchParams.delete("direction");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
searchParams.delete("statuses");
|
||||
searchParams.delete("environments");
|
||||
searchParams.delete("tasks");
|
||||
searchParams.delete("from");
|
||||
searchParams.delete("to");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("period");
|
||||
|
||||
return (
|
||||
<div className="flex flex-row justify-between">
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environments?.at(0) ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder={"Select environment"} className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All environments
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{possibleEnvironments.map((env) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<EnvironmentLabel environment={env} userName={env.userName} />
|
||||
<Paragraph variant="extra-small">environment</Paragraph>
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select name="status" value={statuses?.at(0) ?? "ALL"} onValueChange={handleStatusChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder="Select status" className="ml-2 p-0">
|
||||
{statuses?.at(0) ? (
|
||||
<TaskRunStatusCombo
|
||||
status={statuses[0]}
|
||||
className="text-xs"
|
||||
iconClassName="animate-none"
|
||||
/>
|
||||
) : (
|
||||
"All statuses"
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="overflow-visible">
|
||||
<SelectItem value={"ALL"} className="">
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All statuses
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{allTaskRunStatuses.map((status) => (
|
||||
<TooltipProvider key={status}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<SelectItem value={status} className="">
|
||||
<TaskRunStatusCombo
|
||||
status={status}
|
||||
className="text-xs"
|
||||
iconClassName="animate-none"
|
||||
/>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForTaskRunStatus(status)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select name="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue placeholder="Select task" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
All tasks
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
{possibleTasks
|
||||
.sort((a, b) => a.localeCompare(b)) // 🔤
|
||||
.map((task) => (
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<TimeFrameFilter from={from} to={to} onRangeChanged={handleTimeFrameChange} />
|
||||
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<AppliedFilters {...props} />
|
||||
{hasFilters && (
|
||||
<Form>
|
||||
<Button variant="minimal/small" LeadingIcon={XMarkIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
name: "statuses",
|
||||
title: "Status",
|
||||
icon: (
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<div className="size-3 rounded-full border-2 border-text-dimmed" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
];
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
function FilterMenu(props: RunFiltersProps) {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger
|
||||
icon={
|
||||
<div className="flex size-4 items-center justify-center">
|
||||
<ListFilterIcon className="size-3.5" />
|
||||
</div>
|
||||
}
|
||||
variant={"minimal/small"}
|
||||
shortcut={shortcut}
|
||||
tooltipTitle={"Filter runs"}
|
||||
>
|
||||
Filter
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<FilterMenuProvider onClose={() => setFilterType(undefined)}>
|
||||
{(search, setSearch) => (
|
||||
<Menu
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
}: {
|
||||
children: (search: string, setSearch: (value: string) => void) => React.ReactNode;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{children(searchValue, setSearchValue)}
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks }: RunFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedTaskFilter possibleTasks={possibleTasks} />
|
||||
<AppliedPeriodFilter />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
} & RunFiltersProps;
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "statuses":
|
||||
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "environments":
|
||||
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "tasks":
|
||||
return <TasksDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
}
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, trigger, clearSearchValue, setFilterType }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter by..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{type.title}
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allTaskRunStatuses.map((status) => ({
|
||||
title: runStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function StatusDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ statuses: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("statuses")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger className="group flex w-full flex-col py-0">
|
||||
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={9}>
|
||||
<Paragraph variant="extra-small">
|
||||
{descriptionForTaskRunStatus(item.value)}
|
||||
</Paragraph>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedStatusFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const statuses = values("statuses");
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<StatusDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<AppliedFilter
|
||||
label="Status"
|
||||
value={appliedSummary(statuses.map((v) => runStatusTitle(v as TaskRunStatus)))}
|
||||
onRemove={() => del(["statuses", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EnvironmentsDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleEnvironments,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environments: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleEnvironments.filter((item) => {
|
||||
const title = environmentTitle(item, item.userName);
|
||||
return title.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleEnvironments]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environments")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by environment..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.id}
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedEnvironmentFilter({
|
||||
possibleEnvironments,
|
||||
}: Pick<RunFiltersProps, "possibleEnvironments">) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("environments").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<EnvironmentsDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<AppliedFilter
|
||||
label="Environment"
|
||||
value={appliedSummary(
|
||||
values("environments").map((v) => {
|
||||
const environment = possibleEnvironments.find((env) => env.id === v);
|
||||
return environment ? environmentTitle(environment, environment.userName) : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["environments", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleEnvironments={possibleEnvironments}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ tasks: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleTasks.filter((item) => {
|
||||
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleTasks]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.slug}
|
||||
value={item.slug}
|
||||
icon={<TaskTriggerSourceIcon source={item.triggerSource} className="size-4" />}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedTaskFilter({ possibleTasks }: Pick<RunFiltersProps, "possibleTasks">) {
|
||||
const { values, del } = useSearchParams();
|
||||
|
||||
if (values("tasks").length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
value={appliedSummary(
|
||||
values("tasks").map((v) => {
|
||||
const task = possibleTasks.find((task) => task.slug === v);
|
||||
return task ? task.slug : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["tasks", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const timePeriods = [
|
||||
{
|
||||
label: "All periods",
|
||||
value: "all",
|
||||
},
|
||||
{
|
||||
label: "5 mins ago",
|
||||
value: "5m",
|
||||
},
|
||||
{
|
||||
label: "15 mins ago",
|
||||
value: "15m",
|
||||
},
|
||||
{
|
||||
label: "30 mins ago",
|
||||
value: "30m",
|
||||
},
|
||||
{
|
||||
label: "1 hour ago",
|
||||
value: "1h",
|
||||
},
|
||||
{
|
||||
label: "3 hours ago",
|
||||
value: "3h",
|
||||
},
|
||||
{
|
||||
label: "6 hours ago",
|
||||
value: "6h",
|
||||
},
|
||||
{
|
||||
label: "1 day ago",
|
||||
value: "1d",
|
||||
},
|
||||
{
|
||||
label: "3 days ago",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "7 days ago",
|
||||
value: "7d",
|
||||
},
|
||||
{
|
||||
label: "10 days ago",
|
||||
value: "10d",
|
||||
},
|
||||
{
|
||||
label: "14 days ago",
|
||||
value: "14d",
|
||||
},
|
||||
{
|
||||
label: "30 days ago",
|
||||
value: "30d",
|
||||
},
|
||||
];
|
||||
|
||||
function CreatedDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const { value, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
clearSearchValue();
|
||||
if (newValue === "all") {
|
||||
if (!value) return;
|
||||
}
|
||||
|
||||
replace({ period: newValue, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return timePeriods.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={value("period")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEnter={false}
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by period..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value} hideOnClick={false}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedPeriodFilter() {
|
||||
const { value, del } = useSearchParams();
|
||||
|
||||
if (value("period") === undefined || value("period") === "all") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<CreatedDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer" />}>
|
||||
<AppliedFilter
|
||||
label="Created"
|
||||
value={
|
||||
timePeriods.find((t) => t.value === value("period"))?.label ?? value("period")
|
||||
}
|
||||
onRemove={() => del(["period", "cursor", "direction"])}
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function appliedSummary(values: string[], maxValues = 3) {
|
||||
if (values.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (values.length > maxValues) {
|
||||
return `${values.slice(0, maxValues).join(", ")} + ${values.length - maxValues} more`;
|
||||
}
|
||||
|
||||
return values.join(", ");
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { TrashIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { useCallback } from "react";
|
||||
import { z } from "zod";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
@@ -14,10 +16,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../../primitives/Select";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useThrottle } from "~/hooks/useThrottle";
|
||||
} from "../../primitives/SimpleSelect";
|
||||
|
||||
export const ScheduleListFilters = z.object({
|
||||
page: z.coerce.number().default(1),
|
||||
@@ -53,6 +52,9 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const hasFilters =
|
||||
searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search");
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
searchParams.set(filterType, value);
|
||||
@@ -146,7 +148,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
className="whitespace-nowrap pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
@@ -156,7 +158,11 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon} />
|
||||
{hasFilters && (
|
||||
<Button variant="minimal/small" onClick={() => clearFilters()} LeadingIcon={XMarkIcon}>
|
||||
Clear all
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,6 +15,20 @@ import { SnowflakeIcon } from "lucide-react";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const allTaskRunStatuses = [
|
||||
"WAITING_FOR_DEPLOY",
|
||||
"PENDING",
|
||||
"EXECUTING",
|
||||
"RETRYING_AFTER_FAILURE",
|
||||
"WAITING_TO_RESUME",
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"CRASHED",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
] as TaskRunStatus[];
|
||||
|
||||
const taskRunStatusDescriptions: Record<TaskRunStatus, string> = {
|
||||
PENDING: "Task is waiting to be executed",
|
||||
WAITING_FOR_DEPLOY: "Task needs to be deployed first to start executing",
|
||||
|
||||
@@ -174,6 +174,10 @@ Worker.init().catch((error) => {
|
||||
|
||||
function logError(error: unknown, request?: Request) {
|
||||
console.error(error);
|
||||
|
||||
if (error instanceof Error && error.message.startsWith("There are locked jobs present")) {
|
||||
console.log("⚠️ graphile-worker migration issue detected!");
|
||||
}
|
||||
}
|
||||
|
||||
const sqsEventConsumer = singleton("sqsEventConsumer", getSharedSqsEventConsumer);
|
||||
|
||||
@@ -47,6 +47,8 @@ const EnvironmentSchema = z.object({
|
||||
WORKER_SCHEMA: z.string().default("graphile_worker"),
|
||||
WORKER_CONCURRENCY: z.coerce.number().int().default(10),
|
||||
WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
/** The number of days a failed Graphile task should stay before getting cleaned up */
|
||||
WORKER_CLEANUP_TTL_DAYS: z.coerce.number().int().default(3),
|
||||
EXECUTION_WORKER_CONCURRENCY: z.coerce.number().int().default(10),
|
||||
EXECUTION_WORKER_POLL_INTERVAL: z.coerce.number().int().default(1000),
|
||||
WORKER_ENABLED: z.string().default("true"),
|
||||
@@ -104,6 +106,7 @@ const EnvironmentSchema = z.object({
|
||||
COORDINATOR_SECRET: z.string().default("coordinator-secret"),
|
||||
DEPOT_TOKEN: z.string().optional(),
|
||||
DEPOT_PROJECT_ID: z.string().optional(),
|
||||
DEPOT_ORG_ID: z.string().optional(),
|
||||
CONTAINER_REGISTRY_ORIGIN: z.string().optional(),
|
||||
CONTAINER_REGISTRY_USERNAME: z.string().optional(),
|
||||
CONTAINER_REGISTRY_PASSWORD: z.string().optional(),
|
||||
@@ -152,6 +155,9 @@ const EnvironmentSchema = z.object({
|
||||
INTERNAL_OTEL_TRACE_SAMPLING_RATE: z.string().default("20"),
|
||||
INTERNAL_OTEL_TRACE_INSTRUMENT_PRISMA_ENABLED: z.string().default("0"),
|
||||
INTERNAL_OTEL_TRACE_DISABLED: z.string().default("0"),
|
||||
|
||||
ORG_SLACK_INTEGRATION_CLIENT_ID: z.string().optional(),
|
||||
ORG_SLACK_INTEGRATION_CLIENT_SECRET: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Environment = z.infer<typeof EnvironmentSchema>;
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useTextFilter } from "./useTextFilter";
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
friendlyId: string;
|
||||
taskIdentifier: string;
|
||||
exportName: string;
|
||||
filePath: string;
|
||||
triggerSource: string;
|
||||
};
|
||||
|
||||
export function useFilterTasks<T extends Task>({ tasks }: { tasks: T[] }) {
|
||||
return useTextFilter<T>({
|
||||
items: tasks,
|
||||
filter: (task, text) => {
|
||||
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.id.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -53,3 +53,11 @@ export function useIsNewOrganizationPage(matches?: UIMatch[]): boolean {
|
||||
export const useOrganizationChanged = (action: (org: MatchedOrganization | undefined) => void) => {
|
||||
useChanged(useOptionalOrganization, action);
|
||||
};
|
||||
|
||||
export function useIsImpersonating(matches?: UIMatch[]) {
|
||||
const data = useTypedMatchesData<typeof orgLoader>({
|
||||
id: "routes/_app.orgs.$organizationSlug",
|
||||
matches,
|
||||
});
|
||||
return data?.isImpersonating === true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
type Values = Record<string, string | string[] | undefined>;
|
||||
|
||||
export function useSearchParams() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
|
||||
const set = useCallback(
|
||||
(values: Values) => {
|
||||
for (const [param, value] of Object.entries(values)) {
|
||||
if (value === undefined) {
|
||||
search.delete(param);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof value === "string") {
|
||||
search.set(param, value);
|
||||
continue;
|
||||
}
|
||||
|
||||
search.delete(param);
|
||||
for (const v of value) {
|
||||
search.append(param, v);
|
||||
}
|
||||
}
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const replace = useCallback(
|
||||
(values: Values) => {
|
||||
set(values);
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const del = useCallback(
|
||||
(keys: string | string[]) => {
|
||||
if (!Array.isArray(keys)) {
|
||||
keys = [keys];
|
||||
}
|
||||
for (const key of keys) {
|
||||
search.delete(key);
|
||||
}
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const value = useCallback(
|
||||
(param: string) => {
|
||||
return search.get(param) ?? undefined;
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
const values = useCallback(
|
||||
(param: string) => {
|
||||
return search.getAll(param);
|
||||
},
|
||||
[location, search]
|
||||
);
|
||||
|
||||
return {
|
||||
value,
|
||||
values,
|
||||
set,
|
||||
replace,
|
||||
del,
|
||||
};
|
||||
}
|
||||
@@ -17,16 +17,22 @@ export type ShortcutDefinition =
|
||||
| Shortcut;
|
||||
|
||||
type useShortcutKeysProps = {
|
||||
shortcut: ShortcutDefinition;
|
||||
shortcut: ShortcutDefinition | undefined;
|
||||
action: (event: KeyboardEvent) => void;
|
||||
disabled?: boolean;
|
||||
enabledOnInputElements?: boolean;
|
||||
};
|
||||
|
||||
export function useShortcutKeys({ shortcut, action, disabled = false }: useShortcutKeysProps) {
|
||||
export function useShortcutKeys({
|
||||
shortcut,
|
||||
action,
|
||||
disabled = false,
|
||||
enabledOnInputElements,
|
||||
}: useShortcutKeysProps) {
|
||||
const { platform } = useOperatingSystem();
|
||||
const isMac = platform === "mac";
|
||||
const relevantShortcut = "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
const relevantShortcut =
|
||||
shortcut && "mac" in shortcut ? (isMac ? shortcut.mac : shortcut.windows) : shortcut;
|
||||
|
||||
const keys = createKeysFromShortcut(relevantShortcut);
|
||||
useHotkeys(
|
||||
@@ -36,13 +42,17 @@ export function useShortcutKeys({ shortcut, action, disabled = false }: useShort
|
||||
},
|
||||
{
|
||||
enabled: !disabled,
|
||||
enableOnFormTags: relevantShortcut.enabledOnInputElements,
|
||||
enableOnContentEditable: relevantShortcut.enabledOnInputElements,
|
||||
enableOnFormTags: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
|
||||
enableOnContentEditable: enabledOnInputElements ?? relevantShortcut?.enabledOnInputElements,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createKeysFromShortcut(shortcut: Shortcut) {
|
||||
function createKeysFromShortcut(shortcut: Shortcut | undefined) {
|
||||
if (!shortcut) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const modifiers = shortcut.modifiers;
|
||||
const character = shortcut.key;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { WebClient } from "@slack/web-api";
|
||||
import {
|
||||
IntegrationService,
|
||||
Organization,
|
||||
OrganizationIntegration,
|
||||
SecretReference,
|
||||
} from "@trigger.dev/database";
|
||||
import { z } from "zod";
|
||||
import { $transaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { commitSession, getUserSession } from "~/services/sessionStorage.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
|
||||
const SlackSecretSchema = z.object({
|
||||
botAccessToken: z.string(),
|
||||
userAccessToken: z.string().optional(),
|
||||
expiresIn: z.number().optional(),
|
||||
refreshToken: z.string().optional(),
|
||||
botScopes: z.array(z.string()).optional(),
|
||||
userScopes: z.array(z.string()).optional(),
|
||||
raw: z.record(z.any()).optional(),
|
||||
});
|
||||
|
||||
type SlackSecret = z.infer<typeof SlackSecretSchema>;
|
||||
|
||||
const REDIRECT_AFTER_AUTH_KEY = "redirect-back-after-auth";
|
||||
|
||||
type OrganizationIntegrationForService<TService extends IntegrationService> = Omit<
|
||||
AuthenticatableIntegration,
|
||||
"service"
|
||||
> & {
|
||||
service: TService;
|
||||
};
|
||||
|
||||
type AuthenticatedClientOptions<TService extends IntegrationService> = TService extends "SLACK"
|
||||
? {
|
||||
forceBotToken?: boolean;
|
||||
}
|
||||
: undefined;
|
||||
|
||||
type AuthenticatedClientForIntegration<TService extends IntegrationService> =
|
||||
TService extends "SLACK" ? InstanceType<typeof WebClient> : never;
|
||||
|
||||
export type AuthenticatableIntegration = OrganizationIntegration & {
|
||||
tokenReference: SecretReference;
|
||||
};
|
||||
|
||||
export class OrgIntegrationRepository {
|
||||
static async getAuthenticatedClientForIntegration<TService extends IntegrationService>(
|
||||
integration: OrganizationIntegrationForService<TService>,
|
||||
options?: AuthenticatedClientOptions<TService>
|
||||
): Promise<AuthenticatedClientForIntegration<TService>> {
|
||||
const secretStore = getSecretStore(integration.tokenReference.provider);
|
||||
|
||||
switch (integration.service) {
|
||||
case "SLACK": {
|
||||
const secret = await secretStore.getSecret(
|
||||
SlackSecretSchema,
|
||||
integration.tokenReference.key
|
||||
);
|
||||
|
||||
if (!secret) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
// TODO refresh access token here
|
||||
return new WebClient(
|
||||
options?.forceBotToken
|
||||
? secret.botAccessToken
|
||||
: secret.userAccessToken ?? secret.botAccessToken
|
||||
) as AuthenticatedClientForIntegration<TService>;
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unsupported service ${integration.service}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static isSlackSupported =
|
||||
!!env.ORG_SLACK_INTEGRATION_CLIENT_ID && !!env.ORG_SLACK_INTEGRATION_CLIENT_SECRET;
|
||||
|
||||
static slackAuthorizationUrl(
|
||||
state: string,
|
||||
scopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"],
|
||||
userScopes: string[] = ["channels:read", "groups:read", "im:read", "mpim:read", "chat:write"]
|
||||
) {
|
||||
return `https://slack.com/oauth/v2/authorize?client_id=${
|
||||
env.ORG_SLACK_INTEGRATION_CLIENT_ID
|
||||
}&scope=${scopes.join(",")}&user_scope=${userScopes.join(",")}&state=${state}&redirect_uri=${
|
||||
env.APP_ORIGIN
|
||||
}/integrations/slack/callback`;
|
||||
}
|
||||
|
||||
static async redirectToAuthService(
|
||||
service: IntegrationService,
|
||||
state: string,
|
||||
request: Request,
|
||||
redirectTo: string
|
||||
) {
|
||||
const session = await getUserSession(request);
|
||||
session.set(REDIRECT_AFTER_AUTH_KEY, redirectTo);
|
||||
|
||||
const authUrl = service === "SLACK" ? this.slackAuthorizationUrl(state) : undefined;
|
||||
|
||||
if (!authUrl) {
|
||||
throw new Response("Unsupported service", { status: 400 });
|
||||
}
|
||||
|
||||
logger.debug("Redirecting to auth service", {
|
||||
service,
|
||||
authUrl,
|
||||
redirectTo,
|
||||
});
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: authUrl,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async redirectAfterAuth(request: Request) {
|
||||
const session = await getUserSession(request);
|
||||
|
||||
logger.debug("Redirecting back after auth", {
|
||||
sessionData: session.data,
|
||||
});
|
||||
|
||||
const redirectTo = session.get(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
if (!redirectTo) {
|
||||
throw new Response("Invalid redirect", { status: 400 });
|
||||
}
|
||||
|
||||
session.unset(REDIRECT_AFTER_AUTH_KEY);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
location: redirectTo,
|
||||
"Set-Cookie": await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
static async createOrgIntegration(serviceName: string, code: string, org: Organization) {
|
||||
switch (serviceName) {
|
||||
case "slack": {
|
||||
if (!env.ORG_SLACK_INTEGRATION_CLIENT_ID || !env.ORG_SLACK_INTEGRATION_CLIENT_SECRET) {
|
||||
throw new Error("Slack integration not configured");
|
||||
}
|
||||
|
||||
const client = new WebClient();
|
||||
|
||||
const result = await client.oauth.v2.access({
|
||||
client_id: env.ORG_SLACK_INTEGRATION_CLIENT_ID,
|
||||
client_secret: env.ORG_SLACK_INTEGRATION_CLIENT_SECRET,
|
||||
code,
|
||||
redirect_uri: `${env.APP_ORIGIN}/integrations/slack/callback`,
|
||||
});
|
||||
|
||||
if (result.ok) {
|
||||
logger.debug("Received slack access token", {
|
||||
result,
|
||||
});
|
||||
|
||||
if (!result.access_token) {
|
||||
throw new Error("Failed to get access token");
|
||||
}
|
||||
|
||||
return await $transaction(prisma, async (tx) => {
|
||||
const secretStore = getSecretStore("DATABASE", {
|
||||
prismaClient: tx,
|
||||
});
|
||||
|
||||
const integrationFriendlyId = generateFriendlyId("org_integration");
|
||||
|
||||
const secretValue: SlackSecret = {
|
||||
botAccessToken: result.access_token!,
|
||||
userAccessToken: result.authed_user ? result.authed_user.access_token : undefined,
|
||||
expiresIn: result.expires_in,
|
||||
refreshToken: result.refresh_token,
|
||||
botScopes: result.scope ? result.scope.split(",") : [],
|
||||
userScopes: result.authed_user?.scope ? result.authed_user.scope.split(",") : [],
|
||||
raw: result,
|
||||
};
|
||||
|
||||
logger.debug("Setting secret", {
|
||||
secretValue,
|
||||
});
|
||||
|
||||
await secretStore.setSecret(integrationFriendlyId, secretValue);
|
||||
|
||||
const reference = await tx.secretReference.create({
|
||||
data: {
|
||||
provider: "DATABASE",
|
||||
key: integrationFriendlyId,
|
||||
},
|
||||
});
|
||||
|
||||
return await tx.organizationIntegration.create({
|
||||
data: {
|
||||
friendlyId: integrationFriendlyId,
|
||||
organizationId: org.id,
|
||||
service: "SLACK",
|
||||
tokenReferenceId: reference.id,
|
||||
integrationData: {
|
||||
team: result.team,
|
||||
user: result.authed_user
|
||||
? {
|
||||
id: result.authed_user.id,
|
||||
}
|
||||
: undefined,
|
||||
} as any,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Service ${serviceName} not supported`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,3 +110,15 @@ export async function findProjectBySlug(orgSlug: string, projectSlug: string, us
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findProjectByRef(externalRef: string, userId: string) {
|
||||
// Find the project scoped to the organization, making sure the user belongs to that org
|
||||
return await prisma.project.findFirst({
|
||||
where: {
|
||||
externalRef,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { z } from "zod";
|
||||
import { EncryptedSecretValueSchema } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export const ProjectAlertWebhookProperties = z.object({
|
||||
secret: EncryptedSecretValueSchema,
|
||||
url: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertWebhookProperties = z.infer<typeof ProjectAlertWebhookProperties>;
|
||||
|
||||
export const ProjectAlertEmailProperties = z.object({
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertEmailProperties = z.infer<typeof ProjectAlertEmailProperties>;
|
||||
|
||||
export const DeleteProjectAlertChannel = z.object({
|
||||
id: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectAlertSlackProperties = z.object({
|
||||
channelId: z.string(),
|
||||
channelName: z.string(),
|
||||
integrationId: z.string().nullish(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackProperties = z.infer<typeof ProjectAlertSlackProperties>;
|
||||
|
||||
export const ProjectAlertSlackStorage = z.object({
|
||||
message_ts: z.string(),
|
||||
});
|
||||
|
||||
export type ProjectAlertSlackStorage = z.infer<typeof ProjectAlertSlackStorage>;
|
||||
@@ -1,20 +1,21 @@
|
||||
import type {
|
||||
CronItem,
|
||||
CronItemOptions,
|
||||
Job as GraphileJob,
|
||||
DbJob as GraphileJob,
|
||||
Runner as GraphileRunner,
|
||||
JobHelpers,
|
||||
RunnerOptions,
|
||||
Task,
|
||||
TaskList,
|
||||
TaskSpec,
|
||||
WorkerUtils,
|
||||
} from "graphile-worker";
|
||||
import { run as graphileRun, parseCronItems } from "graphile-worker";
|
||||
import { run as graphileRun, makeWorkerUtils, parseCronItems } from "graphile-worker";
|
||||
import { SpanKind, trace } from "@opentelemetry/api";
|
||||
|
||||
import omit from "lodash.omit";
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { $replica, PrismaClient, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { PgListenService } from "~/services/db/pgListen.server";
|
||||
import { workerLogger as logger } from "~/services/logger.server";
|
||||
import { flattenAttributes } from "@trigger.dev/core/v3";
|
||||
@@ -34,8 +35,8 @@ const RawCronPayloadSchema = z.object({
|
||||
|
||||
const GraphileJobSchema = z.object({
|
||||
id: z.coerce.string(),
|
||||
queue_name: z.string().nullable(),
|
||||
task_identifier: z.string(),
|
||||
job_queue_id: z.number().nullable(),
|
||||
task_id: z.number(),
|
||||
payload: z.unknown(),
|
||||
priority: z.number(),
|
||||
run_at: z.coerce.date(),
|
||||
@@ -72,7 +73,7 @@ type RecurringTaskPayload = {
|
||||
|
||||
export type ZodRecurringTasks = {
|
||||
[key: string]: {
|
||||
pattern: string;
|
||||
match: string;
|
||||
options?: CronItemOptions;
|
||||
handler: (payload: RecurringTaskPayload, job: GraphileJob) => Promise<void>;
|
||||
};
|
||||
@@ -129,6 +130,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
#rateLimiter?: ZodWorkerRateLimiter;
|
||||
#shutdownTimeoutInMs?: number;
|
||||
#shuttingDown = false;
|
||||
#workerUtils?: WorkerUtils;
|
||||
|
||||
constructor(options: ZodWorkerOptions<TMessageCatalog>) {
|
||||
this.#name = options.name;
|
||||
@@ -158,6 +160,8 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
const parsedCronItems = parseCronItems(this.#createCronItemsFromRecurringTasks());
|
||||
|
||||
this.#workerUtils = await makeWorkerUtils(this.#runnerOptions);
|
||||
|
||||
this.#runner = await graphileRun({
|
||||
...this.#runnerOptions,
|
||||
noHandleSignals: true,
|
||||
@@ -188,7 +192,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
this.#logDebug("Detected incoming migration", { latestMigration });
|
||||
|
||||
if (latestMigration > 10) {
|
||||
// already migrated past v0.14 - nothing to do
|
||||
this.#logDebug("Already migrated past v0.14 - nothing to do", { latestMigration });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -263,6 +267,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
public async stop() {
|
||||
await this.#runner?.stop();
|
||||
await this.#workerUtils?.release();
|
||||
}
|
||||
|
||||
public async enqueue<K extends keyof TMessageCatalog>(
|
||||
@@ -442,12 +447,29 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return taskList;
|
||||
}
|
||||
|
||||
async #getQueueName(queueId: number | null) {
|
||||
if (queueId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const schema = z.array(z.object({ queue_name: z.string() }));
|
||||
|
||||
const rawQueueNameResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT queue_name FROM ${this.graphileWorkerSchema}._private_job_queues WHERE id = $1`,
|
||||
queueId
|
||||
);
|
||||
|
||||
const queueNameResults = schema.parse(rawQueueNameResults);
|
||||
|
||||
return queueNameResults[0]?.queue_name;
|
||||
}
|
||||
|
||||
async #rescheduleTask(payload: unknown, helpers: JobHelpers) {
|
||||
this.#logDebug("Rescheduling task", { payload, job: helpers.job });
|
||||
|
||||
await this.enqueue(helpers.job.task_identifier, payload, {
|
||||
runAt: helpers.job.run_at,
|
||||
queueName: helpers.job.queue_name ?? undefined,
|
||||
runAt: new Date(Date.now() + 1000 * 10),
|
||||
queueName: await this.#getQueueName(helpers.job.job_queue_id),
|
||||
priority: helpers.job.priority,
|
||||
jobKey: helpers.job.key ?? undefined,
|
||||
flags: Object.keys(helpers.job.flags ?? []),
|
||||
@@ -460,7 +482,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
if (this.#cleanup) {
|
||||
cronItems.push({
|
||||
pattern: this.#cleanup.frequencyExpression,
|
||||
match: this.#cleanup.frequencyExpression,
|
||||
identifier: CLEANUP_TASK_NAME,
|
||||
task: CLEANUP_TASK_NAME,
|
||||
options: this.#cleanup.taskOptions,
|
||||
@@ -469,7 +491,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
if (this.#reporter) {
|
||||
cronItems.push({
|
||||
pattern: "50 * * * *", // Every hour at 50 minutes past the hour
|
||||
match: "50 * * * *", // Every hour at 50 minutes past the hour
|
||||
identifier: REPORTER_TASK_NAME,
|
||||
task: REPORTER_TASK_NAME,
|
||||
});
|
||||
@@ -481,7 +503,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
|
||||
for (const [key, task] of Object.entries(this.#recurringTasks)) {
|
||||
const cronItem: CronItem = {
|
||||
pattern: task.pattern,
|
||||
match: task.match,
|
||||
identifier: key,
|
||||
task: key,
|
||||
options: task.options,
|
||||
@@ -529,7 +551,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
attributes: {
|
||||
"job.task_identifier": job.task_identifier,
|
||||
"job.id": job.id,
|
||||
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
|
||||
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
|
||||
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
|
||||
"job.priority": job.priority,
|
||||
"job.run_at": job.run_at.toISOString(),
|
||||
@@ -599,7 +621,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
attributes: {
|
||||
"job.task_identifier": job.task_identifier,
|
||||
"job.id": job.id,
|
||||
...(job.queue_name ? { "job.queue_name": job.queue_name } : {}),
|
||||
...(job.job_queue_id ? { "job.queue_id": job.job_queue_id } : {}),
|
||||
...flattenAttributes(job.payload as Record<string, unknown>, "job.payload"),
|
||||
"job.priority": job.priority,
|
||||
"job.run_at": job.run_at.toISOString(),
|
||||
@@ -638,6 +660,10 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.#workerUtils) {
|
||||
throw new Error("WorkerUtils need to be initialized before running job cleanup.");
|
||||
}
|
||||
|
||||
const job = helpers.job;
|
||||
|
||||
logger.debug("Received cleanup task", {
|
||||
@@ -663,23 +689,38 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
payload,
|
||||
});
|
||||
|
||||
const rawResults = await this.#prisma.$queryRawUnsafe(
|
||||
`WITH rows AS (SELECT id FROM ${this.graphileWorkerSchema}.jobs WHERE run_at < $1 AND locked_at IS NULL AND max_attempts = attempts LIMIT $2 FOR UPDATE) DELETE FROM ${this.graphileWorkerSchema}.jobs WHERE id IN (SELECT id FROM rows) RETURNING id`,
|
||||
const rawResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT id
|
||||
FROM ${this.graphileWorkerSchema}.jobs
|
||||
WHERE run_at < $1
|
||||
AND locked_at IS NULL
|
||||
AND max_attempts = attempts
|
||||
LIMIT $2`,
|
||||
expirationDate,
|
||||
this.#cleanup.maxCount
|
||||
);
|
||||
|
||||
const results = Array.isArray(rawResults) ? rawResults : [];
|
||||
const results = z
|
||||
.array(
|
||||
z.object({
|
||||
id: z.coerce.string(),
|
||||
})
|
||||
)
|
||||
.parse(rawResults);
|
||||
|
||||
const completedJobs = await this.#workerUtils.completeJobs(results.map((job) => job.id));
|
||||
|
||||
logger.debug("Cleaned up old jobs", {
|
||||
count: results.length,
|
||||
found: results.length,
|
||||
deleted: completedJobs.length,
|
||||
expirationDate,
|
||||
payload,
|
||||
});
|
||||
|
||||
if (this.#reporter) {
|
||||
await this.#reporter("cleanup_stats", {
|
||||
count: results.length,
|
||||
found: results.length,
|
||||
deleted: completedJobs.length,
|
||||
expirationDate,
|
||||
ts: payload._cron.ts,
|
||||
});
|
||||
@@ -711,7 +752,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const schema = z.array(z.object({ count: z.coerce.number() }));
|
||||
|
||||
// Count the number of jobs that have been added since the startAt date and before the payload._cron.ts date
|
||||
const rawAddedResults = await this.#prisma.$queryRawUnsafe(
|
||||
const rawAddedResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs WHERE created_at > $1 AND created_at < $2`,
|
||||
startAt,
|
||||
payload._cron.ts
|
||||
@@ -720,7 +761,7 @@ export class ZodWorker<TMessageCatalog extends MessageCatalogSchema> {
|
||||
const addedCountResults = schema.parse(rawAddedResults)[0];
|
||||
|
||||
// Count the total number of jobs in the jobs table
|
||||
const rawTotalResults = await this.#prisma.$queryRawUnsafe(
|
||||
const rawTotalResults = await $replica.$queryRawUnsafe(
|
||||
`SELECT COUNT(*) FROM ${this.graphileWorkerSchema}.jobs`
|
||||
);
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
IndexEndpointStats,
|
||||
parseEndpointIndexStats,
|
||||
} from "@trigger.dev/core";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export type Client = {
|
||||
slug: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { httpEndpointUrl } from "~/services/httpendpoint/HandleHttpEndpointService";
|
||||
import { getSecretStore } from "~/services/secrets/secretStore.server";
|
||||
import { projectPath } from "~/utils/pathBuilder";
|
||||
|
||||
@@ -2,7 +2,7 @@ import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export class ProjectPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertSlackProperties,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
|
||||
export type AlertChannelListPresenterData = Awaited<ReturnType<AlertChannelListPresenter["call"]>>;
|
||||
export type AlertChannelListPresenterRecord =
|
||||
AlertChannelListPresenterData["alertChannels"][number];
|
||||
export type AlertChannelListPresenterAlertProperties = NonNullable<
|
||||
AlertChannelListPresenterRecord["properties"]
|
||||
>;
|
||||
|
||||
export class AlertChannelListPresenter extends BasePresenter {
|
||||
public async call(projectId: string) {
|
||||
logger.debug("AlertChannelListPresenter", { projectId });
|
||||
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
alertChannels: await Promise.all(
|
||||
alertChannels.map(async (alertChannel) => ({
|
||||
...alertChannel,
|
||||
properties: await this.#presentProperties(alertChannel),
|
||||
}))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
async #presentProperties(alertChannel: ProjectAlertChannel) {
|
||||
if (!alertChannel.properties) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alertChannel.type) {
|
||||
case "WEBHOOK":
|
||||
const parsedProperties = ProjectAlertWebhookProperties.parse(alertChannel.properties);
|
||||
|
||||
const secret = await decryptSecret(env.ENCRYPTION_KEY, parsedProperties.secret);
|
||||
|
||||
return {
|
||||
type: "WEBHOOK" as const,
|
||||
url: parsedProperties.url,
|
||||
secret,
|
||||
};
|
||||
case "EMAIL":
|
||||
return {
|
||||
type: "EMAIL" as const,
|
||||
...ProjectAlertEmailProperties.parse(alertChannel.properties),
|
||||
};
|
||||
case "SLACK": {
|
||||
return {
|
||||
type: "SLACK" as const,
|
||||
...ProjectAlertSlackProperties.parse(alertChannel.properties),
|
||||
};
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unsupported alert channel type: ${alertChannel.type}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ProjectAlertChannel,
|
||||
ProjectAlertChannelType,
|
||||
ProjectAlertType,
|
||||
} from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
|
||||
export const ApiAlertType = z.enum(["attempt_failure", "deployment_failure", "deployment_success"]);
|
||||
|
||||
export type ApiAlertType = z.infer<typeof ApiAlertType>;
|
||||
|
||||
export const ApiAlertChannel = z.enum(["email", "webhook"]);
|
||||
|
||||
export type ApiAlertChannel = z.infer<typeof ApiAlertChannel>;
|
||||
|
||||
export const ApiAlertChannelData = z.object({
|
||||
email: z.string().optional(),
|
||||
url: z.string().optional(),
|
||||
secret: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiAlertChannelData = z.infer<typeof ApiAlertChannelData>;
|
||||
|
||||
export const ApiCreateAlertChannel = z.object({
|
||||
alertTypes: ApiAlertType.array(),
|
||||
name: z.string(),
|
||||
channel: ApiAlertChannel,
|
||||
channelData: ApiAlertChannelData,
|
||||
deduplicationKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiCreateAlertChannel = z.infer<typeof ApiCreateAlertChannel>;
|
||||
|
||||
export const ApiAlertChannelObject = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
alertTypes: ApiAlertType.array(),
|
||||
channel: ApiAlertChannel,
|
||||
channelData: ApiAlertChannelData,
|
||||
deduplicationKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export type ApiAlertChannelObject = z.infer<typeof ApiAlertChannelObject>;
|
||||
|
||||
export class ApiAlertChannelPresenter {
|
||||
public static async alertChannelToApi(
|
||||
alertChannel: ProjectAlertChannel
|
||||
): Promise<ApiAlertChannelObject> {
|
||||
return {
|
||||
id: alertChannel.friendlyId,
|
||||
name: alertChannel.name,
|
||||
alertTypes: alertChannel.alertTypes.map((type) => this.alertTypeToApi(type)),
|
||||
channel: this.alertChannelTypeToApi(alertChannel.type),
|
||||
channelData: await channelDataFromProperties(alertChannel.type, alertChannel.properties),
|
||||
deduplicationKey: alertChannel.userProvidedDeduplicationKey
|
||||
? alertChannel.deduplicationKey
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
public static alertTypeToApi(alertType: ProjectAlertType): ApiAlertType {
|
||||
switch (alertType) {
|
||||
case "TASK_RUN_ATTEMPT":
|
||||
return "attempt_failure";
|
||||
case "DEPLOYMENT_FAILURE":
|
||||
return "deployment_failure";
|
||||
case "DEPLOYMENT_SUCCESS":
|
||||
return "deployment_success";
|
||||
default:
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
|
||||
public static alertTypeFromApi(alertType: ApiAlertType): ProjectAlertType {
|
||||
switch (alertType) {
|
||||
case "attempt_failure":
|
||||
return "TASK_RUN_ATTEMPT";
|
||||
case "deployment_failure":
|
||||
return "DEPLOYMENT_FAILURE";
|
||||
case "deployment_success":
|
||||
return "DEPLOYMENT_SUCCESS";
|
||||
default:
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
|
||||
public static alertChannelTypeToApi(type: ProjectAlertChannelType): ApiAlertChannel {
|
||||
switch (type) {
|
||||
case "EMAIL":
|
||||
return "email";
|
||||
case "WEBHOOK":
|
||||
return "webhook";
|
||||
case "SLACK":
|
||||
throw new Error("Slack channels are not supported");
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function channelDataFromProperties(
|
||||
type: ProjectAlertChannelType,
|
||||
properties: ProjectAlertChannel["properties"]
|
||||
): Promise<ApiAlertChannelData> {
|
||||
if (!properties) {
|
||||
return {};
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "EMAIL":
|
||||
return ProjectAlertEmailProperties.parse(properties);
|
||||
case "WEBHOOK":
|
||||
const { url, secret } = ProjectAlertWebhookProperties.parse(properties);
|
||||
|
||||
return {
|
||||
url,
|
||||
secret: await decryptSecret(env.ENCRYPTION_KEY, secret),
|
||||
};
|
||||
case "SLACK":
|
||||
throw new Error("Slack channels are not supported");
|
||||
default:
|
||||
assertNever(type);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export class ApiKeysPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { AttemptStatus, RetrieveRunResponse, RunStatus, logger } from "@trigger.dev/core/v3";
|
||||
import { TaskRunAttemptStatus, TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export class ApiRetrieveRunPresenter extends BasePresenter {
|
||||
public async call(
|
||||
friendlyId: string,
|
||||
env: AuthenticatedEnvironment,
|
||||
showSecretDetails: boolean
|
||||
): Promise<RetrieveRunResponse | undefined> {
|
||||
return this.traceWithEnv("call", env, async (span) => {
|
||||
const taskRun = await this._prisma.taskRun.findUnique({
|
||||
where: {
|
||||
friendlyId,
|
||||
runtimeEnvironmentId: env.id,
|
||||
},
|
||||
include: {
|
||||
attempts: {
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
},
|
||||
lockedToVersion: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskRun) {
|
||||
logger.debug("Task run not found", { friendlyId, envId: env.id });
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
id: taskRun.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromRunStatus(taskRun.status),
|
||||
taskIdentifier: taskRun.taskIdentifier,
|
||||
idempotencyKey: taskRun.idempotencyKey ?? undefined,
|
||||
version: taskRun.lockedToVersion ? taskRun.lockedToVersion.version : undefined,
|
||||
createdAt: taskRun.createdAt ?? undefined,
|
||||
updatedAt: taskRun.updatedAt ?? undefined,
|
||||
attempts: !showSecretDetails
|
||||
? []
|
||||
: taskRun.attempts.map((a) => ({
|
||||
id: a.friendlyId,
|
||||
status: ApiRetrieveRunPresenter.apiStatusFromAttemptStatus(a.status),
|
||||
createdAt: a.createdAt ?? undefined,
|
||||
updatedAt: a.updatedAt ?? undefined,
|
||||
startedAt: a.startedAt ?? undefined,
|
||||
completedAt: a.completedAt ?? undefined,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
static apiStatusFromRunStatus(status: TaskRunStatus): RunStatus {
|
||||
switch (status) {
|
||||
case "WAITING_FOR_DEPLOY":
|
||||
case "PENDING": {
|
||||
return "PENDING";
|
||||
}
|
||||
case "RETRYING_AFTER_FAILURE":
|
||||
case "EXECUTING": {
|
||||
return "EXECUTING";
|
||||
}
|
||||
case "WAITING_TO_RESUME":
|
||||
case "PAUSED": {
|
||||
return "PAUSED";
|
||||
}
|
||||
case "CANCELED": {
|
||||
return "CANCELED";
|
||||
}
|
||||
case "COMPLETED_SUCCESSFULLY": {
|
||||
return "COMPLETED";
|
||||
}
|
||||
case "SYSTEM_FAILURE":
|
||||
case "INTERRUPTED":
|
||||
case "CRASHED":
|
||||
case "COMPLETED_WITH_ERRORS": {
|
||||
return "FAILED";
|
||||
}
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static apiStatusFromAttemptStatus(status: TaskRunAttemptStatus): AttemptStatus {
|
||||
switch (status) {
|
||||
case "PENDING": {
|
||||
return "PENDING";
|
||||
}
|
||||
case "PAUSED": {
|
||||
return "PAUSED";
|
||||
}
|
||||
case "EXECUTING": {
|
||||
return "EXECUTING";
|
||||
}
|
||||
case "COMPLETED": {
|
||||
return "COMPLETED";
|
||||
}
|
||||
case "FAILED": {
|
||||
return "FAILED";
|
||||
}
|
||||
case "CANCELED": {
|
||||
return "CANCELED";
|
||||
}
|
||||
default: {
|
||||
assertNever(status);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
DeploymentErrorData,
|
||||
ExternalBuildData,
|
||||
TaskMetadataFailedToParseData,
|
||||
groupTaskMetadataIssuesByTask,
|
||||
} from "@trigger.dev/core/v3";
|
||||
@@ -39,6 +40,7 @@ export class DeploymentPresenter {
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
@@ -65,6 +67,9 @@ export class DeploymentPresenter {
|
||||
shortCode: true,
|
||||
version: true,
|
||||
errorData: true,
|
||||
imageReference: true,
|
||||
externalBuildData: true,
|
||||
projectId: true,
|
||||
environment: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -117,6 +122,10 @@ export class DeploymentPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
@@ -135,13 +144,18 @@ export class DeploymentPresenter {
|
||||
userName: getUsername(deployment.environment.orgMember?.user),
|
||||
},
|
||||
deployedBy: deployment.triggeredBy,
|
||||
errorData: this.#prepareErrorData(deployment.errorData),
|
||||
sdkVersion: deployment.worker?.sdkVersion,
|
||||
imageReference: deployment.imageReference,
|
||||
externalBuildData:
|
||||
externalBuildData && externalBuildData.success ? externalBuildData.data : undefined,
|
||||
projectId: deployment.projectId,
|
||||
organizationId: project.organizationId,
|
||||
errorData: DeploymentPresenter.prepareErrorData(deployment.errorData),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
public static prepareErrorData(errorData: WorkerDeployment["errorData"]): ErrorData | undefined {
|
||||
if (!errorData) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
|
||||
type Result = Awaited<ReturnType<EnvironmentVariablesPresenter["call"]>>;
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
AuthenticatableIntegration,
|
||||
OrgIntegrationRepository,
|
||||
} from "~/models/orgIntegration.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { WebClient } from "@slack/web-api";
|
||||
|
||||
export class NewAlertChannelPresenter extends BasePresenter {
|
||||
public async call(projectId: string) {
|
||||
const project = await this._prisma.project.findUniqueOrThrow({
|
||||
where: {
|
||||
id: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
// Find the latest Slack integration
|
||||
const slackIntegration = await this._prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
// If there is a slack integration, then we need to get a list of Slack Channels
|
||||
if (slackIntegration) {
|
||||
const channels = await getSlackChannelsForToken(slackIntegration);
|
||||
|
||||
return {
|
||||
slack: {
|
||||
status: "READY" as const,
|
||||
channels,
|
||||
integrationId: slackIntegration.id,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
if (OrgIntegrationRepository.isSlackSupported) {
|
||||
return {
|
||||
slack: {
|
||||
status: "NOT_CONFIGURED" as const,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
slack: {
|
||||
status: "NOT_AVAILABLE" as const,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getSlackChannelsForToken(integration: AuthenticatableIntegration) {
|
||||
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(integration);
|
||||
|
||||
const channels = await getAllSlackConversations(client);
|
||||
|
||||
logger.debug("Received a list of slack conversations", {
|
||||
channels,
|
||||
});
|
||||
|
||||
return (channels ?? [])
|
||||
.filter((channel) => !channel.is_archived)
|
||||
.filter((channel) => channel.is_channel)
|
||||
.filter((channel) => !channel.is_ext_shared)
|
||||
.filter((channel) => channel.unlinked === 0)
|
||||
.filter((channel) => channel.num_members)
|
||||
.sort((a, b) => a.name!.localeCompare(b.name!));
|
||||
}
|
||||
|
||||
type Channels = Awaited<ReturnType<WebClient["conversations"]["list"]>>["channels"];
|
||||
|
||||
async function getSlackConversationsPage(client: WebClient, nextCursor?: string) {
|
||||
return client.conversations.list({
|
||||
types: "public_channel,private_channel",
|
||||
exclude_archived: true,
|
||||
cursor: nextCursor,
|
||||
});
|
||||
}
|
||||
|
||||
async function getAllSlackConversations(client: WebClient) {
|
||||
let nextCursor: string | undefined = undefined;
|
||||
let channels: Channels = [];
|
||||
|
||||
do {
|
||||
const response = await getSlackConversationsPage(client, nextCursor);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to get channels: ${response.error}`);
|
||||
}
|
||||
|
||||
channels = channels.concat(response.channels ?? []);
|
||||
nextCursor = response.response_metadata?.next_cursor;
|
||||
} while (nextCursor);
|
||||
|
||||
return channels;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
@@ -15,6 +16,7 @@ type RunListOptions = {
|
||||
statuses?: TaskRunStatus[];
|
||||
environments?: string[];
|
||||
scheduleId?: string;
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
@@ -38,6 +40,7 @@ export class RunListPresenter extends BasePresenter {
|
||||
statuses,
|
||||
environments,
|
||||
scheduleId,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -47,10 +50,11 @@ export class RunListPresenter extends BasePresenter {
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
|
||||
const hasFilters =
|
||||
tasks !== undefined ||
|
||||
versions !== undefined ||
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
environments !== undefined ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
from !== undefined ||
|
||||
to !== undefined;
|
||||
|
||||
@@ -90,6 +94,8 @@ export class RunListPresenter extends BasePresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
let runs = await this._replica.$queryRaw<
|
||||
{
|
||||
@@ -152,6 +158,11 @@ export class RunListPresenter extends BasePresenter {
|
||||
: Prisma.empty
|
||||
}
|
||||
${scheduleId ? Prisma.sql`AND tr."scheduleId" = ${scheduleId}` : Prisma.empty}
|
||||
${
|
||||
periodMs
|
||||
? Prisma.sql`AND tr."createdAt" >= NOW() - INTERVAL '1 millisecond' * ${periodMs}`
|
||||
: Prisma.empty
|
||||
}
|
||||
${
|
||||
from
|
||||
? Prisma.sql`AND tr."createdAt" >= ${new Date(from).toISOString()}::timestamp`
|
||||
@@ -224,7 +235,11 @@ export class RunListPresenter extends BasePresenter {
|
||||
next,
|
||||
previous,
|
||||
},
|
||||
possibleTasks: possibleTasks.map((task) => task.slug),
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({ slug: task.slug, triggerSource: task.triggerSource }))
|
||||
.sort((a, b) => {
|
||||
return a.slug.localeCompare(b.slug);
|
||||
}),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { TestSearchParams } from "~/routes/_app.orgs.$organizationSlug.projects.v3.$projectParam.test/route";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
} from "~/components/primitives/SimpleSelect";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
|
||||
type FirstEndpointSheetProps = {
|
||||
|
||||
+1
-1
@@ -25,7 +25,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
} from "~/components/primitives/SimpleSelect";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { runStatusClassNameColor, runStatusTitle } from "~/components/runs/RunStatuses";
|
||||
import { redirectBackWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
|
||||
+2
-10
@@ -11,7 +11,7 @@ import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/component
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
@@ -261,15 +261,7 @@ export default function Page() {
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<div className="space-x-2">
|
||||
{task.environments.map((environment) => (
|
||||
<EnvironmentLabel
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
</TableRow>
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { getUserSession } from "~/services/sessionStorage.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewProjectAlertPathConnectToSlackPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
// Find an integration for Slack for this org
|
||||
const integration = await prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: project.organizationId,
|
||||
},
|
||||
});
|
||||
|
||||
if (integration) {
|
||||
return redirectWithSuccessMessage(
|
||||
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
|
||||
request,
|
||||
"Successfully connected your Slack workspace"
|
||||
);
|
||||
} else {
|
||||
// Redirect to Slack
|
||||
return await OrgIntegrationRepository.redirectToAuthService(
|
||||
"SLACK",
|
||||
project.organizationId,
|
||||
request,
|
||||
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
|
||||
);
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
import { conform, useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { HashtagIcon, LockClosedIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/router";
|
||||
import { ActionFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Checkbox } from "~/components/primitives/Checkbox";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Fieldset } from "~/components/primitives/Fieldset";
|
||||
import { FormButtons } from "~/components/primitives/FormButtons";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Hint } from "~/components/primitives/Hint";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
CreateAlertChannelOptions,
|
||||
CreateAlertChannelService,
|
||||
} from "~/v3/services/alerts/createAlertChannel.server";
|
||||
|
||||
const FormSchema = z
|
||||
.object({
|
||||
alertTypes: z
|
||||
.array(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"]))
|
||||
.min(1)
|
||||
.or(z.enum(["TASK_RUN_ATTEMPT", "DEPLOYMENT_FAILURE", "DEPLOYMENT_SUCCESS"])),
|
||||
type: z.enum(["WEBHOOK", "SLACK", "EMAIL"]).default("EMAIL"),
|
||||
channelValue: z.string().nonempty(),
|
||||
integrationId: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "EMAIL" ? z.string().email().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid email address",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "WEBHOOK" ? z.string().url().safeParse(value.channelValue).success : true,
|
||||
{
|
||||
message: "Must be a valid URL",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
value.type === "SLACK"
|
||||
? typeof value.channelValue === "string" && value.channelValue.startsWith("C")
|
||||
: true,
|
||||
{
|
||||
message: "Must select a Slack channel",
|
||||
path: ["channelValue"],
|
||||
}
|
||||
);
|
||||
|
||||
function formDataToCreateAlertChannelOptions(
|
||||
formData: z.infer<typeof FormSchema>
|
||||
): CreateAlertChannelOptions {
|
||||
switch (formData.type) {
|
||||
case "WEBHOOK": {
|
||||
return {
|
||||
name: `Webhook to ${new URL(formData.channelValue).hostname}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "EMAIL": {
|
||||
return {
|
||||
name: `Email to ${formData.channelValue}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: formData.channelValue,
|
||||
},
|
||||
};
|
||||
}
|
||||
case "SLACK": {
|
||||
const [channelId, channelName] = formData.channelValue.split("/");
|
||||
|
||||
return {
|
||||
name: `Slack message to ${channelName}`,
|
||||
alertTypes: Array.isArray(formData.alertTypes)
|
||||
? formData.alertTypes
|
||||
: [formData.alertTypes],
|
||||
channel: {
|
||||
type: "SLACK",
|
||||
channelId,
|
||||
channelName,
|
||||
integrationId: formData.integrationId,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found", { status: 404 });
|
||||
}
|
||||
|
||||
const presenter = new NewAlertChannelPresenter();
|
||||
|
||||
const results = await presenter.call(project.id);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const option = url.searchParams.get("option");
|
||||
|
||||
return typedjson({
|
||||
...results,
|
||||
option: option === "slack" ? ("SLACK" as const) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
|
||||
const submission = parse(formData, { schema: FormSchema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
submission.error.key = "Project not found";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
const alertChannel = await service.call(
|
||||
project.externalRef,
|
||||
userId,
|
||||
formDataToCreateAlertChannelOptions(submission.value)
|
||||
);
|
||||
|
||||
if (!alertChannel) {
|
||||
submission.error.key = "Failed to create alert channel";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Created ${alertChannel.name} alert`
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { slack, option } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
|
||||
const [form, { channelValue: channelValue, alertTypes, type, integrationId }] = useForm({
|
||||
id: "create-alert",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema: FormSchema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
|
||||
form.ref.current?.reset();
|
||||
}, [navigation.state, lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3ProjectAlertsPath(organization, project));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>New alert</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<Fieldset className="mt-2">
|
||||
<InputGroup fullWidth>
|
||||
<SegmentedControl
|
||||
{...conform.input(type)}
|
||||
options={[
|
||||
{ label: "Email", value: "EMAIL" },
|
||||
{ label: "Slack", value: "SLACK" },
|
||||
{ label: "Webhook", value: "WEBHOOK" },
|
||||
]}
|
||||
onChange={(value) => {
|
||||
setCurrentAlertChannel(value);
|
||||
}}
|
||||
fullWidth
|
||||
defaultValue={currentAlertChannel ?? undefined}
|
||||
/>
|
||||
</InputGroup>
|
||||
|
||||
{currentAlertChannel === "EMAIL" ? (
|
||||
<InputGroup fullWidth>
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="email@youremail.com"
|
||||
type="email"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
</InputGroup>
|
||||
) : currentAlertChannel === "SLACK" ? (
|
||||
<InputGroup fullWidth>
|
||||
{slack.status === "READY" ? (
|
||||
<>
|
||||
<Select
|
||||
{...conform.select(channelValue)}
|
||||
placeholder="Select a Slack channel"
|
||||
heading="Filter channels…"
|
||||
defaultValue={undefined}
|
||||
dropdownIcon
|
||||
variant="tertiary/medium"
|
||||
items={slack.channels}
|
||||
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 <SlackChannelTitle {...channel} />;
|
||||
}}
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
{matches?.map((channel) => (
|
||||
<SelectItem key={channel.id} value={`${channel.id}/${channel.name}`}>
|
||||
<SlackChannelTitle {...channel} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
<Hint className="leading-relaxed">
|
||||
If selecting a private channel, you will need to invite the bot to the channel
|
||||
using <InlineCode variant="extra-small">/invite @Trigger.dev</InlineCode>
|
||||
</Hint>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
<input type="hidden" name="integrationId" value={slack.integrationId} />
|
||||
</>
|
||||
) : slack.status === "NOT_CONFIGURED" ? (
|
||||
<LinkButton variant="tertiary/large" to="connect-to-slack" fullWidth>
|
||||
<span className="flex items-center gap-2 text-text-bright">
|
||||
<SlackIcon className="size-5" /> Connect to Slack
|
||||
</span>
|
||||
</LinkButton>
|
||||
) : (
|
||||
<Callout variant="warning">
|
||||
Slack integration is not available. Please contact your organization
|
||||
administrator.
|
||||
</Callout>
|
||||
)}
|
||||
</InputGroup>
|
||||
) : (
|
||||
<InputGroup fullWidth>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
{...conform.input(channelValue)}
|
||||
placeholder="https://foobar.com/webhooks"
|
||||
type="url"
|
||||
autoFocus
|
||||
/>
|
||||
<FormError id={channelValue.errorId}>{channelValue.error}</FormError>
|
||||
<Hint>We'll issue POST requests to this URL with a JSON payload.</Hint>
|
||||
</InputGroup>
|
||||
)}
|
||||
|
||||
<InputGroup fullWidth>
|
||||
<Label>Events</Label>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="TASK_RUN_ATTEMPT"
|
||||
value="TASK_RUN_ATTEMPT"
|
||||
variant="simple/small"
|
||||
label="Task run failure"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_FAILURE"
|
||||
value="DEPLOYMENT_FAILURE"
|
||||
variant="simple/small"
|
||||
label="Deployment failure"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Checkbox
|
||||
name={alertTypes.name}
|
||||
id="DEPLOYMENT_SUCCESS"
|
||||
value="DEPLOYMENT_SUCCESS"
|
||||
variant="simple/small"
|
||||
label="Deployment success"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<FormError>{form.error}</FormError>
|
||||
<div className="border-t border-grid-bright pt-3">
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
<Button
|
||||
variant="primary/medium"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create"
|
||||
>
|
||||
{isLoading ? "Saving…" : "Save"}
|
||||
</Button>
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
</LinkButton>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Fieldset>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
import { useForm } from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
BoltIcon,
|
||||
BoltSlashIcon,
|
||||
BookOpenIcon,
|
||||
EnvelopeIcon,
|
||||
GlobeAltIcon,
|
||||
LockClosedIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { ProjectAlertChannelType, ProjectAlertType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DetailCell } from "~/components/primitives/DetailCell";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableCellMenu,
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import {
|
||||
AlertChannelListPresenter,
|
||||
AlertChannelListPresenterRecord,
|
||||
} from "~/presenters/v3/AlertChannelListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
v3NewProjectAlertPath,
|
||||
v3ProjectAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new AlertChannelListPresenter();
|
||||
const data = await presenter.call(project.id);
|
||||
|
||||
return typedjson(data);
|
||||
};
|
||||
|
||||
const schema = z.discriminatedUnion("action", [
|
||||
z.object({ action: z.literal("delete"), id: z.string() }),
|
||||
z.object({ action: z.literal("disable"), id: z.string() }),
|
||||
z.object({ action: z.literal("enable"), id: z.string() }),
|
||||
]);
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
|
||||
if (!submission.value) {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
submission.error.key = "Project not found";
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "delete": {
|
||||
const alertChannel = await prisma.projectAlertChannel.delete({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Deleted ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "disable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: false },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Disabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
case "enable": {
|
||||
const alertChannel = await prisma.projectAlertChannel.update({
|
||||
where: { id: submission.value.id, projectId: project.id },
|
||||
data: { enabled: true },
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Enabled ${alertChannel.name} alert`
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { alertChannels } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Alerts" />
|
||||
<PageAccessories>
|
||||
<LinkButton
|
||||
LeadingIcon={BookOpenIcon}
|
||||
to={docsPath("v3/project-alerts")}
|
||||
variant="minimal/small"
|
||||
>
|
||||
Alerts docs
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("flex h-full flex-col gap-3")}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Alert Types</TableHeaderCell>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alertChannels.length > 0 ? (
|
||||
alertChannels.map((alertChannel) => (
|
||||
<TableRow key={alertChannel.id}>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.name}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
{alertChannel.alertTypes.map((type) => alertTypeTitle(type)).join(", ")}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus enabled={alertChannel.enabled} />
|
||||
</TableCell>
|
||||
<TableCellMenu isSticky>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</TableCellMenu>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5}>
|
||||
<div className="flex items-center justify-center">
|
||||
<Paragraph>No alerts have been created</Paragraph>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "delete-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
<Button
|
||||
name="action"
|
||||
value="delete"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={TrashIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Deleting" : "Delete"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function DisableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "disable-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="disable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltSlashIcon}
|
||||
leadingIconClassName="text-dimmed"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Disabling" : "Disable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function EnableAlertChannelButton(props: { id: string }) {
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "delete";
|
||||
|
||||
const [form, { id }] = useForm({
|
||||
id: "enable-alert-channel",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
return parse(formData, { schema });
|
||||
},
|
||||
shouldRevalidate: "onSubmit",
|
||||
});
|
||||
|
||||
return (
|
||||
<Form method="post" {...form.props}>
|
||||
<input type="hidden" name="id" value={props.id} />
|
||||
|
||||
<Button
|
||||
name="action"
|
||||
value="enable"
|
||||
type="submit"
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={BoltIcon}
|
||||
leadingIconClassName="text-success"
|
||||
className="text-xs"
|
||||
>
|
||||
{isLoading ? "Enabling" : "Enable"}
|
||||
</Button>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertChannelDetails({ alertChannel }: { alertChannel: AlertChannelListPresenterRecord }) {
|
||||
switch (alertChannel.properties?.type) {
|
||||
case "EMAIL": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Email"}
|
||||
description={alertChannel.properties.email}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">Webhook</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={alertChannel.properties.url}
|
||||
description={
|
||||
<ClipboardField
|
||||
value={alertChannel.properties.secret}
|
||||
variant="secondary/small"
|
||||
icon={<LockClosedIcon className="size-4" />}
|
||||
iconButton
|
||||
secure={"•".repeat(alertChannel.properties.secret.length)}
|
||||
className="mt-1 w-80"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
case "SLACK": {
|
||||
return (
|
||||
<DetailCell
|
||||
leadingIcon={
|
||||
<AlertChannelTypeIcon
|
||||
channelType={alertChannel.type}
|
||||
className="size-5 text-charcoal-400"
|
||||
/>
|
||||
}
|
||||
leadingIconClassName="text-charcoal-400"
|
||||
label={"Slack"}
|
||||
description={`#${alertChannel.properties.channelName}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alertTypeTitle(alertType: ProjectAlertType): string {
|
||||
switch (alertType) {
|
||||
case "TASK_RUN_ATTEMPT":
|
||||
return "Task attempt failure";
|
||||
case "DEPLOYMENT_FAILURE":
|
||||
return "Deployment failure";
|
||||
case "DEPLOYMENT_SUCCESS":
|
||||
return "Deployment success";
|
||||
default: {
|
||||
assertNever(alertType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function AlertChannelTypeIcon({
|
||||
channelType,
|
||||
className,
|
||||
}: {
|
||||
channelType: ProjectAlertChannelType;
|
||||
className: string;
|
||||
}) {
|
||||
switch (channelType) {
|
||||
case "EMAIL":
|
||||
return <EnvelopeIcon className={className} />;
|
||||
case "SLACK":
|
||||
return <SlackIcon className={className} />;
|
||||
case "WEBHOOK":
|
||||
return <GlobeAltIcon className={className} />;
|
||||
default: {
|
||||
assertNever(channelType);
|
||||
}
|
||||
}
|
||||
}
|
||||
+44
-1
@@ -1,8 +1,9 @@
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
@@ -68,6 +69,48 @@ export default function Page() {
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden bg-background-bright">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>Deploy: {deployment.shortCode}</Header2>
|
||||
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
<Property label="ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{deployment.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Project ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{deployment.projectId}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Org ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{deployment.organizationId}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
{deployment.imageReference && (
|
||||
<Property label="Image">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">
|
||||
{deployment.imageReference}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
)}
|
||||
{deployment.externalBuildData && (
|
||||
<Property label="Build Server">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
to={`/resources/${deployment.projectId}/deployments/${deployment.id}/logs`}
|
||||
className="extra-small/bright/mono underline"
|
||||
>
|
||||
{deployment.externalBuildData.buildId}
|
||||
</Link>
|
||||
</div>
|
||||
</Property>
|
||||
)}
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
to={`${v3DeploymentsPath(organization, project)}${page ? `?page=${page}` : ""}`}
|
||||
variant="minimal/medium"
|
||||
|
||||
+2
-3
@@ -46,13 +46,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
try {
|
||||
const presenter = new EnvironmentVariablesPresenter();
|
||||
const { environmentVariables, environments } = await presenter.call({
|
||||
const { environments } = await presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
environmentVariables,
|
||||
environments,
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -150,7 +149,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { environmentVariables, environments } = useTypedLoaderData<typeof loader>();
|
||||
const { environments } = useTypedLoaderData<typeof loader>();
|
||||
const lastSubmission = useActionData();
|
||||
const navigation = useNavigation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
+12
-3
@@ -30,8 +30,15 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = Object.fromEntries(url.searchParams.entries());
|
||||
const { tasks, versions, statuses, environments, from, to, cursor, direction } =
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
tasks: url.searchParams.getAll("tasks"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
};
|
||||
const { tasks, versions, statuses, environments, period, from, to, cursor, direction } =
|
||||
TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
@@ -42,6 +49,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction: direction,
|
||||
@@ -88,10 +96,11 @@ export default function Page() {
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<div className="mb-2 flex items-start justify-between gap-x-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
hasFilters={list.hasFilters}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
|
||||
+2
-11
@@ -8,7 +8,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
@@ -211,16 +211,7 @@ export default function Page() {
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Environments">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{schedule.environments.map((env) => (
|
||||
<EnvironmentLabel
|
||||
key={env.id}
|
||||
size="small"
|
||||
environment={env}
|
||||
userName={env.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels size="small" environments={schedule.environments} />
|
||||
</Property>
|
||||
<Property label="External ID">
|
||||
{schedule.externalId ? schedule.externalId : "–"}
|
||||
|
||||
+2
-10
@@ -6,7 +6,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentLabel, EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
@@ -281,15 +281,7 @@ function SchedulesTable({
|
||||
{schedule.lastRun ? <DateTime date={schedule.lastRun} timeZone="utc" /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<div className="flex gap-1">
|
||||
{schedule.environments.map((environment) => (
|
||||
<EnvironmentLabel
|
||||
key={environment.id}
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<EnvironmentLabels environments={schedule.environments} size="small" />
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnabledStatus enabled={schedule.active} />
|
||||
|
||||
+2
-31
@@ -30,11 +30,11 @@ import {
|
||||
} from "~/components/primitives/Table";
|
||||
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useFilterTasks } from "~/hooks/useFilterTasks";
|
||||
import { useLinkStatus } from "~/hooks/useLinkStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import {
|
||||
SelectedEnvironment,
|
||||
TaskListItem,
|
||||
@@ -153,36 +153,7 @@ function TaskSelector({
|
||||
tasks: TaskListItem[];
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<TaskListItem>({
|
||||
items: tasks,
|
||||
filter: (task, text) => {
|
||||
if (task.taskIdentifier.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.exportName.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.id.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.friendlyId.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const { filterText, setFilterText, filteredItems } = useFilterTasks<TaskListItem>({ tasks });
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-charcoal-800 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
|
||||
@@ -16,14 +16,7 @@ import { FormTitle } from "~/components/primitives/FormTitle";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -148,20 +141,24 @@ export default function NewOrganizationPage() {
|
||||
{canCreateV3Projects ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectVersion.id}>Project version</Label>
|
||||
<SelectGroup>
|
||||
<Select
|
||||
{...conform.input(projectVersion, { type: "select" })}
|
||||
defaultValue={defaultVersion}
|
||||
>
|
||||
<SelectTrigger width="full" size="medium">
|
||||
<SelectValue placeholder="Project version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="v2">Version 2</SelectItem>
|
||||
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Select
|
||||
{...conform.select(projectVersion)}
|
||||
defaultValue={undefined}
|
||||
variant="tertiary/medium"
|
||||
placeholder="Select version"
|
||||
dropdownIcon
|
||||
text={(value) => {
|
||||
switch (value) {
|
||||
case "v2":
|
||||
return "Version 2";
|
||||
case "v3":
|
||||
return "Version 3";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem value="v2">Version 2</SelectItem>
|
||||
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
|
||||
</Select>
|
||||
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
|
||||
@@ -17,14 +17,7 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { RadioGroupItem } from "~/components/primitives/RadioButton";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { featuresForRequest } from "~/features.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { createOrganization } from "~/models/organization.server";
|
||||
@@ -143,17 +136,24 @@ export default function NewOrganizationPage() {
|
||||
{v3Enabled ? (
|
||||
<InputGroup>
|
||||
<Label htmlFor={projectVersion.id}>Project version</Label>
|
||||
<SelectGroup>
|
||||
<Select {...conform.input(projectVersion, { type: "select" })} defaultValue={"v2"}>
|
||||
<SelectTrigger width="full" size="medium">
|
||||
<SelectValue placeholder="Project version" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="v2">Version 2</SelectItem>
|
||||
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Select
|
||||
{...conform.select(projectVersion)}
|
||||
defaultValue={undefined}
|
||||
variant="tertiary/medium"
|
||||
placeholder="Select version"
|
||||
dropdownIcon
|
||||
text={(value) => {
|
||||
switch (value) {
|
||||
case "v2":
|
||||
return "Version 2";
|
||||
case "v3":
|
||||
return "Version 3";
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectItem value="v2">Version 2</SelectItem>
|
||||
<SelectItem value="v3">Version 3 (Developer Preview)</SelectItem>
|
||||
</Select>
|
||||
<FormError id={projectVersion.errorId}>{projectVersion.error}</FormError>
|
||||
</InputGroup>
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
ApiAlertChannelPresenter,
|
||||
ApiCreateAlertChannel,
|
||||
} from "~/presenters/v3/ApiAlertChannelPresenter.server";
|
||||
import { authenticateApiRequestWithPersonalAccessToken } from "~/services/personalAccessToken.server";
|
||||
import { CreateAlertChannelService } from "~/v3/services/alerts/createAlertChannel.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectRef: z.string(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const authenticationResult = await authenticateApiRequestWithPersonalAccessToken(request);
|
||||
|
||||
if (!authenticationResult) {
|
||||
return json({ error: "Invalid or Missing Access Token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
return json({ error: "Invalid Params" }, { status: 400 });
|
||||
}
|
||||
|
||||
const { projectRef } = parsedParams.data;
|
||||
|
||||
const rawBody = await request.json();
|
||||
|
||||
const body = ApiCreateAlertChannel.safeParse(rawBody);
|
||||
|
||||
if (!body.success) {
|
||||
return json({ error: "Invalid request body", issues: body.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
const service = new CreateAlertChannelService();
|
||||
|
||||
try {
|
||||
if (body.data.channel === "email") {
|
||||
if (!body.data.channelData.email) {
|
||||
return json({ error: "Email is required" }, { status: 422 });
|
||||
}
|
||||
|
||||
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
|
||||
name: body.data.name,
|
||||
alertTypes: body.data.alertTypes.map((type) =>
|
||||
ApiAlertChannelPresenter.alertTypeFromApi(type)
|
||||
),
|
||||
channel: {
|
||||
type: "EMAIL",
|
||||
email: body.data.channelData.email,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
}
|
||||
|
||||
if (body.data.channel === "webhook") {
|
||||
if (!body.data.channelData.url) {
|
||||
return json({ error: "webhook url is required" }, { status: 422 });
|
||||
}
|
||||
|
||||
const alertChannel = await service.call(projectRef, authenticationResult.userId, {
|
||||
name: body.data.name,
|
||||
alertTypes: body.data.alertTypes.map((type) =>
|
||||
ApiAlertChannelPresenter.alertTypeFromApi(type)
|
||||
),
|
||||
channel: {
|
||||
type: "WEBHOOK",
|
||||
url: body.data.channelData.url,
|
||||
secret: body.data.channelData.secret,
|
||||
},
|
||||
deduplicationKey: body.data.deduplicationKey,
|
||||
});
|
||||
|
||||
return json(await ApiAlertChannelPresenter.alertChannelToApi(alertChannel));
|
||||
}
|
||||
|
||||
return json({ error: "Invalid channel type" }, { status: 422 });
|
||||
} catch (error) {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return json({ error: error.message }, { status: 422 });
|
||||
}
|
||||
|
||||
return json(
|
||||
{ error: error instanceof Error ? error.message : "Internal Server Error" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { ApiRetrieveRunPresenter } from "~/presenters/v3/ApiRetrieveRunPresenter.server";
|
||||
import { authenticateApiRequest } from "~/services/apiAuth.server";
|
||||
import { apiCors } from "~/utils/apiCors";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() === "OPTIONS") {
|
||||
return apiCors(request, json({}));
|
||||
}
|
||||
|
||||
const authenticationResult = await authenticateApiRequest(request, {
|
||||
allowPublicKey: true,
|
||||
});
|
||||
if (!authenticationResult) {
|
||||
return apiCors(request, json({ error: "Invalid or Missing API key" }, { status: 401 }));
|
||||
}
|
||||
|
||||
const authenticatedEnv = authenticationResult.environment;
|
||||
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsed.success) {
|
||||
return apiCors(request, json({ error: "Invalid or missing runId" }, { status: 400 }));
|
||||
}
|
||||
|
||||
const { runId } = parsed.data;
|
||||
|
||||
const showSecretDetails = authenticationResult.type === "PRIVATE";
|
||||
|
||||
const presenter = new ApiRetrieveRunPresenter();
|
||||
const result = await presenter.call(runId, authenticatedEnv, showSecretDetails);
|
||||
|
||||
if (!result) {
|
||||
return apiCors(request, json({ error: "Run not found" }, { status: 404 }));
|
||||
}
|
||||
|
||||
return apiCors(request, json(result));
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import z from "zod";
|
||||
import { redirectBackWithErrorMessage } from "~/models/message.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { CreateOrgIntegrationService } from "~/v3/services/createOrgIntegration.server";
|
||||
|
||||
const URLSearchSchema = z
|
||||
.object({
|
||||
code: z.string().optional(),
|
||||
state: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
serviceName: z.string(),
|
||||
});
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
if (request.method.toUpperCase() !== "GET") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
}
|
||||
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const url = requestUrl(request);
|
||||
|
||||
const parsedSearchParams = URLSearchSchema.safeParse(Object.fromEntries(url.searchParams));
|
||||
|
||||
if (!parsedSearchParams.success) {
|
||||
// TODO: this needs to lookup the redirect url in the cookies
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
if (parsedSearchParams.data.error) {
|
||||
// TODO: this needs to lookup the redirect url in the cookies
|
||||
throw new Response(parsedSearchParams.data.error, { status: 400 });
|
||||
}
|
||||
|
||||
if (!parsedSearchParams.data.code || !parsedSearchParams.data.state) {
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
const parsedParams = ParamsSchema.safeParse(params);
|
||||
|
||||
if (!parsedParams.success) {
|
||||
throw new Response("Invalid params", { status: 400 });
|
||||
}
|
||||
|
||||
const service = new CreateOrgIntegrationService();
|
||||
|
||||
const integration = await service.call(
|
||||
userId,
|
||||
parsedSearchParams.data.state,
|
||||
parsedParams.data.serviceName,
|
||||
parsedSearchParams.data.code
|
||||
);
|
||||
|
||||
if (integration) {
|
||||
return await OrgIntegrationRepository.redirectAfterAuth(request);
|
||||
}
|
||||
|
||||
return redirectBackWithErrorMessage(request, "Failed to connect to the service");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
|
||||
export function action({ request }: ActionFunctionArgs) {
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { ExternalBuildData } from "@trigger.dev/core/v3";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { redirectBackWithErrorMessage } from "~/models/message.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
projectId: z.string(),
|
||||
deploymentId: z.string(),
|
||||
});
|
||||
|
||||
// Redirects to Depot.dev
|
||||
export async function loader({ params, request }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectId, deploymentId } = ParamsSchema.parse(params);
|
||||
|
||||
await prisma.project.findFirstOrThrow({
|
||||
where: {
|
||||
id: projectId,
|
||||
organization: {
|
||||
members: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const deployment = await prisma.workerDeployment.findUniqueOrThrow({
|
||||
where: {
|
||||
id: deploymentId,
|
||||
projectId: projectId,
|
||||
},
|
||||
});
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
|
||||
if (!externalBuildData || externalBuildData.success === false) {
|
||||
return redirectBackWithErrorMessage(request, "No build data found for this deployment.");
|
||||
}
|
||||
|
||||
if (!env.DEPOT_ORG_ID) {
|
||||
return redirectBackWithErrorMessage(request, "No Depot organization ID found.");
|
||||
}
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: {
|
||||
Location: `https://depot.dev/orgs/${env.DEPOT_ORG_ID}/projects/${externalBuildData.data.projectId}/builds/${externalBuildData.data.buildId}/logs`,
|
||||
},
|
||||
});
|
||||
}
|
||||
+17
-27
@@ -21,14 +21,7 @@ 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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "~/components/primitives/Select";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -189,28 +182,25 @@ export function UpsertScheduleForm({
|
||||
<Fieldset>
|
||||
<InputGroup>
|
||||
<Label htmlFor={taskIdentifier.id}>Task</Label>
|
||||
<SelectGroup>
|
||||
<Select
|
||||
{...conform.input(taskIdentifier, { type: "select" })}
|
||||
defaultValue={schedule?.taskIdentifier}
|
||||
>
|
||||
<SelectTrigger size="medium" width="full">
|
||||
<SelectValue placeholder="Select task" className="ml-2 p-0" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{possibleTasks.map((task) => (
|
||||
<Select
|
||||
{...conform.select(taskIdentifier)}
|
||||
placeholder="Select a task"
|
||||
defaultValue={schedule?.taskIdentifier}
|
||||
heading={"Filter..."}
|
||||
items={possibleTasks}
|
||||
filter={(task, search) => task.toLowerCase().includes(search.toLowerCase())}
|
||||
dropdownIcon
|
||||
>
|
||||
{(matches) => (
|
||||
<>
|
||||
{matches?.map((task) => (
|
||||
<SelectItem key={task} value={task}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="pl-0.5 transition group-hover:text-text-bright"
|
||||
>
|
||||
{task}
|
||||
</Paragraph>
|
||||
{task}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
<FormError id={taskIdentifier.errorId}>{taskIdentifier.error}</FormError>
|
||||
</InputGroup>
|
||||
{showGenerateField && <AIGeneratedCronField onSuccess={setCronPattern} />}
|
||||
|
||||
@@ -278,10 +278,8 @@ export default function Story() {
|
||||
<span className="text-charcoal-900">Continue with GitHub</span>
|
||||
</Button>
|
||||
<Button variant="secondary/large" fullWidth>
|
||||
<EnvelopeIcon
|
||||
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
|
||||
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
|
||||
<span className="text-secondary">Continue with Email</span>
|
||||
</Button>
|
||||
<Button variant="tertiary/large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 size-[1.2rem]"} />
|
||||
@@ -308,10 +306,8 @@ export default function Story() {
|
||||
<span className="text-charcoal-900">Continue with GitHub</span>
|
||||
</Button>
|
||||
<Button variant="secondary/extra-large" fullWidth>
|
||||
<EnvelopeIcon
|
||||
className={"mr-1.5 h-5 w-5 text-primary transition group-hover:text-apple-200"}
|
||||
/>
|
||||
<span className="text-primary group-hover:text-apple-200">Continue with Email</span>
|
||||
<EnvelopeIcon className={"mr-1.5 h-5 w-5 text-secondary transition"} />
|
||||
<span className="text-secondary">Continue with Email</span>
|
||||
</Button>
|
||||
<Button variant="tertiary/extra-large" fullWidth>
|
||||
<GitHubLightIcon className={"mr-1.5 h-5 w-5"} />
|
||||
|
||||
@@ -14,6 +14,13 @@ export default function Story() {
|
||||
>
|
||||
{isDisabled ? "Enable checkboxes" : "Disable checkboxes"}
|
||||
</Button>
|
||||
<Checkbox
|
||||
name="Simple checkbox"
|
||||
id="check1"
|
||||
variant="simple/small"
|
||||
label="This is a simple small checkbox"
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
<Checkbox
|
||||
name="Simple checkbox"
|
||||
id="check1"
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
import { CpuChipIcon } from "@heroicons/react/20/solid";
|
||||
import { CircleStackIcon } from "@heroicons/react/24/outline";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { startTransition, useCallback, useMemo, useState } from "react";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import {
|
||||
ComboBox,
|
||||
ComboboxProvider,
|
||||
SelectButtonItem,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
runStatusTitle,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { ShortcutDefinition } from "~/hooks/useShortcutKeys";
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="flex h-full max-w-full flex-wrap items-start justify-start gap-2 px-8 py-16">
|
||||
<Form className="space-y-4">
|
||||
<div className="flex gap-16">
|
||||
<Filter />
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const filterTypes = [
|
||||
{
|
||||
name: "status",
|
||||
title: "Status",
|
||||
icon: <CircleStackIcon className="h-4 w-4" />,
|
||||
},
|
||||
{ name: "environment", title: "Environment", icon: <CpuChipIcon className="h-4 w-4" /> },
|
||||
];
|
||||
|
||||
type FilterType = (typeof filterTypes)[number]["name"];
|
||||
|
||||
//todo what if we had shortcut keys that would deeplink you to the appropriate filter?
|
||||
function Filter() {
|
||||
const [filterType, setFilterType] = useState<FilterType | undefined>();
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const shortcut = { key: "f" };
|
||||
|
||||
const clearSearchValue = useCallback(() => {
|
||||
setSearchValue("");
|
||||
}, [setSearchValue]);
|
||||
|
||||
const filterTrigger = (
|
||||
<SelectTrigger variant={"tertiary/small"} shortcut={shortcut} tooltipTitle={"Filter runs"}>
|
||||
Filter
|
||||
</SelectTrigger>
|
||||
);
|
||||
|
||||
return (
|
||||
<ComboboxProvider
|
||||
resetValueOnHide
|
||||
setValue={(value) => {
|
||||
startTransition(() => {
|
||||
setSearchValue(value);
|
||||
});
|
||||
}}
|
||||
setOpen={(open) => {
|
||||
if (!open) {
|
||||
setFilterType(undefined);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
searchValue={searchValue}
|
||||
clearSearchValue={clearSearchValue}
|
||||
shortcut={shortcut}
|
||||
trigger={filterTrigger}
|
||||
filterType={filterType}
|
||||
setFilterType={setFilterType}
|
||||
/>
|
||||
</ComboboxProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type MenuProps = {
|
||||
searchValue: string;
|
||||
clearSearchValue: () => void;
|
||||
shortcut: ShortcutDefinition;
|
||||
trigger: React.ReactNode;
|
||||
filterType: FilterType | undefined;
|
||||
setFilterType: (filterType: FilterType | undefined) => void;
|
||||
};
|
||||
|
||||
function Menu(props: MenuProps) {
|
||||
switch (props.filterType) {
|
||||
case undefined:
|
||||
return <MainMenu {...props} />;
|
||||
case "status":
|
||||
return <Statuses {...props} />;
|
||||
case "environment":
|
||||
return <Environments {...props} />;
|
||||
}
|
||||
return <></>;
|
||||
}
|
||||
|
||||
function MainMenu({ searchValue, clearSearchValue, setFilterType, trigger, shortcut }: MenuProps) {
|
||||
const filtered = useMemo(() => {
|
||||
return filterTypes.filter((item) =>
|
||||
item.title.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover>
|
||||
<ComboBox placeholder={"Filter..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((type, index) => (
|
||||
<SelectButtonItem
|
||||
key={type.name}
|
||||
onClick={() => {
|
||||
clearSearchValue();
|
||||
setFilterType(type.name);
|
||||
}}
|
||||
icon={type.icon}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
{type.title}
|
||||
</SelectButtonItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allTaskRunStatuses.map((status) => ({
|
||||
title: runStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function Statuses({ trigger, clearSearchValue, shortcut, searchValue, setFilterType }: MenuProps) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ status: values });
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return statuses.filter((item) => item.title.toLowerCase().includes(searchValue.toLowerCase()));
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("status")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEscape={() => {
|
||||
setFilterType(undefined);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by status..."} shortcut={shortcut} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const environments = [
|
||||
{
|
||||
type: "DEVELOPMENT" as const,
|
||||
},
|
||||
{
|
||||
type: "STAGING" as const,
|
||||
},
|
||||
{
|
||||
type: "PRODUCTION" as const,
|
||||
},
|
||||
];
|
||||
|
||||
function Environments({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
shortcut,
|
||||
searchValue,
|
||||
setFilterType,
|
||||
}: MenuProps) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ environment: values });
|
||||
}, []);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return environments.filter((item) =>
|
||||
item.type.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("environment")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
hideOnEscape={() => {
|
||||
setFilterType(undefined);
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<ComboBox
|
||||
placeholder={"Filter by environment..."}
|
||||
shortcut={shortcut}
|
||||
value={searchValue}
|
||||
/>
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.type}
|
||||
value={item.type}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { MainCenteredContainer } from "~/components/layout/AppLayout";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
|
||||
const options = [
|
||||
@@ -8,8 +9,15 @@ const options = [
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<MainCenteredContainer>
|
||||
<SegmentedControl name="name" options={options} />
|
||||
<MainCenteredContainer className="flex flex-col gap-8">
|
||||
<div className="flex flex-col gap-2">
|
||||
<Paragraph>Primary</Paragraph>
|
||||
<SegmentedControl name="name" options={options} variant="primary" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Paragraph>Secondary</Paragraph>
|
||||
<SegmentedControl name="name" options={options} variant="secondary" />
|
||||
</div>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,91 +1,272 @@
|
||||
import { Header1, Header2 } from "~/components/primitives/Headers";
|
||||
import { CircleStackIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useNavigate } from "@remix-run/react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LogoIcon } from "~/components/LogoIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectGroupLabel,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectSeparator,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectLinkItem,
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
allTaskRunStatuses,
|
||||
runStatusTitle,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
|
||||
const branches = [
|
||||
"main",
|
||||
"0.10-stable",
|
||||
"0.11-stable",
|
||||
"0.12-stable",
|
||||
"0.13-stable",
|
||||
"0.14-stable",
|
||||
"15-stable",
|
||||
"15.6-dev",
|
||||
"16.3-dev",
|
||||
"16.4.2-dev",
|
||||
"16.8.3",
|
||||
"16.8.4",
|
||||
"16.8.5",
|
||||
"16.8.6",
|
||||
"17.0.0-dev",
|
||||
"builds/facebook-www",
|
||||
"devtools-v4-merge",
|
||||
"fabric-cleanup",
|
||||
"fabric-focus-blur",
|
||||
"gh-pages",
|
||||
"leg",
|
||||
"nativefb-enable-cache",
|
||||
"nov-main-trigger",
|
||||
"rsckeys",
|
||||
];
|
||||
|
||||
export default function Story() {
|
||||
return (
|
||||
<div className="p-20">
|
||||
<div className="flex flex-col">
|
||||
<Header1 className="mb-4">Variants</Header1>
|
||||
<Header2 className="my-4">size=small width=content</Header2>
|
||||
<SelectGroup>
|
||||
<Select name="colorScheme" defaultValue="dark">
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectLabel>Color Scheme</SelectLabel>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectLabel>Other themes</SelectLabel>
|
||||
<SelectItem value="sunset">Sunset</SelectItem>
|
||||
<SelectItem value="midnight">Midnight</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
</SelectContent>
|
||||
<div className="flex h-full max-w-full flex-wrap items-start justify-start gap-2 px-4 py-16">
|
||||
<Form className="space-y-4">
|
||||
<div className="flex gap-16">
|
||||
<ProjectSelector />
|
||||
<Statuses />
|
||||
|
||||
<Select name="static" text="Static" defaultValue={[]} shortcut={{ key: "e" }}>
|
||||
<SelectItem value={"value"} shortcut={{ key: "1" }}>
|
||||
Item 1
|
||||
</SelectItem>
|
||||
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
|
||||
Item 2
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Header2 className="my-4">size=small width=full</Header2>
|
||||
<SelectGroup>
|
||||
<Select name="colorScheme" defaultValue="dark">
|
||||
<SelectTrigger width="full">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="sunset">Sunset</SelectItem>
|
||||
<SelectItem value="midnight">Midnight</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
<Select
|
||||
variant="tertiary/medium"
|
||||
text="Tertiary medium"
|
||||
defaultValue={[]}
|
||||
shortcut={{ key: "e" }}
|
||||
>
|
||||
<SelectItem value={"value"} shortcut={{ key: "1" }}>
|
||||
Item 1
|
||||
</SelectItem>
|
||||
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
|
||||
Item 2
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Header2 className="my-4">size=medium width=content</Header2>
|
||||
<SelectGroup>
|
||||
<Select name="colorScheme" defaultValue="dark">
|
||||
<SelectTrigger size="medium">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="sunset">Sunset</SelectItem>
|
||||
<SelectItem value="midnight">Midnight</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
<Select variant="minimal/small" text="Minimal" defaultValue={[]} shortcut={{ key: "e" }}>
|
||||
<SelectItem value={"value"} shortcut={{ key: "1" }}>
|
||||
Item 1
|
||||
</SelectItem>
|
||||
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
|
||||
Item 2
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
<Header2 className="my-4">size=medium width=full</Header2>
|
||||
<SelectGroup>
|
||||
<Select name="colorScheme" defaultValue="dark">
|
||||
<SelectTrigger size="medium" width="full">
|
||||
<SelectValue placeholder="Theme" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="sunset">Sunset</SelectItem>
|
||||
<SelectItem value="midnight">Midnight</SelectItem>
|
||||
<SelectSeparator />
|
||||
<SelectItem value="lunar">Lunar</SelectItem>
|
||||
</SelectContent>
|
||||
|
||||
<Select
|
||||
variant="minimal/medium"
|
||||
text="Tertiary medium"
|
||||
defaultValue={[]}
|
||||
shortcut={{ key: "e" }}
|
||||
>
|
||||
<SelectItem value={"value"} shortcut={{ key: "1" }}>
|
||||
Item 1
|
||||
</SelectItem>
|
||||
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
|
||||
Item 2
|
||||
</SelectItem>
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
name="static"
|
||||
text="Heading"
|
||||
defaultValue={[]}
|
||||
showHeading={true}
|
||||
heading="A heading"
|
||||
shortcut={{ key: "h" }}
|
||||
>
|
||||
<SelectItem value={"value"} shortcut={{ key: "1" }}>
|
||||
Item 1
|
||||
</SelectItem>
|
||||
<SelectItem value={"value2"} shortcut={{ key: "2" }}>
|
||||
Item 2
|
||||
</SelectItem>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
name="branch2"
|
||||
heading={"Filter by status..."}
|
||||
defaultValue={"main"}
|
||||
items={branches}
|
||||
>
|
||||
{(matches) => matches?.map((value) => <SelectItem key={value} value={value} />)}
|
||||
</Select>
|
||||
|
||||
<Button variant="tertiary/small">Submit</Button>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const statuses = allTaskRunStatuses.map((status) => ({
|
||||
title: runStatusTitle(status),
|
||||
value: status,
|
||||
}));
|
||||
|
||||
function Statuses() {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
|
||||
const handleChange = useCallback((values: string[]) => {
|
||||
search.delete("status");
|
||||
for (const value of values) {
|
||||
search.append("status", value);
|
||||
}
|
||||
navigate(`${location.pathname}?${search.toString()}`, { replace: true });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Select
|
||||
name="status"
|
||||
text="Status"
|
||||
value={search.getAll("status")}
|
||||
setValue={handleChange}
|
||||
heading={"Filter by status..."}
|
||||
items={statuses}
|
||||
filter={(item, search) => item.title.toLowerCase().includes(search.toLowerCase())}
|
||||
shortcut={{ key: "s" }}
|
||||
>
|
||||
{(matches, { shortcutsEnabled }) => (
|
||||
<>
|
||||
{matches?.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.value}
|
||||
value={item.value}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled })}
|
||||
>
|
||||
<TaskRunStatusCombo status={item.value} iconClassName="animate-none" />
|
||||
</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
export const projects = [
|
||||
{
|
||||
type: "section" as const,
|
||||
title: "Apple",
|
||||
items: [
|
||||
{
|
||||
title: "iTunes",
|
||||
value: "itunes",
|
||||
},
|
||||
{
|
||||
title: "App Store",
|
||||
value: "appstore",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "section" as const,
|
||||
title: "Google",
|
||||
items: [
|
||||
{
|
||||
title: "Maps",
|
||||
value: "maps",
|
||||
},
|
||||
{
|
||||
title: "Gmail",
|
||||
value: "gmail",
|
||||
},
|
||||
{
|
||||
title: "Waymo",
|
||||
value: "waymo",
|
||||
},
|
||||
{
|
||||
title: "Android",
|
||||
value: "android",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "section" as const,
|
||||
title: "Uber",
|
||||
items: [
|
||||
{
|
||||
title: "Planner",
|
||||
value: "planner",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function ProjectSelector() {
|
||||
const location = useOptimisticLocation();
|
||||
const search = new URLSearchParams(location.search);
|
||||
|
||||
const selected = projects
|
||||
.find((p) => p.items.some((i) => i.value === search.get("project")))
|
||||
?.items.find((i) => i.value === search.get("project"));
|
||||
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
searchParams.delete("project");
|
||||
|
||||
return (
|
||||
<Select
|
||||
name="project"
|
||||
defaultValue={selected?.value}
|
||||
text={selected?.title}
|
||||
heading="Find project..."
|
||||
icon={<LogoIcon className="h-3 w-3" />}
|
||||
items={projects}
|
||||
shortcut={{ key: "p", modifiers: ["alt"] }}
|
||||
filter={(item, search, sectionTitle) =>
|
||||
sectionTitle?.toLowerCase().includes(search.toLowerCase()) ||
|
||||
item.title.toLowerCase().includes(search.toLowerCase())
|
||||
}
|
||||
>
|
||||
{(matches, { shortcutsEnabled, section }) => (
|
||||
<SelectGroup>
|
||||
{section && <SelectGroupLabel>{section.title}</SelectGroupLabel>}
|
||||
{matches?.map((match, index) => (
|
||||
<SelectLinkItem
|
||||
icon={<CircleStackIcon className="size-3" />}
|
||||
key={match.value}
|
||||
value={match.value}
|
||||
to={`?${searchParams.toString()}&project=${match.value}`}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled, section })}
|
||||
>
|
||||
{match.title}
|
||||
</SelectLinkItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,10 @@ const stories: Story[] = [
|
||||
name: "Select",
|
||||
slug: "select",
|
||||
},
|
||||
{
|
||||
name: "Filter",
|
||||
slug: "filter",
|
||||
},
|
||||
{
|
||||
name: "Popover",
|
||||
slug: "popover",
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { runMigrations } from "graphile-worker";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { PgNotifyService } from "./pgNotify.server";
|
||||
import { z } from "zod";
|
||||
|
||||
export class GraphileMigrationHelperService {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call() {
|
||||
this.#logDebug("GraphileMigrationHelperService.call");
|
||||
|
||||
await this.#detectAndPrepareForMigrations();
|
||||
|
||||
await runMigrations({
|
||||
connectionString: env.DATABASE_URL,
|
||||
schema: env.WORKER_SCHEMA,
|
||||
});
|
||||
}
|
||||
|
||||
#logDebug(message: string, args?: any) {
|
||||
logger.debug(`[migrationHelper] ${message}`, args);
|
||||
}
|
||||
|
||||
async #getLatestMigration() {
|
||||
const migrationQueryResult = await this.#prismaClient.$queryRawUnsafe(`
|
||||
SELECT id FROM ${env.WORKER_SCHEMA}.migrations
|
||||
ORDER BY id DESC LIMIT 1
|
||||
`);
|
||||
|
||||
const MigrationQueryResultSchema = z.array(z.object({ id: z.number() }));
|
||||
|
||||
const migrationResults = MigrationQueryResultSchema.parse(migrationQueryResult);
|
||||
|
||||
if (!migrationResults.length) {
|
||||
// no migrations applied yet
|
||||
return -1;
|
||||
}
|
||||
|
||||
return migrationResults[0].id;
|
||||
}
|
||||
|
||||
async #graphileSchemaExists() {
|
||||
const schemaCount = await this.#prismaClient.$executeRaw`
|
||||
SELECT schema_name FROM information_schema.schemata
|
||||
WHERE schema_name = ${env.WORKER_SCHEMA}
|
||||
`;
|
||||
|
||||
return schemaCount === 1;
|
||||
}
|
||||
|
||||
/** Helper for graphile-worker v0.14.0 migration. No-op if already migrated. */
|
||||
async #detectAndPrepareForMigrations() {
|
||||
if (!(await this.#graphileSchemaExists())) {
|
||||
// no schema yet, likely first start
|
||||
return;
|
||||
}
|
||||
|
||||
const latestMigration = await this.#getLatestMigration();
|
||||
|
||||
if (latestMigration < 0) {
|
||||
// no migrations found
|
||||
return;
|
||||
}
|
||||
|
||||
// the first v0.14.0 migration has ID 11
|
||||
if (latestMigration > 10) {
|
||||
// already migrated
|
||||
return;
|
||||
}
|
||||
|
||||
// add 15s to graceful shutdown timeout, just to be safe
|
||||
const migrationDelayInMs = env.GRACEFUL_SHUTDOWN_TIMEOUT + 15000;
|
||||
|
||||
this.#logDebug("Delaying worker startup due to pending migration", {
|
||||
latestMigration,
|
||||
migrationDelayInMs,
|
||||
});
|
||||
|
||||
console.log(`⚠️ detected pending graphile migration`);
|
||||
console.log(`⚠️ notifying running workers`);
|
||||
|
||||
const pgNotify = new PgNotifyService();
|
||||
await pgNotify.call("trigger:graphile:migrate", { latestMigration });
|
||||
|
||||
console.log(`⚠️ delaying worker startup by ${migrationDelayInMs}ms`);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, migrationDelayInMs));
|
||||
}
|
||||
}
|
||||
@@ -60,6 +60,11 @@ export class IngestSendEvent {
|
||||
try {
|
||||
const deliverAt = this.#calculateDeliverAt(options);
|
||||
|
||||
if (!environment.organization.runsEnabled) {
|
||||
logger.debug("IngestSendEvent: Runs are disabled for this organization", environment);
|
||||
return;
|
||||
}
|
||||
|
||||
return await $transaction(this.#prismaClient, async (tx) => {
|
||||
const externalAccount = options?.accountId
|
||||
? await tx.externalAccount.upsert({
|
||||
|
||||
@@ -8,6 +8,7 @@ import { $transaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "../worker.server";
|
||||
import { ResumeRunService } from "./resumeRun.server";
|
||||
import { createHash } from "node:crypto";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
type FoundRun = NonNullable<Awaited<ReturnType<typeof findRun>>>;
|
||||
type RunConnectionsByKey = Awaited<ReturnType<typeof createRunConnections>>;
|
||||
@@ -36,6 +37,13 @@ export class StartRunService {
|
||||
}
|
||||
|
||||
#runIsStartable(run: FoundRun) {
|
||||
if (!run.organization.runsEnabled) {
|
||||
logger.debug("StartRunService: Runs are disabled for this organization", {
|
||||
organizationId: run.organization.id,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const startableStatuses = ["PENDING", "WAITING_ON_CONNECTIONS"] as const;
|
||||
return startableStatuses.includes(run.status);
|
||||
}
|
||||
@@ -64,8 +72,6 @@ export class StartRunService {
|
||||
await $transaction(
|
||||
this.#prismaClient,
|
||||
async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.jobCounter.upsert({
|
||||
where: { jobId: run.jobId },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
@@ -146,6 +152,7 @@ async function findRun(tx: PrismaClientOrTransaction, id: string) {
|
||||
include: {
|
||||
queue: true,
|
||||
environment: true,
|
||||
organization: true,
|
||||
version: {
|
||||
include: {
|
||||
integrations: {
|
||||
|
||||
@@ -55,12 +55,14 @@ export class SecretStore {
|
||||
}
|
||||
}
|
||||
|
||||
const EncryptedSecretValueSchema = z.object({
|
||||
export const EncryptedSecretValueSchema = z.object({
|
||||
nonce: z.string(),
|
||||
ciphertext: z.string(),
|
||||
tag: z.string(),
|
||||
});
|
||||
|
||||
export type EncryptedSecretValue = z.infer<typeof EncryptedSecretValueSchema>;
|
||||
|
||||
/** This stores secrets in the Postgres Database, encrypted using aes-256-gcm */
|
||||
class PrismaSecretStore implements SecretStoreProvider {
|
||||
#prismaClient: PrismaClientOrTransaction;
|
||||
@@ -181,18 +183,11 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
}
|
||||
|
||||
async #decrypt(nonce: string, ciphertext: string, tag: string): Promise<string> {
|
||||
const decipher = nodeCrypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
this.encryptionKey,
|
||||
Buffer.from(nonce, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
return await decryptSecret(this.encryptionKey, {
|
||||
nonce,
|
||||
ciphertext,
|
||||
tag,
|
||||
});
|
||||
}
|
||||
|
||||
async #encrypt(value: string): Promise<{
|
||||
@@ -200,19 +195,7 @@ class PrismaSecretStore implements SecretStoreProvider {
|
||||
ciphertext: string;
|
||||
tag: string;
|
||||
}> {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", this.encryptionKey, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
return await encryptSecret(this.encryptionKey, value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -234,3 +217,40 @@ export function getSecretStore<
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function decryptSecret(
|
||||
encryptionKey: string,
|
||||
secret: EncryptedSecretValue
|
||||
): Promise<string> {
|
||||
const decipher = nodeCrypto.createDecipheriv(
|
||||
"aes-256-gcm",
|
||||
encryptionKey,
|
||||
Buffer.from(secret.nonce, "hex")
|
||||
);
|
||||
|
||||
decipher.setAuthTag(Buffer.from(secret.tag, "hex"));
|
||||
|
||||
let decrypted = decipher.update(secret.ciphertext, "hex", "utf8");
|
||||
decrypted += decipher.final("utf8");
|
||||
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
export async function encryptSecret(
|
||||
encryptionKey: string,
|
||||
value: string
|
||||
): Promise<EncryptedSecretValue> {
|
||||
const nonce = nodeCrypto.randomBytes(12);
|
||||
const cipher = nodeCrypto.createCipheriv("aes-256-gcm", encryptionKey, nonce);
|
||||
|
||||
let encrypted = cipher.update(value, "utf8", "hex");
|
||||
encrypted += cipher.final("hex");
|
||||
|
||||
const tag = cipher.getAuthTag().toString("hex");
|
||||
|
||||
return {
|
||||
nonce: nonce.toString("hex"),
|
||||
ciphertext: encrypted,
|
||||
tag,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { workerQueue } from "../worker.server";
|
||||
import { requestUrl } from "~/utils/requestUrl.server";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { createHttpSourceRequest } from "~/utils/createHttpSourceRequest";
|
||||
import { logger } from "../logger.server";
|
||||
|
||||
export class HandleHttpSourceService {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -19,6 +20,7 @@ export class HandleHttpSourceService {
|
||||
endpoint: true,
|
||||
environment: true,
|
||||
secretReference: true,
|
||||
organization: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -30,6 +32,13 @@ export class HandleHttpSourceService {
|
||||
return { status: 200 };
|
||||
}
|
||||
|
||||
if (!triggerSource.organization.runsEnabled) {
|
||||
logger.debug("HandleHttpSourceService: Runs are disabled for this organization", {
|
||||
organizationId: triggerSource.organization.id,
|
||||
});
|
||||
return { status: 404 };
|
||||
}
|
||||
|
||||
if (!triggerSource.interactive) {
|
||||
const sourceRequest = await createHttpSourceRequest(request);
|
||||
|
||||
|
||||
@@ -37,8 +37,6 @@ export class HandleWebhookRequestService {
|
||||
const lockId = webhookIdToLockId(webhookEnvironment.webhookId);
|
||||
|
||||
await this.#prismaClient.$transaction(async (tx) => {
|
||||
await tx.$executeRaw`SELECT pg_advisory_xact_lock(${lockId})`;
|
||||
|
||||
const counter = await tx.webhookDeliveryCounter.upsert({
|
||||
where: { webhookId: webhookEnvironment.id },
|
||||
update: { lastNumber: { increment: 1 } },
|
||||
|
||||
@@ -37,6 +37,10 @@ import { TimeoutDeploymentService } from "~/v3/services/timeoutDeployment.server
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "~/v3/services/executeTasksWaitingForDeploy";
|
||||
import { TriggerScheduledTaskService } from "~/v3/services/triggerScheduledTask.server";
|
||||
import { PerformTaskAttemptAlertsService } from "~/v3/services/alerts/performTaskAttemptAlerts.server";
|
||||
import { DeliverAlertService } from "~/v3/services/alerts/deliverAlert.server";
|
||||
import { PerformDeploymentAlertsService } from "~/v3/services/alerts/performDeploymentAlerts.server";
|
||||
import { GraphileMigrationHelperService } from "./db/graphileMigrationHelper.server";
|
||||
|
||||
const workerCatalog = {
|
||||
indexEndpoint: z.object({
|
||||
@@ -136,6 +140,15 @@ const workerCatalog = {
|
||||
"v3.triggerScheduledTask": z.object({
|
||||
instanceId: z.string(),
|
||||
}),
|
||||
"v3.performTaskAttemptAlerts": z.object({
|
||||
attemptId: z.string(),
|
||||
}),
|
||||
"v3.deliverAlert": z.object({
|
||||
alertId: z.string(),
|
||||
}),
|
||||
"v3.performDeploymentAlerts": z.object({
|
||||
deploymentId: z.string(),
|
||||
}),
|
||||
};
|
||||
|
||||
const executionWorkerCatalog = {
|
||||
@@ -199,9 +212,8 @@ if (env.NODE_ENV === "production") {
|
||||
}
|
||||
|
||||
export async function init() {
|
||||
// const pgNotify = new PgNotifyService();
|
||||
// await pgNotify.call("trigger:graphile:migrate", { latestMigration: 10 });
|
||||
// await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
const migrationHelper = new GraphileMigrationHelperService();
|
||||
await migrationHelper.call();
|
||||
|
||||
if (env.WORKER_ENABLED === "true") {
|
||||
await workerQueue.initialize();
|
||||
@@ -222,7 +234,7 @@ function getWorkerQueue() {
|
||||
prisma,
|
||||
cleanup: {
|
||||
frequencyExpression: "13,27,43 * * * *",
|
||||
ttl: 7 * 24 * 60 * 60 * 1000, // 7 days
|
||||
ttl: env.WORKER_CLEANUP_TTL_DAYS * 24 * 60 * 60 * 1000, // X days
|
||||
maxCount: 1000,
|
||||
},
|
||||
runnerOptions: {
|
||||
@@ -238,7 +250,7 @@ function getWorkerQueue() {
|
||||
recurringTasks: {
|
||||
// Run this every 5 minutes
|
||||
autoIndexProductionEndpoints: {
|
||||
pattern: "*/5 * * * *",
|
||||
match: "*/5 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
const service = new RecurringEndpointIndexService();
|
||||
|
||||
@@ -247,7 +259,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
// Run this every hour
|
||||
purgeOldIndexings: {
|
||||
pattern: "0 * * * *",
|
||||
match: "0 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
// Delete indexings that are older than 7 days
|
||||
await prisma.endpointIndex.deleteMany({
|
||||
@@ -261,7 +273,7 @@ function getWorkerQueue() {
|
||||
},
|
||||
// Run this every hour at the 13 minute mark
|
||||
purgeOldTaskEvents: {
|
||||
pattern: "47 * * * *",
|
||||
match: "47 * * * *",
|
||||
handler: async (payload, job) => {
|
||||
await eventRepository.truncateEvents();
|
||||
},
|
||||
@@ -533,6 +545,33 @@ function getWorkerQueue() {
|
||||
return await service.call(payload.instanceId);
|
||||
},
|
||||
},
|
||||
"v3.performTaskAttemptAlerts": {
|
||||
priority: 0,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformTaskAttemptAlertsService();
|
||||
|
||||
return await service.call(payload.attemptId);
|
||||
},
|
||||
},
|
||||
"v3.deliverAlert": {
|
||||
priority: 0,
|
||||
maxAttempts: 8,
|
||||
handler: async (payload, job) => {
|
||||
const service = new DeliverAlertService();
|
||||
|
||||
return await service.call(payload.alertId);
|
||||
},
|
||||
},
|
||||
"v3.performDeploymentAlerts": {
|
||||
priority: 0,
|
||||
maxAttempts: 3,
|
||||
handler: async (payload, job) => {
|
||||
const service = new PerformDeploymentAlertsService();
|
||||
|
||||
return await service.call(payload.deploymentId);
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
-1
@@ -1,5 +1,4 @@
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { logger } from "./logger.server";
|
||||
|
||||
const environmentSortOrder: RuntimeEnvironmentType[] = [
|
||||
"DEVELOPMENT",
|
||||
@@ -325,6 +325,21 @@ export function v3NewEnvironmentVariablesPath(organization: OrgForPath, project:
|
||||
return `${v3EnvironmentVariablesPath(organization, project)}/new`;
|
||||
}
|
||||
|
||||
export function v3ProjectAlertsPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectPath(organization, project)}/alerts`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPath(organization: OrgForPath, project: ProjectForPath) {
|
||||
return `${v3ProjectAlertsPath(organization, project)}/new`;
|
||||
}
|
||||
|
||||
export function v3NewProjectAlertPathConnectToSlackPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath
|
||||
) {
|
||||
return `${v3ProjectAlertsPath(organization, project)}/new/connect-to-slack`;
|
||||
}
|
||||
|
||||
export function v3TestPath(
|
||||
organization: OrgForPath,
|
||||
project: ProjectForPath,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { ProjectAlertChannel, ProjectAlertType } from "@trigger.dev/database";
|
||||
import { nanoid } from "nanoid";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectByRef } from "~/models/project.server";
|
||||
import { encryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService, ServiceValidationError } from "../baseService.server";
|
||||
|
||||
export type CreateAlertChannelOptions = {
|
||||
name: string;
|
||||
alertTypes: ProjectAlertType[];
|
||||
deduplicationKey?: string;
|
||||
channel:
|
||||
| {
|
||||
type: "EMAIL";
|
||||
email: string;
|
||||
}
|
||||
| {
|
||||
type: "WEBHOOK";
|
||||
url: string;
|
||||
secret?: string;
|
||||
}
|
||||
| {
|
||||
type: "SLACK";
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
integrationId: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export class CreateAlertChannelService extends BaseService {
|
||||
public async call(
|
||||
projectRef: string,
|
||||
userId: string,
|
||||
options: CreateAlertChannelOptions
|
||||
): Promise<ProjectAlertChannel> {
|
||||
const project = await findProjectByRef(projectRef, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new ServiceValidationError("Project not found");
|
||||
}
|
||||
|
||||
const existingAlertChannel = options.deduplicationKey
|
||||
? await this._prisma.projectAlertChannel.findUnique({
|
||||
where: {
|
||||
projectId_deduplicationKey: {
|
||||
projectId: project.id,
|
||||
deduplicationKey: options.deduplicationKey,
|
||||
},
|
||||
},
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (existingAlertChannel) {
|
||||
return await this._prisma.projectAlertChannel.update({
|
||||
where: { id: existingAlertChannel.id },
|
||||
data: {
|
||||
name: options.name,
|
||||
alertTypes: options.alertTypes,
|
||||
type: options.channel.type,
|
||||
properties: await this.#createProperties(options.channel),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const alertChannel = await this._prisma.projectAlertChannel.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert_channel"),
|
||||
name: options.name,
|
||||
alertTypes: options.alertTypes,
|
||||
projectId: project.id,
|
||||
type: options.channel.type,
|
||||
properties: await this.#createProperties(options.channel),
|
||||
enabled: true,
|
||||
deduplicationKey: options.deduplicationKey,
|
||||
userProvidedDeduplicationKey: options.deduplicationKey ? true : false,
|
||||
},
|
||||
});
|
||||
|
||||
return alertChannel;
|
||||
}
|
||||
|
||||
async #createProperties(channel: CreateAlertChannelOptions["channel"]) {
|
||||
switch (channel.type) {
|
||||
case "EMAIL":
|
||||
return {
|
||||
email: channel.email,
|
||||
};
|
||||
case "WEBHOOK":
|
||||
return {
|
||||
url: channel.url,
|
||||
secret: await encryptSecret(env.ENCRYPTION_KEY, channel.secret ?? nanoid()),
|
||||
};
|
||||
case "SLACK":
|
||||
return {
|
||||
channelId: channel.channelId,
|
||||
channelName: channel.channelName,
|
||||
integrationId: channel.integrationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,775 @@
|
||||
import { TaskRunError, createJsonErrorObject } from "@trigger.dev/core/v3";
|
||||
import assertNever from "assert-never";
|
||||
import { subtle } from "crypto";
|
||||
import { Prisma, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
ProjectAlertEmailProperties,
|
||||
ProjectAlertSlackProperties,
|
||||
ProjectAlertSlackStorage,
|
||||
ProjectAlertWebhookProperties,
|
||||
} from "~/models/projectAlert.server";
|
||||
import { DeploymentPresenter } from "~/presenters/v3/DeploymentPresenter.server";
|
||||
import { sendEmail } from "~/services/email.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { OrgIntegrationRepository } from "~/models/orgIntegration.server";
|
||||
|
||||
type FoundAlert = Prisma.Result<
|
||||
typeof prisma.projectAlert,
|
||||
{
|
||||
include: {
|
||||
channel: true;
|
||||
project: {
|
||||
include: {
|
||||
organization: true;
|
||||
};
|
||||
};
|
||||
environment: true;
|
||||
taskRunAttempt: {
|
||||
include: {
|
||||
taskRun: true;
|
||||
backgroundWorkerTask: true;
|
||||
backgroundWorker: true;
|
||||
};
|
||||
};
|
||||
workerDeployment: {
|
||||
include: {
|
||||
worker: {
|
||||
include: {
|
||||
tasks: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
},
|
||||
"findUniqueOrThrow"
|
||||
>;
|
||||
|
||||
export class DeliverAlertService extends BaseService {
|
||||
public async call(alertId: string) {
|
||||
const alert = await this._prisma.projectAlert.findUnique({
|
||||
where: { id: alertId },
|
||||
include: {
|
||||
channel: true,
|
||||
project: {
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
},
|
||||
environment: true,
|
||||
taskRunAttempt: {
|
||||
include: {
|
||||
taskRun: true,
|
||||
backgroundWorkerTask: true,
|
||||
backgroundWorker: true,
|
||||
},
|
||||
},
|
||||
workerDeployment: {
|
||||
include: {
|
||||
worker: {
|
||||
include: {
|
||||
tasks: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!alert) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alert.status !== "PENDING") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (alert.environment.type === "DEVELOPMENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.channel.type) {
|
||||
case "EMAIL": {
|
||||
await this.#sendEmail(alert);
|
||||
break;
|
||||
}
|
||||
case "SLACK": {
|
||||
await this.#sendSlack(alert);
|
||||
break;
|
||||
}
|
||||
case "WEBHOOK": {
|
||||
await this.#sendWebhook(alert);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.channel.type);
|
||||
}
|
||||
}
|
||||
|
||||
await this._prisma.projectAlert.update({
|
||||
where: { id: alertId },
|
||||
data: {
|
||||
status: "SENT",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async #sendEmail(alert: FoundAlert) {
|
||||
const emailProperties = ProjectAlertEmailProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!emailProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse email properties", {
|
||||
issues: emailProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: "alert-attempt",
|
||||
to: emailProperties.data.email,
|
||||
taskIdentifier: alert.taskRunAttempt.taskRun.taskIdentifier,
|
||||
fileName: alert.taskRunAttempt.backgroundWorkerTask.filePath,
|
||||
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
|
||||
version: alert.taskRunAttempt.backgroundWorker.version,
|
||||
environment: alert.environment.slug,
|
||||
error: createJsonErrorObject(taskRunError.data),
|
||||
attemptLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await sendEmail({
|
||||
email: "alert-deployment-failure",
|
||||
to: emailProperties.data.email,
|
||||
version: alert.workerDeployment.version,
|
||||
environment: alert.environment.slug,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
failedAt: alert.workerDeployment.failedAt ?? new Date(),
|
||||
error: preparedError,
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
await sendEmail({
|
||||
email: "alert-deployment-success",
|
||||
to: emailProperties.data.email,
|
||||
version: alert.workerDeployment.version,
|
||||
environment: alert.environment.slug,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
|
||||
deploymentLink: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
taskCount: alert.workerDeployment.worker?.tasks.length ?? 0,
|
||||
});
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendWebhook(alert: FoundAlert) {
|
||||
const webhookProperties = ProjectAlertWebhookProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!webhookProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse webhook properties", {
|
||||
issues: webhookProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = createJsonErrorObject(taskRunError.data);
|
||||
|
||||
const payload = {
|
||||
task: {
|
||||
id: alert.taskRunAttempt.taskRun.taskIdentifier,
|
||||
filePath: alert.taskRunAttempt.backgroundWorkerTask.filePath,
|
||||
exportName: alert.taskRunAttempt.backgroundWorkerTask.exportName,
|
||||
},
|
||||
attempt: {
|
||||
id: alert.taskRunAttempt.friendlyId,
|
||||
number: alert.taskRunAttempt.number,
|
||||
startedAt: alert.taskRunAttempt.startedAt,
|
||||
status: alert.taskRunAttempt.status,
|
||||
},
|
||||
run: {
|
||||
id: alert.taskRunAttempt.taskRun.friendlyId,
|
||||
isTest: alert.taskRunAttempt.taskRun.isTest,
|
||||
createdAt: alert.taskRunAttempt.taskRun.createdAt,
|
||||
idempotencyKey: alert.taskRunAttempt.taskRun.idempotencyKey,
|
||||
},
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
error,
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
deployment: {
|
||||
id: alert.workerDeployment.friendlyId,
|
||||
status: alert.workerDeployment.status,
|
||||
version: alert.workerDeployment.version,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
failedAt: alert.workerDeployment.failedAt ?? new Date(),
|
||||
},
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
error: preparedError,
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
const payload = {
|
||||
deployment: {
|
||||
id: alert.workerDeployment.friendlyId,
|
||||
status: alert.workerDeployment.status,
|
||||
version: alert.workerDeployment.version,
|
||||
shortCode: alert.workerDeployment.shortCode,
|
||||
deployedAt: alert.workerDeployment.deployedAt ?? new Date(),
|
||||
},
|
||||
tasks:
|
||||
alert.workerDeployment.worker?.tasks.map((task) => ({
|
||||
id: task.slug,
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
triggerSource: task.triggerSource,
|
||||
})) ?? [],
|
||||
environment: {
|
||||
id: alert.environment.id,
|
||||
type: alert.environment.type,
|
||||
slug: alert.environment.slug,
|
||||
},
|
||||
organization: {
|
||||
id: alert.project.organizationId,
|
||||
slug: alert.project.organization.slug,
|
||||
name: alert.project.organization.title,
|
||||
},
|
||||
project: {
|
||||
id: alert.project.id,
|
||||
ref: alert.project.externalRef,
|
||||
slug: alert.project.slug,
|
||||
name: alert.project.name,
|
||||
},
|
||||
};
|
||||
|
||||
await this.#deliverWebhook(payload, webhookProperties.data);
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
assertNever(alert.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #sendSlack(alert: FoundAlert) {
|
||||
const slackProperties = ProjectAlertSlackProperties.safeParse(alert.channel.properties);
|
||||
|
||||
if (!slackProperties.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse slack properties", {
|
||||
issues: slackProperties.error.issues,
|
||||
properties: alert.channel.properties,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the org integration
|
||||
const integration = slackProperties.data.integrationId
|
||||
? await this._prisma.organizationIntegration.findUnique({
|
||||
where: {
|
||||
id: slackProperties.data.integrationId,
|
||||
organizationId: alert.project.organizationId,
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
})
|
||||
: await this._prisma.organizationIntegration.findFirst({
|
||||
where: {
|
||||
service: "SLACK",
|
||||
organizationId: alert.project.organizationId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
include: {
|
||||
tokenReference: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!integration) {
|
||||
logger.error("[DeliverAlert] Slack integration not found", {
|
||||
alert,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the client
|
||||
const client = await OrgIntegrationRepository.getAuthenticatedClientForIntegration(
|
||||
integration,
|
||||
{ forceBotToken: true }
|
||||
);
|
||||
|
||||
switch (alert.type) {
|
||||
case "TASK_RUN_ATTEMPT": {
|
||||
if (alert.taskRunAttempt) {
|
||||
// Find existing storage by the run ID
|
||||
const storage = await this._prisma.projectAlertStorage.findFirst({
|
||||
where: {
|
||||
alertChannelId: alert.channel.id,
|
||||
alertType: alert.type,
|
||||
storageId: alert.taskRunAttempt.taskRunId,
|
||||
},
|
||||
});
|
||||
|
||||
const storageData = storage
|
||||
? ProjectAlertSlackStorage.safeParse(storage.storageData)
|
||||
: undefined;
|
||||
|
||||
const thread_ts =
|
||||
storageData && storageData.success ? storageData.data.message_ts : undefined;
|
||||
|
||||
const taskRunError = TaskRunError.safeParse(alert.taskRunAttempt.error);
|
||||
|
||||
if (!taskRunError.success) {
|
||||
logger.error("[DeliverAlert] Failed to parse task run error", {
|
||||
issues: taskRunError.error.issues,
|
||||
taskAttemptError: alert.taskRunAttempt.error,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const error = createJsonErrorObject(taskRunError.data);
|
||||
|
||||
const exportName = alert.taskRunAttempt.backgroundWorkerTask.exportName;
|
||||
const version = alert.taskRunAttempt.backgroundWorker.version;
|
||||
const environment = alert.environment.slug;
|
||||
const taskIdentifier = alert.taskRunAttempt.backgroundWorkerTask.slug;
|
||||
const timestamp = alert.taskRunAttempt.completedAt ?? new Date();
|
||||
const runId = alert.taskRunAttempt.taskRun.friendlyId;
|
||||
const attemptNumber = alert.taskRunAttempt.number;
|
||||
|
||||
try {
|
||||
const message = await client.chat.postMessage({
|
||||
thread_ts,
|
||||
channel: slackProperties.data.channelId,
|
||||
text: `Task error in ${alert.taskRunAttempt.backgroundWorkerTask.exportName} [${alert.taskRunAttempt.backgroundWorker.version}.${alert.environment.slug}]`,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Error in *${exportName}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `\`\`\`${error.stackTrace ?? error.message}\`\`\``,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${runId}.${attemptNumber} | ${taskIdentifier} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "divider",
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "Investigate",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/runs/${alert.taskRunAttempt.taskRun.friendlyId}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Upsert the storage
|
||||
if (message.ts) {
|
||||
if (storage) {
|
||||
await this._prisma.projectAlertStorage.update({
|
||||
where: {
|
||||
id: storage.id,
|
||||
},
|
||||
data: {
|
||||
storageData: {
|
||||
message_ts: message.ts,
|
||||
},
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await this._prisma.projectAlertStorage.create({
|
||||
data: {
|
||||
alertChannelId: alert.channel.id,
|
||||
alertType: alert.type,
|
||||
storageId: alert.taskRunAttempt.taskRunId,
|
||||
storageData: {
|
||||
message_ts: message.ts,
|
||||
},
|
||||
projectId: alert.project.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("[DeliverAlert] Failed to send slack message", {
|
||||
error,
|
||||
alert,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Task run attempt not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_FAILURE": {
|
||||
if (alert.workerDeployment) {
|
||||
const preparedError = DeploymentPresenter.prepareErrorData(
|
||||
alert.workerDeployment.errorData
|
||||
);
|
||||
|
||||
if (!preparedError) {
|
||||
logger.error("[DeliverAlert] Failed to prepare deployment error data", {
|
||||
errorData: alert.workerDeployment.errorData,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const version = alert.workerDeployment.version;
|
||||
const environment = alert.environment.slug;
|
||||
const timestamp = alert.workerDeployment.failedAt ?? new Date();
|
||||
|
||||
try {
|
||||
await client.chat.postMessage({
|
||||
channel: slackProperties.data.channelId,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rotating_light: Deployment failed *${version}.${environment}* _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `\`\`\`${preparedError.stack ?? preparedError.message}\`\`\``,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View Deployment",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("[DeliverAlert] Failed to send slack message", {
|
||||
error,
|
||||
alert,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
case "DEPLOYMENT_SUCCESS": {
|
||||
if (alert.workerDeployment) {
|
||||
const version = alert.workerDeployment.version;
|
||||
const environment = alert.environment.slug;
|
||||
const numberOfTasks = alert.workerDeployment.worker?.tasks.length ?? 0;
|
||||
const timestamp = alert.workerDeployment.deployedAt ?? new Date();
|
||||
|
||||
await client.chat.postMessage({
|
||||
channel: slackProperties.data.channelId,
|
||||
text: `Deployment ${alert.workerDeployment.version} [${alert.environment.slug}] succeeded`,
|
||||
blocks: [
|
||||
{
|
||||
type: "section",
|
||||
text: {
|
||||
type: "mrkdwn",
|
||||
text: `:rocket: Deployed *${version}.${environment}* successfully _<!date^${Math.round(
|
||||
timestamp.getTime() / 1000
|
||||
)}^at {date_num} {time_secs}|${timestamp.toLocaleString()}>_`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "context",
|
||||
elements: [
|
||||
{
|
||||
type: "mrkdwn",
|
||||
text: `${numberOfTasks} tasks | ${alert.workerDeployment.shortCode} | ${version}.${environment} | ${alert.project.name}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "actions",
|
||||
elements: [
|
||||
{
|
||||
type: "button",
|
||||
text: {
|
||||
type: "plain_text",
|
||||
text: "View Deployment",
|
||||
},
|
||||
url: `${env.APP_ORIGIN}/projects/v3/${alert.project.externalRef}/deployments/${alert.workerDeployment.shortCode}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return;
|
||||
} else {
|
||||
logger.error("[DeliverAlert] Worker deployment not found", {
|
||||
alert,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async #deliverWebhook(payload: any, webhook: ProjectAlertWebhookProperties) {
|
||||
const rawPayload = JSON.stringify(payload);
|
||||
const hashPayload = Buffer.from(rawPayload, "utf-8");
|
||||
|
||||
const secret = await decryptSecret(env.ENCRYPTION_KEY, webhook.secret);
|
||||
|
||||
const hmacSecret = Buffer.from(secret, "utf-8");
|
||||
const key = await subtle.importKey(
|
||||
"raw",
|
||||
hmacSecret,
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign"]
|
||||
);
|
||||
const signature = await subtle.sign("HMAC", key, hashPayload);
|
||||
const signatureHex = Buffer.from(signature).toString("hex");
|
||||
|
||||
// Send the webhook to the URL specified in webhook.url
|
||||
const response = await fetch(webhook.url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-trigger-signature-hmacsha256": signatureHex,
|
||||
},
|
||||
body: rawPayload,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.error("[DeliverAlert] Failed to send alert webhook", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
url: webhook.url,
|
||||
body: payload,
|
||||
signature,
|
||||
});
|
||||
|
||||
throw new Error(`Failed to send alert webhook to ${webhook.url}`);
|
||||
}
|
||||
}
|
||||
|
||||
static async enqueue(
|
||||
alertId: string,
|
||||
tx: PrismaClientOrTransaction,
|
||||
options?: { runAt?: Date; queueName?: string }
|
||||
) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.deliverAlert",
|
||||
{
|
||||
alertId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt: options?.runAt,
|
||||
jobKey: `deliverAlert:${alertId}`,
|
||||
queueName: options?.queueName,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { ProjectAlertChannel, ProjectAlertType, WorkerDeployment } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { DeliverAlertService } from "./deliverAlert.server";
|
||||
|
||||
export class PerformDeploymentAlertsService extends BaseService {
|
||||
public async call(deploymentId: string) {
|
||||
const deployment = await this._prisma.workerDeployment.findUnique({
|
||||
where: { id: deploymentId },
|
||||
});
|
||||
|
||||
if (!deployment) {
|
||||
return;
|
||||
}
|
||||
|
||||
const alertType =
|
||||
deployment.status === "DEPLOYED" ? "DEPLOYMENT_SUCCESS" : "DEPLOYMENT_FAILURE";
|
||||
|
||||
// Find all the alert channels
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId: deployment.projectId,
|
||||
alertTypes: {
|
||||
has: alertType,
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const alertChannel of alertChannels) {
|
||||
await this.#createAndSendAlert(alertChannel, deployment, alertType);
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndSendAlert(
|
||||
alertChannel: ProjectAlertChannel,
|
||||
deployment: WorkerDeployment,
|
||||
alertType: ProjectAlertType
|
||||
) {
|
||||
await $transaction(this._prisma, async (tx) => {
|
||||
const alert = await this._prisma.projectAlert.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert"),
|
||||
channelId: alertChannel.id,
|
||||
projectId: deployment.projectId,
|
||||
environmentId: deployment.environmentId,
|
||||
status: "PENDING",
|
||||
type: alertType,
|
||||
workerDeploymentId: deployment.id,
|
||||
},
|
||||
});
|
||||
|
||||
await DeliverAlertService.enqueue(alert.id, tx, {
|
||||
queueName: `alert-channel:${alertChannel.id}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async enqueue(deploymentId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.performDeploymentAlerts",
|
||||
{
|
||||
deploymentId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `performDeploymentAlerts:${deploymentId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Prisma, ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { $transaction, PrismaClientOrTransaction, prisma } from "~/db.server";
|
||||
import { workerQueue } from "~/services/worker.server";
|
||||
import { generateFriendlyId } from "~/v3/friendlyIdentifiers";
|
||||
import { BaseService } from "../baseService.server";
|
||||
import { DeliverAlertService } from "./deliverAlert.server";
|
||||
|
||||
type FoundTaskAttempt = Prisma.Result<
|
||||
typeof prisma.taskRunAttempt,
|
||||
{ include: { taskRun: true; backgroundWorkerTask: true; runtimeEnvironment: true } },
|
||||
"findUniqueOrThrow"
|
||||
>;
|
||||
|
||||
export class PerformTaskAttemptAlertsService extends BaseService {
|
||||
public async call(attemptId: string) {
|
||||
const taskAttempt = await this._prisma.taskRunAttempt.findUnique({
|
||||
where: { id: attemptId },
|
||||
include: {
|
||||
taskRun: true,
|
||||
backgroundWorkerTask: true,
|
||||
runtimeEnvironment: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!taskAttempt) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (taskAttempt.runtimeEnvironment.type === "DEVELOPMENT") {
|
||||
return;
|
||||
}
|
||||
|
||||
// Find all the alert channels
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
where: {
|
||||
projectId: taskAttempt.taskRun.projectId,
|
||||
alertTypes: {
|
||||
has: "TASK_RUN_ATTEMPT",
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
for (const alertChannel of alertChannels) {
|
||||
await this.#createAndSendAlert(alertChannel, taskAttempt);
|
||||
}
|
||||
}
|
||||
|
||||
async #createAndSendAlert(alertChannel: ProjectAlertChannel, taskAttempt: FoundTaskAttempt) {
|
||||
await $transaction(this._prisma, async (tx) => {
|
||||
const alert = await this._prisma.projectAlert.create({
|
||||
data: {
|
||||
friendlyId: generateFriendlyId("alert"),
|
||||
channelId: alertChannel.id,
|
||||
projectId: taskAttempt.taskRun.projectId,
|
||||
environmentId: taskAttempt.runtimeEnvironmentId,
|
||||
status: "PENDING",
|
||||
type: "TASK_RUN_ATTEMPT",
|
||||
taskRunAttemptId: taskAttempt.id,
|
||||
},
|
||||
});
|
||||
|
||||
await DeliverAlertService.enqueue(alert.id, tx, {
|
||||
queueName: `alert-channel:${alertChannel.id}`,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
static async enqueue(attemptId: string, tx: PrismaClientOrTransaction, runAt?: Date) {
|
||||
return await workerQueue.enqueue(
|
||||
"v3.performTaskAttemptAlerts",
|
||||
{
|
||||
attemptId,
|
||||
},
|
||||
{
|
||||
tx,
|
||||
runAt,
|
||||
jobKey: `performTaskAttemptAlerts:${attemptId}`,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { ResumeTaskRunDependenciesService } from "./resumeTaskRunDependencies.se
|
||||
import { MAX_TASK_RUN_ATTEMPTS } from "~/consts";
|
||||
import { CreateCheckpointService } from "./createCheckpoint.server";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { PerformTaskAttemptAlertsService } from "./alerts/performTaskAttemptAlerts.server";
|
||||
|
||||
type FoundAttempt = Awaited<ReturnType<typeof findAttempt>>;
|
||||
|
||||
@@ -154,9 +155,13 @@ export class CompleteAttemptService extends BaseService {
|
||||
},
|
||||
});
|
||||
|
||||
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
|
||||
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
|
||||
const environment = env ?? (await this.#getEnvironment(execution.environment.id));
|
||||
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
await PerformTaskAttemptAlertsService.enqueue(taskRunAttempt.id, this._prisma);
|
||||
}
|
||||
|
||||
if (completion.retry !== undefined && taskRunAttempt.number < MAX_TASK_RUN_ATTEMPTS) {
|
||||
const retryAt = new Date(completion.retry.timestamp);
|
||||
|
||||
// Retry the task run
|
||||
|
||||
@@ -172,7 +172,7 @@ export async function createBackgroundTasks(
|
||||
},
|
||||
});
|
||||
|
||||
if (taskQueue.concurrencyLimit) {
|
||||
if (typeof taskQueue.concurrencyLimit === "number") {
|
||||
await marqs?.updateQueueConcurrencyLimits(
|
||||
environment,
|
||||
taskQueue.name,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { projectPubSub } from "./projectPubSub.server";
|
||||
import { marqs } from "~/v3/marqs/index.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { ExecuteTasksWaitingForDeployService } from "./executeTasksWaitingForDeploy";
|
||||
import { PerformDeploymentAlertsService } from "./alerts/performDeploymentAlerts.server";
|
||||
|
||||
export class CreateDeployedBackgroundWorkerService extends BaseService {
|
||||
public async call(
|
||||
@@ -98,6 +99,7 @@ export class CreateDeployedBackgroundWorkerService extends BaseService {
|
||||
}
|
||||
|
||||
await ExecuteTasksWaitingForDeployService.enqueue(backgroundWorker.id, this._prisma);
|
||||
await PerformDeploymentAlertsService.enqueue(deployment.id, this._prisma);
|
||||
|
||||
return backgroundWorker;
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user