initial commit

This commit is contained in:
2024-11-12 00:31:10 +00:00
commit a7933f8b06
99 changed files with 6836 additions and 0 deletions

162
web/src/templates/api.ts Normal file
View File

@@ -0,0 +1,162 @@
import { useCallback, useState } from "react";
import useSWR, { mutate, useSWRConfig } from "swr";
import type { Template, TemplateMeta, TemplateImage } from "./types";
import { fetchApi, type ApiError } from "@/api";
function useTemplates() {
return useSWR(
"/templates",
async (): Promise<TemplateMeta[]> =>
fetchApi("/templates").then((res) => res.json()),
);
}
function useTemplate(name: string) {
return useSWR(
["/templates", name],
async (): Promise<Template> =>
fetchApi(`/templates/${name}`).then((res) => res.json()),
);
}
function useDeleteTemplate() {
const { mutate } = useSWRConfig();
const deleteTemplate = useCallback(
async (templateName: string) => {
mutate(
"/templates",
fetchApi(`/templates/${templateName}`, { method: "DELETE" }),
{
populateCache: (_, templates) =>
templates?.filter((it: TemplateMeta) => it.name !== templateName),
revalidate: false,
},
);
},
[mutate],
);
return deleteTemplate;
}
function useCreateTemplate() {
const [isCreating, setIsCreating] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const { mutate } = useSWRConfig();
const createTemplate = useCallback(
async ({
name,
description,
}: { name: string; description: string }): Promise<Template | null> => {
try {
const res = await fetchApi(`/templates/${name}`, {
method: "POST",
body: JSON.stringify({ description }),
headers: {
"Content-Type": "application/json",
},
});
const template: Template = await res.json();
mutate(["/templates", name], template, {
populateCache: (newTemplate) => newTemplate,
revalidate: false,
});
return template;
} catch (err: unknown) {
setError(err);
return null;
} finally {
setIsCreating(false);
}
},
[mutate],
);
return { createTemplate, isCreatingTemplate: isCreating, error };
}
function useTemplateFile(templateName: string, filePath: string) {
return useSWR(filePath ? ["/templates", templateName, filePath] : null, () =>
fetchApi(`/templates/${templateName}/${filePath}`).then((res) =>
res.text(),
),
);
}
function useUpdateTemplateFile(name: string) {
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<unknown | null>(null);
const { mutate } = useSWRConfig();
const updateTemplateFile = useCallback(
async (path: string, content: string) => {
setIsUpdating(true);
try {
await fetchApi(`/templates/${name}/${path}`, {
method: "POST",
body: content,
headers: {
"Content-Type": "text/plain",
},
});
mutate(["/templates", name, path], content);
} catch (err: unknown) {
console.error(err);
setError(err);
} finally {
setIsUpdating(false);
}
},
[name, mutate],
);
return { updateTemplateFile, isUpdating, error };
}
async function buildTemplate({
imageTag,
templateName,
onBuildOutput,
}: {
imageTag: string;
templateName: string;
onBuildOutput: (chunk: string) => void;
}) {
const res = await fetchApi(`/templates/${templateName}`, {
method: "POST",
body: JSON.stringify({ imageTag }),
headers: {
Accept: "text/event-stream",
},
});
const stream = res.body?.pipeThrough(new TextDecoderStream()).getReader();
if (stream) {
while (true) {
const { value, done } = await stream.read();
if (done) break;
onBuildOutput(value);
}
}
}
function useTemplateImages() {
return useSWR(
"/template-images",
(): Promise<TemplateImage[]> =>
fetchApi("/template-images").then((res) => res.json()),
);
}
export {
useTemplates,
useTemplate,
useTemplateFile,
useCreateTemplate,
useUpdateTemplateFile,
buildTemplate,
useDeleteTemplate,
useTemplateImages,
};

View File

