feat: brain-engine + brain-ui + docs — template full stack standalone

- brain-engine: server, embed, search, RAG, MCP, start.sh (standalone)
- brain-ui: source React complète, build.sh, DocsView avec tier colors
- docs: 14 pages guides humains (getting-started, architecture, sessions, workflows, agents, vues tier)
- brain-compose.yml v0.9.0: tier featured ajouté, sessions/agents par tier, coach_level, API key schema
- DISTRIBUTION_CHECKLIST v1.2: brain-engine + brain-ui + docs dans la checklist
This commit is contained in:
2026-03-20 20:25:40 +01:00
parent c249d417f5
commit 8244a07881
93 changed files with 12088 additions and 34 deletions

33
brain-ui/build.sh Executable file
View File

@@ -0,0 +1,33 @@
#!/bin/bash
# brain-ui/build.sh — Build le dashboard pour servir via brain-engine
# Usage : bash brain-ui/build.sh
# Prérequis : Node.js 18+, npm
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
echo "=== brain-ui — build ==="
# 1. Vérifier Node
if ! command -v node &>/dev/null; then
echo "❌ Node.js requis (18+). Installe-le : https://nodejs.org/"
exit 1
fi
# 2. Install deps
cd "$SCRIPT_DIR"
if [ ! -d "node_modules" ]; then
echo "→ Installation des dépendances..."
npm install
fi
# 3. Build (skip type check — erreurs TS pré-existantes non bloquantes)
echo "→ Build en cours..."
npx vite build
echo ""
echo "✅ brain-ui build dans dist/"
echo " Servi automatiquement par brain-engine sur /ui/"
echo " Lance : bash brain-engine/start.sh"
echo " Puis ouvre : http://localhost:7700/ui/"

12
brain-ui/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Brain UI</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

34
brain-ui/package.json Normal file
View File

@@ -0,0 +1,34 @@
{
"name": "brain-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@react-three/drei": "^9.122.0",
"@react-three/fiber": "^8.18.0",
"@reactflow/core": "^11.11.4",
"lucide-react": "^0.454.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-markdown": "^10.1.0",
"reactflow": "^11.11.4",
"three": "^0.163.0",
"zustand": "^5.0.12"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@types/three": "^0.163.0",
"@vitejs/plugin-react": "^4.3.3",
"autoprefixer": "^10.4.20",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"typescript": "^5.6.3",
"vite": "^5.4.10"
}
}

View File

@@ -0,0 +1 @@
../../../docs/README.md

View File

@@ -0,0 +1 @@
../../../docs/agents-brain.md

View File

@@ -0,0 +1 @@
../../../docs/agents-code.md

View File

@@ -0,0 +1 @@
../../../docs/agents-infra.md

View File

@@ -0,0 +1 @@
../../../docs/agents.md

View File

@@ -0,0 +1 @@
../../../docs/architecture.md

View File

@@ -0,0 +1 @@
../../../docs/getting-started.md

View File

@@ -0,0 +1 @@
../../../docs/sessions.md

View File

@@ -0,0 +1 @@
../../../docs/vue-featured.md

View File

@@ -0,0 +1 @@
../../../docs/vue-free.md

View File

@@ -0,0 +1 @@
../../../docs/vue-full.md

View File

@@ -0,0 +1 @@
../../../docs/vue-pro.md

View File

@@ -0,0 +1 @@
../../../docs/vue-tiers.md

View File

@@ -0,0 +1 @@
../../../docs/workflows.md

301
brain-ui/src/App.tsx Normal file
View File

