317 lines
11 KiB
TypeScript
317 lines
11 KiB
TypeScript
import { useState, useEffect, Suspense, lazy } from 'react'
|
|
import WorkflowBoard from './components/WorkflowBoard'
|
|
import SecretsZone, { MOCK_SECTIONS } from './components/SecretsZone'
|
|
import WorkflowBuilder from './components/WorkflowBuilder'
|
|
import GatesDrawer from './components/GatesDrawer'
|
|
import GateDrawer from './components/GateDrawer'
|
|
import LogDrawer from './components/LogDrawer'
|
|
import CommandPalette from './components/CommandPalette'
|
|
import TierGate from './components/TierGate'
|
|
import InfraRegistry from './components/InfraRegistry'
|
|
import { ToastProvider, useToast } from './components/ToastProvider'
|
|
import { useWorkflows } from './hooks/useWorkflows'
|
|
import { useWebSocket } from './hooks/useWebSocket'
|
|
import { useBrainStore } from './store/brain.store'
|
|
import { useTier } from './hooks/useTier'
|
|
|
|
const CosmosView = lazy(() => import('./components/cosmos/CosmosView'))
|
|
const WorkspaceView = lazy(() => import('./components/workspace/WorkspaceView'))
|
|
type ActiveView = 'workflows' | 'builder' | 'secrets' | 'infra' | 'cosmos' | 'workspace'
|
|
|
|
interface NavItem {
|
|
id: ActiveView
|
|
icon: string
|
|
label: string
|
|
separator?: boolean
|
|
}
|
|
|
|
interface PendingGate {
|
|
workflowId: string
|
|
stepId: string
|
|
stepLabel: string
|
|
}
|
|
|
|
const NAV_ITEMS: NavItem[] = [
|
|
{ id: 'cosmos', icon: '🌌', label: 'Cosmos' },
|
|
]
|
|
|
|
function AppInner() {
|
|
const { addToast } = useToast()
|
|
|
|
// Detect URL path for direct routing (/ui/docs → docs view)
|
|
const initialView = (): ActiveView => {
|
|
const path = window.location.pathname
|
|
// /docs → redirige vers docs.html (page standalone)
|
|
if (path.includes('/cosmos')) return 'cosmos'
|
|
if (path.includes('/workspace')) return 'workspace'
|
|
return 'workflows'
|
|
}
|
|
const [activeView, setActiveView] = useState<ActiveView>(initialView)
|
|
const [pendingGate, setPendingGate] = useState<PendingGate | null>(null)
|
|
const [gateDrawer, setGateDrawer] = useState<{ open: boolean; workflowId: string | null; stepId: string | null }>({
|
|
open: false,
|
|
workflowId: null,
|
|
stepId: null,
|
|
})
|
|
const [logsProject, setLogsProject] = useState<string | null>(null)
|
|
const [paletteOpen, setPaletteOpen] = useState(false)
|
|
|
|
// Sync URL with active view
|
|
const handleViewChange = (view: ActiveView) => {
|
|
setActiveView(view)
|
|
const base = import.meta.env.BASE_URL || '/ui/'
|
|
const slug = view === 'workflows' ? '' : view
|
|
window.history.replaceState(null, '', `${base}${slug}`)
|
|
}
|
|
|
|
const { workflows, wsStatus } = useWorkflows()
|
|
useWebSocket(addToast)
|
|
const storeWorkflows = useBrainStore((s) => s.workflows)
|
|
const { hasFeature, tierInfo } = useTier()
|
|
|
|
useEffect(() => {
|
|
const handler = (e: KeyboardEvent) => {
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
|
|
e.preventDefault()
|
|
setPaletteOpen(true)
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key === 'l') {
|
|
e.preventDefault()
|
|
setLogsProject((prev) => (prev ? null : (storeWorkflows[0]?.id ?? null)))
|
|
}
|
|
}
|
|
window.addEventListener('keydown', handler)
|
|
return () => window.removeEventListener('keydown', handler)
|
|
}, [storeWorkflows])
|
|
|
|
const handleGateApprove = (workflowId: string, stepId: string) => {
|
|
const wf = storeWorkflows.find((w) => w.id === workflowId)
|
|
const step = wf?.steps.find((s) => s.id === stepId)
|
|
const label = step?.label ?? stepId
|
|
setPendingGate({ workflowId, stepId, stepLabel: label })
|
|
setGateDrawer({ open: true, workflowId, stepId })
|
|
}
|
|
|
|
const handleSecretSave = (section: string, key: string, value: string) => {
|
|
console.log(`secret:save — ${section}.${key} (${value.length} chars)`)
|
|
// TODO: appel API brain
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen overflow-hidden" style={{ background: '#0d0d0d', color: '#e5e7eb' }}>
|
|
{/* Sidebar */}
|
|
<aside
|
|
className="flex flex-col flex-shrink-0 border-r"
|
|
style={{ width: 220, background: '#1a1a1a', borderColor: '#2a2a2a' }}
|
|
>
|
|
{/* Header / Logo */}
|
|
<div className="flex items-center gap-2 px-4 py-4 border-b" style={{ borderColor: '#2a2a2a' }}>
|
|
<span className="font-bold text-white tracking-tight text-lg">brain ui</span>
|
|
<span
|
|
className="text-xs px-1.5 py-0.5 rounded font-mono"
|
|
style={{ background: '#2a2a2a', color: '#9ca3af' }}
|
|
>
|
|
v0.2.0
|
|
</span>
|
|
</div>
|
|
|
|
{/* Kernel status */}
|
|
<div className="flex items-center gap-2 px-4 py-2 border-b" style={{ borderColor: '#2a2a2a' }}>
|
|
<span
|
|
className="w-2 h-2 rounded-full flex-shrink-0"
|
|
style={{
|
|
background:
|
|
wsStatus === 'connected' ? '#22c55e' :
|
|
wsStatus === 'error' ? '#ef4444' : '#6b7280',
|
|
}}
|
|
/>
|
|
<span className="text-xs" style={{ color: '#6b7280' }}>
|
|
{wsStatus === 'connected' ? 'kernel connecté' :
|
|
wsStatus === 'error' ? 'kernel erreur' : 'kernel déconnecté'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Navigation */}
|
|
<nav className="flex flex-col gap-0.5 mt-3 px-2">
|
|
{NAV_ITEMS.map((item) => {
|
|
const isActive = activeView === item.id
|
|
return (
|
|
<div key={item.id}>
|
|
{item.separator && (
|
|
<div className="mx-3 my-1" style={{ borderTop: '1px solid #2a2a2a' }} />
|
|
)}
|
|
<button
|
|
onClick={() => handleViewChange(item.id)}
|
|
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left transition-colors w-full"
|
|
style={
|
|
isActive
|
|
? {
|
|
background: 'rgba(99,102,241,0.2)',
|
|
color: '#6366f1',
|
|
borderLeft: '2px solid #6366f1',
|
|
paddingLeft: 10,
|
|
}
|
|
: {
|
|
color: '#9ca3af',
|
|
borderLeft: '2px solid transparent',
|
|
paddingLeft: 10,
|
|
}
|
|
}
|
|
>
|
|
<span className="text-base leading-none">{item.icon}</span>
|
|
<span>{item.label}</span>
|
|
</button>
|
|
</div>
|
|
)
|
|
})}
|
|
</nav>
|
|
|
|
{/* Docs — lien externe standalone */}
|
|
<div className="px-2 mt-2">
|
|
<a
|
|
href="/ui/docs.html"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left w-full transition-colors"
|
|
style={{ color: '#9ca3af', borderLeft: '2px solid transparent', paddingLeft: 10, textDecoration: 'none' }}
|
|
>
|
|
<span className="text-base leading-none">📖</span>
|
|
<span>Docs</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#4b5563' }}>↗</span>
|
|
</a>
|
|
</div>
|
|
|
|
{/* Bouton Logs */}
|
|
<div className="px-2 mt-2">
|
|
<button
|
|
onClick={() => setLogsProject((prev) => (prev ? null : (storeWorkflows[0]?.id ?? 'ambient')))}
|
|
className="flex items-center gap-3 px-3 py-2 rounded text-sm font-medium text-left w-full transition-colors"
|
|
style={
|
|
logsProject
|
|
? {
|
|
background: 'rgba(99,102,241,0.2)',
|
|
color: '#6366f1',
|
|
borderLeft: '2px solid #6366f1',
|
|
paddingLeft: 10,
|
|
}
|
|
: {
|
|
color: '#9ca3af',
|
|
borderLeft: '2px solid transparent',
|
|
paddingLeft: 10,
|
|
}
|
|
}
|
|
>
|
|
<span className="text-base leading-none">📋</span>
|
|
<span>Logs</span>
|
|
<span style={{ marginLeft: 'auto', fontSize: 9, color: '#4b5563', fontFamily: 'monospace' }}>⌘L</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Tier badge — en bas de sidebar avant ⌘K */}
|
|
<div style={{ padding: '4px 16px', color: '#374151', fontSize: 10, fontFamily: 'monospace' }}>
|
|
{tierInfo.tier}
|
|
</div>
|
|
|
|
{/* Cmd+K hint */}
|
|
<div className="mt-auto px-4 py-3 border-t" style={{ borderColor: '#2a2a2a' }}>
|
|
<button
|
|
onClick={() => setPaletteOpen(true)}
|
|
className="flex items-center gap-2 w-full text-xs font-mono"
|
|
style={{ color: '#4b5563', background: 'transparent' }}
|
|
>
|
|
<span>⌘K</span>
|
|
<span>Commandes</span>
|
|
</button>
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main content */}
|
|
<main className="flex-1 overflow-hidden flex flex-col">
|
|
{activeView === 'workflows' && (
|
|
<WorkflowBoard
|
|
workflows={workflows}
|
|
onGateApprove={handleGateApprove}
|
|
onWorkflowClick={(wfId) => setLogsProject(wfId)}
|
|
/>
|
|
)}
|
|
{activeView === 'builder' && (
|
|
<WorkflowBuilder />
|
|
)}
|
|
{activeView === 'secrets' && (
|
|
<TierGate feature="secrets" hasFeature={hasFeature}>
|
|
<SecretsZone sections={MOCK_SECTIONS} onSecretSave={handleSecretSave} />
|
|
</TierGate>
|
|
)}
|
|
{activeView === 'infra' && (
|
|
<TierGate feature="infra" hasFeature={hasFeature}>
|
|
<InfraRegistry />
|
|
</TierGate>
|
|
)}
|
|
{activeView === 'cosmos' && (
|
|
<div style={{ position: 'relative', flex: 1, minHeight: 0 }}>
|
|
<Suspense fallback={
|
|
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#4b5563' }}>
|
|
<span className="text-sm font-mono">Chargement Cosmos...</span>
|
|
</div>
|
|
}>
|
|
<CosmosView />
|
|
</Suspense>
|
|
</div>
|
|
)}
|
|
{activeView === 'workspace' && (
|
|
<Suspense fallback={
|
|
<div className="flex items-center justify-center h-full" style={{ color: '#4b5563' }}>
|
|
<span className="text-sm font-mono">Chargement Workspace...</span>
|
|
</div>
|
|
}>
|
|
<WorkspaceView />
|
|
</Suspense>
|
|
)}
|
|
</main>
|
|
|
|
{/* GatesDrawer — affiché si gate en attente */}
|
|
{pendingGate && (
|
|
<GatesDrawer
|
|
workflowId={pendingGate.workflowId}
|
|
stepId={pendingGate.stepId}
|
|
stepLabel={pendingGate.stepLabel}
|
|
onApprove={async () => setPendingGate(null)}
|
|
onReject={async () => setPendingGate(null)}
|
|
onClose={() => setPendingGate(null)}
|
|
/>
|
|
)}
|
|
|
|
{/* LogDrawer — slide-in depuis la droite */}
|
|
<LogDrawer
|
|
open={logsProject !== null}
|
|
project={logsProject}
|
|
onClose={() => setLogsProject(null)}
|
|
/>
|
|
|
|
{/* GateDrawer — approbation workflow SuperOAuth */}
|
|
<GateDrawer
|
|
open={gateDrawer.open}
|
|
workflowId={gateDrawer.workflowId}
|
|
stepId={gateDrawer.stepId}
|
|
onClose={() => setGateDrawer((prev) => ({ ...prev, open: false }))}
|
|
/>
|
|
|
|
{/* CommandPalette — Cmd+K */}
|
|
{paletteOpen && (
|
|
<CommandPalette
|
|
onClose={() => setPaletteOpen(false)}
|
|
onNavigate={(view) => { handleViewChange(view as ActiveView); setPaletteOpen(false) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<ToastProvider>
|
|
<AppInner />
|
|
</ToastProvider>
|
|
)
|
|
}
|