Major dashboard improvements (environment centric) (#1796)
* Delete the proxy app (was v2) * Delete RunPresenterElectric * Select the best proj/org/env * Storing current proj/env in DB. Initial selection logic working with tasks page * 2sm needed to be in the Tailwind merge list * Move the task stream route (although we don’t actually use the env for now) * Alerts moved from /v3 * API keys page moved from /v3 * Concurrency page moved from /v3 * WIP on side menu sections * Improved the accordion animation * Moved schedules from /v3 * More pages moved * Move pages working * Run page working * Schedules working * Moved deployments * Alert pages moved * Delete electric hooks, not used * Started setting up blank states * Test page working * Removed “Select task” from the test page * Some work on deployment page * Style tweaks * Redirect from project root to approriate env * Improved env selector styling * Fix for jsx errors * Better min width on env selector * Improved the env switching logic * Added deployments to env routing * Redirect deployments to the correct env * Redirect run from proj to env * JSX icon fix * Only allow single env schedules from now on * Remove env var count from the API keys page * Move improvements and redirects * Project settings moved * Fix for scroll area on test page * Tweaked the test design * Made recent payloads column narrower * Improved the test layout some more * Added org icon, new project selector menu * WIP on org switching menu * Org switching is working * New menu working well, removed old side menu items * Buttons can now have a component name or an actual component for their icons * Removed the Projects page, instead redirect appropriately * Fix for broken blank states * Minor run table improvements * Removed unused switcher log and logic * Concurrency page fix for invalid html, improved layout * Minor improvements * Moved the side menu to the project level * Improved account styling * Moved org settings pages (with redirects) * Add current plan to billing side menu link * Upgrade to get staging from env dropdown * New env badge on concurrency limits page * Show Run Engine version in span presenter * New promote icon * Concurrency limits page is the sum of engine v1 + v2 queues * Fix for missing batch import * Added currentConcurrencyOfEnvQueue function * Basic avatar setting working * Avatar setting is working * You can change the color of your icon * Avatar improvements * Bugfix for mising prop * Removed some old env badges * Fixed replaying * Removed EnvironmentLabel * Old env badge deleted, changed everywhere to the new one * Fix for Slack integration paths * Fix for waitpoint completion form moving * Bulk replay/cancel env fix * Fix for alert webhook path * Redirect projects/v3/* to project/* * Fixes for CLI redirect routes * Remove welcome email (unused) * Change how we count schedules towards your limits * Use new schedules limits when checking a schedule * Added projectId back in to task queries (indexes) * WIP dev presence * CLI modal * Moved things around and use Context * Fix for p inside p * Dev connected status on run page * Correct dev env (not a teammates) * Show disconnected message at the end * Minor tweak on project dropdown icon padding * Fix for inconsistent date format for presence * Added a message when pushing to the billing page * Center the team page * Project settings page centered * Improvements to the dev presence
This commit is contained in:
@@ -1,7 +0,0 @@
|
||||
REWRITE_HOSTNAME=
|
||||
AWS_SQS_ACCESS_KEY_ID=
|
||||
AWS_SQS_SECRET_ACCESS_KEY=
|
||||
AWS_SQS_QUEUE_URL=
|
||||
AWS_SQS_REGION=
|
||||
#optional
|
||||
#REWRITE_PORT=
|
||||
@@ -1,13 +0,0 @@
|
||||
# http://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = tab
|
||||
tab_width = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.yml]
|
||||
indent_style = space
|
||||
@@ -1,172 +0,0 @@
|
||||
# Logs
|
||||
|
||||
logs
|
||||
_.log
|
||||
npm-debug.log_
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# Runtime data
|
||||
|
||||
pids
|
||||
_.pid
|
||||
_.seed
|
||||
\*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
|
||||
coverage
|
||||
\*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
|
||||
\*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
|
||||
.stylelintcache
|
||||
|
||||
# Microbundle cache
|
||||
|
||||
.rpt2_cache/
|
||||
.rts2_cache_cjs/
|
||||
.rts2_cache_es/
|
||||
.rts2_cache_umd/
|
||||
|
||||
# Optional REPL history
|
||||
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
|
||||
\*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
|
||||
.nuxt
|
||||
dist
|
||||
|
||||
# Gatsby files
|
||||
|
||||
.cache/
|
||||
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
|
||||
.dynamodb/
|
||||
|
||||
# TernJS port file
|
||||
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
|
||||
.vscode-test
|
||||
|
||||
# yarn v2
|
||||
|
||||
.yarn/cache
|
||||
.yarn/unplugged
|
||||
.yarn/build-state.yml
|
||||
.yarn/install-state.gz
|
||||
.pnp.\*
|
||||
|
||||
# wrangler project
|
||||
|
||||
.dev.vars
|
||||
.wrangler/
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"jsxSingleQuote": false,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"bracketSameLine": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
# proxy
|
||||
|
||||
## 0.0.11
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.5
|
||||
|
||||
## 0.0.10
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.4
|
||||
|
||||
## 0.0.9
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.3
|
||||
|
||||
## 0.0.8
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.3.2
|
||||
|
||||
## 0.0.7
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [f3efcc0c]
|
||||
- @trigger.dev/core@2.3.1
|
||||
|
||||
## 0.0.6
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [17f6f29d]
|
||||
- @trigger.dev/core@2.3.0
|
||||
|
||||
## 0.0.5
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.2.11
|
||||
|
||||
## 0.0.4
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- @trigger.dev/core@2.2.10
|
||||
|
||||
## 0.0.3
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [6ebd435e]
|
||||
- @trigger.dev/core@2.2.9
|
||||
|
||||
## 0.0.2
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [067e19fe]
|
||||
- @trigger.dev/core@2.2.8
|
||||
|
||||
## 0.0.1
|
||||
|
||||
### Patch Changes
|
||||
|
||||
- Updated dependencies [756024da]
|
||||
- @trigger.dev/core@2.2.7
|
||||
@@ -1,68 +0,0 @@
|
||||
# Trigger.dev proxy
|
||||
|
||||
This is an optional module that can be used to proxy and queue requests to the Trigger.dev API.
|
||||
|
||||
## Why?
|
||||
|
||||
The Trigger.dev API is designed to be fast and reliable. However, if you have a lot of traffic, you may want to use this proxy to queue requests to the API. It intercepts some requests to the API and adds them to an AWS SQS queue, then the webapp can be setup to process the queue.
|
||||
|
||||
## Current features
|
||||
|
||||
- Intercepts `sendEvent` requests and adds them to an AWS SQS queue. The webapp then reads from the queue and creates the events.
|
||||
|
||||
## Setup
|
||||
|
||||
### Create an AWS SQS queue
|
||||
|
||||
In AWS you should create a new AWS SQS queue with appropriate security settings. You will need the queue URL for the next step.
|
||||
|
||||
### Environment variables
|
||||
|
||||
#### Cloudflare secrets
|
||||
|
||||
Locally you should copy the `.dev.var.example` file to `.dev.var` and fill in the values.
|
||||
|
||||
When deploying you should use `wrangler` (the Cloudflare CLI tool) to set secrets. Make sure you set the correct --env ("staging" or "prod")
|
||||
|
||||
```bash
|
||||
wrangler secret put REWRITE_HOSTNAME --env staging
|
||||
wrangler secret put AWS_SQS_ACCESS_KEY_ID --env staging
|
||||
wrangler secret put AWS_SQS_SECRET_ACCESS_KEY --env staging
|
||||
wrangler secret put AWS_SQS_QUEUE_URL --env staging
|
||||
wrangler secret put AWS_SQS_REGION --env staging
|
||||
```
|
||||
|
||||
You need to set your API CNAME entry to be proxied by Cloudflare. You can do this in the Cloudflare dashboard.
|
||||
|
||||
#### Webapp
|
||||
|
||||
These env vars also need setting in the webapp.
|
||||
|
||||
```bash
|
||||
AWS_SQS_REGION
|
||||
AWS_SQS_ACCESS_KEY_ID
|
||||
AWS_SQS_SECRET_ACCESS_KEY
|
||||
AWS_SQS_QUEUE_URL
|
||||
AWS_SQS_BATCH_SIZE
|
||||
```
|
||||
|
||||
## Deployment
|
||||
|
||||
Staging:
|
||||
|
||||
```bash
|
||||
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env staging
|
||||
```
|
||||
|
||||
Prod:
|
||||
|
||||
```bash
|
||||
npx wrangler@latest deploy --route "<your-api-subdomain>/*" --env prod
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Set the environment variables as described above.
|
||||
|
||||
1. `pnpm install`
|
||||
2. `pnpm run dev --filter proxy`
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"name": "proxy",
|
||||
"version": "0.0.11",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"dev": "wrangler dev",
|
||||
"dry-run:staging": "wrangler deploy --dry-run --outdir=dist --env staging"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@cloudflare/workers-types": "^4.20240512.0",
|
||||
"wrangler": "^3.57.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-sqs": "^3.445.0",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"ulidx": "^2.2.1",
|
||||
"zod": "3.23.8",
|
||||
"zod-error": "1.5.0"
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const AuthorizationHeaderSchema = z.string().regex(/^Bearer .+$/);
|
||||
|
||||
export function getApiKeyFromRequest(request: Request) {
|
||||
const rawAuthorization = request.headers.get("Authorization");
|
||||
|
||||
const authorization = AuthorizationHeaderSchema.safeParse(rawAuthorization);
|
||||
if (!authorization.success) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiKey = authorization.data.replace(/^Bearer /, "");
|
||||
const type = isPrivateApiKey(apiKey) ? ("PRIVATE" as const) : ("PUBLIC" as const);
|
||||
return { apiKey, type };
|
||||
}
|
||||
|
||||
function isPrivateApiKey(key: string) {
|
||||
return key.startsWith("tr_");
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { SQSClient, SendMessageCommand } from "@aws-sdk/client-sqs";
|
||||
import { ApiEventLog, SendEventBodySchema } from "@trigger.dev/core";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { Env } from "..";
|
||||
import { getApiKeyFromRequest } from "../apikey";
|
||||
import { json } from "../json";
|
||||
import { calculateDeliverAt } from "./utils";
|
||||
|
||||
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
|
||||
export async function queueEvent(request: Request, env: Env): Promise<Response> {
|
||||
//check there's a private API key
|
||||
const apiKeyResult = getApiKeyFromRequest(request);
|
||||
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
|
||||
return json(
|
||||
{ error: "Invalid or Missing API key" },
|
||||
{
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//parse the request body
|
||||
try {
|
||||
const anyBody = await request.json();
|
||||
const body = SendEventBodySchema.safeParse(anyBody);
|
||||
if (!body.success) {
|
||||
return json(
|
||||
{ error: generateErrorMessage(body.error.issues) },
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The AWS SDK tries to use crypto from off of the window,
|
||||
// so we need to trick it into finding it where it expects it
|
||||
globalThis.global = globalThis;
|
||||
|
||||
const client = new SQSClient({
|
||||
region: env.AWS_SQS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const timestamp = body.data.event.timestamp ?? new Date();
|
||||
|
||||
//add the event to the queue
|
||||
const send = new SendMessageCommand({
|
||||
// use wrangler secrets to provide this global variable
|
||||
QueueUrl: env.AWS_SQS_QUEUE_URL,
|
||||
MessageBody: JSON.stringify({
|
||||
event: { ...body.data.event, timestamp },
|
||||
options: body.data.options,
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
}),
|
||||
});
|
||||
|
||||
const queuedEvent = await client.send(send);
|
||||
console.log("Queued event", queuedEvent);
|
||||
|
||||
//respond with the event
|
||||
const event: ApiEventLog = {
|
||||
id: body.data.event.id,
|
||||
name: body.data.event.name,
|
||||
payload: body.data.event.payload,
|
||||
context: body.data.event.context,
|
||||
timestamp,
|
||||
deliverAt: calculateDeliverAt(body.data.options),
|
||||
};
|
||||
|
||||
return json(event, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("queueEvent error", e);
|
||||
return json(
|
||||
{
|
||||
error: `Failed to send event: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
import { SQSClient, SendMessageBatchCommand } from "@aws-sdk/client-sqs";
|
||||
import { ApiEventLog, SendBulkEventsBodySchema } from "@trigger.dev/core";
|
||||
import { generateErrorMessage } from "zod-error";
|
||||
import { Env } from "..";
|
||||
import { getApiKeyFromRequest } from "../apikey";
|
||||
import { json } from "../json";
|
||||
import { calculateDeliverAt } from "./utils";
|
||||
|
||||
/** Adds the event to an AWS SQS queue, so it can be consumed from the main Trigger.dev API */
|
||||
export async function queueEvents(request: Request, env: Env): Promise<Response> {
|
||||
//check there's a private API key
|
||||
const apiKeyResult = getApiKeyFromRequest(request);
|
||||
if (!apiKeyResult || apiKeyResult.type !== "PRIVATE") {
|
||||
return json(
|
||||
{ error: "Invalid or Missing API key" },
|
||||
{
|
||||
status: 401,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
//parse the request body
|
||||
try {
|
||||
const anyBody = await request.json();
|
||||
const body = SendBulkEventsBodySchema.safeParse(anyBody);
|
||||
if (!body.success) {
|
||||
return json(
|
||||
{ error: generateErrorMessage(body.error.issues) },
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// The AWS SDK tries to use crypto from off of the window,
|
||||
// so we need to trick it into finding it where it expects it
|
||||
globalThis.global = globalThis;
|
||||
|
||||
const client = new SQSClient({
|
||||
region: env.AWS_SQS_REGION,
|
||||
credentials: {
|
||||
accessKeyId: env.AWS_SQS_ACCESS_KEY_ID,
|
||||
secretAccessKey: env.AWS_SQS_SECRET_ACCESS_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
const updatedEvents: ApiEventLog[] = body.data.events.map((event) => {
|
||||
const timestamp = event.timestamp ?? new Date();
|
||||
return {
|
||||
...event,
|
||||
payload: event.payload,
|
||||
timestamp,
|
||||
};
|
||||
});
|
||||
|
||||
//divide updatedEvents into multiple batches of 10 (max size SQS accepts)
|
||||
const batches: ApiEventLog[][] = [];
|
||||
let currentBatch: ApiEventLog[] = [];
|
||||
for (let i = 0; i < updatedEvents.length; i++) {
|
||||
currentBatch.push(updatedEvents[i]);
|
||||
if (currentBatch.length === 10) {
|
||||
batches.push(currentBatch);
|
||||
currentBatch = [];
|
||||
}
|
||||
}
|
||||
if (currentBatch.length > 0) {
|
||||
batches.push(currentBatch);
|
||||
}
|
||||
|
||||
//loop through the batches and send them
|
||||
for (let i = 0; i < batches.length; i++) {
|
||||
const batch = batches[i];
|
||||
//add the event to the queue
|
||||
const send = new SendMessageBatchCommand({
|
||||
// use wrangler secrets to provide this global variable
|
||||
QueueUrl: env.AWS_SQS_QUEUE_URL,
|
||||
Entries: batch.map((event, index) => ({
|
||||
Id: `event-${index}`,
|
||||
MessageBody: JSON.stringify({
|
||||
event,
|
||||
options: body.data.options,
|
||||
apiKey: apiKeyResult.apiKey,
|
||||
}),
|
||||
})),
|
||||
});
|
||||
|
||||
const queuedEvent = await client.send(send);
|
||||
console.log("Queued events", queuedEvent);
|
||||
}
|
||||
|
||||
//respond with the events
|
||||
const events: ApiEventLog[] = updatedEvents.map((event) => ({
|
||||
...event,
|
||||
payload: event.payload,
|
||||
deliverAt: calculateDeliverAt(body.data.options),
|
||||
}));
|
||||
|
||||
return json(events, {
|
||||
status: 200,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("queueEvents error", e);
|
||||
return json(
|
||||
{
|
||||
error: `Failed to send events: ${e instanceof Error ? e.message : JSON.stringify(e)}`,
|
||||
},
|
||||
{
|
||||
status: 422,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { SendEventOptions } from "@trigger.dev/core";
|
||||
|
||||
export function calculateDeliverAt(options?: SendEventOptions) {
|
||||
// If deliverAt is a string and a valid date, convert it to a Date object
|
||||
if (options?.deliverAt) {
|
||||
return options?.deliverAt;
|
||||
}
|
||||
|
||||
// deliverAfter is the number of seconds to wait before delivering the event
|
||||
if (options?.deliverAfter) {
|
||||
return new Date(Date.now() + options.deliverAfter * 1000);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { queueEvent } from "./events/queueEvent";
|
||||
import { queueEvents } from "./events/queueEvents";
|
||||
import { applyRateLimit } from "./rateLimit";
|
||||
import { Ratelimit } from "./rateLimiter";
|
||||
|
||||
export interface Env {
|
||||
/** The hostname needs to be changed to allow requests to pass to the Trigger.dev platform */
|
||||
REWRITE_HOSTNAME: string;
|
||||
REWRITE_PORT?: string;
|
||||
AWS_SQS_ACCESS_KEY_ID: string;
|
||||
AWS_SQS_SECRET_ACCESS_KEY: string;
|
||||
AWS_SQS_QUEUE_URL: string;
|
||||
AWS_SQS_REGION: string;
|
||||
//rate limiter
|
||||
API_RATE_LIMITER: Ratelimit;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
if (!queueingIsEnabled(env)) {
|
||||
console.log("Missing AWS credentials. Passing through to the origin.");
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
switch (url.pathname) {
|
||||
case "/api/v1/events": {
|
||||
if (request.method === "POST") {
|
||||
return applyRateLimit(request, env, () => queueEvent(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "/api/v1/events/bulk": {
|
||||
if (request.method === "POST") {
|
||||
return applyRateLimit(request, env, () => queueEvents(request, env));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
//the same request but with the hostname (and port) changed
|
||||
return fetch(request);
|
||||
},
|
||||
};
|
||||
|
||||
function queueingIsEnabled(env: Env) {
|
||||
return (
|
||||
env.AWS_SQS_ACCESS_KEY_ID &&
|
||||
env.AWS_SQS_SECRET_ACCESS_KEY &&
|
||||
env.AWS_SQS_QUEUE_URL &&
|
||||
env.AWS_SQS_REGION
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
export function json(body: any, init?: ResponseInit) {
|
||||
const headers = {
|
||||
"content-type": "application/json",
|
||||
...(init?.headers ?? {}),
|
||||
};
|
||||
|
||||
const responseInit: ResponseInit = {
|
||||
...(init ?? {}),
|
||||
headers,
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), responseInit);
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Env } from "src";
|
||||
import { getApiKeyFromRequest } from "./apikey";
|
||||
import { json } from "./json";
|
||||
|
||||
export async function applyRateLimit(
|
||||
request: Request,
|
||||
env: Env,
|
||||
fn: () => Promise<Response>
|
||||
): Promise<Response> {
|
||||
const apiKey = getApiKeyFromRequest(request);
|
||||
if (apiKey) {
|
||||
const result = await env.API_RATE_LIMITER.limit({ key: `apikey-${apiKey.apiKey}` });
|
||||
const { success } = result;
|
||||
console.log(`Rate limiter`, {
|
||||
success,
|
||||
key: `${apiKey.apiKey.substring(0, 12)}...`,
|
||||
});
|
||||
if (!success) {
|
||||
//60s in the future
|
||||
const reset = Date.now() + 60 * 1000;
|
||||
const secondsUntilReset = Math.max(0, (reset - new Date().getTime()) / 1000);
|
||||
|
||||
return json(
|
||||
{
|
||||
title: "Rate Limit Exceeded",
|
||||
status: 429,
|
||||
type: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/429",
|
||||
detail: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
error: `Rate limit exceeded. Retry in ${secondsUntilReset} seconds.`,
|
||||
reset,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
"x-ratelimit-reset": reset.toString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log(`Rate limiter: no API key for request`);
|
||||
}
|
||||
|
||||
//call the original function
|
||||
return fn();
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
export interface Ratelimit {
|
||||
/*
|
||||
* The ratelimit function
|
||||
* @param {RatelimitOptions} options
|
||||
* @returns {Promise<RatelimitResponse>}
|
||||
*/
|
||||
limit: (options: RatelimitOptions) => Promise<RatelimitResponse>;
|
||||
}
|
||||
|
||||
export interface RatelimitOptions {
|
||||
/*
|
||||
* The key to identify the user, can be an IP address, user ID, etc.
|
||||
*/
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface RatelimitResponse {
|
||||
/*
|
||||
* The ratelimit success status
|
||||
* @returns {boolean}
|
||||
*/
|
||||
success: boolean;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2021" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
|
||||
"lib": [
|
||||
"es2021"
|
||||
] /* Specify a set of bundled library declaration files that describe the target runtime environment. */,
|
||||
"jsx": "react" /* Specify what JSX code is generated. */,
|
||||
|
||||
"module": "es2022" /* Specify what module code is generated. */,
|
||||
"moduleResolution": "node" /* Specify how TypeScript looks up a file from a given module specifier. */,
|
||||
|
||||
"types": [
|
||||
"@cloudflare/workers-types"
|
||||
] /* Specify type package names to be included without being referenced in a source file. */,
|
||||
"resolveJsonModule": true /* Enable importing .json files */,
|
||||
|
||||
"allowJs": true /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */,
|
||||
"checkJs": false /* Enable error reporting in type-checked JavaScript files. */,
|
||||
|
||||
"noEmit": true /* Disable emitting files from a compilation. */,
|
||||
|
||||
"isolatedModules": true /* Ensure that each file can be safely transpiled without relying on other imports. */,
|
||||
"allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */,
|
||||
"forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
|
||||
|
||||
"strict": true /* Enable all strict type-checking options. */,
|
||||
|
||||
"skipLibCheck": true /* Skip type checking all .d.ts files. */,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@trigger.dev/core": ["../../packages/core/src/index"],
|
||||
"@trigger.dev/core/*": ["../../packages/core/src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
name = "proxy"
|
||||
main = "src/index.ts"
|
||||
compatibility_date = "2024-05-13"
|
||||
compatibility_flags = [ "nodejs_compat" ]
|
||||
|
||||
[env.staging]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.staging.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "1"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 100, period = 60 }
|
||||
|
||||
|
||||
[env.prod]
|
||||
# The rate limiting API is in open beta.
|
||||
[[env.prod.unsafe.bindings]]
|
||||
name = "API_RATE_LIMITER"
|
||||
type = "ratelimit"
|
||||
# An identifier you define, that is unique to your Cloudflare account.
|
||||
# Must be an integer.
|
||||
namespace_id = "2"
|
||||
|
||||
# Limit: the number of tokens allowed within a given period in a single
|
||||
# Cloudflare location
|
||||
# Period: the duration of the period, in seconds. Must be either 10 or 60
|
||||
simple = { limit = 300, period = 60 }
|
||||
@@ -0,0 +1,53 @@
|
||||
export function ConnectedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.5"
|
||||
y="-0.5"
|
||||
width="19"
|
||||
height="1"
|
||||
rx="0.5"
|
||||
transform="matrix(1 0 0 -1 0 17)"
|
||||
stroke="#878C99"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M12.4187 2L4 2C2.89543 2 2 2.89543 2 4L2 12C2 13.1046 2.89543 14 4 14L16 14C17.1046 14 18 13.1046 18 12L18 4.9816L16.5 6.4816L16.5 12C16.5 12.2761 16.2761 12.5 16 12.5L4 12.5C3.72386 12.5 3.5 12.2761 3.5 12L3.5 4C3.5 3.72386 3.72386 3.5 4 3.5L10.9187 3.5L12.4187 2Z"
|
||||
fill="#878C99"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 6.75L9.5 9.75L16 3"
|
||||
stroke="#28BF5C"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DisconnectedIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.2279 14.8334L1.50037 14.8334C1.04013 14.8334 0.667035 15.2065 0.667035 15.6667C0.667035 16.1269 1.04013 16.5 1.50037 16.5L12.8945 16.5L11.2279 14.8334Z"
|
||||
fill="#878C99"
|
||||
/>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M2.33268 5.93927L2.33268 11.1667C2.33268 12.2713 3.22811 13.1667 4.33268 13.1667L9.56016 13.1667L8.06016 11.6667L4.33268 11.6667C4.05654 11.6667 3.83268 11.4429 3.83268 11.1667L3.83268 7.43927L2.33268 5.93927ZM14.166 10.6369L14.166 5.16675C14.166 4.8906 13.9422 4.66675 13.666 4.66675L8.19589 4.66675L6.69589 3.16675L13.666 3.16675C14.7706 3.16675 15.666 4.06218 15.666 5.16675L15.666 11.1667C15.666 11.4522 15.6062 11.7237 15.4985 11.9693L14.166 10.6369Z"
|
||||
fill="#878C99"
|
||||
/>
|
||||
<path
|
||||
d="M1.5 1.50006L16.5 16.5001"
|
||||
stroke="#E11D48"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export function DropdownIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M7.5 9L12 5L16.5 9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M7.5 15L12 19L16.5 15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
export function DevEnvironmentIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clipPath="url(#clip0_15347_71059)">
|
||||
<circle cx="7" cy="7" r="2" fill="currentColor" />
|
||||
<path
|
||||
d="M4.5 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V4.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M13.25 4.5L13.25 2.75C13.25 1.64543 12.3546 0.75 11.25 0.75L9.5 0.75"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 13.25L11.25 13.25C12.3546 13.25 13.25 12.3546 13.25 11.25L13.25 9.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M0.75 9.5L0.75 11.25C0.75 12.3546 1.64543 13.25 2.75 13.25L4.5 13.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15347_71059">
|
||||
<rect width="14" height="14" fill="currentColor" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProdEnvironmentIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.75"
|
||||
y="0.75"
|
||||
width="12.5"
|
||||
height="12.5"
|
||||
rx="3.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<g clipPath="url(#clip0_15515_83281)">
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M7.34731 4.15348C7.21887 3.84467 6.78141 3.84467 6.65298 4.15348L5.92066 5.91419L4.01982 6.06658C3.68644 6.0933 3.55126 6.50935 3.80526 6.72693L5.2535 7.96749L4.81104 9.82238C4.73344 10.1477 5.08735 10.4048 5.37277 10.2305L7.00014 9.23651L8.62752 10.2305C8.91294 10.4048 9.26685 10.1477 9.18925 9.82238L8.74679 7.96749L10.195 6.72693C10.449 6.50935 10.3138 6.0933 9.98046 6.06658L8.07963 5.91419L7.34731 4.15348Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_15515_83281">
|
||||
<rect width="8" height="8" fill="currentColor" transform="translate(3 3)" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeployedEnvironmentIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect
|
||||
x="0.75"
|
||||
y="0.75"
|
||||
width="12.5"
|
||||
height="12.5"
|
||||
rx="3.25"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="7" cy="7" r="2" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
export function PromoteIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.5 15.75H5.25493C4.4265 15.75 3.75493 16.4216 3.75493 17.25V19.741C3.75493 20.5695 4.4265 21.241 5.25493 21.241H18.7582C19.5866 21.241 20.2582 20.5695 20.2582 19.741V17.25C20.2582 16.4216 19.5866 15.75 18.7582 15.75H14.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M9.5 7.75H5.25493C4.4265 7.75 3.75493 8.42157 3.75493 9.25V11.741C3.75493 12.5695 4.4265 13.241 5.25493 13.241H9.5M14.5 7.75002H18.7582C19.5866 7.75002 20.2582 8.42159 20.2582 9.25002V11.741C20.2582 12.5695 19.5866 13.241 18.7582 13.241H14.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path d="M12 18L12 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
<path
|
||||
d="M9 5.22892L12 2.75L15 5.22892"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export function ToggleArrowIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 6 5" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M3.43301 4.25C3.24056 4.58333 2.75944 4.58333 2.56699 4.25L0.834937 1.25C0.642486 0.916667 0.883049 0.5 1.26795 0.5L4.73205 0.5C5.11695 0.5 5.35751 0.916667 5.16506 1.25L3.43301 4.25Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
@@ -0,0 +1,421 @@
|
||||
import {
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ClockIcon,
|
||||
PlusIcon,
|
||||
RectangleGroupIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import {
|
||||
docsPath,
|
||||
v3EnvironmentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewSchedulePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { InlineCode } from "./code/InlineCode";
|
||||
import { environmentFullTitle } from "./environments/EnvironmentLabel";
|
||||
import { Feedback } from "./Feedback";
|
||||
import { Button, LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { InfoPanel } from "./primitives/InfoPanel";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { StepNumber } from "./primitives/StepNumber";
|
||||
import { InitCommandV3, PackageManagerProvider, TriggerDevStepV3 } from "./SetupCommands";
|
||||
import { StepContentContainer } from "./StepContentContainer";
|
||||
import { useLocation } from "react-use";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { TextLink } from "./primitives/TextLink";
|
||||
import { EnvironmentSelector } from "./navigation/EnvironmentSelector";
|
||||
import { Pi } from "lucide-react";
|
||||
|
||||
export function HasNoTasksDev() {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You'll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function HasNoTasksDeployed({ environment }: { environment: MinimumEnvironment }) {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="You don't have any deployed tasks"
|
||||
icon={TaskIcon}
|
||||
iconClassName="text-blue-500"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You don't have any deployed tasks in {environmentFullTitle(environment)}.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("deployment/overview")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
How to deploy tasks
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchedulesNoPossibleTaskPanel() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Create your first scheduled task"
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-sun-500"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no scheduled tasks in your project. Before you can schedule a task you need to
|
||||
create a <InlineCode>schedules.task</InlineCode>.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
View the docs
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function SchedulesNoneAttached() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Attach your first schedule"
|
||||
icon={ClockIcon}
|
||||
iconClassName="text-sun-500"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
Scheduled tasks will only run automatically if you connect a schedule to them, you can do
|
||||
this in the dashboard or using the SDK.
|
||||
</Paragraph>
|
||||
<div className="flex gap-2">
|
||||
<LinkButton
|
||||
to={`${v3NewSchedulePath(organization, project, environment)}${location.search}`}
|
||||
variant="primary/small"
|
||||
LeadingIcon={RectangleGroupIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Use the dashboard
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/tasks-scheduled")}
|
||||
variant="primary/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Use the SDK
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function BatchesNone() {
|
||||
return (
|
||||
<InfoPanel
|
||||
title="Triggering batches"
|
||||
icon={Squares2X2Icon}
|
||||
iconClassName="text-blue-500"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no batches in this environment. You can trigger batches from your backend or from
|
||||
inside other tasks.
|
||||
</Paragraph>
|
||||
<LinkButton to={docsPath("triggering")} variant="docs/medium" LeadingIcon={BookOpenIcon}>
|
||||
How to trigger batches
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function TestHasNoTasks() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
title="No tasks to test"
|
||||
icon={BeakerIcon}
|
||||
iconClassName="text-lime-500"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You have no tasks in this environment.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Add tasks
|
||||
</LinkButton>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentsNone() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-blue-500"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI, Continuous Integration
|
||||
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
|
||||
you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeploymentsNoneDev() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-blue-500"
|
||||
title="Deploying tasks"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
This is the Development environment. When you're ready to deploy your tasks, switch to a
|
||||
different environment.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI, Continuous Integration
|
||||
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
|
||||
you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project, environment)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsNoneDev() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={BellAlertIcon}
|
||||
iconClassName="text-red-500"
|
||||
title="Adding alerts"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You can get alerted when deployed runs fail.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
We don't support alerts in the Development environment. Switch to a deployed environment
|
||||
to setup alerts.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("troubleshooting-alerts")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
How to setup alerts
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AlertsNoneDeployed() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={BellAlertIcon}
|
||||
iconClassName="text-red-500"
|
||||
title="Adding alerts"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You can get alerted when deployed runs fail. We currently support sending Slack, Email,
|
||||
and webhooks.
|
||||
</Paragraph>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("troubleshooting-alerts")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Alert docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertsNoneProd() {
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<InfoPanel
|
||||
icon={BellAlertIcon}
|
||||
iconClassName="text-red-500"
|
||||
title="Adding alerts"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
You can get alerted when deployed runs fail.
|
||||
</Paragraph>
|
||||
<Paragraph spacing variant="small">
|
||||
We don't support alerts in the Development environment. Switch to a deployed environment
|
||||
to setup alerts.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("troubleshooting-alerts")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
How to setup alerts
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
<SwitcherPanel />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SwitcherPanel() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-md border border-grid-bright bg-background-bright p-3">
|
||||
<Paragraph variant="small" className="grow">
|
||||
Switch to a deployed environment
|
||||
</Paragraph>
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-auto grow-0 rounded-sm bg-grid-bright"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
|
||||
// Define Context types
|
||||
type DevPresenceContextType = {
|
||||
lastSeen: Date | null;
|
||||
isConnected: boolean;
|
||||
};
|
||||
|
||||
// Create Context with default values
|
||||
const DevPresenceContext = createContext<DevPresenceContextType>({
|
||||
lastSeen: null,
|
||||
isConnected: false,
|
||||
});
|
||||
|
||||
// Provider component with enabled prop
|
||||
interface DevPresenceProviderProps {
|
||||
children: ReactNode;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export function DevPresenceProvider({ children, enabled = true }: DevPresenceProviderProps) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Only subscribe to event source if enabled is true
|
||||
const streamedEvents = useEventSource(
|
||||
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dev/presence`,
|
||||
{
|
||||
event: "presence",
|
||||
disabled: !enabled,
|
||||
}
|
||||
);
|
||||
|
||||
const [lastSeen, setLastSeen] = useState<Date | null>(null);
|
||||
|
||||
const debouncer = useDebounce((seen: Date | null) => {
|
||||
setLastSeen(seen);
|
||||
}, 3_000);
|
||||
|
||||
useEffect(() => {
|
||||
// If disabled or no events, set lastSeen to null
|
||||
if (!enabled || streamedEvents === null) {
|
||||
debouncer(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(streamedEvents) as any;
|
||||
if ("lastSeen" in data && data.lastSeen) {
|
||||
try {
|
||||
const lastSeenDate = new Date(data.lastSeen);
|
||||
debouncer(lastSeenDate);
|
||||
} catch (error) {
|
||||
console.log("DevPresence: Failed to parse lastSeen timestamp", { error });
|
||||
debouncer(null);
|
||||
}
|
||||
} else {
|
||||
debouncer(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log("DevPresence: Failed to parse presence message", { error });
|
||||
debouncer(null);
|
||||
}
|
||||
}, [streamedEvents, enabled]);
|
||||
|
||||
// Calculate isConnected and memoize the context value
|
||||
const contextValue = useMemo(() => {
|
||||
const isConnected = enabled && lastSeen !== null && lastSeen > new Date(Date.now() - 120_000);
|
||||
return { lastSeen, isConnected };
|
||||
}, [lastSeen, enabled]);
|
||||
|
||||
return <DevPresenceContext.Provider value={contextValue}>{children}</DevPresenceContext.Provider>;
|
||||
}
|
||||
|
||||
// Custom hook to use the context
|
||||
export function useDevPresence() {
|
||||
const context = useContext(DevPresenceContext);
|
||||
if (context === undefined) {
|
||||
throw new Error("useDevPresence must be used within a DevPresenceProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header1 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import Spline from "@splinetool/react-spline";
|
||||
import { ReactNode } from "react";
|
||||
import { type ReactNode } from "react";
|
||||
|
||||
type ErrorDisplayOptions = {
|
||||
button?: {
|
||||
|
||||
@@ -63,7 +63,7 @@ function getApiUrlArg() {
|
||||
|
||||
export function InitCommandV3() {
|
||||
const project = useProject();
|
||||
const projectRef = project.ref;
|
||||
const projectRef = project.externalRef;
|
||||
const apiUrlArg = getApiUrlArg();
|
||||
|
||||
const initCommandParts = [`trigger.dev@${v3PackageTag}`, "init", `-p ${projectRef}`, apiUrlArg];
|
||||
|
||||
@@ -58,7 +58,7 @@ function Content({ children }: { children: React.ReactNode }) {
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Project ref</Property.Label>
|
||||
<Property.Value>{project.ref}</Property.Value>
|
||||
<Property.Value>{project.externalRef}</Property.Value>
|
||||
</Property.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -25,7 +25,7 @@ export function FreePlanUsage({ to, percentage }: { to: string; percentage: numb
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpCircleIcon className="h-5 w-5 text-text-dimmed" />
|
||||
<Paragraph className="text-2sm text-text-bright">Free Plan</Paragraph>
|
||||
<span className="text-2sm text-text-bright">Free Plan</span>
|
||||
</div>
|
||||
<Link to={to} className="text-2sm text-text-link focus-custom">
|
||||
Upgrade
|
||||
|
||||
@@ -2,131 +2,67 @@ import type { RuntimeEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import {
|
||||
DeployedEnvironmentIcon,
|
||||
DevEnvironmentIcon,
|
||||
ProdEnvironmentIcon,
|
||||
} from "~/assets/icons/EnvironmentIcons";
|
||||
|
||||
type Environment = Pick<RuntimeEnvironment, "type">;
|
||||
const variants = {
|
||||
small: "h-4 text-xxs px-[0.1875rem] rounded-[2px]",
|
||||
large: "h-6 text-xs px-1.5 rounded",
|
||||
};
|
||||
|
||||
export function EnvironmentTypeLabel({
|
||||
export function EnvironmentIcon({
|
||||
environment,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
}) {
|
||||
switch (environment.type) {
|
||||
case "DEVELOPMENT":
|
||||
return (
|
||||
<DevEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
|
||||
);
|
||||
case "PRODUCTION":
|
||||
return (
|
||||
<ProdEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
|
||||
);
|
||||
case "STAGING":
|
||||
case "PREVIEW":
|
||||
return (
|
||||
<DeployedEnvironmentIcon className={cn(environmentTextClassName(environment), className)} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function EnvironmentCombo({
|
||||
environment,
|
||||
size = "small",
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
size?: keyof typeof variants;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName(environment),
|
||||
environmentTextClassName(environment),
|
||||
variants[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{environmentTypeTitle(environment)}
|
||||
<span className={cn("flex items-center gap-1.5 text-sm text-text-bright", className)}>
|
||||
<EnvironmentIcon environment={environment} className="size-4" />
|
||||
<EnvironmentLabel environment={environment} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EnvironmentLabel({
|
||||
environment,
|
||||
size = "small",
|
||||
userName,
|
||||
className,
|
||||
}: {
|
||||
environment: Environment;
|
||||
size?: keyof typeof variants;
|
||||
userName?: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-midnight-900 inline-flex items-center justify-center whitespace-nowrap border font-medium uppercase tracking-wider",
|
||||
environmentBorderClassName(environment),
|
||||
environmentTextClassName(environment),
|
||||
variants[size],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{environmentTitle(environment, userName)}
|
||||
<span className={cn(environmentTextClassName(environment), className)}>
|
||||
{environmentFullTitle(environment)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
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":
|
||||
@@ -140,6 +76,19 @@ export function environmentTitle(environment: Environment, username?: string) {
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentFullTitle(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
return "Production";
|
||||
case "STAGING":
|
||||
return "Staging";
|
||||
case "DEVELOPMENT":
|
||||
return "Development";
|
||||
case "PREVIEW":
|
||||
return "Preview";
|
||||
}
|
||||
}
|
||||
|
||||
export function environmentTypeTitle(environment: Environment) {
|
||||
switch (environment.type) {
|
||||
case "PRODUCTION":
|
||||
|
||||
@@ -66,3 +66,24 @@ export function MainCenteredContainer({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MainHorizontallyCenteredContainer({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto mt-6 max-w-lg overflow-y-auto p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 md:mt-14",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ShieldCheckIcon, UserCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { User } from "@trigger.dev/database";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type User } from "@trigger.dev/database";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { accountPath, personalAccessTokensPath, rootPath } from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
@@ -10,54 +9,43 @@ import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
const { v3Enabled } = useFeatures();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className={cn("flex h-10 items-center justify-between border-b p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
>
|
||||
Account
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={user.name ?? user.displayName ?? user.email} />
|
||||
|
||||
<SideMenuItem
|
||||
name="Your profile"
|
||||
icon={UserCircleIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
</div>
|
||||
{v3Enabled && (
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title="Security" />
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
activeIconColor="text-emerald-500"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
</div>
|
||||
<div className={cn("flex items-center justify-between p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="text-text-bright"
|
||||
>
|
||||
Back to app
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="mb-6 flex grow flex-col gap-1 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<SideMenuHeader title="Account" />
|
||||
<SideMenuItem
|
||||
name="Profile"
|
||||
icon={UserCircleIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={accountPath()}
|
||||
data-action="account"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
activeIconColor="text-emerald-500"
|
||||
to={personalAccessTokensPath()}
|
||||
data-action="tokens"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEnvironmentSwitcher } from "~/hooks/useEnvironmentSwitcher";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
} from "../primitives/Popover";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
|
||||
export function EnvironmentSelector({
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
const { urlForEnvironment } = useEnvironmentSwitcher();
|
||||
|
||||
useEffect(() => {
|
||||
setIsMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
const hasStaging = project.environments.some((env) => env.type === "STAGING");
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isMenuOpen}
|
||||
overflowHidden
|
||||
fullWidth
|
||||
className={cn("h-7 overflow-hidden py-1 pl-2", className)}
|
||||
>
|
||||
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{project.environments.map((env) => (
|
||||
<PopoverMenuItem
|
||||
key={env.id}
|
||||
to={urlForEnvironment(env)}
|
||||
title={<EnvironmentCombo environment={env} className="mx-auto grow text-2sm" />}
|
||||
isSelected={env.id === environment.id}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{!hasStaging && isManagedCloud && (
|
||||
<>
|
||||
<PopoverSectionHeader title="Additional environments" />
|
||||
<div className="p-1">
|
||||
<PopoverMenuItem
|
||||
key="staging"
|
||||
to={v3BillingPath(
|
||||
organization,
|
||||
"Upgrade to unlock a Staging environment for your projects."
|
||||
)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<EnvironmentCombo environment={{ type: "STAGING" }} className="text-2sm" />
|
||||
<span className="text-indigo-500">Upgrade</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
ChartBarIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
UserGroupIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowLeftIcon } from "@heroicons/react/24/solid";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
rootPath,
|
||||
v3BillingPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
|
||||
export function OrganizationSettingsSideMenu({
|
||||
organization,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[2.5rem_auto_2.5rem] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex items-center justify-between p-1 transition")}>
|
||||
<LinkButton
|
||||
variant="minimal/medium"
|
||||
LeadingIcon={ArrowLeftIcon}
|
||||
to={rootPath()}
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
className="text-text-bright"
|
||||
>
|
||||
Back to app
|
||||
</LinkButton>
|
||||
</div>
|
||||
<div className="mb-6 flex grow flex-col gap-1 overflow-y-auto px-1 pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<SideMenuHeader title="Organization" />
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3UsagePath(organization)}
|
||||
data-action="usage"
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
activeIconColor="text-emerald-500"
|
||||
to={v3BillingPath(organization)}
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying
|
||||
? currentPlan?.v3Subscription?.plan?.title
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
to={organizationTeamPath(organization)}
|
||||
data-action="team"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="settings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,52 @@
|
||||
import {
|
||||
AcademicCapIcon,
|
||||
ArrowPathRoundedSquareIcon,
|
||||
ArrowRightOnRectangleIcon,
|
||||
BeakerIcon,
|
||||
BellAlertIcon,
|
||||
BookOpenIcon,
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CreditCardIcon,
|
||||
CogIcon,
|
||||
FolderIcon,
|
||||
FolderOpenIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
ShieldCheckIcon,
|
||||
Squares2X2Icon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { UserGroupIcon, UserPlusIcon } from "@heroicons/react/24/solid";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { Fragment, ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { ConnectedIcon, DisconnectedIcon } from "~/assets/icons/ConnectionIcons";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { Avatar } from "~/components/primitives/Avatar";
|
||||
import { type MatchedEnvironment } from "~/hooks/useEnvironment";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { FeedbackType } from "~/routes/resources.feedback";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
accountPath,
|
||||
inviteTeamMemberPath,
|
||||
docsPath,
|
||||
logoutPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
organizationPath,
|
||||
organizationSettingsPath,
|
||||
organizationTeamPath,
|
||||
personalAccessTokensPath,
|
||||
v3ApiKeysPath,
|
||||
v3BatchesPath,
|
||||
v3BillingPath,
|
||||
v3ConcurrencyPath,
|
||||
v3DeploymentsPath,
|
||||
v3EnvironmentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectPath,
|
||||
@@ -52,42 +56,71 @@ import {
|
||||
v3TestPath,
|
||||
v3UsagePath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { useDevPresence } from "../DevPresence";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
import { LogoIcon } from "../LogoIcon";
|
||||
import { PackageManagerProvider, TriggerDevStepV3 } from "../SetupCommands";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import connectedImage from "../../assets/images/cli-connected.png";
|
||||
import disconnectedImage from "../../assets/images/cli-disconnected.png";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { Button, ButtonContent, LinkButton } from "../primitives/Buttons";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTrigger,
|
||||
} from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverCustomTrigger,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { EnvironmentSelector } from "./EnvironmentSelector";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "../primitives/Tooltip";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuProject = Pick<MatchedProject, "id" | "name" | "slug" | "version">;
|
||||
export type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "version" | "environments"
|
||||
>;
|
||||
export type SideMenuEnvironment = MatchedEnvironment;
|
||||
|
||||
type SideMenuProps = {
|
||||
user: SideMenuUser;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
button?: ReactNode;
|
||||
defaultValue?: FeedbackType;
|
||||
};
|
||||
|
||||
export function SideMenu({ user, project, organization, organizations }: SideMenuProps) {
|
||||
export function SideMenu({
|
||||
user,
|
||||
project,
|
||||
environment,
|
||||
organization,
|
||||
organizations,
|
||||
}: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
const { isManagedCloud } = useFeatures();
|
||||
|
||||
const isV3Project = project.version === "V3";
|
||||
const isFreeV3User = currentPlan?.v3Subscription?.isPaying === false;
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
@@ -106,105 +139,161 @@ export function SideMenu({ user, project, organization, organizations }: SideMen
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full flex-col gap-y-8 overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
)}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector organizations={organizations} project={project} />
|
||||
<UserMenu user={user} />
|
||||
</div>
|
||||
<div
|
||||
className="h-full overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-1 px-1">
|
||||
<V3ProjectSideMenu organization={organization} project={project} />
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between px-1 py-1 transition",
|
||||
showHeaderDivider ? " border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<ProjectSelector
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
project={project}
|
||||
user={user}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
ref={borderRef}
|
||||
>
|
||||
<div className="mb-6 flex flex-col gap-4 px-1">
|
||||
<div className="space-y-1">
|
||||
<SideMenuHeader title={"Environment"} />
|
||||
<div className="flex items-center gap-1">
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && <DevConnection />}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-1 flex flex-col gap-1 px-1">
|
||||
<SideMenuHeader title={"Organization"}>
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New Project"
|
||||
icon={PlusIcon}
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={inviteTeamMemberPath(organization)}
|
||||
title="Invite team member"
|
||||
icon={UserPlusIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
</SideMenuHeader>
|
||||
|
||||
<div>
|
||||
<SideMenuItem
|
||||
name="Projects"
|
||||
icon={FolderIcon}
|
||||
to={organizationPath(organization)}
|
||||
data-action="projects"
|
||||
name="Tasks"
|
||||
icon={TaskIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
data-action="tasks"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Team"
|
||||
icon={UserGroupIcon}
|
||||
to={organizationTeamPath(organization)}
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
data-action="batches"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-sun-500"
|
||||
to={v3SchedulesPath(organization, project, environment)}
|
||||
data-action="schedules"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-lime-500"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SideMenuSection title="Observability">
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project, environment)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Manage">
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
data-action="team"
|
||||
to={v3ApiKeysPath(organization, project, environment)}
|
||||
data-action="api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Usage"
|
||||
icon={ChartBarIcon}
|
||||
to={v3UsagePath(organization)}
|
||||
activeIconColor="text-green-600"
|
||||
data-action="usage"
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-pink-500"
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
data-action="environment variables"
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Concurrency limits"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3ConcurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Billing"
|
||||
icon={CreditCardIcon}
|
||||
to={v3BillingPath(organization)}
|
||||
activeIconColor="text-blue-600"
|
||||
data-action="billing"
|
||||
badge={
|
||||
currentPlan?.v3Subscription?.isPaying
|
||||
? currentPlan?.v3Subscription?.plan?.title
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Organization settings"
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
to={organizationSettingsPath(organization)}
|
||||
data-action="organization-settings"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
{isFreeV3User && (
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
)}
|
||||
</SideMenuSection>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
{isFreeUser && (
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectSelector({
|
||||
project,
|
||||
organization,
|
||||
organizations,
|
||||
user,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
user: SideMenuUser;
|
||||
}) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
let plan: string | undefined = undefined;
|
||||
if (currentPlan?.v3Subscription?.isPaying === false) {
|
||||
plan = "Free plan";
|
||||
} else if (currentPlan?.v3Subscription?.isPaying === true) {
|
||||
plan = currentPlan.v3Subscription.plan?.title;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setOrgMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
@@ -214,204 +303,289 @@ function ProjectSelector({
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isOrgMenuOpen}
|
||||
overflowHidden
|
||||
className="h-7 w-full justify-between overflow-hidden py-1 pl-2"
|
||||
className="h-8 w-full justify-between overflow-hidden py-1 pl-1.5"
|
||||
>
|
||||
<LogoIcon className="relative -top-px mr-2 h-4 w-4 min-w-[1rem]" />
|
||||
<span className="truncate">{project.name ?? "Select a project"}</span>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<Avatar avatar={organization.avatar} className="size-5" />
|
||||
<SelectorDivider />
|
||||
<span className="truncate text-2sm font-normal text-text-bright">
|
||||
{project.name ?? "Select a project"}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverArrowTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
{organizations.map((organization) => (
|
||||
<Fragment key={organization.id}>
|
||||
<PopoverSectionHeader title={organization.title} />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.length > 0 ? (
|
||||
organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={v3ProjectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon={FolderIcon}
|
||||
/>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<PopoverMenuItem
|
||||
to={newProjectPath(organization)}
|
||||
title="New project"
|
||||
icon={PlusIcon}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col gap-2 bg-charcoal-750 p-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="box-content size-10 overflow-clip rounded-sm border border-charcoal-700 bg-charcoal-850">
|
||||
<Avatar avatar={organization.avatar} className="size-10" includePadding />
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="space-y-0.5">
|
||||
<Paragraph variant="extra-small/bright">{organization.title}</Paragraph>
|
||||
<div className="flex items-baseline">
|
||||
{plan && (
|
||||
<Paragraph variant="extra-small">
|
||||
<TextLink variant="secondary" to={v3BillingPath(organization)}>
|
||||
{plan}
|
||||
</TextLink>
|
||||
</Paragraph>
|
||||
)}
|
||||
<Paragraph variant="extra-small">
|
||||
<TextLink
|
||||
variant="secondary"
|
||||
to={organizationTeamPath(organization)}
|
||||
>{simplur`${organization.membersCount} member[|s]`}</TextLink>
|
||||
</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={organizationSettingsPath(organization)}
|
||||
fullWidth
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
<CogIcon className="size-4 text-text-dimmed" />
|
||||
<span className="text-text-bright">Settings</span>
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to={v3UsagePath(organization)}
|
||||
fullWidth
|
||||
iconSpacing="gap-1.5"
|
||||
>
|
||||
<ChartBarIcon className="size-4 text-text-dimmed" />
|
||||
<span className="text-text-bright">Usage</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{organization.projects.map((p) => {
|
||||
const isSelected = p.id === project.id;
|
||||
return (
|
||||
<PopoverMenuItem
|
||||
key={p.id}
|
||||
to={v3ProjectPath(organization, p)}
|
||||
title={
|
||||
<div className="flex w-full items-center justify-between text-text-bright">
|
||||
<span className="grow truncate text-left">{p.name}</span>
|
||||
</div>
|
||||
}
|
||||
isSelected={isSelected}
|
||||
icon={isSelected ? FolderOpenIcon : FolderIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<PopoverMenuItem to={newProjectPath(organization)} title="New project" icon={PlusIcon} />
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 p-1">
|
||||
<PopoverMenuItem to={newOrganizationPath()} title="New Organization" icon={PlusIcon} />
|
||||
<SwitchOrganizations organizations={organizations} organization={organization} />
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 p-1">
|
||||
<PopoverMenuItem
|
||||
to={accountPath()}
|
||||
title="Account"
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName={cn(
|
||||
"text-text-dimmed rounded-full border border-transparent",
|
||||
user.isImpersonating && "rounded-full border-yellow-500"
|
||||
)}
|
||||
/>
|
||||
{user.isImpersonating && <ImpersonationBanner />}
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 p-1">
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Logout"
|
||||
icon={ArrowRightOnRectangleIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function UserMenu({ user }: { user: SideMenuUser }) {
|
||||
const [isProfileMenuOpen, setProfileMenuOpen] = useState(false);
|
||||
function SwitchOrganizations({
|
||||
organizations,
|
||||
organization,
|
||||
}: {
|
||||
organizations: MatchedOrganization[];
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
const navigation = useNavigation();
|
||||
const { v3Enabled } = useFeatures();
|
||||
const [isMenuOpen, setMenuOpen] = useState(false);
|
||||
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Clear timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setProfileMenuOpen(false);
|
||||
setMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
const handleMouseEnter = () => {
|
||||
if (timeoutRef.current) {
|
||||
clearTimeout(timeoutRef.current);
|
||||
}
|
||||
setMenuOpen(true);
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Small delay before closing to allow moving to the content
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
setMenuOpen(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setProfileMenuOpen(open)}>
|
||||
<PopoverCustomTrigger isOpen={isProfileMenuOpen} className="p-1 hover:bg-transparent">
|
||||
<UserProfilePhoto
|
||||
className={cn(
|
||||
"h-5 w-5 rounded-full border border-transparent text-charcoal-600 transition hover:border-charcoal-600",
|
||||
user.isImpersonating && "rounded-full border border-yellow-500"
|
||||
)}
|
||||
/>
|
||||
</PopoverCustomTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[12rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
<PopoverSectionHeader title={user.email} variant="extra-small" />
|
||||
<div className="flex flex-col gap-1 p-1">
|
||||
{user.isImpersonating && <ImpersonationBanner />}
|
||||
{user.admin && (
|
||||
<Popover onOpenChange={(open) => setMenuOpen(open)} open={isMenuOpen}>
|
||||
<div onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave}>
|
||||
<PopoverTrigger className="h-7 w-full justify-between overflow-hidden focus-custom">
|
||||
<ButtonContent
|
||||
variant="small-menu-item"
|
||||
className="hover:bg-charcoal-750"
|
||||
LeadingIcon={ArrowPathRoundedSquareIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
TrailingIcon={ChevronRightIcon}
|
||||
trailingIconClassName="text-text-dimmed"
|
||||
textAlignLeft
|
||||
fullWidth
|
||||
>
|
||||
Switch organization
|
||||
</ButtonContent>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
side="right"
|
||||
alignOffset={0}
|
||||
sideOffset={-4}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div className="p-1">
|
||||
{organizations.map((org) => (
|
||||
<PopoverMenuItem
|
||||
to={"/admin"}
|
||||
title="Admin"
|
||||
icon={AcademicCapIcon}
|
||||
leadingIconClassName="text-yellow-500"
|
||||
key={org.id}
|
||||
to={organizationPath(org)}
|
||||
title={org.title}
|
||||
icon={<Avatar className="size-4" avatar={org.avatar} />}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
isSelected={org.id === organization.id}
|
||||
/>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 p-1">
|
||||
<PopoverMenuItem
|
||||
to={accountPath()}
|
||||
title="View profile"
|
||||
icon={UserProfilePhoto}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
/>
|
||||
{v3Enabled && (
|
||||
<PopoverMenuItem
|
||||
to={personalAccessTokensPath()}
|
||||
title="Personal Access Tokens"
|
||||
icon={ShieldCheckIcon}
|
||||
leadingIconClassName="text-emerald-500"
|
||||
/>
|
||||
)}
|
||||
<PopoverMenuItem
|
||||
to={logoutPath()}
|
||||
title="Log out"
|
||||
icon={ArrowRightOnRectangleIcon}
|
||||
leadingIconClassName="text-rose-500"
|
||||
to={newOrganizationPath()}
|
||||
title="New Organization"
|
||||
icon={PlusIcon}
|
||||
leadingIconClassName="text-text-dimmed"
|
||||
/>
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</PopoverContent>
|
||||
</div>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function V3ProjectSideMenu({
|
||||
project,
|
||||
organization,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
}) {
|
||||
function SelectorDivider() {
|
||||
return (
|
||||
<>
|
||||
<SideMenuHeader title={"Project"} />
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3ProjectPath(organization, project)}
|
||||
data-action="tasks"
|
||||
<svg width="6" height="21" viewBox="0 0 6 21" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<line
|
||||
x1="5.3638"
|
||||
y1="0.606339"
|
||||
x2="0.606339"
|
||||
y2="19.6362"
|
||||
stroke="#3B3E45"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3RunsPath(organization, project)}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3BatchesPath(organization, project)}
|
||||
data-action="batches"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-lime-500"
|
||||
to={v3TestPath(organization, project)}
|
||||
data-action="test"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-sun-500"
|
||||
to={v3SchedulesPath(organization, project)}
|
||||
data-action="schedules"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
activeIconColor="text-amber-500"
|
||||
to={v3ApiKeysPath(organization, project)}
|
||||
data-action="api keys"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-pink-500"
|
||||
to={v3EnvironmentVariablesPath(organization, project)}
|
||||
data-action="environment variables"
|
||||
/>
|
||||
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-blue-500"
|
||||
to={v3DeploymentsPath(organization, project)}
|
||||
data-action="deployments"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-red-500"
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
data-action="alerts"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Concurrency limits"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-indigo-500"
|
||||
to={v3ConcurrencyPath(organization, project)}
|
||||
data-action="concurrency"
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-teal-500"
|
||||
to={v3ProjectSettingsPath(organization, project)}
|
||||
data-action="project-settings"
|
||||
/>
|
||||
</>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DevConnection() {
|
||||
const { isConnected } = useDevPresence();
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<div>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="px-1"
|
||||
LeadingIcon={
|
||||
isConnected ? (
|
||||
<ConnectedIcon className="size-5" />
|
||||
) : (
|
||||
<DisconnectedIcon className="size-5" />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
{isConnected ? "Your dev server is connected" : "Your dev server is not connected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
{isConnected
|
||||
? "Your dev server is connected to Trigger.dev"
|
||||
: "Your dev server is not connected to Trigger.dev"}
|
||||
</DialogHeader>
|
||||
<div className="mt-2 flex flex-col gap-3 px-2">
|
||||
<div className="flex flex-col items-center justify-center gap-6 px-6 py-10">
|
||||
<img
|
||||
src={isConnected ? connectedImage : disconnectedImage}
|
||||
alt={isConnected ? "Connected" : "Disconnected"}
|
||||
width={282}
|
||||
height={45}
|
||||
/>
|
||||
<Paragraph variant="small" className={isConnected ? "text-success" : "text-error"}>
|
||||
{isConnected
|
||||
? "Your local dev server is connected to Trigger.dev"
|
||||
: "Your local dev server is not connected to Trigger.dev"}
|
||||
</Paragraph>
|
||||
</div>
|
||||
{isConnected ? null : (
|
||||
<div className="space-y-3">
|
||||
<PackageManagerProvider>
|
||||
<TriggerDevStepV3 />
|
||||
</PackageManagerProvider>
|
||||
<Paragraph variant="small">
|
||||
Run this CLI `dev` command to connect to the Trigger.dev servers to start developing
|
||||
locally. Keep it running while you develop to stay connected.
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<LinkButton variant="tertiary/medium" LeadingIcon={BookOpenIcon} to={docsPath("cli-dev")}>
|
||||
CLI docs
|
||||
</LinkButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
@@ -14,12 +13,7 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<Paragraph
|
||||
variant="extra-extra-small/caps"
|
||||
className="cursor-default truncate text-charcoal-500"
|
||||
>
|
||||
{title}
|
||||
</Paragraph>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
|
||||
@@ -2,6 +2,7 @@ import { type AnchorHTMLAttributes } from "react";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
@@ -13,25 +14,23 @@ export function SideMenuItem({
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
subItem = false,
|
||||
}: {
|
||||
icon?: React.ComponentType<any>;
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
inactiveIconColor?: string;
|
||||
trailingIcon?: React.ComponentType<any>;
|
||||
trailingIcon?: RenderIcon;
|
||||
trailingIconClassName?: string;
|
||||
name: string;
|
||||
to: string;
|
||||
badge?: string;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
subItem?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant={subItem ? "small-menu-sub-item" : "small-menu-item"}
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
@@ -42,7 +41,6 @@ export function SideMenuItem({
|
||||
target={target}
|
||||
className={cn(
|
||||
"text-text-bright group-hover:bg-charcoal-750 focus-visible:outline-none focus-visible:ring-0 focus-visible:ring-offset-0",
|
||||
subItem ? "text-text-dimmed" : "",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import { ToggleArrowIcon } from "~/assets/icons/ToggleArrowIcon";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
* The collapsed state is passed in as a prop, and there's a callback when it's toggled so we can save the state.
|
||||
*/
|
||||
export function SideMenuSection({
|
||||
title,
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
const handleToggle = useCallback(() => {
|
||||
const newIsCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newIsCollapsed);
|
||||
onCollapseToggle?.(newIsCollapsed);
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1 text-text-dimmed hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
variants={{
|
||||
expanded: {
|
||||
height: "auto",
|
||||
transition: {
|
||||
height: { duration: 0.3, ease: "easeOut" },
|
||||
},
|
||||
},
|
||||
collapsed: {
|
||||
height: 0,
|
||||
transition: {
|
||||
height: { duration: 0.2, ease: "easeIn" },
|
||||
},
|
||||
},
|
||||
}}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
opacity: 1,
|
||||
transition: { duration: 0.3, ease: "easeOut" },
|
||||
},
|
||||
collapsed: {
|
||||
translateY: "-100%",
|
||||
opacity: 0,
|
||||
transition: { duration: 0.2, ease: "easeIn" },
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
BuildingOffice2Icon,
|
||||
CodeBracketSquareIcon,
|
||||
CubeIcon,
|
||||
FaceSmileIcon,
|
||||
FireIcon,
|
||||
RocketLaunchIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/24/solid";
|
||||
import { type Prisma } from "@trigger.dev/database";
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export const AvatarType = z.enum(["icon", "letters", "image"]);
|
||||
|
||||
export const AvatarData = z.discriminatedUnion("type", [
|
||||
z.object({
|
||||
type: z.literal(AvatarType.enum.icon),
|
||||
name: z.string(),
|
||||
hex: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(AvatarType.enum.letters),
|
||||
hex: z.string(),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal(AvatarType.enum.image),
|
||||
url: z.string().url(),
|
||||
}),
|
||||
]);
|
||||
|
||||
export type Avatar = z.infer<typeof AvatarData>;
|
||||
export type IconAvatar = Extract<Avatar, { type: "icon" }>;
|
||||
export type ImageAvatar = Extract<Avatar, { type: "image" }>;
|
||||
export type LettersAvatar = Extract<Avatar, { type: "letters" }>;
|
||||
|
||||
export function parseAvatar(json: Prisma.JsonValue, defaultAvatar: Avatar): Avatar {
|
||||
if (!json || typeof json !== "object") {
|
||||
return defaultAvatar;
|
||||
}
|
||||
|
||||
const parsed = AvatarData.safeParse(json);
|
||||
|
||||
if (!parsed.success) {
|
||||
logger.error("Invalid org avatar", { json, error: parsed.error });
|
||||
return defaultAvatar;
|
||||
}
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
avatar,
|
||||
className,
|
||||
includePadding,
|
||||
}: {
|
||||
avatar: Avatar;
|
||||
className?: string;
|
||||
includePadding?: boolean;
|
||||
}) {
|
||||
switch (avatar.type) {
|
||||
case "icon":
|
||||
return <AvatarIcon avatar={avatar} className={className} includePadding={includePadding} />;
|
||||
case "letters":
|
||||
return (
|
||||
<AvatarLetters avatar={avatar} className={className} includePadding={includePadding} />
|
||||
);
|
||||
case "image":
|
||||
return <AvatarImage avatar={avatar} className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarIcons: Record<string, React.ComponentType<React.SVGProps<SVGSVGElement>>> = {
|
||||
"hero:building-office-2": BuildingOffice2Icon,
|
||||
"hero:rocket-launch": RocketLaunchIcon,
|
||||
"hero:code-bracket-square": CodeBracketSquareIcon,
|
||||
"hero:fire": FireIcon,
|
||||
"hero:star": StarIcon,
|
||||
"hero:face-smile": FaceSmileIcon,
|
||||
};
|
||||
|
||||
export const defaultAvatarColors = [
|
||||
{ hex: "#878C99", name: "Gray" },
|
||||
{ hex: "#713F12", name: "Brown" },
|
||||
{ hex: "#F97316", name: "Orange" },
|
||||
{ hex: "#EAB308", name: "Yellow" },
|
||||
{ hex: "#22C55E", name: "Green" },
|
||||
{ hex: "#3B82F6", name: "Blue" },
|
||||
{ hex: "#6366F1", name: "Purple" },
|
||||
{ hex: "#EC4899", name: "Pink" },
|
||||
{ hex: "#F43F5E", name: "Red" },
|
||||
];
|
||||
|
||||
// purple
|
||||
export const defaultAvatarHex = defaultAvatarColors[6].hex;
|
||||
|
||||
export const defaultAvatar: Avatar = {
|
||||
type: "letters",
|
||||
hex: defaultAvatarHex,
|
||||
};
|
||||
|
||||
function AvatarLetters({
|
||||
avatar,
|
||||
className,
|
||||
includePadding,
|
||||
}: {
|
||||
avatar: LettersAvatar;
|
||||
className?: string;
|
||||
includePadding?: boolean;
|
||||
}) {
|
||||
const organization = useOrganization();
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const textRef = useRef<HTMLSpanElement>(null);
|
||||
const [fontSize, setFontSize] = useState("1rem");
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (containerRef.current) {
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
// Set font size to 60% of container width (adjust as needed)
|
||||
setFontSize(`${containerWidth * 0.6}px`);
|
||||
}
|
||||
|
||||
// Optional: Create a ResizeObserver for dynamic resizing
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.target === containerRef.current) {
|
||||
const containerWidth = entry.contentRect.width;
|
||||
setFontSize(`${containerWidth * 0.6}px`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const letters = organization.title.slice(0, 2);
|
||||
|
||||
const classes = cn("grid place-items-center", className);
|
||||
const style = {
|
||||
backgroundColor: avatar.hex,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={cn("grid place-items-center overflow-hidden text-charcoal-750", classes)}>
|
||||
{/* This is the square container */}
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={cn(
|
||||
"relative grid place-items-center overflow-hidden rounded-[10%] font-semibold",
|
||||
includePadding ? "size-[80%]" : "size-[100%]"
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<span ref={textRef} className="font-bold leading-none" style={{ fontSize }}>
|
||||
{letters}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarIcon({
|
||||
avatar,
|
||||
className,
|
||||
includePadding,
|
||||
}: {
|
||||
avatar: IconAvatar;
|
||||
className?: string;
|
||||
includePadding?: boolean;
|
||||
}) {
|
||||
const classes = cn("aspect-square", className);
|
||||
const style = {
|
||||
color: avatar.hex,
|
||||
};
|
||||
|
||||
const IconComponent = avatarIcons[avatar.name];
|
||||
return (
|
||||
<span className={cn("grid place-items-center", classes)}>
|
||||
<IconComponent className={includePadding ? "size-[80%]" : "size-[100%]"} style={style} />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({ avatar, className }: { avatar: ImageAvatar; className?: string }) {
|
||||
return (
|
||||
<span className="grid place-items-center">
|
||||
<img src={avatar.url} alt="Organization avatar" className="size-6" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Link, LinkProps, NavLink, NavLinkProps } from "@remix-run/react";
|
||||
import React, { forwardRef, ReactNode, useImperativeHandle, useRef } from "react";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Link, type LinkProps, NavLink, type NavLinkProps } from "@remix-run/react";
|
||||
import React, { forwardRef, type ReactNode, useImperativeHandle, useRef } from "react";
|
||||
import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "./Tooltip";
|
||||
import { Icon, type RenderIcon } from "./Icon";
|
||||
|
||||
const sizes = {
|
||||
small: {
|
||||
@@ -164,8 +165,8 @@ const allVariants = {
|
||||
|
||||
export type ButtonContentPropsType = {
|
||||
children?: React.ReactNode;
|
||||
LeadingIcon?: React.ComponentType<any>;
|
||||
TrailingIcon?: React.ComponentType<any>;
|
||||
LeadingIcon?: RenderIcon;
|
||||
TrailingIcon?: RenderIcon;
|
||||
trailingIconClassName?: string;
|
||||
leadingIconClassName?: string;
|
||||
fullWidth?: boolean;
|
||||
@@ -220,7 +221,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
)}
|
||||
>
|
||||
{LeadingIcon && (
|
||||
<LeadingIcon
|
||||
<Icon
|
||||
icon={LeadingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
@@ -245,7 +247,8 @@ export function ButtonContent(props: ButtonContentPropsType) {
|
||||
renderShortcutKey()}
|
||||
|
||||
{TrailingIcon && (
|
||||
<TrailingIcon
|
||||
<Icon
|
||||
icon={TrailingIcon}
|
||||
className={cn(
|
||||
iconClassName,
|
||||
variation.icon,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { FunctionComponent, createElement } from "react";
|
||||
import React, { type FunctionComponent, createElement } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
export type RenderIcon = FunctionComponent<{ className?: string }> | React.ReactNode;
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDownIcon, EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { EllipsisVerticalIcon } from "@heroicons/react/24/solid";
|
||||
import * as PopoverPrimitive from "@radix-ui/react-popover";
|
||||
import * as React from "react";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import * as useShortcutKeys from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type ButtonContentPropsType, LinkButton } from "./Buttons";
|
||||
import { Paragraph, type ParagraphVariant } from "./Paragraph";
|
||||
import { ShortcutKey } from "./ShortcutKey";
|
||||
import { ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { CheckIcon } from "@heroicons/react/20/solid";
|
||||
import { type RenderIcon } from "./Icon";
|
||||
|
||||
const Popover = PopoverPrimitive.Root;
|
||||
const PopoverTrigger = PopoverPrimitive.Trigger;
|
||||
@@ -38,7 +40,7 @@ PopoverContent.displayName = PopoverPrimitive.Content.displayName;
|
||||
|
||||
function PopoverSectionHeader({
|
||||
title,
|
||||
variant = "extra-extra-small/dimmed/caps",
|
||||
variant = "extra-small",
|
||||
}: {
|
||||
title: string;
|
||||
variant?: ParagraphVariant;
|
||||
@@ -59,7 +61,7 @@ function PopoverMenuItem({
|
||||
leadingIconClassName,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ComponentType<any>;
|
||||
icon?: RenderIcon;
|
||||
title: React.ReactNode;
|
||||
isSelected?: boolean;
|
||||
variant?: ButtonContentPropsType;
|
||||
@@ -109,11 +111,12 @@ function PopoverSideMenuTrigger({
|
||||
className,
|
||||
shortcut,
|
||||
...props
|
||||
}: { isOpen?: boolean; shortcut?: ShortcutDefinition } & React.ComponentPropsWithoutRef<
|
||||
typeof PopoverTrigger
|
||||
>) {
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
shortcut?: useShortcutKeys.ShortcutDefinition;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys({
|
||||
useShortcutKeys.useShortcutKeys({
|
||||
shortcut: shortcut,
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
@@ -158,7 +161,7 @@ function PopoverArrowTrigger({
|
||||
<PopoverTrigger
|
||||
{...props}
|
||||
className={cn(
|
||||
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
|
||||
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
|
||||
fullWidth && "w-full justify-between",
|
||||
className
|
||||
)}
|
||||
@@ -172,10 +175,10 @@ function PopoverArrowTrigger({
|
||||
>
|
||||
{children}
|
||||
</Paragraph>
|
||||
<ChevronDownIcon
|
||||
<DropdownIcon
|
||||
className={cn(
|
||||
"h-3 w-3 min-w-[0.75rem] text-charcoal-600 transition group-hover:text-text-bright",
|
||||
isOpen && "-rotate-180"
|
||||
"h-4 w-4 min-w-[0.75rem] text-text-dimmed transition group-hover:text-text-bright",
|
||||
isOpen && "-scale-y-100"
|
||||
)}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
|
||||
@@ -70,7 +70,7 @@ export function RadioButtonCircle({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"ring-offset-background aspect-square h-4 w-4 shrink-0 overflow-hidden rounded-full border border-charcoal-600 bg-charcoal-700 focus-custom disabled:cursor-not-allowed disabled:opacity-50",
|
||||
boxClassName
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { Link } from "@remix-run/react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Icon, RenderIcon } from "./Icon";
|
||||
import { Icon, type RenderIcon } from "./Icon";
|
||||
|
||||
const variations = {
|
||||
primary:
|
||||
"text-indigo-500 transition hover:text-indigo-400 inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
secondary:
|
||||
"text-text-dimmed transition underline underline-offset-2 decoration-dimmed/50 hover:decoration-dimmed inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
"text-text-dimmed transition hover:text-text-bright inline-flex gap-0.5 items-center group focus-visible:focus-custom",
|
||||
} as const;
|
||||
|
||||
type TextLinkProps = {
|
||||
|
||||
@@ -59,6 +59,7 @@ function SimpleTooltip({
|
||||
disableHoverableContent = false,
|
||||
className,
|
||||
buttonClassName,
|
||||
asChild = false,
|
||||
}: {
|
||||
button: React.ReactNode;
|
||||
content: React.ReactNode;
|
||||
@@ -68,11 +69,12 @@ function SimpleTooltip({
|
||||
disableHoverableContent?: boolean;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<TooltipProvider disableHoverableContent={disableHoverableContent}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)}>
|
||||
<TooltipTrigger tabIndex={-1} className={cn("h-fit", buttonClassName)} asChild={asChild}>
|
||||
{button}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
|
||||
@@ -38,12 +38,10 @@ import {
|
||||
} from "./BatchStatus";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
|
||||
@@ -52,10 +50,6 @@ export const BatchStatus = z.enum(allBatchStatuses);
|
||||
export const BatchListFilters = z.object({
|
||||
cursor: z.string().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
environments: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
z.string().array().optional()
|
||||
),
|
||||
statuses: z.preprocess(
|
||||
(value) => (typeof value === "string" ? [value] : value),
|
||||
BatchStatus.array().optional()
|
||||
@@ -68,12 +62,7 @@ export const BatchListFilters = z.object({
|
||||
|
||||
export type BatchListFilters = z.infer<typeof BatchListFilters>;
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type BatchFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
hasFilters: boolean;
|
||||
};
|
||||
|
||||
@@ -82,7 +71,6 @@ export function BatchFilters(props: BatchFiltersProps) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("id") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
@@ -91,7 +79,7 @@ export function BatchFilters(props: BatchFiltersProps) {
|
||||
return (
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<FilterMenu {...props} />
|
||||
<AppliedFilters {...props} />
|
||||
<AppliedFilters />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="minimal/small" LeadingIcon={TrashIcon}>
|
||||
@@ -113,7 +101,6 @@ const filterTypes = [
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "daterange", title: "Custom date range", icon: <CalendarIcon className="size-4" /> },
|
||||
{ name: "batch", title: "Batch ID", icon: <Squares2X2Icon className="size-4" /> },
|
||||
@@ -157,11 +144,10 @@ function FilterMenu(props: BatchFiltersProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments }: BatchFiltersProps) {
|
||||
function AppliedFilters() {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedPeriodFilter />
|
||||
<AppliedCustomDateRangeFilter />
|
||||
<AppliedBatchIdFilter />
|
||||
@@ -183,8 +169,6 @@ function Menu(props: MenuProps) {
|
||||
return <MainMenu {...props} />;
|
||||
case "statuses":
|
||||
return <StatusDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "environments":
|
||||
return <EnvironmentsDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "created":
|
||||
return <CreatedAtDropdown onClose={() => props.setFilterType(undefined)} {...props} />;
|
||||
case "daterange":
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { DialogClose } from "@radix-ui/react-dialog";
|
||||
import { Form, useNavigation, useSubmit } from "@remix-run/react";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
|
||||
import { type UseDataFunctionReturn, useTypedFetcher } from "remix-typedjson";
|
||||
import { JSONEditor } from "~/components/code/JSONEditor";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
@@ -135,7 +135,7 @@ function ReplayForm({
|
||||
const env = environments.find((env) => env.id === value)!;
|
||||
return (
|
||||
<div className="flex items-center pl-1 pr-2">
|
||||
<EnvironmentLabel environment={env} userName={env.userName} />
|
||||
<EnvironmentCombo environment={env} />
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
@@ -143,7 +143,7 @@ function ReplayForm({
|
||||
{(matches) =>
|
||||
matches.map((env) => (
|
||||
<SelectItem key={env.id} value={env.id}>
|
||||
<EnvironmentLabel environment={env} userName={env.userName} />
|
||||
<EnvironmentCombo environment={env} />
|
||||
</SelectItem>
|
||||
))
|
||||
}
|
||||
|
||||
@@ -9,16 +9,10 @@ import {
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, useFetcher } from "@remix-run/react";
|
||||
import type {
|
||||
BulkActionType,
|
||||
RuntimeEnvironment,
|
||||
TaskRunStatus,
|
||||
TaskTriggerSource,
|
||||
} from "@trigger.dev/database";
|
||||
import type { BulkActionType, TaskRunStatus, TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { ListChecks, ListFilterIcon } from "lucide-react";
|
||||
import { matchSorter } from "match-sorter";
|
||||
import type { ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { z } from "zod";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
@@ -53,12 +47,10 @@ import { Button } from "../../primitives/Buttons";
|
||||
import { BulkActionStatusCombo } from "./BulkAction";
|
||||
import {
|
||||
AppliedCustomDateRangeFilter,
|
||||
AppliedEnvironmentFilter,
|
||||
AppliedPeriodFilter,
|
||||
appliedSummary,
|
||||
CreatedAtDropdown,
|
||||
CustomDateRangeDropdown,
|
||||
EnvironmentsDropdown,
|
||||
FilterMenuProvider,
|
||||
} from "./SharedFilters";
|
||||
import {
|
||||
@@ -107,12 +99,7 @@ export const TaskRunListSearchFilters = z.object({
|
||||
|
||||
export type TaskRunListSearchFilters = z.infer<typeof TaskRunListSearchFilters>;
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type RunFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
possibleTasks: { slug: string; triggerSource: TaskTriggerSource }[];
|
||||
bulkActions: {
|
||||
id: string;
|
||||
@@ -128,7 +115,6 @@ export function RunsFilters(props: RunFiltersProps) {
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("statuses") ||
|
||||
searchParams.has("environments") ||
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("bulkId") ||
|
||||
@@ -168,7 +154,6 @@ const filterTypes = [
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ name: "environments", title: "Environment", icon: <CpuChipIcon className="size-4" /> },
|
||||
{ name: "tasks", title: "Tasks", icon: <TaskIcon className="size-4" /> },
|
||||
{ name: "tags", title: "Tags", icon: <TagIcon className="size-4" /> },
|
||||
{ name: "created", title: "Created", icon: <CalendarIcon className="size-4" /> },
|
||||
@@ -217,11 +202,10 @@ function FilterMenu(props: RunFiltersProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedFilters({ possibleEnvironments, possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
function AppliedFilters({ possibleTasks, bulkActions }: RunFiltersProps) {
|
||||
return (
|
||||
<>
|
||||
<AppliedStatusFilter />
|
||||
<AppliedEnvironmentFilter possibleEnvironments={possibleEnvironments} />
|
||||
<AppliedTaskFilter possibleTasks={possibleTasks} />
|
||||
<AppliedTagsFilter />
|
||||
<AppliedPeriodFilter />
|
||||
@@ -248,8 +232,7 @@ function Menu(props: MenuProps) {
|
||||
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":
|
||||
|
||||
@@ -6,7 +6,6 @@ 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";
|
||||
import {
|
||||
@@ -37,16 +36,11 @@ export type ScheduleListFilters = z.infer<typeof ScheduleListFilters>;
|
||||
|
||||
const All = "ALL";
|
||||
|
||||
type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
type ScheduleFiltersProps = {
|
||||
possibleEnvironments: DisplayableEnvironment[];
|
||||
possibleTasks: string[];
|
||||
};
|
||||
|
||||
export function ScheduleFilters({ possibleEnvironments, possibleTasks }: ScheduleFiltersProps) {
|
||||
export function ScheduleFilters({ possibleTasks }: ScheduleFiltersProps) {
|
||||
const navigate = useNavigate();
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
@@ -54,8 +48,7 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
Object.fromEntries(searchParams.entries())
|
||||
);
|
||||
|
||||
const hasFilters =
|
||||
searchParams.has("tasks") || searchParams.has("environments") || searchParams.has("search");
|
||||
const hasFilters = searchParams.has("tasks") || searchParams.has("search");
|
||||
|
||||
const handleFilterChange = useCallback((filterType: string, value: string | undefined) => {
|
||||
if (value) {
|
||||
@@ -71,10 +64,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
handleFilterChange("tasks", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleEnvironmentChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("environments", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
|
||||
const handleTypeChange = useCallback((value: string | typeof All) => {
|
||||
handleFilterChange("type", value === "ALL" ? undefined : value);
|
||||
}, []);
|
||||
@@ -87,7 +76,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
searchParams.delete("page");
|
||||
searchParams.delete("enabled");
|
||||
searchParams.delete("tasks");
|
||||
searchParams.delete("environments");
|
||||
searchParams.delete("search");
|
||||
navigate(`${location.pathname}?${searchParams.toString()}`);
|
||||
}, []);
|
||||
@@ -127,39 +115,6 @@ export function ScheduleFilters({ possibleEnvironments, possibleTasks }: Schedul
|
||||
</Select>
|
||||
</SelectGroup>
|
||||
|
||||
<SelectGroup>
|
||||
<Select
|
||||
name="environment"
|
||||
value={environments?.at(0) ?? "ALL"}
|
||||
onValueChange={handleEnvironmentChange}
|
||||
>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
<SelectValue
|
||||
placeholder={"Select environment"}
|
||||
className="ml-2 whitespace-nowrap p-0"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={"ALL"}>
|
||||
<Paragraph
|
||||
variant="extra-small"
|
||||
className="whitespace-nowrap 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="tasks" value={tasks?.at(0) ?? "ALL"} onValueChange={handleTaskChange}>
|
||||
<SelectTrigger size="minimal" width="full">
|
||||
|
||||
@@ -100,7 +100,7 @@ export function EnvironmentsDropdown({
|
||||
value={item.id}
|
||||
shortcut={shortcutFromIndex(index, { shortcutsEnabled: true })}
|
||||
>
|
||||
<EnvironmentLabel environment={item} userName={item.userName} />
|
||||
<EnvironmentLabel environment={item} />
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
TrashIcon,
|
||||
XCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { HourglassIcon } from "lucide-react";
|
||||
import { TimedOutIcon } from "~/assets/icons/TimedOutIcon";
|
||||
|
||||
@@ -18,15 +18,16 @@ import { Header3 } from "~/components/primitives/Headers";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { useSelectedItems } from "~/components/primitives/SelectedItemsProvider";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useEnvironments } from "~/hooks/useEnvironments";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { RunListAppliedFilters, RunListItem } from "~/presenters/v3/RunListPresenter.server";
|
||||
import {
|
||||
type RunListAppliedFilters,
|
||||
type RunListItem,
|
||||
} from "~/presenters/v3/RunListPresenter.server";
|
||||
import { formatCurrencyAccurate, formatNumber } from "~/utils/numberFormatter";
|
||||
import { docsPath, v3RunSpanPath, v3TestPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentLabel } from "../../environments/EnvironmentLabel";
|
||||
import { DateTime } from "../../primitives/DateTime";
|
||||
import { Paragraph } from "../../primitives/Paragraph";
|
||||
import { Spinner } from "../../primitives/Spinner";
|
||||
@@ -39,7 +40,7 @@ import {
|
||||
TableHeader,
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
TableVariant,
|
||||
type TableVariant,
|
||||
} from "../../primitives/Table";
|
||||
import { CancelRunDialog } from "./CancelRunDialog";
|
||||
import { LiveTimer } from "./LiveTimer";
|
||||
@@ -50,6 +51,7 @@ import {
|
||||
filterableTaskRunStatuses,
|
||||
TaskRunStatusCombo,
|
||||
} from "./TaskRunStatus";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
|
||||
type RunsTableProps = {
|
||||
total: number;
|
||||
@@ -74,6 +76,7 @@ export function TaskRunsTable({
|
||||
const user = useUser();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const checkboxes = useRef<(HTMLInputElement | null)[]>([]);
|
||||
const { selectedItems, has, hasAll, select, deselect, toggle } = useSelectedItems(allowSelection);
|
||||
const { isManagedCloud } = useFeatures();
|
||||
@@ -133,7 +136,6 @@ export function TaskRunsTable({
|
||||
</TableHeaderCell>
|
||||
)}
|
||||
<TableHeaderCell alignment="right">Run #</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Task</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
@@ -279,14 +281,16 @@ export function TaskRunsTable({
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{total === 0 && !hasFilters ? (
|
||||
<TableBlankRow colSpan={14}>
|
||||
<TableBlankRow colSpan={15}>
|
||||
{!isLoading && <NoRuns title="No runs found" />}
|
||||
</TableBlankRow>
|
||||
) : runs.length === 0 ? (
|
||||
<BlankState isLoading={isLoading} filters={filters} />
|
||||
) : (
|
||||
runs.map((run, index) => {
|
||||
const path = v3RunSpanPath(organization, project, run, { spanId: run.spanId });
|
||||
const path = v3RunSpanPath(organization, project, environment, run, {
|
||||
spanId: run.spanId,
|
||||
});
|
||||
return (
|
||||
<TableRow key={run.id}>
|
||||
{allowSelection && (
|
||||
@@ -306,12 +310,6 @@ export function TaskRunsTable({
|
||||
<TableCell to={path} alignment="right" isTabbableCell>
|
||||
{formatNumber(run.number)}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel
|
||||
environment={run.environment}
|
||||
userName={run.environment.userName}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<span className="flex items-center gap-x-1">
|
||||
{run.taskIdentifier}
|
||||
@@ -396,7 +394,7 @@ export function TaskRunsTable({
|
||||
)}
|
||||
{isLoading && (
|
||||
<TableBlankRow
|
||||
colSpan={14}
|
||||
colSpan={15}
|
||||
className="absolute left-0 top-0 flex h-full w-full items-center justify-center gap-2 bg-charcoal-900/90"
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading…</span>
|
||||
@@ -535,62 +533,47 @@ function NoRuns({ title }: { title: string }) {
|
||||
function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "filters">) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const envs = useEnvironments();
|
||||
if (isLoading) return <TableBlankRow colSpan={14}></TableBlankRow>;
|
||||
const environment = useEnvironment();
|
||||
if (isLoading) return <TableBlankRow colSpan={15}></TableBlankRow>;
|
||||
|
||||
const { environments, tasks, from, to, ...otherFilters } = filters;
|
||||
|
||||
if (
|
||||
filters.environments.length === 1 &&
|
||||
filters.tasks.length === 1 &&
|
||||
filters.from === undefined &&
|
||||
filters.to === undefined &&
|
||||
Object.values(otherFilters).every((filterArray) => filterArray.length === 0)
|
||||
) {
|
||||
const environment = envs?.find((env) => env.id === filters.environments[0]);
|
||||
return (
|
||||
<TableBlankRow colSpan={14}>
|
||||
<div className="py-14">
|
||||
<Paragraph className="w-auto" variant="base/bright" spacing>
|
||||
There are no runs for {filters.tasks[0]}
|
||||
{environment ? (
|
||||
<>
|
||||
{" "}
|
||||
in{" "}
|
||||
<EnvironmentLabel
|
||||
environment={environment}
|
||||
userName={environment.userName}
|
||||
size="large"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</Paragraph>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Create a test run
|
||||
</LinkButton>
|
||||
<Paragraph variant="small">or</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="primary/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Triggering a task docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
<TableBlankRow colSpan={15}>
|
||||
<Paragraph className="w-auto" variant="base/bright" spacing>
|
||||
There are no runs for {filters.tasks[0]}
|
||||
</Paragraph>
|
||||
<div className="mt-6 flex items-center justify-center gap-2">
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Create a test run
|
||||
</LinkButton>
|
||||
<Paragraph variant="small">or</Paragraph>
|
||||
<LinkButton
|
||||
to={docsPath("v3/triggering")}
|
||||
variant="tertiary/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Triggering a task docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableBlankRow>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableBlankRow colSpan={14}>
|
||||
<TableBlankRow colSpan={15}>
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
<Paragraph className="w-auto" variant="base/bright">
|
||||
No runs match your filters. Try refreshing, modifying your filters or run a test.
|
||||
@@ -609,7 +592,7 @@ function BlankState({ isLoading, filters }: Pick<RunsTableProps, "isLoading" | "
|
||||
<LinkButton
|
||||
LeadingIcon={BeakerIcon}
|
||||
variant="tertiary/medium"
|
||||
to={v3TestPath(organization, project)}
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
>
|
||||
Run a test
|
||||
</LinkButton>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { type UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { organizationMatchId } from "./useOrganizations";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
|
||||
export type MatchedEnvironment = UseDataFunctionReturn<typeof orgLoader>["environment"];
|
||||
|
||||
export function useOptionalEnvironment(matches?: UIMatch[]) {
|
||||
const routeMatch = useTypedMatchesData<typeof orgLoader>({
|
||||
id: organizationMatchId,
|
||||
matches,
|
||||
});
|
||||
|
||||
return routeMatch?.environment;
|
||||
}
|
||||
|
||||
export function useEnvironment(matches?: UIMatch[]) {
|
||||
const environment = useOptionalEnvironment(matches);
|
||||
invariant(environment, "Environment must be defined");
|
||||
return environment;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { type Path, useMatches } from "@remix-run/react";
|
||||
import { type MinimumEnvironment } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { useEnvironment } from "./useEnvironment";
|
||||
import { useEnvironments } from "./useEnvironments";
|
||||
import { useOptimisticLocation } from "./useOptimisticLocation";
|
||||
|
||||
/**
|
||||
* It gives the URLs for the current page for other environments
|
||||
* @returns
|
||||
*/
|
||||
export function useEnvironmentSwitcher() {
|
||||
const matches = useMatches();
|
||||
const location = useOptimisticLocation();
|
||||
|
||||
const urlForEnvironment = (newEnvironment: MinimumEnvironment) => {
|
||||
return routeForEnvironmentSwitch({
|
||||
location,
|
||||
matchId: matches[matches.length - 1].id,
|
||||
environmentSlug: newEnvironment.slug,
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
urlForEnvironment,
|
||||
};
|
||||
}
|
||||
|
||||
/** Function that takes in a UIMatch id, the current URL, the new environment slug, and returns a new URL */
|
||||
export function routeForEnvironmentSwitch({
|
||||
location,
|
||||
matchId,
|
||||
environmentSlug,
|
||||
}: {
|
||||
location: Path;
|
||||
matchId: string;
|
||||
environmentSlug: string;
|
||||
}) {
|
||||
switch (matchId) {
|
||||
// Run page
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/runs\/.*/,
|
||||
"/runs"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.deployments.$deploymentParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/deployments\/.*/,
|
||||
"/deployments"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.$scheduleParam":
|
||||
case "routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.schedules.edit.$scheduleParam": {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug).replace(
|
||||
/\/schedules\/.*/,
|
||||
"/schedules"
|
||||
),
|
||||
search: "",
|
||||
hash: "",
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
default: {
|
||||
const newLocation: Path = {
|
||||
pathname: replaceEnvInPath(location.pathname, environmentSlug),
|
||||
search: location.search,
|
||||
hash: location.hash,
|
||||
};
|
||||
return fullPath(newLocation);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the /env/<slug>/ in the path so it's /env/<environmentSlug>
|
||||
*/
|
||||
function replaceEnvInPath(path: string, environmentSlug: string) {
|
||||
return path.replace(/env\/([a-z0-9-]+)/, `env/${environmentSlug}`);
|
||||
}
|
||||
|
||||
function fullPath(location: Path) {
|
||||
return `${location.pathname}${location.search}${location.hash}`;
|
||||
}
|
||||
@@ -23,12 +23,12 @@ export function useEventSource(
|
||||
return;
|
||||
}
|
||||
|
||||
const eventSource = new EventSource(url, init);
|
||||
eventSource.addEventListener(event ?? "message", handler);
|
||||
|
||||
// reset data if dependencies change
|
||||
setData(null);
|
||||
|
||||
const eventSource = new EventSource(url, init);
|
||||
eventSource.addEventListener(event ?? "message", handler);
|
||||
|
||||
function handler(event: MessageEvent) {
|
||||
setData(event.data || "UNKNOWN_EVENT_DATA");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { loader } from "../root";
|
||||
import { type loader } from "../root";
|
||||
import type { TriggerFeatures } from "~/features.server";
|
||||
|
||||
export function useFeatures(): TriggerFeatures {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import { UseDataFunctionReturn } from "remix-typedjson";
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import { type UseDataFunctionReturn } from "remix-typedjson";
|
||||
import invariant from "tiny-invariant";
|
||||
import type { loader as orgLoader } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useChanged } from "./useChanged";
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
import { useSyncTrace } from "./useSyncTrace";
|
||||
import { useSyncTraceRuns } from "./useSyncTraceRuns";
|
||||
|
||||
type Params = {
|
||||
origin: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export function useSyncRunPage({ origin, traceId }: Params) {
|
||||
const { runs } = useSyncTraceRuns({ origin, traceId });
|
||||
const { events } = useSyncTrace({ origin, traceId });
|
||||
|
||||
const isUpToDate = runs !== undefined && events !== undefined;
|
||||
|
||||
return { isUpToDate, runs, events };
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { useShape } from "@electric-sql/react";
|
||||
import { TaskEvent } from "@trigger.dev/database";
|
||||
import { createTraceTreeFromEvents } from "~/utils/taskEvent";
|
||||
|
||||
type Params = {
|
||||
origin: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export type Trace = ReturnType<typeof createTraceTreeFromEvents>;
|
||||
export type TraceEvent = Trace["events"][number];
|
||||
|
||||
export function useSyncTrace({ origin, traceId }: Params) {
|
||||
const { data, error, isError } = useShape({
|
||||
url: `${origin}/sync/traces/${traceId}`,
|
||||
});
|
||||
|
||||
return { error, isError, events: data ? (data as any as TaskEvent[]) : undefined };
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { Prettify } from "@trigger.dev/core";
|
||||
import { TaskRun } from "@trigger.dev/database";
|
||||
import { SyncedShapeData, useSyncedShape } from "./useSyncedShape";
|
||||
|
||||
type Params = {
|
||||
origin: string;
|
||||
traceId: string;
|
||||
};
|
||||
|
||||
export type RawRun = Prettify<SyncedShapeData<TaskRun>>;
|
||||
|
||||
export function useSyncTraceRuns({ origin, traceId }: Params) {
|
||||
const { data, error, isError } = useSyncedShape<TaskRun>({
|
||||
url: `${origin}/sync/traces/runs/${traceId}`,
|
||||
});
|
||||
|
||||
return { runs: data, error, isError };
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useShape } from "@electric-sql/react";
|
||||
|
||||
export type ShapeInput = Parameters<typeof useShape>[0];
|
||||
export type ShapeOutput<S> = {
|
||||
error: Error | false;
|
||||
isError: boolean;
|
||||
data: S[] | undefined;
|
||||
};
|
||||
|
||||
export type SyncedShapeData<T> = {
|
||||
[K in keyof T]: T[K] extends Date
|
||||
? string
|
||||
: T[K] extends Date | null
|
||||
? string | null
|
||||
: T[K] extends BigInt
|
||||
? number
|
||||
: T[K] extends BigInt | null
|
||||
? number | null
|
||||
: T[K] extends object
|
||||
? SyncedShapeData<T[K]>
|
||||
: T[K];
|
||||
};
|
||||
|
||||
export function useSyncedShape<S>(props: ShapeInput): ShapeOutput<SyncedShapeData<S>> {
|
||||
const output = useShape(props) as any;
|
||||
|
||||
return {
|
||||
error: output.error,
|
||||
isError: output.isError,
|
||||
data: output.data
|
||||
? (transformInput(output.data as InputObject) as SyncedShapeData<S>[])
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type InputObject = {
|
||||
[key: string]: Value;
|
||||
}[];
|
||||
|
||||
type Value =
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| bigint
|
||||
| null
|
||||
| Value[]
|
||||
| {
|
||||
[key: string]: Value;
|
||||
};
|
||||
|
||||
function transformInput(input: InputObject): SyncedShapeData<Value> {
|
||||
return input.map((value) => transformValue(value));
|
||||
}
|
||||
|
||||
function transformValue(value: Value): Value {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(transformValue);
|
||||
} else if (typeof value === "object" && value !== null) {
|
||||
const result: { [key: string]: Value } = {};
|
||||
|
||||
for (const key in value) {
|
||||
result[key] = transformValue(value[key]);
|
||||
}
|
||||
|
||||
return result;
|
||||
} else if (typeof value === "bigint") {
|
||||
return Number(value);
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,28 @@ export async function findEnvironmentById(id: string): Promise<AuthenticatedEnvi
|
||||
return environment;
|
||||
}
|
||||
|
||||
export async function findEnvironmentBySlug(projectId: string, envSlug: string, userId: string) {
|
||||
return prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
projectId: projectId,
|
||||
slug: envSlug,
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
in: ["PREVIEW", "STAGING", "PRODUCTION"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "DEVELOPMENT",
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function findEnvironmentFromRun(
|
||||
runId: string,
|
||||
tx?: PrismaClientOrTransaction
|
||||
|
||||
@@ -2,6 +2,10 @@ import type { Prisma, User } from "@trigger.dev/database";
|
||||
import type { GitHubProfile } from "remix-auth-github";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
DashboardPreferences,
|
||||
getDashboardPreferences,
|
||||
} from "~/services/dashboardPreferences.server";
|
||||
export type { User } from "@trigger.dev/database";
|
||||
|
||||
type FindOrCreateMagicLink = {
|
||||
@@ -158,8 +162,23 @@ export async function findOrCreateGithubUser({
|
||||
};
|
||||
}
|
||||
|
||||
export type UserWithDashboardPreferences = User & {
|
||||
dashboardPreferences: DashboardPreferences;
|
||||
};
|
||||
|
||||
export async function getUserById(id: User["id"]) {
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
const user = await prisma.user.findUnique({ where: { id } });
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const dashboardPreferences = getDashboardPreferences(user.dashboardPreferences);
|
||||
|
||||
return {
|
||||
...user,
|
||||
dashboardPreferences,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getUserByEmail(email: User["email"]) {
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { type PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import {
|
||||
commitCurrentProjectSession,
|
||||
getCurrentProjectId,
|
||||
setCurrentProjectId,
|
||||
} from "~/services/currentProject.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { newProjectPath } from "~/utils/pathBuilder";
|
||||
import { ProjectPresenter } from "./ProjectPresenter.server";
|
||||
import { type UserFromSession } from "~/services/session.server";
|
||||
import { newOrganizationPath, newProjectPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
SelectBestEnvironmentPresenter,
|
||||
type MinimumEnvironment,
|
||||
} from "./SelectBestEnvironmentPresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -19,25 +19,27 @@ export class OrganizationsPresenter {
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
user,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
request,
|
||||
}: {
|
||||
userId: string;
|
||||
user: UserFromSession;
|
||||
organizationSlug: string;
|
||||
projectSlug: string | undefined;
|
||||
environmentSlug: string | undefined;
|
||||
request: Request;
|
||||
}) {
|
||||
//first get the project id, this redirects if there's no session
|
||||
const projectId = await this.#getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
});
|
||||
const organizations = await this.#getOrganizations(user.id);
|
||||
if (organizations.length === 0) {
|
||||
logger.info("No organizations", {
|
||||
organizationSlug,
|
||||
request,
|
||||
});
|
||||
throw redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
const organizations = await this.#getOrganizations(userId);
|
||||
const organization = organizations.find((o) => o.slug === organizationSlug);
|
||||
if (!organization) {
|
||||
logger.info("Not Found: organization", {
|
||||
@@ -45,130 +47,75 @@ export class OrganizationsPresenter {
|
||||
request,
|
||||
organization,
|
||||
});
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
throw new Response("Organization not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const projectPresenter = new ProjectPresenter(this.#prismaClient);
|
||||
const project = await projectPresenter.call({
|
||||
id: projectId,
|
||||
userId,
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const bestProject = await selector.selectBestProjectFromProjects({
|
||||
user,
|
||||
projectSlug,
|
||||
projects: organization.projects,
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
if (!bestProject) {
|
||||
logger.info("Not Found: project", {
|
||||
projectSlug,
|
||||
request,
|
||||
"No projects found in organization"
|
||||
);
|
||||
}
|
||||
|
||||
if (project.organizationId !== organization.id) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
}
|
||||
|
||||
return { organizations, organization, project };
|
||||
}
|
||||
|
||||
async #getProjectId({
|
||||
request,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
userId,
|
||||
}: {
|
||||
request: Request;
|
||||
projectSlug: string | undefined;
|
||||
organizationSlug: string;
|
||||
userId: string;
|
||||
}): Promise<string> {
|
||||
const sessionProjectId = await getCurrentProjectId(request);
|
||||
|
||||
//no project in session, let's set one
|
||||
if (!sessionProjectId) {
|
||||
//no session id and no project slug so we need to select the best project
|
||||
if (!projectSlug) {
|
||||
const bestProject = await this.#selectBestProjectForOrganization(
|
||||
organizationSlug,
|
||||
userId,
|
||||
request
|
||||
);
|
||||
const session = await setCurrentProjectId(bestProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
});
|
||||
}
|
||||
|
||||
//get all the projects
|
||||
const projects = await prisma.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
deletedAt: null,
|
||||
slug: projectSlug,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirectWithErrorMessage(
|
||||
newProjectPath({ slug: organizationSlug }),
|
||||
request,
|
||||
"No projects in this organization"
|
||||
);
|
||||
}
|
||||
|
||||
//try get the project which matches the URL
|
||||
let matchingProject = projects.find((p) => p.slug === projectSlug);
|
||||
|
||||
//if there's no matching project, just use the most recently updated one
|
||||
if (!matchingProject) {
|
||||
matchingProject = projects[0];
|
||||
}
|
||||
|
||||
//set the session
|
||||
const session = await setCurrentProjectId(matchingProject.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
project: bestProject,
|
||||
});
|
||||
throw redirect(newProjectPath(organization));
|
||||
}
|
||||
|
||||
if (!projectSlug) {
|
||||
return sessionProjectId;
|
||||
}
|
||||
|
||||
//check session id matches the project slug
|
||||
const project = await prisma.project.findFirst({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
const fullProject = await this.#prismaClient.project.findFirst({
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
id: bestProject.id,
|
||||
},
|
||||
include: {
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
deletedAt: null,
|
||||
},
|
||||
});
|
||||
|
||||
if (!project) {
|
||||
throw new Response("Project not found in organization", { status: 404 });
|
||||
}
|
||||
|
||||
if (project.id !== sessionProjectId) {
|
||||
const session = await setCurrentProjectId(project.id, request);
|
||||
throw redirect(request.url, {
|
||||
headers: { "Set-Cookie": await commitCurrentProjectSession(session) },
|
||||
if (!fullProject) {
|
||||
logger.info("Not Found: project", {
|
||||
projectSlug,
|
||||
request,
|
||||
project: bestProject,
|
||||
});
|
||||
throw redirect(newProjectPath(organization));
|
||||
}
|
||||
|
||||
return project.id;
|
||||
const environment = this.#getEnvironment({
|
||||
user,
|
||||
projectId: fullProject.id,
|
||||
environments: fullProject.environments,
|
||||
environmentSlug,
|
||||
});
|
||||
|
||||
return {
|
||||
organizations,
|
||||
organization,
|
||||
project: {
|
||||
...fullProject,
|
||||
environments: sortEnvironments(
|
||||
fullProject.environments.filter((env) => {
|
||||
if (env.type !== "DEVELOPMENT") return true;
|
||||
if (env.orgMember?.userId === user.id) return true;
|
||||
return false;
|
||||
})
|
||||
),
|
||||
},
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
async #getOrganizations(userId: string) {
|
||||
@@ -179,17 +126,22 @@ export class OrganizationsPresenter {
|
||||
id: true,
|
||||
slug: true,
|
||||
title: true,
|
||||
runsEnabled: true,
|
||||
avatar: true,
|
||||
projects: {
|
||||
where: { deletedAt: null },
|
||||
where: { deletedAt: null, version: "V3" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
version: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
members: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -198,47 +150,65 @@ export class OrganizationsPresenter {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
title: org.title,
|
||||
avatar: parseAvatar(org.avatar, defaultAvatar),
|
||||
projects: org.projects.map((project) => ({
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
name: project.name,
|
||||
version: project.version,
|
||||
updatedAt: project.updatedAt,
|
||||
})),
|
||||
runsEnabled: org.runsEnabled,
|
||||
membersCount: org._count.members,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async #selectBestProjectForOrganization(
|
||||
organizationSlug: string,
|
||||
userId: string,
|
||||
request: Request
|
||||
) {
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
deletedAt: null,
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
jobs: {
|
||||
_count: "desc",
|
||||
},
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
#getEnvironment({
|
||||
user,
|
||||
projectId,
|
||||
environmentSlug,
|
||||
environments,
|
||||
}: {
|
||||
user: UserFromSession;
|
||||
projectId: string;
|
||||
environmentSlug: string | undefined;
|
||||
environments: MinimumEnvironment[];
|
||||
}) {
|
||||
if (environmentSlug) {
|
||||
const env = environments.find((e) => e.slug === environmentSlug);
|
||||
if (env) {
|
||||
return env;
|
||||
}
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }), request);
|
||||
if (!env) {
|
||||
logger.info("Not Found: environment", {
|
||||
environmentSlug,
|
||||
environments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return projects[0];
|
||||
const currentEnvironmentId: string | undefined =
|
||||
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
|
||||
|
||||
const environment = environments.find((e) => e.id === currentEnvironmentId);
|
||||
if (environment) {
|
||||
return environment;
|
||||
}
|
||||
|
||||
//otherwise show their dev environment
|
||||
const yourDevEnvironment = environments.find(
|
||||
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
|
||||
);
|
||||
if (yourDevEnvironment) {
|
||||
return yourDevEnvironment;
|
||||
}
|
||||
|
||||
//otherwise show their prod environment
|
||||
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
|
||||
if (prodEnvironment) {
|
||||
return prodEnvironment;
|
||||
}
|
||||
|
||||
throw new Error("No environments found");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { type RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { type UserFromSession } from "~/services/session.server";
|
||||
|
||||
export type MinimumEnvironment = Pick<RuntimeEnvironment, "id" | "type" | "slug"> & {
|
||||
orgMember: null | {
|
||||
userId: string | undefined;
|
||||
};
|
||||
};
|
||||
|
||||
export class SelectBestEnvironmentPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ user }: { user: UserFromSession }) {
|
||||
const { project, organization } = await this.getBestProject(user);
|
||||
const environment = await this.selectBestEnvironment(project.id, user, project.environments);
|
||||
|
||||
return {
|
||||
project,
|
||||
organization,
|
||||
environment,
|
||||
};
|
||||
}
|
||||
|
||||
async getBestProject(user: UserFromSession) {
|
||||
//try get current project from cookie
|
||||
const projectId = user.dashboardPreferences.currentProjectId;
|
||||
|
||||
if (projectId) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
where: {
|
||||
id: projectId,
|
||||
deletedAt: null,
|
||||
organization: { members: { some: { userId: user.id } } },
|
||||
},
|
||||
include: {
|
||||
organization: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (project) {
|
||||
return { project, organization: project.organization };
|
||||
}
|
||||
}
|
||||
|
||||
//failing that, we pick the most recently modified project
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: { some: { userId: user.id } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return { project: projects[0], organization: projects[0].organization };
|
||||
}
|
||||
|
||||
async selectBestProjectFromProjects({
|
||||
user,
|
||||
projectSlug,
|
||||
projects,
|
||||
}: {
|
||||
user: UserFromSession;
|
||||
projectSlug: string | undefined;
|
||||
projects: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
updatedAt: Date;
|
||||
}[];
|
||||
}) {
|
||||
if (projectSlug) {
|
||||
const proj = projects.find((p) => p.slug === projectSlug);
|
||||
if (proj) {
|
||||
return proj;
|
||||
}
|
||||
|
||||
if (!proj) {
|
||||
logger.info("Not Found: project", {
|
||||
projectSlug,
|
||||
projects,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const currentProjectId = user.dashboardPreferences.currentProjectId;
|
||||
const project = projects.find((p) => p.id === currentProjectId);
|
||||
if (project) {
|
||||
return project;
|
||||
}
|
||||
|
||||
//most recently updated
|
||||
return projects.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime()).at(0);
|
||||
}
|
||||
|
||||
async selectBestEnvironment(
|
||||
projectId: string,
|
||||
user: UserFromSession,
|
||||
environments: MinimumEnvironment[]
|
||||
): Promise<MinimumEnvironment> {
|
||||
//try get current environment from prefs
|
||||
const currentEnvironmentId: string | undefined =
|
||||
user.dashboardPreferences.projects[projectId]?.currentEnvironment.id;
|
||||
|
||||
const currentEnvironment = environments.find((env) => env.id === currentEnvironmentId);
|
||||
if (currentEnvironment) {
|
||||
return currentEnvironment;
|
||||
}
|
||||
|
||||
//otherwise show their dev environment
|
||||
const yourDevEnvironment = environments.find(
|
||||
(env) => env.type === "DEVELOPMENT" && env.orgMember?.userId === user.id
|
||||
);
|
||||
if (yourDevEnvironment) {
|
||||
return yourDevEnvironment;
|
||||
}
|
||||
|
||||
//otherwise show their prod environment
|
||||
const prodEnvironment = environments.find((env) => env.type === "PRODUCTION");
|
||||
if (prodEnvironment) {
|
||||
return prodEnvironment;
|
||||
}
|
||||
|
||||
throw new Error("No environments found");
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getCurrentProjectId } from "~/services/currentProject.server";
|
||||
|
||||
export class SelectBestProjectPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({ userId, request }: { userId: string; request: Request }) {
|
||||
//try get current project from cookie
|
||||
const projectId = await getCurrentProjectId(request);
|
||||
if (projectId) {
|
||||
const project = await this.#prismaClient.project.findUnique({
|
||||
where: { id: projectId, deletedAt: null, organization: { members: { some: { userId } } } },
|
||||
include: { organization: true },
|
||||
});
|
||||
if (project) {
|
||||
return { project, organization: project.organization };
|
||||
}
|
||||
}
|
||||
|
||||
//failing that, we pick the most recently modified project
|
||||
const projects = await this.#prismaClient.project.findMany({
|
||||
include: {
|
||||
organization: true,
|
||||
},
|
||||
where: {
|
||||
deletedAt: null,
|
||||
organization: {
|
||||
members: { some: { userId } },
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: "desc",
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
if (projects.length === 0) {
|
||||
throw new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
return { project: projects[0], organization: projects[0].organization };
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { type ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { RuntimeEnvironmentType, type ProjectAlertChannel } from "@trigger.dev/database";
|
||||
import { decryptSecret } from "~/services/secrets/secretStore.server";
|
||||
import { env } from "~/env.server";
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ export type AlertChannelListPresenterAlertProperties = NonNullable<
|
||||
>;
|
||||
|
||||
export class AlertChannelListPresenter extends BasePresenter {
|
||||
public async call(projectId: string) {
|
||||
public async call(projectId: string, environmentType?: RuntimeEnvironmentType) {
|
||||
logger.debug("AlertChannelListPresenter", { projectId });
|
||||
|
||||
const alertChannels = await this._prisma.projectAlertChannel.findMany({
|
||||
@@ -45,9 +45,14 @@ export class AlertChannelListPresenter extends BasePresenter {
|
||||
|
||||
const limit = await getLimit(organization.organizationId, "alerts", 100_000_000);
|
||||
|
||||
const relevantChannels = alertChannels.filter((channel) => {
|
||||
if (!environmentType) return true;
|
||||
return channel.environmentTypes.includes(environmentType);
|
||||
});
|
||||
|
||||
return {
|
||||
alertChannels: await Promise.all(
|
||||
alertChannels.map(async (alertChannel) => ({
|
||||
relevantChannels.map(async (alertChannel) => ({
|
||||
...alertChannel,
|
||||
properties: await this.#presentProperties(alertChannel),
|
||||
}))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
|
||||
export class ApiKeysPresenter {
|
||||
@@ -33,11 +33,6 @@ export class ApiKeysPresenter {
|
||||
version: "desc",
|
||||
},
|
||||
},
|
||||
_count: {
|
||||
select: {
|
||||
environmentVariableValues: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
project: {
|
||||
@@ -71,7 +66,6 @@ export class ApiKeysPresenter {
|
||||
slug: environment.slug,
|
||||
updatedAt: environment.updatedAt,
|
||||
latestVersion: environment.backgroundWorkers.at(0)?.version,
|
||||
environmentVariableCount: environment._count.environmentVariableValues,
|
||||
}))
|
||||
),
|
||||
hasStaging: environments.some((environment) => environment.type === "STAGING"),
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import { type BatchTaskRunStatus, Prisma } from "@trigger.dev/database";
|
||||
import parse from "parse-duration";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { Direction } from "~/components/ListPagination";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
|
||||
export type BatchListOptions = {
|
||||
userId?: string;
|
||||
@@ -45,7 +45,6 @@ export class BatchListPresenter extends BasePresenter {
|
||||
|
||||
const hasFilters =
|
||||
hasStatusFilters ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
friendlyId !== undefined ||
|
||||
from !== undefined ||
|
||||
|
||||
@@ -6,10 +6,11 @@ import {
|
||||
type DisplayableInputEnvironment,
|
||||
} from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { filterOrphanedEnvironments, sortEnvironments } from "~/utils/environmentSort";
|
||||
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { execute } from "effect/Stream";
|
||||
import { engine } from "~/v3/runEngine.server";
|
||||
|
||||
export type Environment = Awaited<
|
||||
ReturnType<ConcurrencyPresenter["environmentConcurrency"]>
|
||||
@@ -56,6 +57,7 @@ export class ConcurrencyPresenter extends BasePresenter {
|
||||
|
||||
return {
|
||||
environments: this.environmentConcurrency(
|
||||
project.organizationId,
|
||||
project.id,
|
||||
userId,
|
||||
filterOrphanedEnvironments(project.environments)
|
||||
@@ -64,15 +66,41 @@ export class ConcurrencyPresenter extends BasePresenter {
|
||||
}
|
||||
|
||||
async environmentConcurrency(
|
||||
organizationId: string,
|
||||
projectId: string,
|
||||
userId: string,
|
||||
environments: (DisplayableInputEnvironment & { maximumConcurrencyLimit: number })[]
|
||||
) {
|
||||
const environmentConcurrency = await concurrencyTracker.environmentConcurrentRunCounts(
|
||||
const engineV1Concurrency = await concurrencyTracker.environmentConcurrentRunCounts(
|
||||
projectId,
|
||||
environments.map((env) => env.id)
|
||||
);
|
||||
|
||||
const engineV2Concurrency = await Promise.all(
|
||||
environments.map(async (env) =>
|
||||
engine.currentConcurrencyOfEnvQueue({
|
||||
...env,
|
||||
project: {
|
||||
id: projectId,
|
||||
},
|
||||
organization: {
|
||||
id: organizationId,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
//Build `executingCounts` with both v1 and v2 concurrencies
|
||||
const executingCounts: Record<string, number> = engineV1Concurrency;
|
||||
|
||||
for (let index = 0; index < environments.length; index++) {
|
||||
const env = environments[index];
|
||||
const existingValue: number | undefined = executingCounts[env.id];
|
||||
executingCounts[env.id] = engineV2Concurrency[index] + (existingValue ?? 0);
|
||||
}
|
||||
|
||||
//todo add Run Engine 2 concurrency count
|
||||
|
||||
const queued = await this._replica.$queryRaw<
|
||||
{
|
||||
runtimeEnvironmentId: string;
|
||||
@@ -93,7 +121,7 @@ GROUP BY
|
||||
const sortedEnvironments = sortEnvironments(environments).map((environment) => ({
|
||||
...displayableEnvironment(environment, userId),
|
||||
concurrencyLimit: environment.maximumConcurrencyLimit,
|
||||
concurrency: environmentConcurrency[environment.id] ?? 0,
|
||||
concurrency: executingCounts[environment.id] ?? 0,
|
||||
queued: Number(queued.find((q) => q.runtimeEnvironmentId === environment.id)?.count ?? 0),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { WorkerDeploymentStatus, WorkerInstanceGroupType } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { type WorkerDeploymentStatus, type WorkerInstanceGroupType } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema, type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Organization } from "~/models/organization.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
|
||||
const pageSize = 20;
|
||||
|
||||
@@ -21,11 +21,13 @@ export class DeploymentListPresenter {
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
environmentSlug,
|
||||
page = 1,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
environmentSlug: string;
|
||||
page?: number;
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
@@ -63,17 +65,21 @@ export class DeploymentListPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found`);
|
||||
}
|
||||
|
||||
const totalCount = await this.#prismaClient.workerDeployment.count({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
});
|
||||
|
||||
const labeledDeployments = await this.#prismaClient.workerDeploymentPromotion.findMany({
|
||||
where: {
|
||||
environmentId: {
|
||||
in: project.environments.map((env) => env.id),
|
||||
},
|
||||
environmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
deploymentId: true,
|
||||
@@ -118,6 +124,7 @@ INNER JOIN
|
||||
${sqlDatabaseSchema}."User" as u ON wd."triggeredById" = u."id"
|
||||
WHERE
|
||||
wd."projectId" = ${project.id}
|
||||
AND wd."environmentId" = ${environment.id}
|
||||
ORDER BY
|
||||
string_to_array(wd."version", '.')::int[] DESC
|
||||
LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
@@ -126,11 +133,6 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
currentPage: page,
|
||||
totalPages: Math.ceil(totalCount / pageSize),
|
||||
deployments: deployments.map((deployment, index) => {
|
||||
const environment = project.environments.find((env) => env.id === deployment.environmentId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for deployment ${deployment.id}`);
|
||||
}
|
||||
|
||||
const label = labeledDeployments.find(
|
||||
(labeledDeployment) => labeledDeployment.deploymentId === deployment.id
|
||||
);
|
||||
@@ -153,8 +155,6 @@ LIMIT ${pageSize} OFFSET ${pageSize * (page - 1)};`;
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
userId: environment.orgMember?.user.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
deployedBy: deployment.userId
|
||||
? {
|
||||
|
||||
@@ -3,11 +3,12 @@ import {
|
||||
ExternalBuildData,
|
||||
prepareDeploymentError,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { WorkerDeployment } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { Organization } from "~/models/organization.server";
|
||||
import { Project } from "~/models/project.server";
|
||||
import { User } from "~/models/user.server";
|
||||
import { RuntimeEnvironment, type WorkerDeployment } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { type Organization } from "~/models/organization.server";
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
|
||||
export type ErrorData = {
|
||||
@@ -28,11 +29,13 @@ export class DeploymentPresenter {
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
environmentSlug,
|
||||
deploymentShortCode,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
environmentSlug: RuntimeEnvironment["slug"];
|
||||
deploymentShortCode: WorkerDeployment["shortCode"];
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirstOrThrow({
|
||||
@@ -53,10 +56,16 @@ export class DeploymentPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, environmentSlug, userId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found`);
|
||||
}
|
||||
|
||||
const deployment = await this.#prismaClient.workerDeployment.findFirstOrThrow({
|
||||
where: {
|
||||
projectId: project.id,
|
||||
shortCode: deploymentShortCode,
|
||||
environmentId: environment.id,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { isCancellableRunStatus, isFinalRunStatus } from "~/v3/taskStatus";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { Direction } from "~/components/ListPagination";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
|
||||
export type RunListOptions = {
|
||||
userId?: string;
|
||||
@@ -65,7 +65,6 @@ export class RunListPresenter extends BasePresenter {
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
(environments !== undefined && environments.length > 0) ||
|
||||
(period !== undefined && period !== "all") ||
|
||||
(bulkId !== undefined && bulkId !== "") ||
|
||||
from !== undefined ||
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import { isFinalRunStatus } from "~/v3/taskStatus";
|
||||
|
||||
type Result = Awaited<ReturnType<RunPresenter["call"]>>;
|
||||
export type Run = Result["run"];
|
||||
|
||||
export class RunPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
|
||||
constructor(prismaClient: PrismaClient = prisma) {
|
||||
this.#prismaClient = prismaClient;
|
||||
}
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
runFriendlyId,
|
||||
}: {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
organizationSlug: string;
|
||||
runFriendlyId: string;
|
||||
}) {
|
||||
const run = await this.#prismaClient.taskRun.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
number: true,
|
||||
traceId: true,
|
||||
spanId: true,
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
completedAt: true,
|
||||
logsDeletedAt: true,
|
||||
runtimeEnvironment: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
organizationId: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
friendlyId: runFriendlyId,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
run: {
|
||||
id: run.id,
|
||||
number: run.number,
|
||||
friendlyId: run.friendlyId,
|
||||
traceId: run.traceId,
|
||||
spanId: run.spanId,
|
||||
status: run.status,
|
||||
isFinished: isFinalRunStatus(run.status),
|
||||
completedAt: run.completedAt,
|
||||
logsDeletedAt: run.logsDeletedAt,
|
||||
|
||||
environment: {
|
||||
id: run.runtimeEnvironment.id,
|
||||
organizationId: run.runtimeEnvironment.organizationId,
|
||||
type: run.runtimeEnvironment.type,
|
||||
slug: run.runtimeEnvironment.slug,
|
||||
userId: run.runtimeEnvironment.orgMember?.user.id,
|
||||
userName: getUsername(run.runtimeEnvironment.orgMember?.user),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getCurrentPlan, getLimit, getLimits } from "~/services/platform.v3.server";
|
||||
import { calculateNextScheduledTimestamp } from "~/v3/utils/calculateNextSchedule.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { CheckScheduleService } from "~/v3/services/checkSchedule.server";
|
||||
|
||||
type ScheduleListOptions = {
|
||||
projectId: string;
|
||||
@@ -86,10 +87,9 @@ export class ScheduleListPresenter extends BasePresenter {
|
||||
},
|
||||
});
|
||||
|
||||
const schedulesCount = await this._prisma.taskSchedule.count({
|
||||
where: {
|
||||
projectId,
|
||||
},
|
||||
const schedulesCount = await CheckScheduleService.getUsedSchedulesCount({
|
||||
prisma: this._replica,
|
||||
environments: project.environments,
|
||||
});
|
||||
|
||||
//get all possible scheduled tasks
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { Prisma } from "@trigger.dev/database";
|
||||
import type {
|
||||
RuntimeEnvironmentType,
|
||||
TaskTriggerSource,
|
||||
TaskRunStatus as TaskRunStatusType,
|
||||
import {
|
||||
Prisma,
|
||||
type TaskTriggerSource,
|
||||
type TaskRunStatus as TaskRunStatusType,
|
||||
type RuntimeEnvironment,
|
||||
type TaskRunStatus as DBTaskRunStatus,
|
||||
} from "@trigger.dev/database";
|
||||
import { QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { QUEUED_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import type { Organization } from "~/models/organization.server";
|
||||
import type { Project } from "~/models/project.server";
|
||||
import { displayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import type { User } from "~/models/user.server";
|
||||
import {
|
||||
filterOrphanedEnvironments,
|
||||
onlyDevEnvironments,
|
||||
exceptDevEnvironments,
|
||||
sortEnvironments,
|
||||
} from "~/utils/environmentSort";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { TaskRunStatus } from "~/database-types";
|
||||
import { concurrencyTracker } from "~/v3/services/taskRunConcurrencyTracker.server";
|
||||
import { CURRENT_DEPLOYMENT_LABEL } from "@trigger.dev/core/v3/isomorphic";
|
||||
|
||||
export type Task = {
|
||||
@@ -28,12 +21,6 @@ export type Task = {
|
||||
filePath: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
environments: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
slug: string;
|
||||
userName?: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
type Return = Awaited<ReturnType<TaskListPresenter["call"]>>;
|
||||
@@ -45,51 +32,36 @@ export class TaskListPresenter extends BasePresenter {
|
||||
userId,
|
||||
projectSlug,
|
||||
organizationSlug,
|
||||
environmentSlug,
|
||||
}: {
|
||||
userId: User["id"];
|
||||
projectSlug: Project["slug"];
|
||||
organizationSlug: Organization["slug"];
|
||||
environmentSlug: RuntimeEnvironment["slug"];
|
||||
}) {
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
const environment = await this._replica.runtimeEnvironment.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
type: true,
|
||||
projectId: true,
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
slug: environmentSlug,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
organization: {
|
||||
slug: organizationSlug,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const devEnvironments = onlyDevEnvironments(project.environments);
|
||||
const nonDevEnvironments = exceptDevEnvironments(project.environments);
|
||||
|
||||
const tasks = await this._replica.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
slug: string;
|
||||
exportName: string;
|
||||
filePath: string;
|
||||
runtimeEnvironmentId: string;
|
||||
createdAt: Date;
|
||||
triggerSource: TaskTriggerSource;
|
||||
}[]
|
||||
@@ -99,15 +71,13 @@ export class TaskListPresenter extends BasePresenter {
|
||||
FROM ${sqlDatabaseSchema}."WorkerDeploymentPromotion" wdp
|
||||
INNER JOIN ${sqlDatabaseSchema}."WorkerDeployment" wd
|
||||
ON wd.id = wdp."deploymentId"
|
||||
WHERE wdp."environmentId" IN (${Prisma.join(nonDevEnvironments.map((e) => e.id))})
|
||||
WHERE wdp."environmentId" = ${environment.id}
|
||||
AND wdp."label" = ${CURRENT_DEPLOYMENT_LABEL}
|
||||
),
|
||||
workers AS (
|
||||
workers AS (
|
||||
SELECT DISTINCT ON ("runtimeEnvironmentId") id, "runtimeEnvironmentId", version
|
||||
FROM ${sqlDatabaseSchema}."BackgroundWorker"
|
||||
WHERE "runtimeEnvironmentId" IN (${Prisma.join(
|
||||
filterOrphanedEnvironments(devEnvironments).map((e) => e.id)
|
||||
)})
|
||||
WHERE "runtimeEnvironmentId" = ${environment.id}
|
||||
OR id IN (SELECT id FROM non_dev_workers)
|
||||
ORDER BY "runtimeEnvironmentId", "createdAt" DESC
|
||||
)
|
||||
@@ -116,64 +86,29 @@ export class TaskListPresenter extends BasePresenter {
|
||||
JOIN ${sqlDatabaseSchema}."BackgroundWorkerTask" tasks ON tasks."workerId" = workers.id
|
||||
ORDER BY slug ASC;`;
|
||||
|
||||
//group by the task identifier (task.slug).
|
||||
const outputTasks = tasks.reduce((acc, task) => {
|
||||
const environment = project.environments.find((env) => env.id === task.runtimeEnvironmentId);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment not found for TaskRun ${task.id}`);
|
||||
}
|
||||
|
||||
let existingTask = acc.find((t) => t.slug === task.slug);
|
||||
|
||||
if (!existingTask) {
|
||||
existingTask = {
|
||||
...task,
|
||||
environments: [],
|
||||
};
|
||||
acc.push(existingTask);
|
||||
}
|
||||
|
||||
//favour newer tasks
|
||||
if (task.createdAt > existingTask.createdAt) {
|
||||
existingTask.createdAt = task.createdAt;
|
||||
existingTask.exportName = task.exportName;
|
||||
existingTask.filePath = task.filePath;
|
||||
existingTask.triggerSource = task.triggerSource;
|
||||
}
|
||||
|
||||
existingTask.environments.push(displayableEnvironment(environment, userId));
|
||||
|
||||
//order the environments
|
||||
existingTask.environments = sortEnvironments(existingTask.environments);
|
||||
|
||||
return acc;
|
||||
}, [] as Task[]);
|
||||
|
||||
//then get the activity for each task
|
||||
const activity = this.#getActivity(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
tasks.map((t) => t.slug),
|
||||
environment.projectId,
|
||||
environment.id
|
||||
);
|
||||
|
||||
const runningStats = this.#getRunningStats(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
tasks.map((t) => t.slug),
|
||||
environment.projectId,
|
||||
environment.id
|
||||
);
|
||||
|
||||
const durations = this.#getAverageDurations(
|
||||
outputTasks.map((t) => t.slug),
|
||||
project.id
|
||||
tasks.map((t) => t.slug),
|
||||
environment.projectId,
|
||||
environment.id
|
||||
);
|
||||
|
||||
const userEnvironment = project.environments.find((e) => e.orgMember?.user.id === userId);
|
||||
const userHasTasks = userEnvironment
|
||||
? outputTasks.some((t) => t.environments.some((e) => e.id === userEnvironment.id))
|
||||
: false;
|
||||
|
||||
return { tasks: outputTasks, userHasTasks, activity, runningStats, durations };
|
||||
return { tasks, environment, activity, runningStats, durations };
|
||||
}
|
||||
|
||||
async #getActivity(tasks: string[], projectId: string) {
|
||||
async #getActivity(tasks: string[], projectId: string, environmentId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
@@ -186,22 +121,23 @@ export class TaskListPresenter extends BasePresenter {
|
||||
count: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
DATE(tr."createdAt") as day,
|
||||
COUNT(*)
|
||||
FROM
|
||||
DATE(tr."createdAt") as day,
|
||||
COUNT(*)
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."runtimeEnvironmentId" = ${environmentId}
|
||||
AND tr."createdAt" >= (current_date - interval '6 days')
|
||||
GROUP BY
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
GROUP BY
|
||||
tr."taskIdentifier",
|
||||
tr."status",
|
||||
day
|
||||
ORDER BY
|
||||
ORDER BY
|
||||
tr."taskIdentifier" ASC,
|
||||
day ASC,
|
||||
tr."status" ASC;`;
|
||||
@@ -248,48 +184,68 @@ export class TaskListPresenter extends BasePresenter {
|
||||
}, {} as Record<string, ({ day: string } & Record<TaskRunStatusType, number>)[]>);
|
||||
}
|
||||
|
||||
async #getRunningStats(tasks: string[], projectId: string) {
|
||||
async #getRunningStats(tasks: string[], projectId: string, environmentId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const concurrencies = await concurrencyTracker.taskConcurrentRunCounts(projectId, tasks);
|
||||
|
||||
const queued = await this._replica.$queryRaw<
|
||||
const stats = await this._replica.$queryRaw<
|
||||
{
|
||||
taskIdentifier: string;
|
||||
status: DBTaskRunStatus;
|
||||
count: BigInt;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
COUNT(*)
|
||||
FROM
|
||||
tr.status,
|
||||
COUNT(*)
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."status" = ANY(ARRAY[${Prisma.join(QUEUED_STATUSES)}]::\"TaskRunStatus\"[])
|
||||
GROUP BY
|
||||
tr."taskIdentifier"
|
||||
ORDER BY
|
||||
AND tr."runtimeEnvironmentId" = ${environmentId}
|
||||
AND tr."status" = ANY(ARRAY[${Prisma.join([
|
||||
...QUEUED_STATUSES,
|
||||
"EXECUTING",
|
||||
])}]::\"TaskRunStatus\"[])
|
||||
GROUP BY
|
||||
tr."taskIdentifier",
|
||||
tr.status
|
||||
ORDER BY
|
||||
tr."taskIdentifier" ASC`;
|
||||
|
||||
//create an object combining the queued and concurrency counts
|
||||
const result: Record<string, { queued: number; running: number }> = {};
|
||||
for (const task of tasks) {
|
||||
const concurrency = concurrencies[task] ?? 0;
|
||||
const queuedCount = queued.find((q) => q.taskIdentifier === task)?.count ?? 0;
|
||||
const queued = stats.filter(
|
||||
(q) => q.taskIdentifier === task && QUEUED_STATUSES.includes(q.status)
|
||||
);
|
||||
const queuedCount =
|
||||
queued.length === 0
|
||||
? 0
|
||||
: queued.reduce((acc, q) => {
|
||||
return acc + Number(q.count);
|
||||
}, 0);
|
||||
|
||||
const running = stats.filter((r) => r.taskIdentifier === task && r.status === "EXECUTING");
|
||||
const runningCount =
|
||||
running.length === 0
|
||||
? 0
|
||||
: running.reduce((acc, r) => {
|
||||
return acc + Number(r.count);
|
||||
}, 0);
|
||||
|
||||
result[task] = {
|
||||
queued: Number(queuedCount),
|
||||
running: concurrency,
|
||||
queued: queuedCount,
|
||||
running: runningCount,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async #getAverageDurations(tasks: string[], projectId: string) {
|
||||
async #getAverageDurations(tasks: string[], projectId: string, environmentId: string) {
|
||||
if (tasks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
@@ -299,18 +255,19 @@ export class TaskListPresenter extends BasePresenter {
|
||||
taskIdentifier: string;
|
||||
duration: Number;
|
||||
}[]
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
>`
|
||||
SELECT
|
||||
tr."taskIdentifier",
|
||||
AVG(EXTRACT(EPOCH FROM (tr."updatedAt" - COALESCE(tr."startedAt", tr."lockedAt")))) as duration
|
||||
FROM
|
||||
FROM
|
||||
${sqlDatabaseSchema}."TaskRun" as tr
|
||||
WHERE
|
||||
WHERE
|
||||
tr."taskIdentifier" IN (${Prisma.join(tasks)})
|
||||
AND tr."projectId" = ${projectId}
|
||||
AND tr."runtimeEnvironmentId" = ${environmentId}
|
||||
AND tr."createdAt" >= (current_date - interval '6 days')
|
||||
AND tr."status" IN ('COMPLETED_SUCCESSFULLY', 'COMPLETED_WITH_ERRORS')
|
||||
GROUP BY
|
||||
GROUP BY
|
||||
tr."taskIdentifier";`;
|
||||
|
||||
return Object.fromEntries(durations.map((s) => [s.taskIdentifier, Number(s.duration)]));
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { TaskRun, TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { type TaskRunAttempt } from "@trigger.dev/database";
|
||||
import { eventStream } from "remix-utils/sse/server";
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { eventRepository } from "~/v3/eventRepository.server";
|
||||
import { projectPubSub } from "~/v3/services/projectPubSub.server";
|
||||
|
||||
type RunWithAttempts = {
|
||||
@@ -26,11 +25,13 @@ export class TasksStreamPresenter {
|
||||
request,
|
||||
organizationSlug,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
userId,
|
||||
}: {
|
||||
request: Request;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
userId: string;
|
||||
}) {
|
||||
const project = await this.#prismaClient.project.findFirst({
|
||||
|
||||
@@ -1,92 +1,24 @@
|
||||
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 "~/utils/environmentSort";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { type RuntimeEnvironmentType, type TaskTriggerSource } from "@trigger.dev/database";
|
||||
import { sqlDatabaseSchema } from "~/db.server";
|
||||
import { findCurrentWorkerDeployment } from "~/v3/models/workerDeployment.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
|
||||
type TaskListOptions = {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
url: string;
|
||||
projectId: string;
|
||||
environmentId: string;
|
||||
environmentType: RuntimeEnvironmentType;
|
||||
};
|
||||
|
||||
export type TaskList = Awaited<ReturnType<TestPresenter["call"]>>;
|
||||
export type TaskListItem = NonNullable<TaskList["tasks"]>[0];
|
||||
export type SelectedEnvironment = NonNullable<TaskList["selectedEnvironment"]>;
|
||||
|
||||
export class TestPresenter extends BasePresenter {
|
||||
public async call({ userId, projectSlug, url }: TaskListOptions) {
|
||||
// Find the project scoped to the organization
|
||||
const project = await this._replica.project.findFirstOrThrow({
|
||||
select: {
|
||||
id: true,
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
},
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
type: {
|
||||
in: ["PREVIEW", "STAGING", "PRODUCTION"],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "DEVELOPMENT",
|
||||
orgMember: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
where: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
});
|
||||
|
||||
const environments = sortEnvironments(
|
||||
project.environments.map((environment) => ({
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
slug: environment.slug,
|
||||
}))
|
||||
);
|
||||
|
||||
const searchParams = createSearchParams(url, TestSearchParams);
|
||||
|
||||
//no environmentId
|
||||
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 === environment);
|
||||
if (!matchingEnvironment) {
|
||||
return {
|
||||
hasSelectedEnvironment: false as const,
|
||||
environments,
|
||||
};
|
||||
}
|
||||
|
||||
const isDev = matchingEnvironment.type === "DEVELOPMENT";
|
||||
const tasks = await this.#getTasks(matchingEnvironment.id, isDev);
|
||||
public async call({ userId, projectId, environmentId, environmentType }: TaskListOptions) {
|
||||
const isDev = environmentType === "DEVELOPMENT";
|
||||
const tasks = await this.#getTasks(environmentId, isDev);
|
||||
|
||||
return {
|
||||
hasSelectedEnvironment: true as const,
|
||||
environments,
|
||||
selectedEnvironment: matchingEnvironment,
|
||||
tasks: tasks.map((task) => {
|
||||
return {
|
||||
id: task.id,
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import { ScheduledTaskPayload, parsePacket, prettyPrintPacket } from "@trigger.dev/core/v3";
|
||||
import { BackgroundWorkerTask, RuntimeEnvironmentType, TaskRunStatus } from "@trigger.dev/database";
|
||||
import { PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { type RuntimeEnvironmentType, type TaskRunStatus } from "@trigger.dev/database";
|
||||
import { type PrismaClient, prisma, sqlDatabaseSchema } from "~/db.server";
|
||||
import { getTimezones } from "~/utils/timezones.server";
|
||||
import { getUsername } from "~/utils/username";
|
||||
import {
|
||||
BackgroundWorkerTaskSlim,
|
||||
type BackgroundWorkerTaskSlim,
|
||||
findCurrentWorkerDeployment,
|
||||
} from "~/v3/models/workerDeployment.server";
|
||||
|
||||
type TestTaskOptions = {
|
||||
userId: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
projectId: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
};
|
||||
taskIdentifier: string;
|
||||
};
|
||||
|
||||
@@ -21,12 +23,6 @@ type Task = {
|
||||
filePath: string;
|
||||
exportName: string;
|
||||
friendlyId: string;
|
||||
environment: {
|
||||
id: string;
|
||||
type: RuntimeEnvironmentType;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type TestTask =
|
||||
@@ -87,35 +83,10 @@ export class TestTaskPresenter {
|
||||
|
||||
public async call({
|
||||
userId,
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
projectId,
|
||||
environment,
|
||||
taskIdentifier,
|
||||
}: TestTaskOptions): Promise<TestTaskResult> {
|
||||
const environment = await this.#prismaClient.runtimeEnvironment.findFirstOrThrow({
|
||||
where: {
|
||||
slug: environmentSlug,
|
||||
project: {
|
||||
slug: projectSlug,
|
||||
},
|
||||
orgMember: environmentSlug === "dev" ? { userId } : undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
displayName: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
let task: BackgroundWorkerTaskSlim | null = null;
|
||||
if (environment.type !== "DEVELOPMENT") {
|
||||
const deployment = await findCurrentWorkerDeployment(environment.id);
|
||||
@@ -182,12 +153,6 @@ export class TestTaskPresenter {
|
||||
filePath: task.filePath,
|
||||
exportName: task.exportName,
|
||||
friendlyId: task.friendlyId,
|
||||
environment: {
|
||||
id: environment.id,
|
||||
type: environment.type,
|
||||
userId: environment.orgMember?.user.id,
|
||||
userName: getUsername(environment.orgMember?.user),
|
||||
},
|
||||
};
|
||||
|
||||
switch (task.triggerSource) {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { getUsersInvites } from "~/models/member.server";
|
||||
import { SelectBestProjectPresenter } from "~/presenters/SelectBestProjectPresenter.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
invitesPath,
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
v3EnvironmentPath,
|
||||
v3ProjectPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
@@ -20,11 +21,13 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
return redirect(invitesPath());
|
||||
}
|
||||
|
||||
const presenter = new SelectBestProjectPresenter();
|
||||
const presenter = new SelectBestEnvironmentPresenter();
|
||||
try {
|
||||
const { project, organization } = await presenter.call({ userId: user.id, request });
|
||||
const { project, organization, environment } = await presenter.call({
|
||||
user,
|
||||
});
|
||||
//redirect them to the most appropriate project
|
||||
return redirect(v3ProjectPath(organization, project));
|
||||
return redirect(v3EnvironmentPath(organization, project, environment));
|
||||
} catch (e) {
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -1,70 +1,53 @@
|
||||
import { FolderIcon } from "@heroicons/react/20/solid";
|
||||
import { Link, MetaFunction } from "@remix-run/react";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { newProjectPath, v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import {
|
||||
newOrganizationPath,
|
||||
newProjectPath,
|
||||
OrganizationParamsSchema,
|
||||
v3ProjectPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{
|
||||
title: "Projects | Trigger.dev",
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: { slug: organizationSlug, members: { some: { userId: user.id } }, deletedAt: null },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: {
|
||||
projects: {
|
||||
where: { deletedAt: null, version: "V3" },
|
||||
select: {
|
||||
id: true,
|
||||
slug: true,
|
||||
name: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { name: "asc" },
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw redirect(newOrganizationPath());
|
||||
}
|
||||
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const bestProject = await selector.selectBestProjectFromProjects({
|
||||
user,
|
||||
projectSlug: undefined,
|
||||
projects: org.projects,
|
||||
});
|
||||
if (!bestProject) {
|
||||
logger.info("Not Found: project", {
|
||||
request,
|
||||
project: bestProject,
|
||||
});
|
||||
throw redirect(newProjectPath({ slug: organizationSlug }));
|
||||
}
|
||||
|
||||
return redirect(v3ProjectPath({ slug: organizationSlug }, bestProject));
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
<PageTitle title={`${organization.title} projects`} />
|
||||
<PageAccessories>
|
||||
<Paragraph variant="extra-small" className="text-charcoal-500">
|
||||
Org UID: {organization.id}
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={newProjectPath(organization)}
|
||||
variant="primary/small"
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
Create a new project
|
||||
</LinkButton>
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody>
|
||||
<ul className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{organization.projects.length > 0 ? (
|
||||
organization.projects.map((project) => {
|
||||
return (
|
||||
<li key={project.id}>
|
||||
<Link
|
||||
className="border-grid-bright-dimmed flex gap-4 rounded-md border p-4 transition hover:bg-charcoal-900 "
|
||||
to={v3ProjectPath(organization, project)}
|
||||
>
|
||||
<FolderIcon className="h-10 w-10 flex-none text-indigo-600" />
|
||||
<div className="flex flex-col">
|
||||
<Header3>{project.name}</Header3>
|
||||
<Badge className="max-w-max">{project.version}</Badge>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<li>
|
||||
<LinkButton to={newProjectPath(organization)} variant="primary/small">
|
||||
Create a Project
|
||||
</LinkButton>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
import { redirect, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { SelectBestEnvironmentPresenter } from "~/presenters/SelectBestEnvironmentPresenter.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { ProjectParamSchema, v3EnvironmentPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
|
||||
const project = await prisma.project.findFirst({
|
||||
where: {
|
||||
slug: projectParam,
|
||||
deletedAt: null,
|
||||
organization: { slug: organizationSlug, members: { some: { userId: user.id } } },
|
||||
},
|
||||
include: {
|
||||
environments: {
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
slug: true,
|
||||
orgMember: {
|
||||
select: {
|
||||
userId: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Project not found",
|
||||
});
|
||||
}
|
||||
|
||||
const selector = new SelectBestEnvironmentPresenter();
|
||||
const environment = await selector.selectBestEnvironment(project.id, user, project.environments);
|
||||
|
||||
return redirect(v3EnvironmentPath({ slug: organizationSlug }, project, environment));
|
||||
};
|
||||
+32
-87
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
BeakerIcon,
|
||||
BookOpenIcon,
|
||||
ChatBubbleLeftRightIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronUpIcon,
|
||||
LightBulbIcon,
|
||||
@@ -11,34 +10,31 @@ import {
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { json, type MetaFunction } from "@remix-run/node";
|
||||
import { Link, useRevalidator, useSubmit } from "@remix-run/react";
|
||||
import { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { DiscordIcon } from "@trigger.dev/companyicons";
|
||||
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
|
||||
import { TaskRunStatus } from "@trigger.dev/database";
|
||||
import { type TaskRunStatus } from "@trigger.dev/database";
|
||||
import { Fragment, Suspense, useEffect, useState } from "react";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, TooltipProps } from "recharts";
|
||||
import { Bar, BarChart, ResponsiveContainer, Tooltip, type TooltipProps } from "recharts";
|
||||
import { TypedAwait, typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { RunsIcon } from "~/assets/icons/RunsIcon";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { HasNoTasksDeployed, HasNoTasksDev } from "~/components/BlankStatePanels";
|
||||
import {
|
||||
InitCommandV3,
|
||||
PackageManagerProvider,
|
||||
TriggerDevStepV3,
|
||||
TriggerLoginStepV3,
|
||||
} from "~/components/SetupCommands";
|
||||
import { StepContentContainer } from "~/components/StepContentContainer";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { AnimatingArrow } from "~/components/primitives/AnimatingArrow";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { formatDateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "~/components/primitives/Dialog";
|
||||
import { Header1, Header2, Header3 } from "~/components/primitives/Headers";
|
||||
import { Header2, 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";
|
||||
@@ -69,11 +65,16 @@ import {
|
||||
taskTriggerSourceDescription,
|
||||
TaskTriggerSourceIcon,
|
||||
} from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useTextFilter } from "~/hooks/useTextFilter";
|
||||
import { Task, TaskActivity, TaskListPresenter } from "~/presenters/v3/TaskListPresenter.server";
|
||||
import {
|
||||
type Task,
|
||||
type TaskActivity,
|
||||
TaskListPresenter,
|
||||
} from "~/presenters/v3/TaskListPresenter.server";
|
||||
import {
|
||||
getUsefulLinksPreference,
|
||||
setUsefulLinksPreference,
|
||||
@@ -83,8 +84,8 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
inviteTeamMemberPath,
|
||||
ProjectParamSchema,
|
||||
v3RunsPath,
|
||||
v3TasksStreamingPath,
|
||||
v3TestPath,
|
||||
@@ -101,21 +102,22 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
try {
|
||||
const presenter = new TaskListPresenter();
|
||||
const { tasks, userHasTasks, activity, runningStats, durations } = await presenter.call({
|
||||
const { tasks, environment, activity, runningStats, durations } = await presenter.call({
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
});
|
||||
|
||||
const usefulLinksPreference = await getUsefulLinksPreference(request);
|
||||
|
||||
return typeddefer({
|
||||
tasks,
|
||||
userHasTasks,
|
||||
environment,
|
||||
activity,
|
||||
runningStats,
|
||||
durations,
|
||||
@@ -149,7 +151,8 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const { tasks, userHasTasks, activity, runningStats, durations, usefulLinksPreference } =
|
||||
const environment = useEnvironment();
|
||||
const { tasks, activity, runningStats, durations, usefulLinksPreference } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const { filterText, setFilterText, filteredItems } = useTextFilter<Task>({
|
||||
items: tasks,
|
||||
@@ -180,7 +183,7 @@ export default function Page() {
|
||||
|
||||
//live reload the page when the tasks change
|
||||
const revalidator = useRevalidator();
|
||||
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project), {
|
||||
const streamedEvents = useEventSource(v3TasksStreamingPath(organization, project, environment), {
|
||||
event: "message",
|
||||
});
|
||||
|
||||
@@ -211,13 +214,6 @@ export default function Page() {
|
||||
{tasks.map((task) => (
|
||||
<Property.Item key={task.slug}>
|
||||
<Property.Label>{task.exportName}</Property.Label>
|
||||
<Property.Value>
|
||||
{task.environments
|
||||
.map((e) =>
|
||||
e.userName ? `${e.userName}/${e.id}` : `${e.type.slice(0, 3)}/${e.id}`
|
||||
)
|
||||
.join(", ")}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
))}
|
||||
</Property.Table>
|
||||
@@ -237,7 +233,7 @@ export default function Page() {
|
||||
<div className={cn("grid h-full grid-rows-1")}>
|
||||
{hasTasks ? (
|
||||
<div className="flex min-w-0 max-w-full flex-col">
|
||||
{!userHasTasks && <UserHasNoTasks />}
|
||||
{tasks.length === 0 ? <UserHasNoTasks /> : null}
|
||||
<div className="max-h-full overflow-hidden">
|
||||
<div className="flex items-center gap-1 p-2">
|
||||
<Input
|
||||
@@ -267,33 +263,19 @@ export default function Page() {
|
||||
<TableHeaderCell>Queued</TableHeaderCell>
|
||||
<TableHeaderCell>Activity (7d)</TableHeaderCell>
|
||||
<TableHeaderCell>Avg. duration</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Go to page</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredItems.length > 0 ? (
|
||||
filteredItems.map((task) => {
|
||||
const path = v3RunsPath(organization, project, {
|
||||
const path = v3RunsPath(organization, project, environment, {
|
||||
tasks: [task.slug],
|
||||
});
|
||||
|
||||
const devYouEnvironment = task.environments.find(
|
||||
(e) => e.type === "DEVELOPMENT" && !e.userName
|
||||
);
|
||||
const firstDeployedEnvironment = task.environments
|
||||
.filter((e) => e.type !== "DEVELOPMENT")
|
||||
.at(0);
|
||||
const testEnvironment = devYouEnvironment ?? firstDeployedEnvironment;
|
||||
|
||||
const testPath = testEnvironment
|
||||
? v3TestTaskPath(
|
||||
organization,
|
||||
project,
|
||||
{ taskIdentifier: task.slug },
|
||||
testEnvironment.slug
|
||||
)
|
||||
: v3TestPath(organization, project);
|
||||
const testPath = v3TestTaskPath(organization, project, environment, {
|
||||
taskIdentifier: task.slug,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableRow key={task.slug} className="group">
|
||||
@@ -372,9 +354,6 @@ export default function Page() {
|
||||
</TypedAwait>
|
||||
</Suspense>
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabels environments={task.environments} />
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
@@ -417,9 +396,13 @@ export default function Page() {
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<CreateTaskInstructions />
|
||||
<HasNoTasksDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<HasNoTasksDeployed environment={environment} />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</div>
|
||||
@@ -444,45 +427,6 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateTaskInstructions() {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between border-b">
|
||||
<Header1 spacing>Get setup in 3 minutes</Header1>
|
||||
<div className="flex items-center gap-2">
|
||||
<Feedback
|
||||
button={
|
||||
<Button variant="minimal/small" LeadingIcon={ChatBubbleLeftRightIcon}>
|
||||
I'm stuck!
|
||||
</Button>
|
||||
}
|
||||
defaultValue="help"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<StepNumber stepNumber="1" title="Run the CLI 'init' command in an existing project" />
|
||||
<StepContentContainer>
|
||||
<InitCommandV3 />
|
||||
<Paragraph spacing>
|
||||
You'll notice a new folder in your project called{" "}
|
||||
<InlineCode variant="small">trigger</InlineCode>. We've added a very simple example task
|
||||
in here to help you get started.
|
||||
</Paragraph>
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="2" title="Run the CLI 'dev' command" />
|
||||
<StepContentContainer>
|
||||
<TriggerDevStepV3 />
|
||||
</StepContentContainer>
|
||||
<StepNumber stepNumber="3" title="Waiting for tasks" displaySpinner />
|
||||
<StepContentContainer>
|
||||
<Paragraph>This page will automatically refresh.</Paragraph>
|
||||
</StepContentContainer>
|
||||
</div>
|
||||
</PackageManagerProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function UserHasNoTasks() {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
@@ -639,6 +583,7 @@ const CustomTooltip = ({ active, payload, label }: TooltipProps<number, string>)
|
||||
function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const [isVideoDialogOpen, setIsVideoDialogOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -660,7 +605,7 @@ function HelpfulInfoHasTasks({ onClose }: { onClose: () => void }) {
|
||||
</div>
|
||||
<LinkWithIcon
|
||||
variant="withIcon"
|
||||
to={v3TestPath(organization, project)}
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
description="Test your tasks"
|
||||
icon={<BeakerIcon className="size-5 text-lime-500" />}
|
||||
/>
|
||||
+11
-9
@@ -1,14 +1,12 @@
|
||||
import { LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import {
|
||||
redirectBackWithSuccessMessage,
|
||||
redirectWithSuccessMessage,
|
||||
} from "~/models/message.server";
|
||||
import { env } from "~/env.server";
|
||||
import { 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 {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3NewProjectAlertPath,
|
||||
v3NewProjectAlertPathConnectToSlackPath,
|
||||
@@ -16,7 +14,7 @@ import {
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
@@ -34,7 +32,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
|
||||
if (integration) {
|
||||
return redirectWithSuccessMessage(
|
||||
`${v3NewProjectAlertPath({ slug: organizationSlug }, project)}?option=slack`,
|
||||
`${v3NewProjectAlertPath({ slug: organizationSlug }, project, {
|
||||
slug: envParam,
|
||||
})}?option=slack`,
|
||||
request,
|
||||
"Successfully connected your Slack workspace"
|
||||
);
|
||||
@@ -44,7 +44,9 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
"SLACK",
|
||||
project.organizationId,
|
||||
request,
|
||||
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project)
|
||||
v3NewProjectAlertPathConnectToSlackPath({ slug: organizationSlug }, project, {
|
||||
slug: envParam,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
+15
-23
@@ -9,6 +9,7 @@ import { useEffect, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout, variantClasses } from "~/components/primitives/Callout";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
@@ -24,6 +25,7 @@ import SegmentedControl from "~/components/primitives/SegmentedControl";
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { InfoIconTooltip } from "~/components/primitives/Tooltip";
|
||||
import { env } from "~/env.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -31,7 +33,11 @@ import { findProjectBySlug } from "~/models/project.server";
|
||||
import { NewAlertChannelPresenter } from "~/presenters/v3/NewAlertChannelPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3ProjectAlertsPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3ProjectAlertsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import {
|
||||
type CreateAlertChannelOptions,
|
||||
CreateAlertChannelService,
|
||||
@@ -163,7 +169,7 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
@@ -197,7 +203,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Created ${alertChannel.name} alert`
|
||||
);
|
||||
@@ -211,6 +217,7 @@ export default function Page() {
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const [currentAlertChannel, setCurrentAlertChannel] = useState<string | null>(option ?? "EMAIL");
|
||||
|
||||
const [selectedSlackChannelValue, setSelectedSlackChannelValue] = useState<string | undefined>();
|
||||
@@ -251,7 +258,7 @@ export default function Page() {
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3ProjectAlertsPath(organization, project));
|
||||
navigate(v3ProjectAlertsPath(organization, project, environment));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -407,24 +414,9 @@ export default function Page() {
|
||||
<FormError id={alertTypes.errorId}>{alertTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label>Environments</Label>
|
||||
<CheckboxWithLabel
|
||||
name={environmentTypes.name}
|
||||
id="PRODUCTION"
|
||||
value="PRODUCTION"
|
||||
variant="simple/small"
|
||||
label="PROD"
|
||||
defaultChecked
|
||||
/>
|
||||
<CheckboxWithLabel
|
||||
name={environmentTypes.name}
|
||||
id="STAGING"
|
||||
value="STAGING"
|
||||
variant="simple/small"
|
||||
label="STAGING"
|
||||
defaultChecked
|
||||
/>
|
||||
|
||||
<Label>Environment</Label>
|
||||
<input type="hidden" name={environmentTypes.name} value={environment.type} />
|
||||
<EnvironmentCombo environment={environment} />
|
||||
<FormError id={environmentTypes.errorId}>{environmentTypes.error}</FormError>
|
||||
</InputGroup>
|
||||
<FormError>{form.error}</FormError>
|
||||
@@ -436,7 +428,7 @@ export default function Page() {
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3ProjectAlertsPath(organization, project)}
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
+176
-154
@@ -10,15 +10,16 @@ import {
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Form, MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from "@remix-run/server-runtime";
|
||||
import { SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { type ProjectAlertChannelType, type ProjectAlertType } from "@trigger.dev/database";
|
||||
import assertNever from "assert-never";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { EnvironmentTypeLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { AlertsNoneDev, AlertsNoneDeployed } from "~/components/BlankStatePanels";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, 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";
|
||||
@@ -43,10 +44,12 @@ import {
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
AlertChannelListPresenter,
|
||||
type AlertChannelListPresenterRecord,
|
||||
@@ -54,7 +57,7 @@ import {
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
EnvironmentParamSchema,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3NewProjectAlertPath,
|
||||
@@ -71,10 +74,9 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
@@ -82,8 +84,16 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
});
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Response(undefined, {
|
||||
status: 404,
|
||||
statusText: "Environment not found",
|
||||
});
|
||||
}
|
||||
|
||||
const presenter = new AlertChannelListPresenter();
|
||||
const data = await presenter.call(project.id);
|
||||
const data = await presenter.call(project.id, environment.type);
|
||||
|
||||
return typedjson(data);
|
||||
};
|
||||
@@ -96,7 +106,7 @@ const schema = z.discriminatedUnion("action", [
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
@@ -123,7 +133,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Deleted ${alertChannel.name} alert`
|
||||
);
|
||||
@@ -135,7 +145,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Disabled ${alertChannel.name} alert`
|
||||
);
|
||||
@@ -147,7 +157,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
});
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3ProjectAlertsPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`Enabled ${alertChannel.name} alert`
|
||||
);
|
||||
@@ -157,8 +167,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const { alertChannels, limits } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const requiresUpgrade = limits.used >= limits.limit;
|
||||
|
||||
@@ -177,155 +188,166 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
<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>Environments</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={cn("space-x-2", alertChannel.enabled ? "" : "opacity-50")}
|
||||
>
|
||||
{alertChannel.environmentTypes.map((environmentType) => (
|
||||
<EnvironmentTypeLabel
|
||||
key={environmentType}
|
||||
environment={{ type: environmentType }}
|
||||
/>
|
||||
))}
|
||||
</TableCell>
|
||||
<TableCell className={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus
|
||||
enabled={alertChannel.enabled}
|
||||
enabledIcon={BellAlertIcon}
|
||||
disabledIcon={BellSlashIcon}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</>
|
||||
}
|
||||
className={
|
||||
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Header2 spacing className="text-text-bright">
|
||||
You haven't created any project alerts yet
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Get alerted when runs or deployments fail, or when deployments succeed in
|
||||
both Prod and Staging environments.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{alertChannels.length > 0 ? (
|
||||
<div className="grid max-h-full min-h-full grid-rows-[auto_1fr_auto]">
|
||||
<div className="flex h-fit items-end justify-between p-2 pl-3">
|
||||
<Header2 className="">Project alerts</Header2>
|
||||
{alertChannels.length > 0 && !requiresUpgrade && (
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex h-fit items-stretch gap-3">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Name</TableHeaderCell>
|
||||
<TableHeaderCell>Alert types</TableHeaderCell>
|
||||
<TableHeaderCell>Channel</TableHeaderCell>
|
||||
<TableHeaderCell>Enabled</TableHeaderCell>
|
||||
<TableHeaderCell>Environments</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={cn("py-1", alertChannel.enabled ? "" : "opacity-50")}>
|
||||
<AlertChannelDetails alertChannel={alertChannel} />
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<EnabledStatus
|
||||
enabled={alertChannel.enabled}
|
||||
enabledIcon={BellAlertIcon}
|
||||
disabledIcon={BellSlashIcon}
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
requiresUpgrade ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available alerts. Upgrade your plan to
|
||||
enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your alerts.
|
||||
</Header3>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className={alertChannel.enabled ? "" : "opacity-50"}>
|
||||
<div className="flex items-center gap-3">
|
||||
{alertChannel.environmentTypes.map((environmentType) => (
|
||||
<EnvironmentCombo
|
||||
key={environmentType}
|
||||
environment={{ type: environmentType }}
|
||||
className="text-xs"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
<>
|
||||
{alertChannel.enabled ? (
|
||||
<DisableAlertChannelButton id={alertChannel.id} />
|
||||
) : (
|
||||
<EnableAlertChannelButton id={alertChannel.id} />
|
||||
)}
|
||||
<DeleteAlertChannelButton id={alertChannel.id} />
|
||||
</>
|
||||
}
|
||||
className={
|
||||
alertChannel.enabled ? "" : "group-hover/table-row:bg-charcoal-800/50"
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<div className="flex flex-col items-center justify-center py-6">
|
||||
<Header2 spacing className="text-text-bright">
|
||||
You haven't created any project alerts yet
|
||||
</Header2>
|
||||
<Paragraph variant="small" className="mb-4">
|
||||
Get alerted when runs or deployments fail, or when deployments succeed in
|
||||
both Prod and Staging environments.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3NewProjectAlertPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
>
|
||||
New alert
|
||||
</LinkButton>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<div className="flex h-fit items-stretch gap-3">
|
||||
<div className="flex w-full items-start justify-between">
|
||||
<div className="flex h-fit w-full items-center gap-4 border-t border-grid-bright bg-background-bright p-[0.86rem] pl-4">
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<div className="size-6">
|
||||
<svg className="h-full w-full -rotate-90 overflow-visible">
|
||||
<circle
|
||||
className="fill-none stroke-grid-bright"
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
/>
|
||||
<circle
|
||||
className={`fill-none ${
|
||||
requiresUpgrade ? "stroke-error" : "stroke-success"
|
||||
}`}
|
||||
strokeWidth="4"
|
||||
r="10"
|
||||
cx="12"
|
||||
cy="12"
|
||||
strokeDasharray={`${(limits.used / limits.limit) * 62.8} 62.8`}
|
||||
strokeDashoffset="0"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
}
|
||||
content={`${Math.round((limits.used / limits.limit) * 100)}%`}
|
||||
/>
|
||||
<div className="flex w-full items-center justify-between gap-6">
|
||||
{requiresUpgrade ? (
|
||||
<Header3 className="text-error">
|
||||
You've used all {limits.limit} of your available alerts. Upgrade your plan
|
||||
to enable more.
|
||||
</Header3>
|
||||
) : (
|
||||
<Header3>
|
||||
You've used {limits.used}/{limits.limit} of your alerts.
|
||||
</Header3>
|
||||
)}
|
||||
|
||||
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
<LinkButton to={v3BillingPath(organization)} variant="secondary/small">
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<AlertsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<AlertsNoneDeployed />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
<Outlet />
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
+27
-23
@@ -1,10 +1,10 @@
|
||||
import { BookOpenIcon, InformationCircleIcon, LockOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { MetaFunction } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type MetaFunction } from "@remix-run/react";
|
||||
import { type 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 { environmentTitle, EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { RegenerateApiKeyModal } from "~/components/environments/RegenerateApiKeyModal";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -27,7 +27,7 @@ import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { ApiKeysPresenter } from "~/presenters/v3/ApiKeysPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { ProjectParamSchema, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, ProjectParamSchema, v3BillingPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -99,7 +99,6 @@ export default function Page() {
|
||||
<TableHeaderCell>Secret key</TableHeaderCell>
|
||||
<TableHeaderCell>Key generated</TableHeaderCell>
|
||||
<TableHeaderCell>Latest version</TableHeaderCell>
|
||||
<TableHeaderCell>Env vars</TableHeaderCell>
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -107,7 +106,7 @@ export default function Page() {
|
||||
{environments.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<ClipboardField
|
||||
@@ -121,7 +120,6 @@ export default function Page() {
|
||||
<DateTime date={environment.updatedAt} />
|
||||
</TableCell>
|
||||
<TableCell>{environment.latestVersion ?? "–"}</TableCell>
|
||||
<TableCell>{environment.environmentVariableCount}</TableCell>
|
||||
<TableCellMenu
|
||||
isSticky
|
||||
popoverContent={
|
||||
@@ -133,6 +131,28 @@ export default function Page() {
|
||||
></TableCellMenu>
|
||||
</TableRow>
|
||||
))}
|
||||
{!hasStaging && (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<EnvironmentCombo environment={{ type: "STAGING" }} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<LinkButton
|
||||
to={v3BillingPath(
|
||||
organization,
|
||||
"Upgrade to unlock a Staging environment for your projects."
|
||||
)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade to get staging environment
|
||||
</LinkButton>
|
||||
</TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
@@ -144,22 +164,6 @@ export default function Page() {
|
||||
backend.
|
||||
</Paragraph>
|
||||
</InfoPanel>
|
||||
|
||||
{!hasStaging && (
|
||||
<div className="flex items-center gap-2 pl-3 pr-2">
|
||||
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Upgrade to add a Staging environment
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</PageBody>
|
||||
+43
-38
@@ -4,14 +4,14 @@ import {
|
||||
ExclamationCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useLocation, useNavigation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { formatDuration } from "@trigger.dev/core/v3/utils/durations";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { BatchesNone } from "~/components/BlankStatePanels";
|
||||
import { ListPagination } from "~/components/ListPagination";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
@@ -38,17 +38,19 @@ import {
|
||||
} from "~/components/runs/v3/BatchStatus";
|
||||
import { CheckBatchCompletionDialog } from "~/components/runs/v3/CheckBatchCompletionDialog";
|
||||
import { LiveTimer } from "~/components/runs/v3/LiveTimer";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import {
|
||||
BatchList,
|
||||
BatchListItem,
|
||||
type BatchList,
|
||||
type BatchListItem,
|
||||
BatchListPresenter,
|
||||
} from "~/presenters/v3/BatchListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { docsPath, ProjectParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
import { docsPath, EnvironmentParamSchema, v3BatchRunsPath } from "~/utils/pathBuilder";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -60,13 +62,23 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
environments: [environment.id],
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
from: url.searchParams.get("from") ?? undefined,
|
||||
@@ -75,12 +87,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
};
|
||||
const filters = BatchListFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
return redirectWithErrorMessage("/", request, "Project not found");
|
||||
}
|
||||
|
||||
const presenter = new BatchListPresenter();
|
||||
const list = await presenter.call({
|
||||
userId,
|
||||
@@ -94,7 +100,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
export default function Page() {
|
||||
const { batches, hasFilters, filters, pagination } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -102,7 +107,6 @@ export default function Page() {
|
||||
<PageTitle title="Batches" />
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip />
|
||||
|
||||
<LinkButton
|
||||
variant={"docs/small"}
|
||||
LeadingIcon={BookOpenIcon}
|
||||
@@ -113,21 +117,27 @@ export default function Page() {
|
||||
</PageAccessories>
|
||||
</NavBar>
|
||||
<PageBody scrollable={false}>
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters possibleEnvironments={project.environments} hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
{!hasFilters && batches.length === 0 ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<BatchesNone />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<BatchFilters hasFilters={hasFilters} />
|
||||
<div className="flex items-center justify-end gap-x-2">
|
||||
<ListPagination list={{ pagination }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
<BatchesTable
|
||||
batches={batches}
|
||||
filters={filters}
|
||||
hasFilters={hasFilters}
|
||||
pagination={pagination}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</PageBody>
|
||||
</PageContainer>
|
||||
);
|
||||
@@ -138,13 +148,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<Table className="max-h-full overflow-y-auto">
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>ID</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
<div className="flex flex-col divide-y divide-grid-dimmed">
|
||||
@@ -192,18 +202,13 @@ function BatchesTable({ batches, hasFilters, filters }: BatchList) {
|
||||
</TableBlankRow>
|
||||
) : (
|
||||
batches.map((batch, index) => {
|
||||
const path = v3BatchRunsPath(organization, project, batch);
|
||||
const path = v3BatchRunsPath(organization, project, environment, batch);
|
||||
return (
|
||||
<TableRow key={batch.id}>
|
||||
<TableCell to={path} isTabbableCell>
|
||||
{batch.friendlyId}
|
||||
</TableCell>
|
||||
<TableCell to={path}>
|
||||
<EnvironmentLabel
|
||||
environment={batch.environment}
|
||||
userName={batch.environment.userName}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell to={path}>
|
||||
{batch.batchVersion === "v1" ? (
|
||||
<SimpleTooltip
|
||||
+15
-5
@@ -4,12 +4,12 @@ import {
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { LockOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { Await, MetaFunction } from "@remix-run/react";
|
||||
import { Await, type MetaFunction } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Suspense } from "react";
|
||||
import { typeddefer, useTypedLoaderData } from "remix-typedjson";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Feedback } from "~/components/Feedback";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -95,7 +95,17 @@ export default function Page() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<Suspense fallback={<Spinner />}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<TableRow>
|
||||
<TableCell colSpan={4}>
|
||||
<div className="grid place-items-center py-6">
|
||||
<Spinner />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
}
|
||||
>
|
||||
<Await resolve={environments} errorElement={<p>Error loading environments</p>}>
|
||||
{(environments) => <EnvironmentsTable environments={environments} />}
|
||||
</Await>
|
||||
@@ -124,7 +134,7 @@ export default function Page() {
|
||||
Upgrade for more concurrency
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
to={v3BillingPath(organization, "Upgrade your plan for more concurrency")}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
@@ -145,7 +155,7 @@ function EnvironmentsTable({ environments }: { environments: Environment[] }) {
|
||||
{environments.map((environment) => (
|
||||
<TableRow key={environment.id}>
|
||||
<TableCell>
|
||||
<EnvironmentLabel environment={environment} userName={environment.userName} />
|
||||
<EnvironmentCombo environment={environment} />
|
||||
</TableCell>
|
||||
<TableCell alignment="right">{environment.queued}</TableCell>
|
||||
<TableCell alignment="right">{environment.concurrency}</TableCell>
|
||||
+12
-12
@@ -1,10 +1,10 @@
|
||||
import { Link, useLocation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { type 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 { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTimeAccurate } from "~/components/primitives/DateTime";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { DeploymentError } from "~/components/runs/v3/DeploymentError";
|
||||
import { DeploymentStatus } from "~/components/runs/v3/DeploymentStatus";
|
||||
import { TaskFunctionName } from "~/components/runs/v3/TaskPath";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
@@ -33,7 +34,8 @@ import { capitalizeWord } from "~/utils/string";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, deploymentParam } = v3DeploymentParams.parse(params);
|
||||
const { organizationSlug, projectParam, envParam, deploymentParam } =
|
||||
v3DeploymentParams.parse(params);
|
||||
|
||||
try {
|
||||
const presenter = new DeploymentPresenter();
|
||||
@@ -41,6 +43,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
deploymentShortCode: deploymentParam,
|
||||
});
|
||||
|
||||
@@ -55,16 +58,14 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { deployment } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const location = useLocation();
|
||||
const user = useUser();
|
||||
const { deployment } = useTypedLoaderData<typeof loader>();
|
||||
const page = new URLSearchParams(location.search).get("page");
|
||||
|
||||
const usernameForEnv =
|
||||
user.id !== deployment.environment.userId ? deployment.environment.userName : undefined;
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -107,7 +108,9 @@ export default function Page() {
|
||||
</AdminDebugTooltip>
|
||||
|
||||
<LinkButton
|
||||
to={`${v3DeploymentsPath(organization, project)}${page ? `?page=${page}` : ""}`}
|
||||
to={`${v3DeploymentsPath(organization, project, environment)}${
|
||||
page ? `?page=${page}` : ""
|
||||
}`}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
@@ -129,10 +132,7 @@ export default function Page() {
|
||||
<Property.Item>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentLabel
|
||||
environment={deployment.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
<EnvironmentCombo environment={deployment.environment} />
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
+22
-81
@@ -1,23 +1,17 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUturnLeftIcon,
|
||||
ArrowUturnRightIcon,
|
||||
BookOpenIcon,
|
||||
ServerStackIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { WorkerInstanceGroupType } from "@trigger.dev/database";
|
||||
import { ArrowPathIcon, ArrowUturnLeftIcon, BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { type MetaFunction, Outlet, useLocation, useParams } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { PromoteIcon } from "~/assets/icons/PromoteIcon";
|
||||
import { DeploymentsNone, DeploymentsNoneDev } from "~/components/BlankStatePanels";
|
||||
import { UserAvatar } from "~/components/UserProfilePhoto";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { MainCenteredContainer, PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { Dialog, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { InfoPanel } from "~/components/primitives/InfoPanel";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PaginationControls } from "~/components/primitives/Pagination";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
@@ -36,7 +30,6 @@ import {
|
||||
TableHeaderCell,
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import {
|
||||
DeploymentStatus,
|
||||
deploymentStatusDescription,
|
||||
@@ -47,20 +40,15 @@ import {
|
||||
PromoteDeploymentDialog,
|
||||
RollbackDeploymentDialog,
|
||||
} from "~/components/runs/v3/RollbackDeploymentDialog";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import {
|
||||
DeploymentListItem,
|
||||
type DeploymentListItem,
|
||||
DeploymentListPresenter,
|
||||
} from "~/presenters/v3/DeploymentListPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import {
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
v3DeploymentPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { EnvironmentParamSchema, docsPath, v3DeploymentPath } from "~/utils/pathBuilder";
|
||||
import { createSearchParams } from "~/utils/searchParams";
|
||||
import { deploymentIndexingIsRetryable } from "~/v3/deploymentStatus";
|
||||
import { compareDeploymentVersions } from "~/v3/utils/deploymentVersions";
|
||||
@@ -79,7 +67,7 @@ const SearchParams = z.object({
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const searchParams = createSearchParams(request.url, SearchParams);
|
||||
const page = searchParams.success ? searchParams.params.get("page") ?? 1 : 1;
|
||||
@@ -90,6 +78,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
userId,
|
||||
organizationSlug,
|
||||
projectSlug: projectParam,
|
||||
environmentSlug: envParam,
|
||||
page,
|
||||
});
|
||||
|
||||
@@ -106,7 +95,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const user = useUser();
|
||||
const environment = useEnvironment();
|
||||
const { deployments, currentPage, totalPages } = useTypedLoaderData<typeof loader>();
|
||||
const hasDeployments = totalPages > 0;
|
||||
|
||||
@@ -137,7 +126,6 @@ export default function Page() {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHeaderCell>Deploy</TableHeaderCell>
|
||||
<TableHeaderCell>Env</TableHeaderCell>
|
||||
<TableHeaderCell>Version</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
tooltip={
|
||||
@@ -172,13 +160,10 @@ export default function Page() {
|
||||
<TableBody>
|
||||
{deployments.length > 0 ? (
|
||||
deployments.map((deployment) => {
|
||||
const usernameForEnv =
|
||||
user.id !== deployment.environment.userId
|
||||
? deployment.environment.userName
|
||||
: undefined;
|
||||
const path = v3DeploymentPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
deployment,
|
||||
currentPage
|
||||
);
|
||||
@@ -193,12 +178,6 @@ export default function Page() {
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
<EnvironmentLabel
|
||||
environment={deployment.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell to={path} isSelected={isSelected}>
|
||||
{deployment.version}
|
||||
</TableCell>
|
||||
@@ -248,7 +227,7 @@ export default function Page() {
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableBlankRow colSpan={8}>
|
||||
<TableBlankRow colSpan={7}>
|
||||
<Paragraph className="flex items-center justify-center">
|
||||
No deploys match your filters
|
||||
</Paragraph>
|
||||
@@ -262,8 +241,14 @@ export default function Page() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : environment.type === "DEVELOPMENT" ? (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<DeploymentsNoneDev />
|
||||
</MainCenteredContainer>
|
||||
) : (
|
||||
<CreateDeploymentInstructions />
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<DeploymentsNone />
|
||||
</MainCenteredContainer>
|
||||
)}
|
||||
</ResizablePanel>
|
||||
|
||||
@@ -281,50 +266,6 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function CreateDeploymentInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-md">
|
||||
<InfoPanel
|
||||
icon={ServerStackIcon}
|
||||
iconClassName="text-blue-500"
|
||||
title="Deploy for the first time"
|
||||
panelClassName="max-w-full"
|
||||
>
|
||||
<Paragraph spacing variant="small">
|
||||
There are several ways to deploy your tasks. You can use the CLI, Continuous Integration
|
||||
(like GitHub Actions), or an integration with a service like Netlify or Vercel. Make sure
|
||||
you{" "}
|
||||
<TextLink href={v3EnvironmentVariablesPath(organization, project)}>
|
||||
set your environment variables
|
||||
</TextLink>{" "}
|
||||
first.
|
||||
</Paragraph>
|
||||
<div className="flex gap-3">
|
||||
<LinkButton
|
||||
to={docsPath("v3/cli-deploy")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with the CLI
|
||||
</LinkButton>
|
||||
<LinkButton
|
||||
to={docsPath("v3/github-actions")}
|
||||
variant="docs/medium"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
className="inline-flex"
|
||||
>
|
||||
Deploy with GitHub actions
|
||||
</LinkButton>
|
||||
</div>
|
||||
</InfoPanel>
|
||||
</MainCenteredContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function DeploymentActionsCell({
|
||||
deployment,
|
||||
path,
|
||||
@@ -386,7 +327,7 @@ function DeploymentActionsCell({
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
LeadingIcon={ArrowUturnRightIcon}
|
||||
LeadingIcon={PromoteIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
+22
-18
@@ -16,6 +16,8 @@ import { z } from "zod";
|
||||
import {
|
||||
environmentTextClassName,
|
||||
environmentTitle,
|
||||
EnvironmentCombo,
|
||||
EnvironmentLabel,
|
||||
} from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { CheckboxWithLabel } from "~/components/primitives/Checkbox";
|
||||
@@ -35,7 +37,12 @@ import { useProject } from "~/hooks/useProject";
|
||||
import { EnvironmentVariablesPresenter } from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { ProjectParamSchema, v3BillingPath, v3EnvironmentVariablesPath } from "~/utils/pathBuilder";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3BillingPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { EnvironmentVariablesRepository } from "~/v3/environmentVariables/environmentVariablesRepository.server";
|
||||
import { EnvironmentVariableKey } from "~/v3/environmentVariables/repository";
|
||||
import dotenv from "dotenv";
|
||||
@@ -47,6 +54,7 @@ import {
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "~/components/primitives/Tooltip";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
@@ -108,7 +116,7 @@ const schema = z.object({
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
@@ -160,7 +168,13 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
return json(submission);
|
||||
}
|
||||
|
||||
return redirect(v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }));
|
||||
return redirect(
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
@@ -171,6 +185,7 @@ export default function Page() {
|
||||
const navigate = useNavigate();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const isLoading = navigation.state !== "idle" && navigation.formMethod === "post";
|
||||
|
||||
@@ -195,7 +210,7 @@ export default function Page() {
|
||||
open={isOpen}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) {
|
||||
navigate(v3EnvironmentVariablesPath(organization, project));
|
||||
navigate(v3EnvironmentVariablesPath(organization, project, environment));
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -217,16 +232,7 @@ export default function Page() {
|
||||
value={environment.id}
|
||||
name="environmentIds"
|
||||
type="radio"
|
||||
label={
|
||||
<span
|
||||
className={cn(
|
||||
"text-xs uppercase tracking-wide",
|
||||
environmentTextClassName(environment)
|
||||
)}
|
||||
>
|
||||
{environmentTitle(environment)}
|
||||
</span>
|
||||
}
|
||||
label={<EnvironmentLabel environment={environment} className="text-sm" />}
|
||||
variant="button"
|
||||
/>
|
||||
))}
|
||||
@@ -239,9 +245,7 @@ export default function Page() {
|
||||
className="flex w-fit cursor-pointer items-center gap-2 rounded border border-dashed border-charcoal-600 py-3 pl-3 pr-4 transition hover:border-charcoal-500 hover:bg-charcoal-850"
|
||||
>
|
||||
<LockClosedIcon className="size-4 text-charcoal-500" />
|
||||
<Paragraph className="mt-0.5 text-xs uppercase tracking-wide text-staging">
|
||||
Staging
|
||||
</Paragraph>
|
||||
<EnvironmentLabel environment={{ type: "STAGING" }} className="text-sm" />
|
||||
</TextLink>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="flex items-center gap-2">
|
||||
@@ -305,7 +309,7 @@ export default function Page() {
|
||||
}
|
||||
cancelButton={
|
||||
<LinkButton
|
||||
to={v3EnvironmentVariablesPath(organization, project)}
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="tertiary/medium"
|
||||
>
|
||||
Cancel
|
||||
+27
-35
@@ -3,25 +3,23 @@ import { parse } from "@conform-to/zod";
|
||||
import {
|
||||
BookOpenIcon,
|
||||
InformationCircleIcon,
|
||||
LockOpenIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { ArrowUpCircleIcon } from "@heroicons/react/24/outline";
|
||||
import { Form, MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import { Form, type MetaFunction, Outlet, useActionData, useNavigation } from "@remix-run/react";
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
json,
|
||||
redirectDocument,
|
||||
} from "@remix-run/server-runtime";
|
||||
import { RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { type RuntimeEnvironment } from "@trigger.dev/database";
|
||||
import { Fragment, useState } from "react";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
@@ -35,7 +33,6 @@ import { Input } from "~/components/primitives/Input";
|
||||
import { InputGroup } from "~/components/primitives/InputGroup";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { NavBar, PageAccessories, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import {
|
||||
Table,
|
||||
@@ -47,19 +44,20 @@ import {
|
||||
TableRow,
|
||||
} from "~/components/primitives/Table";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithSuccessMessage } from "~/models/message.server";
|
||||
import {
|
||||
EnvironmentVariableWithSetValues,
|
||||
type EnvironmentVariableWithSetValues,
|
||||
EnvironmentVariablesPresenter,
|
||||
} from "~/presenters/v3/EnvironmentVariablesPresenter.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
docsPath,
|
||||
v3BillingPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3NewEnvironmentVariablesPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
@@ -109,7 +107,7 @@ const schema = z.discriminatedUnion("action", [
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam } = ProjectParamSchema.parse(params);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
if (request.method.toUpperCase() !== "POST") {
|
||||
return { status: 405, body: "Method Not Allowed" };
|
||||
@@ -154,7 +152,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
|
||||
//use redirectDocument because it reloads the page
|
||||
return redirectDocument(
|
||||
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
),
|
||||
{
|
||||
headers: {
|
||||
refresh: "true",
|
||||
@@ -172,7 +174,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
}
|
||||
|
||||
return redirectWithSuccessMessage(
|
||||
v3EnvironmentVariablesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3EnvironmentVariablesPath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam }
|
||||
),
|
||||
request,
|
||||
`Deleted ${submission.value.key} environment variable`
|
||||
);
|
||||
@@ -183,8 +189,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
export default function Page() {
|
||||
const [revealAll, setRevealAll] = useState(false);
|
||||
const { environmentVariables, environments, hasStaging } = useTypedLoaderData<typeof loader>();
|
||||
const project = useProject();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -211,7 +218,7 @@ export default function Page() {
|
||||
onCheckedChange={(e) => setRevealAll(e.valueOf())}
|
||||
/>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/small"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
@@ -225,8 +232,8 @@ export default function Page() {
|
||||
<TableRow>
|
||||
<TableHeaderCell>Key</TableHeaderCell>
|
||||
{environments.map((environment) => (
|
||||
<TableHeaderCell key={environment.id}>
|
||||
<EnvironmentLabel environment={environment} />
|
||||
<TableHeaderCell key={environment.id} className="pl-1">
|
||||
<EnvironmentCombo environment={environment} className="text-sm" />
|
||||
</TableHeaderCell>
|
||||
))}
|
||||
<TableHeaderCell hiddenLabel>Actions</TableHeaderCell>
|
||||
@@ -275,7 +282,7 @@ export default function Page() {
|
||||
<div className="flex flex-col items-center justify-center gap-y-4 py-8">
|
||||
<Header2>You haven't set any environment variables yet.</Header2>
|
||||
<LinkButton
|
||||
to={v3NewEnvironmentVariablesPath(organization, project)}
|
||||
to={v3NewEnvironmentVariablesPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={PlusIcon}
|
||||
shortcut={{ key: "n" }}
|
||||
@@ -289,26 +296,11 @@ export default function Page() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="z-10 -mt-px flex w-full flex-wrap justify-between border-t border-grid-dimmed">
|
||||
<div className="-mt-px w-full border-t border-grid-dimmed">
|
||||
<InfoPanel icon={InformationCircleIcon} variant="minimal" panelClassName="max-w-fit">
|
||||
Dev environment variables specified here will be overridden by ones in your .env file
|
||||
when running locally.
|
||||
</InfoPanel>
|
||||
{!hasStaging && (
|
||||
<div className="flex items-center gap-2 pl-3 pr-2">
|
||||
<LockOpenIcon className="size-5 min-w-5 text-indigo-500" />
|
||||
<Paragraph variant="small" className="text-text-bright">
|
||||
Upgrade to add a Staging environment
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3BillingPath(organization)}
|
||||
variant="secondary/small"
|
||||
LeadingIcon={ArrowUpCircleIcon}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Outlet />
|
||||
@@ -409,7 +401,7 @@ function EditEnvironmentVariablePanel({
|
||||
className="flex items-center justify-end"
|
||||
htmlFor={`values[${index}].value`}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} size="large" className="px-2" />
|
||||
<EnvironmentCombo environment={environment} className="text-sm" />
|
||||
</label>
|
||||
<Input
|
||||
name={`values[${index}].value`}
|
||||
+55
-50
@@ -12,14 +12,14 @@ import {
|
||||
StopCircleIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useLoaderData, useParams, useRevalidator } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs, SerializeFrom, json } from "@remix-run/server-runtime";
|
||||
import { Virtualizer } from "@tanstack/react-virtual";
|
||||
import { type LoaderFunctionArgs, type SerializeFrom, json } from "@remix-run/server-runtime";
|
||||
import { type Virtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
formatDurationMilliseconds,
|
||||
millisecondsToNanoseconds,
|
||||
nanosecondsToMilliseconds,
|
||||
} from "@trigger.dev/core/v3";
|
||||
import { RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { type RuntimeEnvironmentType } from "@trigger.dev/database";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
@@ -27,7 +27,7 @@ import { ShowParentIcon, ShowParentIconSelected } from "~/assets/icons/ShowParen
|
||||
import tileBgPath from "~/assets/images/error-banner-tile@2x.png";
|
||||
import { AdminDebugTooltip } from "~/components/admin/debugTooltip";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody } from "~/components/layout/AppLayout";
|
||||
import { Badge } from "~/components/primitives/Badge";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
@@ -51,8 +51,12 @@ import { Slider } from "~/components/primitives/Slider";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import * as Timeline from "~/components/primitives/Timeline";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { TreeView, UseTreeStateOutput, useTree } from "~/components/primitives/TreeView/TreeView";
|
||||
import { NodesState } from "~/components/primitives/TreeView/reducer";
|
||||
import {
|
||||
TreeView,
|
||||
type UseTreeStateOutput,
|
||||
useTree,
|
||||
} from "~/components/primitives/TreeView/TreeView";
|
||||
import { type NodesState } from "~/components/primitives/TreeView/reducer";
|
||||
import { CancelRunDialog } from "~/components/runs/v3/CancelRunDialog";
|
||||
import { ReplayRunDialog } from "~/components/runs/v3/ReplayRunDialog";
|
||||
import { RunIcon } from "~/components/runs/v3/RunIcon";
|
||||
@@ -64,14 +68,15 @@ import {
|
||||
import { TaskRunStatusIcon, runStatusClassNameColor } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { env } from "~/env.server";
|
||||
import { useDebounce } from "~/hooks/useDebounce";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useEventSource } from "~/hooks/useEventSource";
|
||||
import { useInitialDimensions } from "~/hooks/useInitialDimensions";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useReplaceSearchParams } from "~/hooks/useReplaceSearchParams";
|
||||
import { Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { useHasAdminAccess, useUser } from "~/hooks/useUser";
|
||||
import { Run, RunPresenter } from "~/presenters/v3/RunPresenter.server";
|
||||
import { type Shortcut, useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { RunPresenter } from "~/presenters/v3/RunPresenter.server";
|
||||
import { getImpersonationId } from "~/services/impersonation.server";
|
||||
import { getResizableSnapshot } from "~/services/resizablePanel.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
@@ -87,7 +92,10 @@ import {
|
||||
v3RunsPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.v3.$projectParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { SpanView } from "../resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.$runParam.spans.$spanParam/route";
|
||||
import { useDevPresence } from "~/components/DevPresence";
|
||||
import { DisconnectedIcon } from "~/assets/icons/ConnectionIcons";
|
||||
import { ClipboardField } from "~/components/primitives/ClipboardField";
|
||||
|
||||
const resizableSettings = {
|
||||
parent: {
|
||||
@@ -124,7 +132,7 @@ type TraceEvent = NonNullable<SerializeFrom<typeof loader>["trace"]>["events"][0
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const impersonationId = await getImpersonationId(request);
|
||||
const { projectParam, organizationSlug, runParam } = v3RunParamsSchema.parse(params);
|
||||
const { projectParam, organizationSlug, envParam, runParam } = v3RunParamsSchema.parse(params);
|
||||
|
||||
const presenter = new RunPresenter();
|
||||
const result = await presenter.call({
|
||||
@@ -154,30 +162,19 @@ type LoaderData = SerializeFrom<typeof loader>;
|
||||
|
||||
export default function Page() {
|
||||
const { run, trace, resizable, maximumLiveReloadingSetting } = useLoaderData<typeof loader>();
|
||||
const user = useUser();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
|
||||
const usernameForEnv = user.id !== run.environment.userId ? run.environment.userName : undefined;
|
||||
const environment = useEnvironment();
|
||||
|
||||
return (
|
||||
<>
|
||||
<NavBar>
|
||||
<PageTitle
|
||||
backButton={{
|
||||
to: v3RunsPath(organization, project),
|
||||
to: v3RunsPath(organization, project, environment),
|
||||
text: "Runs",
|
||||
}}
|
||||
title={
|
||||
<div className="flex items-center gap-3">
|
||||
<span>Run #{run.number}</span>
|
||||
<EnvironmentLabel
|
||||
size="large"
|
||||
environment={run.environment}
|
||||
userName={usernameForEnv}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
title={`Run #${run.number}`}
|
||||
/>
|
||||
<PageAccessories>
|
||||
<AdminDebugTooltip>
|
||||
@@ -218,6 +215,7 @@ export default function Page() {
|
||||
failedRedirect={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: run.friendlyId },
|
||||
{ spanId: run.spanId }
|
||||
)}
|
||||
@@ -235,6 +233,7 @@ export default function Page() {
|
||||
redirectPath={v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: run.friendlyId },
|
||||
{ spanId: run.spanId }
|
||||
)}
|
||||
@@ -267,6 +266,7 @@ export default function Page() {
|
||||
function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: LoaderData) {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { searchParams, replaceSearchParam } = useReplaceSearchParams();
|
||||
const selectedSpanId = searchParams.get("span") ?? undefined;
|
||||
|
||||
@@ -282,10 +282,13 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
}, 250);
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const streamedEvents = useEventSource(v3RunStreamingPath(organization, project, run), {
|
||||
event: "message",
|
||||
disabled: !shouldLiveReload,
|
||||
});
|
||||
const streamedEvents = useEventSource(
|
||||
v3RunStreamingPath(organization, project, environment, run),
|
||||
{
|
||||
event: "message",
|
||||
disabled: !shouldLiveReload,
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
if (streamedEvents !== null) {
|
||||
revalidator.revalidate();
|
||||
@@ -325,6 +328,7 @@ function TraceView({ run, trace, maximumLiveReloadingSetting, resizable }: Loade
|
||||
shouldLiveReload={shouldLiveReload}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
rootRun={run.rootTaskRun}
|
||||
isCompleted={run.completedAt !== null}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id={resizableSettings.parent.handleId} />
|
||||
@@ -452,6 +456,7 @@ type TasksTreeViewProps = {
|
||||
taskIdentifier: string;
|
||||
spanId: string;
|
||||
} | null;
|
||||
isCompleted: boolean;
|
||||
};
|
||||
|
||||
function TasksTreeView({
|
||||
@@ -466,6 +471,7 @@ function TasksTreeView({
|
||||
shouldLiveReload,
|
||||
maximumLiveReloadingSetting,
|
||||
rootRun,
|
||||
isCompleted,
|
||||
}: TasksTreeViewProps) {
|
||||
const isAdmin = useHasAdminAccess();
|
||||
const [filterText, setFilterText] = useState("");
|
||||
@@ -477,6 +483,8 @@ function TasksTreeView({
|
||||
const treeScrollRef = useRef<HTMLDivElement>(null);
|
||||
const timelineScrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const displayEvents = showDebug ? events : events.filter((event) => !event.data.isDebug);
|
||||
|
||||
const {
|
||||
nodes,
|
||||
getTreeProps,
|
||||
@@ -490,7 +498,7 @@ function TasksTreeView({
|
||||
scrollToNode,
|
||||
virtualizer,
|
||||
} = useTree({
|
||||
tree: showDebug ? events : events.filter((event) => !event.data.isDebug),
|
||||
tree: displayEvents,
|
||||
selectedId,
|
||||
// collapsedIds,
|
||||
onSelectedIdChanged,
|
||||
@@ -569,7 +577,7 @@ function TasksTreeView({
|
||||
nodes={nodes}
|
||||
getNodeProps={getNodeProps}
|
||||
getTreeProps={getTreeProps}
|
||||
renderNode={({ node, state }) => (
|
||||
renderNode={({ node, state, index }) => (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -636,9 +644,9 @@ function TasksTreeView({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{events.length === 1 && environmentType === "DEVELOPMENT" && (
|
||||
<ConnectedDevWarning />
|
||||
)}
|
||||
{!isCompleted &&
|
||||
environmentType === "DEVELOPMENT" &&
|
||||
index === displayEvents.length - 1 && <ConnectedDevWarning />}
|
||||
</>
|
||||
)}
|
||||
onScroll={(scrollTop) => {
|
||||
@@ -1049,6 +1057,7 @@ function ShowParentLink({
|
||||
const [mouseOver, setMouseOver] = useState(false);
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
const { spanParam } = useParams();
|
||||
|
||||
const span = spanId ? spanId : spanParam;
|
||||
@@ -1061,12 +1070,13 @@ function ShowParentLink({
|
||||
? v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{
|
||||
friendlyId: runFriendlyId,
|
||||
},
|
||||
{ spanId: span }
|
||||
)
|
||||
: v3RunPath(organization, project, {
|
||||
: v3RunPath(organization, project, environment, {
|
||||
friendlyId: runFriendlyId,
|
||||
})
|
||||
}
|
||||
@@ -1239,30 +1249,25 @@ function CurrentTimeIndicator({
|
||||
}
|
||||
|
||||
function ConnectedDevWarning() {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(true);
|
||||
}, 3000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const { isConnected } = useDevPresence();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center overflow-hidden pl-5 pr-2 transition-opacity duration-500",
|
||||
isVisible ? "opacity-100" : "h-0 opacity-0"
|
||||
isConnected ? "h-0 opacity-0" : "opacity-100"
|
||||
)}
|
||||
>
|
||||
<Callout variant="info" className="mt-2">
|
||||
<Callout
|
||||
variant="error"
|
||||
icon={<DisconnectedIcon className="size-5 shrink-0" />}
|
||||
className="mt-2"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Paragraph variant="small">
|
||||
Runs usually start within 1 second in{" "}
|
||||
<EnvironmentLabel environment={{ type: "DEVELOPMENT" }} />. Check you're running the
|
||||
CLI: <InlineCode className="whitespace-nowrap">npx trigger.dev@latest dev</InlineCode>
|
||||
<Paragraph variant="small" spacing>
|
||||
Your local dev server is not connectedr. Check you're running the CLI:
|
||||
</Paragraph>
|
||||
<ClipboardField variant="secondary/small" value="npx trigger.dev@latest dev" />
|
||||
</div>
|
||||
</Callout>
|
||||
</div>
|
||||
+30
-23
@@ -1,7 +1,7 @@
|
||||
import { ArrowPathIcon, StopCircleIcon } from "@heroicons/react/20/solid";
|
||||
import { BeakerIcon, BookOpenIcon } from "@heroicons/react/24/solid";
|
||||
import { Form, MetaFunction, useNavigation } from "@remix-run/react";
|
||||
import { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { Form, type MetaFunction, useNavigation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { IconCircleX } from "@tabler/icons-react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ListChecks, ListX } from "lucide-react";
|
||||
@@ -46,12 +46,16 @@ import { requireUserId } from "~/services/session.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
import {
|
||||
docsPath,
|
||||
EnvironmentParamSchema,
|
||||
ProjectParamSchema,
|
||||
v3ProjectPath,
|
||||
v3RunsPath,
|
||||
v3TestPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { ListPagination } from "../../components/ListPagination";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
@@ -63,7 +67,7 @@ export const meta: MetaFunction = () => {
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { projectParam, organizationSlug } = ProjectParamSchema.parse(params);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
@@ -74,11 +78,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
rootOnlyValue = await getRootOnlyFilterPreference(request);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
throw new Error("Environment not found");
|
||||
}
|
||||
|
||||
const s = {
|
||||
cursor: url.searchParams.get("cursor") ?? undefined,
|
||||
direction: url.searchParams.get("direction") ?? undefined,
|
||||
statuses: url.searchParams.getAll("statuses"),
|
||||
environments: url.searchParams.getAll("environments"),
|
||||
environments: [environment.id],
|
||||
tasks: url.searchParams.getAll("tasks"),
|
||||
period: url.searchParams.get("period") ?? undefined,
|
||||
bulkId: url.searchParams.get("bulkId") ?? undefined,
|
||||
@@ -108,12 +122,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
scheduleId,
|
||||
} = TaskRunListSearchFilters.parse(s);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
|
||||
if (!project) {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const presenter = new RunListPresenter();
|
||||
const list = presenter.call({
|
||||
userId,
|
||||
@@ -155,7 +163,6 @@ export default function Page() {
|
||||
const { data, rootOnlyDefault } = useTypedLoaderData<typeof loader>();
|
||||
const navigation = useNavigation();
|
||||
const isLoading = navigation.state !== "idle";
|
||||
const project = useProject();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -210,7 +217,6 @@ export default function Page() {
|
||||
>
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<RunsFilters
|
||||
possibleEnvironments={project.environments}
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
@@ -313,7 +319,8 @@ function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const failedRedirect = v3RunsPath(organization, project);
|
||||
const environment = useEnvironment();
|
||||
const failedRedirect = v3RunsPath(organization, project, environment);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/cancel`;
|
||||
|
||||
@@ -336,16 +343,15 @@ function CancelRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Cancel {selectedItems.size} runs?</DialogHeader>
|
||||
<DialogDescription className="pt-2">
|
||||
<Paragraph>
|
||||
Canceling these runs will stop them from running. Only runs that are not already
|
||||
finished will be canceled, the others will remain in their existing state.
|
||||
</Paragraph>
|
||||
Canceling these runs will stop them from running. Only runs that are not already finished
|
||||
will be canceled, the others will remain in their existing state.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
<input type="hidden" name="environmentSlug" value={environment.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
@@ -370,7 +376,8 @@ function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const failedRedirect = v3RunsPath(organization, project);
|
||||
const environment = useEnvironment();
|
||||
const failedRedirect = v3RunsPath(organization, project, environment);
|
||||
|
||||
const formAction = `/resources/taskruns/bulk/replay`;
|
||||
|
||||
@@ -393,16 +400,15 @@ function ReplayRuns({ onOpen }: { onOpen: (open: boolean) => void }) {
|
||||
<DialogContent key="replay">
|
||||
<DialogHeader>Replay runs?</DialogHeader>
|
||||
<DialogDescription className="pt-2">
|
||||
<Paragraph>
|
||||
Replaying these runs will create a new run for each with the same payload and
|
||||
environment as the original. It will use the latest version of the code for each task.
|
||||
</Paragraph>
|
||||
Replaying these runs will create a new run for each with the same payload and environment
|
||||
as the original. It will use the latest version of the code for each task.
|
||||
</DialogDescription>
|
||||
<DialogFooter>
|
||||
<Form action={formAction} method="post" reloadDocument>
|
||||
<input type="hidden" name="failedRedirect" value={failedRedirect} />
|
||||
<input type="hidden" name="organizationSlug" value={organization.slug} />
|
||||
<input type="hidden" name="projectSlug" value={project.slug} />
|
||||
<input type="hidden" name="environmentSlug" value={environment.slug} />
|
||||
{[...selectedItems].map((runId) => (
|
||||
<input key={runId} type="hidden" name="runIds" value={runId} />
|
||||
))}
|
||||
@@ -448,6 +454,7 @@ function CreateFirstTaskInstructions() {
|
||||
function RunTaskInstructions() {
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
return (
|
||||
<MainCenteredContainer className="max-w-prose">
|
||||
<Header1 className="mb-6 border-b py-2">How to run your tasks</Header1>
|
||||
@@ -458,7 +465,7 @@ function RunTaskInstructions() {
|
||||
page.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
to={v3TestPath(organization, project)}
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
variant="primary/medium"
|
||||
LeadingIcon={BeakerIcon}
|
||||
className="inline-flex"
|
||||
+20
-7
@@ -13,7 +13,7 @@ import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { InlineCode } from "~/components/code/InlineCode";
|
||||
import { EnvironmentLabels } from "~/components/environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo } from "~/components/environments/EnvironmentLabel";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import {
|
||||
@@ -40,6 +40,7 @@ import { EnabledStatus } from "~/components/runs/v3/EnabledStatus";
|
||||
import { ScheduleTypeCombo } from "~/components/runs/v3/ScheduleType";
|
||||
import { TaskRunsTable } from "~/components/runs/v3/TaskRunsTable";
|
||||
import { prisma } from "~/db.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { redirectWithErrorMessage, redirectWithSuccessMessage } from "~/models/message.server";
|
||||
@@ -95,7 +96,8 @@ const schema = z.discriminatedUnion("action", [
|
||||
|
||||
export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, scheduleParam } = v3ScheduleParams.parse(params);
|
||||
const { organizationSlug, projectParam, envParam, scheduleParam } =
|
||||
v3ScheduleParams.parse(params);
|
||||
|
||||
const formData = await request.formData();
|
||||
const submission = parse(formData, { schema });
|
||||
@@ -115,6 +117,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
@@ -132,7 +135,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
friendlyId: scheduleParam,
|
||||
});
|
||||
return redirectWithSuccessMessage(
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }),
|
||||
v3SchedulesPath({ slug: organizationSlug }, { slug: projectParam }, { slug: envParam }),
|
||||
request,
|
||||
`${scheduleParam} deleted`
|
||||
);
|
||||
@@ -141,6 +144,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
@@ -165,6 +169,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
@@ -175,6 +180,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
v3SchedulePath(
|
||||
{ slug: organizationSlug },
|
||||
{ slug: projectParam },
|
||||
{ slug: envParam },
|
||||
{ friendlyId: scheduleParam }
|
||||
),
|
||||
request,
|
||||
@@ -200,6 +206,7 @@ export default function Page() {
|
||||
const location = useLocation();
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
const isUtc = schedule.timezone === "UTC";
|
||||
|
||||
@@ -215,7 +222,7 @@ export default function Page() {
|
||||
<div className="mx-3 flex items-center justify-between gap-2 border-b border-grid-dimmed">
|
||||
<Header2 className={cn("whitespace-nowrap")}>{schedule.friendlyId}</Header2>
|
||||
<LinkButton
|
||||
to={`${v3SchedulesPath(organization, project)}${location.search}`}
|
||||
to={`${v3SchedulesPath(organization, project, environment)}${location.search}`}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
@@ -255,9 +262,13 @@ export default function Page() {
|
||||
<Property.Value>{schedule.timezone}</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item className="gap-1">
|
||||
<Property.Label>Environments</Property.Label>
|
||||
<Property.Label>Environment</Property.Label>
|
||||
<Property.Value>
|
||||
<EnvironmentLabels size="small" environments={schedule.environments} />
|
||||
<div className="flex flex-col gap-2">
|
||||
{schedule.environments.map((env) => (
|
||||
<EnvironmentCombo key={env.id} environment={env} className="text-xs" />
|
||||
))}
|
||||
</div>
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
{isImperative && (
|
||||
@@ -408,7 +419,9 @@ export default function Page() {
|
||||
<div className="flex items-center gap-4">
|
||||
<LinkButton
|
||||
variant="tertiary/medium"
|
||||
to={`${v3EditSchedulePath(organization, project, schedule)}${location.search}`}
|
||||
to={`${v3EditSchedulePath(organization, project, environment, schedule)}${
|
||||
location.search
|
||||
}`}
|
||||
LeadingIcon={PencilSquareIcon}
|
||||
>
|
||||
Edit schedule
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user