mirror of
https://github.com/fosrl/pangolin.git
synced 2026-05-05 03:54:22 +00:00
Create hcs freely
This commit is contained in:
856
src/components/StandaloneHealthCheckCredenza.tsx
Normal file
856
src/components/StandaloneHealthCheckCredenza.tsx
Normal file
@@ -0,0 +1,856 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { HeadersInput } from "@app/components/HeadersInput";
|
||||
import { z } from "zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage
|
||||
} from "@/components/ui/form";
|
||||
import {
|
||||
Credenza,
|
||||
CredenzaBody,
|
||||
CredenzaClose,
|
||||
CredenzaContent,
|
||||
CredenzaDescription,
|
||||
CredenzaFooter,
|
||||
CredenzaHeader,
|
||||
CredenzaTitle
|
||||
} from "@/components/Credenza";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export type HealthCheckRow = {
|
||||
targetHealthCheckId: number;
|
||||
name: string;
|
||||
hcEnabled: boolean;
|
||||
hcHealth: "unknown" | "healthy" | "unhealthy";
|
||||
hcMode: string | null;
|
||||
hcHostname: string | null;
|
||||
hcPort: number | null;
|
||||
hcPath: string | null;
|
||||
hcScheme: string | null;
|
||||
hcMethod: string | null;
|
||||
hcInterval: number | null;
|
||||
hcUnhealthyInterval: number | null;
|
||||
hcTimeout: number | null;
|
||||
hcHeaders: string | null;
|
||||
hcFollowRedirects: boolean | null;
|
||||
hcStatus: number | null;
|
||||
hcTlsServerName: string | null;
|
||||
hcHealthyThreshold: number | null;
|
||||
hcUnhealthyThreshold: number | null;
|
||||
};
|
||||
|
||||
type StandaloneHealthCheckCredenzaProps = {
|
||||
open: boolean;
|
||||
setOpen: (v: boolean) => void;
|
||||
orgId: string;
|
||||
initialValues?: HealthCheckRow | null;
|
||||
onSaved: () => void;
|
||||
};
|
||||
|
||||
const DEFAULT_VALUES = {
|
||||
name: "",
|
||||
hcEnabled: true,
|
||||
hcMode: "http",
|
||||
hcScheme: "https",
|
||||
hcMethod: "GET",
|
||||
hcHostname: "",
|
||||
hcPort: "",
|
||||
hcPath: "/",
|
||||
hcInterval: 30,
|
||||
hcUnhealthyInterval: 30,
|
||||
hcTimeout: 5,
|
||||
hcHealthyThreshold: 1,
|
||||
hcUnhealthyThreshold: 1,
|
||||
hcFollowRedirects: true,
|
||||
hcTlsServerName: "",
|
||||
hcStatus: null as number | null,
|
||||
hcHeaders: [] as { name: string; value: string }[]
|
||||
};
|
||||
|
||||
export default function StandaloneHealthCheckCredenza({
|
||||
open,
|
||||
setOpen,
|
||||
orgId,
|
||||
initialValues,
|
||||
onSaved
|
||||
}: StandaloneHealthCheckCredenzaProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const healthCheckSchema = z
|
||||
.object({
|
||||
name: z.string().min(1, { message: t("standaloneHcNameLabel") }),
|
||||
hcEnabled: z.boolean(),
|
||||
hcPath: z.string().optional(),
|
||||
hcMethod: z.string().optional(),
|
||||
hcInterval: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(5, { message: t("healthCheckIntervalMin") }),
|
||||
hcTimeout: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckTimeoutMin") }),
|
||||
hcStatus: z.int().positive().min(100).optional().nullable(),
|
||||
hcHeaders: z
|
||||
.array(z.object({ name: z.string(), value: z.string() }))
|
||||
.nullable()
|
||||
.optional(),
|
||||
hcScheme: z.string().optional(),
|
||||
hcHostname: z.string(),
|
||||
hcPort: z
|
||||
.string()
|
||||
.min(1, { message: t("healthCheckPortInvalid") })
|
||||
.refine(
|
||||
(val) => {
|
||||
const port = parseInt(val);
|
||||
return port > 0 && port <= 65535;
|
||||
},
|
||||
{ message: t("healthCheckPortInvalid") }
|
||||
),
|
||||
hcFollowRedirects: z.boolean(),
|
||||
hcMode: z.string(),
|
||||
hcUnhealthyInterval: z.int().positive().min(5),
|
||||
hcTlsServerName: z.string(),
|
||||
hcHealthyThreshold: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckHealthyThresholdMin") }),
|
||||
hcUnhealthyThreshold: z
|
||||
.int()
|
||||
.positive()
|
||||
.min(1, { message: t("healthCheckUnhealthyThresholdMin") })
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.hcMode !== "tcp") {
|
||||
if (!data.hcPath || data.hcPath.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("healthCheckPathRequired"),
|
||||
path: ["hcPath"]
|
||||
});
|
||||
}
|
||||
if (!data.hcMethod || data.hcMethod.length < 1) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: t("healthCheckMethodRequired"),
|
||||
path: ["hcMethod"]
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof healthCheckSchema>;
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(healthCheckSchema),
|
||||
defaultValues: DEFAULT_VALUES
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
if (initialValues) {
|
||||
let parsedHeaders: { name: string; value: string }[] = [];
|
||||
if (initialValues.hcHeaders) {
|
||||
try {
|
||||
parsedHeaders = JSON.parse(initialValues.hcHeaders);
|
||||
} catch {
|
||||
parsedHeaders = [];
|
||||
}
|
||||
}
|
||||
|
||||
form.reset({
|
||||
name: initialValues.name,
|
||||
hcEnabled: initialValues.hcEnabled,
|
||||
hcMode: initialValues.hcMode ?? "http",
|
||||
hcScheme: initialValues.hcScheme ?? "https",
|
||||
hcMethod: initialValues.hcMethod ?? "GET",
|
||||
hcHostname: initialValues.hcHostname ?? "",
|
||||
hcPort: initialValues.hcPort
|
||||
? initialValues.hcPort.toString()
|
||||
: "",
|
||||
hcPath: initialValues.hcPath ?? "/",
|
||||
hcInterval: initialValues.hcInterval ?? 30,
|
||||
hcUnhealthyInterval: initialValues.hcUnhealthyInterval ?? 30,
|
||||
hcTimeout: initialValues.hcTimeout ?? 5,
|
||||
hcHealthyThreshold: initialValues.hcHealthyThreshold ?? 1,
|
||||
hcUnhealthyThreshold: initialValues.hcUnhealthyThreshold ?? 1,
|
||||
hcFollowRedirects: initialValues.hcFollowRedirects ?? true,
|
||||
hcTlsServerName: initialValues.hcTlsServerName ?? "",
|
||||
hcStatus: initialValues.hcStatus ?? null,
|
||||
hcHeaders: parsedHeaders
|
||||
});
|
||||
} else {
|
||||
form.reset(DEFAULT_VALUES);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const watchedEnabled = form.watch("hcEnabled");
|
||||
const watchedMode = form.watch("hcMode");
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const payload = {
|
||||
name: values.name,
|
||||
hcEnabled: values.hcEnabled,
|
||||
hcMode: values.hcMode,
|
||||
hcScheme: values.hcScheme,
|
||||
hcMethod: values.hcMethod,
|
||||
hcHostname: values.hcHostname,
|
||||
hcPort: parseInt(values.hcPort),
|
||||
hcPath: values.hcPath ?? "",
|
||||
hcInterval: values.hcInterval,
|
||||
hcUnhealthyInterval: values.hcUnhealthyInterval,
|
||||
hcTimeout: values.hcTimeout,
|
||||
hcHealthyThreshold: values.hcHealthyThreshold,
|
||||
hcUnhealthyThreshold: values.hcUnhealthyThreshold,
|
||||
hcFollowRedirects: values.hcFollowRedirects,
|
||||
hcTlsServerName: values.hcTlsServerName,
|
||||
hcStatus: values.hcStatus || null,
|
||||
hcHeaders:
|
||||
values.hcHeaders && values.hcHeaders.length > 0
|
||||
? JSON.stringify(values.hcHeaders)
|
||||
: null
|
||||
};
|
||||
|
||||
if (initialValues) {
|
||||
await api.post(
|
||||
`/org/${orgId}/health-check/${initialValues.targetHealthCheckId}`,
|
||||
payload
|
||||
);
|
||||
} else {
|
||||
await api.put(
|
||||
`/org/${orgId}/health-check`,
|
||||
payload
|
||||
);
|
||||
}
|
||||
|
||||
toast({ title: t("standaloneHcSaved") });
|
||||
onSaved();
|
||||
setOpen(false);
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isEditing = !!initialValues;
|
||||
|
||||
return (
|
||||
<Credenza open={open} onOpenChange={setOpen}>
|
||||
<CredenzaContent className="max-w-2xl">
|
||||
<CredenzaHeader>
|
||||
<CredenzaTitle>
|
||||
{isEditing
|
||||
? t("standaloneHcEditTitle")
|
||||
: t("standaloneHcCreateTitle")}
|
||||
</CredenzaTitle>
|
||||
<CredenzaDescription>
|
||||
{t("standaloneHcDescription")}
|
||||
</CredenzaDescription>
|
||||
</CredenzaHeader>
|
||||
<CredenzaBody>
|
||||
<Form {...form}>
|
||||
<form
|
||||
id="standalone-hc-form"
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6"
|
||||
>
|
||||
{/* Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("standaloneHcNameLabel")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={t(
|
||||
"standaloneHcNamePlaceholder"
|
||||
)}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Enable Health Check */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcEnabled"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t("enableHealthChecks")}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"enableHealthChecksDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchedEnabled && (
|
||||
<div className="space-y-4">
|
||||
{/* Mode */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMode"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthCheckMode")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
<SelectItem value="tcp">
|
||||
TCP
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"healthCheckModeDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Connection fields */}
|
||||
{watchedMode === "tcp" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcScheme"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthScheme")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"healthSelectScheme"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="http">
|
||||
HTTP
|
||||
</SelectItem>
|
||||
<SelectItem value="https">
|
||||
HTTPS
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHostname"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthHostname")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPort"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthPort")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcPath"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthCheckPath")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* HTTP Method */}
|
||||
{watchedMode !== "tcp" && (
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcMethod"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("httpMethod")}
|
||||
</FormLabel>
|
||||
<Select
|
||||
onValueChange={
|
||||
field.onChange
|
||||
}
|
||||
value={field.value}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t(
|
||||
"selectHttpMethod"
|
||||
)}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
<SelectItem value="GET">
|
||||
GET
|
||||
</SelectItem>
|
||||
<SelectItem value="POST">
|
||||
POST
|
||||
</SelectItem>
|
||||
<SelectItem value="HEAD">
|
||||
HEAD
|
||||
</SelectItem>
|
||||
<SelectItem value="PUT">
|
||||
PUT
|
||||
</SelectItem>
|
||||
<SelectItem value="DELETE">
|
||||
DELETE
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Check Interval, Unhealthy Interval, and Timeout */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"healthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyInterval"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"unhealthyIntervalSeconds"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTimeout"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("timeoutSeconds")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Healthy and Unhealthy Thresholds */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHealthyThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("healthyThreshold")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"healthyThresholdDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcUnhealthyThreshold"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("unhealthyThreshold")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
{...field}
|
||||
onChange={(e) =>
|
||||
field.onChange(
|
||||
parseInt(
|
||||
e.target
|
||||
.value
|
||||
)
|
||||
)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"unhealthyThresholdDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* HTTP-only fields */}
|
||||
{watchedMode !== "tcp" && (
|
||||
<>
|
||||
{/* Expected Response Code */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcStatus"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t(
|
||||
"expectedResponseCodes"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
value={
|
||||
field.value ??
|
||||
""
|
||||
}
|
||||
onChange={(
|
||||
e
|
||||
) => {
|
||||
const val =
|
||||
e.target
|
||||
.value;
|
||||
field.onChange(
|
||||
val
|
||||
? parseInt(
|
||||
val
|
||||
)
|
||||
: null
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"expectedResponseCodesDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* TLS Server Name */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcTlsServerName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("tlsServerName")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} />
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"tlsServerNameDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Custom Headers */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcHeaders"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("customHeaders")}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<HeadersInput
|
||||
value={
|
||||
field.value
|
||||
}
|
||||
onChange={
|
||||
field.onChange
|
||||
}
|
||||
rows={4}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"customHeadersDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Follow Redirects */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hcFollowRedirects"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>
|
||||
{t(
|
||||
"followRedirects"
|
||||
)}
|
||||
</FormLabel>
|
||||
<FormDescription>
|
||||
{t(
|
||||
"followRedirectsDescription"
|
||||
)}
|
||||
</FormDescription>
|
||||
</div>
|
||||
<FormControl>
|
||||
<Switch
|
||||
checked={
|
||||
field.value
|
||||
}
|
||||
onCheckedChange={
|
||||
field.onChange
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</Form>
|
||||
</CredenzaBody>
|
||||
<CredenzaFooter>
|
||||
<CredenzaClose asChild>
|
||||
<Button variant="outline" type="button">
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
</CredenzaClose>
|
||||
<Button
|
||||
type="submit"
|
||||
form="standalone-hc-form"
|
||||
disabled={loading}
|
||||
>
|
||||
{t("save")}
|
||||
</Button>
|
||||
</CredenzaFooter>
|
||||
</CredenzaContent>
|
||||
</Credenza>
|
||||
);
|
||||
}
|
||||
299
src/components/StandaloneHealthChecksTable.tsx
Normal file
299
src/components/StandaloneHealthChecksTable.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog";
|
||||
import StandaloneHealthCheckCredenza, {
|
||||
HealthCheckRow
|
||||
} from "@app/components/StandaloneHealthCheckCredenza";
|
||||
import { Badge } from "@app/components/ui/badge";
|
||||
import { Button } from "@app/components/ui/button";
|
||||
import { DataTable, ExtendedColumnDef } from "@app/components/ui/data-table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from "@app/components/ui/dropdown-menu";
|
||||
import { Switch } from "@app/components/ui/switch";
|
||||
import { toast } from "@app/hooks/useToast";
|
||||
import { useEnvContext } from "@app/hooks/useEnvContext";
|
||||
import { createApiClient, formatAxiosError } from "@app/lib/api";
|
||||
import { orgQueries } from "@app/lib/queries";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ArrowUpDown, MoreHorizontal } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useState } from "react";
|
||||
|
||||
type StandaloneHealthChecksTableProps = {
|
||||
orgId: string;
|
||||
};
|
||||
|
||||
function formatTarget(row: HealthCheckRow): string {
|
||||
if (!row.hcHostname) return "—";
|
||||
if (row.hcMode === "tcp") {
|
||||
if (!row.hcPort) return row.hcHostname;
|
||||
return `${row.hcHostname}:${row.hcPort}`;
|
||||
}
|
||||
// HTTP / default
|
||||
const scheme = row.hcScheme ?? "http";
|
||||
const host = row.hcHostname;
|
||||
const port = row.hcPort ? `:${row.hcPort}` : "";
|
||||
const path = row.hcPath ?? "/";
|
||||
return `${scheme}://${host}${port}${path}`;
|
||||
}
|
||||
|
||||
const healthLabel: Record<HealthCheckRow["hcHealth"], string> = {
|
||||
healthy: "Healthy",
|
||||
unhealthy: "Unhealthy",
|
||||
unknown: "Unknown"
|
||||
};
|
||||
|
||||
const healthVariant: Record<
|
||||
HealthCheckRow["hcHealth"],
|
||||
"green" | "red" | "secondary"
|
||||
> = {
|
||||
healthy: "green",
|
||||
unhealthy: "red",
|
||||
unknown: "secondary"
|
||||
};
|
||||
|
||||
function HealthBadge({ health }: { health: HealthCheckRow["hcHealth"] }) {
|
||||
return (
|
||||
<Badge variant={healthVariant[health]}>{healthLabel[health]}</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StandaloneHealthChecksTable({
|
||||
orgId
|
||||
}: StandaloneHealthChecksTableProps) {
|
||||
const t = useTranslations();
|
||||
const api = createApiClient(useEnvContext());
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [credenzaOpen, setCredenzaOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [selected, setSelected] = useState<HealthCheckRow | null>(null);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
|
||||
const {
|
||||
data: rows = [],
|
||||
isLoading,
|
||||
refetch,
|
||||
isRefetching
|
||||
} = useQuery(orgQueries.standaloneHealthChecks({ orgId }));
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries(
|
||||
orgQueries.standaloneHealthChecks({ orgId })
|
||||
);
|
||||
|
||||
const handleToggleEnabled = async (
|
||||
row: HealthCheckRow,
|
||||
enabled: boolean
|
||||
) => {
|
||||
setTogglingId(row.targetHealthCheckId);
|
||||
try {
|
||||
await api.post(
|
||||
`/org/${orgId}/health-check/${row.targetHealthCheckId}`,
|
||||
{ hcEnabled: enabled }
|
||||
);
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setTogglingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!selected) return;
|
||||
try {
|
||||
await api.delete(
|
||||
`/org/${orgId}/health-check/${selected.targetHealthCheckId}`
|
||||
);
|
||||
await invalidate();
|
||||
toast({ title: t("standaloneHcDeleted") });
|
||||
} catch (e) {
|
||||
toast({
|
||||
title: t("error"),
|
||||
description: formatAxiosError(e),
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setDeleteOpen(false);
|
||||
setSelected(null);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: ExtendedColumnDef<HealthCheckRow>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
enableHiding: false,
|
||||
friendlyName: t("name"),
|
||||
header: ({ column }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
>
|
||||
{t("name")}
|
||||
<ArrowUpDown className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.name}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "mode",
|
||||
friendlyName: t("standaloneHcColumnMode"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnMode")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="uppercase text-xs font-mono">
|
||||
{row.original.hcMode?.toUpperCase() ?? "—"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "target",
|
||||
friendlyName: t("standaloneHcColumnTarget"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnTarget")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<span className="font-mono text-xs text-muted-foreground truncate max-w-64 block">
|
||||
{formatTarget(row.original)}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: "health",
|
||||
friendlyName: t("standaloneHcColumnHealth"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("standaloneHcColumnHealth")}</span>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<HealthBadge health={row.original.hcHealth} />
|
||||
)
|
||||
},
|
||||
{
|
||||
accessorKey: "hcEnabled",
|
||||
friendlyName: t("alertingColumnEnabled"),
|
||||
header: () => (
|
||||
<span className="p-3">{t("alertingColumnEnabled")}</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Switch
|
||||
checked={r.hcEnabled}
|
||||
disabled={togglingId === r.targetHealthCheckId}
|
||||
onCheckedChange={(v) => handleToggleEnabled(r, v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
id: "rowActions",
|
||||
enableHiding: false,
|
||||
header: () => <span className="p-3" />,
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="h-8 w-8 p-0">
|
||||
<span className="sr-only">
|
||||
{t("openMenu")}
|
||||
</span>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setDeleteOpen(true);
|
||||
}}
|
||||
>
|
||||
<span className="text-red-500">
|
||||
{t("delete")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelected(r);
|
||||
setCredenzaOpen(true);
|
||||
}}
|
||||
>
|
||||
{t("edit")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
{selected && deleteOpen && (
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
setOpen={(val) => {
|
||||
setDeleteOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
dialog={
|
||||
<div className="space-y-2">
|
||||
<p>{t("standaloneHcDeleteQuestion")}</p>
|
||||
</div>
|
||||
}
|
||||
buttonText={t("delete")}
|
||||
onConfirm={handleDelete}
|
||||
string={selected.name}
|
||||
title={t("standaloneHcDeleteTitle")}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StandaloneHealthCheckCredenza
|
||||
open={credenzaOpen}
|
||||
setOpen={(val) => {
|
||||
setCredenzaOpen(val);
|
||||
if (!val) setSelected(null);
|
||||
}}
|
||||
orgId={orgId}
|
||||
initialValues={selected}
|
||||
onSaved={invalidate}
|
||||
/>
|
||||
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
persistPageSize="Org-standalone-health-checks-table"
|
||||
title={t("standaloneHcTableTitle")}
|
||||
searchPlaceholder={t("standaloneHcSearchPlaceholder")}
|
||||
searchColumn="name"
|
||||
onAdd={() => {
|
||||
setSelected(null);
|
||||
setCredenzaOpen(true);
|
||||
}}
|
||||
onRefresh={() => refetch()}
|
||||
isRefreshing={isRefetching || isLoading}
|
||||
addButtonText={t("standaloneHcAddButton")}
|
||||
enableColumnVisibility
|
||||
stickyLeftColumn="name"
|
||||
stickyRightColumn="rowActions"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -182,7 +182,7 @@ function HealthCheckMultiSelect({
|
||||
const query = debounced.trim().toLowerCase();
|
||||
const base = query
|
||||
? healthChecks.filter((hc) =>
|
||||
hc.resourceName.toLowerCase().includes(query)
|
||||
hc.name.toLowerCase().includes(query)
|
||||
)
|
||||
: healthChecks;
|
||||
// Always keep already-selected items visible even if they fall outside the search
|
||||
@@ -243,7 +243,7 @@ function HealthCheckMultiSelect({
|
||||
{shown.map((hc) => (
|
||||
<CommandItem
|
||||
key={hc.targetHealthCheckId}
|
||||
value={`${hc.targetHealthCheckId}:${hc.resourceName}`}
|
||||
value={`${hc.targetHealthCheckId}:${hc.name}`}
|
||||
onSelect={() =>
|
||||
toggle(hc.targetHealthCheckId)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ function HealthCheckMultiSelect({
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<span className="truncate">
|
||||
{hc.resourceName}
|
||||
{hc.name}
|
||||
</span>
|
||||
</CommandItem>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user