Compare commits

..

5 Commits

4 changed files with 41 additions and 7 deletions
+1 -1
View File
@@ -1 +1 @@
1.10.1 1.10.2
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "frontend", "name": "frontend",
"private": true, "private": true,
"version": "1.10.1", "version": "1.10.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
+15 -5
View File
@@ -1,8 +1,9 @@
import { useState, useCallback, useEffect, useRef } from 'react' import { useState, useCallback, useEffect, useRef } from 'react'
import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X } from 'lucide-react' import { Plus, Save, ScanLine, ChevronLeft, ChevronRight, LayoutDashboard, Clock, EyeOff, Trash2, RefreshCw, Loader2, Square, Eye, Settings, StopCircle, X, LogOut } from 'lucide-react'
import { Logo } from '@/components/ui/Logo' import { Logo } from '@/components/ui/Logo'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
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 { useLatestRelease } from '@/hooks/useLatestRelease'
@@ -43,6 +44,7 @@ interface SidebarProps {
export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) { export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeApproved, forceView, highlightPendingId }: SidebarProps) {
const [_collapsed, setCollapsed] = useState(false) const [_collapsed, setCollapsed] = useState(false)
const [_activeView, setActiveView] = useState<SidebarView>('canvas') const [_activeView, setActiveView] = useState<SidebarView>('canvas')
const logout = useAuthStore((s) => s.logout)
// When forceView is set, override local state without useEffect // When forceView is set, override local state without useEffect
const collapsed = forceView ? false : _collapsed const collapsed = forceView ? false : _collapsed
@@ -152,6 +154,14 @@ export function Sidebar({ onAddNode, onAddGroupRect, onScan, onSave, onNodeAppro
onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')} onClick={() => setActiveView((v) => v === 'settings' ? 'canvas' : 'settings')}
/> />
)} )}
{!STANDALONE && (
<SidebarItem
icon={LogOut}
label="Logout"
collapsed={collapsed}
onClick={logout}
/>
)}
</div> </div>
{!collapsed && <VersionBadge />} {!collapsed && <VersionBadge />}
@@ -385,8 +395,8 @@ function PendingDevicesPanel({ onNodeApproved, highlightId }: { onNodeApproved:
<input <input
type="checkbox" type="checkbox"
checked={checkedIds.has(d.id)} checked={checkedIds.has(d.id)}
onClick={(e) => toggleCheck(d.id, e)} onClick={(e) => e.stopPropagation()}
onChange={() => {}} onChange={(e) => { e.stopPropagation(); toggleCheck(d.id, e as unknown as React.MouseEvent) }}
className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0" className="w-3 h-3 accent-[#00d4ff] cursor-pointer shrink-0"
/> />
<span className="text-foreground truncate font-medium">{title}</span> <span className="text-foreground truncate font-medium">{title}</span>
@@ -627,7 +637,7 @@ function SettingsPanel() {
min={10} min={10}
max={3600} max={3600}
value={interval} value={interval}
onChange={(e) => setIntervalValue(Number(e.target.value))} onChange={(e) => { const v = Number(e.target.value); if (!isNaN(v)) setIntervalValue(v) }}
className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]" className="w-24 px-2 py-1 rounded-md text-xs font-mono bg-[#0d1117] border border-border text-foreground focus:outline-none focus:border-[#00d4ff]"
/> />
<span className="text-xs text-muted-foreground">seconds</span> <span className="text-xs text-muted-foreground">seconds</span>
@@ -664,7 +674,7 @@ function VersionBadge() {
</a> </a>
{hasUpdate && latest && ( {hasUpdate && latest && (
<a <a
href={latest.url} href={latest.url.startsWith('https://') ? latest.url : '#'}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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"
@@ -2,12 +2,14 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import { Sidebar } from '../Sidebar' import { Sidebar } from '../Sidebar'
import { useCanvasStore } from '@/stores/canvasStore' import { useCanvasStore } from '@/stores/canvasStore'
import { useAuthStore } from '@/stores/authStore'
import type { Node } from '@xyflow/react' import type { Node } from '@xyflow/react'
import type { NodeData } from '@/types' import type { NodeData } from '@/types'
// ── Mocks ──────────────────────────────────────────────────────────────────── // ── Mocks ────────────────────────────────────────────────────────────────────
vi.mock('@/stores/canvasStore') vi.mock('@/stores/canvasStore')
vi.mock('@/stores/authStore')
const mockBulkApprove = vi.fn() const mockBulkApprove = vi.fn()
const mockBulkHide = vi.fn() const mockBulkHide = vi.fn()
@@ -60,6 +62,7 @@ const makeNode = (id: string, status: NodeData['status'], type: NodeData['type']
}) })
const mockToggleHideIp = vi.fn() const mockToggleHideIp = vi.fn()
const mockLogout = vi.fn()
function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) { function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
vi.mocked(useCanvasStore).mockReturnValue({ vi.mocked(useCanvasStore).mockReturnValue({
@@ -73,6 +76,12 @@ function mockStore(overrides: Partial<ReturnType<typeof useCanvasStore>> = {}) {
} as ReturnType<typeof useCanvasStore>) } as ReturnType<typeof useCanvasStore>)
} }
function mockAuth() {
vi.mocked(useAuthStore).mockImplementation((selector: (s: { logout: () => void }) => unknown) =>
selector({ logout: mockLogout }) as ReturnType<typeof useAuthStore>
)
}
const defaultProps = { const defaultProps = {
onAddNode: vi.fn(), onAddNode: vi.fn(),
onAddGroupRect: vi.fn(), onAddGroupRect: vi.fn(),
@@ -86,6 +95,7 @@ const defaultProps = {
describe('Sidebar', () => { describe('Sidebar', () => {
beforeEach(() => { beforeEach(() => {
mockStore() mockStore()
mockAuth()
vi.clearAllMocks() vi.clearAllMocks()
}) })
@@ -267,6 +277,19 @@ describe('Sidebar', () => {
fireEvent.click(screen.getByRole('button', { name: 'Settings' })) fireEvent.click(screen.getByRole('button', { name: 'Settings' }))
expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument() expect(screen.queryByText('Status check interval (s)')).not.toBeInTheDocument()
}) })
// ── Logout ─────────────────────────────────────────────────────────────────
it('shows Logout button in normal mode', () => {
render(<Sidebar {...defaultProps} />)
expect(screen.getByText('Logout')).toBeInTheDocument()
})
it('calls logout when Logout is clicked', () => {
render(<Sidebar {...defaultProps} />)
fireEvent.click(screen.getByText('Logout'))
expect(mockLogout).toHaveBeenCalledOnce()
})
}) })
// ── PendingDevicesPanel — bulk select ───────────────────────────────────────── // ── PendingDevicesPanel — bulk select ─────────────────────────────────────────
@@ -298,6 +321,7 @@ const DEVICE_B = {
describe('PendingDevicesPanel — bulk select', () => { describe('PendingDevicesPanel — bulk select', () => {
beforeEach(() => { beforeEach(() => {
mockStore() mockStore()
mockAuth()
vi.clearAllMocks() vi.clearAllMocks()
mockBulkApprove.mockResolvedValue({ mockBulkApprove.mockResolvedValue({
data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 }, data: { approved: 2, node_ids: ['n1', 'n2'], device_ids: ['dev-a', 'dev-b'], skipped: 0 },