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

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 />
}