feat(web): enhance dashboard functionality and ui components

This commit is contained in:
kaitranntt
2025-12-07 19:56:26 -05:00
parent 03059dbdcc
commit 6e2da6458a
10 changed files with 185 additions and 43 deletions
+2 -2
View File
@@ -927,7 +927,7 @@ export function getInstalledCliproxyVersion(): string {
* Install a specific version of CLIProxyAPI
* Deletes existing binary and downloads the specified version
*
* @param version Version to install (e.g., "6.5.40")
* @param version Version to install (e.g., "6.5.53")
* @param verbose Enable verbose logging
*/
export async function installCliproxyVersion(version: string, verbose = false): Promise<void> {
@@ -949,7 +949,7 @@ export async function installCliproxyVersion(version: string, verbose = false):
/**
* Fetch the latest CLIProxyAPI version from GitHub API
* @returns Latest version string (e.g., "6.5.40")
* @returns Latest version string (e.g., "6.5.53")
*/
export async function fetchLatestCliproxyVersion(): Promise<string> {
const manager = new BinaryManager();
+1 -1
View File
@@ -11,7 +11,7 @@ import { PlatformInfo, SupportedOS, SupportedArch, ArchiveExtension } from './ty
* CLIProxyAPI fallback version (used when GitHub API unavailable)
* Auto-update fetches latest from GitHub; this is only a safety net
*/
export const CLIPROXY_FALLBACK_VERSION = '6.5.40';
export const CLIPROXY_FALLBACK_VERSION = '6.5.53';
/** @deprecated Use CLIPROXY_FALLBACK_VERSION instead */
export const CLIPROXY_VERSION = CLIPROXY_FALLBACK_VERSION;
+2 -2
View File
@@ -633,7 +633,7 @@ async function showStatus(verbose: boolean): Promise<void> {
async function installVersion(version: string, verbose: boolean): Promise<void> {
// Validate version format (basic semver check)
if (!/^\d+\.\d+\.\d+$/.test(version)) {
console.error('[X] Invalid version format. Expected format: X.Y.Z (e.g., 6.5.40)');
console.error('[X] Invalid version format. Expected format: X.Y.Z (e.g., 6.5.53)');
process.exit(1);
}
@@ -732,7 +732,7 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
if (!version || version.startsWith('-')) {
console.error('[X] Missing version argument for --install');
console.error(' Usage: ccs cliproxy --install <version>');
console.error(' Example: ccs cliproxy --install 6.5.40');
console.error(' Example: ccs cliproxy --install 6.5.53');
process.exit(1);
}
await installVersion(version, verbose);
+1 -1
View File
@@ -210,7 +210,7 @@ Claude Code Profile & Model Switcher`.trim();
printSubSection('CLI Proxy Management', [
['ccs cliproxy', 'Show CLIProxyAPI status and version'],
['ccs cliproxy --help', 'Full CLIProxy management help'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.5.40)'],
['ccs cliproxy --install <ver>', 'Install specific version (e.g., 6.5.53)'],
['ccs cliproxy --latest', 'Update to latest version'],
]);
+24 -12
View File
@@ -127,22 +127,34 @@ function checkSymlinkStatus(): { valid: boolean; message: string } {
return { valid: false, message: 'Shared directory not found' };
}
// Check if ~/.claude/commands links to shared
const claudeDir = path.join(os.homedir(), '.claude');
const commandsLink = path.join(claudeDir, 'commands');
// Check all three symlinks: commands, skills, agents
const linkTypes = ['commands', 'skills', 'agents'];
let validLinks = 0;
try {
if (fs.existsSync(commandsLink)) {
const stats = fs.lstatSync(commandsLink);
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(commandsLink);
if (target.includes('.ccs/shared/commands')) {
return { valid: true, message: 'Symlinks active' };
for (const linkType of linkTypes) {
const linkPath = path.join(sharedDir, linkType);
try {
if (fs.existsSync(linkPath)) {
const stats = fs.lstatSync(linkPath);
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(linkPath);
// Check if it points to ~/.claude/{linkType}
const expectedTarget = path.join(os.homedir(), '.claude', linkType);
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
validLinks++;
}
}
}
} catch {
// Not a symlink or read error
}
} catch {
// Not a symlink or read error
}
if (validLinks === linkTypes.length) {
return { valid: true, message: 'Symlinks active' };
} else if (validLinks > 0) {
return { valid: false, message: `Symlinks partially configured (${validLinks}/${linkTypes.length})` };
}
return { valid: false, message: 'Symlinks not configured' };
+53
View File
@@ -0,0 +1,53 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
warning:
'border-yellow-200 bg-yellow-50 text-yellow-900 dark:border-yellow-900/50 dark:bg-yellow-900/20 dark:text-yellow-200 [&>svg]:text-yellow-600 dark:[&>svg]:text-yellow-500',
info: 'border-blue-200 bg-blue-50 text-blue-900 dark:border-blue-900/50 dark:bg-blue-900/20 dark:text-blue-200 [&>svg]:text-blue-600 dark:[&>svg]:text-blue-500',
success:
'border-green-200 bg-green-50 text-green-900 dark:border-green-900/50 dark:bg-green-900/20 dark:text-green-200 [&>svg]:text-green-600 dark:[&>svg]:text-green-500',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 font-medium leading-none tracking-tight', className)}
{...props}
/>
)
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
+6 -1
View File
@@ -4,7 +4,12 @@ function Skeleton({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
data-slot="skeleton"
className={cn('bg-accent animate-pulse rounded-md', className)}
className={cn(
'relative overflow-hidden bg-muted/50 before:absolute before:inset-0 before:-translate-x-full',
'before:bg-gradient-to-r before:from-transparent before:via-muted/30 before:to-transparent',
'before:animate-[shimmer_2s_infinite_ease-out] rounded-md',
className
)}
{...props}
/>
);
+9
View File
@@ -99,6 +99,15 @@
--radius-lg: calc(var(--radius) + 4px);
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@layer base {
* {
@apply border-border;
+79 -16
View File
@@ -2,20 +2,80 @@ import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { StatCard } from '@/components/stat-card';
import { Key, Zap, Users, Activity, Plus, Stethoscope, BookOpen, FolderOpen } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Skeleton } from '@/components/ui/skeleton';
import {
Key,
Zap,
Users,
Activity,
Plus,
Stethoscope,
BookOpen,
FolderOpen,
AlertTriangle,
} from 'lucide-react';
import { useOverview } from '@/hooks/use-overview';
import { useSharedSummary } from '@/hooks/use-shared';
const HEALTH_COLORS = {
ok: 'text-green-500',
warning: 'text-yellow-500',
error: 'text-red-500',
} as const;
export function HomePage() {
const navigate = useNavigate();
const { data: overview } = useOverview();
const { data: shared } = useSharedSummary();
const { data: overview, isLoading: isOverviewLoading } = useOverview();
const { data: shared, isLoading: isSharedLoading } = useSharedSummary();
const healthColor = {
ok: 'text-green-500',
warning: 'text-yellow-500',
error: 'text-red-500',
};
if (isOverviewLoading || isSharedLoading) {
return (
<div className="p-6 space-y-6">
<div>
<Skeleton className="h-9 w-[200px] mb-2" />
<Skeleton className="h-5 w-[320px]" />
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="space-y-3 p-4 border rounded-lg">
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-8 rounded-full" />
<Skeleton className="h-6 w-12" />
</div>
<div>
<Skeleton className="h-4 w-20 mb-1" />
<Skeleton className="h-7 w-16" />
</div>
</div>
))}
</div>
<div className="border rounded-lg p-6 space-y-4">
<Skeleton className="h-6 w-[180px]" />
<div className="flex flex-wrap gap-3">
<Skeleton className="h-10 w-[140px] rounded-md" />
<Skeleton className="h-10 w-[120px] rounded-md" />
<Skeleton className="h-10 w-[150px] rounded-md" />
</div>
</div>
<div className="border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<Skeleton className="h-6 w-[140px]" />
<Skeleton className="h-8 w-[80px] rounded-md" />
</div>
<div className="flex gap-6">
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center gap-2">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-4 w-8" />
<Skeleton className="h-4 w-16" />
</div>
))}
</div>
</div>
</div>
);
}
return (
<div className="p-6 space-y-6">
@@ -24,6 +84,14 @@ export function HomePage() {
<p className="text-muted-foreground">Manage your Claude Code Switch configuration</p>
</div>
{shared?.symlinkStatus && !shared.symlinkStatus.valid && (
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle>
<AlertDescription>{shared.symlinkStatus.message}</AlertDescription>
</Alert>
)}
{/* Stats Grid */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<StatCard
@@ -48,7 +116,9 @@ export function HomePage() {
title="Health"
value={overview?.health ? `${overview.health.passed}/${overview.health.total}` : '-'}
icon={Activity}
color={overview?.health ? healthColor[overview.health.status] : undefined}
color={
overview?.health ? HEALTH_COLORS[overview.health.status as keyof typeof HEALTH_COLORS] : undefined
}
onClick={() => navigate('/health')}
/>
</div>
@@ -97,13 +167,6 @@ export function HomePage() {
<span className="text-muted-foreground">Agents</span>
</div>
</div>
{shared?.symlinkStatus && (
<p
className={`text-xs mt-2 ${shared.symlinkStatus.valid ? 'text-green-600' : 'text-yellow-600'}`}
>
{shared.symlinkStatus.message}
</p>
)}
</CardContent>
</Card>
</div>
+8 -8
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { useSharedItems, useSharedSummary } from '@/hooks/use-shared';
import { FileText, Sparkles, Bot, AlertTriangle } from 'lucide-react';
@@ -27,14 +28,13 @@ export function SharedPage() {
</div>
{summary && !summary.symlinkStatus.valid && (
<Card className="bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<CardContent className="pt-4 flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-yellow-600" />
<span className="text-sm text-yellow-800 dark:text-yellow-200">
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
</span>
</CardContent>
</Card>
<Alert variant="warning">
<AlertTriangle className="h-4 w-4" />
<AlertTitle>Configuration Required</AlertTitle>
<AlertDescription>
{summary.symlinkStatus.message}. Run `ccs sync` to configure.
</AlertDescription>
</Alert>
)}
{/* Tab buttons */}