feat: implement ssh forwarding

This commit is contained in:
2024-11-17 18:10:35 +00:00
parent a7933f8b06
commit 45bfbe093a
21 changed files with 1175 additions and 296 deletions

Binary file not shown.

View File

@@ -25,6 +25,7 @@
"@radix-ui/react-icons": "^1.3.1",
"@radix-ui/react-label": "^2.1.0",
"@radix-ui/react-navigation-menu": "^1.2.1",
"@radix-ui/react-popover": "^1.1.2",
"@radix-ui/react-select": "^2.1.2",
"@radix-ui/react-separator": "^1.1.0",
"@radix-ui/react-slot": "^1.1.0",

View File

@@ -16,6 +16,7 @@ async function fetchApi(
() => ApiError.Network,
);
if (res.status !== 200) {
console.log(res.status);
switch (res.status) {
case 401:
throw ApiError.BadRequest;

View File

@@ -0,0 +1,7 @@
import { Loader2, type LucideProps } from "lucide-react";
function LoadingSpinner(props: LucideProps) {
return <Loader2 className="animate-spin" {...props} />;
}
export { LoadingSpinner };

View File

@@ -0,0 +1,31 @@
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -124,11 +124,8 @@ function _TemplateEditor({
return (
<TemplateEditorStoreContext.Provider value={store.current}>
<SidebarProvider>
<aside>
<EditorSidebar />
</aside>
<div className="flex flex-col w-full">
<EditorSidebar />
<div className="flex flex-col w-full min-w-0">
<EditorTopBar />
<main className="w-full h-full flex flex-col">
<Editor />
@@ -301,7 +298,7 @@ function TemplateBuildOutputPanel() {
return (
<div
className={cn(
"flex flex-col overflow-hidden",
"flex flex-col overflow-hidden w-full",
isBuildOutputVisible ? "h-96" : "",
)}
>

View File

@@ -1,8 +1,8 @@
import { fetchApi } from "@/api";
import useSWR, { useSWRConfig } from "swr";
import type { Workspace } from "./types";
import { WorkspaceStatus, type Workspace } from "./types";
import { useCallback, useState } from "react";
import { QueryStatus } from "@/lib/query";
import type { QueryStatus } from "@/lib/query";
function useWorkspaces() {
return useSWR(
@@ -20,29 +20,143 @@ function useCreateWorkspace() {
async ({
workspaceName,
imageId,
}: { workspaceName: string; imageId: string }): Promise<Workspace> => {
}: {
workspaceName: string;
imageId: string;
}): Promise<Workspace | null> => {
setStatus({ type: "loading" });
try {
const res = await fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ imageId }),
headers: {
"Content-Type": "application/json",
const workspace = await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ imageId }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (createdWorkspace, workspaces) => [
...workspaces,
createdWorkspace,
],
throwOnError: true,
},
});
const workspace = await res.json();
setStatus({ type: "ok" });
return workspace;
);
return workspace ?? null;
} catch (error: unknown) {
setStatus({ type: "error", error });
return null;
}
},
[],
[mutate],
);
return { createWorkspace, status };
}
export { useWorkspaces, useCreateWorkspace };
function useChangeWorkspaceStatus() {
const [status, setStatus] = useState<QueryStatus>({ type: "idle" });
const { mutate } = useSWRConfig();
const startWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ status: WorkspaceStatus.Running }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (updatedWorkspace, workspaces) =>
workspaces.map((workspace: Workspace) =>
workspace.containerId === updatedWorkspace.containerId
? updatedWorkspace
: workspace,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
const stopWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, {
method: "POST",
body: JSON.stringify({ status: WorkspaceStatus.Stopped }),
headers: {
"Content-Type": "application/json",
},
}).then((res): Promise<Workspace> => res.json()),
{
populateCache: (updatedWorkspace, workspaces) =>
workspaces.map((workspace: Workspace) =>
workspace.containerId === updatedWorkspace.containerId
? updatedWorkspace
: workspace,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
return { startWorkspace, stopWorkspace, status };
}
function useDeleteWorkspace() {
const [status, setStatus] = useState<QueryStatus>({ type: "idle" });
const { mutate } = useSWRConfig();
const deleteWorkspace = useCallback(
async (workspaceName: string) => {
setStatus({ type: "loading" });
try {
await mutate(
"/workspaces",
fetchApi(`/workspaces/${workspaceName}`, { method: "DELETE" }),
{
populateCache: (_, workspaces) =>
workspaces.filter(
(workspace: Workspace) => workspace.name === workspaceName,
),
throwOnError: true,
},
);
setStatus({ type: "ok" });
} catch (error: unknown) {
setStatus({ type: "error", error });
}
},
[mutate],
);
return { deleteWorkspace, status };
}
export {
useWorkspaces,
useCreateWorkspace,
useChangeWorkspaceStatus,
useDeleteWorkspace,
};

View File

@@ -1,60 +1,14 @@
import { Badge } from "@/components/ui/badge.tsx";
import { MainSidebar } from "@/components/main-sidebar.tsx";
import { Button } from "@/components/ui/button";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
import { PageHeader } from "@/components/ui/page-header.tsx";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx";
import dayjs from "dayjs";
import { Page } from "@/components/ui/page.tsx";
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
import { MainSidebar } from "@/components/main-sidebar.tsx";
import { useCreateWorkspace, useWorkspaces } from "./api";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Loader2, Plus } from "lucide-react";
import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useTemplateImages } from "@/templates/api";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { useForm } from "react-hook-form";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Select,
SelectTrigger,
SelectContent,
SelectItem,
SelectValue,
} from "@/components/ui/select";
import { useEffect, useRef } from "react";
import { useToast } from "@/hooks/use-toast";
import { ToastAction } from "@radix-ui/react-toast";
import { Toaster } from "@/components/ui/toaster";
const NewWorkspaceForm = object({
workspaceName: pattern(string(), /^[\w-]+$/),
imageId: nonempty(string()),
});
import { Plus } from "lucide-react";
import { useState } from "react";
import { WorkspaceTable } from "./workspace-table";
import { NewWorkspaceDialog } from "./new-workspace-dialog";
function WorkspaceDashboard() {
return (
@@ -66,225 +20,37 @@ function WorkspaceDashboard() {
<header>
<PageHeader>Workspaces</PageHeader>
</header>
<Dialog>
<main>
<DialogTrigger asChild>
<div className="flex flex-row py-4">
<Button variant="secondary" size="sm">
<Plus /> New workspace
</Button>
</div>
</DialogTrigger>
<WorkspaceTable />
</main>
<NewWorkspaceDialog />
</Dialog>
<Main />
<Toaster />
</Page>
</SidebarProvider>
);
}
function WorkspaceTable() {
const { data: workspaces, isLoading } = useWorkspaces();
function placeholder() {
if (isLoading) {
return (
<div className="w-full py-2 space-y-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>
);
}
if (workspaces?.length === 0) {
return (
<p className="text-center py-2 opacity-80">No workspaces found.</p>
);
}
return null;
}
function Main() {
const [isNewWorkspaceDialogOpen, setIsNewWorkspaceDialogOpen] =
useState(false);
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead className="w-min">Status</TableHead>
<TableHead className="text-right">Created at</TableHead>
</TableRow>
</TableHeader>
{workspaces ? (
<TableBody>
{workspaces.map((workspace) => (
<TableRow key={workspace.containerId}>
<TableCell>{workspace.name}</TableCell>
<TableCell>{workspace.imageTag}</TableCell>
<TableCell>
<Badge>Running</Badge>
</TableCell>
<TableCell className="text-right">
{dayjs(workspace.createdAt).format("YYYY/MM/DD HH:mm")}
</TableCell>
</TableRow>
))}
</TableBody>
) : null}
</Table>
{placeholder()}
</>
);
}
<Dialog
open={isNewWorkspaceDialogOpen}
onOpenChange={setIsNewWorkspaceDialogOpen}
>
<main>
<DialogTrigger asChild>
<div className="flex flex-row py-4">
<Button variant="secondary" size="sm">
<Plus /> New workspace
</Button>
</div>
</DialogTrigger>
<WorkspaceTable />
</main>
function NewWorkspaceDialog() {
const { data: templateImages, isLoading, error } = useTemplateImages();
const form = useForm({
resolver: superstructResolver(NewWorkspaceForm),
defaultValues: {
workspaceName: "",
imageId: "",
},
});
const { createWorkspace, status } = useCreateWorkspace();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement | null>(null);
useEffect(() => {
if (status.type === "error") {
toast({
variant: "destructive",
title: "Failed to create the workspace.",
action: (
<ToastAction
onClick={() => {
formRef.current?.requestSubmit();
}}
altText="Try again"
>
Try again
</ToastAction>
),
});
}
}, [status.type, toast]);
async function onSubmit(values: Infer<typeof NewWorkspaceForm>) {
await createWorkspace({
workspaceName: values.workspaceName,
imageId: values.imageId,
});
}
function content() {
if (error) {
console.log(error);
return (
<p className="opacity-80">
An error occurred when fetching available options.
</p>
);
}
if (isLoading) {
return (
<div className="w-full flex items-center justify-center">
<Loader2 className="animate-spin" />
</div>
);
}
if (!templateImages) {
return null;
}
if (templateImages.length === 0) {
return (
<>
<p className="opacity-80">
No images found. Create and build a template, and the resulting
image will show up here.
</p>
<Alert>
<AlertTitle>What are images?</AlertTitle>
<AlertDescription>
An image is used to bootstrap a workspace, including the operating
system, the environment, and packages, as specified by a template.
</AlertDescription>
</Alert>
</>
);
}
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace name</FormLabel>
<FormControl>
<Input placeholder="my-workspace" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="imageId"
render={({ field }) => (
<FormItem>
<FormLabel>Image for this workspace</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templateImages.map((image) => (
<SelectItem key={image.imageId} value={image.imageId}>
{image.imageTag}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
);
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
</DialogHeader>
{content()}
</DialogContent>
<NewWorkspaceDialog
onCreateSuccess={() => setIsNewWorkspaceDialogOpen(false)}
/>
</Dialog>
);
}

View File

@@ -0,0 +1,194 @@
import { Alert, AlertTitle, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { DialogFooter, DialogHeader } from "@/components/ui/dialog";
import {
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
} from "@/components/ui/form";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { ToastAction } from "@/components/ui/toast";
import { DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { useToast } from "@/hooks/use-toast";
import { useTemplateImages } from "@/templates/api";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import { useRef, useCallback, useEffect } from "react";
import { useForm } from "react-hook-form";
import { nonempty, object, pattern, string, type Infer } from "superstruct";
import { useCreateWorkspace } from "./api";
const NewWorkspaceForm = object({
workspaceName: pattern(string(), /^[\w-]+$/),
imageId: nonempty(string()),
});
function NewWorkspaceDialog({
onCreateSuccess,
}: { onCreateSuccess: () => void }) {
const { data: templateImages, isLoading, error } = useTemplateImages();
const form = useForm({
resolver: superstructResolver(NewWorkspaceForm),
defaultValues: {
workspaceName: "",
imageId: "",
},
});
const { createWorkspace, status } = useCreateWorkspace();
const { toast } = useToast();
const formRef = useRef<HTMLFormElement | null>(null);
const _onCreateSuccess = useCallback(onCreateSuccess, []);
useEffect(() => {
switch (status.type) {
case "error":
toast({
variant: "destructive",
title: "Failed to create the workspace.",
action: (
<ToastAction
onClick={() => {
formRef.current?.requestSubmit();
}}
altText="Try again"
>
Try again
</ToastAction>
),
});
break;
case "ok":
_onCreateSuccess();
break;
default:
break;
}
}, [status.type, toast, _onCreateSuccess]);
async function onSubmit(values: Infer<typeof NewWorkspaceForm>) {
await createWorkspace({
workspaceName: values.workspaceName,
imageId: values.imageId,
});
}
function content() {
if (error) {
console.log(error);
return (
<p className="opacity-80">
An error occurred when fetching available options.
</p>
);
}
if (isLoading) {
return (
<div className="w-full flex items-center justify-center">
<LoadingSpinner />
</div>
);
}
if (!templateImages) {
return null;
}
if (templateImages.length === 0) {
return (
<>
<p className="opacity-80">
No images found. Create and build a template, and the resulting
image will show up here.
</p>
<Alert>
<AlertTitle>What are images?</AlertTitle>
<AlertDescription>
An image is used to bootstrap a workspace, including the operating
system, the environment, and packages, as specified by a template.
</AlertDescription>
</Alert>
</>
);
}
return (
<Form {...form}>
<form
ref={formRef}
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-4"
>
<FormField
control={form.control}
name="workspaceName"
render={({ field }) => (
<FormItem>
<FormLabel>Workspace name</FormLabel>
<FormControl>
<Input placeholder="my-workspace" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="imageId"
render={({ field }) => (
<FormItem>
<FormLabel>Image for this workspace</FormLabel>
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select an image" />
</SelectTrigger>
</FormControl>
<SelectContent>
{templateImages.map((image) => (
<SelectItem key={image.imageId} value={image.imageId}>
{image.imageTag}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="submit">Create</Button>
</DialogFooter>
</form>
</Form>
);
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New workspace</DialogTitle>
</DialogHeader>
{content()}
</DialogContent>
);
}
export { NewWorkspaceDialog };

View File

@@ -1,8 +1,18 @@
enum WorkspaceStatus {
Running = "running",
Stopped = "stopped",
Restarting = "restarting",
Unknown = "unknown",
}
interface Workspace {
name: string;
containerId: string;
imageTag: string;
createdAt: string;
status: WorkspaceStatus;
sshPort?: number;
}
export { WorkspaceStatus };
export type { Workspace };

View File

@@ -0,0 +1,239 @@
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useToast } from "@/hooks/use-toast";
import { StopIcon } from "@radix-ui/react-icons";
import { ToastAction } from "@radix-ui/react-toast";
import dayjs from "dayjs";
import { Info, Loader2, Play, Trash2 } from "lucide-react";
import { useEffect } from "react";
import {
useChangeWorkspaceStatus,
useDeleteWorkspace,
useWorkspaces,
} from "./api";
import { type Workspace, WorkspaceStatus } from "./types";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { Badge } from "@/components/ui/badge";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
function WorkspaceTable() {
const { data: workspaces, isLoading } = useWorkspaces();
function placeholder() {
if (isLoading) {
return (
<div className="w-full py-2 space-y-2">
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
<Skeleton className="w-full h-10" />
</div>
);
}
if (workspaces?.length === 0) {
return (
<p className="text-center py-2 opacity-80">No workspaces found.</p>
);
}
return null;
}
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Image</TableHead>
<TableHead className="w-min">Status</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
{workspaces ? (
<TableBody>
{workspaces.map((workspace) => (
<WorkspaceTableRow
key={workspace.containerId}
workspace={workspace}
/>
))}
</TableBody>
) : null}
</Table>
{placeholder()}
</>
);
}
function WorkspaceTableRow({ workspace }: { workspace: Workspace }) {
function statusLabel() {
switch (workspace.status) {
case WorkspaceStatus.Running:
return "Running";
case WorkspaceStatus.Stopped:
return "Stopped";
case WorkspaceStatus.Restarting:
return "Restarting";
case WorkspaceStatus.Unknown:
return "Unknown";
}
}
return (
<TableRow>
<TableCell>{workspace.name}</TableCell>
<TableCell>{workspace.imageTag}</TableCell>
<TableCell>
<Badge>{statusLabel()}</Badge>
</TableCell>
<TableCell>
{dayjs(workspace.createdAt).format("YYYY/MM/DD HH:mm")}
</TableCell>
<TableCell className="flex justify-end space-x-1">
<WorkspaceInfoButton workspace={workspace} />
<WorkspaceStatusButton workspace={workspace} />
<DeleteWorkspaceButton workspace={workspace} />
</TableCell>
</TableRow>
);
}
function WorkspaceStatusButton({ workspace }: { workspace: Workspace }) {
const { toast } = useToast();
const { startWorkspace, stopWorkspace, status } = useChangeWorkspaceStatus();
useEffect(() => {
switch (status.type) {
case "error":
toast({
variant: "destructive",
title: "Failed to change workspace status.",
action: (
<ToastAction onClick={startOrStopWorkspace} altText="Try again">
Try again
</ToastAction>
),
});
break;
}
}, [toast, status.type]);
async function startOrStopWorkspace() {
switch (workspace.status) {
case WorkspaceStatus.Running:
await stopWorkspace(workspace.name);
break;
case WorkspaceStatus.Stopped:
await startWorkspace(workspace.name);
break;
default:
break;
}
}
function statusIcon() {
if (status.type === "loading") {
return <Loader2 className="animate-spin" />;
}
switch (workspace.status) {
case WorkspaceStatus.Running:
return <StopIcon />;
case WorkspaceStatus.Stopped:
return <Play />;
case WorkspaceStatus.Restarting:
case WorkspaceStatus.Unknown:
return null;
}
}
switch (workspace.status) {
case WorkspaceStatus.Running:
case WorkspaceStatus.Stopped:
return (
<Button
variant="outline"
size="icon"
disabled={status.type === "loading"}
onClick={startOrStopWorkspace}
>
{statusIcon()}
</Button>
);
default:
return null;
}
}
function DeleteWorkspaceButton({ workspace }: { workspace: Workspace }) {
const { toast } = useToast();
const { deleteWorkspace, status } = useDeleteWorkspace();
useEffect(() => {
console.log(status.type);
if (status.type === "error") {
toast({
title: `Failed to delete workspace ${workspace.name}.`,
action: (
<ToastAction onClick={_deleteWorkspace} altText="Try again">
Try again
</ToastAction>
),
});
}
}, [toast, status.type, workspace.name]);
async function _deleteWorkspace() {
await deleteWorkspace(workspace.name);
}
return (
<Button variant="outline" size="icon" onClick={_deleteWorkspace}>
{status.type === "loading" ? (
<LoadingSpinner />
) : (
<Trash2 className="text-destructive" />
)}
</Button>
);
}
function WorkspaceInfoButton({ workspace }: { workspace: Workspace }) {
return (
<Popover>
<PopoverTrigger>
<Button variant="outline" size="icon">
<Info />
</Button>
</PopoverTrigger>
<PopoverContent>
<div className="grid grid-cols-3">
{workspace.sshPort ? (
<>
<div className="col-span-2">
<p>SSH Port</p>
</div>
<p className="text-right">{workspace.sshPort}</p>
</>
) : null}
</div>
</PopoverContent>
</Popover>
);
}
export { WorkspaceTable };