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:
113
portal/src/app/admin/page.tsx
Normal file
113
portal/src/app/admin/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Building2, Users, CreditCard, Shield, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function AdminPortalPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Admin Portal</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const adminSections = [
|
||||
{
|
||||
title: 'Organization Management',
|
||||
description: 'Manage multi-tenant organizations, tenant isolation, and resource quotas',
|
||||
icon: Building2,
|
||||
href: '/admin/organizations',
|
||||
features: ['Multi-tenant view', 'Tenant isolation', 'Resource quotas'],
|
||||
},
|
||||
{
|
||||
title: 'User Management',
|
||||
description: 'Manage users, roles, and permissions across your organization',
|
||||
icon: Users,
|
||||
href: '/admin/users',
|
||||
features: ['User list', 'Role assignment', 'Permission management'],
|
||||
},
|
||||
{
|
||||
title: 'Billing & Subscriptions',
|
||||
description: 'View subscriptions, usage-based billing, invoices, and payment methods',
|
||||
icon: CreditCard,
|
||||
href: '/admin/billing',
|
||||
features: ['Subscriptions', 'Usage billing', 'Invoice history'],
|
||||
},
|
||||
{
|
||||
title: 'Compliance & Reporting',
|
||||
description: 'Compliance dashboard, audit logs, and export reports',
|
||||
icon: Shield,
|
||||
href: '/admin/compliance',
|
||||
features: ['Compliance dashboard', 'Audit logs', 'Export reports'],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Customer / Tenant Admin Portal</h1>
|
||||
<p className="text-gray-400">Manage your organization, users, billing, and compliance</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{adminSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Manage <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
49
portal/src/app/analytics/page.tsx
Normal file
49
portal/src/app/analytics/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { AdvancedAnalytics } from '@/components/analytics/AdvancedAnalytics';
|
||||
|
||||
export default function AnalyticsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Analytics</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Advanced Analytics</h1>
|
||||
<p className="text-gray-400">Comprehensive analytics and insights for your infrastructure</p>
|
||||
</div>
|
||||
|
||||
<AdvancedAnalytics />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
28
portal/src/app/argocd/page.tsx
Normal file
28
portal/src/app/argocd/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import ArgoCDApplications from '@/components/argocd/ArgoCDApplications'
|
||||
|
||||
export default function ArgoCDPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<ArgoCDApplications />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
28
portal/src/app/crossplane/page.tsx
Normal file
28
portal/src/app/crossplane/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import CrossplaneResourceBrowser from '@/components/crossplane/CrossplaneResourceBrowser'
|
||||
|
||||
export default function CrossplanePage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<CrossplaneResourceBrowser />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
78
portal/src/app/dashboard/business/page.tsx
Normal file
78
portal/src/app/dashboard/business/page.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { CostOverviewTile } from '@/components/dashboard/CostOverviewTile';
|
||||
import { CostForecastingTile } from '@/components/dashboard/CostForecastingTile';
|
||||
import { ResourceUsageTile } from '@/components/dashboard/ResourceUsageTile';
|
||||
import { BillingTile } from '@/components/dashboard/BillingTile';
|
||||
import { ServiceAdoptionTile } from '@/components/dashboard/ServiceAdoptionTile';
|
||||
import { ComplianceStatusTile } from '@/components/dashboard/ComplianceStatusTile';
|
||||
import { QuickActionsPanel } from '@/components/dashboard/QuickActionsPanel';
|
||||
|
||||
export default function BusinessDashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Nexus Console</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">Business Owner Dashboard</h1>
|
||||
<p className="text-gray-400 mt-2">Cost, usage, billing, and compliance overview</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<CostOverviewTile />
|
||||
<CostForecastingTile />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<ResourceUsageTile />
|
||||
<BillingTile />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<BillingTile />
|
||||
<ServiceAdoptionTile />
|
||||
<ComplianceStatusTile />
|
||||
</div>
|
||||
|
||||
<QuickActionsPanel
|
||||
actions={[
|
||||
{ label: 'View Reports', href: '/reports', icon: 'FileText' },
|
||||
{ label: 'Manage Billing', href: '/admin/billing', icon: 'CreditCard' },
|
||||
{ label: 'Contact Support', href: '/support', icon: 'HelpCircle' },
|
||||
{ label: 'Export Data', href: '/reports/export', icon: 'Download' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
70
portal/src/app/dashboard/developer/page.tsx
Normal file
70
portal/src/app/dashboard/developer/page.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { APIUsageTile } from '@/components/dashboard/APIUsageTile';
|
||||
import { DeploymentsTile } from '@/components/dashboard/DeploymentsTile';
|
||||
import { TestEnvironmentsTile } from '@/components/dashboard/TestEnvironmentsTile';
|
||||
import { APIKeysTile } from '@/components/dashboard/APIKeysTile';
|
||||
import { QuickActionsPanel } from '@/components/dashboard/QuickActionsPanel';
|
||||
|
||||
export default function DeveloperDashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Nexus Console</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">Developer Dashboard</h1>
|
||||
<p className="text-gray-400 mt-2">API usage, deployments, test environments, and developer tools</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<APIUsageTile />
|
||||
<DeploymentsTile />
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<TestEnvironmentsTile />
|
||||
<APIKeysTile />
|
||||
</div>
|
||||
|
||||
<QuickActionsPanel
|
||||
actions={[
|
||||
{ label: 'Create API Key', href: '/developer/api-keys/new', icon: 'Key' },
|
||||
{ label: 'Deploy Service', href: '/developer/deploy', icon: 'Rocket' },
|
||||
{ label: 'View Docs', href: '/docs', icon: 'Book' },
|
||||
{ label: 'View Logs', href: '/developer/logs', icon: 'FileText' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
61
portal/src/app/dashboard/page.tsx
Normal file
61
portal/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useEffect } from 'react';
|
||||
import { getDashboardRoute } from '@/lib/roles';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
if (status === 'authenticated' && session) {
|
||||
const route = getDashboardRoute(session);
|
||||
router.push(route);
|
||||
}
|
||||
}, [status, session, router]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Nexus Console</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<p className="text-sm text-gray-500 mt-4">
|
||||
Development mode: Use any email/password
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Redirecting...
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Redirecting to your dashboard...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
79
portal/src/app/dashboard/technical/page.tsx
Normal file
79
portal/src/app/dashboard/technical/page.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { SystemHealthTile } from '@/components/dashboard/SystemHealthTile';
|
||||
import { IntegrationStatusTile } from '@/components/dashboard/IntegrationStatusTile';
|
||||
import { DataPipelineTile } from '@/components/dashboard/DataPipelineTile';
|
||||
import { ResourceUtilizationTile } from '@/components/dashboard/ResourceUtilizationTile';
|
||||
import { OptimizationEngine } from '@/components/ai/OptimizationEngine';
|
||||
import { QuickActionsPanel } from '@/components/dashboard/QuickActionsPanel';
|
||||
|
||||
export default function TechnicalDashboardPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Nexus Console</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-3xl font-bold text-white">Technical Admin Dashboard</h1>
|
||||
<p className="text-gray-400 mt-2">Operational view of infrastructure, integrations, and system health</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<div className="lg:col-span-2">
|
||||
<SystemHealthTile />
|
||||
</div>
|
||||
<div>
|
||||
<IntegrationStatusTile />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<DataPipelineTile />
|
||||
<ResourceUtilizationTile />
|
||||
</div>
|
||||
|
||||
<div className="mb-6">
|
||||
<OptimizationEngine />
|
||||
</div>
|
||||
|
||||
<QuickActionsPanel
|
||||
actions={[
|
||||
{ label: 'Add Integration', href: '/integrations/new', icon: 'Plus' },
|
||||
{ label: 'Create Connection', href: '/connections/new', icon: 'Link' },
|
||||
{ label: 'View Logs', href: '/monitoring/logs', icon: 'FileText' },
|
||||
{ label: 'System Settings', href: '/settings/system', icon: 'Settings' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
59
portal/src/app/dashboards/page.tsx
Normal file
59
portal/src/app/dashboards/page.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
// Metrics dashboard - component to be implemented
|
||||
// import Dashboard from '@/components/dashboards/Dashboard';
|
||||
|
||||
export default function DashboardsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to view dashboards</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6 text-white">Metrics Dashboards</h1>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">CPU Usage</h2>
|
||||
<p className="text-gray-400">CPU metrics dashboard coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Memory Usage</h2>
|
||||
<p className="text-gray-400">Memory metrics dashboard coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Network Throughput</h2>
|
||||
<p className="text-gray-400">Network metrics dashboard coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
127
portal/src/app/developer/page.tsx
Normal file
127
portal/src/app/developer/page.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Key, Book, TestTube, BarChart3, Webhook, Download, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function DeveloperPortalPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Developer Portal</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const developerSections = [
|
||||
{
|
||||
title: 'API Key Management',
|
||||
description: 'Create, manage, and revoke API keys for your applications',
|
||||
icon: Key,
|
||||
href: '/developer/api-keys',
|
||||
features: ['Create API keys', 'View usage', 'Revoke keys'],
|
||||
},
|
||||
{
|
||||
title: 'API Documentation',
|
||||
description: 'Interactive API documentation with examples and code snippets',
|
||||
icon: Book,
|
||||
href: '/developer/docs',
|
||||
features: ['REST API docs', 'GraphQL schema', 'Code examples'],
|
||||
},
|
||||
{
|
||||
title: 'Test Environments',
|
||||
description: 'Provision and manage sandbox environments for testing',
|
||||
icon: TestTube,
|
||||
href: '/developer/environments',
|
||||
features: ['Create environments', 'Sandbox access', 'Auto-cleanup'],
|
||||
},
|
||||
{
|
||||
title: 'Usage Analytics',
|
||||
description: 'Monitor API usage, quotas, and performance metrics',
|
||||
icon: BarChart3,
|
||||
href: '/developer/analytics',
|
||||
features: ['API usage stats', 'Quota monitoring', 'Performance metrics'],
|
||||
},
|
||||
{
|
||||
title: 'Webhook Configuration',
|
||||
description: 'Configure webhooks for real-time event notifications',
|
||||
icon: Webhook,
|
||||
href: '/developer/webhooks',
|
||||
features: ['Create webhooks', 'Event subscriptions', 'Delivery logs'],
|
||||
},
|
||||
{
|
||||
title: 'SDK Downloads',
|
||||
description: 'Download SDKs and client libraries for popular languages',
|
||||
icon: Download,
|
||||
href: '/developer/sdks',
|
||||
features: ['TypeScript SDK', 'Python SDK', 'Go SDK'],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Developer Portal</h1>
|
||||
<p className="text-gray-400">API keys, documentation, test environments, and developer tools</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{developerSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Access <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
191
portal/src/app/developer/webhooks/test/page.tsx
Normal file
191
portal/src/app/developer/webhooks/test/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Send, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
|
||||
export default function WebhookTestingPage() {
|
||||
const [url, setUrl] = useState('');
|
||||
const [method, setMethod] = useState('POST');
|
||||
const [headers, setHeaders] = useState('{"Content-Type": "application/json"}');
|
||||
const [payload, setPayload] = useState('{"event": "test", "data": {}}');
|
||||
const [testResult, setTestResult] = useState<any>(null);
|
||||
const [isTesting, setIsTesting] = useState(false);
|
||||
|
||||
const testWebhook = async () => {
|
||||
setIsTesting(true);
|
||||
setTestResult(null);
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
const response = await fetch('/api/webhooks/test', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
method,
|
||||
headers: JSON.parse(headers),
|
||||
payload: JSON.parse(payload),
|
||||
}),
|
||||
});
|
||||
const endTime = Date.now();
|
||||
const data = await response.json();
|
||||
|
||||
setTestResult({
|
||||
success: response.ok,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
responseTime: endTime - startTime,
|
||||
response: data,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} catch (error: any) {
|
||||
setTestResult({
|
||||
success: false,
|
||||
error: error.message,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Webhook Testing Tool</h1>
|
||||
<p className="text-gray-400">Test your webhook endpoints before configuring them</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Webhook Configuration</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Webhook URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/webhook"
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">HTTP Method</label>
|
||||
<select
|
||||
value={method}
|
||||
onChange={(e) => setMethod(e.target.value)}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
>
|
||||
<option value="POST">POST</option>
|
||||
<option value="GET">GET</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Headers (JSON)</label>
|
||||
<textarea
|
||||
value={headers}
|
||||
onChange={(e) => setHeaders(e.target.value)}
|
||||
className="w-full h-24 px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white font-mono text-sm"
|
||||
placeholder='{"Content-Type": "application/json"}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">Payload (JSON)</label>
|
||||
<textarea
|
||||
value={payload}
|
||||
onChange={(e) => setPayload(e.target.value)}
|
||||
className="w-full h-32 px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white font-mono text-sm"
|
||||
placeholder='{"event": "test", "data": {}}'
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={testWebhook}
|
||||
disabled={!url || isTesting}
|
||||
className="w-full px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{isTesting ? 'Testing...' : 'Test Webhook'}
|
||||
</button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Test Results</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!testResult ? (
|
||||
<div className="text-center py-12 text-gray-400">
|
||||
<p>Test results will appear here</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-5 w-5 text-green-400" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-red-400" />
|
||||
)}
|
||||
<span className={`font-semibold ${testResult.success ? 'text-green-400' : 'text-red-400'}`}>
|
||||
{testResult.success ? 'Success' : 'Failed'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{testResult.status && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Status</p>
|
||||
<p className="text-white font-mono">
|
||||
{testResult.status} {testResult.statusText}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult.responseTime && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Response Time</p>
|
||||
<p className="text-white font-mono">{testResult.responseTime}ms</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult.timestamp && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Timestamp</p>
|
||||
<p className="text-white text-sm">{new Date(testResult.timestamp).toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult.response && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Response</p>
|
||||
<pre className="p-3 bg-gray-900 rounded text-white font-mono text-xs overflow-auto max-h-64">
|
||||
{JSON.stringify(testResult.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{testResult.error && (
|
||||
<div>
|
||||
<p className="text-sm text-gray-400 mb-1">Error</p>
|
||||
<p className="text-red-400 font-mono text-sm">{testResult.error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
50
portal/src/app/error.tsx
Normal file
50
portal/src/app/error.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Home, RefreshCw, AlertCircle } from 'lucide-react'
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string }
|
||||
reset: () => void
|
||||
}) {
|
||||
useEffect(() => {
|
||||
// Log error to error reporting service
|
||||
console.error('Application error:', error)
|
||||
}, [error])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<AlertCircle className="h-16 w-16 text-red-500 mx-auto mb-4" />
|
||||
<h1 className="text-3xl font-bold text-white mb-4">Something went wrong!</h1>
|
||||
<p className="text-gray-400 mb-2">
|
||||
An unexpected error occurred. Please try again.
|
||||
</p>
|
||||
{error.digest && (
|
||||
<p className="text-sm text-gray-500 mb-8">Error ID: {error.digest}</p>
|
||||
)}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<button
|
||||
onClick={reset}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-5 w-5" />
|
||||
Try Again
|
||||
</button>
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
28
portal/src/app/kubernetes/page.tsx
Normal file
28
portal/src/app/kubernetes/page.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import KubernetesClusters from '@/components/kubernetes/KubernetesClusters'
|
||||
|
||||
export default function KubernetesPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<KubernetesClusters />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,26 +1,44 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from './providers';
|
||||
'use client'
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
import type { Metadata } from 'next'
|
||||
import { Inter } from 'next/font/google'
|
||||
import './globals.css'
|
||||
import { Providers } from './providers'
|
||||
import { PortalHeader } from '@/components/layout/PortalHeader'
|
||||
import { PortalSidebar } from '@/components/layout/PortalSidebar'
|
||||
import { PortalBreadcrumbs } from '@/components/layout/PortalBreadcrumbs'
|
||||
import { MobileNavigation } from '@/components/layout/MobileNavigation'
|
||||
import { KeyboardShortcutsProvider } from '@/components/KeyboardShortcutsProvider'
|
||||
import { SessionProvider } from 'next-auth/react'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Hybrid Cloud Control Plane',
|
||||
description: 'Unified management portal for hybrid cloud infrastructure',
|
||||
};
|
||||
const inter = Inter({ subsets: ['latin'] })
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={inter.className}>
|
||||
<Providers>{children}</Providers>
|
||||
<SessionProvider>
|
||||
<Providers>
|
||||
<KeyboardShortcutsProvider>
|
||||
<div className="flex min-h-screen flex-col">
|
||||
<PortalHeader />
|
||||
<PortalBreadcrumbs />
|
||||
<div className="flex flex-1">
|
||||
<PortalSidebar />
|
||||
<main className="flex-1 ml-0 md:ml-64">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
<MobileNavigation />
|
||||
</div>
|
||||
</KeyboardShortcutsProvider>
|
||||
</Providers>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
29
portal/src/app/ml/page.tsx
Normal file
29
portal/src/app/ml/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export default function MLPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">AI Foundry</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
ML platform for model training, inference, and pipeline management
|
||||
</p>
|
||||
{/* ML platform UI will be implemented here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">ML platform interface coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
62
portal/src/app/monitoring/page.tsx
Normal file
62
portal/src/app/monitoring/page.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
import { redirect } from 'next/navigation'
|
||||
import GrafanaPanel from '@/components/monitoring/GrafanaPanel'
|
||||
import LokiLogViewer from '@/components/monitoring/LokiLogViewer'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
redirect('/api/auth/signin')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Monitoring</h1>
|
||||
|
||||
<Tabs defaultValue="grafana" className="space-y-4">
|
||||
<TabsList>
|
||||
<TabsTrigger value="grafana">Grafana Dashboards</TabsTrigger>
|
||||
<TabsTrigger value="logs">Logs (Loki)</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="grafana" className="space-y-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">System Metrics</h2>
|
||||
<GrafanaPanel
|
||||
dashboardUID="system"
|
||||
panelId={1}
|
||||
height="400px"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold mb-4">Application Metrics</h2>
|
||||
<GrafanaPanel
|
||||
dashboardUID="application"
|
||||
panelId={2}
|
||||
height="400px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="logs">
|
||||
<LokiLogViewer />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
51
portal/src/app/network/page.tsx
Normal file
51
portal/src/app/network/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
// Network topology view - component to be implemented
|
||||
// import { NetworkTopologyView } from '@/components/network/NetworkTopologyView';
|
||||
|
||||
export default function NetworkPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to view network topology</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6 text-white">Network Topology</h1>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<p className="text-gray-400">Network topology visualization coming soon</p>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
This will display the network graph showing relationships between resources across Proxmox, Kubernetes, and Cloudflare.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
portal/src/app/not-found.tsx
Normal file
35
portal/src/app/not-found.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Home, ArrowLeft } from 'lucide-react'
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-6xl font-bold text-white mb-4">404</h1>
|
||||
<h2 className="text-2xl font-semibold text-gray-300 mb-4">Page Not Found</h2>
|
||||
<p className="text-gray-400 mb-8">
|
||||
The page you're looking for doesn't exist or has been moved.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Go Home
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
37
portal/src/app/onboarding/page.tsx
Normal file
37
portal/src/app/onboarding/page.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
'use client';
|
||||
|
||||
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
|
||||
import { WelcomeStep } from '@/components/onboarding/steps/WelcomeStep';
|
||||
import { ProfileStep } from '@/components/onboarding/steps/ProfileStep';
|
||||
import { PreferencesStep } from '@/components/onboarding/steps/PreferencesStep';
|
||||
|
||||
const onboardingSteps = [
|
||||
{
|
||||
id: 'welcome',
|
||||
title: 'Welcome',
|
||||
description: 'Get started with Nexus Console',
|
||||
component: WelcomeStep,
|
||||
},
|
||||
{
|
||||
id: 'profile',
|
||||
title: 'Profile',
|
||||
description: 'Set up your profile information',
|
||||
component: ProfileStep,
|
||||
},
|
||||
{
|
||||
id: 'preferences',
|
||||
title: 'Preferences',
|
||||
description: 'Configure your preferences',
|
||||
component: PreferencesStep,
|
||||
},
|
||||
];
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const handleComplete = () => {
|
||||
// Mark onboarding as complete
|
||||
localStorage.setItem('onboarding-complete', 'true');
|
||||
};
|
||||
|
||||
return <OnboardingWizard steps={onboardingSteps} onComplete={handleComplete} />;
|
||||
}
|
||||
|
||||
113
portal/src/app/partner/page.tsx
Normal file
113
portal/src/app/partner/page.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Handshake, TrendingUp, BookOpen, Package, ArrowRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function PartnerPortalPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Welcome to Partner Portal</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to continue</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const partnerSections = [
|
||||
{
|
||||
title: 'Co-Sell Deal Management',
|
||||
description: 'Register and manage co-sell deals with Sankofa',
|
||||
icon: Handshake,
|
||||
href: '/partner/deals',
|
||||
features: ['Deal registration', 'Deal tracking', 'Revenue sharing'],
|
||||
},
|
||||
{
|
||||
title: 'Technical Onboarding',
|
||||
description: 'Access technical resources and onboarding materials',
|
||||
icon: BookOpen,
|
||||
href: '/partner/onboarding',
|
||||
features: ['Technical training', 'Architecture guides', 'Best practices'],
|
||||
},
|
||||
{
|
||||
title: 'Solution Registration',
|
||||
description: 'Register your solutions in the marketplace',
|
||||
icon: Package,
|
||||
href: '/partner/solutions',
|
||||
features: ['Solution listing', 'Certification', 'Marketplace access'],
|
||||
},
|
||||
{
|
||||
title: 'Partner Resources',
|
||||
description: 'Marketing materials, sales enablement, and training resources',
|
||||
icon: TrendingUp,
|
||||
href: '/partner/resources',
|
||||
features: ['Marketing assets', 'Sales materials', 'Training programs'],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Partner Portal</h1>
|
||||
<p className="text-gray-400">Co-sell deals, technical onboarding, and solution registration</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{partnerSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.href} className="bg-gray-800 border-gray-700 hover:border-orange-500 transition-colors">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<Icon className="h-6 w-6 text-orange-500" />
|
||||
<CardTitle className="text-white">{section.title}</CardTitle>
|
||||
</div>
|
||||
<p className="text-sm text-gray-400">{section.description}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 mb-4">
|
||||
{section.features.map((feature) => (
|
||||
<li key={feature} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<span className="text-orange-500">•</span>
|
||||
<span>{feature}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Link
|
||||
href={section.href}
|
||||
className="inline-flex items-center gap-2 text-sm text-orange-500 hover:text-orange-400 transition-colors"
|
||||
>
|
||||
Access <ArrowRight className="h-4 w-4" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
29
portal/src/app/policies/page.tsx
Normal file
29
portal/src/app/policies/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export default function PoliciesPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Policy Management</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Manage compliance, security, and cost optimization policies
|
||||
</p>
|
||||
{/* Policy management UI will be implemented here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Policy management interface coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
portal/src/app/resources/graph/page.tsx
Normal file
29
portal/src/app/resources/graph/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export default function ResourceGraphPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Resource Graph</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Visualize resource relationships and dependencies
|
||||
</p>
|
||||
{/* React Flow graph visualization will be implemented here */}
|
||||
<div className="border rounded-lg p-4 h-96">
|
||||
<p className="text-sm text-muted-foreground">Resource graph visualization coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
30
portal/src/app/resources/page.tsx
Normal file
30
portal/src/app/resources/page.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">Resource Inventory</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Unified view of all resources across Proxmox, Kubernetes, and Cloudflare
|
||||
</p>
|
||||
{/* Resource inventory UI will be implemented here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">Resource inventory table coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
157
portal/src/app/settings/2fa/page.tsx
Normal file
157
portal/src/app/settings/2fa/page.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Shield, CheckCircle, XCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export default function TwoFactorAuthPage() {
|
||||
const { data: session } = useSession();
|
||||
const [isEnabled, setIsEnabled] = useState(false);
|
||||
const [qrCode, setQrCode] = useState<string | null>(null);
|
||||
const [secret, setSecret] = useState<string | null>(null);
|
||||
const [verificationCode, setVerificationCode] = useState('');
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const enable2FA = async () => {
|
||||
try {
|
||||
// In production, this would call your backend API
|
||||
const response = await fetch('/api/auth/2fa/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
setSecret(data.secret);
|
||||
const qr = await QRCode.toDataURL(data.qrCodeUrl);
|
||||
setQrCode(qr);
|
||||
} catch (error) {
|
||||
console.error('Failed to enable 2FA:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyAndEnable = async () => {
|
||||
setIsVerifying(true);
|
||||
try {
|
||||
const response = await fetch('/api/auth/2fa/verify', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: verificationCode, secret }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setIsEnabled(true);
|
||||
setQrCode(null);
|
||||
setSecret(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Verification failed:', error);
|
||||
} finally {
|
||||
setIsVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const disable2FA = async () => {
|
||||
try {
|
||||
await fetch('/api/auth/2fa/disable', { method: 'POST' });
|
||||
setIsEnabled(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to disable 2FA:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-3xl font-bold text-white mb-2">Two-Factor Authentication</h1>
|
||||
<p className="text-gray-400">Add an extra layer of security to your account</p>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700 max-w-2xl">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6 text-orange-500" />
|
||||
<div>
|
||||
<CardTitle className="text-white">2FA Status</CardTitle>
|
||||
<CardDescription className="text-gray-400">
|
||||
{isEnabled ? 'Two-factor authentication is enabled' : 'Two-factor authentication is disabled'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{!isEnabled && !qrCode && (
|
||||
<div>
|
||||
<p className="text-gray-300 mb-4">
|
||||
Enable two-factor authentication to protect your account with an additional security layer.
|
||||
</p>
|
||||
<button
|
||||
onClick={enable2FA}
|
||||
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors"
|
||||
>
|
||||
Enable 2FA
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{qrCode && secret && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-gray-300 mb-2">Scan this QR code with your authenticator app:</p>
|
||||
<div className="flex justify-center p-4 bg-white rounded">
|
||||
<img src={qrCode} alt="2FA QR Code" className="w-48 h-48" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-gray-300 mb-2">Or enter this secret manually:</p>
|
||||
<code className="block p-3 bg-gray-900 rounded text-white font-mono text-sm break-all">
|
||||
{secret}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm text-gray-300 mb-2">
|
||||
Enter verification code from your app:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={verificationCode}
|
||||
onChange={(e) => setVerificationCode(e.target.value)}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
className="w-full px-4 py-2 bg-gray-900 border border-gray-700 rounded text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={verifyAndEnable}
|
||||
disabled={verificationCode.length !== 6 || isVerifying}
|
||||
className="px-6 py-3 bg-orange-500 text-white rounded-lg hover:bg-orange-600 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isVerifying ? 'Verifying...' : 'Verify and Enable'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEnabled && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2 text-green-400">
|
||||
<CheckCircle className="h-5 w-5" />
|
||||
<span>Two-factor authentication is active</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={disable2FA}
|
||||
className="px-6 py-3 bg-red-500 text-white rounded-lg hover:bg-red-600 transition-colors"
|
||||
>
|
||||
Disable 2FA
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
132
portal/src/app/settings/page.tsx
Normal file
132
portal/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Settings, User, Bell, Shield, Key } from 'lucide-react';
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to access settings</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6 text-white">Settings</h1>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<User className="h-5 w-5" />
|
||||
<CardTitle>Profile Settings</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300">Email</label>
|
||||
<p className="text-white">{session?.user?.email || 'Not available'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-300">Name</label>
|
||||
<p className="text-white">{session?.user?.name || 'Not available'}</p>
|
||||
</div>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Edit Profile
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Bell className="h-5 w-5" />
|
||||
<CardTitle>Notifications</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded" />
|
||||
<span className="text-white">Email notifications</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded" />
|
||||
<span className="text-white">Alert notifications</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input type="checkbox" className="rounded" />
|
||||
<span className="text-white">Weekly reports</span>
|
||||
</label>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-5 w-5" />
|
||||
<CardTitle>Security</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<button className="w-full px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Change Password
|
||||
</button>
|
||||
<button className="w-full px-4 py-2 bg-gray-600 text-white rounded hover:bg-gray-700">
|
||||
Enable Two-Factor Authentication
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-3">
|
||||
<Key className="h-5 w-5" />
|
||||
<CardTitle>API Keys</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-400">Manage your API keys and tokens</p>
|
||||
<button className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Create API Key
|
||||
</button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
38
portal/src/app/unauthorized.tsx
Normal file
38
portal/src/app/unauthorized.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { Shield, Home, Lock } from 'lucide-react'
|
||||
|
||||
export default function Unauthorized() {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<Shield className="h-16 w-16 text-yellow-500 mx-auto mb-4" />
|
||||
<h1 className="text-3xl font-bold text-white mb-4">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-2">
|
||||
You don't have permission to access this resource.
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mb-8">
|
||||
Please contact your administrator if you believe this is an error.
|
||||
</p>
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
<Home className="h-5 w-5" />
|
||||
Go Home
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="flex items-center gap-2 px-6 py-3 bg-gray-600 text-white rounded-lg hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
<Lock className="h-5 w-5" />
|
||||
Go Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
29
portal/src/app/vm-scale-sets/page.tsx
Normal file
29
portal/src/app/vm-scale-sets/page.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client'
|
||||
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export default function VMScaleSetsPage() {
|
||||
const { data: session, status } = useSession()
|
||||
|
||||
if (status === 'loading') {
|
||||
return <div>Loading...</div>
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6">VM Scale Sets</h1>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Manage auto-scaling VM groups with metrics and scaling policies
|
||||
</p>
|
||||
{/* VM Scale Sets UI will be implemented here */}
|
||||
<div className="border rounded-lg p-4">
|
||||
<p className="text-sm text-muted-foreground">VM Scale Sets management interface coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
71
portal/src/app/well-architected/page.tsx
Normal file
71
portal/src/app/well-architected/page.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
'use client';
|
||||
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
// Well-Architected Framework dashboard - component to be implemented
|
||||
// import WAFDashboard from '@/components/well-architected/WAFDashboard';
|
||||
|
||||
export default function WellArchitectedPage() {
|
||||
const { data: session, status } = useSession();
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 h-8 w-8 animate-spin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>
|
||||
<p className="text-gray-400">Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-900">
|
||||
<div className="text-center max-w-md mx-auto p-8">
|
||||
<h1 className="text-2xl font-bold text-white mb-4">Access Denied</h1>
|
||||
<p className="text-gray-400 mb-6">Please sign in to view Well-Architected Framework</p>
|
||||
<button
|
||||
onClick={() => signIn()}
|
||||
className="px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6">
|
||||
<h1 className="text-3xl font-bold mb-6 text-white">Well-Architected Framework</h1>
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Security</h2>
|
||||
<p className="text-gray-400">Security pillar assessment coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Reliability</h2>
|
||||
<p className="text-gray-400">Reliability pillar assessment coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Performance</h2>
|
||||
<p className="text-gray-400">Performance pillar assessment coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Cost Optimization</h2>
|
||||
<p className="text-gray-400">Cost optimization assessment coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Operational Excellence</h2>
|
||||
<p className="text-gray-400">Operational excellence assessment coming soon</p>
|
||||
</div>
|
||||
<div className="border rounded-lg p-4 bg-gray-800">
|
||||
<h2 className="text-xl font-semibold mb-2 text-white">Sustainability</h2>
|
||||
<p className="text-gray-400">Sustainability assessment coming soon</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
85
portal/src/hooks/useDashboardData.ts
Normal file
85
portal/src/hooks/useDashboardData.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useQuery } from '@apollo/client'
|
||||
import { GET_SYSTEM_HEALTH, GET_COST_OVERVIEW, GET_BILLING_INFO, GET_API_USAGE, GET_DEPLOYMENTS, GET_TEST_ENVIRONMENTS, GET_API_KEYS } from '@/lib/graphql/queries/dashboard'
|
||||
import { useSession } from 'next-auth/react'
|
||||
|
||||
export function useSystemHealth() {
|
||||
return useQuery(GET_SYSTEM_HEALTH, {
|
||||
pollInterval: 30000, // Poll every 30 seconds
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useCostOverview(tenantId?: string) {
|
||||
const { data: session } = useSession()
|
||||
const defaultTenantId = tenantId || (session as any)?.tenantId
|
||||
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - 30)
|
||||
|
||||
return useQuery(GET_COST_OVERVIEW, {
|
||||
variables: {
|
||||
tenantId: defaultTenantId || 'default',
|
||||
timeRange: {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
},
|
||||
},
|
||||
skip: !defaultTenantId,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useBillingInfo(tenantId?: string) {
|
||||
const { data: session } = useSession()
|
||||
const defaultTenantId = tenantId || (session as any)?.tenantId
|
||||
|
||||
return useQuery(GET_BILLING_INFO, {
|
||||
variables: {
|
||||
tenantId: defaultTenantId || 'default',
|
||||
},
|
||||
skip: !defaultTenantId,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useAPIUsage() {
|
||||
const endDate = new Date()
|
||||
const startDate = new Date()
|
||||
startDate.setDate(startDate.getDate() - 7)
|
||||
|
||||
return useQuery(GET_API_USAGE, {
|
||||
variables: {
|
||||
timeRange: {
|
||||
start: startDate.toISOString(),
|
||||
end: endDate.toISOString(),
|
||||
},
|
||||
},
|
||||
pollInterval: 60000, // Poll every minute
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeployments(filter?: { status?: string; limit?: number }) {
|
||||
return useQuery(GET_DEPLOYMENTS, {
|
||||
variables: {
|
||||
filter: filter || { limit: 10 },
|
||||
},
|
||||
pollInterval: 30000,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useTestEnvironments() {
|
||||
return useQuery(GET_TEST_ENVIRONMENTS, {
|
||||
pollInterval: 30000,
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
export function useAPIKeys() {
|
||||
return useQuery(GET_API_KEYS, {
|
||||
errorPolicy: 'all',
|
||||
})
|
||||
}
|
||||
|
||||
59
portal/src/hooks/useDashboardLayout.ts
Normal file
59
portal/src/hooks/useDashboardLayout.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export interface DashboardTile {
|
||||
id: string;
|
||||
component: string;
|
||||
title: string;
|
||||
visible: boolean;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export function useDashboardLayout(role: string) {
|
||||
const storageKey = `dashboard-layout-${role}`;
|
||||
|
||||
const [tiles, setTiles] = useState<DashboardTile[]>(() => {
|
||||
if (typeof window === 'undefined') return [];
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
return saved ? JSON.parse(saved) : getDefaultTiles(role);
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem(storageKey, JSON.stringify(tiles));
|
||||
}
|
||||
}, [tiles, storageKey]);
|
||||
|
||||
const updateTiles = (newTiles: DashboardTile[]) => {
|
||||
setTiles(newTiles.map((tile, index) => ({ ...tile, order: index })));
|
||||
};
|
||||
|
||||
return { tiles, updateTiles };
|
||||
}
|
||||
|
||||
function getDefaultTiles(role: string): DashboardTile[] {
|
||||
const roleTiles: Record<string, DashboardTile[]> = {
|
||||
technical: [
|
||||
{ id: 'system-health', component: 'SystemHealthTile', title: 'System Health', visible: true, order: 0 },
|
||||
{ id: 'integrations', component: 'IntegrationStatusTile', title: 'Integrations', visible: true, order: 1 },
|
||||
{ id: 'pipelines', component: 'DataPipelineTile', title: 'Data Pipelines', visible: true, order: 2 },
|
||||
{ id: 'resources', component: 'ResourceUtilizationTile', title: 'Resource Utilization', visible: true, order: 3 },
|
||||
],
|
||||
business: [
|
||||
{ id: 'cost', component: 'CostOverviewTile', title: 'Cost Overview', visible: true, order: 0 },
|
||||
{ id: 'usage', component: 'ResourceUsageTile', title: 'Resource Usage', visible: true, order: 1 },
|
||||
{ id: 'billing', component: 'BillingTile', title: 'Billing', visible: true, order: 2 },
|
||||
{ id: 'compliance', component: 'ComplianceStatusTile', title: 'Compliance', visible: true, order: 3 },
|
||||
],
|
||||
developer: [
|
||||
{ id: 'api-usage', component: 'APIUsageTile', title: 'API Usage', visible: true, order: 0 },
|
||||
{ id: 'deployments', component: 'DeploymentsTile', title: 'Deployments', visible: true, order: 1 },
|
||||
{ id: 'environments', component: 'TestEnvironmentsTile', title: 'Test Environments', visible: true, order: 2 },
|
||||
{ id: 'api-keys', component: 'APIKeysTile', title: 'API Keys', visible: true, order: 3 },
|
||||
],
|
||||
};
|
||||
|
||||
return roleTiles[role] || roleTiles.technical;
|
||||
}
|
||||
|
||||
81
portal/src/hooks/useKeyboardShortcuts.ts
Normal file
81
portal/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface KeyboardShortcut {
|
||||
key: string;
|
||||
ctrlKey?: boolean;
|
||||
shiftKey?: boolean;
|
||||
altKey?: boolean;
|
||||
action: () => void;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function useKeyboardShortcuts(shortcuts: KeyboardShortcut[]) {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Don't trigger shortcuts when typing in inputs
|
||||
if (
|
||||
event.target instanceof HTMLInputElement ||
|
||||
event.target instanceof HTMLTextAreaElement ||
|
||||
(event.target instanceof HTMLElement && event.target.isContentEditable)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const shortcut of shortcuts) {
|
||||
if (
|
||||
event.key === shortcut.key &&
|
||||
(shortcut.ctrlKey === undefined || event.ctrlKey === shortcut.ctrlKey) &&
|
||||
(shortcut.shiftKey === undefined || event.shiftKey === shortcut.shiftKey) &&
|
||||
(shortcut.altKey === undefined || event.altKey === shortcut.altKey)
|
||||
) {
|
||||
event.preventDefault();
|
||||
shortcut.action();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [shortcuts]);
|
||||
}
|
||||
|
||||
export function useGlobalKeyboardShortcuts() {
|
||||
const router = useRouter();
|
||||
|
||||
useKeyboardShortcuts([
|
||||
{
|
||||
key: '/',
|
||||
action: () => {
|
||||
const searchInput = document.querySelector('input[type="search"]') as HTMLInputElement;
|
||||
if (searchInput) {
|
||||
searchInput.focus();
|
||||
}
|
||||
},
|
||||
description: 'Focus search',
|
||||
},
|
||||
{
|
||||
key: 'g',
|
||||
ctrlKey: true,
|
||||
action: () => router.push('/dashboard'),
|
||||
description: 'Go to dashboard',
|
||||
},
|
||||
{
|
||||
key: '?',
|
||||
action: () => {
|
||||
// Show keyboard shortcuts help
|
||||
const helpModal = document.getElementById('keyboard-shortcuts-help');
|
||||
if (helpModal) {
|
||||
(helpModal as any).showModal?.();
|
||||
}
|
||||
},
|
||||
description: 'Show keyboard shortcuts',
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
175
portal/src/lib/argocd-client.ts
Normal file
175
portal/src/lib/argocd-client.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* ArgoCD API Client
|
||||
* Handles communication with ArgoCD for GitOps status and management
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
|
||||
export interface ArgoCDApplication {
|
||||
metadata: {
|
||||
name: string
|
||||
namespace: string
|
||||
uid: string
|
||||
}
|
||||
spec: {
|
||||
project: string
|
||||
source: {
|
||||
repoURL: string
|
||||
path?: string
|
||||
targetRevision?: string
|
||||
}
|
||||
destination: {
|
||||
server: string
|
||||
namespace: string
|
||||
}
|
||||
syncPolicy?: {
|
||||
automated?: {
|
||||
prune: boolean
|
||||
selfHeal: boolean
|
||||
}
|
||||
syncOptions?: string[]
|
||||
}
|
||||
}
|
||||
status: {
|
||||
health: {
|
||||
status: 'Healthy' | 'Degraded' | 'Progressing' | 'Suspended' | 'Missing' | 'Unknown'
|
||||
message?: string
|
||||
}
|
||||
sync: {
|
||||
status: 'Synced' | 'OutOfSync' | 'Unknown'
|
||||
revision?: string
|
||||
}
|
||||
conditions?: Array<{
|
||||
type: string
|
||||
message: string
|
||||
lastTransitionTime: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
export interface ArgoCDSyncResult {
|
||||
resources: Array<{
|
||||
kind: string
|
||||
namespace: string
|
||||
name: string
|
||||
status: string
|
||||
message?: string
|
||||
}>
|
||||
}
|
||||
|
||||
class ArgoCDClient {
|
||||
private client: AxiosInstance
|
||||
private baseURL: string
|
||||
|
||||
constructor(accessToken?: string) {
|
||||
this.baseURL = process.env.NEXT_PUBLIC_ARGOCD_URL || 'http://localhost:8080'
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all ArgoCD applications
|
||||
*/
|
||||
async getApplications(): Promise<ArgoCDApplication[]> {
|
||||
try {
|
||||
const response = await this.client.get('/api/v1/applications')
|
||||
return response.data.items || []
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch ArgoCD applications:', error)
|
||||
throw new Error(`Failed to fetch applications: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific ArgoCD application
|
||||
*/
|
||||
async getApplication(name: string, namespace: string = 'argocd'): Promise<ArgoCDApplication> {
|
||||
try {
|
||||
const response = await this.client.get(`/api/v1/applications/${name}`, {
|
||||
params: { namespace },
|
||||
})
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch ArgoCD application ${name}:`, error)
|
||||
throw new Error(`Failed to fetch application: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an ArgoCD application
|
||||
*/
|
||||
async syncApplication(
|
||||
name: string,
|
||||
namespace: string = 'argocd',
|
||||
prune: boolean = false,
|
||||
dryRun: boolean = false
|
||||
): Promise<ArgoCDSyncResult> {
|
||||
try {
|
||||
const response = await this.client.post(
|
||||
`/api/v1/applications/${name}/sync`,
|
||||
{
|
||||
prune,
|
||||
dryRun,
|
||||
},
|
||||
{
|
||||
params: { namespace },
|
||||
}
|
||||
)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to sync ArgoCD application ${name}:`, error)
|
||||
throw new Error(`Failed to sync application: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application logs
|
||||
*/
|
||||
async getApplicationLogs(
|
||||
name: string,
|
||||
namespace: string = 'argocd',
|
||||
tailLines: number = 100
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const response = await this.client.get(`/api/v1/applications/${name}/logs`, {
|
||||
params: {
|
||||
namespace,
|
||||
tailLines,
|
||||
},
|
||||
})
|
||||
return response.data.logs || []
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch logs for ${name}:`, error)
|
||||
throw new Error(`Failed to fetch logs: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get application resource tree
|
||||
*/
|
||||
async getApplicationResourceTree(
|
||||
name: string,
|
||||
namespace: string = 'argocd'
|
||||
): Promise<any> {
|
||||
try {
|
||||
const response = await this.client.get(`/api/v1/applications/${name}/resource-tree`, {
|
||||
params: { namespace },
|
||||
})
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to fetch resource tree for ${name}:`, error)
|
||||
throw new Error(`Failed to fetch resource tree: ${error.message}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createArgoCDClient(accessToken?: string): ArgoCDClient {
|
||||
return new ArgoCDClient(accessToken)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,8 @@ class CrossplaneClientImpl implements CrossplaneClient {
|
||||
|
||||
async getVMs(): Promise<VM[]> {
|
||||
try {
|
||||
const response = await this.client.get('/apis/proxmox.yourorg.io/v1alpha1/proxmoxvms');
|
||||
const apiGroup = process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus'
|
||||
const response = await this.client.get(`/apis/${apiGroup}/v1alpha1/proxmoxvms`);
|
||||
return response.data.items || [];
|
||||
} catch (error) {
|
||||
handleAxiosError(error);
|
||||
@@ -103,8 +104,9 @@ class CrossplaneClientImpl implements CrossplaneClient {
|
||||
|
||||
async getVM(name: string): Promise<VM> {
|
||||
try {
|
||||
const apiGroup = process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus'
|
||||
const response = await this.client.get(
|
||||
`/apis/proxmox.yourorg.io/v1alpha1/proxmoxvms/${name}`
|
||||
`/apis/${apiGroup}/v1alpha1/proxmoxvms/${name}`
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
@@ -114,8 +116,9 @@ class CrossplaneClientImpl implements CrossplaneClient {
|
||||
|
||||
async createVM(spec: VMSpec): Promise<VM> {
|
||||
try {
|
||||
const response = await this.client.post('/apis/proxmox.yourorg.io/v1alpha1/proxmoxvms', {
|
||||
apiVersion: 'proxmox.yourorg.io/v1alpha1',
|
||||
const apiGroup = process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus'
|
||||
const response = await this.client.post(`/apis/${apiGroup}/v1alpha1/proxmoxvms`, {
|
||||
apiVersion: `${apiGroup}/v1alpha1`,
|
||||
kind: 'ProxmoxVM',
|
||||
metadata: {
|
||||
name: spec.forProvider.name,
|
||||
@@ -131,8 +134,9 @@ class CrossplaneClientImpl implements CrossplaneClient {
|
||||
|
||||
async updateVM(name: string, spec: Partial<VMSpec>): Promise<VM> {
|
||||
try {
|
||||
const apiGroup = process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus'
|
||||
const response = await this.client.patch(
|
||||
`/apis/proxmox.yourorg.io/v1alpha1/proxmoxvms/${name}`,
|
||||
`/apis/${apiGroup}/v1alpha1/proxmoxvms/${name}`,
|
||||
{
|
||||
spec,
|
||||
},
|
||||
@@ -150,7 +154,8 @@ class CrossplaneClientImpl implements CrossplaneClient {
|
||||
|
||||
async deleteVM(name: string): Promise<void> {
|
||||
try {
|
||||
await this.client.delete(`/apis/proxmox.yourorg.io/v1alpha1/proxmoxvms/${name}`);
|
||||
const apiGroup = process.env.NEXT_PUBLIC_CROSSPLANE_API_GROUP || 'proxmox.sankofa.nexus'
|
||||
await this.client.delete(`/apis/${apiGroup}/v1alpha1/proxmoxvms/${name}`);
|
||||
} catch (error) {
|
||||
handleAxiosError(error);
|
||||
}
|
||||
|
||||
264
portal/src/lib/fairness-orchestration.ts
Normal file
264
portal/src/lib/fairness-orchestration.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Fairness Orchestration Engine Client
|
||||
* This is a client-side wrapper that would call the API in production
|
||||
*/
|
||||
|
||||
export interface OutputType {
|
||||
id: string;
|
||||
name: string;
|
||||
weight: number;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface InputSpec {
|
||||
dataset: string;
|
||||
dateRange?: { start: string; end: string };
|
||||
filters?: Record<string, any>;
|
||||
sensitiveAttributes: string[];
|
||||
estimatedSize?: number;
|
||||
}
|
||||
|
||||
export interface TimelineSpec {
|
||||
mode: 'now' | 'scheduled' | 'continuous';
|
||||
sla?: string;
|
||||
deadline?: string;
|
||||
}
|
||||
|
||||
export interface OrchestrationRequest {
|
||||
input: InputSpec;
|
||||
outputs: string[];
|
||||
timeline: TimelineSpec;
|
||||
}
|
||||
|
||||
export interface OrchestrationResult {
|
||||
totalLoad: number;
|
||||
inputLoad: number;
|
||||
outputLoad: number;
|
||||
estimatedTime: number;
|
||||
feasible: boolean;
|
||||
warnings: string[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
// Output type definitions
|
||||
export const OUTPUT_TYPES: Record<string, OutputType> = {
|
||||
'fairness-audit-pdf': {
|
||||
id: 'fairness-audit-pdf',
|
||||
name: 'Fairness Audit PDF',
|
||||
weight: 2.5,
|
||||
description: 'Comprehensive fairness audit report in PDF format'
|
||||
},
|
||||
'metrics-export': {
|
||||
id: 'metrics-export',
|
||||
name: 'Metrics Export (SPD, TPR, FPR)',
|
||||
weight: 1.0,
|
||||
description: 'Statistical parity difference, true positive rate, false positive rate metrics'
|
||||
},
|
||||
'flagged-cases-csv': {
|
||||
id: 'flagged-cases-csv',
|
||||
name: 'Flagged Cases CSV',
|
||||
weight: 1.5,
|
||||
description: 'Export of cases flagged for potential bias issues'
|
||||
},
|
||||
'exec-summary-slides': {
|
||||
id: 'exec-summary-slides',
|
||||
name: 'Executive Summary Slide Pack',
|
||||
weight: 2.0,
|
||||
description: 'Executive presentation slides with key findings'
|
||||
},
|
||||
'detailed-report-json': {
|
||||
id: 'detailed-report-json',
|
||||
name: 'Detailed Report (JSON)',
|
||||
weight: 1.2,
|
||||
description: 'Machine-readable detailed fairness analysis'
|
||||
},
|
||||
'alerts-config': {
|
||||
id: 'alerts-config',
|
||||
name: 'Alert Configuration',
|
||||
weight: 0.8,
|
||||
description: 'Automated alert rules for ongoing monitoring'
|
||||
},
|
||||
'dashboard-export': {
|
||||
id: 'dashboard-export',
|
||||
name: 'Dashboard Export',
|
||||
weight: 1.8,
|
||||
description: 'Interactive dashboard with fairness metrics'
|
||||
},
|
||||
'compliance-report': {
|
||||
id: 'compliance-report',
|
||||
name: 'Compliance Report',
|
||||
weight: 2.2,
|
||||
description: 'Regulatory compliance documentation'
|
||||
}
|
||||
};
|
||||
|
||||
const INPUT_PASS_MULTIPLIER = 2.0;
|
||||
const TOTAL_LOAD_MULTIPLIER = 3.2;
|
||||
const OUTPUT_TARGET_MULTIPLIER = 1.2;
|
||||
const BASE_PROCESSING_RATE = 10;
|
||||
const INPUT_PROCESSING_RATE = 15;
|
||||
const OUTPUT_PROCESSING_RATE = 8;
|
||||
|
||||
export function calculateInputLoad(input: InputSpec): number {
|
||||
if (input.estimatedSize) {
|
||||
return input.estimatedSize;
|
||||
}
|
||||
|
||||
let baseSize = 100;
|
||||
baseSize += input.sensitiveAttributes.length * 20;
|
||||
|
||||
if (input.dateRange) {
|
||||
const start = new Date(input.dateRange.start);
|
||||
const end = new Date(input.dateRange.end);
|
||||
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
|
||||
baseSize += days * 5;
|
||||
}
|
||||
|
||||
if (input.filters) {
|
||||
baseSize += Object.keys(input.filters).length * 10;
|
||||
}
|
||||
|
||||
return baseSize;
|
||||
}
|
||||
|
||||
export function calculateOutputLoad(outputIds: string[]): number {
|
||||
return outputIds.reduce((total, outputId) => {
|
||||
const output = OUTPUT_TYPES[outputId];
|
||||
if (!output) return total;
|
||||
return total + output.weight;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
export function calculateTotalLoad(inputLoad: number, outputLoad: number): number {
|
||||
const inputPasses = inputLoad * INPUT_PASS_MULTIPLIER;
|
||||
return outputLoad + inputPasses;
|
||||
}
|
||||
|
||||
export function estimateProcessingTime(totalLoad: number): number {
|
||||
const avgRate = (INPUT_PROCESSING_RATE + OUTPUT_PROCESSING_RATE) / 2;
|
||||
return totalLoad / avgRate;
|
||||
}
|
||||
|
||||
function parseSLAToSeconds(sla: string): number {
|
||||
const match = sla.match(/(\d+)\s*(hour|hours|day|days|minute|minutes|min|mins|second|seconds|sec|secs)/i);
|
||||
if (!match) return 3600;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2].toLowerCase();
|
||||
|
||||
const multipliers: Record<string, number> = {
|
||||
second: 1, seconds: 1, sec: 1, secs: 1,
|
||||
minute: 60, minutes: 60, min: 60, mins: 60,
|
||||
hour: 3600, hours: 3600,
|
||||
day: 86400, days: 86400
|
||||
};
|
||||
|
||||
return value * (multipliers[unit] || 3600);
|
||||
}
|
||||
|
||||
function formatTime(seconds: number): string {
|
||||
if (seconds < 60) return `${Math.ceil(seconds)} seconds`;
|
||||
if (seconds < 3600) return `${(seconds / 60).toFixed(1)} minutes`;
|
||||
if (seconds < 86400) return `${(seconds / 3600).toFixed(1)} hours`;
|
||||
return `${(seconds / 86400).toFixed(1)} days`;
|
||||
}
|
||||
|
||||
export function checkFeasibility(
|
||||
totalLoad: number,
|
||||
estimatedTime: number,
|
||||
timeline: TimelineSpec
|
||||
): { feasible: boolean; warnings: string[]; suggestions: string[] } {
|
||||
const warnings: string[] = [];
|
||||
const suggestions: string[] = [];
|
||||
|
||||
let maxTimeSeconds: number | null = null;
|
||||
if (timeline.sla) {
|
||||
maxTimeSeconds = parseSLAToSeconds(timeline.sla);
|
||||
} else if (timeline.deadline) {
|
||||
const now = Date.now();
|
||||
const deadline = new Date(timeline.deadline).getTime();
|
||||
maxTimeSeconds = Math.max(0, (deadline - now) / 1000);
|
||||
}
|
||||
|
||||
const inputLoad = totalLoad / TOTAL_LOAD_MULTIPLIER;
|
||||
const outputLoad = totalLoad - (inputLoad * INPUT_PASS_MULTIPLIER);
|
||||
const targetOutputLoad = inputLoad * OUTPUT_TARGET_MULTIPLIER;
|
||||
|
||||
if (outputLoad > targetOutputLoad * 1.5) {
|
||||
warnings.push(
|
||||
`Output complexity (${outputLoad.toFixed(1)} units) is significantly higher than recommended (${targetOutputLoad.toFixed(1)} units)`
|
||||
);
|
||||
suggestions.push('Consider reducing the number of outputs or simplifying output requirements');
|
||||
}
|
||||
|
||||
if (maxTimeSeconds !== null) {
|
||||
if (estimatedTime > maxTimeSeconds) {
|
||||
warnings.push(
|
||||
`Estimated processing time (${formatTime(estimatedTime)}) exceeds requested timeline (${formatTime(maxTimeSeconds)})`
|
||||
);
|
||||
suggestions.push(`Consider extending timeline to ${formatTime(estimatedTime * 1.2)} or reducing outputs`);
|
||||
} else if (estimatedTime > maxTimeSeconds * 0.8) {
|
||||
warnings.push(
|
||||
`Estimated processing time (${formatTime(estimatedTime)}) is close to timeline limit (${formatTime(maxTimeSeconds)})`
|
||||
);
|
||||
suggestions.push('Consider adding buffer time or reducing outputs for safety');
|
||||
}
|
||||
}
|
||||
|
||||
const expectedTotalLoad = inputLoad * TOTAL_LOAD_MULTIPLIER;
|
||||
if (totalLoad > expectedTotalLoad * 1.3) {
|
||||
warnings.push(
|
||||
`Total process load (${totalLoad.toFixed(1)} units) is higher than expected (${expectedTotalLoad.toFixed(1)} units)`
|
||||
);
|
||||
}
|
||||
|
||||
const feasible = warnings.length === 0 || warnings.every(w => !w.includes('exceeds'));
|
||||
|
||||
return { feasible, warnings, suggestions };
|
||||
}
|
||||
|
||||
export function orchestrate(request: OrchestrationRequest): OrchestrationResult {
|
||||
const inputLoad = calculateInputLoad(request.input);
|
||||
const outputLoad = calculateOutputLoad(request.outputs);
|
||||
const totalLoad = calculateTotalLoad(inputLoad, outputLoad);
|
||||
const estimatedTime = estimateProcessingTime(totalLoad);
|
||||
|
||||
const { feasible, warnings, suggestions } = checkFeasibility(
|
||||
totalLoad,
|
||||
estimatedTime,
|
||||
request.timeline
|
||||
);
|
||||
|
||||
return {
|
||||
totalLoad,
|
||||
inputLoad,
|
||||
outputLoad,
|
||||
estimatedTime,
|
||||
feasible,
|
||||
warnings,
|
||||
suggestions
|
||||
};
|
||||
}
|
||||
|
||||
export function getUserMessage(result: OrchestrationResult, request: OrchestrationRequest): string {
|
||||
const { inputLoad, outputLoad, estimatedTime, feasible, warnings } = result;
|
||||
|
||||
if (feasible && warnings.length === 0) {
|
||||
return `This fairness audit will process approximately ${inputLoad.toFixed(0)} input units and generate ${outputLoad.toFixed(1)} output units, taking approximately ${formatTime(estimatedTime)} to complete.`;
|
||||
}
|
||||
|
||||
if (feasible) {
|
||||
return `This audit is feasible but has some considerations: ${warnings.join('; ')}. Estimated time: ${formatTime(estimatedTime)}.`;
|
||||
}
|
||||
|
||||
return `This audit configuration may not be feasible within the requested timeline. ${warnings.join('; ')}. Estimated time: ${formatTime(estimatedTime)}.`;
|
||||
}
|
||||
|
||||
export function getAvailableOutputs(): OutputType[] {
|
||||
return Object.values(OUTPUT_TYPES);
|
||||
}
|
||||
|
||||
export function getOutputType(id: string): OutputType | undefined {
|
||||
return OUTPUT_TYPES[id];
|
||||
}
|
||||
|
||||
175
portal/src/lib/kubernetes-client.ts
Normal file
175
portal/src/lib/kubernetes-client.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* Kubernetes API Client
|
||||
* Handles communication with Kubernetes API for cluster management
|
||||
*/
|
||||
|
||||
import axios, { AxiosInstance } from 'axios'
|
||||
|
||||
export interface KubernetesCluster {
|
||||
name: string
|
||||
server: string
|
||||
context: string
|
||||
namespace: string
|
||||
version?: string
|
||||
nodes?: {
|
||||
total: number
|
||||
ready: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface KubernetesResource {
|
||||
kind: string
|
||||
apiVersion: string
|
||||
metadata: {
|
||||
name: string
|
||||
namespace?: string
|
||||
uid: string
|
||||
creationTimestamp: string
|
||||
labels?: Record<string, string>
|
||||
annotations?: Record<string, string>
|
||||
}
|
||||
spec?: any
|
||||
status?: any
|
||||
}
|
||||
|
||||
export interface KubernetesNamespace {
|
||||
metadata: {
|
||||
name: string
|
||||
uid: string
|
||||
creationTimestamp: string
|
||||
labels?: Record<string, string>
|
||||
}
|
||||
status: {
|
||||
phase: string
|
||||
}
|
||||
}
|
||||
|
||||
class KubernetesClient {
|
||||
private client: AxiosInstance
|
||||
private baseURL: string
|
||||
|
||||
constructor(accessToken?: string, clusterURL?: string) {
|
||||
this.baseURL = clusterURL || process.env.NEXT_PUBLIC_KUBERNETES_API || 'http://localhost:8001'
|
||||
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(accessToken && { Authorization: `Bearer ${accessToken}` }),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cluster information
|
||||
*/
|
||||
async getClusterInfo(): Promise<KubernetesCluster> {
|
||||
try {
|
||||
const versionResponse = await this.client.get('/version')
|
||||
const nodesResponse = await this.client.get('/api/v1/nodes')
|
||||
|
||||
const nodes = nodesResponse.data.items || []
|
||||
const readyNodes = nodes.filter((node: any) => {
|
||||
const conditions = node.status?.conditions || []
|
||||
return conditions.some((c: any) => c.type === 'Ready' && c.status === 'True')
|
||||
})
|
||||
|
||||
return {
|
||||
name: 'default-cluster',
|
||||
server: this.baseURL,
|
||||
context: 'default',
|
||||
namespace: 'default',
|
||||
version: versionResponse.data.gitVersion,
|
||||
nodes: {
|
||||
total: nodes.length,
|
||||
ready: readyNodes.length,
|
||||
},
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to fetch cluster info:', error)
|
||||
throw new Error(`Failed to fetch cluster info: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List namespaces
|
||||
*/
|
||||
async listNamespaces(): Promise<KubernetesNamespace[]> {
|
||||
try {
|
||||
const response = await this.client.get('/api/v1/namespaces')
|
||||
return response.data.items || []
|
||||
} catch (error: any) {
|
||||
console.error('Failed to list namespaces:', error)
|
||||
throw new Error(`Failed to list namespaces: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List resources by kind
|
||||
*/
|
||||
async listResources(
|
||||
kind: string,
|
||||
namespace?: string,
|
||||
apiVersion: string = 'v1'
|
||||
): Promise<KubernetesResource[]> {
|
||||
try {
|
||||
const path = namespace
|
||||
? `/api/${apiVersion}/namespaces/${namespace}/${kind.toLowerCase()}s`
|
||||
: `/api/${apiVersion}/${kind.toLowerCase()}s`
|
||||
|
||||
const response = await this.client.get(path)
|
||||
return response.data.items || []
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to list ${kind} resources:`, error)
|
||||
throw new Error(`Failed to list resources: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific resource
|
||||
*/
|
||||
async getResource(
|
||||
kind: string,
|
||||
name: string,
|
||||
namespace?: string,
|
||||
apiVersion: string = 'v1'
|
||||
): Promise<KubernetesResource> {
|
||||
try {
|
||||
const path = namespace
|
||||
? `/api/${apiVersion}/namespaces/${namespace}/${kind.toLowerCase()}s/${name}`
|
||||
: `/api/${apiVersion}/${kind.toLowerCase()}s/${name}`
|
||||
|
||||
const response = await this.client.get(path)
|
||||
return response.data
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to get ${kind} ${name}:`, error)
|
||||
throw new Error(`Failed to get resource: ${error.message}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List pods
|
||||
*/
|
||||
async listPods(namespace?: string): Promise<KubernetesResource[]> {
|
||||
return this.listResources('Pod', namespace)
|
||||
}
|
||||
|
||||
/**
|
||||
* List deployments
|
||||
*/
|
||||
async listDeployments(namespace?: string): Promise<KubernetesResource[]> {
|
||||
return this.listResources('Deployment', namespace, 'apps/v1')
|
||||
}
|
||||
|
||||
/**
|
||||
* List services
|
||||
*/
|
||||
async listServices(namespace?: string): Promise<KubernetesResource[]> {
|
||||
return this.listResources('Service', namespace)
|
||||
}
|
||||
}
|
||||
|
||||
export function createKubernetesClient(accessToken?: string, clusterURL?: string): KubernetesClient {
|
||||
return new KubernetesClient(accessToken, clusterURL)
|
||||
}
|
||||
|
||||
42
portal/src/lib/roles.ts
Normal file
42
portal/src/lib/roles.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { Session } from 'next-auth'
|
||||
|
||||
export type UserRole = 'technical' | 'business' | 'developer' | 'admin'
|
||||
|
||||
/**
|
||||
* Determines the primary role for a user based on their session roles
|
||||
* Priority: technical > business > developer > admin (default)
|
||||
*/
|
||||
export function getUserRole(session: Session | null): UserRole {
|
||||
if (!session?.roles || session.roles.length === 0) {
|
||||
return 'admin' // Default role
|
||||
}
|
||||
|
||||
const roles = session.roles.map(r => r.toLowerCase())
|
||||
|
||||
// Check for technical admin roles
|
||||
if (roles.some(r => r.includes('technical') || r.includes('admin') || r.includes('operator'))) {
|
||||
return 'technical'
|
||||
}
|
||||
|
||||
// Check for business owner roles
|
||||
if (roles.some(r => r.includes('business') || r.includes('owner') || r.includes('manager'))) {
|
||||
return 'business'
|
||||
}
|
||||
|
||||
// Check for developer roles
|
||||
if (roles.some(r => r.includes('developer') || r.includes('dev') || r.includes('engineer'))) {
|
||||
return 'developer'
|
||||
}
|
||||
|
||||
// Default to admin/technical
|
||||
return 'technical'
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the dashboard route for a user based on their role
|
||||
*/
|
||||
export function getDashboardRoute(session: Session | null): string {
|
||||
const role = getUserRole(session)
|
||||
return `/dashboard/${role}`
|
||||
}
|
||||
|
||||
6
portal/src/lib/utils.ts
Normal file
6
portal/src/lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Reference in New Issue
Block a user