mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
Merge pull request #1717 from mardausdennis/feat/collapsible-proxy-status-widget
feat(dashboard): collapse the CLIProxy status widget
This commit is contained in:
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