Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .eslintrc.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ module.exports = {
browser: true,
es2021: true,
},
globals: {
__APP_VERSION__: 'readonly',
},
extends: [
'eslint:recommended',
'plugin:vue/vue3-recommended',
Expand Down
72 changes: 72 additions & 0 deletions src/__tests__/version.test.ts
Original file line number Diff line number Diff line change
@@ -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");
}
});
});
45 changes: 45 additions & 0 deletions src/layouts/DashboardLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,15 @@
</div>
</header>

<div v-if="!statsStore.agentCompatible && !statsStore.versionWarningDismissed" class="version-warning">
<i class="pi pi-exclamation-triangle" />
<span>{{ statsStore.agentCompatibilityMessage }}</span>
<span class="version-details">UI v{{ uiVersion }} / Agent v{{ statsStore.agentVersion }}</span>
<button v-if="statsStore.agentDevBuild" class="dismiss-btn" @click="statsStore.versionWarningDismissed = true">
<i class="pi pi-times" />
</button>
</div>

<div class="content-area">
<router-view />
</div>
Expand All @@ -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);
Expand Down Expand Up @@ -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);
}
</style>
13 changes: 13 additions & 0 deletions src/stores/stats.ts
Original file line number Diff line number Diff line change
@@ -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<Date | null>(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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -112,6 +121,10 @@ export const useStatsStore = defineStore("stats", () => {
lastUpdated,
agentOnline,
agentVersion,
agentCompatible,
agentCompatibilityMessage,
agentDevBuild,
versionWarningDismissed,
deployments,
containers,
docker,
Expand Down
59 changes: 59 additions & 0 deletions src/utils/version.ts
Original file line number Diff line number Diff line change
@@ -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: "" };
}
2 changes: 2 additions & 0 deletions src/vite-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
/// <reference types="vite/client" />

declare const __APP_VERSION__: string;

interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly BASE_URL: string;
Expand Down
Loading