add proxies CRUD pages
This commit is contained in:
1 parent
41859f241a
commit
cc137e1d06
7 files changed
+460
-2
No files matched your search
@@ -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]);
|
||||
};
|
||||
@@ -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: (
|
||||
<Typography color={theme.palette.error.main}>
|
||||
{t("pages.common.monitors.actions.delete")}
|
||||
</Typography>
|
||||
),
|
||||
action: async () => {
|
||||
setSelectedProxy(proxy);
|
||||
},
|
||||
closeMenu: true,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const getHeaders = () => {
|
||||
const headers: Header<ProxyResponse>[] = [
|
||||
{
|
||||
id: "name",
|
||||
content: t("common.table.headers.name"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.name}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "protocol",
|
||||
content: t("pages.proxies.table.headers.protocol"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.protocol}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "host",
|
||||
content: t("pages.proxies.table.headers.host"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.host}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "port",
|
||||
content: t("pages.proxies.table.headers.port"),
|
||||
render: (row) => {
|
||||
return <Typography>{row?.port}</Typography>;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
content: t("common.table.headers.actions"),
|
||||
render: (row) => {
|
||||
return <ActionsMenu items={getActions(row)} />;
|
||||
},
|
||||
},
|
||||
];
|
||||
return headers;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
headers={getHeaders()}
|
||||
data={pagedRows}
|
||||
onRowClick={(row) => {
|
||||
navigate(`/proxies/configure/${row.id}`);
|
||||
}}
|
||||
/>
|
||||
{proxies.length > 0 && <Pagination {...paginationProps} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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<ProxyResponse>(
|
||||
isEditMode ? `/proxies/${proxyId}` : null
|
||||
);
|
||||
|
||||
const { post, loading: isSubmitting } = usePost<ProxyFormData, ProxyResponse>();
|
||||
const { patch, loading: isPatching } = usePatch<ProxyFormData, ProxyResponse>();
|
||||
|
||||
const { schema, defaults } = useProxiesForm({ data: existingProxy });
|
||||
|
||||
const form = useForm<ProxyFormData>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
<BasePage
|
||||
component="form"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.name.title")}
|
||||
subtitle={t("pages.proxies.form.name.description")}
|
||||
rightContent={
|
||||
<FormTextField
|
||||
name="name"
|
||||
fieldLabel={t("pages.proxies.form.name.optionName")}
|
||||
placeholder={t("pages.proxies.form.name.placeholder")}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.server.title")}
|
||||
subtitle={t("pages.proxies.form.server.description")}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
<FormSelectField
|
||||
name="protocol"
|
||||
fieldLabel={t("pages.proxies.form.server.option.protocol.label")}
|
||||
options={protocolOptions}
|
||||
/>
|
||||
<FormTextField
|
||||
name="host"
|
||||
fieldLabel={t("pages.proxies.form.server.option.host.label")}
|
||||
placeholder={t("pages.proxies.form.server.option.host.placeholder")}
|
||||
/>
|
||||
<FormNumberField
|
||||
name="port"
|
||||
fieldLabel={t("pages.proxies.form.server.option.port.label")}
|
||||
/>
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConfigBox
|
||||
title={t("pages.proxies.form.auth.title")}
|
||||
subtitle={t("pages.proxies.form.auth.description")}
|
||||
rightContent={
|
||||
<Stack gap={theme.spacing(LAYOUT.MD)}>
|
||||
{(isUsernameSet === false || usernameHasBeenReset === true) && (
|
||||
<FormTextField
|
||||
name="username"
|
||||
fieldLabel={t("pages.proxies.form.auth.option.username.label")}
|
||||
placeholder={t("pages.proxies.form.auth.option.username.placeholder")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isUsernameSet === true && usernameHasBeenReset === false && (
|
||||
<Box>
|
||||
<FieldLabel>
|
||||
{t("pages.proxies.form.auth.option.username.labelSet")}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
onClick={handleResetUsername}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
{t("common.buttons.reset")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{(isPasswordSet === false || passwordHasBeenReset === true) && (
|
||||
<FormTextField
|
||||
name="password"
|
||||
type="password"
|
||||
fieldLabel={t("pages.proxies.form.auth.option.password.label")}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isPasswordSet === true && passwordHasBeenReset === false && (
|
||||
<Box>
|
||||
<FieldLabel>
|
||||
{t("pages.proxies.form.auth.option.password.labelSet")}
|
||||
</FieldLabel>
|
||||
<Button
|
||||
onClick={handleResetPassword}
|
||||
variant="contained"
|
||||
color="error"
|
||||
>
|
||||
{t("common.buttons.reset")}
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
}
|
||||
/>
|
||||
|
||||
<Stack
|
||||
direction="row"
|
||||
justifyContent="flex-end"
|
||||
spacing={theme.spacing(2)}
|
||||
>
|
||||
<Button
|
||||
loading={isSubmitting || isPatching}
|
||||
type="submit"
|
||||
variant="contained"
|
||||
color="primary"
|
||||
>
|
||||
{t("common.buttons.save")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</BasePage>
|
||||
</FormProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxiesCreatePage;
|
||||
@@ -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<ProxyResponse | null>(null);
|
||||
const isDialogOpen = Boolean(selectedProxy);
|
||||
|
||||
const {
|
||||
data: proxies,
|
||||
isLoading,
|
||||
isValidating,
|
||||
error,
|
||||
refetch,
|
||||
} = useGet<ProxyResponse[]>("/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 (
|
||||
<BasePageWithStates
|
||||
headerKey="proxies"
|
||||
page={t("pages.proxies.fallback.title")}
|
||||
description={t("pages.proxies.fallback.description")}
|
||||
loading={isLoading || isValidating}
|
||||
error={!!error}
|
||||
totalCount={proxies?.length ?? 0}
|
||||
actionButtonText={t("pages.proxies.fallback.actionButton")}
|
||||
actionLink="/proxies/create"
|
||||
>
|
||||
<HeaderCreate
|
||||
path="/proxies/create"
|
||||
isLoading={isLoading || isValidating}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<ProxiesTable
|
||||
proxies={proxies ?? []}
|
||||
setSelectedProxy={setSelectedProxy}
|
||||
/>
|
||||
<Dialog
|
||||
open={isDialogOpen}
|
||||
title={t("common.dialogs.delete.title")}
|
||||
content={t("common.dialogs.delete.description")}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={handleCancel}
|
||||
loading={isDeleting}
|
||||
/>
|
||||
</BasePageWithStates>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxiesPage;
|
||||
@@ -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<Proxy, "password"> & { hasPassword: boolean };
|
||||
@@ -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<typeof monitorSchema>;
|
||||
|
||||
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).
|
||||
|
||||
@@ -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<typeof proxySchema>;
|
||||
Reference in new issue
Block a user