Portal: Phoenix API Railing wiring, env example, per-tenant rate limit

- Portal: phoenix-api-client, usePhoenixRailing hooks, /infrastructure page
- Portal: PhoenixHealthTile on dashboard, resources page uses tenant me/resources
- Sidebar: Infrastructure link; Keycloak token used for API calls (BFF)
- api/.env.example: PHOENIX_RAILING_URL, PHOENIX_RAILING_API_KEY
- rate-limit: key by tenant when tenantContext present

Made-with: Cursor
This commit is contained in:
defiQUG
2026-03-11 13:00:46 -07:00
parent 8436e22f4c
commit e123f407d3
9 changed files with 369 additions and 11 deletions

View File

@@ -21,5 +21,14 @@ KEYCLOAK_CLIENT_SECRET=your_keycloak_client_secret
# For production: minimum 64 characters # For production: minimum 64 characters
JWT_SECRET=your_jwt_secret_here_minimum_64_chars_for_production 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 # Logging
LOG_LEVEL=info LOG_LEVEL=info

View File

@@ -18,14 +18,15 @@ const RATE_LIMIT_WINDOW = 60 * 1000 // 1 minute
const RATE_LIMIT_MAX_REQUESTS = 100 // 100 requests per 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 { function getClientId(request: FastifyRequest): string {
// Use IP address or user ID const tenantId = (request as any).tenantContext?.tenantId
const ip = request.ip || request.socket.remoteAddress || 'unknown' if (tenantId) return `tenant:${tenantId}`
const userId = (request as any).user?.id const userId = (request as any).user?.id
if (userId) return `user:${userId}`
return userId ? `user:${userId}` : `ip:${ip}` const ip = request.ip || request.socket.remoteAddress || 'unknown'
return `ip:${ip}`
} }
/** /**

View File

@@ -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 (
<div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Infrastructure</h1>
<p className="text-muted-foreground mb-6">
Cluster nodes and storage from Phoenix API Railing (GET /api/v1/infra/nodes, /api/v1/infra/storage).
</p>
<div className="grid gap-6 md:grid-cols-2">
<Card className="bg-gray-800 border-gray-700">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<Server className="h-5 w-5" />
Cluster Nodes
</CardTitle>
</CardHeader>
<CardContent>
{nodesLoading && <p className="text-gray-400">Loading...</p>}
{nodesError && <p className="text-red-400">Error loading nodes</p>}
{nodesData?.nodes && (
<ul className="space-y-2">
{nodesData.nodes.map((n: any) => (
<li key={n.node || n.name} className="flex justify-between text-sm">
<span>{n.node ?? n.name ?? n.id}</span>
<span className={n.status === 'online' ? 'text-green-400' : 'text-gray-400'}>{n.status ?? '—'}</span>
</li>
))}
</ul>
)}
{nodesData?.stub && <p className="text-xs text-gray-500 mt-2">Stub data (set PROXMOX_* on railing)</p>}
</CardContent>
</Card>
<Card className="bg-gray-800 border-gray-700">
<CardHeader>
<CardTitle className="text-white flex items-center gap-2">
<HardDrive className="h-5 w-5" />
Storage
</CardTitle>
</CardHeader>
<CardContent>
{storageLoading && <p className="text-gray-400">Loading...</p>}
{storageError && <p className="text-red-400">Error loading storage</p>}
{storageData?.storage && (
<ul className="space-y-2">
{storageData.storage.slice(0, 10).map((s: any, i: number) => (
<li key={s.storage || i} className="text-sm">{s.storage ?? s.name ?? s.id}</li>
))}
</ul>
)}
{storageData?.stub && <p className="text-xs text-gray-500 mt-2">Stub data (set PROXMOX_* on railing)</p>}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,10 +1,12 @@
'use client' 'use client'
import { useState } from 'react'
import { useSession } from 'next-auth/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() { export default function ResourcesPage() {
const { data: session, status } = useSession() const { data: session, status } = useSession()
const { data: tenantData, isLoading, error } = useTenantResources()
if (status === 'loading') { if (status === 'loading') {
return <div>Loading...</div> return <div>Loading...</div>
@@ -14,16 +16,37 @@ export default function ResourcesPage() {
return <div>Please sign in</div> return <div>Please sign in</div>
} }
const resources = tenantData?.resources ?? []
return ( return (
<div className="container mx-auto p-6"> <div className="container mx-auto p-6">
<h1 className="text-3xl font-bold mb-6">Resource Inventory</h1> <h1 className="text-3xl font-bold mb-6">Resource Inventory</h1>
<p className="text-muted-foreground mb-4"> <p className="text-muted-foreground mb-4">
Unified view of all resources across Proxmox, Kubernetes, and Cloudflare Tenant-scoped resources from Phoenix API (GET /api/v1/tenants/me/resources)
</p> </p>
{/* Resource inventory UI will be implemented here */} {isLoading && <p className="text-gray-400">Loading...</p>}
<div className="border rounded-lg p-4"> {error && <p className="text-red-400">Error loading resources</p>}
<p className="text-sm text-muted-foreground">Resource inventory table coming soon</p> {tenantData && (
</div> <Card className="bg-gray-800 border-gray-700">
<CardHeader>
<CardTitle className="text-white">Tenant: {tenantData.tenantId}</CardTitle>
</CardHeader>
<CardContent>
{resources.length === 0 ? (
<p className="text-sm text-muted-foreground">No resources</p>
) : (
<ul className="space-y-2">
{resources.map((r: any) => (
<li key={r.id} className="flex justify-between text-sm">
<span>{r.name}</span>
<span className="text-gray-400">{r.resource_type ?? r.provider}</span>
</li>
))}
</ul>
)}
</CardContent>
</Card>
)}
</div> </div>
) )
} }

