feat: mount / unmount

This commit is contained in:
Nicolas Meienberger
2025-09-23 18:22:54 +02:00
parent 833bcb590f
commit f67152146d
17 changed files with 464 additions and 25 deletions

View File

@@ -8,6 +8,8 @@ import {
deleteVolume,
getVolume,
updateVolume,
mountVolume,
unmountVolume,
} from "../sdk.gen";
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
import type {
@@ -21,6 +23,10 @@ import type {
GetVolumeData,
UpdateVolumeData,
UpdateVolumeResponse,
MountVolumeData,
MountVolumeResponse,
UnmountVolumeData,
UnmountVolumeResponse,
} from "../types.gen";
import { client as _heyApiClient } from "../client.gen";
@@ -219,3 +225,81 @@ export const updateVolumeMutation = (
};
return mutationOptions;
};
export const mountVolumeQueryKey = (options: Options<MountVolumeData>) => createQueryKey("mountVolume", options);
/**
* Mount a volume
*/
export const mountVolumeOptions = (options: Options<MountVolumeData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await mountVolume({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: mountVolumeQueryKey(options),
});
};
/**
* Mount a volume
*/
export const mountVolumeMutation = (
options?: Partial<Options<MountVolumeData>>,
): UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> => {
const mutationOptions: UseMutationOptions<MountVolumeResponse, DefaultError, Options<MountVolumeData>> = {
mutationFn: async (localOptions) => {
const { data } = await mountVolume({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};
export const unmountVolumeQueryKey = (options: Options<UnmountVolumeData>) => createQueryKey("unmountVolume", options);
/**
* Unmount a volume
*/
export const unmountVolumeOptions = (options: Options<UnmountVolumeData>) => {
return queryOptions({
queryFn: async ({ queryKey, signal }) => {
const { data } = await unmountVolume({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: unmountVolumeQueryKey(options),
});
};
/**
* Unmount a volume
*/
export const unmountVolumeMutation = (
options?: Partial<Options<UnmountVolumeData>>,
): UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> => {
const mutationOptions: UseMutationOptions<UnmountVolumeResponse, DefaultError, Options<UnmountVolumeData>> = {
mutationFn: async (localOptions) => {
const { data } = await unmountVolume({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};

View File

@@ -16,6 +16,12 @@ import type {
UpdateVolumeData,
UpdateVolumeResponses,
UpdateVolumeErrors,
MountVolumeData,
MountVolumeResponses,
MountVolumeErrors,
UnmountVolumeData,
UnmountVolumeResponses,
UnmountVolumeErrors,
} from "./types.gen";
import { client as _heyApiClient } from "./client.gen";
@@ -115,3 +121,25 @@ export const updateVolume = <ThrowOnError extends boolean = false>(
},
});
};
/**
* Mount a volume
*/
export const mountVolume = <ThrowOnError extends boolean = false>(options: Options<MountVolumeData, ThrowOnError>) => {
return (options.client ?? _heyApiClient).post<MountVolumeResponses, MountVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/mount",
...options,
});
};
/**
* Unmount a volume
*/
export const unmountVolume = <ThrowOnError extends boolean = false>(
options: Options<UnmountVolumeData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<UnmountVolumeResponses, UnmountVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/unmount",
...options,
});
};

View File

@@ -245,6 +245,60 @@ export type UpdateVolumeResponses = {
export type UpdateVolumeResponse = UpdateVolumeResponses[keyof UpdateVolumeResponses];
export type MountVolumeData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/volumes/{name}/mount";
};
export type MountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type MountVolumeResponses = {
/**
* Volume mounted successfully
*/
200: {
message: string;
};
};
export type MountVolumeResponse = MountVolumeResponses[keyof MountVolumeResponses];
export type UnmountVolumeData = {
body?: never;
path: {
name: string;
};
query?: never;
url: "/api/v1/volumes/{name}/unmount";
};
export type UnmountVolumeErrors = {
/**
* Volume not found
*/
404: unknown;
};
export type UnmountVolumeResponses = {
/**
* Volume unmounted successfully
*/
200: {
message: string;
};
};
export type UnmountVolumeResponse = UnmountVolumeResponses[keyof UnmountVolumeResponses];
export type ClientOptions = {
baseUrl: "http://localhost:3000" | (string & {});
};

View File

@@ -5,8 +5,8 @@
@theme {
--font-sans:
"Google Sans Code", ui-sans-serif, system-ui, sans-serif,
"Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
"Google Sans Code", ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol",
"Noto Color Emoji";
}
html,

View File

@@ -8,6 +8,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
position="top-center"
style={
{
"--normal-bg": "var(--popover)",

View File

@@ -3,5 +3,9 @@ export const parseError = (error?: unknown) => {
return { message: error.message as string };
}
if (typeof error === "string") {
return { message: error };
}
return undefined;
};

View File

@@ -3,7 +3,12 @@ import { WifiIcon } from "lucide-react";
import { useNavigate, useParams } from "react-router";
import { toast } from "sonner";
import { getVolume } from "~/api-client";
import { deleteVolumeMutation, getVolumeOptions } from "~/api-client/@tanstack/react-query.gen";
import {
deleteVolumeMutation,
getVolumeOptions,
mountVolumeMutation,
unmountVolumeMutation,
} from "~/api-client/@tanstack/react-query.gen";
import { CreateVolumeForm } from "~/components/create-volume-form";
import { Button } from "~/components/ui/button";
import { Card } from "~/components/ui/card";
@@ -11,6 +16,7 @@ import { VolumeIcon } from "~/components/volume-icon";
import { parseError } from "~/lib/errors";
import { HealthchecksCard } from "~/modules/details/components/healthchecks-card";
import type { Route } from "./+types/details";
import { cn } from "~/lib/utils";
export const clientLoader = async ({ params }: Route.ClientLoaderArgs) => {
const volume = await getVolume({ path: { name: params.name ?? "" } });
@@ -39,6 +45,30 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
},
});
const mountVol = useMutation({
...mountVolumeMutation(),
onSuccess: () => {
toast.success("Volume mounted successfully");
},
onError: (error) => {
toast.error("Failed to mount volume", {
description: parseError(error)?.message,
});
},
});
const unmountVol = useMutation({
...unmountVolumeMutation(),
onSuccess: () => {
toast.success("Volume unmounted successfully");
},
onError: (error) => {
toast.error("Failed to unmount volume", {
description: parseError(error)?.message,
});
},
});
const handleDeleteConfirm = (name: string) => {
if (confirm(`Are you sure you want to delete the volume "${name}"? This action cannot be undone.`)) {
deleteVol.mutate({ path: { name } });
@@ -67,7 +97,22 @@ export default function DetailsPage({ loaderData }: Route.ComponentProps) {
</div>
</div>
<div className="flex gap-4">
<Button>Mount</Button>
<Button
variant="secondary"
onClick={() => mountVol.mutate({ path: { name } })}
disabled={mountVol.isPending}
className={cn({ hidden: data.status === "mounted" })}
>
Mount
</Button>
<Button
variant="secondary"
onClick={() => unmountVol.mutate({ path: { name } })}
disabled={unmountVol.isPending}
className={cn({ hidden: data.status !== "mounted" })}
>
Unmount
</Button>
<Button variant="destructive" onClick={() => handleDeleteConfirm(name)} disabled={deleteVol.isPending}>
Delete
</Button>