From 341eae66e837acfa3ca7f9908c3840febaf66116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 8 Jul 2026 12:12:53 +0200 Subject: [PATCH 1/8] fix saving wg tunnels --- new-ui/src/shared/rust-api/types.ts | 2 ++ src-tauri/core/src/database/models/mod.rs | 2 +- src-tauri/core/src/database/models/tunnel.rs | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/new-ui/src/shared/rust-api/types.ts b/new-ui/src/shared/rust-api/types.ts index c84aabf1..7ffb204b 100644 --- a/new-ui/src/shared/rust-api/types.ts +++ b/new-ui/src/shared/rust-api/types.ts @@ -345,10 +345,12 @@ export type TunnelRequest = { prvkey: string; address: string; server_pubkey: string; + preshared_key?: string; allowed_ips?: string; endpoint: string; dns?: string; persistent_keep_alive: number; + route_all_traffic: boolean; pre_up?: string; post_up?: string; pre_down?: string; diff --git a/src-tauri/core/src/database/models/mod.rs b/src-tauri/core/src/database/models/mod.rs index 1340da6e..449de17a 100644 --- a/src-tauri/core/src/database/models/mod.rs +++ b/src-tauri/core/src/database/models/mod.rs @@ -11,7 +11,7 @@ pub mod wireguard_keys; // Typestate structs to make working with optional IDs easier pub type Id = i64; -#[derive(Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[derive(Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct NoId; const PURGE_DURATION: chrono::Duration = chrono::Duration::days(30); diff --git a/src-tauri/core/src/database/models/tunnel.rs b/src-tauri/core/src/database/models/tunnel.rs index 262ad647..452c81ad 100644 --- a/src-tauri/core/src/database/models/tunnel.rs +++ b/src-tauri/core/src/database/models/tunnel.rs @@ -15,6 +15,7 @@ use crate::{ #[serde_as] #[derive(Clone, Deserialize, Serialize)] pub struct Tunnel { + #[serde(default)] pub id: I, pub name: String, // encryption keys From 0c303553cf3a5517c03dd079dd53ecb8fa574e90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 8 Jul 2026 14:25:10 +0200 Subject: [PATCH 2/8] fix server address validation --- .../components/UpdateTunnelModal/UpdateTunnelModal.tsx | 8 +++++++- .../steps/VpnServerStep/VpnServerStep.tsx | 8 +++++++- new-ui/src/shared/utils/patterns.ts | 7 +++++-- 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx index 91d89873..35095b9b 100644 --- a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx @@ -24,6 +24,7 @@ import { cidrRegex, patternValidEndpoint, patternValidIpV6WithMask, + patternValidIpV6WithPort, patternValidIpWithMask, patternValidWireguardKey, } from '../../../../../shared/utils/patterns'; @@ -84,7 +85,12 @@ const formSchema = z.object({ preshared_key: z .string() .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z.string().refine((v) => patternValidEndpoint.test(v), 'Invalid address'), + endpoint: z + .string() + .refine( + (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), + 'Invalid address', + ), dns: z.string(), allowed_ips: z.string().refine((v) => { if (!v) return true; diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx index e65d9af0..98238287 100644 --- a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx @@ -11,6 +11,7 @@ import { ThemeSpacing } from '../../../../../shared/types'; import { cidrRegex, patternValidEndpoint, + patternValidIpV6WithPort, patternValidWireguardKey, } from '../../../../../shared/utils/patterns'; import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; @@ -22,7 +23,12 @@ const formSchema = z.object({ preshared_key: z .string() .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z.string().refine((v) => patternValidEndpoint.test(v), 'Invalid address'), + endpoint: z + .string() + .refine( + (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), + 'Invalid address', + ), dns: z.string(), allowed_ips: z.string().refine((v) => { if (!v) return true; diff --git a/new-ui/src/shared/utils/patterns.ts b/new-ui/src/shared/utils/patterns.ts index 845194b3..60baf039 100644 --- a/new-ui/src/shared/utils/patterns.ts +++ b/new-ui/src/shared/utils/patterns.ts @@ -75,9 +75,12 @@ export const patternValidIpWithMask = export const cidrRegex = /^(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\/\d{1,2}|[0-9a-fA-F:.]+\/\d{1,3})$/; -// Regular expression to match IPv4, IPv6, domain name, or localhost with port +// Regular expression to match a WireGuard endpoint. A bare IPv4 literal must +// include a port (a port-less IP is almost always a mistake), while domain names +// and localhost may omit it. IPv6 endpoints are validated separately via +// patternValidIpV6WithPort (port required there too). export const patternValidEndpoint = - /^(localhost|\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b|\b(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,}\b)(?::(\d+))?$/; + /^(?:(?:localhost|(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,})(?::\d+)?|(?:[0-9]{1,3}\.){3}[0-9]{1,3}:\d+)$/; // Copied from zod source code export const patternValidIpV6 = From ad0bd3ef6752f5caba3428ac8ae54d3521a3bf4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 8 Jul 2026 14:38:35 +0200 Subject: [PATCH 3/8] fix tunnel display in compact view --- .../CompactLocationsPage.tsx | 41 +++++++++---------- .../components/LocationCard/LocationCard.tsx | 2 +- .../LocationCard/context/context.tsx | 4 +- .../views/DefaultView/DefaultView.tsx | 2 +- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx index 002408fc..87550afe 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx @@ -77,27 +77,26 @@ export const CompactLocationsPage = () => {
- {isPresent(instanceInfo) && - displayedLocations.map((location) => { - const isOpen = - location.id === openLocation || displayedLocations.length === 1; - return ( - { - if (isOpen) { - useAppStore.setState({ expandedLocation: null }); - } else { - useAppStore.setState({ expandedLocation: location.id }); - } - }} - /> - ); - })} + {displayedLocations.map((location) => { + const isOpen = + location.id === openLocation || displayedLocations.length === 1; + return ( + { + if (isOpen) { + useAppStore.setState({ expandedLocation: null }); + } else { + useAppStore.setState({ expandedLocation: location.id }); + } + }} + /> + ); + })}
diff --git a/new-ui/src/shared/components/LocationCard/LocationCard.tsx b/new-ui/src/shared/components/LocationCard/LocationCard.tsx index d578eb25..5c204e12 100644 --- a/new-ui/src/shared/components/LocationCard/LocationCard.tsx +++ b/new-ui/src/shared/components/LocationCard/LocationCard.tsx @@ -24,7 +24,7 @@ interface Props { isOpen: boolean; onOpen: () => void; disableOpen?: boolean; - instance: InstanceInfo; + instance?: InstanceInfo; } const views: Record = { diff --git a/new-ui/src/shared/components/LocationCard/context/context.tsx b/new-ui/src/shared/components/LocationCard/context/context.tsx index 4583759a..538bd6bf 100644 --- a/new-ui/src/shared/components/LocationCard/context/context.tsx +++ b/new-ui/src/shared/components/LocationCard/context/context.tsx @@ -15,7 +15,7 @@ import { LocationCardViews, type LocationCardViewsValue } from './types'; interface LocationCardContextValue { location: LocationInfo; - instance: InstanceInfo; + instance?: InstanceInfo; currentView: LocationCardViewsValue; previousView: LocationCardViewsValue | null; postureError: string | null; @@ -38,7 +38,7 @@ export const useLocationCardContext = (): LocationCardContextValue => { }; interface LocationCardProviderProps { - instance: InstanceInfo; + instance?: InstanceInfo; location: LocationInfo; children: ReactNode; } diff --git a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx index 46e5aade..f9784c11 100644 --- a/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx +++ b/new-ui/src/shared/components/LocationCard/views/DefaultView/DefaultView.tsx @@ -29,7 +29,7 @@ export const DefaultView = () => { return (
- {instance.client_traffic_policy === ClientTrafficPolicy.None && ( + {(instance?.client_traffic_policy === ClientTrafficPolicy.None || !instance) && ( Date: Wed, 8 Jul 2026 14:43:13 +0200 Subject: [PATCH 4/8] fix select option namespacing --- .../CompactLocationsPage/components/InstanceSwitcher.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx index 1394885b..175abf6e 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/components/InstanceSwitcher.tsx @@ -26,7 +26,10 @@ export const InstanceSwitcher = () => { key: 'instances', label: 'Instances', options: instances.map((instance) => ({ - key: instance.id, + // Instances and tunnels have separate id spaces, so prefix the key with + // the kind to keep it unique across groups (the Select marks the checkmark + // by comparing option keys). + key: `instance-${instance.id}`, label: instance.name, value: { kind: 'instance', id: instance.id }, })), @@ -36,7 +39,7 @@ export const InstanceSwitcher = () => { key: 'tunnels', label: 'Tunnels', options: tunnels.map((tunnel) => ({ - key: tunnel.id ?? tunnel.name, + key: `tunnel-${tunnel.id ?? tunnel.name}`, label: tunnel.name, value: { kind: 'tunnel', id: tunnel.id }, })), From 2af4e6b1c89211cf24fdbd1f48cfb6f8cefc1854 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 8 Jul 2026 16:37:43 +0200 Subject: [PATCH 5/8] handle edge case when only tunnels are configured --- .../CompactLocationsPage.tsx | 22 ++++++++++++------- .../pages/full/OverviewPage/OverviewPage.tsx | 9 +++++--- 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx index 87550afe..c405d2c9 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx @@ -14,6 +14,7 @@ import { api } from '../../../shared/rust-api/api'; import { getInstancesQueryOptions, getLocationsQueryOptions, + getTunnelsQueryOptions, } from '../../../shared/rust-api/query'; import { useAppStore } from '../../../shared/store/useAppStore'; import { ThemeSpacing } from '../../../shared/types'; @@ -28,20 +29,23 @@ export const CompactLocationsPage = () => { const routeData = useLoaderData({ from: '/compact/' }); const { data: instances } = useQuery(getInstancesQueryOptions); + const { data: tunnels } = useQuery(getTunnelsQueryOptions); const allInstances = instances ?? routeData.instances; - const allTunnels = routeData.tunnels; + const allTunnels = tunnels ?? routeData.tunnels; const queryInstanceId = useMemo(() => { - if (!isPresent(selection)) return routeData.instances[0].id; + if (!isPresent(selection)) return allInstances[0]?.id; if (selection.kind === 'instance') return selection.id; return ( - allTunnels.find((t) => t.id === selection.id)?.instance_id ?? - routeData.instances[0].id + allTunnels.find((t) => t.id === selection.id)?.instance_id ?? allInstances[0]?.id ); - }, [selection, routeData.instances, allTunnels]); + }, [selection, allInstances, allTunnels]); - const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); + const { data: locations } = useQuery({ + ...getLocationsQueryOptions(queryInstanceId ?? 0), + enabled: isPresent(queryInstanceId), + }); const instanceInfo = useMemo(() => { if (!isPresent(selection)) return allInstances[0]; @@ -62,9 +66,11 @@ export const CompactLocationsPage = () => { useEffect(() => { if (selection?.kind === 'tunnel') return; if (selection === null || instanceInfo === undefined) { - setViewSelection({ kind: 'instance', id: routeData.instances[0].id }); + if (allInstances.length > 0) { + setViewSelection({ kind: 'instance', id: allInstances[0].id }); + } } - }, [routeData.instances, instanceInfo, selection, setViewSelection]); + }, [allInstances, instanceInfo, selection, setViewSelection]); return ( { ); const queryInstanceId = useMemo(() => { - if (!isPresent(selection)) return instances[0].id; + if (!isPresent(selection)) return instances[0]?.id; if (selection.kind === 'instance') return selection.id; - return selectedTunnel?.instance_id ?? instances[0].id; + return selectedTunnel?.instance_id ?? instances[0]?.id; }, [selection, instances, selectedTunnel]); - const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); + const { data: locations } = useQuery({ + ...getLocationsQueryOptions(queryInstanceId ?? 0), + enabled: isPresent(queryInstanceId), + }); const displayedLocations = useMemo(() => { if (!isPresent(selection) || selection.kind === 'instance') { From f6e646d8f80117bbdaa1ed923c33ea4452af76e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 9 Jul 2026 09:37:24 +0200 Subject: [PATCH 6/8] fix name error display --- .../components/form/FormInput/FormInput.tsx | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/new-ui/src/shared/components/form/FormInput/FormInput.tsx b/new-ui/src/shared/components/form/FormInput/FormInput.tsx index ce98c7f5..70a5d8fd 100644 --- a/new-ui/src/shared/components/form/FormInput/FormInput.tsx +++ b/new-ui/src/shared/components/form/FormInput/FormInput.tsx @@ -23,11 +23,12 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => return undefined; }, [onDismiss, props.type]); - // allows field to show error even if isPristine is true, this is needed in cases as input required or checkbox checked but user just clicked submit - const wasSubmittedWithFailure = useStore( - form.store, - (store) => !store.isSubmitSuccessful && store.submissionAttempts > 0, - ); + // allows field to show error even if isPristine is true, this is needed in cases as input required or checkbox checked but user just clicked submit. + // Keyed on submissionAttempts rather than isSubmitSuccessful: a submit whose + // handler injects server-side field errors via setErrorMap (without throwing) + // is still recorded as "successful" by the form, which would otherwise hide + // the error on a pristine field. + const wasSubmitted = useStore(form.store, (store) => store.submissionAttempts > 0); const isPristine = useStore(field.store, (state) => state.meta.isPristine); @@ -39,7 +40,7 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => const errorMessage = useMemo(() => { // ignore errors unless some touches the field or submit's the form - if (isPristine && !wasSubmittedWithFailure) return undefined; + if (isPristine && !wasSubmitted) return undefined; const fieldError = errorState[0]; @@ -57,7 +58,7 @@ export const FormInput = ({ mapError, onDismiss, ...props }: FormInputProps) => } } return undefined; - }, [mapError, errorState[0], isPristine, wasSubmittedWithFailure]); + }, [mapError, errorState[0], isPristine, wasSubmitted]); return ( Date: Thu, 9 Jul 2026 10:42:23 +0200 Subject: [PATCH 7/8] add more shared validation schemas --- .../UpdateTunnelModal/UpdateTunnelModal.tsx | 41 ++++++------------- .../steps/VpnServerStep/VpnServerStep.tsx | 33 ++++----------- new-ui/src/shared/utils/zod.ts | 38 ++++++++++++++++- 3 files changed, 58 insertions(+), 54 deletions(-) diff --git a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx index 35095b9b..44b3ac98 100644 --- a/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx +++ b/new-ui/src/pages/full/OverviewPage/components/UpdateTunnelModal/UpdateTunnelModal.tsx @@ -21,13 +21,15 @@ import { api } from '../../../../../shared/rust-api/api'; import { ThemeSpacing } from '../../../../../shared/types'; import { isPresent } from '../../../../../shared/utils/isPresent'; import { - cidrRegex, - patternValidEndpoint, patternValidIpV6WithMask, - patternValidIpV6WithPort, patternValidIpWithMask, - patternValidWireguardKey, } from '../../../../../shared/utils/patterns'; +import { + allowedIpsSchema, + endpointSchema, + optionalWireguardKeySchema, + wireguardKeySchema, +} from '../../../../../shared/utils/zod'; const modalNameKey = ModalName.UpdateTunnel; @@ -73,32 +75,13 @@ const formSchema = z.object({ (ip) => patternValidIpWithMask.test(ip) || patternValidIpV6WithMask.test(ip), ); }, 'Field is invalid'), - prvkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - server_pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - preshared_key: z - .string() - .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z - .string() - .refine( - (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), - 'Invalid address', - ), + prvkey: wireguardKeySchema, + pubkey: wireguardKeySchema, + server_pubkey: wireguardKeySchema, + preshared_key: optionalWireguardKeySchema, + endpoint: endpointSchema, dns: z.string(), - allowed_ips: z.string().refine((v) => { - if (!v) return true; - return v - .split(',') - .map((s) => s.trim()) - .every((cidr) => cidrRegex.test(cidr)); - }, 'Invalid CIDR notation'), + allowed_ips: allowedIpsSchema, persistent_keep_alive: z.number().int().min(0), pre_up: z.string(), post_up: z.string(), diff --git a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx index 98238287..e910fddf 100644 --- a/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx +++ b/new-ui/src/pages/full/TunnelWizardPage/steps/VpnServerStep/VpnServerStep.tsx @@ -9,34 +9,19 @@ import { useAppForm } from '../../../../../shared/form'; import { formChangeLogic } from '../../../../../shared/formLogic'; import { ThemeSpacing } from '../../../../../shared/types'; import { - cidrRegex, - patternValidEndpoint, - patternValidIpV6WithPort, - patternValidWireguardKey, -} from '../../../../../shared/utils/patterns'; + allowedIpsSchema, + endpointSchema, + optionalWireguardKeySchema, + wireguardKeySchema, +} from '../../../../../shared/utils/zod'; import { useTunnelWizardStore } from '../../hooks/useTunnelWizardStore'; const formSchema = z.object({ - server_pubkey: z - .string() - .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - preshared_key: z - .string() - .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'), - endpoint: z - .string() - .refine( - (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), - 'Invalid address', - ), + server_pubkey: wireguardKeySchema, + preshared_key: optionalWireguardKeySchema, + endpoint: endpointSchema, dns: z.string(), - allowed_ips: z.string().refine((v) => { - if (!v) return true; - return v - .split(',') - .map((s) => s.trim()) - .every((cidr) => cidrRegex.test(cidr)); - }, 'Invalid CIDR notation'), + allowed_ips: allowedIpsSchema, persistent_keep_alive: z.number().int().min(0), }); diff --git a/new-ui/src/shared/utils/zod.ts b/new-ui/src/shared/utils/zod.ts index fcad5086..2e69a823 100644 --- a/new-ui/src/shared/utils/zod.ts +++ b/new-ui/src/shared/utils/zod.ts @@ -1,4 +1,10 @@ -import type z from 'zod'; +import { z } from 'zod'; +import { + cidrRegex, + patternValidEndpoint, + patternValidIpV6WithPort, + patternValidWireguardKey, +} from './patterns'; export const createZodIssue = ( message: string, @@ -8,3 +14,33 @@ export const createZodIssue = ( message, path, }); + +// Shared field schemas for the WireGuard tunnel forms (add/edit). Kept here so +// the tunnel wizard and the edit-tunnel modal validate identically. + +// WireGuard endpoint (host:port): IPv4:port, domain[:port], or [IPv6]:port. +export const endpointSchema = z + .string() + .refine( + (v) => patternValidEndpoint.test(v) || patternValidIpV6WithPort.test(v), + 'Invalid address', + ); + +// A required WireGuard key. +export const wireguardKeySchema = z + .string() + .refine((v) => patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +// An optional WireGuard key - an empty value is allowed (e.g. preshared key). +export const optionalWireguardKeySchema = z + .string() + .refine((v) => !v || patternValidWireguardKey.test(v), 'Invalid WireGuard key'); + +// Comma-separated list of CIDR ranges; an empty value is allowed. +export const allowedIpsSchema = z.string().refine((v) => { + if (!v) return true; + return v + .split(',') + .map((s) => s.trim()) + .every((cidr) => cidrRegex.test(cidr)); +}, 'Invalid CIDR notation'); From c7a2fa9f6722a4a7e8de05d5a40b9f5c411116d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 9 Jul 2026 10:48:11 +0200 Subject: [PATCH 8/8] simplify optional instance query --- .../CompactLocationsPage/CompactLocationsPage.tsx | 5 +---- new-ui/src/pages/full/OverviewPage/OverviewPage.tsx | 5 +---- new-ui/src/shared/rust-api/query.ts | 10 +++++++--- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx index c405d2c9..1a9e864b 100644 --- a/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx +++ b/new-ui/src/pages/compact/CompactLocationsPage/CompactLocationsPage.tsx @@ -42,10 +42,7 @@ export const CompactLocationsPage = () => { ); }, [selection, allInstances, allTunnels]); - const { data: locations } = useQuery({ - ...getLocationsQueryOptions(queryInstanceId ?? 0), - enabled: isPresent(queryInstanceId), - }); + const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); const instanceInfo = useMemo(() => { if (!isPresent(selection)) return allInstances[0]; diff --git a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx index deb0608f..1876b814 100644 --- a/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx +++ b/new-ui/src/pages/full/OverviewPage/OverviewPage.tsx @@ -46,10 +46,7 @@ export const OverviewPage = () => { return selectedTunnel?.instance_id ?? instances[0]?.id; }, [selection, instances, selectedTunnel]); - const { data: locations } = useQuery({ - ...getLocationsQueryOptions(queryInstanceId ?? 0), - enabled: isPresent(queryInstanceId), - }); + const { data: locations } = useQuery(getLocationsQueryOptions(queryInstanceId)); const displayedLocations = useMemo(() => { if (!isPresent(selection) || selection.kind === 'instance') { diff --git a/new-ui/src/shared/rust-api/query.ts b/new-ui/src/shared/rust-api/query.ts index ca5ea023..eaf79d39 100644 --- a/new-ui/src/shared/rust-api/query.ts +++ b/new-ui/src/shared/rust-api/query.ts @@ -1,5 +1,6 @@ -import { queryOptions } from '@tanstack/react-query'; +import { queryOptions, skipToken } from '@tanstack/react-query'; +import { isPresent } from '../utils/isPresent'; import { api } from './api'; import type { ConnectionArgs, LocationDetailsArgs, StatsArgs } from './types'; @@ -15,10 +16,13 @@ export const getInstancesQueryOptions = queryOptions({ refetchInterval: 30_000, }); -export const getLocationsQueryOptions = (instanceId: number) => +// Accepts an absent instance id and self-disables via skipToken, so callers +// (e.g. a tunnel selection with no backing instance) can pass through whatever +// they resolved without a sentinel id or a separate `enabled` flag. +export const getLocationsQueryOptions = (instanceId: number | undefined) => queryOptions({ queryKey: ['locations', instanceId] as const, - queryFn: () => api.getLocations(instanceId), + queryFn: isPresent(instanceId) ? () => api.getLocations(instanceId) : skipToken, }); export const hasAnyVisibleLocationsQueryOptions = queryOptions({