feat(portal): IT ops /it console and read API proxy
Some checks failed
CD Pipeline / Deploy to Staging (push) Failing after 5s
CI Pipeline / Lint and Type Check (push) Failing after 4s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 29s
CI Pipeline / Test Frontend (push) Failing after 4s
CI Pipeline / Security Scan (push) Failing after 56s
Deploy to Staging / Deploy to Staging (push) Failing after 10s
Portal CI / Portal Lint (push) Failing after 3s
Portal CI / Portal Type Check (push) Failing after 3s
Portal CI / Portal Test (push) Failing after 4s
Portal CI / Portal Build (push) Failing after 4s
Test Suite / frontend-tests (push) Failing after 8s
Test Suite / api-tests (push) Failing after 8s
CD Pipeline / Deploy to Production (push) Has been cancelled
Test Suite / blockchain-tests (push) Has been cancelled
Type Check / type-check (map[directory:api name:api]) (push) Has been cancelled
Type Check / type-check (map[directory:portal name:portal]) (push) Has been cancelled
Type Check / type-check (map[directory:. name:root]) (push) Has been cancelled
Some checks failed
CD Pipeline / Deploy to Staging (push) Failing after 5s
CI Pipeline / Lint and Type Check (push) Failing after 4s
CI Pipeline / Build (push) Has been skipped
CI Pipeline / Test Backend (push) Failing after 29s
CI Pipeline / Test Frontend (push) Failing after 4s
CI Pipeline / Security Scan (push) Failing after 56s
Deploy to Staging / Deploy to Staging (push) Failing after 10s
Portal CI / Portal Lint (push) Failing after 3s
Portal CI / Portal Type Check (push) Failing after 3s
Portal CI / Portal Test (push) Failing after 4s
Portal CI / Portal Build (push) Failing after 4s
Test Suite / frontend-tests (push) Failing after 8s
Test Suite / api-tests (push) Failing after 8s
CD Pipeline / Deploy to Production (push) Has been cancelled
Test Suite / blockchain-tests (push) Has been cancelled
Type Check / type-check (map[directory:api name:api]) (push) Has been cancelled
Type Check / type-check (map[directory:portal name:portal]) (push) Has been cancelled
Type Check / type-check (map[directory:. name:root]) (push) Has been cancelled
- Role-gated /it page with drift summary and refresh - Server routes /api/it/drift, inventory, refresh (IT_READ_API_* env) - Propagate credentials user.role into JWT roles for bootstrap - Dashboard card for IT roles; document env in .env.example Made-with: Cursor
This commit is contained in:
29
portal/src/app/api/it/_auth.ts
Normal file
29
portal/src/app/api/it/_auth.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { getServerSession } from 'next-auth';
|
||||
|
||||
import { authOptions } from '@/lib/auth';
|
||||
import { sessionHasItOpsRole } from '@/lib/it-ops-roles';
|
||||
|
||||
export type ItSession = {
|
||||
roles?: string[];
|
||||
} | null;
|
||||
|
||||
export async function requireItOpsSession(): Promise<ItSession> {
|
||||
const session = (await getServerSession(authOptions)) as ItSession;
|
||||
if (!session || !sessionHasItOpsRole(session.roles)) {
|
||||
return null;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
function readEnv(name: string): string | undefined {
|
||||
const v = process.env[name];
|
||||
return typeof v === 'string' && v.trim() !== '' ? v.trim() : undefined;
|
||||
}
|
||||
|
||||
export function itReadApiBaseUrl(): string | undefined {
|
||||
return readEnv('IT_READ_API_URL');
|
||||
}
|
||||
|
||||
export function itReadApiKey(): string | undefined {
|
||||
return readEnv('IT_READ_API_KEY');
|
||||
}
|
||||
40
portal/src/app/api/it/drift/route.ts
Normal file
40
portal/src/app/api/it/drift/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth';
|
||||
|
||||
export async function GET() {
|
||||
const session = await requireItOpsSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const base = itReadApiBaseUrl();
|
||||
if (!base) {
|
||||
return NextResponse.json(
|
||||
{ message: 'IT_READ_API_URL is not configured on the portal server' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${base.replace(/\/$/, '')}/v1/inventory/drift`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const key = itReadApiKey();
|
||||
if (key) {
|
||||
headers['X-API-Key'] = key;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers, cache: 'no-store' });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Upstream drift fetch failed', status: res.status, body: text.slice(0, 2000) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text) as unknown;
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
40
portal/src/app/api/it/inventory/route.ts
Normal file
40
portal/src/app/api/it/inventory/route.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth';
|
||||
|
||||
export async function GET() {
|
||||
const session = await requireItOpsSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const base = itReadApiBaseUrl();
|
||||
if (!base) {
|
||||
return NextResponse.json(
|
||||
{ message: 'IT_READ_API_URL is not configured on the portal server' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${base.replace(/\/$/, '')}/v1/inventory/live`;
|
||||
const headers: Record<string, string> = { Accept: 'application/json' };
|
||||
const key = itReadApiKey();
|
||||
if (key) {
|
||||
headers['X-API-Key'] = key;
|
||||
}
|
||||
|
||||
const res = await fetch(url, { headers, cache: 'no-store' });
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Upstream inventory fetch failed', status: res.status, body: text.slice(0, 2000) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const data = JSON.parse(text) as unknown;
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
49
portal/src/app/api/it/refresh/route.ts
Normal file
49
portal/src/app/api/it/refresh/route.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
import { itReadApiBaseUrl, itReadApiKey, requireItOpsSession } from '@/app/api/it/_auth';
|
||||
|
||||
export async function POST() {
|
||||
const session = await requireItOpsSession();
|
||||
if (!session) {
|
||||
return NextResponse.json({ message: 'Forbidden' }, { status: 403 });
|
||||
}
|
||||
|
||||
const base = itReadApiBaseUrl();
|
||||
const key = itReadApiKey();
|
||||
if (!base) {
|
||||
return NextResponse.json(
|
||||
{ message: 'IT_READ_API_URL is not configured on the portal server' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
if (!key) {
|
||||
return NextResponse.json(
|
||||
{ message: 'IT_READ_API_KEY is required for refresh (server-side only)' },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const url = `${base.replace(/\/$/, '')}/v1/inventory/refresh`;
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-API-Key': key,
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ message: 'Upstream refresh failed', status: res.status, body: text.slice(0, 4000) },
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
try {
|
||||
const data = text ? (JSON.parse(text) as unknown) : {};
|
||||
return NextResponse.json(data);
|
||||
} catch {
|
||||
return NextResponse.json({ message: 'Invalid JSON from IT read API' }, { status: 502 });
|
||||
}
|
||||
}
|
||||
191
portal/src/app/it/page.tsx
Normal file
191
portal/src/app/it/page.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { RoleGate } from '@/components/auth/RoleGate';
|
||||
import { IT_OPS_ALLOWED_ROLES } from '@/lib/it-ops-roles';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card';
|
||||
|
||||
type DriftShape = {
|
||||
collected_at?: string;
|
||||
guest_count?: number;
|
||||
duplicate_ips?: Record<string, string[]>;
|
||||
guest_lan_ips_not_in_declared_sources?: string[];
|
||||
declared_lan11_ips_not_on_live_guests?: string[];
|
||||
vmid_ip_mismatch_live_vs_all_vmids_doc?: Array<{ vmid: string; live_ip: string; all_vmids_doc_ip: string }>;
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
function hoursSinceIso(iso: string | undefined): number | null {
|
||||
if (!iso) return null;
|
||||
const t = Date.parse(iso);
|
||||
if (Number.isNaN(t)) return null;
|
||||
return (Date.now() - t) / (1000 * 60 * 60);
|
||||
}
|
||||
|
||||
export default function ItOpsPage() {
|
||||
const [drift, setDrift] = useState<DriftShape | null>(null);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/it/drift', { cache: 'no-store' });
|
||||
const j = (await r.json()) as DriftShape & { message?: string };
|
||||
if (!r.ok) {
|
||||
setErr(j.message || `HTTP ${r.status}`);
|
||||
setDrift(null);
|
||||
return;
|
||||
}
|
||||
setDrift(j);
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Request failed');
|
||||
setDrift(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const staleHours = useMemo(() => hoursSinceIso(drift?.collected_at), [drift?.collected_at]);
|
||||
const stale = staleHours !== null && staleHours > 24;
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const r = await fetch('/api/it/refresh', { method: 'POST' });
|
||||
const j = (await r.json()) as { message?: string };
|
||||
if (!r.ok) {
|
||||
setErr(j.message || `Refresh HTTP ${r.status}`);
|
||||
setRefreshing(false);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
} catch (e) {
|
||||
setErr(e instanceof Error ? e.message : 'Refresh failed');
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const dupCount = drift?.duplicate_ips ? Object.keys(drift.duplicate_ips).length : 0;
|
||||
|
||||
return (
|
||||
<RoleGate
|
||||
allowedRoles={[...IT_OPS_ALLOWED_ROLES]}
|
||||
callbackUrl="/it"
|
||||
badge="IT Ops"
|
||||
title="IT operations"
|
||||
subtitle="Sign in with an account that has the sankofa-it-admin realm role (or operator ADMIN for bootstrap)."
|
||||
>
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-white">IT inventory & drift</h1>
|
||||
<p className="text-gray-400 text-sm mt-1">
|
||||
Data from proxmox read API (Phase 0). Configure{' '}
|
||||
<code className="text-gray-300">IT_READ_API_URL</code> on the portal host.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onRefresh()}
|
||||
disabled={refreshing}
|
||||
className="inline-flex h-10 items-center justify-center rounded-md bg-orange-600 px-4 text-sm font-medium text-white hover:bg-orange-500 disabled:opacity-50"
|
||||
>
|
||||
{refreshing ? 'Refreshing…' : 'Refresh inventory'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{err && (
|
||||
<div className="mb-6 rounded-lg border border-red-800 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <p className="text-gray-400">Loading drift…</p>}
|
||||
|
||||
{!loading && drift && (
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Freshness</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>
|
||||
<span className="text-gray-500">collected_at:</span>{' '}
|
||||
{drift.collected_at || '—'}
|
||||
</p>
|
||||
{stale && (
|
||||
<p className="text-amber-400">
|
||||
Snapshot is older than 24h — run export on LAN or use Refresh (requires API key on server).
|
||||
</p>
|
||||
)}
|
||||
{!stale && staleHours !== null && (
|
||||
<p className="text-green-400">Within 24h window ({Math.round(staleHours)}h ago).</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-gray-800 border-gray-700">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Summary</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-gray-300">
|
||||
<p>Guests (live): {drift.guest_count ?? '—'}</p>
|
||||
<p>Duplicate guest IPs: {dupCount}</p>
|
||||
<p>
|
||||
LAN guests not in declared sources:{' '}
|
||||
{drift.guest_lan_ips_not_in_declared_sources?.length ?? 0}
|
||||
</p>
|
||||
<p>
|
||||
Declared LAN11 not on live guests:{' '}
|
||||
{drift.declared_lan11_ips_not_on_live_guests?.length ?? 0}
|
||||
</p>
|
||||
<p>
|
||||
VMID IP mismatch (live vs ALL_VMIDS doc):{' '}
|
||||
{drift.vmid_ip_mismatch_live_vs_all_vmids_doc?.length ?? 0}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{dupCount > 0 && (
|
||||
<Card className="bg-gray-800 border-red-900 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-red-400">Duplicate IPs (fix on cluster)</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<pre className="text-xs text-gray-300 overflow-x-auto">
|
||||
{JSON.stringify(drift.duplicate_ips, null, 2)}
|
||||
</pre>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{(drift.notes?.length ?? 0) > 0 && (
|
||||
<Card className="bg-gray-800 border-gray-700 md:col-span-2">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-white">Notes</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="list-disc pl-5 text-sm text-gray-300 space-y-1">
|
||||
{drift.notes!.map((n) => (
|
||||
<li key={n}>{n}</li>
|
||||
))}
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</RoleGate>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user