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:
190
brain-ui/src/components/CommandPalette.tsx
Normal file
190
brain-ui/src/components/CommandPalette.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
155
brain-ui/src/components/DocsView.tsx
Normal file
155
brain-ui/src/components/DocsView.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
271
brain-ui/src/components/GateDrawer.tsx
Normal file
271
brain-ui/src/components/GateDrawer.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
128
brain-ui/src/components/GatesDrawer.tsx
Normal file
128
brain-ui/src/components/GatesDrawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
brain-ui/src/components/InfraRegistry.tsx
Normal file
121
brain-ui/src/components/InfraRegistry.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
brain-ui/src/components/LogDrawer.tsx
Normal file
202
brain-ui/src/components/LogDrawer.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
288
brain-ui/src/components/SecretsZone.tsx
Normal file
288
brain-ui/src/components/SecretsZone.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
128
brain-ui/src/components/StepNode.tsx
Normal file
128
brain-ui/src/components/StepNode.tsx
Normal 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)
|
||||
122
brain-ui/src/components/TeamSelector.tsx
Normal file
122
brain-ui/src/components/TeamSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
21
brain-ui/src/components/TierGate.tsx
Normal file
21
brain-ui/src/components/TierGate.tsx
Normal 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}</>
|
||||
}
|
||||
211
brain-ui/src/components/ToastProvider.tsx
Normal file
211
brain-ui/src/components/ToastProvider.tsx
Normal 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
|
||||
}
|
||||
208
brain-ui/src/components/WorkflowBoard.tsx
Normal file
208
brain-ui/src/components/WorkflowBoard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
283
brain-ui/src/components/WorkflowBuilder.tsx
Normal file
283
brain-ui/src/components/WorkflowBuilder.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
107
brain-ui/src/components/cosmos/CosmosControls.tsx
Normal file
107
brain-ui/src/components/cosmos/CosmosControls.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
212
brain-ui/src/components/cosmos/CosmosInfoPanel.tsx
Normal file
212
brain-ui/src/components/cosmos/CosmosInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
brain-ui/src/components/cosmos/CosmosMetrics.tsx
Normal file
91
brain-ui/src/components/cosmos/CosmosMetrics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
150
brain-ui/src/components/cosmos/CosmosPoints.tsx
Normal file
150
brain-ui/src/components/cosmos/CosmosPoints.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
41
brain-ui/src/components/cosmos/CosmosScene.tsx
Normal file
41
brain-ui/src/components/cosmos/CosmosScene.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
brain-ui/src/components/cosmos/CosmosView.tsx
Normal file
178
brain-ui/src/components/cosmos/CosmosView.tsx
Normal 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 />
|
||||
}
|
||||
64
brain-ui/src/components/workspace/CosmosBackground.tsx
Normal file
64
brain-ui/src/components/workspace/CosmosBackground.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
39
brain-ui/src/components/workspace/GateOctahedron.tsx
Normal file
39
brain-ui/src/components/workspace/GateOctahedron.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
43
brain-ui/src/components/workspace/StepSphere.tsx
Normal file
43
brain-ui/src/components/workspace/StepSphere.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
brain-ui/src/components/workspace/WorkflowConstellation.tsx
Normal file
91
brain-ui/src/components/workspace/WorkflowConstellation.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
149
brain-ui/src/components/workspace/WorkspaceInfoPanel.tsx
Normal file
149
brain-ui/src/components/workspace/WorkspaceInfoPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
59
brain-ui/src/components/workspace/WorkspaceMetrics.tsx
Normal file
59
brain-ui/src/components/workspace/WorkspaceMetrics.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
brain-ui/src/components/workspace/WorkspaceView.tsx
Normal file
120
brain-ui/src/components/workspace/WorkspaceView.tsx
Normal 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 />
|
||||
}
|
||||
Reference in New Issue
Block a user