diff --git a/messages/en-US.json b/messages/en-US.json index 92246b66..76610e35 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1145,5 +1145,7 @@ "settingsErrorUpdate": "Failed to update settings", "settingsErrorUpdateDescription": "An error occurred while updating settings", "sidebarCollapse": "Collapse", - "sidebarExpand": "Expand" + "sidebarExpand": "Expand", + "newtUpdateAvailable": "Update Available", + "newtUpdateAvailableInfo": "A new version of Newt is available. Please update to the latest version for the best experience." } diff --git a/server/routers/site/listSites.ts b/server/routers/site/listSites.ts index ddbe7d43..6227ef28 100644 --- a/server/routers/site/listSites.ts +++ b/server/routers/site/listSites.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, newts } from "@server/db"; import { orgs, roleSites, sites, userSites } from "@server/db"; import logger from "@server/logger"; import HttpCode from "@server/types/HttpCode"; @@ -9,6 +9,42 @@ import createHttpError from "http-errors"; import { z } from "zod"; import { fromError } from "zod-validation-error"; import { OpenAPITags, registry } from "@server/openApi"; +import NodeCache from "node-cache"; +import semver from "semver"; + +const newtVersionCache = new NodeCache({ stdTTL: 3600 }); // 1 hours in seconds + +async function getLatestNewtVersion(): Promise { + try { + const cachedVersion = newtVersionCache.get("latestNewtVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const response = await fetch( + "https://api.github.com/repos/fosrl/newt/tags" + ); + if (!response.ok) { + logger.warn("Failed to fetch latest Newt version from GitHub"); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Newt repository"); + return null; + } + + const latestVersion = tags[0].name; + + newtVersionCache.set("latestNewtVersion", latestVersion); + + return latestVersion; + } catch (error) { + logger.error("Error fetching latest Newt version:", error); + return null; + } +} const listSitesParamsSchema = z .object({ @@ -45,9 +81,11 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { type: sites.type, online: sites.online, address: sites.address, + newtVersion: newts.version }) .from(sites) .leftJoin(orgs, eq(sites.orgId, orgs.orgId)) + .leftJoin(newts, eq(newts.siteId, sites.siteId)) .where( and( inArray(sites.siteId, accessibleSiteIds), @@ -56,8 +94,12 @@ function querySites(orgId: string, accessibleSiteIds: number[]) { ); } +type SiteWithUpdateAvailable = Awaited>[0] & { + newtUpdateAvailable?: boolean; +}; + export type ListSitesResponse = { - sites: Awaited>; + sites: SiteWithUpdateAvailable[]; pagination: { total: number; limit: number; offset: number }; }; @@ -148,9 +190,36 @@ export async function listSites( const totalCountResult = await countQuery; const totalCount = totalCountResult[0].count; + const latestNewtVersion = await getLatestNewtVersion(); + + const sitesWithUpdates: SiteWithUpdateAvailable[] = sitesList.map( + (site) => { + const siteWithUpdate: SiteWithUpdateAvailable = { ...site }; + + if ( + site.type === "newt" && + site.newtVersion && + latestNewtVersion + ) { + try { + siteWithUpdate.newtUpdateAvailable = semver.lt( + site.newtVersion, + latestNewtVersion + ); + } catch (error) { + siteWithUpdate.newtUpdateAvailable = false; + } + } else { + siteWithUpdate.newtUpdateAvailable = false; + } + + return siteWithUpdate; + } + ); + return response(res, { data: { - sites: sitesList, + sites: sitesWithUpdates, pagination: { total: totalCount, limit, diff --git a/src/app/[orgId]/settings/sites/SitesTable.tsx b/src/app/[orgId]/settings/sites/SitesTable.tsx index 9535ba7c..da84b75b 100644 --- a/src/app/[orgId]/settings/sites/SitesTable.tsx +++ b/src/app/[orgId]/settings/sites/SitesTable.tsx @@ -29,6 +29,8 @@ import { useEnvContext } from "@app/hooks/useEnvContext"; import CreateSiteFormModal from "./CreateSiteModal"; import { useTranslations } from "next-intl"; import { parseDataSize } from "@app/lib/dataSize"; +import { Badge } from "@app/components/ui/badge"; +import { InfoPopup } from "@app/components/ui/info-popup"; export type SiteRow = { id: number; @@ -38,6 +40,8 @@ export type SiteRow = { mbOut: string; orgId: string; type: "newt" | "wireguard"; + newtVersion?: string; + newtUpdateAvailable?: boolean; online: boolean; address?: string; }; @@ -238,8 +242,22 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) { if (originalRow.type === "newt") { return ( -
- Newt +
+ +
+ Newt + {originalRow.newtVersion && ( + + v{originalRow.newtVersion} + + )} +
+
+ {originalRow.newtUpdateAvailable && ( + + )}
); } diff --git a/src/app/[orgId]/settings/sites/page.tsx b/src/app/[orgId]/settings/sites/page.tsx index 9c229c24..10bcad53 100644 --- a/src/app/[orgId]/settings/sites/page.tsx +++ b/src/app/[orgId]/settings/sites/page.tsx @@ -49,7 +49,9 @@ export default async function SitesPage(props: SitesPageProps) { mbOut: formatSize(site.megabytesOut || 0, site.type), orgId: params.orgId, type: site.type as any, - online: site.online + online: site.online, + newtVersion: site.newtVersion || undefined, + newtUpdateAvailable: site.newtUpdateAvailable || false, }; });