diff --git a/ui/src/components/monitoring/proxy-status-widget.tsx b/ui/src/components/monitoring/proxy-status-widget.tsx index cfa026df..c61ca5ee 100644 --- a/ui/src/components/monitoring/proxy-status-widget.tsx +++ b/ui/src/components/monitoring/proxy-status-widget.tsx @@ -27,6 +27,8 @@ import { FileDown, Check, AlertCircle, + ChevronDown, + ChevronUp, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -75,6 +77,26 @@ import { RoutingGuidanceCard } from '@/components/cliproxy/routing-guidance-card type PendingInstallRisk = 'faulty' | 'experimental'; +const WIDGET_COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +function readCollapsedPreference(): boolean { + if (typeof window === 'undefined') return false; + try { + return window.localStorage.getItem(WIDGET_COLLAPSED_STORAGE_KEY) === 'true'; + } catch { + return false; + } +} + +function persistCollapsedPreference(collapsed: boolean): void { + if (typeof window === 'undefined') return; + try { + window.localStorage.setItem(WIDGET_COLLAPSED_STORAGE_KEY, String(collapsed)); + } catch { + // Ignore storage errors (private mode, quota, etc.) + } +} + function formatUptime(startedAt?: string): string { if (!startedAt) return ''; const start = new Date(startedAt).getTime(); @@ -135,6 +157,7 @@ function IconButton({ )} onClick={onClick} disabled={disabled} + aria-label={tooltip} > {isPending ? ( @@ -182,6 +205,31 @@ export function ProxyStatusWidget() { const [isExpanded, setIsExpanded] = useState(false); const [selectedVersion, setSelectedVersion] = useState(''); + // Whole-widget collapse so the sidebar provider list keeps the vertical space + const [isCollapsed, setIsCollapsed] = useState(readCollapsedPreference); + + const handleToggleCollapsed = () => { + const next = !isCollapsed; + setIsCollapsed(next); + // Collapsing also closes version management so it cannot stay open but hidden + if (next) setIsExpanded(false); + persistCollapsedPreference(next); + }; + + // Opening version settings from a collapsed widget must reveal the section + const handleToggleVersionSettings = () => { + const next = !isExpanded; + setIsExpanded(next); + if (next && isCollapsed) { + setIsCollapsed(false); + persistCollapsedPreference(false); + } + }; + + const collapseTooltip = isCollapsed + ? t('proxyStatusWidget.tooltipExpandWidget') + : t('proxyStatusWidget.tooltipCollapseWidget'); + // Confirmation dialog state for risky versions const [showUnstableConfirm, setShowUnstableConfirm] = useState(false); const [pendingInstallVersion, setPendingInstallVersion] = useState(null); @@ -325,30 +373,50 @@ export function ProxyStatusWidget() { {t('proxyStatusWidget.active')} - - - -
-
- {remoteDisplayHost} +
+ +
-

- {t('proxyStatusWidget.trafficAutoRouted')} -

- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + {!isCollapsed && ( + <> +
+
+ {remoteDisplayHost} +
+

+ {t('proxyStatusWidget.trafficAutoRouted')} +

+
+ + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )}
); } @@ -413,9 +481,14 @@ export function ProxyStatusWidget() { ? t('proxyStatusWidget.tooltipClose') : t('proxyStatusWidget.tooltipVersionSettings') } - onClick={() => setIsExpanded(!isExpanded)} + onClick={handleToggleVersionSettings} className={isExpanded ? 'bg-muted' : undefined} /> + @@ -458,7 +531,7 @@ export function ProxyStatusWidget() { )} {/* Stats row when running */} - {isRunning && status && ( + {!isCollapsed && isRunning && status && (
{t('proxyStatusWidget.port', { port: status.port })} @@ -478,35 +551,39 @@ export function ProxyStatusWidget() {
)} - {/* Sync status row */} -
- {isSyncConfigured ? ( - - ) : ( - - )} - - {syncStatusText} - -
+ {!isCollapsed && ( + <> + {/* Sync status row */} +
+ {isSyncConfigured ? ( + + ) : ( + + )} + + {syncStatusText} + +
- updateRouting.mutate(strategy)} - onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} - /> + updateRouting.mutate(strategy)} + onApplyAffinity={(data) => updateSessionAffinity.mutate(data)} + /> + + )} {/* Expanded section: Version Management (available even when not running) */} diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 6ac2da8a..d8bdc792 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -428,6 +428,8 @@ const resources = { tooltipStop: 'Stop', tooltipClose: 'Close', tooltipVersionSettings: 'Version settings', + tooltipCollapseWidget: 'Collapse', + tooltipExpandWidget: 'Expand', versionManagement: 'Version Management', selectVersionPlaceholder: 'Select version to install...', stable: '(stable)', @@ -3180,6 +3182,8 @@ const resources = { tooltipStop: 'Parar', tooltipClose: 'Fechar', tooltipVersionSettings: 'Configurações de versão', + tooltipCollapseWidget: 'Recolher', + tooltipExpandWidget: 'Expandir', versionManagement: 'Gerenciamento de Versão', selectVersionPlaceholder: 'Selecione a versão a instalar...', stable: '(estável)', @@ -5962,6 +5966,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '关闭', tooltipVersionSettings: '版本设置', + tooltipCollapseWidget: '收起', + tooltipExpandWidget: '展开', versionManagement: '版本管理', selectVersionPlaceholder: '选择要安装的版本...', stable: '(稳定版)', @@ -8595,6 +8601,8 @@ const resources = { tooltipStop: 'Dừng lại', tooltipClose: 'Đóng', tooltipVersionSettings: 'Cài đặt phiên bản', + tooltipCollapseWidget: 'Thu gọn', + tooltipExpandWidget: 'Mở rộng', versionManagement: 'Quản lý phiên bản', selectVersionPlaceholder: 'Chọn phiên bản để cài đặt...', stable: '(ổn định)', @@ -11329,6 +11337,8 @@ const resources = { tooltipStop: '停止', tooltipClose: '閉じる', tooltipVersionSettings: 'バージョン設定', + tooltipCollapseWidget: '折りたたむ', + tooltipExpandWidget: '展開', versionManagement: 'バージョン管理', selectVersionPlaceholder: 'インストールするバージョンを選択...', stable: '(安定版)', @@ -14087,6 +14097,8 @@ const resources = { tooltipStop: '중지', tooltipClose: '닫기', tooltipVersionSettings: '버전 설정', + tooltipCollapseWidget: '접기', + tooltipExpandWidget: '펼치기', versionManagement: '버전 관리', selectVersionPlaceholder: '설치할 버전 선택...', stable: '(안정)', diff --git a/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx new file mode 100644 index 00000000..55893a05 --- /dev/null +++ b/ui/tests/unit/components/monitoring/proxy-status-widget.test.tsx @@ -0,0 +1,97 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { fireEvent, render, screen } from '../../../setup/test-utils'; + +const COLLAPSED_STORAGE_KEY = 'cliproxy-status-widget-collapsed'; + +const mocks = vi.hoisted(() => ({ + noopMutation: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useProxyStatus: () => ({ + data: { running: true, port: 8317, sessionCount: 1 }, + isLoading: false, + }), + useCliproxyUpdateCheck: () => ({ data: { backendLabel: 'CLIProxy', currentVersion: '7.2.137' } }), + useCliproxyVersions: () => ({ data: undefined, isLoading: false }), + useCliproxyRoutingStrategy: () => ({ data: undefined, isLoading: false, error: null }), + useCliproxySessionAffinity: () => ({ data: undefined, isLoading: false, error: null }), + useUpdateCliproxyRoutingStrategy: mocks.noopMutation, + useUpdateCliproxySessionAffinity: mocks.noopMutation, + useStartProxy: mocks.noopMutation, + useStopProxy: mocks.noopMutation, + useRestartProxy: mocks.noopMutation, + useInstallVersion: mocks.noopMutation, +})); + +vi.mock('@/hooks/use-cliproxy-sync', () => ({ + useSyncStatus: () => ({ data: { configured: true } }), + useExecuteSync: () => ({ mutate: vi.fn(), isPending: false }), +})); + +vi.mock('@/lib/api-client', () => ({ + api: { + cliproxyServer: { + get: () => Promise.resolve({ remote: { enabled: false, host: '' } }), + }, + }, +})); + +vi.mock('@/components/cliproxy/routing-guidance-card', () => ({ + RoutingGuidanceCard: () =>
, +})); + +import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; + +describe('ProxyStatusWidget collapse', () => { + beforeEach(() => { + vi.mocked(window.localStorage.getItem).mockReturnValue(null); + vi.mocked(window.localStorage.setItem).mockClear(); + }); + + it('shows the routing and status detail by default', () => { + render(); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + }); + + it('hides the detail and persists the preference when collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'true'); + }); + + it('keeps the status row and version visible while collapsed', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Collapse' })); + + expect(screen.getByText('CLIProxy')).toBeInTheDocument(); + expect(screen.getByText('v7.2.137')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('starts collapsed when the stored preference says so', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + expect(screen.queryByTestId('routing-guidance-card')).toBeNull(); + expect(screen.getByRole('button', { name: 'Expand' })).toBeInTheDocument(); + }); + + it('reveals the detail again when version settings are opened while collapsed', () => { + vi.mocked(window.localStorage.getItem).mockReturnValue('true'); + + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Version settings' })); + + expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument(); + expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'false'); + }); +});