Skip to content

Commit 36a232a

Browse files
committed
WIP dev presence
1 parent 3b71995 commit 36a232a

File tree

8 files changed

+298
-13
lines changed

8 files changed

+298
-13
lines changed
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
export function ConnectedIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 20 18" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<rect
5+
x="0.5"
6+
y="-0.5"
7+
width="19"
8+
height="1"
9+
rx="0.5"
10+
transform="matrix(1 0 0 -1 0 17)"
11+
stroke="#878C99"
12+
/>
13+
<path
14+
fillRule="evenodd"
15+
clipRule="evenodd"
16+
d="M12.4187 2L4 2C2.89543 2 2 2.89543 2 4L2 12C2 13.1046 2.89543 14 4 14L16 14C17.1046 14 18 13.1046 18 12L18 4.9816L16.5 6.4816L16.5 12C16.5 12.2761 16.2761 12.5 16 12.5L4 12.5C3.72386 12.5 3.5 12.2761 3.5 12L3.5 4C3.5 3.72386 3.72386 3.5 4 3.5L10.9187 3.5L12.4187 2Z"
17+
fill="#878C99"
18+
/>
19+
<path
20+
d="M6.5 6.75L9.5 9.75L16 3"
21+
stroke="#28BF5C"
22+
strokeWidth="1.5"
23+
strokeLinecap="round"
24+
strokeLinejoin="round"
25+
/>
26+
</svg>
27+
);
28+
}
29+
30+
export function DisconnectedIcon({ className }: { className?: string }) {
31+
return (
32+
<svg className={className} viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
33+
<path
34+
fillRule="evenodd"
35+
clipRule="evenodd"
36+
d="M11.2279 14.8334L1.50037 14.8334C1.04013 14.8334 0.667035 15.2065 0.667035 15.6667C0.667035 16.1269 1.04013 16.5 1.50037 16.5L12.8945 16.5L11.2279 14.8334Z"
37+
fill="#878C99"
38+
/>
39+
<path
40+
fillRule="evenodd"
41+
clipRule="evenodd"
42+
d="M2.33268 5.93927L2.33268 11.1667C2.33268 12.2713 3.22811 13.1667 4.33268 13.1667L9.56016 13.1667L8.06016 11.6667L4.33268 11.6667C4.05654 11.6667 3.83268 11.4429 3.83268 11.1667L3.83268 7.43927L2.33268 5.93927ZM14.166 10.6369L14.166 5.16675C14.166 4.8906 13.9422 4.66675 13.666 4.66675L8.19589 4.66675L6.69589 3.16675L13.666 3.16675C14.7706 3.16675 15.666 4.06218 15.666 5.16675L15.666 11.1667C15.666 11.4522 15.6062 11.7237 15.4985 11.9693L14.166 10.6369Z"
43+
fill="#878C99"
44+
/>
45+
<path
46+
d="M1.5 1.50006L16.5 16.5001"
47+
stroke="#E11D48"
48+
strokeWidth="1.5"
49+
strokeLinecap="round"
50+
/>
51+
</svg>
52+
);
53+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import { useEffect, useState } from "react";
2+
import { ConnectedIcon, DisconnectedIcon } from "~/assets/icons/ConnectionIcons";
3+
import { useDebounce } from "~/hooks/useDebounce";
4+
import { useEnvironment } from "~/hooks/useEnvironment";
5+
import { useEventSource } from "~/hooks/useEventSource";
6+
import { useOrganization } from "~/hooks/useOrganizations";
7+
import { useProject } from "~/hooks/useProject";
8+
import { Dialog, DialogContent, DialogHeader, DialogTrigger } from "./primitives/Dialog";
9+
import { Button } from "./primitives/Buttons";
10+
11+
export function useDevPresence() {
12+
const organization = useOrganization();
13+
const project = useProject();
14+
const environment = useEnvironment();
15+
const streamedEvents = useEventSource(
16+
`/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/dev/presence`,
17+
{
18+
event: "presence",
19+
}
20+
);
21+
22+
const [lastSeen, setLastSeen] = useState<Date | null>(null);
23+
24+
const debouncer = useDebounce((seen: Date | null) => {
25+
setLastSeen(seen);
26+
}, 3_000);
27+
28+
useEffect(() => {
29+
if (streamedEvents === null) {
30+
debouncer(null);
31+
return;
32+
}
33+
34+
try {
35+
const data = JSON.parse(streamedEvents) as any;
36+
if ("lastSeen" in data && data.lastSeen) {
37+
// Parse the timestamp string into a Date object
38+
try {
39+
const lastSeenDate = new Date(data.lastSeen);
40+
debouncer(lastSeenDate);
41+
} catch (error) {
42+
console.log("DevPresence: Failed to parse lastSeen timestamp", { error });
43+
debouncer(null);
44+
}
45+
} else {
46+
debouncer(null);
47+
}
48+
} catch (error) {
49+
console.log("DevPresence: Failed to parse presence message", { error });
50+
debouncer(null);
51+
}
52+
}, [streamedEvents]);
53+
54+
return { lastSeen };
55+
}
56+
57+
export function DevPresence() {
58+
const { lastSeen } = useDevPresence();
59+
const isConnected = lastSeen && lastSeen > new Date(Date.now() - 120_000);
60+
61+
return (
62+
<Dialog>
63+
<DialogTrigger asChild>
64+
<Button
65+
variant="minimal/small"
66+
className="px-1"
67+
LeadingIcon={
68+
isConnected ? (
69+
<ConnectedIcon className="size-5" />
70+
) : (
71+
<DisconnectedIcon className="size-5" />
72+
)
73+
}
74+
/>
75+
</DialogTrigger>
76+
<DialogContent>
77+
<DialogHeader>
78+
{isConnected
79+
? "Your dev server is connected to Trigger.dev"
80+
: "Your dev server is not connected to Trigger.dev"}
81+
</DialogHeader>
82+
<div className="mt-2 flex flex-col gap-4"></div>
83+
</DialogContent>
84+
</Dialog>
85+
);
86+
}

apps/webapp/app/components/navigation/SideMenu.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import { SideMenuItem } from "./SideMenuItem";
7171
import { SideMenuSection } from "./SideMenuSection";
7272
import { ButtonContent, LinkButton } from "../primitives/Buttons";
7373
import { TextLink } from "../primitives/TextLink";
74+
import { DevPresence } from "../DevPresence";
7475

7576
type SideMenuUser = Pick<User, "email" | "admin"> & { isImpersonating: boolean };
7677
export type SideMenuProject = Pick<
@@ -141,12 +142,13 @@ export function SideMenu({
141142
<div className="mb-6 flex flex-col gap-4 px-1">
142143
<div className="space-y-1">
143144
<SideMenuHeader title={"Environment"} />
144-
<div className="flex items-center gap-2">
145+
<div className="flex items-center gap-1">
145146
<EnvironmentSelector
146147
organization={organization}
147148
project={project}
148149
environment={environment}
149150
/>
151+
{environment.type === "DEVELOPMENT" && <DevPresence />}
150152
</div>
151153
</div>
152154

apps/webapp/app/components/primitives/Popover.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ function PopoverArrowTrigger({
161161
<PopoverTrigger
162162
{...props}
163163
className={cn(
164-
"group flex h-6 items-center gap-1 rounded px-2 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
164+
"group flex h-6 items-center gap-1 rounded pl-2 pr-1 text-text-dimmed transition focus-custom hover:bg-charcoal-700 hover:text-text-bright",
165165
fullWidth && "w-full justify-between",
166166
className
167167
)}

apps/webapp/app/routes/engine.v1.dev.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { json, TypedResponse } from "@remix-run/server-runtime";
2-
import { DevConfigResponseBody } from "@trigger.dev/core/v3/schemas";
1+
import { json, type TypedResponse } from "@remix-run/server-runtime";
2+
import { type DevConfigResponseBody } from "@trigger.dev/core/v3/schemas";
33
import { z } from "zod";
44
import { env } from "~/env.server";
55
import { logger } from "~/services/logger.server";

apps/webapp/app/routes/engine.v1.dev.presence.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,6 @@ export const loader = createSSELoader({
4040
});
4141
},
4242
initStream: async ({ send }) => {
43-
//todo set a string instead, with the expire on the same call
44-
//won't need multi
45-
4643
// Set initial presence with more context
4744
await redis.setex(presenceKey, env.DEV_PRESENCE_TTL_MS / 1000, Date.now().toString());
4845

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { $replica } from "~/db.server";
2+
import { requireUserId } from "~/services/session.server";
3+
import { EnvironmentParamSchema } from "~/utils/pathBuilder";
4+
import { env } from "~/env.server";
5+
import { DevPresenceStream } from "~/presenters/v3/DevPresenceStream.server";
6+
import { logger } from "~/services/logger.server";
7+
import { createSSELoader, type SendFunction } from "~/utils/sse";
8+
import Redis from "ioredis";
9+
10+
export const loader = createSSELoader({
11+
timeout: env.DEV_PRESENCE_TTL_MS,
12+
interval: env.DEV_PRESENCE_POLL_INTERVAL_MS,
13+
debug: true,
14+
handler: async ({ id, controller, debug, request, params }) => {
15+
const userId = await requireUserId(request);
16+
const { organizationSlug, projectParam, envParam } = EnvironmentParamSchema.parse(params);
17+
18+
const environment = await $replica.runtimeEnvironment.findFirst({
19+
where: {
20+
slug: envParam,
21+
type: "DEVELOPMENT",
22+
project: {
23+
slug: projectParam,
24+
},
25+
organization: {
26+
slug: organizationSlug,
27+
members: {
28+
some: {
29+
userId,
30+
},
31+
},
32+
},
33+
},
34+
});
35+
36+
if (!environment) {
37+
throw new Response("Not Found", { status: 404 });
38+
}
39+
40+
const presenceKey = DevPresenceStream.getPresenceKey(environment.id);
41+
const presenceChannel = DevPresenceStream.getPresenceChannel(environment.id);
42+
43+
// Create two Redis clients - one for subscribing and one for regular commands
44+
const redisConfig = {
45+
port: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PORT ?? undefined,
46+
host: env.RUN_ENGINE_DEV_PRESENCE_REDIS_HOST ?? undefined,
47+
username: env.RUN_ENGINE_DEV_PRESENCE_REDIS_USERNAME ?? undefined,
48+
password: env.RUN_ENGINE_DEV_PRESENCE_REDIS_PASSWORD ?? undefined,
49+
enableAutoPipelining: true,
50+
...(env.RUN_ENGINE_DEV_PRESENCE_REDIS_TLS_DISABLED === "true" ? {} : { tls: {} }),
51+
};
52+
53+
// Subscriber client for pubsub
54+
const subRedis = new Redis(redisConfig);
55+
56+
// Command client for regular Redis commands
57+
const cmdRedis = new Redis(redisConfig);
58+
59+
const checkAndSendPresence = async (send: SendFunction) => {
60+
try {
61+
// Use the command client for the GET operation
62+
const currentPresenceValue = await cmdRedis.get(presenceKey);
63+
const isConnected = !!currentPresenceValue;
64+
65+
// Format lastSeen as ISO string if it exists
66+
let lastSeen = null;
67+
if (currentPresenceValue) {
68+
// Check if it's a numeric timestamp
69+
if (!isNaN(Number(currentPresenceValue))) {
70+
// Convert numeric timestamp to ISO string
71+
lastSeen = new Date(parseInt(currentPresenceValue, 10)).toISOString();
72+
} else {
73+
// It's already a string format, make sure it's ISO
74+
try {
75+
lastSeen = new Date(currentPresenceValue).toISOString();
76+
} catch (e) {
77+
// If parsing fails, use current time as fallback
78+
lastSeen = new Date().toISOString();
79+
logger.warn("Failed to parse lastSeen value, using current time", {
80+
originalValue: currentPresenceValue,
81+
});
82+
}
83+
}
84+
}
85+
86+
send({
87+
event: "presence",
88+
data: JSON.stringify({
89+
type: isConnected ? "connected" : "disconnected",
90+
environmentId: environment.id,
91+
timestamp: new Date().toISOString(), // Also standardize this to ISO
92+
lastSeen: lastSeen,
93+
}),
94+
});
95+
96+
return isConnected;
97+
} catch (error) {
98+
// Handle the case where the controller is closed
99+
logger.debug("Failed to send presence data, stream might be closed", { error });
100+
return false;
101+
}
102+
};
103+
104+
return {
105+
beforeStream: async () => {
106+
logger.debug("Start dev presence listening SSE session", {
107+
environmentId: environment.id,
108+
presenceChannel,
109+
});
110+
},
111+
initStream: async ({ send }) => {
112+
await checkAndSendPresence(send);
113+
114+
//start subscribing with the subscriber client
115+
await subRedis.subscribe(presenceChannel);
116+
117+
subRedis.on("message", async (channel, message) => {
118+
if (channel === presenceChannel) {
119+
try {
120+
await checkAndSendPresence(send);
121+
} catch (error) {
122+
logger.error("Failed to parse presence message", { error, message });
123+
}
124+
}
125+
});
126+
127+
send({ event: "time", data: new Date().toISOString() });
128+
},
129+
iterator: async ({ send, date }) => {
130+
await checkAndSendPresence(send);
131+
},
132+
cleanup: async ({ send }) => {
133+
await checkAndSendPresence(send);
134+
135+
await subRedis.unsubscribe(presenceChannel);
136+
await subRedis.quit();
137+
await cmdRedis.quit();
138+
},
139+
};
140+
},
141+
});

apps/webapp/app/utils/sse.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { LoaderFunctionArgs } from "@remix-run/node";
1+
import { type LoaderFunctionArgs } from "@remix-run/node";
2+
import { type Params } from "@remix-run/router";
23
import { eventStream } from "remix-utils/sse/server";
34
import { setInterval } from "timers/promises";
45

5-
type SendFunction = Parameters<Parameters<typeof eventStream>[1]>[0];
6+
export type SendFunction = Parameters<Parameters<typeof eventStream>[1]>[0];
67

78
type HandlerParams = {
89
send: SendFunction;
@@ -15,12 +16,13 @@ type SSEHandlers = {
1516
initStream?: (params: HandlerParams) => Promise<boolean | void> | boolean | void;
1617
/** Return false to stop */
1718
iterator?: (params: HandlerParams & { date: Date }) => Promise<boolean | void> | boolean | void;
18-
cleanup?: () => void;
19+
cleanup?: (params: HandlerParams) => void;
1920
};
2021

2122
type SSEContext = {
2223
id: string;
2324
request: Request;
25+
params: Params<string>;
2426
controller: AbortController;
2527
debug: (message: string) => void;
2628
};
@@ -38,19 +40,23 @@ const connections: Set<string> = new Set();
3840
export function createSSELoader(options: SSEOptions) {
3941
const { timeout, interval = 500, debug = false, handler } = options;
4042

41-
return async function loader({ request }: LoaderFunctionArgs) {
43+
return async function loader({ request, params }: LoaderFunctionArgs) {
4244
const id = request.headers.get("x-request-id") || Math.random().toString(36).slice(2, 8);
4345

4446
const internalController = new AbortController();
4547
const timeoutSignal = AbortSignal.timeout(timeout);
4648

4749
const log = (message: string) => {
48-
if (debug) console.log(`SSE: [${id}] ${message} (${connections.size} open connections)`);
50+
if (debug)
51+
console.log(
52+
`SSE: [${request.url} ${id}] ${message} (${connections.size} open connections)`
53+
);
4954
};
5055

5156
const context: SSEContext = {
5257
id,
5358
request,
59+
params,
5460
controller: internalController,
5561
debug: log,
5662
};
@@ -167,7 +173,7 @@ export function createSSELoader(options: SSEOptions) {
167173
log("Cleanup called");
168174
if (handlers.cleanup) {
169175
try {
170-
handlers.cleanup();
176+
handlers.cleanup({ send });
171177
} catch (error) {
172178
log(
173179
`Error in cleanup handler: ${

0 commit comments

Comments
 (0)