From 610e46f2d550f7d36722e67a9196735fd0b04f5c Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Mon, 1 Dec 2025 18:26:32 +0100 Subject: [PATCH 01/12] =?UTF-8?q?=F0=9F=9A=A7=20WIP:=20separate=20proxy=20?= =?UTF-8?q?&=20client=20resources?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + .../settings/resources/client/page.tsx | 113 +++ src/app/[orgId]/settings/resources/page.tsx | 145 +-- .../[orgId]/settings/resources/proxy/page.tsx | 122 +++ src/app/navigation.tsx | 72 +- ...rcesTable.tsx => ClientResourcesTable.tsx} | 6 +- src/components/ProxyResourcesTable.tsx | 924 ++++++++++++++++++ 7 files changed, 1212 insertions(+), 172 deletions(-) create mode 100644 src/app/[orgId]/settings/resources/client/page.tsx create mode 100644 src/app/[orgId]/settings/resources/proxy/page.tsx rename src/components/{ResourcesTable.tsx => ClientResourcesTable.tsx} (99%) create mode 100644 src/components/ProxyResourcesTable.tsx diff --git a/messages/en-US.json b/messages/en-US.json index f3ae34a7..3b1c43a4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1151,6 +1151,8 @@ "sidebarHome": "Home", "sidebarSites": "Sites", "sidebarResources": "Resources", + "sidebarProxyResources": "Proxy Resources", + "sidebarClientResources": "Client Resources", "sidebarAccessControl": "Access Control", "sidebarUsers": "Users", "sidebarInvitations": "Invitations", diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx new file mode 100644 index 00000000..a02f0ae0 --- /dev/null +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -0,0 +1,113 @@ +import ClientResourcesTable from "@app/components/ClientResourcesTable"; +import type { InternalResourceRow } from "@app/components/ProxyResourcesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { pullEnv } from "@app/lib/pullEnv"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { cache } from "react"; + +export interface ClientResourcesPageProps { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +} + +export default async function ClientResourcesPage( + props: ClientResourcesPageProps +) { + const params = await props.params; + const searchParams = await props.searchParams; + const t = await getTranslations(); + + const env = pullEnv(); + + // Default to 'proxy' view, or use the query param if provided + let defaultView: "proxy" | "internal" = "proxy"; + if (env.flags.enableClients) { + defaultView = searchParams.view === "internal" ? "internal" : "proxy"; + } + + let resources: ListResourcesResponse["resources"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/resources`, + await authCookieHeader() + ); + resources = res.data.data.resources; + } catch (e) {} + + let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); + siteResources = res.data.data.siteResources; + } catch (e) {} + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const internalResourceRows: InternalResourceRow[] = siteResources.map( + (siteResource) => { + return { + id: siteResource.siteResourceId, + name: siteResource.name, + orgId: params.orgId, + siteName: siteResource.siteName, + siteAddress: siteResource.siteAddress || null, + mode: siteResource.mode || ("port" as any), + // protocol: siteResource.protocol, + // proxyPort: siteResource.proxyPort, + siteId: siteResource.siteId, + destination: siteResource.destination, + // destinationPort: siteResource.destinationPort, + alias: siteResource.alias || null, + siteNiceId: siteResource.siteNiceId + }; + } + ); + return ( + <> + + + + + + + ); +} diff --git a/src/app/[orgId]/settings/resources/page.tsx b/src/app/[orgId]/settings/resources/page.tsx index b6c8b232..954b966a 100644 --- a/src/app/[orgId]/settings/resources/page.tsx +++ b/src/app/[orgId]/settings/resources/page.tsx @@ -1,149 +1,10 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import ResourcesTable, { - ResourceRow, - InternalResourceRow -} from "../../../../components/ResourcesTable"; -import { AxiosResponse } from "axios"; -import { ListResourcesResponse } from "@server/routers/resource"; -import { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { redirect } from "next/navigation"; -import { cache } from "react"; -import { GetOrgResponse } from "@server/routers/org"; -import OrgProvider from "@app/providers/OrgProvider"; -import { getTranslations } from "next-intl/server"; -import { pullEnv } from "@app/lib/pullEnv"; -import { toUnicode } from "punycode"; -type ResourcesPageProps = { +export interface ResourcesPageProps { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; -}; - -export const dynamic = "force-dynamic"; +} export default async function ResourcesPage(props: ResourcesPageProps) { const params = await props.params; - const searchParams = await props.searchParams; - const t = await getTranslations(); - - const env = pullEnv(); - - // Default to 'proxy' view, or use the query param if provided - let defaultView: "proxy" | "internal" = "proxy"; - if (env.flags.enableClients) { - defaultView = searchParams.view === "internal" ? "internal" : "proxy"; - } - - let resources: ListResourcesResponse["resources"] = []; - try { - const res = await internal.get>( - `/org/${params.orgId}/resources`, - await authCookieHeader() - ); - resources = res.data.data.resources; - } catch (e) {} - - let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; - try { - const res = await internal.get< - AxiosResponse - >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); - siteResources = res.data.data.siteResources; - } catch (e) {} - - let org = null; - try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); - org = res.data.data; - } catch { - redirect(`/${params.orgId}/settings/resources`); - } - - if (!org) { - redirect(`/${params.orgId}/settings/resources`); - } - - const resourceRows: ResourceRow[] = resources.map((resource) => { - return { - id: resource.resourceId, - name: resource.name, - orgId: params.orgId, - nice: resource.niceId, - domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, - protocol: resource.protocol, - proxyPort: resource.proxyPort, - http: resource.http, - authState: !resource.http - ? "none" - : resource.sso || - resource.pincodeId !== null || - resource.passwordId !== null || - resource.whitelist || - resource.headerAuthId - ? "protected" - : "not_protected", - enabled: resource.enabled, - domainId: resource.domainId || undefined, - ssl: resource.ssl, - targets: resource.targets?.map((target) => ({ - targetId: target.targetId, - ip: target.ip, - port: target.port, - enabled: target.enabled, - healthStatus: target.healthStatus - })) - }; - }); - - const internalResourceRows: InternalResourceRow[] = siteResources.map( - (siteResource) => { - return { - id: siteResource.siteResourceId, - name: siteResource.name, - orgId: params.orgId, - siteName: siteResource.siteName, - siteAddress: siteResource.siteAddress || null, - mode: siteResource.mode || ("port" as any), - // protocol: siteResource.protocol, - // proxyPort: siteResource.proxyPort, - siteId: siteResource.siteId, - destination: siteResource.destination, - // destinationPort: siteResource.destinationPort, - alias: siteResource.alias || null, - siteNiceId: siteResource.siteNiceId - }; - } - ); - - return ( - <> - - - - - - - ); + redirect(`/${params.orgId}/settings/resources/proxy`); } diff --git a/src/app/[orgId]/settings/resources/proxy/page.tsx b/src/app/[orgId]/settings/resources/proxy/page.tsx new file mode 100644 index 00000000..edbe5ca0 --- /dev/null +++ b/src/app/[orgId]/settings/resources/proxy/page.tsx @@ -0,0 +1,122 @@ +import type { ResourceRow } from "@app/components/ProxyResourcesTable"; +import ProxyResourcesTable from "@app/components/ProxyResourcesTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { pullEnv } from "@app/lib/pullEnv"; +import OrgProvider from "@app/providers/OrgProvider"; +import type { GetOrgResponse } from "@server/routers/org"; +import type { ListResourcesResponse } from "@server/routers/resource"; +import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; +import type { AxiosResponse } from "axios"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; +import { toUnicode } from "punycode"; +import { cache } from "react"; + +export interface ProxyResourcesPageProps { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +} + +export default async function ProxyResourcesPage( + props: ProxyResourcesPageProps +) { + const params = await props.params; + const searchParams = await props.searchParams; + const t = await getTranslations(); + + const env = pullEnv(); + + // Default to 'proxy' view, or use the query param if provided + let defaultView: "proxy" | "internal" = "proxy"; + if (env.flags.enableClients) { + defaultView = searchParams.view === "internal" ? "internal" : "proxy"; + } + + let resources: ListResourcesResponse["resources"] = []; + try { + const res = await internal.get>( + `/org/${params.orgId}/resources`, + await authCookieHeader() + ); + resources = res.data.data.resources; + } catch (e) {} + + let siteResources: ListAllSiteResourcesByOrgResponse["siteResources"] = []; + try { + const res = await internal.get< + AxiosResponse + >(`/org/${params.orgId}/site-resources`, await authCookieHeader()); + siteResources = res.data.data.siteResources; + } catch (e) {} + + let org = null; + try { + const getOrg = cache(async () => + internal.get>( + `/org/${params.orgId}`, + await authCookieHeader() + ) + ); + const res = await getOrg(); + org = res.data.data; + } catch { + redirect(`/${params.orgId}/settings/resources`); + } + + if (!org) { + redirect(`/${params.orgId}/settings/resources`); + } + + const resourceRows: ResourceRow[] = resources.map((resource) => { + return { + id: resource.resourceId, + name: resource.name, + orgId: params.orgId, + nice: resource.niceId, + domain: `${resource.ssl ? "https://" : "http://"}${toUnicode(resource.fullDomain || "")}`, + protocol: resource.protocol, + proxyPort: resource.proxyPort, + http: resource.http, + authState: !resource.http + ? "none" + : resource.sso || + resource.pincodeId !== null || + resource.passwordId !== null || + resource.whitelist || + resource.headerAuthId + ? "protected" + : "not_protected", + enabled: resource.enabled, + domainId: resource.domainId || undefined, + ssl: resource.ssl, + targets: resource.targets?.map((target) => ({ + targetId: target.targetId, + ip: target.ip, + port: target.port, + enabled: target.enabled, + healthStatus: target.healthStatus + })) + }; + }); + return ( + <> + + + + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index debf9b74..ceefa8f0 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -17,7 +17,8 @@ import { CreditCard, Logs, SquareMousePointer, - ScanEye + ScanEye, + GlobeLock } from "lucide-react"; export type SidebarNavSection = { @@ -31,7 +32,7 @@ export const orgLangingNavItems: SidebarNavItem[] = [ { title: "sidebarAccount", href: "/{orgId}", - icon: + icon: } ]; @@ -44,19 +45,36 @@ export const orgNavSections = ( { title: "sidebarSites", href: "/{orgId}/settings/sites", - icon: + icon: }, { title: "sidebarResources", - href: "/{orgId}/settings/resources", - icon: + icon: , + items: [ + { + title: "sidebarProxyResources", + href: "/{orgId}/settings/resources/proxy", + icon: + }, + ...(enableClients + ? [ + { + title: "sidebarClientResources", + href: "/{orgId}/settings/resources/client", + icon: ( + + ) + } + ] + : []) + ] }, ...(enableClients ? [ { title: "sidebarClients", href: "/{orgId}/settings/clients", - icon: , + icon: , isBeta: true } ] @@ -66,7 +84,7 @@ export const orgNavSections = ( { title: "sidebarRemoteExitNodes", href: "/{orgId}/settings/remote-exit-nodes", - icon: , + icon: , showEE: true } ] @@ -74,12 +92,12 @@ export const orgNavSections = ( { title: "sidebarDomains", href: "/{orgId}/settings/domains", - icon: + icon: }, { title: "sidebarBluePrints", href: "/{orgId}/settings/blueprints", - icon: + icon: } ] }, @@ -88,31 +106,31 @@ export const orgNavSections = ( items: [ { title: "sidebarUsers", - icon: , + icon: , items: [ { title: "sidebarUsers", href: "/{orgId}/settings/access/users", - icon: + icon: }, { title: "sidebarInvitations", href: "/{orgId}/settings/access/invitations", - icon: + icon: } ] }, { title: "sidebarRoles", href: "/{orgId}/settings/access/roles", - icon: + icon: }, ...(build == "saas" ? [ { title: "sidebarIdentityProviders", href: "/{orgId}/settings/idp", - icon: , + icon: , showEE: true } ] @@ -120,7 +138,7 @@ export const orgNavSections = ( { title: "sidebarShareableLinks", href: "/{orgId}/settings/share-links", - icon: + icon: } ] }, @@ -131,19 +149,19 @@ export const orgNavSections = ( { title: "sidebarLogsRequest", href: "/{orgId}/settings/logs/request", - icon: + icon: }, ...(build != "oss" ? [ { title: "sidebarLogsAccess", href: "/{orgId}/settings/logs/access", - icon: + icon: }, { title: "sidebarLogsAction", href: "/{orgId}/settings/logs/action", - icon: + icon: } ] : []) @@ -158,7 +176,7 @@ export const orgNavSections = ( return [ { title: "sidebarLogs", - icon: , + icon: , items: logItems } ]; @@ -170,14 +188,14 @@ export const orgNavSections = ( { title: "sidebarApiKeys", href: "/{orgId}/settings/api-keys", - icon: + icon: }, ...(build == "saas" ? [ { title: "sidebarBilling", href: "/{orgId}/settings/billing", - icon: + icon: } ] : []), @@ -186,14 +204,14 @@ export const orgNavSections = ( { title: "sidebarEnterpriseLicenses", href: "/{orgId}/settings/license", - icon: + icon: } ] : []), { title: "sidebarSettings", href: "/{orgId}/settings/general", - icon: + icon: } ] } @@ -206,24 +224,24 @@ export const adminNavSections: SidebarNavSection[] = [ { title: "sidebarAllUsers", href: "/admin/users", - icon: + icon: }, { title: "sidebarApiKeys", href: "/admin/api-keys", - icon: + icon: }, { title: "sidebarIdentityProviders", href: "/admin/idp", - icon: + icon: }, ...(build == "enterprise" ? [ { title: "sidebarLicense", href: "/admin/license", - icon: + icon: } ] : []) diff --git a/src/components/ResourcesTable.tsx b/src/components/ClientResourcesTable.tsx similarity index 99% rename from src/components/ResourcesTable.tsx rename to src/components/ClientResourcesTable.tsx index 4a28c586..b37f3eb1 100644 --- a/src/components/ResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -177,7 +177,7 @@ export type InternalResourceRow = { type Site = ListSitesResponse["sites"][0]; -type ResourcesTableProps = { +type ClientResourcesTableProps = { resources: ResourceRow[]; internalResources: InternalResourceRow[]; orgId: string; @@ -270,13 +270,13 @@ const setStoredColumnVisibility = ( } }; -export default function ResourcesTable({ +export default function ClientResourcesTable({ resources, internalResources, orgId, defaultView = "proxy", defaultSort -}: ResourcesTableProps) { +}: ClientResourcesTableProps) { const router = useRouter(); const searchParams = useSearchParams(); const t = useTranslations(); diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx new file mode 100644 index 00000000..14830840 --- /dev/null +++ b/src/components/ProxyResourcesTable.tsx @@ -0,0 +1,924 @@ +"use client"; + +import { + flexRender, + getCoreRowModel, + useReactTable, + getPaginationRowModel, + SortingState, + getSortedRowModel, + ColumnFiltersState, + getFilteredRowModel, + VisibilityState +} from "@tanstack/react-table"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuCheckboxItem +} from "@app/components/ui/dropdown-menu"; +import { Button } from "@app/components/ui/button"; +import { + ArrowRight, + ArrowUpDown, + MoreHorizontal, + ShieldOff, + ShieldCheck, + RefreshCw, + Columns, + Plus, + Search, + ChevronDown, + Clock, + CheckCircle2, + XCircle +} from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useEffect, useTransition } from "react"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { formatAxiosError } from "@app/lib/api"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient } from "@app/lib/api"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { Switch } from "@app/components/ui/switch"; +import { AxiosResponse } from "axios"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { ListSitesResponse } from "@server/routers/site"; +import { useTranslations } from "next-intl"; +import { InfoPopup } from "@app/components/ui/info-popup"; +import { Input } from "@app/components/ui/input"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; + +export type TargetHealth = { + targetId: number; + ip: string; + port: number; + enabled: boolean; + healthStatus?: "healthy" | "unhealthy" | "unknown"; +}; + +export type ResourceRow = { + id: number; + nice: string | null; + name: string; + orgId: string; + domain: string; + authState: string; + http: boolean; + protocol: string; + proxyPort: number | null; + enabled: boolean; + domainId?: string; + ssl: boolean; + targetHost?: string; + targetPort?: number; + targets?: TargetHealth[]; +}; + +function getOverallHealthStatus( + targets?: TargetHealth[] +): "online" | "degraded" | "offline" | "unknown" { + if (!targets || targets.length === 0) { + return "unknown"; + } + + const monitoredTargets = targets.filter( + (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" + ); + + if (monitoredTargets.length === 0) { + return "unknown"; + } + + const healthyCount = monitoredTargets.filter( + (t) => t.healthStatus === "healthy" + ).length; + const unhealthyCount = monitoredTargets.filter( + (t) => t.healthStatus === "unhealthy" + ).length; + + if (healthyCount === monitoredTargets.length) { + return "online"; + } else if (unhealthyCount === monitoredTargets.length) { + return "offline"; + } else { + return "degraded"; + } +} + +function StatusIcon({ + status, + className = "" +}: { + status: "online" | "degraded" | "offline" | "unknown"; + className?: string; +}) { + const iconClass = `h-4 w-4 ${className}`; + + switch (status) { + case "online": + return ; + case "degraded": + return ; + case "offline": + return ; + case "unknown": + return ; + default: + return null; + } +} + +type ProxyResourcesTableProps = { + resources: ResourceRow[]; + orgId: string; + defaultSort?: { + id: string; + desc: boolean; + }; +}; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + COLUMN_VISIBILITY: "datatable-column-visibility", + getTablePageSize: (tableId?: string) => + tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE, + getTableColumnVisibility: (tableId?: string) => + tableId + ? `datatable-${tableId}-column-visibility` + : STORAGE_KEYS.COLUMN_VISIBILITY +}; + +const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +const setStoredPageSize = (pageSize: number, tableId?: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +const getStoredColumnVisibility = ( + tableId?: string, + defaultVisibility?: Record +): Record => { + if (typeof window === "undefined") return defaultVisibility || {}; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + // Validate that it's an object + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } + } catch (error) { + console.warn( + "Failed to read column visibility from localStorage:", + error + ); + } + return defaultVisibility || {}; +}; + +const setStoredColumnVisibility = ( + visibility: Record, + tableId?: string +): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + localStorage.setItem(key, JSON.stringify(visibility)); + } catch (error) { + console.warn( + "Failed to save column visibility to localStorage:", + error + ); + } +}; + +export default function ProxyResourcesTable({ + resources, + orgId, + defaultSort +}: ProxyResourcesTableProps) { + const router = useRouter(); + const t = useTranslations(); + + const { env } = useEnvContext(); + + const api = createApiClient({ env }); + + const [proxyPageSize, setProxyPageSize] = useState(() => + getStoredPageSize("proxy-resources", 20) + ); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedResource, setSelectedResource] = + useState(); + + const [proxySorting, setProxySorting] = useState( + defaultSort ? [defaultSort] : [] + ); + + const [proxyColumnFilters, setProxyColumnFilters] = + useState([]); + const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); + + const [isRefreshing, startTransition] = useTransition(); + const [proxyColumnVisibility, setProxyColumnVisibility] = + useState(() => + getStoredColumnVisibility("proxy-resources", {}) + ); + + const refreshData = () => { + try { + router.refresh(); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }; + + const deleteResource = (resourceId: number) => { + api.delete(`/resource/${resourceId}`) + .catch((e) => { + console.error(t("resourceErrorDelte"), e); + toast({ + variant: "destructive", + title: t("resourceErrorDelte"), + description: formatAxiosError(e, t("resourceErrorDelte")) + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + async function toggleResourceEnabled(val: boolean, resourceId: number) { + await api + .post>( + `resource/${resourceId}`, + { + enabled: val + } + ) + .catch((e) => { + toast({ + variant: "destructive", + title: t("resourcesErrorUpdate"), + description: formatAxiosError( + e, + t("resourcesErrorUpdateDescription") + ) + }); + }); + } + + function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { + const overallStatus = getOverallHealthStatus(targets); + + if (!targets || targets.length === 0) { + return ( +
+ + + {t("resourcesTableNoTargets")} + +
+ ); + } + + const monitoredTargets = targets.filter( + (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" + ); + const unknownTargets = targets.filter( + (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" + ); + + return ( + + + + + + {monitoredTargets.length > 0 && ( + <> + {monitoredTargets.map((target) => ( + +
+ + {`${target.ip}:${target.port}`} +
+ + {target.healthStatus} + +
+ ))} + + )} + {unknownTargets.length > 0 && ( + <> + {unknownTargets.map((target) => ( + +
+ + {`${target.ip}:${target.port}`} +
+ + {!target.enabled + ? t("disabled") + : t("resourcesTableNotMonitored")} + +
+ ))} + + )} +
+
+ ); + } + + const proxyColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: t("name"), + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "nice", + friendlyName: t("resource"), + enableHiding: true, + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "protocol", + friendlyName: t("protocol"), + header: () => {t("protocol")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( + + {resourceRow.http + ? resourceRow.ssl + ? "HTTPS" + : "HTTP" + : resourceRow.protocol.toUpperCase()} + + ); + } + }, + { + id: "status", + accessorKey: "status", + friendlyName: t("status"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const resourceRow = row.original; + return ; + }, + sortingFn: (rowA, rowB) => { + const statusA = getOverallHealthStatus(rowA.original.targets); + const statusB = getOverallHealthStatus(rowB.original.targets); + const statusOrder = { + online: 3, + degraded: 2, + offline: 1, + unknown: 0 + }; + return statusOrder[statusA] - statusOrder[statusB]; + } + }, + { + accessorKey: "domain", + friendlyName: t("access"), + header: () => {t("access")}, + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {!resourceRow.http ? ( + + ) : !resourceRow.domainId ? ( + + ) : ( + + )} +
+ ); + } + }, + { + accessorKey: "authState", + friendlyName: t("authentication"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ {resourceRow.authState === "protected" ? ( + + + {t("protected")} + + ) : resourceRow.authState === "not_protected" ? ( + + + {t("notProtected")} + + ) : ( + - + )} +
+ ); + } + }, + { + accessorKey: "enabled", + friendlyName: t("enabled"), + header: () => {t("enabled")}, + cell: ({ row }) => ( + + toggleResourceEnabled(val, row.original.id) + } + /> + ) + }, + { + id: "actions", + enableHiding: false, + header: ({ table }) => { + const hasHideableColumns = table + .getAllColumns() + .some((column) => column.getCanHide()); + if (!hasHideableColumns) { + return ; + } + return ( +
+ + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnDef = + column.columnDef as any; + const friendlyName = + columnDef.friendlyName; + const displayName = + friendlyName || + (typeof columnDef.header === + "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility( + !!value + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {displayName} + + ); + })} + + +
+ ); + }, + cell: ({ row }) => { + const resourceRow = row.original; + return ( +
+ + + + + + + + {t("viewSettings")} + + + { + setSelectedResource(resourceRow); + setIsDeleteModalOpen(true); + }} + > + + {t("delete")} + + + + + + + +
+ ); + } + } + ]; + + const proxyTable = useReactTable({ + data: resources, + columns: proxyColumns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setProxySorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setProxyColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setProxyGlobalFilter, + onColumnVisibilityChange: setProxyColumnVisibility, + initialState: { + pagination: { + pageSize: proxyPageSize, + pageIndex: 0 + }, + columnVisibility: proxyColumnVisibility + }, + state: { + sorting: proxySorting, + columnFilters: proxyColumnFilters, + globalFilter: proxyGlobalFilter, + columnVisibility: proxyColumnVisibility + } + }); + + const handleProxyPageSizeChange = (newPageSize: number) => { + setProxyPageSize(newPageSize); + setStoredPageSize(newPageSize, "proxy-resources"); + }; + + // Persist column visibility changes to localStorage + useEffect(() => { + setStoredColumnVisibility(proxyColumnVisibility, "proxy-resources"); + }, [proxyColumnVisibility]); + + return ( + <> + {selectedResource && ( + { + setIsDeleteModalOpen(val); + setSelectedResource(null); + }} + dialog={ +
+

