192 lines
5.7 KiB
TypeScript
192 lines
5.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
OperationsNavId,
|
|
OperatorSession,
|
|
OperationsHealth,
|
|
SafeAuditEvent,
|
|
} from './types';
|
|
import { OperationsTopBar } from './operations-top-bar';
|
|
import { OperationsSidebar } from './operations-sidebar';
|
|
import { OperationsOverview } from './operations-overview';
|
|
import { OperationsAudit } from './operations-audit';
|
|
import { OperationsOperators } from './operations-operators';
|
|
import { OperationsSessionView } from './operations-session';
|
|
|
|
interface OperationsShellProps {
|
|
initialSession: OperatorSession;
|
|
initialHealth: OperationsHealth;
|
|
initialAudit?: {
|
|
events: SafeAuditEvent[];
|
|
next_cursor?: string | null;
|
|
total?: number;
|
|
};
|
|
initialNav?: OperationsNavId;
|
|
}
|
|
|
|
export function OperationsShell({
|
|
initialSession,
|
|
initialHealth,
|
|
initialAudit,
|
|
initialNav = 'overview',
|
|
}: OperationsShellProps) {
|
|
const [session, setSession] = useState<OperatorSession>(initialSession);
|
|
const [activeNav, setActiveNav] = useState<OperationsNavId>(initialNav);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
|
|
// Health state
|
|
const [health, setHealth] = useState<OperationsHealth>(initialHealth);
|
|
const [isRefreshingHealth, setIsRefreshingHealth] = useState(false);
|
|
|
|
// Audit state
|
|
const [auditEvents, setAuditEvents] = useState<SafeAuditEvent[]>(
|
|
initialAudit?.events ?? []
|
|
);
|
|
const [nextCursor, setNextCursor] = useState<string | null>(
|
|
initialAudit?.next_cursor ?? null
|
|
);
|
|
const [auditTotal, setAuditTotal] = useState<number | undefined>(
|
|
initialAudit?.total
|
|
);
|
|
const [isLoadingAudit, setIsLoadingAudit] = useState(false);
|
|
const [isLoadingMoreAudit, setIsLoadingMoreAudit] = useState(false);
|
|
|
|
// Sign out state
|
|
const [signingOut, setSigningOut] = useState(false);
|
|
|
|
// Manual Health Refresh (Zero automatic polling)
|
|
async function handleRefreshHealth() {
|
|
if (isRefreshingHealth) return;
|
|
setIsRefreshingHealth(true);
|
|
try {
|
|
const response = await fetch('/api/ops/health', {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
if (response.ok) {
|
|
const data = (await response.json()) as OperationsHealth;
|
|
setHealth(data);
|
|
} else {
|
|
setHealth((prev: OperationsHealth) => ({
|
|
...prev,
|
|
status: 'unavailable',
|
|
checked_at: new Date().toISOString(),
|
|
}));
|
|
}
|
|
} catch {
|
|
setHealth((prev: OperationsHealth) => ({
|
|
...prev,
|
|
status: 'unavailable',
|
|
checked_at: new Date().toISOString(),
|
|
}));
|
|
} finally {
|
|
setIsRefreshingHealth(false);
|
|
}
|
|
}
|
|
|
|
// Next Page of Audit Events (Owner only, server cursor)
|
|
async function handleLoadNextAuditPage() {
|
|
if (!nextCursor || isLoadingMoreAudit) return;
|
|
setIsLoadingMoreAudit(true);
|
|
try {
|
|
const response = await fetch(
|
|
`/api/ops/audit-events?cursor=${encodeURIComponent(nextCursor)}`,
|
|
{
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
}
|
|
);
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
setAuditEvents((prev) => [...prev, ...(data.events ?? [])]);
|
|
setNextCursor(data.next_cursor ?? null);
|
|
if (typeof data.total === 'number') {
|
|
setAuditTotal(data.total);
|
|
}
|
|
}
|
|
} catch {
|
|
// Retain current events on failure
|
|
} finally {
|
|
setIsLoadingMoreAudit(false);
|
|
}
|
|
}
|
|
|
|
// Sign Out Handler
|
|
async function handleSignOut() {
|
|
if (signingOut) return;
|
|
setSigningOut(true);
|
|
try {
|
|
await fetch('/api/ops/auth/logout', {
|
|
method: 'POST',
|
|
headers: {
|
|
origin: window.location.origin,
|
|
},
|
|
});
|
|
} catch {
|
|
// Continue client cleanup regardless of network error
|
|
} finally {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary-container selection:text-on-primary-container">
|
|
{/* Sidebar (Desktop persistent + Mobile off-canvas drawer) */}
|
|
<OperationsSidebar
|
|
activeNav={activeNav}
|
|
onSelectNav={(nav) => setActiveNav(nav)}
|
|
isOpenOnMobile={mobileSidebarOpen}
|
|
onCloseMobile={() => setMobileSidebarOpen(false)}
|
|
role={session.role}
|
|
/>
|
|
|
|
{/* Main Workspace Area */}
|
|
<div className="lg:pl-64 flex flex-col min-h-screen">
|
|
{/* Top App Bar */}
|
|
<OperationsTopBar
|
|
session={session}
|
|
onSignOut={handleSignOut}
|
|
onToggleMobileSidebar={() => setMobileSidebarOpen(!mobileSidebarOpen)}
|
|
signingOut={signingOut}
|
|
/>
|
|
|
|
{/* Content Body Container */}
|
|
<main className="relative pt-20 px-4 sm:px-6 py-6 w-full max-w-[1440px] mx-auto flex-1">
|
|
{activeNav === 'overview' && (
|
|
<OperationsOverview
|
|
health={health}
|
|
onRefresh={handleRefreshHealth}
|
|
isRefreshing={isRefreshingHealth}
|
|
/>
|
|
)}
|
|
|
|
{activeNav === 'audit' && (
|
|
<OperationsAudit
|
|
role={session.role}
|
|
events={auditEvents}
|
|
nextCursor={nextCursor}
|
|
onNextPage={handleLoadNextAuditPage}
|
|
isLoading={isLoadingAudit}
|
|
isLoadingMore={isLoadingMoreAudit}
|
|
total={auditTotal}
|
|
/>
|
|
)}
|
|
|
|
{activeNav === 'operators' && <OperationsOperators />}
|
|
|
|
{activeNav === 'session' && (
|
|
<OperationsSessionView
|
|
session={session}
|
|
onSignOut={handleSignOut}
|
|
signingOut={signingOut}
|
|
/>
|
|
)}
|
|
</main>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|