diff --git a/api/.env.example b/api/.env.example index 9f514f0..31316e6 100644 --- a/api/.env.example +++ b/api/.env.example @@ -21,5 +21,14 @@ KEYCLOAK_CLIENT_SECRET=your_keycloak_client_secret # For production: minimum 64 characters JWT_SECRET=your_jwt_secret_here_minimum_64_chars_for_production +# Phoenix API Railing (optional — for /api/v1/infra, /api/v1/ve, /api/v1/health proxy) +# Base URL of Phoenix Deploy API or Phoenix API (e.g. http://phoenix-deploy-api:4001) +PHOENIX_RAILING_URL= +# Optional: API key for server-to-server calls when railing requires PHOENIX_PARTNER_KEYS +PHOENIX_RAILING_API_KEY= + +# Public URL for GraphQL Playground link (default http://localhost:4000) +# PUBLIC_URL=https://api.sankofa.nexus + # Logging LOG_LEVEL=info diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts index 9051aac..454f9bd 100644 --- a/api/src/middleware/rate-limit.ts +++ b/api/src/middleware/rate-limit.ts @@ -18,14 +18,15 @@ const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute const RATE_LIMIT_MAX_REQUESTS = 100 // 100 requests per minute /** - * Get client identifier from request + * Get client identifier from request (per-tenant when available) */ function getClientId(request: FastifyRequest): string { - // Use IP address or user ID - const ip = request.ip || request.socket.remoteAddress || 'unknown' + const tenantId = (request as any).tenantContext?.tenantId + if (tenantId) return `tenant:${tenantId}` const userId = (request as any).user?.id - - return userId ? `user:${userId}` : `ip:${ip}` + if (userId) return `user:${userId}` + const ip = request.ip || request.socket.remoteAddress || 'unknown' + return `ip:${ip}` } /** diff --git a/portal/src/app/infrastructure/page.tsx b/portal/src/app/infrastructure/page.tsx new file mode 100644 index 0000000..5289cb2 --- /dev/null +++ b/portal/src/app/infrastructure/page.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { usePhoenixInfraNodes, usePhoenixInfraStorage } from '@/hooks/usePhoenixRailing'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Server, HardDrive } from 'lucide-react'; + +export default function InfrastructurePage() { + const { data: nodesData, isLoading: nodesLoading, error: nodesError } = usePhoenixInfraNodes(); + const { data: storageData, isLoading: storageLoading, error: storageError } = usePhoenixInfraStorage(); + + return ( +
+

Infrastructure

+

+ Cluster nodes and storage from Phoenix API Railing (GET /api/v1/infra/nodes, /api/v1/infra/storage). +

