Compare commits
99 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 | |||
| bf4e505c70 | |||
| c8538c236c | |||
| ff6e2461d5 | |||
| 3ed016bc2b | |||
| 68dd3804a7 | |||
| 95f0c7b780 | |||
| 339c39d739 | |||
| bc0ca01319 | |||
| c37c822314 | |||
| 2dfe191c55 | |||
| e337b21650 | |||
| 9e5382951b | |||
| ed03f4bc15 | |||
| 9feb0f70b0 | |||
| 25de9e59af | |||
| 83dc871550 | |||
| 62700245a3 | |||
| 6ce820cb45 | |||
| 0f0a6884e8 | |||
| 7ff8f0ebab | |||
| 68455c796a | |||
| b0a2c42e0e | |||
| cac3c32f6a | |||
| ed8d24fd3d | |||
| d0ef36260a | |||
| 8eb68dd852 | |||
| a42037da03 | |||
| 43bc7ed94e | |||
| 4fdb7f8288 | |||
| 37b9b056c4 | |||
| 801c86bf73 | |||
| a1de11a001 | |||
| 29e9e372ee | |||
| affc128161 | |||
| 40ba8ad0ee | |||
| 96168eb383 | |||
| 2c68473cc8 | |||
| 469c8a2532 | |||
| ebeb790522 |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fixes an issue with scoped packages in additionalPackages option
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Hoist uncaughtException handler to the top of workers to better report error messages
|
||||
@@ -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,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Fix issues with consecutive waits
|
||||
@@ -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,39 +44,54 @@
|
||||
"@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",
|
||||
"purple-garlics-shop",
|
||||
"rare-lamps-promise",
|
||||
"rare-roses-float",
|
||||
"real-planets-stare",
|
||||
"rich-kangaroos-unite",
|
||||
@@ -88,6 +103,7 @@
|
||||
"sharp-zebras-serve",
|
||||
"shiny-coats-cry",
|
||||
"silly-suits-switch",
|
||||
"six-ligers-exist",
|
||||
"slow-buses-own",
|
||||
"smart-needles-move",
|
||||
"smart-olives-eat",
|
||||
@@ -99,7 +115,9 @@
|
||||
"swift-dragons-peel",
|
||||
"tall-bees-wave",
|
||||
"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
|
||||
---
|
||||
|
||||
Add typescript as a dependency so the esbuild-decorator will work even when running in npx
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Add a postInstall option to allow running scripts after dependencies have been installed in deployed images
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
Improve the display of non-object return types in the run trace viewer
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Use locked package versions when resolving dependencies in deployed workers
|
||||
@@ -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.
|
||||
|
||||
@@ -4,13 +4,15 @@ on:
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PRISMA_ENGINES_CHECKSUM_IGNORE_MISSING: 1
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
short_sha: ${{ steps.get_commit.outputs.sha_short }}
|
||||
steps:
|
||||
- name: Setup Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -157,16 +157,18 @@ class Checkpointer {
|
||||
return this.#abortControllers.has(runId);
|
||||
}
|
||||
|
||||
cancelCheckpoint(runId: string) {
|
||||
cancelCheckpoint(runId: string): boolean {
|
||||
const controller = this.#abortControllers.get(runId);
|
||||
|
||||
if (!controller) {
|
||||
logger.debug("Nothing to cancel", { runId });
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
controller.abort("cancelCheckpointing()");
|
||||
this.#abortControllers.delete(runId);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async #checkpointAndPush({
|
||||
@@ -725,10 +727,18 @@ class TaskCoordinator {
|
||||
checkpointable.resolve();
|
||||
});
|
||||
|
||||
socket.on("CANCEL_CHECKPOINT", async (message) => {
|
||||
socket.on("CANCEL_CHECKPOINT", async (message, callback) => {
|
||||
logger.log("[CANCEL_CHECKPOINT]", message);
|
||||
|
||||
this.#cancelCheckpoint(socket.data.runId);
|
||||
if (message.version === "v1") {
|
||||
this.#cancelCheckpoint(socket.data.runId);
|
||||
// v1 has no callback
|
||||
return;
|
||||
}
|
||||
|
||||
const checkpointCanceled = this.#cancelCheckpoint(socket.data.runId);
|
||||
|
||||
callback({ version: "v2", checkpointCanceled });
|
||||
});
|
||||
|
||||
socket.on("WAIT_FOR_DURATION", async (message, callback) => {
|
||||
@@ -933,7 +943,9 @@ class TaskCoordinator {
|
||||
}
|
||||
|
||||
// Cancel checkpointing procedure
|
||||
this.#checkpointer.cancelCheckpoint(runId);
|
||||
const checkpointCanceled = this.#checkpointer.cancelCheckpoint(runId);
|
||||
|
||||
return checkpointCanceled;
|
||||
}
|
||||
|
||||
#createHttpServer() {
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
@@ -212,6 +213,7 @@ class KubernetesTaskOperations implements TaskOperations {
|
||||
{
|
||||
name: "populate-taskinfo",
|
||||
image: "docker.io/library/busybox",
|
||||
imagePullPolicy: "IfNotPresent",
|
||||
command: ["/bin/sh", "-c"],
|
||||
args: ["printenv COORDINATOR_HOST | tee /etc/taskinfo/coordinator-host"],
|
||||
env: [
|
||||
@@ -542,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();
|
||||
|
||||
@@ -17,4 +17,5 @@ build-storybook.log
|
||||
.storybook-out
|
||||
storybook-static
|
||||
|
||||
/prisma/seed.js
|
||||
/prisma/seed.js
|
||||
/prisma/populate.js
|
||||
@@ -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">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
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 && !isImpersonating) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<ShieldCheckIcon className="h-5 w-5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-1">{children}</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
export function Hint({ children }: { children: React.ReactNode }) {
|
||||
return <Paragraph variant="extra-small">{children}</Paragraph>;
|
||||
export function Hint({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<Paragraph variant="extra-small" className={className}>
|
||||
{children}
|
||||
</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,
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { VirtualElement as IVirtualElement } from "@popperjs/core";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { usePopper } from "react-popper";
|
||||
import { useEvent } from "react-use";
|
||||
import useLazyRef from "~/hooks/useLazyRef";
|
||||
|
||||
// Recharts 3.x will have portal support, but until then we're using this:
|
||||
//https://github.com/recharts/recharts/issues/2458#issuecomment-1063463873
|
||||
|
||||
export interface PopperPortalProps {
|
||||
active?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function TooltipPortal({ active = true, children }: PopperPortalProps) {
|
||||
const [portalElement, setPortalElement] = useState<HTMLDivElement>();
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>();
|
||||
const virtualElementRef = useLazyRef(() => new VirtualElement());
|
||||
|
||||
const { styles, attributes, update } = usePopper(
|
||||
virtualElementRef.current,
|
||||
popperElement,
|
||||
POPPER_OPTIONS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = document.createElement("div");
|
||||
document.body.appendChild(el);
|
||||
setPortalElement(el);
|
||||
return () => el.remove();
|
||||
}, []);
|
||||
|
||||
useEvent("mousemove", ({ clientX: x, clientY: y }) => {
|
||||
virtualElementRef.current?.update(x, y);
|
||||
if (!active) return;
|
||||
update?.();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
update?.();
|
||||
}, [active, update]);
|
||||
|
||||
if (!portalElement) return null;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={setPopperElement}
|
||||
{...attributes.popper}
|
||||
style={{
|
||||
...styles.popper,
|
||||
zIndex: 1000,
|
||||
display: active ? "block" : "none",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
portalElement
|
||||
);
|
||||
}
|
||||
|
||||
class VirtualElement implements IVirtualElement {
|
||||
private rect = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON() {
|
||||
return this;
|
||||
},
|
||||
};
|
||||
|
||||
update(x: number, y: number) {
|
||||
this.rect.y = y;
|
||||
this.rect.top = y;
|
||||
this.rect.bottom = y;
|
||||
|
||||
this.rect.x = x;
|
||||
this.rect.left = x;
|
||||
this.rect.right = x;
|
||||
}
|
||||
|
||||
getBoundingClientRect(): DOMRect {
|
||||
return this.rect;
|
||||
}
|
||||
}
|
||||
|
||||
const POPPER_OPTIONS: Parameters<typeof usePopper>[2] = {
|
||||
placement: "right-start",
|
||||
modifiers: [
|
||||
{
|
||||
name: "offset",
|
||||
options: {
|
||||
offset: [8, 8],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -1,10 +1,9 @@
|
||||
import { VirtualItem, Virtualizer, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import { motion } from "framer-motion";
|
||||
import { MutableRefObject, RefObject, useCallback, useEffect, useReducer, useRef } from "react";
|
||||
import { UnmountClosed } from "react-collapse";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { NodeState, NodesState, reducer } from "./reducer";
|
||||
import { applyFilterToState, concreteStateFromInput, selectedIdFromState } from "./utils";
|
||||
import { concreteStateFromInput, selectedIdFromState } from "./utils";
|
||||
|
||||
export type TreeViewProps<TData> = {
|
||||
tree: FlatTree<TData>;
|
||||
@@ -104,23 +103,22 @@ export function TreeView<TData>({
|
||||
if (!node) return null;
|
||||
const state = nodes[node.id];
|
||||
if (!state) return null;
|
||||
if (!state.visible) return null;
|
||||
return (
|
||||
<div
|
||||
key={node.id}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="overflow-clip [&_.ReactCollapse--collapse]:transition-all"
|
||||
className="overflow-clip"
|
||||
{...getNodeProps(node.id)}
|
||||
>
|
||||
<UnmountClosed key={node.id} isOpened={state.visible}>
|
||||
{renderNode({
|
||||
node,
|
||||
state,
|
||||
index: virtualItem.index,
|
||||
virtualizer: virtualizer,
|
||||
virtualItem,
|
||||
})}
|
||||
</UnmountClosed>
|
||||
{renderNode({
|
||||
node,
|
||||
state,
|
||||
index: virtualItem.index,
|
||||
virtualizer: virtualizer,
|
||||
virtualItem,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -130,19 +128,23 @@ export function TreeView<TData>({
|
||||
);
|
||||
}
|
||||
|
||||
type TreeStateHookProps<TData> = {
|
||||
export type Filter<TData, TFilterValue> = {
|
||||
value?: TFilterValue;
|
||||
fn: (value: TFilterValue, node: FlatTreeItem<TData>) => boolean;
|
||||
};
|
||||
|
||||
type TreeStateHookProps<TData, TFilterValue> = {
|
||||
tree: FlatTree<TData>;
|
||||
selectedId?: string;
|
||||
collapsedIds?: string[];
|
||||
onSelectedIdChanged?: (selectedId: string | undefined) => void;
|
||||
onCollapsedIdsChanged?: (collapsedIds: string[]) => void;
|
||||
estimatedRowHeight: (params: {
|
||||
node: FlatTreeItem<TData>;
|
||||
state: NodeState;
|
||||
index: number;
|
||||
}) => number;
|
||||
parentRef: RefObject<any>;
|
||||
filter?: (node: FlatTreeItem<TData>) => boolean;
|
||||
filter?: Filter<TData, TFilterValue>;
|
||||
};
|
||||
|
||||
//this is so Framer Motion can be used to render the components
|
||||
@@ -178,24 +180,24 @@ export type UseTreeStateOutput = {
|
||||
scrollToNode: (id: string) => void;
|
||||
};
|
||||
|
||||
export function useTree<TData>({
|
||||
export function useTree<TData, TFilterValue>({
|
||||
tree,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
onSelectedIdChanged,
|
||||
onCollapsedIdsChanged,
|
||||
parentRef,
|
||||
estimatedRowHeight,
|
||||
filter,
|
||||
}: TreeStateHookProps<TData>): UseTreeStateOutput {
|
||||
}: TreeStateHookProps<TData, TFilterValue>): UseTreeStateOutput {
|
||||
const previousNodeCount = useRef(tree.length);
|
||||
const previousSelectedId = useRef<string | undefined>(selectedId);
|
||||
|
||||
const [state, dispatch] = useReducer(
|
||||
reducer,
|
||||
concreteStateFromInput({ tree, selectedId, collapsedIds })
|
||||
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
|
||||
);
|
||||
|
||||
//fire onSelectedIdChanged()
|
||||
useEffect(() => {
|
||||
const selectedId = selectedIdFromState(state.nodes);
|
||||
if (selectedId !== previousSelectedId.current) {
|
||||
@@ -204,12 +206,7 @@ export function useTree<TData>({
|
||||
}
|
||||
}, [state.changes.selectedId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.changes.collapsedIds) {
|
||||
onCollapsedIdsChanged?.(state.changes.collapsedIds);
|
||||
}
|
||||
}, [state.changes.collapsedIds]);
|
||||
|
||||
//update tree when the number of nodes changes
|
||||
useEffect(() => {
|
||||
if (tree.length !== previousNodeCount.current) {
|
||||
previousNodeCount.current = tree.length;
|
||||
@@ -217,9 +214,25 @@ export function useTree<TData>({
|
||||
}
|
||||
}, [previousNodeCount.current, tree.length]);
|
||||
|
||||
//update the filter, if it's changed
|
||||
const previousFilter = useRef(filter);
|
||||
useEffect(() => {
|
||||
//check if the value (not reference) of the filter is the same
|
||||
const previousValue = previousFilter.current
|
||||
? JSON.stringify(previousFilter.current.value)
|
||||
: undefined;
|
||||
const newValue = filter ? JSON.stringify(filter.value) : undefined;
|
||||
|
||||
previousFilter.current = filter;
|
||||
|
||||
if (previousValue !== newValue) {
|
||||
dispatch({ type: "UPDATE_FILTER", payload: { filter } });
|
||||
}
|
||||
}, [filter?.value]);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: tree.length,
|
||||
getItemKey: (index) => tree[index].id,
|
||||
count: state.visibleNodeIds.length,
|
||||
getItemKey: (index) => state.visibleNodeIds[index],
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: (index: number) => {
|
||||
return estimatedRowHeight({
|
||||
@@ -233,7 +246,7 @@ export function useTree<TData>({
|
||||
|
||||
const scrollToNodeFn = useCallback(
|
||||
(id: string) => {
|
||||
const itemIndex = tree.findIndex((node) => node.id === id);
|
||||
const itemIndex = state.visibleNodeIds.findIndex((n) => n === id);
|
||||
|
||||
if (itemIndex !== -1) {
|
||||
virtualizer.scrollToIndex(itemIndex, { align: "auto" });
|
||||
@@ -269,21 +282,21 @@ export function useTree<TData>({
|
||||
|
||||
const expandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "EXPAND_NODE", payload: { id, tree, scrollToNode, scrollToNodeFn } });
|
||||
dispatch({ type: "EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseNode = useCallback(
|
||||
(id: string) => {
|
||||
dispatch({ type: "COLLAPSE_NODE", payload: { id, tree } });
|
||||
dispatch({ type: "COLLAPSE_NODE", payload: { id } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const toggleExpandNode = useCallback(
|
||||
(id: string, scrollToNode = true) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, tree, scrollToNode, scrollToNodeFn } });
|
||||
dispatch({ type: "TOGGLE_EXPAND_NODE", payload: { id, scrollToNode, scrollToNodeFn } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
@@ -292,7 +305,7 @@ export function useTree<TData>({
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: { tree, scrollToNode, scrollToNodeFn },
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
@@ -302,7 +315,7 @@ export function useTree<TData>({
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_LAST_VISIBLE_NODE",
|
||||
payload: { tree, scrollToNode, scrollToNodeFn },
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[tree, state]
|
||||
@@ -312,7 +325,7 @@ export function useTree<TData>({
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_NEXT_VISIBLE_NODE",
|
||||
payload: { tree, scrollToNode, scrollToNodeFn },
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
@@ -322,7 +335,7 @@ export function useTree<TData>({
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE",
|
||||
payload: { tree, scrollToNode, scrollToNodeFn },
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
@@ -332,7 +345,7 @@ export function useTree<TData>({
|
||||
(scrollToNode = true) => {
|
||||
dispatch({
|
||||
type: "SELECT_PARENT_NODE",
|
||||
payload: { tree, scrollToNode, scrollToNodeFn },
|
||||
payload: { scrollToNode, scrollToNodeFn },
|
||||
});
|
||||
},
|
||||
[state]
|
||||
@@ -340,35 +353,35 @@ export function useTree<TData>({
|
||||
|
||||
const expandAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { tree, depth } });
|
||||
dispatch({ type: "EXPAND_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseAllBelowDepth = useCallback(
|
||||
(depth: number) => {
|
||||
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { tree, depth } });
|
||||
dispatch({ type: "COLLAPSE_ALL_BELOW_DEPTH", payload: { depth } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const expandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "EXPAND_LEVEL", payload: { tree, level } });
|
||||
dispatch({ type: "EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const collapseLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "COLLAPSE_LEVEL", payload: { tree, level } });
|
||||
dispatch({ type: "COLLAPSE_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
|
||||
const toggleExpandLevel = useCallback(
|
||||
(level: number) => {
|
||||
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { tree, level } });
|
||||
dispatch({ type: "TOGGLE_EXPAND_LEVEL", payload: { level } });
|
||||
},
|
||||
[state]
|
||||
);
|
||||
@@ -480,7 +493,7 @@ export function useTree<TData>({
|
||||
|
||||
return {
|
||||
selected: selectedIdFromState(state.nodes),
|
||||
nodes: filter ? applyFilterToState(tree, state.nodes, filter) : state.nodes,
|
||||
nodes: state.nodes,
|
||||
getTreeProps,
|
||||
getNodeProps,
|
||||
selectNode,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { FlatTree } from "./TreeView";
|
||||
import assertNever from "assert-never";
|
||||
import { Filter, FlatTree } from "./TreeView";
|
||||
import {
|
||||
applyFilterToState,
|
||||
applyVisibility,
|
||||
collapsedIdsFromState,
|
||||
concreteStateFromInput,
|
||||
@@ -18,12 +20,15 @@ export type NodeState = {
|
||||
|
||||
export type Changes = {
|
||||
selectedId: string | undefined;
|
||||
collapsedIds: string[] | undefined;
|
||||
};
|
||||
|
||||
export type TreeState = {
|
||||
tree: FlatTree<any>;
|
||||
nodes: NodesState;
|
||||
filteredNodes: NodesState;
|
||||
changes: Changes;
|
||||
filter: Filter<any, any> | undefined;
|
||||
visibleNodeIds: string[];
|
||||
};
|
||||
|
||||
export type NodesState = Record<string, NodeState>;
|
||||
@@ -71,7 +76,6 @@ type ExpandNodeAction = {
|
||||
type: "EXPAND_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
@@ -79,7 +83,6 @@ type CollapseNodeAction = {
|
||||
type: "COLLAPSE_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -87,7 +90,6 @@ type ToggleExpandNodeAction = {
|
||||
type: "TOGGLE_EXPAND_NODE";
|
||||
payload: {
|
||||
id: string;
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
};
|
||||
|
||||
@@ -95,7 +97,6 @@ type ExpandAllBelowDepthAction = {
|
||||
type: "EXPAND_ALL_BELOW_DEPTH";
|
||||
payload: {
|
||||
depth: number;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -103,7 +104,6 @@ type CollapseAllBelowDepthAction = {
|
||||
type: "COLLAPSE_ALL_BELOW_DEPTH";
|
||||
payload: {
|
||||
depth: number;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -111,7 +111,6 @@ type ExpandLevelAction = {
|
||||
type: "EXPAND_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -119,7 +118,6 @@ type CollapseLevelAction = {
|
||||
type: "COLLAPSE_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -127,43 +125,39 @@ type ToggleExpandLevelAction = {
|
||||
type: "TOGGLE_EXPAND_LEVEL";
|
||||
payload: {
|
||||
level: number;
|
||||
tree: FlatTree<any>;
|
||||
};
|
||||
};
|
||||
|
||||
type SelectFirstVisibleNodeAction = {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectLastVisibleNodeAction = {
|
||||
type: "SELECT_LAST_VISIBLE_NODE";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectNextVisibleNodeAction = {
|
||||
type: "SELECT_NEXT_VISIBLE_NODE";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectPreviousVisibleNodeAction = {
|
||||
type: "SELECT_PREVIOUS_VISIBLE_NODE";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type SelectParentNodeAction = {
|
||||
type: "SELECT_PARENT_NODE";
|
||||
payload: {} & WithScrollToNode;
|
||||
};
|
||||
|
||||
type UpdateFilterAction = {
|
||||
type: "UPDATE_FILTER";
|
||||
payload: {
|
||||
tree: FlatTree<any>;
|
||||
} & WithScrollToNode;
|
||||
filter: Filter<any, any> | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export type Action =
|
||||
@@ -184,7 +178,8 @@ export type Action =
|
||||
| SelectLastVisibleNodeAction
|
||||
| SelectNextVisibleNodeAction
|
||||
| SelectPreviousVisibleNodeAction
|
||||
| SelectParentNodeAction;
|
||||
| SelectParentNodeAction
|
||||
| UpdateFilterAction;
|
||||
|
||||
export function reducer(state: TreeState, action: Action): TreeState {
|
||||
switch (action.type) {
|
||||
@@ -204,7 +199,12 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
action.payload.scrollToNodeFn(action.payload.id);
|
||||
}
|
||||
|
||||
return { nodes: newNodes, changes: generateChanges(state.nodes, newNodes) };
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
tree: state.tree,
|
||||
nodes: newNodes,
|
||||
changes: generateChanges(state.nodes, newNodes),
|
||||
});
|
||||
}
|
||||
case "DESELECT_NODE": {
|
||||
const nodes = {
|
||||
@@ -212,28 +212,36 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
[action.payload.id]: { ...state.nodes[action.payload.id], selected: false },
|
||||
};
|
||||
|
||||
return { nodes, changes: generateChanges(state.nodes, nodes) };
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes,
|
||||
changes: generateChanges(state.nodes, nodes),
|
||||
});
|
||||
}
|
||||
case "DESELECT_ALL_NODES": {
|
||||
const nodes = Object.fromEntries(
|
||||
Object.entries(state.nodes).map(([key, value]) => [key, { ...value, selected: false }])
|
||||
);
|
||||
return { nodes, changes: generateChanges(state.nodes, nodes) };
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes,
|
||||
changes: generateChanges(state.nodes, nodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_NODE_SELECTION": {
|
||||
const currentlySelected = state.nodes[action.payload.id]?.selected ?? false;
|
||||
if (currentlySelected) {
|
||||
return reducer(state, { type: "DESELECT_NODE", payload: { id: action.payload.id } });
|
||||
} else {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "EXPAND_NODE": {
|
||||
const newNodes = {
|
||||
@@ -245,37 +253,44 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
action.payload.scrollToNodeFn(action.payload.id);
|
||||
}
|
||||
|
||||
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_NODE": {
|
||||
const visibleNodes = applyVisibility(action.payload.tree, {
|
||||
const visibleNodes = applyVisibility(state.tree, {
|
||||
...state.nodes,
|
||||
[action.payload.id]: { ...state.nodes[action.payload.id], expanded: false },
|
||||
});
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_EXPAND_NODE": {
|
||||
const currentlyExpanded = state.nodes[action.payload.id]?.expanded ?? true;
|
||||
if (currentlyExpanded) {
|
||||
return reducer(state, {
|
||||
type: "COLLAPSE_NODE",
|
||||
payload: { id: action.payload.id, tree: action.payload.tree },
|
||||
});
|
||||
} else {
|
||||
return reducer(state, {
|
||||
type: "EXPAND_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
tree: action.payload.tree,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
payload: { id: action.payload.id },
|
||||
});
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "EXPAND_NODE",
|
||||
payload: {
|
||||
id: action.payload.id,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "EXPAND_ALL_BELOW_DEPTH": {
|
||||
const nodesToExpand = action.payload.tree.filter(
|
||||
const nodesToExpand = state.tree.filter(
|
||||
(n) => n.level >= action.payload.depth && n.hasChildren
|
||||
);
|
||||
|
||||
@@ -289,11 +304,15 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_ALL_BELOW_DEPTH": {
|
||||
const nodesToCollapse = action.payload.tree.filter(
|
||||
const nodesToCollapse = state.tree.filter(
|
||||
(n) => n.level >= action.payload.depth && n.hasChildren
|
||||
);
|
||||
|
||||
@@ -307,11 +326,15 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "EXPAND_LEVEL": {
|
||||
const nodesToExpand = action.payload.tree.filter(
|
||||
const nodesToExpand = state.tree.filter(
|
||||
(n) => n.level <= action.payload.level && n.hasChildren
|
||||
);
|
||||
|
||||
@@ -325,11 +348,15 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "COLLAPSE_LEVEL": {
|
||||
const nodesToCollapse = action.payload.tree.filter(
|
||||
const nodesToCollapse = state.tree.filter(
|
||||
(n) => n.level === action.payload.level && n.hasChildren
|
||||
);
|
||||
|
||||
@@ -343,13 +370,17 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
])
|
||||
);
|
||||
|
||||
const visibleNodes = applyVisibility(action.payload.tree, newNodes);
|
||||
return { nodes: visibleNodes, changes: generateChanges(state.nodes, visibleNodes) };
|
||||
const visibleNodes = applyVisibility(state.tree, newNodes);
|
||||
return applyFilterToState({
|
||||
...state,
|
||||
nodes: visibleNodes,
|
||||
changes: generateChanges(state.nodes, visibleNodes),
|
||||
});
|
||||
}
|
||||
case "TOGGLE_EXPAND_LEVEL": {
|
||||
//first get the first item at that level in the tree. If it is expanded, collapse all nodes at that level
|
||||
//if it is collapsed, expand all nodes at that level
|
||||
const nodesAtLevel = action.payload.tree.filter(
|
||||
const nodesAtLevel = state.tree.filter(
|
||||
(n) => n.level === action.payload.level && n.hasChildren
|
||||
);
|
||||
const firstNode = nodesAtLevel[0];
|
||||
@@ -364,21 +395,19 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
type: "COLLAPSE_LEVEL",
|
||||
payload: {
|
||||
level: action.payload.level,
|
||||
tree: action.payload.tree,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
return reducer(state, {
|
||||
type: "EXPAND_LEVEL",
|
||||
payload: {
|
||||
level: action.payload.level,
|
||||
tree: action.payload.tree,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return reducer(state, {
|
||||
type: "EXPAND_LEVEL",
|
||||
payload: {
|
||||
level: action.payload.level,
|
||||
},
|
||||
});
|
||||
}
|
||||
case "SELECT_FIRST_VISIBLE_NODE": {
|
||||
const node = firstVisibleNode(action.payload.tree, state.nodes);
|
||||
const node = firstVisibleNode(state.tree, state.filteredNodes);
|
||||
if (node) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
@@ -389,9 +418,11 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_LAST_VISIBLE_NODE": {
|
||||
const node = lastVisibleNode(action.payload.tree, state.nodes);
|
||||
const node = lastVisibleNode(state.tree, state.filteredNodes);
|
||||
if (node) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
@@ -402,6 +433,8 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_NEXT_VISIBLE_NODE": {
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
@@ -409,14 +442,13 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
tree: action.payload.tree,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const visible = visibleNodes(action.payload.tree, state.nodes);
|
||||
const visible = visibleNodes(state.tree, state.filteredNodes);
|
||||
const selectedIndex = visible.findIndex((node) => node.id === selected);
|
||||
const nextNode = visible[selectedIndex + 1];
|
||||
if (nextNode) {
|
||||
@@ -429,6 +461,8 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return state;
|
||||
}
|
||||
case "SELECT_PREVIOUS_VISIBLE_NODE": {
|
||||
const selected = selectedIdFromState(state.nodes);
|
||||
@@ -437,16 +471,15 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
tree: action.payload.tree,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const visible = visibleNodes(action.payload.tree, state.nodes);
|
||||
const visible = visibleNodes(state.tree, state.filteredNodes);
|
||||
const selectedIndex = visible.findIndex((node) => node.id === selected);
|
||||
const previousNode = visible[selectedIndex - 1];
|
||||
const previousNode = visible[Math.max(0, selectedIndex - 1)];
|
||||
if (previousNode) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
@@ -467,19 +500,18 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
return reducer(state, {
|
||||
type: "SELECT_FIRST_VISIBLE_NODE",
|
||||
payload: {
|
||||
tree: action.payload.tree,
|
||||
scrollToNode: action.payload.scrollToNode,
|
||||
scrollToNodeFn: action.payload.scrollToNodeFn,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const selectedNode = action.payload.tree.find((node) => node.id === selected);
|
||||
const selectedNode = state.tree.find((node) => node.id === selected);
|
||||
if (!selectedNode) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const parentNode = action.payload.tree.find((node) => node.id === selectedNode.parentId);
|
||||
const parentNode = state.tree.find((node) => node.id === selectedNode.parentId);
|
||||
if (parentNode) {
|
||||
return reducer(state, {
|
||||
type: "SELECT_NODE",
|
||||
@@ -498,12 +530,23 @@ export function reducer(state: TreeState, action: Action): TreeState {
|
||||
const selectedId = selectedIdFromState(state.nodes);
|
||||
const collapsedIds = collapsedIdsFromState(state.nodes);
|
||||
const newState = concreteStateFromInput({
|
||||
...state,
|
||||
tree: action.payload.tree,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
case "UPDATE_FILTER": {
|
||||
const newState = applyFilterToState({
|
||||
...state,
|
||||
filter: action.payload.filter,
|
||||
});
|
||||
return newState;
|
||||
}
|
||||
default: {
|
||||
assertNever(action);
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Unhandled action type: ${(action as any).type}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { FlatTree, FlatTreeItem } from "./TreeView";
|
||||
import { Filter, FlatTree, FlatTreeItem } from "./TreeView";
|
||||
import { Changes, NodeState, NodesState, TreeState } from "./reducer";
|
||||
|
||||
type PartialNodeState = Record<string, Partial<NodeState>>;
|
||||
@@ -8,10 +8,12 @@ const defaultExpanded = true;
|
||||
|
||||
export function concreteStateFromInput({
|
||||
tree,
|
||||
filter,
|
||||
selectedId,
|
||||
collapsedIds,
|
||||
}: {
|
||||
tree: FlatTree<any>;
|
||||
filter: Filter<any, any> | undefined;
|
||||
selectedId: string | undefined;
|
||||
collapsedIds: string[] | undefined;
|
||||
}): TreeState {
|
||||
@@ -35,10 +37,15 @@ export function concreteStateFromInput({
|
||||
}
|
||||
}
|
||||
}
|
||||
const nodes = concreteStateFromPartialState(tree, state);
|
||||
|
||||
return {
|
||||
nodes: concreteStateFromPartialState(tree, state),
|
||||
changes: { selectedId, collapsedIds: [] },
|
||||
tree,
|
||||
nodes,
|
||||
changes: { selectedId },
|
||||
filter,
|
||||
filteredNodes: nodes,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,28 +89,48 @@ export function selectedIdFromState(state: NodesState): string | undefined {
|
||||
return selected?.[0];
|
||||
}
|
||||
|
||||
export function applyFilterToState<TData>(
|
||||
tree: FlatTree<TData>,
|
||||
inputNodes: NodesState,
|
||||
filter: (node: FlatTreeItem<TData>) => boolean
|
||||
): NodesState {
|
||||
export function applyFilterToState<TData>({
|
||||
tree,
|
||||
nodes,
|
||||
filter,
|
||||
visibleNodeIds,
|
||||
changes,
|
||||
}: TreeState): TreeState {
|
||||
if (!filter || !filter.value) {
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes: nodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
//we need to do two passes, first collect all the nodes that are results
|
||||
const newFilteredOut = new Set<string>();
|
||||
for (const node of tree) {
|
||||
if (!filter(node)) {
|
||||
if (!filter.fn(filter.value, node)) {
|
||||
newFilteredOut.add(node.id);
|
||||
}
|
||||
}
|
||||
|
||||
//nothing is filtered out
|
||||
if (newFilteredOut.size === 0) {
|
||||
return inputNodes;
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes: nodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, nodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
//copy of nodes
|
||||
const nodes = { ...inputNodes };
|
||||
const filteredNodes = { ...nodes };
|
||||
|
||||
const selected = selectedIdFromState(nodes);
|
||||
const selected = selectedIdFromState(filteredNodes);
|
||||
|
||||
const visible = new Set<string>();
|
||||
const expanded = new Set<string>();
|
||||
@@ -148,28 +175,35 @@ export function applyFilterToState<TData>(
|
||||
|
||||
//now set the visibility and expanded state
|
||||
for (const id of hidden) {
|
||||
nodes[id] = { ...nodes[id], visible: false };
|
||||
filteredNodes[id] = { ...filteredNodes[id], visible: false };
|
||||
}
|
||||
for (const id of visible) {
|
||||
nodes[id] = { ...nodes[id], visible: true };
|
||||
filteredNodes[id] = { ...filteredNodes[id], visible: true };
|
||||
}
|
||||
|
||||
for (const id of collapsed) {
|
||||
nodes[id] = { ...nodes[id], expanded: false };
|
||||
filteredNodes[id] = { ...filteredNodes[id], expanded: false };
|
||||
}
|
||||
for (const id of expanded) {
|
||||
nodes[id] = { ...nodes[id], expanded: true };
|
||||
filteredNodes[id] = { ...filteredNodes[id], expanded: true };
|
||||
}
|
||||
|
||||
if (selected) {
|
||||
if (visible.has(selected)) {
|
||||
nodes[selected] = { ...nodes[selected], selected: true };
|
||||
filteredNodes[selected] = { ...filteredNodes[selected], selected: true };
|
||||
} else {
|
||||
nodes[selected] = { ...nodes[selected], selected: false };
|
||||
filteredNodes[selected] = { ...filteredNodes[selected], selected: false };
|
||||
}
|
||||
}
|
||||
|
||||
return nodes;
|
||||
return {
|
||||
tree,
|
||||
nodes,
|
||||
filteredNodes,
|
||||
changes,
|
||||
filter,
|
||||
visibleNodeIds: visibleNodes(tree, filteredNodes).map((node) => node.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function visibleNodes(tree: FlatTree<any>, nodes: NodesState) {
|
||||
@@ -215,6 +249,5 @@ export function generateChanges(a: NodesState, b: NodesState): Changes {
|
||||
|
||||
return {
|
||||
selectedId: selectedIdA !== selectedIdB ? selectedIdB : undefined,
|
||||
collapsedIds: collapsedChanges.length > 0 ? collapsedChanges : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "../primitives/Select";
|
||||
} from "../primitives/SimpleSelect";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import {
|
||||
FilterableEnvironment,
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
import { formatDuration } from "@trigger.dev/core/v3";
|
||||
import { useState, useEffect } from "react";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function LiveTimer({
|
||||
startTime,
|
||||
endTime,
|
||||
updateInterval = 250,
|
||||
className,
|
||||
}: {
|
||||
startTime: Date;
|
||||
endTime?: Date;
|
||||
updateInterval?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
const [now, setNow] = useState<Date>();
|
||||
|
||||
@@ -30,13 +26,13 @@ export function LiveTimer({
|
||||
}, [startTime]);
|
||||
|
||||
return (
|
||||
<Paragraph variant="extra-small" className={cn("whitespace-nowrap tabular-nums", className)}>
|
||||
<>
|
||||
{formatDuration(startTime, now, {
|
||||
style: "short",
|
||||
maxDecimalPoints: 0,
|
||||
units: ["d", "h", "m", "s"],
|
||||
})}
|
||||
</Paragraph>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
@@ -38,6 +52,15 @@ export const RUNNING_STATUSES: TaskRunStatus[] = [
|
||||
"WAITING_TO_RESUME",
|
||||
];
|
||||
|
||||
export const FINISHED_STATUSES: TaskRunStatus[] = [
|
||||
"COMPLETED_SUCCESSFULLY",
|
||||
"CANCELED",
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"INTERRUPTED",
|
||||
"SYSTEM_FAILURE",
|
||||
"CRASHED",
|
||||
];
|
||||
|
||||
export function descriptionForTaskRunStatus(status: TaskRunStatus): string {
|
||||
return taskRunStatusDescriptions[status];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { StopIcon } from "@heroicons/react/24/outline";
|
||||
import { BeakerIcon, BookOpenIcon, CheckIcon } from "@heroicons/react/24/solid";
|
||||
import { useLocation } from "@remix-run/react";
|
||||
import { formatDuration } from "@trigger.dev/core/v3";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { useEnvironments } from "~/hooks/useEnvironments";
|
||||
@@ -28,6 +27,7 @@ import {
|
||||
import { CancelRunDialog } from "./CancelRunDialog";
|
||||
import { ReplayRunDialog } from "./ReplayRunDialog";
|
||||
import { TaskRunStatusCombo } from "./TaskRunStatus";
|
||||
import { LiveTimer } from "./LiveTimer";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -94,9 +94,15 @@ export function TaskRunsTable({
|
||||
{run.startedAt ? <DateTime date={run.startedAt} /> : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{formatDuration(run.startedAt, run.completedAt, {
|
||||
style: "short",
|
||||
})}
|
||||
{run.startedAt && run.finishedAt ? (
|
||||
formatDuration(new Date(run.startedAt), new Date(run.finishedAt), {
|
||||
style: "short",
|
||||
})
|
||||
) : run.startedAt ? (
|
||||
<LiveTimer startTime={new Date(run.startedAt)} />
|
||||
) : (
|
||||
"–"
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{run.isTest ? (
|
||||
@@ -195,7 +201,12 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
{environment ? (
|
||||
<>
|
||||
{" "}
|
||||
in <EnvironmentLabel environment={environment} size="large" />
|
||||
in{" "}
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size="large"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Paragraph>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useRef } from "react";
|
||||
|
||||
//a function that you call with a debounce delay, the function will only be called after the delay has passed
|
||||
/**
|
||||
* A function that you call with a debounce delay, the function will only be called after the delay has passed
|
||||
*
|
||||
* @param fn The function to debounce
|
||||
* @param delay In ms
|
||||
*/
|
||||
export function useDebounce<T extends (...args: any[]) => any>(fn: T, delay: number) {
|
||||
const timeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useRef, MutableRefObject } from "react";
|
||||
|
||||
const useLazyRef = <T>(initialValFunc: () => T) => {
|
||||
const ref: MutableRefObject<T | null> = useRef(null);
|
||||
if (ref.current === null) {
|
||||
ref.current = initialValFunc();
|
||||
}
|
||||
return ref;
|
||||
};
|
||||
|
||||
export default useLazyRef;
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Reducer, useReducer } from "react";
|
||||
|
||||
export type ListState<T> = {
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type AppendAction<T> = {
|
||||
type: "append";
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type UpdateAction<T> = {
|
||||
type: "update";
|
||||
index: number;
|
||||
item: T;
|
||||
};
|
||||
|
||||
type DeleteAction<T> = {
|
||||
type: "delete";
|
||||
index: number;
|
||||
};
|
||||
|
||||
type InsertAfter<T> = {
|
||||
type: "insertAfter";
|
||||
index: number;
|
||||
items: T[];
|
||||
};
|
||||
|
||||
type Action<T> = AppendAction<T> | UpdateAction<T> | DeleteAction<T> | InsertAfter<T>;
|
||||
|
||||
function reducer<T>(state: ListState<T>, action: Action<T>): ListState<T> {
|
||||
switch (action.type) {
|
||||
case "append":
|
||||
return { items: [...state.items, ...action.items] };
|
||||
case "update":
|
||||
return {
|
||||
items: state.items.map((v, i) => (i === action.index ? action.item : v)),
|
||||
};
|
||||
case "delete":
|
||||
return { items: state.items.filter((_, i) => i !== action.index) };
|
||||
case "insertAfter":
|
||||
return {
|
||||
items: [
|
||||
...state.items.slice(0, action.index + 1),
|
||||
...action.items,
|
||||
...state.items.slice(action.index + 1),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type HookReturn<T> = {
|
||||
items: T[];
|
||||
append: (items: T[]) => void;
|
||||
update: (index: number, item: T) => void;
|
||||
delete: (index: number) => void;
|
||||
insertAfter: (index: number, items: T[]) => void;
|
||||
};
|
||||
|
||||
export function useList<T>(initialItems: T[]): HookReturn<T> {
|
||||
const [state, dispatch] = useReducer<Reducer<ListState<T>, Action<T>>>(reducer, {
|
||||
items: initialItems,
|
||||
});
|
||||
|
||||
return {
|
||||
items: state.items,
|
||||
append: (items: T[]) => dispatch({ type: "append", items }),
|
||||
update: (index: number, item: T) => dispatch({ type: "update", index, item }),
|
||||
delete: (index: number) => dispatch({ type: "delete", index }),
|
||||
insertAfter: (index: number, items: T[]) => dispatch({ type: "insertAfter", index, items }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -26,3 +26,9 @@ export function useUser(matches?: UIMatch[]): User {
|
||||
export function useUserChanged(callback: (user: User | undefined) => void) {
|
||||
useChanged(useOptionalUser, callback);
|
||||
}
|
||||
|
||||
export function useHasAdminAccess(matches?: UIMatch[]): boolean {
|
||||
const user = useOptionalUser(matches);
|
||||
|
||||
return Boolean(user?.admin);
|
||||
}
|
||||
|
||||
@@ -168,6 +168,12 @@ export async function adminGetOrganizations(userId: string, { page, search }: Se
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: {
|
||||
contains: search,
|
||||
mode: "insensitive",
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { json, Session } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { createCookieSessionStorage } from "@remix-run/node";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
export type ToastMessage = {
|
||||
|
||||
@@ -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,
|
||||
@@ -103,6 +108,7 @@ export class DeploymentPresenter {
|
||||
exportName: "asc",
|
||||
},
|
||||
},
|
||||
sdkVersion: true,
|
||||
},
|
||||
},
|
||||
triggeredBy: {
|
||||
@@ -116,6 +122,10 @@ export class DeploymentPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const externalBuildData = deployment.externalBuildData
|
||||
? ExternalBuildData.safeParse(deployment.externalBuildData)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
deployment: {
|
||||
id: deployment.id,
|
||||
@@ -134,12 +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,9 +1,11 @@
|
||||
import { Prisma, TaskRunStatus } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { Direction } from "~/components/runs/RunStatuses";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { FINISHED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { CANCELLABLE_STATUSES } from "~/v3/services/cancelTaskRun.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type RunListOptions = {
|
||||
userId?: string;
|
||||
@@ -14,6 +16,7 @@ type RunListOptions = {
|
||||
statuses?: TaskRunStatus[];
|
||||
environments?: string[];
|
||||
scheduleId?: string;
|
||||
period?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
//pagination
|
||||
@@ -28,13 +31,7 @@ export type RunList = Awaited<ReturnType<RunListPresenter["call"]>>;
|
||||
export type RunListItem = RunList["runs"][0];
|
||||
export type RunListAppliedFilters = RunList["filters"];
|
||||
|
||||
export class RunListPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
export class RunListPresenter extends BasePresenter {
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
@@ -43,6 +40,7 @@ export class RunListPresenter {
|
||||
statuses,
|
||||
environments,
|
||||
scheduleId,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction = "forward",
|
||||
@@ -52,15 +50,16 @@ export class RunListPresenter {
|
||||
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;
|
||||
|
||||
// Find the project scoped to the organization
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
@@ -88,15 +87,17 @@ export class RunListPresenter {
|
||||
});
|
||||
|
||||
//get all possible tasks
|
||||
const possibleTasks = await this.#prismaClient.backgroundWorkerTask.findMany({
|
||||
const possibleTasks = await this._replica.backgroundWorkerTask.findMany({
|
||||
distinct: ["slug"],
|
||||
where: {
|
||||
projectId: project.id,
|
||||
},
|
||||
});
|
||||
|
||||
const periodMs = period ? parse(period) : undefined;
|
||||
|
||||
//get the runs
|
||||
let runs = await this.#prismaClient.$queryRaw<
|
||||
let runs = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
number: BigInt;
|
||||
@@ -107,10 +108,9 @@ export class RunListPresenter {
|
||||
status: TaskRunStatus;
|
||||
createdAt: Date;
|
||||
lockedAt: Date | null;
|
||||
completedAt: Date | null;
|
||||
updatedAt: Date;
|
||||
isTest: boolean;
|
||||
spanId: string;
|
||||
attempts: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
@@ -123,20 +123,13 @@ export class RunListPresenter {
|
||||
tr.status AS status,
|
||||
tr."createdAt" AS "createdAt",
|
||||
tr."lockedAt" AS "lockedAt",
|
||||
tra."completedAt" AS "completedAt",
|
||||
tr."updatedAt" AS "updatedAt",
|
||||
tr."isTest" AS "isTest",
|
||||
tr."spanId" AS "spanId",
|
||||
COUNT(tra.id) AS attempts
|
||||
tr."spanId" AS "spanId"
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" tr
|
||||
LEFT JOIN
|
||||
(
|
||||
SELECT *,
|
||||
ROW_NUMBER() OVER (PARTITION BY "taskRunId" ORDER BY "createdAt" DESC) rn
|
||||
FROM ${sqlDatabaseSchema}."TaskRunAttempt"
|
||||
) tra ON tr.id = tra."taskRunId" AND tra.rn = 1
|
||||
LEFT JOIN
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tra."backgroundWorkerId" = bw.id
|
||||
${sqlDatabaseSchema}."BackgroundWorker" bw ON tr."lockedToVersionId" = bw.id
|
||||
WHERE
|
||||
-- project
|
||||
tr."projectId" = ${project.id}
|
||||
@@ -154,21 +147,22 @@ export class RunListPresenter {
|
||||
? Prisma.sql`AND tr."taskIdentifier" IN (${Prisma.join(tasks)})`
|
||||
: Prisma.empty
|
||||
}
|
||||
${hasStatusFilters ? Prisma.sql`AND (` : Prisma.empty}
|
||||
${
|
||||
statuses && statuses.length > 0
|
||||
? Prisma.sql`tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
|
||||
? Prisma.sql`AND tr.status = ANY(ARRAY[${Prisma.join(statuses)}]::"TaskRunStatus"[])`
|
||||
: Prisma.empty
|
||||
}
|
||||
${statuses && statuses.length > 0 && hasStatusFilters ? Prisma.sql` OR ` : Prisma.empty}
|
||||
${hasStatusFilters ? Prisma.sql`tr.status IS NULL` : Prisma.empty}
|
||||
${hasStatusFilters ? Prisma.sql`) ` : Prisma.empty}
|
||||
${
|
||||
environments && environments.length > 0
|
||||
? Prisma.sql`AND tr."runtimeEnvironmentId" IN (${Prisma.join(environments)})`
|
||||
: 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`
|
||||
@@ -179,8 +173,6 @@ export class RunListPresenter {
|
||||
? Prisma.sql`AND tr."createdAt" <= ${new Date(to).toISOString()}::timestamp`
|
||||
: Prisma.empty
|
||||
}
|
||||
GROUP BY
|
||||
tr."friendlyId", tr."taskIdentifier", tr."runtimeEnvironmentId", tr.id, bw.version, tra.status, tr."createdAt", tra."startedAt", tra."completedAt"
|
||||
ORDER BY
|
||||
${direction === "forward" ? Prisma.sql`tr.id DESC` : Prisma.sql`tr.id ASC`}
|
||||
LIMIT ${pageSize + 1}`;
|
||||
@@ -219,19 +211,21 @@ export class RunListPresenter {
|
||||
throw new Error(`Environment not found for TaskRun ${run.id}`);
|
||||
}
|
||||
|
||||
const hasFinished = FINISHED_STATUSES.includes(run.status);
|
||||
|
||||
return {
|
||||
id: run.id,
|
||||
friendlyId: run.runFriendlyId,
|
||||
number: Number(run.number),
|
||||
createdAt: run.createdAt,
|
||||
startedAt: run.lockedAt,
|
||||
completedAt: run.completedAt,
|
||||
createdAt: run.createdAt.toISOString(),
|
||||
startedAt: run.lockedAt ? run.lockedAt.toISOString() : undefined,
|
||||
hasFinished,
|
||||
finishedAt: hasFinished ? run.updatedAt.toISOString() : undefined,
|
||||
isTest: run.isTest,
|
||||
status: run.status,
|
||||
version: run.version,
|
||||
taskIdentifier: run.taskIdentifier,
|
||||
spanId: run.spanId,
|
||||
attempts: Number(run.attempts),
|
||||
isReplayable: true,
|
||||
isCancellable: CANCELLABLE_STATUSES.includes(run.status),
|
||||
environment: displayableEnvironments(environment, userId),
|
||||
@@ -241,7 +235,11 @@ export class RunListPresenter {
|
||||
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 || [],
|
||||
|
||||
@@ -38,6 +38,7 @@ export class RunPresenter {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
organizationId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
@@ -66,9 +67,13 @@ export class RunPresenter {
|
||||
if (!traceSummary) {
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
@@ -118,9 +123,13 @@ export class RunPresenter {
|
||||
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
|
||||
@@ -42,7 +42,7 @@ export class SpanPresenter {
|
||||
const output =
|
||||
span.outputType === "application/store"
|
||||
? `/resources/packets/${span.environmentId}/${span.output}`
|
||||
: typeof span.output !== "undefined" && span.output !== null
|
||||
: typeof span.output !== "undefined"
|
||||
? await prettyPrintPacket(span.output, span.outputType ?? undefined)
|
||||
: undefined;
|
||||
|
||||
|
||||
@@ -4,16 +4,15 @@ import {
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/services/environmentSort.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { displayableEnvironments } from "~/models/runtimeEnvironment.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
export type Task = {
|
||||
slug: string;
|
||||
@@ -26,10 +25,6 @@ export type Task = {
|
||||
type: RuntimeEnvironmentType;
|
||||
userName?: string;
|
||||
}[];
|
||||
latestRun?: {
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
};
|
||||
};
|
||||
|
||||
type Return = Awaited<ReturnType<TaskListPresenter["call"]>>;
|
||||
@@ -98,39 +93,8 @@ export class TaskListPresenter extends BasePresenter {
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" tasks ON tasks."workerId" = workers.id
|
||||
ORDER BY slug ASC;`;
|
||||
|
||||
let latestRuns = [] as {
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
taskIdentifier: string;
|
||||
}[];
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const uniqueTaskSlugs = new Set(tasks.map((t) => t.slug));
|
||||
latestRuns = await this._replica.$queryRaw<
|
||||
{
|
||||
createdAt: Date;
|
||||
status: TaskRunStatus;
|
||||
taskIdentifier: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT * FROM (
|
||||
SELECT
|
||||
"createdAt",
|
||||
"status",
|
||||
"taskIdentifier",
|
||||
ROW_NUMBER() OVER (PARTITION BY "taskIdentifier" ORDER BY "updatedAt" DESC) AS rn
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun"
|
||||
WHERE
|
||||
"taskIdentifier" IN(${Prisma.join(Array.from(uniqueTaskSlugs))})
|
||||
AND "projectId" = ${project.id}
|
||||
) t
|
||||
WHERE rn = 1;`;
|
||||
}
|
||||
|
||||
//group by the task identifier (task.slug). Add the latestRun and add all the environments.
|
||||
const outputTasks = tasks.reduce((acc, task) => {
|
||||
const latestRun = latestRuns.find((r) => r.taskIdentifier === task.slug);
|
||||
const environment = project.environments.find((env) => env.id === task.runtimeEnvironmentId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for TaskRun ${task.id}`);
|
||||
@@ -151,13 +115,6 @@ export class TaskListPresenter extends BasePresenter {
|
||||
//order the environments
|
||||
existingTask.environments = sortEnvironments(existingTask.environments);
|
||||
|
||||
existingTask.latestRun = latestRun
|
||||
? {
|
||||
createdAt: latestRun.createdAt,
|
||||
status: latestRun.status,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return acc;
|
||||
}, [] as Task[]);
|
||||
|
||||
@@ -186,6 +143,10 @@ export class TaskListPresenter extends BasePresenter {
|
||||
}
|
||||
|
||||
async #getActivity(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const activity = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
@@ -257,6 +218,10 @@ export class TaskListPresenter extends BasePresenter {
|
||||
}
|
||||
|
||||
async #getRunningStats(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const statuses = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
@@ -305,6 +270,10 @@ export class TaskListPresenter extends BasePresenter {
|
||||
}
|
||||
|
||||
async #getAverageDurations(tasks: string[], projectId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const durations = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -63,17 +63,18 @@ export class TestPresenter {
|
||||
const searchParams = createSearchParams(url, TestSearchParams);
|
||||
|
||||
//no environmentId
|
||||
if (!searchParams.success || !searchParams.params.get("environment")) {
|
||||
if (!searchParams.success) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
environments,
|
||||
};
|
||||
}
|
||||
|
||||
//default to dev environment
|
||||
const environment = searchParams.params.get("environment") ?? "dev";
|
||||
|
||||
//is the environmentId valid?
|
||||
const matchingEnvironment = project.environments.find(
|
||||
(env) => env.slug === searchParams.params.get("environment")
|
||||
);
|
||||
const matchingEnvironment = project.environments.find((env) => env.slug === environment);
|
||||
if (!matchingEnvironment) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
@@ -101,7 +102,7 @@ export class TestPresenter {
|
||||
WHERE "runtimeEnvironmentId" = ${matchingEnvironment.id}
|
||||
),
|
||||
latest_workers AS (SELECT * FROM workers WHERE rn = 1)
|
||||
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId"
|
||||
SELECT bwt.id, version, slug as "taskIdentifier", "filePath", "exportName", bwt."friendlyId", bwt."triggerSource"
|
||||
FROM latest_workers
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" bwt ON bwt."workerId" = latest_workers.id
|
||||
ORDER BY bwt."exportName" ASC;
|
||||
|
||||
@@ -171,17 +171,23 @@ export class TestTaskPresenter {
|
||||
return {
|
||||
triggerSource: "SCHEDULED",
|
||||
task: taskWithEnvironment,
|
||||
runs: await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
runs: (
|
||||
await Promise.all(
|
||||
latestRuns.map(async (r) => {
|
||||
const number = Number(r.number);
|
||||
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: await getScheduleTaskRunPayload(r),
|
||||
};
|
||||
})
|
||||
),
|
||||
const payload = await getScheduleTaskRunPayload(r);
|
||||
|
||||
if (payload.success) {
|
||||
return {
|
||||
...r,
|
||||
number,
|
||||
payload: payload.data,
|
||||
};
|
||||
}
|
||||
})
|
||||
)
|
||||
).filter(Boolean),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -189,6 +195,6 @@ export class TestTaskPresenter {
|
||||
|
||||
async function getScheduleTaskRunPayload(run: RawRun) {
|
||||
const payload = await parsePacket({ data: run.payload, dataType: run.payloadType });
|
||||
const parsed = ScheduledTaskPayload.parse(payload);
|
||||
const parsed = ScheduledTaskPayload.safeParse(payload);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+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";
|
||||
|
||||
+1
-1
@@ -232,7 +232,7 @@ export default function Page() {
|
||||
<FormError id={projectSlug.errorId}>{projectSlug.error}</FormError>
|
||||
<FormError>{deleteForm.error}</FormError>
|
||||
<Hint>
|
||||
This change is irreversible, so please be certain. Type in the Project slug
|
||||
This change is irreversible, so please be certain. Type in the Project slug{" "}
|
||||
<InlineCode variant="extra-small">{project.slug}</InlineCode> and then press
|
||||
Delete.
|
||||
</Hint>
|
||||
|
||||
+97
-65
@@ -1,23 +1,26 @@
|
||||
import { ChatBubbleLeftRightIcon, ChevronDownIcon, ChevronUpIcon } from "@heroicons/react/20/solid";
|
||||
import { useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration, formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps, XAxis, YAxis } from "recharts";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { InitCommandV3, TriggerDevStepV3, TriggerLoginStepV3 } from "~/components/SetupCommands";
|
||||
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";
|
||||
import { DateTime, formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { StepNumber } from "~/components/primitives/StepNumber";
|
||||
import {
|
||||
@@ -31,13 +34,9 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import TooltipPortal from "~/components/primitives/TooltipPortal";
|
||||
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
|
||||
import {
|
||||
TaskRunStatusCombo,
|
||||
TaskRunStatusIcon,
|
||||
runStatusClassNameColor,
|
||||
runStatusTitle,
|
||||
} from "~/components/runs/v3/TaskRunStatus";
|
||||
import { TaskRunStatusCombo } from "~/components/runs/v3/TaskRunStatus";
|
||||
import {
|
||||
TaskTriggerSourceIcon,
|
||||
taskTriggerSourceDescription,
|
||||
@@ -45,8 +44,8 @@ import {
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3RunsPath, v3TasksStreamingPath } from "~/utils/pathBuilder";
|
||||
@@ -84,6 +83,31 @@ export default function Page() {
|
||||
const project = useProject();
|
||||
const { tasks, userHasTasks, activity, runningStats, durations } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
|
||||
items: tasks,
|
||||
filter: (task, text) => {
|
||||
if (task.slug.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (
|
||||
task.exportName.toLowerCase().includes(text.toLowerCase().replace("(", "").replace(")", ""))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.filePath.toLowerCase().includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (task.triggerSource === "SCHEDULED" && "scheduled".includes(text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
const hasTasks = tasks.length > 0;
|
||||
|
||||
//live reload the page when the tasks change
|
||||
@@ -103,13 +127,43 @@ export default function Page() {
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title="Tasks" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
{tasks.map((task) => (
|
||||
<Property label={task.exportName} key={task.slug}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">
|
||||
{task.environments
|
||||
.map((e) =>
|
||||
e.userName ? `${e.userName}/${e.id}` : `${e.type.slice(0, 3)}/${e.id}`
|
||||
)
|
||||
.join(", ")}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
))}
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<div className={cn("grid h-full grid-cols-1 gap-4")}>
|
||||
<div className="h-full">
|
||||
{hasTasks ? (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex flex-col gap-4 pb-4">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
<div className="pb-4">
|
||||
<div className="h-8">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -120,13 +174,12 @@ export default function Page() {
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell>Last run</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.length > 0 ? (
|
||||
tasks.map((task) => {
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
@@ -208,40 +261,14 @@ 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>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
{task.latestRun ? (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1",
|
||||
runStatusClassNameColor(task.latestRun.status)
|
||||
)}
|
||||
>
|
||||
<TaskRunStatusIcon
|
||||
status={task.latestRun.status}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<DateTime date={task.latestRun.createdAt} />
|
||||
</div>
|
||||
) : (
|
||||
"Never run"
|
||||
)}
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellChevron to={path} />
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={6}>
|
||||
<TableBlankRow colSpan={8}>
|
||||
<Paragraph variant="small" className="flex items-center justify-center">
|
||||
No tasks match your filters
|
||||
</Paragraph>
|
||||
@@ -250,12 +277,12 @@ export default function Page() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
@@ -362,7 +389,9 @@ function TaskActivityGraph({ activity }: { activity: TaskActivity }) {
|
||||
content={<CustomTooltip />}
|
||||
allowEscapeViewBox={{ x: true, y: true }}
|
||||
wrapperStyle={{ zIndex: 1000 }}
|
||||
animationDuration={0}
|
||||
/>
|
||||
|
||||
{/* The background */}
|
||||
<Bar
|
||||
dataKey="bg"
|
||||
@@ -425,18 +454,21 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
}));
|
||||
const title = payload[0].payload.day as string;
|
||||
const formattedDate = formatDateTime(new Date(title), "UTC", [], false, false);
|
||||
|
||||
return (
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.status}>
|
||||
<TaskRunStatusCombo status={item.status} />
|
||||
<p>{item.value}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
<TooltipPortal active={active}>
|
||||
<div className="rounded-sm border border-grid-bright bg-background-dimmed px-3 py-2">
|
||||
<Header3 className="border-b-charcoal-650 border-b pb-2">{formattedDate}</Header3>
|
||||
<div className="mt-2 grid grid-cols-[1fr_auto] gap-2 text-xs text-text-bright">
|
||||
{items.map((item) => (
|
||||
<Fragment key={item.status}>
|
||||
<TaskRunStatusCombo status={item.status} />
|
||||
<p>{item.value}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipPortal>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+16
-3
@@ -1,7 +1,8 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon, LightBulbIcon, ShieldCheckIcon } from "@heroicons/react/20/solid";
|
||||
import { Form } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel, environmentTitle } from "~/components/environments/EnvironmentLabel";
|
||||
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
@@ -10,8 +11,9 @@ import { Callout } from "~/components/primitives/Callout";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { PageAccessories, NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -24,7 +26,6 @@ import {
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { createEnvironment } from "~/models/organization.server";
|
||||
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
|
||||
@@ -132,6 +133,18 @@ export default function Page() {
|
||||
<NavBar>
|
||||
<PageTitle title="API keys" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
{environments.map((environment) => (
|
||||
<Property label={environment.slug} key={environment.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{environment.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
))}
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
variant={"minimal/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
|
||||
+47
-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"
|
||||
@@ -92,6 +135,9 @@ export default function Page() {
|
||||
<DeploymentStatus status={deployment.status} className="text-sm" />
|
||||
</Property>
|
||||
<Property label="Tasks">{deployment.tasks ? deployment.tasks.length : "–"}</Property>
|
||||
<Property label="SDK Version">
|
||||
{deployment.sdkVersion ? deployment.sdkVersion : "–"}
|
||||
</Property>
|
||||
<Property label="Started at">
|
||||
<Paragraph variant="small/bright">
|
||||
<DateTimeAccurate date={deployment.createdAt} /> UTC
|
||||
|
||||
+283
-102
@@ -1,35 +1,44 @@
|
||||
import { Submission, conform, useForm } from "@conform-to/react";
|
||||
import {
|
||||
FieldConfig,
|
||||
list,
|
||||
requestIntent,
|
||||
useFieldList,
|
||||
useFieldset,
|
||||
useForm,
|
||||
} from "@conform-to/react";
|
||||
import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useLocation, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { PlusIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { Form, useActionData, useNavigate, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { Fragment, useEffect, useRef, useState } from "react";
|
||||
import { RefObject, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { redirect, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import {
|
||||
environmentTextClassName,
|
||||
environmentTitle,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
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 { Switch } from "~/components/primitives/Switch";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useList } from "~/hooks/useList";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3NewEnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { CreateEnvironmentVariable } from "~/v3/environmentVariables/repository";
|
||||
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
||||
import dotenv from "dotenv";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -37,17 +46,15 @@ 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) {
|
||||
console.error(error);
|
||||
throw new Response(undefined, {
|
||||
status: 400,
|
||||
statusText: "Something went wrong, if this problem persists please contact support.",
|
||||
@@ -55,9 +62,39 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
}
|
||||
};
|
||||
|
||||
const Variable = z.object({
|
||||
key: EnvironmentVariableKey,
|
||||
value: z.string().nonempty("Value is required"),
|
||||
});
|
||||
|
||||
type Variable = z.infer<typeof Variable>;
|
||||
|
||||
const schema = z.object({
|
||||
action: z.enum(["create", "create-more"]),
|
||||
...CreateEnvironmentVariable.shape,
|
||||
overwrite: z.preprocess((i) => {
|
||||
if (i === "true") return true;
|
||||
if (i === "false") return false;
|
||||
return;
|
||||
}, z.boolean()),
|
||||
environmentIds: z.preprocess((i) => {
|
||||
if (typeof i === "string") return [i];
|
||||
|
||||
if (Array.isArray(i)) {
|
||||
const ids = i.filter((v) => typeof v === "string" && v !== "");
|
||||
if (ids.length === 0) {
|
||||
return;
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
return;
|
||||
}, z.array(z.string(), { required_error: "At least one environment is required" })),
|
||||
variables: z.preprocess((i) => {
|
||||
if (!Array.isArray(i)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return i;
|
||||
}, Variable.array().nonempty("At least one variable is required")),
|
||||
});
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
@@ -92,41 +129,37 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const result = await repository.create(project.id, userId, submission.value);
|
||||
|
||||
if (!result.success) {
|
||||
submission.error.key = result.error;
|
||||
if (result.variableErrors) {
|
||||
for (const { key, error } of result.variableErrors) {
|
||||
const index = submission.value.variables.findIndex((v) => v.key === key);
|
||||
|
||||
if (index !== -1) {
|
||||
submission.error[`variables[${index}].key`] = error;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
submission.error.variables = result.error;
|
||||
}
|
||||
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
switch (submission.value.action) {
|
||||
case "create":
|
||||
return redirect(
|
||||
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam })
|
||||
);
|
||||
case "create-more":
|
||||
return redirectWithSuccessMessage(
|
||||
v3NewEnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
request,
|
||||
`Created ${submission.value.key} environment variable`
|
||||
);
|
||||
}
|
||||
return redirect(v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
};
|
||||
|
||||
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();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const keyFieldRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const isLoading =
|
||||
navigation.state !== "idle" &&
|
||||
navigation.formMethod === "post" &&
|
||||
navigation.formData?.get("action") === "create";
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
|
||||
|
||||
const [form, { key }] = useForm({
|
||||
id: "create-environment-variable",
|
||||
const [form, { environmentIds, variables }] = useForm({
|
||||
id: "create-environment-variables",
|
||||
// TODO: type this
|
||||
lastSubmission: lastSubmission as any,
|
||||
onValidate({ formData }) {
|
||||
@@ -141,14 +174,6 @@ export default function Page() {
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (navigation.state !== "idle") return;
|
||||
if (lastSubmission !== undefined) return;
|
||||
|
||||
form.ref.current?.reset();
|
||||
keyFieldRef.current?.focus();
|
||||
}, [navigation.state, lastSubmission]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
@@ -158,61 +183,64 @@ export default function Page() {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>New environment variable</DialogHeader>
|
||||
<Form method="post" {...form.props}>
|
||||
<DialogContent className="md:max-w-2xl lg:max-w-3xl">
|
||||
<DialogHeader>New environment variables</DialogHeader>
|
||||
<Form
|
||||
method="post"
|
||||
{...form.props}
|
||||
className="max-h-[70vh] overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<Fieldset className="mt-2">
|
||||
<InputGroup fullWidth>
|
||||
<Label>Key</Label>
|
||||
<Input
|
||||
{...conform.input(key)}
|
||||
placeholder="e.g. CLIENT_KEY"
|
||||
autoFocus
|
||||
ref={keyFieldRef}
|
||||
/>
|
||||
</InputGroup>
|
||||
<InputGroup fullWidth>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Values</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal values"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-[auto_1fr] gap-x-2 gap-y-2">
|
||||
{environments.map((environment, index) => {
|
||||
return (
|
||||
<Fragment key={environment.id}>
|
||||
<input
|
||||
type="hidden"
|
||||
name={`values[${index}].environmentId`}
|
||||
value={environment.id}
|
||||
/>
|
||||
<label
|
||||
className="flex items-center justify-end"
|
||||
htmlFor={`values[${index}].value`}
|
||||
<Label>Environments</Label>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{environments.map((environment) => (
|
||||
<Checkbox
|
||||
key={environment.id}
|
||||
id={environment.id}
|
||||
value={environment.id}
|
||||
name="environmentIds"
|
||||
type="radio"
|
||||
label={
|
||||
<span
|
||||
className={cn("text-xs uppercase", environmentTextClassName(environment))}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="h-5 px-2" />
|
||||
</label>
|
||||
<Input
|
||||
type={revealAll ? "text" : "password"}
|
||||
name={`values[${index}].value`}
|
||||
placeholder="Not set"
|
||||
/>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
{environmentTitle(environment)}
|
||||
</span>
|
||||
}
|
||||
variant="button"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<FormError id={environmentIds.errorId}>{environmentIds.error}</FormError>
|
||||
<Hint>
|
||||
Dev environment variables specified here will be overridden by ones in your .env
|
||||
file when running locally.
|
||||
</Hint>
|
||||
</InputGroup>
|
||||
<Hint>Tip: Paste your .env into this form to populate it:</Hint>
|
||||
<InputGroup fullWidth>
|
||||
<FieldLayout>
|
||||
<Label>Keys</Label>
|
||||
<div className="flex justify-between gap-1">
|
||||
<Label>Values</Label>
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Reveal"
|
||||
checked={revealAll}
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
</div>
|
||||
</FieldLayout>
|
||||
<VariableFields
|
||||
revealValues={revealAll}
|
||||
formId={form.id}
|
||||
formRef={form.ref}
|
||||
variablesFields={variables}
|
||||
/>
|
||||
<FormError id={variables.errorId}>{variables.error}</FormError>
|
||||
</InputGroup>
|
||||
|
||||
<Callout variant="info" className="inline-flex">
|
||||
Dev environment variables specified here will be overridden by ones in your{" "}
|
||||
<InlineCode variant="extra-small">.env</InlineCode> file when running locally.
|
||||
</Callout>
|
||||
|
||||
<FormError id={key.errorId}>{key.error}</FormError>
|
||||
<FormError>{form.error}</FormError>
|
||||
<FormButtons
|
||||
confirmButton={
|
||||
@@ -221,18 +249,18 @@ export default function Page() {
|
||||
type="submit"
|
||||
variant="primary/small"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create-more"
|
||||
name="overwrite"
|
||||
value="false"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save and add another"}
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
disabled={isLoading}
|
||||
name="action"
|
||||
value="create"
|
||||
name="overwrite"
|
||||
value="true"
|
||||
>
|
||||
{isLoading ? "Saving" : "Save"}
|
||||
{isLoading ? "Overwriting" : "Overwrite"}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
@@ -251,3 +279,156 @@ export default function Page() {
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLayout({ children }: { children: React.ReactNode }) {
|
||||
return <div className="grid w-full grid-cols-[1fr_1fr_2rem] gap-2">{children}</div>;
|
||||
}
|
||||
|
||||
function VariableFields({
|
||||
revealValues,
|
||||
formId,
|
||||
variablesFields,
|
||||
formRef,
|
||||
}: {
|
||||
revealValues: boolean;
|
||||
formId?: string;
|
||||
variablesFields: FieldConfig<any>;
|
||||
formRef: RefObject<HTMLFormElement>;
|
||||
}) {
|
||||
const {
|
||||
items,
|
||||
append,
|
||||
update,
|
||||
delete: remove,
|
||||
insertAfter,
|
||||
} = useList<Variable>([{ key: "", value: "" }]);
|
||||
|
||||
const handlePaste = useCallback((index: number, e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
const clipboardData = e.clipboardData;
|
||||
if (!clipboardData) return;
|
||||
|
||||
let text = clipboardData.getData("text");
|
||||
if (!text) return;
|
||||
|
||||
const variables = dotenv.parse(text);
|
||||
const keyValuePairs = Object.entries(variables).map(([key, value]) => ({ key, value }));
|
||||
|
||||
//do the default paste
|
||||
if (keyValuePairs.length === 0) return;
|
||||
|
||||
//prevent default pasting
|
||||
e.preventDefault();
|
||||
|
||||
const [firstPair, ...rest] = keyValuePairs;
|
||||
update(index, firstPair);
|
||||
|
||||
for (const pair of rest) {
|
||||
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
|
||||
}
|
||||
insertAfter(index, rest);
|
||||
}, []);
|
||||
|
||||
const fields = useFieldList(formRef, variablesFields);
|
||||
|
||||
return (
|
||||
<>
|
||||
{fields.map((field, index) => {
|
||||
const item = items[index];
|
||||
|
||||
return (
|
||||
<VariableField
|
||||
formId={formId}
|
||||
key={index}
|
||||
index={index}
|
||||
value={item}
|
||||
onChange={(value) => update(index, value)}
|
||||
onPaste={(e) => handlePaste(index, e)}
|
||||
onDelete={() => {
|
||||
requestIntent(
|
||||
formRef.current ?? undefined,
|
||||
list.remove(variablesFields.name, { index })
|
||||
);
|
||||
remove(index);
|
||||
}}
|
||||
showDeleteButton={items.length > 1}
|
||||
showValue={revealValues}
|
||||
config={field}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<Button
|
||||
variant="tertiary/medium"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
requestIntent(formRef.current ?? undefined, list.append(variablesFields.name));
|
||||
append([{ key: "", value: "" }]);
|
||||
}}
|
||||
LeadingIcon={PlusIcon}
|
||||
>
|
||||
Add another
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function VariableField({
|
||||
formId,
|
||||
index,
|
||||
value,
|
||||
onChange,
|
||||
onPaste,
|
||||
onDelete,
|
||||
showDeleteButton,
|
||||
showValue,
|
||||
config,
|
||||
}: {
|
||||
formId?: string;
|
||||
index: number;
|
||||
value: Variable;
|
||||
onChange: (value: Variable) => void;
|
||||
onPaste: (e: React.ClipboardEvent<HTMLInputElement>) => void;
|
||||
onDelete: () => void;
|
||||
showDeleteButton: boolean;
|
||||
showValue: boolean;
|
||||
config: FieldConfig<Variable>;
|
||||
}) {
|
||||
const ref = useRef<HTMLFieldSetElement>(null);
|
||||
const fields = useFieldset(ref, config);
|
||||
const baseFieldName = `variables[${index}]`;
|
||||
|
||||
return (
|
||||
<fieldset ref={ref}>
|
||||
<FieldLayout>
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.key`}
|
||||
name={`${baseFieldName}.key`}
|
||||
placeholder="e.g. CLIENT_KEY"
|
||||
value={value.key}
|
||||
onChange={(e) => onChange({ ...value, key: e.currentTarget.value })}
|
||||
autoFocus={index === 0}
|
||||
onPaste={onPaste}
|
||||
/>
|
||||
<Input
|
||||
id={`${formId}-${baseFieldName}.value`}
|
||||
name={`${baseFieldName}.value`}
|
||||
type={showValue ? "text" : "password"}
|
||||
placeholder="Not set"
|
||||
value={value.value}
|
||||
onChange={(e) => onChange({ ...value, value: e.currentTarget.value })}
|
||||
/>
|
||||
{showDeleteButton && (
|
||||
<Button
|
||||
variant="minimal/medium"
|
||||
type="button"
|
||||
onClick={() => onDelete()}
|
||||
LeadingIcon={XMarkIcon}
|
||||
/>
|
||||
)}
|
||||
</FieldLayout>
|
||||
<div className="space-y-2">
|
||||
<FormError id={fields.key.errorId}>{fields.key.error}</FormError>
|
||||
<FormError id={fields.value.errorId}>{fields.value.error}</FormError>
|
||||
</div>
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
+2
-1
@@ -187,8 +187,9 @@ export default function Page() {
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New environment variable
|
||||
Add new
|
||||
</LinkButton>
|
||||
</div>
|
||||
<Table>
|
||||
|
||||
+97
-24
@@ -1,6 +1,4 @@
|
||||
import {
|
||||
ArrowsPointingInIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
MagnifyingGlassMinusIcon,
|
||||
@@ -17,7 +15,8 @@ import {
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParentIcon";
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
@@ -26,11 +25,13 @@ import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -66,10 +67,8 @@ import {
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { number } from "zod";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primitives/Popover";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -119,6 +118,33 @@ export default function Page() {
|
||||
title={`Run #${run.number}`}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
<Property label="ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Trace ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.traceId}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Env ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.environment.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Org ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">
|
||||
{run.environment.organizationId}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<EnvironmentLabel
|
||||
size="large"
|
||||
environment={run.environment}
|
||||
@@ -168,6 +194,33 @@ export default function Page() {
|
||||
title={`Run #${run.number}`}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
<Property label="ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Trace ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.traceId}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Env ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{run.environment.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Org ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">
|
||||
{run.environment.organizationId}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<EnvironmentLabel size="large" environment={run.environment} userName={usernameForEnv} />
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
@@ -268,29 +321,25 @@ function TasksTreeView({
|
||||
onSelectedIdChanged,
|
||||
estimatedRowHeight: () => 32,
|
||||
parentRef,
|
||||
filter: (node) => {
|
||||
const nodePassesErrorTest = (errorsOnly && node.data.isError) || !errorsOnly;
|
||||
if (!nodePassesErrorTest) return false;
|
||||
filter: {
|
||||
value: { text: filterText, errorsOnly },
|
||||
fn: (value, node) => {
|
||||
const nodePassesErrorTest = (value.errorsOnly && node.data.isError) || !value.errorsOnly;
|
||||
if (!nodePassesErrorTest) return false;
|
||||
|
||||
if (filterText === "") return true;
|
||||
if (node.data.message.toLowerCase().includes(filterText.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
if (value.text === "") return true;
|
||||
if (node.data.message.toLowerCase().includes(value.text.toLowerCase())) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="grid h-full grid-rows-[2.5rem_1fr_3.25rem] overflow-hidden">
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Input
|
||||
placeholder="Search log"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
<SearchField onChange={setFilterText} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
variant="small"
|
||||
@@ -1004,3 +1053,27 @@ function NumberShortcuts({ toggleLevel }: { toggleLevel: (depth: number) => void
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SearchField({ onChange }: { onChange: (value: string) => void }) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
const updateFilterText = useDebounce((text: string) => {
|
||||
onChange(text);
|
||||
}, 250);
|
||||
|
||||
const updateValue = useCallback((value: string) => {
|
||||
setValue(value);
|
||||
updateFilterText(value);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Input
|
||||
placeholder="Search log"
|
||||
variant="tertiary"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={value}
|
||||
onChange={(e) => updateValue(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
+63
-35
@@ -1,7 +1,7 @@
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TypedAwait, typeddefer, typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { BlankstateInstructions } from "~/components/BlankstateInstructions";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
@@ -22,37 +22,47 @@ import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3ProjectPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Suspense } from "react";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
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();
|
||||
const list = await presenter.call({
|
||||
const list = presenter.call({
|
||||
userId,
|
||||
projectSlug: projectParam,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
environments,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
direction: direction,
|
||||
cursor: cursor,
|
||||
});
|
||||
|
||||
return typedjson({
|
||||
list,
|
||||
return typeddefer({
|
||||
data: list,
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { list } = useTypedLoaderData<typeof loader>();
|
||||
const { data } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const project = useProject();
|
||||
@@ -64,36 +74,54 @@ export default function Page() {
|
||||
<PageTitle title="Runs" />
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between gap-x-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={list} />
|
||||
</div>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center py-2">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading runs</Paragraph>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
>
|
||||
<TypedAwait resolve={data}>
|
||||
{(list) => (
|
||||
<>
|
||||
{list.runs.length === 0 && !list.hasFilters ? (
|
||||
list.possibleTasks.length === 0 ? (
|
||||
<CreateFirstTaskInstructions />
|
||||
) : (
|
||||
<RunTaskInstructions />
|
||||
)
|
||||
) : (
|
||||
<div className={cn("grid h-fit grid-cols-1 gap-4")}>
|
||||
<div>
|
||||
<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} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskRunsTable
|
||||
total={list.runs.length}
|
||||
hasFilters={list.hasFilters}
|
||||
filters={list.filters}
|
||||
runs={list.runs}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ListPagination list={list} className="mt-2 justify-end" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
|
||||
+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 : "–"}
|
||||
|
||||
+18
-12
@@ -4,14 +4,16 @@ import { Outlet, useLocation, useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
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";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
@@ -93,6 +95,18 @@ export default function Page() {
|
||||
<NavBar>
|
||||
<PageTitle title="Schedules" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
{schedules.map((schedule) => (
|
||||
<Property label={schedule.friendlyId} key={schedule.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{schedule.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
))}
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
LeadingIcon={PlusIcon}
|
||||
to={`${v3NewSchedulePath(organization, project)}${location.search}`}
|
||||
@@ -261,21 +275,13 @@ function SchedulesTable({
|
||||
{schedule.userProvidedDeduplicationKey ? schedule.deduplicationKey : "–"}
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
<DateTime date={schedule.nextRun} />
|
||||
<DateTime date={schedule.nextRun} timeZone="utc" />
|
||||
</TableCell>
|
||||
<TableCell to={path} className={cellClass}>
|
||||
{schedule.lastRun ? <DateTime date={schedule.lastRun} /> : "–"}
|
||||
{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} />
|
||||
|
||||
+21
-1
@@ -3,6 +3,7 @@ import { parse } from "@conform-to/zod";
|
||||
import { Form, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { ActionFunction, json } from "@remix-run/server-runtime";
|
||||
import { z } from "zod";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
@@ -14,7 +15,9 @@ 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 { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Property, PropertyTable } from "~/components/primitives/PropertyTable";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -110,6 +113,23 @@ export default function Page() {
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={`${project.name} project settings`} />
|
||||
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
<PropertyTable>
|
||||
<Property label="ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{project.id}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
<Property label="Org ID">
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="extra-small/bright/mono">{project.organizationId}</Paragraph>
|
||||
</div>
|
||||
</Property>
|
||||
</PropertyTable>
|
||||
</AdminDebugTooltip>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
|
||||
<PageBody>
|
||||
|
||||
+67
-39
@@ -9,6 +9,7 @@ import {
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Header2 } from "~/components/primitives/Headers";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
@@ -28,6 +30,7 @@ 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";
|
||||
@@ -67,7 +70,7 @@ export default function Page() {
|
||||
|
||||
//get optimistic location for the segment control
|
||||
const optimisticLocation = useOptimisticLocation();
|
||||
const environment = new URLSearchParams(optimisticLocation.search).get("environment");
|
||||
const environment = new URLSearchParams(optimisticLocation.search).get("environment") ?? "dev";
|
||||
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -150,11 +153,21 @@ function TaskSelector({
|
||||
tasks: TaskListItem[];
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
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">
|
||||
<div className="px-2 pb-2">
|
||||
<Input
|
||||
placeholder="Search tasks"
|
||||
variant="medium"
|
||||
icon="search"
|
||||
fullWidth={true}
|
||||
value={filterText}
|
||||
autoFocus
|
||||
onChange={(e) => setFilterText(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -166,42 +179,17 @@ function TaskSelector({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((t) => {
|
||||
const path = v3TestTaskPath(organization, project, t, environmentSlug);
|
||||
const { isActive, isPending } = useLinkStatus(path);
|
||||
return (
|
||||
<TableRow
|
||||
key={t.taskIdentifier}
|
||||
className={cn(
|
||||
(isActive || isPending) &&
|
||||
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
|
||||
)}
|
||||
>
|
||||
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
|
||||
<RadioButtonCircle checked={isActive || isPending} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TaskFunctionName
|
||||
variant="extra-small"
|
||||
functionName={t.exportName}
|
||||
className="-ml-1 inline-flex"
|
||||
/>
|
||||
<div className="flex items-start gap-1">
|
||||
<TaskTriggerSourceIcon source={t.triggerSource} className="size-3.5" />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{t.taskIdentifier}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path} actionClassName="px-2 py-1">
|
||||
{t.filePath}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((t) => (
|
||||
<TaskRow key={t.friendlyId} task={t} environmentSlug={environmentSlug} />
|
||||
))
|
||||
) : (
|
||||
<TableBlankRow colSpan={3}>
|
||||
<Paragraph spacing variant="small">
|
||||
No tasks match "{filterText}"
|
||||
</Paragraph>
|
||||
</TableBlankRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
@@ -217,3 +205,43 @@ function NoTaskInstructions({ environment }: { environment?: SelectedEnvironment
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskRow({ task, environmentSlug }: { task: TaskListItem; environmentSlug: string }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
const path = v3TestTaskPath(organization, project, task, environmentSlug);
|
||||
const { isActive, isPending } = useLinkStatus(path);
|
||||
return (
|
||||
<TableRow
|
||||
key={task.taskIdentifier}
|
||||
className={cn(
|
||||
(isActive || isPending) &&
|
||||
"z-20 rounded-sm outline outline-1 outline-offset-[-1px] outline-secondary"
|
||||
)}
|
||||
>
|
||||
<TableCell to={path} actionClassName="pl-2.5 pr-1 py-1">
|
||||
<RadioButtonCircle checked={isActive || isPending} />
|
||||
</TableCell>
|
||||
<TableCell to={path} actionClassName="pl-1 pr-2 py-1">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<TaskFunctionName
|
||||
variant="extra-small"
|
||||
functionName={task.exportName}
|
||||
className="-ml-1 inline-flex"
|
||||
/>
|
||||
<div className="flex items-start gap-1">
|
||||
<TaskTriggerSourceIcon source={task.triggerSource} className="size-3.5" />
|
||||
<Paragraph variant="extra-small" className="text-text-dimmed">
|
||||
{task.taskIdentifier}
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path} actionClassName="px-2 py-1">
|
||||
{task.filePath}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user