Skip to content

Commit 332dda5

Browse files
committed
Merge remote-tracking branch 'origin/main' into feat/routing-semantics-n-tri-13427
# Conflicts: # internal-packages/run-store/src/index.ts
2 parents 837b6d6 + ee29393 commit 332dda5

52 files changed

Lines changed: 2969 additions & 565 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
Self-hosted instances can now disable the admin dashboard and user impersonation entirely. See the self-hosting docs for the new setting.
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
When a runs list or runs.list API request spans too much data to complete, it now returns a clear, actionable error asking you to narrow the time range, instead of failing with a generic error.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Callout } from "~/components/primitives/Callout";
2+
3+
/**
4+
* Error state for a runs list that failed to load. Shown as the `errorElement` of the deferred
5+
* runs-list data. The most common recoverable cause is a query that was too expensive over a broad
6+
* time range (see `RunsListQueryError`), so the copy guides narrowing the range; a refresh covers
7+
* transient failures. The precise reason is not shown because Remix scrubs thrown error messages in
8+
* production.
9+
*/
10+
export function RunsListErrorState() {
11+
return (
12+
<div className="flex items-center justify-center px-3 py-12">
13+
<Callout variant="error" className="max-w-fit">
14+
We couldn't load these runs. If you're filtering over a broad time range, try narrowing it,
15+
then refresh to try again.
16+
</Callout>
17+
</div>
18+
);
19+
}
20+
21+
/**
22+
* Renders nothing. Used as the `errorElement` for secondary awaits of the same runs-list promise
23+
* (e.g. the pagination controls), so a rejection is handled locally there and does not bubble to
24+
* the route error boundary. The primary awaits render {@link RunsListErrorState}.
25+
*/
26+
export function RunsListErrorStateNoop() {
27+
return null;
28+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { describe, expect, it } from "vitest";
2+
import { DeploymentLogsCache, type DeploymentLogEntry } from "./deploymentLogsCache";
3+
4+
function lines(count: number): DeploymentLogEntry[] {
5+
return Array.from({ length: count }, (_, i) => ({
6+
message: `line ${i}`,
7+
timestamp: new Date(0),
8+
level: "info" as const,
9+
}));
10+
}
11+
12+
describe("DeploymentLogsCache", () => {
13+
it("returns undefined for unknown keys", () => {
14+
const cache = new DeploymentLogsCache(2, 100);
15+
expect(cache.get("missing")).toBeUndefined();
16+
});
17+
18+
it("stores and returns entries", () => {
19+
const cache = new DeploymentLogsCache(2, 100);
20+
const value = { logs: lines(3), nextSeqNum: 3, finalized: true, complete: true };
21+
cache.set("a", value);
22+
expect(cache.get("a")).toBe(value);
23+
expect(cache.size).toBe(1);
24+
expect(cache.lineCount).toBe(3);
25+
});
26+
27+
it("evicts the least recently used deployment past the entry limit", () => {
28+
const cache = new DeploymentLogsCache(2, 100);
29+
cache.set("a", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
30+
cache.set("b", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
31+
cache.get("a");
32+
cache.set("c", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
33+
34+
expect(cache.get("b")).toBeUndefined();
35+
expect(cache.get("a")).toBeDefined();
36+
expect(cache.get("c")).toBeDefined();
37+
expect(cache.size).toBe(2);
38+
});
39+
40+
it("evicts oldest deployments past the total line budget", () => {
41+
const cache = new DeploymentLogsCache(10, 10);
42+
cache.set("a", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
43+
cache.set("b", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
44+
cache.set("c", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
45+
46+
expect(cache.get("a")).toBeUndefined();
47+
expect(cache.get("b")).toBeDefined();
48+
expect(cache.get("c")).toBeDefined();
49+
expect(cache.lineCount).toBe(8);
50+
});
51+
52+
it("always keeps the entry just set, even when it alone exceeds the budget", () => {
53+
const cache = new DeploymentLogsCache(10, 10);
54+
cache.set("a", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
55+
cache.set("big", { logs: lines(50), nextSeqNum: 50, finalized: true, complete: true });
56+
57+
expect(cache.get("a")).toBeUndefined();
58+
expect(cache.get("big")?.logs).toHaveLength(50);
59+
expect(cache.size).toBe(1);
60+
expect(cache.lineCount).toBe(50);
61+
});
62+
63+
it("treats replacing a key as a recent use", () => {
64+
const cache = new DeploymentLogsCache(2, 100);
65+
cache.set("a", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
66+
cache.set("b", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
67+
cache.set("a", { logs: lines(2), nextSeqNum: 2, finalized: true, complete: true });
68+
cache.set("c", { logs: lines(1), nextSeqNum: 1, finalized: false, complete: false });
69+
70+
expect(cache.get("b")).toBeUndefined();
71+
expect(cache.get("a")?.logs).toHaveLength(2);
72+
expect(cache.get("c")).toBeDefined();
73+
});
74+
75+
it("keeps recently read deployments when evicting for the line budget", () => {
76+
const cache = new DeploymentLogsCache(10, 10);
77+
cache.set("a", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
78+
cache.set("b", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
79+
cache.get("a");
80+
cache.set("c", { logs: lines(4), nextSeqNum: 4, finalized: true, complete: true });
81+
82+
expect(cache.get("b")).toBeUndefined();
83+
expect(cache.get("a")).toBeDefined();
84+
expect(cache.get("c")).toBeDefined();
85+
expect(cache.lineCount).toBe(8);
86+
});
87+
88+
it("replaces an existing key without double counting lines", () => {
89+
const cache = new DeploymentLogsCache(10, 100);
90+
cache.set("a", { logs: lines(4), nextSeqNum: 4, finalized: false, complete: false });
91+
cache.set("a", { logs: lines(6), nextSeqNum: 6, finalized: true, complete: true });
92+
93+
expect(cache.size).toBe(1);
94+
expect(cache.lineCount).toBe(6);
95+
expect(cache.get("a")?.complete).toBe(true);
96+
});
97+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
export type DeploymentLogEntry = {
2+
message: string;
3+
timestamp: Date;
4+
level: "info" | "error" | "warn" | "debug";
5+
};
6+
7+
export type CachedDeploymentLogs = {
8+
logs: readonly DeploymentLogEntry[];
9+
nextSeqNum: number;
10+
finalized: boolean;
11+
complete: boolean;
12+
};
13+
14+
export class DeploymentLogsCache {
15+
private entries = new Map<string, CachedDeploymentLogs>();
16+
private totalLines = 0;
17+
18+
constructor(
19+
private readonly maxDeployments: number,
20+
private readonly maxTotalLines: number
21+
) {}
22+
23+
get(key: string): CachedDeploymentLogs | undefined {
24+
const entry = this.entries.get(key);
25+
if (!entry) return undefined;
26+
this.entries.delete(key);
27+
this.entries.set(key, entry);
28+
return entry;
29+
}
30+
31+
set(key: string, value: CachedDeploymentLogs) {
32+
const existing = this.entries.get(key);
33+
if (existing) {
34+
this.totalLines -= existing.logs.length;
35+
this.entries.delete(key);
36+
}
37+
this.entries.set(key, value);
38+
this.totalLines += value.logs.length;
39+
40+
for (const [oldestKey, oldest] of this.entries) {
41+
if (oldestKey === key) break;
42+
if (this.entries.size <= this.maxDeployments && this.totalLines <= this.maxTotalLines) break;
43+
this.entries.delete(oldestKey);
44+
this.totalLines -= oldest.logs.length;
45+
}
46+
}
47+
48+
get size() {
49+
return this.entries.size;
50+
}
51+
52+
get lineCount() {
53+
return this.totalLines;
54+
}
55+
}
56+
57+
export const deploymentLogsCache = new DeploymentLogsCache(20, 20_000);

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ const EnvironmentSchema = z
332332
.refine(isValidRegex, "WHITELISTED_EMAILS must be a valid regex.")
333333
.optional(),
334334
ADMIN_EMAILS: z.string().refine(isValidRegex, "ADMIN_EMAILS must be a valid regex.").optional(),
335+
// Instance-level kill switch for the admin dashboard and user impersonation.
336+
ADMIN_DASHBOARD_ENABLED: BoolEnv.default(true),
335337
REMIX_APP_PORT: z.string().optional(),
336338
// Opt-in, dev-only: stream this process's logs over a local telnet/TCP socket on this port.
337339
// Read directly from process.env in server.ts (before this schema loads); declared here for discoverability.
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
import { S2, S2Error } from "@s2-dev/streamstore";
2+
import { DeploymentEventFromString } from "@trigger.dev/core/v3/schemas";
3+
import type { WorkerDeploymentStatus } from "@trigger.dev/database";
4+
import { useEffect, useState } from "react";
5+
import {
6+
deploymentLogsCache,
7+
type DeploymentLogEntry,
8+
} from "~/components/runs/v3/deploymentLogsCache";
9+
10+
type DeploymentEventStream = {
11+
s2: {
12+
basin: string;
13+
stream: string;
14+
accessToken: string;
15+
};
16+
};
17+
18+
const FINISHED_DEPLOYMENT_STATUSES = new Set<WorkerDeploymentStatus>([
19+
"DEPLOYED",
20+
"FAILED",
21+
"CANCELED",
22+
"TIMED_OUT",
23+
]);
24+
25+
type UseDeploymentLogsOptions = {
26+
eventStream: DeploymentEventStream | undefined;
27+
status: WorkerDeploymentStatus;
28+
};
29+
30+
export function useDeploymentLogs({ eventStream, status }: UseDeploymentLogsOptions) {
31+
const [logs, setLogs] = useState<readonly DeploymentLogEntry[]>([]);
32+
const [isStreaming, setIsStreaming] = useState(true);
33+
const [streamError, setStreamError] = useState<string | null>(null);
34+
35+
const basin = eventStream?.s2.basin;
36+
const stream = eventStream?.s2.stream;
37+
const accessToken = eventStream?.s2.accessToken;
38+
39+
useEffect(() => {
40+
if (!basin || !stream || !accessToken) return;
41+
42+
const isFinished = FINISHED_DEPLOYMENT_STATUSES.has(status);
43+
const cacheKey = `${basin}/${stream}`;
44+
const cached = deploymentLogsCache.get(cacheKey);
45+
46+
let entries = cached?.logs ?? [];
47+
let nextSeqNum = cached?.nextSeqNum ?? 0;
48+
let pending: DeploymentLogEntry[] = [];
49+
let flushTimer: ReturnType<typeof setTimeout> | undefined;
50+
let finalized = cached?.finalized ?? false;
51+
52+
// oxlint-disable-next-line react/set-state-in-effect -- Seed from the cache when the selected deployment changes.
53+
setLogs(entries);
54+
setStreamError(null);
55+
56+
if (cached?.complete) {
57+
setIsStreaming(false);
58+
return;
59+
}
60+
61+
setIsStreaming(true);
62+
63+
const abortController = new AbortController();
64+
65+
const flush = () => {
66+
clearTimeout(flushTimer);
67+
flushTimer = undefined;
68+
if (abortController.signal.aborted || pending.length === 0) return;
69+
entries = entries.concat(pending);
70+
pending = [];
71+
setLogs(entries);
72+
};
73+
74+
const push = (entry: DeploymentLogEntry) => {
75+
pending.push(entry);
76+
flushTimer ??= setTimeout(flush, 0);
77+
};
78+
79+
const store = () => {
80+
clearTimeout(flushTimer);
81+
flushTimer = undefined;
82+
if (pending.length > 0) {
83+
entries = entries.concat(pending);
84+
pending = [];
85+
}
86+
if (entries.length === 0 && nextSeqNum === 0 && !finalized) return;
87+
deploymentLogsCache.set(cacheKey, {
88+
logs: entries,
89+
nextSeqNum,
90+
finalized,
91+
complete: finalized && isFinished,
92+
});
93+
};
94+
95+
const streamLogs = async () => {
96+
try {
97+
const s2Stream = new S2({ accessToken }).basin(basin).stream(stream);
98+
99+
do {
100+
const readSession = await s2Stream.readSession(
101+
{
102+
start: { from: { seqNum: nextSeqNum }, clamp: true },
103+
stop: { waitSecs: 60 },
104+
},
105+
{ signal: abortController.signal }
106+
);
107+
108+
for await (const record of readSession) {
109+
nextSeqNum = record.seqNum + 1;
110+
111+
const decoded = record.body;
112+
const result = DeploymentEventFromString.safeParse(decoded);
113+
114+
if (!result.success) {
115+
// fallback to the previous format in s2 logs for compatibility
116+
const headers: Record<string, string> = {};
117+
if (record.headers) {
118+
for (const [name, value] of record.headers) {
119+
headers[name] = value;
120+
}
121+
}
122+
const level =
123+
(headers["level"]?.toLowerCase() as DeploymentLogEntry["level"]) ?? "info";
124+
125+
push({ timestamp: new Date(record.timestamp), message: decoded, level });
126+
continue;
127+
}
128+
129+
const event = result.data;
130+
if (event.type === "finalized") finalized = true;
131+
if (event.type !== "log") continue;
132+
133+
push({
134+
timestamp: new Date(record.timestamp),
135+
message: event.data.message,
136+
level: event.data.level,
137+
});
138+
}
139+
} while (!abortController.signal.aborted && !finalized && !isFinished);
140+
} catch (error) {
141+
if (abortController.signal.aborted) return;
142+
143+
if (error instanceof S2Error && error.code === "stream_not_found") {
144+
finalized = isFinished;
145+
return;
146+
}
147+
if (error instanceof S2Error && error.code === "permission_denied") return;
148+
149+
console.error("Failed to stream logs:", error);
150+
setStreamError("Failed to stream logs");
151+
} finally {
152+
if (!abortController.signal.aborted) {
153+
flush();
154+
setIsStreaming(false);
155+
store();
156+
}
157+
}
158+
};
159+
160+
streamLogs();
161+
162+
return () => {
163+
abortController.abort();
164+
store();
165+
};
166+
}, [basin, stream, accessToken, status]);
167+
168+
return { logs, isStreaming, streamError };
169+
}

apps/webapp/app/hooks/useUser.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,12 @@ export function useHasAdminAccess(matches?: UIMatch[]): boolean {
4848
const user = useOptionalUser(matches);
4949
const isImpersonating = useIsImpersonating(matches);
5050
const isViewingAsUser = useIsViewingAsUser(matches);
51+
const routeMatch = useTypedMatchesData<typeof loader>({
52+
id: "root",
53+
matches,
54+
});
55+
56+
if (routeMatch?.adminDashboardEnabled === false) return false;
5157

5258
return (Boolean(user?.admin) || isImpersonating) && !isViewingAsUser;
5359
}

0 commit comments

Comments
 (0)