diff --git a/messages/en-US.json b/messages/en-US.json index 4cffaf98..ffbceb19 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1891,5 +1891,7 @@ "cannotbeUndone": "This can not be undone.", "toConfirm": "to confirm", "deleteClientQuestion": "Are you sure you want to remove the client from the site and organization?", - "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site." + "clientMessageRemove": "Once removed, the client will no longer be able to connect to the site.", + "olmUpdateAvailableInfo": "An updated version of Olm is available. Please update to the latest version for the best experience.", + "client": "Client" } diff --git a/server/routers/client/listClients.ts b/server/routers/client/listClients.ts index ff03b2e0..209b54b4 100644 --- a/server/routers/client/listClients.ts +++ b/server/routers/client/listClients.ts @@ -1,4 +1,4 @@ -import { db } from "@server/db"; +import { db, olms } from "@server/db"; import { clients, orgs, @@ -16,6 +16,67 @@ 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 olmVersionCache = new NodeCache({ stdTTL: 3600 }); + +async function getLatestOlmVersion(): Promise { + try { + const cachedVersion = olmVersionCache.get("latestOlmVersion"); + if (cachedVersion) { + return cachedVersion; + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 1500); + + const response = await fetch( + "https://api.github.com/repos/fosrl/olm/tags", + { + signal: controller.signal + } + ); + + clearTimeout(timeoutId); + + if (!response.ok) { + logger.warn( + `Failed to fetch latest Olm version from GitHub: ${response.status} ${response.statusText}` + ); + return null; + } + + const tags = await response.json(); + if (!Array.isArray(tags) || tags.length === 0) { + logger.warn("No tags found for Olm repository"); + return null; + } + + const latestVersion = tags[0].name; + + olmVersionCache.set("latestOlmVersion", latestVersion); + + return latestVersion; + } catch (error: any) { + if (error.name === "AbortError") { + logger.warn( + "Request to fetch latest Olm version timed out (1.5s)" + ); + } else if (error.cause?.code === "UND_ERR_CONNECT_TIMEOUT") { + logger.warn( + "Connection timeout while fetching latest Olm version" + ); + } else { + logger.warn( + "Error fetching latest Olm version:", + error.message || error + ); + } + return null; + } +} + const listClientsParamsSchema = z .object({ @@ -50,10 +111,12 @@ function queryClients(orgId: string, accessibleClientIds: number[]) { megabytesOut: clients.megabytesOut, orgName: orgs.name, type: clients.type, - online: clients.online + online: clients.online, + olmVersion: olms.version }) .from(clients) .leftJoin(orgs, eq(clients.orgId, orgs.orgId)) + .leftJoin(olms, eq(clients.clientId, olms.clientId)) .where( and( inArray(clients.clientId, accessibleClientIds), @@ -77,12 +140,20 @@ async function getSiteAssociations(clientIds: number[]) { .where(inArray(clientSites.clientId, clientIds)); } +type OlmWithUpdateAvailable = Awaited>[0] & { + olmUpdateAvailable?: boolean; +}; + + export type ListClientsResponse = { - clients: Array>[0] & { sites: Array<{ - siteId: number; - siteName: string | null; - siteNiceId: string | null; - }> }>; + clients: Array>[0] & { + sites: Array<{ + siteId: number; + siteName: string | null; + siteNiceId: string | null; + }> + olmUpdateAvailable?: boolean; + }>; pagination: { total: number; limit: number; offset: number }; }; @@ -206,6 +277,43 @@ export async function listClients( sites: sitesByClient[client.clientId] || [] })); + const latestOlVersionPromise = getLatestOlmVersion(); + + const olmsWithUpdates: OlmWithUpdateAvailable[] = clientsWithSites.map( + (client) => { + const OlmWithUpdate: OlmWithUpdateAvailable = { ...client }; + // Initially set to false, will be updated if version check succeeds + OlmWithUpdate.olmUpdateAvailable = false; + return OlmWithUpdate; + } + ); + + // Try to get the latest version, but don't block if it fails + try { + const latestOlVersion = await latestOlVersionPromise; + + if (latestOlVersion) { + olmsWithUpdates.forEach((client) => { + try { + client.olmUpdateAvailable = semver.lt( + client.olmVersion ? client.olmVersion : "", + latestOlVersion + ); + } 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: { clients: clientsWithSites, diff --git a/src/app/[orgId]/settings/clients/page.tsx b/src/app/[orgId]/settings/clients/page.tsx index 0813ad3c..fd73b736 100644 --- a/src/app/[orgId]/settings/clients/page.tsx +++ b/src/app/[orgId]/settings/clients/page.tsx @@ -44,7 +44,9 @@ export default async function ClientsPage(props: ClientsPageProps) { mbIn: formatSize(client.megabytesIn || 0), mbOut: formatSize(client.megabytesOut || 0), orgId: params.orgId, - online: client.online + online: client.online, + olmVersion: client.olmVersion || undefined, + olmUpdateAvailable: client.olmUpdateAvailable || false, }; }); diff --git a/src/components/ClientsTable.tsx b/src/components/ClientsTable.tsx index 95a6b55d..471cdf28 100644 --- a/src/components/ClientsTable.tsx +++ b/src/components/ClientsTable.tsx @@ -26,6 +26,8 @@ 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"; export type ClientRow = { id: number; @@ -36,6 +38,8 @@ export type ClientRow = { mbOut: string; orgId: string; online: boolean; + olmVersion?: string; + olmUpdateAvailable: boolean; }; type ClientTableProps = { @@ -204,6 +208,45 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { ); } }, + { + accessorKey: "client", + header: ({ column }) => { + return ( + + ); + }, + cell: ({ row }) => { + const originalRow = row.original; + + return ( +
+ +
+ Olm + {originalRow.olmVersion && ( + + v{originalRow.olmVersion} + + )} +
+
+ {originalRow.olmUpdateAvailable && ( + + )} +
+ ); + } + }, { accessorKey: "subnet", header: ({ column }) => { @@ -282,7 +325,7 @@ export default function ClientsTable({ clients, orgId }: ClientTableProps) { {t("deleteClientQuestion")}

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

}