feat: implement port forwarding

This commit is contained in:
2024-11-28 19:17:37 +00:00
parent 6e6fb06351
commit cacf66067e
18 changed files with 553 additions and 101 deletions

Binary file not shown.

View File

@@ -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",

View 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 }

View File

@@ -183,7 +183,10 @@ function useAddWorkspacePort() {
throwOnError: true,
},
);
} catch (err: unknown) {}
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);

View 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 };

View 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 };

View File

@@ -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 };