feat: display app version in sidebar with GitHub release check

This commit is contained in:
Pouzor
2026-04-08 12:22:39 +02:00
parent 8bd1c48976
commit 75c7f25a30
7 changed files with 306 additions and 0 deletions
Binary file not shown.
@@ -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
/>
)}
</div>
{!collapsed && <VersionBadge />}
</aside>
)
}
@@ -548,6 +551,34 @@ function SettingsPanel() {
)
}
function VersionBadge() {
const current = __APP_VERSION__
const { latest, hasUpdate } = useLatestRelease(current)
return (
<div className="px-3 py-2 border-t border-border flex flex-col gap-1">
<a
href={`https://github.com/Pouzor/homelable/releases/tag/v${current}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-[11px] text-muted-foreground hover:text-foreground transition-colors"
>
v{current}
</a>
{hasUpdate && latest && (
<a
href={latest.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium bg-[#e3b341]/15 text-[#e3b341] border border-[#e3b341]/30 hover:bg-[#e3b341]/25 transition-colors self-start"
>
v{latest.version} available
</a>
)}
</div>
)
}
const MAC_OUI: Record<string, { label: string; title: string }> = {
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
@@ -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<typeof useCanvasStore>)
return render(
<Sidebar
onAddNode={vi.fn()}
onAddGroupRect={vi.fn()}
onScan={vi.fn()}
onSave={vi.fn()}
onNodeApproved={vi.fn()}
/>,
)
}
// ── 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()
})
})
@@ -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)
})
})
+42
View File
@@ -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<ReleaseInfo | null>(
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 }
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __APP_VERSION__: string
+4
View File
@@ -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: {