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 ( +
+ + v{current} + + {hasUpdate && latest && ( + + ↑ v{latest.version} available + + )} +
+ ) +} + 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: {