feat: manual health check

This commit is contained in:
Nicolas Meienberger
2025-09-27 11:09:19 +02:00
parent 082192f38a
commit 7154dcdbac
9 changed files with 142 additions and 7 deletions

View File

@@ -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<HealthCheckVolumeData>) =>
createQueryKey("healthCheckVolume", options);
/**
* Perform a health check on a volume
*/
export const healthCheckVolumeOptions = (options: Options<HealthCheckVolumeData>) => {
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<Options<HealthCheckVolumeData>>,
): UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> => {
const mutationOptions: UseMutationOptions<HealthCheckVolumeResponse, DefaultError, Options<HealthCheckVolumeData>> = {
mutationFn: async (localOptions) => {
const { data } = await healthCheckVolume({
...options,
...localOptions,
throwOnError: true,
});
return data;
},
};
return mutationOptions;
};

View File

@@ -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 = <ThrowOnError extends boolean = false>(
...options,
});
};
/**
* Perform a health check on a volume
*/
export const healthCheckVolume = <ThrowOnError extends boolean = false>(
options: Options<HealthCheckVolumeData, ThrowOnError>,
) => {
return (options.client ?? _heyApiClient).post<HealthCheckVolumeResponses, HealthCheckVolumeErrors, ThrowOnError>({
url: "/api/v1/volumes/{name}/health-check",
...options,
});
};

View File

@@ -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 & {});
};

View File

@@ -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 (
<Card className="flex-1 flex flex-col h-full">
<CardHeader>
@@ -30,7 +44,6 @@ export const HealthchecksCard = ({ volume }: Props) => {
{volume.status !== "unmounted" && (
<span className="text-xs text-muted-foreground mb-4">Checked {timeAgo || "never"}</span>
)}
<span className="flex justify-between items-center gap-2">
<span className="text-sm">Remount on error</span>
<OnOff isOn={volume.autoRemount} toggle={() => {}} enabledLabel="Enabled" disabledLabel="Paused" />
@@ -38,7 +51,12 @@ export const HealthchecksCard = ({ volume }: Props) => {
</div>
{volume.status !== "unmounted" && (
<div className="flex justify-center">
<Button variant="outline" className="mt-4 self-end">
<Button
variant="outline"
className="mt-4"
loading={healthcheck.isPending}
onClick={() => healthcheck.mutate({ path: { name: volume.name } })}
>
Run Health Check
</Button>
</div>

View File

@@ -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 = {

View File

@@ -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";

View File

@@ -15,11 +15,11 @@ export const VolumeInfoTabContent = ({ volume, statfs }: Props) => {
<Card className="p-6">
<CreateVolumeForm initialValues={{ ...volume, ...volume.config }} onSubmit={console.log} />
</Card>
<div className="grid gap-4">
<div className="lg:row-span-1">
<div className="flex flex-col gap-4">
<div className="self-start w-full">
<HealthchecksCard volume={volume} />
</div>
<div>
<div className="flex-1 w-full">
<StorageChart statfs={statfs} />
</div>
</div>

View File

@@ -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);
});

View File

@@ -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
*/