diff --git a/backend/data/homelab.db.back b/backend/data/homelab.db.back
new file mode 100644
index 0000000..9d2fd3f
Binary files /dev/null and b/backend/data/homelab.db.back differ
diff --git a/frontend/src/components/panels/Sidebar.tsx b/frontend/src/components/panels/Sidebar.tsx
index 22e63dd..eb77c46 100644
--- a/frontend/src/components/panels/Sidebar.tsx
+++ b/frontend/src/components/panels/Sidebar.tsx
@@ -5,6 +5,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
import { useCanvasStore } from '@/stores/canvasStore'
import { scanApi, settingsApi } from '@/api/client'
import { toast } from 'sonner'
+import { useLatestRelease } from '@/hooks/useLatestRelease'
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
@@ -152,6 +153,8 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
/>
)}
+
+ {!collapsed && }
)
}
@@ -548,6 +551,34 @@ function SettingsPanel() {
)
}
+function VersionBadge() {
+ const current = __APP_VERSION__
+ const { latest, hasUpdate } = useLatestRelease(current)
+
+ return (
+
+ )
+}
+
const MAC_OUI: Record = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
diff --git a/frontend/src/components/panels/__tests__/VersionBadge.test.tsx b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx
new file mode 100644
index 0000000..7aad9de
--- /dev/null
+++ b/frontend/src/components/panels/__tests__/VersionBadge.test.tsx
@@ -0,0 +1,121 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { Sidebar } from '../Sidebar'
+import { useCanvasStore } from '@/stores/canvasStore'
+
+// ── Mocks ─────────────────────────────────────────────────────────────────────
+
+vi.mock('@/stores/canvasStore')
+
+vi.mock('@/api/client', () => ({
+ scanApi: {
+ trigger: vi.fn().mockResolvedValue({}),
+ pending: vi.fn().mockResolvedValue({ data: [] }),
+ hidden: vi.fn().mockResolvedValue({ data: [] }),
+ runs: vi.fn().mockResolvedValue({ data: [] }),
+ stop: vi.fn().mockResolvedValue({}),
+ getConfig: vi.fn().mockResolvedValue({ data: { ranges: [] } }),
+ },
+ settingsApi: {
+ get: vi.fn().mockResolvedValue({ data: { interval_seconds: 60 } }),
+ save: vi.fn().mockResolvedValue({}),
+ },
+}))
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+vi.mock('@/components/ui/Logo', () => ({ Logo: () => null }))
+vi.mock('@/components/ui/tooltip', () => ({
+ Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipTrigger: ({ children }: { children: React.ReactNode }) => <>{children}>,
+ TooltipContent: () => null,
+}))
+vi.mock('@/components/modals/PendingDeviceModal', () => ({ PendingDeviceModal: () => null }))
+vi.mock('@/components/modals/StatusTimelineModal', () => ({ StatusTimelineModal: () => null }))
+
+vi.mock('@/hooks/useLatestRelease', () => ({
+ useLatestRelease: vi.fn(),
+}))
+
+import { useLatestRelease } from '@/hooks/useLatestRelease'
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function renderSidebar() {
+ vi.mocked(useCanvasStore).mockReturnValue({
+ nodes: [],
+ hasUnsavedChanges: false,
+ hideIp: false,
+ toggleHideIp: vi.fn(),
+ addNode: vi.fn(),
+ scanEventTs: 0,
+ } as unknown as ReturnType)
+
+ return render(
+ ,
+ )
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+describe('VersionBadge', () => {
+ beforeEach(() => {
+ vi.mocked(useLatestRelease).mockReturnValue({ latest: null, hasUpdate: false })
+ })
+
+ it('displays the current app version', () => {
+ renderSidebar()
+ expect(screen.getByText(`v${__APP_VERSION__}`)).toBeInTheDocument()
+ })
+
+ it('links current version to its GitHub release page', () => {
+ renderSidebar()
+ const link = screen.getByText(`v${__APP_VERSION__}`).closest('a')
+ expect(link).toHaveAttribute(
+ 'href',
+ `https://github.com/Pouzor/homelable/releases/tag/v${__APP_VERSION__}`,
+ )
+ expect(link).toHaveAttribute('target', '_blank')
+ })
+
+ it('does not show update badge when on latest version', () => {
+ renderSidebar()
+ expect(screen.queryByText(/available/)).not.toBeInTheDocument()
+ })
+
+ it('shows update badge when a newer version is available', async () => {
+ vi.mocked(useLatestRelease).mockReturnValue({
+ latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
+ hasUpdate: true,
+ })
+ renderSidebar()
+ await waitFor(() => expect(screen.getByText('↑ v9.9.9 available')).toBeInTheDocument())
+ })
+
+ it('update badge links to the latest release URL', async () => {
+ vi.mocked(useLatestRelease).mockReturnValue({
+ latest: { version: '9.9.9', url: 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9' },
+ hasUpdate: true,
+ })
+ renderSidebar()
+ await waitFor(() => {
+ const badge = screen.getByText('↑ v9.9.9 available').closest('a')
+ expect(badge).toHaveAttribute('href', 'https://github.com/Pouzor/homelable/releases/tag/v9.9.9')
+ expect(badge).toHaveAttribute('target', '_blank')
+ })
+ })
+
+ it('does not show update badge when hasUpdate is false even if latest exists', () => {
+ vi.mocked(useLatestRelease).mockReturnValue({
+ latest: { version: __APP_VERSION__, url: 'https://github.com' },
+ hasUpdate: false,
+ })
+ renderSidebar()
+ expect(screen.queryByText(/available/)).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/hooks/__tests__/useLatestRelease.test.ts b/frontend/src/hooks/__tests__/useLatestRelease.test.ts
new file mode 100644
index 0000000..54092bc
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useLatestRelease.test.ts
@@ -0,0 +1,105 @@
+import { describe, it, expect, beforeEach, vi } from 'vitest'
+import { renderHook, waitFor } from '@testing-library/react'
+
+// Reset module between tests so the module-level cache is cleared
+async function freshHook() {
+ vi.resetModules()
+ const mod = await import('../useLatestRelease')
+ return mod.useLatestRelease
+}
+
+const CURRENT = '1.8.3'
+
+function mockFetch(payload: unknown, ok = true) {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({
+ ok,
+ json: () => Promise.resolve(payload),
+ }),
+ )
+}
+
+describe('useLatestRelease', () => {
+ beforeEach(() => {
+ vi.unstubAllGlobals()
+ })
+
+ it('returns no update when latest version matches current', async () => {
+ mockFetch({ tag_name: 'v1.8.3', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.8.3' })
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await waitFor(() => expect(result.current.latest).not.toBeNull())
+ expect(result.current.hasUpdate).toBe(false)
+ })
+
+ it('returns update when latest version is newer', async () => {
+ mockFetch({ tag_name: 'v1.9.0', html_url: 'https://github.com/Pouzor/homelable/releases/tag/v1.9.0' })
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await waitFor(() => expect(result.current.hasUpdate).toBe(true))
+ expect(result.current.latest?.version).toBe('1.9.0')
+ expect(result.current.latest?.url).toBe('https://github.com/Pouzor/homelable/releases/tag/v1.9.0')
+ })
+
+ it('strips leading v from tag_name', async () => {
+ mockFetch({ tag_name: 'v2.0.0', html_url: 'https://github.com/example' })
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await waitFor(() => expect(result.current.latest).not.toBeNull())
+ expect(result.current.latest?.version).toBe('2.0.0')
+ })
+
+ it('does not show update when API returns non-ok response', async () => {
+ mockFetch({ message: 'Not Found' }, false)
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await new Promise((r) => setTimeout(r, 50))
+ expect(result.current.hasUpdate).toBe(false)
+ expect(result.current.latest).toBeNull()
+ })
+
+ it('does not show update when API returns missing tag_name', async () => {
+ mockFetch({ html_url: 'https://github.com/example' })
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await new Promise((r) => setTimeout(r, 50))
+ expect(result.current.hasUpdate).toBe(false)
+ })
+
+ it('does not show update when API returns missing html_url', async () => {
+ mockFetch({ tag_name: 'v2.0.0' })
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await new Promise((r) => setTimeout(r, 50))
+ expect(result.current.hasUpdate).toBe(false)
+ })
+
+ it('does not show update when fetch throws', async () => {
+ vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('network error')))
+ const useLatestRelease = await freshHook()
+ const { result } = renderHook(() => useLatestRelease(CURRENT))
+ await new Promise((r) => setTimeout(r, 50))
+ expect(result.current.hasUpdate).toBe(false)
+ })
+
+ it('fetches only once when hook is mounted multiple times concurrently', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ tag_name: 'v1.8.3', html_url: 'https://github.com' }),
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ const useLatestRelease = await freshHook()
+ // Mount all three before the fetch resolves — cache is set to 'pending' after first mount
+ const a = renderHook(() => useLatestRelease(CURRENT))
+ const b = renderHook(() => useLatestRelease(CURRENT))
+ const c = renderHook(() => useLatestRelease(CURRENT))
+ await waitFor(() => {
+ expect(a.result.current.latest).not.toBeNull()
+ })
+ expect(fetchMock).toHaveBeenCalledTimes(1)
+ // All hooks see the same result once cache resolves
+ expect(b.result.current.hasUpdate).toBe(false)
+ expect(c.result.current.hasUpdate).toBe(false)
+ })
+})
diff --git a/frontend/src/hooks/useLatestRelease.ts b/frontend/src/hooks/useLatestRelease.ts
new file mode 100644
index 0000000..443d379
--- /dev/null
+++ b/frontend/src/hooks/useLatestRelease.ts
@@ -0,0 +1,42 @@
+import { useEffect, useState } from 'react'
+
+interface ReleaseInfo {
+ version: string
+ url: string
+}
+
+let cache: ReleaseInfo | null | 'error' | 'pending' = null
+
+export function useLatestRelease(currentVersion: string) {
+ const [latest, setLatest] = useState(
+ cache && cache !== 'error' && cache !== 'pending' ? cache : null,
+ )
+
+ useEffect(() => {
+ if (cache !== null) return
+ cache = 'pending'
+ fetch('https://api.github.com/repos/Pouzor/homelable/releases/latest', {
+ headers: { Accept: 'application/vnd.github+json' },
+ })
+ .then((res) => {
+ if (!res.ok) { cache = 'error'; return }
+ return res.json()
+ })
+ .then((data) => {
+ if (!data || typeof data.tag_name !== 'string' || !data.html_url) {
+ cache = 'error'
+ return
+ }
+ const version = data.tag_name.replace(/^v/, '')
+ const info: ReleaseInfo = { version, url: data.html_url }
+ cache = info
+ setLatest(info)
+ })
+ .catch(() => {
+ cache = 'error'
+ })
+ }, [])
+
+ const hasUpdate = latest !== null && latest.version !== currentVersion
+ return { latest, hasUpdate }
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
new file mode 100644
index 0000000..54eaa07
--- /dev/null
+++ b/frontend/src/vite-env.d.ts
@@ -0,0 +1,3 @@
+///
+
+declare const __APP_VERSION__: string
diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts
index db648cc..c7f1f73 100644
--- a/frontend/vite.config.ts
+++ b/frontend/vite.config.ts
@@ -2,8 +2,12 @@ import path from 'path'
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
+import pkg from './package.json'
export default defineConfig({
+ define: {
+ __APP_VERSION__: JSON.stringify(pkg.version),
+ },
plugins: [react(), tailwindcss()],
resolve: {
alias: {