View File

@@ -5,6 +5,7 @@ import { useQuery } from '@tanstack/react-query';
import { createCrossplaneClient, VM } from '@/lib/crossplane-client'; import { createCrossplaneClient, VM } from '@/lib/crossplane-client';
import { Card, CardContent, CardHeader, CardTitle } from './ui/Card'; import { Card, CardContent, CardHeader, CardTitle } from './ui/Card';
import { Server, Activity, AlertCircle, CheckCircle, Loader2 } from 'lucide-react'; import { Server, Activity, AlertCircle, CheckCircle, Loader2 } from 'lucide-react';
import { PhoenixHealthTile } from './dashboard/PhoenixHealthTile';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { gql } from '@apollo/client'; import { gql } from '@apollo/client';
import { useQuery as useApolloQuery } from '@apollo/client'; import { useQuery as useApolloQuery } from '@apollo/client';
@@ -132,6 +133,10 @@ export default function Dashboard() {
</Card> </Card>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<PhoenixHealthTile />
</div>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Recent Activity</CardTitle> <CardTitle>Recent Activity</CardTitle>

View File

@@ -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 (
<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" />
Phoenix Health
</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" />
Phoenix Health
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-center text-red-400 py-4">Error loading health (check PHOENIX_RAILING_URL)</div>
</CardContent>
</Card>
);
}
const status = data?.status ?? 'unknown';
const hosts = data?.hosts ?? [];
const alerts = data?.alerts ?? [];
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" />
Phoenix Health (Railing)
</CardTitle>
<span
className={`px-3 py-1 rounded-full text-xs font-semibold ${
status === 'healthy' ? 'bg-green-500/20 text-green-400' :
status === 'degraded' ? 'bg-yellow-500/20 text-yellow-400' :
'bg-red-500/20 text-red-400'
}`}
>
{status}
</span>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-green-400" />
<span>Hosts: {hosts.length}</span>
</div>
{alerts.length > 0 && (
<div className="flex items-center gap-2 text-yellow-400">
<AlertCircle className="h-4 w-4" />
<span>Alerts: {alerts.length}</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}

View File

@@ -18,6 +18,7 @@ import { cn } from '@/lib/utils'
const navigation = [ const navigation = [
{ name: 'Dashboard', href: '/', icon: LayoutDashboard }, { name: 'Dashboard', href: '/', icon: LayoutDashboard },
{ name: 'Infrastructure', href: '/infrastructure', icon: Server },
{ name: 'Resources', href: '/resources', icon: Server }, { name: 'Resources', href: '/resources', icon: Server },
{ name: 'Virtual Machines', href: '/vms', icon: Server }, { name: 'Virtual Machines', href: '/vms', icon: Server },
{ name: 'Networking', href: '/network', icon: Network }, { name: 'Networking', href: '/network', icon: Network },

View File

@@ -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'] });
},
});
}

View File

@@ -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<T>(
path: string,
token: string | undefined,
options: RequestInit = {}
): Promise<T> {
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);
}