Add public Ecosystem Atlas route
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
import type { Metadata } from 'next';
|
||||
|
||||
import AtlasDashboard from '@/components/atlas/AtlasDashboard';
|
||||
import { loadAtlasSnapshot } from '@/lib/atlas';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Ecosystem Atlas',
|
||||
description:
|
||||
'Live Sankofa route matrix, hidden inventory, and revision notes with server-side IT inventory awareness.',
|
||||
alternates: {
|
||||
canonical: 'https://sankofa.nexus/atlas',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function AtlasPage() {
|
||||
const snapshot = await loadAtlasSnapshot();
|
||||
|
||||
return (
|
||||
<AtlasDashboard
|
||||
routeRows={snapshot.routeRows}
|
||||
guestRows={snapshot.guestRows}
|
||||
hiddenRows={snapshot.hiddenRows}
|
||||
revisions={snapshot.revisions}
|
||||
recommendations={snapshot.recommendations}
|
||||
liveSummary={snapshot.liveSummary}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowUpDown, Filter, Globe2, HardDrive, History, Search, ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
import { Input } from '@/components/ui/Input';
|
||||
import type { AtlasGuestRow, AtlasHiddenRow, AtlasRecommendation, AtlasRevision, AtlasRouteRow } from '@/lib/atlas';
|
||||
|
||||
type Props = {
|
||||
routeRows: AtlasRouteRow[];
|
||||
guestRows: AtlasGuestRow[];
|
||||
hiddenRows: AtlasHiddenRow[];
|
||||
revisions: AtlasRevision[];
|
||||
recommendations: AtlasRecommendation[];
|
||||
liveSummary: {
|
||||
collectedAt?: string;
|
||||
guestCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
stoppedCount?: number | null;
|
||||
portMapState?: string;
|
||||
portMapNote?: string;
|
||||
inventoryState?: string;
|
||||
summaryState?: string;
|
||||
readApiConfigured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type SortKey = 'name' | 'status' | 'host' | 'serviceFamily';
|
||||
|
||||
function includesQuery(value: string, query: string): boolean {
|
||||
return value.toLowerCase().includes(query.toLowerCase());
|
||||
}
|
||||
|
||||
function sortRouteRows(rows: AtlasRouteRow[], sortKey: SortKey): AtlasRouteRow[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
if (sortKey === 'status') return a.status.localeCompare(b.status) || a.hostnames[0].localeCompare(b.hostnames[0]);
|
||||
if (sortKey === 'host') return a.hostnames[0].localeCompare(b.hostnames[0]);
|
||||
return a.hostnames.join(', ').localeCompare(b.hostnames.join(', '));
|
||||
});
|
||||
}
|
||||
|
||||
function sortGuestRows(rows: AtlasGuestRow[], sortKey: SortKey): AtlasGuestRow[] {
|
||||
return [...rows].sort((a, b) => {
|
||||
if (sortKey === 'status') return a.status.localeCompare(b.status) || a.vmid.localeCompare(b.vmid);
|
||||
if (sortKey === 'host') return a.node.localeCompare(b.node) || a.vmid.localeCompare(b.vmid);
|
||||
if (sortKey === 'serviceFamily') return a.serviceFamily.localeCompare(b.serviceFamily) || a.vmid.localeCompare(b.vmid);
|
||||
return a.name.localeCompare(b.name) || a.vmid.localeCompare(b.vmid);
|
||||
});
|
||||
}
|
||||
|
||||
export default function AtlasDashboard({
|
||||
routeRows,
|
||||
guestRows,
|
||||
hiddenRows,
|
||||
revisions,
|
||||
recommendations,
|
||||
liveSummary,
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('');
|
||||
const [scope, setScope] = useState<'all' | 'routes' | 'guests' | 'hidden'>('all');
|
||||
const [sortKey, setSortKey] = useState<SortKey>('name');
|
||||
|
||||
const filteredRouteRows = useMemo(() => {
|
||||
const rows = routeRows.filter((row) => {
|
||||
if (!query) return true;
|
||||
return (
|
||||
row.hostnames.some((host) => includesQuery(host, query)) ||
|
||||
includesQuery(row.edge, query) ||
|
||||
includesQuery(row.backend, query) ||
|
||||
includesQuery(row.source, query) ||
|
||||
includesQuery(row.notes ?? '', query)
|
||||
);
|
||||
});
|
||||
return sortRouteRows(rows, sortKey);
|
||||
}, [query, routeRows, sortKey]);
|
||||
|
||||
const filteredGuestRows = useMemo(() => {
|
||||
const rows = guestRows.filter((row) => {
|
||||
if (!query) return true;
|
||||
return (
|
||||
includesQuery(row.vmid, query) ||
|
||||
includesQuery(row.name, query) ||
|
||||
includesQuery(row.node, query) ||
|
||||
includesQuery(row.ip, query) ||
|
||||
includesQuery(row.status, query) ||
|
||||
includesQuery(row.serviceFamily, query)
|
||||
);
|
||||
});
|
||||
return sortGuestRows(rows, sortKey);
|
||||
}, [guestRows, query, sortKey]);
|
||||
|
||||
const filteredHiddenRows = useMemo(() => {
|
||||
const rows = hiddenRows.filter((row) => {
|
||||
if (!query) return true;
|
||||
return includesQuery(row.label, query) || includesQuery(row.detail, query) || includesQuery(row.source, query);
|
||||
});
|
||||
return [...rows].sort((a, b) => a.label.localeCompare(b.label));
|
||||
}, [hiddenRows, query]);
|
||||
|
||||
return (
|
||||
<div className="bg-sovereign-obsidian text-sovereign-ivory">
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 py-8 sm:px-6 lg:px-8">
|
||||
<section className="overflow-hidden rounded-3xl border border-sovereign-bronze/25 bg-gradient-to-br from-sovereign-midnight via-sovereign-obsidian to-sovereign-midnight p-6 shadow-2xl shadow-black/30">
|
||||
<div className="grid gap-6 lg:grid-cols-[1.6fr_1fr] lg:items-start">
|
||||
<div>
|
||||
<Badge className="border border-sovereign-gold/30 bg-sovereign-gold/10 text-sovereign-gold">
|
||||
Live atlas
|
||||
</Badge>
|
||||
<h1 className="mt-4 text-4xl font-bold tracking-tight sm:text-5xl">Ecosystem Atlas</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-sovereign-ivory/70 sm:text-lg">
|
||||
Public Sankofa routing, live inventory, hidden operator surfaces, and revision notes in one
|
||||
place. The live counters come from the portal server's IT read API, while the route matrix
|
||||
is normalized from the repo's canonical hostname and tunnel docs.
|
||||
</p>
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<Link
|
||||
href="#routes"
|
||||
className="inline-flex h-9 items-center justify-center rounded-md bg-sovereign-gold px-4 text-sm font-medium text-sovereign-obsidian no-underline transition hover:bg-sovereign-ivory"
|
||||
>
|
||||
Review routes
|
||||
</Link>
|
||||
<Link
|
||||
href="#inventory"
|
||||
className="inline-flex h-9 items-center justify-center rounded-md border border-sovereign-bronze/35 bg-transparent px-4 text-sm font-medium text-sovereign-ivory no-underline transition hover:bg-sovereign-midnight"
|
||||
>
|
||||
Inspect inventory
|
||||
</Link>
|
||||
<Link
|
||||
href="https://sankofa.nexus"
|
||||
className="inline-flex h-9 items-center justify-center rounded-md px-4 text-sm font-medium text-sovereign-gold no-underline transition hover:text-sovereign-ivory"
|
||||
>
|
||||
Back to Sankofa
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-obsidian/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-sovereign-ivory/60">Live guests</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-semibold tabular-nums">
|
||||
{liveSummary.guestCount ?? guestRows.length}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-sovereign-ivory/50">
|
||||
{liveSummary.readApiConfigured ? 'Server-side inventory feed configured' : 'Live inventory feed unavailable'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-obsidian/70">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-sovereign-ivory/60">Running / stopped</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-semibold tabular-nums">
|
||||
{liveSummary.runningCount ?? '—'} / {liveSummary.stoppedCount ?? '—'}
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-sovereign-ivory/50">
|
||||
{liveSummary.collectedAt ? `Collected ${liveSummary.collectedAt}` : 'No collection timestamp yet'}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-obsidian/70 sm:col-span-2">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm text-sovereign-ivory/60">Port-map state</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-sovereign-ivory/70">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe2 className="h-4 w-4 text-sovereign-gold" />
|
||||
<span>{liveSummary.portMapState ?? 'unknown'}</span>
|
||||
</div>
|
||||
{liveSummary.portMapNote ? <p className="text-sovereign-ivory/55">{liveSummary.portMapNote}</p> : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm text-sovereign-ivory/60">
|
||||
<ShieldCheck className="h-4 w-4 text-sovereign-gold" />
|
||||
Public routes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tabular-nums">{routeRows.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm text-sovereign-ivory/60">
|
||||
<HardDrive className="h-4 w-4 text-sovereign-gold" />
|
||||
Hidden inventory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tabular-nums">{hiddenRows.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/50">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center gap-2 text-sm text-sovereign-ivory/60">
|
||||
<History className="h-4 w-4 text-sovereign-gold" />
|
||||
Revision notes
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-semibold tabular-nums">{revisions.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 rounded-2xl border border-sovereign-bronze/20 bg-sovereign-midnight/40 p-4 lg:grid-cols-[1fr_auto_auto] lg:items-center">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-sovereign-ivory/35" />
|
||||
<Input
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Search hostnames, VMIDs, IPs, or notes"
|
||||
className="border-sovereign-bronze/25 bg-sovereign-obsidian pl-10 text-sovereign-ivory placeholder:text-sovereign-ivory/35"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-sovereign-ivory/70">
|
||||
<Filter className="h-4 w-4 text-sovereign-gold" />
|
||||
<select
|
||||
value={scope}
|
||||
onChange={(event) => setScope(event.target.value as typeof scope)}
|
||||
className="h-10 rounded-md border border-sovereign-bronze/25 bg-sovereign-obsidian px-3 text-sm text-sovereign-ivory"
|
||||
>
|
||||
<option value="all">All sections</option>
|
||||
<option value="routes">Routes</option>
|
||||
<option value="guests">Guests</option>
|
||||
<option value="hidden">Hidden</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-sovereign-ivory/70">
|
||||
<ArrowUpDown className="h-4 w-4 text-sovereign-gold" />
|
||||
<select
|
||||
value={sortKey}
|
||||
onChange={(event) => setSortKey(event.target.value as SortKey)}
|
||||
className="h-10 rounded-md border border-sovereign-bronze/25 bg-sovereign-obsidian px-3 text-sm text-sovereign-ivory"
|
||||
>
|
||||
<option value="name">Name</option>
|
||||
<option value="host">Host</option>
|
||||
<option value="status">Status</option>
|
||||
<option value="serviceFamily">Service family</option>
|
||||
</select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
{(scope === 'all' || scope === 'routes') && (
|
||||
<section id="routes" className="scroll-mt-24">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sovereign-ivory">
|
||||
<Globe2 className="h-4 w-4 text-sovereign-gold" />
|
||||
Route matrix
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<thead className="text-sovereign-ivory/45">
|
||||
<tr className="border-b border-sovereign-bronze/20">
|
||||
<th className="py-3 pr-4 font-medium">Hostname</th>
|
||||
<th className="py-3 pr-4 font-medium">Edge</th>
|
||||
<th className="py-3 pr-4 font-medium">Backend</th>
|
||||
<th className="py-3 pr-4 font-medium">Visibility</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredRouteRows.map((row) => (
|
||||
<tr key={row.hostnames.join('|')} className="border-b border-sovereign-bronze/10 align-top">
|
||||
<td className="py-4 pr-4">
|
||||
<div className="font-medium text-sovereign-ivory">{row.hostnames.join(', ')}</div>
|
||||
<div className="mt-1 text-xs text-sovereign-ivory/45">{row.source}</div>
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-sovereign-ivory/70">{row.edge}</td>
|
||||
<td className="py-4 pr-4 text-sovereign-ivory/70">{row.backend}</td>
|
||||
<td className="py-4 pr-4">
|
||||
<Badge className="border-sovereign-bronze/20 bg-sovereign-obsidian text-sovereign-ivory/75">
|
||||
{row.visibility}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 pr-4">
|
||||
<Badge
|
||||
className={
|
||||
row.status === 'live'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
|
||||
: row.status === 'documented'
|
||||
? 'border-cyan-500/30 bg-cyan-500/10 text-cyan-300'
|
||||
: 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
{row.notes ? <p className="mt-1 max-w-md text-xs text-sovereign-ivory/45">{row.notes}</p> : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(scope === 'all' || scope === 'guests') && (
|
||||
<section id="inventory" className="scroll-mt-24">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sovereign-ivory">
|
||||
<HardDrive className="h-4 w-4 text-sovereign-gold" />
|
||||
Live guest inventory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="min-w-full border-collapse text-left text-sm">
|
||||
<thead className="text-sovereign-ivory/45">
|
||||
<tr className="border-b border-sovereign-bronze/20">
|
||||
<th className="py-3 pr-4 font-medium">VMID</th>
|
||||
<th className="py-3 pr-4 font-medium">Name</th>
|
||||
<th className="py-3 pr-4 font-medium">IP</th>
|
||||
<th className="py-3 pr-4 font-medium">Node</th>
|
||||
<th className="py-3 pr-4 font-medium">Status</th>
|
||||
<th className="py-3 pr-4 font-medium">Family</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredGuestRows.map((row) => (
|
||||
<tr key={`${row.node}-${row.vmid}-${row.name}`} className="border-b border-sovereign-bronze/10 align-top">
|
||||
<td className="py-4 pr-4 font-mono text-sovereign-ivory/80">{row.vmid}</td>
|
||||
<td className="py-4 pr-4">
|
||||
<div className="font-medium text-sovereign-ivory">{row.name}</div>
|
||||
<div className="mt-1 text-xs text-sovereign-ivory/45">{row.type}</div>
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-sovereign-ivory/70">{row.ip}</td>
|
||||
<td className="py-4 pr-4 text-sovereign-ivory/70">{row.node}</td>
|
||||
<td className="py-4 pr-4">
|
||||
<Badge
|
||||
className={
|
||||
row.status.toLowerCase() === 'running'
|
||||
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-300'
|
||||
: 'border-amber-500/30 bg-amber-500/10 text-amber-300'
|
||||
}
|
||||
>
|
||||
{row.status}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-4 pr-4 text-sovereign-ivory/70">{row.serviceFamily}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(scope === 'all' || scope === 'hidden') && (
|
||||
<section id="hidden" className="scroll-mt-24">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sovereign-ivory">
|
||||
<Sparkles className="h-4 w-4 text-sovereign-gold" />
|
||||
Capture required
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
{filteredHiddenRows.map((row) => (
|
||||
<article key={row.label} className="rounded-2xl border border-sovereign-bronze/15 bg-sovereign-obsidian/50 p-4">
|
||||
<h3 className="font-medium text-sovereign-ivory">{row.label}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-sovereign-ivory/65">{row.detail}</p>
|
||||
<p className="mt-3 text-xs text-sovereign-ivory/40">{row.source}</p>
|
||||
</article>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sovereign-ivory">
|
||||
<History className="h-4 w-4 text-sovereign-gold" />
|
||||
Revision history
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{revisions.map((revision) => (
|
||||
<div key={`${revision.date}-${revision.title}`} className="border-l border-sovereign-bronze/25 pl-4">
|
||||
<p className="text-xs uppercase tracking-wide text-sovereign-gold/80">{revision.date}</p>
|
||||
<h3 className="mt-1 font-medium text-sovereign-ivory">{revision.title}</h3>
|
||||
<p className="mt-2 text-sm leading-6 text-sovereign-ivory/65">{revision.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-sovereign-bronze/20 bg-sovereign-midnight/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-sovereign-ivory">
|
||||
<Sparkles className="h-4 w-4 text-sovereign-gold" />
|
||||
Recommendations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{recommendations.map((recommendation) => (
|
||||
<div key={recommendation.title} className="rounded-2xl border border-sovereign-bronze/15 bg-sovereign-obsidian/50 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium text-sovereign-ivory">{recommendation.title}</h3>
|
||||
<Badge className="border-sovereign-gold/20 bg-sovereign-gold/10 text-sovereign-gold">
|
||||
{recommendation.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="mt-2 text-sm leading-6 text-sovereign-ivory/65">{recommendation.detail}</p>
|
||||
</div>
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -30,6 +30,7 @@ const footerColumns = [
|
||||
title: 'Company',
|
||||
links: [
|
||||
{ label: 'About Sankofa', href: '#resources' },
|
||||
{ label: 'Ecosystem Atlas', href: '/atlas' },
|
||||
{ label: 'Institutional registry', href: 'https://d-bis.org/cb/dbis' },
|
||||
{ label: 'Partner program', href: '/partner' },
|
||||
{ label: 'Contact', href: 'https://portal.sankofa.nexus' },
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { itReadApiBaseUrl, itReadApiKey } from '@/app/api/it/_auth';
|
||||
|
||||
export type AtlasRouteRow = {
|
||||
hostnames: string[];
|
||||
edge: string;
|
||||
backend: string;
|
||||
visibility: 'public' | 'internal' | 'hidden';
|
||||
status: 'live' | 'documented' | 'pending';
|
||||
source: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type AtlasGuestRow = {
|
||||
vmid: string;
|
||||
type: string;
|
||||
name: string;
|
||||
node: string;
|
||||
ip: string;
|
||||
status: string;
|
||||
visibility: 'live' | 'hidden';
|
||||
serviceFamily: string;
|
||||
};
|
||||
|
||||
export type AtlasHiddenRow = {
|
||||
label: string;
|
||||
detail: string;
|
||||
source: string;
|
||||
};
|
||||
|
||||
export type AtlasRevision = {
|
||||
date: string;
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type AtlasRecommendation = {
|
||||
priority: 'P1' | 'P2' | 'P3';
|
||||
title: string;
|
||||
detail: string;
|
||||
};
|
||||
|
||||
export type AtlasSnapshot = {
|
||||
routeRows: AtlasRouteRow[];
|
||||
guestRows: AtlasGuestRow[];
|
||||
hiddenRows: AtlasHiddenRow[];
|
||||
revisions: AtlasRevision[];
|
||||
recommendations: AtlasRecommendation[];
|
||||
liveSummary: {
|
||||
collectedAt?: string;
|
||||
guestCount?: number | null;
|
||||
runningCount?: number | null;
|
||||
stoppedCount?: number | null;
|
||||
portMapState?: string;
|
||||
portMapNote?: string;
|
||||
inventoryState?: string;
|
||||
summaryState?: string;
|
||||
readApiConfigured: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type RecordLike = Record<string, unknown>;
|
||||
|
||||
async function fetchJson(url: string, apiKey?: string): Promise<unknown | null> {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
cache: 'no-store',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(apiKey ? { 'X-API-Key': apiKey } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) return null;
|
||||
return (await res.json()) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): RecordLike | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as RecordLike) : null;
|
||||
}
|
||||
|
||||
function firstRecordArray(value: unknown): RecordLike[] {
|
||||
if (!value || typeof value !== 'object') return [];
|
||||
if (Array.isArray(value)) {
|
||||
return value.filter((entry): entry is RecordLike => Boolean(entry && typeof entry === 'object'));
|
||||
}
|
||||
const record = value as RecordLike;
|
||||
for (const key of ['guests', 'rows', 'items', 'nodes', 'vms', 'inventory', 'data']) {
|
||||
const candidate = record[key];
|
||||
if (Array.isArray(candidate)) {
|
||||
return candidate.filter((entry): entry is RecordLike => Boolean(entry && typeof entry === 'object'));
|
||||
}
|
||||
}
|
||||
for (const candidate of Object.values(record)) {
|
||||
const nested = firstRecordArray(candidate);
|
||||
if (nested.length > 0) return nested;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function toText(value: unknown, fallback = '—'): string {
|
||||
if (typeof value === 'string') return value;
|
||||
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function buildGuestRows(liveInventory: unknown): AtlasGuestRow[] {
|
||||
const rows = firstRecordArray(liveInventory);
|
||||
const vmRows = rows.filter((row) =>
|
||||
['vmid', 'name', 'ip', 'status', 'node', 'type'].some((key) => key in row)
|
||||
);
|
||||
|
||||
return vmRows.map((row) => ({
|
||||
vmid: toText(row.vmid ?? row.id ?? row.vmid_number ?? row.vmidId),
|
||||
type: toText(row.type ?? row.kind ?? row.resourceType ?? 'guest'),
|
||||
name: toText(row.name ?? row.hostname ?? row.label),
|
||||
node: toText(row.node ?? row.host ?? row.site ?? row.cluster ?? '—'),
|
||||
ip: toText(row.ip ?? row.ipAddress ?? row.address ?? row.primary_ip ?? '—'),
|
||||
status: toText(row.status ?? row.state ?? row.phase ?? 'unknown'),
|
||||
visibility: 'live',
|
||||
serviceFamily: toText(row.serviceFamily ?? row.family ?? row.role ?? row.group ?? 'general'),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildRoutes(): AtlasRouteRow[] {
|
||||
return [
|
||||
{
|
||||
hostnames: ['sankofa.nexus', 'www.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> 76.53.10.36 -> NPMplus',
|
||||
backend: '192.168.11.51:3000 Sankofa public site',
|
||||
visibility: 'public',
|
||||
status: 'live',
|
||||
source: 'docs/INSTITUTIONAL_REGISTRY.md',
|
||||
notes: 'Public corporate apex. www canonicalizes to apex.',
|
||||
},
|
||||
{
|
||||
hostnames: ['phoenix.sankofa.nexus', 'www.phoenix.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> 76.53.10.36 -> NPMplus',
|
||||
backend: '192.168.11.50:4000 Phoenix surface',
|
||||
visibility: 'public',
|
||||
status: 'live',
|
||||
source: 'docs/INSTITUTIONAL_REGISTRY.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['the-order.sankofa.nexus', 'www.the-order.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> 76.53.10.36 -> 192.168.11.39:80 HAProxy -> portal',
|
||||
backend: '192.168.11.51:3000 Sankofa portal stack',
|
||||
visibility: 'public',
|
||||
status: 'live',
|
||||
source: 'docs/04-configuration/ALL_VMIDS_ENDPOINTS.md',
|
||||
notes: 'www redirects to apex.',
|
||||
},
|
||||
{
|
||||
hostnames: ['studio.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> 76.53.10.36 -> NPMplus',
|
||||
backend: '192.168.11.72:8000 Sankofa Studio',
|
||||
visibility: 'public',
|
||||
status: 'live',
|
||||
source: 'docs/04-configuration/ALL_VMIDS_ENDPOINTS.md',
|
||||
notes: 'Public tooling surface under /studio.',
|
||||
},
|
||||
{
|
||||
hostnames: ['portal.sankofa.nexus'],
|
||||
edge: 'Cloudflare Tunnel -> control-plane ingress',
|
||||
backend: 'portal.portal.svc.cluster.local:80',
|
||||
visibility: 'internal',
|
||||
status: 'live',
|
||||
source: 'cloudflare/tunnel-configs/control-plane.yaml',
|
||||
notes: 'Client workspace and SSO shell.',
|
||||
},
|
||||
{
|
||||
hostnames: ['keycloak.sankofa.nexus'],
|
||||
edge: 'Cloudflare Tunnel -> control-plane ingress',
|
||||
backend: 'keycloak.keycloak.svc.cluster.local:8080',
|
||||
visibility: 'internal',
|
||||
status: 'live',
|
||||
source: 'cloudflare/tunnel-configs/control-plane.yaml',
|
||||
notes: 'Shared identity provider.',
|
||||
},
|
||||
{
|
||||
hostnames: ['admin.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> NPMplus / protected app',
|
||||
backend: 'SSO administration boundary',
|
||||
visibility: 'internal',
|
||||
status: 'documented',
|
||||
source: 'docs/02-architecture/EXPECTED_WEB_CONTENT.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['dash.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> NPMplus / operator access',
|
||||
backend: 'IP allowlist + MFA operator dashboard',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'docs/02-architecture/EXPECTED_WEB_CONTENT.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['pve.sankofa.nexus', 'pve1.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> NPMplus / tunnel',
|
||||
backend: 'Proxmox management surfaces',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'docs/INSTITUTIONAL_REGISTRY.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['cc.sankofa.nexus', 'auth.cc.sankofa.nexus', 'admin.cc.sankofa.nexus', 'entity.cc.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> Complete Credential stack',
|
||||
backend: 'Credential issuance and admin portals',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'docs/INSTITUTIONAL_REGISTRY.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['ml110-01.sankofa.nexus', 'ml110-01-api.sankofa.nexus', 'ml110-01-metrics.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> internal instance routing',
|
||||
backend: '192.168.11.10 / instance services',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'docs/proxmox/DNS_CONFIGURATION.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['r630-01.sankofa.nexus', 'r630-01-api.sankofa.nexus', 'r630-01-metrics.sankofa.nexus'],
|
||||
edge: 'Cloudflare DNS -> internal instance routing',
|
||||
backend: '192.168.11.11 / instance services',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'docs/proxmox/DNS_CONFIGURATION.md',
|
||||
},
|
||||
{
|
||||
hostnames: ['rancher.sankofa.nexus', 'argocd.sankofa.nexus', 'grafana.sankofa.nexus', 'vault.sankofa.nexus', 'k8s-api.sankofa.nexus'],
|
||||
edge: 'Cloudflare Tunnel -> control-plane ingress',
|
||||
backend: 'Kubernetes control-plane services',
|
||||
visibility: 'hidden',
|
||||
status: 'documented',
|
||||
source: 'cloudflare/tunnel-configs/control-plane.yaml',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildHiddenRows(): AtlasHiddenRow[] {
|
||||
return [
|
||||
{
|
||||
label: 'Atlas hidden inventory',
|
||||
detail: 'Public page can surface routes and hosts that are documented in repo but not exposed in the main nav.',
|
||||
source: 'portal/src/lib/atlas.ts',
|
||||
},
|
||||
{
|
||||
label: 'IT read API',
|
||||
detail: 'Server-side inventory and drift fetches are backed by IT_READ_API_URL and IT_READ_API_KEY.',
|
||||
source: 'portal/src/app/api/it/_auth.ts',
|
||||
},
|
||||
{
|
||||
label: 'Control-plane tunnel',
|
||||
detail: 'portal, keycloak, rancher, argocd, grafana, vault, and k8s-api share the Cloudflare tunnel ingress.',
|
||||
source: 'cloudflare/tunnel-configs/control-plane.yaml',
|
||||
},
|
||||
{
|
||||
label: 'Proxmox instance DNS',
|
||||
detail: 'ml110-01 and r630-01 hostnames are documented internal inventory rather than public marketing surfaces.',
|
||||
source: 'docs/proxmox/DNS_CONFIGURATION.md',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildRevisions(): AtlasRevision[] {
|
||||
return [
|
||||
{
|
||||
date: '2026-07-28',
|
||||
title: 'Atlas route published',
|
||||
detail: 'Added a public /atlas page on the Sankofa apex so the route can be linked from the corporate shell.',
|
||||
},
|
||||
{
|
||||
date: '2026-06-04',
|
||||
title: 'Institutional registry published',
|
||||
detail: 'Documented the primary Sankofa and Phoenix hostnames, including public portal and identity boundaries.',
|
||||
},
|
||||
{
|
||||
date: '2026-03-27',
|
||||
title: 'NPM routing authority updated',
|
||||
detail: 'Route ownership for sankofa.nexus, phoenix.sankofa.nexus, and the-order.sankofa.nexus was consolidated in the NPM routing docs.',
|
||||
},
|
||||
{
|
||||
date: '2025-12-15',
|
||||
title: 'Cluster status baseline',
|
||||
detail: 'The sfvalley-01 Proxmox / Ceph status report captured the original cluster baseline and quorum state.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function buildRecommendations(): AtlasRecommendation[] {
|
||||
return [
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'Publish an atlas snapshot feed',
|
||||
detail: 'Generate a JSON snapshot of routes, hosts, and revision history so public rendering and incident review use the same payload.',
|
||||
},
|
||||
{
|
||||
priority: 'P1',
|
||||
title: 'Keep live inventory server-side',
|
||||
detail: 'Continue fetching the IT inventory from the server-side read API so the public page stays current without exposing credentials.',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: 'Export hidden inventory separately',
|
||||
detail: 'Add a distinct capture-required section for hidden or operator-only hosts so the public page stays readable while still exhaustive.',
|
||||
},
|
||||
{
|
||||
priority: 'P2',
|
||||
title: 'Add CSV / JSON export buttons',
|
||||
detail: 'Allow the atlas to feed handoff, audit, and escalation workflows without copy/paste.',
|
||||
},
|
||||
{
|
||||
priority: 'P3',
|
||||
title: 'Add route health badges',
|
||||
detail: 'Annotate route rows with health and 404/5xx checks once the checkers are wired to the public edge.',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
async function loadLiveSummary() {
|
||||
const base = itReadApiBaseUrl();
|
||||
const apiKey = itReadApiKey();
|
||||
const readApiConfigured = Boolean(base);
|
||||
|
||||
if (!base) {
|
||||
return {
|
||||
collectedAt: undefined,
|
||||
guestCount: null,
|
||||
runningCount: null,
|
||||
stoppedCount: null,
|
||||
portMapState: 'unconfigured',
|
||||
portMapNote: 'IT_READ_API_URL is not configured on the portal server.',
|
||||
inventoryState: 'unconfigured',
|
||||
summaryState: 'unconfigured',
|
||||
readApiConfigured,
|
||||
};
|
||||
}
|
||||
|
||||
const root = base.replace(/\/$/, '');
|
||||
const [summary, inventory, portMap] = await Promise.all([
|
||||
fetchJson(`${root}/v1/summary`, apiKey),
|
||||
fetchJson(`${root}/v1/inventory/live`, apiKey),
|
||||
fetchJson(`${root}/v1/portmap/joined`, apiKey),
|
||||
]);
|
||||
|
||||
const summaryRecord = asRecord(summary);
|
||||
const inventoryRecord = asRecord(inventory);
|
||||
const portMapRecord = asRecord(portMap);
|
||||
const guestRows = buildGuestRows(inventoryRecord ?? inventory);
|
||||
|
||||
const liveSummary = summaryRecord && 'artifacts' in summaryRecord ? (summaryRecord.artifacts as RecordLike) : null;
|
||||
|
||||
return {
|
||||
collectedAt:
|
||||
toText(summaryRecord?.envelope_at ?? summaryRecord?.live_collected_at ?? summaryRecord?.drift_collected_at, undefined),
|
||||
guestCount: typeof summaryRecord?.guest_count === 'number' ? summaryRecord.guest_count : guestRows.length,
|
||||
runningCount:
|
||||
typeof inventoryRecord?.running === 'number'
|
||||
? inventoryRecord.running
|
||||
: typeof summaryRecord?.guest_count === 'number'
|
||||
? summaryRecord.guest_count
|
||||
: null,
|
||||
stoppedCount:
|
||||
typeof inventoryRecord?.stopped === 'number' ? inventoryRecord.stopped : null,
|
||||
portMapState: portMapRecord?.stale ? 'stale' : 'live',
|
||||
portMapNote: toText(portMapRecord?.note, undefined),
|
||||
inventoryState: inventory ? 'live' : 'unavailable',
|
||||
summaryState: summary ? 'live' : 'unavailable',
|
||||
readApiConfigured,
|
||||
// keep the snapshot summary available if the UI wants to expose it later
|
||||
// without another fetch path.
|
||||
...(liveSummary ? { liveArtifacts: liveSummary } : {}),
|
||||
} as AtlasSnapshot['liveSummary'] & { liveArtifacts?: RecordLike };
|
||||
}
|
||||
|
||||
export async function loadAtlasSnapshot(): Promise<AtlasSnapshot> {
|
||||
const liveSummary = await loadLiveSummary();
|
||||
const routes = buildRoutes();
|
||||
const hiddenRows = buildHiddenRows();
|
||||
const revisions = buildRevisions();
|
||||
const recommendations = buildRecommendations();
|
||||
|
||||
const liveInventory = await (async () => {
|
||||
const base = itReadApiBaseUrl();
|
||||
const apiKey = itReadApiKey();
|
||||
if (!base) return null;
|
||||
return fetchJson(`${base.replace(/\/$/, '')}/v1/inventory/live`, apiKey);
|
||||
})();
|
||||
|
||||
return {
|
||||
routeRows: routes,
|
||||
guestRows: buildGuestRows(liveInventory),
|
||||
hiddenRows,
|
||||
revisions,
|
||||
recommendations,
|
||||
liveSummary,
|
||||
};
|
||||
}
|
||||
@@ -68,6 +68,7 @@ export const corporateNav: CorporateNavItem[] = [
|
||||
{ label: 'Institutional grade', href: '#institutional-grade' },
|
||||
{ label: 'Compliance', href: '#institutional-compliance' },
|
||||
{ label: 'Resources', href: '#resources' },
|
||||
{ label: 'Atlas', href: '/atlas' },
|
||||
];
|
||||
|
||||
export const productDivisions: ProductDivision[] = [
|
||||
|
||||
Reference in New Issue
Block a user