Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e0998e6049 | |||
| e536d35b17 | |||
| b96a0b70d4 | |||
| 3bb9aac014 | |||
| 283f88b203 | |||
| c55af7bead | |||
| db4fb9eeef | |||
| f99c780c28 | |||
| 171c50f72a | |||
| dcd73f129e | |||
| c0595700f8 | |||
| 6a45f5623b | |||
| 104f720f6f | |||
| e017913021 | |||
| 7781e2aad1 | |||
| 8e0034484c | |||
| b72cacc671 | |||
| 1ccb8c186f | |||
| 279102c17c | |||
| b221719c09 | |||
| e6861f4fe4 | |||
| bc7ce78103 | |||
| 9937823a7f | |||
| 3925f8cc49 | |||
| 01208fde27 | |||
| 5e049cde3a | |||
| 72c357125b | |||
| 0674d74bbb | |||
| 9e08712749 | |||
| f53db6fd16 | |||
| c0b86efbd3 | |||
| e29e1c86d9 | |||
| 2066843998 | |||
| 34203d6a6f | |||
| d4e4fbd7fc | |||
| 49de105862 | |||
| 5fb9cc36bc | |||
| 70c8d6d14b | |||
| eeab6bdeac | |||
| 825219a2f4 | |||
| b143027d95 | |||
| 409388365e | |||
| fe5178f3e8 | |||
| a3f1eb2361 | |||
| ab4b50b95a | |||
| 1859fd0283 | |||
| 12f508a949 | |||
| fd46381c8b | |||
| a2277d5a37 | |||
| b4d6d7859e |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Add `maxDelay` option to debounce feature. This allows setting a maximum time limit for how long a debounced run can be delayed, ensuring execution happens within a specified window even with continuous triggers.
|
||||
|
||||
```typescript
|
||||
await myTask.trigger(payload, {
|
||||
debounce: {
|
||||
key: "my-key",
|
||||
delay: "5s",
|
||||
maxDelay: "30m", // Execute within 30 minutes regardless of continuous triggers
|
||||
},
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/react-hooks": patch
|
||||
---
|
||||
|
||||
Fix `onComplete` callback firing prematurely when the realtime stream disconnects before the run finishes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Aligned the SDK's `getRunIdForOptions` logic with the Core package to handle semantic targets (`root`, `parent`) in root tasks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@trigger.dev/sdk": patch
|
||||
---
|
||||
|
||||
Export `AnyOnStartAttemptHookFunction` type to allow defining `onStartAttempt` hooks for individual tasks.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"trigger.dev": patch
|
||||
---
|
||||
|
||||
Fix runner getting stuck indefinitely when `execute()` is called on a dead child process.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@trigger.dev/core": patch
|
||||
---
|
||||
|
||||
fix: vendor superjson to fix ESM/CJS compatibility
|
||||
|
||||
Bundle superjson during build to avoid `ERR_REQUIRE_ESM` errors on Node.js versions that don't support `require(ESM)` by default (< 22.12.0) and AWS Lambda which intentionally disables it.
|
||||
@@ -29,3 +29,7 @@ jobs:
|
||||
with:
|
||||
package: cli-v3
|
||||
secrets: inherit
|
||||
|
||||
sdk-compat:
|
||||
uses: ./.github/workflows/sdk-compat.yml
|
||||
secrets: inherit
|
||||
|
||||
@@ -122,7 +122,6 @@ jobs:
|
||||
package_version=$(echo '${{ steps.changesets.outputs.publishedPackages }}' | jq -r '.[0].version')
|
||||
echo "package_version=${package_version}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# this triggers the publish workflow for the docker images
|
||||
- name: Create and push Docker tag
|
||||
if: steps.changesets.outputs.published == 'true'
|
||||
run: |
|
||||
@@ -130,6 +129,17 @@ jobs:
|
||||
git tag "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
git push origin "v.docker.${{ steps.get_version.outputs.package_version }}"
|
||||
|
||||
# Trigger Docker builds directly via workflow_call since tags pushed with
|
||||
# GITHUB_TOKEN don't trigger other workflows (GitHub Actions limitation).
|
||||
publish-docker:
|
||||
name: 🐳 Publish Docker images
|
||||
needs: release
|
||||
if: needs.release.outputs.published == 'true'
|
||||
uses: ./.github/workflows/publish.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
image_tag: v${{ needs.release.outputs.published_package_version }}
|
||||
|
||||
# The prerelease job needs to be on the same workflow file due to a limitation related to how npm verifies OIDC claims.
|
||||
prerelease:
|
||||
name: 🧪 Prerelease
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
name: "🔌 SDK Compatibility Tests"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
node-compat:
|
||||
name: "Node.js ${{ matrix.node }} (${{ matrix.os }})"
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node: ["20.20", "22.12"]
|
||||
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk^...'
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
shell: bash
|
||||
run: pnpm run build --filter '@trigger.dev/sdk'
|
||||
|
||||
- name: 🧪 Run SDK Compatibility Tests
|
||||
shell: bash
|
||||
run: pnpm --filter @internal/sdk-compat-tests test
|
||||
|
||||
bun-compat:
|
||||
name: "Bun Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🥟 Setup Bun
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🧪 Run Bun Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/bun
|
||||
run: bun run test.ts
|
||||
|
||||
deno-compat:
|
||||
name: "Deno Runtime"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 🦕 Setup Deno
|
||||
uses: denoland/setup-deno@v2
|
||||
with:
|
||||
deno-version: v2.x
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 🔗 Link node_modules for Deno fixture
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: ln -s ../../../../../node_modules node_modules
|
||||
|
||||
- name: 🧪 Run Deno Compatibility Test
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/deno
|
||||
run: deno run --allow-read --allow-env --allow-sys test.ts
|
||||
|
||||
cloudflare-compat:
|
||||
name: "Cloudflare Workers"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: ⬇️ Checkout repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: ⎔ Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.23.0
|
||||
|
||||
- name: ⎔ Setup node
|
||||
uses: buildjet/setup-node@v4
|
||||
with:
|
||||
node-version: 20.20.0
|
||||
cache: "pnpm"
|
||||
|
||||
- name: 📥 Download deps
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: 📀 Generate Prisma Client
|
||||
run: pnpm run generate
|
||||
|
||||
- name: 🔨 Build SDK dependencies
|
||||
run: pnpm run build --filter @trigger.dev/sdk^...
|
||||
|
||||
- name: 🔨 Build SDK
|
||||
run: pnpm run build --filter @trigger.dev/sdk
|
||||
|
||||
- name: 📥 Install Cloudflare fixture deps
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: pnpm install
|
||||
|
||||
- name: 🧪 Run Cloudflare Workers Compatibility Test (dry-run)
|
||||
working-directory: internal-packages/sdk-compat-tests/src/fixtures/cloudflare-worker
|
||||
run: npx wrangler deploy --dry-run --outdir dist
|
||||
@@ -15,6 +15,9 @@ out/
|
||||
dist
|
||||
packages/**/dist
|
||||
|
||||
# vendored bundles (generated during build)
|
||||
packages/**/src/**/vendor
|
||||
|
||||
# Tailwind
|
||||
apps/**/styles/tailwind.css
|
||||
packages/**/styles/tailwind.css
|
||||
|
||||
@@ -35,7 +35,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter coordinator build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter coordinator build:bundle
|
||||
|
||||
FROM alpine AS cri-tools
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter docker-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter docker-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ COPY --from=pruner --chown=node:node /app/out/full/ .
|
||||
COPY --from=dev-deps --chown=node:node /app/ .
|
||||
COPY --chown=node:node turbo.json turbo.json
|
||||
|
||||
RUN pnpm run -r --filter kubernetes-provider build:bundle
|
||||
RUN pnpm run -r --filter @trigger.dev/core bundle-vendor && pnpm run -r --filter kubernetes-provider build:bundle
|
||||
|
||||
FROM base AS runner
|
||||
|
||||
|
||||
@@ -112,6 +112,11 @@ const Env = z.object({
|
||||
KUBERNETES_SCHEDULER_NAME: z.string().optional(), // Custom scheduler name for pods
|
||||
KUBERNETES_LARGE_MACHINE_POOL_LABEL: z.string().optional(), // if set, large-* presets affinity for machinepool=<value>
|
||||
|
||||
// Project affinity settings - pods from the same project prefer the same node
|
||||
KUBERNETES_PROJECT_AFFINITY_ENABLED: BoolEnv.default(false),
|
||||
KUBERNETES_PROJECT_AFFINITY_WEIGHT: z.coerce.number().int().min(1).max(100).default(50),
|
||||
KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY: z.string().trim().min(1).default("kubernetes.io/hostname"),
|
||||
|
||||
// Placement tags settings
|
||||
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
|
||||
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),
|
||||
|
||||
@@ -120,7 +120,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
},
|
||||
spec: {
|
||||
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
|
||||
affinity: this.#getNodeAffinity(opts.machine),
|
||||
affinity: this.#getAffinity(opts.machine, opts.projectId),
|
||||
terminationGracePeriodSeconds: 60 * 60,
|
||||
containers: [
|
||||
{
|
||||
@@ -390,7 +390,21 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
return preset.name.startsWith("large-");
|
||||
}
|
||||
|
||||
#getNodeAffinity(preset: MachinePreset): k8s.V1Affinity | undefined {
|
||||
#getAffinity(preset: MachinePreset, projectId: string): k8s.V1Affinity | undefined {
|
||||
const nodeAffinity = this.#getNodeAffinityRules(preset);
|
||||
const podAffinity = this.#getProjectPodAffinity(projectId);
|
||||
|
||||
if (!nodeAffinity && !podAffinity) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(nodeAffinity && { nodeAffinity }),
|
||||
...(podAffinity && { podAffinity }),
|
||||
};
|
||||
}
|
||||
|
||||
#getNodeAffinityRules(preset: MachinePreset): k8s.V1NodeAffinity | undefined {
|
||||
if (!env.KUBERNETES_LARGE_MACHINE_POOL_LABEL) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -398,42 +412,64 @@ export class KubernetesWorkloadManager implements WorkloadManager {
|
||||
if (this.#isLargeMachine(preset)) {
|
||||
// soft preference for the large-machine pool, falls back to standard if unavailable
|
||||
return {
|
||||
nodeAffinity: {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: 100,
|
||||
preference: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "In",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// not schedulable in the large-machine pool
|
||||
return {
|
||||
nodeAffinity: {
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
requiredDuringSchedulingIgnoredDuringExecution: {
|
||||
nodeSelectorTerms: [
|
||||
{
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "node.cluster.x-k8s.io/machinepool",
|
||||
operator: "NotIn",
|
||||
values: [env.KUBERNETES_LARGE_MACHINE_POOL_LABEL],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
#getProjectPodAffinity(projectId: string): k8s.V1PodAffinity | undefined {
|
||||
if (!env.KUBERNETES_PROJECT_AFFINITY_ENABLED) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
preferredDuringSchedulingIgnoredDuringExecution: [
|
||||
{
|
||||
weight: env.KUBERNETES_PROJECT_AFFINITY_WEIGHT,
|
||||
podAffinityTerm: {
|
||||
labelSelector: {
|
||||
matchExpressions: [
|
||||
{
|
||||
key: "project",
|
||||
operator: "In",
|
||||
values: [projectId],
|
||||
},
|
||||
],
|
||||
},
|
||||
topologyKey: env.KUBERNETES_PROJECT_AFFINITY_TOPOLOGY_KEY,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
@@ -37,7 +38,7 @@ function useKapaWebsiteId() {
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
|
||||
export function AskAI() {
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
@@ -54,21 +55,23 @@ export function AskAI() {
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} />}
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -112,28 +115,39 @@ function AskAIProvider({ websiteId }: AskAIProviderProps) {
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "/", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="flex items-center gap-1 py-1.5 pl-2.5 pr-2 text-xs">
|
||||
Ask AI
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
fullWidth={isCollapsed}
|
||||
className={cn("h-full", isCollapsed && "justify-center")}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
|
||||
@@ -599,9 +599,9 @@ function DeploymentOnboardingSteps() {
|
||||
return (
|
||||
<PackageManagerProvider>
|
||||
<div className="mb-2 flex items-center justify-between border-b">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8" />
|
||||
<Header1>Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
||||
<EnvironmentIcon environment={environment} className="-ml-1 size-8 shrink-0" />
|
||||
<Header1 className="truncate">Deploy your tasks to {environmentFullTitle(environment)}</Header1>
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<SimpleTooltip
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { BookOpenIcon } from "@heroicons/react/20/solid";
|
||||
import { LinkButton } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
|
||||
export function LogLevelTooltipInfo() {
|
||||
return (
|
||||
<div className="flex max-w-xs flex-col gap-4 p-1 pb-2">
|
||||
<div>
|
||||
<Header3>Log Levels</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Structured logging helps you debug and monitor your tasks.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-blue-400">Info</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
General informational messages about task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-warning">Warn</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Warning messages indicating potential issues that don't prevent execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-error">Error</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Error messages for failures and exceptions during task execution.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-0.5">
|
||||
<Header3 className="text-charcoal-400">Debug</Header3>
|
||||
</div>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Detailed diagnostic information for development and debugging.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div className="border-t border-charcoal-700 pt-4">
|
||||
<Header3>Tracing & Spans</Header3>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Automatically track the flow of your code through task triggers, attempts, and HTTP
|
||||
requests. Create custom traces to monitor specific operations.
|
||||
</Paragraph>
|
||||
</div>
|
||||
<LinkButton
|
||||
to="https://trigger.dev/docs/logging#tracing-and-spans"
|
||||
variant="docs/small"
|
||||
LeadingIcon={BookOpenIcon}
|
||||
>
|
||||
Read docs
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,17 @@
|
||||
import { Keyboard } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Header3 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
SheetTrigger
|
||||
} from "./primitives/SheetV3";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { useState } from "react";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
|
||||
export function Shortcuts() {
|
||||
return (
|
||||
@@ -26,8 +25,8 @@ export function Shortcuts() {
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
shortcut={{ modifiers: ["shift"], key: "?", enabled: false }}
|
||||
className="gap-x-0 pl-0.5"
|
||||
iconSpacing="gap-x-0.5"
|
||||
className="gap-x-0 pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Shortcuts
|
||||
</Button>
|
||||
@@ -77,11 +76,16 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "enter" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Ask AI">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"], key: "/" }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter">
|
||||
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Toggle side menu">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"]}} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select filter">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
@@ -158,6 +162,37 @@ function ShortcutContent() {
|
||||
<ShortcutKey shortcut={{ key: "p" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Logs page</Header3>
|
||||
<Shortcut name="Filter by task">
|
||||
<ShortcutKey shortcut={{ key: "t" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by run ID">
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Filter by level">
|
||||
<ShortcutKey shortcut={{ key: "l" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Select log level">
|
||||
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
|
||||
<Paragraph variant="small" className="ml-1.5">
|
||||
to
|
||||
</Paragraph>
|
||||
<ShortcutKey shortcut={{ key: "4" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Close detail panel">
|
||||
<ShortcutKey shortcut={{ key: "esc" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Details tab">
|
||||
<ShortcutKey shortcut={{ key: "d" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="Run tab">
|
||||
<ShortcutKey shortcut={{ key: "r" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
<Shortcut name="View full run">
|
||||
<ShortcutKey shortcut={{ key: "v" }} variant="medium/bright" />
|
||||
</Shortcut>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<Header3>Schedules page</Header3>
|
||||
<Shortcut name="New schedule">
|
||||
|
||||
@@ -5,9 +5,10 @@ import { Chart } from "~/components/primitives/charts/ChartCompound";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import type { AggregationType, ChartConfiguration } from "./ChartConfigPanel";
|
||||
|
||||
// Color palette for chart series
|
||||
// Color palette for chart series - 30 distinct colors for large datasets
|
||||
const CHART_COLORS = [
|
||||
"#7655fd", // Primary purple
|
||||
// Primary colors
|
||||
"#7655fd", // Purple
|
||||
"#22c55e", // Green
|
||||
"#f59e0b", // Amber
|
||||
"#ef4444", // Red
|
||||
@@ -17,6 +18,28 @@ const CHART_COLORS = [
|
||||
"#14b8a6", // Teal
|
||||
"#f97316", // Orange
|
||||
"#6366f1", // Indigo
|
||||
// Extended palette
|
||||
"#84cc16", // Lime
|
||||
"#0ea5e9", // Sky
|
||||
"#f43f5e", // Rose
|
||||
"#a855f7", // Fuchsia
|
||||
"#eab308", // Yellow
|
||||
"#10b981", // Emerald
|
||||
"#3b82f6", // Blue
|
||||
"#d946ef", // Magenta
|
||||
"#78716c", // Stone
|
||||
"#facc15", // Gold
|
||||
// Additional distinct colors
|
||||
"#2dd4bf", // Turquoise
|
||||
"#fb923c", // Light orange
|
||||
"#a3e635", // Yellow-green
|
||||
"#38bdf8", // Light blue
|
||||
"#c084fc", // Light purple
|
||||
"#4ade80", // Light green
|
||||
"#fbbf24", // Light amber
|
||||
"#f472b6", // Light pink
|
||||
"#67e8f9", // Light cyan
|
||||
"#818cf8", // Light indigo
|
||||
];
|
||||
|
||||
function getSeriesColor(index: number): string {
|
||||
@@ -30,6 +53,8 @@ interface QueryResultsChartProps {
|
||||
fullLegend?: boolean;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
}
|
||||
|
||||
interface TransformedData {
|
||||
@@ -702,6 +727,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
config,
|
||||
fullLegend = false,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
}: QueryResultsChartProps) {
|
||||
const {
|
||||
xAxisColumn,
|
||||
@@ -872,6 +898,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
>
|
||||
<Chart.Bar
|
||||
xAxisProps={xAxisPropsForBar}
|
||||
@@ -896,6 +923,7 @@ export const QueryResultsChart = memo(function QueryResultsChart({
|
||||
minHeight="300px"
|
||||
fillContainer
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
>
|
||||
<Chart.Line
|
||||
xAxisProps={xAxisPropsForLine}
|
||||
|
||||
@@ -672,7 +672,9 @@ function EnvironmentCellValue({ value }: { value: string }) {
|
||||
}
|
||||
|
||||
function JSONCellValue({ value }: { value: unknown }) {
|
||||
const jsonString = JSON.stringify(value);
|
||||
// If the value is already a string (e.g., from a textColumn optimization),
|
||||
// use it directly without double-stringifying
|
||||
const jsonString = typeof value === "string" ? value : JSON.stringify(value);
|
||||
const isTruncated = jsonString.length > MAX_STRING_DISPLAY_LENGTH;
|
||||
|
||||
if (isTruncated) {
|
||||
@@ -1137,6 +1139,7 @@ export const TSQLResultsTable = memo(function TSQLResultsTable({
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
position: "relative",
|
||||
}}
|
||||
className="bg-background-dimmed divide-y divide-charcoal-700"
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||
const row = tableRows[virtualRow.index];
|
||||
|
||||
@@ -80,11 +80,13 @@ export function EnvironmentLabel({
|
||||
className,
|
||||
tooltipSideOffset = 34,
|
||||
tooltipSide = "right",
|
||||
disableTooltip = false,
|
||||
}: {
|
||||
environment: Environment;
|
||||
className?: string;
|
||||
tooltipSideOffset?: number;
|
||||
tooltipSide?: "top" | "right" | "bottom" | "left";
|
||||
disableTooltip?: boolean;
|
||||
}) {
|
||||
const spanRef = useRef<HTMLSpanElement>(null);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
@@ -117,7 +119,7 @@ export function EnvironmentLabel({
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
if (isTruncated && !disableTooltip) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
asChild
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { getLevelColor, getKindColor, getKindLabel } from "~/utils/logUtils";
|
||||
import { getLevelColor } from "~/utils/logUtils";
|
||||
import { v3RunSpanPath, v3RunsPath, v3DeploymentVersionPath } from "~/utils/pathBuilder";
|
||||
import type { loader as logDetailLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.logs.$logId";
|
||||
import { TaskRunStatusCombo, descriptionForTaskRunStatus } from "~/components/runs/v3/TaskRunStatus";
|
||||
@@ -94,16 +94,34 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
const isLoading = fetcher.state === "loading";
|
||||
const log = fetcher.data ?? initialLog;
|
||||
|
||||
// Handle Escape key to close panel
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log?.runId ?? "" },
|
||||
{ spanId: log?.spanId ?? "" }
|
||||
);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target && (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.tagName === "SELECT" ||
|
||||
target.contentEditable === "true"
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [onClose]);
|
||||
}, [onClose, log, runPath, isLoading]);
|
||||
|
||||
if (isLoading && !log) {
|
||||
return (
|
||||
@@ -129,36 +147,18 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
);
|
||||
}
|
||||
|
||||
const runPath = v3RunSpanPath(
|
||||
organization,
|
||||
project,
|
||||
environment,
|
||||
{ friendlyId: log.runId },
|
||||
{ spanId: log.spanId }
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium",
|
||||
getKindColor(log.kind)
|
||||
)}
|
||||
>
|
||||
{getKindLabel(log.kind)}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-b border-grid-dimmed px-2 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium uppercase tracking-wider",
|
||||
getLevelColor(log.level)
|
||||
)}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
<Button variant="minimal/small" onClick={onClose} shortcut={{ key: "esc" }}>
|
||||
<XMarkIcon className="size-5" />
|
||||
</Button>
|
||||
@@ -185,8 +185,8 @@ export function LogDetailView({ logId, initialLog, onClose, searchTerm }: LogDet
|
||||
</TabButton>
|
||||
</TabContainer>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="secondary/small" LeadingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View Full Run
|
||||
<Button variant="minimal/small" LeadingIcon={ArrowTopRightOnSquareIcon} shortcut={{ key: "v" }}>
|
||||
View full run
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { type ReactNode, useMemo } from "react";
|
||||
import { IconListTree } from "@tabler/icons-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
@@ -12,24 +11,20 @@ import {
|
||||
shortcutFromIndex,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider, appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import { appliedSummary } from "~/components/runs/v3/SharedFilters";
|
||||
import type { LogLevel } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { cn } from "~/utils/cn";
|
||||
|
||||
const allLogLevels: { level: LogLevel; label: string; color: string }[] = [
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "INFO", label: "Info", color: "text-blue-400" },
|
||||
{ level: "CANCELLED", label: "Cancelled", color: "text-charcoal-400" },
|
||||
{ level: "WARN", label: "Warning", color: "text-warning" },
|
||||
{ level: "ERROR", label: "Error", color: "text-error" },
|
||||
{ level: "DEBUG", label: "Debug", color: "text-charcoal-400" },
|
||||
{ level: "TRACE", label: "Trace", color: "text-charcoal-500" },
|
||||
];
|
||||
|
||||
function getAvailableLevels(showDebug: boolean): typeof allLogLevels {
|
||||
if (showDebug) {
|
||||
return allLogLevels;
|
||||
}
|
||||
return allLogLevels.filter((level) => level.level !== "DEBUG");
|
||||
// In the future we might add other levels or change which are available
|
||||
function getAvailableLevels(): typeof allLogLevels {
|
||||
return allLogLevels;
|
||||
}
|
||||
|
||||
function getLevelBadgeColor(level: LogLevel): string {
|
||||
@@ -42,10 +37,6 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
@@ -53,81 +44,50 @@ function getLevelBadgeColor(level: LogLevel): string {
|
||||
|
||||
const shortcut = { key: "l" };
|
||||
|
||||
export function LogsLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
export function LogsLevelFilter() {
|
||||
const { values } = useSearchParams();
|
||||
const selectedLevels = values("levels");
|
||||
const hasLevels = selectedLevels.length > 0 && selectedLevels.some((v) => v !== "");
|
||||
|
||||
if (hasLevels) {
|
||||
return <AppliedLevelFilter showDebug={showDebug} />;
|
||||
return <AppliedLevelFilter/>;
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<IconListTree className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by level"
|
||||
>
|
||||
Level
|
||||
</SelectTrigger>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
showDebug = false,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
showDebug?: boolean;
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ levels: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const availableLevels = getAvailableLevels(showDebug);
|
||||
const filtered = useMemo(() => {
|
||||
return availableLevels.filter((item) =>
|
||||
item.label.toLowerCase().includes(searchValue.toLowerCase())
|
||||
);
|
||||
}, [searchValue, availableLevels]);
|
||||
const availableLevels = getAvailableLevels();
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("levels")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder="Filter by level..." value={searchValue} />
|
||||
<SelectPopover className="min-w-0 max-w-[min(240px,var(--popover-available-width))]">
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
{availableLevels.map((item, index) => (
|
||||
<SelectItem
|
||||
key={item.level}
|
||||
value={item.level}
|
||||
@@ -149,7 +109,7 @@ function LevelDropdown({
|
||||
);
|
||||
}
|
||||
|
||||
function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
function AppliedLevelFilter() {
|
||||
const { values, del } = useSearchParams();
|
||||
const levels = values("levels");
|
||||
|
||||
@@ -158,25 +118,18 @@ function AppliedLevelFilter({ showDebug = false }: { showDebug?: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<ExclamationTriangleIcon className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
showDebug={showDebug}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
<LevelDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Level"
|
||||
icon={<IconListTree className="size-4" />}
|
||||
value={appliedSummary(levels)}
|
||||
onRemove={() => del(["levels", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
|
||||
const shortcut = { key: "r" };
|
||||
const shortcut = { key: "i" };
|
||||
|
||||
export function LogsRunIdFilter() {
|
||||
const { value } = useSearchParams();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { MagnifyingGlassIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
|
||||
@@ -52,7 +53,16 @@ export function LogsSearchInput() {
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="relative h-6 min-w-52">
|
||||
<motion.div
|
||||
initial={{ width: "auto" }}
|
||||
animate={{ width: isFocused && text.length > 0 ? "24rem" : "auto" }}
|
||||
transition={{
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 30,
|
||||
}}
|
||||
className="relative h-6 min-w-52"
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
@@ -80,7 +90,7 @@ export function LogsSearchInput() {
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{text.length > 0 && (
|
||||
<button
|
||||
|
||||
@@ -12,6 +12,7 @@ import { DateTime } from "../primitives/DateTime";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Spinner } from "../primitives/Spinner";
|
||||
import { TruncatedCopyableValue } from "../primitives/TruncatedCopyableValue";
|
||||
import { LogLevelTooltipInfo } from "~/components/LogLevelTooltipInfo";
|
||||
import {
|
||||
Table,
|
||||
TableBlankRow,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
type TableVariant,
|
||||
} from "../primitives/Table";
|
||||
import { PopoverMenuItem } from "~/components/primitives/Popover";
|
||||
import { Link } from "@remix-run/react";
|
||||
|
||||
type LogsTableProps = {
|
||||
logs: LogEntry[];
|
||||
@@ -37,25 +39,23 @@ type LogsTableProps = {
|
||||
onLogSelect?: (logId: string) => void;
|
||||
};
|
||||
|
||||
// Left border color for error highlighting
|
||||
function getLevelBorderColor(level: LogEntry["level"]): string {
|
||||
// Inner shadow for level highlighting (better scroll performance than border-l)
|
||||
function getLevelBoxShadow(level: LogEntry["level"]): string {
|
||||
switch (level) {
|
||||
case "ERROR":
|
||||
return "border-l-error";
|
||||
return "inset 2px 0 0 0 rgb(239, 68, 68)";
|
||||
case "WARN":
|
||||
return "border-l-warning";
|
||||
return "inset 2px 0 0 0 rgb(234, 179, 8)";
|
||||
case "INFO":
|
||||
return "border-l-blue-500";
|
||||
case "CANCELLED":
|
||||
return "border-l-charcoal-600";
|
||||
return "inset 2px 0 0 0 rgb(59, 130, 246)";
|
||||
case "DEBUG":
|
||||
case "TRACE":
|
||||
default:
|
||||
return "border-l-transparent hover:border-l-charcoal-800";
|
||||
return "none";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function LogsTable({
|
||||
logs,
|
||||
searchTerm,
|
||||
@@ -112,14 +112,19 @@ export function LogsTable({
|
||||
}, [hasMore, isLoadingMore, onLoadMore]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible">
|
||||
<div className="relative h-full overflow-auto border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<Table variant="compact/mono" containerClassName="overflow-visible" showTopBorder={false}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
<TableRow>
|
||||
<TableHeaderCell className="min-w-48 whitespace-nowrap">Time</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Run</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-32 whitespace-nowrap">Task</TableHeaderCell>
|
||||
<TableHeaderCell className="min-w-24 whitespace-nowrap">Level</TableHeaderCell>
|
||||
<TableHeaderCell
|
||||
className="min-w-24 whitespace-nowrap"
|
||||
tooltip={<LogLevelTooltipInfo />}
|
||||
>
|
||||
Level
|
||||
</TableHeaderCell>
|
||||
<TableHeaderCell className="w-full min-w-0">Message</TableHeaderCell>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -143,8 +148,7 @@ export function LogsTable({
|
||||
<TableRow
|
||||
key={log.id}
|
||||
className={cn(
|
||||
"cursor-pointer border-l-2 transition-colors",
|
||||
getLevelBorderColor(log.level),
|
||||
"cursor-pointer transition-colors",
|
||||
isSelected ? "bg-charcoal-750" : "hover:bg-charcoal-850"
|
||||
)}
|
||||
isSelected={isSelected}
|
||||
@@ -153,6 +157,9 @@ export function LogsTable({
|
||||
className="whitespace-nowrap tabular-nums"
|
||||
onClick={handleRowClick}
|
||||
hasAction
|
||||
style={{
|
||||
boxShadow: getLevelBoxShadow(log.level),
|
||||
}}
|
||||
>
|
||||
<DateTime date={log.startTime} />
|
||||
</TableCell>
|
||||
@@ -180,12 +187,11 @@ export function LogsTable({
|
||||
<TableCellMenu
|
||||
className="pl-32"
|
||||
hiddenButtons={
|
||||
<PopoverMenuItem
|
||||
openInNewTab={true}
|
||||
to={runPath}
|
||||
icon={ArrowTopRightOnSquareIcon}
|
||||
title="View Run"
|
||||
/>
|
||||
<Link to={runPath} target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="minimal/small" TrailingIcon={ArrowTopRightOnSquareIcon}>
|
||||
View run
|
||||
</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
</TableRow>
|
||||
@@ -196,12 +202,23 @@ export function LogsTable({
|
||||
</Table>
|
||||
{/* Infinite scroll trigger */}
|
||||
{hasMore && logs.length > 0 && (
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-4">
|
||||
{showLoadMoreSpinner && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={loadMoreRef} className="flex items-center justify-center py-12">
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-2",
|
||||
!showLoadMoreSpinner && "invisible"
|
||||
)}
|
||||
>
|
||||
<Spinner /> <span className="text-text-dimmed">Loading more…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Show all logs message */}
|
||||
{!hasMore && logs.length > 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-text-dimmed">Showing all {logs.length} logs</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TaskTriggerSource } from "@trigger.dev/database";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
import * as Ariakit from "@ariakit/react";
|
||||
import {
|
||||
ComboBox,
|
||||
SelectItem,
|
||||
SelectList,
|
||||
SelectPopover,
|
||||
SelectProvider,
|
||||
SelectTrigger,
|
||||
} from "~/components/primitives/Select";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { TaskTriggerSourceIcon } from "~/components/runs/v3/TaskTriggerSource";
|
||||
import { TaskIcon } from "~/assets/icons/TaskIcon";
|
||||
import { appliedSummary, FilterMenuProvider } from "~/components/runs/v3/SharedFilters";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
|
||||
const shortcut = { key: "t" };
|
||||
|
||||
type TaskOption = {
|
||||
slug: string;
|
||||
triggerSource: TaskTriggerSource;
|
||||
};
|
||||
|
||||
interface LogsTaskFilterProps {
|
||||
possibleTasks: TaskOption[];
|
||||
}
|
||||
|
||||
export function LogsTaskFilter({ possibleTasks }: LogsTaskFilterProps) {
|
||||
const { values, replace, del } = useSearchParams();
|
||||
const selectedTasks = values("tasks");
|
||||
|
||||
if (selectedTasks.length === 0 || selectedTasks.every((v) => v === "")) {
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<SelectTrigger
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
variant="secondary/small"
|
||||
shortcut={shortcut}
|
||||
tooltipTitle="Filter by task"
|
||||
>
|
||||
Tasks
|
||||
</SelectTrigger>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<FilterMenuProvider>
|
||||
{(search, setSearch) => (
|
||||
<TasksDropdown
|
||||
trigger={
|
||||
<Ariakit.Select render={<div className="group cursor-pointer focus-custom" />}>
|
||||
<AppliedFilter
|
||||
label="Task"
|
||||
icon={<TaskIcon className="size-4" />}
|
||||
value={appliedSummary(
|
||||
selectedTasks.map((v) => {
|
||||
const task = possibleTasks.find((task) => task.slug === v);
|
||||
return task ? task.slug : v;
|
||||
})
|
||||
)}
|
||||
onRemove={() => del(["tasks", "cursor", "direction"])}
|
||||
variant="secondary/small"
|
||||
/>
|
||||
</Ariakit.Select>
|
||||
}
|
||||
searchValue={search}
|
||||
clearSearchValue={() => setSearch("")}
|
||||
possibleTasks={possibleTasks}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksDropdown({
|
||||
trigger,
|
||||
clearSearchValue,
|
||||
searchValue,
|
||||
onClose,
|
||||
possibleTasks,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
clearSearchValue: () => void;
|
||||
searchValue: string;
|
||||
onClose?: () => void;
|
||||
possibleTasks: TaskOption[];
|
||||
}) {
|
||||
const { values, replace } = useSearchParams();
|
||||
|
||||
const handleChange = (values: string[]) => {
|
||||
clearSearchValue();
|
||||
replace({ tasks: values, cursor: undefined, direction: undefined });
|
||||
};
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return possibleTasks.filter((item) => {
|
||||
return item.slug.toLowerCase().includes(searchValue.toLowerCase());
|
||||
});
|
||||
}, [searchValue, possibleTasks]);
|
||||
|
||||
return (
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}}
|
||||
>
|
||||
<ComboBox placeholder={"Filter by task..."} value={searchValue} />
|
||||
<SelectList>
|
||||
{filtered.map((item, index) => (
|
||||
<SelectItem
|
||||
key={`${item.triggerSource}-${item.slug}`}
|
||||
value={item.slug}
|
||||
icon={
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
</SelectPopover>
|
||||
</SelectProvider>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,11 @@ import {
|
||||
personalAccessTokensPath,
|
||||
rootPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
|
||||
export function AccountSideMenu({ user }: { user: User }) {
|
||||
return (
|
||||
@@ -55,8 +56,9 @@ export function AccountSideMenu({ user }: { user: User }) {
|
||||
data-action="security"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ChevronRightIcon, Cog8ToothIcon } from "@heroicons/react/20/solid";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
@@ -9,19 +10,19 @@ import { useOrganization, type MatchedOrganization } from "~/hooks/useOrganizati
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { branchesPath, docsPath, v3BillingPath } from "~/utils/pathBuilder";
|
||||
import { EnvironmentCombo } from "../environments/EnvironmentLabel";
|
||||
import { EnvironmentCombo, EnvironmentIcon, EnvironmentLabel, environmentFullTitle } from "../environments/EnvironmentLabel";
|
||||
import { ButtonContent } from "../primitives/Buttons";
|
||||
import { Header2 } from "../primitives/Headers";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverSectionHeader,
|
||||
PopoverTrigger,
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { type SideMenuEnvironment, type SideMenuProject } from "./SideMenu";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
@@ -31,11 +32,13 @@ export function EnvironmentSelector({
|
||||
project,
|
||||
environment,
|
||||
className,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
organization: MatchedOrganization;
|
||||
project: SideMenuProject;
|
||||
environment: SideMenuEnvironment;
|
||||
className?: string;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
@@ -50,16 +53,48 @@ export function EnvironmentSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setIsMenuOpen(open)} open={isMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isMenuOpen}
|
||||
overflowHidden
|
||||
fullWidth
|
||||
className={cn("h-7 overflow-hidden py-1 pl-1.5", className)}
|
||||
>
|
||||
<EnvironmentCombo environment={environment} className="w-full text-2sm" />
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "justify-center pr-0.5" : "justify-between pr-1",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<EnvironmentIcon environment={environment} className="size-5 shrink-0" />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<EnvironmentLabel environment={environment} className="text-2sm" disableTooltip />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={environmentFullTitle(environment)}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
SignalIcon,
|
||||
StarIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { DiscordIcon, SlackIcon } from "@trigger.dev/companyicons";
|
||||
import { Fragment, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { Feedback } from "../Feedback";
|
||||
import { Shortcuts } from "../Shortcuts";
|
||||
import { StepContentContainer } from "../StepContentContainer";
|
||||
@@ -19,30 +22,85 @@ import { ClipboardField } from "../primitives/ClipboardField";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Icon } from "../primitives/Icon";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverSideMenuTrigger } from "../primitives/Popover";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../primitives/Popover";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { StepNumber } from "../primitives/StepNumber";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
|
||||
export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?: boolean }) {
|
||||
export function HelpAndFeedback({
|
||||
disableShortcut = false,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
disableShortcut?: boolean;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const [isHelpMenuOpen, setHelpMenuOpen] = useState(false);
|
||||
const currentPlan = useCurrentPlan();
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: disableShortcut ? undefined : { key: "h", enabledOnInputElements: false },
|
||||
action: (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setHelpMenuOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setHelpMenuOpen(open)}>
|
||||
<PopoverSideMenuTrigger
|
||||
isOpen={isHelpMenuOpen}
|
||||
shortcut={{ key: "h", enabledOnInputElements: false }}
|
||||
className="grow pr-2"
|
||||
disabled={disableShortcut}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<QuestionMarkCircleIcon className="size-4 text-success" />
|
||||
Help & Feedback
|
||||
</div>
|
||||
</PopoverSideMenuTrigger>
|
||||
<motion.div
|
||||
layout="position"
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={isCollapsed ? undefined : "flex-1"}
|
||||
>
|
||||
<Popover open={isHelpMenuOpen} onOpenChange={setHelpMenuOpen}>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center gap-1.5 rounded pl-[0.4375rem] pr-2 transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "w-full" : "w-full justify-between"
|
||||
)}
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<QuestionMarkCircleIcon className="size-5 min-w-5 shrink-0 text-success" />
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden whitespace-nowrap text-2sm text-text-bright transition-all duration-150",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[150px] opacity-100"
|
||||
)}
|
||||
>
|
||||
Help & Feedback
|
||||
</span>
|
||||
</span>
|
||||
<ShortcutKey
|
||||
className={cn(
|
||||
"size-4 flex-none transition-all duration-150",
|
||||
isCollapsed ? "hidden" : ""
|
||||
)}
|
||||
shortcut={{ key: "h" }}
|
||||
variant="medium/bright"
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={
|
||||
<span className="flex items-center gap-1">
|
||||
Help & Feedback
|
||||
<ShortcutKey shortcut={{ key: "h" }} variant="medium/bright" />
|
||||
</span>
|
||||
}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8 w-full"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[14rem] divide-y divide-grid-bright overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "top"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
>
|
||||
<Fragment>
|
||||
@@ -176,8 +234,9 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
button={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
className="pl-2"
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
leadingIconClassName="text-blue-500 pr-1"
|
||||
data-action="contact-us"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
@@ -189,6 +248,7 @@ export function HelpAndFeedback({ disableShortcut = false }: { disableShortcut?:
|
||||
</div>
|
||||
</Fragment>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</Popover>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import { Badge } from "../primitives/Badge";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
export type BuildInfo = {
|
||||
appVersion: string | undefined;
|
||||
@@ -144,8 +145,9 @@ export function OrganizationSettingsSideMenu({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between border-t border-grid-bright p-1">
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
BellAlertIcon,
|
||||
ChartBarIcon,
|
||||
ChevronRightIcon,
|
||||
CircleStackIcon,
|
||||
ClockIcon,
|
||||
Cog8ToothIcon,
|
||||
CogIcon,
|
||||
@@ -15,19 +14,20 @@ import {
|
||||
GlobeAmericasIcon,
|
||||
IdentificationIcon,
|
||||
KeyIcon,
|
||||
MagnifyingGlassCircleIcon,
|
||||
PencilSquareIcon,
|
||||
PlusIcon,
|
||||
RectangleStackIcon,
|
||||
ServerStackIcon,
|
||||
Squares2X2Icon,
|
||||
TableCellsIcon,
|
||||
UsersIcon,
|
||||
UsersIcon
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Link, useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { Link, useFetcher, useNavigation } from "@remix-run/react";
|
||||
import { LayoutGroup, motion } from "framer-motion";
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
|
||||
import { DropdownIcon } from "~/assets/icons/DropdownIcon";
|
||||
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
|
||||
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
|
||||
import { LogsIcon } from "~/assets/icons/LogsIcon";
|
||||
@@ -41,7 +41,9 @@ import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type MatchedOrganization } from "~/hooks/useOrganizations";
|
||||
import { type MatchedProject } from "~/hooks/useProject";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
|
||||
import { ShortcutKey } from "../primitives/ShortcutKey";
|
||||
import { type UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { useCurrentPlan } from "~/routes/_app.orgs.$organizationSlug/route";
|
||||
import { type FeedbackType } from "~/routes/resources.feedback";
|
||||
import { IncidentStatusPanel } from "~/routes/resources.incidents";
|
||||
@@ -78,6 +80,7 @@ import {
|
||||
v3UsagePath,
|
||||
v3WaitpointTokensPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
|
||||
@@ -87,24 +90,23 @@ import { Dialog, DialogTrigger } from "../primitives/Dialog";
|
||||
import { Paragraph } from "../primitives/Paragraph";
|
||||
import {
|
||||
Popover,
|
||||
PopoverArrowTrigger,
|
||||
PopoverContent,
|
||||
PopoverMenuItem,
|
||||
PopoverTrigger,
|
||||
PopoverTrigger
|
||||
} from "../primitives/Popover";
|
||||
import { TextLink } from "../primitives/TextLink";
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { SimpleTooltip, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../primitives/Tooltip";
|
||||
import { ShortcutsAutoOpen } from "../Shortcuts";
|
||||
import { UserProfilePhoto } from "../UserProfilePhoto";
|
||||
import { V4Badge } from "../V4Badge";
|
||||
import { EnvironmentSelector } from "./EnvironmentSelector";
|
||||
import { HelpAndFeedback } from "./HelpAndFeedbackPopover";
|
||||
import { SideMenuHeader } from "./SideMenuHeader";
|
||||
import { SideMenuItem } from "./SideMenuItem";
|
||||
import { SideMenuSection } from "./SideMenuSection";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
|
||||
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
|
||||
type SideMenuUser = Pick<UserWithDashboardPreferences, "email" | "admin" | "dashboardPreferences"> & {
|
||||
isImpersonating: boolean;
|
||||
};
|
||||
export type SideMenuProject = Pick<
|
||||
MatchedProject,
|
||||
"id" | "name" | "slug" | "version" | "environments" | "engine"
|
||||
@@ -130,6 +132,15 @@ export function SideMenu({
|
||||
}: SideMenuProps) {
|
||||
const borderRef = useRef<HTMLDivElement>(null);
|
||||
const [showHeaderDivider, setShowHeaderDivider] = useState(false);
|
||||
const [isCollapsed, setIsCollapsed] = useState(
|
||||
user.dashboardPreferences.sideMenu?.isCollapsed ?? false
|
||||
);
|
||||
const preferencesFetcher = useFetcher();
|
||||
const pendingPreferencesRef = useRef<{
|
||||
isCollapsed?: boolean;
|
||||
manageSectionCollapsed?: boolean;
|
||||
}>({});
|
||||
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const currentPlan = useCurrentPlan();
|
||||
const { isConnected } = useDevPresence();
|
||||
const isFreeUser = currentPlan?.v3Subscription?.isPaying === false;
|
||||
@@ -137,6 +148,84 @@ export function SideMenu({
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const featureFlags = useFeatureFlags();
|
||||
|
||||
const persistSideMenuPreferences = useCallback(
|
||||
(data: { isCollapsed?: boolean; manageSectionCollapsed?: boolean }) => {
|
||||
if (user.isImpersonating) return;
|
||||
|
||||
// Merge with any pending changes
|
||||
pendingPreferencesRef.current = {
|
||||
...pendingPreferencesRef.current,
|
||||
...data,
|
||||
};
|
||||
|
||||
// Clear existing timeout
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
|
||||
// Debounce the actual submission by 500ms
|
||||
debounceTimeoutRef.current = setTimeout(() => {
|
||||
const pending = pendingPreferencesRef.current;
|
||||
const formData = new FormData();
|
||||
if (pending.isCollapsed !== undefined) {
|
||||
formData.append("isCollapsed", String(pending.isCollapsed));
|
||||
}
|
||||
if (pending.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
}
|
||||
preferencesFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
pendingPreferencesRef.current = {};
|
||||
}, 500);
|
||||
},
|
||||
[user.isImpersonating, preferencesFetcher]
|
||||
);
|
||||
|
||||
// Flush pending preferences on unmount to avoid losing the last toggle
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeoutRef.current) {
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
if (user.isImpersonating) return;
|
||||
const pending = pendingPreferencesRef.current;
|
||||
if (pending.isCollapsed !== undefined || pending.manageSectionCollapsed !== undefined) {
|
||||
const formData = new FormData();
|
||||
if (pending.isCollapsed !== undefined) {
|
||||
formData.append("isCollapsed", String(pending.isCollapsed));
|
||||
}
|
||||
if (pending.manageSectionCollapsed !== undefined) {
|
||||
formData.append("manageSectionCollapsed", String(pending.manageSectionCollapsed));
|
||||
}
|
||||
preferencesFetcher.submit(formData, {
|
||||
method: "POST",
|
||||
action: "/resources/preferences/sidemenu",
|
||||
});
|
||||
pendingPreferencesRef.current = {};
|
||||
}
|
||||
};
|
||||
}, [preferencesFetcher, user.isImpersonating]);
|
||||
|
||||
const handleToggleCollapsed = () => {
|
||||
const newIsCollapsed = !isCollapsed;
|
||||
setIsCollapsed(newIsCollapsed);
|
||||
persistSideMenuPreferences({ isCollapsed: newIsCollapsed });
|
||||
};
|
||||
|
||||
const handleManageSectionToggle = useCallback(
|
||||
(collapsed: boolean) => {
|
||||
persistSideMenuPreferences({ manageSectionCollapsed: collapsed });
|
||||
},
|
||||
[persistSideMenuPreferences]
|
||||
);
|
||||
|
||||
useShortcutKeys({
|
||||
shortcut: { modifiers: ["mod"], key: "b", enabledOnInputElements: true },
|
||||
action: handleToggleCollapsed,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => {
|
||||
if (borderRef.current) {
|
||||
@@ -154,246 +243,306 @@ export function SideMenu({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-full grid-rows-[2.5rem_1fr_auto] overflow-hidden border-r border-grid-bright bg-background-bright transition"
|
||||
"relative h-full border-r border-grid-bright bg-background-bright transition-all duration-200",
|
||||
isCollapsed ? "w-[2.75rem]" : "w-56"
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center overflow-hidden border-b px-1 py-1 transition duration-300",
|
||||
showHeaderDivider ? "border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CollapseToggle isCollapsed={isCollapsed} onToggle={handleToggleCollapsed} />
|
||||
<div className="absolute inset-0 grid grid-cols-[100%] grid-rows-[2.5rem_1fr_auto] overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 items-center overflow-hidden border-b px-1 py-1 transition duration-300",
|
||||
showHeaderDivider || isCollapsed ? "border-grid-bright" : "border-transparent"
|
||||
)}
|
||||
>
|
||||
<div className={cn("min-w-0", !isCollapsed && "flex-1")}>
|
||||
<ProjectSelector
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
project={project}
|
||||
user={user}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</div>
|
||||
{isAdmin && !user.isImpersonating ? (
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<LinkButton variant="minimal/medium" to={adminPath()} TrailingIcon={UsersIcon} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className={"text-xs"}>
|
||||
Admin dashboard
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</CollapsibleElement>
|
||||
) : isAdmin && user.isImpersonating ? (
|
||||
<ImpersonationBanner />
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<ImpersonationBanner />
|
||||
</CollapsibleElement>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className="overflow-hidden overflow-y-auto pt-2 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className={cn(
|
||||
"min-h-0 overflow-y-auto pt-2",
|
||||
isCollapsed
|
||||
? "scrollbar-none"
|
||||
: "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="mb-6 flex w-full flex-col gap-4 overflow-hidden px-1">
|
||||
<div className="w-full space-y-1">
|
||||
<SideMenuHeader title={"Environment"} isCollapsed={isCollapsed} collapsedTitle="Env" />
|
||||
<div className="flex items-center">
|
||||
<EnvironmentSelector
|
||||
organization={organization}
|
||||
project={project}
|
||||
environment={environment}
|
||||
className="w-full"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{environment.type === "DEVELOPMENT" && project.engine === "V2" && (
|
||||
<Dialog>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="aspect-square h-7 p-1"
|
||||
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your dev server is connected"
|
||||
: "Your dev server is not connected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DevPresencePanel isConnected={isConnected} />
|
||||
</Dialog>
|
||||
<CollapsibleElement isCollapsed={isCollapsed}>
|
||||
<Dialog>
|
||||
<TooltipProvider disableHoverableContent={true}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="inline-flex">
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
className="aspect-square h-7 p-1"
|
||||
LeadingIcon={<ConnectionIcon isConnected={isConnected} />}
|
||||
/>
|
||||
</DialogTrigger>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className={"text-xs"}>
|
||||
{isConnected === undefined
|
||||
? "Checking connection..."
|
||||
: isConnected
|
||||
? "Your dev server is connected"
|
||||
: "Your dev server is not connected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<DevPresencePanel isConnected={isConnected} />
|
||||
</Dialog>
|
||||
</CollapsibleElement>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="w-full">
|
||||
<SideMenuItem
|
||||
name="Tasks"
|
||||
icon={TaskIconSmall}
|
||||
activeIconColor="text-tasks"
|
||||
inactiveIconColor="text-tasks"
|
||||
to={v3EnvironmentPath(organization, project, environment)}
|
||||
data-action="tasks"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Runs"
|
||||
icon={RunsIconExtraSmall}
|
||||
activeIconColor="text-runs"
|
||||
inactiveIconColor="text-runs"
|
||||
to={v3RunsPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Batches"
|
||||
icon={Squares2X2Icon}
|
||||
activeIconColor="text-batches"
|
||||
inactiveIconColor="text-batches"
|
||||
to={v3BatchesPath(organization, project, environment)}
|
||||
data-action="batches"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Schedules"
|
||||
icon={ClockIcon}
|
||||
activeIconColor="text-schedules"
|
||||
inactiveIconColor="text-schedules"
|
||||
to={v3SchedulesPath(organization, project, environment)}
|
||||
data-action="schedules"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Queues"
|
||||
icon={RectangleStackIcon}
|
||||
activeIconColor="text-queues"
|
||||
inactiveIconColor="text-queues"
|
||||
to={v3QueuesPath(organization, project, environment)}
|
||||
data-action="queues"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Waitpoint tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
inactiveIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Deployments"
|
||||
icon={ServerStackIcon}
|
||||
activeIconColor="text-deployments"
|
||||
inactiveIconColor="text-deployments"
|
||||
to={v3DeploymentsPath(organization, project, environment)}
|
||||
data-action="deployments"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasLogsPageAccess) && (
|
||||
<SideMenuItem
|
||||
name="Logs"
|
||||
icon={LogsIcon}
|
||||
activeIconColor="text-logs"
|
||||
inactiveIconColor="text-logs"
|
||||
to={v3LogsPath(organization, project, environment)}
|
||||
data-action="logs"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Test"
|
||||
icon={BeakerIcon}
|
||||
activeIconColor="text-tests"
|
||||
inactiveIconColor="text-tests"
|
||||
to={v3TestPath(organization, project, environment)}
|
||||
data-action="test"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{(user.admin || user.isImpersonating || featureFlags.hasQueryAccess) && (
|
||||
<SideMenuItem
|
||||
name="Query"
|
||||
icon={TableCellsIcon}
|
||||
activeIconColor="text-purple-500"
|
||||
inactiveIconColor="text-purple-500"
|
||||
to={queryPath(organization, project, environment)}
|
||||
data-action="query"
|
||||
badge={<AlphaBadge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SideMenuSection title="Waitpoints">
|
||||
<SideMenuItem
|
||||
name="Tokens"
|
||||
icon={WaitpointTokenIcon}
|
||||
activeIconColor="text-sky-500"
|
||||
to={v3WaitpointTokensPath(organization, project, environment)}
|
||||
badge={<V4Badge />}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
|
||||
<SideMenuSection title="Manage">
|
||||
<SideMenuSection
|
||||
title="Manage"
|
||||
isSideMenuCollapsed={isCollapsed}
|
||||
itemSpacingClassName="space-y-0"
|
||||
initialCollapsed={user.dashboardPreferences.sideMenu?.manageSectionCollapsed ?? false}
|
||||
onCollapseToggle={handleManageSectionToggle}
|
||||
>
|
||||
<SideMenuItem
|
||||
name="Bulk actions"
|
||||
icon={ListCheckedIcon}
|
||||
activeIconColor="text-bulkActions"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3BulkActionsPath(organization, project, environment)}
|
||||
data-action="bulk actions"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="API keys"
|
||||
icon={KeyIcon}
|
||||
activeIconColor="text-apiKeys"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ApiKeysPath(organization, project, environment)}
|
||||
data-action="api keys"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Environment variables"
|
||||
icon={IdentificationIcon}
|
||||
activeIconColor="text-environmentVariables"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3EnvironmentVariablesPath(organization, project, environment)}
|
||||
data-action="environment variables"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Alerts"
|
||||
icon={BellAlertIcon}
|
||||
activeIconColor="text-alerts"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectAlertsPath(organization, project, environment)}
|
||||
data-action="alerts"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Preview branches"
|
||||
icon={BranchEnvironmentIconSmall}
|
||||
activeIconColor="text-preview"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={branchesPath(organization, project, environment)}
|
||||
data-action="preview-branches"
|
||||
badge={<V4Badge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
{isManagedCloud && (
|
||||
<SideMenuItem
|
||||
name="Concurrency"
|
||||
icon={ConcurrencyIcon}
|
||||
activeIconColor="text-concurrency"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={concurrencyPath(organization, project, environment)}
|
||||
data-action="concurrency"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
)}
|
||||
<SideMenuItem
|
||||
name="Regions"
|
||||
icon={GlobeAmericasIcon}
|
||||
activeIconColor="text-regions"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={regionsPath(organization, project, environment)}
|
||||
data-action="regions"
|
||||
badge={<V4Badge />}
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Limits"
|
||||
icon={AdjustmentsHorizontalIcon}
|
||||
activeIconColor="text-limits"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={limitsPath(organization, project, environment)}
|
||||
data-action="limits"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
<SideMenuItem
|
||||
name="Project settings"
|
||||
icon={Cog8ToothIcon}
|
||||
activeIconColor="text-projectSettings"
|
||||
activeIconColor="text-text-bright"
|
||||
inactiveIconColor="text-text-dimmed"
|
||||
to={v3ProjectSettingsPath(organization, project, environment)}
|
||||
data-action="project-settings"
|
||||
isCollapsed={isCollapsed}
|
||||
/>
|
||||
</SideMenuSection>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<IncidentStatusPanel />
|
||||
<div className="flex flex-col gap-1 border-t border-grid-bright p-1">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<HelpAndAI />
|
||||
</div>
|
||||
<IncidentStatusPanel isCollapsed={isCollapsed} />
|
||||
<motion.div
|
||||
layout
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn("flex flex-col gap-1 border-t border-grid-bright p-1", isCollapsed && "items-center")}
|
||||
>
|
||||
<HelpAndAI isCollapsed={isCollapsed} />
|
||||
{isFreeUser && (
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
<CollapsibleHeight isCollapsed={isCollapsed}>
|
||||
<FreePlanUsage
|
||||
to={v3BillingPath(organization)}
|
||||
percentage={currentPlan.v3Usage.usagePercentage}
|
||||
/>
|
||||
</CollapsibleHeight>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -404,11 +553,13 @@ function ProjectSelector({
|
||||
organization,
|
||||
organizations,
|
||||
user,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
project: SideMenuProject;
|
||||
organization: MatchedOrganization;
|
||||
organizations: MatchedOrganization[];
|
||||
user: SideMenuUser;
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const currentPlan = useCurrentPlan();
|
||||
const [isOrgMenuOpen, setOrgMenuOpen] = useState(false);
|
||||
@@ -428,21 +579,50 @@ function ProjectSelector({
|
||||
|
||||
return (
|
||||
<Popover onOpenChange={(open) => setOrgMenuOpen(open)} open={isOrgMenuOpen}>
|
||||
<PopoverArrowTrigger
|
||||
isOpen={isOrgMenuOpen}
|
||||
overflowHidden
|
||||
className="h-8 w-full justify-between py-1 pl-1.5"
|
||||
>
|
||||
<span className="flex items-center gap-1.5 overflow-hidden">
|
||||
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
|
||||
<SelectorDivider />
|
||||
<span className="truncate text-2sm font-normal text-text-bright">
|
||||
{project.name ?? "Select a project"}
|
||||
</span>
|
||||
</span>
|
||||
</PopoverArrowTrigger>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger
|
||||
className={cn(
|
||||
"group flex h-8 items-center rounded pl-[0.4375rem] transition-colors hover:bg-charcoal-750",
|
||||
isCollapsed ? "justify-center pr-0.5" : "w-full justify-between pr-1"
|
||||
)}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
|
||||
<Avatar avatar={organization.avatar} size={1.25} orgName={organization.title} />
|
||||
<span
|
||||
className={cn(
|
||||
"flex min-w-0 items-center gap-1.5 overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[200px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<SelectorDivider />
|
||||
<span className="truncate text-2sm font-normal text-text-bright">
|
||||
{project.name ?? "Select a project"}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[16px] opacity-100"
|
||||
)}
|
||||
>
|
||||
<DropdownIcon className="size-4 min-w-4 text-text-dimmed transition group-hover:text-text-bright" />
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content={`${organization.title} / ${project.name ?? "Select a project"}`}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
hidden={!isCollapsed}
|
||||
buttonClassName="!h-8"
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
<PopoverContent
|
||||
className="min-w-[16rem] overflow-y-auto p-0 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
side={isCollapsed ? "right" : "bottom"}
|
||||
sideOffset={isCollapsed ? 8 : 4}
|
||||
align="start"
|
||||
style={{ maxHeight: `calc(var(--radix-popover-content-available-height) - 10vh)` }}
|
||||
>
|
||||
@@ -661,12 +841,190 @@ function SelectorDivider() {
|
||||
);
|
||||
}
|
||||
|
||||
function HelpAndAI() {
|
||||
/** Helper component that fades out but preserves width (collapses to 0 width) */
|
||||
function CollapsibleElement({
|
||||
isCollapsed,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback />
|
||||
<AskAI />
|
||||
</>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isCollapsed ? "max-w-0 opacity-0" : "max-w-[100px] opacity-100",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Helper component that fades out and collapses height completely */
|
||||
function CollapsibleHeight({
|
||||
isCollapsed,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"grid transition-all duration-200 ease-in-out",
|
||||
isCollapsed ? "grid-rows-[0fr] opacity-0" : "grid-rows-[1fr] opacity-100",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HelpAndAI({ isCollapsed }: { isCollapsed: boolean }) {
|
||||
return (
|
||||
<LayoutGroup>
|
||||
<div className={cn("flex w-full", isCollapsed ? "flex-col-reverse gap-1" : "items-center justify-between")}>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} />
|
||||
<AskAI isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
}
|
||||
|
||||
function AnimatedChevron({
|
||||
isHovering,
|
||||
isCollapsed,
|
||||
}: {
|
||||
isHovering: boolean;
|
||||
isCollapsed: boolean;
|
||||
}) {
|
||||
// When hovering and expanded: left chevron (pointing left to collapse)
|
||||
// When hovering and collapsed: right chevron (pointing right to expand)
|
||||
// When not hovering: straight vertical line
|
||||
|
||||
const getRotation = () => {
|
||||
if (!isHovering) return { top: 0, bottom: 0 };
|
||||
if (isCollapsed) {
|
||||
// Right chevron
|
||||
return { top: -17, bottom: 17 };
|
||||
} else {
|
||||
// Left chevron
|
||||
return { top: 17, bottom: -17 };
|
||||
}
|
||||
};
|
||||
|
||||
const { top, bottom } = getRotation();
|
||||
|
||||
// Calculate horizontal offset to keep chevron centered when rotated
|
||||
// Left chevron: translate left (-1.5px)
|
||||
// Right chevron: translate right (+1.5px)
|
||||
const getTranslateX = () => {
|
||||
if (!isHovering) return 0;
|
||||
return isCollapsed ? 1.5 : -1.5;
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.svg
|
||||
width="4"
|
||||
height="30"
|
||||
viewBox="0 0 4 30"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="pointer-events-none relative z-10 overflow-visible text-charcoal-600 group-hover:text-text-bright transition-colors"
|
||||
initial={false}
|
||||
animate={{
|
||||
x: getTranslateX(),
|
||||
}}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
>
|
||||
{/* Top segment */}
|
||||
<motion.line
|
||||
x1="2"
|
||||
y1="1.5"
|
||||
x2="2"
|
||||
y2="15"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
initial={false}
|
||||
animate={{
|
||||
rotate: top,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ transformOrigin: "2px 15px" }}
|
||||
/>
|
||||
{/* Bottom segment */}
|
||||
<motion.line
|
||||
x1="2"
|
||||
y1="15"
|
||||
x2="2"
|
||||
y2="28.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="3"
|
||||
strokeLinecap="round"
|
||||
initial={false}
|
||||
animate={{
|
||||
rotate: bottom,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
style={{ transformOrigin: "2px 15px" }}
|
||||
/>
|
||||
</motion.svg>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapseToggle({
|
||||
isCollapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
}) {
|
||||
const [isHovering, setIsHovering] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="absolute -right-3 top-1/2 z-10 -translate-y-1/2">
|
||||
{/* Vertical line to mask the side menu border */}
|
||||
<div className={cn(
|
||||
"pointer-events-none absolute left-1/2 top-1/2 h-10 w-px -translate-y-1/2 transition-colors duration-200",
|
||||
isHovering ? "bg-charcoal-750" : "bg-background-bright"
|
||||
)} />
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={isCollapsed ? "Expand side menu" : "Collapse side menu"}
|
||||
onClick={onToggle}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className={cn(
|
||||
"group flex h-12 w-6 items-center justify-center rounded-md text-text-dimmed transition-all duration-200 focus-custom",
|
||||
isHovering
|
||||
? "border border-grid-bright bg-background-bright shadow-md hover:bg-charcoal-750 hover:text-text-bright"
|
||||
: "border border-transparent bg-transparent"
|
||||
)}
|
||||
>
|
||||
<AnimatedChevron isHovering={isHovering} isCollapsed={isCollapsed} />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
||||
{isCollapsed ? "Expand" : "Collapse"}
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
import { useNavigation } from "@remix-run/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { Popover, PopoverContent, PopoverCustomTrigger } from "../primitives/Popover";
|
||||
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
|
||||
|
||||
export function SideMenuHeader({ title, children }: { title: string; children?: React.ReactNode }) {
|
||||
export function SideMenuHeader({
|
||||
title,
|
||||
children,
|
||||
isCollapsed = false,
|
||||
collapsedTitle,
|
||||
}: {
|
||||
title: string;
|
||||
children?: React.ReactNode;
|
||||
isCollapsed?: boolean;
|
||||
/** When provided, this text stays visible when collapsed and the rest fades out */
|
||||
collapsedTitle?: string;
|
||||
}) {
|
||||
const [isHeaderMenuOpen, setHeaderMenuOpen] = useState(false);
|
||||
const navigation = useNavigation();
|
||||
|
||||
@@ -11,9 +23,34 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
setHeaderMenuOpen(false);
|
||||
}, [navigation.location?.pathname]);
|
||||
|
||||
// If collapsedTitle is provided and title starts with it, split the title
|
||||
const hasCollapsedTitle = collapsedTitle && title.startsWith(collapsedTitle);
|
||||
const visiblePart = hasCollapsedTitle ? collapsedTitle : title;
|
||||
const fadingPart = hasCollapsedTitle ? title.slice(collapsedTitle.length) : "";
|
||||
|
||||
return (
|
||||
<div className="group flex items-center justify-between pl-1.5">
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<motion.div
|
||||
className="group flex h-4 items-center justify-between overflow-hidden pl-1.5"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: hasCollapsedTitle ? 1 : isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<h2 className="text-xs whitespace-nowrap">
|
||||
{visiblePart}
|
||||
{fadingPart && (
|
||||
<motion.span
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{fadingPart}
|
||||
</motion.span>
|
||||
)}
|
||||
</h2>
|
||||
{children !== undefined ? (
|
||||
<Popover onOpenChange={(open) => setHeaderMenuOpen(open)} open={isHeaderMenuOpen}>
|
||||
<PopoverCustomTrigger className="p-1">
|
||||
@@ -27,6 +64,6 @@ export function SideMenuHeader({ title, children }: { title: string; children?:
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : null}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { type AnchorHTMLAttributes, type ReactNode } from "react";
|
||||
import { Link } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { usePathName } from "~/hooks/usePathName";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { LinkButton } from "../primitives/Buttons";
|
||||
import { type RenderIcon } from "../primitives/Icon";
|
||||
import { type RenderIcon, Icon } from "../primitives/Icon";
|
||||
import { SimpleTooltip } from "../primitives/Tooltip";
|
||||
|
||||
export function SideMenuItem({
|
||||
icon,
|
||||
@@ -14,6 +16,7 @@ export function SideMenuItem({
|
||||
to,
|
||||
badge,
|
||||
target,
|
||||
isCollapsed = false,
|
||||
}: {
|
||||
icon?: RenderIcon;
|
||||
activeIconColor?: string;
|
||||
@@ -24,30 +27,67 @@ export function SideMenuItem({
|
||||
to: string;
|
||||
badge?: ReactNode;
|
||||
target?: AnchorHTMLAttributes<HTMLAnchorElement>["target"];
|
||||
isCollapsed?: boolean;
|
||||
}) {
|
||||
const pathName = usePathName();
|
||||
const isActive = pathName === to;
|
||||
|
||||
return (
|
||||
<LinkButton
|
||||
variant="small-menu-item"
|
||||
fullWidth
|
||||
textAlignLeft
|
||||
LeadingIcon={icon}
|
||||
leadingIconClassName={isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"}
|
||||
TrailingIcon={trailingIcon}
|
||||
trailingIconClassName={trailingIconClassName}
|
||||
to={to}
|
||||
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",
|
||||
isActive ? "bg-tertiary text-text-bright" : "group-hover:text-text-bright"
|
||||
)}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{name}
|
||||
<div className="flex items-center gap-1">{badge !== undefined && badge}</div>
|
||||
</div>
|
||||
</LinkButton>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<Link
|
||||
to={to}
|
||||
target={target}
|
||||
className={cn(
|
||||
"flex h-8 w-full items-center gap-2 overflow-hidden rounded pr-2 pl-[0.4375rem] text-text-bright transition-colors hover:bg-charcoal-750",
|
||||
isActive ? "bg-tertiary" : ""
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
icon={icon}
|
||||
className={cn(
|
||||
"size-5 shrink-0",
|
||||
isActive ? activeIconColor : inactiveIconColor ?? "text-text-dimmed"
|
||||
)}
|
||||
/>
|
||||
<motion.div
|
||||
className="flex min-w-0 flex-1 items-center justify-between overflow-hidden"
|
||||
initial={false}
|
||||
animate={{
|
||||
width: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2, ease: "easeOut" }}
|
||||
>
|
||||
<span className="truncate text-2sm">{name}</span>
|
||||
{badge && !isCollapsed && (
|
||||
<motion.div
|
||||
className="ml-1 flex shrink-0 items-center gap-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
{badge}
|
||||
</motion.div>
|
||||
)}
|
||||
{trailingIcon && !isCollapsed && (
|
||||
<Icon
|
||||
icon={trailingIcon}
|
||||
className={cn("ml-1 size-4 shrink-0", trailingIconClassName)}
|
||||
/>
|
||||
)}
|
||||
</motion.div>
|
||||
</Link>
|
||||
}
|
||||
content={name}
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
buttonClassName="!h-8 block w-full"
|
||||
hidden={!isCollapsed}
|
||||
asChild
|
||||
disableHoverableContent
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ type Props = {
|
||||
initialCollapsed?: boolean;
|
||||
onCollapseToggle?: (isCollapsed: boolean) => void;
|
||||
children: React.ReactNode;
|
||||
/** When true, hides the section header and shows only children */
|
||||
isSideMenuCollapsed?: boolean;
|
||||
itemSpacingClassName?: string;
|
||||
};
|
||||
|
||||
/** A collapsible section for the side menu
|
||||
@@ -17,6 +20,8 @@ export function SideMenuSection({
|
||||
initialCollapsed = false,
|
||||
onCollapseToggle,
|
||||
children,
|
||||
isSideMenuCollapsed = false,
|
||||
itemSpacingClassName = "space-y-px",
|
||||
}: Props) {
|
||||
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
|
||||
|
||||
@@ -27,22 +32,42 @@ export function SideMenuSection({
|
||||
}, [isCollapsed, onCollapseToggle]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
className="flex cursor-pointer items-center gap-1 rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={handleToggle}
|
||||
>
|
||||
<h2 className="text-xs">{title}</h2>
|
||||
<div className="w-full overflow-hidden">
|
||||
{/* Header container - stays in DOM to preserve height */}
|
||||
<div className="relative w-full">
|
||||
{/* Header - fades out when sidebar is collapsed */}
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
className="flex cursor-pointer items-center gap-1 overflow-hidden rounded-sm py-1 pl-1.5 text-text-dimmed transition hover:bg-charcoal-750 hover:text-text-bright"
|
||||
onClick={isSideMenuCollapsed ? undefined : handleToggle}
|
||||
style={{ cursor: isSideMenuCollapsed ? "default" : "pointer" }}
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
<h2 className="text-xs whitespace-nowrap">{title}</h2>
|
||||
<motion.div
|
||||
initial={isCollapsed}
|
||||
animate={{ rotate: isCollapsed ? -90 : 0 }}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<ToggleArrowIcon className="size-2" />
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
{/* Divider - absolutely positioned, visible when sidebar is collapsed but section is expanded */}
|
||||
<motion.div
|
||||
className="absolute left-2 right-2 top-1 h-px bg-charcoal-600"
|
||||
initial={false}
|
||||
animate={{
|
||||
opacity: isSideMenuCollapsed && !isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeOut" }}
|
||||
/>
|
||||
</div>
|
||||
<AnimatePresence initial={false}>
|
||||
<motion.div
|
||||
className="w-full"
|
||||
initial={isCollapsed ? "collapsed" : "expanded"}
|
||||
animate={isCollapsed ? "collapsed" : "expanded"}
|
||||
exit="collapsed"
|
||||
@@ -63,6 +88,7 @@ export function SideMenuSection({
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<motion.div
|
||||
className={`w-full ${itemSpacingClassName}`}
|
||||
variants={{
|
||||
expanded: {
|
||||
translateY: 0,
|
||||
|
||||
@@ -1,9 +1,64 @@
|
||||
import { animate, motion, useMotionValue, useTransform } from "framer-motion";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
|
||||
export function AnimatedNumber({ value, duration = 0.5 }: { value: number; duration?: number }) {
|
||||
/**
|
||||
* Determines the number of decimal places to display based on the value.
|
||||
* - For integers or large numbers (>=100), no decimals
|
||||
* - For numbers >= 10, 1 decimal place
|
||||
* - For numbers >= 1, 2 decimal places
|
||||
* - For smaller numbers, up to 4 decimal places
|
||||
*/
|
||||
function getDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) return 0;
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
if (absValue >= 100) return 0;
|
||||
if (absValue >= 10) return 1;
|
||||
if (absValue >= 1) return 2;
|
||||
if (absValue >= 0.1) return 3;
|
||||
return 4;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes a decimal places value to ensure it's valid for toLocaleString.
|
||||
* - Coerces to a finite number (handles NaN, Infinity, -Infinity)
|
||||
* - Rounds to an integer
|
||||
* - Clamps to the valid 0-20 range for toLocaleString options
|
||||
*/
|
||||
function sanitizeDecimals(decimals: number): number {
|
||||
if (!Number.isFinite(decimals)) {
|
||||
return 0;
|
||||
}
|
||||
return Math.min(20, Math.max(0, Math.round(decimals)));
|
||||
}
|
||||
|
||||
export function AnimatedNumber({
|
||||
value,
|
||||
duration = 0.5,
|
||||
decimalPlaces,
|
||||
}: {
|
||||
value: number;
|
||||
duration?: number;
|
||||
/** Number of decimal places to display. If not provided, auto-detects based on value. */
|
||||
decimalPlaces?: number;
|
||||
}) {
|
||||
const motionValue = useMotionValue(value);
|
||||
let display = useTransform(motionValue, (current) => Math.round(current).toLocaleString());
|
||||
|
||||
// Determine decimal places - use provided value or auto-detect, then sanitize
|
||||
const safeDecimals = useMemo(() => {
|
||||
const rawDecimals = decimalPlaces !== undefined ? decimalPlaces : getDecimalPlaces(value);
|
||||
return sanitizeDecimals(rawDecimals);
|
||||
}, [decimalPlaces, value]);
|
||||
|
||||
const display = useTransform(motionValue, (current) => {
|
||||
if (safeDecimals === 0) {
|
||||
return Math.round(current).toLocaleString();
|
||||
}
|
||||
return current.toLocaleString(undefined, {
|
||||
minimumFractionDigits: safeDecimals,
|
||||
maximumFractionDigits: safeDecimals,
|
||||
});
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
animate(motionValue, value, {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
CreditCardIcon,
|
||||
ExclamationCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
InformationCircleIcon,
|
||||
@@ -60,10 +61,10 @@ export const variantClasses = {
|
||||
linkClassName: "transition hover:bg-blue-400/20",
|
||||
},
|
||||
pricing: {
|
||||
className: "border-charcoal-700 bg-charcoal-800",
|
||||
icon: <ChartBarIcon className="h-5 w-5 shrink-0 text-text-dimmed" />,
|
||||
textColor: "text-text-bright",
|
||||
linkClassName: "transition hover:bg-charcoal-750",
|
||||
className: "border-indigo-400/20 bg-indigo-800/30",
|
||||
icon: <CreditCardIcon className="h-5 w-5 shrink-0 text-indigo-400" />,
|
||||
textColor: "text-indigo-300",
|
||||
linkClassName: "transition hover:bg-indigo-400/20",
|
||||
},
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useRef, useState, useLayoutEffect, useCallback } from "react";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { SimpleTooltip } from "./Tooltip";
|
||||
|
||||
type MiddleTruncateProps = {
|
||||
text: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A component that truncates text in the middle, showing the beginning and end.
|
||||
* Shows the full text in a tooltip on hover when truncated.
|
||||
*
|
||||
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
|
||||
*/
|
||||
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
|
||||
const containerRef = useRef<HTMLSpanElement>(null);
|
||||
const measureRef = useRef<HTMLSpanElement>(null);
|
||||
const [displayText, setDisplayText] = useState(text);
|
||||
const [isTruncated, setIsTruncated] = useState(false);
|
||||
|
||||
const calculateTruncation = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
const measure = measureRef.current;
|
||||
if (!container || !measure) return;
|
||||
|
||||
const parent = container.parentElement;
|
||||
if (!parent) return;
|
||||
|
||||
// Get the available width from the parent container
|
||||
const parentStyle = getComputedStyle(parent);
|
||||
const availableWidth =
|
||||
parent.clientWidth -
|
||||
parseFloat(parentStyle.paddingLeft) -
|
||||
parseFloat(parentStyle.paddingRight);
|
||||
|
||||
// Measure full text width
|
||||
measure.textContent = text;
|
||||
const fullTextWidth = measure.offsetWidth;
|
||||
|
||||
// If text fits, no truncation needed
|
||||
if (fullTextWidth <= availableWidth) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Text needs truncation - find optimal split
|
||||
const ellipsis = "…";
|
||||
measure.textContent = ellipsis;
|
||||
const ellipsisWidth = measure.offsetWidth;
|
||||
|
||||
const targetWidth = availableWidth - ellipsisWidth - 4; // small buffer
|
||||
|
||||
if (targetWidth <= 0) {
|
||||
setDisplayText(ellipsis);
|
||||
setIsTruncated(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Incrementally find the optimal character counts
|
||||
let startChars = 0;
|
||||
let endChars = 0;
|
||||
|
||||
// Alternate adding characters from start and end
|
||||
while (startChars + endChars < text.length) {
|
||||
// Try adding to start
|
||||
const testStart = text.slice(0, startChars + 1);
|
||||
const testEnd = endChars > 0 ? text.slice(-endChars) : "";
|
||||
measure.textContent = testStart + ellipsis + testEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
startChars++;
|
||||
|
||||
if (startChars + endChars >= text.length) break;
|
||||
|
||||
// Try adding to end
|
||||
const newTestEnd = text.slice(-(endChars + 1));
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + newTestEnd;
|
||||
|
||||
if (measure.offsetWidth > targetWidth) break;
|
||||
endChars++;
|
||||
}
|
||||
|
||||
// Ensure minimum characters on each side for readability
|
||||
const minChars = 4;
|
||||
const prevStartChars = startChars;
|
||||
const prevEndChars = endChars;
|
||||
|
||||
if (startChars < minChars && text.length > minChars * 2 + 1) {
|
||||
startChars = minChars;
|
||||
}
|
||||
if (endChars < minChars && text.length > minChars * 2 + 1) {
|
||||
endChars = minChars;
|
||||
}
|
||||
|
||||
// Re-measure after enforcing minChars to prevent overflow
|
||||
if (startChars !== prevStartChars || endChars !== prevEndChars) {
|
||||
measure.textContent = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
if (measure.offsetWidth > targetWidth) {
|
||||
// Revert to previous values if minChars enforcement causes overflow
|
||||
startChars = prevStartChars;
|
||||
endChars = prevEndChars;
|
||||
}
|
||||
}
|
||||
|
||||
// If combined chars would exceed text length, show full text
|
||||
if (startChars + endChars >= text.length) {
|
||||
setDisplayText(text);
|
||||
setIsTruncated(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = text.slice(0, startChars) + ellipsis + text.slice(-endChars);
|
||||
setDisplayText(result);
|
||||
setIsTruncated(true);
|
||||
}, [text]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
calculateTruncation();
|
||||
|
||||
// Recalculate on resize (guard for jsdom/older browsers)
|
||||
if (typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
calculateTruncation();
|
||||
});
|
||||
|
||||
const container = containerRef.current;
|
||||
if (container?.parentElement) {
|
||||
resizeObserver.observe(container.parentElement);
|
||||
}
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [calculateTruncation]);
|
||||
|
||||
const content = (
|
||||
<span
|
||||
ref={containerRef}
|
||||
className={cn("block", isTruncated && "min-w-[360px]", className)}
|
||||
>
|
||||
{/* Hidden span for measuring text width */}
|
||||
<span
|
||||
ref={measureRef}
|
||||
className="invisible absolute whitespace-nowrap"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{displayText}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (isTruncated) {
|
||||
return (
|
||||
<SimpleTooltip
|
||||
button={content}
|
||||
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
|
||||
side="top"
|
||||
asChild
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
@@ -154,10 +154,12 @@ function PopoverSideMenuTrigger({
|
||||
children,
|
||||
className,
|
||||
shortcut,
|
||||
hideShortcutKey = false,
|
||||
...props
|
||||
}: {
|
||||
isOpen?: boolean;
|
||||
shortcut?: useShortcutKeys.ShortcutDefinition;
|
||||
hideShortcutKey?: boolean;
|
||||
} & React.ComponentPropsWithoutRef<typeof PopoverTrigger>) {
|
||||
const ref = React.useRef<HTMLButtonElement>(null);
|
||||
useShortcutKeys.useShortcutKeys({
|
||||
@@ -176,14 +178,14 @@ function PopoverSideMenuTrigger({
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center gap-x-1.5 rounded-sm bg-transparent px-[0.4rem] text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut ? "justify-between" : "",
|
||||
"flex h-[1.8rem] shrink-0 select-none items-center rounded-sm bg-transparent pl-[0.4rem] pr-2.5 text-center font-sans text-2sm font-normal text-text-bright transition duration-150 focus-custom hover:bg-charcoal-750",
|
||||
shortcut && !hideShortcutKey ? "justify-between gap-x-1.5" : "",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
{shortcut && (
|
||||
<ShortcutKey className={cn("size-4 flex-none")} shortcut={shortcut} variant={"small"} />
|
||||
{shortcut && !hideShortcutKey && (
|
||||
<ShortcutKey className="size-4 flex-none" shortcut={shortcut} variant={"small"} />
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
);
|
||||
|
||||
@@ -26,19 +26,40 @@ const ResizableHandle = ({
|
||||
}) => (
|
||||
<PanelResizer
|
||||
className={cn(
|
||||
"group relative flex w-0.75 items-center justify-center focus-custom after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:-translate-y-1/2 data-[panel-group-direction=vertical]:after:translate-x-0 hover:w-0.75 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
// Base styles
|
||||
"group relative flex items-center justify-center focus-custom",
|
||||
// Horizontal orientation (default)
|
||||
"w-0.75 after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2",
|
||||
// Vertical orientation
|
||||
"data-[handle-orientation=vertical]:h-0.75 data-[handle-orientation=vertical]:w-full",
|
||||
"data-[handle-orientation=vertical]:after:inset-x-0 data-[handle-orientation=vertical]:after:inset-y-auto",
|
||||
"data-[handle-orientation=vertical]:after:top-1/2 data-[handle-orientation=vertical]:after:left-0",
|
||||
"data-[handle-orientation=vertical]:after:h-1 data-[handle-orientation=vertical]:after:w-full",
|
||||
"data-[handle-orientation=vertical]:after:-translate-y-1/2 data-[handle-orientation=vertical]:after:translate-x-0",
|
||||
className
|
||||
)}
|
||||
size="3px"
|
||||
{...props}
|
||||
>
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500" />
|
||||
{/* Horizontal orientation line indicator */}
|
||||
<div className="absolute left-[0.0625rem] top-0 h-full w-px bg-grid-bright transition group-hover:left-0 group-hover:w-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:hidden" />
|
||||
{/* Vertical orientation line indicator */}
|
||||
<div className="absolute left-0 top-[0.0625rem] hidden h-px w-full bg-grid-bright transition group-hover:top-0 group-hover:h-0.75 group-hover:bg-lavender-500 group-data-[handle-orientation=vertical]:block" />
|
||||
{withHandle && (
|
||||
<div className="z-10 flex h-5 w-3 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
<>
|
||||
{/* Horizontal orientation dots (vertical arrangement) */}
|
||||
<div className="z-10 flex h-5 w-0.75 flex-col items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:hidden">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-[0.1875rem] w-0.75 rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
{/* Vertical orientation dots (horizontal arrangement) */}
|
||||
<div className="z-10 hidden h-0.75 w-5 flex-row items-center justify-center gap-[0.1875rem] bg-background-dimmed group-hover:hidden group-data-[handle-orientation=vertical]:flex">
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<div key={index} className="h-0.75 w-[0.1875rem] rounded-full bg-charcoal-600" />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PanelResizer>
|
||||
);
|
||||
|
||||
@@ -64,18 +64,30 @@ type TableProps = {
|
||||
className?: string;
|
||||
children: ReactNode;
|
||||
fullWidth?: boolean;
|
||||
showTopBorder?: boolean;
|
||||
};
|
||||
|
||||
// Add TableContext
|
||||
const TableContext = createContext<{ variant: TableVariant }>({ variant: "dimmed" });
|
||||
|
||||
export const Table = forwardRef<HTMLTableElement, TableProps & { variant?: TableVariant }>(
|
||||
({ className, containerClassName, children, fullWidth, variant = "dimmed" }, ref) => {
|
||||
(
|
||||
{
|
||||
className,
|
||||
containerClassName,
|
||||
children,
|
||||
fullWidth,
|
||||
variant = "dimmed",
|
||||
showTopBorder = true,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
return (
|
||||
<TableContext.Provider value={{ variant }}>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-x-auto whitespace-nowrap border-t scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
"overflow-x-auto whitespace-nowrap scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600",
|
||||
showTopBorder && "border-t",
|
||||
containerClassName,
|
||||
fullWidth && "w-full"
|
||||
)}
|
||||
@@ -230,6 +242,7 @@ type TableCellProps = TableCellBasicProps & {
|
||||
isSelected?: boolean;
|
||||
isTabbableCell?: boolean;
|
||||
children?: ReactNode;
|
||||
style?: React.CSSProperties;
|
||||
};
|
||||
|
||||
export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
@@ -246,6 +259,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
isSticky = false,
|
||||
isSelected,
|
||||
isTabbableCell = false,
|
||||
style,
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -291,6 +305,7 @@ export const TableCell = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||
className
|
||||
)}
|
||||
colSpan={colSpan}
|
||||
style={style}
|
||||
>
|
||||
{to ? (
|
||||
<Link
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -16,12 +17,21 @@ interface MousePosition {
|
||||
y: number;
|
||||
}
|
||||
const MousePositionContext = createContext<MousePosition | undefined>(undefined);
|
||||
export function MousePositionProvider({ children }: { children: ReactNode }) {
|
||||
export function MousePositionProvider({
|
||||
children,
|
||||
recalculateTrigger,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
recalculateTrigger?: unknown;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState<MousePosition | undefined>(undefined);
|
||||
const lastMouseCoordsRef = useRef<{ clientX: number; clientY: number } | null>(null);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
lastMouseCoordsRef.current = { clientX: e.clientX, clientY: e.clientY };
|
||||
|
||||
if (!ref.current) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
@@ -41,11 +51,41 @@ export function MousePositionProvider({ children }: { children: ReactNode }) {
|
||||
[ref.current]
|
||||
);
|
||||
|
||||
// Recalculate position when trigger changes (e.g., panel opens/closes)
|
||||
// Use requestAnimationFrame to wait for the DOM layout to complete
|
||||
useEffect(() => {
|
||||
if (!lastMouseCoordsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rafId = requestAnimationFrame(() => {
|
||||
if (!ref.current || !lastMouseCoordsRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { top, left, width, height } = ref.current.getBoundingClientRect();
|
||||
const x = (lastMouseCoordsRef.current.clientX - left) / width;
|
||||
const y = (lastMouseCoordsRef.current.clientY - top) / height;
|
||||
|
||||
if (x < 0 || x > 1 || y < 0 || y > 1) {
|
||||
setPosition(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({ x, y });
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, [recalculateTrigger]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseEnter={handleMouseMove}
|
||||
onMouseLeave={() => setPosition(undefined)}
|
||||
onMouseLeave={() => {
|
||||
lastMouseCoordsRef.current = null;
|
||||
setPosition(undefined);
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
>
|
||||
@@ -83,6 +123,8 @@ export type RootProps = {
|
||||
maxWidth: number;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
/** When this value changes, recalculate the mouse position (useful when panels resize) */
|
||||
recalculateTrigger?: unknown;
|
||||
};
|
||||
|
||||
/** The main element that determines the dimensions for all sub-elements */
|
||||
@@ -94,6 +136,7 @@ export function Root({
|
||||
maxWidth,
|
||||
children,
|
||||
className,
|
||||
recalculateTrigger,
|
||||
}: RootProps) {
|
||||
const pixelWidth = calculatePixelWidth(minWidth, maxWidth, scale);
|
||||
|
||||
@@ -106,7 +149,9 @@ export function Root({
|
||||
width: `${pixelWidth}px`,
|
||||
}}
|
||||
>
|
||||
<MousePositionProvider>{children}</MousePositionProvider>
|
||||
<MousePositionProvider recalculateTrigger={recalculateTrigger}>
|
||||
{children}
|
||||
</MousePositionProvider>
|
||||
</div>
|
||||
</TimelineContext.Provider>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-2 pt-4",
|
||||
"flex flex-col rounded-lg border border-grid-bright bg-background-bright pb-1.5 pt-3",
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -17,7 +17,7 @@ export const Card = ({ children, className }: { children: ReactNode; className?:
|
||||
|
||||
const CardHeader = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<Header3 className="mb-4 flex items-center justify-between gap-2 px-4">{children}</Header3>
|
||||
<Header3 className="mb-3 flex items-center justify-between gap-2 px-3">{children}</Header3>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ export type ChartLegendCompoundProps = {
|
||||
totalLabel?: string;
|
||||
/** Callback when "View all" button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
scrollable?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -37,6 +39,7 @@ export function ChartLegendCompound({
|
||||
className,
|
||||
totalLabel = "Total",
|
||||
onViewAllLegendItems,
|
||||
scrollable = false,
|
||||
}: ChartLegendCompoundProps) {
|
||||
const { config, dataKey, dataKeys, highlight, labelFormatter } = useChartContext();
|
||||
const totals = useSeriesTotal();
|
||||
@@ -128,11 +131,17 @@ export function ChartLegendCompound({
|
||||
const isHovering = (highlight.activePayload?.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-col pt-4 text-sm", className)}>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col pt-4 text-sm",
|
||||
scrollable && "max-h-[50%] min-h-0",
|
||||
className
|
||||
)}
|
||||
>
|
||||
{/* Total row */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex w-full items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
"flex w-full shrink-0 items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
isHovering ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
@@ -143,62 +152,68 @@ export function ChartLegendCompound({
|
||||
</div>
|
||||
|
||||
{/* Separator */}
|
||||
<div className="mx-2 my-1 border-t border-charcoal-750" />
|
||||
<div className="mx-2 my-1 shrink-0 border-t border-charcoal-750" />
|
||||
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
{/* Legend items - scrollable when scrollable prop is true */}
|
||||
<div className={cn("flex flex-col", scrollable && "min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600")}>
|
||||
{legendItems.visible.map((item) => {
|
||||
const total = currentData[item.dataKey] ?? 0;
|
||||
const isActive = highlight.activeBarKey === item.dataKey;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
>
|
||||
{/* Active highlight background */}
|
||||
{isActive && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="h-3 w-1 shrink-0 rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
return (
|
||||
<div
|
||||
key={item.dataKey}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-pointer items-center justify-between gap-2 rounded px-2 py-1 transition",
|
||||
total === 0 && "opacity-50"
|
||||
)}
|
||||
onMouseEnter={() => highlight.setHoveredLegendItem(item.dataKey)}
|
||||
onMouseLeave={() => highlight.reset()}
|
||||
>
|
||||
{/* Active highlight background */}
|
||||
{isActive && item.color && (
|
||||
<div
|
||||
className="absolute inset-0 rounded opacity-10"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<div className="relative flex w-full items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.color && (
|
||||
<div
|
||||
className="w-1 shrink-0 self-stretch rounded-[2px]"
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
)}
|
||||
<span className={isActive ? "text-text-bright" : "text-text-dimmed"}>
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
"self-start tabular-nums",
|
||||
isActive ? "text-text-bright" : "text-text-dimmed"
|
||||
)}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
<span
|
||||
className={cn("tabular-nums", isActive ? "text-text-bright" : "text-text-dimmed")}
|
||||
>
|
||||
<AnimatedNumber value={total} duration={0.25} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
);
|
||||
})}
|
||||
|
||||
{/* View more row - replaced by hovered hidden item when applicable */}
|
||||
{legendItems.remaining > 0 &&
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
))}
|
||||
{/* View more row - replaced by hovered hidden item when applicable */}
|
||||
{legendItems.remaining > 0 &&
|
||||
(legendItems.hoveredHiddenItem ? (
|
||||
<HoveredHiddenItemRow
|
||||
item={legendItems.hoveredHiddenItem}
|
||||
value={currentData[legendItems.hoveredHiddenItem.dataKey] ?? 0}
|
||||
remainingCount={legendItems.remaining - 1}
|
||||
/>
|
||||
) : (
|
||||
<ViewAllDataRow remainingCount={legendItems.remaining} onViewAll={onViewAllLegendItems} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,7 @@ export function ChartLineRenderer({
|
||||
stroke={config[key]?.color}
|
||||
fill={config[key]?.color}
|
||||
fillOpacity={0.6}
|
||||
strokeWidth={2}
|
||||
strokeWidth={1}
|
||||
stackId="stack"
|
||||
isAnimationActive={false}
|
||||
/>
|
||||
@@ -220,7 +220,7 @@ export function ChartLineRenderer({
|
||||
dataKey={key}
|
||||
type={lineType}
|
||||
stroke={config[key]?.color}
|
||||
strokeWidth={2}
|
||||
strokeWidth={1}
|
||||
dot={false}
|
||||
activeDot={{ r: 4 }}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -31,6 +31,8 @@ export type ChartRootProps = {
|
||||
legendTotalLabel?: string;
|
||||
/** Callback when "View all" legend button is clicked */
|
||||
onViewAllLegendItems?: () => void;
|
||||
/** When true, constrains legend to max 50% height with scrolling */
|
||||
legendScrollable?: boolean;
|
||||
/** When true, chart fills its parent container height and distributes space between chart and legend */
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
@@ -72,6 +74,7 @@ export function ChartRoot({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootProps) {
|
||||
@@ -94,6 +97,7 @@ export function ChartRoot({
|
||||
maxLegendItems={maxLegendItems}
|
||||
legendTotalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
legendScrollable={legendScrollable}
|
||||
fillContainer={fillContainer}
|
||||
>
|
||||
{children}
|
||||
@@ -109,6 +113,7 @@ type ChartRootInnerProps = {
|
||||
maxLegendItems?: number;
|
||||
legendTotalLabel?: string;
|
||||
onViewAllLegendItems?: () => void;
|
||||
legendScrollable?: boolean;
|
||||
fillContainer?: boolean;
|
||||
children: React.ComponentProps<typeof RechartsPrimitive.ResponsiveContainer>["children"];
|
||||
};
|
||||
@@ -120,6 +125,7 @@ function ChartRootInner({
|
||||
maxLegendItems = 5,
|
||||
legendTotalLabel,
|
||||
onViewAllLegendItems,
|
||||
legendScrollable = false,
|
||||
fillContainer = false,
|
||||
children,
|
||||
}: ChartRootInnerProps) {
|
||||
@@ -160,6 +166,7 @@ function ChartRootInner({
|
||||
maxItems={maxLegendItems}
|
||||
totalLabel={legendTotalLabel}
|
||||
onViewAllLegendItems={onViewAllLegendItems}
|
||||
scrollable={legendScrollable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -201,6 +201,7 @@ function ReplayForm({
|
||||
tags,
|
||||
version,
|
||||
machine,
|
||||
prioritySeconds,
|
||||
},
|
||||
] = useForm({
|
||||
id: "replay-task",
|
||||
@@ -499,6 +500,12 @@ function ReplayForm({
|
||||
<Hint>Delays run by a specific duration.</Hint>
|
||||
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label variant="small">Priority</Label>
|
||||
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
|
||||
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
|
||||
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label variant="small">TTL</Label>
|
||||
<DurationPicker
|
||||
|
||||
@@ -31,6 +31,7 @@ import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { FormError } from "~/components/primitives/FormError";
|
||||
import { Input } from "~/components/primitives/Input";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { MiddleTruncate } from "~/components/primitives/MiddleTruncate";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import {
|
||||
ComboBox,
|
||||
@@ -634,7 +635,7 @@ function TasksDropdown({
|
||||
<SelectProvider value={values("tasks")} setValue={handleChange} virtualFocus={true}>
|
||||
{trigger}
|
||||
<SelectPopover
|
||||
className="min-w-0 max-w-[min(240px,var(--popover-available-width))]"
|
||||
className="min-w-0 max-w-[min(360px,var(--popover-available-width))]"
|
||||
hideOnEscape={() => {
|
||||
if (onClose) {
|
||||
onClose();
|
||||
@@ -654,7 +655,7 @@ function TasksDropdown({
|
||||
<TaskTriggerSourceIcon source={item.triggerSource} className="size-4 flex-none" />
|
||||
}
|
||||
>
|
||||
{item.slug}
|
||||
<MiddleTruncate text={item.slug}/>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectList>
|
||||
|
||||
@@ -4,36 +4,31 @@ import {
|
||||
endOfDay,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
isSaturday,
|
||||
isSunday,
|
||||
previousSaturday,
|
||||
startOfDay,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
startOfYear,
|
||||
subDays,
|
||||
subMonths,
|
||||
subWeeks,
|
||||
subWeeks
|
||||
} from "date-fns";
|
||||
import parse from "parse-duration";
|
||||
import { startTransition, useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
import simplur from "simplur";
|
||||
import { AppliedFilter } from "~/components/primitives/AppliedFilter";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { DateTime } from "~/components/primitives/DateTime";
|
||||
import { DateTimePicker } from "~/components/primitives/DateTimePicker";
|
||||
import { Label } from "~/components/primitives/Label";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { RadioButtonCircle } from "~/components/primitives/RadioButton";
|
||||
import { ComboboxProvider, SelectPopover, SelectProvider } from "~/components/primitives/Select";
|
||||
import { useOptionalOrganization } from "~/hooks/useOrganizations";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { type ShortcutDefinition } from "~/hooks/useShortcutKeys";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button } from "../../primitives/Buttons";
|
||||
import { organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { Button, LinkButton } from "../../primitives/Buttons";
|
||||
import { filterIcon } from "./RunFilters";
|
||||
|
||||
export type DisplayableEnvironment = Pick<RuntimeEnvironment, "type" | "id"> & {
|
||||
userName?: string;
|
||||
};
|
||||
|
||||
export function FilterMenuProvider({
|
||||
children,
|
||||
onClose,
|
||||
@@ -95,6 +90,10 @@ const timePeriods = [
|
||||
label: "3 days",
|
||||
value: "3d",
|
||||
},
|
||||
{
|
||||
label: "5 days",
|
||||
value: "5d",
|
||||
},
|
||||
{
|
||||
label: "7 days",
|
||||
value: "7d",
|
||||
@@ -106,11 +105,7 @@ const timePeriods = [
|
||||
{
|
||||
label: "30 days",
|
||||
value: "30d",
|
||||
},
|
||||
{
|
||||
label: "90 days",
|
||||
value: "90d",
|
||||
},
|
||||
}
|
||||
];
|
||||
|
||||
const timeUnits = [
|
||||
@@ -128,6 +123,22 @@ function parsePeriodString(period: string): { value: number; unit: string } | nu
|
||||
return null;
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
// Convert a period string to days using parse-duration
|
||||
function periodToDays(period: string): number {
|
||||
const ms = parse(period);
|
||||
if (!ms) return 0;
|
||||
return ms / MS_PER_DAY;
|
||||
}
|
||||
|
||||
// Calculate the number of days a date range spans from now
|
||||
function dateRangeToDays(from?: Date): number {
|
||||
if (!from) return 0;
|
||||
const now = new Date();
|
||||
return Math.ceil((now.getTime() - from.getTime()) / MS_PER_DAY);
|
||||
}
|
||||
|
||||
const DEFAULT_PERIOD = "7d";
|
||||
const defaultPeriodMs = parse(DEFAULT_PERIOD);
|
||||
if (!defaultPeriodMs) {
|
||||
@@ -292,6 +303,8 @@ export interface TimeFilterProps {
|
||||
applyShortcut?: ShortcutDefinition | undefined;
|
||||
/** Callback when the user applies a time filter selection, receives the applied values */
|
||||
onValueChange?: (values: TimeFilterApplyValues) => void;
|
||||
/** When set an upgrade message will be shown if you select a period further back than this number of days */
|
||||
maxPeriodDays?: number;
|
||||
}
|
||||
|
||||
export function TimeFilter({
|
||||
@@ -303,6 +316,7 @@ export function TimeFilter({
|
||||
hideLabel = false,
|
||||
applyShortcut,
|
||||
onValueChange,
|
||||
maxPeriodDays,
|
||||
}: TimeFilterProps = {}) {
|
||||
const { value } = useSearchParams();
|
||||
const periodValue = period ?? value("period");
|
||||
@@ -339,6 +353,7 @@ export function TimeFilter({
|
||||
labelName={labelName}
|
||||
applyShortcut={applyShortcut}
|
||||
onValueChange={onValueChange}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
)}
|
||||
</FilterMenuProvider>
|
||||
@@ -356,6 +371,8 @@ function getInitialCustomDuration(period?: string): { value: string; unit: strin
|
||||
return { value: "", unit: "m" };
|
||||
}
|
||||
|
||||
type SectionType = "duration" | "dateRange";
|
||||
|
||||
export function TimeDropdown({
|
||||
trigger,
|
||||
period,
|
||||
@@ -366,6 +383,7 @@ export function TimeDropdown({
|
||||
applyShortcut,
|
||||
onApply,
|
||||
onValueChange,
|
||||
maxPeriodDays,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
period?: string;
|
||||
@@ -377,14 +395,16 @@ export function TimeDropdown({
|
||||
onApply?: (values: TimeFilterApplyValues) => void;
|
||||
/** When provided, the component operates in controlled mode and skips URL navigation */
|
||||
onValueChange?: (values: TimeFilterApplyValues) => void;
|
||||
/** When set an upgrade message will be shown if you select a period further back than this number of days */
|
||||
maxPeriodDays?: number;
|
||||
}) {
|
||||
const organization = useOptionalOrganization();
|
||||
const [open, setOpen] = useState<boolean | undefined>();
|
||||
const { replace } = useSearchParams();
|
||||
const [fromValue, setFromValue] = useState(from);
|
||||
const [toValue, setToValue] = useState(to);
|
||||
|
||||
// Section selection state: "duration" or "dateRange"
|
||||
type SectionType = "duration" | "dateRange";
|
||||
const initialSection: SectionType = from || to ? "dateRange" : "duration";
|
||||
const [activeSection, setActiveSection] = useState<SectionType>(initialSection);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
@@ -418,9 +438,28 @@ export function TimeDropdown({
|
||||
return !isNaN(value) && value > 0;
|
||||
})();
|
||||
|
||||
// Calculate if the current selection exceeds maxPeriodDays
|
||||
const exceedsMaxPeriod = (() => {
|
||||
if (!maxPeriodDays) return false;
|
||||
|
||||
if (activeSection === "duration") {
|
||||
const periodToCheck = selectedPeriod === "custom" ? `${customValue}${customUnit}` : selectedPeriod;
|
||||
if (!periodToCheck) return false;
|
||||
return periodToDays(periodToCheck) > maxPeriodDays;
|
||||
} else {
|
||||
// For date range, check if fromValue is further back than maxPeriodDays
|
||||
return dateRangeToDays(fromValue) > maxPeriodDays;
|
||||
}
|
||||
})();
|
||||
|
||||
const applySelection = useCallback(() => {
|
||||
setValidationError(null);
|
||||
|
||||
if (exceedsMaxPeriod) {
|
||||
setValidationError(`Your plan allows a maximum of ${maxPeriodDays} days. Upgrade for longer retention.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeSection === "duration") {
|
||||
// Validate custom duration
|
||||
if (selectedPeriod === "custom" && !isCustomDurationValid) {
|
||||
@@ -498,6 +537,8 @@ export function TimeDropdown({
|
||||
replace,
|
||||
onApply,
|
||||
onValueChange,
|
||||
exceedsMaxPeriod,
|
||||
maxPeriodDays
|
||||
]);
|
||||
|
||||
return (
|
||||
@@ -683,7 +724,7 @@ export function TimeDropdown({
|
||||
/>
|
||||
</div>
|
||||
{/* Quick select date ranges */}
|
||||
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="mt-2 grid grid-cols-2 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<QuickDateButton
|
||||
label="Yesterday"
|
||||
isActive={selectedQuickDate === "yesterday"}
|
||||
@@ -702,45 +743,26 @@ export function TimeDropdown({
|
||||
onClick={() => {
|
||||
const today = new Date();
|
||||
setFromValue(startOfDay(today));
|
||||
setToValue(today);
|
||||
setToValue(endOfDay(today));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("today");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 grid grid-cols-3 gap-2" onClick={(e) => e.stopPropagation()}>
|
||||
<QuickDateButton
|
||||
label="This week"
|
||||
isActive={selectedQuickDate === "thisWeek"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfWeek(now, { weekStartsOn: 1 }));
|
||||
setToValue(now);
|
||||
setToValue(endOfWeek(now, { weekStartsOn: 1 }));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("thisWeek");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last weekend"
|
||||
isActive={selectedQuickDate === "lastWeekend"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
let saturday: Date;
|
||||
if (isSaturday(now)) {
|
||||
saturday = subDays(now, 7);
|
||||
} else if (isSunday(now)) {
|
||||
saturday = subDays(now, 8);
|
||||
} else {
|
||||
saturday = previousSaturday(now);
|
||||
}
|
||||
const sunday = endOfDay(subDays(saturday, -1));
|
||||
setFromValue(startOfDay(saturday));
|
||||
setToValue(sunday);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastWeekend");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last week"
|
||||
isActive={selectedQuickDate === "lastWeek"}
|
||||
@@ -753,56 +775,18 @@ export function TimeDropdown({
|
||||
setSelectedQuickDate("lastWeek");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last weekdays"
|
||||
isActive={selectedQuickDate === "lastWeekdays"}
|
||||
onClick={() => {
|
||||
const lastWeek = subWeeks(new Date(), 1);
|
||||
const monday = startOfWeek(lastWeek, { weekStartsOn: 1 });
|
||||
const friday = endOfDay(subDays(monday, -4)); // Monday + 4 days = Friday
|
||||
setFromValue(startOfDay(monday));
|
||||
setToValue(friday);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastWeekdays");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Last month"
|
||||
isActive={selectedQuickDate === "lastMonth"}
|
||||
onClick={() => {
|
||||
const lastMonth = subMonths(new Date(), 1);
|
||||
setFromValue(startOfMonth(lastMonth));
|
||||
setToValue(endOfMonth(lastMonth));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("lastMonth");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="This month"
|
||||
isActive={selectedQuickDate === "thisMonth"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfMonth(now));
|
||||
setToValue(now);
|
||||
setToValue(endOfMonth(now));
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("thisMonth");
|
||||
}}
|
||||
/>
|
||||
<QuickDateButton
|
||||
label="Year to date"
|
||||
isActive={selectedQuickDate === "yearToDate"}
|
||||
onClick={() => {
|
||||
const now = new Date();
|
||||
setFromValue(startOfYear(now));
|
||||
setToValue(now);
|
||||
setActiveSection("dateRange");
|
||||
setValidationError(null);
|
||||
setSelectedQuickDate("yearToDate");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{validationError && activeSection === "dateRange" && (
|
||||
<Paragraph variant="extra-small" className="mt-2 text-error">
|
||||
@@ -812,6 +796,17 @@ export function TimeDropdown({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upgrade callout when exceeding maxPeriodDays */}
|
||||
{exceedsMaxPeriod && organization && (
|
||||
<Callout
|
||||
variant="pricing"
|
||||
cta={<LinkButton variant="primary/small" to={organizationBillingPath({ slug: organization.slug })}>Upgrade</LinkButton>}
|
||||
className="items-center"
|
||||
>
|
||||
{simplur`Your plan allows a maximum of ${maxPeriodDays} day[|s].`}
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex justify-between gap-1 border-t border-grid-bright px-0 pt-3">
|
||||
<Button
|
||||
@@ -839,6 +834,7 @@ export function TimeDropdown({
|
||||
applySelection();
|
||||
}}
|
||||
type="button"
|
||||
disabled={exceedsMaxPeriod}
|
||||
>
|
||||
Apply
|
||||
</Button>
|
||||
|
||||
@@ -521,7 +521,6 @@ const EnvironmentSchema = z
|
||||
PROD_USAGE_HEARTBEAT_INTERVAL_MS: z.coerce.number().int().optional(),
|
||||
|
||||
CENTS_PER_RUN: z.coerce.number().default(0),
|
||||
CENTS_PER_QUERY_BYTE_SECOND: z.coerce.number().default(0),
|
||||
|
||||
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
|
||||
RESOURCE_MONITOR_ENABLED: z.string().default("0"),
|
||||
@@ -1191,12 +1190,16 @@ const EnvironmentSchema = z
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_THREADS: z.coerce.number().int().default(2),
|
||||
CLICKHOUSE_LOGS_DETAIL_MAX_EXECUTION_TIME: z.coerce.number().int().default(60),
|
||||
|
||||
// Query feature flag
|
||||
QUERY_FEATURE_ENABLED: z.string().default("1"),
|
||||
|
||||
// Query page ClickHouse limits (for TSQL queries)
|
||||
QUERY_CLICKHOUSE_MAX_EXECUTION_TIME: z.coerce.number().int().default(10),
|
||||
QUERY_CLICKHOUSE_MAX_MEMORY_USAGE: z.coerce.number().int().default(1_073_741_824), // 1GB in bytes
|
||||
QUERY_CLICKHOUSE_MAX_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||||
QUERY_CLICKHOUSE_MAX_EXPANDED_AST_ELEMENTS: z.coerce.number().int().default(4_000_000),
|
||||
QUERY_CLICKHOUSE_MAX_BYTES_BEFORE_EXTERNAL_GROUP_BY: z.coerce.number().int().default(0),
|
||||
QUERY_CLICKHOUSE_MAX_RETURNED_ROWS: z.coerce.number().int().default(10_000),
|
||||
|
||||
// Query page concurrency limits
|
||||
QUERY_DEFAULT_ORG_CONCURRENCY_LIMIT: z.coerce.number().int().default(3),
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTypedFetcher } from "remix-typedjson";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { type loader as canViewLogsPageLoader } from "~/routes/resources.orgs.$organizationSlug.can-view-logs-page/route";
|
||||
|
||||
export function useCanViewLogsPage(): boolean | undefined {
|
||||
const organization = useOrganization();
|
||||
const fetcher = useTypedFetcher<typeof canViewLogsPageLoader>();
|
||||
|
||||
useEffect(() => {
|
||||
const url = `/resources/orgs/${organization.slug}/can-view-logs-page`;
|
||||
fetcher.load(url);
|
||||
}, [organization.slug]);
|
||||
|
||||
return fetcher.data?.canViewLogsPage;
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
import { UIMatch } from "@remix-run/react";
|
||||
import type { User } from "~/models/user.server";
|
||||
import { loader } from "~/root";
|
||||
import { type UIMatch } from "@remix-run/react";
|
||||
import type { UserWithDashboardPreferences } from "~/models/user.server";
|
||||
import { type loader } from "~/root";
|
||||
import { useChanged } from "./useChanged";
|
||||
import { useTypedMatchesData } from "./useTypedMatchData";
|
||||
import { useIsImpersonating } from "./useOrganizations";
|
||||
|
||||
export type User = UserWithDashboardPreferences;
|
||||
|
||||
export function useOptionalUser(matches?: UIMatch[]): User | undefined {
|
||||
const routeMatch = useTypedMatchesData<typeof loader>({
|
||||
id: "root",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeEnvironment, type PrismaClient } from "@trigger.dev/database";
|
||||
import type { RuntimeEnvironment, PrismaClient } from "@trigger.dev/database";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "./SelectBestEnvironmentPresenter.server";
|
||||
import { sortEnvironments } from "~/utils/environmentSort";
|
||||
import { defaultAvatar, parseAvatar } from "~/components/primitives/Avatar";
|
||||
import { validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
import { flags, validatePartialFeatureFlags } from "~/v3/featureFlags.server";
|
||||
|
||||
export class OrganizationsPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -153,18 +153,24 @@ export class OrganizationsPresenter {
|
||||
},
|
||||
});
|
||||
|
||||
// Get global feature flags (no overrides or defaults)
|
||||
const globalFlags = await flags();
|
||||
|
||||
return orgs.map((org) => {
|
||||
const flagsResult = org.featureFlags
|
||||
const orgFlagsResult = org.featureFlags
|
||||
? validatePartialFeatureFlags(org.featureFlags as Record<string, unknown>)
|
||||
: ({ success: false } as const);
|
||||
const flags = flagsResult.success ? flagsResult.data : {};
|
||||
const orgFlags = orgFlagsResult.success ? orgFlagsResult.data : {};
|
||||
|
||||
// Combine global flags with org flags (org flags win)
|
||||
const combinedFlags = { ...globalFlags, ...orgFlags };
|
||||
|
||||
return {
|
||||
id: org.id,
|
||||
slug: org.slug,
|
||||
title: org.title,
|
||||
avatar: parseAvatar(org.avatar, defaultAvatar),
|
||||
featureFlags: flags,
|
||||
featureFlags: combinedFlags,
|
||||
projects: org.projects.map((project) => ({
|
||||
id: project.id,
|
||||
slug: project.slug,
|
||||
|
||||
@@ -156,6 +156,7 @@ export class DeploymentPresenter {
|
||||
},
|
||||
},
|
||||
buildServerMetadata: true,
|
||||
triggeredVia: true,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -225,6 +226,7 @@ export class DeploymentPresenter {
|
||||
isBuilt: !!deployment.builtAt,
|
||||
type: deployment.type,
|
||||
git: gitMetadata,
|
||||
triggeredVia: deployment.triggeredVia,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,10 +36,7 @@ export class LogDetailPresenter {
|
||||
);
|
||||
}
|
||||
|
||||
const isClickhouseV2 = store === "clickhouse_v2";
|
||||
const queryBuilder = isClickhouseV2
|
||||
? this.clickhouse.taskEventsV2.logDetailQueryBuilder()
|
||||
: this.clickhouse.taskEvents.logDetailQueryBuilder();
|
||||
const queryBuilder = this.clickhouse.taskEventsV2.logDetailQueryBuilder();
|
||||
|
||||
// Required filters - spanId, traceId, and startTime uniquely identify the log
|
||||
// Multiple events can share the same spanId (span, span events, logs), so startTime is needed
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
import { z } from "zod";
|
||||
import { type ClickHouse, type LogsListResult } from "@internal/clickhouse";
|
||||
import { MachinePresetName } from "@trigger.dev/core/v3";
|
||||
import { type ClickHouse } from "@internal/clickhouse";
|
||||
import {
|
||||
type PrismaClientOrTransaction,
|
||||
type TaskRunStatus,
|
||||
TaskRunStatus as TaskRunStatusEnum,
|
||||
} from "@trigger.dev/database";
|
||||
import { getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
|
||||
import { EVENT_STORE_TYPES, getConfiguredEventRepository } from "~/v3/eventRepository/index.server";
|
||||
|
||||
// Create a schema that validates TaskRunStatus enum values
|
||||
const TaskRunStatusSchema = z.array(z.nativeEnum(TaskRunStatusEnum));
|
||||
import parseDuration from "parse-duration";
|
||||
import { type Direction } from "~/components/ListPagination";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { findDisplayableEnvironment } from "~/models/runtimeEnvironment.server";
|
||||
import { getAllTaskIdentifiers } from "~/models/task.server";
|
||||
import { RunsRepository } from "~/services/runsRepository/runsRepository.server";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import { kindToLevel, type LogLevel, LogLevelSchema } from "~/utils/logUtils";
|
||||
import { BasePresenter } from "~/presenters/v3/basePresenter.server";
|
||||
@@ -33,27 +27,27 @@ type ErrorAttributes = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function escapeClickHouseString(val: string): string {
|
||||
return val
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/\//g, "\\/")
|
||||
.replace(/%/g, "\\%")
|
||||
.replace(/_/g, "\\_");
|
||||
}
|
||||
|
||||
|
||||
export type LogsListOptions = {
|
||||
userId?: string;
|
||||
projectId: string;
|
||||
// filters
|
||||
tasks?: string[];
|
||||
versions?: string[];
|
||||
statuses?: TaskRunStatus[];
|
||||
tags?: string[];
|
||||
scheduleId?: string;
|
||||
runId?: string;
|
||||
period?: string;
|
||||
bulkId?: string;
|
||||
from?: number;
|
||||
to?: number;
|
||||
isTest?: boolean;
|
||||
rootOnly?: boolean;
|
||||
batchId?: string;
|
||||
runId?: string[];
|
||||
queues?: string[];
|
||||
machines?: MachinePresetName[];
|
||||
levels?: LogLevel[];
|
||||
defaultPeriod?: string;
|
||||
retentionLimitDays?: number;
|
||||
// search
|
||||
search?: string;
|
||||
includeDebugLogs?: boolean;
|
||||
@@ -67,22 +61,13 @@ export const LogsListOptionsSchema = z.object({
|
||||
userId: z.string().optional(),
|
||||
projectId: z.string(),
|
||||
tasks: z.array(z.string()).optional(),
|
||||
versions: z.array(z.string()).optional(),
|
||||
statuses: TaskRunStatusSchema.optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
scheduleId: z.string().optional(),
|
||||
runId: z.string().optional(),
|
||||
period: z.string().optional(),
|
||||
bulkId: z.string().optional(),
|
||||
from: z.number().int().nonnegative().optional(),
|
||||
to: z.number().int().nonnegative().optional(),
|
||||
isTest: z.boolean().optional(),
|
||||
rootOnly: z.boolean().optional(),
|
||||
batchId: z.string().optional(),
|
||||
runId: z.array(z.string()).optional(),
|
||||
queues: z.array(z.string()).optional(),
|
||||
machines: z.array(MachinePresetName).optional(),
|
||||
levels: z.array(LogLevelSchema).optional(),
|
||||
defaultPeriod: z.string().optional(),
|
||||
retentionLimitDays: z.number().int().positive().optional(),
|
||||
search: z.string().max(1000).optional(),
|
||||
includeDebugLogs: z.boolean().optional(),
|
||||
direction: z.enum(["forward", "backward"]).optional(),
|
||||
@@ -91,7 +76,6 @@ export const LogsListOptionsSchema = z.object({
|
||||
});
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const MAX_RUN_IDS = 5000;
|
||||
|
||||
export type LogsList = Awaited<ReturnType<LogsListPresenter["call"]>>;
|
||||
export type LogEntry = LogsList["logs"][0];
|
||||
@@ -99,17 +83,15 @@ export type LogsListAppliedFilters = LogsList["filters"];
|
||||
|
||||
// Cursor is a base64 encoded JSON of the pagination keys
|
||||
type LogCursor = {
|
||||
startTime: string;
|
||||
environmentId: string;
|
||||
unixTimestamp: number;
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
runId: string;
|
||||
};
|
||||
|
||||
const LogCursorSchema = z.object({
|
||||
startTime: z.string(),
|
||||
environmentId: z.string(),
|
||||
unixTimestamp: z.number(),
|
||||
traceId: z.string(),
|
||||
spanId: z.string(),
|
||||
runId: z.string(),
|
||||
});
|
||||
|
||||
function encodeCursor(cursor: LogCursor): string {
|
||||
@@ -141,10 +123,6 @@ function levelToKindsAndStatuses(level: LogLevel): { kinds?: string[]; statuses?
|
||||
return { kinds: ["LOG_WARN"] };
|
||||
case "ERROR":
|
||||
return { kinds: ["LOG_ERROR"], statuses: ["ERROR"] };
|
||||
case "CANCELLED":
|
||||
return { statuses: ["CANCELLED"] };
|
||||
case "TRACE":
|
||||
return { kinds: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"] };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,18 +158,8 @@ export class LogsListPresenter extends BasePresenter {
|
||||
userId,
|
||||
projectId,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
tags,
|
||||
scheduleId,
|
||||
period,
|
||||
bulkId,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
queues,
|
||||
machines,
|
||||
period,
|
||||
levels,
|
||||
search,
|
||||
from,
|
||||
@@ -200,6 +168,7 @@ export class LogsListPresenter extends BasePresenter {
|
||||
pageSize = DEFAULT_PAGE_SIZE,
|
||||
includeDebugLogs = true,
|
||||
defaultPeriod,
|
||||
retentionLimitDays,
|
||||
}: LogsListOptions
|
||||
) {
|
||||
const time = timeFilters({
|
||||
@@ -220,23 +189,20 @@ export class LogsListPresenter extends BasePresenter {
|
||||
}
|
||||
}
|
||||
|
||||
const hasStatusFilters = statuses && statuses.length > 0;
|
||||
const hasRunLevelFilters =
|
||||
(versions !== undefined && versions.length > 0) ||
|
||||
hasStatusFilters ||
|
||||
(bulkId !== undefined && bulkId !== "") ||
|
||||
(scheduleId !== undefined && scheduleId !== "") ||
|
||||
(tags !== undefined && tags.length > 0) ||
|
||||
batchId !== undefined ||
|
||||
(runId !== undefined && runId.length > 0) ||
|
||||
(queues !== undefined && queues.length > 0) ||
|
||||
(machines !== undefined && machines.length > 0) ||
|
||||
typeof isTest === "boolean" ||
|
||||
rootOnly === true;
|
||||
// Apply retention limit if provided
|
||||
let wasClampedByRetention = false;
|
||||
if (retentionLimitDays !== undefined && effectiveFrom) {
|
||||
const retentionCutoffDate = new Date(Date.now() - retentionLimitDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
if (effectiveFrom < retentionCutoffDate) {
|
||||
effectiveFrom = retentionCutoffDate;
|
||||
wasClampedByRetention = true;
|
||||
}
|
||||
}
|
||||
|
||||
const hasFilters =
|
||||
(tasks !== undefined && tasks.length > 0) ||
|
||||
hasRunLevelFilters ||
|
||||
(runId !== undefined && runId !== "") ||
|
||||
(levels !== undefined && levels.length > 0) ||
|
||||
(search !== undefined && search !== "") ||
|
||||
!time.isDefault;
|
||||
@@ -266,120 +232,29 @@ export class LogsListPresenter extends BasePresenter {
|
||||
findDisplayableEnvironment(environmentId, userId),
|
||||
]);
|
||||
|
||||
if (bulkId && !bulkActions.some((bulkAction) => bulkAction.friendlyId === bulkId)) {
|
||||
const selectedBulkAction = await this.replica.bulkActionGroup.findFirst({
|
||||
select: {
|
||||
friendlyId: true,
|
||||
type: true,
|
||||
createdAt: true,
|
||||
name: true,
|
||||
},
|
||||
where: {
|
||||
friendlyId: bulkId,
|
||||
projectId,
|
||||
environmentId,
|
||||
},
|
||||
});
|
||||
|
||||
if (selectedBulkAction) {
|
||||
bulkActions.push(selectedBulkAction);
|
||||
}
|
||||
}
|
||||
|
||||
if (!displayableEnvironment) {
|
||||
throw new ServiceValidationError("No environment found");
|
||||
}
|
||||
|
||||
// If we have run-level filters, we need to first get matching run IDs from Postgres
|
||||
let runIds: string[] | undefined;
|
||||
if (hasRunLevelFilters) {
|
||||
const runsRepository = new RunsRepository({
|
||||
clickhouse: this.clickhouse,
|
||||
prisma: this.replica,
|
||||
});
|
||||
|
||||
function clampToNow(date: Date): Date {
|
||||
const now = new Date();
|
||||
return date > now ? now : date;
|
||||
}
|
||||
|
||||
runIds = await runsRepository.listFriendlyRunIds({
|
||||
organizationId,
|
||||
environmentId,
|
||||
projectId,
|
||||
tasks,
|
||||
versions,
|
||||
statuses,
|
||||
tags,
|
||||
scheduleId,
|
||||
period,
|
||||
from: effectiveFrom ? effectiveFrom.getTime() : undefined,
|
||||
to: effectiveTo ? clampToNow(effectiveTo).getTime() : undefined,
|
||||
isTest,
|
||||
rootOnly,
|
||||
batchId,
|
||||
runId,
|
||||
bulkId,
|
||||
queues,
|
||||
machines,
|
||||
page: {
|
||||
size: MAX_RUN_IDS,
|
||||
direction: "forward",
|
||||
},
|
||||
});
|
||||
|
||||
if (runIds.length === 0) {
|
||||
return {
|
||||
logs: [],
|
||||
pagination: {
|
||||
next: undefined,
|
||||
previous: undefined,
|
||||
},
|
||||
possibleTasks: possibleTasks
|
||||
.map((task) => ({
|
||||
slug: task.slug,
|
||||
triggerSource: task.triggerSource,
|
||||
}))
|
||||
.sort((a, b) => a.slug.localeCompare(b.slug)),
|
||||
bulkActions: bulkActions.map((bulkAction) => ({
|
||||
id: bulkAction.friendlyId,
|
||||
type: bulkAction.type,
|
||||
createdAt: bulkAction.createdAt,
|
||||
name: bulkAction.name || bulkAction.friendlyId,
|
||||
})),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
statuses: statuses || [],
|
||||
levels: levels || [],
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
},
|
||||
hasFilters,
|
||||
hasAnyLogs: false,
|
||||
searchTerm: search,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Determine which store to use based on organization configuration
|
||||
const { store } = await getConfiguredEventRepository(organizationId);
|
||||
|
||||
// Throw error if postgres is detected
|
||||
if (store === "postgres") {
|
||||
if (store === EVENT_STORE_TYPES.POSTGRES) {
|
||||
throw new ServiceValidationError(
|
||||
"Logs are not available for PostgreSQL event store. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
// Get the appropriate query builder based on store type
|
||||
const isClickhouseV2 = store === "clickhouse_v2";
|
||||
if (store === EVENT_STORE_TYPES.CLICKHOUSE) {
|
||||
throw new ServiceValidationError(
|
||||
"Logs are not available for ClickHouse event store. Please contact support."
|
||||
);
|
||||
}
|
||||
|
||||
const queryBuilder = isClickhouseV2
|
||||
? this.clickhouse.taskEventsV2.logsListQueryBuilder()
|
||||
: this.clickhouse.taskEvents.logsListQueryBuilder();
|
||||
const queryBuilder = this.clickhouse.taskEventsV2.logsListQueryBuilder();
|
||||
|
||||
queryBuilder.prewhere("environment_id = {environmentId: String}", {
|
||||
queryBuilder.where("environment_id = {environmentId: String}", {
|
||||
environmentId,
|
||||
});
|
||||
|
||||
@@ -388,16 +263,13 @@ export class LogsListPresenter extends BasePresenter {
|
||||
});
|
||||
queryBuilder.where("project_id = {projectId: String}", { projectId });
|
||||
|
||||
// Time filters - inserted_at in PREWHERE only for v2, start_time in WHERE for both
|
||||
|
||||
if (effectiveFrom) {
|
||||
const fromNs = convertDateToNanoseconds(effectiveFrom);
|
||||
|
||||
// Only use inserted_at for partition pruning if v2
|
||||
if (isClickhouseV2) {
|
||||
queryBuilder.prewhere("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
queryBuilder.where("inserted_at >= {insertedAtStart: DateTime64(3)}", {
|
||||
insertedAtStart: convertDateToClickhouseDateTime(effectiveFrom),
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.where("start_time >= {fromTime: String}", {
|
||||
fromTime: formatNanosecondsForClickhouse(fromNs),
|
||||
@@ -408,12 +280,9 @@ export class LogsListPresenter extends BasePresenter {
|
||||
const clampedTo = effectiveTo > new Date() ? new Date() : effectiveTo;
|
||||
const toNs = convertDateToNanoseconds(clampedTo);
|
||||
|
||||
// Only use inserted_at for partition pruning if v2
|
||||
if (isClickhouseV2) {
|
||||
queryBuilder.prewhere("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
|
||||
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
|
||||
});
|
||||
}
|
||||
queryBuilder.where("inserted_at <= {insertedAtEnd: DateTime64(3)}", {
|
||||
insertedAtEnd: convertDateToClickhouseDateTime(clampedTo),
|
||||
});
|
||||
|
||||
queryBuilder.where("start_time <= {toTime: String}", {
|
||||
toTime: formatNanosecondsForClickhouse(toNs),
|
||||
@@ -427,19 +296,18 @@ export class LogsListPresenter extends BasePresenter {
|
||||
});
|
||||
}
|
||||
|
||||
// Run IDs filter (from Postgres lookup)
|
||||
if (runIds && runIds.length > 0) {
|
||||
queryBuilder.where("run_id IN {runIds: Array(String)}", { runIds });
|
||||
// Run ID filter
|
||||
if (runId && runId !== "") {
|
||||
queryBuilder.where("run_id = {runId: String}", { runId });
|
||||
}
|
||||
|
||||
// Case-insensitive search in message, attributes, and status fields
|
||||
if (search && search.trim() !== "") {
|
||||
const searchTerm = search.trim();
|
||||
const searchTerm = escapeClickHouseString(search.trim()).toLowerCase();
|
||||
queryBuilder.where(
|
||||
"(message ilike {searchPattern: String} OR attributes_text ilike {searchPattern: String} OR status = {statusTerm: String})",
|
||||
"(lower(message) like {searchPattern: String} OR lower(attributes_text) like {searchPattern: String})",
|
||||
{
|
||||
searchPattern: `%${searchTerm}%`,
|
||||
statusTerm: searchTerm.toUpperCase(),
|
||||
searchPattern: `%${searchTerm}%`
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -447,7 +315,6 @@ export class LogsListPresenter extends BasePresenter {
|
||||
if (levels && levels.length > 0) {
|
||||
const conditions: string[] = [];
|
||||
const params: Record<string, string[]> = {};
|
||||
const hasErrorOrCancelledLevel = levels.includes("ERROR") || levels.includes("CANCELLED");
|
||||
|
||||
for (const level of levels) {
|
||||
const filter = levelToKindsAndStatuses(level);
|
||||
@@ -457,11 +324,10 @@ export class LogsListPresenter extends BasePresenter {
|
||||
const kindsKey = `kinds_${level}`;
|
||||
let kindCondition = `kind IN {${kindsKey}: Array(String)}`;
|
||||
|
||||
// For TRACE: exclude error/cancelled traces if ERROR/CANCELLED not explicitly selected
|
||||
if (level === "TRACE" && !hasErrorOrCancelledLevel) {
|
||||
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
|
||||
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
|
||||
}
|
||||
|
||||
kindCondition += ` AND status NOT IN {excluded_statuses: Array(String)}`;
|
||||
params["excluded_statuses"] = ["ERROR", "CANCELLED"];
|
||||
|
||||
|
||||
levelConditions.push(kindCondition);
|
||||
params[kindsKey] = filter.kinds;
|
||||
@@ -486,28 +352,34 @@ export class LogsListPresenter extends BasePresenter {
|
||||
// Debug logs are available only to admins
|
||||
if (includeDebugLogs === false) {
|
||||
queryBuilder.where("kind NOT IN {debugKinds: Array(String)}", {
|
||||
debugKinds: ["DEBUG_EVENT", "LOG_DEBUG"],
|
||||
debugKinds: ["DEBUG_EVENT"],
|
||||
});
|
||||
}
|
||||
|
||||
queryBuilder.where("kind NOT IN {debugSpans: Array(String)}", {
|
||||
debugSpans: ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"],
|
||||
});
|
||||
|
||||
// kindCondition += ` `;
|
||||
// params["excluded_statuses"] = ["SPAN", "ANCESTOR_OVERRIDE", "SPAN_EVENT"];
|
||||
|
||||
|
||||
queryBuilder.where("NOT (kind = 'SPAN' AND status = 'PARTIAL')");
|
||||
|
||||
// Cursor pagination
|
||||
const decodedCursor = cursor ? decodeCursor(cursor) : null;
|
||||
if (decodedCursor) {
|
||||
queryBuilder.where(
|
||||
"(start_time, trace_id, span_id, run_id) < ({cursorStartTime: String}, {cursorTraceId: String}, {cursorSpanId: String}, {cursorRunId: String})",
|
||||
"(environment_id, toUnixTimestamp(start_time), trace_id) < ({cursorEnvId: String}, {cursorUnixTimestamp: Int64}, {cursorTraceId: String})",
|
||||
{
|
||||
cursorStartTime: decodedCursor.startTime,
|
||||
cursorEnvId: decodedCursor.environmentId,
|
||||
cursorUnixTimestamp: decodedCursor.unixTimestamp,
|
||||
cursorTraceId: decodedCursor.traceId,
|
||||
cursorSpanId: decodedCursor.spanId,
|
||||
cursorRunId: decodedCursor.runId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
queryBuilder.orderBy("start_time DESC, trace_id DESC, span_id DESC, run_id DESC");
|
||||
|
||||
queryBuilder.orderBy("environment_id DESC, toUnixTimestamp(start_time) DESC, trace_id DESC");
|
||||
// Limit + 1 to check if there are more results
|
||||
queryBuilder.limit(pageSize + 1);
|
||||
|
||||
@@ -525,11 +397,11 @@ export class LogsListPresenter extends BasePresenter {
|
||||
let nextCursor: string | undefined;
|
||||
if (hasMore && logs.length > 0) {
|
||||
const lastLog = logs[logs.length - 1];
|
||||
const unixTimestamp = Math.floor(new Date(lastLog.start_time).getTime() / 1000);
|
||||
nextCursor = encodeCursor({
|
||||
startTime: lastLog.start_time,
|
||||
environmentId,
|
||||
unixTimestamp,
|
||||
traceId: lastLog.trace_id,
|
||||
spanId: lastLog.span_id,
|
||||
runId: lastLog.run_id,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -587,8 +459,6 @@ export class LogsListPresenter extends BasePresenter {
|
||||
})),
|
||||
filters: {
|
||||
tasks: tasks || [],
|
||||
versions: versions || [],
|
||||
statuses: statuses || [],
|
||||
levels: levels || [],
|
||||
from: effectiveFrom,
|
||||
to: effectiveTo,
|
||||
@@ -596,6 +466,10 @@ export class LogsListPresenter extends BasePresenter {
|
||||
hasFilters,
|
||||
hasAnyLogs: transformedLogs.length > 0,
|
||||
searchTerm: search,
|
||||
retention: retentionLimitDays !== undefined ? {
|
||||
limitDays: retentionLimitDays,
|
||||
wasClamped: wasClampedByRetention,
|
||||
} : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ export type QueryHistoryItem = {
|
||||
scope: QueryScope;
|
||||
createdAt: Date;
|
||||
userName: string | null;
|
||||
/** AI-generated title summarizing the query */
|
||||
title: string | null;
|
||||
/** Time filter settings */
|
||||
filterPeriod: string | null;
|
||||
filterFrom: Date | null;
|
||||
@@ -24,6 +26,7 @@ export class QueryPresenter extends BasePresenter {
|
||||
id: true,
|
||||
query: true,
|
||||
scope: true,
|
||||
title: true,
|
||||
createdAt: true,
|
||||
filterPeriod: true,
|
||||
filterFrom: true,
|
||||
@@ -43,6 +46,7 @@ export class QueryPresenter extends BasePresenter {
|
||||
scope: q.scope.toLowerCase() as QueryScope,
|
||||
createdAt: q.createdAt,
|
||||
userName: q.user?.displayName ?? q.user?.name ?? null,
|
||||
title: q.title,
|
||||
filterPeriod: q.filterPeriod,
|
||||
filterFrom: q.filterFrom,
|
||||
filterTo: q.filterTo,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type Project } from "~/models/project.server";
|
||||
import { type User } from "~/models/user.server";
|
||||
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
import { BasePresenter } from "./basePresenter.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
@@ -48,7 +48,7 @@ export class RegionsPresenter extends BasePresenter {
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const getFlag = makeFlags(this._replica);
|
||||
const getFlag = makeFlag(this._replica);
|
||||
const defaultWorkerInstanceGroupId = await getFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
});
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { PrismaClient, prisma } from "~/db.server";
|
||||
import { type PrismaClient, prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { singleton } from "~/utils/singleton";
|
||||
import { createSSELoader } from "~/utils/sse";
|
||||
import { createSSELoader, SendFunction } from "~/utils/sse";
|
||||
import { throttle } from "~/utils/throttle";
|
||||
import { tracePubSub } from "~/v3/services/tracePubSub.server";
|
||||
|
||||
const PING_INTERVAL = 1000;
|
||||
const STREAM_TIMEOUT = 30 * 1000; // 30 seconds
|
||||
const PING_INTERVAL = 5_000;
|
||||
const STREAM_TIMEOUT = 30_000;
|
||||
|
||||
export class RunStreamPresenter {
|
||||
#prismaClient: PrismaClient;
|
||||
@@ -49,36 +49,40 @@ export class RunStreamPresenter {
|
||||
// Subscribe to trace updates
|
||||
const { unsubscribe, eventEmitter } = await tracePubSub.subscribeToTrace(run.traceId);
|
||||
|
||||
// Store throttled send function and message listener for cleanup
|
||||
let throttledSend: ReturnType<typeof throttle> | undefined;
|
||||
// Only send max every 1 second
|
||||
const throttledSend = throttle(
|
||||
(args: { send: SendFunction; event?: string; data: string }) => {
|
||||
try {
|
||||
args.send({ event: args.event, data: args.data });
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE in RunStreamPresenter", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Abort the stream on send error
|
||||
context.controller.abort("Send error");
|
||||
}
|
||||
},
|
||||
1000
|
||||
);
|
||||
|
||||
let messageListener: ((event: string) => void) | undefined;
|
||||
|
||||
return {
|
||||
initStream: ({ send }) => {
|
||||
// Create throttled send function
|
||||
throttledSend = throttle((args: { event?: string; data: string }) => {
|
||||
try {
|
||||
send(args);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name !== "TypeError") {
|
||||
logger.debug("Error sending SSE in RunStreamPresenter", {
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
stack: error.stack,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
// Abort the stream on send error
|
||||
context.controller.abort("Send error");
|
||||
}
|
||||
}, 1000);
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
|
||||
// Set up message listener for pub/sub events
|
||||
messageListener = (event: string) => {
|
||||
throttledSend?.({ data: event });
|
||||
throttledSend({ send, event: "message", data: event });
|
||||
};
|
||||
eventEmitter.addListener("message", messageListener);
|
||||
|
||||
@@ -88,7 +92,8 @@ export class RunStreamPresenter {
|
||||
iterator: ({ send }) => {
|
||||
// Send ping to keep connection alive
|
||||
try {
|
||||
send({ event: "ping", data: new Date().toISOString() });
|
||||
// Send an actual message so the client refreshes
|
||||
throttledSend({ send, event: "message", data: new Date().toISOString() });
|
||||
} catch (error) {
|
||||
// If we can't send a ping, the connection is likely dead
|
||||
return false;
|
||||
|
||||
+109
-1
@@ -3,7 +3,16 @@ import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson, useTypedLoaderData } from "remix-typedjson";
|
||||
import { useEffect, useState, useRef, useCallback } from "react";
|
||||
import { S2, S2Error } from "@s2-dev/streamstore";
|
||||
import { Clipboard, ClipboardCheck, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import {
|
||||
Clipboard,
|
||||
ClipboardCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
TerminalSquareIcon,
|
||||
LayoutDashboardIcon,
|
||||
GitBranchIcon,
|
||||
ServerIcon,
|
||||
} from "lucide-react";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { GitMetadata } from "~/components/GitMetadata";
|
||||
import { RuntimeIcon } from "~/components/RuntimeIcon";
|
||||
@@ -73,6 +82,90 @@ type LogEntry = {
|
||||
level: "info" | "error" | "warn" | "debug";
|
||||
};
|
||||
|
||||
function getTriggeredViaDisplay(triggeredVia: string | null | undefined): {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
} | null {
|
||||
if (!triggeredVia) return null;
|
||||
|
||||
const iconClass = "size-4 text-text-dimmed";
|
||||
|
||||
switch (triggeredVia) {
|
||||
case "cli:manual":
|
||||
return {
|
||||
icon: <TerminalSquareIcon className={iconClass} />,
|
||||
label: "CLI (Manual)",
|
||||
};
|
||||
case "cli:github_actions":
|
||||
return {
|
||||
icon: <GitBranchIcon className={iconClass} />,
|
||||
label: "CLI (GitHub Actions)",
|
||||
};
|
||||
case "cli:gitlab_ci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (GitLab CI)",
|
||||
};
|
||||
case "cli:circleci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (CircleCI)",
|
||||
};
|
||||
case "cli:jenkins":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Jenkins)",
|
||||
};
|
||||
case "cli:azure_pipelines":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Azure Pipelines)",
|
||||
};
|
||||
case "cli:bitbucket_pipelines":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Bitbucket Pipelines)",
|
||||
};
|
||||
case "cli:travis_ci":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Travis CI)",
|
||||
};
|
||||
case "cli:buildkite":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (Buildkite)",
|
||||
};
|
||||
case "cli:ci_other":
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: "CLI (CI)",
|
||||
};
|
||||
case "git_integration:github":
|
||||
return {
|
||||
icon: <GitBranchIcon className={iconClass} />,
|
||||
label: "GitHub Integration",
|
||||
};
|
||||
case "dashboard":
|
||||
return {
|
||||
icon: <LayoutDashboardIcon className={iconClass} />,
|
||||
label: "Dashboard",
|
||||
};
|
||||
default:
|
||||
// Handle any unknown values gracefully
|
||||
if (triggeredVia.startsWith("cli:")) {
|
||||
return {
|
||||
icon: <TerminalSquareIcon className={iconClass} />,
|
||||
label: `CLI (${triggeredVia.replace("cli:", "")})`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: <ServerIcon className={iconClass} />,
|
||||
label: triggeredVia,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { deployment, eventStream } = useTypedLoaderData<typeof loader>();
|
||||
const organization = useOrganization();
|
||||
@@ -408,6 +501,21 @@ export default function Page() {
|
||||
)}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
<Property.Item>
|
||||
<Property.Label>Triggered via</Property.Label>
|
||||
<Property.Value>
|
||||
{(() => {
|
||||
const display = getTriggeredViaDisplay(deployment.triggeredVia);
|
||||
if (!display) return "–";
|
||||
return (
|
||||
<span className="flex items-center gap-1.5">
|
||||
{display.icon}
|
||||
{display.label}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</Property.Value>
|
||||
</Property.Item>
|
||||
</Property.Table>
|
||||
</div>
|
||||
|
||||
|
||||
+253
-133
@@ -1,5 +1,6 @@
|
||||
import { type LoaderFunctionArgs , redirect} from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useFetcher, useNavigation, useLocation } from "@remix-run/react";
|
||||
import { type LoaderFunctionArgs, redirect } from "@remix-run/server-runtime";
|
||||
import { type MetaFunction, useFetcher, useNavigation, useLocation, Form } from "@remix-run/react";
|
||||
import { XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { ServiceValidationError } from "~/v3/services/baseService.server";
|
||||
import {
|
||||
TypedAwait,
|
||||
@@ -8,41 +9,41 @@ import {
|
||||
useTypedLoaderData,
|
||||
} from "remix-typedjson";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
|
||||
import { LogsListPresenter } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import type { LogLevel } from "~/utils/logUtils";
|
||||
import { $replica, prisma } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import {
|
||||
setRootOnlyFilterPreference,
|
||||
uiPreferencesStorage,
|
||||
} from "~/services/preferences/uiPreferences.server";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useState, useTransition } from "react";
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { RunsFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { LogsTable } from "~/components/logs/LogsTable";
|
||||
import type { LogEntry } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { LogDetailView } from "~/components/logs/LogDetailView";
|
||||
import { LogsSearchInput } from "~/components/logs/LogsSearchInput";
|
||||
import { LogsLevelFilter } from "~/components/logs/LogsLevelFilter";
|
||||
import { LogsTaskFilter } from "~/components/logs/LogsTaskFilter";
|
||||
import { LogsRunIdFilter } from "~/components/logs/LogsRunIdFilter";
|
||||
import { TimeFilter } from "~/components/runs/v3/SharedFilters";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "~/components/primitives/Resizable";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
|
||||
// Valid log levels for filtering
|
||||
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
|
||||
@@ -58,6 +59,7 @@ export const meta: MetaFunction = () => {
|
||||
];
|
||||
};
|
||||
|
||||
// TODO: Move this to a more appropriate shared location
|
||||
async function hasLogsPageAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
@@ -97,7 +99,6 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = user.id;
|
||||
const isAdmin = user.admin || user.isImpersonating;
|
||||
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasLogsPageAccess(
|
||||
@@ -121,53 +122,58 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
// Get search term, levels, and showDebug from query params
|
||||
// Get filters from query params
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const runId = url.searchParams.get("runId") ?? undefined;
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const levels = parseLevelsFromUrl(url);
|
||||
const showDebug = url.searchParams.get("showDebug") === "true";
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
const from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
const to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
|
||||
// Get the user's plan to determine log retention limit
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const presenter = new LogsListPresenter($replica, clickhouseClient);
|
||||
|
||||
const listPromise = presenter.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
search,
|
||||
levels,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
}).catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: "Failed to load logs. Please refresh and try again." };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
const session = await setRootOnlyFilterPreference(filters.rootOnly, request);
|
||||
const cookieValue = await uiPreferencesStorage.commitSession(session);
|
||||
|
||||
return typeddefer(
|
||||
{
|
||||
data: listPromise,
|
||||
rootOnlyDefault: filters.rootOnly,
|
||||
filters,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
const listPromise = presenter
|
||||
.call(project.organizationId, environment.id, {
|
||||
userId,
|
||||
projectId: project.id,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
runId,
|
||||
search,
|
||||
levels,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Set-Cookie": cookieValue,
|
||||
},
|
||||
}
|
||||
);
|
||||
retentionLimitDays,
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ServiceValidationError) {
|
||||
return { error: error.message };
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
|
||||
return typeddefer({
|
||||
data: listPromise,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
defaultPeriod: "1h",
|
||||
});
|
||||
};
|
||||
|
||||
export default function Page() {
|
||||
const { data, rootOnlyDefault, isAdmin, showDebug, defaultPeriod } = useTypedLoaderData<typeof loader>();
|
||||
const { data, isAdmin, showDebug, defaultPeriod } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
@@ -183,7 +189,7 @@ export default function Page() {
|
||||
<div className="my-2 flex items-center justify-center">
|
||||
<div className="mx-auto flex items-center gap-2">
|
||||
<Spinner />
|
||||
<Paragraph variant="small">Loading logs</Paragraph>
|
||||
<Paragraph variant="small">Loading logs…</Paragraph>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -192,10 +198,17 @@ export default function Page() {
|
||||
<TypedAwait
|
||||
resolve={data}
|
||||
errorElement={
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
Unable to load your logs. Please refresh the page or try again in a moment.
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
@@ -203,21 +216,35 @@ export default function Page() {
|
||||
// Check if result contains an error
|
||||
if ("error" in result) {
|
||||
return (
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_auto_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="flex items-center justify-center px-3 py-12">
|
||||
<Callout variant="error" className="max-w-fit">
|
||||
{result.error}
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LogsList
|
||||
list={result}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_1fr] overflow-hidden">
|
||||
<FiltersBar
|
||||
list={result}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<LogsList
|
||||
list={result}
|
||||
isAdmin={isAdmin}
|
||||
showDebug={showDebug}
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</TypedAwait>
|
||||
@@ -227,15 +254,117 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
function RetentionNotice({
|
||||
logCount,
|
||||
retentionDays,
|
||||
}: {
|
||||
logCount: number;
|
||||
retentionDays: number;
|
||||
}) {
|
||||
return (
|
||||
<Paragraph variant="extra-small" className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span className="text-text-dimmed">
|
||||
Showing last {retentionDays} {retentionDays === 1 ? 'day' : 'days'}
|
||||
</span>
|
||||
<a
|
||||
href="https://trigger.dev/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-text-link hover:underline"
|
||||
>
|
||||
Upgrade
|
||||
</a>
|
||||
</Paragraph>
|
||||
);
|
||||
}
|
||||
|
||||
function FiltersBar({
|
||||
list,
|
||||
rootOnlyDefault,
|
||||
isAdmin,
|
||||
showDebug,
|
||||
defaultPeriod,
|
||||
}: {
|
||||
list?: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>;
|
||||
isAdmin: boolean;
|
||||
showDebug: boolean;
|
||||
defaultPeriod?: string;
|
||||
}) {
|
||||
const location = useOptimisticLocation();
|
||||
const searchParams = new URLSearchParams(location.search);
|
||||
const hasFilters =
|
||||
searchParams.has("tasks") ||
|
||||
searchParams.has("runId") ||
|
||||
searchParams.has("search") ||
|
||||
searchParams.has("levels") ||
|
||||
searchParams.has("period") ||
|
||||
searchParams.has("from") ||
|
||||
searchParams.has("to");
|
||||
|
||||
const handleDebugToggle = useCallback((checked: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (checked) {
|
||||
url.searchParams.set("showDebug", "true");
|
||||
} else {
|
||||
url.searchParams.delete("showDebug");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-x-2 border-b border-grid-bright p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
{list ? (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={list.possibleTasks} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} />
|
||||
<LogsLevelFilter/>
|
||||
<LogsSearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LogsTaskFilter possibleTasks={[]} />
|
||||
<LogsRunIdFilter />
|
||||
<TimeFilter defaultPeriod={defaultPeriod} />
|
||||
<LogsLevelFilter/>
|
||||
<LogsSearchInput />
|
||||
{hasFilters && (
|
||||
<Form className="h-6">
|
||||
<Button variant="secondary/small" LeadingIcon={XMarkIcon} tooltip="Clear all filters" />
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{list?.retention?.wasClamped && (
|
||||
<RetentionNotice
|
||||
logCount={list.logs.length}
|
||||
retentionDays={list.retention.limitDays}
|
||||
/>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Debug"
|
||||
checked={showDebug}
|
||||
onCheckedChange={handleDebugToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsList({
|
||||
list,
|
||||
}: {
|
||||
list: Exclude<Awaited<UseDataFunctionReturn<typeof loader>["data"]>, { error: string }>; //exclude error, it is handled
|
||||
rootOnlyDefault: boolean;
|
||||
isAdmin: boolean;
|
||||
showDebug: boolean;
|
||||
defaultPeriod?: string;
|
||||
@@ -253,37 +382,50 @@ function LogsList({
|
||||
// Selected log state - managed locally to avoid triggering navigation
|
||||
const [selectedLogId, setSelectedLogId] = useState<string | undefined>();
|
||||
|
||||
const handleDebugToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (checked) {
|
||||
url.searchParams.set("showDebug", "true");
|
||||
} else {
|
||||
url.searchParams.delete("showDebug");
|
||||
}
|
||||
window.location.href = url.toString();
|
||||
},
|
||||
[]
|
||||
);
|
||||
// Track which filter state (search params) the current fetcher request corresponds to
|
||||
const fetcherFilterStateRef = useRef<string>(location.search);
|
||||
|
||||
// Clear accumulated logs immediately when filters change (for instant visual feedback)
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs([]);
|
||||
setNextCursor(undefined);
|
||||
// Close side panel when filters change to avoid showing a log that's no longer visible
|
||||
setSelectedLogId(undefined);
|
||||
}, [location.search]);
|
||||
|
||||
// Reset accumulated logs when the initial list changes (e.g., filters change)
|
||||
// Populate accumulated logs when new data arrives
|
||||
useEffect(() => {
|
||||
setAccumulatedLogs(list.logs);
|
||||
setNextCursor(list.pagination.next);
|
||||
}, [list.logs, list.pagination.next]);
|
||||
|
||||
// Clear log parameter from URL when selectedLogId is cleared
|
||||
useEffect(() => {
|
||||
if (!selectedLogId) {
|
||||
const url = new URL(window.location.href);
|
||||
if (url.searchParams.has("log")) {
|
||||
url.searchParams.delete("log");
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}
|
||||
}
|
||||
}, [selectedLogId]);
|
||||
|
||||
// Append new logs when fetcher completes (with deduplication)
|
||||
useEffect(() => {
|
||||
if (fetcher.data && fetcher.state === "idle") {
|
||||
// Ignore fetcher data if it was loaded for a different filter state
|
||||
if (fetcherFilterStateRef.current !== location.search) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existingIds = new Set(accumulatedLogs.map((log) => log.id));
|
||||
const newLogs = fetcher.data.logs.filter((log) => !existingIds.has(log.id));
|
||||
if (newLogs.length > 0) {
|
||||
setAccumulatedLogs((prev) => [...prev, ...newLogs]);
|
||||
setNextCursor(fetcher.data.pagination.next);
|
||||
}
|
||||
setNextCursor(fetcher.data.pagination.next);
|
||||
}
|
||||
}, [fetcher.data, fetcher.state, accumulatedLogs]);
|
||||
}, [fetcher.data, fetcher.state, accumulatedLogs, location.search]);
|
||||
|
||||
// Build resource URL for loading more
|
||||
const loadMoreUrl = useMemo(() => {
|
||||
@@ -297,27 +439,26 @@ function LogsList({
|
||||
|
||||
const handleLoadMore = useCallback(() => {
|
||||
if (loadMoreUrl && fetcher.state === "idle") {
|
||||
// Store the current filter state before loading
|
||||
fetcherFilterStateRef.current = location.search;
|
||||
fetcher.load(loadMoreUrl);
|
||||
}
|
||||
}, [loadMoreUrl, fetcher]);
|
||||
}, [loadMoreUrl, fetcher, location.search]);
|
||||
|
||||
const selectedLog = useMemo(() => {
|
||||
if (!selectedLogId) return undefined;
|
||||
return accumulatedLogs.find((log) => log.id === selectedLogId);
|
||||
}, [selectedLogId, accumulatedLogs]);
|
||||
|
||||
const updateUrlWithLog = useCallback(
|
||||
(logId: string | undefined) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (logId) {
|
||||
url.searchParams.set("log", logId);
|
||||
} else {
|
||||
url.searchParams.delete("log");
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
},
|
||||
[]
|
||||
);
|
||||
const updateUrlWithLog = useCallback((logId: string | undefined) => {
|
||||
const url = new URL(window.location.href);
|
||||
if (logId) {
|
||||
url.searchParams.set("log", logId);
|
||||
} else {
|
||||
url.searchParams.delete("log");
|
||||
}
|
||||
window.history.replaceState(null, "", url.toString());
|
||||
}, []);
|
||||
|
||||
const handleLogSelect = useCallback(
|
||||
(logId: string) => {
|
||||
@@ -339,51 +480,30 @@ function LogsList({
|
||||
return (
|
||||
<ResizablePanelGroup orientation="horizontal" className="max-h-full">
|
||||
<ResizablePanel id="logs-main" min="200px">
|
||||
<div className="grid h-full max-h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
{/* Filters */}
|
||||
<div className="flex items-start justify-between gap-x-2 p-2">
|
||||
<div className="flex flex-row flex-wrap items-center gap-1">
|
||||
<RunsFilters
|
||||
possibleTasks={list.possibleTasks}
|
||||
bulkActions={list.bulkActions}
|
||||
hasFilters={list.hasFilters}
|
||||
rootOnlyDefault={rootOnlyDefault}
|
||||
hideSearch
|
||||
defaultPeriod={defaultPeriod}
|
||||
/>
|
||||
<LogsLevelFilter showDebug={showDebug} />
|
||||
<LogsSearchInput />
|
||||
</div>
|
||||
{isAdmin && (
|
||||
<Switch
|
||||
variant="small"
|
||||
label="Debug"
|
||||
checked={showDebug}
|
||||
onCheckedChange={handleDebugToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<LogsTable
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</div>
|
||||
<LogsTable
|
||||
key={location.search}
|
||||
logs={accumulatedLogs}
|
||||
searchTerm={list.searchTerm}
|
||||
isLoading={isLoading}
|
||||
isLoadingMore={fetcher.state === "loading"}
|
||||
hasMore={!!nextCursor}
|
||||
onLoadMore={handleLoadMore}
|
||||
selectedLogId={selectedLogId}
|
||||
onLogSelect={handleLogSelect}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
|
||||
{/* Side panel for log details */}
|
||||
{selectedLogId && (
|
||||
<>
|
||||
<ResizableHandle id="logs-handle" />
|
||||
<ResizablePanel id="log-detail" min="300px" default="430px" max="600px" isStaticAtRest>
|
||||
<Suspense fallback={<div className="flex h-full items-center justify-center"><Spinner /></div>}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<Spinner />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LogDetailView
|
||||
logId={selectedLogId}
|
||||
initialLog={selectedLog}
|
||||
|
||||
+1
-2
@@ -48,7 +48,7 @@ LIMIT 20`,
|
||||
total_cost,
|
||||
usage_duration,
|
||||
machine,
|
||||
created_at
|
||||
triggered_at
|
||||
FROM runs
|
||||
WHERE triggered_at > now() - INTERVAL 7 DAY
|
||||
ORDER BY total_cost DESC
|
||||
@@ -79,4 +79,3 @@ export function ExamplesContent({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+59
-30
@@ -36,53 +36,82 @@ export function QueryHelpSidebar({
|
||||
onValueChange={onTabChange}
|
||||
className="flex min-h-0 flex-col overflow-hidden pt-1"
|
||||
>
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger value="ai" variant="underline" layoutId="query-help-tabs">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="guide" variant="underline" layoutId="query-help-tabs">
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="schema" variant="underline" layoutId="query-help-tabs">
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger value="examples" variant="underline" layoutId="query-help-tabs">
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
<div className="h-fit overflow-x-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
<ClientTabsList variant="underline" className="mx-3 shrink-0">
|
||||
<ClientTabsTrigger
|
||||
value="ai"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<AISparkleIcon className="size-4" /> AI
|
||||
</div>
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="guide"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Writing TRQL
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="schema"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Table schema
|
||||
</ClientTabsTrigger>
|
||||
<ClientTabsTrigger
|
||||
value="examples"
|
||||
variant="underline"
|
||||
layoutId="query-help-tabs"
|
||||
className="shrink-0"
|
||||
>
|
||||
Examples
|
||||
</ClientTabsTrigger>
|
||||
</ClientTabsList>
|
||||
</div>
|
||||
<ClientTabsContent
|
||||
value="ai"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
<div className="min-w-64 p-3">
|
||||
<AITabContent
|
||||
onQueryGenerated={onQueryGenerated}
|
||||
onTimeFilterChange={onTimeFilterChange}
|
||||
getCurrentQuery={getCurrentQuery}
|
||||
aiFixRequest={aiFixRequest}
|
||||
/>
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="guide"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
<div className="min-w-64 p-3">
|
||||
<TRQLGuideContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="schema"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<TableSchemaContent />
|
||||
<div className="min-w-64 p-3">
|
||||
<TableSchemaContent />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="examples"
|
||||
className="min-h-0 flex-1 overflow-y-auto p-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
<div className="min-w-64 p-3">
|
||||
<ExamplesContent onTryExample={onTryExample} />
|
||||
</div>
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+23
-11
@@ -36,9 +36,14 @@ const SQL_KEYWORDS = [
|
||||
];
|
||||
|
||||
function highlightSQL(query: string): React.ReactNode[] {
|
||||
// Normalize whitespace for display (let CSS line-clamp handle truncation)
|
||||
const normalized = query.replace(/\s+/g, " ").slice(0, 200);
|
||||
const suffix = "";
|
||||
// Normalize: collapse multiple spaces/tabs to single space, but preserve newlines
|
||||
// Then trim each line and limit total length
|
||||
const normalized = query
|
||||
.split("\n")
|
||||
.map((line) => line.replace(/[ \t]+/g, " ").trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.join("\n")
|
||||
.slice(0, 500);
|
||||
|
||||
// Create a regex pattern that matches keywords as whole words (case insensitive)
|
||||
const keywordPattern = new RegExp(
|
||||
@@ -69,10 +74,6 @@ function highlightSQL(query: string): React.ReactNode[] {
|
||||
parts.push(normalized.slice(lastIndex));
|
||||
}
|
||||
|
||||
if (suffix) {
|
||||
parts.push(suffix);
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
@@ -118,10 +119,21 @@ export function QueryHistoryPopover({
|
||||
}}
|
||||
className="flex w-full items-center gap-2 rounded-sm px-2 py-2 outline-none transition-colors focus-custom hover:bg-charcoal-900"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-start overflow-hidden">
|
||||
<p className="line-clamp-2 w-full break-words text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
<div className="flex flex-1 flex-col items-start gap-0.5 overflow-hidden">
|
||||
{item.title ? (
|
||||
<>
|
||||
<p className="w-full truncate text-left text-sm font-medium text-text-bright">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-text-dimmed">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="line-clamp-4 w-full whitespace-pre-wrap text-left font-mono text-xs text-[#9b99ff]">
|
||||
{highlightSQL(item.query)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 text-xs text-text-dimmed">
|
||||
<span className="capitalize">{item.scope}</span>
|
||||
{valueLabel && <span>· {valueLabel}</span>}
|
||||
|
||||
+432
-170
@@ -1,13 +1,23 @@
|
||||
import { ArrowDownTrayIcon, ArrowsPointingInIcon, ArrowsPointingOutIcon, ArrowTrendingUpIcon, ClipboardIcon } from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata, WhereClauseFallback } from "@internal/clickhouse";
|
||||
import {
|
||||
ArrowDownTrayIcon,
|
||||
ArrowsPointingOutIcon,
|
||||
ArrowTrendingUpIcon,
|
||||
ClipboardIcon,
|
||||
TableCellsIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { OutputColumnMetadata } from "@internal/clickhouse";
|
||||
import { type WhereClauseCondition } from "@internal/tsql";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import {
|
||||
redirect,
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
} from "@remix-run/server-runtime";
|
||||
import parse from "parse-duration";
|
||||
import { forwardRef, useCallback, useEffect, useImperativeHandle, useRef, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { typedjson, useTypedFetcher, useTypedLoaderData } from "remix-typedjson";
|
||||
import simplur from "simplur";
|
||||
import { z } from "zod";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { AlphaTitle } from "~/components/AlphaBadge";
|
||||
@@ -21,9 +31,7 @@ import { autoFormatSQL, TSQLEditor } from "~/components/code/TSQLEditor";
|
||||
import { TSQLResultsTable } from "~/components/code/TSQLResultsTable";
|
||||
import { EnvironmentLabel } from "~/components/environments/EnvironmentLabel";
|
||||
import { PageBody, PageContainer } from "~/components/layout/AppLayout";
|
||||
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Callout } from "~/components/primitives/Callout";
|
||||
import { Card } from "~/components/primitives/charts/Card";
|
||||
import {
|
||||
@@ -32,6 +40,7 @@ import {
|
||||
ClientTabsList,
|
||||
ClientTabsTrigger,
|
||||
} from "~/components/primitives/ClientTabs";
|
||||
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import { NavBar, PageTitle } from "~/components/primitives/PageHeader";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
@@ -49,27 +58,30 @@ import {
|
||||
import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
import { Switch } from "~/components/primitives/Switch";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { TimeFilter, timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { QueryPresenter, type QueryHistoryItem } from "~/presenters/v3/QueryPresenter.server";
|
||||
import type { action as titleAction } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.query.ai-title";
|
||||
import { getLimit } from "~/services/platform.v3.server";
|
||||
import { executeQuery, type QueryScope } from "~/services/queryService.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { downloadFile, rowsToCSV, rowsToJSON } from "~/utils/dataExport";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
import { EnvironmentParamSchema, organizationBillingPath } from "~/utils/pathBuilder";
|
||||
import { canAccessQuery } from "~/v3/canAccessQuery.server";
|
||||
import { querySchemas } from "~/v3/querySchemas";
|
||||
import { useCurrentPlan } from "../_app.orgs.$organizationSlug/route";
|
||||
import { QueryHelpSidebar } from "./QueryHelpSidebar";
|
||||
import { QueryHistoryPopover } from "./QueryHistoryPopover";
|
||||
import type { AITimeFilter } from "./types";
|
||||
import { formatQueryStats } from "./utils";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import parse from "parse-duration";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogPortal, DialogTrigger } from "~/components/primitives/Dialog";
|
||||
import { DialogOverlay } from "@radix-ui/react-dialog";
|
||||
import { formatDurationNanoseconds } from "@trigger.dev/core/v3";
|
||||
|
||||
/** Convert a Date or ISO string to ISO string format */
|
||||
function toISOString(value: Date | string): string {
|
||||
@@ -79,40 +91,6 @@ function toISOString(value: Date | string): string {
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
async function hasQueryAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
isImpersonating: boolean,
|
||||
organizationSlug: string
|
||||
): Promise<boolean> {
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check organization feature flags
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization?.featureFlags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flags = organization.featureFlags as Record<string, unknown>;
|
||||
const hasQueryAccessResult = validateFeatureFlagValue(
|
||||
FEATURE_FLAG.hasQueryAccess,
|
||||
flags.hasQueryAccess
|
||||
);
|
||||
|
||||
return hasQueryAccessResult.success && hasQueryAccessResult.data === true;
|
||||
}
|
||||
|
||||
const scopeOptions = [
|
||||
{ value: "environment", label: "Environment" },
|
||||
{ value: "project", label: "Project" },
|
||||
@@ -123,12 +101,12 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasQueryAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
throw redirect("/");
|
||||
}
|
||||
@@ -159,12 +137,21 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
|
||||
return typedjson({
|
||||
defaultQuery,
|
||||
defaultPeriod: await getDefaultPeriod(project.organizationId),
|
||||
history,
|
||||
isAdmin,
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
});
|
||||
};
|
||||
|
||||
const DEFAULT_PERIOD = "7d";
|
||||
async function getDefaultPeriod(organizationId: string): Promise<string> {
|
||||
const idealDefaultPeriodDays = 7;
|
||||
const maxQueryPeriod = await getLimit(organizationId, "queryPeriodDays", 30);
|
||||
if (maxQueryPeriod < idealDefaultPeriodDays) {
|
||||
return `${maxQueryPeriod}d`;
|
||||
}
|
||||
return `${idealDefaultPeriodDays}d`;
|
||||
}
|
||||
|
||||
const ActionSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
@@ -179,12 +166,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const canAccess = await hasQueryAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
const canAccess = await canAccessQuery({
|
||||
userId: user.id,
|
||||
isAdmin: user.admin,
|
||||
isImpersonating: user.isImpersonating,
|
||||
organizationSlug,
|
||||
});
|
||||
if (!canAccess) {
|
||||
return typedjson(
|
||||
{
|
||||
@@ -193,8 +180,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
@@ -209,8 +198,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
@@ -225,8 +216,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
@@ -250,8 +243,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -263,31 +258,54 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
const explain = explainParam === "true" && isAdmin;
|
||||
|
||||
// Build time filter fallback for triggered_at column
|
||||
const defaultPeriod = await getDefaultPeriod(project.organizationId);
|
||||
const timeFilter = timeFilters({
|
||||
period: period ?? undefined,
|
||||
from: from ?? undefined,
|
||||
to: to ?? undefined,
|
||||
defaultPeriod: DEFAULT_PERIOD,
|
||||
defaultPeriod,
|
||||
});
|
||||
|
||||
let triggeredAtFallback: WhereClauseFallback;
|
||||
if (timeFilter.from && timeFilter.to) {
|
||||
// Both from and to specified - use BETWEEN
|
||||
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
|
||||
} else if (timeFilter.from) {
|
||||
// Only from specified
|
||||
triggeredAtFallback = { op: "gte", value: timeFilter.from };
|
||||
} else if (timeFilter.to) {
|
||||
// Only to specified
|
||||
triggeredAtFallback = { op: "lte", value: timeFilter.to };
|
||||
} else {
|
||||
// Calculate the effective "from" date the user is requesting (for period clipping check)
|
||||
// This is null only when the user specifies just a "to" date (rare case)
|
||||
let requestedFromDate: Date | null = null;
|
||||
if (timeFilter.from) {
|
||||
requestedFromDate = new Date(timeFilter.from);
|
||||
} else if (!timeFilter.to) {
|
||||
// Period specified (or default) - calculate from now
|
||||
const periodMs = parse(timeFilter.period ?? DEFAULT_PERIOD) ?? 7 * 24 * 60 * 60 * 1000;
|
||||
triggeredAtFallback = { op: "gte", value: new Date(Date.now() - periodMs) };
|
||||
const periodMs = parse(timeFilter.period ?? defaultPeriod) ?? 7 * 24 * 60 * 60 * 1000;
|
||||
requestedFromDate = new Date(Date.now() - periodMs);
|
||||
}
|
||||
|
||||
// Build the fallback WHERE condition based on what the user specified
|
||||
let triggeredAtFallback: WhereClauseCondition;
|
||||
if (timeFilter.from && timeFilter.to) {
|
||||
triggeredAtFallback = { op: "between", low: timeFilter.from, high: timeFilter.to };
|
||||
} else if (timeFilter.from) {
|
||||
triggeredAtFallback = { op: "gte", value: timeFilter.from };
|
||||
} else if (timeFilter.to) {
|
||||
triggeredAtFallback = { op: "lte", value: timeFilter.to };
|
||||
} else {
|
||||
triggeredAtFallback = { op: "gte", value: requestedFromDate! };
|
||||
}
|
||||
|
||||
const maxQueryPeriod = await getLimit(project.organizationId, "queryPeriodDays", 30);
|
||||
const maxQueryPeriodDate = new Date(Date.now() - maxQueryPeriod * 24 * 60 * 60 * 1000);
|
||||
|
||||
// Check if the requested time period exceeds the plan limit
|
||||
const periodClipped = requestedFromDate !== null && requestedFromDate < maxQueryPeriodDate;
|
||||
|
||||
// Force tenant isolation and time period limits
|
||||
const enforcedWhereClause = {
|
||||
organization_id: { op: "eq", value: project.organizationId },
|
||||
project_id:
|
||||
scope === "project" || scope === "environment" ? { op: "eq", value: project.id } : undefined,
|
||||
environment_id: scope === "environment" ? { op: "eq", value: environment.id } : undefined,
|
||||
triggered_at: { op: "gte", value: maxQueryPeriodDate },
|
||||
} satisfies Record<string, WhereClauseCondition | undefined>;
|
||||
|
||||
try {
|
||||
const [error, result] = await executeQuery({
|
||||
const [error, result, queryId] = await executeQuery({
|
||||
name: "query-page",
|
||||
query,
|
||||
schema: z.record(z.any()),
|
||||
@@ -298,6 +316,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
projectId: project.id,
|
||||
environmentId: environment.id,
|
||||
explain,
|
||||
enforcedWhereClause,
|
||||
whereClauseFallback: {
|
||||
triggered_at: triggeredAtFallback,
|
||||
},
|
||||
@@ -323,8 +342,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
@@ -336,8 +358,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: result.columns,
|
||||
stats: result.stats,
|
||||
hiddenColumns: result.hiddenColumns ?? null,
|
||||
reachedMaxRows: result.reachedMaxRows,
|
||||
explainOutput: result.explainOutput ?? null,
|
||||
generatedSql: result.generatedSql ?? null,
|
||||
queryId,
|
||||
periodClipped: periodClipped ? maxQueryPeriod : null,
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Unknown error executing query";
|
||||
@@ -348,8 +373,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
|
||||
columns: null,
|
||||
stats: null,
|
||||
hiddenColumns: null,
|
||||
reachedMaxRows: null,
|
||||
explainOutput: null,
|
||||
generatedSql: null,
|
||||
queryId: null,
|
||||
periodClipped: null,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
@@ -368,18 +396,45 @@ interface QueryEditorFormHandle {
|
||||
const QueryEditorForm = forwardRef<
|
||||
QueryEditorFormHandle,
|
||||
{
|
||||
defaultPeriod: string;
|
||||
defaultQuery: string;
|
||||
defaultScope: QueryScope;
|
||||
defaultTimeFilter?: { period?: string; from?: string; to?: string };
|
||||
history: QueryHistoryItem[];
|
||||
fetcher: ReturnType<typeof useTypedFetcher<typeof action>>;
|
||||
isAdmin: boolean;
|
||||
onQuerySubmit?: () => void;
|
||||
onHistorySelected?: (item: QueryHistoryItem) => void;
|
||||
}
|
||||
>(function QueryEditorForm({ defaultQuery, defaultScope, defaultTimeFilter, history, fetcher, isAdmin }, ref) {
|
||||
>(function QueryEditorForm(
|
||||
{
|
||||
defaultPeriod,
|
||||
defaultQuery,
|
||||
defaultScope,
|
||||
defaultTimeFilter,
|
||||
history,
|
||||
fetcher,
|
||||
isAdmin,
|
||||
onQuerySubmit,
|
||||
onHistorySelected,
|
||||
},
|
||||
ref
|
||||
) {
|
||||
const isLoading = fetcher.state === "submitting" || fetcher.state === "loading";
|
||||
const [query, setQuery] = useState(defaultQuery);
|
||||
const [scope, setScope] = useState<QueryScope>(defaultScope);
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const prevFetcherState = useRef(fetcher.state);
|
||||
const plan = useCurrentPlan();
|
||||
const maxPeriodDays = plan?.v3Subscription?.plan?.limits?.queryPeriodDays?.number;
|
||||
|
||||
// Notify parent when query is submitted (for title generation)
|
||||
useEffect(() => {
|
||||
if (prevFetcherState.current !== "submitting" && fetcher.state === "submitting") {
|
||||
onQuerySubmit?.();
|
||||
}
|
||||
prevFetcherState.current = fetcher.state;
|
||||
}, [fetcher.state, onQuerySubmit]);
|
||||
|
||||
// Get time filter values - initialize from props (which may come from history)
|
||||
const [period, setPeriod] = useState<string | undefined>(defaultTimeFilter?.period);
|
||||
@@ -406,18 +461,23 @@ const QueryEditorForm = forwardRef<
|
||||
[query]
|
||||
);
|
||||
|
||||
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
|
||||
setQuery(item.query);
|
||||
setScope(item.scope);
|
||||
// Apply time filter from history item
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
setPeriod(item.filterPeriod ?? undefined);
|
||||
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
|
||||
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
|
||||
}, []);
|
||||
const handleHistorySelected = useCallback(
|
||||
(item: QueryHistoryItem) => {
|
||||
setQuery(item.query);
|
||||
setScope(item.scope);
|
||||
// Apply time filter from history item
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
setPeriod(item.filterPeriod ?? undefined);
|
||||
setFrom(item.filterFrom ? toISOString(item.filterFrom) : undefined);
|
||||
setTo(item.filterTo ? toISOString(item.filterTo) : undefined);
|
||||
// Notify parent about history selection (for title)
|
||||
onHistorySelected?.(item);
|
||||
},
|
||||
[onHistorySelected]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 bg-charcoal-900 pb-2">
|
||||
<div className="flex h-full flex-col gap-2 bg-charcoal-900 pb-2">
|
||||
<TSQLEditor
|
||||
defaultValue={query}
|
||||
onChange={setQuery}
|
||||
@@ -425,10 +485,13 @@ const QueryEditorForm = forwardRef<
|
||||
linterEnabled={true}
|
||||
showCopyButton={true}
|
||||
showClearButton={true}
|
||||
minHeight="200px"
|
||||
className="min-h-[200px]"
|
||||
className="min-h-0 flex-1"
|
||||
/>
|
||||
<fetcher.Form ref={formRef} method="post" className="flex items-center justify-between gap-2 px-2">
|
||||
<fetcher.Form
|
||||
ref={formRef}
|
||||
method="post"
|
||||
className="flex items-center justify-between gap-2 px-2"
|
||||
>
|
||||
<input type="hidden" name="query" value={query} />
|
||||
<input type="hidden" name="scope" value={scope} />
|
||||
{/* Pass time filter values to action */}
|
||||
@@ -468,14 +531,16 @@ const QueryEditorForm = forwardRef<
|
||||
</Select>
|
||||
{queryHasTriggeredAt ? (
|
||||
<SimpleTooltip
|
||||
button={<Button variant="tertiary/small" disabled={true} type="button">
|
||||
Set in query
|
||||
</Button>}
|
||||
button={
|
||||
<Button variant="tertiary/small" disabled={true} type="button">
|
||||
Set in query
|
||||
</Button>
|
||||
}
|
||||
content="Your query includes a WHERE clause with triggered_at so this filter is disabled."
|
||||
/>
|
||||
) : (
|
||||
<TimeFilter
|
||||
defaultPeriod={DEFAULT_PERIOD}
|
||||
defaultPeriod={defaultPeriod}
|
||||
labelName="Triggered"
|
||||
hideLabel
|
||||
period={period}
|
||||
@@ -492,6 +557,7 @@ const QueryEditorForm = forwardRef<
|
||||
fetcher.submit(formRef.current);
|
||||
}
|
||||
}}
|
||||
maxPeriodDays={maxPeriodDays}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
@@ -510,22 +576,28 @@ const QueryEditorForm = forwardRef<
|
||||
});
|
||||
|
||||
export default function Page() {
|
||||
const { defaultQuery, history, isAdmin } = useTypedLoaderData<typeof loader>();
|
||||
const { defaultPeriod, defaultQuery, history, isAdmin, maxRows } =
|
||||
useTypedLoaderData<typeof loader>();
|
||||
const fetcher = useTypedFetcher<typeof action>();
|
||||
const results = fetcher.data;
|
||||
const { replace: replaceSearchParams } = useSearchParams();
|
||||
|
||||
const organization = useOrganization();
|
||||
const project = useProject();
|
||||
const environment = useEnvironment();
|
||||
|
||||
// Use most recent history item if available, otherwise fall back to defaults
|
||||
const initialQuery = history.length > 0 ? history[0].query : defaultQuery;
|
||||
const initialScope: QueryScope = history.length > 0 ? history[0].scope : "environment";
|
||||
const initialTimeFilter = history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
const initialTimeFilter =
|
||||
history.length > 0
|
||||
? {
|
||||
period: history[0].filterPeriod ?? undefined,
|
||||
// Note: filterFrom/filterTo might be Date objects or ISO strings depending on serialization
|
||||
from: history[0].filterFrom ? toISOString(history[0].filterFrom) : undefined,
|
||||
to: history[0].filterTo ? toISOString(history[0].filterTo) : undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const editorRef = useRef<QueryEditorFormHandle>(null);
|
||||
const [prettyFormatting, setPrettyFormatting] = useState(true);
|
||||
@@ -534,6 +606,53 @@ export default function Page() {
|
||||
const [sidebarTab, setSidebarTab] = useState<string>("ai");
|
||||
const [aiFixRequest, setAiFixRequest] = useState<{ prompt: string; key: number } | null>(null);
|
||||
|
||||
// Title generation state
|
||||
const titleFetcher = useFetcher<typeof titleAction>();
|
||||
const isTitleLoading = titleFetcher.state !== "idle";
|
||||
const generatedTitle = titleFetcher.data?.title;
|
||||
const [historyTitle, setHistoryTitle] = useState<string | null>(
|
||||
history.length > 0 ? history[0].title ?? null : null
|
||||
);
|
||||
|
||||
// Effective title: history title takes precedence, then generated
|
||||
const queryTitle = historyTitle ?? generatedTitle ?? null;
|
||||
|
||||
// Track whether we should generate a title for the current results
|
||||
const [shouldGenerateTitle, setShouldGenerateTitle] = useState(false);
|
||||
|
||||
// Trigger title generation when query succeeds (only for new queries, not history)
|
||||
useEffect(() => {
|
||||
if (
|
||||
results?.rows &&
|
||||
!results.error &&
|
||||
results.queryId &&
|
||||
shouldGenerateTitle &&
|
||||
!historyTitle &&
|
||||
titleFetcher.state === "idle"
|
||||
) {
|
||||
const currentQuery = editorRef.current?.getQuery();
|
||||
if (currentQuery) {
|
||||
titleFetcher.submit(
|
||||
{ query: currentQuery, queryId: results.queryId },
|
||||
{
|
||||
method: "POST",
|
||||
action: `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/query/ai-title`,
|
||||
encType: "application/json",
|
||||
}
|
||||
);
|
||||
setShouldGenerateTitle(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
results,
|
||||
shouldGenerateTitle,
|
||||
historyTitle,
|
||||
titleFetcher,
|
||||
organization.slug,
|
||||
project.slug,
|
||||
environment.slug,
|
||||
]);
|
||||
|
||||
const handleTryFixError = useCallback((errorMessage: string) => {
|
||||
setSidebarTab("ai");
|
||||
setAiFixRequest((prev) => ({
|
||||
@@ -576,6 +695,18 @@ export default function Page() {
|
||||
setChartConfig(config);
|
||||
}, []);
|
||||
|
||||
// Handle query submission - prepare for title generation
|
||||
const handleQuerySubmit = useCallback(() => {
|
||||
setHistoryTitle(null); // Clear history title when running a new query
|
||||
setShouldGenerateTitle(true); // Enable title generation for new results
|
||||
}, []);
|
||||
|
||||
// Handle history selection - use existing title if available
|
||||
const handleHistorySelected = useCallback((item: QueryHistoryItem) => {
|
||||
setHistoryTitle(item.title ?? null);
|
||||
setShouldGenerateTitle(false); // Don't generate title for history items
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PageContainer>
|
||||
<NavBar>
|
||||
@@ -584,23 +715,38 @@ export default function Page() {
|
||||
<PageBody scrollable={false}>
|
||||
<ResizablePanelGroup orientation="horizontal" className="h-full max-h-full bg-charcoal-800">
|
||||
<ResizablePanel id="query-main" className="h-full">
|
||||
<div className="grid h-full grid-rows-[auto_1fr] overflow-hidden">
|
||||
<ResizablePanelGroup orientation="vertical" className="h-full overflow-hidden">
|
||||
{/* Query editor - isolated component to prevent re-renders */}
|
||||
<QueryEditorForm
|
||||
ref={editorRef}
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
fetcher={fetcher}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<ResizablePanel
|
||||
id="query-editor"
|
||||
min="100px"
|
||||
default="300px"
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<QueryEditorForm
|
||||
ref={editorRef}
|
||||
defaultPeriod={defaultPeriod}
|
||||
defaultQuery={initialQuery}
|
||||
defaultScope={initialScope}
|
||||
defaultTimeFilter={initialTimeFilter}
|
||||
history={history}
|
||||
fetcher={fetcher}
|
||||
isAdmin={isAdmin}
|
||||
onQuerySubmit={handleQuerySubmit}
|
||||
onHistorySelected={handleHistorySelected}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="query-editor-handle" />
|
||||
{/* Results */}
|
||||
<div className="grid max-h-full grid-rows-[1fr] overflow-hidden border-t border-grid-dimmed bg-charcoal-800">
|
||||
<ResizablePanel
|
||||
id="query-results"
|
||||
min="200px"
|
||||
className="overflow-hidden bg-charcoal-800"
|
||||
>
|
||||
<ClientTabs
|
||||
value={resultsView}
|
||||
onValueChange={(v) => setResultsView(v as "table" | "graph")}
|
||||
className="grid min-h-0 grid-rows-[auto_1fr] overflow-hidden"
|
||||
className="grid h-full max-h-full min-h-0 grid-rows-[auto_1fr] overflow-hidden"
|
||||
>
|
||||
<ClientTabsList
|
||||
variant="underline"
|
||||
@@ -620,12 +766,22 @@ export default function Page() {
|
||||
{results?.rows ? (
|
||||
<div className="flex flex-1 items-center justify-end gap-2 overflow-hidden border-b border-grid-dimmed pl-3">
|
||||
<div className="flex items-center gap-2 overflow-hidden truncate">
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{results?.rows?.length ? `${results.rows.length} Results` : "Results"}
|
||||
</span>
|
||||
{results.reachedMaxRows ? (
|
||||
<SimpleTooltip
|
||||
buttonClassName="text-warning text-xs"
|
||||
button={`${results.rows.length.toLocaleString()} Results`}
|
||||
content={`Results are limited to ${maxRows.toLocaleString()} rows maximum.`}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{results.rows.length > 0
|
||||
? `${results.rows.length.toLocaleString()} Results`
|
||||
: "Results"}
|
||||
</span>
|
||||
)}
|
||||
{results?.stats && (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{formatQueryStats(results.stats)}
|
||||
{formatDurationNanoseconds(parseInt(results.stats.elapsed_ns, 10))}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -687,19 +843,32 @@ export default function Page() {
|
||||
</div>
|
||||
</div>
|
||||
) : results?.rows && results?.columns ? (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
{results.hiddenColumns && results.hiddenColumns.length > 0 && (
|
||||
<Callout variant="warning" className="m-2 shrink-0 text-sm">
|
||||
<code>SELECT *</code> doesn't return all columns because it's slow. The
|
||||
following columns are not shown:{" "}
|
||||
<span className="font-mono text-xs">
|
||||
{results.hiddenColumns.join(", ")}
|
||||
</span>
|
||||
. Specify them explicitly to include them.
|
||||
</Callout>
|
||||
)}
|
||||
<div className="h-full bg-charcoal-900 p-2">
|
||||
<Card className="h-full overflow-hidden p-0">
|
||||
<div
|
||||
className={`grid h-full max-h-full overflow-hidden bg-charcoal-900 ${
|
||||
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
|
||||
? "grid-rows-[auto_1fr]"
|
||||
: "grid-rows-[1fr]"
|
||||
}`}
|
||||
>
|
||||
<QueryResultsCallouts
|
||||
hiddenColumns={results.hiddenColumns}
|
||||
periodClipped={results.periodClipped}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
<div className="overflow-hidden p-2">
|
||||
<Card className="h-full overflow-hidden px-0 pb-0">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<TableCellsIcon className="size-5 text-indigo-500" />
|
||||
{isTitleLoading ? (
|
||||
<span className="flex items-center gap-2 text-text-dimmed">
|
||||
<Spinner className="size-3" /> Generating title...
|
||||
</span>
|
||||
) : (
|
||||
queryTitle ?? "Results"
|
||||
)}
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content className="min-h-0 flex-1 overflow-hidden p-0">
|
||||
<TSQLResultsTable
|
||||
rows={results.rows}
|
||||
@@ -718,15 +887,30 @@ export default function Page() {
|
||||
</ClientTabsContent>
|
||||
<ClientTabsContent
|
||||
value="graph"
|
||||
className="m-0 grid min-h-0 grid-rows-[1fr] overflow-hidden"
|
||||
className={`m-0 grid h-full max-h-full min-h-0 overflow-hidden bg-charcoal-900 ${
|
||||
results?.rows &&
|
||||
results.rows.length > 0 &&
|
||||
hasQueryResultsCallouts(results.hiddenColumns, results.periodClipped)
|
||||
? "grid-rows-[auto_1fr]"
|
||||
: "grid-rows-[1fr]"
|
||||
}`}
|
||||
>
|
||||
{results?.rows && results?.columns && results.rows.length > 0 ? (
|
||||
<ResultsChart
|
||||
rows={results.rows}
|
||||
columns={results.columns}
|
||||
chartConfig={chartConfig}
|
||||
onChartConfigChange={handleChartConfigChange}
|
||||
/>
|
||||
<>
|
||||
<QueryResultsCallouts
|
||||
hiddenColumns={results.hiddenColumns}
|
||||
periodClipped={results.periodClipped}
|
||||
organizationSlug={organization.slug}
|
||||
/>
|
||||
<ResultsChart
|
||||
rows={results.rows}
|
||||
columns={results.columns}
|
||||
chartConfig={chartConfig}
|
||||
onChartConfigChange={handleChartConfigChange}
|
||||
queryTitle={queryTitle}
|
||||
isTitleLoading={isTitleLoading}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Paragraph variant="small" className="p-4 text-text-dimmed">
|
||||
Run a query to visualize results.
|
||||
@@ -734,13 +918,15 @@ export default function Page() {
|
||||
)}
|
||||
</ClientTabsContent>
|
||||
</ClientTabs>
|
||||
</div>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="query-handle" />
|
||||
<ResizablePanel
|
||||
id="query-help"
|
||||
min="200px"
|
||||
collapsible
|
||||
collapsedSize="20px"
|
||||
default="400px"
|
||||
max="500px"
|
||||
className="w-full"
|
||||
@@ -844,52 +1030,128 @@ function ScopeItem({ scope }: { scope: QueryScope }) {
|
||||
}
|
||||
}
|
||||
|
||||
function QueryResultsCallouts({
|
||||
hiddenColumns,
|
||||
periodClipped,
|
||||
organizationSlug,
|
||||
}: {
|
||||
hiddenColumns: string[] | null | undefined;
|
||||
periodClipped: number | null | undefined;
|
||||
organizationSlug: string;
|
||||
}) {
|
||||
const hasCallouts = (hiddenColumns && hiddenColumns.length > 0) || periodClipped;
|
||||
|
||||
if (!hasCallouts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-2 pt-2">
|
||||
{hiddenColumns && hiddenColumns.length > 0 && (
|
||||
<Callout variant="warning" className="shrink-0 text-sm">
|
||||
<code>SELECT *</code> doesn't return all columns because it's slow. The following columns
|
||||
are not shown: <span className="font-mono text-xs">{hiddenColumns.join(", ")}</span>.
|
||||
Specify them explicitly to include them.
|
||||
</Callout>
|
||||
)}
|
||||
{periodClipped && (
|
||||
<Callout
|
||||
variant="pricing"
|
||||
cta={
|
||||
<LinkButton
|
||||
variant="primary/small"
|
||||
to={organizationBillingPath({ slug: organizationSlug })}
|
||||
>
|
||||
Upgrade
|
||||
</LinkButton>
|
||||
}
|
||||
className="items-center"
|
||||
>
|
||||
{simplur`Results are limited to the last ${periodClipped} day[|s] based on your plan.`}
|
||||
</Callout>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function hasQueryResultsCallouts(
|
||||
hiddenColumns: string[] | null | undefined,
|
||||
periodClipped: number | null | undefined
|
||||
): boolean {
|
||||
return (hiddenColumns && hiddenColumns.length > 0) || !!periodClipped;
|
||||
}
|
||||
|
||||
function ResultsChart({
|
||||
rows,
|
||||
columns,
|
||||
chartConfig,
|
||||
onChartConfigChange,
|
||||
queryTitle,
|
||||
isTitleLoading,
|
||||
}: {
|
||||
rows: Record<string, unknown>[];
|
||||
columns: OutputColumnMetadata[];
|
||||
chartConfig: ChartConfiguration;
|
||||
onChartConfigChange: (config: ChartConfiguration) => void;
|
||||
queryTitle: string | null;
|
||||
isTitleLoading: boolean;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<><ResizablePanelGroup className="h-full overflow-hidden">
|
||||
<ResizablePanel id="chart-results">
|
||||
<div className="h-full bg-charcoal-900 p-2 overflow-hidden">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
|
||||
Chart
|
||||
</div>
|
||||
<Card.Accessory>
|
||||
<Button variant="minimal/small" LeadingIcon={ArrowsPointingOutIcon} onClick={() => setIsOpen(true)} />
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-full flex-1 min-h-0">
|
||||
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} onViewAllLegendItems={() => setIsOpen(true)} />
|
||||
</Card.Content>
|
||||
</Card>
|
||||
const titleContent = isTitleLoading ? (
|
||||
<span className="flex items-center gap-2 text-text-dimmed">
|
||||
<Spinner className="size-3" /> Generating title...
|
||||
</span>
|
||||
) : (
|
||||
queryTitle ?? "Chart"
|
||||
);
|
||||
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="chart-split" />
|
||||
<ResizablePanel id="chart-config" min="50px" default="200px">
|
||||
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
return (
|
||||
<>
|
||||
<ResizablePanelGroup className="overflow-hidden">
|
||||
<ResizablePanel id="chart-results">
|
||||
<div className="h-full overflow-hidden bg-charcoal-900 p-2">
|
||||
<Card className="h-full">
|
||||
<Card.Header>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowTrendingUpIcon className="size-5 text-indigo-500" />
|
||||
{titleContent}
|
||||
</div>
|
||||
<Card.Accessory>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={ArrowsPointingOutIcon}
|
||||
onClick={() => setIsOpen(true)}
|
||||
/>
|
||||
</Card.Accessory>
|
||||
</Card.Header>
|
||||
<Card.Content className="h-full min-h-0 flex-1">
|
||||
<QueryResultsChart
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
config={chartConfig}
|
||||
onViewAllLegendItems={() => setIsOpen(true)}
|
||||
/>
|
||||
</Card.Content>
|
||||
</Card>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle id="chart-split" />
|
||||
<ResizablePanel id="chart-config" min="50px" default="200px">
|
||||
<ChartConfigPanel columns={columns} config={chartConfig} onChange={onChartConfigChange} />
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogContent fullscreen>
|
||||
<DialogHeader>
|
||||
Chart
|
||||
</DialogHeader>
|
||||
<div className="h-full min-h-0 flex-1 overflow-hidden w-full pt-4">
|
||||
<QueryResultsChart rows={rows} columns={columns} config={chartConfig} fullLegend={true} />
|
||||
<DialogHeader>{queryTitle ?? "Chart"}</DialogHeader>
|
||||
<div className="h-full min-h-0 w-full flex-1 overflow-hidden pt-4">
|
||||
<QueryResultsChart
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
config={chartConfig}
|
||||
fullLegend={true}
|
||||
legendScrollable={true}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
export function formatQueryStats(stats: {
|
||||
read_rows: string;
|
||||
read_bytes: string;
|
||||
elapsed_ns: string;
|
||||
byte_seconds: string;
|
||||
}): string {
|
||||
const readRows = parseInt(stats.read_rows, 10);
|
||||
const readBytes = parseInt(stats.read_bytes, 10);
|
||||
const elapsedNs = parseInt(stats.elapsed_ns, 10);
|
||||
const byteSeconds = parseFloat(stats.byte_seconds);
|
||||
|
||||
const elapsedMs = elapsedNs / 1_000_000;
|
||||
const formattedTime =
|
||||
elapsedMs < 1000 ? `${elapsedMs.toFixed(1)}ms` : `${(elapsedMs / 1000).toFixed(2)}s`;
|
||||
const formattedBytes = formatBytes(readBytes);
|
||||
|
||||
return `${readRows.toLocaleString()} rows read · ${formattedBytes} · ${formattedTime} · ${formatBytes(
|
||||
byteSeconds
|
||||
)}s`;
|
||||
}
|
||||
|
||||
export function formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
if (bytes < 0) return "-" + formatBytes(-bytes);
|
||||
const k = 1024;
|
||||
const sizes = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.max(
|
||||
0,
|
||||
Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1)
|
||||
);
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||
}
|
||||
+2
-2
@@ -364,14 +364,14 @@ export default function Page() {
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
valueClassName={env.paused ? "text-warning" : undefined}
|
||||
valueClassName={cn(env.paused ? "text-warning" : undefined, "tabular-nums")}
|
||||
compactThreshold={1000000}
|
||||
/>
|
||||
<BigNumber
|
||||
title="Running"
|
||||
value={environment.running}
|
||||
animate
|
||||
valueClassName={limitClassName}
|
||||
valueClassName={cn(limitClassName, "tabular-nums")}
|
||||
suffix={
|
||||
limitStatus === "burst" ? (
|
||||
<span className={cn(limitClassName, "flex items-center gap-1")}>
|
||||
|
||||
+26
-4
@@ -436,6 +436,24 @@ export default function Page() {
|
||||
);
|
||||
}
|
||||
|
||||
function shouldLiveReload({
|
||||
events,
|
||||
maximumLiveReloadingSetting,
|
||||
run,
|
||||
}: {
|
||||
events: TraceEvent[];
|
||||
maximumLiveReloadingSetting: number;
|
||||
run: { completedAt: string | null };
|
||||
}): boolean {
|
||||
// We don't live reload if there are a ton of spans/logs
|
||||
if (events.length > maximumLiveReloadingSetting) return false;
|
||||
|
||||
// If the run was completed a while ago, we don't need to live reload anymore
|
||||
if (run.completedAt && new Date(run.completedAt).getTime() < Date.now() - 30_000) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function TraceView({
|
||||
run,
|
||||
trace,
|
||||
@@ -453,18 +471,19 @@ function TraceView({
|
||||
|
||||
const { events, duration, rootSpanStatus, rootStartedAt, queuedDuration, overridesBySpanId } =
|
||||
trace;
|
||||
const shouldLiveReload = events.length <= maximumLiveReloadingSetting;
|
||||
|
||||
const changeToSpan = useDebounce((selectedSpan: string) => {
|
||||
replaceSearchParam("span", selectedSpan, { replace: true });
|
||||
}, 250);
|
||||
|
||||
const isLiveReloading = shouldLiveReload({ events, maximumLiveReloadingSetting, run });
|
||||
|
||||
const revalidator = useRevalidator();
|
||||
const streamedEvents = useEventSource(
|
||||
v3RunStreamingPath(organization, project, environment, run),
|
||||
{
|
||||
event: "message",
|
||||
disabled: !shouldLiveReload,
|
||||
disabled: !isLiveReloading,
|
||||
}
|
||||
);
|
||||
useEffect(() => {
|
||||
@@ -511,7 +530,7 @@ function TraceView({
|
||||
rootStartedAt={rootStartedAt ? new Date(rootStartedAt) : undefined}
|
||||
queuedDuration={queuedDuration}
|
||||
environmentType={run.environment.type}
|
||||
shouldLiveReload={shouldLiveReload}
|
||||
shouldLiveReload={isLiveReloading}
|
||||
maximumLiveReloadingSetting={maximumLiveReloadingSetting}
|
||||
rootRun={run.rootTaskRun}
|
||||
parentRun={run.parentTaskRun}
|
||||
@@ -901,6 +920,7 @@ function TasksTreeView({
|
||||
treeScrollRef={treeScrollRef}
|
||||
virtualizer={virtualizer}
|
||||
toggleNodeSelection={toggleNodeSelection}
|
||||
selectedId={selectedId}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
@@ -954,7 +974,7 @@ function TasksTreeView({
|
||||
|
||||
type TimelineViewProps = Pick<
|
||||
TasksTreeViewProps,
|
||||
"totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration"
|
||||
"totalDuration" | "rootSpanStatus" | "events" | "rootStartedAt" | "queuedDuration" | "selectedId"
|
||||
> & {
|
||||
scale: number;
|
||||
parentRef: React.RefObject<HTMLDivElement>;
|
||||
@@ -985,6 +1005,7 @@ function TimelineView({
|
||||
showDurations,
|
||||
treeScrollRef,
|
||||
queuedDuration,
|
||||
selectedId,
|
||||
}: TimelineViewProps) {
|
||||
const timelineContainerRef = useRef<HTMLDivElement>(null);
|
||||
const initialTimelineDimensions = useInitialDimensions(timelineContainerRef);
|
||||
@@ -1023,6 +1044,7 @@ function TimelineView({
|
||||
className="h-full overflow-hidden"
|
||||
minWidth={minTimelineWidth}
|
||||
maxWidth={maxTimelineWidth}
|
||||
recalculateTrigger={selectedId}
|
||||
>
|
||||
{/* Follows the cursor */}
|
||||
<CurrentTimeIndicator
|
||||
|
||||
+16
@@ -409,6 +409,7 @@ function StandardTaskForm({
|
||||
tags,
|
||||
version,
|
||||
machine,
|
||||
prioritySeconds,
|
||||
},
|
||||
] = useForm({
|
||||
id: "test-task",
|
||||
@@ -730,6 +731,12 @@ function StandardTaskForm({
|
||||
<Hint>Delays run by a specific duration.</Hint>
|
||||
<FormError id={delaySeconds.errorId}>{delaySeconds.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label variant="small">Priority</Label>
|
||||
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
|
||||
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
|
||||
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label variant="small">TTL</Label>
|
||||
<DurationPicker
|
||||
@@ -872,6 +879,7 @@ function ScheduledTaskForm({
|
||||
tags,
|
||||
version,
|
||||
machine,
|
||||
prioritySeconds,
|
||||
},
|
||||
] = useForm({
|
||||
id: "test-task-scheduled",
|
||||
@@ -1237,6 +1245,14 @@ function ScheduledTaskForm({
|
||||
<Hint>Limits concurrency by creating a separate queue for each value of the key.</Hint>
|
||||
<FormError id={concurrencyKey.errorId}>{concurrencyKey.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={prioritySeconds.id} variant="small">
|
||||
Priority
|
||||
</Label>
|
||||
<DurationPicker name={prioritySeconds.name} id={prioritySeconds.id} />
|
||||
<Hint>Sets the priority of the run. Higher values mean higher priority.</Hint>
|
||||
<FormError id={prioritySeconds.errorId}>{prioritySeconds.error}</FormError>
|
||||
</InputGroup>
|
||||
<InputGroup>
|
||||
<Label htmlFor={ttlSeconds.id} variant="small">
|
||||
TTL
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function Project() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[14rem_1fr] overflow-hidden">
|
||||
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
|
||||
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
|
||||
<SideMenu
|
||||
user={{ ...user, isImpersonating }}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { ExclamationTriangleIcon } from "@heroicons/react/20/solid";
|
||||
import { json } from "@remix-run/node";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useCallback, useEffect } from "react";
|
||||
import { LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { SimpleTooltip } from "~/components/primitives/Tooltip";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { BetterStackClient } from "~/services/betterstack/betterstack.server";
|
||||
|
||||
@@ -21,51 +23,61 @@ export async function loader() {
|
||||
});
|
||||
}
|
||||
|
||||
export function IncidentStatusPanel() {
|
||||
export function IncidentStatusPanel({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
if (!isManagedCloud) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fetcher = useFetcher<typeof loader>();
|
||||
|
||||
const fetchIncidents = useCallback(() => {
|
||||
if (fetcher.state === "idle") {
|
||||
fetcher.load("/resources/incidents");
|
||||
}
|
||||
}, [fetcher]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isManagedCloud) return;
|
||||
|
||||
fetchIncidents();
|
||||
|
||||
const interval = setInterval(fetchIncidents, 60 * 1000); // 1 minute
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [isManagedCloud, fetchIncidents]);
|
||||
|
||||
const operational = fetcher.data?.operational ?? true;
|
||||
|
||||
if (!isManagedCloud || operational) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!operational && (
|
||||
<Popover>
|
||||
<div className="p-1">
|
||||
{/* Expanded panel - animated height and opacity */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="p-1"
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? 0 : "auto",
|
||||
opacity: isCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
<Paragraph variant="small/bright" className="text-warning">
|
||||
Active incident
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<Paragraph variant="extra-small/bright" className="text-warning/80">
|
||||
Our team is working on resolving the issue. Check our status page for more
|
||||
information.
|
||||
</Paragraph>
|
||||
|
||||
{/* Button */}
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to="https://status.trigger.dev"
|
||||
@@ -77,7 +89,59 @@ export function IncidentStatusPanel() {
|
||||
</LinkButton>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{/* Collapsed button - animated height and opacity */}
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isCollapsed ? "auto" : 0,
|
||||
opacity: isCollapsed ? 1 : 0,
|
||||
}}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<PopoverTrigger className="flex !h-8 w-full items-center justify-center rounded border border-warning/20 bg-warning/10 transition-colors hover:border-warning/30 hover:bg-warning/20">
|
||||
<ExclamationTriangleIcon className="size-5 text-warning" />
|
||||
</PopoverTrigger>
|
||||
}
|
||||
content="Active incident"
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
disableHoverableContent
|
||||
asChild
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
<PopoverContent side="right" sideOffset={8} align="start" className="!min-w-0 w-52 p-0">
|
||||
<IncidentPopoverContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function IncidentPopoverContent() {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded border border-warning/20 bg-warning/5 p-2 pt-1.5">
|
||||
<div className="flex items-center gap-1 border-b border-warning/20 pb-1 text-warning">
|
||||
<ExclamationTriangleIcon className="size-4" />
|
||||
<Paragraph variant="small/bright" className="text-warning">
|
||||
Active incident
|
||||
</Paragraph>
|
||||
</div>
|
||||
<Paragraph variant="extra-small/bright" className="text-warning/80">
|
||||
Our team is working on resolving the issue. Check our status page for more information.
|
||||
</Paragraph>
|
||||
<LinkButton
|
||||
variant="secondary/small"
|
||||
to="https://status.trigger.dev"
|
||||
target="_blank"
|
||||
fullWidth
|
||||
className="border-warning/20 bg-warning/10 hover:!border-warning/30 hover:!bg-warning/20"
|
||||
>
|
||||
<span className="text-warning">View status page</span>
|
||||
</LinkButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { typedjson } from "remix-typedjson";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { FEATURE_FLAG, validateFeatureFlagValue } from "~/v3/featureFlags.server";
|
||||
import { OrganizationParamsSchema } from "~/utils/pathBuilder";
|
||||
|
||||
async function hasLogsPageAccess(
|
||||
userId: string,
|
||||
isAdmin: boolean,
|
||||
isImpersonating: boolean,
|
||||
organizationSlug: string
|
||||
): Promise<boolean> {
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organization?.featureFlags) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const flags = organization.featureFlags as Record<string, unknown>;
|
||||
const hasLogsPageAccessResult = validateFeatureFlagValue(
|
||||
FEATURE_FLAG.hasLogsPageAccess,
|
||||
flags.hasLogsPageAccess
|
||||
);
|
||||
|
||||
return hasLogsPageAccessResult.success && hasLogsPageAccessResult.data === true;
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const user = await requireUser(request);
|
||||
const { organizationSlug } = OrganizationParamsSchema.parse(params);
|
||||
|
||||
const canViewLogsPage = user.admin || user.isImpersonating || await hasLogsPageAccess(
|
||||
user.id,
|
||||
user.admin,
|
||||
user.isImpersonating,
|
||||
organizationSlug
|
||||
);
|
||||
|
||||
return typedjson({ canViewLogsPage });
|
||||
};
|
||||
+25
-9
@@ -4,13 +4,13 @@ import { requireUser, requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { getRunFiltersFromRequest } from "~/presenters/RunFilters.server";
|
||||
import { LogsListPresenter, type LogLevel, LogsListOptionsSchema } from "~/presenters/v3/LogsListPresenter.server";
|
||||
import { $replica } from "~/db.server";
|
||||
import { clickhouseClient } from "~/services/clickhouseInstance.server";
|
||||
import { getCurrentPlan } from "~/services/platform.v3.server";
|
||||
|
||||
// Valid log levels for filtering
|
||||
const validLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
const validLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR"];
|
||||
|
||||
function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
const levelParams = url.searchParams.getAll("levels").filter((v) => v.length > 0);
|
||||
@@ -19,7 +19,10 @@ function parseLevelsFromUrl(url: URL): LogLevel[] | undefined {
|
||||
}
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const user = await requireUser(request);
|
||||
const userId = user.id;
|
||||
const isAdmin = user?.admin || user?.isImpersonating;
|
||||
|
||||
const { projectParam, organizationSlug, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
@@ -32,28 +35,41 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
throw new Response("Environment not found", { status: 404 });
|
||||
}
|
||||
|
||||
const user = await requireUser(request);
|
||||
const isAdmin = user?.admin || user?.isImpersonating;
|
||||
// Get the user's plan to determine log retention limit
|
||||
const plan = await getCurrentPlan(project.organizationId);
|
||||
const retentionLimitDays = plan?.v3Subscription?.plan?.limits.logRetentionDays.number ?? 30;
|
||||
|
||||
const filters = await getRunFiltersFromRequest(request);
|
||||
|
||||
// Get search term, cursor, levels, and showDebug from query params
|
||||
// Get filters from query params
|
||||
const url = new URL(request.url);
|
||||
const tasks = url.searchParams.getAll("tasks").filter((t) => t.length > 0);
|
||||
const runId = url.searchParams.get("runId") ?? undefined;
|
||||
const search = url.searchParams.get("search") ?? undefined;
|
||||
const cursor = url.searchParams.get("cursor") ?? undefined;
|
||||
const levels = parseLevelsFromUrl(url);
|
||||
const showDebug = url.searchParams.get("showDebug") === "true";
|
||||
const period = url.searchParams.get("period") ?? undefined;
|
||||
const fromStr = url.searchParams.get("from");
|
||||
const toStr = url.searchParams.get("to");
|
||||
let from = fromStr ? parseInt(fromStr, 10) : undefined;
|
||||
let to = toStr ? parseInt(toStr, 10) : undefined;
|
||||
|
||||
if (Number.isNaN(from)) from = undefined;
|
||||
if (Number.isNaN(to)) to = undefined;
|
||||
|
||||
const options = LogsListOptionsSchema.parse({
|
||||
userId,
|
||||
projectId: project.id,
|
||||
...filters,
|
||||
tasks: tasks.length > 0 ? tasks : undefined,
|
||||
runId,
|
||||
search,
|
||||
cursor,
|
||||
period,
|
||||
from,
|
||||
to,
|
||||
levels,
|
||||
includeDebugLogs: isAdmin && showDebug,
|
||||
defaultPeriod: "1h",
|
||||
retentionLimitDays,
|
||||
}) as any; // Validated by LogsListOptionsSchema at runtime
|
||||
|
||||
const presenter = new LogsListPresenter($replica, clickhouseClient);
|
||||
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { tryCatch } from "@trigger.dev/core/utils";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
|
||||
import { AIQueryTitleService } from "~/v3/services/aiQueryTitleService.server";
|
||||
|
||||
const RequestSchema = z.object({
|
||||
query: z.string().min(1, "Query is required"),
|
||||
queryId: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function action({ request, params }: ActionFunctionArgs) {
|
||||
const userId = await requireUserId(request);
|
||||
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
|
||||
|
||||
// Parse the request body
|
||||
const [error, data] = await tryCatch(request.json());
|
||||
if (error) {
|
||||
return json({ success: false as const, error: error.message, title: null }, { status: 400 });
|
||||
}
|
||||
const submission = RequestSchema.safeParse(data);
|
||||
|
||||
if (!submission.success) {
|
||||
return json(
|
||||
{ success: false as const, error: "Invalid request data", title: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
|
||||
if (!project) {
|
||||
return json(
|
||||
{ success: false as const, error: "Project not found", title: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, envParam, userId);
|
||||
if (!environment) {
|
||||
return json(
|
||||
{ success: false as const, error: "Environment not found", title: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return json(
|
||||
{ success: false as const, error: "OpenAI API key is not configured", title: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { query, queryId } = submission.data;
|
||||
|
||||
const service = new AIQueryTitleService(openai(env.AI_RUN_FILTER_MODEL ?? "gpt-4o-mini"));
|
||||
|
||||
const result = await service.generateTitle(query);
|
||||
|
||||
if (!result.success) {
|
||||
return json({ success: false as const, error: result.error, title: null }, { status: 500 });
|
||||
}
|
||||
|
||||
// Strip leading/trailing quotes that AI sometimes adds
|
||||
const title = result.title.replace(/^["']|["']$/g, "");
|
||||
|
||||
// If a queryId was provided, update the CustomerQuery record with the title
|
||||
if (queryId) {
|
||||
await prisma.customerQuery.update({
|
||||
where: { id: queryId, organizationId: project.organizationId },
|
||||
data: { title },
|
||||
});
|
||||
}
|
||||
|
||||
return json({ success: true as const, title, error: null });
|
||||
}
|
||||
+47
-34
@@ -73,6 +73,7 @@ import { useOrganization } from "~/hooks/useOrganizations";
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useHasAdminAccess } from "~/hooks/useUser";
|
||||
import { useCanViewLogsPage } from "~/hooks/useCanViewLogsPage";
|
||||
import { redirectWithErrorMessage } from "~/models/message.server";
|
||||
import { type Span, SpanPresenter, type SpanRun } from "~/presenters/v3/SpanPresenter.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
@@ -319,6 +320,7 @@ function RunBody({
|
||||
const { value, replace } = useSearchParams();
|
||||
const tab = value("tab");
|
||||
const resetFetcher = useTypedFetcher<typeof resetIdempotencyKeyAction>();
|
||||
const canViewLogsPage = useCanViewLogsPage();
|
||||
|
||||
return (
|
||||
<div className="grid h-full max-h-full grid-rows-[2.5rem_2rem_1fr_minmax(3.25rem,auto)] overflow-hidden bg-background-bright">
|
||||
@@ -1012,44 +1014,55 @@ function RunBody({
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
{run.logsDeletedAt === null ? (
|
||||
<div className="flex">
|
||||
canViewLogsPage ? (
|
||||
<div className="flex">
|
||||
<LinkButton
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
variant="secondary/medium"
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
View logs
|
||||
</LinkButton>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
className="rounded-l-none border-l-charcoal-700 px-1.5"
|
||||
>
|
||||
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[140px] p-1" align="end">
|
||||
<PopoverMenuItem
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
title="View logs"
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
title="Download logs"
|
||||
icon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
openInNewTab
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
) : (
|
||||
<LinkButton
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
LeadingIcon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-400"
|
||||
variant="secondary/medium"
|
||||
className="rounded-r-none border-r-0"
|
||||
>
|
||||
View logs
|
||||
Download logs
|
||||
</LinkButton>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary/medium"
|
||||
className="rounded-l-none border-l-charcoal-700 px-1.5"
|
||||
>
|
||||
<ChevronUpIcon className="size-4 transition group-hover/button:text-text-bright" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="min-w-[140px] p-1" align="end">
|
||||
<PopoverMenuItem
|
||||
to={`${v3LogsPath(organization, project, environment)}?runId=${runParam}&from=${
|
||||
new Date(run.createdAt).getTime() - 60000
|
||||
}`}
|
||||
title="View logs"
|
||||
icon={ArrowRightIcon}
|
||||
leadingIconClassName="text-blue-500"
|
||||
/>
|
||||
<PopoverMenuItem
|
||||
to={v3RunDownloadLogsPath({ friendlyId: runParam })}
|
||||
title="Download logs"
|
||||
icon={CloudArrowDownIcon}
|
||||
leadingIconClassName="text-indigo-500"
|
||||
openInNewTab
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { json, type ActionFunctionArgs } from "@remix-run/node";
|
||||
import { z } from "zod";
|
||||
import { updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
|
||||
import { requireUser } from "~/services/session.server";
|
||||
|
||||
// Transforms form data string "true"/"false" to boolean, or undefined if not present
|
||||
const booleanFromFormData = z
|
||||
.enum(["true", "false"])
|
||||
.transform((val) => val === "true")
|
||||
.optional();
|
||||
|
||||
const RequestSchema = z.object({
|
||||
isCollapsed: booleanFromFormData,
|
||||
manageSectionCollapsed: booleanFromFormData,
|
||||
});
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const user = await requireUser(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const rawData = Object.fromEntries(formData);
|
||||
|
||||
const result = RequestSchema.safeParse(rawData);
|
||||
if (!result.success) {
|
||||
return json({ success: false, error: "Invalid request data" }, { status: 400 });
|
||||
}
|
||||
|
||||
await updateSideMenuPreferences({
|
||||
user,
|
||||
isCollapsed: result.data.isCollapsed,
|
||||
manageSectionCollapsed: result.data.manageSectionCollapsed,
|
||||
});
|
||||
|
||||
return json({ success: true });
|
||||
}
|
||||
@@ -199,6 +199,7 @@ export const action: ActionFunction = async ({ request, params }) => {
|
||||
idempotencyKeyTTLSeconds: submission.value.idempotencyKeyTTLSeconds,
|
||||
ttlSeconds: submission.value.ttlSeconds,
|
||||
version: submission.value.version,
|
||||
prioritySeconds: submission.value.prioritySeconds,
|
||||
});
|
||||
|
||||
if (!newRun) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { Ratelimit } from "@upstash/ratelimit";
|
||||
import { Request as ExpressRequest, Response as ExpressResponse, NextFunction } from "express";
|
||||
import { createHash } from "node:crypto";
|
||||
@@ -157,10 +157,7 @@ export function authorizationRateLimitMiddleware({
|
||||
limiterConfigOverride,
|
||||
}: Options) {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({
|
||||
persistentMap: new Map(),
|
||||
unstableEvictOnSet: { frequency: 0.001, maxItems: limiterCache?.maxItems ?? 1000 },
|
||||
});
|
||||
const memory = createLRUMemoryStore(limiterCache?.maxItems ?? 1000);
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
connection: {
|
||||
keyPrefix: `cache:${keyPrefix}:rate-limit-cache:`,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type ApiResult, wrapZodFetch } from "@trigger.dev/core/v3/zodfetch";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { z } from "zod";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
@@ -17,7 +17,7 @@ const IncidentSchema = z.object({
|
||||
export type Incident = z.infer<typeof IncidentSchema>;
|
||||
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({ persistentMap: new Map() });
|
||||
const memory = createLRUMemoryStore(100);
|
||||
|
||||
const cache = createCache({
|
||||
query: new Namespace<ApiResult<Incident>>(ctx, {
|
||||
|
||||
@@ -3,6 +3,13 @@ import { prisma } from "~/db.server";
|
||||
import { logger } from "./logger.server";
|
||||
import { type UserFromSession } from "./session.server";
|
||||
|
||||
const SideMenuPreferences = z.object({
|
||||
isCollapsed: z.boolean().default(false),
|
||||
manageSectionCollapsed: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
|
||||
|
||||
const DashboardPreferences = z.object({
|
||||
version: z.literal("1"),
|
||||
currentProjectId: z.string().optional(),
|
||||
@@ -12,6 +19,7 @@ const DashboardPreferences = z.object({
|
||||
currentEnvironment: z.object({ id: z.string() }),
|
||||
})
|
||||
),
|
||||
sideMenu: SideMenuPreferences.optional(),
|
||||
});
|
||||
|
||||
export type DashboardPreferences = z.infer<typeof DashboardPreferences>;
|
||||
@@ -99,3 +107,47 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateSideMenuPreferences({
|
||||
user,
|
||||
isCollapsed,
|
||||
manageSectionCollapsed,
|
||||
}: {
|
||||
user: UserFromSession;
|
||||
isCollapsed?: boolean;
|
||||
manageSectionCollapsed?: boolean;
|
||||
}) {
|
||||
if (user.isImpersonating) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse with schema to apply defaults, then overlay any new values
|
||||
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
|
||||
const updatedSideMenu = SideMenuPreferences.parse({
|
||||
...currentSideMenu,
|
||||
...(isCollapsed !== undefined && { isCollapsed }),
|
||||
...(manageSectionCollapsed !== undefined && { manageSectionCollapsed }),
|
||||
});
|
||||
|
||||
// Only update if something changed
|
||||
if (
|
||||
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
|
||||
updatedSideMenu.manageSectionCollapsed === currentSideMenu.manageSectionCollapsed
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedPreferences: DashboardPreferences = {
|
||||
...user.dashboardPreferences,
|
||||
sideMenu: updatedSideMenu,
|
||||
};
|
||||
|
||||
return prisma.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
dashboardPreferences: updatedPreferences,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
type CurrentPlan,
|
||||
} from "@trigger.dev/platform";
|
||||
import { createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { redirect } from "remix-typedjson";
|
||||
import { z } from "zod";
|
||||
@@ -45,13 +45,7 @@ const client = singleton("billingClient", initializeClient);
|
||||
|
||||
function initializePlatformCache() {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({
|
||||
persistentMap: new Map(),
|
||||
unstableEvictOnSet: {
|
||||
frequency: 0.01,
|
||||
maxItems: 1000,
|
||||
},
|
||||
});
|
||||
const memory = createLRUMemoryStore(1000);
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
connection: {
|
||||
keyPrefix: "tr:cache:platform:v3",
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
type TSQLQueryResult,
|
||||
} from "@internal/clickhouse";
|
||||
import type { CustomerQuerySource } from "@trigger.dev/database";
|
||||
import type { TableSchema } from "@internal/tsql";
|
||||
import type { TableSchema, WhereClauseCondition } from "@internal/tsql";
|
||||
import { type z } from "zod";
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
@@ -56,17 +56,14 @@ function getDefaultClickhouseSettings(): ClickHouseSettings {
|
||||
|
||||
export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
|
||||
ExecuteTSQLOptions<TOut>,
|
||||
"tableSchema" | "organizationId" | "projectId" | "environmentId" | "fieldMappings"
|
||||
"tableSchema" | "fieldMappings"
|
||||
> & {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
tableSchema: TableSchema[];
|
||||
/** The scope of the query - determines tenant isolation */
|
||||
scope: QueryScope;
|
||||
/** Organization ID (required) */
|
||||
organizationId: string;
|
||||
/** Project ID (required for project/environment scope) */
|
||||
projectId: string;
|
||||
/** Environment ID (required for environment scope) */
|
||||
environmentId: string;
|
||||
/** History options for saving query to billing/audit */
|
||||
history?: {
|
||||
/** Where the query originated from */
|
||||
@@ -89,18 +86,27 @@ export type ExecuteQueryOptions<TOut extends z.ZodSchema> = Omit<
|
||||
customOrgConcurrencyLimit?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Extended result type that includes the optional queryId when saved to history
|
||||
*/
|
||||
export type ExecuteQueryResult<T> =
|
||||
| [error: Error, result: null, queryId: null]
|
||||
| [error: null, result: T, queryId: string | null];
|
||||
|
||||
/**
|
||||
* Execute a TSQL query against ClickHouse with tenant isolation
|
||||
* Handles building tenant options, field mappings, and optionally saves to history
|
||||
* Returns [error, result, queryId] where queryId is the CustomerQuery ID if saved to history
|
||||
*/
|
||||
export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
options: ExecuteQueryOptions<TOut>
|
||||
): Promise<TSQLQueryResult<z.output<TOut>>> {
|
||||
): Promise<ExecuteQueryResult<Exclude<TSQLQueryResult<z.output<TOut>>[1], null>>> {
|
||||
const {
|
||||
scope,
|
||||
organizationId,
|
||||
projectId,
|
||||
environmentId,
|
||||
enforcedWhereClause,
|
||||
history,
|
||||
customOrgConcurrencyLimit,
|
||||
whereClauseFallback,
|
||||
@@ -112,39 +118,22 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
const orgLimit = customOrgConcurrencyLimit ?? DEFAULT_ORG_CONCURRENCY_LIMIT;
|
||||
|
||||
// Acquire concurrency slot
|
||||
const acquireResult = await queryConcurrencyLimiter.acquire({
|
||||
key: organizationId,
|
||||
requestId,
|
||||
keyLimit: orgLimit,
|
||||
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
|
||||
});
|
||||
const acquireResult = await queryConcurrencyLimiter.acquire({
|
||||
key: organizationId,
|
||||
requestId,
|
||||
keyLimit: orgLimit,
|
||||
globalLimit: GLOBAL_CONCURRENCY_LIMIT,
|
||||
});
|
||||
|
||||
if (!acquireResult.success) {
|
||||
const errorMessage =
|
||||
acquireResult.reason === "key_limit"
|
||||
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
|
||||
: "We're experiencing a lot of queries at the moment. Please try again later.";
|
||||
return [new QueryError(errorMessage, { query: options.query }), null];
|
||||
}
|
||||
if (!acquireResult.success) {
|
||||
const errorMessage =
|
||||
acquireResult.reason === "key_limit"
|
||||
? `You've exceeded your query concurrency of ${orgLimit} for this organization. Please try again later.`
|
||||
: "We're experiencing a lot of queries at the moment. Please try again later.";
|
||||
return [new QueryError(errorMessage, { query: options.query }), null, null];
|
||||
}
|
||||
|
||||
try {
|
||||
// Build tenant IDs based on scope
|
||||
const tenantOptions: {
|
||||
organizationId: string;
|
||||
projectId?: string;
|
||||
environmentId?: string;
|
||||
} = {
|
||||
organizationId,
|
||||
};
|
||||
|
||||
if (scope === "project" || scope === "environment") {
|
||||
tenantOptions.projectId = projectId;
|
||||
}
|
||||
|
||||
if (scope === "environment") {
|
||||
tenantOptions.environmentId = environmentId;
|
||||
}
|
||||
|
||||
// Build field mappings for project_ref → project_id and environment_id → slug translation
|
||||
const projects = await prisma.project.findMany({
|
||||
where: { organizationId },
|
||||
@@ -163,18 +152,29 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
|
||||
const result = await executeTSQL(clickhouseClient.reader, {
|
||||
...baseOptions,
|
||||
...tenantOptions,
|
||||
enforcedWhereClause,
|
||||
fieldMappings,
|
||||
whereClauseFallback,
|
||||
clickhouseSettings: {
|
||||
...getDefaultClickhouseSettings(),
|
||||
...baseOptions.clickhouseSettings, // Allow caller overrides if needed
|
||||
},
|
||||
querySettings: {
|
||||
maxRows: env.QUERY_CLICKHOUSE_MAX_RETURNED_ROWS,
|
||||
...baseOptions.querySettings, // Allow caller overrides if needed
|
||||
},
|
||||
});
|
||||
|
||||
// If query failed, return early with no queryId
|
||||
if (result[0] !== null) {
|
||||
return [result[0], null, null];
|
||||
}
|
||||
|
||||
let queryId: string | null = null;
|
||||
|
||||
// If query succeeded and history options provided, save to history
|
||||
// Skip history for EXPLAIN queries (admin debugging) and when explicitly skipped (e.g., impersonating)
|
||||
if (result[0] === null && history && !history.skip && !baseOptions.explain) {
|
||||
if (history && !history.skip && !baseOptions.explain) {
|
||||
// Check if this query is the same as the last one saved (avoid duplicate history entries)
|
||||
const lastQuery = await prisma.customerQuery.findFirst({
|
||||
where: {
|
||||
@@ -183,7 +183,7 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
userId: history.userId ?? null,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
|
||||
select: { id: true, query: true, scope: true, filterPeriod: true, filterFrom: true, filterTo: true },
|
||||
});
|
||||
|
||||
const timeFilter = history.timeFilter;
|
||||
@@ -195,17 +195,15 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
lastQuery.filterFrom?.getTime() === (timeFilter?.from?.getTime() ?? undefined) &&
|
||||
lastQuery.filterTo?.getTime() === (timeFilter?.to?.getTime() ?? undefined);
|
||||
|
||||
if (!isDuplicate) {
|
||||
const stats = result[1].stats;
|
||||
const byteSeconds = parseFloat(stats.byte_seconds) || 0;
|
||||
const costInCents = byteSeconds * env.CENTS_PER_QUERY_BYTE_SECOND;
|
||||
|
||||
await prisma.customerQuery.create({
|
||||
if (isDuplicate && lastQuery) {
|
||||
// Return the existing query's ID for duplicate queries
|
||||
queryId = lastQuery.id;
|
||||
} else {
|
||||
const created = await prisma.customerQuery.create({
|
||||
data: {
|
||||
query: options.query,
|
||||
scope: scopeToEnum[scope],
|
||||
stats: { ...stats },
|
||||
costInCents,
|
||||
stats: { ...result[1].stats },
|
||||
source: history.source,
|
||||
organizationId,
|
||||
projectId: scope === "project" || scope === "environment" ? projectId : null,
|
||||
@@ -216,10 +214,11 @@ export async function executeQuery<TOut extends z.ZodSchema>(
|
||||
filterTo: history.timeFilter?.to ?? null,
|
||||
},
|
||||
});
|
||||
queryId = created.id;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
return [null, result[1], queryId];
|
||||
} finally {
|
||||
// Always release the concurrency slot
|
||||
await queryConcurrencyLimiter.release({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import {
|
||||
createCache,
|
||||
createMemoryStore,
|
||||
createLRUMemoryStore,
|
||||
DefaultStatefulContext,
|
||||
Namespace,
|
||||
RedisCacheStore,
|
||||
@@ -97,7 +97,7 @@ function initializeS2RealtimeStreamsCache() {
|
||||
useModernCacheKeyBuilder: true,
|
||||
});
|
||||
|
||||
const memoryStore = createMemoryStore(5000, 0.001);
|
||||
const memoryStore = createLRUMemoryStore(5000);
|
||||
|
||||
return createCache({
|
||||
accessToken: new Namespace<string>(ctx, {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { longPollingFetch } from "~/utils/longPollingFetch";
|
||||
import { logger } from "./logger.server";
|
||||
import { jumpHash } from "@trigger.dev/core/v3/serverOnly";
|
||||
import { Cache, createCache, DefaultStatefulContext, Namespace } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
||||
import { env } from "~/env.server";
|
||||
import { API_VERSIONS, CURRENT_API_VERSION } from "~/api/versions";
|
||||
@@ -84,10 +84,7 @@ export class RealtimeClient {
|
||||
this.#registerCommands();
|
||||
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({
|
||||
persistentMap: new Map(),
|
||||
unstableEvictOnSet: { frequency: 0.01, maxItems: 1000 },
|
||||
});
|
||||
const memory = createLRUMemoryStore(1000);
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
connection: {
|
||||
keyPrefix: "tr:cache:realtime",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Logger, LogLevel } from "@trigger.dev/core/logger";
|
||||
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { RedisCacheStore } from "./unkey/redisCacheStore.server";
|
||||
import { RedisWithClusterOptions } from "~/redis.server";
|
||||
import { validate as uuidValidate, version as uuidVersion } from "uuid";
|
||||
@@ -33,13 +33,7 @@ export class RequestIdempotencyService<TTypes extends string> {
|
||||
: "request-idempotency:";
|
||||
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({
|
||||
persistentMap: new Map(),
|
||||
unstableEvictOnSet: {
|
||||
frequency: 0.001,
|
||||
maxItems: 1000,
|
||||
},
|
||||
});
|
||||
const memory = createLRUMemoryStore(1000);
|
||||
const redisCacheStore = new RedisCacheStore({
|
||||
name: "request-idempotency",
|
||||
connection: {
|
||||
|
||||
@@ -8,7 +8,7 @@ import parseDuration from "parse-duration";
|
||||
import { z } from "zod";
|
||||
import { timeFilters } from "~/components/runs/v3/SharedFilters";
|
||||
import { type PrismaClient, type PrismaClientOrTransaction } from "~/db.server";
|
||||
import { FEATURE_FLAG, makeFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
import { startActiveSpan } from "~/v3/tracer.server";
|
||||
import { logger } from "../logger.server";
|
||||
import { ClickHouseRunsRepository } from "./clickhouseRunsRepository.server";
|
||||
@@ -163,7 +163,7 @@ export class RunsRepository implements IRunsRepository {
|
||||
|
||||
async #getRepository(): Promise<IRunsRepository> {
|
||||
return startActiveSpan("runsRepository.getRepository", async (span) => {
|
||||
const getFlag = makeFlags(this.options.prisma);
|
||||
const getFlag = makeFlag(this.options.prisma);
|
||||
const runsListRepository = await getFlag({
|
||||
key: FEATURE_FLAG.runsListRepository,
|
||||
defaultValue: this.defaultRepository,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { createElement, Fragment, type ReactNode } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
export const LogLevelSchema = z.enum(["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"]);
|
||||
export const LogLevelSchema = z.enum(["DEBUG", "INFO", "WARN", "ERROR"]);
|
||||
export type LogLevel = z.infer<typeof LogLevelSchema>;
|
||||
|
||||
export const validLogLevels: LogLevel[] = ["TRACE", "DEBUG", "INFO", "WARN", "ERROR", "CANCELLED"];
|
||||
export const validLogLevels: LogLevel[] = ["DEBUG", "INFO", "WARN", "ERROR",];
|
||||
|
||||
// Default styles for search highlighting
|
||||
const DEFAULT_HIGHLIGHT_STYLES: React.CSSProperties = {
|
||||
@@ -71,10 +71,6 @@ export function highlightSearchText(
|
||||
|
||||
// Convert ClickHouse kind to display level
|
||||
export function kindToLevel(kind: string, status: string): LogLevel {
|
||||
if (status === "CANCELLED") {
|
||||
return "CANCELLED";
|
||||
}
|
||||
|
||||
// ERROR can come from either kind or status
|
||||
if (kind === "LOG_ERROR" || status === "ERROR") {
|
||||
return "ERROR";
|
||||
@@ -94,7 +90,7 @@ export function kindToLevel(kind: string, status: string): LogLevel {
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
case "SPAN_EVENT":
|
||||
default:
|
||||
return "TRACE";
|
||||
return "INFO";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,47 +105,7 @@ export function getLevelColor(level: LogLevel): string {
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
case "INFO":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
case "TRACE":
|
||||
return "text-charcoal-500 bg-charcoal-800 border-charcoal-700";
|
||||
case "CANCELLED":
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
default:
|
||||
return "text-text-dimmed bg-charcoal-750 border-charcoal-700";
|
||||
}
|
||||
}
|
||||
|
||||
// Event kind badge color styles
|
||||
export function getKindColor(kind: string): string {
|
||||
if (kind === "SPAN") {
|
||||
return "text-purple-400 bg-purple-500/10 border-purple-500/20";
|
||||
}
|
||||
if (kind === "SPAN_EVENT") {
|
||||
return "text-amber-400 bg-amber-500/10 border-amber-500/20";
|
||||
}
|
||||
if (kind.startsWith("LOG_")) {
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/20";
|
||||
}
|
||||
return "text-charcoal-400 bg-charcoal-700 border-charcoal-600";
|
||||
}
|
||||
|
||||
// Get human readable kind label
|
||||
export function getKindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "SPAN":
|
||||
return "Span";
|
||||
case "SPAN_EVENT":
|
||||
return "Event";
|
||||
case "LOG_DEBUG":
|
||||
case "LOG_INFO":
|
||||
case "LOG_WARN":
|
||||
case "LOG_ERROR":
|
||||
case "LOG_LOG":
|
||||
return "Log";
|
||||
case "DEBUG_EVENT":
|
||||
return "Debug";
|
||||
case "ANCESTOR_OVERRIDE":
|
||||
return "Override";
|
||||
default:
|
||||
return kind;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
//From: https://kettanaito.com/blog/debounce-vs-throttle
|
||||
|
||||
/** A very simple throttle. Will execute the function at the end of each period and discard any other calls during that period. */
|
||||
export function throttle(
|
||||
func: (...args: any[]) => void,
|
||||
export function throttle<TArgs extends unknown[]>(
|
||||
func: (...args: TArgs) => void,
|
||||
durationMs: number
|
||||
): (...args: any[]) => void {
|
||||
): (...args: TArgs) => void {
|
||||
let isPrimedToFire = false;
|
||||
|
||||
return (...args: any[]) => {
|
||||
return (...args: TArgs) => {
|
||||
if (!isPrimedToFire) {
|
||||
isPrimedToFire = true;
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { prisma } from "~/db.server";
|
||||
import { env } from "~/env.server";
|
||||
import { FEATURE_FLAG, makeFlag } from "~/v3/featureFlags.server";
|
||||
|
||||
export async function canAccessQuery(options: {
|
||||
userId: string;
|
||||
isAdmin: boolean;
|
||||
isImpersonating: boolean;
|
||||
organizationSlug: string;
|
||||
}): Promise<boolean> {
|
||||
const { userId, isAdmin, isImpersonating, organizationSlug } = options;
|
||||
|
||||
// 1. If it's on then we have access
|
||||
const globallyEnabled = env.QUERY_FEATURE_ENABLED === "1";
|
||||
if (globallyEnabled) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 2. Admins always have access
|
||||
if (isAdmin || isImpersonating) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 3. Check if org/global feature flag is on
|
||||
const org = await prisma.organization.findFirst({
|
||||
where: {
|
||||
slug: organizationSlug,
|
||||
members: { some: { userId } },
|
||||
},
|
||||
select: {
|
||||
featureFlags: true,
|
||||
},
|
||||
});
|
||||
|
||||
const flag = makeFlag();
|
||||
const flagResult = await flag({
|
||||
key: FEATURE_FLAG.hasQueryAccess,
|
||||
defaultValue: false,
|
||||
overrides: (org?.featureFlags as Record<string, unknown>) ?? {},
|
||||
});
|
||||
if (flagResult) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 4. Not enabled anywhere
|
||||
return false;
|
||||
}
|
||||
@@ -5,9 +5,9 @@ import {
|
||||
clickhouseEventRepositoryV2,
|
||||
} from "./clickhouseEventRepositoryInstance.server";
|
||||
import { IEventRepository, TraceEventOptions } from "./eventRepository.types";
|
||||
import { prisma } from "~/db.server";
|
||||
import { prisma } from "~/db.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG, flags } from "../featureFlags.server";
|
||||
import { FEATURE_FLAG, flag } from "../featureFlags.server";
|
||||
import { getTaskEventStore } from "../taskEventStore.server";
|
||||
|
||||
export function resolveEventRepositoryForStore(store: string | undefined): IEventRepository {
|
||||
@@ -24,13 +24,13 @@ export function resolveEventRepositoryForStore(store: string | undefined): IEven
|
||||
return eventRepository;
|
||||
}
|
||||
|
||||
export const EVENT_STORE_TYPES = {
|
||||
POSTGRES: "postgres",
|
||||
CLICKHOUSE: "clickhouse",
|
||||
CLICKHOUSE_V2: "clickhouse_v2",
|
||||
} as const;
|
||||
export const EVENT_STORE_TYPES = {
|
||||
POSTGRES: "postgres",
|
||||
CLICKHOUSE: "clickhouse",
|
||||
CLICKHOUSE_V2: "clickhouse_v2",
|
||||
} as const;
|
||||
|
||||
export type EventStoreType = typeof EVENT_STORE_TYPES[keyof typeof EVENT_STORE_TYPES];
|
||||
export type EventStoreType = (typeof EVENT_STORE_TYPES)[keyof typeof EVENT_STORE_TYPES];
|
||||
|
||||
export async function getConfiguredEventRepository(
|
||||
organizationId: string
|
||||
@@ -122,21 +122,21 @@ export async function getV3EventRepository(
|
||||
async function resolveTaskEventRepositoryFlag(
|
||||
featureFlags: Record<string, unknown> | undefined
|
||||
): Promise<"clickhouse" | "clickhouse_v2" | "postgres"> {
|
||||
const flag = await flags({
|
||||
const flagResult = await flag({
|
||||
key: FEATURE_FLAG.taskEventRepository,
|
||||
defaultValue: env.EVENT_REPOSITORY_DEFAULT_STORE,
|
||||
overrides: featureFlags,
|
||||
});
|
||||
|
||||
if (flag === "clickhouse_v2") {
|
||||
if (flagResult === "clickhouse_v2") {
|
||||
return "clickhouse_v2";
|
||||
}
|
||||
|
||||
if (flag === "clickhouse") {
|
||||
if (flagResult === "clickhouse") {
|
||||
return "clickhouse";
|
||||
}
|
||||
|
||||
return flag;
|
||||
return flagResult;
|
||||
}
|
||||
|
||||
export async function recordRunDebugLog(
|
||||
|
||||
@@ -25,14 +25,14 @@ export type FlagsOptions<T extends FeatureFlagKey> = {
|
||||
overrides?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
function flags<T extends FeatureFlagKey>(
|
||||
export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T> & { defaultValue: z.infer<(typeof FeatureFlagCatalog)[T]> }
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
|
||||
function flags<T extends FeatureFlagKey>(
|
||||
function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T>
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined>;
|
||||
async function flags<T extends FeatureFlagKey>(
|
||||
async function flag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T>
|
||||
): Promise<z.infer<(typeof FeatureFlagCatalog)[T]> | undefined> {
|
||||
const value = await _prisma.featureFlag.findUnique({
|
||||
@@ -60,11 +60,11 @@ export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
return flags;
|
||||
return flag;
|
||||
}
|
||||
|
||||
export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function setFlags<T extends FeatureFlagKey>(
|
||||
export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function setFlag<T extends FeatureFlagKey>(
|
||||
opts: FlagsOptions<T> & { value: z.infer<(typeof FeatureFlagCatalog)[T]> }
|
||||
): Promise<void> {
|
||||
await _prisma.featureFlag.upsert({
|
||||
@@ -82,8 +82,59 @@ export function makeSetFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
};
|
||||
}
|
||||
|
||||
export type AllFlagsOptions = {
|
||||
defaultValues?: Partial<FeatureFlagCatalog>;
|
||||
overrides?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export function makeFlags(_prisma: PrismaClientOrTransaction = prisma) {
|
||||
return async function flags(options?: AllFlagsOptions): Promise<Partial<FeatureFlagCatalog>> {
|
||||
const rows = await _prisma.featureFlag.findMany();
|
||||
|
||||
// Build a map of key -> value from database
|
||||
const dbValues = new Map<string, unknown>();
|
||||
for (const row of rows) {
|
||||
dbValues.set(row.key, row.value);
|
||||
}
|
||||
|
||||
const result: Partial<FeatureFlagCatalog> = {};
|
||||
|
||||
// Process each flag in the catalog
|
||||
for (const key of Object.keys(FeatureFlagCatalog) as FeatureFlagKey[]) {
|
||||
const schema = FeatureFlagCatalog[key];
|
||||
|
||||
// Priority: overrides > database > defaultValues
|
||||
if (options?.overrides?.[key] !== undefined) {
|
||||
const parsed = schema.safeParse(options.overrides[key]);
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (dbValues.has(key)) {
|
||||
const parsed = schema.safeParse(dbValues.get(key));
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.defaultValues?.[key] !== undefined) {
|
||||
const parsed = schema.safeParse(options.defaultValues[key]);
|
||||
if (parsed.success) {
|
||||
(result as any)[key] = parsed.data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
}
|
||||
|
||||
export const flag = makeFlag();
|
||||
export const flags = makeFlags();
|
||||
export const setFlags = makeSetFlags();
|
||||
export const setFlag = makeSetFlag();
|
||||
|
||||
// Create a Zod schema from the existing catalog
|
||||
export const FeatureFlagCatalogSchema = z.object(FeatureFlagCatalog);
|
||||
@@ -112,7 +163,7 @@ export function makeSetMultipleFlags(_prisma: PrismaClientOrTransaction = prisma
|
||||
return async function setMultipleFlags(
|
||||
flags: Partial<z.infer<typeof FeatureFlagCatalogSchema>>
|
||||
): Promise<{ key: string; value: any }[]> {
|
||||
const setFlag = makeSetFlags(_prisma);
|
||||
const setFlag = makeSetFlag(_prisma);
|
||||
const updatedFlags: { key: string; value: any }[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(flags)) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createCache, DefaultStatefulContext, Namespace, Cache as UnkeyCache } from "@unkey/cache";
|
||||
import { MemoryStore } from "@unkey/cache/stores";
|
||||
import { createLRUMemoryStore } from "@internal/cache";
|
||||
import { randomUUID } from "crypto";
|
||||
import { Redis } from "ioredis";
|
||||
import { EnvQueues, MarQSFairDequeueStrategy, MarQSKeyProducer } from "./types";
|
||||
@@ -99,13 +99,7 @@ export class FairDequeuingStrategy implements MarQSFairDequeueStrategy {
|
||||
|
||||
constructor(private options: FairDequeuingStrategyOptions) {
|
||||
const ctx = new DefaultStatefulContext();
|
||||
const memory = new MemoryStore({
|
||||
persistentMap: new Map(),
|
||||
unstableEvictOnSet: {
|
||||
frequency: 0.01,
|
||||
maxItems: 500,
|
||||
},
|
||||
});
|
||||
const memory = createLRUMemoryStore(500);
|
||||
|
||||
this._cache = createCache({
|
||||
concurrencyLimit: new Namespace<number>(ctx, {
|
||||
|
||||
@@ -167,10 +167,14 @@ export const runsSchema: TableSchema = {
|
||||
expression: "if(depth > 0, true, false)",
|
||||
},
|
||||
|
||||
// Useless until we show the user-provided key
|
||||
idempotency_key: {
|
||||
name: "idempotency_key",
|
||||
...column("String", { description: "Idempotency key", example: "user-123-action-456" }),
|
||||
clickhouseName: "idempotency_key_user",
|
||||
...column("String", { description: "Idempotency key (available from 4.3.3)", example: "user-123-action-456" }),
|
||||
},
|
||||
idempotency_key_scope: {
|
||||
name: "idempotency_key_scope",
|
||||
...column("String", { description: "The idempotency key scope determines whether a task should be considered unique within a parent run, a specific attempt, or globally. An empty value means there's no idempotency key set (available from 4.3.3).", example: "run", allowedValues: ["global", "run", "attempt"], }),
|
||||
},
|
||||
region: {
|
||||
name: "region",
|
||||
@@ -325,6 +329,8 @@ export const runsSchema: TableSchema = {
|
||||
// Output & error (JSON columns)
|
||||
// For JSON columns, NULL checks are transformed to check for empty object '{}'
|
||||
// So `error IS NULL` becomes `error = '{}'` and `error IS NOT NULL` becomes `error != '{}'`
|
||||
// textColumn uses the pre-materialized text columns for better performance
|
||||
// dataPrefix handles the internal {"data": ...} wrapper transparently
|
||||
output: {
|
||||
name: "output",
|
||||
...column("JSON", {
|
||||
@@ -332,6 +338,8 @@ export const runsSchema: TableSchema = {
|
||||
example: '{"result": "success"}',
|
||||
}),
|
||||
nullValue: "'{}'", // Transform NULL checks to compare against empty object
|
||||
textColumn: "output_text", // Use output_text for full JSON value queries
|
||||
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
|
||||
},
|
||||
error: {
|
||||
name: "error",
|
||||
@@ -341,6 +349,8 @@ export const runsSchema: TableSchema = {
|
||||
example: '{"message": "Task failed"}',
|
||||
}),
|
||||
nullValue: "'{}'", // Transform NULL checks to compare against empty object
|
||||
textColumn: "error_text", // Use error_text for full JSON value queries
|
||||
dataPrefix: "data", // Internal data is wrapped in {"data": ...}
|
||||
},
|
||||
|
||||
// Tags & versions
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText, type LanguageModelV1 } from "ai";
|
||||
import { env } from "~/env.server";
|
||||
|
||||
/**
|
||||
* Result type for title generation
|
||||
*/
|
||||
export type AIQueryTitleResult =
|
||||
| { success: true; title: string }
|
||||
| { success: false; error: string };
|
||||
|
||||
/**
|
||||
* Service for generating concise titles for SQL queries using AI
|
||||
*/
|
||||
export class AIQueryTitleService {
|
||||
constructor(private readonly model: LanguageModelV1 = openai("gpt-4o-mini")) {}
|
||||
|
||||
/**
|
||||
* Generate a concise title for a SQL query
|
||||
*/
|
||||
async generateTitle(query: string): Promise<AIQueryTitleResult> {
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return { success: false, error: "OpenAI API key is not configured" };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: this.model,
|
||||
system: `You are a helpful assistant that generates concise titles for SQL queries.
|
||||
|
||||
Your task is to create a short, descriptive title (5-10 words) that summarizes what the query does.
|
||||
|
||||
Guidelines:
|
||||
- Focus on the main purpose/intent of the query
|
||||
- Use plain language, not technical SQL terms
|
||||
- Start with an action verb when appropriate (e.g., "Count", "List", "Show", "Find")
|
||||
- Be specific about what data is being retrieved
|
||||
- Do not include quotes around the title
|
||||
- Do not include punctuation at the end
|
||||
|
||||
Examples:
|
||||
- "Failed runs by hour over 7 days"
|
||||
- "Top 50 most expensive task runs"
|
||||
- "Run counts grouped by status"
|
||||
- "Average execution time by task"
|
||||
- "Recent runs with errors"`,
|
||||
prompt: `Generate a concise title for this SQL query:\n\n${query}`,
|
||||
maxTokens: 50,
|
||||
experimental_telemetry: {
|
||||
isEnabled: true,
|
||||
metadata: {
|
||||
feature: "ai-query-title",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const title = result.text.trim();
|
||||
|
||||
if (!title) {
|
||||
return { success: false, error: "No title generated" };
|
||||
}
|
||||
|
||||
return { success: true, title };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : "Failed to generate title",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,7 @@ export class ReplayTaskRunService extends BaseService {
|
||||
overrideOptions.version === "latest" ? undefined : overrideOptions.version,
|
||||
bulkActionId: overrideOptions?.bulkActionId,
|
||||
region,
|
||||
priority: overrideOptions.prioritySeconds,
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -29,6 +29,7 @@ export class TestTaskService extends BaseService {
|
||||
tags: data.tags,
|
||||
machine: data.machine,
|
||||
lockToVersion: data.version === "latest" ? undefined : data.version,
|
||||
priority: data.prioritySeconds,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -66,6 +67,7 @@ export class TestTaskService extends BaseService {
|
||||
tags: data.tags,
|
||||
machine: data.machine,
|
||||
lockToVersion: data.version === "latest" ? undefined : data.version,
|
||||
priority: data.prioritySeconds,
|
||||
},
|
||||
},
|
||||
{ customIcon: "scheduled" }
|
||||
|
||||
@@ -2,7 +2,7 @@ import { WorkerInstanceGroup, WorkerInstanceGroupType } from "@trigger.dev/datab
|
||||
import { WithRunEngine } from "../baseService.server";
|
||||
import { WorkerGroupTokenService } from "./workerGroupTokenService.server";
|
||||
import { logger } from "~/services/logger.server";
|
||||
import { FEATURE_FLAG, makeFlags, makeSetFlags } from "~/v3/featureFlags.server";
|
||||
import { FEATURE_FLAG, makeFlag, makeSetFlag } from "~/v3/featureFlags.server";
|
||||
|
||||
export class WorkerGroupService extends WithRunEngine {
|
||||
private readonly defaultNamePrefix = "worker_group";
|
||||
@@ -47,14 +47,14 @@ export class WorkerGroupService extends WithRunEngine {
|
||||
},
|
||||
});
|
||||
|
||||
const getFlag = makeFlags(this._prisma);
|
||||
const getFlag = makeFlag(this._prisma);
|
||||
const defaultWorkerInstanceGroupId = await getFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
});
|
||||
|
||||
// If there's no global default yet we should set it to the new worker group
|
||||
if (!defaultWorkerInstanceGroupId) {
|
||||
const setFlag = makeSetFlags(this._prisma);
|
||||
const setFlag = makeSetFlag(this._prisma);
|
||||
await setFlag({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
value: workerGroup.id,
|
||||
@@ -166,7 +166,7 @@ export class WorkerGroupService extends WithRunEngine {
|
||||
}
|
||||
|
||||
async getGlobalDefaultWorkerGroup() {
|
||||
const flags = makeFlags(this._prisma);
|
||||
const flags = makeFlag(this._prisma);
|
||||
|
||||
const defaultWorkerInstanceGroupId = await flags({
|
||||
key: FEATURE_FLAG.defaultWorkerInstanceGroupId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createCache, createMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
|
||||
import { createCache, createLRUMemoryStore, DefaultStatefulContext, Namespace } from "@internal/cache";
|
||||
import {
|
||||
CheckpointInput,
|
||||
CompleteRunAttemptResult,
|
||||
@@ -39,7 +39,7 @@ function createAuthenticatedWorkerInstanceCache() {
|
||||
authenticatedWorkerInstance: new Namespace<AuthenticatedWorkerInstance>(
|
||||
new DefaultStatefulContext(),
|
||||
{
|
||||
stores: [createMemoryStore(1000, 0.001)],
|
||||
stores: [createLRUMemoryStore(1000)],
|
||||
fresh: 60_000 * 10, // 10 minutes
|
||||
stale: 60_000 * 11, // 11 minutes
|
||||
}
|
||||
|
||||
@@ -46,6 +46,11 @@ export const RunOptionsData = z.object({
|
||||
message: "Each tag must be at most 128 characters long",
|
||||
}),
|
||||
version: z.string().optional(),
|
||||
prioritySeconds: z
|
||||
.number()
|
||||
.min(0)
|
||||
.optional()
|
||||
.transform((val) => (val === 0 ? undefined : val)),
|
||||
});
|
||||
|
||||
export type RunOptionsData = z.infer<typeof RunOptionsData>;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user