diff --git a/apps/client/app/api-client/@tanstack/react-query.gen.ts b/apps/client/app/api-client/@tanstack/react-query.gen.ts index 5c75e54..aa9bf19 100644 --- a/apps/client/app/api-client/@tanstack/react-query.gen.ts +++ b/apps/client/app/api-client/@tanstack/react-query.gen.ts @@ -11,6 +11,7 @@ import { getContainersUsingVolume, mountVolume, unmountVolume, + healthCheckVolume, } from "../sdk.gen"; import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query"; import type { @@ -29,6 +30,8 @@ import type { MountVolumeResponse, UnmountVolumeData, UnmountVolumeResponse, + HealthCheckVolumeData, + HealthCheckVolumeResponse, } from "../types.gen"; import { client as _heyApiClient } from "../client.gen"; @@ -326,3 +329,43 @@ export const unmountVolumeMutation = ( }; return mutationOptions; }; + +export const healthCheckVolumeQueryKey = (options: Options) => + createQueryKey("healthCheckVolume", options); + +/** + * Perform a health check on a volume + */ +export const healthCheckVolumeOptions = (options: Options) => { + return queryOptions({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await healthCheckVolume({ + ...options, + ...queryKey[0], + signal, + throwOnError: true, + }); + return data; + }, + queryKey: healthCheckVolumeQueryKey(options), + }); +}; + +/** + * Perform a health check on a volume + */ +export const healthCheckVolumeMutation = ( + options?: Partial>, +): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (localOptions) => { + const { data } = await healthCheckVolume({ + ...options, + ...localOptions, + throwOnError: true, + }); + return data; + }, + }; + return mutationOptions; +}; diff --git a/apps/client/app/api-client/sdk.gen.ts b/apps/client/app/api-client/sdk.gen.ts index d80a631..dbf3ced 100644 --- a/apps/client/app/api-client/sdk.gen.ts +++ b/apps/client/app/api-client/sdk.gen.ts @@ -25,6 +25,9 @@ import type { UnmountVolumeData, UnmountVolumeResponses, UnmountVolumeErrors, + HealthCheckVolumeData, + HealthCheckVolumeResponses, + HealthCheckVolumeErrors, } from "./types.gen"; import { client as _heyApiClient } from "./client.gen"; @@ -162,3 +165,15 @@ export const unmountVolume = ( ...options, }); }; + +/** + * Perform a health check on a volume + */ +export const healthCheckVolume = ( + options: Options, +) => { + return (options.client ?? _heyApiClient).post({ + url: "/api/v1/volumes/{name}/health-check", + ...options, + }); +}; diff --git a/apps/client/app/api-client/types.gen.ts b/apps/client/app/api-client/types.gen.ts index eade818..184207f 100644 --- a/apps/client/app/api-client/types.gen.ts +++ b/apps/client/app/api-client/types.gen.ts @@ -438,6 +438,34 @@ export type UnmountVolumeResponses = { export type UnmountVolumeResponse = UnmountVolumeResponses[keyof UnmountVolumeResponses]; +export type HealthCheckVolumeData = { + body?: never; + path: { + name: string; + }; + query?: never; + url: "/api/v1/volumes/{name}/health-check"; +}; + +export type HealthCheckVolumeErrors = { + /** + * Volume not found + */ + 404: unknown; +}; + +export type HealthCheckVolumeResponses = { + /** + * Volume health check result + */ + 200: { + status: "error" | "mounted" | "unmounted"; + error?: string; + }; +}; + +export type HealthCheckVolumeResponse = HealthCheckVolumeResponses[keyof HealthCheckVolumeResponses]; + export type ClientOptions = { baseUrl: "http://localhost:3000" | (string & {}); }; diff --git a/apps/client/app/modules/details/components/healthchecks-card.tsx b/apps/client/app/modules/details/components/healthchecks-card.tsx index 0ef7329..a7cbf36 100644 --- a/apps/client/app/modules/details/components/healthchecks-card.tsx +++ b/apps/client/app/modules/details/components/healthchecks-card.tsx @@ -1,5 +1,8 @@ +import { useMutation } from "@tanstack/react-query"; import { formatDistanceToNow } from "date-fns"; import { HeartIcon } from "lucide-react"; +import { toast } from "sonner"; +import { healthCheckVolumeMutation } from "~/api-client/@tanstack/react-query.gen"; import { OnOff } from "~/components/onoff"; import { Button } from "~/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/components/ui/card"; @@ -14,6 +17,17 @@ export const HealthchecksCard = ({ volume }: Props) => { addSuffix: true, }); + const healthcheck = useMutation({ + ...healthCheckVolumeMutation(), + onSuccess: (d) => { + if (d.error) { + toast.error("Health check failed", { description: d.error }); + return; + } + toast.success("Health check completed", { description: "The volume is healthy." }); + }, + }); + return ( @@ -30,7 +44,6 @@ export const HealthchecksCard = ({ volume }: Props) => { {volume.status !== "unmounted" && ( Checked {timeAgo || "never"} )} - Remount on error {}} enabledLabel="Enabled" disabledLabel="Paused" /> @@ -38,7 +51,12 @@ export const HealthchecksCard = ({ volume }: Props) => { {volume.status !== "unmounted" && (
-
diff --git a/apps/client/app/modules/details/components/storage-chart.tsx b/apps/client/app/modules/details/components/storage-chart.tsx index fe5be08..7a60a95 100644 --- a/apps/client/app/modules/details/components/storage-chart.tsx +++ b/apps/client/app/modules/details/components/storage-chart.tsx @@ -5,7 +5,7 @@ import * as React from "react"; import { Label, Pie, PieChart } from "recharts"; import { ByteSize } from "~/components/bytes-size"; import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card"; -import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from "~/components/ui/chart"; +import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "~/components/ui/chart"; import type { StatFs } from "~/lib/types"; type Props = { diff --git a/apps/client/app/modules/details/tabs/backups.tsx b/apps/client/app/modules/details/tabs/backups.tsx index 8c7c921..c0ccd44 100644 --- a/apps/client/app/modules/details/tabs/backups.tsx +++ b/apps/client/app/modules/details/tabs/backups.tsx @@ -8,7 +8,6 @@ import { Input } from "~/components/ui/input"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/components/ui/select"; import { Switch } from "~/components/ui/switch"; import type { Volume } from "~/lib/types"; -import { cn } from "~/lib/utils"; type BackupDestination = "s3" | "sftp" | "filesystem"; type BackupFrequency = "hourly" | "daily" | "weekly"; diff --git a/apps/client/app/modules/details/tabs/info.tsx b/apps/client/app/modules/details/tabs/info.tsx index 309544d..a033e09 100644 --- a/apps/client/app/modules/details/tabs/info.tsx +++ b/apps/client/app/modules/details/tabs/info.tsx @@ -15,11 +15,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => { -
-
+
+
-
+
diff --git a/apps/server/src/modules/volumes/volume.controller.ts b/apps/server/src/modules/volumes/volume.controller.ts index c186a3e..d1a47eb 100644 --- a/apps/server/src/modules/volumes/volume.controller.ts +++ b/apps/server/src/modules/volumes/volume.controller.ts @@ -16,6 +16,7 @@ import { unmountVolumeDto, updateVolumeBody, updateVolumeDto, + healthCheckDto, } from "./volume.dto"; import { volumeService } from "./volume.service"; @@ -112,4 +113,10 @@ export const volumeController = new Hono() const { error, status } = await volumeService.unmountVolume(name); return c.json({ error, status }, error ? 500 : 200); + }) + .post("/:name/health-check", healthCheckDto, async (c) => { + const { name } = c.req.param(); + const { error, status } = await volumeService.checkHealth(name); + + return c.json({ error, status }, 200); }); diff --git a/apps/server/src/modules/volumes/volume.dto.ts b/apps/server/src/modules/volumes/volume.dto.ts index 7cf29f4..445275b 100644 --- a/apps/server/src/modules/volumes/volume.dto.ts +++ b/apps/server/src/modules/volumes/volume.dto.ts @@ -259,6 +259,31 @@ export const unmountVolumeDto = describeRoute({ }, }); +export const healthCheckResponse = type({ + error: "string?", + status: type.enumerated("mounted", "unmounted", "error"), +}); + +export const healthCheckDto = describeRoute({ + description: "Perform a health check on a volume", + operationId: "healthCheckVolume", + validateResponse: true, + tags: ["Volumes"], + responses: { + 200: { + description: "Volume health check result", + content: { + "application/json": { + schema: resolver(healthCheckResponse), + }, + }, + }, + 404: { + description: "Volume not found", + }, + }, +}); + /** * Get containers using a volume */