308 lines
9.7 KiB
TypeScript
308 lines
9.7 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState } from 'react';
|
|
import {
|
|
OperationsNavId,
|
|
OperatorSession,
|
|
OperationsHealth,
|
|
SafeAuditEvent,
|
|
OperationsOverviewData,
|
|
} 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';
|
|
import { OperationsIncidentsList } from './operations-incidents-list';
|
|
import { OperationsCreateIncident } from './operations-create-incident';
|
|
import { OperationsIncidentDetail } from './operations-incident-detail';
|
|
import { OperationsUsers } from './operations-users';
|
|
import { OperationsSupport } from './operations-support';
|
|
import { OperationsCms } from './operations-cms';
|
|
import { OperationsInfrastructure } from './operations-infrastructure';
|
|
|
|
|
|
interface OperationsShellProps {
|
|
initialSession: OperatorSession;
|
|
initialHealth: OperationsHealth;
|
|
initialOverview?: OperationsOverviewData | null;
|
|
initialAudit?: {
|
|
events: SafeAuditEvent[];
|
|
next_cursor?: string | null;
|
|
total?: number;
|
|
};
|
|
initialNav?: OperationsNavId;
|
|
initialCaseRef?: string | null;
|
|
}
|
|
|
|
export function OperationsShell({
|
|
initialSession,
|
|
initialHealth,
|
|
initialOverview = null,
|
|
initialAudit,
|
|
initialNav = 'overview',
|
|
initialCaseRef = null,
|
|
}: OperationsShellProps) {
|
|
const [session, setSession] = useState<OperatorSession>(initialSession);
|
|
const [activeNav, setActiveNav] = useState<OperationsNavId>(initialNav);
|
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
|
|
|
// Incidents navigation sub-state
|
|
const [incidentView, setIncidentView] = useState<'list' | 'create' | 'detail'>(
|
|
initialCaseRef ? 'detail' : 'list'
|
|
);
|
|
const [selectedCaseRef, setSelectedCaseRef] = useState<string | null>(initialCaseRef);
|
|
|
|
// Overview state
|
|
const [overviewData, setOverviewData] = useState<OperationsOverviewData | null>(initialOverview);
|
|
const [isRefreshingOverview, setIsRefreshingOverview] = 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);
|
|
}
|
|
}
|
|
|
|
// Manual Overview Refresh (Zero automatic polling)
|
|
async function handleRefreshOverview() {
|
|
if (isRefreshingOverview) return;
|
|
setIsRefreshingOverview(true);
|
|
try {
|
|
const response = await fetch('/api/ops/overview', {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
if (response.ok) {
|
|
const data = (await response.json()) as OperationsOverviewData;
|
|
setOverviewData(data);
|
|
if (data.health) {
|
|
setHealth(data.health);
|
|
}
|
|
}
|
|
} catch {
|
|
// Retain current state on failure
|
|
} finally {
|
|
setIsRefreshingOverview(false);
|
|
}
|
|
}
|
|
|
|
// Single initial fetch on mount (Zero interval polling)
|
|
React.useEffect(() => {
|
|
void handleRefreshOverview();
|
|
}, []);
|
|
|
|
|
|
// 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="ops-shell-root min-h-screen bg-surface font-body text-sm text-on-surface antialiased selection:bg-primary-container selection:text-on-primary-container w-full">
|
|
{/* 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 lg:px-8 py-8 w-full flex-1">
|
|
{activeNav === 'overview' && (
|
|
<OperationsOverview
|
|
overview={overviewData}
|
|
health={overviewData?.health ?? health}
|
|
onRefresh={async () => {
|
|
await Promise.all([handleRefreshHealth(), handleRefreshOverview()]);
|
|
}}
|
|
isRefreshing={isRefreshingHealth || isRefreshingOverview}
|
|
onSelectIncident={(caseRef) => {
|
|
setSelectedCaseRef(caseRef);
|
|
setIncidentView('detail');
|
|
setActiveNav('incidents');
|
|
}}
|
|
onNavigateNav={(nav) => setActiveNav(nav)}
|
|
role={session.role}
|
|
operatorIdentity={session.identity}
|
|
/>
|
|
)}
|
|
|
|
{activeNav === 'infrastructure' && (
|
|
<OperationsInfrastructure role={session.role} />
|
|
)}
|
|
|
|
{activeNav === 'users' && (
|
|
<OperationsUsers role={session.role} />
|
|
)}
|
|
|
|
{activeNav === 'incidents' && (
|
|
<>
|
|
{incidentView === 'list' && (
|
|
<OperationsIncidentsList
|
|
role={session.role}
|
|
onSelectIncident={(caseRef) => {
|
|
setSelectedCaseRef(caseRef);
|
|
setIncidentView('detail');
|
|
}}
|
|
onCreateIncident={() => setIncidentView('create')}
|
|
/>
|
|
)}
|
|
|
|
{incidentView === 'create' && (
|
|
<OperationsCreateIncident
|
|
onIncidentCreated={(caseRef) => {
|
|
setSelectedCaseRef(caseRef);
|
|
setIncidentView('detail');
|
|
}}
|
|
onCancel={() => setIncidentView('list')}
|
|
/>
|
|
)}
|
|
|
|
{incidentView === 'detail' && selectedCaseRef && (
|
|
<OperationsIncidentDetail
|
|
caseReference={selectedCaseRef}
|
|
role={session.role}
|
|
onBack={() => {
|
|
setSelectedCaseRef(null);
|
|
setIncidentView('list');
|
|
}}
|
|
/>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeNav === 'support' && (
|
|
<OperationsSupport role={session.role} session={session} />
|
|
)}
|
|
|
|
{activeNav === 'cms' && (
|
|
<OperationsCms role={session.role} />
|
|
)}
|
|
|
|
{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>
|
|
);
|
|
}
|