diff --git a/client/src/Hooks/useProxiesForm.ts b/client/src/Hooks/useProxiesForm.ts
new file mode 100644
index 000000000..1b8de2045
--- /dev/null
+++ b/client/src/Hooks/useProxiesForm.ts
@@ -0,0 +1,22 @@
+import { useMemo } from "react";
+import { proxySchema, type ProxyFormData } from "@/Validation/proxy";
+import type { ProxyResponse } from "@/Types/Proxy";
+
+interface UseProxiesFormOptions {
+ data?: ProxyResponse | null;
+}
+
+export const useProxiesForm = ({ data }: UseProxiesFormOptions) => {
+ return useMemo(() => {
+ const defaults: ProxyFormData = {
+ name: data?.name ?? "",
+ protocol: data?.protocol ?? "http",
+ host: data?.host ?? "",
+ port: data?.port ?? 8080,
+ username: "",
+ password: "",
+ };
+
+ return { schema: proxySchema, defaults };
+ }, [data]);
+};
diff --git a/client/src/Pages/Proxies/components/ProxiesTable.tsx b/client/src/Pages/Proxies/components/ProxiesTable.tsx
new file mode 100644
index 000000000..f2ae148e3
--- /dev/null
+++ b/client/src/Pages/Proxies/components/ProxiesTable.tsx
@@ -0,0 +1,103 @@
+import { ActionsMenu, type ActionMenuItem } from "@/Components/actions-menu";
+import Typography from "@mui/material/Typography";
+import type { Header } from "@/Components/design-elements/Table";
+import { Table } from "@/Components/design-elements";
+import { Pagination } from "@/Components/design-elements/Table";
+import { useClientPagination } from "@/Hooks/useClientPagination";
+
+import type { ProxyResponse } from "@/Types/Proxy";
+import { useNavigate } from "react-router";
+import { useTranslation } from "react-i18next";
+import { useTheme } from "@mui/material";
+
+interface ProxiesTableProps {
+ proxies: ProxyResponse[];
+ setSelectedProxy: Function;
+}
+
+export const ProxiesTable = ({ proxies, setSelectedProxy }: ProxiesTableProps) => {
+ const navigate = useNavigate();
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const { pagedRows, paginationProps } = useClientPagination(proxies);
+
+ const getActions = (proxy: ProxyResponse): ActionMenuItem[] => {
+ return [
+ {
+ id: 1,
+ label: t("pages.common.monitors.actions.configure"),
+ action: () => {
+ navigate(`/proxies/configure/${proxy.id}`);
+ },
+ closeMenu: true,
+ },
+
+ {
+ id: 2,
+ label: (
+
+ {t("pages.common.monitors.actions.delete")}
+
+ ),
+ action: async () => {
+ setSelectedProxy(proxy);
+ },
+ closeMenu: true,
+ },
+ ];
+ };
+
+ const getHeaders = () => {
+ const headers: Header[] = [
+ {
+ id: "name",
+ content: t("common.table.headers.name"),
+ render: (row) => {
+ return {row?.name};
+ },
+ },
+ {
+ id: "protocol",
+ content: t("pages.proxies.table.headers.protocol"),
+ render: (row) => {
+ return {row?.protocol};
+ },
+ },
+ {
+ id: "host",
+ content: t("pages.proxies.table.headers.host"),
+ render: (row) => {
+ return {row?.host};
+ },
+ },
+ {
+ id: "port",
+ content: t("pages.proxies.table.headers.port"),
+ render: (row) => {
+ return {row?.port};
+ },
+ },
+ {
+ id: "actions",
+ content: t("common.table.headers.actions"),
+ render: (row) => {
+ return ;
+ },
+ },
+ ];
+ return headers;
+ };
+
+ return (
+ <>
+ {
+ navigate(`/proxies/configure/${row.id}`);
+ }}
+ />
+ {proxies.length > 0 && }
+ >
+ );
+};
diff --git a/client/src/Pages/Proxies/create/index.tsx b/client/src/Pages/Proxies/create/index.tsx
new file mode 100644
index 000000000..2783c93a9
--- /dev/null
+++ b/client/src/Pages/Proxies/create/index.tsx
@@ -0,0 +1,213 @@
+import { BasePage, ConfigBox } from "@/Components/design-elements";
+import { LAYOUT } from "@/Utils/Theme/constants";
+import { Button, FieldLabel } from "@/Components/inputs";
+import Box from "@mui/material/Box";
+import Stack from "@mui/material/Stack";
+import { useTheme } from "@mui/material/styles";
+
+import { useEffect, useState } from "react";
+import { useParams } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
+import { useForm, FormProvider } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { mutate } from "swr";
+import { useGet, usePost, usePatch } from "@/Hooks/UseApi";
+import type { ProxyFormData } from "@/Validation/proxy";
+import { ProxyProtocols, type ProxyResponse } from "@/Types/Proxy";
+import { useTranslation } from "react-i18next";
+import { useProxiesForm } from "@/Hooks/useProxiesForm";
+import { FormTextField } from "@/Components/inputs/forms/FormTextField";
+import { FormSelectField } from "@/Components/inputs/forms/FormSelectField";
+import { useMemo } from "react";
+import { FormNumberField } from "@/Components/inputs/forms/FormNumberField";
+const ProxiesCreatePage = () => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const navigate = useNavigate();
+ const { proxyId } = useParams<{ proxyId: string }>();
+ const isEditMode = Boolean(proxyId);
+
+ const { data: existingProxy } = useGet(
+ isEditMode ? `/proxies/${proxyId}` : null
+ );
+
+ const { post, loading: isSubmitting } = usePost();
+ const { patch, loading: isPatching } = usePatch();
+
+ const { schema, defaults } = useProxiesForm({ data: existingProxy });
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: defaults,
+ });
+
+ const { reset, handleSubmit } = form;
+
+ const isPasswordSet = existingProxy?.hasPassword ?? false;
+ const [passwordHasBeenReset, setPasswordHasBeenReset] = useState(false);
+
+ const handleResetPassword = () => {
+ form.setValue("password", "");
+ setPasswordHasBeenReset(true);
+ };
+
+ const isUsernameSet = Boolean(existingProxy?.username);
+ const [usernameHasBeenReset, setUsernameHasBeenReset] = useState(false);
+
+ const handleResetUsername = () => {
+ form.setValue("username", "");
+ setUsernameHasBeenReset(true);
+ };
+
+ const protocolOptions = useMemo(
+ () =>
+ ProxyProtocols.map((protocol) => ({
+ value: protocol,
+ label: protocol,
+ })),
+ []
+ );
+
+ useEffect(() => {
+ reset(defaults);
+ }, [defaults, reset]);
+
+ const onSubmit = async (data: ProxyFormData) => {
+ const payload: ProxyFormData & { clearPassword?: boolean; clearUsername?: boolean } =
+ {
+ ...data,
+ };
+ // Don't send password/username if one is set and the user hasn't clicked reset
+ if (isPasswordSet && !passwordHasBeenReset) {
+ delete payload.password;
+ } else if (passwordHasBeenReset && !payload.password) {
+ payload.clearPassword = true;
+ }
+ if (isUsernameSet && !usernameHasBeenReset) {
+ delete payload.username;
+ } else if (usernameHasBeenReset && !payload.username) {
+ payload.clearUsername = true;
+ }
+ const result = isEditMode
+ ? await patch(`/proxies/${proxyId}`, payload)
+ : await post("/proxies", payload);
+ if (result?.success) {
+ await mutate((key) => typeof key === "string" && key.startsWith("/proxies"));
+ navigate("/proxies");
+ }
+ };
+
+ return (
+
+
+
+ }
+ />
+
+
+
+
+
+ }
+ />
+
+
+ {(isUsernameSet === false || usernameHasBeenReset === true) && (
+
+ )}
+
+ {isUsernameSet === true && usernameHasBeenReset === false && (
+
+
+ {t("pages.proxies.form.auth.option.username.labelSet")}
+
+
+
+ )}
+
+ {(isPasswordSet === false || passwordHasBeenReset === true) && (
+
+ )}
+
+ {isPasswordSet === true && passwordHasBeenReset === false && (
+
+
+ {t("pages.proxies.form.auth.option.password.labelSet")}
+
+
+
+ )}
+
+ }
+ />
+
+
+
+
+
+
+ );
+};
+
+export default ProxiesCreatePage;
diff --git a/client/src/Pages/Proxies/index.tsx b/client/src/Pages/Proxies/index.tsx
new file mode 100644
index 000000000..5b40242b8
--- /dev/null
+++ b/client/src/Pages/Proxies/index.tsx
@@ -0,0 +1,72 @@
+import { BasePageWithStates } from "@/Components/design-elements";
+import { ProxiesTable } from "@/Pages/Proxies/components/ProxiesTable";
+import { Dialog } from "@/Components/inputs";
+import { HeaderCreate } from "@/Components/common";
+
+import { useState } from "react";
+import { useGet, useDelete } from "@/Hooks/UseApi";
+import { useTranslation } from "react-i18next";
+import type { ProxyResponse } from "@/Types/Proxy";
+import { useIsAdmin } from "@/Hooks/useIsAdmin";
+
+const ProxiesPage = () => {
+ const { t } = useTranslation();
+ const isAdmin = useIsAdmin();
+
+ const [selectedProxy, setSelectedProxy] = useState(null);
+ const isDialogOpen = Boolean(selectedProxy);
+
+ const {
+ data: proxies,
+ isLoading,
+ isValidating,
+ error,
+ refetch,
+ } = useGet("/proxies/team", {}, { keepPreviousData: true });
+
+ const { deleteFn, loading: isDeleting } = useDelete();
+
+ const handleConfirm = async () => {
+ if (!selectedProxy) return;
+ await deleteFn(`/proxies/${selectedProxy.id}`);
+ setSelectedProxy(null);
+ refetch();
+ };
+ ``;
+ const handleCancel = () => {
+ setSelectedProxy(null);
+ };
+
+ return (
+
+
+
+
+
+ );
+};
+
+export default ProxiesPage;
diff --git a/client/src/Types/Proxy.ts b/client/src/Types/Proxy.ts
new file mode 100644
index 000000000..dd098dee3
--- /dev/null
+++ b/client/src/Types/Proxy.ts
@@ -0,0 +1,17 @@
+export const ProxyProtocols = ["http", "https"] as const;
+export type ProxyProtocol = (typeof ProxyProtocols)[number];
+
+export interface Proxy {
+ id: string;
+ teamId: string;
+ name: string;
+ protocol: ProxyProtocol;
+ host: string;
+ port: number;
+ username?: string;
+ password?: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export type ProxyResponse = Omit & { hasPassword: boolean };
diff --git a/client/src/Validation/monitor.ts b/client/src/Validation/monitor.ts
index a31cdb919..5b53ff3a5 100644
--- a/client/src/Validation/monitor.ts
+++ b/client/src/Validation/monitor.ts
@@ -5,6 +5,7 @@ import {
DnsRecordTypes,
HttpMethods,
PageSpeedStrategies,
+ ProxyModes,
type MonitorType,
} from "@/Types/Monitor";
import { ALL_HTTP_STATUS_CODES } from "@/Utils/statusCode";
@@ -67,6 +68,8 @@ const httpStatusCode = z.number().refine((code) => httpStatusCodeSet.has(code),
const httpSchema = baseSchema.extend({
type: z.literal("http"),
url: urlSchema,
+ proxyMode: z.enum(ProxyModes),
+ proxyId: z.string().optional(),
method: z.enum(HttpMethods).optional().register(monitorStepRegistry, { step: 2 }),
ignoreTlsErrors: z.boolean().register(monitorStepRegistry, { step: 2 }),
useAdvancedMatching: z.boolean().register(monitorStepRegistry, { step: 2 }),
@@ -195,7 +198,7 @@ const dnsSchema = baseSchema.extend({
});
// Discriminated union of all monitor types
-export const monitorSchema = z.discriminatedUnion("type", [
+const monitorSchemaUnion = z.discriminatedUnion("type", [
httpSchema,
pingSchema,
portSchema,
@@ -208,10 +211,21 @@ export const monitorSchema = z.discriminatedUnion("type", [
dnsSchema,
]);
+// Mirrors the server's refineProxySelection (monitorValidation.ts)
+export const monitorSchema = monitorSchemaUnion.superRefine((data, ctx) => {
+ if (data.type === "http" && data.proxyMode === "custom" && !data.proxyId) {
+ ctx.addIssue({
+ code: "custom",
+ path: ["proxyId"],
+ message: "A proxy must be selected when proxy mode is custom",
+ });
+ }
+});
+
export type MonitorFormData = z.infer;
const optionForType = (type: MonitorType) =>
- monitorSchema.options.find((o) => o.shape.type.value === type);
+ monitorSchemaUnion.options.find((o) => o.shape.type.value === type);
// The step each field of a type is validated on, from the per-field
// `monitorStepRegistry` metadata (unannotated fields default to step 0).
diff --git a/client/src/Validation/proxy.ts b/client/src/Validation/proxy.ts
new file mode 100644
index 000000000..22582e92a
--- /dev/null
+++ b/client/src/Validation/proxy.ts
@@ -0,0 +1,17 @@
+import { z } from "zod";
+import { ProxyProtocols } from "@/Types/Proxy";
+
+export const proxySchema = z.object({
+ name: z.string().min(1, "Proxy name is required"),
+ protocol: z.enum(ProxyProtocols, "Invalid proxy protocol"),
+ host: z.string().min(1, "Proxy host is required"),
+ port: z
+ .number()
+ .int()
+ .min(1, "Proxy port is required")
+ .max(65535, "Proxy port must be between 1 and 65535"),
+ username: z.string().optional(),
+ password: z.string().optional(),
+});
+
+export type ProxyFormData = z.infer;