gmal-scope-builder/frontend/src/App.tsx
Vadym Samoilenko dbbef4972b Fix blocking JWKS fetch causing 504s + app-only logout
- auth.py: replace synchronous httpx.get (blocked event loop) with
  async httpx.AsyncClient; add key-rotation refresh on unknown kid
- App.tsx: use onRedirectNavigate: false so Sign out clears only the
  local MSAL session without redirecting to Microsoft logout endpoint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 11:11:46 +01:00

242 lines
8.4 KiB
TypeScript

import { BrowserRouter, Routes, Route, Link, useLocation } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useMsal } from '@azure/msal-react';
import { AuthProvider } from './auth/AuthProvider';
import api from './api/client';
import Dashboard from './pages/Dashboard';
import NewProject from './pages/NewProject';
import ProjectView from './pages/ProjectView';
import GmalBrowser from './pages/GmalBrowser';
import Help from './pages/Help';
import GmalEditor from './pages/GmalEditor';
import './App.css';
const navItems = [
{ path: '/', label: 'Projects' },
{ path: '/gmal', label: 'GMAL Browser' },
{ path: '/gmal-editor', label: 'GMAL Editor' },
{ path: '/help', label: 'Help' },
];
interface AiUsage {
total_input_tokens: number;
total_output_tokens: number;
total_cost_usd: number;
call_count: number;
}
interface DebugEntry {
timestamp: string;
model: string;
system_prompt: string;
user_message_length: number;
user_message_preview: string;
tools: string[];
tool_choice: any;
status: string;
stop_reason?: string;
response_parts?: Array<{ type: string; text?: string; tool?: string; input_preview?: string }>;
tool_results_count?: number;
input_tokens?: number;
output_tokens?: number;
cost_usd?: number;
error?: string;
}
function AiCostTracker() {
const [usage, setUsage] = useState<AiUsage | null>(null);
useEffect(() => {
loadUsage();
const interval = setInterval(loadUsage, 5000);
return () => clearInterval(interval);
}, []);
async function loadUsage() {
try {
const res = await api.get('/ai/usage');
setUsage(res.data);
} catch {}
}
if (!usage) return null;
return (
<div className="ai-tracker">
<div className="ai-tracker-label">AI Cost</div>
<div className="ai-tracker-cost">${usage.total_cost_usd.toFixed(4)}</div>
<div className="ai-tracker-detail">
{usage.call_count} calls &middot; {(usage.total_input_tokens / 1000).toFixed(1)}k in &middot; {(usage.total_output_tokens / 1000).toFixed(1)}k out
</div>
</div>
);
}
function DebugPanel() {
const [open, setOpen] = useState(false);
const [entries, setEntries] = useState<DebugEntry[]>([]);
const [expanded, setExpanded] = useState<number | null>(null);
useEffect(() => {
if (open) {
loadDebug();
const interval = setInterval(loadDebug, 3000);
return () => clearInterval(interval);
}
}, [open]);
async function loadDebug() {
try {
const res = await api.get('/ai/debug');
setEntries(res.data);
} catch {}
}
return (
<div className={`debug-panel ${open ? 'debug-open' : ''}`}>
<button className="debug-toggle" onClick={() => setOpen(!open)}>
{open ? 'Hide' : 'Show'} AI Debug ({entries.length})
</button>
{open && (
<div className="debug-content">
{entries.length === 0 ? (
<div className="debug-empty">No AI calls yet. Upload a document or run matching.</div>
) : (
[...entries].reverse().map((e, idx) => {
const realIdx = entries.length - 1 - idx;
const isExpanded = expanded === realIdx;
return (
<div key={realIdx} className={`debug-entry debug-${e.status}`}>
<div className="debug-entry-header" onClick={() => setExpanded(isExpanded ? null : realIdx)}>
<span className={`debug-status debug-status-${e.status}`}>
{e.status === 'success' ? 'OK' : 'ERR'}
</span>
<span className="debug-time">{new Date(e.timestamp + 'Z').toLocaleTimeString()}</span>
<span className="debug-tools">{e.tools.join(', ') || 'no tools'}</span>
{e.input_tokens != null && (
<span className="debug-tokens">
{e.input_tokens} in / {e.output_tokens} out &middot; ${e.cost_usd?.toFixed(4)}
</span>
)}
<span className="debug-reason">{e.stop_reason || ''}</span>
<span className="debug-expand">{isExpanded ? '▼' : '▶'}</span>
</div>
{isExpanded && (
<div className="debug-entry-body">
<div className="debug-section">
<div className="debug-label">System Prompt</div>
<pre className="debug-pre">{e.system_prompt}</pre>
</div>
<div className="debug-section">
<div className="debug-label">User Message ({e.user_message_length.toLocaleString()} chars)</div>
<pre className="debug-pre">{e.user_message_preview}</pre>
</div>
{e.tool_choice && (
<div className="debug-section">
<div className="debug-label">Tool Choice</div>
<pre className="debug-pre">{JSON.stringify(e.tool_choice)}</pre>
</div>
)}
{e.response_parts && e.response_parts.length > 0 && (
<div className="debug-section">
<div className="debug-label">Response ({e.response_parts.length} parts)</div>
{e.response_parts.map((part, pi) => (
<div key={pi} className="debug-response-part">
<span className="debug-part-type">{part.type}</span>
{part.type === 'text' && <pre className="debug-pre">{part.text}</pre>}
{part.type === 'tool_use' && (
<div>
<div className="debug-tool-name">Tool: {part.tool}</div>
<pre className="debug-pre">{part.input_preview}</pre>
</div>
)}
</div>
))}
</div>
)}
{e.error && (
<div className="debug-section">
<div className="debug-label">Error</div>
<pre className="debug-pre debug-error-text">{e.error}</pre>
</div>
)}
</div>
)}
</div>
);
})
)}
</div>
)}
</div>
);
}
function NavBar() {
const location = useLocation();
const { instance, accounts } = useMsal();
const user = accounts[0];
function handleLogout() {
// Sign out from the app only — does not sign out of the Microsoft account
instance.logoutRedirect({ onRedirectNavigate: () => false });
}
return (
<nav className="nav">
<div className="nav-inner">
<Link to="/" className="logo">
<span className="logo-icon">S</span>
Scope Builder
</Link>
<div className="nav-links">
{navItems.map(item => (
<Link
key={item.path}
to={item.path}
className={`nav-link ${location.pathname === item.path ? 'nav-link-active' : ''}`}
>
{item.label}
</Link>
))}
</div>
<div className="nav-spacer" />
<AiCostTracker />
{user && (
<div className="nav-user">
<span className="nav-user-name">{user.name || user.username}</span>
<button className="nav-logout" onClick={handleLogout}>Sign out</button>
</div>
)}
</div>
</nav>
);
}
export default function App() {
return (
<AuthProvider>
<BrowserRouter basename="/gsb">
<div className="app">
<NavBar />
<main className="main">
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/new" element={<NewProject />} />
<Route path="/projects/:id/*" element={<ProjectView />} />
<Route path="/gmal" element={<GmalBrowser />} />
<Route path="/gmal-editor" element={<GmalEditor />} />
<Route path="/help" element={<Help />} />
</Routes>
</main>
<DebugPanel />
</div>
</BrowserRouter>
</AuthProvider>
);
}