+ +
+ + + + + Cluster Nodes + + + + {nodesLoading &&

Loading...

} + {nodesError &&

Error loading nodes

} + {nodesData?.nodes && ( +
    + {nodesData.nodes.map((n: any) => ( +
  • + {n.node ?? n.name ?? n.id} + {n.status ?? '—'} +
  • + ))} +
+ )} + {nodesData?.stub &&

Stub data (set PROXMOX_* on railing)

} +
+
+ + + + + + Storage + + + + {storageLoading &&

Loading...

} + {storageError &&

Error loading storage

} + {storageData?.storage && ( +
    + {storageData.storage.slice(0, 10).map((s: any, i: number) => ( +
  • {s.storage ?? s.name ?? s.id}
  • + ))} +
+ )} + {storageData?.stub &&

Stub data (set PROXMOX_* on railing)

} +
+
+
+
+ ); +} diff --git a/portal/src/app/resources/page.tsx b/portal/src/app/resources/page.tsx index b6874df..730995e 100644 --- a/portal/src/app/resources/page.tsx +++ b/portal/src/app/resources/page.tsx @@ -1,10 +1,12 @@ 'use client' -import { useState } from 'react' import { useSession } from 'next-auth/react' +import { useTenantResources } from '@/hooks/usePhoenixRailing' +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card' export default function ResourcesPage() { const { data: session, status } = useSession() + const { data: tenantData, isLoading, error } = useTenantResources() if (status === 'loading') { return
Loading...
@@ -14,16 +16,37 @@ export default function ResourcesPage() { return
Please sign in
} + const resources = tenantData?.resources ?? [] + return (

Resource Inventory

- Unified view of all resources across Proxmox, Kubernetes, and Cloudflare + Tenant-scoped resources from Phoenix API (GET /api/v1/tenants/me/resources)

- {/* Resource inventory UI will be implemented here */} -
-

Resource inventory table coming soon

-
+ {isLoading &&

Loading...

} + {error &&

Error loading resources

} + {tenantData && ( + + + Tenant: {tenantData.tenantId} + + + {resources.length === 0 ? ( +

No resources

+ ) : ( +
    + {resources.map((r: any) => ( +
  • + {r.name} + {r.resource_type ?? r.provider} +
  • + ))} +
+ )} +
+
+ )}
) } diff --git a/portal/src/components/Dashboard.tsx b/portal/src/components/Dashboard.tsx index edac7fd..38563c3 100644 --- a/portal/src/components/Dashboard.tsx +++ b/portal/src/components/Dashboard.tsx @@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query'; import { createCrossplaneClient, VM } from '@/lib/crossplane-client'; import { Card, CardContent, CardHeader, CardTitle } from './ui/Card'; import { Server, Activity, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'; +import { PhoenixHealthTile } from './dashboard/PhoenixHealthTile'; import { Badge } from './ui/badge'; import { gql } from '@apollo/client'; import { useQuery as useApolloQuery } from '@apollo/client'; @@ -132,6 +133,10 @@ export default function Dashboard() { +
+ +
+ Recent Activity diff --git a/portal/src/components/dashboard/PhoenixHealthTile.tsx b/portal/src/components/dashboard/PhoenixHealthTile.tsx new file mode 100644 index 0000000..fe52d5b --- /dev/null +++ b/portal/src/components/dashboard/PhoenixHealthTile.tsx @@ -0,0 +1,81 @@ +'use client'; + +import { usePhoenixHealthSummary } from '@/hooks/usePhoenixRailing'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card'; +import { Activity, CheckCircle, AlertCircle } from 'lucide-react'; + +export function PhoenixHealthTile() { + const { data, isLoading, error } = usePhoenixHealthSummary(); + + if (isLoading) { + return ( + + + + + Phoenix Health + + + +
Loading...
+
+
+ ); + } + + if (error) { + return ( + + + + + Phoenix Health + + + +
Error loading health (check PHOENIX_RAILING_URL)
+
+
+ ); + } + + const status = data?.status ?? 'unknown'; + const hosts = data?.hosts ?? []; + const alerts = data?.alerts ?? []; + + return ( + + +
+ + + Phoenix Health (Railing) + + + {status} + +
+
+ +
+
+ + Hosts: {hosts.length} +
+ {alerts.length > 0 && ( +
+ + Alerts: {alerts.length} +
+ )} +
+
+
+ ); +} diff --git a/portal/src/components/layout/PortalSidebar.tsx b/portal/src/components/layout/PortalSidebar.tsx index 56424fc..b3d91b2 100644 --- a/portal/src/components/layout/PortalSidebar.tsx +++ b/portal/src/components/layout/PortalSidebar.tsx @@ -18,6 +18,7 @@ import { cn } from '@/lib/utils' const navigation = [ { name: 'Dashboard', href: '/', icon: LayoutDashboard }, + { name: 'Infrastructure', href: '/infrastructure', icon: Server }, { name: 'Resources', href: '/resources', icon: Server }, { name: 'Virtual Machines', href: '/vms', icon: Server }, { name: 'Networking', href: '/network', icon: Network }, diff --git a/portal/src/hooks/usePhoenixRailing.ts b/portal/src/hooks/usePhoenixRailing.ts new file mode 100644 index 0000000..6621833 --- /dev/null +++ b/portal/src/hooks/usePhoenixRailing.ts @@ -0,0 +1,99 @@ +'use client'; + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useSession } from 'next-auth/react'; +import { + getInfraNodes, + getInfraStorage, + getVMs, + getHealthSummary, + getHealthAlerts, + getTenantResources, + getTenantHealth, + vmAction, +} from '@/lib/phoenix-api-client'; + +export function usePhoenixInfraNodes() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'infra', 'nodes'], + queryFn: () => getInfraNodes(token), + enabled: !!token, + }); +} + +export function usePhoenixInfraStorage() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'infra', 'storage'], + queryFn: () => getInfraStorage(token), + enabled: !!token, + }); +} + +export function usePhoenixVMs(node?: string) { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 've', 'vms', node], + queryFn: () => getVMs(token, node), + enabled: !!token, + }); +} + +export function usePhoenixHealthSummary() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'health', 'summary'], + queryFn: () => getHealthSummary(token), + enabled: !!token, + refetchInterval: 60000, + }); +} + +export function usePhoenixHealthAlerts() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'health', 'alerts'], + queryFn: () => getHealthAlerts(token), + enabled: !!token, + refetchInterval: 30000, + }); +} + +export function useTenantResources() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'tenants', 'me', 'resources'], + queryFn: () => getTenantResources(token), + enabled: !!token, + }); +} + +export function useTenantHealth() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + return useQuery({ + queryKey: ['phoenix', 'tenants', 'me', 'health'], + queryFn: () => getTenantHealth(token), + enabled: !!token, + }); +} + +export function useVMAction() { + const { data: session } = useSession(); + const token = (session as any)?.accessToken as string | undefined; + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ node, vmid, action, type }: { node: string; vmid: string; action: 'start' | 'stop' | 'reboot'; type?: 'qemu' | 'lxc' }) => + vmAction(node, vmid, action, token, type || 'qemu'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['phoenix', 've', 'vms'] }); + }, + }); +} diff --git a/portal/src/lib/phoenix-api-client.ts b/portal/src/lib/phoenix-api-client.ts new file mode 100644 index 0000000..ff2cfe1 --- /dev/null +++ b/portal/src/lib/phoenix-api-client.ts @@ -0,0 +1,73 @@ +/** + * Phoenix API Railing client — Infra, VE, Health, tenant-scoped. + * Calls Sankofa API /api/v1/* (proxies to Phoenix Deploy API when PHOENIX_RAILING_URL is set). + * Auth: Bearer token from NextAuth session (Keycloak/JWT) or X-API-Key. + */ + +const getBaseUrl = () => { + const g = process.env.NEXT_PUBLIC_GRAPHQL_ENDPOINT || ''; + if (g) return g.replace(/\/graphql\/?$/, ''); + return process.env.NEXT_PUBLIC_API_URL || 'http://localhost:4000'; +}; + +export async function phoenixFetch( + path: string, + token: string | undefined, + options: RequestInit = {} +): Promise { + const base = getBaseUrl(); + const url = path.startsWith('http') ? path : `${base}${path}`; + const res = await fetch(url, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...(token && { Authorization: `Bearer ${token}` }), + ...options.headers, + }, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error((err as any).message || (err as any).error || res.statusText); + } + return res.json(); +} + +export async function getInfraNodes(token?: string) { + return phoenixFetch<{ nodes: any[]; stub?: boolean }>('/api/v1/infra/nodes', token); +} + +export async function getInfraStorage(token?: string) { + return phoenixFetch<{ storage: any[]; stub?: boolean }>('/api/v1/infra/storage', token); +} + +export async function getVMs(token?: string, node?: string) { + const qs = node ? `?node=${encodeURIComponent(node)}` : ''; + return phoenixFetch<{ vms: any[]; stub?: boolean }>(`/api/v1/ve/vms${qs}`, token); +} + +export async function getVMStatus(node: string, vmid: string, token?: string, type: 'qemu' | 'lxc' = 'qemu') { + return phoenixFetch<{ node: string; vmid: string; status?: string }>( + `/api/v1/ve/vms/${node}/${vmid}/status?type=${type}`, + token + ); +} + +export async function vmAction(node: string, vmid: string, action: 'start' | 'stop' | 'reboot', token?: string, type: 'qemu' | 'lxc' = 'qemu') { + return phoenixFetch<{ ok: boolean }>(`/api/v1/ve/vms/${node}/${vmid}/${action}?type=${type}`, token, { method: 'POST' }); +} + +export async function getHealthSummary(token?: string) { + return phoenixFetch<{ status: string; updated_at: string; hosts: any[]; alerts: any[] }>('/api/v1/health/summary', token); +} + +export async function getHealthAlerts(token?: string) { + return phoenixFetch<{ alerts: any[] }>('/api/v1/health/alerts', token); +} + +export async function getTenantResources(token?: string) { + return phoenixFetch<{ resources: any[]; tenantId: string }>('/api/v1/tenants/me/resources', token); +} + +export async function getTenantHealth(token?: string) { + return phoenixFetch<{ tenantId: string; status: string }>('/api/v1/tenants/me/health', token); +}