{t("resourceQuestionRemove")}

+

{t("resourceMessageRemove")}

+
+ } + buttonText={t("resourceDeleteConfirm")} + onConfirm={async () => deleteResource(selectedResource!.id)} + string={selectedResource.name} + title={t("resourceDelete")} + /> + )} + +
+ + +
+
+ + proxyTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+
+
+
+ +
+
+ +
+
+
+ +
+ + + {proxyTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {proxyTable.getRowModel().rows?.length ? ( + proxyTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t( + "resourcesTableNoProxyResourcesFound" + )} + + + )} + +
+
+
+ +
+
+
+
+ + ); +} From 18db4a11c85e248e2f741bbc7dad5f5a4717d6a4 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 02:33:43 +0100 Subject: [PATCH 02/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20separate=20client=20?= =?UTF-8?q?&=20proxy=20resources=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 8 +- .../settings/resources/client/page.tsx | 30 +- .../[orgId]/settings/resources/proxy/page.tsx | 14 +- src/components/ClientResourcesTable.tsx | 1108 +++-------------- src/lib/queries.ts | 15 + 5 files changed, 213 insertions(+), 962 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 3b1c43a4..0aa56ac5 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -144,8 +144,10 @@ "expires": "Expires", "never": "Never", "shareErrorSelectResource": "Please select a resource", - "resourceTitle": "Manage Resources", - "resourceDescription": "Access resources on sites publically or privately", + "proxyResourceTitle": "Manage Proxy Resources", + "proxyResourceDescription": "Access web resources on sites", + "clientResourceTitle": "Manage Client Resources", + "clientResourceDescription": "Access Internal resources on sites", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -2184,7 +2186,7 @@ "generatedcredentials": "Generated Credentials", "copyandsavethesecredentials": "Copy and save these credentials", "copyandsavethesecredentialsdescription": "These credentials will not be shown again after you leave this page. Save them securely now.", - "credentialsSaved" : "Credentials Saved", + "credentialsSaved": "Credentials Saved", "credentialsSavedDescription": "Credentials have been regenerated and saved successfully.", "credentialsSaveError": "Credentials Save Error", "credentialsSaveErrorDescription": "An error occurred while regenerating and saving the credentials.", diff --git a/src/app/[orgId]/settings/resources/client/page.tsx b/src/app/[orgId]/settings/resources/client/page.tsx index a02f0ae0..34a7288f 100644 --- a/src/app/[orgId]/settings/resources/client/page.tsx +++ b/src/app/[orgId]/settings/resources/client/page.tsx @@ -1,11 +1,10 @@ import ClientResourcesTable from "@app/components/ClientResourcesTable"; -import type { InternalResourceRow } from "@app/components/ProxyResourcesTable"; +import type { InternalResourceRow } from "@app/components/ClientResourcesTable"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { pullEnv } from "@app/lib/pullEnv"; +import { getCachedOrg } from "@app/lib/api/getCachedOrg"; import OrgProvider from "@app/providers/OrgProvider"; -import type { GetOrgResponse } from "@server/routers/org"; import type { ListResourcesResponse } from "@server/routers/resource"; import type { ListAllSiteResourcesByOrgResponse } from "@server/routers/siteResource"; import type { AxiosResponse } from "axios"; @@ -22,17 +21,8 @@ export default async function ClientResourcesPage( props: ClientResourcesPageProps ) { const params = await props.params; - const searchParams = await props.searchParams; const t = await getTranslations(); - const env = pullEnv(); - - // Default to 'proxy' view, or use the query param if provided - let defaultView: "proxy" | "internal" = "proxy"; - if (env.flags.enableClients) { - defaultView = searchParams.view === "internal" ? "internal" : "proxy"; - } - let resources: ListResourcesResponse["resources"] = []; try { const res = await internal.get>( @@ -52,13 +42,7 @@ export default async function ClientResourcesPage( let org = null; try { - const getOrg = cache(async () => - internal.get>( - `/org/${params.orgId}`, - await authCookieHeader() - ) - ); - const res = await getOrg(); + const res = await getCachedOrg(params.orgId); org = res.data.data; } catch { redirect(`/${params.orgId}/settings/resources`); @@ -90,18 +74,14 @@ export default async function ClientResourcesPage( return ( <> >( @@ -103,8 +93,8 @@ export default async function ProxyResourcesPage( return ( <> diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index b37f3eb1..0d246fb3 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -1,65 +1,21 @@ "use client"; -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuCheckboxItem + DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - MoreHorizontal, - ArrowUpRight, - ShieldOff, - ShieldCheck, - RefreshCw, - Columns, - Settings2, - Plus, - Search, - ChevronDown, - Clock, - Wifi, - WifiOff, - CheckCircle2, - XCircle -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState, useEffect } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import CopyToClipboard from "@app/components/CopyToClipboard"; -import { Switch } from "@app/components/ui/switch"; -import { AxiosResponse } from "axios"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { ListSitesResponse } from "@server/routers/site"; -import { useTranslations } from "next-intl"; -import { InfoPopup } from "@app/components/ui/info-popup"; import { Input } from "@app/components/ui/input"; -import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { Table, TableBody, @@ -68,16 +24,42 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { ListSitesResponse } from "@server/routers/site"; import { - Tabs, - TabsContent, - TabsList, - TabsTrigger -} from "@app/components/ui/tabs"; -import { useSearchParams } from "next/navigation"; -import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable, + VisibilityState +} from "@tanstack/react-table"; +import { + ArrowUpDown, + ArrowUpRight, + CheckCircle2, + Clock, + Columns, + MoreHorizontal, + Plus, + RefreshCw, + Search, + XCircle +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useState, useTransition } from "react"; + import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; +import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; +import { siteQueries } from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; export type TargetHealth = { targetId: number; @@ -178,10 +160,8 @@ export type InternalResourceRow = { type Site = ListSitesResponse["sites"][0]; type ClientResourcesTableProps = { - resources: ResourceRow[]; internalResources: InternalResourceRow[]; orgId: string; - defaultView?: "proxy" | "internal"; defaultSort?: { id: string; desc: boolean; @@ -271,180 +251,60 @@ const setStoredColumnVisibility = ( }; export default function ClientResourcesTable({ - resources, internalResources, orgId, - defaultView = "proxy", defaultSort }: ClientResourcesTableProps) { const router = useRouter(); - const searchParams = useSearchParams(); const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); - const [proxyPageSize, setProxyPageSize] = useState(() => - getStoredPageSize("proxy-resources", 20) - ); const [internalPageSize, setInternalPageSize] = useState(() => getStoredPageSize("internal-resources", 20) ); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedResource, setSelectedResource] = - useState(); + const [selectedInternalResource, setSelectedInternalResource] = useState(); const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); const [editingResource, setEditingResource] = useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const [sites, setSites] = useState([]); - const [proxySorting, setProxySorting] = useState( - defaultSort ? [defaultSort] : [] + const { data: sites = [] } = useQuery( + siteQueries.listPerOrg({ orgId, api }) ); - const [proxyColumnFilters, setProxyColumnFilters] = - useState([]); - const [proxyGlobalFilter, setProxyGlobalFilter] = useState([]); - const [internalSorting, setInternalSorting] = useState( defaultSort ? [defaultSort] : [] ); const [internalColumnFilters, setInternalColumnFilters] = useState([]); const [internalGlobalFilter, setInternalGlobalFilter] = useState([]); - const [isRefreshing, setIsRefreshing] = useState(false); - const [proxyColumnVisibility, setProxyColumnVisibility] = - useState(() => - getStoredColumnVisibility("proxy-resources", {}) - ); + const [isRefreshing, startTransition] = useTransition(); + const [internalColumnVisibility, setInternalColumnVisibility] = useState(() => getStoredColumnVisibility("internal-resources", {}) ); - 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(); + console.log("Data refreshed"); } catch (error) { toast({ title: t("error"), description: t("refreshError"), variant: "destructive" }); - } finally { - setIsRefreshing(false); } }; - useEffect(() => { - const fetchSites = async () => { - try { - const res = await api.get>( - `/org/${orgId}/sites` - ); - setSites(res.data.data.sites); - } catch (error) { - console.error("Failed to fetch sites:", error); - } - }; - - if (orgId) { - fetchSites(); - } - }, [orgId]); - - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams); - if (value === "internal") { - params.set("view", "internal"); - } else { - params.delete("view"); - } - - const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; - router.replace(newUrl, { scroll: false }); - }; - - const getSearchInput = () => { - if (currentView === "internal") { - return ( -
- - internalTable.setGlobalFilter( - String(e.target.value) - ) - } - className="w-full pl-8" - /> - -
- ); - } - return ( -
- - proxyTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - }; - - const getActionButton = () => { - if (currentView === "internal") { - return ( - - ); - } - return ( - - ); - }; - - const deleteResource = (resourceId: number) => { - api.delete(`/resource/${resourceId}`) - .catch((e) => { - console.error(t("resourceErrorDelte"), e); - toast({ - variant: "destructive", - title: t("resourceErrorDelte"), - description: formatAxiosError(e, t("resourceErrorDelte")) - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); - }; - const deleteInternalResource = async ( resourceId: number, siteId: number @@ -465,417 +325,6 @@ export default function ClientResourcesTable({ } }; - async function toggleResourceEnabled(val: boolean, resourceId: number) { - const res = await api - .post>( - `resource/${resourceId}`, - { - enabled: val - } - ) - .catch((e) => { - toast({ - variant: "destructive", - title: t("resourcesErrorUpdate"), - description: formatAxiosError( - e, - t("resourcesErrorUpdateDescription") - ) - }); - }); - } - - function TargetStatusCell({ targets }: { targets?: TargetHealth[] }) { - const overallStatus = getOverallHealthStatus(targets); - - if (!targets || targets.length === 0) { - return ( -
- - - {t("resourcesTableNoTargets")} - -
- ); - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - const unknownTargets = targets.filter( - (t) => !t.enabled || !t.healthStatus || t.healthStatus === "unknown" - ); - - return ( - - - - - - {monitoredTargets.length > 0 && ( - <> - {monitoredTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {target.healthStatus} - -
- ))} - - )} - {unknownTargets.length > 0 && ( - <> - {unknownTargets.map((target) => ( - -
- - {`${target.ip}:${target.port}`} -
- - {!target.enabled - ? t("disabled") - : t("resourcesTableNotMonitored")} - -
- ))} - - )} -
-
- ); - } - - const proxyColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: t("name"), - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "nice", - friendlyName: t("resource"), - enableHiding: true, - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "protocol", - friendlyName: t("protocol"), - header: () => {t("protocol")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ( - - {resourceRow.http - ? resourceRow.ssl - ? "HTTPS" - : "HTTP" - : resourceRow.protocol.toUpperCase()} - - ); - } - }, - { - id: "status", - accessorKey: "status", - friendlyName: t("status"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ; - }, - sortingFn: (rowA, rowB) => { - const statusA = getOverallHealthStatus(rowA.original.targets); - const statusB = getOverallHealthStatus(rowB.original.targets); - const statusOrder = { - online: 3, - degraded: 2, - offline: 1, - unknown: 0 - }; - return statusOrder[statusA] - statusOrder[statusB]; - } - }, - { - accessorKey: "domain", - friendlyName: t("access"), - header: () => {t("access")}, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {!resourceRow.http ? ( - - ) : !resourceRow.domainId ? ( - - ) : ( - - )} -
- ); - } - }, - { - accessorKey: "authState", - friendlyName: t("authentication"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- {resourceRow.authState === "protected" ? ( - - - {t("protected")} - - ) : resourceRow.authState === "not_protected" ? ( - - - {t("notProtected")} - - ) : ( - - - )} -
- ); - } - }, - { - accessorKey: "enabled", - friendlyName: t("enabled"), - header: () => {t("enabled")}, - cell: ({ row }) => ( - - toggleResourceEnabled(val, row.original.id) - } - /> - ) - }, - { - id: "actions", - enableHiding: false, - header: ({ table }) => { - const hasHideableColumns = table - .getAllColumns() - .some((column) => column.getCanHide()); - if (!hasHideableColumns) { - return ; - } - return ( -
- - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - const columnDef = - column.columnDef as any; - const friendlyName = - columnDef.friendlyName; - const displayName = - friendlyName || - (typeof columnDef.header === - "string" - ? columnDef.header - : column.id); - return ( - - column.toggleVisibility( - !!value - ) - } - onSelect={(e) => - e.preventDefault() - } - > - {displayName} - - ); - })} - - -
- ); - }, - cell: ({ row }) => { - const resourceRow = row.original; - return ( -
- - - - - - - - {t("viewSettings")} - - - { - setSelectedResource(resourceRow); - setIsDeleteModalOpen(true); - }} - > - - {t("delete")} - - - - - - - -
- ); - } - } - ]; - const internalColumns: ExtendedColumnDef[] = [ { accessorKey: "name", @@ -1095,32 +544,6 @@ export default function ClientResourcesTable({ } ]; - const proxyTable = useReactTable({ - data: resources, - columns: proxyColumns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setProxySorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setProxyColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setProxyGlobalFilter, - onColumnVisibilityChange: setProxyColumnVisibility, - initialState: { - pagination: { - pageSize: proxyPageSize, - pageIndex: 0 - }, - columnVisibility: proxyColumnVisibility - }, - state: { - sorting: proxySorting, - columnFilters: proxyColumnFilters, - globalFilter: proxyGlobalFilter, - columnVisibility: proxyColumnVisibility - } - }); - const internalTable = useReactTable({ data: internalResources, columns: internalColumns, @@ -1147,21 +570,12 @@ export default function ClientResourcesTable({ } }); - const handleProxyPageSizeChange = (newPageSize: number) => { - setProxyPageSize(newPageSize); - setStoredPageSize(newPageSize, "proxy-resources"); - }; - const handleInternalPageSizeChange = (newPageSize: number) => { setInternalPageSize(newPageSize); setStoredPageSize(newPageSize, "internal-resources"); }; // Persist column visibility changes to localStorage - useEffect(() => { - setStoredColumnVisibility(proxyColumnVisibility, "proxy-resources"); - }, [proxyColumnVisibility]); - useEffect(() => { setStoredColumnVisibility( internalColumnVisibility, @@ -1171,26 +585,6 @@ export default function ClientResourcesTable({ return ( <> - {selectedResource && ( - { - setIsDeleteModalOpen(val); - setSelectedResource(null); - }} - dialog={ -
-

{t("resourceQuestionRemove")}

-

{t("resourceMessageRemove")}

-
- } - buttonText={t("resourceDeleteConfirm")} - onConfirm={async () => deleteResource(selectedResource!.id)} - string={selectedResource.name} - title={t("resourceDelete")} - /> - )} - {selectedInternalResource && ( - - -
- {getSearchInput()} - - {env.flags.enableClients && ( - - - {t("resourcesTableProxyResources")} - - - {t("resourcesTableClientResources")} - - - )} + +
+
+ + internalTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> +
-
-
- -
-
{getActionButton()}
+
+
+
+
- - - -
- - - {proxyTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - + {" "} + + + + + +
+
+ + {internalTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {proxyTable.getRowModel().rows - ?.length ? ( - proxyTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - + ))} + + ))} + + + {internalTable.getRowModel().rows + ?.length ? ( + internalTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t( - "resourcesTableNoProxyResourcesFound" - )} - + "name" + ? "md:sticky md:left-0 z-10 bg-card" + : "" + }`} + > + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} - )} - -
-
-
- -
-
- -
- - - {internalTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {internalTable.getRowModel().rows - ?.length ? ( - internalTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t( - "resourcesTableNoInternalResourcesFound" - )} - - - )} - -
-
-
- -
-
-
- + )) + ) : ( + + + {t( + "resourcesTableNoInternalResourcesFound" + )} + + + )} + + +
+
+ +
+
diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 3ddf32bf..da6f435c 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -3,6 +3,8 @@ import { durationToMs } from "./durationToMs"; import { build } from "@server/build"; import { remote } from "./api"; import type ResponseT from "@server/types/Response"; +import type { ListSitesResponse } from "@server/routers/site"; +import type { AxiosInstance, AxiosResponse } from "axios"; export type ProductUpdate = { link: string | null; @@ -65,3 +67,16 @@ export const productUpdatesQueries = { // because we don't need to listen for new versions there }) }; + +export const siteQueries = { + listPerOrg: ({ orgId, api }: { orgId: string; api: AxiosInstance }) => + queryOptions({ + queryKey: ["SITE_PER_ORG", orgId] as const, + queryFn: async ({ signal }) => { + const res = await api.get>( + `/org/${orgId}/sites` + ); + return res.data.data.sites; + } + }) +}; From 342bedc01209a78aa920cf54bda24f768c967045 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 02:40:50 +0100 Subject: [PATCH 03/12] =?UTF-8?q?=F0=9F=8E=A8=20format=20with=20prettier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CreateInternalResourceDialog.tsx | 425 ++++++++++++----- src/components/EditInternalResourceDialog.tsx | 444 +++++++++++++----- 2 files changed, 627 insertions(+), 242 deletions(-) diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 16179f8a..44ed12a5 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -89,7 +89,9 @@ export default function CreateInternalResourceDialog({ // mode: z.enum(["host", "cidr", "port"]), mode: z.enum(["host", "cidr"]), destination: z.string().min(1), - siteId: z.int().positive(t("createInternalResourceDialogPleaseSelectSite")), + siteId: z + .int() + .positive(t("createInternalResourceDialogPleaseSelectSite")), // protocol: z.enum(["tcp", "udp"]), // proxyPort: z.int() // .positive() @@ -101,25 +103,31 @@ export default function CreateInternalResourceDialog({ // .max(65535, t("createInternalResourceDialogDestinationPortMax")) // .nullish(), alias: z.string().nullish(), - roles: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional(), - users: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional(), - clients: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional() - }) + roles: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional(), + users: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional(), + clients: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional() + }); // .refine( // (data) => { // if (data.mode === "port") { @@ -159,12 +167,24 @@ export default function CreateInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]); - const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState(null); + const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( + [] + ); + const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( + [] + ); + const [allClients, setAllClients] = useState< + { id: string; text: string }[] + >([]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< + number | null + >(null); const [hasMachineClients, setHasMachineClients] = useState(false); const availableSites = sites.filter( @@ -211,11 +231,18 @@ export default function CreateInternalResourceDialog({ useEffect(() => { const fetchRolesUsersAndClients = async () => { try { - const [rolesResponse, usersResponse, clientsResponse] = await Promise.all([ - api.get>(`/org/${orgId}/roles`), - api.get>(`/org/${orgId}/users`), - api.get>(`/org/${orgId}/clients?filter=machine&limit=1000`) - ]); + const [rolesResponse, usersResponse, clientsResponse] = + await Promise.all([ + api.get>( + `/org/${orgId}/roles` + ), + api.get>( + `/org/${orgId}/users` + ), + api.get>( + `/org/${orgId}/clients?filter=machine&limit=1000` + ) + ]); setAllRoles( rolesResponse.data.data.roles @@ -243,7 +270,10 @@ export default function CreateInternalResourceDialog({ setAllClients(machineClients); setHasMachineClients(machineClients.length > 0); } catch (error) { - console.error("Error fetching roles, users, and clients:", error); + console.error( + "Error fetching roles, users, and clients:", + error + ); } }; @@ -265,10 +295,19 @@ export default function CreateInternalResourceDialog({ // destinationPort: data.mode === "port" ? data.destinationPort : undefined, destination: data.destination, enabled: true, - alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : undefined, - roleIds: data.roles ? data.roles.map((r) => parseInt(r.id)) : [], + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : undefined, + roleIds: data.roles + ? data.roles.map((r) => parseInt(r.id)) + : [], userIds: data.users ? data.users.map((u) => u.id) : [], - clientIds: data.clients ? data.clients.map((c) => parseInt(c.id)) : [] + clientIds: data.clients + ? data.clients.map((c) => parseInt(c.id)) + : [] } ); @@ -295,7 +334,9 @@ export default function CreateInternalResourceDialog({ toast({ title: t("createInternalResourceDialogSuccess"), - description: t("createInternalResourceDialogInternalResourceCreatedSuccessfully"), + description: t( + "createInternalResourceDialogInternalResourceCreatedSuccessfully" + ), variant: "default" }); @@ -307,7 +348,9 @@ export default function CreateInternalResourceDialog({ title: t("createInternalResourceDialogError"), description: formatAxiosError( error, - t("createInternalResourceDialogFailedToCreateInternalResource") + t( + "createInternalResourceDialogFailedToCreateInternalResource" + ) ), variant: "destructive" }); @@ -321,13 +364,19 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogNoSitesAvailable")} + + {t("createInternalResourceDialogNoSitesAvailable")} + - {t("createInternalResourceDialogNoSitesAvailableDescription")} + {t( + "createInternalResourceDialogNoSitesAvailableDescription" + )} - + @@ -338,9 +387,13 @@ export default function CreateInternalResourceDialog({ - {t("createInternalResourceDialogCreateClientResource")} + + {t("createInternalResourceDialogCreateClientResource")} + - {t("createInternalResourceDialogCreateClientResourceDescription")} + {t( + "createInternalResourceDialogCreateClientResourceDescription" + )} @@ -353,7 +406,9 @@ export default function CreateInternalResourceDialog({ {/* Resource Properties Form */}

- {t("createInternalResourceDialogResourceProperties")} + {t( + "createInternalResourceDialogResourceProperties" + )}

( - {t("createInternalResourceDialogName")} + + {t( + "createInternalResourceDialogName" + )} + @@ -375,7 +434,11 @@ export default function CreateInternalResourceDialog({ name="siteId" render={({ field }) => ( - {t("createInternalResourceDialogSite")} + + {t( + "createInternalResourceDialogSite" + )} + @@ -384,43 +447,71 @@ export default function CreateInternalResourceDialog({ role="combobox" className={cn( "w-full justify-between", - !field.value && "text-muted-foreground" + !field.value && + "text-muted-foreground" )} > {field.value ? availableSites.find( - (site) => site.siteId === field.value + ( + site + ) => + site.siteId === + field.value )?.name - : t("createInternalResourceDialogSelectSite")} + : t( + "createInternalResourceDialogSelectSite" + )} - + - {t("createInternalResourceDialogNoSitesFound")} + + {t( + "createInternalResourceDialogNoSitesFound" + )} + - {availableSites.map((site) => ( - { - field.onChange(site.siteId); - }} - > - - {site.name} - - ))} + {availableSites.map( + ( + site + ) => ( + { + field.onChange( + site.siteId + ); + }} + > + + { + site.name + } + + ) + )} @@ -431,14 +522,20 @@ export default function CreateInternalResourceDialog({ )} /> - ( - {t("createInternalResourceDialogMode")} + + {t( + "createInternalResourceDialogMode" + )} + )} /> -{/* + {/* {mode === "port" && ( <>
@@ -521,7 +626,9 @@ export default function CreateInternalResourceDialog({ {/* Target Configuration Form */}

- {t("createInternalResourceDialogTargetConfiguration")} + {t( + "createInternalResourceDialogTargetConfiguration" + )}

( - {t("createInternalResourceDialogDestination")} + {t( + "createInternalResourceDialogDestination" + )} - {mode === "host" && t("createInternalResourceDialogDestinationHostDescription")} - {mode === "cidr" && t("createInternalResourceDialogDestinationCidrDescription")} + {mode === "host" && + t( + "createInternalResourceDialogDestinationHostDescription" + )} + {mode === "cidr" && + t( + "createInternalResourceDialogDestinationCidrDescription" + )} {/* {mode === "port" && t("createInternalResourceDialogDestinationIPDescription")} */} @@ -584,12 +699,23 @@ export default function CreateInternalResourceDialog({ name="alias" render={({ field }) => ( - {t("createInternalResourceDialogAlias")} + + {t( + "createInternalResourceDialogAlias" + )} + - + - {t("createInternalResourceDialogAliasDescription")} + {t( + "createInternalResourceDialogAliasDescription" + )} @@ -609,31 +735,53 @@ export default function CreateInternalResourceDialog({ name="roles" render={({ field }) => ( - {t("roles")} + + {t("roles")} + { form.setValue( "roles", - newRoles as [Tag, ...Tag[]] + newRoles as [ + Tag, + ...Tag[] + ] ); }} - enableAutocomplete={true} - autocompleteOptions={allRoles} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} /> - {t("resourceRoleDescription")} + {t( + "resourceRoleDescription" + )} )} @@ -643,25 +791,45 @@ export default function CreateInternalResourceDialog({ name="users" render={({ field }) => ( - {t("users")} + + {t("users")} + { form.setValue( "users", - newUsers as [Tag, ...Tag[]] + newUsers as [ + Tag, + ...Tag[] + ] ); }} - enableAutocomplete={true} - autocompleteOptions={allUsers} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} /> @@ -675,31 +843,62 @@ export default function CreateInternalResourceDialog({ name="clients" render={({ field }) => ( - {t("clients")} + + {t("clients")} + { + tags={ + form.getValues() + .clients || + [] + } + setTags={( + newClients + ) => { form.setValue( "clients", - newClients as [Tag, ...Tag[]] + newClients as [ + Tag, + ...Tag[] + ] ); }} - enableAutocomplete={true} - autocompleteOptions={allClients} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} /> - {t("resourceClientDescription") || "Machine clients that can access this resource"} + {t( + "resourceClientDescription" + ) || + "Machine clients that can access this resource"} )} diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index b575dc61..2eea4bb0 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -81,32 +81,41 @@ export default function EditInternalResourceDialog({ const [isSubmitting, setIsSubmitting] = useState(false); const formSchema = z.object({ - name: z.string().min(1, t("editInternalResourceDialogNameRequired")).max(255, t("editInternalResourceDialogNameMaxLength")), + name: z + .string() + .min(1, t("editInternalResourceDialogNameRequired")) + .max(255, t("editInternalResourceDialogNameMaxLength")), mode: z.enum(["host", "cidr", "port"]), // protocol: z.enum(["tcp", "udp"]).nullish(), // proxyPort: z.int().positive().min(1, t("editInternalResourceDialogProxyPortMin")).max(65535, t("editInternalResourceDialogProxyPortMax")).nullish(), destination: z.string().min(1), // destinationPort: z.int().positive().min(1, t("editInternalResourceDialogDestinationPortMin")).max(65535, t("editInternalResourceDialogDestinationPortMax")).nullish(), alias: z.string().nullish(), - roles: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional(), - users: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional(), - clients: z.array( - z.object({ - id: z.string(), - text: z.string() - }) - ).optional() - }) + roles: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional(), + users: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional(), + clients: z + .array( + z.object({ + id: z.string(), + text: z.string() + }) + ) + .optional() + }); // .refine( // (data) => { // if (data.mode === "port") { @@ -146,12 +155,24 @@ export default function EditInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>([]); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>([]); - const [allClients, setAllClients] = useState<{ id: string; text: string }[]>([]); - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState(null); + const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( + [] + ); + const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( + [] + ); + const [allClients, setAllClients] = useState< + { id: string; text: string }[] + >([]); + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< + number | null + >(null); + const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< + number | null + >(null); + const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< + number | null + >(null); const [loadingRolesUsers, setLoadingRolesUsers] = useState(false); const [hasMachineClients, setHasMachineClients] = useState(false); @@ -183,22 +204,30 @@ export default function EditInternalResourceDialog({ resourceUsersResponse, clientsResponse ] = await Promise.all([ - api.get>(`/org/${orgId}/roles`), + api.get>( + `/org/${orgId}/roles` + ), api.get>( `/site-resource/${resource.id}/roles` ), - api.get>(`/org/${orgId}/users`), + api.get>( + `/org/${orgId}/users` + ), api.get>( `/site-resource/${resource.id}/users` ), - api.get>(`/org/${orgId}/clients?filter=machine&limit=1000`) + api.get>( + `/org/${orgId}/clients?filter=machine&limit=1000` + ) ]); - let resourceClientsResponse: AxiosResponse>; + let resourceClientsResponse: AxiosResponse< + AxiosResponse + >; try { - resourceClientsResponse = await api.get>( - `/site-resource/${resource.id}/clients` - ); + resourceClientsResponse = await api.get< + AxiosResponse + >(`/site-resource/${resource.id}/clients`); } catch { resourceClientsResponse = { data: { @@ -255,16 +284,21 @@ export default function EditInternalResourceDialog({ })); setAllClients(machineClients); - - const existingClients = resourceClientsResponse.data.data.clients.map((c: { clientId: number; name: string }) => ({ - id: c.clientId.toString(), - text: c.name - })); + + const existingClients = + resourceClientsResponse.data.data.clients.map( + (c: { clientId: number; name: string }) => ({ + id: c.clientId.toString(), + text: c.name + }) + ); form.setValue("clients", existingClients); // Show clients tag input if there are machine clients OR existing client access - setHasMachineClients(machineClients.length > 0 || existingClients.length > 0); + setHasMachineClients( + machineClients.length > 0 || existingClients.length > 0 + ); } catch (error) { console.error("Error fetching roles, users, and clients:", error); } finally { @@ -295,18 +329,26 @@ export default function EditInternalResourceDialog({ setIsSubmitting(true); try { // Update the site resource - await api.post(`/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, { - name: data.name, - mode: data.mode, - // protocol: data.mode === "port" ? data.protocol : null, - // proxyPort: data.mode === "port" ? data.proxyPort : null, - // destinationPort: data.mode === "port" ? data.destinationPort : null, - destination: data.destination, - alias: data.alias && typeof data.alias === "string" && data.alias.trim() ? data.alias : null, - roleIds: (data.roles || []).map((r) => parseInt(r.id)), - userIds: (data.users || []).map((u) => u.id), - clientIds: (data.clients || []).map((c) => parseInt(c.id)) - }); + await api.post( + `/org/${orgId}/site/${resource.siteId}/resource/${resource.id}`, + { + name: data.name, + mode: data.mode, + // protocol: data.mode === "port" ? data.protocol : null, + // proxyPort: data.mode === "port" ? data.proxyPort : null, + // destinationPort: data.mode === "port" ? data.destinationPort : null, + destination: data.destination, + alias: + data.alias && + typeof data.alias === "string" && + data.alias.trim() + ? data.alias + : null, + roleIds: (data.roles || []).map((r) => parseInt(r.id)), + userIds: (data.users || []).map((u) => u.id), + clientIds: (data.clients || []).map((c) => parseInt(c.id)) + } + ); // Update roles, users, and clients // await Promise.all([ @@ -323,7 +365,9 @@ export default function EditInternalResourceDialog({ toast({ title: t("editInternalResourceDialogSuccess"), - description: t("editInternalResourceDialogInternalResourceUpdatedSuccessfully"), + description: t( + "editInternalResourceDialogInternalResourceUpdatedSuccessfully" + ), variant: "default" }); @@ -333,7 +377,12 @@ export default function EditInternalResourceDialog({ console.error("Error updating internal resource:", error); toast({ title: t("editInternalResourceDialogError"), - description: formatAxiosError(error, t("editInternalResourceDialogFailedToUpdateInternalResource")), + description: formatAxiosError( + error, + t( + "editInternalResourceDialogFailedToUpdateInternalResource" + ) + ), variant: "destructive" }); } finally { @@ -345,24 +394,41 @@ export default function EditInternalResourceDialog({ - {t("editInternalResourceDialogEditClientResource")} + + {t("editInternalResourceDialogEditClientResource")} + - {t("editInternalResourceDialogUpdateResourceProperties", { resourceName: resource.name })} + {t( + "editInternalResourceDialogUpdateResourceProperties", + { resourceName: resource.name } + )}
- + {/* Resource Properties Form */}
-

{t("editInternalResourceDialogResourceProperties")}

+

+ {t( + "editInternalResourceDialogResourceProperties" + )} +

( - {t("editInternalResourceDialogName")} + + {t( + "editInternalResourceDialogName" + )} + @@ -376,9 +442,15 @@ export default function EditInternalResourceDialog({ name="mode" render={({ field }) => ( - {t("editInternalResourceDialogMode")} + + {t( + "editInternalResourceDialogMode" + )} + @@ -448,20 +528,34 @@ export default function EditInternalResourceDialog({ {/* Target Configuration Form */}
-

{t("editInternalResourceDialogTargetConfiguration")}

+

+ {t( + "editInternalResourceDialogTargetConfiguration" + )} +

( - {t("editInternalResourceDialogDestination")} + + {t( + "editInternalResourceDialogDestination" + )} + - {mode === "host" && t("editInternalResourceDialogDestinationHostDescription")} - {mode === "cidr" && t("editInternalResourceDialogDestinationCidrDescription")} + {mode === "host" && + t( + "editInternalResourceDialogDestinationHostDescription" + )} + {mode === "cidr" && + t( + "editInternalResourceDialogDestinationCidrDescription" + )} {/* {mode === "port" && t("editInternalResourceDialogDestinationIPDescription")} */} @@ -499,12 +593,23 @@ export default function EditInternalResourceDialog({ name="alias" render={({ field }) => ( - {t("editInternalResourceDialogAlias")} + + {t( + "editInternalResourceDialogAlias" + )} + - + - {t("editInternalResourceDialogAliasDescription")} + {t( + "editInternalResourceDialogAliasDescription" + )} @@ -529,31 +634,57 @@ export default function EditInternalResourceDialog({ name="roles" render={({ field }) => ( - {t("roles")} + + {t("roles")} + { + tags={ + form.getValues() + .roles || [] + } + setTags={( + newRoles + ) => { form.setValue( "roles", - newRoles as [Tag, ...Tag[]] + newRoles as [ + Tag, + ...Tag[] + ] ); }} - enableAutocomplete={true} - autocompleteOptions={allRoles} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} + enableAutocomplete={ + true + } + autocompleteOptions={ + allRoles + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} /> - {t("resourceRoleDescription")} + {t( + "resourceRoleDescription" + )} )} @@ -563,25 +694,49 @@ export default function EditInternalResourceDialog({ name="users" render={({ field }) => ( - {t("users")} + + {t("users")} + { + setTags={( + newUsers + ) => { form.setValue( "users", - newUsers as [Tag, ...Tag[]] + newUsers as [ + Tag, + ...Tag[] + ] ); }} - enableAutocomplete={true} - autocompleteOptions={allUsers} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} + enableAutocomplete={ + true + } + autocompleteOptions={ + allUsers + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } sortTags={true} /> @@ -589,42 +744,73 @@ export default function EditInternalResourceDialog({ )} /> - {hasMachineClients && ( - ( - - {t("clients")} - - { - form.setValue( - "clients", - newClients as [Tag, ...Tag[]] - ); - }} - enableAutocomplete={true} - autocompleteOptions={allClients} - allowDuplicates={false} - restrictTagsToAutocompleteOptions={true} - sortTags={true} - /> - - - - {t("resourceClientDescription") || "Machine clients that can access this resource"} - - - )} - /> - )} + {hasMachineClients && ( + ( + + + {t("clients")} + + + { + form.setValue( + "clients", + newClients as [ + Tag, + ...Tag[] + ] + ); + }} + enableAutocomplete={ + true + } + autocompleteOptions={ + allClients + } + allowDuplicates={ + false + } + restrictTagsToAutocompleteOptions={ + true + } + sortTags={true} + /> + + + + {t( + "resourceClientDescription" + ) || + "Machine clients that can access this resource"} + + + )} + /> + )}
)}
@@ -645,7 +831,7 @@ export default function EditInternalResourceDialog({ disabled={isSubmitting} loading={isSubmitting} > - {t("editInternalResourceDialogSaveResource")} + {t("editInternalResourceDialogSaveResource")} From 45a82f3ecc7407fbdf769ac53af3cdddca7133a3 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 03:14:02 +0100 Subject: [PATCH 04/12] =?UTF-8?q?=F0=9F=9A=A7WIP:=20Separate=20user=20&=20?= =?UTF-8?q?machine=20clients?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 + .../[orgId]/settings/clients/machine/page.tsx | 92 +++++++++++++++++++ src/app/[orgId]/settings/clients/page.tsx | 70 +------------- .../[orgId]/settings/clients/user/page.tsx | 92 +++++++++++++++++++ src/app/navigation.tsx | 20 +++- 5 files changed, 205 insertions(+), 71 deletions(-) create mode 100644 src/app/[orgId]/settings/clients/machine/page.tsx create mode 100644 src/app/[orgId]/settings/clients/user/page.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 0aa56ac5..279da0e3 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1166,6 +1166,8 @@ "sidebarIdentityProviders": "Identity Providers", "sidebarLicense": "License", "sidebarClients": "Clients", + "sidebarUserDevices": "User devices", + "sidebarMachineClients": "Machine Clients", "sidebarDomains": "Domains", "sidebarBluePrints": "Blueprints", "blueprints": "Blueprints", diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx new file mode 100644 index 00000000..e0b4ebf7 --- /dev/null +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -0,0 +1,92 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import { getTranslations } from "next-intl/server"; +import type { ClientRow } from "@app/components/ClientsTable"; +import ClientsTable from "@app/components/ClientsTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const t = await getTranslations(); + + const params = await props.params; + const searchParams = await props.searchParams; + + // Default to 'user' view, or use the query param if provided + let defaultView: "user" | "machine" = "user"; + defaultView = searchParams.view === "machine" ? "machine" : "user"; + + let userClients: ListClientsResponse["clients"] = []; + let machineClients: ListClientsResponse["clients"] = []; + + try { + const [userRes, machineRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ), + internal.get>( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ) + ]); + userClients = userRes.data.data.clients; + machineClients = machineRes.data.data.clients; + } catch (e) {} + + function formatSize(mb: number): string { + if (mb >= 1024 * 1024) { + return `${(mb / (1024 * 1024)).toFixed(2)} TB`; + } else if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } else { + return `${mb.toFixed(2)} MB`; + } + } + + const mapClientToRow = ( + client: ListClientsResponse["clients"][0] + ): ClientRow => { + return { + name: client.name, + id: client.clientId, + subnet: client.subnet.split("/")[0], + mbIn: formatSize(client.megabytesIn || 0), + mbOut: formatSize(client.megabytesOut || 0), + orgId: params.orgId, + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, + userId: client.userId, + username: client.username, + userEmail: client.userEmail + }; + }; + + const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); + + return ( + <> + + + + + ); +} diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 7049306e..40bcc569 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -6,6 +6,7 @@ import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListClientsResponse } from "@server/routers/client"; import ClientsTable from "../../../../components/ClientsTable"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; type ClientsPageProps = { params: Promise<{ orgId: string }>; @@ -18,73 +19,6 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - const searchParams = await props.searchParams; - // Default to 'user' view, or use the query param if provided - let defaultView: "user" | "machine" = "user"; - defaultView = searchParams.view === "machine" ? "machine" : "user"; - - let userClients: ListClientsResponse["clients"] = []; - let machineClients: ListClientsResponse["clients"] = []; - - try { - const [userRes, machineRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ), - internal.get>( - `/org/${params.orgId}/clients?filter=machine`, - await authCookieHeader() - ) - ]); - userClients = userRes.data.data.clients; - machineClients = machineRes.data.data.clients; - } catch (e) {} - - function formatSize(mb: number): string { - if (mb >= 1024 * 1024) { - return `${(mb / (1024 * 1024)).toFixed(2)} TB`; - } else if (mb >= 1024) { - return `${(mb / 1024).toFixed(2)} GB`; - } else { - return `${mb.toFixed(2)} MB`; - } - } - - const mapClientToRow = (client: ListClientsResponse["clients"][0]): ClientRow => { - return { - name: client.name, - id: client.clientId, - subnet: client.subnet.split("/")[0], - mbIn: formatSize(client.megabytesIn || 0), - mbOut: formatSize(client.megabytesOut || 0), - orgId: params.orgId, - online: client.online, - olmVersion: client.olmVersion || undefined, - olmUpdateAvailable: client.olmUpdateAvailable || false, - userId: client.userId, - username: client.username, - userEmail: client.userEmail - }; - }; - - const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); - - return ( - <> - - - - - ); + redirect(`/${params.orgId}/settings/clients/user`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx new file mode 100644 index 00000000..e0b4ebf7 --- /dev/null +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -0,0 +1,92 @@ +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { AxiosResponse } from "axios"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; +import { ListClientsResponse } from "@server/routers/client"; +import { getTranslations } from "next-intl/server"; +import type { ClientRow } from "@app/components/ClientsTable"; +import ClientsTable from "@app/components/ClientsTable"; + +type ClientsPageProps = { + params: Promise<{ orgId: string }>; + searchParams: Promise<{ view?: string }>; +}; + +export const dynamic = "force-dynamic"; + +export default async function ClientsPage(props: ClientsPageProps) { + const t = await getTranslations(); + + const params = await props.params; + const searchParams = await props.searchParams; + + // Default to 'user' view, or use the query param if provided + let defaultView: "user" | "machine" = "user"; + defaultView = searchParams.view === "machine" ? "machine" : "user"; + + let userClients: ListClientsResponse["clients"] = []; + let machineClients: ListClientsResponse["clients"] = []; + + try { + const [userRes, machineRes] = await Promise.all([ + internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ), + internal.get>( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ) + ]); + userClients = userRes.data.data.clients; + machineClients = machineRes.data.data.clients; + } catch (e) {} + + function formatSize(mb: number): string { + if (mb >= 1024 * 1024) { + return `${(mb / (1024 * 1024)).toFixed(2)} TB`; + } else if (mb >= 1024) { + return `${(mb / 1024).toFixed(2)} GB`; + } else { + return `${mb.toFixed(2)} MB`; + } + } + + const mapClientToRow = ( + client: ListClientsResponse["clients"][0] + ): ClientRow => { + return { + name: client.name, + id: client.clientId, + subnet: client.subnet.split("/")[0], + mbIn: formatSize(client.megabytesIn || 0), + mbOut: formatSize(client.megabytesOut || 0), + orgId: params.orgId, + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, + userId: client.userId, + username: client.username, + userEmail: client.userEmail + }; + }; + + const userClientRows: ClientRow[] = userClients.map(mapClientToRow); + const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); + + return ( + <> + + + + + ); +} diff --git a/src/app/navigation.tsx b/src/app/navigation.tsx index ceefa8f0..a61ff4af 100644 --- a/src/app/navigation.tsx +++ b/src/app/navigation.tsx @@ -18,7 +18,8 @@ import { Logs, SquareMousePointer, ScanEye, - GlobeLock + GlobeLock, + Smartphone } from "lucide-react"; export type SidebarNavSection = { @@ -73,9 +74,22 @@ export const orgNavSections = ( ? [ { title: "sidebarClients", - href: "/{orgId}/settings/clients", icon: , - isBeta: true + isBeta: true, + items: [ + { + href: "/{orgId}/settings/clients/user", + title: "sidebarUserDevices", + icon: ( + + ) + }, + { + href: "/{orgId}/settings/clients/machine", + title: "sidebarMachineClients", + icon: + } + ] } ] : []), From 3d400b23217ac78f755e34d0327afbb7af2ff540 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 16:06:10 +0100 Subject: [PATCH 05/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20ignore=20hydrateSaas?= =?UTF-8?q?=20script=20and=20`exit(0)`=20on=20PG=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 ++- server/db/pg/migrate.ts | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 2fc6b10b..700963cc 100644 --- a/.gitignore +++ b/.gitignore @@ -49,4 +49,5 @@ postgres/ dynamic/ *.mmdb scratch/ -tsconfig.json \ No newline at end of file +tsconfig.json +hydrateSaas.ts \ No newline at end of file diff --git a/server/db/pg/migrate.ts b/server/db/pg/migrate.ts index 70b2ef54..8bbcceb7 100644 --- a/server/db/pg/migrate.ts +++ b/server/db/pg/migrate.ts @@ -11,6 +11,7 @@ const runMigrations = async () => { migrationsFolder: migrationsFolder }); console.log("Migrations completed successfully."); + process.exit(0); } catch (error) { console.error("Error running migrations:", error); process.exit(1); From 06a31bb7160378c82bedac588ae9ef125e1f1e95 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 18:58:51 +0100 Subject: [PATCH 06/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20separate=20machine?= =?UTF-8?q?=20client=20&=20user=20devices=20tables=20+=20move=20common=20f?= =?UTF-8?q?unctions=20into=20hooks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 6 +- .../[orgId]/settings/clients/machine/page.tsx | 40 +- src/app/[orgId]/settings/clients/page.tsx | 11 - .../[orgId]/settings/clients/user/page.tsx | 35 +- src/components/ClientResourcesTable.tsx | 57 +- src/components/ClientsTable.tsx | 985 ------------------ src/components/MachineClientsTable.tsx | 698 +++++++++++++ src/components/ProxyResourcesTable.tsx | 2 +- src/components/UserDevicesTable.tsx | 677 ++++++++++++ src/hooks/useLocalStorage.ts | 4 +- src/hooks/useStoredColumnVisibility.ts | 81 ++ src/hooks/useStoredPageSize.ts | 60 ++ 12 files changed, 1546 insertions(+), 1110 deletions(-) delete mode 100644 src/components/ClientsTable.tsx create mode 100644 src/components/MachineClientsTable.tsx create mode 100644 src/components/UserDevicesTable.tsx create mode 100644 src/hooks/useStoredColumnVisibility.ts create mode 100644 src/hooks/useStoredPageSize.ts diff --git a/messages/en-US.json b/messages/en-US.json index 279da0e3..6483b025 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1881,8 +1881,10 @@ "enterpriseEdition": "Enterprise Edition", "unlicensed": "Unlicensed", "beta": "Beta", - "manageClients": "Manage Clients", - "manageClientsDescription": "Clients are devices that can connect to your sites", + "manageUserDevices": "Manage User Devices", + "manageUserDevicesDescription": "View user devices (laptops, phones, tablets) that can access your sites through clients", + "manageMachineClients": "Manage Machine Clients", + "manageMachineClientsDescription": "Create and manage credentials for automated systems and infrastructure (CI/CD, VMs, VPCs) to securely access your sites", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", diff --git a/src/app/[orgId]/settings/clients/machine/page.tsx b/src/app/[orgId]/settings/clients/machine/page.tsx index e0b4ebf7..b450b09f 100644 --- a/src/app/[orgId]/settings/clients/machine/page.tsx +++ b/src/app/[orgId]/settings/clients/machine/page.tsx @@ -1,11 +1,11 @@ +import type { ClientRow } from "@app/components/MachineClientsTable"; +import MachineClientsTable from "@app/components/MachineClientsTable"; +import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { internal } from "@app/lib/api"; import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListClientsResponse } from "@server/routers/client"; +import { AxiosResponse } from "axios"; import { getTranslations } from "next-intl/server"; -import type { ClientRow } from "@app/components/ClientsTable"; -import ClientsTable from "@app/components/ClientsTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; @@ -18,27 +18,16 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - const searchParams = await props.searchParams; - // Default to 'user' view, or use the query param if provided - let defaultView: "user" | "machine" = "user"; - defaultView = searchParams.view === "machine" ? "machine" : "user"; - - let userClients: ListClientsResponse["clients"] = []; let machineClients: ListClientsResponse["clients"] = []; try { - const [userRes, machineRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ), - internal.get>( - `/org/${params.orgId}/clients?filter=machine`, - await authCookieHeader() - ) - ]); - userClients = userRes.data.data.clients; + const machineRes = await internal.get< + AxiosResponse + >( + `/org/${params.orgId}/clients?filter=machine`, + await authCookieHeader() + ); machineClients = machineRes.data.data.clients; } catch (e) {} @@ -71,21 +60,18 @@ export default async function ClientsPage(props: ClientsPageProps) { }; }; - const userClientRows: ClientRow[] = userClients.map(mapClientToRow); const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> - ); diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 40bcc569..aeea1c83 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -1,11 +1,3 @@ -import { internal } from "@app/lib/api"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { AxiosResponse } from "axios"; -import { ClientRow } from "../../../../components/ClientsTable"; -import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; -import { ListClientsResponse } from "@server/routers/client"; -import ClientsTable from "../../../../components/ClientsTable"; -import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; type ClientsPageProps = { @@ -16,9 +8,6 @@ type ClientsPageProps = { export const dynamic = "force-dynamic"; export default async function ClientsPage(props: ClientsPageProps) { - const t = await getTranslations(); - const params = await props.params; - redirect(`/${params.orgId}/settings/clients/user`); } diff --git a/src/app/[orgId]/settings/clients/user/page.tsx b/src/app/[orgId]/settings/clients/user/page.tsx index e0b4ebf7..399588fc 100644 --- a/src/app/[orgId]/settings/clients/user/page.tsx +++ b/src/app/[orgId]/settings/clients/user/page.tsx @@ -4,12 +4,11 @@ import { AxiosResponse } from "axios"; import SettingsSectionTitle from "@app/components/SettingsSectionTitle"; import { ListClientsResponse } from "@server/routers/client"; import { getTranslations } from "next-intl/server"; -import type { ClientRow } from "@app/components/ClientsTable"; -import ClientsTable from "@app/components/ClientsTable"; +import type { ClientRow } from "@app/components/MachineClientsTable"; +import UserDevicesTable from "@app/components/UserDevicesTable"; type ClientsPageProps = { params: Promise<{ orgId: string }>; - searchParams: Promise<{ view?: string }>; }; export const dynamic = "force-dynamic"; @@ -18,28 +17,15 @@ export default async function ClientsPage(props: ClientsPageProps) { const t = await getTranslations(); const params = await props.params; - const searchParams = await props.searchParams; - - // Default to 'user' view, or use the query param if provided - let defaultView: "user" | "machine" = "user"; - defaultView = searchParams.view === "machine" ? "machine" : "user"; let userClients: ListClientsResponse["clients"] = []; - let machineClients: ListClientsResponse["clients"] = []; try { - const [userRes, machineRes] = await Promise.all([ - internal.get>( - `/org/${params.orgId}/clients?filter=user`, - await authCookieHeader() - ), - internal.get>( - `/org/${params.orgId}/clients?filter=machine`, - await authCookieHeader() - ) - ]); + const userRes = await internal.get>( + `/org/${params.orgId}/clients?filter=user`, + await authCookieHeader() + ); userClients = userRes.data.data.clients; - machineClients = machineRes.data.data.clients; } catch (e) {} function formatSize(mb: number): string { @@ -72,20 +58,17 @@ export default async function ClientsPage(props: ClientsPageProps) { }; const userClientRows: ClientRow[] = userClients.map(mapClientToRow); - const machineClientRows: ClientRow[] = machineClients.map(mapClientToRow); return ( <> - ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 0d246fb3..39578a9b 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -87,59 +87,6 @@ export type ResourceRow = { targets?: TargetHealth[]; }; -function getOverallHealthStatus( - targets?: TargetHealth[] -): "online" | "degraded" | "offline" | "unknown" { - if (!targets || targets.length === 0) { - return "unknown"; - } - - const monitoredTargets = targets.filter( - (t) => t.enabled && t.healthStatus && t.healthStatus !== "unknown" - ); - - if (monitoredTargets.length === 0) { - return "unknown"; - } - - const healthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "healthy" - ).length; - const unhealthyCount = monitoredTargets.filter( - (t) => t.healthStatus === "unhealthy" - ).length; - - if (healthyCount === monitoredTargets.length) { - return "online"; - } else if (unhealthyCount === monitoredTargets.length) { - return "offline"; - } else { - return "degraded"; - } -} - -function StatusIcon({ - status, - className = "" -}: { - status: "online" | "degraded" | "offline" | "unknown"; - className?: string; -}) { - const iconClass = `h-4 w-4 ${className}`; - - switch (status) { - case "online": - return ; - case "degraded": - return ; - case "offline": - return ; - case "unknown": - return ; - default: - return null; - } -} export type InternalResourceRow = { id: number; name: string; @@ -157,8 +104,6 @@ export type InternalResourceRow = { alias: string | null; }; -type Site = ListSitesResponse["sites"][0]; - type ClientResourcesTableProps = { internalResources: InternalResourceRow[]; orgId: string; @@ -655,7 +600,7 @@ export default function ClientResourcesTable({
-
+
{internalTable diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx deleted file mode 100644 index 96f9430d..00000000 --- a/src/components/ClientsTable.tsx +++ /dev/null @@ -1,985 +0,0 @@ -"use client"; - -import { - ColumnDef, - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; -import { ExtendedColumnDef } from "@app/components/ui/data-table"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuCheckboxItem, - DropdownMenuLabel, - DropdownMenuSeparator -} from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - ArrowUpRight, - Check, - MoreHorizontal, - X, - RefreshCw, - Columns, - Search, - Plus -} from "lucide-react"; -import Link from "next/link"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useState, useEffect, useMemo } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -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"; -import { Badge } from "./ui/badge"; -import { InfoPopup } from "./ui/info-popup"; -import { Input } from "@app/components/ui/input"; -import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow -} from "@app/components/ui/table"; -import { - Tabs, - TabsContent, - TabsList, - TabsTrigger -} from "@app/components/ui/tabs"; -import { Alert, AlertDescription } from "@app/components/ui/alert"; - -export type ClientRow = { - id: number; - name: string; - subnet: string; - // siteIds: string; - mbIn: string; - mbOut: string; - orgId: string; - online: boolean; - olmVersion?: string; - olmUpdateAvailable: boolean; - userId: string | null; - username: string | null; - userEmail: string | null; -}; - -type ClientTableProps = { - userClients: ClientRow[]; - machineClients: ClientRow[]; - orgId: string; - defaultView?: "user" | "machine"; -}; - -const STORAGE_KEYS = { - PAGE_SIZE: "datatable-page-size", - COLUMN_VISIBILITY: "datatable-column-visibility", - getTablePageSize: (tableId?: string) => - tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE, - getTableColumnVisibility: (tableId?: string) => - tableId - ? `datatable-${tableId}-column-visibility` - : STORAGE_KEYS.COLUMN_VISIBILITY -}; - -const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { - if (typeof window === "undefined") return defaultSize; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) { - return parsed; - } - } - } catch (error) { - console.warn("Failed to read page size from localStorage:", error); - } - return defaultSize; -}; - -const setStoredPageSize = (pageSize: number, tableId?: string): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - localStorage.setItem(key, pageSize.toString()); - } catch (error) { - console.warn("Failed to save page size to localStorage:", error); - } -}; - -const getStoredColumnVisibility = ( - tableId?: string, - defaultVisibility?: Record -): Record => { - if (typeof window === "undefined") return defaultVisibility || {}; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = JSON.parse(stored); - // Validate that it's an object - if (typeof parsed === "object" && parsed !== null) { - return parsed; - } - } - } catch (error) { - console.warn( - "Failed to read column visibility from localStorage:", - error - ); - } - return defaultVisibility || {}; -}; - -const setStoredColumnVisibility = ( - visibility: Record, - tableId?: string -): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - localStorage.setItem(key, JSON.stringify(visibility)); - } catch (error) { - console.warn( - "Failed to save column visibility to localStorage:", - error - ); - } -}; - -export default function ClientsTable({ - userClients, - machineClients, - orgId, - defaultView = "user" -}: ClientTableProps) { - const router = useRouter(); - const searchParams = useSearchParams(); - const t = useTranslations(); - - const [userPageSize, setUserPageSize] = useState(() => - getStoredPageSize("user-clients", 20) - ); - const [machinePageSize, setMachinePageSize] = useState(() => - getStoredPageSize("machine-clients", 20) - ); - - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - const [selectedClient, setSelectedClient] = useState( - null - ); - - const api = createApiClient(useEnvContext()); - const [isRefreshing, setIsRefreshing] = useState(false); - - const [userSorting, setUserSorting] = useState([]); - const [userColumnFilters, setUserColumnFilters] = - useState([]); - const [userGlobalFilter, setUserGlobalFilter] = useState([]); - - const [machineSorting, setMachineSorting] = useState([]); - const [machineColumnFilters, setMachineColumnFilters] = - useState([]); - const [machineGlobalFilter, setMachineGlobalFilter] = useState([]); - - const defaultUserColumnVisibility = { - client: false, - subnet: false - }; - const defaultMachineColumnVisibility = { - client: false, - subnet: false, - userId: false - }; - - const [userColumnVisibility, setUserColumnVisibility] = - useState(() => - getStoredColumnVisibility( - "user-clients", - defaultUserColumnVisibility - ) - ); - const [machineColumnVisibility, setMachineColumnVisibility] = - useState(() => - getStoredColumnVisibility( - "machine-clients", - defaultMachineColumnVisibility - ) - ); - - 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); - } - }; - - const handleTabChange = (value: string) => { - const params = new URLSearchParams(searchParams); - if (value === "machine") { - params.set("view", "machine"); - } else { - params.delete("view"); - } - - const newUrl = `${window.location.pathname}${params.toString() ? "?" + params.toString() : ""}`; - router.replace(newUrl, { scroll: false }); - }; - - const deleteClient = (clientId: number) => { - api.delete(`/client/${clientId}`) - .catch((e) => { - console.error("Error deleting client", e); - toast({ - variant: "destructive", - title: "Error deleting client", - description: formatAxiosError(e, "Error deleting client") - }); - }) - .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); - }); - }; - - const getSearchInput = () => { - if (currentView === "machine") { - return ( -
- - machineTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - } - return ( -
- - userTable.setGlobalFilter(String(e.target.value)) - } - className="w-full pl-8" - /> - -
- ); - }; - - const getActionButton = () => { - // Only show create button on machine clients tab - if (currentView === "machine") { - return ( - - ); - } - return null; - }; - - // Check if there are any rows without userIds in the current view's data - const hasRowsWithoutUserId = useMemo(() => { - const currentData = currentView === "machine" ? machineClients : userClients; - return currentData?.some((client) => !client.userId) ?? false; - }, [currentView, machineClients, userClients]); - - const columns: ExtendedColumnDef[] = useMemo(() => { - const baseColumns: ExtendedColumnDef[] = [ - { - accessorKey: "name", - enableHiding: false, - friendlyName: "Name", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "userId", - friendlyName: "User", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const r = row.original; - return r.userId ? ( - - - - ) : ( - "-" - ); - } - }, - // { - // accessorKey: "siteName", - // header: ({ column }) => { - // return ( - // - // ); - // }, - // cell: ({ row }) => { - // const r = row.original; - // return ( - // - // - // - // ); - // } - // }, - { - accessorKey: "online", - friendlyName: "Connectivity", - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - if (originalRow.online) { - return ( - -
- Connected -
- ); - } else { - return ( - -
- Disconnected -
- ); - } - } - }, - { - accessorKey: "mbIn", - friendlyName: "Data In", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "mbOut", - friendlyName: "Data Out", - header: ({ column }) => { - return ( - - ); - } - }, - { - accessorKey: "client", - friendlyName: t("client"), - header: ({ column }) => { - return ( - - ); - }, - cell: ({ row }) => { - const originalRow = row.original; - - return ( -
- -
- Olm - {originalRow.olmVersion && ( - - v{originalRow.olmVersion} - - )} -
-
- {originalRow.olmUpdateAvailable && ( - - )} -
- ); - } - }, - { - accessorKey: "subnet", - friendlyName: "Address", - header: ({ column }) => { - return ( - - ); - } - }, - ]; - - // Only include actions column if there are rows without userIds - if (hasRowsWithoutUserId) { - baseColumns.push({ - id: "actions", - enableHiding: false, - header: ({ table }) => { - const hasHideableColumns = table - .getAllColumns() - .some((column) => column.getCanHide()); - if (!hasHideableColumns) { - return ; - } - return ( -
- - - - - - - {t("toggleColumns") || "Toggle columns"} - - - {table - .getAllColumns() - .filter((column) => column.getCanHide()) - .map((column) => { - const columnDef = column.columnDef as any; - const friendlyName = columnDef.friendlyName; - const displayName = friendlyName || - (typeof columnDef.header === "string" - ? columnDef.header - : column.id); - return ( - - column.toggleVisibility(!!value) - } - onSelect={(e) => e.preventDefault()} - > - {displayName} - - ); - })} - - -
- ); - }, - cell: ({ row }) => { - const clientRow = row.original; - return !clientRow.userId ? ( -
- - - - - - {/* */} - {/* */} - {/* View settings */} - {/* */} - {/* */} - { - setSelectedClient(clientRow); - setIsDeleteModalOpen(true); - }} - > - Delete - - - - - - -
- ) : null; - } - }); - } - - return baseColumns; - }, [hasRowsWithoutUserId, t]); - - const userTable = useReactTable({ - data: userClients || [], - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setUserSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setUserColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setUserGlobalFilter, - onColumnVisibilityChange: setUserColumnVisibility, - initialState: { - pagination: { - pageSize: userPageSize, - pageIndex: 0 - }, - columnVisibility: userColumnVisibility - }, - state: { - sorting: userSorting, - columnFilters: userColumnFilters, - globalFilter: userGlobalFilter, - columnVisibility: userColumnVisibility - } - }); - - const machineTable = useReactTable({ - data: machineClients || [], - columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - onSortingChange: setMachineSorting, - getSortedRowModel: getSortedRowModel(), - onColumnFiltersChange: setMachineColumnFilters, - getFilteredRowModel: getFilteredRowModel(), - onGlobalFilterChange: setMachineGlobalFilter, - onColumnVisibilityChange: setMachineColumnVisibility, - initialState: { - pagination: { - pageSize: machinePageSize, - pageIndex: 0 - }, - columnVisibility: machineColumnVisibility - }, - state: { - sorting: machineSorting, - columnFilters: machineColumnFilters, - globalFilter: machineGlobalFilter, - columnVisibility: machineColumnVisibility - } - }); - - const handleUserPageSizeChange = (newPageSize: number) => { - setUserPageSize(newPageSize); - setStoredPageSize(newPageSize, "user-clients"); - }; - - const handleMachinePageSizeChange = (newPageSize: number) => { - setMachinePageSize(newPageSize); - setStoredPageSize(newPageSize, "machine-clients"); - }; - - // Persist column visibility changes to localStorage - useEffect(() => { - setStoredColumnVisibility(userColumnVisibility, "user-clients"); - }, [userColumnVisibility]); - - useEffect(() => { - setStoredColumnVisibility(machineColumnVisibility, "machine-clients"); - }, [machineColumnVisibility]); - - return ( - <> - {selectedClient && ( - { - setIsDeleteModalOpen(val); - setSelectedClient(null); - }} - dialog={ -
-

