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);
+}