mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(frontend): create volume
This commit is contained in:
18
internal/core/text-utils.go
Normal file
18
internal/core/text-utils.go
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
package core
|
||||||
|
|
||||||
|
import (
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var nonAlnum = regexp.MustCompile(`[^a-z0-9_-]+`)
|
||||||
|
|
||||||
|
var hyphenRuns = regexp.MustCompile(`[-_]{2,}`)
|
||||||
|
|
||||||
|
func Slugify(input string) string {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(input))
|
||||||
|
s = nonAlnum.ReplaceAllString(s, "-")
|
||||||
|
s = hyphenRuns.ReplaceAllString(s, "-")
|
||||||
|
s = strings.Trim(s, "-")
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -2,56 +2,57 @@
|
|||||||
package volumes
|
package volumes
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"ironmount/internal/core"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/rs/zerolog/log"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SetupHandlers sets up the API routes for the application.
|
// SetupHandlers sets up the API routes for the application.
|
||||||
func SetupHandlers(router *gin.Engine) {
|
func SetupHandlers(router *gin.Engine) {
|
||||||
|
|
||||||
volumeService := VolumeService{}
|
volumeService := VolumeService{}
|
||||||
|
|
||||||
router.GET("/api/volumes", func(c *gin.Context) {
|
router.GET("/api/volumes", func(c *gin.Context) {
|
||||||
volumes := volumeService.ListVolumes()
|
volumes := volumeService.ListVolumes()
|
||||||
|
|
||||||
log.Debug().Msgf("Listing volumes: %v", volumes)
|
log.Debug().Msgf("Listing volumes: %v", volumes)
|
||||||
|
c.JSON(200, gin.H{"volumes": volumes})
|
||||||
c.JSON(200, gin.H{
|
|
||||||
"volumes": volumes,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
router.POST("/api/volumes", func(c *gin.Context) {
|
router.POST("/api/volumes", func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
c.JSON(400, gin.H{"error": "Invalid request body"})
|
c.JSON(400, gin.H{"error": "Invalid request body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
volume, status, err := volumeService.CreateVolume(req.Name)
|
clean := core.Slugify(req.Name)
|
||||||
|
if clean == "" || clean != req.Name {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid volume name"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
volume, status, err := volumeService.CreateVolume(clean)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(status, gin.H{"error": err.Error()})
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(status, volume)
|
c.JSON(status, volume)
|
||||||
})
|
})
|
||||||
|
|
||||||
router.GET("/api/volumes/:name", func(c *gin.Context) {
|
router.GET("/api/volumes/:name", func(c *gin.Context) {
|
||||||
volume, err := volumeService.GetVolume(c.Param("name"))
|
volume, err := volumeService.GetVolume(c.Param("name"))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(500, gin.H{"error": err.Error()})
|
c.JSON(500, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if volume == nil {
|
if volume == nil {
|
||||||
c.JSON(404, gin.H{"error": "Volume not found"})
|
c.JSON(404, gin.H{"error": "Volume not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
"name": volume.Name,
|
"name": volume.Name,
|
||||||
"mountpoint": volume.Path,
|
"mountpoint": volume.Path,
|
||||||
@@ -62,12 +63,10 @@ func SetupHandlers(router *gin.Engine) {
|
|||||||
|
|
||||||
router.DELETE("/api/volumes/:name", func(c *gin.Context) {
|
router.DELETE("/api/volumes/:name", func(c *gin.Context) {
|
||||||
status, err := volumeService.DeleteVolume(c.Param("name"))
|
status, err := volumeService.DeleteVolume(c.Param("name"))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(status, gin.H{"error": err.Error()})
|
c.JSON(status, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, gin.H{"message": "Volume deleted successfully"})
|
c.JSON(200, gin.H{"message": "Volume deleted successfully"})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
123
web/app/components/create-volume-dialog.tsx
Normal file
123
web/app/components/create-volume-dialog.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Plus } from "lucide-react";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { useCreateVolume } from "~/hooks/useCreateVolume";
|
||||||
|
import { slugify } from "~/lib/utils";
|
||||||
|
import { Button } from "./ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "./ui/dialog";
|
||||||
|
import {
|
||||||
|
Form,
|
||||||
|
FormControl,
|
||||||
|
FormDescription,
|
||||||
|
FormField,
|
||||||
|
FormItem,
|
||||||
|
FormLabel,
|
||||||
|
FormMessage,
|
||||||
|
} from "./ui/form";
|
||||||
|
import { Input } from "./ui/input";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
setOpen: (open: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(2, {
|
||||||
|
message: "Volume name must be at least 2 characters long",
|
||||||
|
})
|
||||||
|
.max(32, {
|
||||||
|
message: "Volume name must be at most 32 characters long",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CreateVolumeDialog = ({ open, setOpen }: Props) => {
|
||||||
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
|
resolver: zodResolver(formSchema),
|
||||||
|
defaultValues: {
|
||||||
|
name: "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const nameValue = form.watch("name");
|
||||||
|
const createVolume = useCreateVolume();
|
||||||
|
|
||||||
|
const onSubmit = (values: { name: string }) => {
|
||||||
|
createVolume.mutate(values, {
|
||||||
|
onSuccess: () => {
|
||||||
|
form.reset();
|
||||||
|
setOpen(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button className="bg-blue-900 hover:bg-blue-800">
|
||||||
|
<Plus size={16} />
|
||||||
|
Create volume
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create volume</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Enter a name for the new volume.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
name="name"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>Name</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
placeholder="Volume name"
|
||||||
|
value={field.value ?? ""}
|
||||||
|
onChange={(e) => field.onChange(slugify(e.target.value))}
|
||||||
|
max={32}
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
Unique identifier for the volume.
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{createVolume.error && (
|
||||||
|
<div className="text-red-500 text-sm">
|
||||||
|
{createVolume.error.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="secondary" onClick={() => setOpen(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
disabled={createVolume.status === "pending" || !nameValue}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { Slot } from "@radix-ui/react-slot";
|
import { Slot } from "@radix-ui/react-slot";
|
||||||
import { cva, type VariantProps } from "class-variance-authority";
|
import { cva, type VariantProps } from "class-variance-authority";
|
||||||
|
import type * as React from "react";
|
||||||
|
|
||||||
import { cn } from "~/lib/utils";
|
import { cn } from "~/lib/utils";
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:sbg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex cursor-pointer uppercase border items-center justify-center dark:border-white dark:bg-secondary dark:text-white gap-2 whitespace-nowrap text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
|
|||||||
57
web/app/hooks/useCreateVolume.ts
Normal file
57
web/app/hooks/useCreateVolume.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { type } from "arktype";
|
||||||
|
import { slugify } from "~/lib/utils";
|
||||||
|
|
||||||
|
const createVolume = async (variables: { name: string }) => {
|
||||||
|
const cleanName = slugify(variables.name);
|
||||||
|
if (!cleanName) {
|
||||||
|
throw new Error("Invalid volume name");
|
||||||
|
}
|
||||||
|
const response = await fetch("/api/volumes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ name: cleanName }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
let errorText = "Network response was not ok";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const errorData = await response.json();
|
||||||
|
if (errorData.error && typeof errorData.error === "string") {
|
||||||
|
errorText = errorData.error;
|
||||||
|
} else {
|
||||||
|
errorText = JSON.stringify(errorData);
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
throw new Error(errorText);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
const createVolumeSchema = type({
|
||||||
|
name: "string",
|
||||||
|
});
|
||||||
|
|
||||||
|
export const useCreateVolume = () => {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: createVolume,
|
||||||
|
onSuccess: (data) => {
|
||||||
|
const result = createVolumeSchema(data);
|
||||||
|
|
||||||
|
if (result instanceof type.errors) {
|
||||||
|
console.error("Create volume response validation failed:", result);
|
||||||
|
return { message: "Invalid data format" };
|
||||||
|
}
|
||||||
|
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["volumes"] });
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -1,6 +1,30 @@
|
|||||||
import { clsx, type ClassValue } from "clsx"
|
import { type ClassValue, clsx } from "clsx";
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
|
/** Conditional merge of class names */
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an arbitrary string into a URL-safe slug:
|
||||||
|
* - lowercase
|
||||||
|
* - trims whitespace
|
||||||
|
* - replaces non-alphanumeric runs with "-"
|
||||||
|
* - collapses multiple hyphens
|
||||||
|
* - trims leading/trailing hyphens
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Live slugify for UI: lowercases, normalizes dashes, replaces invalid runs with "-",
|
||||||
|
* collapses repeats, but DOES NOT trim leading/trailing hyphens so the user can type
|
||||||
|
* spaces/dashes progressively while editing.
|
||||||
|
*/
|
||||||
|
export function slugify(input: string): string {
|
||||||
|
return input
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[ ]/g, "-")
|
||||||
|
.replace(/[^a-z0-9_-]+/g, "")
|
||||||
|
.replace(/[-]{2,}/g, "-")
|
||||||
|
.replace(/[_]{2,}/g, "_")
|
||||||
|
.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Copy, Folder, Plus } from "lucide-react";
|
import { Copy, Folder } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CreateVolumeDialog } from "~/components/create-volume-dialog";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import { Input } from "~/components/ui/input";
|
import { Input } from "~/components/ui/input";
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,7 @@ import { cn } from "~/lib/utils";
|
|||||||
export function Welcome() {
|
export function Welcome() {
|
||||||
const { data } = useVolumes();
|
const { data } = useVolumes();
|
||||||
const deleteVolume = useDeleteVolume();
|
const deleteVolume = useDeleteVolume();
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -42,15 +45,15 @@ export function Welcome() {
|
|||||||
</h2>
|
</h2>
|
||||||
<div className="flex items-center gap-2 mt-4 justify-between">
|
<div className="flex items-center gap-2 mt-4 justify-between">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Input className="w-[180px]" placeholder="Search volumes..." />
|
<Input className="w-[180px]" placeholder="Search volumes…" />
|
||||||
<Select>
|
<Select>
|
||||||
<SelectTrigger className="w-[180px]">
|
<SelectTrigger className="w-[180px]">
|
||||||
<SelectValue placeholder="All status" />
|
<SelectValue placeholder="All status" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="light">Mounted</SelectItem>
|
<SelectItem value="mounted">Mounted</SelectItem>
|
||||||
<SelectItem value="dark">Unmounted</SelectItem>
|
<SelectItem value="unmounted">Unmounted</SelectItem>
|
||||||
<SelectItem value="system">Error</SelectItem>
|
<SelectItem value="error">Error</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Select>
|
<Select>
|
||||||
@@ -58,16 +61,13 @@ export function Welcome() {
|
|||||||
<SelectValue placeholder="All backends" />
|
<SelectValue placeholder="All backends" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="light">Directory</SelectItem>
|
<SelectItem value="directory">Directory</SelectItem>
|
||||||
<SelectItem value="dark">NFS</SelectItem>
|
<SelectItem value="nfs">NFS</SelectItem>
|
||||||
<SelectItem value="system">SMB</SelectItem>
|
<SelectItem value="smb">SMB</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</span>
|
</span>
|
||||||
<Button className="bg-blue-900 hover:bg-blue-800">
|
<CreateVolumeDialog open={open} setOpen={setOpen} />
|
||||||
<Plus size={16} />
|
|
||||||
Create volume
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
<Table className="mt-4 border bg-white dark:bg-secondary">
|
<Table className="mt-4 border bg-white dark:bg-secondary">
|
||||||
<TableCaption>A list of your managed Docker volumes.</TableCaption>
|
<TableCaption>A list of your managed Docker volumes.</TableCaption>
|
||||||
@@ -84,7 +84,7 @@ export function Welcome() {
|
|||||||
{data?.volumes.map((volume) => (
|
{data?.volumes.map((volume) => (
|
||||||
<TableRow key={volume.name}>
|
<TableRow key={volume.name}>
|
||||||
<TableCell className="font-medium">{volume.name}</TableCell>
|
<TableCell className="font-medium">{volume.name}</TableCell>
|
||||||
<TableCell className="">
|
<TableCell>
|
||||||
<span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1">
|
<span className="mx-auto flex items-center gap-2 text-purple-800 dark:text-purple-300 rounded-md px-2 py-1">
|
||||||
<Folder size={10} />
|
<Folder size={10} />
|
||||||
Dir
|
Dir
|
||||||
@@ -98,10 +98,10 @@ export function Welcome() {
|
|||||||
<Copy size={10} />
|
<Copy size={10} />
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="">
|
<TableCell className="text-center">
|
||||||
<span className="relative flex size-3 mx-auto">
|
<span className="relative flex size-3 mx-auto">
|
||||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75"></span>
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-green-400 opacity-75" />
|
||||||
<span className="relative inline-flex size-3 rounded-full bg-green-500"></span>
|
<span className="relative inline-flex size-3 rounded-full bg-green-500" />
|
||||||
</span>
|
</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
|||||||
Reference in New Issue
Block a user