mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 07:12:18 +00:00
feat(dashboard): collapse the CLIProxy status widget
ProxyStatusWidget sits outside the sidebar ScrollArea, so every row it renders is taken from the provider list. With the proxy running and pool routing on it renders ten stacked blocks, and the only Collapsible covers version management, which is already closed in that state. Add a chevron that collapses the whole widget in local and remote mode, keeping the status row, version row and action buttons visible. The preference is stored in localStorage and defaults to expanded, so existing setups are unchanged. Opening version settings from a collapsed widget expands it again.
This commit is contained in:
1 parent
a53980782f
commit
fde7bd83db
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}>
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user