@@ -0,0 +1,301 @@
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'))
const DocsView = lazy(() => import('./components/DocsView'))
type ActiveView = 'workflows' | 'builder' | 'secrets' | 'infra' | 'cosmos' | 'workspace' | 'docs'
interface NavItem {
id: ActiveView
icon: string
label: string
separator?: boolean
}
interface PendingGate {
workflowId: string
stepId: string
stepLabel: string
}
const NAV_ITEMS: NavItem[] = [
{ id: 'workflows', icon: '🔀', label: 'Workflows' },
{ id: 'builder', icon: '⚡', label: 'Nouveau' },
{ id: 'secrets', icon: '🔑', label: 'Secrets' },
{ id: 'infra', icon: '🖥️', label: 'Infra' },
{ id: 'cosmos', icon: '🌌', label: 'Cosmos', separator: true },
{ id: 'docs', icon: '📖', label: 'Docs' },
]
function AppInner() {
const { addToast } = useToast()
const [activeView, setActiveView] = useState<ActiveView>('workflows')
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)
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={() => setActiveView(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>
{/* 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 === 'docs' && (
<Suspense fallback={
<div className="flex items-center justify-center h-full" style={{ color: '#4b5563' }}>
<span className="text-sm font-mono">Chargement Docs...</span>
</div>
}>
<DocsView />
</Suspense>
)}
{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) => { setActiveView(view as ActiveView); setPaletteOpen(false) }}
/>
)}
</div>
)
}
export default function App() {
return (
<ToastProvider>
<AppInner />
</ToastProvider>
)
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect, useRef, useCallback } from 'react'
interface PaletteCommand {
id: string
label: string
description: string
keywords: string[]
action: () => void
}
interface CommandPaletteProps {
onClose: () => void
onNavigate: (view: string) => void
}
export default function CommandPalette({ onClose, onNavigate }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIdx, setSelectedIdx] = useState(0)
const inputRef = useRef<HTMLInputElement>(null)
const commands: PaletteCommand[] = [
{
id: 'workspace:open',
label: 'Espace Workflow 3D',
description: "Piloter les workflows dans l'espace",
keywords: ['workspace', '3d', 'workflow', 'constellation', 'space'],
action: () => { onNavigate('workspace'); onClose() },
},
{
id: 'cosmos:open',
label: 'Ouvrir Cosmos',
description: 'Visualisation 3D du brain',
keywords: ['cosmos', '3d', 'brain', 'visualisation', 'points', 'umap'],
action: () => { onNavigate('cosmos'); onClose() },
},
{
id: 'workflows:view',
label: 'Workflows',
description: 'Voir les workflows actifs',
keywords: ['workflows', 'pipeline', 'tasks'],
action: () => { onNavigate('workflows'); onClose() },
},
{
id: 'builder:open',
label: 'Nouveau workflow',
description: 'Ouvrir le WorkflowBuilder',
keywords: ['builder', 'nouveau', 'create', 'workflow', 'new'],
action: () => { onNavigate('builder'); onClose() },
},
{
id: 'secrets:view',
label: 'Secrets',
description: 'Gérer les secrets et tokens',
keywords: ['secrets', 'tokens', 'keys', 'env'],
action: () => { onNavigate('secrets'); onClose() },
},
{
id: 'infra:view',
label: 'Infra',
description: 'Registre infrastructure',
keywords: ['infra', 'infrastucture', 'servers', 'vps'],
action: () => { onNavigate('infra'); onClose() },
},
]
const filtered = query.trim()
? commands.filter((cmd) => {
const q = query.toLowerCase()
return (
cmd.label.toLowerCase().includes(q) ||
cmd.description.toLowerCase().includes(q) ||
cmd.keywords.some((kw) => kw.includes(q))
)
})
: commands
useEffect(() => {
setSelectedIdx(0)
}, [query])
useEffect(() => {
inputRef.current?.focus()
}, [])
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
onClose()
} else if (e.key === 'ArrowDown') {
e.preventDefault()
setSelectedIdx((i) => Math.min(i + 1, filtered.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setSelectedIdx((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter') {
if (filtered[selectedIdx]) {
filtered[selectedIdx].action()
}
}
}, [filtered, selectedIdx, onClose])
return (
<div
className="fixed inset-0 flex items-start justify-center"
style={{ background: 'rgba(0,0,0,0.6)', zIndex: 100, paddingTop: 80 }}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
className="w-full rounded-lg overflow-hidden"
style={{
maxWidth: 512,
background: '#1a1a1a',
border: '1px solid #2a2a2a',
boxShadow: '0 20px 60px rgba(0,0,0,0.5)',
}}
onKeyDown={handleKeyDown}
>
{/* Input */}
<div style={{ borderBottom: '1px solid #2a2a2a' }}>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Taper une commande..."
className="w-full px-4 py-3 text-sm font-mono outline-none"
style={{
background: 'transparent',
color: '#e5e7eb',
}}
/>
</div>
{/* Commands list */}
<div style={{ maxHeight: 320, overflowY: 'auto' }}>
{filtered.length === 0 && (
<div
className="px-4 py-3 text-xs font-mono"
style={{ color: '#4b5563' }}
>
Aucune commande trouvée
</div>
)}
{filtered.map((cmd, idx) => (
<button
key={cmd.id}
onClick={cmd.action}
onMouseEnter={() => setSelectedIdx(idx)}
className="w-full flex items-start gap-3 px-4 py-3 text-left"
style={{
background: idx === selectedIdx ? 'rgba(99,102,241,0.1)' : 'transparent',
borderLeft: `2px solid ${idx === selectedIdx ? '#6366f1' : 'transparent'}`,
}}
>
<div className="flex-1 min-w-0">
<div
className="text-sm font-medium"
style={{ color: '#e5e7eb' }}
>
{cmd.label}
</div>
<div
className="text-xs mt-0.5 font-mono"
style={{ color: '#6b7280' }}
>
{cmd.description}
</div>
</div>
<div
className="text-xs font-mono flex-shrink-0 mt-0.5"
style={{ color: '#4b5563' }}
>
{cmd.id}
</div>
</button>
))}
</div>
{/* Footer */}
<div
className="flex items-center gap-4 px-4 py-2"
style={{ borderTop: '1px solid #2a2a2a', color: '#4b5563', fontSize: 10, fontFamily: 'monospace' }}
>
<span> naviguer</span>
<span> exécuter</span>
<span>Esc fermer</span>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,155 @@
import { useState, useEffect, ReactNode } from 'react'
import ReactMarkdown, { Components } from 'react-markdown'
interface DocFile {
name: string
label: string
path: string
group?: string
}
const DOCS: DocFile[] = [
{ name: 'getting-started', label: 'Demarrer', path: import.meta.env.BASE_URL + 'docs/getting-started.md', group: 'Guides' },
{ name: 'architecture', label: 'Architecture', path: import.meta.env.BASE_URL + 'docs/architecture.md', group: 'Guides' },
{ name: 'sessions', label: 'Sessions', path: import.meta.env.BASE_URL + 'docs/sessions.md', group: 'Guides' },
{ name: 'workflows', label: 'Workflows', path: import.meta.env.BASE_URL + 'docs/workflows.md', group: 'Guides' },
{ name: 'agents', label: 'Vue d\'ensemble', path: import.meta.env.BASE_URL + 'docs/agents.md', group: 'Agents' },
{ name: 'agents-code', label: 'Code & Qualite', path: import.meta.env.BASE_URL + 'docs/agents-code.md', group: 'Agents' },
{ name: 'agents-infra', label: 'Infra & Deploy', path: import.meta.env.BASE_URL + 'docs/agents-infra.md', group: 'Agents' },
{ name: 'agents-brain', label: 'Brain & Systeme', path: import.meta.env.BASE_URL + 'docs/agents-brain.md', group: 'Agents' },
{ name: 'vue-tiers', label: 'Comparatif', path: import.meta.env.BASE_URL + 'docs/vue-tiers.md', group: 'Vues' },
{ name: 'vue-free', label: '🟢 free', path: import.meta.env.BASE_URL + 'docs/vue-free.md', group: 'Vues' },
{ name: 'vue-featured', label: '🔵 featured', path: import.meta.env.BASE_URL + 'docs/vue-featured.md', group: 'Vues' },
{ name: 'vue-pro', label: '🟠 pro', path: import.meta.env.BASE_URL + 'docs/vue-pro.md', group: 'Vues' },
{ name: 'vue-full', label: '🟣 full', path: import.meta.env.BASE_URL + 'docs/vue-full.md', group: 'Vues' },
]
// Detect tier markers in blockquote content and apply CSS class
const TIER_MARKERS: Record<string, string> = {
'\u{1F7E2}': 'tier-free', // 🟢
'\u{1F535}': 'tier-featured', // 🔵
'\u{1F7E0}': 'tier-pro', // 🟠
'\u{1F7E3}': 'tier-full', // 🟣
}
function extractText(children: ReactNode): string {
if (typeof children === 'string') return children
if (Array.isArray(children)) return children.map(extractText).join('')
if (children && typeof children === 'object' && 'props' in children) {
return extractText((children as { props: { children?: ReactNode } }).props.children)
}
return ''
}
const mdComponents: Components = {
blockquote({ children }) {
const text = extractText(children)
let tierClass = ''
for (const [marker, cls] of Object.entries(TIER_MARKERS)) {
if (text.includes(marker)) {
tierClass = cls
break
}
}
return <blockquote className={tierClass || undefined}>{children}</blockquote>
},
}
export default function DocsView() {
const [activeDoc, setActiveDoc] = useState<string>('getting-started')
const [content, setContent] = useState<string>('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const doc = DOCS.find((d) => d.name === activeDoc)
if (!doc) return
setLoading(true)
setError(null)
fetch(doc.path)
.then((res) => {
if (!res.ok) throw new Error(`${res.status}`)
return res.text()
})
.then((text) => {
const stripped = text.replace(/^---[\s\S]*?---\n*/, '')
setContent(stripped)
setLoading(false)
})
.catch((err) => {
setError(`Impossible de charger ${doc.path}: ${err.message}`)
setLoading(false)
})
}, [activeDoc])
// Group docs by group
const groups = DOCS.reduce<Record<string, DocFile[]>>((acc, doc) => {
const g = doc.group || 'Autres'
if (!acc[g]) acc[g] = []
acc[g].push(doc)
return acc
}, {})
return (
<div className="flex h-full overflow-hidden">
{/* Sidebar docs */}
<div
className="flex flex-col flex-shrink-0 border-r overflow-y-auto"
style={{ width: 200, borderColor: '#2a2a2a', background: '#141414' }}
>
<div className="px-3 py-3 border-b" style={{ borderColor: '#2a2a2a' }}>
<span className="text-xs font-mono" style={{ color: '#6b7280' }}>
Documentation
</span>
</div>
<nav className="flex flex-col gap-0.5 p-2">
{Object.entries(groups).map(([group, docs]) => (
<div key={group}>
<div
className="text-xs font-mono px-3 py-1.5 mt-2"
style={{ color: '#4b5563', letterSpacing: '0.05em' }}
>
{group.toUpperCase()}
</div>
{docs.map((doc) => (
<button
key={doc.name}
onClick={() => setActiveDoc(doc.name)}
className="text-left px-3 py-1.5 rounded text-sm transition-colors w-full"
style={
activeDoc === doc.name
? { background: 'rgba(99,102,241,0.15)', color: '#818cf8' }
: { color: '#9ca3af' }
}
>
{doc.label}
</button>
))}
</div>
))}
</nav>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto" style={{ padding: '2rem 3rem' }}>
{loading && (
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
Chargement...
</div>
)}
{error && (
<div style={{ color: '#ef4444' }} className="text-sm font-mono">
{error}
</div>
)}
{!loading && !error && (
<article className="docs-markdown">
<ReactMarkdown components={mdComponents}>{content}</ReactMarkdown>
</article>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,271 @@
import { useState, useEffect } from 'react'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface GateDrawerProps {
open: boolean
onClose: () => void
workflowId: string | null
stepId: string | null
}
export default function GateDrawer({ open, onClose, workflowId, stepId }: GateDrawerProps) {
const [busy, setBusy] = useState(false)
const [approved, setApproved] = useState(false)
// Reset state when drawer opens for a new gate
useEffect(() => {
if (open) {
setBusy(false)
setApproved(false)
}
}, [open, workflowId, stepId])
const handleApprove = async () => {
if (!workflowId || !stepId || busy) return
setBusy(true)
try {
await fetch(
`${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/approve`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
}
)
setApproved(true)
setTimeout(() => {
setApproved(false)
onClose()
}, 1500)
} finally {
setBusy(false)
}
}
const handleReject = async () => {
if (!workflowId || !stepId || busy) return
setBusy(true)
try {
const res = await fetch(
`${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/reject`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
}
)
// 404 = endpoint optionnel — gérer silencieusement
if (res.ok || res.status === 404) {
onClose()
}
} finally {
setBusy(false)
}
}
return (
<>
{/* Overlay — cliquable pour fermer */}
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 49,
background: open ? 'rgba(0,0,0,0.4)' : 'transparent',
pointerEvents: open ? 'auto' : 'none',
transition: 'background 0.2s',
}}
/>
{/* Panel slide-in depuis la droite */}
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
zIndex: 50,
width: 380,
background: '#0a0a0a',
borderLeft: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '12px 16px',
borderBottom: '1px solid #2a2a2a',
flexShrink: 0,
}}
>
{/* Titre */}
<span
style={{
color: '#9ca3af',
fontFamily: 'monospace',
fontSize: 12,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Gate {stepId ?? '—'}
</span>
{/* Badge "En attente d'approbation" */}
<span
style={{
fontSize: 10,
fontFamily: 'monospace',
color: '#f59e0b',
background: 'rgba(245,158,11,0.12)',
border: '1px solid rgba(245,158,11,0.35)',
borderRadius: 4,
padding: '2px 7px',
flexShrink: 0,
}}
>
En attente d'approbation
</span>
{/* Bouton fermer */}
<button
onClick={onClose}
title="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
>
</button>
</div>
{/* Corps */}
<div
style={{
flex: 1,
padding: '24px 20px',
display: 'flex',
flexDirection: 'column',
gap: 20,
}}
>
{/* Description */}
<p
style={{
color: '#9ca3af',
fontSize: 13,
lineHeight: 1.6,
margin: 0,
}}
>
Cette étape est un point de contrôle. Approuver pour continuer le workflow.
</p>
{/* Métadonnées */}
{workflowId && stepId && (
<div
style={{
background: '#111',
border: '1px solid #1f1f1f',
borderRadius: 6,
padding: '10px 14px',
fontFamily: 'monospace',
fontSize: 11,
color: '#4b5563',
lineHeight: 1.7,
}}
>
<div><span style={{ color: '#374151' }}>workflow</span> {workflowId}</div>
<div><span style={{ color: '#374151' }}>step </span> {stepId}</div>
</div>
)}
{/* État "Approuvé" */}
{approved && (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
color: '#22c55e',
fontSize: 14,
fontWeight: 600,
background: 'rgba(34,197,94,0.08)',
border: '1px solid rgba(34,197,94,0.25)',
borderRadius: 6,
padding: '10px 14px',
}}
>
Approuvé ✓
</div>
)}
{/* Boutons */}
{!approved && (
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
{/* Bouton Approuver */}
<button
disabled={busy}
onClick={handleApprove}
style={{
background: 'rgba(34,197,94,0.15)',
border: '1px solid #22c55e',
color: '#22c55e',
borderRadius: 6,
padding: '10px 0',
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
transition: 'opacity 0.15s',
width: '100%',
}}
>
{busy ? 'En cours' : 'Approuver'}
</button>
{/* Bouton Rejeter */}
<button
disabled={busy}
onClick={handleReject}
style={{
background: 'rgba(239,68,68,0.1)',
border: '1px solid #ef4444',
color: '#ef4444',
borderRadius: 6,
padding: '10px 0',
fontSize: 13,
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
transition: 'opacity 0.15s',
width: '100%',
}}
>
Rejeter
</button>
</div>
)}
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,128 @@
import { useState } from 'react'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface GatesDrawerProps {
workflowId: string
stepId: string
stepLabel: string
onApprove: () => Promise<void>
onReject: (action: 'abort' | 'skip') => Promise<void>
onClose: () => void
}
export default function GatesDrawer({
workflowId,
stepId,
stepLabel,
onApprove,
onReject,
onClose,
}: GatesDrawerProps) {
const [busy, setBusy] = useState(false)
const gateUrl = `${API_BASE}/gate/${encodeURIComponent(workflowId)}/${encodeURIComponent(stepId)}/approve`
const approve = async () => {
setBusy(true)
try {
await fetch(gateUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action: 'approve' }),
})
await onApprove()
} finally {
setBusy(false)
}
}
const reject = async (action: 'abort' | 'skip') => {
setBusy(true)
try {
await fetch(gateUrl, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
})
await onReject(action)
} finally {
setBusy(false)
}
}
return (
<div
style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
zIndex: 50,
background: 'rgba(245,158,11,0.15)',
borderTop: '1px solid rgba(245,158,11,0.5)',
backdropFilter: 'blur(4px)',
padding: '12px 24px',
display: 'flex',
alignItems: 'center',
gap: 12,
}}
>
<span style={{ color: '#fbbf24', fontWeight: 600, flex: 1 }}>
Gate en attente <span style={{ color: '#fff' }}>{stepLabel}</span>
</span>
<button
disabled={busy}
onClick={approve}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Approuver
</button>
<button
disabled={busy}
onClick={() => reject('abort')}
style={{
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Rejeter
</button>
<button
disabled={busy}
onClick={onClose}
style={{
background: 'transparent',
color: '#9ca3af',
border: '1px solid #374151',
borderRadius: 6,
padding: '6px 16px',
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Ignorer
</button>
</div>
)
}

View File

@@ -0,0 +1,121 @@
import { useInfra } from '../hooks/useInfra'
const STATUS_DOT: Record<string, string> = {
online: '#22c55e',
stopped: '#6b7280',
errored: '#ef4444',
unknown: '#f59e0b',
}
const TYPE_BADGE: Record<string, { bg: string, color: string, label: string }> = {
pm2: { bg: 'rgba(99,102,241,0.15)', color: '#6366f1', label: 'pm2' },
system: { bg: 'rgba(34,197,94,0.15)', color: '#22c55e', label: 'system' },
info: { bg: 'rgba(107,114,128,0.15)', color: '#6b7280', label: 'info' },
}
export default function InfraRegistry() {
const { services, loading, error, reload, formatUptime, formatMemory } = useInfra()
return (
<div style={{ padding: '24px', maxWidth: 900 }}>
{/* Header */}
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 24 }}>
<div>
<h2 style={{ color: '#e5e7eb', fontSize: 18, fontWeight: 600, margin: 0 }}>InfraRegistry</h2>
<p style={{ color: '#6b7280', fontSize: 12, margin: '4px 0 0', fontFamily: 'monospace' }}>
{loading ? 'Chargement...' : `${services.length} services`}
{error && <span style={{ color: '#ef4444', marginLeft: 8 }}> {error}</span>}
</p>
</div>
<button
onClick={reload}
disabled={loading}
style={{
marginLeft: 'auto', background: '#1a1a1a', border: '1px solid #2a2a2a',
color: '#9ca3af', borderRadius: 6, padding: '6px 12px', fontSize: 12,
cursor: loading ? 'not-allowed' : 'pointer', fontFamily: 'monospace',
}}
>
Actualiser
</button>
</div>
{/* Table */}
<div style={{ border: '1px solid #2a2a2a', borderRadius: 8, overflow: 'hidden' }}>
{/* Header row */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 80px 80px 70px 70px 60px 60px',
padding: '8px 16px',
background: '#1a1a1a',
borderBottom: '1px solid #2a2a2a',
fontSize: 10, fontFamily: 'monospace', color: '#4b5563', textTransform: 'uppercase', letterSpacing: 1,
}}>
<span>Service</span>
<span>Type</span>
<span>Statut</span>
<span>Port</span>
<span>Uptime</span>
<span>Mem</span>
<span>Restarts</span>
</div>
{/* Rows */}
{services.map((svc) => {
const dot = STATUS_DOT[svc.status] ?? '#6b7280'
const badge = TYPE_BADGE[svc.type] ?? TYPE_BADGE.info
return (
<div
key={svc.id}
style={{
display: 'grid',
gridTemplateColumns: '1fr 80px 80px 70px 70px 60px 60px',
padding: '10px 16px',
borderBottom: '1px solid #1a1a1a',
alignItems: 'center',
fontSize: 13,
}}
>
<span style={{ color: '#e5e7eb', fontWeight: 500 }}>{svc.name}</span>
<span style={{
display: 'inline-block', padding: '2px 6px', borderRadius: 4,
fontSize: 10, fontFamily: 'monospace',
background: badge.bg, color: badge.color,
}}>
{badge.label}
</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span style={{ width: 7, height: 7, borderRadius: '50%', background: dot, flexShrink: 0 }} />
<span style={{ color: dot, fontSize: 11, fontFamily: 'monospace' }}>{svc.status}</span>
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{svc.port ?? '—'}
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{formatUptime(svc.uptime)}
</span>
<span style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{formatMemory(svc.memory)}
</span>
<span style={{ color: svc.restarts && svc.restarts > 10 ? '#f59e0b' : '#6b7280', fontFamily: 'monospace', fontSize: 12 }}>
{svc.restarts ?? '—'}
</span>
</div>
)
})}
{!loading && services.length === 0 && (
<div style={{ padding: '32px 16px', textAlign: 'center', color: '#4b5563', fontFamily: 'monospace', fontSize: 12 }}>
Aucun service détecté
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useRef } from 'react'
import { useBrainStore } from '../store/brain.store'
interface LogDrawerProps {
open: boolean
onClose: () => void
project: string | null
}
const LEVEL_COLOR: Record<string, string> = {
error: '#ef4444',
warn: '#f59e0b',
info: '#9ca3af',
debug: '#4b5563',
}
const EMPTY_LOGS: never[] = []
export default function LogDrawer({ open, onClose, project }: LogDrawerProps) {
const logs = useBrainStore((s) => s.logs[project ?? ''] ?? EMPTY_LOGS)
const wsStatus = useBrainStore((s) => s.wsStatus)
const clearLogs = useBrainStore((s) => s.clearLogs)
const bottomRef = useRef<HTMLDivElement>(null)
// Auto-scroll quand nouveaux logs
useEffect(() => {
if (open) {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}
}, [logs, open])
// Badge wsStatus
const wsBadgeColor =
wsStatus === 'connected' ? '#22c55e' :
wsStatus === 'error' ? '#ef4444' : '#6b7280'
const wsLabel =
wsStatus === 'connected' ? 'ws live' :
wsStatus === 'error' ? 'ws erreur' : 'ws off'
return (
<>
{/* Overlay — cliquable pour fermer */}
<div
onClick={onClose}
style={{
position: 'fixed',
inset: 0,
zIndex: 49,
background: open ? 'rgba(0,0,0,0.4)' : 'transparent',
pointerEvents: open ? 'auto' : 'none',
transition: 'background 0.2s',
}}
/>
{/* Panel slide-in */}
<div
style={{
position: 'fixed',
top: 0,
right: 0,
bottom: 0,
zIndex: 50,
width: 420,
background: '#0a0a0a',
borderLeft: '1px solid #1a1a1a',
display: 'flex',
flexDirection: 'column',
transform: open ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 0.25s cubic-bezier(0.4, 0, 0.2, 1)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '12px 16px',
borderBottom: '1px solid #1a1a1a',
flexShrink: 0,
}}
>
{/* Titre */}
<span
style={{
color: '#9ca3af',
fontFamily: 'monospace',
fontSize: 12,
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
Logs {project ?? '—'}
</span>
{/* Badge wsStatus */}
<span
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 10,
fontFamily: 'monospace',
color: wsBadgeColor,
background: `${wsBadgeColor}1a`,
border: `1px solid ${wsBadgeColor}33`,
borderRadius: 4,
padding: '2px 6px',
flexShrink: 0,
}}
>
<span
style={{
width: 6,
height: 6,
borderRadius: '50%',
background: wsBadgeColor,
flexShrink: 0,
}}
/>
{wsLabel}
</span>
{/* Bouton Effacer */}
<button
onClick={() => project && clearLogs(project)}
title="Effacer les logs"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
borderRadius: 4,
color: '#6b7280',
cursor: 'pointer',
fontSize: 10,
fontFamily: 'monospace',
padding: '2px 8px',
lineHeight: '16px',
flexShrink: 0,
}}
>
Effacer
</button>
{/* Bouton fermer */}
<button
onClick={onClose}
title="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
lineHeight: 1,
padding: '0 2px',
flexShrink: 0,
}}
>
</button>
</div>
{/* Corps — log lines */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '8px 12px',
fontFamily: 'monospace',
fontSize: 11,
}}
>
{logs.length === 0 ? (
<div style={{ color: '#4b5563', marginTop: 8, lineHeight: 1.6 }}>
Aucun log démarrer un workflow pour voir les événements.
</div>
) : (
logs.map((line, i) => (
<div key={i} style={{ marginBottom: 2, lineHeight: 1.5 }}>
<span style={{ color: '#4b5563' }}>
{line.ts.slice(11, 19)}{' '}
</span>
<span
style={{
color: LEVEL_COLOR[line.level] ?? '#9ca3af',
marginRight: 6,
}}
>
{line.level.toUpperCase().padEnd(5)}
</span>
<span style={{ color: '#d1d5db' }}>{line.msg}</span>
</div>
))
)}
<div ref={bottomRef} />
</div>
</div>
</>
)
}

View File

