Skip to content

Commit 7f073d4

Browse files
ross-rldines-rl
andauthored
feat(devbox): allow tunnel config interactive, show tunnel in info (#102)
## Description <!-- Provide a brief description of your changes --> <img width="392" height="272" alt="Screenshot 2026-02-05 at 10 00 37 AM" src="https://github.com/user-attachments/assets/e5758699-0199-432b-a49c-c95910d22942" /> <img width="859" height="443" alt="Screenshot 2026-02-05 at 9 59 21 AM" src="https://github.com/user-attachments/assets/e638d9f7-568d-4ff4-821d-3b8dc2919038" /> **Note:** PR titles should follow [Conventional Commits](https://www.conventionalcommits.org/) format (e.g., `feat(devbox): add support for custom env vars` or `fix(snapshot): resolve pagination issue`) as they are used for automatic release notes generation. ## Type of Change <!-- Mark the relevant option with an 'x' --> - [ ] Bug fix (non-breaking change which fixes an issue) - [ ] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) - [ ] Documentation update - [ ] Code refactoring - [ ] Performance improvement - [ ] Test updates ## Related Issues <!-- Link to related issues using #issue-number --> Closes # ## Changes Made <!-- Describe the changes in detail --> ## Testing <!-- Describe how you tested your changes --> - [ ] I have tested locally - [ ] I have added/updated tests - [ ] All existing tests pass ## Checklist - [ ] My code follows the code style of this project - [ ] I have performed a self-review of my own code - [ ] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [ ] My changes generate no new warnings - [ ] I have added tests that prove my fix is effective or that my feature works - [ ] New and existing unit tests pass locally with my changes - [ ] Any dependent changes have been merged and published ## Screenshots (if applicable) <!-- Add screenshots to help explain your changes --> ## Additional Notes <!-- Any additional information that reviewers should know --> --------- Co-authored-by: Alexander Dines <alex@runloop.ai>
1 parent 5c9b884 commit 7f073d4

5 files changed

Lines changed: 227 additions & 68 deletions

File tree

src/commands/devbox/list.tsx

Lines changed: 39 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -428,28 +428,46 @@ const ListDevboxesUI = ({
428428
const endIndex = startIndex + devboxes.length;
429429

430430
// Filter operations based on devbox status
431+
const hasTunnel = !!(
432+
selectedDevbox?.tunnel && selectedDevbox.tunnel.tunnel_key
433+
);
431434
const operations = selectedDevbox
432-
? allOperations.filter((op) => {
433-
const devboxStatus = selectedDevbox.status;
434-
435-
if (devboxStatus === "suspended") {
436-
return op.key === "resume" || op.key === "logs";
437-
}
438-
439-
if (
440-
devboxStatus !== "running" &&
441-
devboxStatus !== "provisioning" &&
442-
devboxStatus !== "initializing"
443-
) {
444-
return op.key === "logs";
445-
}
446-
447-
if (devboxStatus === "running") {
448-
return op.key !== "resume";
449-
}
450-
451-
return op.key === "logs" || op.key === "delete";
452-
})
435+
? allOperations
436+
.filter((op) => {
437+
const devboxStatus = selectedDevbox.status;
438+
439+
if (devboxStatus === "suspended") {
440+
return op.key === "resume" || op.key === "logs";
441+
}
442+
443+
if (
444+
devboxStatus !== "running" &&
445+
devboxStatus !== "provisioning" &&
446+
devboxStatus !== "initializing"
447+
) {
448+
return op.key === "logs";
449+
}
450+
451+
if (devboxStatus === "running") {
452+
return op.key !== "resume";
453+
}
454+
455+
return op.key === "logs" || op.key === "delete";
456+
})
457+
.map((op) => {
458+
// Dynamic tunnel label based on whether tunnel is active
459+
if (op.key === "tunnel") {
460+
return hasTunnel
461+
? {
462+
...op,
463+
label: "Tunnel (Active)",
464+
color: colors.success,
465+
icon: figures.tick,
466+
}
467+
: op;
468+
}
469+
return op;
470+
})
453471
: allOperations;
454472

455473
const closePopup = React.useCallback(() => {

src/components/DevboxActionsMenu.tsx

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
createSSHKey,
2626
} from "../services/devboxService.js";
2727
import { StreamingLogsViewer } from "./StreamingLogsViewer.js";
28+
import { DevboxView } from "@runloop/api-client/resources/devboxes.mjs";
2829

2930
type Operation =
3031
| "exec"
@@ -39,7 +40,7 @@ type Operation =
3940
| null;
4041

4142
interface DevboxActionsMenuProps {
42-
devbox: any;
43+
devbox: DevboxView;
4344
onBack: () => void;
4445
breadcrumbItems?: Array<{ label: string; active?: boolean }>;
4546
initialOperation?: string; // Operation to execute immediately
@@ -196,32 +197,48 @@ export const DevboxActionsMenu = ({
196197
];
197198

198199
// Filter operations based on devbox status
200+
const hasTunnel = !!(devbox?.tunnel && devbox.tunnel.tunnel_key);
199201
const operations = devbox
200-
? allOperations.filter((op) => {
201-
const status = devbox.status;
202+
? allOperations
203+
.filter((op) => {
204+
const status = devbox.status;
202205

203-
// When suspended: logs and resume
204-
if (status === "suspended") {
205-
return op.key === "resume" || op.key === "logs";
206-
}
206+
// When suspended: logs and resume
207+
if (status === "suspended") {
208+
return op.key === "resume" || op.key === "logs";
209+
}
207210

208-
// When not running (shutdown, failure, etc): only logs
209-
if (
210-
status !== "running" &&
211-
status !== "provisioning" &&
212-
status !== "initializing"
213-
) {
214-
return op.key === "logs";
215-
}
211+
// When not running (shutdown, failure, etc): only logs
212+
if (
213+
status !== "running" &&
214+
status !== "provisioning" &&
215+
status !== "initializing"
216+
) {
217+
return op.key === "logs";
218+
}
216219

217-
// When running: everything except resume
218-
if (status === "running") {
219-
return op.key !== "resume";
220-
}
220+
// When running: everything except resume
221+
if (status === "running") {
222+
return op.key !== "resume";
223+
}
221224

222-
// Default for transitional states (provisioning, initializing)
223-
return op.key === "logs" || op.key === "delete";
224-
})
225+
// Default for transitional states (provisioning, initializing)
226+
return op.key === "logs" || op.key === "delete";
227+
})
228+
.map((op) => {
229+
// Dynamic tunnel label based on whether tunnel is active
230+
if (op.key === "tunnel") {
231+
return hasTunnel
232+
? {
233+
...op,
234+
label: "Tunnel (Active)",
235+
color: colors.success,
236+
icon: figures.tick,
237+
}
238+
: op;
239+
}
240+
return op;
241+
})
225242
: allOperations;
226243

227244
// Auto-execute operations that don't need input (except delete which needs confirmation)

src/components/DevboxCreatePage.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type FormField =
6262
| "metadata"
6363
| "source"
6464
| "network_policy_id"
65+
| "tunnel_auth_mode"
6566
| "gateways";
6667

6768
// Gateway configuration for devbox
@@ -97,6 +98,7 @@ interface FormData {
9798
blueprint_id: string;
9899
snapshot_id: string;
99100
network_policy_id: string;
101+
tunnel_auth_mode: "none" | "open" | "authenticated";
100102
gateways: GatewaySpec[];
101103
}
102104

@@ -110,6 +112,7 @@ const resourceSizes = [
110112
"XX_LARGE",
111113
"CUSTOM_SIZE",
112114
] as const;
115+
const tunnelAuthModes = ["none", "open", "authenticated"] as const;
113116

114117
export const DevboxCreatePage = ({
115118
onBack,
@@ -130,6 +133,7 @@ export const DevboxCreatePage = ({
130133
blueprint_id: initialBlueprintId || "",
131134
snapshot_id: initialSnapshotId || "",
132135
network_policy_id: "",
136+
tunnel_auth_mode: "none",
133137
gateways: [],
134138
});
135139
const [metadataKey, setMetadataKey] = React.useState("");
@@ -259,6 +263,12 @@ export const DevboxCreatePage = ({
259263
type: "picker",
260264
placeholder: "Select a network policy...",
261265
},
266+
{
267+
key: "tunnel_auth_mode",
268+
label: "Tunnel (optional)",
269+
type: "select",
270+
placeholder: "none",
271+
},
262272
{
263273
key: "gateways",
264274
label: "AI Gateway Configs (optional)",
@@ -290,6 +300,13 @@ export const DevboxCreatePage = ({
290300
currentField === "resource_size",
291301
);
292302

303+
const handleTunnelNav = useFormSelectNavigation(
304+
formData.tunnel_auth_mode,
305+
tunnelAuthModes,
306+
(value) => setFormData({ ...formData, tunnel_auth_mode: value }),
307+
currentField === "tunnel_auth_mode",
308+
);
309+
293310
const handleSourceTypeNav = useFormSelectNavigation(
294311
sourceTypeToggle,
295312
sourceTypes,
@@ -395,6 +412,7 @@ export const DevboxCreatePage = ({
395412
// Handle select field navigation using shared hooks
396413
if (handleArchitectureNav(input, key)) return;
397414
if (handleResourceSizeNav(input, key)) return;
415+
if (handleTunnelNav(input, key)) return;
398416
if (handleSourceTypeNav(input, key)) return;
399417

400418
// Navigation (up/down arrows and tab/shift+tab)
@@ -865,6 +883,13 @@ export const DevboxCreatePage = ({
865883
createParams.gateways = gateways;
866884
}
867885

886+
// Add tunnel configuration if not "none"
887+
if (formData.tunnel_auth_mode !== "none") {
888+
createParams.tunnel = {
889+
auth_mode: formData.tunnel_auth_mode,
890+
};
891+
}
892+
868893
const devbox = await client.devboxes.create(createParams);
869894
setResult(devbox);
870895
} catch (err) {
@@ -1461,14 +1486,22 @@ export const DevboxCreatePage = ({
14611486

14621487
if (field.type === "select") {
14631488
const value = fieldData as string;
1489+
let options: readonly string[];
1490+
if (field.key === "architecture") {
1491+
options = architectures;
1492+
} else if (field.key === "resource_size") {
1493+
options = resourceSizes;
1494+
} else if (field.key === "tunnel_auth_mode") {
1495+
options = tunnelAuthModes;
1496+
} else {
1497+
options = [];
1498+
}
14641499
return (
14651500
<FormSelect
14661501
key={field.key}
14671502
label={field.label}
14681503
value={value || ""}
1469-
options={
1470-
field.key === "architecture" ? architectures : resourceSizes
1471-
}
1504+
options={options}
14721505
onChange={(newValue) =>
14731506
setFormData({ ...formData, [field.key]: newValue })
14741507
}

0 commit comments

Comments
 (0)