Apply Composer changes: comprehensive API updates, migrations, middleware, and infrastructure improvements
- Add comprehensive database migrations (001-024) for schema evolution - Enhance API schema with expanded type definitions and resolvers - Add new middleware: audit logging, rate limiting, MFA enforcement, security, tenant auth - Implement new services: AI optimization, billing, blockchain, compliance, marketplace - Add adapter layer for cloud integrations (Cloudflare, Kubernetes, Proxmox, storage) - Update Crossplane provider with enhanced VM management capabilities - Add comprehensive test suite for API endpoints and services - Update frontend components with improved GraphQL subscriptions and real-time updates - Enhance security configurations and headers (CSP, CORS, etc.) - Update documentation and configuration files - Add new CI/CD workflows and validation scripts - Implement design system improvements and UI enhancements
This commit is contained in:
@@ -4,20 +4,80 @@ import { useSession } from 'next-auth/react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { createCrossplaneClient } from '@/lib/crossplane-client';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
|
||||
import { Server, Activity, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { Server, Activity, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { gql } from '@apollo/client';
|
||||
import { useQuery as useApolloQuery } from '@apollo/client';
|
||||
|
||||
interface VM {
|
||||
id: string;
|
||||
status?: {
|
||||
state?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ActivityItem {
|
||||
id: string;
|
||||
type: string;
|
||||
description: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
const GET_RESOURCES = gql`
|
||||
query GetResources {
|
||||
resources {
|
||||
id
|
||||
name
|
||||
type
|
||||
status
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const GET_HEALTH = gql`
|
||||
query GetHealth {
|
||||
health {
|
||||
status
|
||||
timestamp
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function Dashboard() {
|
||||
const { data: session } = useSession();
|
||||
const crossplane = createCrossplaneClient(session?.accessToken as string);
|
||||
|
||||
const { data: vms = [], isLoading } = useQuery({
|
||||
const { data: vms = [], isLoading: vmsLoading } = useQuery<VM[]>({
|
||||
queryKey: ['vms'],
|
||||
queryFn: () => crossplane.getVMs(),
|
||||
});
|
||||
|
||||
const runningVMs = vms.filter((vm: any) => vm.status?.state === 'running').length;
|
||||
const stoppedVMs = vms.filter((vm: any) => vm.status?.state === 'stopped').length;
|
||||
const { data: resourcesData, loading: resourcesLoading } = useApolloQuery(GET_RESOURCES, {
|
||||
skip: !session,
|
||||
});
|
||||
|
||||
const { data: healthData, loading: healthLoading } = useApolloQuery(GET_HEALTH, {
|
||||
skip: !session,
|
||||
pollInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
const resources = resourcesData?.resources || [];
|
||||
const health = healthData?.health;
|
||||
|
||||
const runningVMs = vms.filter((vm: VM) => vm.status?.state === 'running').length;
|
||||
const stoppedVMs = vms.filter((vm: VM) => vm.status?.state === 'stopped').length;
|
||||
const totalVMs = vms.length;
|
||||
|
||||
// Get recent activity from resources (last 10 created/updated)
|
||||
const recentActivity: ActivityItem[] = resources
|
||||
?.slice(0, 10)
|
||||
.map((resource: any) => ({
|
||||
id: resource.id,
|
||||
type: resource.type,
|
||||
description: `${resource.name} - ${resource.status}`,
|
||||
timestamp: new Date(resource.updatedAt || resource.createdAt),
|
||||
}))
|
||||
.sort((a: ActivityItem, b: ActivityItem) => b.timestamp.getTime() - a.timestamp.getTime()) || [];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
@@ -63,8 +123,18 @@ export default function Dashboard() {
|
||||
<Activity className="h-4 w-4 text-blue-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">Healthy</div>
|
||||
<p className="text-xs text-muted-foreground">All systems operational</p>
|
||||
{healthLoading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{health?.status === 'ok' ? 'Healthy' : health?.status || 'Unknown'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{health?.status === 'ok' ? 'All systems operational' : 'Checking system status...'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -74,9 +144,27 @@ export default function Dashboard() {
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Activity feed will be displayed here
|
||||
</p>
|
||||
{resourcesLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : recentActivity.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No recent activity</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{recentActivity.map((activity) => (
|
||||
<div key={activity.id} className="flex items-center justify-between border-b pb-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{activity.description}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{activity.timestamp.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="outline">{activity.type}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
54
portal/src/components/KeyboardShortcutsHelp.tsx
Normal file
54
portal/src/components/KeyboardShortcutsHelp.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { X } from 'lucide-react';
|
||||
|
||||
interface Shortcut {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
const shortcuts: Shortcut[] = [
|
||||
{ keys: ['/'], description: 'Focus search' },
|
||||
{ keys: ['Ctrl', 'G'], description: 'Go to dashboard' },
|
||||
{ keys: ['?'], description: 'Show keyboard shortcuts' },
|
||||
{ keys: ['Esc'], description: 'Close modal/dialog' },
|
||||
];
|
||||
|
||||
export function KeyboardShortcutsHelp() {
|
||||
return (
|
||||
<dialog id="keyboard-shortcuts-help" className="modal">
|
||||
<div className="modal-box bg-gray-800 border border-gray-700">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-xl font-bold text-white">Keyboard Shortcuts</h3>
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost text-gray-400 hover:text-white">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{shortcuts.map((shortcut, index) => (
|
||||
<div key={index} className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<span className="text-sm text-gray-300">{shortcut.description}</span>
|
||||
<div className="flex gap-1">
|
||||
{shortcut.keys.map((key, keyIndex) => (
|
||||
<kbd
|
||||
key={keyIndex}
|
||||
className="px-2 py-1 text-xs font-semibold text-gray-300 bg-gray-700 border border-gray-600 rounded"
|
||||
>
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
17
portal/src/components/KeyboardShortcutsProvider.tsx
Normal file
17
portal/src/components/KeyboardShortcutsProvider.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { useGlobalKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts';
|
||||
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
|
||||
|
||||
export function KeyboardShortcutsProvider({ children }: { children: ReactNode }) {
|
||||
useGlobalKeyboardShortcuts();
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<KeyboardShortcutsHelp />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
177
portal/src/components/ResourceExplorer.tsx
Normal file
177
portal/src/components/ResourceExplorer.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
interface Resource {
|
||||
id: string
|
||||
name: string
|
||||
type: string
|
||||
provider: string
|
||||
region: string
|
||||
status: string
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function ResourceExplorer() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterProvider, setFilterProvider] = useState<string>('all')
|
||||
const [filterType, setFilterType] = useState<string>('all')
|
||||
|
||||
const { data: resources, isLoading } = useQuery<Resource[]>({
|
||||
queryKey: ['resources', filterProvider, filterType],
|
||||
queryFn: async () => {
|
||||
const graphqlEndpoint = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || 'http://localhost:4000/graphql'
|
||||
|
||||
const query = `
|
||||
query GetResourceInventory($filter: ResourceInventoryFilter) {
|
||||
resourceInventory(filter: $filter) {
|
||||
id
|
||||
name
|
||||
resourceType
|
||||
provider
|
||||
region
|
||||
tags
|
||||
metadata
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const variables: any = {}
|
||||
if (filterProvider !== 'all') {
|
||||
variables.provider = filterProvider
|
||||
}
|
||||
if (filterType !== 'all') {
|
||||
variables.resourceType = filterType
|
||||
}
|
||||
|
||||
const response = await fetch(graphqlEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
variables: Object.keys(variables).length > 0 ? { filter: variables } : undefined,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch resources: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const result = await response.json()
|
||||
|
||||
if (result.errors) {
|
||||
throw new Error(result.errors[0]?.message || 'GraphQL error')
|
||||
}
|
||||
|
||||
// Transform GraphQL response to component format
|
||||
return (result.data?.resourceInventory || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
type: item.resourceType,
|
||||
provider: item.provider,
|
||||
region: item.region || 'Unknown',
|
||||
status: item.metadata?.status || 'UNKNOWN',
|
||||
tags: item.tags || [],
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
const filteredResources = resources?.filter((resource) => {
|
||||
const matchesSearch = resource.name.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesProvider = filterProvider === 'all' || resource.provider === filterProvider
|
||||
const matchesType = filterType === 'all' || resource.type === filterType
|
||||
return matchesSearch && matchesProvider && matchesType
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={filterProvider} onValueChange={setFilterProvider}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Provider" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Providers</SelectItem>
|
||||
<SelectItem value="PROXMOX">Proxmox</SelectItem>
|
||||
<SelectItem value="KUBERNETES">Kubernetes</SelectItem>
|
||||
<SelectItem value="CLOUDFLARE">Cloudflare</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterType} onValueChange={setFilterType}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="VM">VM</SelectItem>
|
||||
<SelectItem value="CONTAINER">Container</SelectItem>
|
||||
<SelectItem value="STORAGE">Storage</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div>Loading resources...</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredResources?.map((resource) => (
|
||||
<Card key={resource.id}>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">{resource.name}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Provider:</span>
|
||||
<Badge>{resource.provider}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Type:</span>
|
||||
<Badge variant="outline">{resource.type}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Region:</span>
|
||||
<span className="text-sm">{resource.region}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Status:</span>
|
||||
<Badge
|
||||
variant={
|
||||
resource.status === 'RUNNING' ? 'default' : 'secondary'
|
||||
}
|
||||
>
|
||||
{resource.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{resource.tags && resource.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 mt-2">
|
||||
{resource.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
96
portal/src/components/VMList.tsx
Normal file
96
portal/src/components/VMList.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Server, Play, Pause, Trash2 } from 'lucide-react'
|
||||
|
||||
interface VM {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
cpu: number
|
||||
memory: number
|
||||
disk: number
|
||||
}
|
||||
|
||||
export function VMList() {
|
||||
const { data: vms, isLoading } = useQuery<VM[]>({
|
||||
queryKey: ['vms'],
|
||||
queryFn: async () => {
|
||||
// Use Crossplane client to get VMs
|
||||
const { createCrossplaneClient } = await import('@/lib/crossplane-client')
|
||||
const { useSession } = await import('next-auth/react')
|
||||
|
||||
// Get session token if available
|
||||
const session = typeof window !== 'undefined'
|
||||
? await import('next-auth/react').then(m => {
|
||||
// This is a workaround - in a real component we'd use the hook
|
||||
// For now, we'll use the client without auth or get token from storage
|
||||
return null
|
||||
})
|
||||
: null
|
||||
|
||||
const client = createCrossplaneClient()
|
||||
const vms = await client.getVMs()
|
||||
|
||||
// Transform Crossplane VM format to component format
|
||||
return vms.map((vm: any) => ({
|
||||
id: vm.metadata?.name || vm.metadata?.uid || '',
|
||||
name: vm.metadata?.name || 'Unknown',
|
||||
status: vm.status?.state || 'unknown',
|
||||
cpu: vm.spec?.forProvider?.cpu || 0,
|
||||
memory: vm.spec?.forProvider?.memory || 0,
|
||||
disk: vm.spec?.forProvider?.disk || 0,
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div>Loading VMs...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{vms?.map((vm) => (
|
||||
<Card key={vm.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5" />
|
||||
<div>
|
||||
<CardTitle>{vm.name}</CardTitle>
|
||||
<p className="text-sm text-gray-500">Status: {vm.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm">
|
||||
<Play className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Pause className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-500">CPU:</span> {vm.cpu} cores
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Memory:</span> {vm.memory} GB
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gray-500">Disk:</span> {vm.disk} GB
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
94
portal/src/components/ai/OptimizationEngine.tsx
Normal file
94
portal/src/components/ai/OptimizationEngine.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Sparkles, TrendingDown, TrendingUp, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function OptimizationEngine() {
|
||||
const [recommendations, setRecommendations] = useState([
|
||||
{
|
||||
id: '1',
|
||||
type: 'cost',
|
||||
title: 'Right-size underutilized VMs',
|
||||
description: '3 VMs are running at less than 20% capacity',
|
||||
savings: '$450/month',
|
||||
impact: 'high',
|
||||
action: 'Resize VMs',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
type: 'performance',
|
||||
title: 'Enable auto-scaling for web tier',
|
||||
description: 'Web tier experiencing traffic spikes',
|
||||
improvement: '30% better response time',
|
||||
impact: 'medium',
|
||||
action: 'Configure auto-scaling',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
type: 'security',
|
||||
title: 'Update security policies',
|
||||
description: '5 resources have outdated security policies',
|
||||
impact: 'high',
|
||||
action: 'Review policies',
|
||||
},
|
||||
]);
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Sparkles className="h-5 w-5 text-orange-500" />
|
||||
AI-Powered Optimization Recommendations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{recommendations.map((rec) => (
|
||||
<div
|
||||
key={rec.id}
|
||||
className="p-4 bg-gray-900 rounded-lg border border-gray-700"
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<h4 className="text-white font-semibold mb-1">{rec.title}</h4>
|
||||
<p className="text-sm text-gray-400">{rec.description}</p>
|
||||
</div>
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded ${
|
||||
rec.impact === 'high'
|
||||
? 'bg-red-500/20 text-red-400'
|
||||
: rec.impact === 'medium'
|
||||
? 'bg-yellow-500/20 text-yellow-400'
|
||||
: 'bg-blue-500/20 text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{rec.impact} impact
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{rec.savings && (
|
||||
<div className="flex items-center gap-2 text-green-400 mb-2">
|
||||
<TrendingDown className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">{rec.savings}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{rec.improvement && (
|
||||
<div className="flex items-center gap-2 text-blue-400 mb-2">
|
||||
<TrendingUp className="h-4 w-4" />
|
||||
<span className="text-sm font-semibold">{rec.improvement}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="mt-2 px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors text-sm">
|
||||
{rec.action}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
76
portal/src/components/analytics/AdvancedAnalytics.tsx
Normal file
76
portal/src/components/analytics/AdvancedAnalytics.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { BarChart3, TrendingUp, Users, DollarSign } from 'lucide-react';
|
||||
|
||||
export function AdvancedAnalytics() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Total Revenue</p>
|
||||
<p className="text-2xl font-bold text-white">$125,430</p>
|
||||
<p className="text-xs text-green-400 mt-1">+12.5% from last month</p>
|
||||
</div>
|
||||
<DollarSign className="h-8 w-8 text-green-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Active Users</p>
|
||||
<p className="text-2xl font-bold text-white">2,450</p>
|
||||
<p className="text-xs text-blue-400 mt-1">+8.2% from last month</p>
|
||||
</div>
|
||||
<Users className="h-8 w-8 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">API Requests</p>
|
||||
<p className="text-2xl font-bold text-white">12.5M</p>
|
||||
<p className="text-xs text-purple-400 mt-1">+15.3% from last month</p>
|
||||
</div>
|
||||
<BarChart3 className="h-8 w-8 text-purple-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Growth Rate</p>
|
||||
<p className="text-2xl font-bold text-white">18.7%</p>
|
||||
<p className="text-xs text-orange-400 mt-1">+2.1% from last month</p>
|
||||
</div>
|
||||
<TrendingUp className="h-8 w-8 text-orange-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Analytics Dashboard</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-64 bg-gray-900 rounded flex items-center justify-center">
|
||||
<p className="text-gray-400">Advanced charts and visualizations would appear here</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
165
portal/src/components/argocd/ArgoCDApplications.tsx
Normal file
165
portal/src/components/argocd/ArgoCDApplications.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { createArgoCDClient, type ArgoCDApplication } from '@/lib/argocd-client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'
|
||||
import { Button } from '../ui/Button'
|
||||
import { CheckCircle, XCircle, AlertCircle, RefreshCw, GitBranch } from 'lucide-react'
|
||||
|
||||
export default function ArgoCDApplications() {
|
||||
const { data: session } = useSession()
|
||||
const queryClient = useQueryClient()
|
||||
const argocd = createArgoCDClient(session?.accessToken as string)
|
||||
|
||||
const { data: applications = [], isLoading } = useQuery({
|
||||
queryKey: ['argocd-applications'],
|
||||
queryFn: () => argocd.getApplications(),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
})
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: ({ name, namespace }: { name: string; namespace: string }) =>
|
||||
argocd.syncApplication(name, namespace),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['argocd-applications'] })
|
||||
},
|
||||
})
|
||||
|
||||
const getHealthIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Healthy':
|
||||
return <CheckCircle className="h-5 w-5 text-green-500" />
|
||||
case 'Degraded':
|
||||
case 'Missing':
|
||||
return <XCircle className="h-5 w-5 text-red-500" />
|
||||
case 'Progressing':
|
||||
return <AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||
default:
|
||||
return <AlertCircle className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
}
|
||||
|
||||
const getSyncStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'Synced':
|
||||
return 'text-green-500'
|
||||
case 'OutOfSync':
|
||||
return 'text-yellow-500'
|
||||
default:
|
||||
return 'text-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">ArgoCD Applications</h2>
|
||||
<Button
|
||||
onClick={() => queryClient.invalidateQueries({ queryKey: ['argocd-applications'] })}
|
||||
variant="outline"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{applications.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-gray-400">
|
||||
No ArgoCD applications found
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{applications.map((app: ArgoCDApplication) => (
|
||||
<Card key={app.metadata.uid}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">{app.metadata.name}</CardTitle>
|
||||
{getHealthIcon(app.status.health.status)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Namespace</div>
|
||||
<div className="text-sm font-medium">{app.metadata.namespace}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Source</div>
|
||||
<div className="text-sm font-medium flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4" />
|
||||
{app.spec.source.repoURL}
|
||||
</div>
|
||||
{app.spec.source.path && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Path: {app.spec.source.path}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Sync Status</div>
|
||||
<div className={`text-sm font-medium ${getSyncStatusColor(app.status.sync.status)}`}>
|
||||
{app.status.sync.status}
|
||||
</div>
|
||||
{app.status.sync.revision && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
Revision: {app.status.sync.revision.substring(0, 7)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-sm text-gray-400">Health</div>
|
||||
<div className="text-sm font-medium">{app.status.health.status}</div>
|
||||
{app.status.health.message && (
|
||||
<div className="text-xs text-gray-500 mt-1">
|
||||
{app.status.health.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
syncMutation.mutate({
|
||||
name: app.metadata.name,
|
||||
namespace: app.metadata.namespace,
|
||||
})
|
||||
}
|
||||
disabled={syncMutation.isPending}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Syncing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Sync
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
222
portal/src/components/crossplane/CrossplaneResourceBrowser.tsx
Normal file
222
portal/src/components/crossplane/CrossplaneResourceBrowser.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { createCrossplaneClient } from '@/lib/crossplane-client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'
|
||||
import { Input } from '../ui/Input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'
|
||||
import { Badge } from '../ui/badge'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Server, Database, Network, HardDrive } from 'lucide-react'
|
||||
|
||||
interface CrossplaneResource {
|
||||
apiVersion: string
|
||||
kind: string
|
||||
metadata: {
|
||||
name: string
|
||||
namespace: string
|
||||
uid: string
|
||||
creationTimestamp: string
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
spec?: any
|
||||
status?: any
|
||||
}
|
||||
|
||||
export default function CrossplaneResourceBrowser() {
|
||||
const { data: session } = useSession()
|
||||
const [search, setSearch] = useState('')
|
||||
const [filterKind, setFilterKind] = useState<string>('all')
|
||||
const [filterNamespace, setFilterNamespace] = useState<string>('all')
|
||||
|
||||
// This would need to be implemented to query Crossplane resources
|
||||
// For now, we'll use a placeholder that shows the structure
|
||||
const { data: resources = [], isLoading } = useQuery<CrossplaneResource[]>({
|
||||
queryKey: ['crossplane-resources', filterKind, filterNamespace],
|
||||
queryFn: async () => {
|
||||
// In a real implementation, this would query the Kubernetes API
|
||||
// for Crossplane managed resources
|
||||
const crossplane = createCrossplaneClient(session?.accessToken as string)
|
||||
|
||||
// Get VMs as an example
|
||||
try {
|
||||
const vms = await crossplane.getVMs()
|
||||
return vms.map((vm) => ({
|
||||
apiVersion: process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus/v1alpha1',
|
||||
kind: 'ProxmoxVM',
|
||||
metadata: vm.metadata,
|
||||
spec: vm.spec,
|
||||
status: vm.status,
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Crossplane resources:', error)
|
||||
return []
|
||||
}
|
||||
},
|
||||
enabled: !!session,
|
||||
})
|
||||
|
||||
const getResourceIcon = (kind: string) => {
|
||||
if (kind.includes('VM') || kind.includes('VirtualMachine')) {
|
||||
return <Server className="h-5 w-5 text-blue-500" />
|
||||
}
|
||||
if (kind.includes('Storage') || kind.includes('Volume')) {
|
||||
return <HardDrive className="h-5 w-5 text-green-500" />
|
||||
}
|
||||
if (kind.includes('Network')) {
|
||||
return <Network className="h-5 w-5 text-purple-500" />
|
||||
}
|
||||
if (kind.includes('Database')) {
|
||||
return <Database className="h-5 w-5 text-yellow-500" />
|
||||
}
|
||||
return <Server className="h-5 w-5 text-gray-500" />
|
||||
}
|
||||
|
||||
const getResourceStatusColor = (status: any) => {
|
||||
if (!status) return 'bg-gray-500'
|
||||
|
||||
const state = status.state || status.phase || 'Unknown'
|
||||
switch (state.toLowerCase()) {
|
||||
case 'running':
|
||||
case 'ready':
|
||||
case 'active':
|
||||
return 'bg-green-500'
|
||||
case 'pending':
|
||||
case 'provisioning':
|
||||
return 'bg-yellow-500'
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return 'bg-red-500'
|
||||
default:
|
||||
return 'bg-gray-500'
|
||||
}
|
||||
}
|
||||
|
||||
const filteredResources = resources.filter((resource) => {
|
||||
const matchesSearch = resource.metadata.name.toLowerCase().includes(search.toLowerCase())
|
||||
const matchesKind = filterKind === 'all' || resource.kind === filterKind
|
||||
const matchesNamespace =
|
||||
filterNamespace === 'all' || resource.metadata.namespace === filterNamespace
|
||||
return matchesSearch && matchesKind && matchesNamespace
|
||||
})
|
||||
|
||||
const uniqueKinds = Array.from(new Set(resources.map((r) => r.kind)))
|
||||
const uniqueNamespaces = Array.from(new Set(resources.map((r) => r.metadata.namespace)))
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-2xl font-bold">Crossplane Resources</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Select value={filterKind} onValueChange={setFilterKind}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Resource Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{uniqueKinds.map((kind) => (
|
||||
<SelectItem key={kind} value={kind}>
|
||||
{kind}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={filterNamespace} onValueChange={setFilterNamespace}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="Namespace" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Namespaces</SelectItem>
|
||||
{uniqueNamespaces.map((ns) => (
|
||||
<SelectItem key={ns} value={ns}>
|
||||
{ns}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : filteredResources.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-gray-400">
|
||||
No Crossplane resources found
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{filteredResources.map((resource) => (
|
||||
<Card key={resource.metadata.uid}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{getResourceIcon(resource.kind)}
|
||||
<CardTitle className="text-lg">{resource.metadata.name}</CardTitle>
|
||||
</div>
|
||||
<div
|
||||
className={`h-3 w-3 rounded-full ${getResourceStatusColor(resource.status)}`}
|
||||
title={resource.status?.state || 'Unknown'}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Kind:</span>
|
||||
<Badge>{resource.kind}</Badge>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">Namespace:</span>
|
||||
<span className="text-sm">{resource.metadata.namespace}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">API Version:</span>
|
||||
<span className="text-sm text-gray-400">{resource.apiVersion}</span>
|
||||
</div>
|
||||
{resource.status?.state && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">State:</span>
|
||||
<Badge variant="outline">{resource.status.state}</Badge>
|
||||
</div>
|
||||
)}
|
||||
{resource.status?.ipAddress && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-500">IP Address:</span>
|
||||
<span className="text-sm font-mono">{resource.status.ipAddress}</span>
|
||||
</div>
|
||||
)}
|
||||
{resource.metadata.labels && Object.keys(resource.metadata.labels).length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-gray-700">
|
||||
<div className="text-xs text-gray-500 mb-1">Labels:</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{Object.entries(resource.metadata.labels).map(([key, value]) => (
|
||||
<Badge key={key} variant="outline" className="text-xs">
|
||||
{key}={value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
63
portal/src/components/dashboard/APIKeysTile.tsx
Normal file
63
portal/src/components/dashboard/APIKeysTile.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Key, AlertCircle } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function APIKeysTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const apiKeys = {
|
||||
active: 5,
|
||||
expired: 0,
|
||||
expiringSoon: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Key className="h-5 w-5 text-orange-500" />
|
||||
API Keys & Credentials
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-white mb-2">{apiKeys.active}</p>
|
||||
<p className="text-sm text-gray-400">Active API Keys</p>
|
||||
</div>
|
||||
|
||||
{apiKeys.expiringSoon > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-yellow-400 font-semibold">
|
||||
{apiKeys.expiringSoon} key{apiKeys.expiringSoon > 1 ? 's' : ''} expiring soon
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{apiKeys.expired > 0 && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-red-400 font-semibold">
|
||||
{apiKeys.expired} expired key{apiKeys.expired > 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/developer/api-keys"
|
||||
className="block text-center text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Manage API Keys →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
58
portal/src/components/dashboard/APIUsageTile.tsx
Normal file
58
portal/src/components/dashboard/APIUsageTile.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Activity, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function APIUsageTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const usage = {
|
||||
requests: { current: 1250000, limit: 2000000, percentage: 62.5 },
|
||||
quota: { used: 62.5, remaining: 37.5 },
|
||||
};
|
||||
|
||||
const isNearLimit = usage.quota.used > 80;
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
API Usage & Quotas
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-400">Requests (this month)</span>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{usage.requests.current.toLocaleString()} / {usage.requests.limit.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-900 rounded-full h-3">
|
||||
<div
|
||||
className={`h-3 rounded-full ${isNearLimit ? 'bg-red-500' : 'bg-orange-500'}`}
|
||||
style={{ width: `${usage.quota.used}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{usage.quota.remaining}% remaining
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isNearLimit && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-yellow-400 font-semibold">
|
||||
Approaching quota limit
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
67
portal/src/components/dashboard/BillingTile.tsx
Normal file
67
portal/src/components/dashboard/BillingTile.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { CreditCard, FileText } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export function BillingTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const billing = {
|
||||
currentInvoice: 45230,
|
||||
nextBillingDate: '2024-02-15',
|
||||
paymentMethod: 'Credit Card ending in 4242',
|
||||
invoicesPending: 0,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-orange-500" />
|
||||
Billing & Invoices
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-white mb-1">
|
||||
${billing.currentInvoice.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Current Invoice</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Next Billing Date</p>
|
||||
<p className="text-sm font-semibold text-white">
|
||||
{new Date(billing.nextBillingDate).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<p className="text-xs text-gray-400 mb-1">Payment Method</p>
|
||||
<p className="text-sm text-white">{billing.paymentMethod}</p>
|
||||
</div>
|
||||
|
||||
{billing.invoicesPending > 0 && (
|
||||
<div className="p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-yellow-400 font-semibold">
|
||||
{billing.invoicesPending} invoice{billing.invoicesPending > 1 ? 's' : ''} pending
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href="/admin/billing"
|
||||
className="block text-center text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
View All Invoices →
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
60
portal/src/components/dashboard/ComplianceStatusTile.tsx
Normal file
60
portal/src/components/dashboard/ComplianceStatusTile.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Shield, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function ComplianceStatusTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const compliance = {
|
||||
soc2: { status: 'compliant', lastAudit: '2024-01-15' },
|
||||
iso27001: { status: 'compliant', lastAudit: '2024-01-10' },
|
||||
gdpr: { status: 'compliant', lastAudit: '2024-01-20' },
|
||||
hipaa: { status: 'pending', lastAudit: null },
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
if (status === 'compliant') {
|
||||
return <CheckCircle className="h-4 w-4 text-green-400" />;
|
||||
}
|
||||
return <AlertCircle className="h-4 w-4 text-yellow-400" />;
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
if (status === 'compliant') return 'text-green-400';
|
||||
return 'text-yellow-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-orange-500" />
|
||||
Compliance Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(compliance).map(([key, value]) => (
|
||||
<div key={key} className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusIcon(value.status)}
|
||||
<span className="text-sm text-gray-300 uppercase">{key}</span>
|
||||
</div>
|
||||
<span className={`text-xs font-semibold ${getStatusColor(value.status)}`}>
|
||||
{value.status}
|
||||
</span>
|
||||
</div>
|
||||
{value.lastAudit && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Last audit: {new Date(value.lastAudit).toLocaleDateString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
84
portal/src/components/dashboard/CostForecastingTile.tsx
Normal file
84
portal/src/components/dashboard/CostForecastingTile.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { TrendingUp, TrendingDown, DollarSign } from 'lucide-react';
|
||||
|
||||
export function CostForecastingTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const forecast = {
|
||||
currentMonth: 45230,
|
||||
nextMonth: 48500,
|
||||
threeMonths: 52000,
|
||||
sixMonths: 58000,
|
||||
trend: 'up',
|
||||
confidence: 85,
|
||||
};
|
||||
|
||||
const TrendIcon = forecast.trend === 'up' ? TrendingUp : TrendingDown;
|
||||
const trendColor = forecast.trend === 'up' ? 'text-red-400' : 'text-green-400';
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-orange-500" />
|
||||
Cost Forecasting
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Current Month</p>
|
||||
<p className="text-2xl font-bold text-white">
|
||||
${forecast.currentMonth.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">Next Month</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<TrendIcon className={`h-4 w-4 ${trendColor}`} />
|
||||
<span className={`text-sm font-semibold ${trendColor}`}>
|
||||
{((forecast.nextMonth - forecast.currentMonth) / forecast.currentMonth * 100).toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
${forecast.nextMonth.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">3 Months</span>
|
||||
<span className="text-sm text-gray-400">{forecast.confidence}% confidence</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
${forecast.threeMonths.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm text-gray-400">6 Months</span>
|
||||
<span className="text-sm text-gray-400">{forecast.confidence}% confidence</span>
|
||||
</div>
|
||||
<p className="text-lg font-semibold text-white">
|
||||
${forecast.sixMonths.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<p className="text-xs text-gray-400">
|
||||
Forecast based on historical usage patterns and trends
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
53
portal/src/components/dashboard/CostOverviewTile.tsx
Normal file
53
portal/src/components/dashboard/CostOverviewTile.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { DollarSign, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
|
||||
export function CostOverviewTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const costData = {
|
||||
currentMonth: 45230,
|
||||
lastMonth: 38950,
|
||||
trend: 'up',
|
||||
percentageChange: 16.1,
|
||||
};
|
||||
|
||||
const trend = costData.trend === 'up' ? TrendingUp : TrendingDown;
|
||||
const TrendIcon = trend;
|
||||
const trendColor = costData.trend === 'up' ? 'text-red-400' : 'text-green-400';
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<DollarSign className="h-5 w-5 text-orange-500" />
|
||||
Cost Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-3xl font-bold text-white mb-1">
|
||||
${costData.currentMonth.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400">Current Month</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 p-3 bg-gray-900 rounded-lg">
|
||||
<TrendIcon className={`h-4 w-4 ${trendColor}`} />
|
||||
<span className={`text-sm font-semibold ${trendColor}`}>
|
||||
{costData.percentageChange}% vs last month
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<p className="text-xs text-gray-400">
|
||||
Last month: <span className="text-white font-semibold">${costData.lastMonth.toLocaleString()}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
131
portal/src/components/dashboard/DashboardCustomizer.tsx
Normal file
131
portal/src/components/dashboard/DashboardCustomizer.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { GripVertical, X, Plus } from 'lucide-react';
|
||||
import { DragDropContext, Droppable, Draggable, DropResult } from 'react-beautiful-dnd';
|
||||
|
||||
interface DashboardTile {
|
||||
id: string;
|
||||
component: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
interface DashboardCustomizerProps {
|
||||
tiles: DashboardTile[];
|
||||
onTilesChange: (tiles: DashboardTile[]) => void;
|
||||
availableTiles: Array<{ id: string; title: string; component: string }>;
|
||||
}
|
||||
|
||||
export function DashboardCustomizer({ tiles, onTilesChange, availableTiles }: DashboardCustomizerProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const onDragEnd = (result: DropResult) => {
|
||||
if (!result.destination) return;
|
||||
|
||||
const newTiles = Array.from(tiles);
|
||||
const [reorderedItem] = newTiles.splice(result.source.index, 1);
|
||||
newTiles.splice(result.destination.index, 0, reorderedItem);
|
||||
|
||||
onTilesChange(newTiles);
|
||||
};
|
||||
|
||||
const toggleTileVisibility = (tileId: string) => {
|
||||
const newTiles = tiles.map(tile =>
|
||||
tile.id === tileId ? { ...tile, visible: !tile.visible } : tile
|
||||
);
|
||||
onTilesChange(newTiles);
|
||||
};
|
||||
|
||||
const addTile = (tileId: string) => {
|
||||
const tile = availableTiles.find(t => t.id === tileId);
|
||||
if (tile && !tiles.find(t => t.id === tileId)) {
|
||||
onTilesChange([...tiles, { ...tile, visible: true }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-xl font-bold text-white">Customize Dashboard</h2>
|
||||
<button
|
||||
onClick={() => setIsEditing(!isEditing)}
|
||||
className="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
{isEditing ? 'Done' : 'Customize'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<Card className="bg-gray-800 border-gray-700 mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Available Tiles</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
||||
{availableTiles
|
||||
.filter(t => !tiles.find(ct => ct.id === t.id))
|
||||
.map(tile => (
|
||||
<button
|
||||
key={tile.id}
|
||||
onClick={() => addTile(tile.id)}
|
||||
className="p-3 bg-gray-900 rounded hover:bg-gray-700 transition-colors text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-sm text-white">{tile.title}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<DragDropContext onDragEnd={onDragEnd}>
|
||||
<Droppable droppableId="dashboard-tiles">
|
||||
{(provided) => (
|
||||
<div {...provided.droppableProps} ref={provided.innerRef}>
|
||||
{tiles.map((tile, index) => (
|
||||
<Draggable key={tile.id} draggableId={tile.id} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
className={`mb-2 p-3 bg-gray-900 rounded flex items-center justify-between ${
|
||||
snapshot.isDragging ? 'opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div {...provided.dragHandleProps}>
|
||||
<GripVertical className="h-5 w-5 text-gray-400" />
|
||||
</div>
|
||||
<span className="text-white">{tile.title}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={tile.visible}
|
||||
onChange={() => toggleTileVisibility(tile.id)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Visible</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
</Droppable>
|
||||
</DragDropContext>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
62
portal/src/components/dashboard/DataPipelineTile.tsx
Normal file
62
portal/src/components/dashboard/DataPipelineTile.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { GitBranch, PlayCircle, PauseCircle, AlertCircle } from 'lucide-react';
|
||||
|
||||
export function DataPipelineTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const pipelines = {
|
||||
running: 12,
|
||||
failed: 1,
|
||||
pending: 3,
|
||||
total: 16,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<GitBranch className="h-5 w-5 text-orange-500" />
|
||||
Data Pipeline Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PlayCircle className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-gray-400">Running</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{pipelines.running}</p>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<PauseCircle className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-gray-400">Pending</span>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-white">{pipelines.pending}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pipelines.failed > 0 && (
|
||||
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-red-400 font-semibold">
|
||||
{pipelines.failed} pipeline{pipelines.failed > 1 ? 's' : ''} failed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-2 border-t border-gray-700">
|
||||
<p className="text-xs text-gray-400">Total Pipelines: <span className="text-white font-semibold">{pipelines.total}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
64
portal/src/components/dashboard/DeploymentsTile.tsx
Normal file
64
portal/src/components/dashboard/DeploymentsTile.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Rocket, CheckCircle, Clock, XCircle } from 'lucide-react';
|
||||
|
||||
export function DeploymentsTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const deployments = {
|
||||
active: 8,
|
||||
syncing: 2,
|
||||
failed: 0,
|
||||
total: 10,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Rocket className="h-5 w-5 text-orange-500" />
|
||||
Active Deployments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-white mb-2">{deployments.total}</p>
|
||||
<p className="text-sm text-gray-400">Total Deployments</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Synced</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{deployments.active}</span>
|
||||
</div>
|
||||
|
||||
{deployments.syncing > 0 && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-gray-300">Syncing</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{deployments.syncing}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deployments.failed > 0 && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<XCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-gray-300">Failed</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{deployments.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
64
portal/src/components/dashboard/IntegrationStatusTile.tsx
Normal file
64
portal/src/components/dashboard/IntegrationStatusTile.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Link2, CheckCircle, AlertCircle, Clock } from 'lucide-react';
|
||||
|
||||
export function IntegrationStatusTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const integrations = {
|
||||
total: 45,
|
||||
active: 42,
|
||||
pending: 2,
|
||||
failed: 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Link2 className="h-5 w-5 text-orange-500" />
|
||||
Active Integrations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-white mb-2">{integrations.total}</p>
|
||||
<p className="text-sm text-gray-400">Total Integrations</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<CheckCircle className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Active</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{integrations.active}</span>
|
||||
</div>
|
||||
|
||||
{integrations.pending > 0 && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-yellow-400" />
|
||||
<span className="text-sm text-gray-300">Pending</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{integrations.pending}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{integrations.failed > 0 && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-red-400" />
|
||||
<span className="text-sm text-gray-300">Failed</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{integrations.failed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
35
portal/src/components/dashboard/LazyTile.tsx
Normal file
35
portal/src/components/dashboard/LazyTile.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, lazy, ComponentType } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface LazyTileProps {
|
||||
component: ComponentType<any>;
|
||||
props?: any;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LazyTile({ component: Component, props, fallback }: LazyTileProps) {
|
||||
const LazyComponent = lazy(() => Promise.resolve({ default: Component }));
|
||||
|
||||
const defaultFallback = (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Loading...</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback || defaultFallback}>
|
||||
<LazyComponent {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
59
portal/src/components/dashboard/QuickActionsPanel.tsx
Normal file
59
portal/src/components/dashboard/QuickActionsPanel.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import Link from 'next/link';
|
||||
import { Plus, Link as LinkIcon, FileText, Settings, Key, Rocket, Book, CreditCard, HelpCircle, Download } from 'lucide-react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
|
||||
interface QuickAction {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
interface QuickActionsPanelProps {
|
||||
actions: QuickAction[];
|
||||
}
|
||||
|
||||
const iconMap: Record<string, any> = {
|
||||
Plus,
|
||||
Link: LinkIcon,
|
||||
FileText,
|
||||
Settings,
|
||||
Key,
|
||||
Rocket,
|
||||
Book,
|
||||
CreditCard,
|
||||
HelpCircle,
|
||||
Download,
|
||||
};
|
||||
|
||||
export function QuickActionsPanel({ actions }: QuickActionsPanelProps) {
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Quick Actions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{actions.map((action) => {
|
||||
const Icon = iconMap[action.icon] || Plus;
|
||||
return (
|
||||
<Link
|
||||
key={action.href}
|
||||
href={action.href}
|
||||
className="flex flex-col items-center justify-center p-4 bg-gray-900 rounded-lg hover:bg-gray-700 transition-colors group"
|
||||
>
|
||||
<Icon className="h-6 w-6 text-gray-400 group-hover:text-orange-500 mb-2 transition-colors" />
|
||||
<span className="text-xs text-center text-gray-300 group-hover:text-white transition-colors">
|
||||
{action.label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
45
portal/src/components/dashboard/ResourceUsageTile.tsx
Normal file
45
portal/src/components/dashboard/ResourceUsageTile.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { BarChart3 } from 'lucide-react';
|
||||
|
||||
export function ResourceUsageTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const usageByDivision = [
|
||||
{ name: 'Core Infrastructure', usage: 45, cost: 20350 },
|
||||
{ name: 'Data Services', usage: 28, cost: 12600 },
|
||||
{ name: 'Security', usage: 15, cost: 6750 },
|
||||
{ name: 'AI/ML', usage: 12, cost: 5530 },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<BarChart3 className="h-5 w-5 text-orange-500" />
|
||||
Resource Usage by Division
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{usageByDivision.map((division) => (
|
||||
<div key={division.name} className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-300">{division.name}</span>
|
||||
<span className="text-sm font-semibold text-white">{division.usage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2 mb-1">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full"
|
||||
style={{ width: `${division.usage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">${division.cost.toLocaleString()}/month</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
94
portal/src/components/dashboard/ResourceUtilizationTile.tsx
Normal file
94
portal/src/components/dashboard/ResourceUtilizationTile.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Cpu, HardDrive, Activity } from 'lucide-react';
|
||||
|
||||
export function ResourceUtilizationTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const utilization = {
|
||||
cpu: { used: 68, total: 100 },
|
||||
memory: { used: 45, total: 100 },
|
||||
storage: { used: 72, total: 100 },
|
||||
};
|
||||
|
||||
const getColor = (percent: number) => {
|
||||
if (percent >= 80) return 'text-red-400';
|
||||
if (percent >= 60) return 'text-yellow-400';
|
||||
return 'text-green-400';
|
||||
};
|
||||
|
||||
const getBgColor = (percent: number) => {
|
||||
if (percent >= 80) return 'bg-red-500/20';
|
||||
if (percent >= 60) return 'bg-yellow-500/20';
|
||||
return 'bg-green-500/20';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
Resource Utilization
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm text-gray-400">CPU</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${getColor(utilization.cpu.used)}`}>
|
||||
{utilization.cpu.used}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-900 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getBgColor(utilization.cpu.used)}`}
|
||||
style={{ width: `${utilization.cpu.used}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-400" />
|
||||
<span className="text-sm text-gray-400">Memory</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${getColor(utilization.memory.used)}`}>
|
||||
{utilization.memory.used}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-900 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getBgColor(utilization.memory.used)}`}
|
||||
style={{ width: `${utilization.memory.used}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<HardDrive className="h-4 w-4 text-cyan-400" />
|
||||
<span className="text-sm text-gray-400">Storage</span>
|
||||
</div>
|
||||
<span className={`text-sm font-semibold ${getColor(utilization.storage.used)}`}>
|
||||
{utilization.storage.used}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-900 rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full ${getBgColor(utilization.storage.used)}`}
|
||||
style={{ width: `${utilization.storage.used}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
44
portal/src/components/dashboard/ServiceAdoptionTile.tsx
Normal file
44
portal/src/components/dashboard/ServiceAdoptionTile.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { TrendingUp } from 'lucide-react';
|
||||
|
||||
export function ServiceAdoptionTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const services = [
|
||||
{ name: 'PhoenixCore Compute', adoption: 95, trend: 'up' },
|
||||
{ name: 'OkraVault Storage', adoption: 87, trend: 'up' },
|
||||
{ name: 'SankofaGrid Network', adoption: 72, trend: 'up' },
|
||||
{ name: 'Firebird AI Engine', adoption: 45, trend: 'up' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-orange-500" />
|
||||
Service Adoption Metrics
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{services.map((service) => (
|
||||
<div key={service.name} className="p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm text-gray-300">{service.name}</span>
|
||||
<span className="text-sm font-semibold text-white">{service.adoption}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-gray-800 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full"
|
||||
style={{ width: `${service.adoption}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
173
portal/src/components/dashboard/SystemHealthTile.tsx
Normal file
173
portal/src/components/dashboard/SystemHealthTile.tsx
Normal file
@@ -0,0 +1,173 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Activity, CheckCircle, AlertCircle, XCircle, Globe, Server } from 'lucide-react';
|
||||
import { useSystemHealth } from '@/hooks/useDashboardData';
|
||||
|
||||
export function SystemHealthTile() {
|
||||
const { data, loading, error } = useSystemHealth();
|
||||
|
||||
// Calculate health data from API response
|
||||
const healthData = data ? {
|
||||
regions: {
|
||||
total: data.sites?.length || 0,
|
||||
healthy: data.sites?.filter((s: any) => s.status === 'ACTIVE').length || 0,
|
||||
warning: data.sites?.filter((s: any) => s.status === 'MAINTENANCE').length || 0,
|
||||
critical: data.sites?.filter((s: any) => s.status === 'INACTIVE').length || 0,
|
||||
},
|
||||
clusters: {
|
||||
total: data.resources?.length || 0,
|
||||
healthy: data.resources?.filter((r: any) => r.status === 'RUNNING').length || 0,
|
||||
warning: data.resources?.filter((r: any) => r.status === 'PROVISIONING').length || 0,
|
||||
critical: data.resources?.filter((r: any) => r.status === 'ERROR').length || 0,
|
||||
},
|
||||
nodes: {
|
||||
total: data.resources?.length || 0,
|
||||
healthy: data.resources?.filter((r: any) => r.status === 'RUNNING').length || 0,
|
||||
warning: data.resources?.filter((r: any) => r.status === 'PROVISIONING').length || 0,
|
||||
critical: data.resources?.filter((r: any) => r.status === 'ERROR').length || 0,
|
||||
},
|
||||
} : {
|
||||
regions: { total: 0, healthy: 0, warning: 0, critical: 0 },
|
||||
clusters: { total: 0, healthy: 0, warning: 0, critical: 0 },
|
||||
nodes: { total: 0, healthy: 0, warning: 0, critical: 0 },
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
System Health Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center text-gray-400 py-4">Loading...</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
System Health Overview
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-center text-red-400 py-4">Error loading health data</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const overallHealth = healthData.regions.healthy / healthData.regions.total > 0.95 ? 'healthy' :
|
||||
healthData.regions.healthy / healthData.regions.total > 0.85 ? 'warning' : 'critical';
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-orange-500" />
|
||||
System Health Overview
|
||||
</CardTitle>
|
||||
<div className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||
overallHealth === 'healthy' ? 'bg-green-500/20 text-green-400' :
|
||||
overallHealth === 'warning' ? 'bg-yellow-500/20 text-yellow-400' :
|
||||
'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{overallHealth === 'healthy' ? 'Healthy' : overallHealth === 'warning' ? 'Warning' : 'Critical'}
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Globe className="h-5 w-5 text-blue-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Regions</p>
|
||||
<p className="text-lg font-semibold text-white">{healthData.regions.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.regions.healthy}</span>
|
||||
</div>
|
||||
{healthData.regions.warning > 0 && (
|
||||
<div className="flex items-center gap-1 text-yellow-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.regions.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
{healthData.regions.critical > 0 && (
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.regions.critical}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Server className="h-5 w-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Clusters</p>
|
||||
<p className="text-lg font-semibold text-white">{healthData.clusters.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.clusters.healthy}</span>
|
||||
</div>
|
||||
{healthData.clusters.warning > 0 && (
|
||||
<div className="flex items-center gap-1 text-yellow-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.clusters.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-3 bg-gray-900 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<Activity className="h-5 w-5 text-cyan-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">Nodes</p>
|
||||
<p className="text-lg font-semibold text-white">{healthData.nodes.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center gap-1 text-green-400">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.nodes.healthy}</span>
|
||||
</div>
|
||||
{healthData.nodes.warning > 0 && (
|
||||
<div className="flex items-center gap-1 text-yellow-400">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.nodes.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
{healthData.nodes.critical > 0 && (
|
||||
<div className="flex items-center gap-1 text-red-400">
|
||||
<XCircle className="h-4 w-4" />
|
||||
<span className="text-sm">{healthData.nodes.critical}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
53
portal/src/components/dashboard/TestEnvironmentsTile.tsx
Normal file
53
portal/src/components/dashboard/TestEnvironmentsTile.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { TestTube, PlayCircle, PauseCircle } from 'lucide-react';
|
||||
|
||||
export function TestEnvironmentsTile() {
|
||||
// Mock data - in production, this would come from API
|
||||
const environments = {
|
||||
active: 3,
|
||||
stopped: 1,
|
||||
total: 4,
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white flex items-center gap-2">
|
||||
<TestTube className="h-5 w-5 text-orange-500" />
|
||||
Test Environments
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="text-center">
|
||||
<p className="text-4xl font-bold text-white mb-2">{environments.total}</p>
|
||||
<p className="text-sm text-gray-400">Total Environments</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<PlayCircle className="h-4 w-4 text-green-400" />
|
||||
<span className="text-sm text-gray-300">Active</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{environments.active}</span>
|
||||
</div>
|
||||
|
||||
{environments.stopped > 0 && (
|
||||
<div className="flex items-center justify-between p-2 bg-gray-900 rounded">
|
||||
<div className="flex items-center gap-2">
|
||||
<PauseCircle className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-300">Stopped</span>
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-white">{environments.stopped}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
344
portal/src/components/fairness/FairnessOrchestrationWizard.tsx
Normal file
344
portal/src/components/fairness/FairnessOrchestrationWizard.tsx
Normal file
@@ -0,0 +1,344 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||
import { InfoIcon, AlertTriangleIcon, CheckCircleIcon } from 'lucide-react';
|
||||
|
||||
// Import orchestration engine (would be from API in production)
|
||||
import type {
|
||||
OrchestrationRequest,
|
||||
OrchestrationResult,
|
||||
OutputType,
|
||||
InputSpec,
|
||||
TimelineSpec
|
||||
} from '@/lib/fairness-orchestration';
|
||||
import { orchestrate, getAvailableOutputs, getUserMessage } from '@/lib/fairness-orchestration';
|
||||
|
||||
export default function FairnessOrchestrationWizard() {
|
||||
const [selectedOutputs, setSelectedOutputs] = useState<string[]>([]);
|
||||
const [inputSpec, setInputSpec] = useState<InputSpec>({
|
||||
dataset: '',
|
||||
sensitiveAttributes: [],
|
||||
dateRange: undefined,
|
||||
filters: {}
|
||||
});
|
||||
const [timeline, setTimeline] = useState<TimelineSpec>({
|
||||
mode: 'now',
|
||||
sla: '2 hours'
|
||||
});
|
||||
const [orchestrationResult, setOrchestrationResult] = useState<OrchestrationResult | null>(null);
|
||||
|
||||
const availableOutputs = getAvailableOutputs();
|
||||
|
||||
// Calculate orchestration when inputs change
|
||||
const result = useMemo(() => {
|
||||
if (!inputSpec.dataset || selectedOutputs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const request: OrchestrationRequest = {
|
||||
input: inputSpec,
|
||||
outputs: selectedOutputs,
|
||||
timeline
|
||||
};
|
||||
|
||||
return orchestrate(request);
|
||||
}, [inputSpec, selectedOutputs, timeline]);
|
||||
|
||||
const handleOutputToggle = (outputId: string) => {
|
||||
setSelectedOutputs(prev =>
|
||||
prev.includes(outputId)
|
||||
? prev.filter(id => id !== outputId)
|
||||
: [...prev, outputId]
|
||||
);
|
||||
};
|
||||
|
||||
const handleRun = () => {
|
||||
if (result) {
|
||||
setOrchestrationResult(result);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 p-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Fairness Audit Orchestration</h1>
|
||||
<p className="text-muted-foreground mt-2">
|
||||
Design from the end result backwards. Choose your outputs, inputs, and timeline.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Step 1: Output Selection */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>1. Output</CardTitle>
|
||||
<CardDescription>What do you want to generate?</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{availableOutputs.map((output) => (
|
||||
<div key={output.id} className="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id={output.id}
|
||||
checked={selectedOutputs.includes(output.id)}
|
||||
onCheckedChange={() => handleOutputToggle(output.id)}
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor={output.id} className="font-medium cursor-pointer">
|
||||
{output.name}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{output.description}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Weight: {output.weight} units
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{selectedOutputs.length > 0 && (
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm">
|
||||
<strong>Total Output Load:</strong>{' '}
|
||||
{result?.outputLoad.toFixed(1) || '0'} units
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step 2: Input Specification */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>2. Input</CardTitle>
|
||||
<CardDescription>What data are you analyzing?</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="dataset">Dataset</Label>
|
||||
<Input
|
||||
id="dataset"
|
||||
value={inputSpec.dataset}
|
||||
onChange={(e) => setInputSpec(prev => ({ ...prev, dataset: e.target.value }))}
|
||||
placeholder="Select or enter dataset name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="dateRange">Date Range (Optional)</Label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={inputSpec.dateRange?.start || ''}
|
||||
onChange={(e) => setInputSpec(prev => ({
|
||||
...prev,
|
||||
dateRange: { ...prev.dateRange, start: e.target.value } as any
|
||||
}))}
|
||||
/>
|
||||
<Input
|
||||
type="date"
|
||||
value={inputSpec.dateRange?.end || ''}
|
||||
onChange={(e) => setInputSpec(prev => ({
|
||||
...prev,
|
||||
dateRange: { ...prev.dateRange, end: e.target.value } as any
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sensitiveAttributes">Sensitive Attributes</Label>
|
||||
<Input
|
||||
id="sensitiveAttributes"
|
||||
value={inputSpec.sensitiveAttributes.join(', ')}
|
||||
onChange={(e) => setInputSpec(prev => ({
|
||||
...prev,
|
||||
sensitiveAttributes: e.target.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
}))}
|
||||
placeholder="race, gender, age (comma-separated)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="pt-4 border-t">
|
||||
<p className="text-sm">
|
||||
<strong>Estimated Input Load:</strong>{' '}
|
||||
{result.inputLoad.toFixed(0)} units
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Step 3: Timeline */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>3. Timeline</CardTitle>
|
||||
<CardDescription>When does this need to be ready?</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<Label htmlFor="mode">Execution Mode</Label>
|
||||
<Select
|
||||
value={timeline.mode}
|
||||
onValueChange={(value: 'now' | 'scheduled' | 'continuous') =>
|
||||
setTimeline(prev => ({ ...prev, mode: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="now">Run Now</SelectItem>
|
||||
<SelectItem value="scheduled">Schedule</SelectItem>
|
||||
<SelectItem value="continuous">Continuous</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label htmlFor="sla">SLA / Time Limit</Label>
|
||||
<Input
|
||||
id="sla"
|
||||
value={timeline.sla || ''}
|
||||
onChange={(e) => setTimeline(prev => ({ ...prev, sla: e.target.value }))}
|
||||
placeholder="2 hours, 1 day, 30 minutes"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className="pt-4 border-t space-y-2">
|
||||
<p className="text-sm">
|
||||
<strong>Estimated Time:</strong>{' '}
|
||||
{formatTime(result.estimatedTime)}
|
||||
</p>
|
||||
<p className="text-sm">
|
||||
<strong>Total Process Load:</strong>{' '}
|
||||
{result.totalLoad.toFixed(1)} units
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Orchestration Result */}
|
||||
{result && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Orchestration Analysis</CardTitle>
|
||||
<CardDescription>
|
||||
The engine calculates: Total Load ≈ Output + 2×Input ≈ 3.2×Input
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Input Load</p>
|
||||
<p className="text-2xl font-bold">{result.inputLoad.toFixed(0)}</p>
|
||||
<p className="text-xs text-muted-foreground">× 2 = {result.inputLoad * 2} (passes)</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Output Load</p>
|
||||
<p className="text-2xl font-bold">{result.outputLoad.toFixed(1)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Total Load</p>
|
||||
<p className="text-2xl font-bold">{result.totalLoad.toFixed(1)}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
≈ {result.inputLoad.toFixed(0)} × 3.2 = {(result.inputLoad * 3.2).toFixed(1)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{result.warnings.length > 0 && (
|
||||
<Alert variant={result.feasible ? 'default' : 'destructive'}>
|
||||
<AlertTriangleIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-2">
|
||||
{result.warnings.map((warning, i) => (
|
||||
<p key={i}>{warning}</p>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result.suggestions.length > 0 && (
|
||||
<Alert>
|
||||
<InfoIcon className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Suggestions:</p>
|
||||
{result.suggestions.map((suggestion, i) => (
|
||||
<p key={i} className="text-sm">• {suggestion}</p>
|
||||
))}
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{result.feasible && result.warnings.length === 0 && (
|
||||
<Alert variant="default" className="border-green-500">
|
||||
<CheckCircleIcon className="h-4 w-4 text-green-500" />
|
||||
<AlertDescription className="text-green-700">
|
||||
This configuration is feasible and ready to run.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="pt-4">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
{getUserMessage(result, {
|
||||
input: inputSpec,
|
||||
outputs: selectedOutputs,
|
||||
timeline
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={handleRun}
|
||||
disabled={!result.feasible || selectedOutputs.length === 0 || !inputSpec.dataset}
|
||||
className="w-full"
|
||||
>
|
||||
{result.feasible ? 'Run Fairness Audit' : 'Adjust Configuration'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Info Box */}
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-semibold">How It Works</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<strong>We design from the end result backwards.</strong> First we list every output you want
|
||||
(reports, files, metrics), then we calculate how much input validation and enrichment is needed
|
||||
— roughly <strong>2× your input effort</strong> — and size the total job at about <strong>3.2× the input</strong>.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You only choose: (1) What goes in, (2) What comes out, (3) When it needs to be ready.
|
||||
The orchestration engine does everything in between.
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds < 60) return `${Math.ceil(seconds)}s`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)}m`;
|
||||
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)}h`;
|
||||
return `${(seconds / 86400).toFixed(1)}d`;
|
||||
}
|
||||
|
||||
132
portal/src/components/kubernetes/KubernetesClusters.tsx
Normal file
132
portal/src/components/kubernetes/KubernetesClusters.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client'
|
||||
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { createKubernetesClient } from '@/lib/kubernetes-client'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'
|
||||
import { Server, CheckCircle, XCircle } from 'lucide-react'
|
||||
|
||||
export default function KubernetesClusters() {
|
||||
const { data: session } = useSession()
|
||||
const k8s = createKubernetesClient(session?.accessToken as string)
|
||||
|
||||
const { data: clusterInfo, isLoading } = useQuery({
|
||||
queryKey: ['kubernetes-cluster'],
|
||||
queryFn: () => k8s.getClusterInfo(),
|
||||
refetchInterval: 30000,
|
||||
})
|
||||
|
||||
const { data: namespaces = [] } = useQuery({
|
||||
queryKey: ['kubernetes-namespaces'],
|
||||
queryFn: () => k8s.listNamespaces(),
|
||||
enabled: !!clusterInfo,
|
||||
})
|
||||
|
||||
const { data: pods = [] } = useQuery({
|
||||
queryKey: ['kubernetes-pods'],
|
||||
queryFn: () => k8s.listPods(),
|
||||
enabled: !!clusterInfo,
|
||||
})
|
||||
|
||||
const { data: deployments = [] } = useQuery({
|
||||
queryKey: ['kubernetes-deployments'],
|
||||
queryFn: () => k8s.listDeployments(),
|
||||
enabled: !!clusterInfo,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!clusterInfo) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-8 text-center text-gray-400">
|
||||
Unable to connect to Kubernetes cluster
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
const nodeHealthPercentage =
|
||||
clusterInfo.nodes && clusterInfo.nodes.total > 0
|
||||
? Math.round((clusterInfo.nodes.ready / clusterInfo.nodes.total) * 100)
|
||||
: 0
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-2xl font-bold">Kubernetes Clusters</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Cluster</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-blue-500" />
|
||||
<div>
|
||||
<div className="text-lg font-bold">{clusterInfo.name}</div>
|
||||
<div className="text-sm text-gray-400">{clusterInfo.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Nodes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
{nodeHealthPercentage === 100 ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-yellow-500" />
|
||||
)}
|
||||
<div>
|
||||
<div className="text-lg font-bold">
|
||||
{clusterInfo.nodes?.ready || 0} / {clusterInfo.nodes?.total || 0}
|
||||
</div>
|
||||
<div className="text-sm text-gray-400">{nodeHealthPercentage}% healthy</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Namespaces</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-lg font-bold">{namespaces.length}</div>
|
||||
<div className="text-sm text-gray-400">Total namespaces</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm font-medium text-gray-400">Workloads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-400">Pods:</span>
|
||||
<span className="text-sm font-medium">{pods.length}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-gray-400">Deployments:</span>
|
||||
<span className="text-sm font-medium">{deployments.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
77
portal/src/components/layout/MobileNavigation.tsx
Normal file
77
portal/src/components/layout/MobileNavigation.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Network,
|
||||
Settings,
|
||||
FileText,
|
||||
Activity,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
Menu,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/dashboard', icon: LayoutDashboard },
|
||||
{ name: 'Resources', href: '/resources', icon: Server },
|
||||
{ name: 'Virtual Machines', href: '/vms', icon: Server },
|
||||
{ name: 'Networking', href: '/network', icon: Network },
|
||||
{ name: 'Monitoring', href: '/dashboards', icon: Activity },
|
||||
{ name: 'Users & Access', href: '/users', icon: Users },
|
||||
{ name: 'Billing', href: '/billing', icon: CreditCard },
|
||||
{ name: 'Security', href: '/security', icon: Shield },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
];
|
||||
|
||||
export function MobileNavigation() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Mobile menu button */}
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="md:hidden fixed bottom-4 right-4 z-50 p-4 bg-orange-500 text-white rounded-full shadow-lg"
|
||||
aria-label="Toggle menu"
|
||||
>
|
||||
{isOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isOpen && (
|
||||
<div className="md:hidden fixed inset-0 z-40 bg-gray-900/95 backdrop-blur">
|
||||
<nav className="flex flex-col h-full p-4 pt-20">
|
||||
{navigation.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/');
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setIsOpen(false)}
|
||||
className={`flex items-center gap-4 p-4 rounded-lg mb-2 transition-colors ${
|
||||
isActive
|
||||
? 'bg-orange-500/20 text-orange-500 border border-orange-500/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-6 w-6" />
|
||||
<span className="text-lg font-medium">{item.name}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
55
portal/src/components/layout/PortalBreadcrumbs.tsx
Normal file
55
portal/src/components/layout/PortalBreadcrumbs.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import { ChevronRight, Home } from 'lucide-react'
|
||||
|
||||
export function PortalBreadcrumbs() {
|
||||
const pathname = usePathname()
|
||||
|
||||
const segments = pathname.split('/').filter(Boolean)
|
||||
if (segments.length <= 1) return null
|
||||
|
||||
return (
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex items-center space-x-2 text-sm text-gray-400 px-4 py-2 bg-gray-900 border-b border-gray-800"
|
||||
>
|
||||
<Link
|
||||
href="/"
|
||||
className="hover:text-orange-500 transition-colors"
|
||||
aria-label="Home"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
{segments.map((segment, index) => {
|
||||
const isLast = index === segments.length - 1
|
||||
const href = '/' + segments.slice(0, index + 1).join('/')
|
||||
const label = segment
|
||||
.split('-')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
|
||||
return (
|
||||
<div key={href} className="flex items-center space-x-2">
|
||||
<ChevronRight className="h-4 w-4 text-gray-600" />
|
||||
{isLast ? (
|
||||
<span className="text-white font-medium" aria-current="page">
|
||||
{label}
|
||||
</span>
|
||||
) : (
|
||||
<Link
|
||||
href={href}
|
||||
className="hover:text-orange-500 transition-colors"
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
72
portal/src/components/layout/PortalHeader.tsx
Normal file
72
portal/src/components/layout/PortalHeader.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { Search, Bell, Settings, User, LogOut } from 'lucide-react'
|
||||
import { signOut } from 'next-auth/react'
|
||||
|
||||
export function PortalHeader() {
|
||||
const { data: session } = useSession()
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 w-full border-b border-gray-800 bg-gray-900/95 backdrop-blur supports-[backdrop-filter]:bg-gray-900/60">
|
||||
<div className="container flex h-16 items-center">
|
||||
<Link href="/" className="mr-6 flex items-center space-x-2">
|
||||
<span className="text-xl font-bold bg-gradient-to-r from-orange-500 to-yellow-500 bg-clip-text text-transparent">
|
||||
Nexus Console
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
{/* Search Bar - Enterprise-class search-first UX */}
|
||||
<div className="flex flex-1 items-center justify-center max-w-2xl mx-8">
|
||||
<div className="relative w-full">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search resources, settings, docs..."
|
||||
className="w-full rounded-md border border-gray-700 bg-gray-800 py-2 pl-10 pr-4 text-sm text-white placeholder-gray-400 focus:border-orange-500 focus:outline-none focus:ring-1 focus:ring-orange-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex items-center space-x-4">
|
||||
<button className="relative p-2 text-gray-400 hover:text-white transition-colors">
|
||||
<Bell className="h-5 w-5" />
|
||||
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-orange-500" />
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
>
|
||||
<Settings className="h-5 w-5" />
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center space-x-2 border-l border-gray-700 pl-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-orange-500 to-yellow-500 flex items-center justify-center">
|
||||
<User className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
<div className="hidden md:block text-sm">
|
||||
<div className="text-white font-medium">
|
||||
{session?.user?.name || session?.user?.email || 'User'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400">
|
||||
{session?.user?.role || 'Admin'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut()}
|
||||
className="p-2 text-gray-400 hover:text-white transition-colors"
|
||||
title="Sign out"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
82
portal/src/components/layout/PortalSidebar.tsx
Normal file
82
portal/src/components/layout/PortalSidebar.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Server,
|
||||
Network,
|
||||
Settings,
|
||||
FileText,
|
||||
Activity,
|
||||
Users,
|
||||
CreditCard,
|
||||
Shield,
|
||||
HelpCircle
|
||||
} from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Dashboard', href: '/', icon: LayoutDashboard },
|
||||
{ name: 'Resources', href: '/resources', icon: Server },
|
||||
{ name: 'Virtual Machines', href: '/vms', icon: Server },
|
||||
{ name: 'Networking', href: '/network', icon: Network },
|
||||
{ name: 'Monitoring', href: '/dashboards', icon: Activity },
|
||||
{ name: 'Users & Access', href: '/users', icon: Users },
|
||||
{ name: 'Billing', href: '/billing', icon: CreditCard },
|
||||
{ name: 'Security', href: '/security', icon: Shield },
|
||||
{ name: 'Settings', href: '/settings', icon: Settings },
|
||||
]
|
||||
|
||||
const helpLinks = [
|
||||
{ name: 'Documentation', href: '/help/docs', icon: FileText },
|
||||
{ name: 'Support', href: '/help/support', icon: HelpCircle },
|
||||
]
|
||||
|
||||
export function PortalSidebar() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-16 h-[calc(100vh-4rem)] w-64 border-r border-gray-800 bg-gray-900 overflow-y-auto">
|
||||
<nav className="p-4 space-y-1">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href || pathname?.startsWith(item.href + '/')
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors',
|
||||
isActive
|
||||
? 'bg-orange-500/10 text-orange-500 border border-orange-500/20'
|
||||
: 'text-gray-400 hover:bg-gray-800 hover:text-white'
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-gray-800 mt-4 pt-4 px-4">
|
||||
<div className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-2">
|
||||
Help & Support
|
||||
</div>
|
||||
<nav className="space-y-1">
|
||||
{helpLinks.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className="flex items-center space-x-3 rounded-lg px-3 py-2 text-sm font-medium text-gray-400 hover:bg-gray-800 hover:text-white transition-colors"
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
<span>{item.name}</span>
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
57
portal/src/components/monitoring/GrafanaPanel.tsx
Normal file
57
portal/src/components/monitoring/GrafanaPanel.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface GrafanaPanelProps {
|
||||
dashboardUID: string
|
||||
panelId: number
|
||||
from?: string
|
||||
to?: string
|
||||
refresh?: string
|
||||
theme?: 'light' | 'dark'
|
||||
height?: string
|
||||
width?: string
|
||||
}
|
||||
|
||||
export default function GrafanaPanel({
|
||||
dashboardUID,
|
||||
panelId,
|
||||
from = 'now-6h',
|
||||
to = 'now',
|
||||
refresh = '30s',
|
||||
theme = 'dark',
|
||||
height = '400px',
|
||||
width = '100%',
|
||||
}: GrafanaPanelProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const grafanaURL = process.env.NEXT_PUBLIC_GRAFANA_URL || 'http://localhost:3001'
|
||||
|
||||
useEffect(() => {
|
||||
if (iframeRef.current) {
|
||||
const url = new URL(`${grafanaURL}/d/${dashboardUID}`)
|
||||
url.searchParams.set('orgId', '1')
|
||||
url.searchParams.set('from', from)
|
||||
url.searchParams.set('to', to)
|
||||
url.searchParams.set('refresh', refresh)
|
||||
url.searchParams.set('theme', theme)
|
||||
url.searchParams.set('panelId', panelId.toString())
|
||||
url.searchParams.set('kiosk', 'tv')
|
||||
|
||||
iframeRef.current.src = url.toString()
|
||||
}
|
||||
}, [dashboardUID, panelId, from, to, refresh, theme, grafanaURL])
|
||||
|
||||
return (
|
||||
<div className="w-full rounded-lg overflow-hidden border border-gray-700">
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
width={width}
|
||||
height={height}
|
||||
frameBorder="0"
|
||||
className="bg-gray-900"
|
||||
title={`Grafana Panel ${panelId}`}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
153
portal/src/components/monitoring/LokiLogViewer.tsx
Normal file
153
portal/src/components/monitoring/LokiLogViewer.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '../ui/Card'
|
||||
import { Button } from '../ui/Button'
|
||||
import { Input } from '../ui/Input'
|
||||
import { RefreshCw, Search, Download } from 'lucide-react'
|
||||
import axios from 'axios'
|
||||
|
||||
interface LogEntry {
|
||||
stream: Record<string, string>
|
||||
values: Array<[string, string]>
|
||||
}
|
||||
|
||||
interface LokiQueryResponse {
|
||||
status: string
|
||||
data: {
|
||||
resultType: string
|
||||
result: LogEntry[]
|
||||
}
|
||||
}
|
||||
|
||||
interface LokiLogViewerProps {
|
||||
query?: string
|
||||
limit?: number
|
||||
since?: string
|
||||
}
|
||||
|
||||
export default function LokiLogViewer({
|
||||
query = '{job="default"}',
|
||||
limit = 100,
|
||||
since = '1h',
|
||||
}: LokiLogViewerProps) {
|
||||
const [searchQuery, setSearchQuery] = useState(query)
|
||||
const [logLines, setLogLines] = useState<Array<{ timestamp: string; message: string; labels: Record<string, string> }>>([])
|
||||
const lokiURL = process.env.NEXT_PUBLIC_LOKI_URL || 'http://localhost:3100'
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['loki-logs', searchQuery, limit, since],
|
||||
queryFn: async () => {
|
||||
const end = Math.floor(Date.now() / 1000)
|
||||
const start = end - (since === '1h' ? 3600 : since === '6h' ? 21600 : 86400)
|
||||
|
||||
const response = await axios.get<LokiQueryResponse>(`${lokiURL}/loki/api/v1/query_range`, {
|
||||
params: {
|
||||
query: searchQuery,
|
||||
start,
|
||||
end,
|
||||
limit,
|
||||
},
|
||||
})
|
||||
|
||||
const logs: Array<{ timestamp: string; message: string; labels: Record<string, string> }> = []
|
||||
|
||||
response.data.data.result.forEach((entry: LogEntry) => {
|
||||
entry.values.forEach(([timestamp, message]) => {
|
||||
logs.push({
|
||||
timestamp: new Date(parseInt(timestamp) / 1000000).toISOString(),
|
||||
message,
|
||||
labels: entry.stream,
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
return logs.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
||||
},
|
||||
refetchInterval: 10000, // Refresh every 10 seconds
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setLogLines(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
const handleExport = () => {
|
||||
const logText = logLines
|
||||
.map((log) => `[${log.timestamp}] ${JSON.stringify(log.labels)} ${log.message}`)
|
||||
.join('\n')
|
||||
|
||||
const blob = new Blob([logText], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `logs-${Date.now()}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Log Viewer (Loki)</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={handleExport} variant="outline" size="sm">
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<Button onClick={() => refetch()} variant="outline" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="LogQL query (e.g., {job='default'})"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button onClick={() => refetch()}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
) : logLines.length === 0 ? (
|
||||
<div className="text-center text-gray-400 p-8">No logs found</div>
|
||||
) : (
|
||||
<div className="bg-gray-900 rounded-lg p-4 font-mono text-sm overflow-auto max-h-96">
|
||||
{logLines.map((log, index) => (
|
||||
<div key={index} className="mb-2 border-b border-gray-800 pb-2 last:border-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-gray-500 text-xs whitespace-nowrap">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="text-blue-400 text-xs">
|
||||
{Object.entries(log.labels)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-gray-300 mt-1 break-words">{log.message}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
139
portal/src/components/onboarding/OnboardingWizard.tsx
Normal file
139
portal/src/components/onboarding/OnboardingWizard.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { CheckCircle, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface OnboardingStep {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
component: React.ComponentType<any>;
|
||||
}
|
||||
|
||||
interface OnboardingWizardProps {
|
||||
steps: OnboardingStep[];
|
||||
onComplete: () => void;
|
||||
}
|
||||
|
||||
export function OnboardingWizard({ steps, onComplete }: OnboardingWizardProps) {
|
||||
const [currentStep, setCurrentStep] = useState(0);
|
||||
const [completedSteps, setCompletedSteps] = useState<Set<string>>(new Set());
|
||||
const router = useRouter();
|
||||
|
||||
const handleNext = () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setCurrentStep(currentStep + 1);
|
||||
} else {
|
||||
onComplete();
|
||||
router.push('/dashboard');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStepComplete = (stepId: string) => {
|
||||
setCompletedSteps(new Set([...completedSteps, stepId]));
|
||||
};
|
||||
|
||||
const CurrentStepComponent = steps[currentStep].component;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-gray-900/95 backdrop-blur flex items-center justify-center p-4">
|
||||
<Card className="bg-gray-800 border-gray-700 max-w-4xl w-full">
|
||||
<CardHeader>
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<CardTitle className="text-white text-2xl">
|
||||
Welcome to Nexus Console
|
||||
</CardTitle>
|
||||
<span className="text-sm text-gray-400">
|
||||
Step {currentStep + 1} of {steps.length}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Progress bar */}
|
||||
<div className="w-full bg-gray-900 rounded-full h-2">
|
||||
<div
|
||||
className="bg-orange-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${((currentStep + 1) / steps.length) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step indicators */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={step.id} className="flex items-center flex-1">
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center border-2 ${
|
||||
index < currentStep
|
||||
? 'bg-green-500 border-green-500'
|
||||
: index === currentStep
|
||||
? 'bg-orange-500 border-orange-500'
|
||||
: 'bg-gray-700 border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{index < currentStep ? (
|
||||
<CheckCircle className="h-6 w-6 text-white" />
|
||||
) : (
|
||||
<span className="text-white font-semibold">{index + 1}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-gray-400 mt-2 text-center max-w-20">
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-0.5 mx-2 ${
|
||||
index < currentStep ? 'bg-green-500' : 'bg-gray-700'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="mb-6">
|
||||
<h3 className="text-xl font-semibold text-white mb-2">
|
||||
{steps[currentStep].title}
|
||||
</h3>
|
||||
<p className="text-gray-400 mb-6">
|
||||
{steps[currentStep].description}
|
||||
</p>
|
||||
<CurrentStepComponent
|
||||
onComplete={() => handleStepComplete(steps[currentStep].id)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-gray-700">
|
||||
<button
|
||||
onClick={handlePrevious}
|
||||
disabled={currentStep === 0}
|
||||
className="px-6 py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNext}
|
||||
className="px-6 py-2 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{currentStep === steps.length - 1 ? 'Complete' : 'Next'}
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
49
portal/src/components/onboarding/steps/PreferencesStep.tsx
Normal file
49
portal/src/components/onboarding/steps/PreferencesStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function PreferencesStep({ onComplete }: { onComplete: () => void }) {
|
||||
const [notifications, setNotifications] = useState(true);
|
||||
const [theme, setTheme] = useState('dark');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Save preferences
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={notifications}
|
||||
onChange={(e) => setNotifications(e.target.checked)}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<span className="text-sm text-gray-300">Enable email notifications</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Theme</label>
|
||||
<select
|
||||
value={theme}
|
||||
onChange={(e) => setTheme(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="light">Light</option>
|
||||
<option value="auto">Auto</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
49
portal/src/components/onboarding/steps/ProfileStep.tsx
Normal file
49
portal/src/components/onboarding/steps/ProfileStep.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
export function ProfileStep({ onComplete }: { onComplete: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [role, setRole] = useState('');
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
// Save profile data
|
||||
onComplete();
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Full Name</label>
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
placeholder="Enter your name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Role</label>
|
||||
<select
|
||||
value={role}
|
||||
onChange={(e) => setRole(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
>
|
||||
<option value="">Select your role</option>
|
||||
<option value="technical">Technical Admin</option>
|
||||
<option value="business">Business Owner</option>
|
||||
<option value="developer">Developer</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="w-full px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
25
portal/src/components/onboarding/steps/WelcomeStep.tsx
Normal file
25
portal/src/components/onboarding/steps/WelcomeStep.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export function WelcomeStep({ onComplete }: { onComplete: () => void }) {
|
||||
useEffect(() => {
|
||||
// Auto-complete after a brief delay
|
||||
const timer = setTimeout(() => {
|
||||
onComplete();
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [onComplete]);
|
||||
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<h2 className="text-2xl font-bold text-white mb-4">
|
||||
Welcome to Sankofa Phoenix Nexus Console
|
||||
</h2>
|
||||
<p className="text-gray-400 mb-6">
|
||||
Let's get you set up in just a few steps. This will only take a minute.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
39
portal/src/components/ui/Button.tsx
Normal file
39
portal/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'primary' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
}
|
||||
|
||||
export function Button({
|
||||
className,
|
||||
variant = 'default',
|
||||
size = 'md',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const baseStyles = 'inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50'
|
||||
|
||||
const variants = {
|
||||
default: 'bg-gray-700 text-white hover:bg-gray-600',
|
||||
primary: 'bg-blue-600 text-white hover:bg-blue-700',
|
||||
secondary: 'bg-gray-600 text-white hover:bg-gray-500',
|
||||
outline: 'border border-gray-600 bg-transparent hover:bg-gray-800',
|
||||
ghost: 'hover:bg-gray-800',
|
||||
destructive: 'bg-red-600 text-white hover:bg-red-700',
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: 'h-8 px-3 text-sm',
|
||||
md: 'h-10 px-4 text-base',
|
||||
lg: 'h-12 px-6 text-lg',
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(baseStyles, variants[variant], sizes[size], className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
22
portal/src/components/ui/Input.tsx
Normal file
22
portal/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-10 w-full rounded-md border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-white ring-offset-gray-900 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
53
portal/src/components/ui/Tabs.tsx
Normal file
53
portal/src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-10 items-center justify-center rounded-md bg-gray-800 p-1 text-gray-400',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-gray-900 transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-gray-700 data-[state=active]:text-white data-[state=active]:shadow-sm',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
|
||||
29
portal/src/components/ui/badge.tsx
Normal file
29
portal/src/components/ui/badge.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
variant?: 'default' | 'secondary' | 'outline' | 'success' | 'warning' | 'error'
|
||||
}
|
||||
|
||||
export function Badge({ className, variant = 'default', ...props }: BadgeProps) {
|
||||
const variants = {
|
||||
default: 'bg-gray-700 text-white',
|
||||
secondary: 'bg-gray-600 text-white',
|
||||
outline: 'border border-gray-600 bg-transparent',
|
||||
success: 'bg-green-600 text-white',
|
||||
warning: 'bg-yellow-600 text-white',
|
||||
error: 'bg-red-600 text-white',
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors',
|
||||
variants[variant],
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
90
portal/src/components/ui/select.tsx
Normal file
90
portal/src/components/ui/select.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-10 w-full items-center justify-between rounded-md border border-gray-600 bg-gray-900 px-3 py-2 text-sm text-white ring-offset-gray-900 placeholder:text-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-gray-600 bg-gray-900 text-white shadow-md',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-gray-800 focus:text-white data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user