Merge pull request #1717 from mardausdennis/feat/collapsible-proxy-status-widget

feat(dashboard): collapse the CLIProxy status widget
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-08-25 14:50:28 -04:00
commit a5aa9ac35f
3 files changed
+237 -51

No files matched your search

@@ -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 ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
@@ -182,6 +205,31 @@ export function ProxyStatusWidget() {
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>('');
// Whole-widget collapse so the sidebar provider list keeps the vertical space
const [isCollapsed, setIsCollapsed] = useState<boolean>(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<string | null>(null);
@@ -325,30 +373,50 @@ export function ProxyStatusWidget() {
{t('proxyStatusWidget.active')}
</Badge>
</div>
<Activity className="w-3 h-3 text-blue-600" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
<div className="flex items-center gap-1">
<Activity className="w-3 h-3 text-blue-600" />
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0"
onClick={handleToggleCollapsed}
title={collapseTooltip}
aria-label={collapseTooltip}
>
{isCollapsed ? (
<ChevronDown className="w-3.5 h-3.5" />
) : (
<ChevronUp className="w-3.5 h-3.5" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
<RoutingGuidanceCard
key={`remote:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
{!isCollapsed && (
<>
<div className="mt-2 text-xs text-muted-foreground">
<div className="flex items-center gap-1 mb-1">
<span className="font-mono">{remoteDisplayHost}</span>
</div>
<p className="text-[10px] text-muted-foreground/70 leading-tight">
{t('proxyStatusWidget.trafficAutoRouted')}
</p>
</div>
<RoutingGuidanceCard
key={`remote:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
</>
)}
</div>
);
}
@@ -413,9 +481,14 @@ export function ProxyStatusWidget() {
? t('proxyStatusWidget.tooltipClose')
: t('proxyStatusWidget.tooltipVersionSettings')
}
onClick={() => setIsExpanded(!isExpanded)}
onClick={handleToggleVersionSettings}
className={isExpanded ? 'bg-muted' : undefined}
/>
<IconButton
icon={isCollapsed ? ChevronDown : ChevronUp}
tooltip={collapseTooltip}
onClick={handleToggleCollapsed}
/>
</div>
</div>
@@ -458,7 +531,7 @@ export function ProxyStatusWidget() {
)}
{/* Stats row when running */}
{isRunning && status && (
{!isCollapsed && isRunning && status && (
<div className="mt-2 flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
{t('proxyStatusWidget.port', { port: status.port })}
@@ -478,35 +551,39 @@ export function ProxyStatusWidget() {
</div>
)}
{/* Sync status row */}
<div className="mt-2 flex items-center gap-1.5 text-xs">
{isSyncConfigured ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-3 h-3 text-muted-foreground" />
)}
<span
className={cn(
'text-xs',
isSyncConfigured ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}
>
{syncStatusText}
</span>
</div>
{!isCollapsed && (
<>
{/* Sync status row */}
<div className="mt-2 flex items-center gap-1.5 text-xs">
{isSyncConfigured ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-3 h-3 text-muted-foreground" />
)}
<span
className={cn(
'text-xs',
isSyncConfigured ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}
>
{syncStatusText}
</span>
</div>
<RoutingGuidanceCard
key={`local:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
<RoutingGuidanceCard
key={`local:${routingState?.strategy ?? 'round-robin'}:${effectiveSessionAffinityState?.enabled ?? 'na'}:${effectiveSessionAffinityState?.ttl ?? 'na'}:${effectiveSessionAffinityState?.manageable ?? 'na'}`}
compact
className="mt-3"
state={routingState}
sessionAffinityState={effectiveSessionAffinityState}
isLoading={routingLoading || sessionAffinityLoading}
isSaving={isSavingRoutingConfig}
error={routingConfigError}
onApply={(strategy) => updateRouting.mutate(strategy)}
onApplyAffinity={(data) => updateSessionAffinity.mutate(data)}
/>
</>
)}
{/* Expanded section: Version Management (available even when not running) */}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
+12
View File
@@ -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: '(안정)',
@@ -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: () => <div data-testid="routing-guidance-card" />,
}));
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
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(<ProxyStatusWidget />);
fireEvent.click(screen.getByRole('button', { name: 'Version settings' }));
expect(screen.getByTestId('routing-guidance-card')).toBeInTheDocument();
expect(window.localStorage.setItem).toHaveBeenCalledWith(COLLAPSED_STORAGE_KEY, 'false');
});
});