From 6866282a9ece12bdd0168ffe936a61dbf4b3a787 Mon Sep 17 00:00:00 2001 From: nfebe Date: Sun, 22 Mar 2026 20:51:34 +0100 Subject: [PATCH] feat(ui): Add agent version compatibility check Validate agent version against min/max bounds on each health check. Show a warning banner when incompatible. Dev builds (suffix -dev, -alpha, -beta, -rc, etc.) show a dismissable warning. Non-dev incompatible versions cannot be dismissed. Signed-off-by: nfebe --- .eslintrc.cjs | 3 ++ src/__tests__/version.test.ts | 72 +++++++++++++++++++++++++++++++++ src/layouts/DashboardLayout.vue | 45 +++++++++++++++++++++ src/stores/stats.ts | 13 ++++++ src/utils/version.ts | 59 +++++++++++++++++++++++++++ src/vite-env.d.ts | 2 + 6 files changed, 194 insertions(+) create mode 100644 src/__tests__/version.test.ts create mode 100644 src/utils/version.ts diff --git a/.eslintrc.cjs b/.eslintrc.cjs index 8f083de..3d66c09 100644 --- a/.eslintrc.cjs +++ b/.eslintrc.cjs @@ -5,6 +5,9 @@ module.exports = { browser: true, es2021: true, }, + globals: { + __APP_VERSION__: 'readonly', + }, extends: [ 'eslint:recommended', 'plugin:vue/vue3-recommended', diff --git a/src/__tests__/version.test.ts b/src/__tests__/version.test.ts new file mode 100644 index 0000000..ab5b935 --- /dev/null +++ b/src/__tests__/version.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect } from "vitest"; +import { compareVersions, isAgentCompatible } from "@/utils/version"; + +describe("compareVersions", () => { + it("returns 0 for equal versions", () => { + expect(compareVersions("1.2.3", "1.2.3")).toBe(0); + }); + + it("returns -1 when a < b", () => { + expect(compareVersions("0.1.4", "0.1.5")).toBe(-1); + expect(compareVersions("0.1.9", "0.2.0")).toBe(-1); + expect(compareVersions("0.9.9", "1.0.0")).toBe(-1); + }); + + it("returns 1 when a > b", () => { + expect(compareVersions("0.1.5", "0.1.4")).toBe(1); + expect(compareVersions("1.0.0", "0.9.9")).toBe(1); + }); + + it("handles v prefix", () => { + expect(compareVersions("v1.0.0", "1.0.0")).toBe(0); + }); + + it("handles wildcard x as infinity", () => { + expect(compareVersions("0.5.0", "0.x.x")).toBe(-1); + expect(compareVersions("1.0.0", "0.x.x")).toBe(1); + }); +}); + +describe("isAgentCompatible", () => { + it("returns compatible for unknown version", () => { + const result = isAgentCompatible("unknown"); + expect(result.compatible).toBe(true); + }); + + it("returns compatible for empty version", () => { + const result = isAgentCompatible(""); + expect(result.compatible).toBe(true); + }); + + it("returns compatible for valid version", () => { + const result = isAgentCompatible("0.1.5"); + expect(result.compatible).toBe(true); + }); + + it("returns incompatible for old version", () => { + const result = isAgentCompatible("0.1.3"); + expect(result.compatible).toBe(false); + expect(result.message).toContain("too old"); + }); + + it("returns compatible for minimum version", () => { + const result = isAgentCompatible("0.1.4"); + expect(result.compatible).toBe(true); + }); + + it("returns incompatible for version beyond max", () => { + const result = isAgentCompatible("1.0.0"); + expect(result.compatible).toBe(false); + expect(result.message).toContain("newer than supported"); + }); + + it("flags dev versions as incompatible but dismissable", () => { + const versions = ["dev", "0.1.5-dev", "0.0.1-alpha", "1.0.0-rc.1", "0.2.0-beta", "0.0.0-snapshot"]; + for (const v of versions) { + const result = isAgentCompatible(v); + expect(result.compatible).toBe(false); + expect(result.dev).toBe(true); + expect(result.message).toContain("development build"); + } + }); +}); diff --git a/src/layouts/DashboardLayout.vue b/src/layouts/DashboardLayout.vue index 4ccb1e5..83d1079 100644 --- a/src/layouts/DashboardLayout.vue +++ b/src/layouts/DashboardLayout.vue @@ -420,6 +420,15 @@ +
+ + {{ statsStore.agentCompatibilityMessage }} + UI v{{ uiVersion }} / Agent v{{ statsStore.agentVersion }} + +
+
@@ -439,6 +448,7 @@ const route = useRoute(); const router = useRouter(); const statsStore = useStatsStore(); const authStore = useAuthStore(); +const uiVersion = __APP_VERSION__; const sidebarCollapsed = ref(false); const isRefreshing = ref(false); const envDropdownOpen = ref(false); @@ -1101,4 +1111,39 @@ onMounted(() => { padding: 1.5rem; background: #f8fafc; } + +.version-warning { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + background: var(--color-warning-50); + border-bottom: 1px solid var(--color-warning-500); + color: var(--color-warning-700); + font-size: var(--text-sm); +} + +.version-warning .pi { + color: var(--color-warning-600); +} + +.version-warning .version-details { + margin-left: auto; + font-size: var(--text-xs); + color: var(--color-warning-600); +} + +.version-warning .dismiss-btn { + background: none; + border: none; + color: var(--color-warning-700); + cursor: pointer; + padding: 0.25rem; + margin-left: 0.5rem; + border-radius: var(--radius-sm); +} + +.version-warning .dismiss-btn:hover { + background: var(--color-warning-100); +} diff --git a/src/stores/stats.ts b/src/stores/stats.ts index e4ddeae..5869096 100644 --- a/src/stores/stats.ts +++ b/src/stores/stats.ts @@ -1,12 +1,17 @@ import { defineStore } from "pinia"; import { ref, reactive } from "vue"; import { healthApi } from "@/services/api"; +import { isAgentCompatible } from "@/utils/version"; export const useStatsStore = defineStore("stats", () => { const loading = ref(false); const lastUpdated = ref(null); const agentOnline = ref(false); const agentVersion = ref("unknown"); + const agentCompatible = ref(true); + const agentCompatibilityMessage = ref(""); + const agentDevBuild = ref(false); + const versionWarningDismissed = ref(false); const deployments = reactive({ total: 0, @@ -52,6 +57,10 @@ export const useStatsStore = defineStore("stats", () => { agentOnline.value = healthRes.data.status === "healthy"; if (healthRes.data.version?.version) { agentVersion.value = healthRes.data.version.version; + const compat = isAgentCompatible(agentVersion.value); + agentCompatible.value = compat.compatible; + agentCompatibilityMessage.value = compat.message; + agentDevBuild.value = compat.dev || false; } const statsRes = await healthApi.stats(); @@ -112,6 +121,10 @@ export const useStatsStore = defineStore("stats", () => { lastUpdated, agentOnline, agentVersion, + agentCompatible, + agentCompatibilityMessage, + agentDevBuild, + versionWarningDismissed, deployments, containers, docker, diff --git a/src/utils/version.ts b/src/utils/version.ts new file mode 100644 index 0000000..0774d92 --- /dev/null +++ b/src/utils/version.ts @@ -0,0 +1,59 @@ +export const MIN_AGENT_VERSION = "0.1.4"; +export const MAX_AGENT_VERSION = "0.x.x"; + +function parseVersion(version: string): number[] { + return version + .replace(/^v/, "") + .split(".") + .map((p) => (p === "x" ? Infinity : parseInt(p, 10) || 0)); +} + +export function compareVersions(a: string, b: string): number { + const pa = parseVersion(a); + const pb = parseVersion(b); + const len = Math.max(pa.length, pb.length); + + for (let i = 0; i < len; i++) { + const na = pa[i] ?? 0; + const nb = pb[i] ?? 0; + if (na < nb) return -1; + if (na > nb) return 1; + } + return 0; +} + +export function isAgentCompatible(agentVersion: string): { + compatible: boolean; + dev?: boolean; + message: string; +} { + if (!agentVersion || agentVersion === "unknown") { + return { compatible: true, message: "" }; + } + + const version = agentVersion.replace(/^v/, ""); + + if (/^dev$|-(dev|alpha|beta|rc|snapshot|canary)/i.test(version)) { + return { + compatible: false, + dev: true, + message: `Agent ${version} is a development build. Some features may not work as expected.`, + }; + } + + if (compareVersions(version, MIN_AGENT_VERSION) < 0) { + return { + compatible: false, + message: `Agent ${version} is too old. This UI requires agent ${MIN_AGENT_VERSION} or newer.`, + }; + } + + if (compareVersions(version, MAX_AGENT_VERSION) > 0) { + return { + compatible: false, + message: `Agent ${version} is newer than supported. This UI supports up to agent ${MAX_AGENT_VERSION}.`, + }; + } + + return { compatible: true, message: "" }; +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 2437528..b2646aa 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1,5 +1,7 @@ /// +declare const __APP_VERSION__: string; + interface ImportMetaEnv { readonly VITE_API_URL: string; readonly BASE_URL: string;