{t("deleteClientQuestion")}

-

{t("clientMessageRemove")}

-
- } - buttonText="Confirm Delete Client" - onConfirm={async () => deleteClient(selectedClient!.id)} - string={selectedClient.name} - title="Delete Client" - /> - )} - -
- - - -
- {getSearchInput()} - - - - {t("clientsTableUserClients")} - - - {t("clientsTableMachineClients")} - - -
-
-
- -
-
{getActionButton()}
-
-
- - -
-
- - {userTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {userTable.getRowModel().rows - ?.length ? ( - userTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t("noResults")} - - - )} - -
-
-
- -
- - -
- - - {machineTable - .getHeaderGroups() - .map((headerGroup) => ( - - {headerGroup.headers - .filter((header) => - header.column.getIsVisible() - ) - .map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header - .column - .columnDef - .header, - header.getContext() - )} - - ))} - - ))} - - - {machineTable.getRowModel().rows - ?.length ? ( - machineTable - .getRowModel() - .rows.map((row) => ( - - {row - .getVisibleCells() - .map((cell) => ( - - {flexRender( - cell - .column - .columnDef - .cell, - cell.getContext() - )} - - ))} - - )) - ) : ( - - - {t("noResults")} - - - )} - -
-
-
- -
-
- - - -
- - ); -} diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx new file mode 100644 index 00000000..0ba8bd13 --- /dev/null +++ b/src/components/MachineClientsTable.tsx @@ -0,0 +1,698 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Input } from "@app/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Columns, + MoreHorizontal, + Plus, + RefreshCw, + Search +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Badge } from "./ui/badge"; +import { InfoPopup } from "./ui/info-popup"; + +export type ClientRow = { + id: number; + name: string; + subnet: string; + // siteIds: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; + userId: string | null; + username: string | null; + userEmail: string | null; +}; + +type ClientTableProps = { + machineClients: ClientRow[]; + orgId: string; +}; + +export default function MachineClientsTable({ + machineClients, + orgId +}: ClientTableProps) { + const router = useRouter(); + + const t = useTranslations(); + + const [machinePageSize, setMachinePageSize] = useStoredPageSize( + "machine-clients", + 20 + ); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + + const api = createApiClient(useEnvContext()); + const [isRefreshing, startTransition] = useTransition(); + + const [machineSorting, setMachineSorting] = useState([]); + const [machineColumnFilters, setMachineColumnFilters] = + useState([]); + const [machineGlobalFilter, setMachineGlobalFilter] = useState([]); + + const defaultMachineColumnVisibility = { + client: false, + subnet: false, + userId: false + }; + + const [machineColumnVisibility, setMachineColumnVisibility] = + useStoredColumnVisibility( + "machine-clients", + defaultMachineColumnVisibility + ); + + const refreshData = async () => { + try { + router.refresh(); + console.log("Data refreshed"); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }; + + const deleteClient = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + return machineClients.some((client) => !client.userId) ?? false; + }, [machineClients]); + + const columns: ExtendedColumnDef[] = useMemo(() => { + const baseColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: "Name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "userId", + friendlyName: "User", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return r.userId ? ( + + + + ) : ( + "-" + ); + } + }, + // { + // accessorKey: "siteName", + // header: ({ column }) => { + // return ( + // + // ); + // }, + // cell: ({ row }) => { + // const r = row.original; + // return ( + // + // + // + // ); + // } + // }, + { + accessorKey: "online", + friendlyName: "Connectivity", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Connected +
+ ); + } else { + return ( + +
+ Disconnected +
+ ); + } + } + }, + { + accessorKey: "mbIn", + friendlyName: "Data In", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: "Data Out", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "client", + friendlyName: t("client"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, + { + accessorKey: "subnet", + friendlyName: "Address", + header: ({ column }) => { + return ( + + ); + } + } + ]; + + // Only include actions column if there are rows without userIds + if (hasRowsWithoutUserId) { + baseColumns.push({ + id: "actions", + enableHiding: false, + header: ({ table }) => { + const hasHideableColumns = table + .getAllColumns() + .some((column) => column.getCanHide()); + if (!hasHideableColumns) { + return ; + } + return ( +
+ + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnDef = + column.columnDef as any; + const friendlyName = + columnDef.friendlyName; + const displayName = + friendlyName || + (typeof columnDef.header === + "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility( + !!value + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {displayName} + + ); + })} + + +
+ ); + }, + cell: ({ row }) => { + const clientRow = row.original; + return !clientRow.userId ? ( +
+ + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + + +
+ ) : null; + } + }); + } + + return baseColumns; + }, [hasRowsWithoutUserId, t]); + + const machineTable = useReactTable({ + data: machineClients || [], + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setMachineSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setMachineColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setMachineGlobalFilter, + onColumnVisibilityChange: setMachineColumnVisibility, + initialState: { + pagination: { + pageSize: machinePageSize, + pageIndex: 0 + }, + columnVisibility: machineColumnVisibility + }, + state: { + sorting: machineSorting, + columnFilters: machineColumnFilters, + globalFilter: machineGlobalFilter, + columnVisibility: machineColumnVisibility + } + }); + + return ( + <> + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

{t("deleteClientQuestion")}

+

{t("clientMessageRemove")}

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteClient(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + +
+ + +
+
+ + machineTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+
+
+
+ +
+
+ {" "} + +
+
+
+ +
+ + + {machineTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {machineTable.getRowModel().rows?.length ? ( + machineTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+ +
+
+
+
+ + ); +} diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 14830840..29107100 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -814,7 +814,7 @@ export default function ProxyResourcesTable({
-
+
{proxyTable diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx new file mode 100644 index 00000000..f4573eec --- /dev/null +++ b/src/components/UserDevicesTable.tsx @@ -0,0 +1,677 @@ +"use client"; + +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { ExtendedColumnDef } from "@app/components/ui/data-table"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger +} from "@app/components/ui/dropdown-menu"; +import { Input } from "@app/components/ui/input"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow +} from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable +} from "@tanstack/react-table"; +import { + ArrowRight, + ArrowUpDown, + ArrowUpRight, + Columns, + MoreHorizontal, + RefreshCw, + Search +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useMemo, useState, useTransition } from "react"; +import { Badge } from "./ui/badge"; +import { InfoPopup } from "./ui/info-popup"; + +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; + +export type ClientRow = { + id: number; + name: string; + subnet: string; + // siteIds: string; + mbIn: string; + mbOut: string; + orgId: string; + online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; + userId: string | null; + username: string | null; + userEmail: string | null; +}; + +type ClientTableProps = { + userClients: ClientRow[]; + orgId: string; +}; + +export default function UserDevicesTable({ userClients }: ClientTableProps) { + const router = useRouter(); + const t = useTranslations(); + + const [userPageSize, setUserPageSize] = useStoredPageSize( + "user-clients", + 20 + ); + + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [selectedClient, setSelectedClient] = useState( + null + ); + + const api = createApiClient(useEnvContext()); + const [isRefreshing, startTransition] = useTransition(); + + const [userSorting, setUserSorting] = useState([]); + const [userColumnFilters, setUserColumnFilters] = + useState([]); + const [userGlobalFilter, setUserGlobalFilter] = useState([]); + + const defaultUserColumnVisibility = { + client: false, + subnet: false + }; + + const [userColumnVisibility, setUserColumnVisibility] = + useStoredColumnVisibility("user-clients", defaultUserColumnVisibility); + + const refreshData = async () => { + try { + router.refresh(); + console.log("Data refreshed"); + } catch (error) { + toast({ + title: t("error"), + description: t("refreshError"), + variant: "destructive" + }); + } + }; + + const deleteClient = (clientId: number) => { + api.delete(`/client/${clientId}`) + .catch((e) => { + console.error("Error deleting client", e); + toast({ + variant: "destructive", + title: "Error deleting client", + description: formatAxiosError(e, "Error deleting client") + }); + }) + .then(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }; + + // Check if there are any rows without userIds in the current view's data + const hasRowsWithoutUserId = useMemo(() => { + return userClients.some((client) => !client.userId); + }, [userClients]); + + const columns: ExtendedColumnDef[] = useMemo(() => { + const baseColumns: ExtendedColumnDef[] = [ + { + accessorKey: "name", + enableHiding: false, + friendlyName: "Name", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "userId", + friendlyName: "User", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const r = row.original; + return r.userId ? ( + + + + ) : ( + "-" + ); + } + }, + // { + // accessorKey: "siteName", + // header: ({ column }) => { + // return ( + // + // ); + // }, + // cell: ({ row }) => { + // const r = row.original; + // return ( + // + // + // + // ); + // } + // }, + { + accessorKey: "online", + friendlyName: "Connectivity", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + if (originalRow.online) { + return ( + +
+ Connected +
+ ); + } else { + return ( + +
+ Disconnected +
+ ); + } + } + }, + { + accessorKey: "mbIn", + friendlyName: "Data In", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "mbOut", + friendlyName: "Data Out", + header: ({ column }) => { + return ( + + ); + } + }, + { + accessorKey: "client", + friendlyName: t("client"), + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, + { + accessorKey: "subnet", + friendlyName: "Address", + header: ({ column }) => { + return ( + + ); + } + } + ]; + + // Only include actions column if there are rows without userIds + if (hasRowsWithoutUserId) { + baseColumns.push({ + id: "actions", + enableHiding: false, + header: ({ table }) => { + const hasHideableColumns = table + .getAllColumns() + .some((column) => column.getCanHide()); + if (!hasHideableColumns) { + return ; + } + return ( +
+ + + + + + + {t("toggleColumns") || "Toggle columns"} + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnDef = + column.columnDef as any; + const friendlyName = + columnDef.friendlyName; + const displayName = + friendlyName || + (typeof columnDef.header === + "string" + ? columnDef.header + : column.id); + return ( + + column.toggleVisibility( + !!value + ) + } + onSelect={(e) => + e.preventDefault() + } + > + {displayName} + + ); + })} + + +
+ ); + }, + cell: ({ row }) => { + const clientRow = row.original; + return !clientRow.userId ? ( +
+ + + + + + {/* */} + {/* */} + {/* View settings */} + {/* */} + {/* */} + { + setSelectedClient(clientRow); + setIsDeleteModalOpen(true); + }} + > + + Delete + + + + + + + +
+ ) : null; + } + }); + } + + return baseColumns; + }, [hasRowsWithoutUserId, t]); + + const userTable = useReactTable({ + data: userClients || [], + columns, + getCoreRowModel: getCoreRowModel(), + getPaginationRowModel: getPaginationRowModel(), + onSortingChange: setUserSorting, + getSortedRowModel: getSortedRowModel(), + onColumnFiltersChange: setUserColumnFilters, + getFilteredRowModel: getFilteredRowModel(), + onGlobalFilterChange: setUserGlobalFilter, + onColumnVisibilityChange: setUserColumnVisibility, + initialState: { + pagination: { + pageSize: userPageSize, + pageIndex: 0 + }, + columnVisibility: userColumnVisibility + }, + state: { + sorting: userSorting, + columnFilters: userColumnFilters, + globalFilter: userGlobalFilter, + columnVisibility: userColumnVisibility + } + }); + + return ( + <> + {selectedClient && ( + { + setIsDeleteModalOpen(val); + setSelectedClient(null); + }} + dialog={ +
+

{t("deleteClientQuestion")}

+

{t("clientMessageRemove")}

+
+ } + buttonText="Confirm Delete Client" + onConfirm={async () => deleteClient(selectedClient!.id)} + string={selectedClient.name} + title="Delete Client" + /> + )} + +
+ + +
+
+ + userTable.setGlobalFilter( + String(e.target.value) + ) + } + className="w-full pl-8" + /> + +
+
+
+
+ +
+
+
+ +
+
+ + {userTable + .getHeaderGroups() + .map((headerGroup) => ( + + {headerGroup.headers + .filter((header) => + header.column.getIsVisible() + ) + .map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header + .column + .columnDef + .header, + header.getContext() + )} + + ))} + + ))} + + + {userTable.getRowModel().rows?.length ? ( + userTable + .getRowModel() + .rows.map((row) => ( + + {row + .getVisibleCells() + .map((cell) => ( + + {flexRender( + cell.column + .columnDef + .cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + {t("noResults")} + + + )} + +
+
+
+ +
+ + +
+ + ); +} diff --git a/src/hooks/useLocalStorage.ts b/src/hooks/useLocalStorage.ts index e7fdc353..2448b1e2 100644 --- a/src/hooks/useLocalStorage.ts +++ b/src/hooks/useLocalStorage.ts @@ -2,8 +2,8 @@ import { useState, useEffect, useCallback, - Dispatch, - SetStateAction + type Dispatch, + type SetStateAction } from "react"; type SetValue = Dispatch>; diff --git a/src/hooks/useStoredColumnVisibility.ts b/src/hooks/useStoredColumnVisibility.ts new file mode 100644 index 00000000..1d5d6662 --- /dev/null +++ b/src/hooks/useStoredColumnVisibility.ts @@ -0,0 +1,81 @@ +import type { VisibilityState } from "@tanstack/react-table"; +import { useCallback, useState } from "react"; + +const STORAGE_KEYS = { + COLUMN_VISIBILITY: "datatable-column-visibility", + getTableColumnVisibility: (tableId: string) => + `datatable-${tableId}-column-visibility` +}; + +const getStoredColumnVisibility = ( + tableId: string, + defaultVisibility?: Record +): Record => { + if (typeof window === "undefined") return defaultVisibility || {}; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = JSON.parse(stored); + // Validate that it's an object + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } + } catch (error) { + console.warn( + "Failed to read column visibility from localStorage:", + error + ); + } + return defaultVisibility || {}; +}; + +const setStoredColumnVisibility = ( + visibility: Record, + tableId: string +): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTableColumnVisibility(tableId); + localStorage.setItem(key, JSON.stringify(visibility)); + } catch (error) { + console.warn( + "Failed to save column visibility to localStorage:", + error + ); + } +}; + +export function useStoredColumnVisibility( + tableId: string, + defaultColumnVisibility?: Record +) { + const [columnVisibility, setVisibility] = useState(() => + getStoredColumnVisibility(tableId, defaultColumnVisibility) + ); + + const setColumnVisibility = useCallback( + ( + updaterOrValue: + | VisibilityState + | ((old: VisibilityState) => VisibilityState) + ) => { + if (typeof updaterOrValue === "function") { + setVisibility((oldValue) => { + const newValue = updaterOrValue(oldValue); + setStoredColumnVisibility(newValue, tableId); + return newValue; + }); + } else { + setVisibility(updaterOrValue); + setStoredColumnVisibility(updaterOrValue, tableId); + } + }, + [tableId] + ); + + return [columnVisibility, setColumnVisibility] as const; +} diff --git a/src/hooks/useStoredPageSize.ts b/src/hooks/useStoredPageSize.ts new file mode 100644 index 00000000..2c4e7692 --- /dev/null +++ b/src/hooks/useStoredPageSize.ts @@ -0,0 +1,60 @@ +import { useState, useCallback } from "react"; + +const STORAGE_KEYS = { + PAGE_SIZE: "datatable-page-size", + getTablePageSize: (tableId: string) => `datatable-${tableId}-page-size` +}; + +const getStoredPageSize = (tableId: string, defaultSize = 20): number => { + if (typeof window === "undefined") return defaultSize; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + const stored = localStorage.getItem(key); + if (stored) { + const parsed = parseInt(stored, 10); + if (parsed > 0 && parsed <= 1000) { + return parsed; + } + } + } catch (error) { + console.warn("Failed to read page size from localStorage:", error); + } + return defaultSize; +}; + +const setStoredPageSize = (pageSize: number, tableId: string): void => { + if (typeof window === "undefined") return; + + try { + const key = STORAGE_KEYS.getTablePageSize(tableId); + localStorage.setItem(key, pageSize.toString()); + } catch (error) { + console.warn("Failed to save page size to localStorage:", error); + } +}; + +// export function useStore +export function useStoredPageSize(tableId: string, defaultPageSize?: number) { + const [pageSize, setSize] = useState(() => + getStoredPageSize(tableId, defaultPageSize) + ); + + const setPageSize = useCallback( + (updaterOrValue: number | ((old: number) => number)) => { + if (typeof updaterOrValue === "function") { + setSize((oldValue) => { + const newValue = updaterOrValue(oldValue); + setStoredPageSize(newValue, tableId); + return newValue; + }); + } else { + setSize(updaterOrValue); + setStoredPageSize(updaterOrValue, tableId); + } + }, + [tableId] + ); + + return [pageSize, setPageSize] as const; +} From c93ab340213a9eca9131beda48c708f74de97f98 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 19:08:35 +0100 Subject: [PATCH 07/12] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20some=20refactors?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ClientResourcesTable.tsx | 134 +++------------- src/components/MachineClientsTable.tsx | 6 +- src/components/ProxyResourcesTable.tsx | 200 +++++++----------------- src/components/UserDevicesTable.tsx | 6 +- 4 files changed, 79 insertions(+), 267 deletions(-) diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 39578a9b..5d96d1d2 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -27,7 +27,6 @@ import { import { useEnvContext } from "@app/hooks/useEnvContext"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { ListSitesResponse } from "@server/routers/site"; import { ColumnFiltersState, flexRender, @@ -36,28 +35,26 @@ import { getPaginationRowModel, getSortedRowModel, SortingState, - useReactTable, - VisibilityState + useReactTable } from "@tanstack/react-table"; import { ArrowUpDown, ArrowUpRight, - CheckCircle2, - Clock, Columns, MoreHorizontal, Plus, RefreshCw, - Search, - XCircle + Search } from "lucide-react"; import { useTranslations } from "next-intl"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { useEffect, useState, useTransition } from "react"; +import { useState, useTransition } from "react"; import CreateInternalResourceDialog from "@app/components/CreateInternalResourceDialog"; import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; import { siteQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; @@ -113,88 +110,6 @@ type ClientResourcesTableProps = { }; }; -const STORAGE_KEYS = { - PAGE_SIZE: "datatable-page-size", - COLUMN_VISIBILITY: "datatable-column-visibility", - getTablePageSize: (tableId?: string) => - tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE, - getTableColumnVisibility: (tableId?: string) => - tableId - ? `datatable-${tableId}-column-visibility` - : STORAGE_KEYS.COLUMN_VISIBILITY -}; - -const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { - if (typeof window === "undefined") return defaultSize; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) { - return parsed; - } - } - } catch (error) { - console.warn("Failed to read page size from localStorage:", error); - } - return defaultSize; -}; - -const setStoredPageSize = (pageSize: number, tableId?: string): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - localStorage.setItem(key, pageSize.toString()); - } catch (error) { - console.warn("Failed to save page size to localStorage:", error); - } -}; - -const getStoredColumnVisibility = ( - tableId?: string, - defaultVisibility?: Record -): Record => { - if (typeof window === "undefined") return defaultVisibility || {}; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = JSON.parse(stored); - // Validate that it's an object - if (typeof parsed === "object" && parsed !== null) { - return parsed; - } - } - } catch (error) { - console.warn( - "Failed to read column visibility from localStorage:", - error - ); - } - return defaultVisibility || {}; -}; - -const setStoredColumnVisibility = ( - visibility: Record, - tableId?: string -): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - localStorage.setItem(key, JSON.stringify(visibility)); - } catch (error) { - console.warn( - "Failed to save column visibility to localStorage:", - error - ); - } -}; - export default function ClientResourcesTable({ internalResources, orgId, @@ -207,10 +122,10 @@ export default function ClientResourcesTable({ const api = createApiClient({ env }); - const [internalPageSize, setInternalPageSize] = useState(() => - getStoredPageSize("internal-resources", 20) + const [internalPageSize, setInternalPageSize] = useStoredPageSize( + "internal-resources", + 20 ); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedInternalResource, setSelectedInternalResource] = @@ -233,10 +148,7 @@ export default function ClientResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [internalColumnVisibility, setInternalColumnVisibility] = - useState(() => - getStoredColumnVisibility("internal-resources", {}) - ); - + useStoredColumnVisibility("internal-resources", {}); const refreshData = async () => { try { router.refresh(); @@ -255,11 +167,14 @@ export default function ClientResourcesTable({ siteId: number ) => { try { - await api.delete( - `/org/${orgId}/site/${siteId}/resource/${resourceId}` - ); - router.refresh(); - setIsDeleteModalOpen(false); + await api + .delete(`/org/${orgId}/site/${siteId}/resource/${resourceId}`) + .then(() => { + startTransition(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); + }); } catch (e) { console.error(t("resourceErrorDelete"), e); toast({ @@ -515,19 +430,6 @@ export default function ClientResourcesTable({ } }); - const handleInternalPageSizeChange = (newPageSize: number) => { - setInternalPageSize(newPageSize); - setStoredPageSize(newPageSize, "internal-resources"); - }; - - // Persist column visibility changes to localStorage - useEffect(() => { - setStoredColumnVisibility( - internalColumnVisibility, - "internal-resources" - ); - }, [internalColumnVisibility]); - return ( <> {selectedInternalResource && ( @@ -700,7 +602,7 @@ export default function ClientResourcesTable({
diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index 0ba8bd13..daff754b 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -138,8 +138,10 @@ export default function MachineClientsTable({ }); }) .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); + startTransition(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); }); }; diff --git a/src/components/ProxyResourcesTable.tsx b/src/components/ProxyResourcesTable.tsx index 29107100..9fb4c91e 100644 --- a/src/components/ProxyResourcesTable.tsx +++ b/src/components/ProxyResourcesTable.tsx @@ -1,60 +1,23 @@ "use client"; -import { - flexRender, - getCoreRowModel, - useReactTable, - getPaginationRowModel, - SortingState, - getSortedRowModel, - ColumnFiltersState, - getFilteredRowModel, - VisibilityState -} from "@tanstack/react-table"; +import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; +import CopyToClipboard from "@app/components/CopyToClipboard"; +import { DataTablePagination } from "@app/components/DataTablePagination"; +import { Button } from "@app/components/ui/button"; +import { Card, CardContent, CardHeader } from "@app/components/ui/card"; import { ExtendedColumnDef } from "@app/components/ui/data-table"; import { DropdownMenu, + DropdownMenuCheckboxItem, DropdownMenuContent, DropdownMenuItem, - DropdownMenuTrigger, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuCheckboxItem + DropdownMenuTrigger } from "@app/components/ui/dropdown-menu"; -import { Button } from "@app/components/ui/button"; -import { - ArrowRight, - ArrowUpDown, - MoreHorizontal, - ShieldOff, - ShieldCheck, - RefreshCw, - Columns, - Plus, - Search, - ChevronDown, - Clock, - CheckCircle2, - XCircle -} from "lucide-react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { useState, useEffect, useTransition } from "react"; -import ConfirmDeleteDialog from "@app/components/ConfirmDeleteDialog"; -import { formatAxiosError } from "@app/lib/api"; -import { toast } from "@app/hooks/useToast"; -import { createApiClient } from "@app/lib/api"; -import { useEnvContext } from "@app/hooks/useEnvContext"; -import CopyToClipboard from "@app/components/CopyToClipboard"; -import { Switch } from "@app/components/ui/switch"; -import { AxiosResponse } from "axios"; -import { UpdateResourceResponse } from "@server/routers/resource"; -import { ListSitesResponse } from "@server/routers/site"; -import { useTranslations } from "next-intl"; import { InfoPopup } from "@app/components/ui/info-popup"; import { Input } from "@app/components/ui/input"; -import { DataTablePagination } from "@app/components/DataTablePagination"; -import { Card, CardContent, CardHeader } from "@app/components/ui/card"; +import { Switch } from "@app/components/ui/switch"; import { Table, TableBody, @@ -63,6 +26,42 @@ import { TableHeader, TableRow } from "@app/components/ui/table"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; +import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; +import { UpdateResourceResponse } from "@server/routers/resource"; +import { + ColumnFiltersState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + SortingState, + useReactTable +} from "@tanstack/react-table"; +import { AxiosResponse } from "axios"; +import { + ArrowRight, + ArrowUpDown, + CheckCircle2, + ChevronDown, + Clock, + Columns, + MoreHorizontal, + Plus, + RefreshCw, + Search, + ShieldCheck, + ShieldOff, + XCircle +} from "lucide-react"; +import { useTranslations } from "next-intl"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useState, useTransition } from "react"; export type TargetHealth = { targetId: number; @@ -153,88 +152,6 @@ type ProxyResourcesTableProps = { }; }; -const STORAGE_KEYS = { - PAGE_SIZE: "datatable-page-size", - COLUMN_VISIBILITY: "datatable-column-visibility", - getTablePageSize: (tableId?: string) => - tableId ? `datatable-${tableId}-page-size` : STORAGE_KEYS.PAGE_SIZE, - getTableColumnVisibility: (tableId?: string) => - tableId - ? `datatable-${tableId}-column-visibility` - : STORAGE_KEYS.COLUMN_VISIBILITY -}; - -const getStoredPageSize = (tableId?: string, defaultSize = 20): number => { - if (typeof window === "undefined") return defaultSize; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = parseInt(stored, 10); - if (parsed > 0 && parsed <= 1000) { - return parsed; - } - } - } catch (error) { - console.warn("Failed to read page size from localStorage:", error); - } - return defaultSize; -}; - -const setStoredPageSize = (pageSize: number, tableId?: string): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTablePageSize(tableId); - localStorage.setItem(key, pageSize.toString()); - } catch (error) { - console.warn("Failed to save page size to localStorage:", error); - } -}; - -const getStoredColumnVisibility = ( - tableId?: string, - defaultVisibility?: Record -): Record => { - if (typeof window === "undefined") return defaultVisibility || {}; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - const stored = localStorage.getItem(key); - if (stored) { - const parsed = JSON.parse(stored); - // Validate that it's an object - if (typeof parsed === "object" && parsed !== null) { - return parsed; - } - } - } catch (error) { - console.warn( - "Failed to read column visibility from localStorage:", - error - ); - } - return defaultVisibility || {}; -}; - -const setStoredColumnVisibility = ( - visibility: Record, - tableId?: string -): void => { - if (typeof window === "undefined") return; - - try { - const key = STORAGE_KEYS.getTableColumnVisibility(tableId); - localStorage.setItem(key, JSON.stringify(visibility)); - } catch (error) { - console.warn( - "Failed to save column visibility to localStorage:", - error - ); - } -}; - export default function ProxyResourcesTable({ resources, orgId, @@ -247,10 +164,10 @@ export default function ProxyResourcesTable({ const api = createApiClient({ env }); - const [proxyPageSize, setProxyPageSize] = useState(() => - getStoredPageSize("proxy-resources", 20) + const [proxyPageSize, setProxyPageSize] = useStoredPageSize( + "proxy-resources", + 20 ); - const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [selectedResource, setSelectedResource] = useState(); @@ -265,10 +182,7 @@ export default function ProxyResourcesTable({ const [isRefreshing, startTransition] = useTransition(); const [proxyColumnVisibility, setProxyColumnVisibility] = - useState(() => - getStoredColumnVisibility("proxy-resources", {}) - ); - + useStoredColumnVisibility("proxy-resources", {}); const refreshData = () => { try { router.refresh(); @@ -292,8 +206,10 @@ export default function ProxyResourcesTable({ }); }) .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); + startTransition(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); }); }; @@ -734,16 +650,6 @@ export default function ProxyResourcesTable({ } }); - const handleProxyPageSizeChange = (newPageSize: number) => { - setProxyPageSize(newPageSize); - setStoredPageSize(newPageSize, "proxy-resources"); - }; - - // Persist column visibility changes to localStorage - useEffect(() => { - setStoredColumnVisibility(proxyColumnVisibility, "proxy-resources"); - }, [proxyColumnVisibility]); - return ( <> {selectedResource && ( @@ -913,7 +819,7 @@ export default function ProxyResourcesTable({
diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index f4573eec..0d903c88 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -130,8 +130,10 @@ export default function UserDevicesTable({ userClients }: ClientTableProps) { }); }) .then(() => { - router.refresh(); - setIsDeleteModalOpen(false); + startTransition(() => { + router.refresh(); + setIsDeleteModalOpen(false); + }); }); }; From 6eb6b44f41bd2b21bb5dba73eb909a7195b7cb4e Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 19:22:43 +0100 Subject: [PATCH 08/12] =?UTF-8?q?=F0=9F=92=AC=20update=20some=20text=20lab?= =?UTF-8?q?els?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/messages/en-US.json b/messages/en-US.json index 6483b025..40fec4a4 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -145,9 +145,9 @@ "never": "Never", "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Proxy Resources", - "proxyResourceDescription": "Access web resources on sites", + "proxyResourceDescription": "Create and manage secure, publicly accessible resources that require authentication through a web browser", "clientResourceTitle": "Manage Client Resources", - "clientResourceDescription": "Access Internal resources on sites", + "clientResourceDescription": "Create and manage private resources that are accessible only through a connected client, with no public exposure", "resourcesSearch": "Search resources...", "resourceAdd": "Add Resource", "resourceErrorDelte": "Error deleting resource", @@ -1881,10 +1881,10 @@ "enterpriseEdition": "Enterprise Edition", "unlicensed": "Unlicensed", "beta": "Beta", - "manageUserDevices": "Manage User Devices", - "manageUserDevicesDescription": "View user devices (laptops, phones, tablets) that can access your sites through clients", + "manageUserDevices": "User Devices", + "manageUserDevicesDescription": "View and manage personal devices that users connect with to securely access your organization’s resources", "manageMachineClients": "Manage Machine Clients", - "manageMachineClientsDescription": "Create and manage credentials for automated systems and infrastructure (CI/CD, VMs, VPCs) to securely access your sites", + "manageMachineClientsDescription": "Create and manage automated clients, such as servers or services, that securely connect to your network", "clientsTableUserClients": "User", "clientsTableMachineClients": "Machine", "licenseTableValidUntil": "Valid Until", From f060063f5362956b6ea1da7fd3f6ee267a1ec2d8 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Tue, 2 Dec 2025 19:24:02 +0100 Subject: [PATCH 09/12] =?UTF-8?q?=F0=9F=92=AC=20update=20text?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- messages/en-US.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/messages/en-US.json b/messages/en-US.json index 40fec4a4..f69d11a8 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -145,7 +145,7 @@ "never": "Never", "shareErrorSelectResource": "Please select a resource", "proxyResourceTitle": "Manage Proxy Resources", - "proxyResourceDescription": "Create and manage secure, publicly accessible resources that require authentication through a web browser", + "proxyResourceDescription": "Create and manage secure, publicly accessible resources through a web browser", "clientResourceTitle": "Manage Client Resources", "clientResourceDescription": "Create and manage private resources that are accessible only through a connected client, with no public exposure", "resourcesSearch": "Search resources...", From f5bfddd2622b040c4b503d31362cdef766c94c33 Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 3 Dec 2025 16:58:12 +0100 Subject: [PATCH 10/12] =?UTF-8?q?=F0=9F=9A=A8=20run=20`eslint=20--fix`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server/lib/ip.ts | 4 ++-- server/lib/rebuildClientAssociations.ts | 10 +++++----- server/private/routers/hybrid.ts | 2 +- server/routers/newt/handleGetConfigMessage.ts | 2 +- server/routers/siteResource/updateSiteResource.ts | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/server/lib/ip.ts b/server/lib/ip.ts index d530e2f0..8f09d86e 100644 --- a/server/lib/ip.ts +++ b/server/lib/ip.ts @@ -304,7 +304,7 @@ export async function getNextAvailableOrgSubnet(): Promise { } export function generateRemoteSubnets(allSiteResources: SiteResource[]): string[] { - let remoteSubnets = allSiteResources + const remoteSubnets = allSiteResources .filter((sr) => { if (sr.mode === "cidr") return true; if (sr.mode === "host") { @@ -344,7 +344,7 @@ export function generateSubnetProxyTargets( subnet: string | null; }[] ): SubnetProxyTarget[] { - let targets: SubnetProxyTarget[] = []; + const targets: SubnetProxyTarget[] = []; if (clients.length === 0) { logger.debug( diff --git a/server/lib/rebuildClientAssociations.ts b/server/lib/rebuildClientAssociations.ts index 2773e098..483d3a99 100644 --- a/server/lib/rebuildClientAssociations.ts +++ b/server/lib/rebuildClientAssociations.ts @@ -392,9 +392,9 @@ async function handleMessagesForSiteClients( return; } - let newtJobs: Promise[] = []; - let olmJobs: Promise[] = []; - let exitNodeJobs: Promise[] = []; + const newtJobs: Promise[] = []; + const olmJobs: Promise[] = []; + const exitNodeJobs: Promise[] = []; // Combine all clients that need processing (those being added or removed) const clientsToProcess = new Map< @@ -678,8 +678,8 @@ async function handleSubnetProxyTargetUpdates( return; } - let proxyJobs = []; - let olmJobs = []; + const proxyJobs = []; + const olmJobs = []; // Generate targets for added associations if (clientSiteResourcesToAdd.length > 0) { const addedClients = allClients.filter((client) => diff --git a/server/private/routers/hybrid.ts b/server/private/routers/hybrid.ts index f78fb592..4fc3f97e 100644 --- a/server/private/routers/hybrid.ts +++ b/server/private/routers/hybrid.ts @@ -1043,7 +1043,7 @@ hybridRouter.get( ); } - let rules = await db + const rules = await db .select() .from(resourceRules) .where(eq(resourceRules.resourceId, resourceId)); diff --git a/server/routers/newt/handleGetConfigMessage.ts b/server/routers/newt/handleGetConfigMessage.ts index 68116686..35c65716 100644 --- a/server/routers/newt/handleGetConfigMessage.ts +++ b/server/routers/newt/handleGetConfigMessage.ts @@ -246,7 +246,7 @@ export const handleGetConfigMessage: MessageHandler = async (context) => { .from(siteResources) .where(eq(siteResources.siteId, siteId)); - let targetsToSend: SubnetProxyTarget[] = []; + const targetsToSend: SubnetProxyTarget[] = []; for (const resource of allSiteResources) { // Get clients associated with this specific resource diff --git a/server/routers/siteResource/updateSiteResource.ts b/server/routers/siteResource/updateSiteResource.ts index 2e2c1592..470c24f6 100644 --- a/server/routers/siteResource/updateSiteResource.ts +++ b/server/routers/siteResource/updateSiteResource.ts @@ -262,7 +262,7 @@ export async function updateSiteResource( newTargets: newTargets }); - let olmJobs: Promise[] = []; + const olmJobs: Promise[] = []; for (const client of mergedAllClients) { // we also need to update the remote subnets on the olms for each client that has access to this site olmJobs.push( From cb3861a5c8e325325fdd85ff2a06575fbc77c3bb Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 3 Dec 2025 16:58:40 +0100 Subject: [PATCH 11/12] =?UTF-8?q?=F0=9F=9A=9A=20rename=20react-query-provi?= =?UTF-8?q?der=20to=20TanstackQueryProvider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 6 +++--- .../{react-query-provider.tsx => TanstackQueryProvider.tsx} | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/components/{react-query-provider.tsx => TanstackQueryProvider.tsx} (91%) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c8907a49..460cace0 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -20,7 +20,7 @@ import { Toaster } from "@app/components/ui/toaster"; import { build } from "@server/build"; import { TopLoader } from "@app/components/Toploader"; import Script from "next/script"; -import { ReactQueryProvider } from "@app/components/react-query-provider"; +import { TanstackQueryProvider } from "@app/components/TanstackQueryProvider"; export const metadata: Metadata = { title: `Dashboard - ${process.env.BRANDING_APP_NAME || "Pangolin"}`, @@ -95,7 +95,7 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - + - + ); diff --git a/src/components/react-query-provider.tsx b/src/components/TanstackQueryProvider.tsx similarity index 91% rename from src/components/react-query-provider.tsx rename to src/components/TanstackQueryProvider.tsx index 0f65ba62..3d9f62e1 100644 --- a/src/components/react-query-provider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -8,7 +8,7 @@ export type ReactQueryProviderProps = { children: React.ReactNode; }; -export function ReactQueryProvider({ children }: ReactQueryProviderProps) { +export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { const [queryClient] = React.useState( () => new QueryClient({ From bf987d867c39010a0bd04661fc3e1fbabcbe12ed Mon Sep 17 00:00:00 2001 From: Fred KISSIE Date: Wed, 3 Dec 2025 19:28:07 +0100 Subject: [PATCH 12/12] =?UTF-8?q?=F0=9F=9A=A7=20WIP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/[orgId]/page.tsx | 22 +- src/app/layout.tsx | 30 +- src/components/ClientResourcesTable.tsx | 6 +- .../CreateInternalResourceDialog.tsx | 167 ++++------ src/components/EditInternalResourceDialog.tsx | 287 ++++++++++-------- src/components/TanstackQueryProvider.tsx | 12 +- src/lib/queries.ts | 106 ++++++- src/types/tanstack-query.d.ts | 13 + 8 files changed, 370 insertions(+), 273 deletions(-) create mode 100644 src/types/tanstack-query.d.ts diff --git a/src/app/[orgId]/page.tsx b/src/app/[orgId]/page.tsx index c883f038..fc806acc 100644 --- a/src/app/[orgId]/page.tsx +++ b/src/app/[orgId]/page.tsx @@ -1,17 +1,15 @@ -import { verifySession } from "@app/lib/auth/verifySession"; -import UserProvider from "@app/providers/UserProvider"; -import { cache } from "react"; -import MemberResourcesPortal from "../../components/MemberResourcesPortal"; -import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; -import { internal } from "@app/lib/api"; -import { AxiosResponse } from "axios"; -import { authCookieHeader } from "@app/lib/api/cookies"; -import { redirect } from "next/navigation"; import { Layout } from "@app/components/Layout"; -import { ListUserOrgsResponse } from "@server/routers/org"; +import MemberResourcesPortal from "@app/components/MemberResourcesPortal"; +import { internal } from "@app/lib/api"; +import { authCookieHeader } from "@app/lib/api/cookies"; +import { verifySession } from "@app/lib/auth/verifySession"; import { pullEnv } from "@app/lib/pullEnv"; -import EnvProvider from "@app/providers/EnvProvider"; -import { orgLangingNavItems } from "@app/app/navigation"; +import UserProvider from "@app/providers/UserProvider"; +import { ListUserOrgsResponse } from "@server/routers/org"; +import { GetOrgOverviewResponse } from "@server/routers/org/getOrgOverview"; +import { AxiosResponse } from "axios"; +import { redirect } from "next/navigation"; +import { cache } from "react"; type OrgPageProps = { params: Promise<{ orgId: string }>; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 460cace0..bdd6c3fe 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -95,16 +95,16 @@ export default async function RootLayout({ strategy="afterInteractive" /> )} - - - - - + + + + + @@ -124,11 +124,11 @@ export default async function RootLayout({ - - - - - + + + + + ); diff --git a/src/components/ClientResourcesTable.tsx b/src/components/ClientResourcesTable.tsx index 5d96d1d2..53998e2e 100644 --- a/src/components/ClientResourcesTable.tsx +++ b/src/components/ClientResourcesTable.tsx @@ -55,7 +55,7 @@ import CreateInternalResourceDialog from "@app/components/CreateInternalResource import EditInternalResourceDialog from "@app/components/EditInternalResourceDialog"; import { useStoredColumnVisibility } from "@app/hooks/useStoredColumnVisibility"; import { useStoredPageSize } from "@app/hooks/useStoredPageSize"; -import { siteQueries } from "@app/lib/queries"; +import { orgQueries, siteQueries } from "@app/lib/queries"; import { useQuery } from "@tanstack/react-query"; export type TargetHealth = { @@ -135,9 +135,7 @@ export default function ClientResourcesTable({ useState(); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); - const { data: sites = [] } = useQuery( - siteQueries.listPerOrg({ orgId, api }) - ); + const { data: sites = [] } = useQuery(orgQueries.sites({ orgId, api })); const [internalSorting, setInternalSorting] = useState( defaultSort ? [defaultSort] : [] diff --git a/src/components/CreateInternalResourceDialog.tsx b/src/components/CreateInternalResourceDialog.tsx index 44ed12a5..9db438a8 100644 --- a/src/components/CreateInternalResourceDialog.tsx +++ b/src/components/CreateInternalResourceDialog.tsx @@ -1,15 +1,16 @@ "use client"; -import { useEffect, useState } from "react"; -import { Button } from "@app/components/ui/button"; -import { Input } from "@app/components/ui/input"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue -} from "@app/components/ui/select"; + Credenza, + CredenzaBody, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; +import { Tag, TagInput } from "@app/components/tags/tag-input"; +import { Button } from "@app/components/ui/button"; import { Command, CommandEmpty, @@ -18,15 +19,6 @@ import { CommandItem, CommandList } from "@app/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger -} from "@app/components/ui/popover"; -import { Check, ChevronsUpDown } from "lucide-react"; -import { useForm } from "react-hook-form"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { z } from "zod"; import { Form, FormControl, @@ -36,29 +28,36 @@ import { FormLabel, FormMessage } from "@app/components/ui/form"; +import { Input } from "@app/components/ui/input"; import { - Credenza, - CredenzaBody, - CredenzaClose, - CredenzaContent, - CredenzaDescription, - CredenzaFooter, - CredenzaHeader, - CredenzaTitle -} from "@app/components/Credenza"; -import { toast } from "@app/hooks/useToast"; -import { useTranslations } from "next-intl"; -import { createApiClient, formatAxiosError } from "@app/lib/api"; + Popover, + PopoverContent, + PopoverTrigger +} from "@app/components/ui/popover"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue +} from "@app/components/ui/select"; import { useEnvContext } from "@app/hooks/useEnvContext"; -import { ListSitesResponse } from "@server/routers/site"; -import { ListRolesResponse } from "@server/routers/role"; -import { ListUsersResponse } from "@server/routers/user"; -import { ListClientsResponse } from "@server/routers/client/listClients"; +import { toast } from "@app/hooks/useToast"; +import { createApiClient, formatAxiosError } from "@app/lib/api"; import { cn } from "@app/lib/cn"; -import { Tag, TagInput } from "@app/components/tags/tag-input"; -import { Separator } from "@app/components/ui/separator"; -import { AxiosResponse } from "axios"; +import { orgQueries } from "@app/lib/queries"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { ListClientsResponse } from "@server/routers/client/listClients"; +import { ListSitesResponse } from "@server/routers/site"; +import { ListUsersResponse } from "@server/routers/user"; import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { AxiosResponse } from "axios"; +import { Check, ChevronsUpDown } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useEffect, useState } from "react"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; type Site = ListSitesResponse["sites"][0]; @@ -167,15 +166,38 @@ export default function CreateInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId })); + const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); + const { data: clientsResponse = [] } = useQuery( + orgQueries.clients({ + orgId, + filters: { + filter: "machine" + } + }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] - ); - const [allClients, setAllClients] = useState< - { id: string; text: string }[] - >([]); + + const allRoles = rolesResponse + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + + const allUsers = usersResponse.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + + const allClients = clientsResponse + .filter((client) => !client.userId) + .map((client) => ({ + id: client.clientId.toString(), + text: client.name + })); + + const hasMachineClients = allClients.length > 0; + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -185,7 +207,6 @@ export default function CreateInternalResourceDialog({ const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< number | null >(null); - const [hasMachineClients, setHasMachineClients] = useState(false); const availableSites = sites.filter( (site) => site.type === "newt" && site.subnet @@ -228,60 +249,6 @@ export default function CreateInternalResourceDialog({ } }, [open]); - useEffect(() => { - const fetchRolesUsersAndClients = async () => { - try { - const [rolesResponse, usersResponse, clientsResponse] = - await Promise.all([ - api.get>( - `/org/${orgId}/roles` - ), - api.get>( - `/org/${orgId}/users` - ), - api.get>( - `/org/${orgId}/clients?filter=machine&limit=1000` - ) - ]); - - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); - - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); - - const machineClients = clientsResponse.data.data.clients - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); - - setAllClients(machineClients); - setHasMachineClients(machineClients.length > 0); - } catch (error) { - console.error( - "Error fetching roles, users, and clients:", - error - ); - } - }; - - if (open) { - fetchRolesUsersAndClients(); - } - }, [open, orgId]); - const handleSubmit = async (data: FormData) => { setIsSubmitting(true); try { diff --git a/src/components/EditInternalResourceDialog.tsx b/src/components/EditInternalResourceDialog.tsx index 2eea4bb0..231940cd 100644 --- a/src/components/EditInternalResourceDialog.tsx +++ b/src/components/EditInternalResourceDialog.tsx @@ -45,6 +45,8 @@ import { ListClientsResponse } from "@server/routers/client/listClients"; import { Tag, TagInput } from "@app/components/tags/tag-input"; import { AxiosResponse } from "axios"; import { UserType } from "@server/types/UserTypes"; +import { useQuery } from "@tanstack/react-query"; +import { orgQueries, resourceQueries } from "@app/lib/queries"; type InternalResourceData = { id: number; @@ -155,15 +157,43 @@ export default function EditInternalResourceDialog({ type FormData = z.infer; - const [allRoles, setAllRoles] = useState<{ id: string; text: string }[]>( - [] + const { data: rolesResponse = [] } = useQuery(orgQueries.roles({ orgId })); + + const allRoles = rolesResponse + .map((role) => ({ + id: role.roleId.toString(), + text: role.name + })) + .filter((role) => role.text !== "Admin"); + + const { data: usersResponse = [] } = useQuery(orgQueries.users({ orgId })); + const { data: existingClients = [] } = useQuery( + resourceQueries.resourceUsers({ resourceId: resource.id }) ); - const [allUsers, setAllUsers] = useState<{ id: string; text: string }[]>( - [] + const { data: clientsResponse = [] } = useQuery( + orgQueries.clients({ + orgId, + filters: { + filter: "machine" + } + }) ); - const [allClients, setAllClients] = useState< - { id: string; text: string }[] - >([]); + + const allUsers = usersResponse.map((user) => ({ + id: user.id.toString(), + text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` + })); + + const allClients = clientsResponse + .filter((client) => !client.userId) + .map((client) => ({ + id: client.clientId.toString(), + text: client.name + })); + + const hasMachineClients = + allClients.length > 0 || existingClients.length > 0; + const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< number | null >(null); @@ -174,7 +204,6 @@ export default function EditInternalResourceDialog({ number | null >(null); const [loadingRolesUsers, setLoadingRolesUsers] = useState(false); - const [hasMachineClients, setHasMachineClients] = useState(false); const form = useForm({ resolver: zodResolver(formSchema), @@ -194,136 +223,116 @@ export default function EditInternalResourceDialog({ const mode = form.watch("mode"); - const fetchRolesAndUsers = async () => { - setLoadingRolesUsers(true); - try { - const [ - rolesResponse, - resourceRolesResponse, - usersResponse, - resourceUsersResponse, - clientsResponse - ] = await Promise.all([ - api.get>( - `/org/${orgId}/roles` - ), - api.get>( - `/site-resource/${resource.id}/roles` - ), - api.get>( - `/org/${orgId}/users` - ), - api.get>( - `/site-resource/${resource.id}/users` - ), - api.get>( - `/org/${orgId}/clients?filter=machine&limit=1000` - ) - ]); + // const fetchRolesAndUsers = async () => { + // setLoadingRolesUsers(true); + // try { + // const [ + // // rolesResponse, + // resourceRolesResponse, + // usersResponse, + // resourceUsersResponse, + // clientsResponse + // ] = await Promise.all([ + // // api.get>( + // // `/org/${orgId}/roles` + // // ), + // api.get>( + // `/site-resource/${resource.id}/roles` + // ), + // api.get>( + // `/org/${orgId}/users` + // ), + // api.get>( + // `/site-resource/${resource.id}/users` + // ), + // api.get>( + // `/org/${orgId}/clients?filter=machine&limit=1000` + // ) + // ]); - let resourceClientsResponse: AxiosResponse< - AxiosResponse - >; - try { - resourceClientsResponse = await api.get< - AxiosResponse - >(`/site-resource/${resource.id}/clients`); - } catch { - resourceClientsResponse = { - data: { - data: { - clients: [] - } - }, - status: 200, - statusText: "OK", - headers: {} as any, - config: {} as any - } as any; - } + // let resourceClientsResponse: AxiosResponse< + // AxiosResponse + // >; + // try { + // resourceClientsResponse = await api.get< + // AxiosResponse + // >(`/site-resource/${resource.id}/clients`); + // } catch { + // resourceClientsResponse = { + // data: { + // data: { + // clients: [] + // } + // }, + // status: 200, + // statusText: "OK", + // headers: {} as any, + // config: {} as any + // } as any; + // } - setAllRoles( - rolesResponse.data.data.roles - .map((role) => ({ - id: role.roleId.toString(), - text: role.name - })) - .filter((role) => role.text !== "Admin") - ); + // // setAllRoles( + // // rolesResponse.data.data.roles + // // .map((role) => ({ + // // id: role.roleId.toString(), + // // text: role.name + // // })) + // // .filter((role) => role.text !== "Admin") + // // ); - form.setValue( - "roles", - resourceRolesResponse.data.data.roles - .map((i) => ({ - id: i.roleId.toString(), - text: i.name - })) - .filter((role) => role.text !== "Admin") - ); + // form.setValue( + // "roles", + // resourceRolesResponse.data.data.roles + // .map((i) => ({ + // id: i.roleId.toString(), + // text: i.name + // })) + // .filter((role) => role.text !== "Admin") + // ); - setAllUsers( - usersResponse.data.data.users.map((user) => ({ - id: user.id.toString(), - text: `${user.email || user.username}${user.type !== UserType.Internal ? ` (${user.idpName})` : ""}` - })) - ); + // form.setValue( + // "users", + // resourceUsersResponse.data.data.users.map((i) => ({ + // id: i.userId.toString(), + // text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` + // })) + // ); - form.setValue( - "users", - resourceUsersResponse.data.data.users.map((i) => ({ - id: i.userId.toString(), - text: `${i.email || i.username}${i.type !== UserType.Internal ? ` (${i.idpName})` : ""}` - })) - ); + // const machineClients = clientsResponse.data.data.clients + // .filter((client) => !client.userId) + // .map((client) => ({ + // id: client.clientId.toString(), + // text: client.name + // })); - const machineClients = clientsResponse.data.data.clients - .filter((client) => !client.userId) - .map((client) => ({ - id: client.clientId.toString(), - text: client.name - })); + // setAllClients(machineClients); - setAllClients(machineClients); + // const existingClients = + // resourceClientsResponse.data.data.clients.map( + // (c: { clientId: number; name: string }) => ({ + // id: c.clientId.toString(), + // text: c.name + // }) + // ); - const existingClients = - resourceClientsResponse.data.data.clients.map( - (c: { clientId: number; name: string }) => ({ - id: c.clientId.toString(), - text: c.name - }) - ); + // form.setValue("clients", existingClients); - form.setValue("clients", existingClients); + // // Show clients tag input if there are machine clients OR existing client access + // setHasMachineClients( + // machineClients.length > 0 || existingClients.length > 0 + // ); + // } catch (error) { + // console.error("Error fetching roles, users, and clients:", error); + // } finally { + // setLoadingRolesUsers(false); + // } + // }; - // Show clients tag input if there are machine clients OR existing client access - setHasMachineClients( - machineClients.length > 0 || existingClients.length > 0 - ); - } catch (error) { - console.error("Error fetching roles, users, and clients:", error); - } finally { - setLoadingRolesUsers(false); - } - }; - - useEffect(() => { - if (open) { - form.reset({ - name: resource.name, - mode: resource.mode || "host", - // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, - // proxyPort: resource.proxyPort ?? undefined, - destination: resource.destination || "", - // destinationPort: resource.destinationPort ?? undefined, - alias: resource.alias ?? null, - roles: [], - users: [], - clients: [] - }); - fetchRolesAndUsers(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [open, resource]); + // useEffect(() => { + // if (open) { + // fetchRolesAndUsers(); + // } + // }, [open, resource]); const handleSubmit = async (data: FormData) => { setIsSubmitting(true); @@ -391,7 +400,27 @@ export default function EditInternalResourceDialog({ }; return ( - + { + if (!open) { + // reset only on close + form.reset({ + name: resource.name, + mode: resource.mode || "host", + // protocol: (resource.protocol as "tcp" | "udp" | null | undefined) ?? undefined, + // proxyPort: resource.proxyPort ?? undefined, + destination: resource.destination || "", + // destinationPort: resource.destinationPort ?? undefined, + alias: resource.alias ?? null, + roles: [], + users: [], + clients: [] + }); + } + setOpen(open); + }} + > diff --git a/src/components/TanstackQueryProvider.tsx b/src/components/TanstackQueryProvider.tsx index 3d9f62e1..b052ce96 100644 --- a/src/components/TanstackQueryProvider.tsx +++ b/src/components/TanstackQueryProvider.tsx @@ -3,19 +3,29 @@ import * as React from "react"; import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { QueryClient } from "@tanstack/react-query"; +import { useEnvContext } from "@app/hooks/useEnvContext"; +import { createApiClient } from "@app/lib/api"; +import { durationToMs } from "@app/lib/durationToMs"; export type ReactQueryProviderProps = { children: React.ReactNode; }; export function TanstackQueryProvider({ children }: ReactQueryProviderProps) { + const api = createApiClient(useEnvContext()); const [queryClient] = React.useState( () => new QueryClient({ defaultOptions: { queries: { retry: 2, // retry twice by default - staleTime: 5 * 60 * 1_000 // 5 minutes + staleTime: durationToMs(5, "minutes"), + meta: { + api + } + }, + mutations: { + meta: { api } } } }) diff --git a/src/lib/queries.ts b/src/lib/queries.ts index da6f435c..260209b2 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -1,10 +1,18 @@ -import { keepPreviousData, queryOptions } from "@tanstack/react-query"; -import { durationToMs } from "./durationToMs"; import { build } from "@server/build"; -import { remote } from "./api"; -import type ResponseT from "@server/types/Response"; +import type { ListClientsResponse } from "@server/routers/client"; +import type { ListRolesResponse } from "@server/routers/role"; import type { ListSitesResponse } from "@server/routers/site"; -import type { AxiosInstance, AxiosResponse } from "axios"; +import type { + ListSiteResourceRolesResponse, + ListSiteResourceUsersResponse +} from "@server/routers/siteResource"; +import type { ListUsersResponse } from "@server/routers/user"; +import type ResponseT from "@server/types/Response"; +import { keepPreviousData, queryOptions } from "@tanstack/react-query"; +import type { AxiosResponse } from "axios"; +import z from "zod"; +import { remote } from "./api"; +import { durationToMs } from "./durationToMs"; export type ProductUpdate = { link: string | null; @@ -68,15 +76,89 @@ export const productUpdatesQueries = { }) }; -export const siteQueries = { - listPerOrg: ({ orgId, api }: { orgId: string; api: AxiosInstance }) => +export const clientFilterSchema = z.object({ + filter: z.enum(["machine", "user"]), + limit: z.int().prefault(1000).optional() +}); + +export const orgQueries = { + clients: ({ + orgId, + filters + }: { + orgId: string; + filters: z.infer; + }) => queryOptions({ - queryKey: ["SITE_PER_ORG", orgId] as const, - queryFn: async ({ signal }) => { - const res = await api.get>( - `/org/${orgId}/sites` - ); + queryKey: ["ORG", orgId, "CLIENTS", filters] as const, + queryFn: async ({ signal, meta }) => { + const sp = new URLSearchParams({ + ...filters, + limit: (filters.limit ?? 1000).toString() + }); + + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/clients?${sp.toString()}`, { signal }); + + return res.data.data.clients; + } + }), + users: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "USERS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/users`, { signal }); + + return res.data.data.users; + } + }), + roles: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "ROLES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/roles`, { signal }); + + return res.data.data.roles; + } + }), + + sites: ({ orgId }: { orgId: string }) => + queryOptions({ + queryKey: ["ORG", orgId, "SITES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/org/${orgId}/sites`, { signal }); return res.data.data.sites; } }) }; + +export const resourceQueries = { + resourceUsers: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "USERS"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site-resource/${resourceId}/users`, { signal }); + return res.data.data.users; + } + }), + resourceRoles: ({ resourceId }: { resourceId: number }) => + queryOptions({ + queryKey: ["RESOURCES", resourceId, "ROLES"] as const, + queryFn: async ({ signal, meta }) => { + const res = await meta!.api.get< + AxiosResponse + >(`/site-resource/${resourceId}/roles`, { signal }); + + return res.data.data.roles; + } + }) +}; diff --git a/src/types/tanstack-query.d.ts b/src/types/tanstack-query.d.ts new file mode 100644 index 00000000..b93f5c2c --- /dev/null +++ b/src/types/tanstack-query.d.ts @@ -0,0 +1,13 @@ +import "@tanstack/react-query"; +import type { AxiosInstance } from "axios"; + +interface Meta extends Record { + api: AxiosInstance; +} + +declare module "@tanstack/react-query" { + interface Register { + queryMeta: Meta; + mutationMeta: Meta; + } +}