Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b0df8f389a |
Binary file not shown.
@@ -98,7 +98,7 @@ export function CanvasContainer({ onConnect: onConnectProp, onEdgeDoubleClick, o
|
|||||||
>
|
>
|
||||||
<Background
|
<Background
|
||||||
variant={BackgroundVariant.Dots}
|
variant={BackgroundVariant.Dots}
|
||||||
gap={24}
|
gap={16}
|
||||||
size={1}
|
size={1}
|
||||||
color={theme.colors.canvasDotColor}
|
color={theme.colors.canvasDotColor}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -39,11 +39,13 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
style={{
|
style={{
|
||||||
background: colors.background,
|
background: colors.background,
|
||||||
borderColor: colors.border,
|
borderColor: colors.border,
|
||||||
borderWidth: selected ? 2 : 1,
|
borderWidth: 1,
|
||||||
boxShadow: isOnline
|
boxShadow: isOnline && selected
|
||||||
|
? `0 0 0 1px ${colors.border}, 0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
|
: isOnline
|
||||||
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
? `0 0 10px ${colors.border}2e, 0 0 3px ${colors.border}1a`
|
||||||
: selected
|
: selected
|
||||||
? `0 0 8px ${colors.border}44`
|
? `0 0 0 1px ${colors.border}, 0 0 8px ${colors.border}44`
|
||||||
: 'none',
|
: 'none',
|
||||||
opacity: data.status === 'offline' ? 0.55 : 1,
|
opacity: data.status === 'offline' ? 0.55 : 1,
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
@@ -55,7 +57,7 @@ export function BaseNode({ id, data, selected, icon: typeIcon, width, height }:
|
|||||||
isVisible={selected}
|
isVisible={selected}
|
||||||
minWidth={140}
|
minWidth={140}
|
||||||
minHeight={50}
|
minHeight={50}
|
||||||
lineStyle={{ borderColor: colors.border, borderWidth: 1 }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
handleStyle={{ borderColor: colors.border, background: colors.border, width: 8, height: 8 }}
|
||||||
/>
|
/>
|
||||||
<Handle
|
<Handle
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
background: '#00d4ff',
|
background: '#00d4ff',
|
||||||
border: '1px solid #0d1117',
|
border: '1px solid #0d1117',
|
||||||
}}
|
}}
|
||||||
lineStyle={{ borderColor: '#00d4ff55', borderWidth: 1 }}
|
lineStyle={{ borderColor: 'transparent' }}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -86,7 +86,8 @@ export function GroupRectNode({ id, data, selected }: NodeProps<Node<NodeData>>)
|
|||||||
justifyContent: posStyle.justifyContent,
|
justifyContent: posStyle.justifyContent,
|
||||||
padding: 12,
|
padding: 12,
|
||||||
background: backgroundColor,
|
background: backgroundColor,
|
||||||
border: `${selected ? borderWidth + 1 : borderWidth}px ${selected ? 'solid' : borderStyle} ${selected ? '#00d4ff' : borderColor}`,
|
border: `${borderWidth}px ${borderStyle} ${borderColor}`,
|
||||||
|
boxShadow: selected ? '0 0 0 1px #00d4ff, 0 0 8px #00d4ff44' : 'none',
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
boxSizing: 'border-box',
|
boxSizing: 'border-box',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip
|
|||||||
import { useCanvasStore } from '@/stores/canvasStore'
|
import { useCanvasStore } from '@/stores/canvasStore'
|
||||||
import { scanApi, settingsApi } from '@/api/client'
|
import { scanApi, settingsApi } from '@/api/client'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useLatestRelease } from '@/hooks/useLatestRelease'
|
|
||||||
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
import { PendingDeviceModal, type PendingDevice } from '@/components/modals/PendingDeviceModal'
|
||||||
|
|
||||||
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
const STANDALONE = import.meta.env.VITE_STANDALONE === 'true'
|
||||||
@@ -153,8 +152,6 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && <VersionBadge />}
|
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -551,34 +548,6 @@ 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 }> = {
|
const MAC_OUI: Record<string, { label: string; title: string }> = {
|
||||||
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
'52:54:00': { label: 'QEMU', title: 'QEMU/KVM Virtual Machine' },
|
||||||
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
'bc:24:11': { label: 'PVE', title: 'Proxmox Virtual Machine or LXC' },
|
||||||
|
|||||||
@@ -1,121 +0,0 @@
|
|||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
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 }
|
|
||||||
}
|
|
||||||
Vendored
-3
@@ -1,3 +0,0 @@
|
|||||||
/// <reference types="vite/client" />
|
|
||||||
|
|
||||||
declare const __APP_VERSION__: string
|
|
||||||
@@ -2,12 +2,8 @@ import path from 'path'
|
|||||||
import { defineConfig } from 'vitest/config'
|
import { defineConfig } from 'vitest/config'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
import tailwindcss from '@tailwindcss/vite'
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
import pkg from './package.json'
|
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
define: {
|
|
||||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
|
||||||
},
|
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
|
|||||||
Reference in New Issue
Block a user