diff --git a/messages/en-US.json b/messages/en-US.json index 5937595b5..584a43d79 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -2967,6 +2967,7 @@ "orgOrDomainIdMissing": "Organization or Domain ID is missing", "loadingDNSRecords": "Loading DNS records...", "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "updateAvailableInfo": "An updated version is available. Please update to the latest version for the best experience.", "client": "Client", "proxyProtocol": "Proxy Protocol Settings", "proxyProtocolDescription": "Configure Proxy Protocol to preserve client IP addresses for TCP services.", diff --git a/server/routers/client/listUserDevices.ts b/server/routers/client/listUserDevices.ts index 5a864f93b..e2a035929 100644 --- a/server/routers/client/listUserDevices.ts +++ b/server/routers/client/listUserDevices.ts @@ -420,31 +420,6 @@ export async function listUserDevices( } ); - // REMOVING THIS BECAUSE WE HAVE DIFFERENT TYPES OF CLIENTS NOW - // // Try to get the latest version, but don't block if it fails - // try { - // const latestOlmVersion = await getLatestOlmVersion(); - - // if (latestOlmVersion) { - // olmsWithUpdates.forEach((client) => { - // try { - // client.olmUpdateAvailable = semver.lt( - // client.olmVersion ? client.olmVersion : "", - // latestOlmVersion - // ); - // } catch (error) { - // client.olmUpdateAvailable = false; - // } - // }); - // } - // } catch (error) { - // // Log the error but don't let it block the response - // logger.warn( - // "Failed to check for OLM updates, continuing without update info:", - // error - // ); - // } - return response(res, { data: { devices: olmsWithUpdates, diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index 6bb095030..09e4dfb5a 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,20 +1,22 @@ +import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; import { db, exitNodes, + labels, newts, orgs, remoteExitNodes, roleSites, + siteLabels, siteNetworks, siteResources, - targets, sites, + targets, userSites, - labels, - siteLabels, type Label } from "@server/db"; import { regionalCache as cache } from "#dynamic/lib/cache"; +import { tierMatrix } from "@server/lib/billing/tierMatrix"; import response from "@server/lib/response"; import logger from "@server/logger"; import { OpenAPITags, registry } from "@server/openApi"; @@ -23,102 +25,8 @@ import type { PaginatedResponse } from "@server/types/Pagination"; import { and, asc, desc, eq, inArray, like, or, sql } from "drizzle-orm"; import { NextFunction, Request, Response } from "express"; import createHttpError from "http-errors"; -import semver from "semver"; import { z } from "zod"; import { fromError } from "zod-validation-error"; -import { isLicensedOrSubscribed } from "#dynamic/lib/isLicencedOrSubscribed"; -import { tierMatrix } from "@server/lib/billing/tierMatrix"; - -// Stale-while-revalidate: keeps the last successfully fetched version so that -// a transient network failure / timeout does not flip every site back to -// newtUpdateAvailable: false. -let staleNewtVersion: string | null = null; - -async function getLatestNewtVersion(): Promise { - try { - const cachedVersion = await cache.get( - "cache:latestNewtVersion" - ); - if (cachedVersion) { - return cachedVersion; - } - - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 1500); - - const response = await fetch( - "https://api.github.com/repos/fosrl/newt/tags", - { - signal: controller.signal - } - ); - - clearTimeout(timeoutId); - - if (!response.ok) { - logger.warn( - `Failed to fetch latest Newt version from GitHub: ${response.status} ${response.statusText}` - ); - return staleNewtVersion; - } - - let tags = await response.json(); - if (!Array.isArray(tags) || tags.length === 0) { - logger.warn("No tags found for Newt repository"); - return staleNewtVersion; - } - - // Remove release-candidates, then sort descending by semver so that - // duplicate tags (e.g. "1.10.3" and "v1.10.3") and any ordering quirks - // from the GitHub API do not cause an older tag to be selected. - tags = tags.filter((tag: any) => !tag.name.includes("rc")); - tags.sort((a: any, b: any) => { - const va = semver.coerce(a.name); - const vb = semver.coerce(b.name); - if (!va && !vb) return 0; - if (!va) return 1; - if (!vb) return -1; - return semver.rcompare(va, vb); - }); - - // Deduplicate: keep only the first (highest) entry per normalised version - const seen = new Set(); - tags = tags.filter((tag: any) => { - const normalised = semver.coerce(tag.name)?.version; - if (!normalised || seen.has(normalised)) return false; - seen.add(normalised); - return true; - }); - - if (tags.length === 0) { - logger.warn("No valid semver tags found for Newt repository"); - return staleNewtVersion; - } - - const latestVersion = tags[0].name; - - staleNewtVersion = latestVersion; - await cache.set("cache:latestNewtVersion", latestVersion, 3600); - - return latestVersion; - } catch (error: any) { - if (error.name === "AbortError") { - logger.warn( - "Request to fetch latest Newt version timed out (1.5s)" - ); - } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { - logger.warn( - "Connection timeout while fetching latest Newt version" - ); - } else { - logger.warn( - "Error fetching latest Newt version:", - error.message || error - ); - } - return staleNewtVersion; - } -} const listSitesParamsSchema = z.strictObject({ orgId: z.string() @@ -449,9 +357,6 @@ export async function listSites( const totalCount = Number(countRows[0]?.count ?? 0); - // Get latest version asynchronously without blocking the response - const latestNewtVersionPromise = getLatestNewtVersion(); - const siteIds = rows.map((site) => site.siteId); let labelsForSites: Array<{ @@ -494,36 +399,6 @@ export async function listSites( return { ...siteWithUpdate, labels: labelsForSite }; }); - // Try to get the latest version, but don't block if it fails - try { - const latestNewtVersion = await latestNewtVersionPromise; - - if (latestNewtVersion) { - sitesWithUpdates.forEach((site) => { - if ( - site.type === "newt" && - site.newtVersion && - latestNewtVersion - ) { - try { - site.newtUpdateAvailable = semver.lt( - site.newtVersion, - latestNewtVersion - ); - } catch (error) { - site.newtUpdateAvailable = false; - } - } - }); - } - } catch (error) { - // Log the error but don't let it block the response - logger.warn( - "Failed to check for Newt updates, continuing without update info:", - error - ); - } - const sitesPayload = sitesWithUpdates.map((site) => site.type === "local" ? { ...site, online: undefined } : site ); diff --git a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx index cfdc5a996..8573e8199 100644 --- a/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx +++ b/src/app/[orgId]/settings/clients/user/[niceId]/general/page.tsx @@ -41,6 +41,13 @@ import { useParams } from "next/navigation"; import { FaApple, FaWindows, FaLinux } from "react-icons/fa"; import { SiAndroid } from "react-icons/si"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "@app/components/ui/info-popup"; function formatTimestamp(timestamp: number | null | undefined): string { if (!timestamp) return "-"; @@ -166,6 +173,34 @@ export default function GeneralPage() { }>(null); const [isCheckingCache, setIsCheckingCache] = useState(false); const [isRebuildingCache, setIsRebuildingCache] = useState(false); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + const latestPlatformVersions = data.data?.data; + + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + if (client.agent && client.olmVersion && latestPlatformVersions) { + const agent = agentVersionMap[ + client.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + client.olmVersion, + agentVersion.latestVersion + ); + } + } // get "imp" from local storage to determine if we should show the verify button (imp = "1" means show) const showVerifyButton = @@ -451,11 +486,21 @@ export default function GeneralPage() { {t("agent")} - - {client.agent + - " v" + - client.olmVersion} - +
+ + {client.agent + + " v" + + client.olmVersion} + + + {updateAvailable && ( + + )} +
diff --git a/src/components/MachineClientsTable.tsx b/src/components/MachineClientsTable.tsx index eba0c9762..3094348e3 100644 --- a/src/components/MachineClientsTable.tsx +++ b/src/components/MachineClientsTable.tsx @@ -11,10 +11,10 @@ import { } from "@app/components/ui/dropdown-menu"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { useNavigationContext } from "@app/hooks/useNavigationContext"; +import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { toast } from "@app/hooks/useToast"; import { createApiClient, formatAxiosError } from "@app/lib/api"; -import { cn } from "@app/lib/cn"; import { getNextSortOrder, getSortDirection } from "@app/lib/sortColumn"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import type { PaginationState } from "@tanstack/react-table"; @@ -31,15 +31,18 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { startTransition, useMemo, useState, useTransition } from "react"; import { useDebouncedCallback } from "use-debounce"; -import z from "zod"; import { ColumnFilterButton } from "./ColumnFilterButton"; -import { type SelectedLabel } from "./labels-selector"; +import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; -import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; -import { useLocalLabels } from "@app/hooks/useLocalLabels"; -import { useOptimisticLabels } from "@app/hooks/useOptimisticLabels"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; +import { InfoPopup } from "./ui/info-popup"; export type ClientRow = { id: number; @@ -101,6 +104,9 @@ export default function MachineClientsTable({ const { isPaidUser } = usePaidStatus(); const isLabelFeatureEnabled = isPaidUser(tierMatrix.labels); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultMachineColumnVisibility = { subnet: false, @@ -375,6 +381,37 @@ export default function MachineClientsTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -386,9 +423,9 @@ export default function MachineClientsTable({ ) : ( "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } diff --git a/src/components/PrivateResourceForm.tsx b/src/components/PrivateResourceForm.tsx index 1f7d56b18..6082fd20e 100644 --- a/src/components/PrivateResourceForm.tsx +++ b/src/components/PrivateResourceForm.tsx @@ -411,9 +411,9 @@ export function PrivateResourceForm({ type FormData = z.infer; - const rolesQuery = useQuery(orgQueries.roles({ orgId })); - const usersQuery = useQuery(orgQueries.users({ orgId })); - const clientsQuery = useQuery(orgQueries.machineClients({ orgId })); + const clientsQuery = useQuery( + orgQueries.machineClients({ orgId, perPage: 1 }) + ); const resourceRolesQuery = useQuery({ ...resourceQueries.siteResourceRoles({ siteResourceId: siteResourceId ?? 0 @@ -433,13 +433,6 @@ export function PrivateResourceForm({ enabled: siteResourceId != null }); - const allRoles = (rolesQuery.data ?? []) - .map((r) => ({ id: r.roleId.toString(), text: r.name })) - .filter((r) => r.text !== "Admin"); - const allUsers = (usersQuery.data ?? []).map((u) => ({ - id: u.id.toString(), - text: `${getUserDisplayName({ email: u.email, username: u.username })}${u.type !== UserType.Internal ? ` (${u.idpName})` : ""}` - })); const allClients = (clientsQuery.data ?? []) .filter((c) => !c.userId) .map((c) => ({ id: c.clientId.toString(), text: c.name })); @@ -478,8 +471,6 @@ export function PrivateResourceForm({ } const loadingRolesUsers = - rolesQuery.isLoading || - usersQuery.isLoading || clientsQuery.isLoading || (siteResourceId != null && (resourceRolesQuery.isLoading || @@ -488,16 +479,6 @@ export function PrivateResourceForm({ const hasMachineClients = allClients.length > 0; - const [activeRolesTagIndex, setActiveRolesTagIndex] = useState< - number | null - >(null); - const [activeUsersTagIndex, setActiveUsersTagIndex] = useState< - number | null - >(null); - const [activeClientsTagIndex, setActiveClientsTagIndex] = useState< - number | null - >(null); - const [sshServerMode, setSshServerMode] = useState<"standard" | "native">( () => { if (variant === "edit" && resource) { diff --git a/src/components/SitesTable.tsx b/src/components/SitesTable.tsx index 8c3036c4a..5b5ac1db1 100644 --- a/src/components/SitesTable.tsx +++ b/src/components/SitesTable.tsx @@ -55,6 +55,9 @@ import { usePaidStatus } from "@app/hooks/usePaidStatus"; import { tierMatrix } from "@server/lib/billing/tierMatrix"; import { LabelColumnFilterButton } from "./LabelColumnFilterButton"; import { LabelsTableCell } from "./LabelsTableCell"; +import { useQuery } from "@tanstack/react-query"; +import { productUpdatesQueries } from "@app/lib/queries"; +import semver from "semver"; export type SiteRow = { id: number; @@ -113,12 +116,11 @@ export default function SitesTable({ const api = createApiClient(useEnvContext()); const t = useTranslations(); - // useEffect(() => { - // const interval = setInterval(() => { - // router.refresh(); - // }, 30_000); - // return () => clearInterval(interval); - // }, []); + const { data: latestVersions } = useQuery( + productUpdatesQueries.latestVersion(true) + ); + + const latestNewtVersion = latestVersions?.data?.newt?.latestVersion; const booleanSearchFilterSchema = z .enum(["true", "false"]) @@ -333,6 +335,11 @@ export default function SitesTable({ cell: ({ row }) => { const originalRow = row.original; + let updateAvailable = + latestNewtVersion && + originalRow.newtVersion && + semver.lt(originalRow.newtVersion, latestNewtVersion); + if (originalRow.type === "newt") { return (
@@ -346,7 +353,7 @@ export default function SitesTable({ )}
- {originalRow.newtUpdateAvailable && ( + {updateAvailable && ( @@ -561,7 +568,7 @@ export default function SitesTable({ } return cols; - }, [isLabelFeatureEnabled, orgId, t, searchParams]); + }, [isLabelFeatureEnabled, orgId, t, searchParams, latestNewtVersion]); function toggleSort(column: string) { const newSearch = getNextSortOrder(column, searchParams); diff --git a/src/components/UserDevicesTable.tsx b/src/components/UserDevicesTable.tsx index 8ee2ddb87..17a82dfc9 100644 --- a/src/components/UserDevicesTable.tsx +++ b/src/components/UserDevicesTable.tsx @@ -38,6 +38,12 @@ import { ColumnFilterButton } from "./ColumnFilterButton"; import IdpTypeBadge from "./IdpTypeBadge"; import { Badge } from "./ui/badge"; import { ControlledDataTable } from "./ui/controlled-data-table"; +import { + productUpdatesQueries, + type LatestVersionResponse +} from "@app/lib/queries"; +import { useQuery } from "@tanstack/react-query"; +import semver from "semver"; export type ClientRow = { id: number; @@ -100,6 +106,9 @@ export default function UserDevicesTable({ searchParams } = useNavigationContext(); const [isRefreshing, startTransition] = useTransition(); + const data = useQuery(productUpdatesQueries.latestVersion(true)); + + const latestPlatformVersions = data.data?.data; const defaultUserColumnVisibility = { subnet: false, @@ -555,6 +564,37 @@ export default function UserDevicesTable({ cell: ({ row }) => { const originalRow = row.original; + const agentVersionMap: Record = { + "Pangolin Windows": "windows", + "Pangolin Android": "android", + "Pangolin iOS": "ios", + "Pangolin iPadOS": "ios", + "Pangolin macOS": "mac", + "Pangolin CLI": "cli", + "Olm CLI": "olm" + }; + + let updateAvailable = false; + + if ( + originalRow.olmVersion && + originalRow.agent && + latestPlatformVersions + ) { + const agent = agentVersionMap[ + originalRow.agent + ] as keyof LatestVersionResponse; + + if (agent in latestPlatformVersions) { + const agentVersion = latestPlatformVersions[agent]; + + updateAvailable = semver.lt( + originalRow.olmVersion, + agentVersion.latestVersion + ); + } + } + return (
{originalRow.agent && originalRow.olmVersion ? ( @@ -567,9 +607,9 @@ export default function UserDevicesTable({ "-" )} - {/*originalRow.olmUpdateAvailable && ( - - )*/} + {updateAvailable && ( + + )}
); } @@ -714,7 +754,7 @@ export default function UserDevicesTable({ } return allOptions; - }, [t]); + }, [t, latestPlatformVersions]); function handleFilterChange( column: string, diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 7d224c7b1..b8a50a908 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -63,6 +63,34 @@ export type LatestVersionResponse = { latestVersion: string; releaseNotes: string; }; + newt: { + latestVersion: string; + releaseNotes: string; + }; + cli: { + latestVersion: string; + releaseNotes: string; + }; + "panglin-node": { + latestVersion: string; + releaseNotes: string; + }; + windows: { + latestVersion: string; + releaseNotes: string; + }; + android: { + latestVersion: string; + releaseNotes: string; + }; + mac: { + latestVersion: string; + releaseNotes: string; + }; + ios: { + latestVersion: string; + releaseNotes: string; + }; }; export const productUpdatesQueries = {