diff --git a/messages/en-US.json b/messages/en-US.json index ff54ae33..a3c3aa2b 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -468,7 +468,10 @@ "createdAt": "Created At", "proxyErrorInvalidHeader": "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header.", "proxyErrorTls": "Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.", - "proxyEnableSSL": "Enable SSL (https)", + "proxyEnableSSL": "Enable SSL", + "proxyEnableSSLDescription": "Enable SSL/TLS encryption for secure HTTPS connections to your targets.", + "target": "Target", + "configureTarget": "Configure Targets", "targetErrorFetch": "Failed to fetch targets", "targetErrorFetchDescription": "An error occurred while fetching targets", "siteErrorFetch": "Failed to fetch resource", @@ -495,7 +498,7 @@ "targetTlsSettings": "Secure Connection Configuration", "targetTlsSettingsDescription": "Configure SSL/TLS settings for your resource", "targetTlsSettingsAdvanced": "Advanced TLS Settings", - "targetTlsSni": "TLS Server Name (SNI)", + "targetTlsSni": "TLS Server Name", "targetTlsSniDescription": "The TLS Server Name to use for SNI. Leave empty to use the default.", "targetTlsSubmit": "Save Settings", "targets": "Targets Configuration", @@ -504,9 +507,21 @@ "targetStickySessionsDescription": "Keep connections on the same backend target for their entire session.", "methodSelect": "Select method", "targetSubmit": "Add Target", - "targetNoOne": "No targets. Add a target using the form.", + "targetNoOne": "This resource doesn't have any targets. Add a target to configure where to send requests to your backend.", "targetNoOneDescription": "Adding more than one target above will enable load balancing.", "targetsSubmit": "Save Targets", + "addTarget": "Add Target", + "targetErrorInvalidIp": "Invalid IP address", + "targetErrorInvalidIpDescription": "Please enter a valid IP address or hostname", + "targetErrorInvalidPort": "Invalid port", + "targetErrorInvalidPortDescription": "Please enter a valid port number", + "targetErrorNoSite": "No site selected", + "targetErrorNoSiteDescription": "Please select a site for the target", + "targetCreated": "Target created", + "targetCreatedDescription": "Target has been created successfully", + "targetErrorCreate": "Failed to create target", + "targetErrorCreateDescription": "An error occurred while creating the target", + "save": "Save", "proxyAdditional": "Additional Proxy Settings", "proxyAdditionalDescription": "Configure how your resource handles proxy settings", "proxyCustomHeader": "Custom Host Header", @@ -1409,6 +1424,7 @@ "externalProxyEnabled": "External Proxy Enabled", "addNewTarget": "Add New Target", "targetsList": "Targets List", + "advancedMode": "Advanced Mode", "targetErrorDuplicateTargetFound": "Duplicate target found", "healthCheckHealthy": "Healthy", "healthCheckUnhealthy": "Unhealthy", @@ -1840,5 +1856,7 @@ "description": "An error occurred while generating the license key." } } - } + }, + "priority": "Priority", + "priorityDescription": "Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority." } diff --git a/server/lib/resend.ts b/server/lib/resend.ts index 931dca20..7dd130c8 100644 --- a/server/lib/resend.ts +++ b/server/lib/resend.ts @@ -11,5 +11,5 @@ export async function moveEmailToAudience( email: string, audienceId: AudienceIds ) { - return + return; } \ No newline at end of file diff --git a/server/routers/resource/createResource.ts b/server/routers/resource/createResource.ts index f4fe352e..2a4e67a7 100644 --- a/server/routers/resource/createResource.ts +++ b/server/routers/resource/createResource.ts @@ -37,7 +37,8 @@ const createHttpResourceSchema = z subdomain: z.string().nullable().optional(), http: z.boolean(), protocol: z.enum(["tcp", "udp"]), - domainId: z.string() + domainId: z.string(), + stickySession: z.boolean().optional(), }) .strict() .refine( @@ -191,6 +192,7 @@ async function createHttpResource( const { name, domainId } = parsedBody.data; const subdomain = parsedBody.data.subdomain; + const stickySession=parsedBody.data.stickySession; // Validate domain and construct full domain const domainResult = await validateAndConstructDomain( @@ -254,7 +256,8 @@ async function createHttpResource( subdomain: finalSubdomain, http: true, protocol: "tcp", - ssl: true + ssl: true, + stickySession: stickySession }) .returning(); diff --git a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx index 302d16d2..00893b61 100644 --- a/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx +++ b/src/app/[orgId]/settings/resources/[niceId]/proxy/page.tsx @@ -109,14 +109,22 @@ import { PathRewriteModal } from "@app/components/PathMatchRenameModal"; import { Badge } from "@app/components/ui/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger +} from "@app/components/ui/tooltip"; const addTargetSchema = z .object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), port: z.coerce.number().int().positive(), - siteId: z.number().int().positive(), + siteId: z + .number() + .int() + .positive({ message: "You must select a site for a target." }), path: z.string().optional().nullable(), pathMatchType: z .enum(["exact", "prefix", "regex"]) @@ -250,6 +258,13 @@ export default function ReverseProxyTargets(props: { const [pageLoading, setPageLoading] = useState(true); const [isAdvancedOpen, setIsAdvancedOpen] = useState(false); + const [isAdvancedMode, setIsAdvancedMode] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("proxy-advanced-mode"); + return saved === "true"; + } + return false; + }); const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = useState(null); @@ -297,31 +312,6 @@ export default function ReverseProxyTargets(props: { type TlsSettingsValues = z.infer; type TargetsSettingsValues = z.infer; - const addTargetForm = useForm({ - resolver: zodResolver(addTargetSchema), - defaultValues: { - ip: "", - method: resource.http ? "http" : null, - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100 - } as z.infer - }); - - const watchedIp = addTargetForm.watch("ip"); - const watchedPort = addTargetForm.watch("port"); - const watchedSiteId = addTargetForm.watch("siteId"); - - const handleContainerSelect = (hostname: string, port?: number) => { - addTargetForm.setValue("ip", hostname); - if (port) { - addTargetForm.setValue("port", port); - } - }; - const tlsSettingsForm = useForm({ resolver: zodResolver(tlsSettingsSchema), defaultValues: { @@ -398,13 +388,7 @@ export default function ReverseProxyTargets(props: { initializeDockerForSite(site.siteId); } - // If there's only one site, set it as the default in the form - if (res.data.data.sites.length) { - addTargetForm.setValue( - "siteId", - res.data.data.sites[0].siteId - ); - } + // Sites loaded successfully } }; fetchSites(); @@ -433,6 +417,158 @@ export default function ReverseProxyTargets(props: { // fetchSite(); }, []); + // Save advanced mode preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "proxy-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + function addNewTarget() { + const newTarget: LocalTarget = { + targetId: -Date.now(), // Use negative timestamp as temporary ID + ip: "", + method: resource.http ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + path: null, + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + priority: 100, + enabled: true, + resourceId: resource.resourceId, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null, + siteType: sites.length > 0 ? sites[0].type : null, + new: true, + updated: false + }; + + setTargets((prev) => [...prev, newTarget]); + } + + async function saveNewTarget(target: LocalTarget) { + // Validate the target + if (!isTargetValid(target.ip)) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + + if (!target.port || target.port <= 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidPort"), + description: t("targetErrorInvalidPortDescription") + }); + return; + } + + if (!target.siteId) { + toast({ + variant: "destructive", + title: t("targetErrorNoSite"), + description: t("targetErrorNoSiteDescription") + }); + return; + } + + // Check if target with same IP, port and method already exists + const isDuplicate = targets.some( + (t) => + t.targetId !== target.targetId && + t.ip === target.ip && + t.port === target.port && + t.method === target.method && + t.siteId === target.siteId + ); + + if (isDuplicate) { + toast({ + variant: "destructive", + title: t("targetErrorDuplicate"), + description: t("targetErrorDuplicateDescription") + }); + return; + } + + try { + setTargetsLoading(true); + + const response = await api.post< + AxiosResponse + >(`/target`, { + resourceId: resource.resourceId, + siteId: target.siteId, + ip: target.ip, + method: target.method, + port: target.port, + path: target.path, + pathMatchType: target.pathMatchType, + rewritePath: target.rewritePath, + rewritePathType: target.rewritePathType, + priority: target.priority, + enabled: target.enabled, + hcEnabled: target.hcEnabled, + hcPath: target.hcPath, + hcInterval: target.hcInterval, + hcTimeout: target.hcTimeout + }); + + if (response.status === 200) { + // Update the target with the new ID and remove the new flag + setTargets((prev) => + prev.map((t) => + t.targetId === target.targetId + ? { + ...t, + targetId: response.data.data.targetId, + new: false, + updated: false + } + : t + ) + ); + + toast({ + title: t("targetCreated"), + description: t("targetCreatedDescription") + }); + } + } catch (err) { + console.error(err); + toast({ + variant: "destructive", + title: t("targetErrorCreate"), + description: formatAxiosError( + err, + t("targetErrorCreateDescription") + ) + }); + } finally { + setTargetsLoading(false); + } + } + async function addTarget(data: z.infer) { // Check if target with same IP, port and method already exists const isDuplicate = targets.some( @@ -509,16 +645,6 @@ export default function ReverseProxyTargets(props: { }; setTargets([...targets, newTarget]); - addTargetForm.reset({ - ip: "", - method: resource.http ? "http" : null, - port: "" as any as number, - path: null, - pathMatchType: null, - rewritePath: null, - rewritePathType: null, - priority: 100, - }); } const removeTarget = (targetId: number) => { @@ -568,6 +694,24 @@ export default function ReverseProxyTargets(props: { }; async function saveAllSettings() { + // Validate that no targets have blank IPs or invalid ports + const targetsWithInvalidFields = targets.filter( + (target) => + !target.ip || + target.ip.trim() === "" || + !target.port || + target.port <= 0 || + isNaN(target.port) + ); + if (targetsWithInvalidFields.length > 0) { + toast({ + variant: "destructive", + title: t("targetErrorInvalidIp"), + description: t("targetErrorInvalidIpDescription") + }); + return; + } + try { setTargetsLoading(true); setHttpsTlsLoading(true); @@ -668,19 +812,23 @@ export default function ReverseProxyTargets(props: { } } - const columns: ColumnDef[] = [ - { + const getColumns = (): ColumnDef[] => { + const baseColumns: ColumnDef[] = []; + + const priorityColumn: ColumnDef = { id: "priority", header: () => (
- Priority + {t("priority")} -

Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.

+

+ {t("priorityDescription")} +

@@ -688,13 +836,13 @@ export default function ReverseProxyTargets(props: { ), cell: ({ row }) => { return ( -
+
{ const value = parseInt(e.target.value, 10); if (value >= 1 && value <= 1000) { @@ -707,371 +855,13 @@ export default function ReverseProxyTargets(props: { />
); - } - }, - { - accessorKey: "path", - header: t("matchPath"), - cell: ({ row }) => { - const hasPathMatch = !!( - row.original.path || row.original.pathMatchType - ); + }, + size: 120, + minSize: 100, + maxSize: 150 + }; - return hasPathMatch ? ( -
- - updateTarget(row.original.targetId, config) - } - trigger={ - - } - /> - - - {/* */} -
- ) : ( - - updateTarget(row.original.targetId, config) - } - trigger={ - - } - /> - ); - } - }, - { - accessorKey: "siteId", - header: t("site"), - cell: ({ row }) => { - const selectedSite = sites.find( - (site) => site.siteId === row.original.siteId - ); - - const handleContainerSelectForTarget = ( - hostname: string, - port?: number - ) => { - updateTarget(row.original.targetId, { - ...row.original, - ip: hostname - }); - if (port) { - updateTarget(row.original.targetId, { - ...row.original, - port: port - }); - } - }; - - return ( -
- - - - - - - - - - {t("siteNotFound")} - - - {sites.map((site) => ( - { - updateTarget( - row.original - .targetId, - { - siteId: site.siteId - } - ); - }} - > - - {site.name} - - ))} - - - - - - {selectedSite && - selectedSite.type === "newt" && - (() => { - const dockerState = getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })()} -
- ); - } - }, - ...(resource.http - ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] - : []), - { - accessorKey: "ip", - header: t("targetAddr"), - cell: ({ row }) => ( - { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); - - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); - if (parsed) { - updateTarget(row.original.targetId, { - ...row.original, - method: hasProtocol - ? parsed.protocol - : row.original.method, - ip: parsed.host, - port: hasPort - ? parsed.port - : row.original.port - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - }} - /> - ) - }, - { - accessorKey: "port", - header: t("targetPort"), - cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - port: parseInt(e.target.value, 10) - }) - } - /> - ) - }, - { - accessorKey: "rewritePath", - header: t("rewritePath"), - cell: ({ row }) => { - const hasRewritePath = !!( - row.original.rewritePath || row.original.rewritePathType - ); - const noPathMatch = - !row.original.path && !row.original.pathMatchType; - - return hasRewritePath && !noPathMatch ? ( -
- {/* */} - - updateTarget(row.original.targetId, config) - } - trigger={ - - } - /> - -
- ) : ( - - updateTarget(row.original.targetId, config) - } - trigger={ - - } - disabled={noPathMatch} - /> - ); - } - }, - - // { - // accessorKey: "protocol", - // header: t('targetProtocol'), - // cell: ({ row }) => ( - // - // ), - // }, - { + const healthCheckColumn: ColumnDef = { accessorKey: "healthCheck", header: t("healthCheck"), cell: ({ row }) => { @@ -1115,74 +905,439 @@ export default function ReverseProxyTargets(props: { }; return ( - <> +
{row.original.siteType === "newt" ? ( -
+ -
+ + ) : ( - - {t("healthCheckNotAvailable")} - + - )} - +
); - } - }, - { + }, + size: 200, + minSize: 180, + maxSize: 250 + }; + + const matchPathColumn: ColumnDef = { + accessorKey: "path", + header: t("matchPath"), + cell: ({ row }) => { + const hasPathMatch = !!( + row.original.path || row.original.pathMatchType + ); + + return ( +
+ {hasPathMatch ? ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + ) : ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + )} +
+ ); + }, + size: 200, + minSize: 180, + maxSize: 200 + }; + + const addressColumn: ColumnDef = { + accessorKey: "address", + header: t("address"), + cell: ({ row }) => { + const selectedSite = sites.find( + (site) => site.siteId === row.original.siteId + ); + + const handleContainerSelectForTarget = ( + hostname: string, + port?: number + ) => { + updateTarget(row.original.targetId, { + ...row.original, + ip: hostname + }); + if (port) { + updateTarget(row.original.targetId, { + ...row.original, + port: port + }); + } + }; + + return ( +
+
+ + + + + + + + + + {t("siteNotFound")} + + + {sites.map((site) => ( + + updateTarget( + row.original + .targetId, + { + siteId: site.siteId + } + ) + } + > + + {site.name} + + ))} + + + + + + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()} + + + +
+ {"://"} +
+ + { + const input = e.target.value.trim(); + const hasProtocol = + /^(https?|h2c):\/\//.test(input); + const hasPort = /:\d+(?:\/|$)/.test(input); + + if (hasProtocol || hasPort) { + const parsed = parseHostTarget(input); + if (parsed) { + updateTarget( + row.original.targetId, + { + ...row.original, + method: hasProtocol + ? parsed.protocol + : row.original.method, + ip: parsed.host, + port: hasPort + ? parsed.port + : row.original.port + } + ); + } else { + updateTarget( + row.original.targetId, + { + ...row.original, + ip: input + } + ); + } + } else { + updateTarget(row.original.targetId, { + ...row.original, + ip: input + }); + } + }} + /> +
+ {":"} +
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value > 0) { + updateTarget(row.original.targetId, { + ...row.original, + port: value + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + port: 0 + }); + } + }} + /> +
+
+ ); + }, + size: 400, + minSize: 350, + maxSize: 500 + }; + + const rewritePathColumn: ColumnDef = { + accessorKey: "rewritePath", + header: t("rewritePath"), + cell: ({ row }) => { + const hasRewritePath = !!( + row.original.rewritePath || row.original.rewritePathType + ); + const noPathMatch = + !row.original.path && !row.original.pathMatchType; + + return ( +
+ {hasRewritePath && !noPathMatch ? ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + ) : ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + disabled={noPathMatch} + /> + )} +
+ ); + }, + size: 200, + minSize: 180, + maxSize: 200 + }; + + const enabledColumn: ColumnDef = { accessorKey: "enabled", header: t("enabled"), cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - enabled: val - }) - } - /> - ) - }, - { +
+ + updateTarget(row.original.targetId, { + ...row.original, + enabled: val + }) + } + /> +
+ ), + size: 100, + minSize: 80, + maxSize: 120 + }; + + const actionsColumn: ColumnDef = { id: "actions", cell: ({ row }) => ( - <> -
- {/* */} +
+ +
+ ), + size: 100, + minSize: 80, + maxSize: 120 + }; - -
- - ) + if (isAdvancedMode) { + return [ + matchPathColumn, + addressColumn, + rewritePathColumn, + priorityColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; + } else { + return [ + addressColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; } - ]; + }; + + const columns = getColumns(); const table = useReactTable({ data: targets, @@ -1213,352 +1368,9 @@ export default function ReverseProxyTargets(props: { -
-
- -
- ( - - - {t("site")} - -
- - - - - - - - - - - - {t( - "siteNotFound" - )} - - - {sites.map( - ( - site - ) => ( - { - addTargetForm.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - {field.value && - (() => { - const selectedSite = - sites.find( - (site) => - site.siteId === - field.value - ); - return selectedSite && - selectedSite.type === - "newt" - ? (() => { - const dockerState = - getDockerStateForSite( - selectedSite.siteId - ); - return ( - - refreshContainersForSite( - selectedSite.siteId - ) - } - /> - ); - })() - : null; - })()} -
- -
- )} - /> - - {resource.http && ( - ( - - - {t("method")} - - - - - - - )} - /> - )} - - ( - - - {t("targetAddr")} - - - { - const input = - e.target.value.trim(); - const hasProtocol = - /^(https?|h2c):\/\//.test( - input - ); - const hasPort = - /:\d+(?:\/|$)/.test( - input - ); - - if ( - hasProtocol || - hasPort - ) { - const parsed = - parseHostTarget( - input - ); - if (parsed) { - if ( - hasProtocol || - !addTargetForm.getValues( - "method" - ) - ) { - addTargetForm.setValue( - "method", - parsed.protocol - ); - } - addTargetForm.setValue( - "ip", - parsed.host - ); - if ( - hasPort || - !addTargetForm.getValues( - "port" - ) - ) { - addTargetForm.setValue( - "port", - parsed.port - ); - } - } - } else { - field.onBlur(); - } - }} - /> - - - - )} - /> - ( - - - {t("targetPort")} - - - - - - - )} - /> - -
-
- -
- {targets.length > 0 ? ( <> -
- {t("targetsList")} -
- -
- - ( - - - { - field.onChange( - val - ); - }} - /> - - - )} - /> - - -
-
+
{table @@ -1626,12 +1438,40 @@ export default function ReverseProxyTargets(props: { {/* */}
+
+
+ +
+ + +
+
+
) : ( -
-

+

+

{t("targetNoOne")}

+
)} @@ -1669,6 +1509,9 @@ export default function ReverseProxyTargets(props: { label={t( "proxyEnableSSL" )} + description={t( + "proxyEnableSSLDescription" + )} defaultChecked={ field.value } @@ -1709,6 +1552,46 @@ export default function ReverseProxyTargets(props: { + +
+ + ( + + + { + field.onChange(val); + }} + /> + + + )} + /> + + +
+
( - + {t("customHeaders")} diff --git a/src/app/[orgId]/settings/resources/create/page.tsx b/src/app/[orgId]/settings/resources/create/page.tsx index 55a7a7be..e12da03a 100644 --- a/src/app/[orgId]/settings/resources/create/page.tsx +++ b/src/app/[orgId]/settings/resources/create/page.tsx @@ -58,7 +58,7 @@ import { } from "@app/components/ui/popover"; import { CaretSortIcon, CheckIcon } from "@radix-ui/react-icons"; import { cn } from "@app/lib/cn"; -import { ArrowRight, Info, MoveRight, Plus, SquareArrowOutUpRight } from "lucide-react"; +import { ArrowRight, CircleCheck, CircleX, Info, MoveRight, Plus, Settings, SquareArrowOutUpRight } from "lucide-react"; import CopyTextBox from "@app/components/CopyTextBox"; import Link from "next/link"; import { useTranslations } from "next-intl"; @@ -94,6 +94,9 @@ import { DomainRow } from "../../../../../components/DomainsTable"; import { finalizeSubdomainSanitize } from "@app/lib/subdomain-utils"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@app/components/ui/tooltip"; import { PathMatchDisplay, PathMatchModal, PathRewriteDisplay, PathRewriteModal } from "@app/components/PathMatchRenameModal"; +import { Badge } from "@app/components/ui/badge"; +import HealthCheckDialog from "@app/components/HealthCheckDialog"; +import { SwitchInput } from "@app/components/SwitchInput"; const baseResourceFormSchema = z.object({ @@ -112,6 +115,11 @@ const tcpUdpResourceFormSchema = z.object({ // enableProxy: z.boolean().default(false) }); +const targetsSettingsSchema = z.object({ + stickySession: z.boolean() +}); + + const addTargetSchema = z.object({ ip: z.string().refine(isTargetValid), method: z.string().nullable(), @@ -215,6 +223,64 @@ export default function Page() { const [targetsToRemove, setTargetsToRemove] = useState([]); const [dockerStates, setDockerStates] = useState>(new Map()); + const [selectedTargetForHealthCheck, setSelectedTargetForHealthCheck] = + useState(null); + const [healthCheckDialogOpen, setHealthCheckDialogOpen] = useState(false); + + const [isAdvancedMode, setIsAdvancedMode] = useState(() => { + if (typeof window !== "undefined") { + const saved = localStorage.getItem("create-advanced-mode"); + return saved === "true"; + } + return false; + }); + + // Save advanced mode preference to localStorage + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem( + "create-advanced-mode", + isAdvancedMode.toString() + ); + } + }, [isAdvancedMode]); + + function addNewTarget() { + const newTarget: LocalTarget = { + targetId: -Date.now(), // Use negative timestamp as temporary ID + ip: "", + method: baseForm.watch("http") ? "http" : null, + port: 0, + siteId: sites.length > 0 ? sites[0].siteId : 0, + path: null, + pathMatchType: null, + rewritePath: null, + rewritePathType: null, + priority: 100, + enabled: true, + resourceId: 0, + hcEnabled: false, + hcPath: null, + hcMethod: null, + hcInterval: null, + hcTimeout: null, + hcHeaders: null, + hcScheme: null, + hcHostname: null, + hcPort: null, + hcFollowRedirects: null, + hcHealth: "unknown", + hcStatus: null, + hcMode: null, + hcUnhealthyInterval: null, + siteType: sites.length > 0 ? sites[0].type : null, + new: true, + updated: false + }; + + setTargets((prev) => [...prev, newTarget]); + } + const resourceTypes: ReadonlyArray = [ { id: "http", @@ -268,6 +334,13 @@ export default function Page() { } as z.infer }); + const targetsSettingsForm = useForm({ + resolver: zodResolver(targetsSettingsSchema), + defaultValues: { + stickySession: false + } + }); + const watchedIp = addTargetForm.watch("ip"); const watchedPort = addTargetForm.watch("port"); const watchedSiteId = addTargetForm.watch("siteId"); @@ -405,11 +478,13 @@ export default function Page() { const baseData = baseForm.getValues(); const isHttp = baseData.http; + const stickySessionData = targetsSettingsForm.getValues(); try { const payload = { name: baseData.name, - http: baseData.http + http: baseData.http, + stickySession: stickySessionData.stickySession }; let sanitizedSubdomain: string | undefined; @@ -603,19 +678,43 @@ export default function Page() { load(); }, []); - const columns: ColumnDef[] = [ - { + function TargetHealthCheck(targetId: number, config: any) { + setTargets( + targets.map((target) => + target.targetId === targetId + ? { + ...target, + ...config, + updated: true + } + : target + ) + ); + } + + const openHealthCheckDialog = (target: LocalTarget) => { + console.log(target); + setSelectedTargetForHealthCheck(target); + setHealthCheckDialogOpen(true); + }; + + const getColumns = (): ColumnDef[] => { + const baseColumns: ColumnDef[] = []; + + const priorityColumn: ColumnDef = { id: "priority", header: () => (
- Priority + {t("priority")} -

Higher priority routes are evaluated first. Priority = 100 means automatic ordering (system decides). Use another number to enforce manual priority.

+

+ {t("priorityDescription")} +

@@ -623,13 +722,13 @@ export default function Page() { ), cell: ({ row }) => { return ( -
+
{ const value = parseInt(e.target.value, 10); if (value >= 1 && value <= 1000) { @@ -642,76 +741,149 @@ export default function Page() { />
); - } - }, - { + }, + size: 120, + minSize: 100, + maxSize: 150 + }; + + const healthCheckColumn: ColumnDef = { + accessorKey: "healthCheck", + header: t("healthCheck"), + cell: ({ row }) => { + const status = row.original.hcHealth || "unknown"; + const isEnabled = row.original.hcEnabled; + + const getStatusColor = (status: string) => { + switch (status) { + case "healthy": + return "green"; + case "unhealthy": + return "red"; + case "unknown": + default: + return "secondary"; + } + }; + + const getStatusText = (status: string) => { + switch (status) { + case "healthy": + return t("healthCheckHealthy"); + case "unhealthy": + return t("healthCheckUnhealthy"); + case "unknown": + default: + return t("healthCheckUnknown"); + } + }; + + const getStatusIcon = (status: string) => { + switch (status) { + case "healthy": + return ; + case "unhealthy": + return ; + case "unknown": + default: + return null; + } + }; + + return ( +
+ {row.original.siteType === "newt" ? ( + + ) : ( + - + )} +
+ ); + }, + size: 200, + minSize: 180, + maxSize: 250 + }; + + const matchPathColumn: ColumnDef = { accessorKey: "path", header: t("matchPath"), cell: ({ row }) => { - const hasPathMatch = !!(row.original.path || row.original.pathMatchType); + const hasPathMatch = !!( + row.original.path || row.original.pathMatchType + ); - return hasPathMatch ? ( -
- updateTarget(row.original.targetId, config)} - trigger={ - - } - /> - - - {/* */} + return ( +
+ {hasPathMatch ? ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + ) : ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + )}
- ) : ( - updateTarget(row.original.targetId, config)} - trigger={ - - } - /> ); }, - }, - { - accessorKey: "siteId", - header: t("site"), + size: 200, + minSize: 180, + maxSize: 200 + }; + + const addressColumn: ColumnDef = { + accessorKey: "address", + header: t("address"), cell: ({ row }) => { const selectedSite = sites.find( (site) => site.siteId === row.original.siteId @@ -734,260 +906,324 @@ export default function Page() { }; return ( -
- - - - - - - - - - {t("siteNotFound")} - - - {sites.map((site) => ( - { - updateTarget( - row.original - .targetId, - { - siteId: site.siteId - } - ); - }} - > - +
+ + + + + + + + + + {t("siteNotFound")} + + + {sites.map((site) => ( + + updateTarget( row.original - .siteId - ? "opacity-100" - : "opacity-0" - )} - /> - {site.name} - - ))} - - - - - - {selectedSite && selectedSite.type === "newt" && (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })()} + .targetId, + { + siteId: site.siteId + } + ) + } + > + + {site.name} + + ))} + + + + + + {selectedSite && + selectedSite.type === "newt" && + (() => { + const dockerState = getDockerStateForSite( + selectedSite.siteId + ); + return ( + + refreshContainersForSite( + selectedSite.siteId + ) + } + /> + ); + })()} + + + +
+ {"://"} +
+ + { + const input = e.target.value.trim(); + const hasProtocol = + /^(https?|h2c):\/\//.test(input); + const hasPort = /:\d+(?:\/|$)/.test(input); + + if (hasProtocol || hasPort) { + const parsed = parseHostTarget(input); + if (parsed) { + updateTarget( + row.original.targetId, + { + ...row.original, + method: hasProtocol + ? parsed.protocol + : row.original.method, + ip: parsed.host, + port: hasPort + ? parsed.port + : row.original.port + } + ); + } else { + updateTarget( + row.original.targetId, + { + ...row.original, + ip: input + } + ); + } + } else { + updateTarget(row.original.targetId, { + ...row.original, + ip: input + }); + } + }} + /> +
+ {":"} +
+ { + const value = parseInt(e.target.value, 10); + if (!isNaN(value) && value > 0) { + updateTarget(row.original.targetId, { + ...row.original, + port: value + }); + } else { + updateTarget(row.original.targetId, { + ...row.original, + port: 0 + }); + } + }} + /> +
); - } - }, - ...(baseForm.watch("http") - ? [ - { - accessorKey: "method", - header: t("method"), - cell: ({ row }: { row: Row }) => ( - - ) - } - ] - : []), - { - accessorKey: "ip", - header: t("targetAddr"), - cell: ({ row }) => ( - { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); + }, + size: 400, + minSize: 350, + maxSize: 500 + }; - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); - if (parsed) { - updateTarget(row.original.targetId, { - ...row.original, - method: hasProtocol ? parsed.protocol : row.original.method, - ip: parsed.host, - port: hasPort ? parsed.port : row.original.port - }); - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - } else { - updateTarget(row.original.targetId, { - ...row.original, - ip: input - }); - } - }} - /> - ) - }, - { - accessorKey: "port", - header: t("targetPort"), - cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - port: parseInt(e.target.value, 10) - }) - } - /> - ) - }, - { + const rewritePathColumn: ColumnDef = { accessorKey: "rewritePath", header: t("rewritePath"), cell: ({ row }) => { - const hasRewritePath = !!(row.original.rewritePath || row.original.rewritePathType); - const noPathMatch = !row.original.path && !row.original.pathMatchType; + const hasRewritePath = !!( + row.original.rewritePath || row.original.rewritePathType + ); + const noPathMatch = + !row.original.path && !row.original.pathMatchType; - return hasRewritePath && !noPathMatch ? ( -
- {/* */} - updateTarget(row.original.targetId, config)} - trigger={ - - } - /> - + return ( +
+ {hasRewritePath && !noPathMatch ? ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + /> + ) : ( + + updateTarget(row.original.targetId, config) + } + trigger={ + + } + disabled={noPathMatch} + /> + )}
- ) : ( - updateTarget(row.original.targetId, config)} - trigger={ - - } - disabled={noPathMatch} - /> ); }, - }, - { + size: 200, + minSize: 180, + maxSize: 200 + }; + + const enabledColumn: ColumnDef = { accessorKey: "enabled", header: t("enabled"), cell: ({ row }) => ( - - updateTarget(row.original.targetId, { - ...row.original, - enabled: val - }) - } - /> - ) - }, - { +
+ + updateTarget(row.original.targetId, { + ...row.original, + enabled: val + }) + } + /> +
+ ), + size: 100, + minSize: 80, + maxSize: 120 + }; + + const actionsColumn: ColumnDef = { id: "actions", cell: ({ row }) => ( - <> -
- -
- - ) +
+ +
+ ), + size: 100, + minSize: 80, + maxSize: 120 + }; + + if (isAdvancedMode) { + return [ + matchPathColumn, + addressColumn, + rewritePathColumn, + priorityColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; + } else { + return [ + addressColumn, + healthCheckColumn, + enabledColumn, + actionsColumn + ]; } - ]; + }; + + const columns = getColumns(); const table = useReactTable({ data: targets, @@ -1303,378 +1539,110 @@ export default function Page() { -
- - -
- ( - - - {t("site")} - -
- - - - - - - - - - - - {t( - "siteNotFound" - )} - - - {sites.map( - ( - site - ) => ( - { - addTargetForm.setValue( - "siteId", - site.siteId - ); - }} - > - - { - site.name - } - - ) - )} - - - - - - - {field.value && - (() => { - const selectedSite = - sites.find( - ( - site - ) => - site.siteId === - field.value - ); - return selectedSite && - selectedSite.type === - "newt" ? (() => { - const dockerState = getDockerStateForSite(selectedSite.siteId); - return ( - refreshContainersForSite(selectedSite.siteId)} - /> - ); - })() : null; - })()} -
- -
- )} - /> - - {baseForm.watch("http") && ( - ( - - - {t( - "method" - )} - - - - - - - )} - /> - )} - - ( - - {t("targetAddr")} - - { - const input = e.target.value.trim(); - const hasProtocol = /^(https?|h2c):\/\//.test(input); - const hasPort = /:\d+(?:\/|$)/.test(input); - - if (hasProtocol || hasPort) { - const parsed = parseHostTarget(input); - if (parsed) { - if (hasProtocol || !addTargetForm.getValues("method")) { - addTargetForm.setValue("method", parsed.protocol); - } - addTargetForm.setValue("ip", parsed.host); - if (hasPort || !addTargetForm.getValues("port")) { - addTargetForm.setValue("port", parsed.port); - } - } - } else { - field.onBlur(); - } - }} - /> - - - - )} - /> - ( - - - {t( - "targetPort" - )} - - - - - - - )} - /> - -
- - -
- {targets.length > 0 ? ( <> -
- {t("targetsList")} -
-
+
{table .getHeaderGroups() - .map( - ( - headerGroup - ) => ( - - {headerGroup.headers.map( - ( - header - ) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ) - )} - - ) - )} + .map((headerGroup) => ( + + {headerGroup.headers.map( + (header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ) + )} + + ))} - {table.getRowModel() - .rows?.length ? ( + {table.getRowModel().rows?.length ? ( table .getRowModel() - .rows.map( - (row) => ( - - {row - .getVisibleCells() - .map( - ( + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( cell - ) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ) - )} - - ) - ) + .column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) ) : ( - {t( - "targetNoOne" - )} + {t("targetNoOne")} )} + {/* */} + {/* {t('targetNoOneDescription')} */} + {/* */}
+
+
+ +
+ + +
+
+
) : ( -
-

+

+

{t("targetNoOne")}

+
)} @@ -1713,6 +1681,55 @@ export default function Page() { {t("resourceCreate")}
+ {selectedTargetForHealthCheck && ( + { + if (selectedTargetForHealthCheck) { + console.log(config); + TargetHealthCheck( + selectedTargetForHealthCheck.targetId, + config + ); + } + }} + /> + )} ) : ( diff --git a/src/components/AdminIdpDataTable.tsx b/src/components/AdminIdpDataTable.tsx index 2efd9e7c..63a0b4bb 100644 --- a/src/components/AdminIdpDataTable.tsx +++ b/src/components/AdminIdpDataTable.tsx @@ -8,11 +8,15 @@ import { useTranslations } from "next-intl"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; } export function IdpDataTable({ columns, - data + data, + onRefresh, + isRefreshing }: DataTableProps) { const router = useRouter(); const t = useTranslations(); @@ -29,6 +33,8 @@ export function IdpDataTable({ onAdd={() => { router.push("/admin/idp/create"); }} + onRefresh={onRefresh} + isRefreshing={isRefreshing} /> ); } diff --git a/src/components/AdminIdpTable.tsx b/src/components/AdminIdpTable.tsx index 8849ba25..2db1415e 100644 --- a/src/components/AdminIdpTable.tsx +++ b/src/components/AdminIdpTable.tsx @@ -39,8 +39,26 @@ export default function IdpTable({ idps }: Props) { const [selectedIdp, setSelectedIdp] = useState(null); const api = createApiClient(useEnvContext()); const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); const t = useTranslations(); + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + const deleteIdp = async (idpId: number) => { try { await api.delete(`/idp/${idpId}`); @@ -194,7 +212,12 @@ export default function IdpTable({ idps }: Props) { /> )} - + ); } diff --git a/src/components/AdminUsersDataTable.tsx b/src/components/AdminUsersDataTable.tsx index fecba7fb..b0f38587 100644 --- a/src/components/AdminUsersDataTable.tsx +++ b/src/components/AdminUsersDataTable.tsx @@ -9,11 +9,15 @@ import { useTranslations } from "next-intl"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; } export function UsersDataTable({ columns, - data + data, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -26,6 +30,8 @@ export function UsersDataTable({ title={t('userServer')} searchPlaceholder={t('userSearch')} searchColumn="email" + onRefresh={onRefresh} + isRefreshing={isRefreshing} /> ); } diff --git a/src/components/AdminUsersTable.tsx b/src/components/AdminUsersTable.tsx index 8e75ff24..6bca4a74 100644 --- a/src/components/AdminUsersTable.tsx +++ b/src/components/AdminUsersTable.tsx @@ -46,6 +46,25 @@ export default function UsersTable({ users }: Props) { const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + const deleteUser = (id: string) => { api.delete(`/user/${id}`) .catch((e) => { @@ -168,7 +187,7 @@ export default function UsersTable({ users }: Props) {
{userRow.twoFactorEnabled || - userRow.twoFactorSetupRequested ? ( + userRow.twoFactorSetupRequested ? ( {t("enabled")} @@ -263,7 +282,12 @@ export default function UsersTable({ users }: Props) { /> )} - + ); } diff --git a/src/components/ApiKeysDataTable.tsx b/src/components/ApiKeysDataTable.tsx index 6ac8d68b..58ab9252 100644 --- a/src/components/ApiKeysDataTable.tsx +++ b/src/components/ApiKeysDataTable.tsx @@ -33,16 +33,20 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; addApiKey?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function ApiKeysDataTable({ addApiKey, columns, - data + data, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); - + return ( ({ searchColumn="name" onAdd={addApiKey} addButtonText={t('apiKeysAdd')} + onRefresh={onRefresh} + isRefreshing={isRefreshing} /> ); } diff --git a/src/components/ApiKeysTable.tsx b/src/components/ApiKeysTable.tsx index 99094651..adc150cf 100644 --- a/src/components/ApiKeysTable.tsx +++ b/src/components/ApiKeysTable.tsx @@ -43,6 +43,25 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { const t = useTranslations(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + const deleteSite = (apiKeyId: string) => { api.delete(`/api-key/${apiKeyId}`) .catch((e) => { @@ -186,6 +205,8 @@ export default function ApiKeysTable({ apiKeys }: ApiKeyTableProps) { addApiKey={() => { router.push(`/admin/api-keys/create`); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/ClientsDataTable.tsx b/src/components/ClientsDataTable.tsx index 6242ba05..619f1fad 100644 --- a/src/components/ClientsDataTable.tsx +++ b/src/components/ClientsDataTable.tsx @@ -8,13 +8,17 @@ import { DataTable } from "@app/components/ui/data-table"; interface DataTableProps { columns: ColumnDef[]; data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; addClient?: () => void; } export function ClientsDataTable({ columns, data, - addClient + addClient, + onRefresh, + isRefreshing }: DataTableProps) { return ( ({ searchPlaceholder="Search clients..." searchColumn="name" onAdd={addClient} + onRefresh={onRefresh} + isRefreshing={isRefreshing} addButtonText="Add Client" /> ); diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index fc7c7c84..425b8395 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -25,6 +25,7 @@ import { toast } from "@app/hooks/useToast"; import { formatAxiosError } from "@app/lib/api"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useTranslations } from "next-intl"; export type ClientRow = { id: number; @@ -53,6 +54,25 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { const [rows, setRows] = useState(clients); const api = createApiClient(useEnvContext()); + const [isRefreshing, setIsRefreshing] = useState(false); + const t = useTranslations(); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const deleteClient = (clientId: number) => { api.delete(`/client/${clientId}`) @@ -207,32 +227,32 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { return (
- - - - - - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - + + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + Delete + + + @@ -292,6 +312,8 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { addClient={() => { router.push(`/${orgId}/settings/clients/create`); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/HealthCheckDialog.tsx b/src/components/HealthCheckDialog.tsx index 1942e1d8..6fa36a5b 100644 --- a/src/components/HealthCheckDialog.tsx +++ b/src/components/HealthCheckDialog.tsx @@ -107,7 +107,7 @@ export default function HealthCheckDialog({ useEffect(() => { if (!open) return; - + // Determine default scheme from target method const getDefaultScheme = () => { if (initialConfig?.hcScheme) { @@ -177,7 +177,7 @@ export default function HealthCheckDialog({ render={({ field }) => (
- + {t("enableHealthChecks")} @@ -210,7 +210,7 @@ export default function HealthCheckDialog({ name="hcScheme" render={({ field }) => ( - + {t("healthScheme")} ( - + {t( "healthyIntervalSeconds" )} @@ -425,7 +425,7 @@ export default function HealthCheckDialog({ name="hcUnhealthyInterval" render={({ field }) => ( - + {t( "unhealthyIntervalSeconds" )} @@ -460,7 +460,7 @@ export default function HealthCheckDialog({ name="hcTimeout" render={({ field }) => ( - + {t("timeoutSeconds")} @@ -499,7 +499,7 @@ export default function HealthCheckDialog({ name="hcStatus" render={({ field }) => ( - + {t("expectedResponseCodes")} @@ -541,7 +541,7 @@ export default function HealthCheckDialog({ name="hcHeaders" render={({ field }) => ( - + {t("customHeaders")} diff --git a/src/components/InvitationsDataTable.tsx b/src/components/InvitationsDataTable.tsx index 396a3c20..d73ad2ca 100644 --- a/src/components/InvitationsDataTable.tsx +++ b/src/components/InvitationsDataTable.tsx @@ -9,11 +9,15 @@ import { useTranslations } from 'next-intl'; interface DataTableProps { columns: ColumnDef[]; data: TData[]; + onRefresh?: () => void; + isRefreshing?: boolean; } export function InvitationsDataTable({ columns, - data + data, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -26,6 +30,8 @@ export function InvitationsDataTable({ title={t('invite')} searchPlaceholder={t('inviteSearch')} searchColumn="email" + onRefresh={onRefresh} + isRefreshing={isRefreshing} /> ); } diff --git a/src/components/InvitationsTable.tsx b/src/components/InvitationsTable.tsx index a97220f2..900003d7 100644 --- a/src/components/InvitationsTable.tsx +++ b/src/components/InvitationsTable.tsx @@ -19,6 +19,7 @@ import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; import moment from "moment"; +import { useRouter } from "next/navigation"; export type InvitationRow = { id: string; @@ -45,6 +46,25 @@ export default function InvitationsTable({ const api = createApiClient(useEnvContext()); const { org } = useOrgContext(); + const router = useRouter(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const columns: ColumnDef[] = [ { @@ -185,7 +205,12 @@ export default function InvitationsTable({ }} /> - + ); } diff --git a/src/components/OrgApiKeysDataTable.tsx b/src/components/OrgApiKeysDataTable.tsx index 773b2141..b6ad4bc3 100644 --- a/src/components/OrgApiKeysDataTable.tsx +++ b/src/components/OrgApiKeysDataTable.tsx @@ -8,12 +8,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; addApiKey?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function OrgApiKeysDataTable({ addApiKey, columns, - data + data, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -27,6 +31,8 @@ export function OrgApiKeysDataTable({ searchPlaceholder={t('searchApiKeys')} searchColumn="name" onAdd={addApiKey} + onRefresh={onRefresh} + isRefreshing={isRefreshing} addButtonText={t('apiKeysAdd')} /> ); diff --git a/src/components/OrgApiKeysTable.tsx b/src/components/OrgApiKeysTable.tsx index 52030b66..d4c81e80 100644 --- a/src/components/OrgApiKeysTable.tsx +++ b/src/components/OrgApiKeysTable.tsx @@ -46,6 +46,24 @@ export default function OrgApiKeysTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const deleteSite = (apiKeyId: string) => { api.delete(`/org/${orgId}/api-key/${apiKeyId}`) @@ -195,6 +213,8 @@ export default function OrgApiKeysTable({ addApiKey={() => { router.push(`/${orgId}/settings/api-keys/create`); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/PathMatchRenameModal.tsx b/src/components/PathMatchRenameModal.tsx index 574c9c70..101dea19 100644 --- a/src/components/PathMatchRenameModal.tsx +++ b/src/components/PathMatchRenameModal.tsx @@ -1,4 +1,4 @@ -import { Pencil } from "lucide-react"; +import { Settings } from "lucide-react"; import { Select, SelectContent, @@ -6,20 +6,13 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, - DialogTrigger, -} from "@app/components/ui/dialog"; + import { Badge } from "@app/components/ui/badge"; import { Label } from "@app/components/ui/label"; import { useEffect, useState } from "react"; import { Input } from "./ui/input"; import { Button } from "./ui/button"; +import { Credenza, CredenzaContent, CredenzaDescription, CredenzaFooter, CredenzaHeader, CredenzaTitle, CredenzaTrigger } from "./Credenza"; export function PathMatchModal({ @@ -68,15 +61,15 @@ export function PathMatchModal({ }; return ( - - {trigger} - - - Configure Path Matching - + + {trigger} + + + Configure Path Matching + Set up how incoming requests should be matched based on their path. - - + +
@@ -102,7 +95,7 @@ export function PathMatchModal({

{getHelpText()}

- + {value?.path && ( - -
-
+ + + ); } @@ -177,17 +170,17 @@ export function PathRewriteModal({ }; return ( - - + !disabled && setOpen(v)}> + {trigger} - - - - Configure Path Rewriting - + + + + Configure Path Rewriting + Transform the matched path before forwarding to the target. - - + +
@@ -214,7 +207,7 @@ export function PathRewriteModal({

{getHelpText()}

- + {value?.rewritePath && ( - -
-
+ + + ); } @@ -250,13 +243,13 @@ export function PathMatchDisplay({ return (
- + {getTypeLabel(value.pathMatchType)} {value.path} - +
); } @@ -281,13 +274,13 @@ export function PathRewriteDisplay({ return (
- + {getTypeLabel(value.rewritePathType)} {value.rewritePath || (strip)} - +
); } diff --git a/src/components/ResourcesTable.tsx b/src/components/ResourcesTable.tsx index 7a645bc7..ad8b4fab 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ResourcesTable.tsx @@ -24,7 +24,8 @@ import { MoreHorizontal, ArrowUpRight, ShieldOff, - ShieldCheck + ShieldCheck, + RefreshCw } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; @@ -179,9 +180,27 @@ export default function ResourcesTable({ const [internalColumnFilters, setInternalColumnFilters] = useState([]); const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); + const [isRefreshing, setIsRefreshing] = useState(false); const currentView = searchParams.get("view") || defaultView; + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + useEffect(() => { const fetchSites = async () => { try { @@ -753,7 +772,21 @@ export default function ResourcesTable({ )}
- {getActionButton()} +
+ +
+
+ {getActionButton()} +
diff --git a/src/components/RolesDataTable.tsx b/src/components/RolesDataTable.tsx index e88f9a2f..8043fc23 100644 --- a/src/components/RolesDataTable.tsx +++ b/src/components/RolesDataTable.tsx @@ -10,12 +10,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; createRole?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function RolesDataTable({ columns, data, - createRole + createRole, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -29,6 +33,8 @@ export function RolesDataTable({ searchPlaceholder={t('accessRolesSearch')} searchColumn="name" onAdd={createRole} + onRefresh={onRefresh} + isRefreshing={isRefreshing} addButtonText={t('accessRolesAdd')} /> ); diff --git a/src/components/RolesTable.tsx b/src/components/RolesTable.tsx index e92e71b6..292384a8 100644 --- a/src/components/RolesTable.tsx +++ b/src/components/RolesTable.tsx @@ -20,6 +20,7 @@ import DeleteRoleForm from "@app/components/DeleteRoleForm"; import { createApiClient } from "@app/lib/api"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useTranslations } from "next-intl"; +import { useRouter } from "next/navigation"; export type RoleRow = Role; @@ -30,6 +31,7 @@ type RolesTableProps = { export default function UsersTable({ roles: r }: RolesTableProps) { const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const router = useRouter(); const [roles, setRoles] = useState(r); @@ -40,6 +42,24 @@ export default function UsersTable({ roles: r }: RolesTableProps) { const { org } = useOrgContext(); const t = useTranslations(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const columns: ColumnDef[] = [ { @@ -116,6 +136,8 @@ export default function UsersTable({ roles: r }: RolesTableProps) { createRole={() => { setIsCreateModalOpen(true); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/ShareLinksDataTable.tsx b/src/components/ShareLinksDataTable.tsx index dd266bcf..f2753bcf 100644 --- a/src/components/ShareLinksDataTable.tsx +++ b/src/components/ShareLinksDataTable.tsx @@ -10,12 +10,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; createShareLink?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function ShareLinksDataTable({ columns, data, - createShareLink + createShareLink, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -29,6 +33,8 @@ export function ShareLinksDataTable({ searchPlaceholder={t('shareSearch')} searchColumn="name" onAdd={createShareLink} + onRefresh={onRefresh} + isRefreshing={isRefreshing} addButtonText={t('shareCreate')} /> ); diff --git a/src/components/ShareLinksTable.tsx b/src/components/ShareLinksTable.tsx index 2943311f..ba9169c1 100644 --- a/src/components/ShareLinksTable.tsx +++ b/src/components/ShareLinksTable.tsx @@ -61,6 +61,25 @@ export default function ShareLinksTable({ const [isCreateModalOpen, setIsCreateModalOpen] = useState(false); const [rows, setRows] = useState(shareLinks); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; + function formatLink(link: string) { return link.substring(0, 20) + "..." + link.substring(link.length - 20); } @@ -292,6 +311,8 @@ export default function ShareLinksTable({ createShareLink={() => { setIsCreateModalOpen(true); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/UsersDataTable.tsx b/src/components/UsersDataTable.tsx index 1999b620..db12b697 100644 --- a/src/components/UsersDataTable.tsx +++ b/src/components/UsersDataTable.tsx @@ -10,12 +10,16 @@ interface DataTableProps { columns: ColumnDef[]; data: TData[]; inviteUser?: () => void; + onRefresh?: () => void; + isRefreshing?: boolean; } export function UsersDataTable({ columns, data, - inviteUser + inviteUser, + onRefresh, + isRefreshing }: DataTableProps) { const t = useTranslations(); @@ -29,6 +33,8 @@ export function UsersDataTable({ searchPlaceholder={t('accessUsersSearch')} searchColumn="email" onAdd={inviteUser} + onRefresh={onRefresh} + isRefreshing={isRefreshing} addButtonText={t('accessUserCreate')} /> ); diff --git a/src/components/UsersTable.tsx b/src/components/UsersTable.tsx index 2d4c122f..be8aea49 100644 --- a/src/components/UsersTable.tsx +++ b/src/components/UsersTable.tsx @@ -51,6 +51,24 @@ export default function UsersTable({ users: u }: UsersTableProps) { const { user, updateUser } = useUserContext(); const { org } = useOrgContext(); const t = useTranslations(); + const [isRefreshing, setIsRefreshing] = useState(false); + + const refreshData = async () => { + console.log("Data refreshed"); + setIsRefreshing(true); + try { + await new Promise((resolve) => setTimeout(resolve, 200)); + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } finally { + setIsRefreshing(false); + } + }; const columns: ColumnDef[] = [ { @@ -290,6 +308,8 @@ export default function UsersTable({ users: u }: UsersTableProps) { `/${org?.org.orgId}/settings/access/users/create` ); }} + onRefresh={refreshData} + isRefreshing={isRefreshing} /> ); diff --git a/src/components/ui/textarea.tsx b/src/components/ui/textarea.tsx index dcfc646d..83e84073 100644 --- a/src/components/ui/textarea.tsx +++ b/src/components/ui/textarea.tsx @@ -10,7 +10,7 @@ const Textarea = React.forwardRef( return (