mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 04:17:11 +00:00
feat(web): enhance dashboard functionality and ui components
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
|
||||
@@ -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' };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in New Issue
Block a user