@@ -0,0 +1,254 @@
import { PageHeader } from "@/components/ui/page-header.tsx";
import { Button } from "@/components/ui/button.tsx";
import { Info, Loader2, Pencil, Plus, Trash2 } from "lucide-react";
import { Page } from "@/components/ui/page.tsx";
import { SidebarProvider } from "@/components/ui/sidebar.tsx";
import { MainSidebar } from "@/components/main-sidebar.tsx";
import {
Table,
TableHeader,
TableRow,
TableHead,
TableBody,
TableCell,
} from "@/components/ui/table";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Link, useRouter } from "@tanstack/react-router";
import { object, pattern, string, type Infer } from "superstruct";
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 { useCreateTemplate, useDeleteTemplate, useTemplates } from "./api";
import { Skeleton } from "@/components/ui/skeleton";
import dayjs from "dayjs";
import { ToastAction } from "@radix-ui/react-toast";
import { useToast } from "@/hooks/use-toast";
import { Toaster } from "@/components/ui/toaster";
const NewTemplateForm = object({
templateName: pattern(string(), /^[\w-]+$/),
templateDescription: string(),
});
function TemplatesDashboard() {
return (
<SidebarProvider>
<aside>
<MainSidebar />
</aside>
<Page>
<PageHeader>Templates</PageHeader>
<Dialog>
<main>
<DialogTrigger asChild>
<div className="flex flex-row py-4">
<Button variant="secondary" size="sm">
<Plus /> New template
</Button>
</div>
</DialogTrigger>
<TemplateTable />
</main>
<NewTemplateDialog />
</Dialog>
<Toaster />
</Page>
</SidebarProvider>
);
}
function TemplateTable() {
const { data: templates, isLoading } = useTemplates();
const deleteTemplate = useDeleteTemplate();
const { toast } = useToast();
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 (templates?.length === 0) {
return <p className="text-center py-2 opacity-80">No templates found.</p>;
}
return null;
}
async function _deleteTemplate(templateName: string) {
try {
await deleteTemplate(templateName);
toast({
title: "Template deleted!",
});
} catch (error) {
toast({
variant: "destructive",
title: "Failed to delete template.",
action: (
<ToastAction
altText="Try again"
onClick={() => _deleteTemplate(templateName)}
>
Try again
</ToastAction>
),
});
}
}
return (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Description</TableHead>
<TableHead>Created at</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
{templates ? (
<TableBody>
{templates.map((template) => (
<TableRow key={template.name}>
<TableCell>{template.name}</TableCell>
<TableCell>
{template.description || "No description"}
</TableCell>
<TableCell>
{dayjs(template.createdOn).format("YYYY/MM/DD")}
</TableCell>
<TableCell className="flex justify-end space-x-1">
<Button variant="outline" size="icon" asChild>
<Link to={`/templates/${template.name}`} preload="intent">
<div>
<Pencil />
<span className="sr-only">Edit template</span>
</div>
</Link>
</Button>
<Button variant="outline" size="icon">
<Info />
</Button>
<Button
variant="outline"
size="icon"
onClick={() => _deleteTemplate(template.name)}
>
<Trash2 className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
) : null}
</Table>
{placeholder()}
</>
);
}
function NewTemplateDialog() {
const router = useRouter();
const { createTemplate, isCreatingTemplate } = useCreateTemplate();
const form = useForm({
resolver: superstructResolver(NewTemplateForm),
disabled: isCreatingTemplate,
defaultValues: {
templateName: "",
templateDescription: "",
},
});
async function onSubmit(values: Infer<typeof NewTemplateForm>) {
const createdTemplate = await createTemplate({
name: values.templateName,
description: values.templateDescription,
});
if (createdTemplate) {
router.navigate({ to: `/templates/${createdTemplate.name}` });
}
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>New template</DialogTitle>
<DialogDescription>
Create a new template for workspaces
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="templateName"
render={({ field }) => (
<FormItem>
<FormLabel>Template name</FormLabel>
<FormControl>
<Input placeholder="my-template" {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="templateDescription"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
Optional description for this template
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button disabled={isCreatingTemplate} type="submit">
{isCreatingTemplate ? <Loader2 className="animate-spin" /> : null}
Create
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export { TemplatesDashboard };

View File

@@ -0,0 +1,18 @@
import { createRoute } from "@tanstack/react-router";
import { rootRoute } from "@/root-route.tsx";
import { TemplatesDashboard } from "@/templates/dashboard.tsx";
import { TemplateEditor } from "@/templates/template-editor.tsx";
const templatesDashboardRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/templates",
component: TemplatesDashboard,
});
const templateEditorRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/templates/$templateName/$",
component: TemplateEditor,
});
export { templatesDashboardRoute, templateEditorRoute };

View File

@@ -0,0 +1,90 @@
import { create, createStore, useStore } from "zustand";
import type { Template } from "./types";
import { createContext, useContext } from "react";
import { buildTemplate } from "./api";
interface TemplateEditorState {
template: Template;
currentFilePath: string;
isBuildInProgress: boolean;
isBuildOutputVisible: boolean;
buildOutput: string;
startBuild: ({ imageTag }: { imageTag: string }) => Promise<void>;
setCurrentFilePath: (path: string) => void;
addBuildOutputChunk: (chunk: string) => void;
toggleBuildOutput: () => void;
}
type TemplateEditorStore = ReturnType<typeof createTemplateEditorStore>;
function createTemplateEditorStore({
template,
currentFilePath,
}: { template: Template; currentFilePath: string }) {
return createStore<TemplateEditorState>()((set, get) => ({
template,
currentFilePath,
isBuildInProgress: false,
isBuildOutputVisible: false,
buildOutput: "",
startBuild: async ({ imageTag }) => {
const state = get();
set({
isBuildInProgress: true,
isBuildOutputVisible: true,
buildOutput: "",
});
try {
await buildTemplate({
imageTag,
templateName: state.template.name,
onBuildOutput: state.addBuildOutputChunk,
});
} catch {
// TODO: handle build error
} finally {
set({ isBuildInProgress: false });
}
},
setCurrentFilePath: (path) => set({ currentFilePath: path }),
addBuildOutputChunk: (chunk) =>
set((state) => ({
...state,
buildOutput: state.buildOutput + chunk,
})),
toggleBuildOutput: () =>
set((state) => ({
...state,
isBuildOutputVisible: !state.isBuildOutputVisible,
})),
}));
}
const TemplateEditorStoreContext = createContext<TemplateEditorStore | null>(
null,
);
function useTemplateEditorStore<T>(
selector: (state: TemplateEditorState) => T,
): T {
const store = useContext(TemplateEditorStoreContext);
if (!store) throw new Error("TemplateEditorStore not in context");
return useStore(store, selector);
}
export {
TemplateEditorStoreContext,
createTemplateEditorStore,
useTemplateEditorStore,
};
export type { TemplateEditorStore, TemplateEditorState };

View File

@@ -0,0 +1,391 @@
import { ApiError } from "@/api";
import { CodeMirrorEditor } from "@/components/codemirror-editor";
import { Button } from "@/components/ui/button.tsx";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarProvider,
} from "@/components/ui/sidebar.tsx";
import { cn } from "@/lib/utils";
import { superstructResolver } from "@hookform/resolvers/superstruct";
import { Link } from "@tanstack/react-router";
import {
ArrowLeft,
ChevronDown,
ChevronUp,
Hammer,
Loader2,
} from "lucide-react";
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import { object, pattern, string, type Infer } from "superstruct";
import { useStore } from "zustand";
import { useTemplate, useTemplateFile, useUpdateTemplateFile } from "./api";
import { templateEditorRoute } from "./routes";
import {
type TemplateEditorStore,
TemplateEditorStoreContext,
createTemplateEditorStore,
useTemplateEditorStore,
} from "./template-editor-store";
import type { Template } from "./types";
import { DialogClose } from "@radix-ui/react-dialog";
const BuildOptionForm = object({
imageName: pattern(string(), /^[\w-]+$/),
});
function TemplateEditor() {
const { templateName, _splat } = templateEditorRoute.useParams();
const { data: template, isLoading, error } = useTemplate(templateName);
if (isLoading) {
return (
<main className="w-full h-full flex items-center justify-center">
<Loader2 className="animate-spin" />
</main>
);
}
if (error || !template) {
if (error === ApiError.NotFound) {
return (
<main className="w-full h-full flex flex-col items-center justify-center space-y-2">
<p>Template does not exist</p>
<Button variant="secondary">Create template</Button>
</main>
);
}
let message = "";
switch (error) {
case ApiError.Network:
message = "We are having trouble contacting the server.";
break;
default:
message = "An error occurred on our end.";
break;
}
return (
<main className="w-full h-full flex flex-col items-center justify-center space-y-2">
<p className="text-destructive">{message}</p>
<Button variant="secondary">Refresh</Button>
</main>
);
}
return <_TemplateEditor template={template} currentFilePath={_splat ?? ""} />;
}
function _TemplateEditor({
template,
currentFilePath,
}: { template: Template; currentFilePath: string }) {
const store = useRef<TemplateEditorStore | null>(null);
if (!store.current) {
store.current = createTemplateEditorStore({ template, currentFilePath });
}
const setCurrentFilePath = useStore(
store.current,
(state) => state.setCurrentFilePath,
);
useEffect(() => {
setCurrentFilePath(currentFilePath);
}, [setCurrentFilePath, currentFilePath]);
return (
<TemplateEditorStoreContext.Provider value={store.current}>
<SidebarProvider>
<aside>
<EditorSidebar />
</aside>
<div className="flex flex-col w-full">
<EditorTopBar />
<main className="w-full h-full flex flex-col">
<Editor />
<TemplateBuildOutputPanel />
</main>
</div>
</SidebarProvider>
</TemplateEditorStoreContext.Provider>
);
}
function Editor() {
const templateName = useTemplateEditorStore((state) => state.template.name);
const currentPath = useTemplateEditorStore((state) => state.currentFilePath);
const {
isLoading,
data: fileContent,
error,
} = useTemplateFile(templateName, currentPath);
const saveTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
const { updateTemplateFile } = useUpdateTemplateFile(templateName);
useEffect(
() => () => {
if (saveTimeout.current) {
clearTimeout(saveTimeout.current);
}
},
[],
);
if (!currentPath) {
return (
<div className="w-full h-full flex items-center justify-center">
<p>Select a file from the sidebar</p>
</div>
);
}
if (isLoading) {
return (
<div className="w-full h-full flex items-center justify-center">
<p className="animate-pulse">Loading file</p>
</div>
);
}
if (error || fileContent === undefined) {
let message = "";
switch (error) {
case ApiError.NotFound:
message = "This file does not exist in the template.";
break;
case ApiError.Network:
message = "Having trouble contacting the server.";
break;
default:
message = "An error occured on our end.";
}
return (
<div className="w-full h-full flex items-center justify-center">
<p className="text-destructive">{message}</p>
</div>
);
}
function onValueChanged(path: string, value: string) {
if (saveTimeout.current) {
clearTimeout(saveTimeout.current);
}
saveTimeout.current = setTimeout(() => {
updateTemplateFile(path, value);
}, 500);
}
return (
<CodeMirrorEditor
className="w-full flex-1 grow"
path={currentPath}
initialValue={fileContent}
onValueChanged={onValueChanged}
/>
);
}
function EditorTopBar() {
const currentFilePath = useTemplateEditorStore(
(state) => state.currentFilePath,
);
const isBuildInProgress = useTemplateEditorStore(
(state) => state.isBuildInProgress,
);
return (
<Dialog>
<header className="sticky top-0 flex shrink-0 items-center justify-between gap-2 border-b bg-background p-4">
<p className="font-bold">{currentFilePath}</p>
<DialogTrigger>
<Button>
{isBuildInProgress ? (
<Loader2 className="animate-spin" />
) : (
<Hammer />
)}{" "}
Build
</Button>
</DialogTrigger>
</header>
<BuildOptionDialog />
</Dialog>
);
}
function EditorSidebar() {
const templateName = useTemplateEditorStore((state) => state.template.name);
return (
<Sidebar>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild className="opacity-80">
<Link to="/templates" className="text-xs">
<ArrowLeft /> All templates
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<p className="px-2 font-semibold">{templateName}</p>
</SidebarHeader>
<SidebarContent>
<SidebarGroup>
<SidebarGroupLabel>Files</SidebarGroupLabel>
<EditorSidebarFileTree />
</SidebarGroup>
</SidebarContent>
</Sidebar>
);
}
function EditorSidebarFileTree() {
const template = useTemplateEditorStore((state) => state.template);
const currentFilePath = useTemplateEditorStore(
(state) => state.currentFilePath,
);
return (
<SidebarMenu>
{Object.values(template.files).map((file) => (
<SidebarMenuItem key={file.path}>
<SidebarMenuButton isActive={currentFilePath === file.path} asChild>
<Link to={`/templates/${template.name}/${file.path}`}>
{file.path}
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
);
}
function TemplateBuildOutputPanel() {
const isBuildOutputVisible = useTemplateEditorStore(
(state) => state.isBuildOutputVisible,
);
const toggleBuildOutput = useTemplateEditorStore(
(state) => state.toggleBuildOutput,
);
return (
<div
className={cn(
"flex flex-col overflow-hidden",
isBuildOutputVisible ? "h-96" : "",
)}
>
<div className="flex justify-between items-center border-y border-y-border pl-4 pr-2 py-0.5">
<p className="font-semibold text-sm">Build output</p>
<Button variant="ghost" size="icon" onClick={toggleBuildOutput}>
{isBuildOutputVisible ? <ChevronDown /> : <ChevronUp />}
</Button>
</div>
{isBuildOutputVisible ? <BuildOutput /> : null}
</div>
);
}
function BuildOutput() {
const buildOutput = useTemplateEditorStore((state) => state.buildOutput);
const el = useRef<HTMLPreElement | null>(null);
// biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
useEffect(() => {
if (el.current) {
el.current.scrollTo(0, el.current.scrollHeight);
}
}, [buildOutput]);
return (
<pre ref={el} className="p-4 overflow-auto">
{buildOutput}
</pre>
);
}
function BuildOptionDialog() {
const templateName = useTemplateEditorStore((state) => state.template.name);
const startBuild = useTemplateEditorStore((state) => state.startBuild);
const form = useForm({
resolver: superstructResolver(BuildOptionForm),
defaultValues: {
imageName: templateName,
},
});
function onSubmit(values: Infer<typeof BuildOptionForm>) {
startBuild({
imageTag: values.imageName,
});
}
return (
<DialogContent>
<DialogHeader>
<DialogTitle>Build options</DialogTitle>
<DialogDescription>
Build options for this Docker image
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="imageName"
render={({ field }) => (
<FormItem>
<FormLabel>Image name</FormLabel>
<FormControl>
<Input placeholder={templateName} {...field} />
</FormControl>
<FormDescription>
Must only contain alphanumeric characters and "-".
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="pt-4">
<DialogClose asChild>
<Button type="submit">Build template</Button>
</DialogClose>
</DialogFooter>
</form>
</Form>
</DialogContent>
);
}
export { TemplateEditor };

View File

@@ -0,0 +1,22 @@
interface TemplateMeta {
name: string;
description: string;
createdOn: string;
lastModifiedOn: string;
}
interface Template extends TemplateMeta {
files: Record<string, FileInTemplate>;
}
interface TemplateImage {
imageTag: string;
imageId: string;
}
interface FileInTemplate {
path: string;
content: string;
}
export type { TemplateMeta, Template, FileInTemplate, TemplateImage };