refactor(webhooks): headers as array
This commit is contained in:
parent
f439fb39a1
commit
be7eef7028
15 changed files with 129 additions and 106 deletions
|
|
@ -2436,12 +2436,12 @@ export type ListBackupSchedulesResponses = {
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
|
|
@ -2724,12 +2724,12 @@ export type CreateBackupScheduleData = {
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
preBackupWebhook?: {
|
preBackupWebhook?: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook?: {
|
postBackupWebhook?: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
|
|
@ -2769,12 +2769,12 @@ export type CreateBackupScheduleResponses = {
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
|
|
@ -2848,12 +2848,12 @@ export type GetBackupScheduleResponses = {
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
|
|
@ -3135,12 +3135,12 @@ export type UpdateBackupScheduleData = {
|
||||||
customResticParams?: Array<string>;
|
customResticParams?: Array<string>;
|
||||||
preBackupWebhook?: {
|
preBackupWebhook?: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook?: {
|
postBackupWebhook?: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries?: number;
|
maxRetries?: number;
|
||||||
|
|
@ -3182,12 +3182,12 @@ export type UpdateBackupScheduleResponses = {
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
|
|
@ -3241,12 +3241,12 @@ export type GetBackupScheduleForVolumeResponses = {
|
||||||
customResticParams: Array<string> | null;
|
customResticParams: Array<string> | null;
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: string;
|
url: string;
|
||||||
headers?: Record<string, string>;
|
headers?: Array<string>;
|
||||||
body?: string;
|
body?: string;
|
||||||
} | null;
|
} | null;
|
||||||
maxRetries: number;
|
maxRetries: number;
|
||||||
|
|
|
||||||
25
app/client/components/webhook-request-preview.tsx
Normal file
25
app/client/components/webhook-request-preview.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { CodeBlock } from "~/client/components/ui/code-block";
|
||||||
|
import { Label } from "~/client/components/ui/label";
|
||||||
|
|
||||||
|
type WebhookRequestPreviewProps = {
|
||||||
|
method: string;
|
||||||
|
url?: string;
|
||||||
|
contentType?: string;
|
||||||
|
headers?: string[];
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WebhookRequestPreview = ({ method, url, contentType, headers, body }: WebhookRequestPreviewProps) => {
|
||||||
|
const headerLines = [contentType ? `Content-Type: ${contentType}` : undefined, ...(headers ?? [])].filter(Boolean);
|
||||||
|
const previewCode = `${method} ${url || "https://api.example.com/webhook"}${headerLines.length > 0 ? `\n${headerLines.join("\n")}` : ""}
|
||||||
|
|
||||||
|
${body}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-4 border-t">
|
||||||
|
<Label>Request Preview</Label>
|
||||||
|
<CodeBlock code={previewCode} filename="HTTP Request" />
|
||||||
|
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
import { FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from "~/client/components/ui/form";
|
||||||
import { Textarea } from "~/client/components/ui/textarea";
|
import { Textarea } from "~/client/components/ui/textarea";
|
||||||
import type { UseFormReturn } from "react-hook-form";
|
import { useWatch, type UseFormReturn } from "react-hook-form";
|
||||||
import type { InternalFormValues } from "./types";
|
import type { InternalFormValues } from "./types";
|
||||||
import { Input } from "~/client/components/ui/input";
|
import { Input } from "~/client/components/ui/input";
|
||||||
|
|
||||||
|
|
@ -9,6 +9,10 @@ type AdvancedSectionProps = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
|
const values = useWatch({ control: form.control });
|
||||||
|
const preBackupWebhookBody = values.preBackupWebhookBody?.trim();
|
||||||
|
const postBackupWebhookBody = values.postBackupWebhookBody?.trim();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<FormField
|
<FormField
|
||||||
|
|
@ -74,18 +78,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="preBackupWebhookHeadersText"
|
name="preBackupWebhookHeaders"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Pre-backup webhook headers</FormLabel>
|
<FormLabel>Pre-backup webhook headers</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={'{\n "Authorization": "Bearer token"\n}'}
|
placeholder="Authorization: Bearer token X-Custom-Header: value"
|
||||||
|
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value.split("\n"))}
|
||||||
className="font-mono text-sm min-h-24"
|
className="font-mono text-sm min-h-24"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Optional JSON object. Values are stored as plain text.</FormDescription>
|
<FormDescription>
|
||||||
|
One header per line in Key: Value format. Values are stored as plain text.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
@ -122,18 +130,22 @@ export const AdvancedSection = ({ form }: AdvancedSectionProps) => {
|
||||||
/>
|
/>
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="postBackupWebhookHeadersText"
|
name="postBackupWebhookHeaders"
|
||||||
render={({ field }) => (
|
render={({ field }) => (
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>Post-backup webhook headers</FormLabel>
|
<FormLabel>Post-backup webhook headers</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<Textarea
|
<Textarea
|
||||||
{...field}
|
{...field}
|
||||||
placeholder={'{\n "Authorization": "Bearer token"\n}'}
|
placeholder="Authorization: Bearer token X-Custom-Header: value"
|
||||||
|
value={Array.isArray(field.value) ? field.value.join("\n") : ""}
|
||||||
|
onChange={(e) => field.onChange(e.target.value.split("\n"))}
|
||||||
className="font-mono text-sm min-h-24"
|
className="font-mono text-sm min-h-24"
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>Optional JSON object. Values are stored as plain text.</FormDescription>
|
<FormDescription>
|
||||||
|
One header per line in Key: Value format. Values are stored as plain text.
|
||||||
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -47,10 +47,10 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
maxRetries,
|
maxRetries,
|
||||||
retryDelay,
|
retryDelay,
|
||||||
preBackupWebhookUrl,
|
preBackupWebhookUrl,
|
||||||
preBackupWebhookHeadersText,
|
preBackupWebhookHeaders,
|
||||||
preBackupWebhookBody,
|
preBackupWebhookBody,
|
||||||
postBackupWebhookUrl,
|
postBackupWebhookUrl,
|
||||||
postBackupWebhookHeadersText,
|
postBackupWebhookHeaders,
|
||||||
postBackupWebhookBody,
|
postBackupWebhookBody,
|
||||||
...rest
|
...rest
|
||||||
} = data;
|
} = data;
|
||||||
|
|
@ -74,14 +74,14 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
||||||
preBackupWebhook: preBackupWebhookUrlValue
|
preBackupWebhook: preBackupWebhookUrlValue
|
||||||
? {
|
? {
|
||||||
url: preBackupWebhookUrlValue,
|
url: preBackupWebhookUrlValue,
|
||||||
headers: parseWebhookHeaders(preBackupWebhookHeadersText),
|
headers: parseWebhookHeaders(preBackupWebhookHeaders),
|
||||||
body: preBackupWebhookBodyValue || undefined,
|
body: preBackupWebhookBodyValue || undefined,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
postBackupWebhook: postBackupWebhookUrlValue
|
postBackupWebhook: postBackupWebhookUrlValue
|
||||||
? {
|
? {
|
||||||
url: postBackupWebhookUrlValue,
|
url: postBackupWebhookUrlValue,
|
||||||
headers: parseWebhookHeaders(postBackupWebhookHeadersText),
|
headers: parseWebhookHeaders(postBackupWebhookHeaders),
|
||||||
body: postBackupWebhookBodyValue || undefined,
|
body: postBackupWebhookBodyValue || undefined,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,13 @@
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const webhookHeadersTextSchema = z
|
const webhookHeadersSchema = z
|
||||||
.string()
|
.array(
|
||||||
.optional()
|
z.string().refine((header) => !header.trim() || header.includes(":"), {
|
||||||
.refine(
|
message: "Headers must use Key: Value format",
|
||||||
(value) => {
|
}),
|
||||||
const trimmed = value?.trim();
|
)
|
||||||
if (!trimmed) {
|
.catch(() => [])
|
||||||
return true;
|
.optional();
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(trimmed);
|
|
||||||
return (
|
|
||||||
parsed &&
|
|
||||||
typeof parsed === "object" &&
|
|
||||||
!Array.isArray(parsed) &&
|
|
||||||
Object.values(parsed).every((headerValue) => typeof headerValue === "string")
|
|
||||||
);
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ message: "Headers must be a JSON object with string values" },
|
|
||||||
);
|
|
||||||
|
|
||||||
export const internalFormSchema = z.object({
|
export const internalFormSchema = z.object({
|
||||||
name: z.string().min(1).max(128),
|
name: z.string().min(1).max(128),
|
||||||
|
|
@ -46,10 +30,10 @@ export const internalFormSchema = z.object({
|
||||||
oneFileSystem: z.boolean().optional(),
|
oneFileSystem: z.boolean().optional(),
|
||||||
customResticParamsText: z.string().optional(),
|
customResticParamsText: z.string().optional(),
|
||||||
preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
preBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
preBackupWebhookHeadersText: webhookHeadersTextSchema,
|
preBackupWebhookHeaders: webhookHeadersSchema,
|
||||||
preBackupWebhookBody: z.string().optional(),
|
preBackupWebhookBody: z.string().optional(),
|
||||||
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
postBackupWebhookUrl: z.union([z.string().url(), z.literal("")]).optional(),
|
||||||
postBackupWebhookHeadersText: webhookHeadersTextSchema,
|
postBackupWebhookHeaders: webhookHeadersSchema,
|
||||||
postBackupWebhookBody: z.string().optional(),
|
postBackupWebhookBody: z.string().optional(),
|
||||||
maxRetries: z.number().min(0).max(32).optional(),
|
maxRetries: z.number().min(0).max(32).optional(),
|
||||||
retryDelay: z.number().min(1).max(1440).optional(),
|
retryDelay: z.number().min(1).max(1440).optional(),
|
||||||
|
|
@ -74,16 +58,16 @@ export type BackupScheduleFormValues = Omit<
|
||||||
| "includePatterns"
|
| "includePatterns"
|
||||||
| "customResticParamsText"
|
| "customResticParamsText"
|
||||||
| "preBackupWebhookUrl"
|
| "preBackupWebhookUrl"
|
||||||
| "preBackupWebhookHeadersText"
|
| "preBackupWebhookHeaders"
|
||||||
| "preBackupWebhookBody"
|
| "preBackupWebhookBody"
|
||||||
| "postBackupWebhookUrl"
|
| "postBackupWebhookUrl"
|
||||||
| "postBackupWebhookHeadersText"
|
| "postBackupWebhookHeaders"
|
||||||
| "postBackupWebhookBody"
|
| "postBackupWebhookBody"
|
||||||
> & {
|
> & {
|
||||||
excludePatterns?: string[];
|
excludePatterns?: string[];
|
||||||
excludeIfPresent?: string[];
|
excludeIfPresent?: string[];
|
||||||
includePatterns?: string[];
|
includePatterns?: string[];
|
||||||
customResticParams?: string[];
|
customResticParams?: string[];
|
||||||
preBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
|
preBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
|
||||||
postBackupWebhook?: { url: string; headers?: Record<string, string>; body?: string } | null;
|
postBackupWebhook?: { url: string; headers?: string[]; body?: string } | null;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -10,21 +10,10 @@ export const parseMultilineEntries = (value?: string) =>
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
export const parseWebhookHeaders = (value?: string) => {
|
export const parseWebhookHeaders = (headers?: string[]) => {
|
||||||
const trimmed = value?.trim();
|
const parsedHeaders = headers?.map((header) => header.trim()).filter(Boolean) ?? [];
|
||||||
if (!trimmed) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.parse(trimmed) as Record<string, string>;
|
return parsedHeaders.length > 0 ? parsedHeaders : undefined;
|
||||||
};
|
|
||||||
|
|
||||||
const stringifyWebhookHeaders = (headers?: Record<string, string>) => {
|
|
||||||
if (!headers || Object.keys(headers).length === 0) {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
|
|
||||||
return JSON.stringify(headers, null, 2);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalFormValues | undefined => {
|
||||||
|
|
@ -44,10 +33,10 @@ export const backupScheduleToFormValues = (schedule?: BackupSchedule): InternalF
|
||||||
oneFileSystem: schedule.oneFileSystem ?? false,
|
oneFileSystem: schedule.oneFileSystem ?? false,
|
||||||
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
customResticParamsText: schedule.customResticParams?.join("\n") ?? "",
|
||||||
preBackupWebhookUrl: schedule.preBackupWebhook?.url ?? "",
|
preBackupWebhookUrl: schedule.preBackupWebhook?.url ?? "",
|
||||||
preBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.preBackupWebhook?.headers),
|
preBackupWebhookHeaders: schedule.preBackupWebhook?.headers ?? [],
|
||||||
preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "",
|
preBackupWebhookBody: schedule.preBackupWebhook?.body ?? "",
|
||||||
postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "",
|
postBackupWebhookUrl: schedule.postBackupWebhook?.url ?? "",
|
||||||
postBackupWebhookHeadersText: stringifyWebhookHeaders(schedule.postBackupWebhook?.headers),
|
postBackupWebhookHeaders: schedule.postBackupWebhook?.headers ?? [],
|
||||||
postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "",
|
postBackupWebhookBody: schedule.postBackupWebhook?.body ?? "",
|
||||||
maxRetries: schedule.maxRetries,
|
maxRetries: schedule.maxRetries,
|
||||||
retryDelay: schedule.retryDelay,
|
retryDelay: schedule.retryDelay,
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ test("submits webhook headers and body as plain config values", async () => {
|
||||||
await userEvent.click(await screen.findByText("Advanced"));
|
await userEvent.click(await screen.findByText("Advanced"));
|
||||||
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
|
await userEvent.type(screen.getByLabelText("Pre-backup webhook"), "http://localhost:8080/stop");
|
||||||
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
|
fireEvent.change(screen.getByLabelText("Pre-backup webhook headers"), {
|
||||||
target: { value: '{ "Authorization": "Bearer stop-token" }' },
|
target: { value: "Authorization: Bearer stop-token" },
|
||||||
});
|
});
|
||||||
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
|
fireEvent.change(screen.getByLabelText("Pre-backup webhook body"), {
|
||||||
target: { value: '{"action":"stop"}' },
|
target: { value: '{"action":"stop"}' },
|
||||||
|
|
@ -143,7 +143,7 @@ test("submits webhook headers and body as plain config values", async () => {
|
||||||
await expect(submittedBody).resolves.toMatchObject({
|
await expect(submittedBody).resolves.toMatchObject({
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: "http://localhost:8080/stop",
|
url: "http://localhost:8080/stop",
|
||||||
headers: { Authorization: "Bearer stop-token" },
|
headers: ["Authorization: Bearer stop-token"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,7 @@ import { Input } from "~/client/components/ui/input";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
|
||||||
import { Checkbox } from "~/client/components/ui/checkbox";
|
import { Checkbox } from "~/client/components/ui/checkbox";
|
||||||
import { Textarea } from "~/client/components/ui/textarea";
|
import { Textarea } from "~/client/components/ui/textarea";
|
||||||
import { CodeBlock } from "~/client/components/ui/code-block";
|
import { WebhookRequestPreview } from "~/client/components/webhook-request-preview";
|
||||||
import { Label } from "~/client/components/ui/label";
|
|
||||||
import type { NotificationFormValues } from "../create-notification-form";
|
import type { NotificationFormValues } from "../create-notification-form";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|
@ -35,16 +34,14 @@ const WebhookPreview = ({ values }: { values: Partial<NotificationFormValues> })
|
||||||
body = "Notification message";
|
body = "Notification message";
|
||||||
}
|
}
|
||||||
|
|
||||||
const previewCode = `${values.method} ${values.url}\nContent-Type: ${contentType}${headers.length > 0 ? `\n${headers.join("\n")}` : ""}
|
|
||||||
|
|
||||||
${body}`;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 pt-4 border-t">
|
<WebhookRequestPreview
|
||||||
<Label>Request Preview</Label>
|
method={values.method || "POST"}
|
||||||
<CodeBlock code={previewCode} filename="HTTP Request" />
|
url={values.url}
|
||||||
<p className="text-[0.8rem] text-muted-foreground">This is a preview of the HTTP request that will be sent.</p>
|
contentType={contentType}
|
||||||
</div>
|
headers={headers}
|
||||||
|
body={body}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -285,12 +285,12 @@ describe("backup execution - validation failures", () => {
|
||||||
repositoryId: repository.id,
|
repositoryId: repository.id,
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: "http://localhost:8080/stop",
|
url: "http://localhost:8080/stop",
|
||||||
headers: { authorization: "Bearer stop-token" },
|
headers: ["authorization: Bearer stop-token"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
},
|
},
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: "http://localhost:8080/start",
|
url: "http://localhost:8080/start",
|
||||||
headers: { authorization: "Bearer start-token" },
|
headers: ["authorization: Bearer start-token"],
|
||||||
body: '{"action":"start"}',
|
body: '{"action":"start"}',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -304,12 +304,12 @@ describe("backup execution - validation failures", () => {
|
||||||
webhooks: {
|
webhooks: {
|
||||||
pre: {
|
pre: {
|
||||||
url: "http://localhost:8080/stop",
|
url: "http://localhost:8080/stop",
|
||||||
headers: { authorization: "Bearer stop-token" },
|
headers: ["authorization: Bearer stop-token"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
},
|
},
|
||||||
post: {
|
post: {
|
||||||
url: "http://localhost:8080/start",
|
url: "http://localhost:8080/start",
|
||||||
headers: { authorization: "Bearer start-token" },
|
headers: ["authorization: Bearer start-token"],
|
||||||
body: '{"action":"start"}',
|
body: '{"action":"start"}',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -416,12 +416,12 @@ describe("schedule webhooks", () => {
|
||||||
cronExpression: "0 0 * * *",
|
cronExpression: "0 0 * * *",
|
||||||
preBackupWebhook: {
|
preBackupWebhook: {
|
||||||
url: "http://localhost:8080/stop",
|
url: "http://localhost:8080/stop",
|
||||||
headers: { authorization: "Bearer stop-token" },
|
headers: ["authorization: Bearer stop-token"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
},
|
},
|
||||||
postBackupWebhook: {
|
postBackupWebhook: {
|
||||||
url: "http://localhost:8080/start",
|
url: "http://localhost:8080/start",
|
||||||
headers: { authorization: "Bearer start-token" },
|
headers: ["authorization: Bearer start-token"],
|
||||||
body: '{"action":"start"}',
|
body: '{"action":"start"}',
|
||||||
},
|
},
|
||||||
retryDelay: 15 * 60 * 1000,
|
retryDelay: 15 * 60 * 1000,
|
||||||
|
|
@ -430,12 +430,12 @@ describe("schedule webhooks", () => {
|
||||||
|
|
||||||
expect(schedule.preBackupWebhook).toEqual({
|
expect(schedule.preBackupWebhook).toEqual({
|
||||||
url: "http://localhost:8080/stop",
|
url: "http://localhost:8080/stop",
|
||||||
headers: { authorization: "Bearer stop-token" },
|
headers: ["authorization: Bearer stop-token"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
});
|
});
|
||||||
expect(schedule.postBackupWebhook).toEqual({
|
expect(schedule.postBackupWebhook).toEqual({
|
||||||
url: "http://localhost:8080/start",
|
url: "http://localhost:8080/start",
|
||||||
headers: { authorization: "Bearer start-token" },
|
headers: ["authorization: Bearer start-token"],
|
||||||
body: '{"action":"start"}',
|
body: '{"action":"start"}',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { Hono } from "hono";
|
||||||
import { validator } from "hono-openapi";
|
import { validator } from "hono-openapi";
|
||||||
import {
|
import {
|
||||||
createBackupScheduleBody,
|
createBackupScheduleBody,
|
||||||
|
createBackupScheduleResponse,
|
||||||
createBackupScheduleDto,
|
createBackupScheduleDto,
|
||||||
deleteBackupScheduleDto,
|
deleteBackupScheduleDto,
|
||||||
getBackupScheduleDto,
|
getBackupScheduleDto,
|
||||||
getBackupScheduleResponse,
|
getBackupScheduleResponse,
|
||||||
getBackupScheduleForVolumeDto,
|
getBackupScheduleForVolumeDto,
|
||||||
|
getBackupScheduleForVolumeResponse,
|
||||||
listBackupSchedulesDto,
|
listBackupSchedulesDto,
|
||||||
listBackupSchedulesResponse,
|
listBackupSchedulesResponse,
|
||||||
runBackupNowDto,
|
runBackupNowDto,
|
||||||
|
|
@ -14,6 +16,7 @@ import {
|
||||||
stopBackupDto,
|
stopBackupDto,
|
||||||
updateBackupScheduleDto,
|
updateBackupScheduleDto,
|
||||||
updateBackupScheduleBody,
|
updateBackupScheduleBody,
|
||||||
|
updateBackupScheduleResponse,
|
||||||
getScheduleMirrorsDto,
|
getScheduleMirrorsDto,
|
||||||
updateScheduleMirrorsDto,
|
updateScheduleMirrorsDto,
|
||||||
updateScheduleMirrorsBody,
|
updateScheduleMirrorsBody,
|
||||||
|
|
@ -73,20 +76,20 @@ export const backupScheduleController = new Hono()
|
||||||
const volumeShortId = asShortId(c.req.param("volumeShortId"));
|
const volumeShortId = asShortId(c.req.param("volumeShortId"));
|
||||||
const schedule = await backupsService.getScheduleForVolume(volumeShortId);
|
const schedule = await backupsService.getScheduleForVolume(volumeShortId);
|
||||||
|
|
||||||
return c.json<GetBackupScheduleForVolumeResponseDto>(schedule, 200);
|
return c.json<GetBackupScheduleForVolumeResponseDto>(getBackupScheduleForVolumeResponse.parse(schedule), 200);
|
||||||
})
|
})
|
||||||
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
|
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const schedule = await backupsService.createSchedule(body);
|
const schedule = await backupsService.createSchedule(body);
|
||||||
|
|
||||||
return c.json<CreateBackupScheduleDto>(schedule, 201);
|
return c.json<CreateBackupScheduleDto>(createBackupScheduleResponse.parse(schedule), 201);
|
||||||
})
|
})
|
||||||
.patch("/:shortId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
|
.patch("/:shortId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
|
||||||
const shortId = asShortId(c.req.param("shortId"));
|
const shortId = asShortId(c.req.param("shortId"));
|
||||||
const body = c.req.valid("json");
|
const body = c.req.valid("json");
|
||||||
const schedule = await backupsService.updateSchedule(shortId, body);
|
const schedule = await backupsService.updateSchedule(shortId, body);
|
||||||
|
|
||||||
return c.json<UpdateBackupScheduleDto>(schedule, 200);
|
return c.json<UpdateBackupScheduleDto>(updateBackupScheduleResponse.parse(schedule), 200);
|
||||||
})
|
})
|
||||||
.delete("/:shortId", deleteBackupScheduleDto, async (c) => {
|
.delete("/:shortId", deleteBackupScheduleDto, async (c) => {
|
||||||
const shortId = asShortId(c.req.param("shortId"));
|
const shortId = asShortId(c.req.param("shortId"));
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,7 @@ export const getBackupScheduleDto = describeRoute({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
|
export const getBackupScheduleForVolumeResponse = backupScheduleSchema.nullable();
|
||||||
|
|
||||||
export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
|
export type GetBackupScheduleForVolumeResponseDto = z.infer<typeof getBackupScheduleForVolumeResponse>;
|
||||||
|
|
||||||
|
|
@ -143,7 +143,7 @@ export const createBackupScheduleBody = z.object({
|
||||||
|
|
||||||
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
|
export type CreateBackupScheduleBody = z.infer<typeof createBackupScheduleBody>;
|
||||||
|
|
||||||
const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
export const createBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
||||||
|
|
||||||
export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
|
export type CreateBackupScheduleDto = z.infer<typeof createBackupScheduleResponse>;
|
||||||
|
|
||||||
|
|
@ -190,7 +190,7 @@ export const updateBackupScheduleBody = z.object({
|
||||||
|
|
||||||
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
|
export type UpdateBackupScheduleBody = z.infer<typeof updateBackupScheduleBody>;
|
||||||
|
|
||||||
const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
export const updateBackupScheduleResponse = backupScheduleSchema.omit({ volume: true, repository: true });
|
||||||
|
|
||||||
export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;
|
export type UpdateBackupScheduleDto = z.infer<typeof updateBackupScheduleResponse>;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,12 +140,12 @@ test("sends configured webhook headers and body without changing them", async ()
|
||||||
webhooks: {
|
webhooks: {
|
||||||
pre: {
|
pre: {
|
||||||
url: "http://localhost:8080/pre",
|
url: "http://localhost:8080/pre",
|
||||||
headers: { authorization: "Bearer pre-token", "content-type": "application/json" },
|
headers: ["authorization: Bearer pre-token", "content-type: application/json"],
|
||||||
body: '{"action":"stop"}',
|
body: '{"action":"stop"}',
|
||||||
},
|
},
|
||||||
post: {
|
post: {
|
||||||
url: "http://localhost:8080/post",
|
url: "http://localhost:8080/post",
|
||||||
headers: { authorization: "Bearer post-token" },
|
headers: ["authorization: Bearer post-token"],
|
||||||
body: "start-container",
|
body: "start-container",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -164,7 +164,10 @@ test("sends configured webhook headers and body without changing them", async ()
|
||||||
|
|
||||||
test("fails without running restic when the pre-backup webhook fails", async () => {
|
test("fails without running restic when the pre-backup webhook fails", async () => {
|
||||||
const backupMock = vi.fn();
|
const backupMock = vi.fn();
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("stop failed", { status: 500 })));
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("stop failed", { status: 500 })),
|
||||||
|
);
|
||||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||||
fromPartial({
|
fromPartial({
|
||||||
backup: backupMock,
|
backup: backupMock,
|
||||||
|
|
@ -189,7 +192,10 @@ test("fails without running restic when the pre-backup webhook fails", async ()
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reports a post-backup webhook failure as completed warning details", async () => {
|
test("reports a post-backup webhook failure as completed warning details", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => new Response("start failed", { status: 500 })));
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => new Response("start failed", { status: 500 })),
|
||||||
|
);
|
||||||
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
vi.spyOn(resticServer, "createRestic").mockReturnValue(
|
||||||
fromPartial({
|
fromPartial({
|
||||||
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }),
|
backup: () => Effect.succeed({ exitCode: 0, result: null, warningDetails: null }),
|
||||||
|
|
|
||||||
|
|
@ -198,7 +198,7 @@ test("sends configured webhook headers and body without replacing them", async (
|
||||||
pre: null,
|
pre: null,
|
||||||
post: {
|
post: {
|
||||||
url: "http://localhost:8080/post",
|
url: "http://localhost:8080/post",
|
||||||
headers: { authorization: "Bearer post-token" },
|
headers: ["authorization: Bearer post-token"],
|
||||||
body: "start-container",
|
body: "start-container",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,10 @@ export const DEFAULT_BACKUP_WEBHOOK_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
export const backupWebhookConfigSchema = z.object({
|
export const backupWebhookConfigSchema = z.object({
|
||||||
url: z.url(),
|
url: z.url(),
|
||||||
headers: z.record(z.string(), z.string()).optional(),
|
headers: z
|
||||||
|
.array(z.string())
|
||||||
|
.catch(() => [])
|
||||||
|
.optional(),
|
||||||
body: z.string().optional(),
|
body: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -138,8 +141,12 @@ const createRequestInit = (config: BackupWebhookConfig, context: BackupWebhookCo
|
||||||
headers.set("content-type", "application/json");
|
headers.set("content-type", "application/json");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [name, value] of Object.entries(config.headers ?? {})) {
|
for (const header of config.headers ?? []) {
|
||||||
headers.set(name, value);
|
const [name, ...valueParts] = header.split(":");
|
||||||
|
|
||||||
|
if (name && valueParts.length > 0) {
|
||||||
|
headers.set(name.trim(), valueParts.join(":").trim());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue