feat: implement port forwarding
This commit is contained in:
BIN
web/bun.lockb
BIN
web/bun.lockb
Binary file not shown.
@@ -30,6 +30,7 @@
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
"@radix-ui/react-slot": "^1.1.0",
|
||||
"@radix-ui/react-switch": "^1.1.1",
|
||||
"@radix-ui/react-tabs": "^1.1.1",
|
||||
"@radix-ui/react-toast": "^1.2.2",
|
||||
"@radix-ui/react-tooltip": "^1.1.3",
|
||||
"@replit/codemirror-vim": "^6.2.1",
|
||||
|
53
web/src/components/ui/tabs.tsx
Normal file
53
web/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from "react"
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
@@ -183,7 +183,10 @@ function useAddWorkspacePort() {
|
||||
throwOnError: true,
|
||||
},
|
||||
);
|
||||
} catch (err: unknown) {}
|
||||
setStatus({ type: "ok" });
|
||||
} catch (error: unknown) {
|
||||
setStatus({ type: "error", error });
|
||||
}
|
||||
},
|
||||
[mutate],
|
||||
);
|
||||
|
62
web/src/workspaces/workspace-info-dialog.tsx
Normal file
62
web/src/workspaces/workspace-info-dialog.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import {
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useContext } from "react";
|
||||
import { WorkspaceTableRowContext } from "./workspace-table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { PortInfoTab } from "./workspace-port-info-tab";
|
||||
|
||||
function WorkspaceInfoDialog() {
|
||||
const workspace = useContext(WorkspaceTableRowContext);
|
||||
|
||||
return (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{workspace.name}</DialogTitle>
|
||||
<DialogDescription>{workspace.imageTag}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="ssh">
|
||||
<TabsList>
|
||||
<TabsTrigger value="ssh">SSH Information</TabsTrigger>
|
||||
<TabsTrigger value="ports">Forwarded Ports</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="ssh">
|
||||
<SshTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="ports">
|
||||
<PortInfoTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
|
||||
function TabContainer({ children }: React.PropsWithChildren) {
|
||||
return <div className="pt-4">{children}</div>;
|
||||
}
|
||||
|
||||
function SshTab() {
|
||||
const workspace = useContext(WorkspaceTableRowContext);
|
||||
|
||||
if (!workspace.sshPort) {
|
||||
return (
|
||||
<p>SSH server is not running in this workspace, so SSH is unavailable.</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TabContainer>
|
||||
<p className="text-sm text-muted-foreground">SSH Port</p>
|
||||
<pre>{workspace.sshPort}</pre>
|
||||
<p className="text-sm text-muted-foreground mt-4">Command</p>
|
||||
<pre>
|
||||
ssh -p {workspace.sshPort} testuser@{import.meta.env.VITE_HOST_NAME}
|
||||
</pre>
|
||||
</TabContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkspaceInfoDialog };
|
194
web/src/workspaces/workspace-port-info-tab.tsx
Normal file
194
web/src/workspaces/workspace-port-info-tab.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FormField, FormItem, FormControl, Form } from "@/components/ui/form";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableCell,
|
||||
Table,
|
||||
} from "@/components/ui/table";
|
||||
import { superstructResolver } from "@hookform/resolvers/superstruct";
|
||||
import { Check, Trash2, X } from "lucide-react";
|
||||
import { useContext, useId } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { object, pattern, string, size, number, type Infer } from "superstruct";
|
||||
import { WorkspaceTableRowContext } from "./workspace-table";
|
||||
import { create } from "zustand";
|
||||
import { useAddWorkspacePort } from "./api";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
|
||||
interface PortInfoTabStore {
|
||||
isAddingPort: boolean;
|
||||
setIsAddingPort: (isAddingPort: boolean) => void;
|
||||
}
|
||||
|
||||
const useStore = create<PortInfoTabStore>()((set) => ({
|
||||
isAddingPort: false,
|
||||
setIsAddingPort: (isAddingPort) => set({ isAddingPort }),
|
||||
}));
|
||||
|
||||
function PortInfoTab() {
|
||||
return (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Subdomain</TableHead>
|
||||
<TableHead>Port</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<PortInfoTableBody />
|
||||
</Table>
|
||||
<AddPortButton />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function PortInfoTableBody() {
|
||||
const workspace = useContext(WorkspaceTableRowContext);
|
||||
const ports = workspace.ports ?? [];
|
||||
|
||||
return (
|
||||
<TableBody>
|
||||
{ports.map(({ port, subdomain }) => (
|
||||
<TableRow key={subdomain}>
|
||||
<TableCell className="py-0">{subdomain}</TableCell>
|
||||
<TableCell className="py-0">{port}</TableCell>
|
||||
<TableCell className="p-0 text-right">
|
||||
<DeletePortMappingButton />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<NewPortMappingRow />
|
||||
</TableBody>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPortButton() {
|
||||
const isAddingPort = useStore((state) => state.isAddingPort);
|
||||
const setIsAddingPort = useStore((state) => state.setIsAddingPort);
|
||||
|
||||
if (isAddingPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="mt-4"
|
||||
onClick={() => setIsAddingPort(true)}
|
||||
>
|
||||
Add port
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
const NewPortMappingForm = object({
|
||||
subdomain: pattern(string(), /^[\w-]+$/),
|
||||
port: size(number(), 1, 65536),
|
||||
});
|
||||
|
||||
function NewPortMappingRow() {
|
||||
const { addWorkspacePort, status } = useAddWorkspacePort();
|
||||
const workspace = useContext(WorkspaceTableRowContext);
|
||||
const isAddingPort = useStore((state) => state.isAddingPort);
|
||||
const setIsAddingPort = useStore((state) => state.setIsAddingPort);
|
||||
const formId = useId();
|
||||
const form = useForm({
|
||||
resolver: superstructResolver(NewPortMappingForm),
|
||||
disabled: status.type === "loading",
|
||||
defaultValues: {
|
||||
subdomain: "",
|
||||
port: 3000,
|
||||
},
|
||||
});
|
||||
|
||||
if (!isAddingPort) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function submitForm(values: Infer<typeof NewPortMappingForm>) {
|
||||
await addWorkspacePort(workspace.name, [
|
||||
{
|
||||
subdomain: values.subdomain,
|
||||
port: values.port,
|
||||
},
|
||||
]);
|
||||
setIsAddingPort(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(submitForm)} id={formId}>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="subdomain"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormControl>
|
||||
<Input type="text" placeholder="subdomain" {...field} />
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</form>
|
||||
</Form>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<Input
|
||||
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
style={{ MozAppearance: "textfield" }}
|
||||
type="number"
|
||||
min={0}
|
||||
max={65535}
|
||||
placeholder="8080"
|
||||
form={formId}
|
||||
{...field}
|
||||
onChange={(value) =>
|
||||
field.onChange(value.currentTarget.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="px-0">
|
||||
<Button
|
||||
form={formId}
|
||||
disabled={status.type === "loading"}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setIsAddingPort(false)}
|
||||
>
|
||||
<X />
|
||||
</Button>
|
||||
<Button
|
||||
form={formId}
|
||||
disabled={status.type === "loading"}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
>
|
||||
{status.type === "loading" ? <LoadingSpinner /> : <Check />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
function DeletePortMappingButton() {
|
||||
return (
|
||||
<Button variant="ghost" size="icon">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export { PortInfoTab };
|
@@ -46,6 +46,8 @@ import {
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
|
||||
import { WorkspaceInfoDialog } from "./workspace-info-dialog";
|
||||
|
||||
const WorkspaceTableRowContext = createContext<Workspace>(
|
||||
null as unknown as Workspace,
|
||||
@@ -230,51 +232,58 @@ function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="icon" onClick={_deleteWorkspace}>
|
||||
{status.type === "loading" ? (
|
||||
<LoadingSpinner />
|
||||
) : (
|
||||
<Trash2 className="text-destructive" />
|
||||
)}
|
||||
<Button variant="destructive" size="icon" onClick={_deleteWorkspace}>
|
||||
{status.type === "loading" ? <LoadingSpinner /> : <Trash2 />}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceInfoButton() {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger>
|
||||
<Dialog>
|
||||
<DialogTrigger>
|
||||
<Button variant="outline" size="icon">
|
||||
<Info />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<WorkspaceInfoPopoverContent />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
<WorkspaceInfoDialog />
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceInfoPopoverContent() {
|
||||
const workspace = useContext(WorkspaceTableRowContext);
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
{workspace.sshPort ? (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p>SSH Port</p>
|
||||
</div>
|
||||
<p className="text-right">{workspace.sshPort}</p>
|
||||
</>
|
||||
) : null}
|
||||
{workspace?.ports?.map(({ port, subdomain }) => (
|
||||
<Fragment key={port}>
|
||||
<div className="col-span-2">
|
||||
<p>{subdomain}</p>
|
||||
</div>
|
||||
<p className="text-right">{port}</p>
|
||||
</Fragment>
|
||||
))}
|
||||
<div className="flex flex-col">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{workspace.sshPort ? (
|
||||
<>
|
||||
<div className="col-span-2">
|
||||
<p>SSH Port</p>
|
||||
</div>
|
||||
<p className="text-right">{workspace.sshPort}</p>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<hr className="my-2" />
|
||||
<p className="text-sm text-muted-foreground col-span-3 mb-1">
|
||||
Forwarded ports
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{workspace?.ports?.map(({ port, subdomain }) => (
|
||||
<Fragment key={port}>
|
||||
<div className="col-span-2 flex items-center">
|
||||
<p>{subdomain}</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-right">{port}</p>
|
||||
<Button variant="destructive" size="icon">
|
||||
<Trash2 />
|
||||
</Button>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
<PortEntry />
|
||||
</div>
|
||||
);
|
||||
@@ -314,7 +323,7 @@ function PortEntry() {
|
||||
if (!isAddingPort) {
|
||||
return (
|
||||
<Button
|
||||
className="col-span-3"
|
||||
className="col-span-3 mt-4"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onAddPortButtonClick}
|
||||
@@ -337,9 +346,8 @@ function PortEntry() {
|
||||
name="portName"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-2">
|
||||
<FormLabel>Subdomain</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="web-app" {...field} />
|
||||
<Input placeholder="Subdomain" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -350,7 +358,6 @@ function PortEntry() {
|
||||
name="port"
|
||||
render={({ field }) => (
|
||||
<FormItem className="col-span-1">
|
||||
<FormLabel>Port</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
className="[&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
@@ -361,6 +368,9 @@ function PortEntry() {
|
||||
max={65535}
|
||||
placeholder="8080"
|
||||
{...field}
|
||||
onChange={(value) =>
|
||||
field.onChange(value.currentTarget.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
@@ -390,4 +400,4 @@ function PortEntry() {
|
||||
);
|
||||
}
|
||||
|
||||
export { WorkspaceTable };
|
||||
export { WorkspaceTable, WorkspaceTableRowContext };
|
||||
|
Reference in New Issue
Block a user