Skip to content

Commit d7bbabd

Browse files
committed
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 <fenn25.fn@gmail.com>
1 parent 1a8b1ed commit d7bbabd

6 files changed

Lines changed: 193 additions & 0 deletions

File tree

.eslintrc.cjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@ module.exports = {
55
browser: true,
66
es2021: true,
77
},
8+
globals: {
9+
__APP_VERSION__: 'readonly',
10+
},
811
extends: [
912
'eslint:recommended',
1013
'plugin:vue/vue3-recommended',

src/__tests__/version.test.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { describe, it, expect } from "vitest";
2+
import { compareVersions, isAgentCompatible } from "@/utils/version";
3+
4+
describe("compareVersions", () => {
5+
it("returns 0 for equal versions", () => {
6+
expect(compareVersions("1.2.3", "1.2.3")).toBe(0);
7+
});
8+
9+
it("returns -1 when a < b", () => {
10+
expect(compareVersions("0.1.4", "0.1.5")).toBe(-1);
11+
expect(compareVersions("0.1.9", "0.2.0")).toBe(-1);
12+
expect(compareVersions("0.9.9", "1.0.0")).toBe(-1);
13+
});
14+
15+
it("returns 1 when a > b", () => {
16+
expect(compareVersions("0.1.5", "0.1.4")).toBe(1);
17+
expect(compareVersions("1.0.0", "0.9.9")).toBe(1);
18+
});
19+
20+
it("handles v prefix", () => {
21+
expect(compareVersions("v1.0.0", "1.0.0")).toBe(0);
22+
});
23+
24+
it("handles wildcard x as infinity", () => {
25+
expect(compareVersions("0.5.0", "0.x.x")).toBe(-1);
26+
expect(compareVersions("1.0.0", "0.x.x")).toBe(1);
27+
});
28+
});
29+
30+
describe("isAgentCompatible", () => {
31+
it("returns compatible for unknown version", () => {
32+
const result = isAgentCompatible("unknown");
33+
expect(result.compatible).toBe(true);
34+
});
35+
36+
it("returns compatible for empty version", () => {
37+
const result = isAgentCompatible("");
38+
expect(result.compatible).toBe(true);
39+
});
40+
41+
it("returns compatible for valid version", () => {
42+
const result = isAgentCompatible("0.1.5");
43+
expect(result.compatible).toBe(true);
44+
});
45+
46+
it("returns incompatible for old version", () => {
47+
const result = isAgentCompatible("0.1.3");
48+
expect(result.compatible).toBe(false);
49+
expect(result.message).toContain("too old");
50+
});
51+
52+
it("returns compatible for minimum version", () => {
53+
const result = isAgentCompatible("0.1.4");
54+
expect(result.compatible).toBe(true);
55+
});
56+
57+
it("returns incompatible for version beyond max", () => {
58+
const result = isAgentCompatible("1.0.0");
59+
expect(result.compatible).toBe(false);
60+
expect(result.message).toContain("newer than supported");
61+
});
62+
63+
it("flags dev versions as incompatible but dismissable", () => {
64+
const versions = ["dev", "0.1.5-dev", "0.0.1-alpha", "1.0.0-rc.1", "0.2.0-beta", "0.0.0-snapshot"];
65+
for (const v of versions) {
66+
const result = isAgentCompatible(v);
67+
expect(result.compatible).toBe(false);
68+
expect(result.dev).toBe(true);
69+
expect(result.message).toContain("development build");
70+
}
71+
});
72+
});

src/layouts/DashboardLayout.vue

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,15 @@
420420
</div>
421421
</header>
422422

423+
<div v-if="!statsStore.agentCompatible && !versionWarningDismissed" class="version-warning">
424+
<i class="pi pi-exclamation-triangle" />
425+
<span>{{ statsStore.agentCompatibilityMessage }}</span>
426+
<span class="version-details">UI v{{ uiVersion }} / Agent v{{ statsStore.agentVersion }}</span>
427+
<button v-if="statsStore.agentDevBuild" class="dismiss-btn" @click="versionWarningDismissed = true">
428+
<i class="pi pi-times" />
429+
</button>
430+
</div>
431+
423432
<div class="content-area">
424433
<router-view />
425434
</div>
@@ -439,7 +448,9 @@ const route = useRoute();
439448
const router = useRouter();
440449
const statsStore = useStatsStore();
441450
const authStore = useAuthStore();
451+
const uiVersion = __APP_VERSION__;
442452
const sidebarCollapsed = ref(false);
453+
const versionWarningDismissed = ref(false);
443454
const isRefreshing = ref(false);
444455
const envDropdownOpen = ref(false);
445456
const currentServerName = ref("Local Server");
@@ -1101,4 +1112,39 @@ onMounted(() => {
11011112
padding: 1.5rem;
11021113
background: #f8fafc;
11031114
}
1115+
1116+
.version-warning {
1117+
display: flex;
1118+
align-items: center;
1119+
gap: 0.5rem;
1120+
padding: 0.75rem 1.5rem;
1121+
background: var(--color-warning-50);
1122+
border-bottom: 1px solid var(--color-warning-500);
1123+
color: var(--color-warning-700);
1124+
font-size: var(--text-sm);
1125+
}
1126+
1127+
.version-warning .pi {
1128+
color: var(--color-warning-600);
1129+
}
1130+
1131+
.version-warning .version-details {
1132+
margin-left: auto;
1133+
font-size: var(--text-xs);
1134+
color: var(--color-warning-600);
1135+
}
1136+
1137+
.version-warning .dismiss-btn {
1138+
background: none;
1139+
border: none;
1140+
color: var(--color-warning-700);
1141+
cursor: pointer;
1142+
padding: 0.25rem;
1143+
margin-left: 0.5rem;
1144+
border-radius: var(--radius-sm);
1145+
}
1146+
1147+
.version-warning .dismiss-btn:hover {
1148+
background: var(--color-warning-100);
1149+
}
11041150
</style>

src/stores/stats.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,16 @@
11
import { defineStore } from "pinia";
22
import { ref, reactive } from "vue";
33
import { healthApi } from "@/services/api";
4+
import { isAgentCompatible } from "@/utils/version";
45

56
export const useStatsStore = defineStore("stats", () => {
67
const loading = ref(false);
78
const lastUpdated = ref<Date | null>(null);
89
const agentOnline = ref(false);
910
const agentVersion = ref("unknown");
11+
const agentCompatible = ref(true);
12+
const agentCompatibilityMessage = ref("");
13+
const agentDevBuild = ref(false);
1014

1115
const deployments = reactive({
1216
total: 0,
@@ -52,6 +56,10 @@ export const useStatsStore = defineStore("stats", () => {
5256
agentOnline.value = healthRes.data.status === "healthy";
5357
if (healthRes.data.version?.version) {
5458
agentVersion.value = healthRes.data.version.version;
59+
const compat = isAgentCompatible(agentVersion.value);
60+
agentCompatible.value = compat.compatible;
61+
agentCompatibilityMessage.value = compat.message;
62+
agentDevBuild.value = compat.dev || false;
5563
}
5664

5765
const statsRes = await healthApi.stats();
@@ -112,6 +120,9 @@ export const useStatsStore = defineStore("stats", () => {
112120
lastUpdated,
113121
agentOnline,
114122
agentVersion,
123+
agentCompatible,
124+
agentCompatibilityMessage,
125+
agentDevBuild,
115126
deployments,
116127
containers,
117128
docker,

src/utils/version.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
export const MIN_AGENT_VERSION = "0.1.4";
2+
export const MAX_AGENT_VERSION = "0.x.x";
3+
4+
function parseVersion(version: string): number[] {
5+
return version
6+
.replace(/^v/, "")
7+
.split(".")
8+
.map((p) => (p === "x" ? Infinity : parseInt(p, 10) || 0));
9+
}
10+
11+
export function compareVersions(a: string, b: string): number {
12+
const pa = parseVersion(a);
13+
const pb = parseVersion(b);
14+
const len = Math.max(pa.length, pb.length);
15+
16+
for (let i = 0; i < len; i++) {
17+
const na = pa[i] ?? 0;
18+
const nb = pb[i] ?? 0;
19+
if (na < nb) return -1;
20+
if (na > nb) return 1;
21+
}
22+
return 0;
23+
}
24+
25+
export function isAgentCompatible(agentVersion: string): {
26+
compatible: boolean;
27+
dev?: boolean;
28+
message: string;
29+
} {
30+
if (!agentVersion || agentVersion === "unknown") {
31+
return { compatible: true, message: "" };
32+
}
33+
34+
const version = agentVersion.replace(/^v/, "");
35+
36+
if (/^dev$|-(dev|alpha|beta|rc|snapshot|canary)/i.test(version)) {
37+
return {
38+
compatible: false,
39+
dev: true,
40+
message: `Agent ${version} is a development build. Some features may not work as expected.`,
41+
};
42+
}
43+
44+
if (compareVersions(version, MIN_AGENT_VERSION) < 0) {
45+
return {
46+
compatible: false,
47+
message: `Agent ${version} is too old. This UI requires agent ${MIN_AGENT_VERSION} or newer.`,
48+
};
49+
}
50+
51+
if (compareVersions(version, MAX_AGENT_VERSION) > 0) {
52+
return {
53+
compatible: false,
54+
message: `Agent ${version} is newer than supported. This UI supports up to agent ${MAX_AGENT_VERSION}.`,
55+
};
56+
}
57+
58+
return { compatible: true, message: "" };
59+
}

src/vite-env.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
/// <reference types="vite/client" />
22

3+
declare const __APP_VERSION__: string;
4+
35
interface ImportMetaEnv {
46
readonly VITE_API_URL: string;
57
readonly BASE_URL: string;

0 commit comments

Comments
 (0)