MotorSignal-App/website/components/ops/operations-shell.tsx

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>
);
}