@@ -0,0 +1,288 @@
import { useState, useCallback } from 'react'
import { ChevronDown, ChevronRight, Eye, EyeOff, RefreshCw, Save, CheckCircle2, AlertTriangle, XCircle } from 'lucide-react'
export interface SecretKey {
key: string
label: string
status: 'filled' | 'empty' | 'missing'
canGenerate?: boolean
}
export interface SecretSection {
id: string
label: string
keys: SecretKey[]
}
interface SecretsZoneProps {
sections: SecretSection[]
onSecretSave: (section: string, key: string, value: string) => void
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function generateSecret(length = 48): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()-_=+'
return Array.from({ length }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
}
function StatusIcon({ status }: { status: SecretKey['status'] }) {
if (status === 'filled')
return <CheckCircle2 size={14} className="text-emerald-400 shrink-0" />
if (status === 'empty')
return <AlertTriangle size={14} className="text-amber-400 shrink-0" />
return <XCircle size={14} className="text-red-500 shrink-0" />
}
function statusLabel(status: SecretKey['status']): string {
if (status === 'filled') return 'remplie'
if (status === 'empty') return 'vide'
return 'manquante'
}
// ---------------------------------------------------------------------------
// SecretRow
// ---------------------------------------------------------------------------
interface SecretRowProps {
sectionId: string
secret: SecretKey
onSave: (section: string, key: string, value: string) => void
}
function SecretRow({ sectionId, secret, onSave }: SecretRowProps) {
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [showValue, setShowValue] = useState(false)
const [saved, setSaved] = useState(false)
const handleGenerate = useCallback(() => {
setValue(generateSecret())
setEditing(true)
setShowValue(false)
}, [])
const handleSave = useCallback(() => {
if (!value.trim()) return
onSave(sectionId, secret.key, value)
setValue('')
setShowValue(false)
setEditing(false)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
}, [value, sectionId, secret.key, onSave])
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSave()
if (e.key === 'Escape') {
setValue('')
setEditing(false)
setShowValue(false)
}
},
[handleSave],
)
return (
<div className="group">
{/* Row header */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-md cursor-pointer hover:bg-[#242424] transition-colors"
onClick={() => !editing && setEditing(true)}
>
<StatusIcon status={saved ? 'filled' : secret.status} />
<span className="flex-1 text-sm text-gray-300">{secret.label}</span>
<span className="text-xs text-gray-600 font-mono">{secret.key}</span>
<span
className={`text-xs px-1.5 py-0.5 rounded font-medium ${
saved
? 'text-emerald-400 bg-emerald-400/10'
: secret.status === 'filled'
? 'text-emerald-400 bg-emerald-400/10'
: secret.status === 'empty'
? 'text-amber-400 bg-amber-400/10'
: 'text-red-400 bg-red-400/10'
}`}
>
{saved ? 'sauvegardée' : statusLabel(secret.status)}
</span>
</div>
{/* Inline edit */}
{editing && (
<div className="mx-3 mb-2 p-3 rounded-md bg-[#141414] border border-[#2a2a2a] space-y-2">
<div className="flex items-center gap-2">
<div className="relative flex-1">
<input
type={showValue ? 'text' : 'password'}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={`Valeur pour ${secret.key}`}
autoFocus
className="w-full bg-[#1a1a1a] border border-[#2a2a2a] rounded px-3 py-1.5 text-sm text-gray-200 placeholder-gray-600 focus:outline-none focus:border-[#6366f1] pr-9 font-mono"
/>
<button
type="button"
onClick={() => setShowValue((v) => !v)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-500 hover:text-gray-300 transition-colors"
title={showValue ? 'Masquer' : 'Afficher'}
>
{showValue ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
{secret.canGenerate && (
<button
type="button"
onClick={handleGenerate}
className="flex items-center gap-1 px-2 py-1.5 rounded text-xs text-indigo-400 border border-indigo-400/30 hover:bg-indigo-400/10 transition-colors whitespace-nowrap"
title="Générer un secret aléatoire"
>
<RefreshCw size={12} />
Générer
</button>
)}
<button
type="button"
onClick={handleSave}
disabled={!value.trim()}
className="flex items-center gap-1 px-2 py-1.5 rounded text-xs text-emerald-400 border border-emerald-400/30 hover:bg-emerald-400/10 transition-colors disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
>
<Save size={12} />
Sauvegarder
</button>
</div>
<p className="text-xs text-gray-600">
La valeur ne sera jamais affichée en clair après sauvegarde. Appuyez sur Échap pour annuler.
</p>
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SectionCard
// ---------------------------------------------------------------------------
interface SectionCardProps {
section: SecretSection
onSave: (section: string, key: string, value: string) => void
}
function SectionCard({ section, onSave }: SectionCardProps) {
const [open, setOpen] = useState(false)
const filledCount = section.keys.filter((k) => k.status === 'filled').length
const total = section.keys.length
const allFilled = filledCount === total
const hasIssues = section.keys.some((k) => k.status === 'missing')
return (
<div className="rounded-lg border border-[#2a2a2a] bg-[#1a1a1a] overflow-hidden">
{/* Header */}
<button
type="button"
onClick={() => setOpen((o) => !o)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-[#212121] transition-colors text-left"
>
<span className="text-gray-400">
{open ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
<span className="font-semibold text-sm text-gray-100 flex-1">{section.label}</span>
{/* Progress pill */}
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
allFilled
? 'text-emerald-400 bg-emerald-400/10'
: hasIssues
? 'text-red-400 bg-red-400/10'
: 'text-amber-400 bg-amber-400/10'
}`}
>
{filledCount}/{total}
</span>
</button>
{/* Body */}
{open && (
<div className="border-t border-[#2a2a2a] py-1">
{section.keys.map((secret) => (
<SecretRow key={secret.key} sectionId={section.id} secret={secret} onSave={onSave} />
))}
</div>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// SecretsZone (root)
// ---------------------------------------------------------------------------
export const MOCK_SECTIONS: SecretSection[] = [
{
id: 'brain',
label: 'BRAIN',
keys: [
{ key: 'BRAIN_TOKEN_READ', label: 'Token lecture', status: 'filled' },
{ key: 'BRAIN_TOKEN_WRITE', label: 'Token écriture', status: 'filled' },
{ key: 'BRAIN_SERVEUR_SECRET', label: 'Secret serveur', status: 'empty', canGenerate: true },
],
},
{
id: 'vps',
label: 'VPS',
keys: [
{ key: 'VPS_IP', label: 'IP du VPS', status: 'filled' },
{ key: 'VPS_USER', label: 'Utilisateur SSH', status: 'filled' },
],
},
{
id: 'mysql',
label: 'MySQL',
keys: [
{ key: 'MYSQL_ROOT_PASSWORD', label: 'Mot de passe root', status: 'empty', canGenerate: true },
],
},
{
id: 'tetardpg',
label: 'TetaRdPG',
keys: [
{ key: 'TETARDPG_DATABASE_URL', label: 'Database URL', status: 'missing' },
{ key: 'TETARDPG_TWITCH_WEBHOOK_SECRET', label: 'Twitch Webhook Secret', status: 'missing', canGenerate: true },
{ key: 'TETARDPG_COOKIE_SECRET', label: 'Cookie Secret', status: 'missing', canGenerate: true },
],
},
{
id: 'originsdigital',
label: 'OriginsDigital',
keys: [
{ key: 'ORIGINSDIGITAL_DB_PASSWORD', label: 'DB Password', status: 'empty', canGenerate: true },
{ key: 'ORIGINSDIGITAL_JWT_SECRET', label: 'JWT Secret', status: 'missing', canGenerate: true },
],
},
]
export default function SecretsZone({ sections, onSecretSave }: SecretsZoneProps) {
return (
<div className="space-y-3">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-100">Secrets</h2>
<p className="text-xs text-gray-500">Les valeurs ne sont jamais affichées en clair</p>
</div>
{sections.map((section) => (
<SectionCard key={section.id} section={section} onSave={onSecretSave} />
))}
</div>
)
}

View File

@@ -0,0 +1,128 @@
import { memo } from 'react'
import { Handle, Position, NodeProps } from 'reactflow'
import type { StepStatus } from '../types'
export interface StepNodeData {
label: string
status: StepStatus
isGate?: boolean
workflowId: string
stepId: string
onGateApprove?: (workflowId: string, stepId: string) => void
}
const STATUS_COLORS: Record<StepStatus, string> = {
done: '#22c55e',
gate: '#f59e0b',
fail: '#ef4444',
'in-progress': '#6366f1',
pending: '#2a2a2a',
partial: '#f97316',
blocked: '#6b7280',
}
const STATUS_BORDER: Record<StepStatus, string> = {
done: '#16a34a',
gate: '#d97706',
fail: '#dc2626',
'in-progress': '#4f46e5',
pending: '#3f3f3f',
partial: '#ea580c',
blocked: '#4b5563',
}
const STATUS_LABELS: Record<StepStatus, string> = {
done: 'DONE',
gate: 'GATE',
fail: 'FAIL',
'in-progress': 'IN PROGRESS',
pending: 'PENDING',
partial: 'PARTIAL',
blocked: 'BLOCKED',
}
function StepNode({ data }: NodeProps<StepNodeData>) {
const { label, status, isGate, workflowId, stepId, onGateApprove } = data
const bg = STATUS_COLORS[status]
const border = STATUS_BORDER[status]
const isClickable = isGate && (status === 'gate' || status === 'pending') && onGateApprove
const handleClick = () => {
if (isClickable) {
onGateApprove!(workflowId, stepId)
}
}
if (isGate) {
// Diamond shape via CSS transform on a square
const size = 64
return (
<>
<Handle type="target" position={Position.Top} style={{ background: border, border: 'none' }} />
<div
onClick={handleClick}
title={isClickable ? `Approve gate: ${label}` : undefined}
style={{
width: size,
height: size,
background: bg,
border: `2px solid ${border}`,
transform: 'rotate(45deg)',
cursor: isClickable ? 'pointer' : 'default',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxShadow: isClickable ? `0 0 12px ${bg}88` : undefined,
transition: 'box-shadow 0.15s ease',
}}
>
<span
style={{
transform: 'rotate(-45deg)',
fontSize: 10,
fontWeight: 700,
color: '#fff',
textAlign: 'center',
lineHeight: 1.2,
userSelect: 'none',
maxWidth: 52,
wordBreak: 'break-word',
}}
>
{label}
</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: border, border: 'none' }} />
</>
)
}
return (
<>
<Handle type="target" position={Position.Top} style={{ background: border, border: 'none' }} />
<div
style={{
background: bg,
border: `2px solid ${border}`,
borderRadius: 8,
padding: '8px 16px',
minWidth: 120,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 2,
cursor: 'default',
boxShadow: status === 'in-progress' ? `0 0 10px ${bg}66` : undefined,
}}
>
<span style={{ fontSize: 12, fontWeight: 700, color: '#fff', userSelect: 'none' }}>{label}</span>
<span style={{ fontSize: 9, fontWeight: 500, color: '#ffffff99', letterSpacing: 1, userSelect: 'none' }}>
{STATUS_LABELS[status]}
</span>
</div>
<Handle type="source" position={Position.Bottom} style={{ background: border, border: 'none' }} />
</>
)
}
export default memo(StepNode)

View File

@@ -0,0 +1,122 @@
import { useState, useRef, useEffect } from 'react'
import type { TeamPreset } from '../types'
interface TeamSelectorProps {
presets: TeamPreset[]
selected: string | null
onChange: (teamId: string) => void
isLoading?: boolean
}
export default function TeamSelector({ presets, selected, onChange, isLoading }: TeamSelectorProps) {
const [open, setOpen] = useState(false)
const ref = useRef<HTMLDivElement>(null)
const selectedPreset = presets.find((p) => p.id === selected) ?? null
// Fermer le dropdown si clic en dehors
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [])
return (
<div ref={ref} className="relative">
{/* Trigger */}
<button
type="button"
onClick={() => setOpen((v) => !v)}
disabled={isLoading}
className="flex items-center gap-2 w-full px-3 py-2 rounded text-sm text-left"
style={{
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: selectedPreset ? '#e5e7eb' : '#6b7280',
}}
>
{isLoading ? (
<span style={{ color: '#6b7280' }}>Chargement</span>
) : selectedPreset ? (
<>
<span>{selectedPreset.icon}</span>
<span className="flex-1">{selectedPreset.label}</span>
<span style={{ color: '#6b7280' }}></span>
</>
) : (
<>
<span className="flex-1">Sélectionner une équipe</span>
<span style={{ color: '#6b7280' }}></span>
</>
)}
</button>
{/* Dropdown */}
{open && (
<div
className="absolute z-50 w-full mt-1 rounded overflow-hidden"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a', top: '100%' }}
>
{presets.map((preset) => (
<button
key={preset.id}
type="button"
onClick={() => { onChange(preset.id); setOpen(false) }}
className="flex flex-col w-full px-3 py-2 text-left"
style={{
background: preset.id === selected ? 'rgba(99,102,241,0.15)' : 'transparent',
borderLeft: preset.id === selected ? '2px solid #6366f1' : '2px solid transparent',
color: '#e5e7eb',
}}
>
{/* Header */}
<div className="flex items-center gap-2 text-sm font-medium">
<span>{preset.icon}</span>
<span>{preset.label}</span>
{preset.gate_required && (
<span
className="text-xs px-1 rounded font-mono"
style={{ background: '#292524', color: '#f59e0b' }}
>
gate
</span>
)}
</div>
{/* Preview agents */}
<div className="flex flex-wrap gap-1 mt-1">
{preset.agents.slice(0, 4).map((agent) => (
<span
key={agent}
className="text-xs px-1 rounded font-mono"
style={{ background: '#0d0d0d', color: '#9ca3af' }}
>
{agent}
</span>
))}
{preset.agents.length > 4 && (
<span className="text-xs" style={{ color: '#4b5563' }}>
+{preset.agents.length - 4}
</span>
)}
</div>
{/* Capabilities */}
<div className="flex gap-1 mt-1">
{preset.capabilities.slice(0, 5).map((cap) => (
<span key={cap} className="text-xs" style={{ color: '#4b5563' }}>
{cap}
</span>
))}
</div>
</button>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,21 @@
import type { ReactNode } from 'react'
interface TierGateProps {
feature: string
hasFeature: (f: string) => boolean
fallback?: ReactNode
children: ReactNode
}
export default function TierGate({ feature, hasFeature, fallback, children }: TierGateProps) {
if (!hasFeature(feature)) {
return fallback ? <>{fallback}</> : (
<div className="flex flex-col items-center justify-center h-full" style={{ color: '#4b5563' }}>
<div className="text-3xl mb-3">🔒</div>
<div className="text-sm font-medium">Fonctionnalité non disponible</div>
<div className="text-xs mt-1 font-mono" style={{ color: '#374151' }}>{feature} tier insuffisant</div>
</div>
)
}
return <>{children}</>
}

View File

@@ -0,0 +1,211 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useRef,
useState,
type ReactNode,
} from 'react'
// ─── Types ───────────────────────────────────────────────────────────────────
export interface Toast {
id: string
message: string
level: 'info' | 'warn' | 'error' | 'success'
context?: string
}
interface ToastContextValue {
addToast: (message: string, level: Toast['level'], context?: string) => void
}
// ─── Context ─────────────────────────────────────────────────────────────────
const ToastContext = createContext<ToastContextValue | null>(null)
// ─── Level → border color ────────────────────────────────────────────────────
const LEVEL_COLOR: Record<Toast['level'], string> = {
info: '#6366f1',
warn: '#f59e0b',
error: '#ef4444',
success: '#22c55e',
}
const DISMISS_DELAY: Record<Toast['level'], number> = {
info: 4000,
success: 4000,
warn: 7000,
error: 7000,
}
const MAX_VISIBLE = 4
// ─── ToastItem ────────────────────────────────────────────────────────────────
interface ToastItemProps {
toast: Toast
onDismiss: (id: string) => void
}
function ToastItem({ toast, onDismiss }: ToastItemProps) {
const [visible, setVisible] = useState(false)
// Slide-in on mount
useEffect(() => {
const raf = requestAnimationFrame(() => setVisible(true))
return () => cancelAnimationFrame(raf)
}, [])
const handleDismiss = () => {
setVisible(false)
setTimeout(() => onDismiss(toast.id), 220)
}
const borderColor = LEVEL_COLOR[toast.level]
return (
<div
style={{
background: '#0a0a0a',
border: `1px solid ${borderColor}`,
borderRadius: 6,
padding: '10px 14px',
minWidth: 280,
maxWidth: 380,
fontFamily: 'monospace',
fontSize: 12,
color: '#e5e7eb',
display: 'flex',
alignItems: 'flex-start',
gap: 8,
boxShadow: '0 4px 16px rgba(0,0,0,0.6)',
transform: visible ? 'translateX(0)' : 'translateX(110%)',
transition: 'transform 200ms ease, opacity 200ms ease',
opacity: visible ? 1 : 0,
cursor: 'default',
}}
>
{/* Level dot */}
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
background: borderColor,
flexShrink: 0,
marginTop: 3,
}}
/>
{/* Content */}
<div style={{ flex: 1, lineHeight: 1.5 }}>
{toast.context && (
<span style={{ color: borderColor, marginRight: 6, fontSize: 10 }}>
[{toast.context}]
</span>
)}
{toast.message}
</div>
{/* Dismiss button */}
<button
onClick={handleDismiss}
aria-label="Fermer"
style={{
background: 'transparent',
border: 'none',
color: '#4b5563',
cursor: 'pointer',
fontSize: 14,
lineHeight: 1,
padding: 0,
flexShrink: 0,
}}
>
</button>
</div>
)
}
// ─── ToastProvider ────────────────────────────────────────────────────────────
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([])
const timersRef = useRef<Map<string, ReturnType<typeof setTimeout>>>(new Map())
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id))
const timer = timersRef.current.get(id)
if (timer !== undefined) {
clearTimeout(timer)
timersRef.current.delete(id)
}
}, [])
const addToast = useCallback(
(message: string, level: Toast['level'], context?: string) => {
const id = Date.now().toString()
const toast: Toast = { id, message, level, context }
setToasts((prev) => {
const next = [...prev, toast]
// Keep only the last MAX_VISIBLE toasts
return next.slice(-MAX_VISIBLE)
})
const delay = DISMISS_DELAY[level]
const timer = setTimeout(() => removeToast(id), delay)
timersRef.current.set(id, timer)
},
[removeToast],
)
// Cleanup all timers on unmount
useEffect(() => {
const timers = timersRef.current
return () => {
timers.forEach((timer) => clearTimeout(timer))
timers.clear()
}
}, [])
return (
<ToastContext.Provider value={{ addToast }}>
{children}
{/* Toast container */}
<div
style={{
position: 'fixed',
bottom: 16,
right: 16,
zIndex: 100,
display: 'flex',
flexDirection: 'column',
gap: 8,
pointerEvents: 'none',
}}
>
{toasts.map((toast) => (
<div key={toast.id} style={{ pointerEvents: 'auto' }}>
<ToastItem toast={toast} onDismiss={removeToast} />
</div>
))}
</div>
</ToastContext.Provider>
)
}
// ─── useToast ─────────────────────────────────────────────────────────────────
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext)
if (!ctx) {
throw new Error('useToast must be used inside <ToastProvider>')
}
return ctx
}

View File

@@ -0,0 +1,208 @@
import 'reactflow/dist/style.css'
import ReactFlow, {
ReactFlowProvider,
Node,
Edge,
Background,
BackgroundVariant,
Controls,
MiniMap,
useNodesState,
useEdgesState,
} from 'reactflow'
import { useMemo } from 'react'
import type { Workflow } from '../types'
import StepNode, { StepNodeData } from './StepNode'
// ─── Mock data ───────────────────────────────────────────────────────────────
export const MOCK_WORKFLOWS: Workflow[] = [
{
id: 'clk',
name: 'Clickerz Sprint 2',
project: 'clickerz',
steps: [
{ id: 'init', label: 'INIT', status: 'done' },
{ id: 's1', label: 'UI Components', status: 'in-progress' },
{ id: 's2', label: 'Tests', status: 'pending' },
{ id: 'deploy', label: 'Deploy', status: 'pending', isGate: true },
],
},
{
id: 'od',
name: 'OriginsDigital Sprint 4',
project: 'originsdigital',
steps: [
{ id: 'init', label: 'INIT', status: 'done' },
{ id: 's1', label: 'SuperOAuth SDK', status: 'gate', isGate: true },
{ id: 's2', label: 'Auth Flow', status: 'blocked' },
{ id: 'deploy', label: 'Deploy', status: 'blocked', isGate: true },
],
},
]
// ─── Layout constants ─────────────────────────────────────────────────────────
const COL_WIDTH = 220 // horizontal spacing between workflow columns
const ROW_HEIGHT = 110 // vertical spacing between steps
const COL_OFFSET_X = 80 // left margin
const ROW_OFFSET_Y = 60 // top margin
const GATE_NODE_SIZE = 68 // diamond bounding box — must match StepNode size
// ─── Node type registry ───────────────────────────────────────────────────────
const nodeTypes = { stepNode: StepNode }
// ─── Builder helpers ──────────────────────────────────────────────────────────
function buildNodesAndEdges(
workflows: Workflow[],
onGateApprove: (wfId: string, stepId: string) => void
): { nodes: Node[]; edges: Edge[] } {
const nodes: Node[] = []
const edges: Edge[] = []
workflows.forEach((wf, colIdx) => {
if (!wf.steps?.length) return
const x = COL_OFFSET_X + colIdx * COL_WIDTH
wf.steps.forEach((step, rowIdx) => {
const y = ROW_OFFSET_Y + rowIdx * ROW_HEIGHT
const nodeId = `${wf.id}__${step.id}`
const data: StepNodeData = {
label: step.label,
status: step.status,
isGate: step.isGate,
workflowId: wf.id,
stepId: step.id,
onGateApprove,
}
nodes.push({
id: nodeId,
type: 'stepNode',
position: { x, y },
data,
// Gate nodes are diamond — center them the same as rect nodes
style: step.isGate
? { width: GATE_NODE_SIZE, height: GATE_NODE_SIZE }
: undefined,
})
// Edge from previous step to this one
if (rowIdx > 0) {
const prevNodeId = `${wf.id}__${wf.steps[rowIdx - 1].id}`
edges.push({
id: `e_${prevNodeId}_${nodeId}`,
source: prevNodeId,
target: nodeId,
animated: wf.steps[rowIdx - 1].status === 'in-progress',
style: { stroke: '#555', strokeWidth: 1.5 },
})
}
})
})
return { nodes, edges }
}
// ─── Inner board (needs ReactFlow context) ────────────────────────────────────
interface BoardInnerProps {
workflows: Workflow[]
onGateApprove: (wfId: string, stepId: string) => void
onWorkflowClick?: (wfId: string) => void
}
function BoardInner({ workflows, onGateApprove, onWorkflowClick }: BoardInnerProps) {
const { nodes: initialNodes, edges: initialEdges } = useMemo(
() => buildNodesAndEdges(workflows, onGateApprove),
[workflows, onGateApprove]
)
const [nodes, , onNodesChange] = useNodesState(initialNodes)
const [edges, , onEdgesChange] = useEdgesState(initialEdges)
// Column headers — rendered as workflow name labels above the first node
const headerNodes: Node[] = useMemo(
() =>
workflows.map((wf, colIdx) => ({
id: `header__${wf.id}`,
type: 'default',
position: { x: COL_OFFSET_X + colIdx * COL_WIDTH - 10, y: 10 },
data: { label: wf.name },
style: {
background: 'transparent',
border: 'none',
fontSize: 11,
fontWeight: 700,
color: '#aaa',
letterSpacing: 0.5,
pointerEvents: 'all',
cursor: 'pointer',
width: 180,
},
selectable: false,
draggable: false,
})),
[workflows]
)
const allNodes = useMemo(() => [...headerNodes, ...nodes], [headerNodes, nodes])
return (
<div style={{ width: '100%', height: '100%', background: '#111' }}>
<ReactFlow
nodes={allNodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
onNodeClick={(_e, node) => {
if (onWorkflowClick && node.id.startsWith('header__')) {
onWorkflowClick(node.id.replace('header__', ''))
}
}}
fitView
fitViewOptions={{ padding: 0.3 }}
minZoom={0.3}
maxZoom={2}
proOptions={{ hideAttribution: true }}
>
<Background color="#222" variant={BackgroundVariant.Dots} gap={24} size={1} />
<Controls style={{ background: '#1a1a1a', border: '1px solid #333', color: '#aaa' }} />
<MiniMap
style={{ background: '#1a1a1a', border: '1px solid #333' }}
nodeColor={(n) => {
const d = n.data as StepNodeData | undefined
if (!d?.status) return '#333'
const map: Record<string, string> = {
done: '#22c55e', gate: '#f59e0b', fail: '#ef4444',
'in-progress': '#6366f1', pending: '#2a2a2a',
partial: '#f97316', blocked: '#6b7280',
}
return map[d.status] ?? '#333'
}}
maskColor="#11111188"
/>
</ReactFlow>
</div>
)
}
// ─── Public component ─────────────────────────────────────────────────────────
export interface WorkflowBoardProps {
workflows: Workflow[]
onGateApprove: (wfId: string, stepId: string) => void
onWorkflowClick?: (wfId: string) => void
}
export default function WorkflowBoard({ workflows, onGateApprove, onWorkflowClick }: WorkflowBoardProps) {
return (
<ReactFlowProvider>
<BoardInner workflows={workflows} onGateApprove={onGateApprove} onWorkflowClick={onWorkflowClick} />
</ReactFlowProvider>
)
}

View File

@@ -0,0 +1,283 @@
import { useState } from 'react'
import type { StepDraft, WorkflowDraft } from '../types'
import TeamSelector from './TeamSelector'
import { useTeams } from '../hooks/useTeams'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
function makeId() {
return Math.random().toString(36).slice(2, 8)
}
export default function WorkflowBuilder() {
const { teams, isLoading: teamsLoading } = useTeams()
const [title, setTitle] = useState('')
const [teamId, setTeamId] = useState<string | null>(null)
const [steps, setSteps] = useState<StepDraft[]>([
{ id: makeId(), label: '', type: 'step' },
])
const [gateRequired, setGateRequired] = useState(false)
const [sending, setSending] = useState(false)
const [result, setResult] = useState<{ ok: boolean; claimId?: string; error?: string } | null>(null)
// Sync gateRequired depuis le preset sélectionné
const handleTeamChange = (id: string) => {
setTeamId(id)
const preset = teams.find((t) => t.id === id)
if (preset) setGateRequired(preset.gate_required)
setResult(null)
}
const addStep = (type: 'step' | 'gate') => {
setSteps((prev) => [...prev, { id: makeId(), label: '', type }])
setResult(null)
}
const updateStep = (id: string, label: string) => {
setSteps((prev) => prev.map((s) => (s.id === id ? { ...s, label } : s)))
}
const removeStep = (id: string) => {
setSteps((prev) => prev.filter((s) => s.id !== id))
}
const moveStep = (id: string, dir: -1 | 1) => {
setSteps((prev) => {
const idx = prev.findIndex((s) => s.id === id)
if (idx < 0) return prev
const next = idx + dir
if (next < 0 || next >= prev.length) return prev
const arr = [...prev]
;[arr[idx], arr[next]] = [arr[next], arr[idx]]
return arr
})
}
const canSend = title.trim().length > 0 && teamId !== null && steps.some((s) => s.label.trim())
const handleSend = async () => {
if (!canSend) return
setSending(true)
setResult(null)
const draft: WorkflowDraft = {
title: title.trim(),
teamId: teamId!,
steps: steps.filter((s) => s.label.trim()),
gateRequired,
}
if (USE_MOCK || !API_BASE) {
// Simulation locale
await new Promise((r) => setTimeout(r, 600))
const fakeId = `sess-mock-${Date.now()}`
setResult({ ok: true, claimId: fakeId })
setSending(false)
return
}
try {
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const resp = await fetch(`${API_BASE}/workflows/create`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(draft),
})
const data = await resp.json()
if (resp.ok && data.ok) {
setResult({ ok: true, claimId: data.claimId })
} else {
setResult({ ok: false, error: data.error ?? 'Erreur inconnue' })
}
} catch (e) {
setResult({ ok: false, error: 'Impossible de joindre le kernel' })
} finally {
setSending(false)
}
}
return (
<div className="flex flex-col gap-6 p-6 max-w-2xl">
<div>
<h2 className="text-lg font-semibold text-white mb-1">Nouveau workflow</h2>
<p className="text-sm" style={{ color: '#6b7280' }}>
Configure et envoie un workflow au kernel brain.
</p>
</div>
{/* Titre */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Titre
</label>
<input
autoFocus
type="text"
value={title}
onChange={(e) => { setTitle(e.target.value); setResult(null) }}
placeholder="ex: Clickerz Sprint 2 — Zustand + Gates"
className="px-3 py-2 rounded text-sm text-white placeholder-gray-600 outline-none"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a' }}
/>
</div>
{/* Team preset */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Équipe
</label>
<TeamSelector
presets={teams}
selected={teamId}
onChange={handleTeamChange}
isLoading={teamsLoading}
/>
</div>
{/* Steps */}
<div className="flex flex-col gap-1.5">
<label className="text-xs font-medium" style={{ color: '#9ca3af' }}>
Étapes
</label>
<div className="flex flex-col gap-1">
{steps.map((step, idx) => (
<div key={step.id} className="flex items-center gap-2">
{/* Move */}
<div className="flex flex-col gap-0.5">
<button
type="button"
onClick={() => moveStep(step.id, -1)}
disabled={idx === 0}
className="text-xs leading-none px-1"
style={{ color: idx === 0 ? '#374151' : '#6b7280' }}
>
</button>
<button
type="button"
onClick={() => moveStep(step.id, 1)}
disabled={idx === steps.length - 1}
className="text-xs leading-none px-1"
style={{ color: idx === steps.length - 1 ? '#374151' : '#6b7280' }}
>
</button>
</div>
{/* Type badge */}
<span
className="text-xs px-1.5 py-0.5 rounded font-mono w-10 text-center flex-shrink-0"
style={
step.type === 'gate'
? { background: 'rgba(245,158,11,0.15)', color: '#f59e0b' }
: { background: '#1a1a1a', color: '#6b7280' }
}
>
{step.type === 'gate' ? 'gate' : 'step'}
</span>
{/* Label input */}
<input
type="text"
value={step.label}
onChange={(e) => updateStep(step.id, e.target.value)}
placeholder={step.type === 'gate' ? 'ex: Review humain' : 'ex: Setup Zustand store'}
className="flex-1 px-2 py-1.5 rounded text-sm text-white placeholder-gray-600 outline-none"
style={{ background: '#1a1a1a', border: '1px solid #2a2a2a' }}
/>
{/* Remove */}
{steps.length > 1 && (
<button
type="button"
onClick={() => removeStep(step.id)}
className="text-xs px-1"
style={{ color: '#4b5563' }}
>
</button>
)}
</div>
))}
</div>
{/* Add step / gate */}
<div className="flex gap-2 mt-1">
<button
type="button"
onClick={() => addStep('step')}
className="text-xs px-2 py-1 rounded"
style={{ background: '#1a1a1a', color: '#9ca3af', border: '1px solid #2a2a2a' }}
>
+ step
</button>
<button
type="button"
onClick={() => addStep('gate')}
className="text-xs px-2 py-1 rounded"
style={{ background: 'rgba(245,158,11,0.1)', color: '#f59e0b', border: '1px solid rgba(245,158,11,0.2)' }}
>
+ gate
</button>
</div>
</div>
{/* Gate required toggle */}
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => { setGateRequired((v) => !v); setResult(null) }}
className="relative w-10 h-5 rounded-full transition-colors flex-shrink-0"
style={{ background: gateRequired ? '#6366f1' : '#2a2a2a' }}
>
<span
className="absolute top-0.5 left-0.5 w-4 h-4 rounded-full transition-transform"
style={{
background: '#fff',
transform: gateRequired ? 'translateX(20px)' : 'translateX(0)',
}}
/>
</button>
<span className="text-sm" style={{ color: '#9ca3af' }}>
Gate humaine requise avant exécution
</span>
</div>
{/* Submit */}
<div className="flex items-center gap-4">
<button
type="button"
onClick={handleSend}
disabled={!canSend || sending}
className="flex items-center gap-2 px-4 py-2 rounded text-sm font-medium transition-opacity"
style={{
background: canSend && !sending ? '#6366f1' : '#2a2a2a',
color: canSend && !sending ? '#fff' : '#4b5563',
cursor: canSend && !sending ? 'pointer' : 'not-allowed',
}}
>
{sending ? 'Envoi…' : 'Envoyer au kernel ▶'}
</button>
{result && (
<div
className="text-sm px-3 py-1.5 rounded"
style={
result.ok
? { background: 'rgba(34,197,94,0.1)', color: '#22c55e' }
: { background: 'rgba(239,68,68,0.1)', color: '#ef4444' }
}
>
{result.ok ? `✓ Claim créé : ${result.claimId}` : `${result.error}`}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,107 @@
import type { ZoneKey } from '../../types'
type ZoneFilter = 'all' | ZoneKey
interface ZoneOption {
id: ZoneFilter
label: string
color: string
}
const ZONE_OPTIONS: ZoneOption[] = [
{ id: 'all', label: 'Tout', color: '#9ca3af' },
{ id: 'kernel', label: 'kernel', color: '#ef4444' },
{ id: 'instance', label: 'instance', color: '#f59e0b' },
{ id: 'satellite', label: 'satellite', color: '#6366f1' },
{ id: 'public', label: 'public', color: '#e5e7eb' },
]
interface CosmosControlsProps {
activeZone: ZoneFilter
searchQuery: string
onZoneChange: (zone: ZoneFilter) => void
onSearchChange: (query: string) => void
isFullscreen: boolean
onToggleFullscreen: () => void
isHeatmap: boolean
onToggleHeatmap: () => void
}
export function CosmosControls({ activeZone, searchQuery, onZoneChange, onSearchChange, isFullscreen, onToggleFullscreen, isHeatmap, onToggleHeatmap }: CosmosControlsProps) {
return (
<div
className="flex items-center gap-2 flex-shrink-0"
style={{
padding: '8px 12px',
borderBottom: '1px solid #2a2a2a',
background: '#0d0d0d',
}}
>
<div className="flex items-center gap-1">
{ZONE_OPTIONS.map((opt) => {
const isActive = activeZone === opt.id
return (
<button
key={opt.id}
onClick={() => onZoneChange(opt.id)}
className="text-xs px-2.5 py-1 rounded font-mono transition-colors"
style={{
background: isActive ? 'rgba(99,102,241,0.15)' : 'transparent',
color: isActive ? opt.color : '#6b7280',
border: `1px solid ${isActive ? opt.color : '#2a2a2a'}`,
}}
>
{opt.label}
</button>
)
})}
</div>
<div className="flex-1" />
<input
type="text"
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
placeholder="Rechercher..."
className="text-xs font-mono rounded px-2.5 py-1 outline-none"
style={{
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: '#e5e7eb',
width: 200,
}}
/>
<button
onClick={onToggleHeatmap}
title={isHeatmap ? 'Mode points' : 'Mode nébuleuse'}
className="text-xs font-mono rounded px-2 py-1 transition-colors"
style={{
background: isHeatmap ? 'rgba(99,102,241,0.15)' : 'transparent',
border: `1px solid ${isHeatmap ? '#6366f1' : '#2a2a2a'}`,
color: isHeatmap ? '#818cf8' : '#6b7280',
lineHeight: 1,
flexShrink: 0,
}}
>
</button>
<button
onClick={onToggleFullscreen}
title={isFullscreen ? 'Quitter le plein écran' : 'Plein écran'}
className="text-xs font-mono rounded px-2 py-1 transition-colors"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
color: '#6b7280',
lineHeight: 1,
flexShrink: 0,
}}
>
{isFullscreen ? '⊠' : '⊡'}
</button>
</div>
)
}

View File

@@ -0,0 +1,212 @@
import { useState } from 'react'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_BADGE_COLORS: Record<ZoneKey, { bg: string; text: string }> = {
public: { bg: 'rgba(229,231,235,0.1)', text: '#e5e7eb' },
work: { bg: 'rgba(99,102,241,0.15)', text: '#6366f1' },
kernel: { bg: 'rgba(239,68,68,0.15)', text: '#ef4444' },
unknown: { bg: 'rgba(75,85,99,0.2)', text: '#6b7280' },
}
function getNearestNeighbors(target: CosmosPoint, all: CosmosPoint[], n = 10): CosmosPoint[] {
return all
.filter((p) => p.id !== target.id)
.map((p) => ({
point: p,
dist: Math.sqrt(
(p.x - target.x) ** 2 +
(p.y - target.y) ** 2 +
(p.z - target.z) ** 2
),
}))
.sort((a, b) => a.dist - b.dist)
.slice(0, n)
.map((e) => e.point)
}
interface CosmosInfoPanelProps {
point: CosmosPoint | null
allPoints: CosmosPoint[]
onClose: () => void
onHighlightNeighbors: (ids: Set<string>) => void
highlightedIds: Set<string>
kernelAccess?: boolean
}
export function CosmosInfoPanel({ point, allPoints, onClose, onHighlightNeighbors, highlightedIds, kernelAccess }: CosmosInfoPanelProps) {
const [neighborsActive, setNeighborsActive] = useState(false)
const [editing, setEditing] = useState(false)
const [draftContent, setDraftContent] = useState('')
const [saving, setSaving] = useState(false)
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const isOpen = point !== null
const handleToggleNeighbors = () => {
if (!point) return
if (neighborsActive) {
onHighlightNeighbors(new Set())
setNeighborsActive(false)
} else {
const neighbors = getNearestNeighbors(point, allPoints, 10)
onHighlightNeighbors(new Set(neighbors.map((p) => p.id)))
setNeighborsActive(true)
}
}
// Reset neighbors active state when point changes
const handleClose = () => {
setNeighborsActive(false)
setEditing(false)
onHighlightNeighbors(new Set())
onClose()
}
const handleSave = async () => {
if (!point) return
setSaving(true)
try {
await fetch(`${API_BASE}/brain/${encodeURIComponent(point.path)}`, {
method: 'PUT',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: draftContent }),
})
setEditing(false)
} catch {
// silencieux — pas de connexion
} finally {
setSaving(false)
}
}
const badgeColors = point ? ZONE_BADGE_COLORS[point.zone] : ZONE_BADGE_COLORS.unknown
return (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 320,
background: '#0d0d0d',
borderLeft: '1px solid #2a2a2a',
transform: isOpen ? 'translateX(0)' : 'translateX(100%)',
transition: 'transform 200ms ease',
zIndex: 20,
display: 'flex',
flexDirection: 'column',
padding: '16px',
overflowY: 'auto',
}}
>
{point && (
<>
{/* Close button */}
<div className="flex justify-end mb-4">
<button
onClick={handleClose}
className="text-xs px-2 py-1 rounded"
style={{ color: '#6b7280', background: 'transparent', border: '1px solid #2a2a2a' }}
>
</button>
</div>
{/* Path */}
<div
className="font-mono text-xs mb-2 break-all"
style={{ color: '#6b7280' }}
>
{point.path}
</div>
{/* Zone badge */}
<div className="mb-3">
<span
className="text-xs px-2 py-0.5 rounded font-mono"
style={{ background: badgeColors.bg, color: badgeColors.text }}
>
{point.zone}
</span>
</div>
{/* Label */}
<div
className="text-base font-semibold mb-3"
style={{ color: '#e5e7eb' }}
>
{point.label}
</div>
{/* Separator */}
<div style={{ borderTop: '1px solid #2a2a2a', marginBottom: 12 }} />
{/* Excerpt / Editor */}
{editing ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 16 }}>
<textarea
value={draftContent}
onChange={(e) => setDraftContent(e.target.value)}
style={{
background: '#1a1a1a', border: '1px solid #2a2a2a', color: '#e5e7eb',
borderRadius: 6, padding: 8, fontSize: 12, fontFamily: 'monospace',
resize: 'vertical', minHeight: 120, outline: 'none',
}}
/>
<div style={{ display: 'flex', gap: 8 }}>
<button onClick={handleSave} disabled={saving}
style={{ background: '#6366f1', color: '#fff', border: 'none', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer', opacity: saving ? 0.6 : 1 }}>
{saving ? 'Sauvegarde...' : 'Sauvegarder'}
</button>
<button onClick={() => setEditing(false)}
style={{ background: 'transparent', color: '#6b7280', border: '1px solid #2a2a2a', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}>
Annuler
</button>
</div>
</div>
) : (
<div className="mb-4">
<p style={{ color: '#9ca3af', fontSize: 14, lineHeight: 1.6, marginBottom: 8 }}>{point.excerpt}</p>
{kernelAccess && (
<button
onClick={() => { setEditing(true); setDraftContent(point.excerpt) }}
style={{ background: '#1a1a1a', color: '#6366f1', border: '1px solid #2a2a2a', borderRadius: 6, padding: '4px 12px', fontSize: 12, cursor: 'pointer' }}
>
Modifier
</button>
)}
</div>
)}
{/* Separator */}
<div style={{ borderTop: '1px solid #2a2a2a', marginBottom: 12 }} />
{/* Neighbors button */}
<button
onClick={handleToggleNeighbors}
className="text-xs px-3 py-2 rounded text-left"
style={{
background: neighborsActive ? 'rgba(99,102,241,0.15)' : '#1a1a1a',
color: neighborsActive ? '#6366f1' : '#e5e7eb',
border: `1px solid ${neighborsActive ? '#6366f1' : '#2a2a2a'}`,
}}
>
{neighborsActive ? 'Réinitialiser les voisins' : 'Voir les 10 voisins'}
</button>
{highlightedIds.size > 0 && (
<div
className="text-xs mt-2 font-mono"
style={{ color: '#6b7280' }}
>
{highlightedIds.size} points mis en surbrillance
</div>
)}
</>
)}
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { useMemo } from 'react'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_TEXT_COLORS: Record<ZoneKey, string> = {
kernel: '#ef4444',
instance: '#f59e0b',
satellite: '#6366f1',
public: '#e5e7eb',
work: '#6366f1',
unknown: '#6b7280',
}
interface CosmosMetricsProps {
points: CosmosPoint[]
generatedAt: string | null
onReload: () => void
loading: boolean
}
export function CosmosMetrics({ points, generatedAt, onReload, loading }: CosmosMetricsProps) {
const { total, byZone, lastSync } = useMemo(() => {
const total = points.length
const byZone = points.reduce((acc, p) => {
acc[p.zone] = (acc[p.zone] ?? 0) + 1
return acc
}, {} as Partial<Record<ZoneKey, number>>)
const lastSync = generatedAt
? new Intl.DateTimeFormat('fr-FR', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(generatedAt))
: '—'
return { total, byZone, lastSync }
}, [points, generatedAt])
const zones: ZoneKey[] = ['kernel', 'instance', 'satellite', 'public']
return (
<div
className="flex items-center gap-3 flex-shrink-0 px-3"
style={{
height: 40,
background: '#0d0d0d',
borderTop: '1px solid #2a2a2a',
fontSize: 11,
fontFamily: 'monospace',
color: '#6b7280',
}}
>
<span>Total : {total}</span>
<span style={{ color: '#2a2a2a' }}>|</span>
{zones.map((zone) => (
byZone[zone] != null ? (
<span key={zone}>
<span style={{ color: ZONE_TEXT_COLORS[zone] }}>{zone}</span>
{' : '}
<span style={{ color: '#9ca3af' }}>{byZone[zone]}</span>
</span>
) : null
))}
<span style={{ color: '#2a2a2a' }}>|</span>
<span>sync : {lastSync}</span>
<div className="flex-1" />
<button
onClick={onReload}
disabled={loading}
className="text-xs px-2 py-1 rounded"
style={{
background: 'transparent',
border: '1px solid #2a2a2a',
color: loading ? '#4b5563' : '#9ca3af',
cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'monospace',
fontSize: 11,
}}
>
{loading ? '...' : '⟳ Recharger'}
</button>
</div>
)
}

View File

@@ -0,0 +1,150 @@
import { useRef, useMemo, useCallback } from 'react'
import { useThree } from '@react-three/fiber'
import * as THREE from 'three'
import type { CosmosPoint, ZoneKey } from '../../types'
export const ZONE_COLORS: Record<ZoneKey, [number, number, number]> = {
kernel: [0.937, 0.267, 0.267], // rouge — protection maximale
instance: [1.000, 0.600, 0.200], // orange — config machine
satellite: [0.388, 0.400, 0.945], // bleu — satellites autonomes
public: [0.898, 0.906, 0.922], // blanc — visible, distribué
work: [0.388, 0.400, 0.945], // bleu (compat legacy)
unknown: [0.294, 0.337, 0.369], // gris
}
interface CosmosPointsProps {
points: CosmosPoint[]
activeZone: 'all' | ZoneKey
highlightedIds: Set<string>
onPointClick: (point: CosmosPoint) => void
heatmap?: boolean
}
export function CosmosPoints({ points, activeZone, highlightedIds, onPointClick, heatmap = false }: CosmosPointsProps) {
const pointsRef = useRef<THREE.Points>(null)
const { camera, raycaster, gl } = useThree()
const { positions, colors } = useMemo(() => {
const positions = new Float32Array(points.length * 3)
const colors = new Float32Array(points.length * 3)
// Normalise les coords UMAP vers [-2, 2] centrées à l'origine
// Centre de masse (mean) — robuste aux outliers qui décalent le bounding box
const xs = points.map((p) => p.x)
const ys = points.map((p) => p.y)
const zs = points.map((p) => p.z)
const n = points.length || 1
const cx = xs.reduce((a, b) => a + b, 0) / n
const cy = ys.reduce((a, b) => a + b, 0) / n
const cz = zs.reduce((a, b) => a + b, 0) / n
// Scale sur percentile 95 — les outliers ne déforment plus la nébuleuse
const dists = points.map((p) =>
Math.max(Math.abs(p.x - cx), Math.abs(p.y - cy), Math.abs(p.z - cz))
).sort((a, b) => a - b)
const p95 = dists[Math.floor(n * 0.95)] ?? dists[dists.length - 1] ?? 1
const scale = 2 / Math.max(p95, 0.001)
points.forEach((p, i) => {
positions[i * 3] = (p.x - cx) * scale
positions[i * 3 + 1] = (p.y - cy) * scale
positions[i * 3 + 2] = (p.z - cz) * scale
const [r, g, b] = ZONE_COLORS[p.zone] ?? ZONE_COLORS.unknown
if (heatmap) {
// Mode nébuleuse — couleur pleine, l'alpha est géré dans le fragment shader
const dimmed = activeZone !== 'all' && p.zone !== activeZone ? 0.15 : 1.0
colors[i * 3] = r * dimmed
colors[i * 3 + 1] = g * dimmed
colors[i * 3 + 2] = b * dimmed
} else {
let alpha = 1.0
if (activeZone !== 'all' && p.zone !== activeZone) {
alpha = 0.08
} else if (highlightedIds.size > 0 && !highlightedIds.has(p.id)) {
alpha = 0.05
}
colors[i * 3] = r * alpha
colors[i * 3 + 1] = g * alpha
colors[i * 3 + 2] = b * alpha
}
})
return { positions, colors }
}, [points, activeZone, highlightedIds])
const handleClick = useCallback((event: { nativeEvent: MouseEvent }) => {
if (!pointsRef.current) return
const nativeEvent = event.nativeEvent
const rect = gl.domElement.getBoundingClientRect()
const mouse = new THREE.Vector2(
((nativeEvent.clientX - rect.left) / rect.width) * 2 - 1,
-((nativeEvent.clientY - rect.top) / rect.height) * 2 + 1,
)
raycaster.setFromCamera(mouse, camera)
raycaster.params.Points = { threshold: 0.05 }
const intersects = raycaster.intersectObject(pointsRef.current)
if (intersects.length > 0 && intersects[0].index != null) {
const idx = intersects[0].index
onPointClick(points[idx])
}
}, [points, camera, raycaster, gl, onPointClick])
return (
<points ref={pointsRef} onClick={handleClick}>
<bufferGeometry>
<bufferAttribute attach="attributes-position" args={[positions, 3]} />
<bufferAttribute attach="attributes-color" args={[colors, 3]} />
</bufferGeometry>
{heatmap ? (
<shaderMaterial
vertexShader={`
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(60.0 / -mvPosition.z, 10.0, 50.0);
gl_Position = projectionMatrix * mvPosition;
}
`}
fragmentShader={`
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
float d = dot(uv, uv);
if (d > 0.25) discard;
float alpha = 0.25 * (1.0 - d * 3.0);
gl_FragColor = vec4(vColor, alpha);
}
`}
transparent={true}
blending={THREE.AdditiveBlending}
depthWrite={false}
/>
) : (
<shaderMaterial
vertexShader={`
attribute vec3 color;
varying vec3 vColor;
void main() {
vColor = color;
vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
gl_PointSize = clamp(12.0 / -mvPosition.z, 1.5, 5.0);
gl_Position = projectionMatrix * mvPosition;
}
`}
fragmentShader={`
varying vec3 vColor;
void main() {
vec2 uv = gl_PointCoord - vec2(0.5);
if (dot(uv, uv) > 0.25) discard;
gl_FragColor = vec4(vColor, 1.0);
}
`}
transparent={false}
/>
)}
</points>
)
}

View File

@@ -0,0 +1,41 @@
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { CosmosPoints } from './CosmosPoints'
import type { CosmosPoint, ZoneKey } from '../../types'
interface CosmosSceneProps {
points: CosmosPoint[]
activeZone: 'all' | ZoneKey
highlightedIds: Set<string>
onPointClick: (point: CosmosPoint) => void
heatmap?: boolean
}
export function CosmosScene({ points, activeZone, highlightedIds, onPointClick, heatmap }: CosmosSceneProps) {
return (
<Canvas
style={{ height: '100%', background: '#080808' }}
camera={{ position: [0, 0, 5], fov: 60 }}
gl={{ antialias: false }}
onCreated={({ gl }) => {
gl.setPixelRatio(Math.min(window.devicePixelRatio, 2))
}}
>
<ambientLight intensity={0.3} />
<CosmosPoints
points={points}
activeZone={activeZone}
highlightedIds={highlightedIds}
onPointClick={onPointClick}
heatmap={heatmap}
/>
<OrbitControls
enableDamping={true}
dampingFactor={0.05}
rotateSpeed={0.5}
autoRotate={heatmap}
autoRotateSpeed={0.4}
/>
</Canvas>
)
}

View File

@@ -0,0 +1,178 @@
import { useState, useMemo, useEffect, useRef, useCallback } from 'react'
function checkWebGL(): boolean {
try {
const canvas = document.createElement('canvas')
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
} catch { return false }
}
import { useCosmosData } from '../../hooks/useCosmosData'
import { CosmosScene } from './CosmosScene'
import { CosmosControls } from './CosmosControls'
import { CosmosInfoPanel } from './CosmosInfoPanel'
import { CosmosMetrics } from './CosmosMetrics'
import type { CosmosPoint, ZoneKey } from '../../types'
type ZoneFilter = 'all' | ZoneKey
function NoWebGL() {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ background: '#080808' }}>
<div className="text-3xl mb-3">🖥</div>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-1">WebGL non disponible</div>
<div style={{ color: '#4b5563' }} className="text-xs text-center max-w-xs">
Active l'accélération matérielle dans Chrome : Paramètres → Système → Utiliser l'accélération matérielle
</div>
</div>
)
}
function CosmosInner() {
const { points, loading, error, generatedAt, reload } = useCosmosData()
const [selectedPoint, setSelectedPoint] = useState<CosmosPoint | null>(null)
const [activeZone, setActiveZone] = useState<ZoneFilter>('all')
const [searchQuery, setSearchQuery] = useState('')
const [highlightedIds, setHighlightedIds] = useState<Set<string>>(new Set())
const [isFullscreen, setIsFullscreen] = useState(false)
const [isHeatmap, setIsHeatmap] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const toggleFullscreen = useCallback(() => {
if (!document.fullscreenElement) {
containerRef.current?.requestFullscreen()
} else {
document.exitFullscreen()
}
}, [])
useEffect(() => {
const onFsChange = () => setIsFullscreen(!!document.fullscreenElement)
document.addEventListener('fullscreenchange', onFsChange)
return () => document.removeEventListener('fullscreenchange', onFsChange)
}, [])
const filteredPoints = useMemo(() => {
if (!searchQuery.trim()) return points
const q = searchQuery.toLowerCase()
const matched = points.filter(
(p) => p.label.toLowerCase().includes(q) || p.path.toLowerCase().includes(q)
)
return points.map((p) => p) // keep all points but highlight matched
}, [points, searchQuery])
const searchHighlightedIds = useMemo(() => {
if (!searchQuery.trim()) return new Set<string>()
const q = searchQuery.toLowerCase()
return new Set(
points
.filter((p) => p.label.toLowerCase().includes(q) || p.path.toLowerCase().includes(q))
.map((p) => p.id)
)
}, [points, searchQuery])
const effectiveHighlightedIds = useMemo(() => {
if (highlightedIds.size > 0) return highlightedIds
return searchHighlightedIds
}, [highlightedIds, searchHighlightedIds])
const handlePointClick = (point: CosmosPoint) => {
setSelectedPoint(point)
setHighlightedIds(new Set())
}
const handleSearchChange = (query: string) => {
setSearchQuery(query)
setHighlightedIds(new Set())
if (!query.trim()) setSelectedPoint(null)
}
return (
<div
ref={containerRef}
style={{ position: 'absolute', inset: 0, display: 'flex', flexDirection: 'column', background: '#080808' }}
>
<CosmosControls
activeZone={activeZone}
searchQuery={searchQuery}
onZoneChange={setActiveZone}
onSearchChange={handleSearchChange}
isFullscreen={isFullscreen}
onToggleFullscreen={toggleFullscreen}
isHeatmap={isHeatmap}
onToggleHeatmap={() => setIsHeatmap((v) => !v)}
/>
<div style={{ position: 'relative', flex: 1, minHeight: 0, overflow: 'hidden' }}>
{/* Canvas 3D — toujours monté si on a des données (caméra + état préservés au reload) */}
{!error && filteredPoints.length > 0 && (
<CosmosScene
points={filteredPoints}
activeZone={activeZone}
highlightedIds={effectiveHighlightedIds}
onPointClick={handlePointClick}
heatmap={isHeatmap}
/>
)}
{/* Loading overlay — par-dessus la scène, ne la démonte pas */}
{loading && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
style={{ background: filteredPoints.length > 0 ? 'rgba(8,8,8,0.75)' : '#080808', zIndex: 10 }}
>
<div className="text-2xl mb-3">🌌</div>
<div style={{ color: '#6366f1' }} className="text-sm font-mono">
{filteredPoints.length > 0 ? 'Mise à jour UMAP…' : 'Projection UMAP en cours…'}
</div>
{filteredPoints.length === 0 && (
<div style={{ color: '#4b5563' }} className="text-xs mt-2">
Peut prendre jusqu'à 30s lors de la première génération
</div>
)}
</div>
)}
{/* Error overlay */}
{!loading && error && (
<div
className="absolute inset-0 flex flex-col items-center justify-center"
style={{ background: '#080808' }}
>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-2">
Erreur : {error}
</div>
<button
onClick={reload}
style={{ background: '#1a1a1a', color: '#e5e7eb', border: '1px solid #2a2a2a' }}
className="text-xs px-3 py-1.5 rounded mt-2"
>
Réessayer
</button>
</div>
)}
{/* Info panel */}
<CosmosInfoPanel
point={selectedPoint}
allPoints={points}
onClose={() => setSelectedPoint(null)}
onHighlightNeighbors={setHighlightedIds}
highlightedIds={highlightedIds}
/>
</div>
<CosmosMetrics
points={points}
generatedAt={generatedAt}
onReload={reload}
loading={loading}
/>
</div>
)
}
export default function CosmosView() {
if (!checkWebGL()) return <NoWebGL />
return <CosmosInner />
}

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react'
import * as THREE from 'three'
import type { CosmosPoint, ZoneKey } from '../../types'
const ZONE_COLORS: Record<ZoneKey, string> = {
public: '#6366f1',
work: '#22c55e',
kernel: '#f59e0b',
unknown: '#6b7280',
}
interface Props {
points: CosmosPoint[]
}
export function CosmosBackground({ points }: Props) {
const { positions, colors } = useMemo(() => {
const positions = new Float32Array(points.length * 3)
const colors = new Float32Array(points.length * 3)
const color = new THREE.Color()
points.forEach((p, i) => {
positions[i * 3] = p.x * 3
positions[i * 3 + 1] = p.y * 3
positions[i * 3 + 2] = p.z * 3
color.set(ZONE_COLORS[p.zone] ?? ZONE_COLORS.unknown)
colors[i * 3] = color.r
colors[i * 3 + 1] = color.g
colors[i * 3 + 2] = color.b
})
return { positions, colors }
}, [points])
if (points.length === 0) return null
return (
<points>
<bufferGeometry>
<bufferAttribute
attach="attributes-position"
array={positions}
count={points.length}
itemSize={3}
/>
<bufferAttribute
attach="attributes-color"
array={colors}
count={points.length}
itemSize={3}
/>
</bufferGeometry>
<pointsMaterial
size={0.04}
vertexColors
transparent
opacity={0.2}
sizeAttenuation
depthWrite={false}
/>
</points>
)
}

View File

@@ -0,0 +1,39 @@
import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import type { WorkspaceStep } from '../../types'
interface Props {
step: WorkspaceStep
onClick: () => void
}
export function GateOctahedron({ step, onClick }: Props) {
const meshRef = useRef<THREE.Mesh>(null)
const [hovered, setHovered] = useState(false)
useFrame((_, delta) => {
if (meshRef.current) {
meshRef.current.rotation.y += delta * 0.8
meshRef.current.rotation.x += delta * 0.3
}
})
return (
<mesh
ref={meshRef}
position={[step.x, step.y, step.z]}
onClick={(e) => { e.stopPropagation(); onClick() }}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<octahedronGeometry args={[hovered ? 0.45 : 0.35]} />
<meshStandardMaterial
color="#f59e0b"
emissive="#f59e0b"
emissiveIntensity={hovered ? 0.6 : 0.3}
wireframe={!hovered}
/>
</mesh>
)
}

View File

@@ -0,0 +1,43 @@
import { useRef, useState } from 'react'
import { useFrame } from '@react-three/fiber'
import * as THREE from 'three'
import type { WorkspaceStep } from '../../types'
interface Props {
step: WorkspaceStep
color: string
onClick: () => void
}
export function StepSphere({ step, color, onClick }: Props) {
const meshRef = useRef<THREE.Mesh>(null)
const [hovered, setHovered] = useState(false)
useFrame(() => {
if (!meshRef.current) return
if (step.status === 'in-progress') {
meshRef.current.scale.setScalar(1 + Math.sin(Date.now() * 0.003) * 0.08)
}
})
const size = step.status === 'done' ? 0.18 : 0.25
return (
<mesh
ref={meshRef}
position={[step.x, step.y, step.z]}
onClick={(e) => { e.stopPropagation(); onClick() }}
onPointerOver={() => setHovered(true)}
onPointerOut={() => setHovered(false)}
>
<sphereGeometry args={[hovered ? size * 1.3 : size, 16, 16]} />
<meshStandardMaterial
color={color}
emissive={color}
emissiveIntensity={step.status === 'in-progress' ? 0.4 : hovered ? 0.3 : 0.1}
transparent
opacity={step.status === 'done' ? 0.5 : 1}
/>
</mesh>
)
}

View File

@@ -0,0 +1,91 @@
import { Text } from '@react-three/drei'
import * as THREE from 'three'
import { StepSphere } from './StepSphere'
import { GateOctahedron } from './GateOctahedron'
import type { WorkspaceWorkflow, WorkspaceStep } from '../../types'
interface Props {
workflow: WorkspaceWorkflow
onStepClick: (step: WorkspaceStep) => void
}
const STATUS_COLORS: Record<string, string> = {
done: '#22c55e',
'in-progress': '#6366f1',
pending: '#4b5563',
gate: '#f59e0b',
fail: '#ef4444',
blocked: '#6b7280',
}
function ConnectionLine({
from,
to,
color,
animated,
}: {
from: [number, number, number]
to: [number, number, number]
color: string
animated: boolean
}) {
const points = [new THREE.Vector3(...from), new THREE.Vector3(...to)]
const geometry = new THREE.BufferGeometry().setFromPoints(points)
const line = new THREE.Line(
geometry,
new THREE.LineBasicMaterial({ color, opacity: animated ? 1 : 0.4, transparent: true })
)
return <primitive object={line} />
}
export function WorkflowConstellation({ workflow, onStepClick }: Props) {
const firstStep = workflow.steps[0]
return (
<group>
{firstStep && (
<Text
position={[firstStep.x, firstStep.y + 1.2, firstStep.z]}
fontSize={0.25}
color={workflow.color}
anchorX="center"
anchorY="bottom"
font={undefined}
>
{workflow.name}
</Text>
)}
{workflow.steps.slice(0, -1).map((step, i) => {
const next = workflow.steps[i + 1]
return (
<ConnectionLine
key={`edge-${step.id}-${next.id}`}
from={[step.x, step.y, step.z]}
to={[next.x, next.y, next.z]}
color={STATUS_COLORS[step.status] ?? '#4b5563'}
animated={step.status === 'in-progress'}
/>
)
})}
{workflow.steps.map((step) =>
step.isGate ? (
<GateOctahedron
key={step.id}
step={step}
onClick={() => onStepClick(step)}
/>
) : (
<StepSphere
key={step.id}
step={step}
color={STATUS_COLORS[step.status] ?? '#4b5563'}
onClick={() => onStepClick(step)}
/>
)
)}
</group>
)
}

View File

@@ -0,0 +1,149 @@
import { useState } from 'react'
import type { WorkspaceStep, WorkspaceWorkflow } from '../../types'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const STATUS_COLORS: Record<string, string> = {
done: '#22c55e',
'in-progress': '#6366f1',
pending: '#4b5563',
gate: '#f59e0b',
fail: '#ef4444',
blocked: '#6b7280',
}
interface Props {
selection: { step: WorkspaceStep; wf: WorkspaceWorkflow } | null
onClose: () => void
}
export function WorkspaceInfoPanel({ selection, onClose }: Props) {
const [busy, setBusy] = useState(false)
if (!selection) return null
const { step, wf } = selection
const gateAction = async (action: 'approve' | 'abort') => {
setBusy(true)
try {
await fetch(
`${API_BASE}/gate/${encodeURIComponent(wf.id)}/${encodeURIComponent(step.id)}/approve`,
{
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ action }),
}
)
onClose()
} finally {
setBusy(false)
}
}
return (
<div
style={{
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 320,
background: '#0d0d0d',
borderLeft: '1px solid #2a2a2a',
display: 'flex',
flexDirection: 'column',
zIndex: 10,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
padding: '12px 16px',
borderBottom: '1px solid #2a2a2a',
gap: 8,
}}
>
<span
style={{ color: '#6b7280', fontFamily: 'monospace', fontSize: 11, flex: 1 }}
>
{wf.name}
</span>
<button
onClick={onClose}
style={{
background: 'transparent',
border: 'none',
color: '#6b7280',
cursor: 'pointer',
fontSize: 16,
}}
>
</button>
</div>
<div
style={{
padding: '16px',
flex: 1,
display: 'flex',
flexDirection: 'column',
gap: 12,
}}
>
<div style={{ color: '#e5e7eb', fontWeight: 600, fontSize: 16 }}>{step.label}</div>
<span
style={{
display: 'inline-block',
padding: '2px 8px',
borderRadius: 4,
fontSize: 11,
background: `${STATUS_COLORS[step.status] ?? '#4b5563'}22`,
color: STATUS_COLORS[step.status] ?? '#4b5563',
}}
>
{step.status}
</span>
{step.isGate && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
disabled={busy}
onClick={() => gateAction('approve')}
style={{
background: '#16a34a',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Approuver
</button>
<button
disabled={busy}
onClick={() => gateAction('abort')}
style={{
background: '#dc2626',
color: '#fff',
border: 'none',
borderRadius: 6,
padding: '6px 16px',
fontWeight: 600,
cursor: busy ? 'not-allowed' : 'pointer',
opacity: busy ? 0.6 : 1,
}}
>
Rejeter
</button>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,59 @@
import type { WorkspaceWorkflow } from '../../types'
interface Props {
workflows: WorkspaceWorkflow[]
}
export function WorkspaceMetrics({ workflows }: Props) {
const total = workflows.reduce((n, wf) => n + wf.steps.length, 0)
const active = workflows.reduce(
(n, wf) => n + wf.steps.filter((s) => s.status === 'in-progress').length,
0
)
const gates = workflows.reduce(
(n, wf) => n + wf.steps.filter((s) => s.isGate && s.status === 'gate').length,
0
)
return (
<div
style={{
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 40,
background: '#0d0d0d',
borderTop: '1px solid #2a2a2a',
display: 'flex',
alignItems: 'center',
padding: '0 16px',
gap: 16,
fontFamily: 'monospace',
fontSize: 11,
color: '#6b7280',
zIndex: 5,
}}
>
<span>
Workflows : <span style={{ color: '#e5e7eb' }}>{workflows.length}</span>
</span>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Steps : <span style={{ color: '#e5e7eb' }}>{total}</span>
</span>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Actifs : <span style={{ color: '#6366f1' }}>{active}</span>
</span>
{gates > 0 && (
<>
<span style={{ color: '#2a2a2a' }}>|</span>
<span>
Gates en attente : <span style={{ color: '#f59e0b' }}>{gates}</span>
</span>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { useState, Suspense } from 'react'
function checkWebGL(): boolean {
try {
const canvas = document.createElement('canvas')
return !!(canvas.getContext('webgl') || canvas.getContext('experimental-webgl'))
} catch { return false }
}
function NoWebGL() {
return (
<div className="flex flex-col items-center justify-center h-full" style={{ background: '#080808' }}>
<div className="text-3xl mb-3">🖥</div>
<div style={{ color: '#ef4444' }} className="text-sm font-mono mb-1">WebGL non disponible</div>
<div style={{ color: '#4b5563' }} className="text-xs text-center max-w-xs">
Active l'accélération matérielle dans Chrome : Paramètres → Système → Utiliser l'accélération matérielle
</div>
</div>
)
}
import { Canvas } from '@react-three/fiber'
import { OrbitControls } from '@react-three/drei'
import { useWorkspaceData } from '../../hooks/useWorkspaceData'
import { useCosmosData } from '../../hooks/useCosmosData'
import { WorkflowConstellation } from './WorkflowConstellation'
import { WorkspaceInfoPanel } from './WorkspaceInfoPanel'
import { WorkspaceMetrics } from './WorkspaceMetrics'
import { CosmosBackground } from './CosmosBackground'
import type { WorkspaceStep, WorkspaceWorkflow } from '../../types'
function WorkspaceInner() {
const { workflows } = useWorkspaceData()
const { points } = useCosmosData()
const [selectedStep, setSelectedStep] = useState<{
step: WorkspaceStep
wf: WorkspaceWorkflow
} | null>(null)
const [showCosmos, setShowCosmos] = useState(true)
if (workflows.length === 0) {
return (
<div
className="flex flex-col items-center justify-center h-full"
style={{ background: '#080808' }}
>
<div className="text-4xl mb-3">🌌</div>
<div style={{ color: '#4b5563' }} className="text-sm font-mono">
Aucun workflow actif
</div>
<div style={{ color: '#374151' }} className="text-xs mt-1">
Créer un workflow via K Nouveau workflow
</div>
</div>
)
}
return (
<div style={{ width: '100%', height: '100%', background: '#080808', position: 'relative' }}>
<Canvas
camera={{ position: [0, 2, 12], fov: 60 }}
gl={{ antialias: true }}
style={{ width: '100%', height: '100%' }}
>
<ambientLight intensity={0.2} />
<pointLight position={[10, 10, 10]} intensity={0.5} />
<Suspense fallback={null}>
{showCosmos && <CosmosBackground points={points} />}
{workflows.map((wf) => (
<WorkflowConstellation
key={wf.id}
workflow={wf}
onStepClick={(step) => setSelectedStep({ step, wf })}
/>
))}
</Suspense>
<OrbitControls
enableDamping
dampingFactor={0.05}
rotateSpeed={0.4}
minDistance={3}
maxDistance={30}
/>
</Canvas>
<WorkspaceInfoPanel
selection={selectedStep}
onClose={() => setSelectedStep(null)}
/>
<WorkspaceMetrics workflows={workflows} />
<button
onClick={() => setShowCosmos((v) => !v)}
style={{
position: 'absolute',
top: '0.5rem',
right: '0.5rem',
background: '#1a1a1a',
border: '1px solid #2a2a2a',
color: showCosmos ? '#6366f1' : '#6b7280',
fontFamily: 'monospace',
fontSize: '0.75rem',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem',
cursor: 'pointer',
zIndex: 10,
}}
>
🌌 Cosmos
</button>
</div>
)
}
export default function WorkspaceView() {
if (!checkWebGL()) return <NoWebGL />
return <WorkspaceInner />
}

View File

@@ -0,0 +1,104 @@
import { useState, useEffect, useCallback } from 'react'
import type { CosmosPoint, VisualizeResponse, ZoneKey } from '../types'
const CACHE_TTL_MS = 30 * 60 * 1000
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
interface CosmosCache {
timestamp: number
points: CosmosPoint[]
generated_at: string
umap_params: VisualizeResponse['umap_params']
}
const MOCK_ZONES: ZoneKey[] = ['public', 'kernel', 'instance', 'satellite']
function generateMockPoints(): CosmosPoint[] {
return Array.from({ length: 50 }, (_, i) => {
const zone = MOCK_ZONES[i % 4]
return {
id: `mock-${i}`,
path: `${zone}/document-${i}.md`,
zone,
label: `Document ${i}`,
excerpt: `Extrait du document ${i} — contenu de démonstration pour la visualisation Cosmos Sprint 4.`,
x: (Math.random() - 0.5) * 4,
y: (Math.random() - 0.5) * 4,
z: (Math.random() - 0.5) * 4,
}
})
}
export function useCosmosData() {
const [points, setPoints] = useState<CosmosPoint[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [generatedAt, setGeneratedAt] = useState<string | null>(null)
const [cached, setCached] = useState(false)
const cacheKey = `cosmos_cache_${Math.floor(Date.now() / CACHE_TTL_MS)}`
const load = useCallback(async (force = false) => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise((r) => setTimeout(r, 400))
setPoints(generateMockPoints())
setGeneratedAt(new Date().toISOString())
setCached(false)
setLoading(false)
return
}
if (!force) {
const raw = localStorage.getItem(cacheKey)
if (raw) {
try {
const parsed: CosmosCache = JSON.parse(raw)
if (Date.now() - parsed.timestamp < CACHE_TTL_MS) {
setPoints(parsed.points)
setGeneratedAt(parsed.generated_at)
setCached(true)
setLoading(false)
return
}
} catch {
localStorage.removeItem(cacheKey)
}
}
}
try {
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
const url = force ? `${API_BASE}/visualize?force=true` : `${API_BASE}/visualize`
const res = await fetch(url, { credentials: 'include', headers })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data: VisualizeResponse = await res.json()
setPoints(data.points)
setGeneratedAt(data.generated_at)
setCached(data.cached)
const cachePayload: CosmosCache = {
timestamp: Date.now(),
points: data.points,
generated_at: data.generated_at,
umap_params: data.umap_params,
}
localStorage.setItem(cacheKey, JSON.stringify(cachePayload))
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur inconnue')
} finally {
setLoading(false)
}
}, [cacheKey])
useEffect(() => {
load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { points, loading, error, generatedAt, cached, reload: () => load(true) }
}

View File

@@ -0,0 +1,63 @@
import { useState, useEffect } from 'react'
import type { InfraService, InfraResponse } from '../types'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_SERVICES: InfraService[] = [
{ id: 'pm2-brain-engine', name: 'brain-engine', type: 'pm2', status: 'online', port: 7700, uptime: 3600000, restarts: 0, memory: 52428800, cpu: 0 },
{ id: 'pm2-tetardpg', name: 'tetardpg', type: 'pm2', status: 'online', port: 4000, uptime: 7200000, restarts: 2, memory: 97517568, cpu: 0 },
{ id: 'pm2-super-oauth', name: 'super-oauth', type: 'pm2', status: 'online', port: 3001, uptime: 18000000, restarts: 0, memory: 94371840, cpu: 0 },
{ id: 'pm2-originsdigital', name: 'originsdigital', type: 'pm2', status: 'online', port: 3002, uptime: 7200000, restarts: 58, memory: 83886080, cpu: 0 },
{ id: 'apache', name: 'Apache2', type: 'system', status: 'online', port: 443 },
{ id: 'brain-engine-info', name: 'brain-engine', type: 'info', status: 'online', port: 7700 },
{ id: 'gitea', name: 'Gitea', type: 'info', status: 'online', port: 3000 },
]
function formatUptime(ms: number | null | undefined): string {
if (!ms) return '—'
const s = Math.floor(ms / 1000)
if (s < 60) return `${s}s`
if (s < 3600) return `${Math.floor(s / 60)}m`
if (s < 86400) return `${Math.floor(s / 3600)}h`
return `${Math.floor(s / 86400)}j`
}
function formatMemory(bytes: number | null | undefined): string {
if (!bytes) return '—'
return `${Math.round(bytes / 1024 / 1024)}mb`
}
export function useInfra() {
const [services, setServices] = useState<InfraService[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = async () => {
setLoading(true)
setError(null)
if (USE_MOCK || !API_BASE) {
await new Promise(r => setTimeout(r, 300))
setServices(MOCK_SERVICES)
setLoading(false)
return
}
try {
const r = await fetch(`${API_BASE}/infra`, { credentials: 'include' })
if (!r.ok) throw new Error(`HTTP ${r.status}`)
const data: InfraResponse = await r.json()
setServices(data.services)
} catch (err) {
setError(err instanceof Error ? err.message : 'Erreur')
setServices(MOCK_SERVICES)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [])
return { services, loading, error, reload: load, formatUptime, formatMemory }
}

View File

@@ -0,0 +1,50 @@
import { useEffect, useRef } from 'react'
import { useBrainStore, LogLine } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
const MOCK_LINES: LogLine[] = [
{ ts: new Date().toISOString(), level: 'info', msg: '[mock] workflow started' },
{ ts: new Date().toISOString(), level: 'debug', msg: '[mock] step INIT — done' },
{ ts: new Date().toISOString(), level: 'warn', msg: '[mock] gate pending — awaiting approval' },
]
export function useLogs(project: string, active: boolean) {
const logs = useBrainStore((s) => s.logs[project] ?? [])
const appendLogs = useBrainStore((s) => s.appendLogs)
const lastTsRef = useRef<string>('')
useEffect(() => {
if (!active) return
if (USE_MOCK || !API_BASE) {
appendLogs(project, MOCK_LINES)
return
}
const poll = async () => {
try {
const since = lastTsRef.current ? `?since=${encodeURIComponent(lastTsRef.current)}` : ''
const r = await fetch(`${API_BASE}/logs/${encodeURIComponent(project)}${since}`, {
credentials: 'include',
})
if (!r.ok) return
const data = await r.json()
const lines: LogLine[] = data.lines ?? []
if (lines.length > 0) {
lastTsRef.current = lines[lines.length - 1].ts
appendLogs(project, lines)
}
} catch {
// réseau — on ignore
}
}
poll()
const interval = setInterval(poll, 2000)
return () => clearInterval(interval)
}, [project, active]) // eslint-disable-line react-hooks/exhaustive-deps
return logs
}

View File

@@ -0,0 +1,96 @@
import { useState, useEffect } from 'react'
import type { TeamPreset } from '../types'
const MOCK_TEAMS: TeamPreset[] = [
{
id: 'team-frontend',
label: 'Team Frontend',
icon: '⚛️',
agents: ['brain-ui-scribe', 'frontend-stack', 'optimizer-frontend'],
capabilities: ['react', 'typescript', 'tailwind', 'vite'],
gate_required: false,
default_timeout_min: 30,
},
{
id: 'team-backend',
label: 'Team Backend',
icon: '⚙️',
agents: ['debug', 'optimizer-backend', 'optimizer-db', 'pm2', 'migration'],
capabilities: ['nestjs', 'typescript', 'mysql', 'typeorm'],
gate_required: false,
default_timeout_min: 45,
},
{
id: 'team-infra',
label: 'Team Infra',
icon: '🖥️',
agents: ['vps', 'ci-cd', 'monitoring', 'secrets-guardian'],
capabilities: ['apache', 'vps', 'ssl', 'ci-cd'],
gate_required: true,
default_timeout_min: 20,
},
{
id: 'team-content',
label: 'Team Content',
icon: '🎬',
agents: ['content-strategist', 'scriptwriter', 'seo-youtube'],
capabilities: ['youtube', 'seo', 'scriptwriting'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-security',
label: 'Team Sécurité',
icon: '🔒',
agents: ['security', 'secrets-guardian', 'code-review'],
capabilities: ['jwt', 'oauth', 'owasp', 'secrets-rotation'],
gate_required: true,
default_timeout_min: 30,
},
{
id: 'team-fullstack',
label: 'Team Fullstack',
icon: '🔀',
agents: ['frontend-stack', 'optimizer-backend', 'optimizer-db', 'debug'],
capabilities: ['react', 'nestjs', 'mysql', 'typescript'],
gate_required: false,
default_timeout_min: 60,
},
{
id: 'team-game',
label: 'Team Game',
icon: '🎮',
agents: ['game-designer', 'optimizer-backend', 'optimizer-db'],
capabilities: ['game-design', 'nestjs', 'mysql'],
gate_required: false,
default_timeout_min: 45,
},
]
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useTeams() {
const [teams, setTeams] = useState<TeamPreset[]>([])
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTeams(MOCK_TEAMS)
setIsLoading(false)
return
}
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
fetch(`${API_BASE}/teams`, {
credentials: 'include',
headers: token ? { Authorization: `Bearer ${token}` } : {},
})
.then((r) => r.json())
.then((data) => setTeams(data))
.catch(() => setTeams(MOCK_TEAMS))
.finally(() => setIsLoading(false))
}, [])
return { teams, isLoading }
}

View File

@@ -0,0 +1,39 @@
import { useState, useEffect } from 'react'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export interface TierInfo {
tier: 'owner' | 'pro' | 'free'
features: string[]
kernel_access: boolean
}
const MOCK_TIER: TierInfo = {
tier: 'owner',
features: ['cosmos', 'workspace', 'workflows', 'builder', 'secrets', 'infra', 'editor'],
kernel_access: true,
}
export function useTier() {
const [tierInfo, setTierInfo] = useState<TierInfo>(MOCK_TIER)
const [loading, setLoading] = useState(!USE_MOCK)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setTierInfo(MOCK_TIER)
setLoading(false)
return
}
fetch(`${API_BASE}/tier`, { credentials: 'include' })
.then((r) => r.json())
.then((data: TierInfo) => setTierInfo(data))
.catch(() => setTierInfo(MOCK_TIER))
.finally(() => setLoading(false))
}, [])
const hasFeature = (feature: string) => tierInfo.features.includes(feature)
return { tierInfo, loading, hasFeature }
}

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { Toast } from '../components/ToastProvider'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
function buildWsUrl(): string {
// Si API_BASE est un chemin relatif (ex: '/api'), construire l'URL dynamiquement
if (!API_BASE || API_BASE.startsWith('/')) {
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
const base = API_BASE || '/api'
return `${proto}://${location.host}${base}/ws`
}
// Si API_BASE est une URL absolue (ex: 'http://localhost:3333/api')
return API_BASE.replace(/^http/, 'ws') + '/ws'
}
const RECONNECT_DELAY_MS = 3000
type AddToast = (message: string, level: Toast['level'], context?: string) => void
export function useWebSocket(addToast?: AddToast) {
const statusRef = useRef<'connecting' | 'connected' | 'disconnected'>('disconnected')
useEffect(() => {
if (USE_MOCK || !API_BASE) {
useBrainStore.getState().setWsStatus('connected')
return
}
const wsUrl = buildWsUrl()
let ws: WebSocket | null = null
let reconnectTimeout: ReturnType<typeof setTimeout> | null = null
let destroyed = false
const setStatus = (s: 'connecting' | 'connected' | 'disconnected') => {
statusRef.current = s
const storeStatus =
s === 'connected' ? 'connected' :
s === 'connecting' ? 'disconnected' :
'disconnected'
useBrainStore.getState().setWsStatus(storeStatus)
}
const connect = () => {
if (destroyed) return
setStatus('connecting')
ws = new WebSocket(wsUrl)
ws.onopen = () => {
setStatus('connected')
}
ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data as string)
const store = useBrainStore.getState()
switch (msg.type) {
case 'workflow:update':
if (Array.isArray(msg.data?.workflows)) {
store.setWorkflows(msg.data.workflows)
} else if (msg.payload) {
store.updateWorkflow(msg.payload)
}
break
case 'log:line': {
const project = msg.data?.project ?? msg.project ?? 'unknown'
const line = msg.data?.line ?? msg.line ?? ''
if (line) {
store.appendLogs(project, [{
ts: new Date().toISOString(),
level: detectLevel(line),
msg: line,
}])
}
break
}
case 'ambient:event': {
const context = msg.data?.context ?? msg.context ?? ''
const message = msg.data?.message ?? msg.message ?? ''
store.appendLogs('ambient', [{
ts: new Date().toISOString(),
level: 'info',
msg: `[${context}] ${message}`,
}])
addToast?.(
message,
(msg.data?.level ?? msg.level) === 'warn' ? 'warn' : 'info',
context || undefined,
)
break
}
case 'brain:updated': {
const path = msg.data?.path ?? msg.path ?? ''
console.log('brain:updated', path)
addToast?.(`brain mis à jour : ${path}`, 'success')
break
}
// Compatibilité avec l'ancien format gate:pending de useWorkflows
case 'gate:pending': {
const { workflowId, stepId } = msg.payload ?? {}
if (workflowId && stepId) {
store.appendLogs(workflowId, [{
ts: new Date().toISOString(),
level: 'warn',
msg: `Gate en attente — step ${stepId}`,
}])
}
const step = msg.payload?.stepId ?? msg.data?.step ?? ''
const workflow = msg.payload?.workflowId ?? msg.data?.workflow ?? ''
addToast?.(`Gate en attente : ${step}${workflow}`, 'warn')
break
}
default:
break
}
} catch {
// message malformé — ignorer
}
}
ws.onclose = () => {
if (!destroyed) {
setStatus('disconnected')
reconnectTimeout = setTimeout(connect, RECONNECT_DELAY_MS)
}
}
ws.onerror = () => {
useBrainStore.getState().setWsStatus('error')
ws?.close()
}
}
connect()
return () => {
destroyed = true
if (reconnectTimeout) clearTimeout(reconnectTimeout)
ws?.close()
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { status: statusRef.current }
}
// Détecte le niveau de log d'une ligne texte brute
function detectLevel(line: string): 'info' | 'warn' | 'error' | 'debug' {
const upper = line.toUpperCase()
if (upper.includes('ERROR') || upper.includes('ERR ') || upper.includes('FATAL')) return 'error'
if (upper.includes('WARN')) return 'warn'
if (upper.includes('DEBUG')) return 'debug'
return 'info'
}

View File

@@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { MOCK_WORKFLOWS } from '../components/WorkflowBoard'
import { useBrainStore } from '../store/brain.store'
const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false'
const API_BASE = import.meta.env.VITE_BRAIN_API ?? ''
export function useWorkflows() {
const workflows = useBrainStore((s) => s.workflows)
const wsStatus = useBrainStore((s) => s.wsStatus)
const setWorkflows = useBrainStore((s) => s.setWorkflows)
const setWsStatus = useBrainStore((s) => s.setWsStatus)
useEffect(() => {
if (USE_MOCK || !API_BASE) {
setWorkflows(MOCK_WORKFLOWS)
setWsStatus('connected')
return
}
// Fetch initial
const token = import.meta.env.VITE_BRAIN_TOKEN ?? ''
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {}
fetch(`${API_BASE}/workflows`, { credentials: 'include', headers })
.then((r) => r.json())
.then((data) => setWorkflows(data))
.catch(() => setWorkflows(MOCK_WORKFLOWS))
return () => {}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
return { workflows, wsStatus }
}

View File

@@ -0,0 +1,33 @@
import { useMemo } from 'react'
import { useBrainStore } from '../store/brain.store'
import type { WorkspaceWorkflow } from '../types'
const WORKFLOW_COLORS = ['#6366f1', '#f59e0b', '#22c55e', '#ef4444', '#8b5cf6', '#06b6d4']
function computeLayout(workflows: ReturnType<typeof useBrainStore.getState>['workflows']): WorkspaceWorkflow[] {
return workflows.map((wf, wfIdx) => {
const baseX = (wfIdx - workflows.length / 2) * 4
const color = WORKFLOW_COLORS[wfIdx % WORKFLOW_COLORS.length]
const steps = (wf.steps ?? []).map((step, stepIdx) => {
const z = step.status === 'done' ? -stepIdx * 0.5 : stepIdx === 0 ? 1 : 0
return {
id: step.id,
label: step.label,
status: step.status as WorkspaceWorkflow['steps'][number]['status'],
isGate: step.isGate ?? false,
x: baseX + Math.sin(stepIdx * 0.8) * 0.5,
y: (workflows.length / 2 - stepIdx) * 1.5,
z,
}
})
return { id: wf.id, name: wf.name, steps, teamId: undefined, color }
})
}
export function useWorkspaceData() {
const workflows = useBrainStore((s) => s.workflows)
const workspaceWorkflows = useMemo(() => computeLayout(workflows), [workflows])
return { workflows: workspaceWorkflows }
}

45
brain-ui/src/index.css Normal file
View File

@@ -0,0 +1,45 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* { box-sizing: border-box; }
body {
margin: 0;
background: #0d0d0d;
color: #e5e7eb;
font-family: 'Inter', system-ui, sans-serif;
}
/* React Flow overrides */
.react-flow__background { background: #0d0d0d; }
.react-flow__edge-path { stroke: #2a2a2a; }
/* Docs markdown */
.docs-markdown { max-width: 780px; line-height: 1.7; color: #d1d5db; }
.docs-markdown h1 { font-size: 1.75rem; font-weight: 700; color: #f3f4f6; margin: 0 0 0.5rem; padding-bottom: 0.5rem; border-bottom: 1px solid #2a2a2a; }
.docs-markdown h2 { font-size: 1.25rem; font-weight: 600; color: #e5e7eb; margin: 2rem 0 0.75rem; padding-bottom: 0.25rem; border-bottom: 1px solid #1f1f1f; }
.docs-markdown h3 { font-size: 1.05rem; font-weight: 600; color: #c4c8ce; margin: 1.5rem 0 0.5rem; }
.docs-markdown p { margin: 0.5rem 0; }
.docs-markdown strong { color: #f3f4f6; }
.docs-markdown code { background: #1e1e1e; color: #a78bfa; padding: 0.1em 0.4em; border-radius: 4px; font-size: 0.875em; }
.docs-markdown pre { background: #141414; border: 1px solid #2a2a2a; border-radius: 6px; padding: 1rem; overflow-x: auto; margin: 0.75rem 0; }
.docs-markdown pre code { background: none; padding: 0; color: #d1d5db; }
.docs-markdown table { width: 100%; border-collapse: collapse; margin: 0.75rem 0; font-size: 0.875rem; }
.docs-markdown th { text-align: left; padding: 0.5rem 0.75rem; background: #1a1a1a; color: #9ca3af; border-bottom: 1px solid #2a2a2a; font-weight: 600; }
.docs-markdown td { padding: 0.5rem 0.75rem; border-bottom: 1px solid #1a1a1a; }
.docs-markdown tr:hover td { background: rgba(99,102,241,0.05); }
.docs-markdown ul, .docs-markdown ol { margin: 0.5rem 0; padding-left: 1.5rem; }
.docs-markdown li { margin: 0.25rem 0; }
.docs-markdown hr { border: none; border-top: 1px solid #2a2a2a; margin: 1.5rem 0; }
.docs-markdown blockquote { border-left: 3px solid #6366f1; padding: 0.5rem 1rem; margin: 0.75rem 0; color: #9ca3af; background: rgba(99,102,241,0.05); border-radius: 0 4px 4px 0; }
.docs-markdown blockquote.tier-free { border-left-color: #22c55e; background: rgba(34,197,94,0.06); }
.docs-markdown blockquote.tier-free strong { color: #4ade80; }
.docs-markdown blockquote.tier-featured { border-left-color: #3b82f6; background: rgba(59,130,246,0.06); }
.docs-markdown blockquote.tier-featured strong { color: #60a5fa; }
.docs-markdown blockquote.tier-pro { border-left-color: #f59e0b; background: rgba(245,158,11,0.06); }
.docs-markdown blockquote.tier-pro strong { color: #fbbf24; }
.docs-markdown blockquote.tier-full { border-left-color: #a855f7; background: rgba(168,85,247,0.06); }
.docs-markdown blockquote.tier-full strong { color: #c084fc; }
.docs-markdown a { color: #818cf8; text-decoration: none; }
.docs-markdown a:hover { text-decoration: underline; }

10
brain-ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@@ -0,0 +1,36 @@
import { create } from 'zustand'
import type { Workflow } from '../types'
export interface LogLine {
ts: string
level: 'info' | 'warn' | 'error' | 'debug'
msg: string
}
interface BrainStore {
workflows: Workflow[]
logs: Record<string, LogLine[]>
wsStatus: 'connected' | 'disconnected' | 'error'
setWorkflows: (w: Workflow[]) => void
updateWorkflow: (w: Workflow) => void
appendLogs: (project: string, lines: LogLine[]) => void
clearLogs: (project: string) => void
setWsStatus: (s: BrainStore['wsStatus']) => void
}
export const useBrainStore = create<BrainStore>((set) => ({
workflows: [],
logs: {},
wsStatus: 'disconnected',
setWorkflows: (workflows) => set({ workflows }),
updateWorkflow: (w) => set((s) => ({
workflows: s.workflows.map((x) => (x.id === w.id ? w : x)),
})),
appendLogs: (project, lines) => set((s) => ({
logs: { ...s.logs, [project]: [...(s.logs[project] ?? []), ...lines] },
})),
clearLogs: (project) => set((s) => ({
logs: { ...s.logs, [project]: [] },
})),
setWsStatus: (wsStatus) => set({ wsStatus }),
}))

105
brain-ui/src/types/index.ts Normal file
View File

@@ -0,0 +1,105 @@
export type StepStatus = 'pending' | 'in-progress' | 'done' | 'gate' | 'partial' | 'fail' | 'blocked'
export interface WorkflowStep {
id: string
label: string
status: StepStatus
isGate?: boolean
}
export interface Workflow {
id: string
name: string
project: string
steps: WorkflowStep[]
}
// Team presets
export interface TeamPreset {
id: string
label: string
icon: string
agents: string[]
capabilities: string[]
gate_required: boolean
default_timeout_min: number
}
// WorkflowBuilder
export type StepDraftType = 'step' | 'gate'
export interface StepDraft {
id: string
label: string
type: StepDraftType
agentHint?: string
}
export interface WorkflowDraft {
title: string
teamId: string
steps: StepDraft[]
gateRequired: boolean
}
// Cosmos — Sprint 4
export type ZoneKey = 'public' | 'work' | 'kernel' | 'instance' | 'satellite' | 'unknown'
export interface CosmosPoint {
id: string
path: string
zone: ZoneKey
label: string
excerpt: string
x: number
y: number
z: number
}
export interface VisualizeResponse {
points: CosmosPoint[]
generated_at: string
cached: boolean
umap_params: {
n_components: 3
n_neighbors: number
min_dist: number
}
}
// Workspace — Sprint 5
export interface WorkspaceStep {
id: string
label: string
status: 'pending' | 'in-progress' | 'done' | 'gate' | 'fail' | 'blocked'
isGate?: boolean
x: number
y: number
z: number
}
export interface WorkspaceWorkflow {
id: string
name: string
steps: WorkspaceStep[]
teamId?: string
color: string
}
// InfraRegistry — Sprint 7
export interface InfraService {
id: string
name: string
type: 'pm2' | 'system' | 'info'
status: 'online' | 'stopped' | 'errored' | 'unknown'
port?: number | null
uptime?: number | null
restarts?: number
memory?: number
cpu?: number
}
export interface InfraResponse {
services: InfraService[]
total: number
}

11
brain-ui/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_USE_MOCK: string
readonly VITE_BRAIN_API: string
readonly VITE_BRAIN_TOKEN: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}

View File

@@ -0,0 +1,20 @@
export default {
content: ['./index.html', './src/**/*.{ts,tsx}'],
theme: {
extend: {
colors: {
brain: {
bg: '#0d0d0d',
surface: '#1a1a1a',
border: '#2a2a2a',
accent: '#6366f1',
gate: '#f59e0b',
ok: '#22c55e',
fail: '#ef4444',
muted: '#6b7280',
}
}
}
},
plugins: []
}

15
brain-ui/tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"noUnusedLocals": false,
"noUnusedParameters": false
},
"include": ["src"]
}

19
brain-ui/vite.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
base: '/ui/',
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:7700',
changeOrigin: true,
secure: false,
ws: true,
rewrite: (path: string) => path.replace(/^\/api/, ''),
},
},
},
})