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:
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
66
portal/src/app/infrastructure/page.tsx
Normal file
66
portal/src/app/infrastructure/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 <div>Loading...</div>
|
||||
@@ -14,16 +16,37 @@ export default function ResourcesPage() {
|
||||
return <div>Please sign in</div>
|
||||
}
|
||||
|
||||
const resources = tenantData?.resources ?? []
|
||||
|
||||
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
|
||||
Tenant-scoped resources from Phoenix API (GET /api/v1/tenants/me/resources)
|
||||
</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>
|
||||
{isLoading && <p className="text-gray-400">Loading...</p>}
|
||||
{error && <p className="text-red-400">Error loading resources</p>}
|
||||
{tenantData && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<PhoenixHealthTile />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
|
||||
81
portal/src/components/dashboard/PhoenixHealthTile.tsx
Normal file
81
portal/src/components/dashboard/PhoenixHealthTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
99
portal/src/hooks/usePhoenixRailing.ts
Normal file
99
portal/src/hooks/usePhoenixRailing.ts
Normal 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'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
73
portal/src/lib/phoenix-api-client.ts
Normal file
73
portal/src/lib/phoenix-api-client.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user