feat: publish CodexPi contest snapshot
Some checks failed
Qualité / tests (push) Has been cancelled

This commit is contained in:
CodexPi
2026-11-14 20:23:59 +01:00
commit 995b17a6e2
295 changed files with 36803 additions and 0 deletions

View File

@@ -0,0 +1,75 @@
import AxeBuilder from '@axe-core/playwright'
import { expect, Page, test } from '@playwright/test'
const projects=[
{id:'project-tv',name:'TV Hub',path:'/home/tv-hub',owner_id:'owner',owner_username:'alice',kind:'local',archived:0,full_access:0,git_available:true,execution_group:'',groups:[]},
{id:'project-player',name:'Lecteur TV',path:'/home/tv-player',owner_id:'owner',owner_username:'alice',kind:'local',archived:0,full_access:0,git_available:true,execution_group:'',groups:[]},
{id:'project-remote',name:'Télécommande',path:'/home/tv-remote',owner_id:'owner',owner_username:'alice',kind:'local',archived:0,full_access:0,git_available:true,execution_group:'',groups:[]},
]
async function mockApi(page:Page){
let groups:any[]=[]
await page.route('**/api/**',async route=>{
const request=route.request(),url=new URL(request.url()),path=url.pathname,method=request.method()
let body:unknown={}
if(path==='/api/bootstrap')body={setup_required:false,csrf_token:'csrf'}
else if(path==='/api/auth/me')body={username:'alice',is_admin:true,csrf_token:'csrf'}
else if(path==='/api/projects'&&url.searchParams.get('archived')==='true')body=[]
else if(path==='/api/projects')body=projects
else if(path==='/api/projects/activity')body={running_project_ids:['project-player']}
else if(path==='/api/application/preferences')body={auto_compact_context:false,interface_type:'dashboard',theme:'dark'}
else if(path==='/api/duck/preferences')body={enabled:true,reminder_minutes:30}
else if(path==='/api/activity')body={cards:[],counts:{running:0,waiting:0,review:0},observed_at:'2026-11-12T20:00:00Z'}
else if(path==='/api/dashboard')body={connected:true,hostname:'NasPi',platform:'Linux',cpu_percent:17,cpu_count:4,ram_percent:42,ram_used:4_000_000_000,ram_total:8_000_000_000,temperature_c:48,disk_percent:55,disk_used:55_000_000_000,disk_total:100_000_000_000,uptime_seconds:86400,active_sessions:1,total_sessions:3,docker:{available:true,version:'27'},codex:{available:true,version:'1'},git:{available:true,version:'2.47'},codex_quota:{available:false}}
else if(path==='/api/project-groups'&&method==='GET')body=groups
else if(path==='/api/project-groups'&&method==='POST'){
const input=request.postDataJSON()
groups=[{id:'group-tv',key:'tv',owner_id:'owner',owner_username:'alice',name:input.name,description:input.description,member_count:input.project_ids.length,members:projects.filter(item=>input.project_ids.includes(item.id)),updates:[],created_at:'2026-11-12T20:00:00Z',updated_at:'2026-11-12T20:00:00Z'}]
body=groups[0]
}else if(path==='/api/search')body=[]
return route.fulfill({status:200,contentType:'application/json',body:JSON.stringify(body)})
})
}
test.beforeEach(async({page})=>{
await mockApi(page)
await page.goto('/?view=company')
await expect(page.getByRole('heading',{name:'Qui fait quoi ?'})).toBeVisible()
})
test('crée un groupe à partir de plusieurs projets existants',async({page})=>{
await page.getByRole('button',{name:'+ Groupe'}).first().click()
await page.getByLabel('Nom du groupe').fill('TV')
await page.getByLabel('Objectif commun').fill('Applications qui communiquent avec la télévision.')
await page.locator('label').filter({hasText:'TV Hub'}).click()
await page.locator('label').filter({hasText:'Lecteur TV'}).click()
const requestPromise=page.waitForRequest(request=>request.url().endsWith('/api/project-groups')&&request.method()==='POST')
await page.getByRole('button',{name:'Créer le groupe'}).click()
const request=await requestPromise
expect(request.postDataJSON().project_ids.sort()).toEqual(['project-player','project-tv'])
await expect(page.locator('.company-groups article>header b').filter({hasText:/^TV$/})).toBeVisible()
})
test('conserve la vue dans l’URL, au retour et au rechargement',async({page})=>{
await page.getByRole('button',{name:'Projets',exact:true}).click()
await expect(page).toHaveURL(/view=dashboard/)
await expect(page.getByRole('heading',{name:'Projets et chefs d’équipe'})).toBeVisible()
await page.goBack()
await expect(page).toHaveURL(/view=company/)
await page.reload()
await expect(page.getByRole('heading',{name:'Qui fait quoi ?'})).toBeVisible()
})
test('le lanceur est utilisable au clavier et la vue ne présente pas de violation axe sérieuse',async({page})=>{
await page.keyboard.press('Control+K')
const dialog=page.getByRole('dialog',{name:'Rechercher et ouvrir'})
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toBeHidden()
const results=await new AxeBuilder({page}).withTags(['wcag2a','wcag2aa','wcag21a','wcag21aa']).analyze()
expect(results.violations.filter(item=>item.impact==='critical'||item.impact==='serious')).toEqual([])
})
test('capture la vue Organisation de référence',async({page})=>{
await expect(page).toHaveScreenshot('organisation.png',{fullPage:true})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

17
frontend/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!doctype html>
<html lang="fr">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0b0c0e" />
<meta name="description" content="Atelier Codex privé sur Raspberry Pi" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="CodexPi" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>CodexPi</title>
</head>
<body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body>
</html>

3167
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "codexpi-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "node scripts/generate-icons.mjs && tsc -b && vite build",
"lint": "tsc --noEmit",
"test": "node --test --experimental-strip-types tests/*.test.ts",
"test:e2e": "playwright test",
"test:e2e:update": "playwright test --update-snapshots"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"diff": "^8.0.2",
"highlight.js": "^11.11.1",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-markdown": "^10.1.0",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@axe-core/playwright": "^4.14.0",
"@playwright/test": "^1.65.0",
"@vitejs/plugin-react": "^6.3.0",
"sharp": "^0.35.5",
"typescript": "^5.9.2",
"vite": "^8.3.8"
}
}

View File

@@ -0,0 +1,15 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir:'./e2e',
fullyParallel:true,
timeout:30_000,
expect:{timeout:5_000,toHaveScreenshot:{animations:'disabled',maxDiffPixelRatio:.01}},
reporter:process.env.CI?'github':'line',
use:{baseURL:process.env.CODEXPI_E2E_BASE_URL||'http://127.0.0.1:4173',trace:'retain-on-failure',screenshot:'only-on-failure',serviceWorkers:'block'},
webServer:process.env.CODEXPI_E2E_BASE_URL?undefined:{command:'npm run dev -- --port 4173',url:'http://127.0.0.1:4173',reuseExistingServer:!process.env.CI,timeout:120_000},
projects:[
{name:'ordinateur',use:{...devices['Desktop Chrome'],viewport:{width:1440,height:900}}},
{name:'mobile-375',use:{...devices['iPhone 13'],viewport:{width:375,height:812}}},
],
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

View File

@@ -0,0 +1,7 @@
export function relativePointerPosition(pointer,dx,dy,scaleX,scaleY,speed,width,height){
const clamp=(value,min,max)=>Math.min(max,Math.max(min,value))
return {
x:clamp(pointer.x+dx*scaleX*speed,0,Math.max(0,width-1)),
y:clamp(pointer.y+dy*scaleY*speed,0,Math.max(0,height-1)),
}
}

View File

@@ -0,0 +1,154 @@
import UI from '/browser/app/ui.js'
import {relativePointerPosition} from '/browser-touchpad-math.js'
const POINTER_SPEED=1.35
const MOVE_THRESHOLD=6
const LONG_PRESS_DELAY=560
const DOUBLE_TAP_DELAY=320
const SCROLL_STEP=18
let installedCanvas=null
let touchSurface=null
let pointer={x:0,y:0}
let primaryTouch=null
let origin=null
let lastPoint=null
let moved=false
let longPressed=false
let dragging=false
let longPressTimer=null
let lastTapAt=0
let secondTap=false
let scrollY=null
let scrollRemainder=0
function rfb(){return UI.rfb}
function canvas(){return rfb()?._canvas||null}
function bounds(){const target=canvas();return target?{target,rect:target.getBoundingClientRect()}:null}
function syncPointer(){
const remote=rfb()?._mousePos
const target=canvas()
if(Number.isFinite(remote?.x)&&Number.isFinite(remote?.y)){pointer={x:remote.x,y:remote.y}}
else if(target){pointer={x:target.width/2,y:target.height/2}}
}
function movePointer(dx,dy){
const current=rfb(),geometry=bounds()
if(!current||!geometry||!geometry.rect.width||!geometry.rect.height)return
const scaleX=geometry.target.width/geometry.rect.width
const scaleY=geometry.target.height/geometry.rect.height
pointer=relativePointerPosition(pointer,dx,dy,scaleX,scaleY,POINTER_SPEED,geometry.target.width,geometry.target.height)
current._handleMouseMove(pointer.x,pointer.y)
current._cursor?.move(geometry.rect.left+pointer.x/scaleX,geometry.rect.top+pointer.y/scaleY)
}
function click(mask=1){
const current=rfb()
if(!current)return
syncPointer()
current._handleMouseButton(pointer.x,pointer.y,mask)
current._handleMouseButton(pointer.x,pointer.y,0)
}
function beginDrag(){
if(dragging||!rfb())return
dragging=true
rfb()._handleMouseButton(pointer.x,pointer.y,1)
}
function endDrag(){
if(!dragging||!rfb())return
rfb()._handleMouseButton(pointer.x,pointer.y,0)
dragging=false
}
function clearLongPress(){if(longPressTimer!==null){clearTimeout(longPressTimer);longPressTimer=null}}
function touchAverage(touches){let total=0;for(const touch of touches)total+=touch.clientY;return touches.length?total/touches.length:0}
function wheel(direction){
const current=rfb()
if(!current)return
const mask=direction>0?8:16
current._handleMouseButton(pointer.x,pointer.y,mask)
current._handleMouseButton(pointer.x,pointer.y,0)
}
function consume(event){event.preventDefault();event.stopImmediatePropagation()}
function onTouchStart(event){
consume(event)
canvas()?.focus()
if(event.touches.length>=2){
clearLongPress();endDrag();primaryTouch=null;scrollY=touchAverage(event.touches);scrollRemainder=0;return
}
const touch=event.changedTouches[0]
if(!touch)return
syncPointer()
primaryTouch=touch.identifier
origin={x:touch.clientX,y:touch.clientY}
lastPoint={...origin}
moved=false
longPressed=false
secondTap=Date.now()-lastTapAt<DOUBLE_TAP_DELAY
clearLongPress()
if(!secondTap)longPressTimer=setTimeout(()=>{longPressed=true;click(4);navigator.vibrate?.(25)},LONG_PRESS_DELAY)
}
function onTouchMove(event){
consume(event)
if(event.touches.length>=2){
clearLongPress()
const next=touchAverage(event.touches)
if(scrollY!==null){
scrollRemainder+=next-scrollY
while(Math.abs(scrollRemainder)>=SCROLL_STEP){const direction=Math.sign(scrollRemainder);wheel(direction);scrollRemainder-=direction*SCROLL_STEP}
}
scrollY=next
return
}
const touch=[...event.touches].find(item=>item.identifier===primaryTouch)
if(!touch||!lastPoint||!origin)return
const total=Math.hypot(touch.clientX-origin.x,touch.clientY-origin.y)
if(total>MOVE_THRESHOLD){moved=true;clearLongPress();if(secondTap)beginDrag()}
movePointer(touch.clientX-lastPoint.x,touch.clientY-lastPoint.y)
lastPoint={x:touch.clientX,y:touch.clientY}
}
function onTouchEnd(event){
consume(event)
if(event.touches.length>=2){scrollY=touchAverage(event.touches);return}
if(event.touches.length===1){
scrollY=null
const touch=event.touches[0]
primaryTouch=touch.identifier;origin={x:touch.clientX,y:touch.clientY};lastPoint={...origin};return
}
clearLongPress();endDrag()
if(!moved&&!longPressed){click(1);lastTapAt=Date.now()}else if(moved){lastTapAt=0}
primaryTouch=null;origin=null;lastPoint=null;scrollY=null;scrollRemainder=0;secondTap=false
}
function onTouchCancel(event){
consume(event);clearLongPress();endDrag()
primaryTouch=null;origin=null;lastPoint=null;scrollY=null;scrollRemainder=0;secondTap=false;moved=false
}
function installTouchSurface(target){
if(!touchSurface){
touchSurface=document.createElement('div')
touchSurface.id='codexpi-relative-touch-surface'
touchSurface.setAttribute('aria-label','Pavé tactile relatif')
// noVNC draws its software cursor at z-index 65535. Stay immediately
// below it so touches hit this surface while the pointer remains visible.
Object.assign(touchSurface.style,{position:'fixed',zIndex:'65534',background:'transparent',touchAction:'none',userSelect:'none'})
touchSurface.addEventListener('touchstart',onTouchStart,{capture:true,passive:false})
touchSurface.addEventListener('touchmove',onTouchMove,{capture:true,passive:false})
touchSurface.addEventListener('touchend',onTouchEnd,{capture:true,passive:false})
touchSurface.addEventListener('touchcancel',onTouchCancel,{capture:true,passive:false})
document.body.appendChild(touchSurface)
}
const rect=target.getBoundingClientRect()
Object.assign(touchSurface.style,{left:`${rect.left}px`,top:`${rect.top}px`,width:`${rect.width}px`,height:`${rect.height}px`,display:rect.width&&rect.height?'block':'none'})
}
function install(){
const target=canvas()
if(!target){if(touchSurface)touchSurface.style.display='none';return}
target.style.touchAction='none'
target.style.userSelect='none'
installTouchSurface(target)
if(target!==installedCanvas){
installedCanvas=target
syncPointer()
}
}
window.codexpiTouchpad={click,install}
window.setInterval(install,500)
install()

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#111316"/>
<rect x="74" y="74" width="364" height="364" rx="92" fill="#e8e9e4"/>
<path d="M173 286c0-61 36-101 88-101 34 0 62 16 78 44l-45 26c-8-15-19-22-34-22-23 0-39 20-39 53 0 34 16 54 40 54 16 0 28-8 35-24l46 24c-16 31-46 49-82 49-52 0-87-40-87-103Z" fill="#111316"/>
<circle cx="354" cy="167" r="22" fill="#5ab978"/>
</svg>

After

Width:  |  Height:  |  Size: 446 B

View File

@@ -0,0 +1,16 @@
{
"name": "CodexPi",
"short_name": "CodexPi",
"description": "Atelier Codex privé hébergé sur Raspberry Pi",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0b0c0e",
"theme_color": "#0b0c0e",
"orientation": "any",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
{ "src": "/icons/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}

58
frontend/public/sw.js Normal file
View File

@@ -0,0 +1,58 @@
const CACHE = 'codexpi-shell-v18'
const SHELL = ['/', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png', '/icons/apple-touch-icon.png']
self.addEventListener('install', event => {
event.waitUntil(caches.open(CACHE).then(cache => cache.addAll(SHELL)).then(() => self.skipWaiting()))
})
self.addEventListener('activate', event => {
event.waitUntil(caches.keys().then(keys => Promise.all(keys.filter(key => key !== CACHE).map(key => caches.delete(key)))).then(() => self.clients.claim()))
})
self.addEventListener('fetch', event => {
const request = event.request
const url = new URL(request.url)
if (request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/') || url.pathname.startsWith('/ws/')) return
if (url.pathname === '/version.json' || url.pathname === '/sw.js') {
event.respondWith(fetch(request, {cache: 'no-store'}))
return
}
if (request.mode === 'navigate') {
event.respondWith(fetch(request).then(response => {
const copy = response.clone()
caches.open(CACHE).then(cache => cache.put('/', copy))
return response
}).catch(() => caches.match('/')))
return
}
event.respondWith(caches.match(request).then(cached => cached || fetch(request).then(response => {
if (response.ok) caches.open(CACHE).then(cache => cache.put(request, response.clone()))
return response
})))
})
self.addEventListener('push', event => {
let message = {}
try { message = event.data ? event.data.json() : {} } catch {}
event.waitUntil(self.registration.showNotification(message.title || 'Tâche CodexPi terminée', {
body: message.body || 'Ouvrez CodexPi pour consulter le résultat.',
icon: '/icons/icon-192.png', badge: '/icons/icon-192.png',
tag: message.tag || 'codexpi-task', data: {url: message.url || '/'},
}))
})
self.addEventListener('notificationclick', event => {
event.notification.close()
event.waitUntil((async () => {
const target = new URL(event.notification.data?.url || '/', self.location.origin)
if (target.origin !== self.location.origin) return
const windows = await self.clients.matchAll({type: 'window', includeUncontrolled: true})
for (const client of windows) {
if (new URL(client.url).origin === target.origin && 'navigate' in client) {
const navigated = await client.navigate(target.href)
if (navigated) { await navigated.focus(); return }
}
}
await self.clients.openWindow(target.href)
})())
})

View File

@@ -0,0 +1,58 @@
import sharp from 'sharp'
import { fileURLToPath } from 'node:url'
import { dirname, join } from 'node:path'
import { mkdir } from 'node:fs/promises'
const here = dirname(fileURLToPath(import.meta.url))
const source = join(here, '..', 'public', 'icons', 'icon.svg')
const output = join(here, '..', 'public', 'icons')
const appOutput = join(here, '..', 'public', 'app-icons')
const appIcons = [
['loutre-os','dashboard','#0a84ff','#0056b8'],['network-guardian','network','#34c759','#087d34'],
['reverse-proxy-manager','proxy','#64d2ff','#0079a8'],['codex-commander','terminal','#bf5af2','#6b25a2'],
['autonomous-dev-studio','studio','#5e5ce6','#2f2e9f'],['loutre-brain','brain','#ff2d55','#a90e30'],
['loutre-finance','finance','#30d158','#058632'],['print-pilot','printer','#ff9f0a','#c45f00'],
['loutre-home','home','#ffd60a','#d58a00'],['auto-care','car','#8e8e93','#3a3a3c'],
['smart-inventory','inventory','#0a84ff','#144ab8'],['knowledge-graph','graph','#ac8e68','#6d5035'],
['trip-organizer','trip','#5ac8fa','#0578af'],['pwa-factory','phone','#ff375f','#a80f38'],
['nasflix-media-health','media-health','#ff453a','#a91e18'],['nasflix-ai-media-curator','media-ai','#af52de','#592488'],
['pi-doctor','doctor','#32d74b','#087923'],['loutre-app-store','store','#007aff','#123fba'],
]
const symbols = {
dashboard: '<path d="M142 326a132 132 0 1 1 228 0"/><path d="m256 256 70-62"/><circle cx="256" cy="256" r="21"/><path d="M156 353h200"/>',
network: '<circle cx="256" cy="151" r="42"/><circle cx="151" cy="337" r="42"/><circle cx="361" cy="337" r="42"/><path d="m235 188-63 112m105-112 63 112M193 337h126"/>',
proxy: '<rect x="127" y="136" width="258" height="70" rx="22"/><rect x="127" y="306" width="258" height="70" rx="22"/><path d="M177 256h158m-36-36 36 36-36 36"/><circle cx="170" cy="171" r="8" fill="white"/><circle cx="170" cy="341" r="8" fill="white"/>',
terminal: '<rect x="102" y="119" width="308" height="274" rx="40"/><path d="m155 206 55 50-55 50m99 0h102"/>',
studio: '<path d="m126 357 85-85m90-90 85-85M168 119l225 225-49 49-225-225z"/><path d="m285 128 38 38m34-86 9 31 31 9-31 9-9 31-9-31-31-9 31-9z"/>',
brain: '<path d="M211 371c-55 18-98-34-74-78-43-30-25-100 28-102-4-52 65-78 91-34 26-44 95-18 91 34 53 2 71 72 28 102 24 44-19 96-74 78-12 25-78 24-90 0z"/><path d="M256 157v235m-91-201c41 7 57 31 50 70m82-70c-28 12-37 36-29 69m-131 33c33-16 61-10 79 17m159-17c-33-16-61-10-79 17"/>',
finance: '<path d="M123 377h266M153 337V236m81 101V171m81 166v-72m74 72V122"/><path d="m137 207 91-78 84 59 77-91"/>',
printer: '<rect x="126" y="101" width="260" height="166" rx="28"/><path d="M196 267v72h120v-72m-93 72-38 72h142l-38-72"/><path d="m190 190 66 38 66-38-66-38z"/>',
home: '<path d="m94 247 162-137 162 137"/><path d="M133 221v180h246V221M221 401V291h70v110"/>',
car: '<path d="m124 277 42-102c8-19 26-31 47-31h86c21 0 39 12 47 31l42 102"/><rect x="93" y="250" width="326" height="112" rx="36"/><circle cx="163" cy="362" r="30"/><circle cx="349" cy="362" r="30"/><path d="M116 293h55m170 0h55"/>',
inventory: '<path d="m256 95 143 76-143 76-143-76zM113 171v170l143 76 143-76V171M256 247v170"/><path d="m183 133 143 76"/>',
graph: '<circle cx="130" cy="263" r="40"/><circle cx="261" cy="126" r="40"/><circle cx="382" cy="246" r="40"/><circle cx="263" cy="386" r="40"/><path d="m158 234 74-78m57-2 65 66m0 55-64 82m-56 0-76-67"/>',
trip: '<path d="m81 279 350-137-137 350-36-159z"/><path d="m258 333 83-83"/>',
phone: '<rect x="158" y="72" width="196" height="368" rx="42"/><path d="M217 115h78m-66 278h54"/><path d="m93 177 17 39 39 17-39 17-17 39-17-39-39-17 39-17z"/>',
'media-health': '<rect x="94" y="118" width="324" height="276" rx="38"/><path d="M94 188h324M174 118v70m82-70v70m82-70v70"/><path d="M167 293h48l24-48 40 97 28-49h43"/>',
'media-ai': '<rect x="87" y="128" width="338" height="264" rx="38"/><path d="M87 198h338M172 128v70m84-70v70m84-70v70"/><path d="m277 256 18 43 43 18-43 18-18 43-18-43-43-18 43-18z"/>',
doctor: '<path d="M205 86h102v119h119v102H307v119H205V307H86V205h119z"/><path d="m117 355 48-43 42 43 48-86 42 61 35-36h63"/>',
store: '<path d="M135 190h242l28 228H107z"/><path d="M190 209v-37c0-88 132-88 132 0v37"/><rect x="159" y="258" width="64" height="64" rx="14" fill="white"/><rect x="289" y="258" width="64" height="64" rx="14" fill="white"/><rect x="159" y="340" width="64" height="48" rx="14" fill="white"/><rect x="289" y="340" width="64" height="48" rx="14" fill="white"/>',
}
function appSvg(symbol,top,bottom){return Buffer.from(`<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1"><stop stop-color="${top}"/><stop offset="1" stop-color="${bottom}"/></linearGradient></defs><rect width="512" height="512" rx="112" fill="url(#g)"/><circle cx="388" cy="112" r="92" fill="#fff" opacity=".12"/><g fill="none" stroke="white" stroke-width="24" stroke-linecap="round" stroke-linejoin="round">${symbols[symbol]}</g></svg>`)}
await mkdir(appOutput,{recursive:true})
await Promise.all([
sharp(source).resize(192, 192).png().toFile(join(output, 'icon-192.png')),
sharp(source).resize(512, 512).png().toFile(join(output, 'icon-512.png')),
sharp(source).resize(512, 512).extend({ top: 51, bottom: 51, left: 51, right: 51, background: '#111316' }).resize(512, 512).png().toFile(join(output, 'icon-maskable-512.png')),
sharp(source).resize(180, 180).png().toFile(join(output, 'apple-touch-icon.png')),
...appIcons.flatMap(([slug,symbol,top,bottom])=>[
sharp(appSvg(symbol,top,bottom)).resize(192,192).png().toFile(join(appOutput,`${slug}-192.png`)),
sharp(appSvg(symbol,top,bottom)).resize(512,512).png().toFile(join(appOutput,`${slug}-512.png`)),
sharp(appSvg(symbol,top,bottom)).resize(180,180).png().toFile(join(appOutput,`${slug}-apple.png`)),
]),
])

32
frontend/src/Activity.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import { ActivityCard, ActivityData, DashboardData, WorkspaceTab } from './types'
import { relativeDate } from './utils'
import { sharedApi } from './sharedApi'
import { useVisiblePolling } from './useVisiblePolling'
export function Activity({onOpen,onOrchestrate,onAdd,onError}:{onOpen:(projectId:string,conversationId:string,tab:WorkspaceTab)=>void;onOrchestrate:(id?:string)=>void;onAdd:()=>void;onError:(error:Error|string)=>void}){
const[data,setData]=useState<ActivityData|null>(null),[system,setSystem]=useState<DashboardData|null>(null),[filter,setFilter]=useState<'all'|'running'|'waiting'|'review'>(()=>{const saved=localStorage.getItem('codexpi-activity-filter');return['running','waiting','review'].includes(saved||'')?saved as 'running'|'waiting'|'review':'all'}),[query,setQuery]=useState(''),[layout,setLayout]=useState<'board'|'feed'>(()=>innerWidth<700?'feed':'board'),[browserOffline,setBrowserOffline]=useState(!navigator.onLine),[apiOffline,setApiOffline]=useState(false)
const refresh=useCallback(async()=>{try{const[value,pi]=await Promise.all([sharedApi<ActivityData>('/api/activity'),sharedApi<DashboardData>('/api/dashboard')]);setData(value);setSystem(pi);setApiOffline(false)}catch{setApiOffline(true)}},[])
useVisiblePolling(refresh,8000)
useEffect(()=>{const online=()=>{setBrowserOffline(false);void refresh()},offline=()=>setBrowserOffline(true);addEventListener('online',online);addEventListener('offline',offline);return()=>{removeEventListener('online',online);removeEventListener('offline',offline)}},[refresh])
useEffect(()=>localStorage.setItem('codexpi-activity-filter',filter),[filter])
const cards=useMemo(()=>{const needle=query.trim().toLocaleLowerCase();return(data?.cards||[]).filter(card=>(filter==='all'||card.state===filter)&&(!needle||`${card.project_name} ${card.title} ${card.agent} ${card.branch||''}`.toLocaleLowerCase().includes(needle)))},[data,filter,query])
return <div className="cp-page activity-shell activity-canvas-page">
<header className="page-header activity-page-header"><div><p className="eyebrow">JOURNAL UNIFIÉ</p><h1>Activité de l’entreprise</h1><p>Conversations, agents, validations et signaux réels.</p>{(browserOffline||apiOffline)&&<span className="offline-banner">{browserOffline?'Appareil hors ligne':'Serveur CodexPi inaccessible'} — dernière activité connue</span>}</div><div><button className="button secondary" onClick={onAdd}>+ Projet</button><button className="button activity-primary" onClick={()=>onOrchestrate()}>+ Chantier</button></div></header>
<div className="activity-toolbar"><div className="activity-layout-tabs"><button className={layout==='board'?'active':''} onClick={()=>setLayout('board')}>▦ Tableau</button><button className={layout==='feed'?'active':''} onClick={()=>setLayout('feed')}>☷ Flux</button></div><label>⌕<input value={query} onChange={event=>setQuery(event.target.value)} placeholder="Rechercher un projet, une tâche…"/></label></div>
<div className="activity-canvas-grid">
<section className="canvas-window activity-journal-window"><header className="canvas-window-head"><div><i/><b>Journal unifié</b></div><small>temps réel</small></header><div className="activity-filters"><Filter label="Tous" active={filter==='all'} count={data?.cards.length||0} onClick={()=>setFilter('all')}/><Filter label="En cours" active={filter==='running'} count={data?.counts.running||0} onClick={()=>setFilter('running')}/><Filter label="En attente" active={filter==='waiting'} count={data?.counts.waiting||0} onClick={()=>setFilter('waiting')}/><Filter label="À relire" active={filter==='review'} count={data?.counts.review||0} onClick={()=>setFilter('review')}/></div>{!data?<div className="activity-loading">Chargement de l’activité réelle…</div>:cards.length===0?<div className="activity-empty"><span>✓</span><h2>Aucune tâche dans ce filtre</h2><p>Les conversations, validations et signaux apparaîtront ici en direct.</p></div>:<><div className={`activity-board ${layout!=='board'?'layout-hidden':''}`}>{(['running','waiting','review'] as const).map(state=><section key={state}><h2><span>{state==='running'?'▷':state==='waiting'?'Ⅱ':'▤'} {state==='running'?'En cours':state==='waiting'?'En attente':'À relire'}</span><b>{cards.filter(card=>card.state===state).length}</b></h2>{cards.filter(card=>card.state===state).map(card=><TaskCard key={`${card.kind}-${card.id}`} card={card} onOpen={onOpen} onOrchestrate={onOrchestrate} onRefresh={refresh} onError={onError}/>)}</section>)}</div><div className={`activity-feed ${layout!=='feed'?'layout-hidden':''}`}>{cards.map(card=><TaskCard key={`${card.kind}-${card.id}`} card={card} onOpen={onOpen} onOrchestrate={onOrchestrate} onRefresh={refresh} onError={onError}/>)}</div></>}</section>
<aside className="activity-canvas-side"><section className="canvas-window"><header className="canvas-window-head"><div><i/><b>Capacité</b></div><small>Raspberry Pi</small></header><div className="activity-capacity">{system?<><strong><i/> {system.hostname}</strong><small>Connecté · {system.active_sessions} session{system.active_sessions>1?'s':''} active{system.active_sessions>1?'s':''}</small><Metric label="CPU" value={system.cpu_percent}/><Metric label="RAM" value={system.ram_percent}/><Metric label="Stockage" value={system.disk_percent}/></>:<p>Chargement du serveur…</p>}</div></section><section className="canvas-window"><header className="canvas-window-head"><div><i/><b>Validations</b></div><small>à traiter</small></header><div className="activity-summary"><span><b>{data?.counts.running||0}</b><small>En cours</small></span><span><b>{data?.counts.waiting||0}</b><small>En attente</small></span><span><b>{data?.counts.review||0}</b><small>À relire</small></span></div></section></aside>
</div>
</div>
}
function Metric({label,value}:{label:string;value:number}){return <div className="activity-metric"><span>{label}</span><div><i style={{width:`${Math.max(0,Math.min(100,value))}%`}}/></div><b>{Math.round(value)}%</b></div>}
function Filter({label,count,active,onClick}:{label:string;count:number;active:boolean;onClick:()=>void}){return <button className={active?'active':''} onClick={onClick}>{label}<b>{count}</b></button>}
function TaskCard({card,onOpen,onOrchestrate,onRefresh,onError}:{card:ActivityCard;onOpen:(projectId:string,conversationId:string,tab:WorkspaceTab)=>void;onOrchestrate:(id?:string)=>void;onRefresh:()=>Promise<void>;onError:(error:Error|string)=>void}){
const action=(tab:WorkspaceTab)=>onOpen(card.project_id,card.id,tab)
async function duck(signalId:string,operation:'dismiss'|'checkpoint'|'suspend'){try{await api(`/api/duck-signals/${signalId}/action`,{method:'POST',body:JSON.stringify({action:operation})});await onRefresh()}catch(reason){onError(reason as Error)}}
return <article className={`activity-card ${card.state}`}><header><div><span>{card.kind==='orchestration'?'♙':'◇'}</span><b>{card.project_name}</b></div><em>{card.state==='running'?'En cours':card.state==='waiting'?'En attente':'À relire'}</em></header><h3>{card.title}</h3><div className="activity-tags"><span>♙ {card.agent}</span>{card.branch&&<code>⑂ {card.branch}</code>}{card.mode&&<span>{card.mode}</span>}</div><p className="activity-step">{card.last_step}</p>{card.error&&<p className="activity-error">{card.error}</p>}<small>Dernière activité {relativeDate(card.last_activity)}</small>{card.signals.map(signal=><aside className="duck-signal" key={signal.id}><b>🦆 Point d’attention</b><p>{signal.kind==='repeated_failure'?`${signal.occurrence_count} échecs similaires observés.`:'Tâche longue; aucune conclusion de blocage.'} Dernière activité {signal.last_activity?relativeDate(signal.last_activity):'inconnue'}.</p><div><button onClick={()=>action('chat')}>Voir les journaux</button><button onClick={()=>void duck(signal.id,'checkpoint')}>Demander un point</button><button onClick={()=>void duck(signal.id,'suspend')}>Suspendre</button><button aria-label="Masquer" onClick={()=>void duck(signal.id,'dismiss')}>×</button></div></aside>)}<footer>{card.kind==='orchestration'?<button className="primary" onClick={()=>onOrchestrate(card.id)}>Ouvrir le suivi</button>:<><button className="primary" onClick={()=>action('chat')}>Discussion</button><button onClick={()=>action('preview')}>Aperçus</button><button onClick={()=>action('changes')}>Diff</button></>}</footer></article>
}

View File

@@ -0,0 +1,34 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { copyText, Modal } from './Common'
import { TerminalPanel } from './RuntimePanels'
import { Project } from './types'
type AgentAccount={
id:'codex'|'claude'|'opencode'
installed:boolean
connected:boolean
account:string|null
command:string
}
const DETAILS:Record<AgentAccount['id'],{name:string;company:string;icon:string;description:string}>={
codex:{name:'OpenAI Codex',company:'OpenAI',icon:'C',description:'Utilisez votre abonnement ChatGPT/Codex avec la connexion par appareil.'},
claude:{name:'Claude Code',company:'Anthropic',icon:'A',description:'Connectez un compte Claude pris en charge par le CLI officiel.'},
opencode:{name:'OpenCode',company:'Multi-fournisseurs',icon:'O',description:'Choisissez ensuite le fournisseur et le modèle depuis OpenCode.'},
}
export function AgentAccounts({project}:{project:Project|null}){
const[accounts,setAccounts]=useState<AgentAccount[]>([]),[selected,setSelected]=useState<AgentAccount|null>(null),[loading,setLoading]=useState(true),[checking,setChecking]=useState(false),[error,setError]=useState(''),[copied,setCopied]=useState(false)
const refresh=useCallback(async()=>{setLoading(true);setError('');try{const rows=await api<AgentAccount[]>('/api/agents/status');setAccounts(rows);setSelected(current=>current?rows.find(item=>item.id===current.id)||current:null)}catch(reason){setError((reason as Error).message)}finally{setLoading(false)}},[])
useEffect(()=>{void refresh()},[refresh])
async function copyCommand(command:string){setCopied(await copyText(command));window.setTimeout(()=>setCopied(false),1600)}
async function verify(){setChecking(true);await refresh();setChecking(false)}
return <>
<div className="settings-section-intro"><div><p className="eyebrow">MOTEURS D’AGENTS</p><h2>Comptes IA</h2><p>Connectez les moteurs installés sur CodexPi. Les identifiants restent gérés par leurs outils officiels et ne sont jamais enregistrés dans l’application.</p></div><button className="button secondary" onClick={()=>void refresh()} disabled={loading}>{loading?'Vérification…':'↻ Actualiser'}</button></div>
{error&&<div className="inline-error" role="alert">{error}</div>}
<div className="agent-account-grid">{accounts.map(account=>{const detail=DETAILS[account.id];return <article className="agent-account-card" key={account.id}><div className={`agent-logo ${account.connected?'connected':''}`}>{detail.icon}</div><div className="agent-account-copy"><small>{detail.company}</small><h3>{detail.name}</h3><p>{detail.description}</p>{account.account&&<code>{account.account}</code>}</div><span className={`agent-state ${account.connected?'connected':account.installed?'disconnected':'missing'}`}>{account.connected?'● Connecté':account.installed?'○ Non connecté':'Non installé'}</span><button className={account.connected?'button secondary':'button primary'} disabled={!account.installed} onClick={()=>setSelected(account)}>{account.connected?'Gérer':'Connecter'}</button></article>})}{!loading&&!accounts.length&&<p>Aucun moteur détecté.</p>}</div>
<div className="agent-security-note"><span>⌾</span><div><b>Connexion administrateur et commune au serveur</b><p>Ces comptes alimentent tous les projets autorisés sur cette installation CodexPi. Chaque utilisateur conserve ses projets et ses discussions séparés.</p></div></div>
{selected&&<Modal title={`Connecter ${DETAILS[selected.id].name}`} onClose={()=>setSelected(null)} wide><div className="agent-connect-modal"><div className="agent-connect-steps"><span>1</span><div><b>Lancez la commande officielle</b><p>Le fournisseur peut afficher un lien et un code. Ouvrez le lien dans votre navigateur et validez vous-même la connexion.</p></div></div><div className="agent-command"><code>{selected.command}</code><button type="button" onClick={()=>void copyCommand(selected.command)}>{copied?'✓ Copiée':'Copier'}</button></div>{project&&project.kind!=='remote'?<div className="agent-terminal-shell"><TerminalPanel project={project}/></div>:<div className="agent-project-needed"><b>Sélectionnez un projet local</b><p>Le terminal de connexion s’ouvre depuis un projet local. Les projets reliés par l’agent distant ne peuvent pas modifier les comptes de ce serveur.</p></div>}<div className="agent-connect-steps"><span>2</span><div><b>Vérifiez la connexion</b><p>Une fois l’autorisation terminée dans le terminal, actualisez le statut.</p></div></div>{error&&<div className="inline-error" role="alert">{error}</div>}<div className="modal-actions"><button className="button secondary" onClick={()=>setSelected(null)}>Fermer</button><button className="button primary" onClick={()=>void verify()} disabled={checking}>{checking?'Vérification…':'J’ai terminé — vérifier'}</button></div></div></Modal>}
</>
}

189
frontend/src/App.tsx Normal file
View File

@@ -0,0 +1,189 @@
import { FormEvent, lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { api, refreshCsrf, setCsrf } from './api'
import { AutonomyDialog, ProjectDialog } from './Common'
import { AppearanceTheme, ApplicationPreferences, CodexOptions, DashboardData, InterfaceType, Project, ProjectActivity, RepairRecord, WorkspaceTab } from './types'
import { PushSettings, disablePush } from './PushSettings'
import { RepositorySettings } from './RepositorySettings'
import { SSHServersSettings } from './SSHServersSettings'
import { UserManagement } from './UserManagement'
import { AgentAccounts } from './AgentAccounts'
import { expectArrayPayload } from './apiPayload'
import { ProjectRulesSettings, RulesSettings } from './RulesSettings'
import { interfaceLanding, rememberAppearance, storedInterfaceType, storedTheme } from './appearance'
import { useDialogFocus } from './dialogFocus'
import { sharedApi } from './sharedApi'
import { useVisiblePolling } from './useVisiblePolling'
import { MainView, mainViews, RouteOptions, routeHref, routeView } from './appRoutes'
import { Icon, IconName } from './Icon'
const Company=lazy(()=>import('./Company').then(module=>({default:module.Company})))
const Activity=lazy(()=>import('./Activity').then(module=>({default:module.Activity})))
const Dashboard=lazy(()=>import('./Dashboard').then(module=>({default:module.Dashboard})))
const Ecosystem=lazy(()=>import('./Ecosystem').then(module=>({default:module.Ecosystem})))
const Workspace=lazy(()=>import('./Workspace').then(module=>({default:module.Workspace})))
const Orchestrator=lazy(()=>import('./Orchestrator').then(module=>({default:module.Orchestrator})))
const MasterAgent=lazy(()=>import('./MasterAgent').then(module=>({default:module.MasterAgent})))
const RemotePanel=lazy(()=>import('./RemotePanel').then(module=>({default:module.RemotePanel})))
type CurrentUser={username:string;is_admin:boolean}
const viewMeta:Record<MainView,{eyebrow:string;title:string;detail:string}>={
company:{eyebrow:'CODEXPI · ORGANISATION',title:'Entreprise',detail:'Agents, projets et responsabilités'},
dashboard:{eyebrow:'PORTEFEUILLE',title:'Projets',detail:'Équipes et ressources'},
workspace:{eyebrow:'ATELIER',title:'Session de développement',detail:'Discussion, code et validation'},
activity:{eyebrow:'JOURNAL UNIFIÉ',title:'Activité',detail:'Tâches et validations'},
server:{eyebrow:'INFRASTRUCTURE',title:'Serveur',detail:'Raspberry Pi et diagnostics'},
portfolio:{eyebrow:'APPLICATIONS ET SERVICES',title:'Écosystème',detail:'Catalogue et déploiements'},
orchestrator:{eyebrow:'AGENTS',title:'Chef d’orchestre',detail:'Chantiers et délégations'},
master:{eyebrow:'PROPRIÉTAIRE',title:'Agent maître',detail:'Canal hôte privé'},
remote:{eyebrow:'CONNEXION',title:'Machines distantes',detail:'Machines et dossiers associés'},
settings:{eyebrow:'CONFIGURATION',title:'Réglages',detail:'Comptes, sécurité et connexions'},
}
export default function App(){
const[ready,setReady]=useState(false),[setupRequired,setSetupRequired]=useState(false)
const[user,setUser]=useState<CurrentUser|null>(null),[projects,setProjects]=useState<Project[]>([]),[archivedProjects,setArchivedProjects]=useState<Project[]>([]),[project,setProject]=useState<Project|null>(null)
const[activeProjectIds,setActiveProjectIds]=useState<Set<string>>(()=>new Set()),[serverConnected,setServerConnected]=useState<boolean|null>(null)
const initialQuery=new URLSearchParams(location.search)
const[view,setView]=useState<MainView>(()=>{const routed=routeView(location.search);if(location.search)return routed;const stored=localStorage.getItem('codexpi-view');if(localStorage.getItem('codexpi-agent-company-shell')!=='1'){localStorage.setItem('codexpi-agent-company-shell','1');return'company'}return stored&&mainViews.includes(stored as MainView)?stored as MainView:'company'}),[menuOpen,setMenuOpen]=useState(false),[projectDialog,setProjectDialog]=useState(false),[autonomyDialog,setAutonomyDialog]=useState(false),[launcherOpen,setLauncherOpen]=useState(false),[workspaceTab,setWorkspaceTab]=useState<WorkspaceTab>(()=>{const tab=initialQuery.get('tab');return(['chat','browser','changes','preview','terminal','git','docker'] as WorkspaceTab[]).includes(tab as WorkspaceTab)?tab as WorkspaceTab:'chat'}),[orchestrationId,setOrchestrationId]=useState<string|null>(()=>initialQuery.get('orchestration'))
const[notice,setNotice]=useState<{kind:'error'|'info';text:string}|null>(null),[workspaceFocus,setWorkspaceFocus]=useState(()=>localStorage.getItem('codexpi-workspace-focus')!=='0')
const[preferences,setPreferences]=useState<ApplicationPreferences>(()=>({auto_compact_context:false,interface_type:storedInterfaceType(),theme:storedTheme()}))
const lastNotice=useRef<{text:string;at:number}|null>(null)
const showError=useCallback((error:string|Error)=>{const text=typeof error==='string'?error:error.message,now=Date.now();if(lastNotice.current?.text===text&&now-lastNotice.current.at<15_000)return;lastNotice.current={text,at:now};setNotice({kind:'error',text})},[])
const loadProjects=useCallback(async()=>{const[activePayload,archivedPayload]=await Promise.all([api<unknown>('/api/projects'),api<unknown>('/api/projects?archived=true')]),rows=expectArrayPayload<Project>(activePayload,'projets'),archived=expectArrayPayload<Project>(archivedPayload,'projets archivés');setProjects(rows);setArchivedProjects(archived);const requested=new URLSearchParams(location.search).get('project'),stored=localStorage.getItem('codexpi-project');const all=[...rows,...archived];setProject(current=>all.find(item=>item.id===(current?.id||requested||stored))||rows[0]||null);const conversation=new URLSearchParams(location.search).get('conversation');if(requested&&conversation)localStorage.setItem(`codexpi-session-${requested}`,conversation)},[])
const loadProjectActivity=useCallback(async()=>{const[activity,dashboard]=await Promise.all([sharedApi<ProjectActivity>('/api/projects/activity'),sharedApi<DashboardData>('/api/dashboard')]);setActiveProjectIds(new Set(activity.running_project_ids));setServerConnected(Boolean(dashboard.connected))},[])
const refreshGlobalStatus=useCallback(async()=>{if(!user)return;try{await loadProjectActivity()}catch{setServerConnected(false)}},[user,loadProjectActivity])
useEffect(()=>{const unauthorized=()=>setUser(null);window.addEventListener('codexpi:unauthorized',unauthorized);return()=>window.removeEventListener('codexpi:unauthorized',unauthorized)},[])
useEffect(()=>{Promise.all([api<{setup_required:boolean;csrf_token?:string}>('/api/bootstrap'),api<{username:string;is_admin:boolean;csrf_token:string}>('/api/auth/me').catch(()=>null)]).then(([boot,me])=>{setSetupRequired(boot.setup_required);if(boot.csrf_token)setCsrf(boot.csrf_token);if(me){setUser({username:me.username,is_admin:me.is_admin});setCsrf(me.csrf_token);return loadProjects()}}).finally(()=>setReady(true))},[loadProjects])
useEffect(()=>{if(project)localStorage.setItem('codexpi-project',project.id)},[project?.id])
useEffect(()=>{if(user)Promise.all([api<ApplicationPreferences>('/api/application/preferences'),api<{enabled:boolean;reminder_minutes:number}>('/api/duck/preferences')]).then(([base,duck])=>{const value={...base,duck_enabled:duck.enabled,duck_reminder_minutes:duck.reminder_minutes};rememberAppearance(value.interface_type,value.theme);setPreferences(value)}).catch(showError)},[user,showError])
useEffect(()=>{rememberAppearance(preferences.interface_type,preferences.theme);const media=matchMedia('(prefers-color-scheme: dark)'),apply=()=>{document.documentElement.dataset.themePreference=preferences.theme;document.documentElement.dataset.theme=preferences.theme==='system'?(media.matches?'dark':'light'):preferences.theme;document.documentElement.style.colorScheme=preferences.theme==='system'?'light dark':preferences.theme};apply();media.addEventListener('change',apply);return()=>media.removeEventListener('change',apply)},[preferences.interface_type,preferences.theme])
useVisiblePolling(refreshGlobalStatus,10_000,Boolean(user))
useEffect(()=>localStorage.setItem('codexpi-view',view),[view])
useEffect(()=>localStorage.setItem('codexpi-workspace-focus',workspaceFocus?'1':'0'),[workspaceFocus])
useEffect(()=>{const restore=()=>{const query=new URLSearchParams(location.search),next=routeView(location.search);setView(next);const tab=query.get('tab');setWorkspaceTab((['chat','browser','changes','preview','terminal','git','docker'] as WorkspaceTab[]).includes(tab as WorkspaceTab)?tab as WorkspaceTab:'chat');setOrchestrationId(query.get('orchestration'));const projectId=query.get('project');if(projectId){const item=[...projects,...archivedProjects].find(row=>row.id===projectId);if(item)setProject(item)}};window.addEventListener('popstate',restore);return()=>window.removeEventListener('popstate',restore)},[projects,archivedProjects])
useEffect(()=>{const title=viewMeta[view].title,projectTitle=['workspace','settings'].includes(view)&&project?` · ${project.name}`:'';document.title=`CodexPi · ${title}${projectTitle}`},[view,project?.name])
useEffect(()=>{const shortcut=(event:KeyboardEvent)=>{if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='k'){event.preventDefault();setLauncherOpen(true)}else if(event.key==='Escape')setLauncherOpen(false)};document.addEventListener('keydown',shortcut);return()=>document.removeEventListener('keydown',shortcut)},[])
if(!ready)return <Splash/>;if(!user)return <Login setupRequired={setupRequired} onLogin={(value,csrf)=>{setUser(value);setCsrf(csrf);void loadProjects()}}/>
const navigate=(next:MainView,options:RouteOptions={})=>{const target=options.project===undefined?project:options.project,conversationId=options.conversationId===undefined?(next==='workspace'&&target?localStorage.getItem(`codexpi-session-${target.id}`):null):options.conversationId,tab=options.tab||workspaceTab;setView(next);if(options.project!==undefined)setProject(options.project as Project|null);if(next==='workspace')setWorkspaceTab(tab);if(next==='orchestrator')setOrchestrationId(options.orchestrationId||null);setMenuOpen(false);setLauncherOpen(false);const href=routeHref(next,{project:target,conversationId,tab,orchestrationId:options.orchestrationId});if(options.replace)history.replaceState({view:next},'',href);else history.pushState({view:next},'',href)}
const nav=(next:MainView)=>navigate(next)
const changeAppearance=async(interfaceType:InterfaceType,theme:AppearanceTheme)=>{try{const saved=await api<ApplicationPreferences>('/api/application/preferences',{method:'PUT',body:JSON.stringify({interface_type:interfaceType,theme})});setPreferences(current=>({...current,...saved}));if(interfaceType!==preferences.interface_type){const landing=interfaceLanding(interfaceType),conversationId=project?localStorage.getItem(`codexpi-session-${project.id}`):null;navigate(landing.view,{project,conversationId,tab:landing.tab})}}catch(error){showError(error as Error);throw error}}
const openWorkspace=async(id:string,tab:WorkspaceTab='chat',conversationId?:string)=>{try{const payload=await api<unknown>('/api/projects'),rows=expectArrayPayload<Project>(payload,'projets');setProjects(rows);const item=rows.find(row=>row.id===id);if(!item)throw new Error('Espace de travail introuvable');if(conversationId)localStorage.setItem(`codexpi-session-${id}`,conversationId);navigate('workspace',{project:item,conversationId:conversationId||localStorage.getItem(`codexpi-session-${id}`),tab})}catch(error){showError(error as Error)}}
const openOrchestrator=(id?:string)=>navigate('orchestrator',{orchestrationId:id||null})
const selectableProjects=project?.archived?[project,...projects.filter(item=>item.id!==project.id)]:projects
const currentView=viewMeta[view]
const projectScoped=['workspace','settings'].includes(view)
const chooseProject=(item:Project)=>{setNotice(null);navigate(view,{project:item,tab:workspaceTab})}
const enterProject=(item:Project)=>navigate('workspace',{project:item,conversationId:localStorage.getItem(`codexpi-session-${item.id}`),tab:'chat'})
return <div className={`cp-shell agent-company-shell interface-${preferences.interface_type} theme-${preferences.theme} view-${view} ${view==='workspace'&&workspaceFocus?'workspace-focus':''}`} data-interface={preferences.interface_type}>
<a className="skip-link" href="#codexpi-main">Aller au contenu principal</a>
<header className="cp-topbar">
<button className="cp-hamburger" onClick={()=>setMenuOpen(true)} aria-label="Ouvrir la navigation">☰</button>
<button className="cp-brand-button" onClick={()=>nav('company')}><span>C</span><div><b>CodexPi</b><small>Entreprise d’agents</small></div></button>
<div className="cp-context-box"><div className="cp-project-select">{projectScoped?<ProjectSwitcher projects={selectableProjects} project={project} activeProjectIds={activeProjectIds} onSelect={chooseProject} onNew={()=>setProjectDialog(true)}/>:<div className="cp-enterprise-context"><span className="online-dot"/><b>Entreprise</b></div>}</div><small>{currentView.title} · {currentView.detail}</small></div>
<button className="cp-global-search" onClick={()=>setLauncherOpen(true)}><span><Icon name="search"/></span><b>Rechercher un projet, une session, un agent ou une commande…</b><kbd>Ctrl K</kbd></button>
<div className="cp-top-actions"><span className={`cp-pi-status ${serverConnected===false?'offline':serverConnected===null?'checking':''}`}><i/> {serverConnected===null?'NasPi · vérification':serverConnected?'NasPi · connecté':'NasPi · hors ligne'}</span>{projectScoped&&project&&project.kind!=='remote'&&<button className={`autonomy-pill ${project.full_access?'full':''}`} onClick={()=>setAutonomyDialog(true)} title={`Autonomie de ${project.name}`}><b>{project.full_access?'FULL ACCESS':'SAFE'}</b></button>}<button className="cp-activity-button" onClick={()=>nav('activity')} aria-label={`${activeProjectIds.size} projet actif`}>◇{activeProjectIds.size>0&&<b>{activeProjectIds.size}</b>}</button><button className="cp-account-button" onClick={()=>nav('settings')} title={user.username}>{user.username.slice(0,2).toUpperCase()}</button></div>
</header>
<aside className={`cp-rail ${menuOpen?'open':''}`}><nav><Nav active={view==='company'||view==='orchestrator'||view==='master'} icon="company" label="Entreprise" onClick={()=>nav('company')}/><Nav active={view==='dashboard'} icon="projects" label="Projets" onClick={()=>nav('dashboard')}/><Nav active={view==='workspace'} icon="workspace" label="Atelier" onClick={()=>nav('workspace')}/><Nav active={view==='activity'} icon="activity" label="Activité" badge={activeProjectIds.size||undefined} onClick={()=>nav('activity')}/><Nav active={view==='server'} icon="server" label="Serveur" onClick={()=>nav('server')}/><Nav active={view==='portfolio'||view==='remote'} icon="ecosystem" label="Écosystème" onClick={()=>nav('portfolio')}/><div className="rail-spacer"/><Nav active={view==='settings'} icon="settings" label="Réglages" onClick={()=>nav('settings')}/><button type="button" className="cp-more-nav" onClick={()=>setLauncherOpen(true)}><span><Icon name="more"/></span><b>Plus</b></button></nav></aside>
{menuOpen&&<button className="cp-scrim" onClick={()=>setMenuOpen(false)} aria-label="Fermer la navigation"/>}
<main className="cp-main" id="codexpi-main" tabIndex={-1}>
{notice&&<div className={`cp-toast ${notice.kind}`}><span>{notice.text}</span><button onClick={()=>setNotice(null)}>×</button></div>}
<Suspense fallback={<RouteLoading/>}>{view==='company'&&<Company username={user.username} isAdmin={user.is_admin} projects={projects} activeProjectIds={activeProjectIds} onMaster={()=>user.is_admin?nav('master'):openOrchestrator()} onOrchestrate={()=>openOrchestrator()} onProjects={()=>nav('dashboard')} onServer={()=>nav('server')} onEcosystem={()=>nav('portfolio')} onProject={enterProject} onProjectsRefresh={loadProjects} onError={showError}/>} {view==='activity'&&<Activity onOpen={(projectId,conversationId,tab)=>void openWorkspace(projectId,tab,conversationId)} onOrchestrate={openOrchestrator} onAdd={()=>setProjectDialog(true)} onError={showError}/>} {view==='dashboard'&&<Dashboard projects={projects} archivedProjects={archivedProjects} onOpen={enterProject} onAdd={()=>setProjectDialog(true)} onError={showError}/>} {view==='portfolio'&&<Ecosystem key="ecosystem" isAdmin={user.is_admin} onOpenWorkspace={(id,tab)=>void openWorkspace(id,tab)} onError={showError}/>} {view==='server'&&<Ecosystem key="server" isAdmin={user.is_admin} initialView="doctor" showNav={false} onOpenWorkspace={(id,tab)=>void openWorkspace(id,tab)} onError={showError}/>} {view==='workspace'&&(project?<Workspace project={project} initialTab={workspaceTab} interfaceType={preferences.interface_type} focusMode={workspaceFocus} onFocusMode={setWorkspaceFocus} autoCompactContext={preferences.auto_compact_context} onProjectRefresh={loadProjects} onProjectArchived={()=>navigate('activity')} onError={showError}/>:<EmptyProjects onAdd={()=>setProjectDialog(true)}/>)} {view==='orchestrator'&&<Orchestrator projects={projects} selectedId={orchestrationId} onOpenWorkspace={(projectId,conversationId)=>void openWorkspace(projectId,'chat',conversationId)} onError={showError}/>} {view==='master'&&user.is_admin&&<MasterAgent onError={showError}/>} {view==='remote'&&<RemotePanel projects={projects} initialProject={project} onError={showError} onChanged={loadProjects} onOpenProject={id=>{const item=projects.find(row=>row.id===id);if(item)enterProject(item)}}/>} {view==='settings'&&<Settings user={user} project={project} preferences={preferences} onPreferences={setPreferences} onAppearance={changeAppearance} onSaved={loadProjects} onAutonomy={()=>setAutonomyDialog(true)}/>}</Suspense>
</main>
{launcherOpen&&<CommandLauncher projects={projects} canUseMaster={user.is_admin} onClose={()=>setLauncherOpen(false)} onView={nav} onWorkspace={(projectId,conversationId)=>void openWorkspace(projectId,'chat',conversationId||undefined)} onNewProject={()=>{setLauncherOpen(false);setProjectDialog(true)}}/>}
{projectDialog&&<ProjectDialog onClose={()=>setProjectDialog(false)} onCreated={async created=>{setProjectDialog(false);await loadProjects();navigate('workspace',{project:{...created,git_available:Boolean(created.git_available),remote_online:created.kind==='remote'?true:created.remote_online},tab:'chat'})}}/>}
{autonomyDialog&&project&&<AutonomyDialog project={project} onClose={()=>setAutonomyDialog(false)} onChanged={async()=>{setAutonomyDialog(false);await loadProjects()}} onError={showError}/>}</div>
}
function ProjectSwitcher({projects,project,activeProjectIds,onSelect,onNew}:{projects:Project[];project:Project|null;activeProjectIds:Set<string>;onSelect:(project:Project)=>void;onNew:()=>void}){
const menu=useRef<HTMLDetailsElement>(null)
useEffect(()=>{const close=(event:PointerEvent)=>{if(menu.current&&!menu.current.contains(event.target as Node))menu.current.open=false};const escape=(event:KeyboardEvent)=>{if(event.key==='Escape'&&menu.current)menu.current.open=false};document.addEventListener('pointerdown',close);document.addEventListener('keydown',escape);return()=>{document.removeEventListener('pointerdown',close);document.removeEventListener('keydown',escape)}},[])
function choose(item:Project){if(menu.current)menu.current.open=false;onSelect(item)}
function create(){if(menu.current)menu.current.open=false;onNew()}
const indicator=(item:Project|null)=>item&&activeProjectIds.has(item.id)?<span className="project-working" role="status" aria-label="Une IA travaille sur ce projet" title="Une IA travaille sur ce projet"/>:<span className={item?.kind==='remote'&&!item.remote_online?'offline-dot':'online-dot'} aria-hidden="true"/>
return <details className="project-switcher" ref={menu}><summary>{indicator(project)}<b>{project?.kind==='remote'&&'⌁ '}{project?.name||'Choisir un projet'}</b>{project?.archived&&<em>Archivé</em>}<i>⌄</i></summary><div className="project-switcher-menu"><header>PROJETS</header><div>{projects.map(item=><button type="button" className={item.id===project?.id?'selected':''} onClick={()=>choose(item)} key={item.id}>{indicator(item)}<div><b>{item.kind==='remote'&&'⌁ '}{item.name}</b><small>{item.archived?'Projet archivé':item.kind==='remote'?'Machine distante':item.path}</small></div>{item.id===project?.id&&<i>✓</i>}</button>)}</div><button type="button" className="project-switcher-new" onClick={create}><span>+</span><div><b>Nouveau projet</b><small>Choisir un dossier dans /home</small></div></button></div></details>
}
function Nav({active,icon,label,badge,onClick}:{active:boolean;icon:IconName;label:string;badge?:number;onClick:()=>void}){return <button className={active?'active':''} aria-current={active?'page':undefined} onClick={onClick}><span><Icon name={icon}/></span><b>{label}</b>{badge!=null&&<i>{badge}</i>}</button>}
type SearchResult={kind:'project'|'conversation'|'group';id:string;project_id:string|null;conversation_id:string|null;label:string;detail:string}
function CommandLauncher({projects,canUseMaster,onClose,onView,onWorkspace,onNewProject}:{projects:Project[];canUseMaster:boolean;onClose:()=>void;onView:(view:MainView)=>void;onWorkspace:(projectId:string,conversationId?:string|null)=>void;onNewProject:()=>void}){
const[query,setQuery]=useState(''),[results,setResults]=useState<SearchResult[]>([]),[searching,setSearching]=useState(false),dialog=useRef<HTMLElement>(null)
useDialogFocus(dialog,onClose)
const entries:{view:MainView;icon:IconName;label:string;detail:string}[]=[
{view:'company',icon:'company',label:'Entreprise',detail:'Organisation et responsabilités'},
{view:'dashboard',icon:'projects',label:'Projets',detail:'Portefeuille et équipes'},
{view:'workspace',icon:'workspace',label:'Atelier',detail:'Discussion, code et revue'},
{view:'activity',icon:'activity',label:'Activité',detail:'Tâches et validations'},
{view:'server',icon:'server',label:'Serveur Raspberry Pi',detail:'Diagnostic et incidents'},
{view:'portfolio',icon:'ecosystem',label:'Écosystème',detail:'Applications et déploiements'},
{view:'orchestrator',icon:'orchestrator',label:'Chef d’orchestre',detail:'Créer et suivre un chantier'},
...(canUseMaster?[{view:'master' as MainView,icon:'master' as IconName,label:'Agent maître',detail:'Canal hôte privé'}]:[]),
{view:'remote',icon:'remote',label:'Machines distantes',detail:'Machines et dossiers associés'},
{view:'settings',icon:'settings',label:'Réglages',detail:'Comptes et connexions'},
]
const needle=query.trim().toLocaleLowerCase(),filteredEntries=entries.filter(item=>!needle||`${item.label} ${item.detail}`.toLocaleLowerCase().includes(needle)),filteredProjects=!needle?projects.slice(0,6):[]
useEffect(()=>{if(needle.length<2){setResults([]);setSearching(false);return}const controller=new AbortController(),timer=window.setTimeout(()=>{setSearching(true);api<SearchResult[]>(`/api/search?q=${encodeURIComponent(query.trim())}`,{signal:controller.signal}).then(setResults).catch(reason=>{if((reason as Error).name!=='AbortError')setResults([])}).finally(()=>setSearching(false))},180);return()=>{window.clearTimeout(timer);controller.abort()}},[needle,query])
function openResult(item:SearchResult){if(item.kind==='group'){onView('company');return}if(item.project_id)onWorkspace(item.project_id,item.conversation_id);onClose()}
return <div className="cp-launcher-backdrop" role="presentation" onClick={onClose}>
<section ref={dialog} className="cp-launcher" role="dialog" aria-modal="true" aria-labelledby="cp-launcher-title" tabIndex={-1} onClick={event=>event.stopPropagation()}>
<header><div><small>TOUT CODEXPI</small><h2 id="cp-launcher-title">Rechercher et ouvrir</h2></div><button type="button" onClick={onClose} aria-label="Fermer">×</button></header>
<label><span><Icon name="search"/></span><input autoFocus value={query} onChange={event=>setQuery(event.target.value)} placeholder="Projet, session, groupe, agent ou réglage…" aria-label="Rechercher dans CodexPi"/></label>
<div className="cp-launcher-grid">{filteredEntries.map(item=><button key={item.view} onClick={()=>onView(item.view)}><span><Icon name={item.icon}/></span><div><b>{item.label}</b><small>{item.detail}</small></div></button>)}</div>
{filteredProjects.length>0&&<><h3>Projets récents</h3><div className="cp-launcher-projects">{filteredProjects.map(item=><button key={item.id} onClick={()=>{onWorkspace(item.id);onClose()}}><span>{item.name.slice(0,2).toUpperCase()}</span><div><b>{item.name}</b><small>{item.kind==='remote'?'Machine distante':item.path}</small></div></button>)}</div></>}
{needle.length>=2&&<><h3>Résultats {searching&&<small>Recherche…</small>}</h3><div className="cp-launcher-projects cp-search-results">{results.map(item=><button key={`${item.kind}-${item.id}`} onClick={()=>openResult(item)}><span><Icon name={item.kind==='conversation'?'activity':item.kind==='group'?'company':'workspace'}/></span><div><b>{item.label}</b><small>{item.detail}</small></div><i>{item.kind==='conversation'?'Session':item.kind==='group'?'Groupe':'Projet'}</i></button>)}{!searching&&!results.length&&<p>Aucun projet, groupe ou session trouvé.</p>}</div></>}
<footer><button onClick={onNewProject}>+ Nouveau projet</button><small>Échap pour fermer · Ctrl K pour ouvrir</small></footer>
</section>
</div>
}
type SettingsTab='appearance'|'general'|'agents'|'connections'|'users'|'system'
function Settings({user,project,preferences,onPreferences,onAppearance,onAutonomy,onSaved}:{user:CurrentUser;project:Project|null;preferences:ApplicationPreferences;onPreferences:(value:ApplicationPreferences)=>void;onAppearance:(type:InterfaceType,theme:AppearanceTheme)=>Promise<void>;onAutonomy:()=>void;onSaved:()=>Promise<void>}){
const[tab,setTab]=useState<SettingsTab>('appearance')
async function logout(){await disablePush();await api('/api/auth/logout',{method:'POST'});setCsrf('');location.reload()}
const settingsTabs:{id:SettingsTab;icon:IconName;label:string;detail:string;admin?:boolean}[]=[{id:'appearance',icon:'workspace',label:'Apparence',detail:'Interface et thème'},{id:'general',icon:'settings',label:'Général',detail:'Projet et contexte'},{id:'agents',icon:'orchestrator',label:'Comptes IA',detail:'Codex, Claude, OpenCode'},{id:'connections',icon:'remote',label:'Connexions',detail:'SSH et dépôts Git'},{id:'users',icon:'company',label:'Utilisateurs',detail:'Accès et projets',admin:true},{id:'system',icon:'server',label:'Système',detail:'Réseau et maintenance'}]
return <div className="cp-page settings-modern"><div className="page-header"><div><p className="eyebrow">PROFIL ET APPLICATION</p><h1>Paramètres</h1><p>Gérez l’interface, les préférences, les comptes IA et les connexions de CodexPi.</p></div></div><nav className="settings-nav" aria-label="Catégories des paramètres">{settingsTabs.filter(item=>!item.admin||user.is_admin).map(item=><button key={item.id} className={tab===item.id?'active':''} aria-current={tab===item.id?'page':undefined} onClick={()=>setTab(item.id)}><span><Icon name={item.icon}/></span><b>{item.label}</b><small>{item.detail}</small></button>)}</nav><div className="settings-content">
{tab==='appearance'&&<AppearanceSettings value={preferences} onChange={onAppearance}/>}
{tab==='general'&&<><div className="settings-section-intro"><div><p className="eyebrow">PRÉFÉRENCES</p><h2>Général</h2><p>Réglages de la discussion et du projet actuellement sélectionné.</p></div></div><ContextCompressionSettings value={preferences.auto_compact_context} onChanged={onPreferences}/><DuckSettings value={preferences} onChanged={onPreferences}/>{project&&<ProjectEffortSettings key={project.id} project={project} onSaved={onSaved}/>} {project&&<ProjectRulesSettings project={project}/>} {project?.kind!=='remote'&&<section><div><h3>Autonomie du projet</h3><p>Configurée par projet. CodexPi ne lance jamais un agent avec sudo.</p></div><button className={`autonomy-pill ${project?.full_access?'full':''}`} onClick={onAutonomy}>{project?.full_access?'FULL ACCESS':'SAFE'}</button></section>}{user.is_admin&&<RulesSettings/>}<PushSettings/></>}
{tab==='agents'&&(user.is_admin?<AgentAccounts project={project}/>:<section><div><h3>Accès administrateur requis</h3><p>Les comptes IA sont communs au serveur et ne peuvent être modifiés que par un administrateur.</p></div></section>)}
{tab==='connections'&&(user.is_admin?<><div className="settings-section-intro"><div><p className="eyebrow">INFRASTRUCTURE</p><h2>Connexions</h2><p>Serveurs de fichiers distants et fournisseurs de dépôts du projet.</p></div></div><SSHServersSettings/><RepositorySettings project={project} onSaved={onSaved}/></>:<section><div><h3>Accès administrateur requis</h3><p>Les connexions serveur sont gérées par un administrateur.</p></div></section>)}
{tab==='users'&&user.is_admin&&<><div className="settings-section-intro"><div><p className="eyebrow">ADMINISTRATION</p><h2>Utilisateurs</h2><p>Créez les comptes et contrôlez leur accès à CodexPi.</p></div></div><UserManagement currentUser={user}/></>}
{tab==='system'&&<><div className="settings-section-intro"><div><p className="eyebrow">INSTALLATION</p><h2>Système</h2><p>Maintenance, accès réseau et session de cette installation.</p></div></div><RepairHistory/><section><div><h3>Accès réseau</h3><p>Disponible sur le port 8765 avec l’adresse IP locale du Pi. Tailscale Serve HTTPS reste utilisable en parallèle.</p></div><code>IP_DU_PI:8765</code></section><section><div><h3>Installer sur iPhone</h3><p>Dans Safari, utilisez Partager → Sur l’écran d’accueil. CodexPi s’ouvre ensuite comme une application.</p></div><span className="pwa-badge">PWA PRÊTE</span></section><section><div><h3>Session</h3><p>Supprime cette session Django et revient à l’écran de connexion.</p></div><button className="button secondary" onClick={logout}>Se déconnecter</button></section></>}
</div></div>
}
function AppearanceSettings({value,onChange}:{value:ApplicationPreferences;onChange:(type:InterfaceType,theme:AppearanceTheme)=>Promise<void>}){
const[busy,setBusy]=useState(false),[error,setError]=useState('')
async function select(type:InterfaceType,theme:AppearanceTheme){if(busy||(type===value.interface_type&&theme===value.theme))return;setBusy(true);setError('');try{await onChange(type,theme)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
const interfaces:{id:InterfaceType;icon:string;title:string;description:string}[]=[
{id:'dashboard',icon:'▦',title:'Tableau de bord',description:'Projets et tâches en colonnes, puis en fil sur iPhone.'},
{id:'atelier',icon:'◫',title:'Atelier',description:'Discussions et arbre de travail réunis autour de la conversation.'},
{id:'review',icon:'⑂',title:'Revue Git',description:'Fichiers, diff, branche, tests et publication au premier plan.'},
]
const themes:{id:AppearanceTheme;icon:string;title:string}[]=[{id:'light',icon:'☀',title:'Clair'},{id:'dark',icon:'●',title:'Sombre'},{id:'system',icon:'◐',title:'Système'}]
return <><div className="settings-section-intro"><div><p className="eyebrow">STRUCTURE ET COULEURS</p><h2>Apparence</h2><p>Le type change toute la structure de l’application. Le thème reste indépendant.</p></div></div><section className="appearance-setting"><fieldset><legend>Type d’interface</legend><p>Le projet et la dernière discussion restent sélectionnés pendant le changement.</p><div className="interface-choice-grid">{interfaces.map(item=><button type="button" key={item.id} disabled={busy} className={value.interface_type===item.id?'selected':''} aria-pressed={value.interface_type===item.id} onClick={()=>void select(item.id,value.theme)}><span>{item.icon}</span><b>{item.title}</b><small>{item.description}</small><i>{value.interface_type===item.id?'✓ Actif':'Choisir'}</i></button>)}</div></fieldset><fieldset><legend>Thème</legend><p>Clair, sombre, ou synchronisé avec le réglage de l’appareil.</p><div className="theme-choice-grid">{themes.map(item=><button type="button" key={item.id} disabled={busy} className={value.theme===item.id?'selected':''} aria-pressed={value.theme===item.id} onClick={()=>void select(value.interface_type,item.id)}><span>{item.icon}</span><b>{item.title}</b><i>{value.theme===item.id?'✓':''}</i></button>)}</div></fieldset>{error&&<p className="inline-error" role="alert">{error}</p>}</section></>
}
function ContextCompressionSettings({value,onChanged}:{value:boolean;onChanged:(value:ApplicationPreferences)=>void}){
const[busy,setBusy]=useState(false),[error,setError]=useState('')
async function toggle(){if(busy)return;const next=!value;setBusy(true);setError('');try{const saved=await api<ApplicationPreferences>('/api/application/preferences',{method:'PUT',body:JSON.stringify({auto_compact_context:next})});onChanged(saved)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
return <section className="context-compression-setting"><div><h3>Compression automatique du contexte</h3><p>À environ 20 000 tokens ou 20 demandes, Codex résume le contexte et poursuit dans la même discussion, sans afficher de fenêtre.</p>{error&&<span className="inline-error" role="alert">{error}</span>}</div><button type="button" role="switch" aria-checked={value} aria-label="Compression automatique du contexte" className={`preference-switch ${value?'on':''}`} disabled={busy} onClick={()=>void toggle()}><span/><b>{value?'Activée':'Désactivée'}</b></button></section>
}
function DuckSettings({value,onChanged}:{value:ApplicationPreferences;onChanged:(value:ApplicationPreferences)=>void}){const[busy,setBusy]=useState(false),[minutes,setMinutes]=useState(value.duck_reminder_minutes||30);async function save(enabled=value.duck_enabled!==false){setBusy(true);try{const saved=await api<{enabled:boolean;reminder_minutes:number}>('/api/duck/preferences',{method:'PUT',body:JSON.stringify({enabled,reminder_minutes:minutes})});onChanged({...value,duck_enabled:saved.enabled,duck_reminder_minutes:saved.reminder_minutes})}finally{setBusy(false)}}return <section className="duck-setting"><div><h3>Canard jaune</h3><p>Signale trois échecs similaires ou une tâche longue sans affirmer que l’agent est bloqué.</p></div><label>Rappel après <input type="number" min="1" max="1440" value={minutes} onChange={event=>setMinutes(Number(event.target.value))} onBlur={()=>void save()}/> min</label><button type="button" role="switch" aria-checked={value.duck_enabled!==false} className={`preference-switch ${value.duck_enabled!==false?'on':''}`} disabled={busy} onClick={()=>void save(value.duck_enabled===false)}><span/><b>{value.duck_enabled!==false?'Activé':'Désactivé'}</b></button></section>}
function RepairHistory(){
const[records,setRecords]=useState<RepairRecord[]>([]),[loading,setLoading]=useState(true),[error,setError]=useState('')
const refresh=useCallback(async()=>{setLoading(true);setError('');try{setRecords(await api<RepairRecord[]>('/api/system/repair-history'))}catch(reason){setError((reason as Error).message)}finally{setLoading(false)}},[])
useEffect(()=>{void refresh()},[refresh])
return <section className="repair-history"><header><div><h3>Historique de réparation</h3><p>Chaque intervention conserve les erreurs brutes, l’analyse de Codex et les modifications réellement appliquées.</p><code>data/repair.json</code></div><button className="button secondary" onClick={()=>void refresh()} disabled={loading}>{loading?'Chargement…':'↻ Actualiser'}</button></header>{error&&<p className="inline-error" role="alert">{error}</p>}<div className="repair-list">{records.map(record=><details key={record.id}><summary><span className={`repair-state ${record.status}`}>{record.status==='repaired'?'Réparé':'Échec'}</span><b>{new Date(record.date).toLocaleDateString('fr-FR',{dateStyle:'long'})}</b><time dateTime={record.date}>{new Date(record.date).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit',second:'2-digit'})}</time><i>⌄</i></summary><div className="repair-sections"><section><h4>Problème rencontré · erreur console brute</h4>{record.problem.errors.length>0&&<ul>{record.problem.errors.map((item,index)=><li key={`error-${index}`}>{item}</li>)}</ul>}<pre>{record.problem.console||'Aucune erreur console : relance préventive du projet.'}</pre></section><section><h4>Analyse du problème et solution</h4><pre>{record.analysis_solution||'Aucune analyse nécessaire.'}</pre></section><section><h4>Modifications apportées</h4>{record.modifications.length?<ul>{record.modifications.map((item,index)=><li key={`change-${index}`}>{item}</li>)}</ul>:<p>Aucune modification enregistrée.</p>}</section></div></details>)}{!loading&&!records.length&&!error&&<p>Aucune réparation enregistrée pour le moment.</p>}</div></section>
}
function RouteLoading(){return <div className="route-loading" role="status"><span/><b>Chargement de la vue…</b></div>}
function EmptyProjects({onAdd}:{onAdd:()=>void}){return <div className="empty-pane full"><span>◇</span><h2>Aucun projet pour le moment</h2><p>Enregistrez un dossier autorisé pour commencer à travailler.</p><button className="button primary" onClick={onAdd}>Ajouter un projet</button></div>}
function Splash(){return <div className="cp-splash"><span>C</span><b>CodexPi</b><small>Connexion au Raspberry Pi…</small></div>}
function Login({setupRequired,onLogin}:{setupRequired:boolean;onLogin:(u:CurrentUser,c:string)=>void}){const[error,setError]=useState('');async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();const data=new FormData(event.currentTarget);try{await refreshCsrf();const endpoint=setupRequired?'/api/auth/setup':'/api/auth/login';const result=await api<{username:string;is_admin?:boolean;csrf_token:string}>(endpoint,{method:'POST',body:JSON.stringify({username:data.get('username'),password:data.get('password')})});onLogin({username:result.username,is_admin:setupRequired||Boolean(result.is_admin)},result.csrf_token)}catch(e){setError((e as Error).message)}}return <div className="cp-login"><section><div className="login-brand"><span>C</span><div><b>CodexPi</b><small>Espace de développement</small></div></div><h1>{setupRequired?'Configurer CodexPi':'Bienvenue'}</h1><p>{setupRequired?'Créez le premier compte administrateur. Aucun terminal nécessaire.':'Connectez-vous à votre espace de développement privé.'}</p><form onSubmit={submit}><label>Nom d’utilisateur<input name="username" required minLength={3} autoComplete="username" autoCapitalize="none"/></label><label>{setupRequired?'Mot de passe administrateur':'Mot de passe'}<input name="password" type="password" required minLength={12} autoComplete={setupRequired?'new-password':'current-password'}/></label>{setupRequired&&<div className="setup-callout"><b>Première configuration</b><span>Ce compte pourra ensuite créer les autres utilisateurs depuis les paramètres.</span></div>}{error&&<div className="inline-error" role="alert">{error}</div>}<button className="button primary">{setupRequired?'Créer et commencer':'Se connecter'}</button></form><footer><span className="online-dot"/> Privé · HTTPS · Argon2id</footer></section></div>}
function ProjectEffortSettings({project,onSaved}:{project:Project;onSaved:()=>Promise<void>}){
const[model,setModel]=useState(project.model||''),[effort,setEffort]=useState(project.reasoning_effort||'medium'),[options,setOptions]=useState<CodexOptions|null>(null),[busy,setBusy]=useState(false),[error,setError]=useState(''),[saved,setSaved]=useState(false)
useEffect(()=>{let disposed=false;Promise.all([api<{model:string;reasoning_effort:string}>(`/api/projects/${project.id}/settings`),api<CodexOptions>('/api/codex/options')]).then(([value,catalog])=>{if(!disposed){setModel(value.model||'');setEffort(value.reasoning_effort);setOptions(catalog)}}).catch(reason=>{if(!disposed)setError(reason.message)});return()=>{disposed=true}},[project.id])
const allowed=options?.models.find(item=>item.id===model)?.efforts||options?.efforts||[]
function selectModel(value:string){setModel(value);setSaved(false);const efforts=options?.models.find(item=>item.id===value)?.efforts||options?.efforts||[];if(!efforts.includes(effort))setEffort(efforts.includes('medium')?'medium':efforts[0]||'medium')}
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy(true);setSaved(false);setError('');try{await api(`/api/projects/${project.id}/settings`,{method:'PUT',body:JSON.stringify({model,reasoning_effort:effort})});await onSaved();setSaved(true)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
return <section className="project-effort-settings"><div><h3>Paramètres du projet · {project.name}</h3><p>Le modèle et l’effort s’appliquent aux prochaines demandes de toutes les sessions du projet, y compris celles en attente, et deviennent les choix initiaux des nouvelles sessions. La demande en cours conserve ses réglages.</p></div><form className="modal-form" onSubmit={submit}><label>Modèle<select value={model} disabled={busy||!options} onChange={event=>selectModel(event.target.value)}>{options?.models.map(item=><option key={item.id||'default'} value={item.id}>{item.label}</option>)}</select></label><label>Effort de raisonnement<select value={effort} disabled={busy||!allowed.length} onChange={event=>{setEffort(event.target.value);setSaved(false)}}>{!allowed.includes(effort)&&<option value={effort} disabled>{effort}</option>}{allowed.map(value=><option key={value} value={value}>{value}</option>)}</select></label><button className="button primary" disabled={busy||!options||!allowed.includes(effort)}>{busy?'Enregistrement…':'Enregistrer'}</button>{error&&<p className="inline-error" role="alert">{error}</p>}{saved&&<p role="status">Modèle et effort enregistrés.</p>}</form></section>
}

27
frontend/src/AppStore.tsx Normal file
View File

@@ -0,0 +1,27 @@
import {type FormEvent,useCallback,useEffect,useMemo,useState} from 'react'
import {api} from './api'
import {AppStoreApp,WorkspaceTab} from './types'
import {Modal} from './Common'
function Artwork({item,size='small'}:{item:AppStoreApp;size?:'large'|'small'}){
const[failed,setFailed]=useState(false)
return failed?<span className={`ios-app-icon ios-app-icon-fallback ${size}`} aria-label={`Icône de ${item.name}`}>{item.name.slice(0,2).toUpperCase()}</span>:<img className={`ios-app-icon ${size}`} src={item.icon_url} alt={`Icône de ${item.name}`} loading="lazy" onError={()=>setFailed(true)}/>
}
export function AppStore({isAdmin,onOpenWorkspace,onError}:{isAdmin:boolean;onOpenWorkspace:(id:string,tab?:WorkspaceTab)=>void;onError:(error:Error|string)=>void}){
const[apps,setApps]=useState<AppStoreApp[]>([]),[installing,setInstalling]=useState<AppStoreApp|null>(null),[editing,setEditing]=useState<AppStoreApp|null>(null),[busy,setBusy]=useState(false),[query,setQuery]=useState('')
const load=useCallback(()=>api<AppStoreApp[]>('/api/app-store/apps').then(setApps).catch(error=>onError(error as Error)),[onError])
useEffect(()=>{void load()},[load])
const featured=apps.slice(0,3)
const visible=useMemo(()=>{const value=query.trim().toLocaleLowerCase('fr');return value?apps.filter(item=>`${item.name} ${item.description}`.toLocaleLowerCase('fr').includes(value)):apps},[apps,query])
function share(item:AppStoreApp){setInstalling(item)}
async function save(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!editing)return;const data=new FormData(event.currentTarget);setBusy(true);try{await api(`/api/app-store/apps/${editing.id}`,{method:'PUT',body:JSON.stringify({launch_path:data.get('launch_path'),installable:data.get('installable')==='on'})});setEditing(null);await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
return <div className="ecosystem-page app-store-page ios-store"><header className="ios-store-header"><div><p className="ios-store-date">ÉCOSYSTÈME CODEXPI</p><h1>Applications</h1><p>Votre catalogue privé CodexPi, optimisé pour mobile et ordinateur.</p></div><span className="ios-store-avatar">C</span></header>
<label className="ios-store-search"><span>⌕</span><input type="search" value={query} onChange={event=>setQuery(event.target.value)} placeholder="Apps, projets et services" aria-label="Rechercher une application"/>{query&&<button onClick={()=>setQuery('')} aria-label="Effacer la recherche">×</button>}</label>
{!query&&featured.length>0&&<section className="ios-featured" aria-labelledby="featured-title"><header><h2 id="featured-title">À découvrir</h2><span>Applications CodexPi</span></header><div>{featured.map((item,index)=><a className={`ios-feature-card feature-${index}`} href={item.open_url} target="_blank" rel="noreferrer" key={item.id}><small>{index===0?'NOUVEAUTÉ':'SÉLECTION'}</small><h3>{item.name}</h3><p>{item.description}</p><Artwork item={item} size="large"/></a>)}</div></section>}
<section className="ios-app-list" aria-labelledby="all-apps-title"><header><div><h2 id="all-apps-title">{query?'Résultats':'Toutes les applications'}</h2><p>{visible.length} application{visible.length>1?'s':''}</p></div></header><div className="ios-app-list-body">{visible.map(item=><article key={item.id}><Artwork item={item}/><div className="ios-app-copy"><h3>{item.name}</h3><p>{item.description}</p><small>{item.status.replaceAll('_',' ')}</small></div><div className="ios-app-actions"><a href={item.open_url} target="_blank" rel="noreferrer">OUVRIR</a>{item.installable===1&&<button type="button" onClick={()=>share(item)}>AJOUTER</button>}{item.installable!==1&&<small>LOCAL</small>}</div>{item.managed&&(item.workspace_id||isAdmin)&&<details className="ios-app-more"><summary aria-label={`Plus d’actions pour ${item.name}`}>•••</summary><div>{item.workspace_id&&<button onClick={()=>onOpenWorkspace(item.workspace_id!,'chat')}>Développer</button>}{isAdmin&&<button onClick={()=>setEditing(item)}>Configurer</button>}<code>{item.open_url}</code></div></details>}</article>)}{!visible.length&&<div className="ios-store-empty"><span>⌕</span><h3>Aucune application trouvée</h3><p>Essayez un autre nom ou une autre description.</p></div>}</div></section>
<footer className="ios-store-footer"><b>Catalogue privé CodexPi</b><p>Les applications restent protégées par l’authentification CodexPi et utilisent une adresse dédiée.</p></footer>
{installing&&<Modal title={`Ajouter ${installing.name}`} onClose={()=>setInstalling(null)}><div className="app-store-install-body"><div className="install-title-art"><Artwork item={installing}/></div><ol><li>Ouvrez l’application.</li><li>Si une fenêtre avec un bouton <b>X</b> apparaît, choisissez <b>Ouvrir dans Safari</b>.</li><li>Dans Safari, touchez son bouton <b>Partager</b>.</li><li>Choisissez <b>Sur l’écran d’accueil</b>, puis <b>Ajouter</b>.</li></ol><p>iOS autorise cet ajout uniquement depuis le bouton Partager de Safari. Le signet ouvrira directement <code>{installing.share_url}</code>.</p><div className="modal-actions"><button className="button secondary" onClick={()=>setInstalling(null)}>Fermer</button><a className="button primary" href={installing.share_url} target="_blank" rel="noreferrer">Ouvrir l’application</a></div></div></Modal>}
{editing&&<Modal title={`Configurer ${editing.name}`} onClose={()=>setEditing(null)}><form className="modal-form" onSubmit={save}><label>Destination interne<input name="launch_path" defaultValue={editing.launch_path} required/><small>Chemin relatif interne uniquement. L’adresse publique reste immuable.</small></label><label className="checkbox-row"><input name="installable" type="checkbox" defaultChecked={editing.installable===1}/> Proposer l’ajout en signet PWA</label><div className="modal-actions"><button type="button" className="button secondary" onClick={()=>setEditing(null)}>Annuler</button><button className="button primary" disabled={busy}>{busy?'Enregistrement…':'Enregistrer'}</button></div></form></Modal>}
</div>
}

View File

@@ -0,0 +1,86 @@
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api } from './api'
import { BrowserState, Project } from './types'
import { relativeDate } from './utils'
import { useVisiblePolling } from './useVisiblePolling'
const emptyState:BrowserState={available:false,running:false,current_url:'about:blank',control_mode:'codex',events:[]}
export function BrowserPanel({project,onError}:{project:Project;onError:(error:Error|string)=>void}){
const[state,setState]=useState<BrowserState>(emptyState),[url,setUrl]=useState(''),[busy,setBusy]=useState(false),[frameKey,setFrameKey]=useState(0),[showActivity,setShowActivity]=useState(false)
const polling=useRef(false)
const browserFrame=useRef<HTMLIFrameElement>(null)
const refresh=useCallback(async()=>{if(polling.current)return;polling.current=true;try{const value=await api<BrowserState>(`/api/projects/${project.id}/browser`);setState(value);setUrl(current=>document.activeElement?.id==='browser-address'?current:value.current_url||current)}catch(error){onError(error as Error)}finally{polling.current=false}},[project.id,onError])
useEffect(()=>{setState(emptyState);setUrl('')},[project.id])
useVisiblePolling(refresh,1500)
async function mutate(path:string,body:object={}){if(busy)return;setBusy(true);try{const value=await api<BrowserState|{url?:string}>(`/api/projects/${project.id}/browser/${path}`,{method:'POST',body:JSON.stringify(body)});if('events'in value)setState(value);await refresh()}catch(error){onError(error as Error)}finally{setBusy(false)}}
async function start(){await mutate('start');setFrameKey(value=>value+1)}
async function navigate(event:FormEvent){event.preventDefault();if(!url.trim())return;await mutate('navigate',{url:url.trim()})}
async function reset(){if(!confirm('Ouvrir une nouvelle fenêtre Chromium ? Les onglets actuels seront fermés.'))return;await mutate('reset');setFrameKey(value=>value+1)}
function prepareTouchFrame(){
try{
const document=browserFrame.current?.contentDocument
if(!document)return
document.documentElement.style.overscrollBehavior='none'
document.body.style.overscrollBehavior='none'
document.querySelectorAll<HTMLElement>('canvas').forEach(canvas=>{canvas.style.touchAction='none'})
if(!document.getElementById('codexpi-touchpad')){
const script=document.createElement('script')
script.id='codexpi-touchpad';script.type='module';script.src='/browser-touchpad.js'
document.head.appendChild(script)
}
}catch{/* Le navigateur reste utilisable si l'iframe n'est pas encore accessible. */}
}
function pointerClick(button:1|4){
if(!userMode)return
try{
const touchpad=(browserFrame.current?.contentWindow as (Window&{codexpiTouchpad?:{click:(button:number)=>void}})|null)?.codexpiTouchpad
if(touchpad){touchpad.click(button);return}
onError('Le pavé tactile n’est pas encore prêt. Touchez à nouveau dans un instant.')
}catch{onError('Le pavé tactile n’est pas encore prêt. Touchez à nouveau dans un instant.')}
}
function showTouchKeyboard(){
if(!userMode)return
try{
const document=browserFrame.current?.contentDocument
const keyboardButton=document?.getElementById('noVNC_keyboard_button') as HTMLInputElement|null
if(keyboardButton){keyboardButton.click();return}
const keyboardInput=document?.getElementById('noVNC_keyboardinput') as HTMLInputElement|null
keyboardInput?.focus()
}catch{onError('Le clavier tactile n’est pas encore prêt. Touchez à nouveau dans un instant.')}
}
const userMode=state.control_mode==='user'
return <div className="browser-page">
<header className="browser-toolbar">
<div className="browser-lights"><i/><i/><i/></div>
<button type="button" onClick={()=>void mutate('navigation/back')} disabled={!state.running||busy} aria-label="Page précédente">←</button>
<button type="button" onClick={()=>void mutate('navigation/forward')} disabled={!state.running||busy} aria-label="Page suivante">→</button>
<button type="button" onClick={()=>void mutate('navigation/refresh')} disabled={!state.running||busy} aria-label="Actualiser">↻</button>
<form onSubmit={navigate}><span>⌕</span><input id="browser-address" value={url} onChange={event=>setUrl(event.target.value)} placeholder="https://…" disabled={!state.running||busy}/><button disabled={!state.running||busy||!url.trim()}>Aller</button></form>
<button type="button" className={userMode?'control-user':'control-codex'} onClick={()=>void mutate('control',{mode:userMode?'codex':'user'})} disabled={!state.running||busy}>{userMode?'Rendre à Codex':'Prendre la main'}</button>
<button type="button" onClick={()=>void mutate('screenshot',{label:'Capture manuelle'})} disabled={!state.running||busy}>Capture</button>
<button type="button" className={showActivity?'active':''} onClick={()=>setShowActivity(value=>!value)}>Activité</button>
<button type="button" onClick={()=>void reset()} disabled={!state.running||busy} aria-label="Redémarrer Chromium">⟳</button>
</header>
{state.handoff_message&&userMode&&<div className="browser-handoff"><span>Action requise</span><p>{state.handoff_message}</p><button className="button primary" onClick={()=>void mutate('control',{mode:'codex'})}>J’ai terminé — rendre à Codex</button></div>}
<div className={`browser-layout ${showActivity?'with-activity':'simple'}`}>
<section className={`browser-screen ${userMode?'user-control':'codex-control'}`}>
{!state.available?<div className="browser-empty"><span>◎</span><h2>Navigateur indisponible</h2><p>Le conteneur Chromium n’est pas encore prêt. Réessayez dans quelques instants.</p><button className="button secondary" onClick={()=>void refresh()}>Réessayer</button></div>:!state.running?<div className="browser-empty"><span>◎</span><h2>Navigateur partagé</h2><p>Une même session Chromium est visible et pilotable par toutes les IA de vos projets.</p><button className="button primary" onClick={()=>void start()} disabled={busy}>{busy?'Démarrage…':'Ouvrir Chromium'}</button></div>:<>
<iframe ref={browserFrame} key={frameKey} title="Navigateur Chromium intégré" src="/browser/vnc.html?autoconnect=true&resize=scale&path=browser%2Fwebsockify&reconnect=true&reconnect_delay=1000" allow="clipboard-read; clipboard-write" onLoad={prepareTouchFrame}/>
{userMode&&<div className="browser-touchbar" aria-label="Commandes tactiles du navigateur">
<span><b>Mode trackpad</b> glissez pour déplacer le curseur</span>
<div>
<button type="button" onClick={()=>pointerClick(1)} aria-label="Clic gauche">● <b>Clic</b></button>
<button type="button" onClick={()=>pointerClick(4)} aria-label="Clic droit">◐ <b>Droit</b></button>
<button type="button" className="touch-keyboard" onClick={showTouchKeyboard} aria-label="Afficher le clavier tactile">⌨ <b>Clavier</b></button>
</div>
</div>}
{!userMode&&<button className="browser-view-lock" onClick={()=>void mutate('control',{mode:'user'})}><b>Codex contrôle le navigateur</b><small>Vous voyez ses actions en direct · cliquer pour prendre la main</small></button>}
</>}
</section>
{showActivity&&<aside className="browser-activity"><header><div><b>Activité en direct</b><small>{state.running?(userMode?'Contrôle utilisateur':'Contrôle Codex'):'Arrêté'}</small></div><i className={state.running?'live':''}/></header><div>{state.events.map(event=><article key={event.id}><span>{eventIcon(event.kind)}</span><div><b>{event.summary}</b><time>{relativeDate(event.created_at)}</time>{event.screenshot_url&&<a href={event.screenshot_url} download>Voir / télécharger</a>}</div></article>)}{!state.events.length&&<p className="browser-no-events">Les actions de Codex apparaîtront ici.</p>}</div></aside>}
</div>
</div>
}
function eventIcon(kind:string){return({started:'●',navigate:'↗',click:'⌁',type:'T',press:'⌨',scroll:'↕',screenshot:'▧',handoff:'☝',resume:'C',reset:'↻',back:'←',forward:'→',refresh:'↻'} as Record<string,string>)[kind]||'·'}

116
frontend/src/Common.tsx Normal file

File diff suppressed because one or more lines are too long

94
frontend/src/Company.tsx Normal file
View File

@@ -0,0 +1,94 @@
import {FormEvent,useCallback,useMemo,useRef,useState} from 'react'
import {api} from './api'
import {ActivityCard,ActivityData,DashboardData,Project,ProjectGroup} from './types'
import {useDialogFocus} from './dialogFocus'
import {sharedApi} from './sharedApi'
import {useVisiblePolling} from './useVisiblePolling'
type CompanyProps={
username:string
isAdmin:boolean
projects:Project[]
activeProjectIds:Set<string>
onMaster:()=>void
onOrchestrate:()=>void
onProjects:()=>void
onServer:()=>void
onEcosystem:()=>void
onProject:(project:Project)=>void
onProjectsRefresh:()=>Promise<void>
onError:(error:Error|string)=>void
}
export function Company({username,isAdmin,projects,activeProjectIds,onMaster,onOrchestrate,onProjects,onServer,onEcosystem,onProject,onProjectsRefresh,onError}:CompanyProps){
const[groups,setGroups]=useState<ProjectGroup[]>([]),[activity,setActivity]=useState<ActivityData|null>(null),[system,setSystem]=useState<DashboardData|null>(null),[editingGroup,setEditingGroup]=useState<ProjectGroup|null|undefined>(undefined)
const localProjects=useMemo(()=>projects.filter(project=>project.kind!=='remote'),[projects])
const refresh=useCallback(async()=>{const[groupRows,activityData,systemData]=await Promise.all([api<ProjectGroup[]>('/api/project-groups'),sharedApi<ActivityData>('/api/activity'),sharedApi<DashboardData>('/api/dashboard')]);setGroups(groupRows);setActivity(activityData);setSystem(systemData)},[])
const poll=useCallback(()=>refresh().catch(()=>undefined),[refresh])
useVisiblePolling(poll,12_000)
const priority=activity?.cards.find(card=>card.state==='running')||activity?.cards.find(card=>card.state==='waiting')||activity?.cards[0]
const visibleActivity=(activity?.cards||[]).slice(0,6)
const groupedIds=new Set(groups.flatMap(group=>group.members.map(member=>member.id)))
const ungrouped=localProjects.filter(project=>!groupedIds.has(project.id))
const projectById=(id:string)=>projects.find(project=>project.id===id)
async function groupsChanged(){await Promise.all([refresh(),onProjectsRefresh()])}
return <div className="cp-page company-page">
<header className="page-header company-page-header"><div><p className="eyebrow">CODEXPI · ORGANISATION</p><h1>Qui fait quoi&nbsp;?</h1><p>Le propriétaire décide, le coordinateur délègue et les groupes partagent leurs contrats techniques.</p></div><div><button className="button secondary company-manage-projects" onClick={onProjects}>Gérer les projets</button><button className="button secondary" onClick={()=>setEditingGroup(null)}>+ Groupe</button><button className="button primary" onClick={onOrchestrate}>+ Chantier</button></div></header>
<div className="company-command-grid">
<section className="canvas-window company-org-window">
<header className="canvas-window-head"><div><i/><b>Organisation</b></div><small>{groups.length} groupe{groups.length>1?'s':''} · {localProjects.length} projet{localProjects.length>1?'s':''}</small></header>
<div className="company-org-map">
<div className="company-owner"><span>VOUS</span><div><b>{username}</b><small>Vous décidez et validez</small></div></div>
<div className="company-arrow">↓</div>
<button className="company-master-card" onClick={onMaster}><span>{isAdmin?'★':'♙'}</span><div><b>{isAdmin?'Agent maître':'Chef d’orchestre'}</b><small>{isAdmin?'Coordonne l’entreprise et les privilèges':'Prépare et suit les chantiers autorisés'}</small></div><em>Ouvrir</em></button>
<div className="company-arrow">↓</div>
<div className="company-divisions">
<section className="company-division">
<header><span>01</span><div><b>Infrastructure commune</b><small>Services utilisés par tous les projets</small></div></header>
<button className="company-lead" onClick={onServer}><span>π</span><div><b>{system?.hostname||'Raspberry Pi'}</b><small>Docker · réseau · stockage · sécurité</small></div><em className={system?.connected?'online':'offline'}>{system?.connected?'Connecté':'État inconnu'}</em></button>
<button className="company-library" onClick={onEcosystem}><span>⬡</span><div><b>Écosystème partagé</b><small>Applications, composants et déploiements</small></div><em>Ouvrir</em></button>
</section>
<section className="company-division company-project-division">
<header><span>02</span><div><b>Groupes de projets</b><small>Lecture croisée · exécution coordonnée</small></div><button onClick={()=>setEditingGroup(null)}>+ Groupe</button></header>
<div className="company-groups">{groups.map(group=><article key={group.id}><header><div><b>{group.name}</b><small>{group.member_count} projet{group.member_count>1?'s':''}{isAdmin&&group.owner_username?` · ${group.owner_username}`:''}</small></div><button onClick={()=>setEditingGroup(group)} aria-label={`Modifier le groupe ${group.name}`}>Modifier</button></header>{group.description&&<p>{group.description}</p>}<div>{group.members.map(member=><button key={member.id} onClick={()=>{const item=projectById(member.id);if(item)onProject(item)}}><span className={activeProjectIds.has(member.id)?'busy':''}/><b>{member.name}</b></button>)}</div>{group.updates.length>0&&<details className="company-group-updates"><summary>Relais entre agents <b>{group.updates.length}</b></summary><div>{group.updates.slice(0,3).map(update=><p key={update.id}><strong>{update.project_name}</strong><span>{update.summary}</span></p>)}</div></details>}<footer>Les agents consultent le code des autres membres. En FULL ACCESS, ils respectent ce périmètre par consigne, sans frontière technique.</footer></article>)}{ungrouped.length>0&&<article className="company-ungrouped"><header><div><b>Sans groupe</b><small>{ungrouped.length} projet{ungrouped.length>1?'s':''}</small></div></header><div>{ungrouped.map(item=><button className="company-ungrouped-project" key={item.id} onClick={()=>onProject(item)}><i className={activeProjectIds.has(item.id)?'busy':''}/><div><b>{item.name}</b>{isAdmin&&item.owner_username?<small>Propriétaire · {item.owner_username}</small>:null}</div></button>)}</div></article>}{!localProjects.length&&<div className="company-empty">Aucun projet enregistré.</div>}</div>
</section>
</div>
</div>
</section>
<aside className="company-command-side">
<section className="canvas-window company-brief-window"><header className="canvas-window-head"><div><i/><b>Coordination</b></div><small>activité réelle</small></header><div><label>Travail prioritaire</label>{priority?<><b>{priority.title}</b><p>{priority.project_name} · {priority.agent} · {stateLabel(priority)}</p></>:<><b>Aucun chantier actif</b><p>Créez un chantier ou donnez un objectif au Chef d’orchestre.</p></>}<label>Autonomie</label><p>Les agents locaux utilisent FULL ACCESS. Les groupes conservent leurs chemins partagés et leur exécution coordonnée.</p><button className="button primary" onClick={onOrchestrate}>Donner un objectif</button></div></section>
<section className="canvas-window company-running-window"><header className="canvas-window-head"><div><i/><b>En cours</b></div><small>{visibleActivity.length} signal{visibleActivity.length>1?'aux':''}</small></header><div>{visibleActivity.map(card=><ActivityRow key={`${card.kind}-${card.id}`} card={card} onOpen={()=>{const item=projectById(card.project_id);if(item)onProject(item)}}/>)}{!visibleActivity.length&&<div className="company-empty">Aucune tâche active ou en validation.</div>}</div></section>
</aside>
</div>
{editingGroup!==undefined&&<GroupDialog group={editingGroup} projects={projects} onClose={()=>setEditingGroup(undefined)} onSaved={groupsChanged} onError={onError}/>}
</div>
}
function stateLabel(card:ActivityCard){return card.state==='running'?'En cours':card.state==='waiting'?'En attente':'À relire'}
function ActivityRow({card,onOpen}:{card:ActivityCard;onOpen:()=>void}){return <button onClick={onOpen}><i className={card.state}/><span><b>{card.title}</b><small>{card.project_name} · {card.agent}</small></span><em>{stateLabel(card)}</em></button>}
function GroupDialog({group,projects,onClose,onSaved,onError}:{group:ProjectGroup|null;projects:Project[];onClose:()=>void;onSaved:()=>Promise<void>;onError:(error:Error|string)=>void}){
const dialog=useRef<HTMLFormElement>(null)
const[name,setName]=useState(group?.name||''),[description,setDescription]=useState(group?.description||''),[selected,setSelected]=useState<Set<string>>(()=>new Set(group?.members.map(item=>item.id)||[])),[busy,setBusy]=useState(false),[error,setError]=useState('')
const candidates=projects.filter(project=>!project.archived)
const selectedOwner=candidates.find(project=>selected.has(project.id))?.owner_id||null
useDialogFocus(dialog,()=>{if(!busy)onClose()})
function toggle(project:Project){setSelected(current=>{const next=new Set(current);if(next.has(project.id))next.delete(project.id);else next.add(project.id);return next})}
async function submit(event:FormEvent){event.preventDefault();if(busy||selected.size<2||!name.trim())return;setBusy(true);setError('');try{await api(group?`/api/project-groups/${group.id}`:'/api/project-groups',{method:group?'PUT':'POST',body:JSON.stringify({name:name.trim(),description:description.trim(),project_ids:[...selected]})});await onSaved();onClose()}catch(reason){const message=(reason as Error).message;setError(message);onError(reason as Error)}finally{setBusy(false)}}
async function remove(){if(!group||busy||!confirm(`Supprimer le groupe « ${group.name} » ? Les projets resteront intacts.`))return;setBusy(true);setError('');try{await api(`/api/project-groups/${group.id}`,{method:'DELETE'});await onSaved();onClose()}catch(reason){const message=(reason as Error).message;setError(message);onError(reason as Error)}finally{setBusy(false)}}
return <div className="modal-backdrop" onMouseDown={event=>{if(event.target===event.currentTarget&&!busy)onClose()}}>
<form ref={dialog} className="cp-modal wide group-dialog" role="dialog" aria-modal="true" aria-labelledby="project-group-dialog-title" tabIndex={-1} onSubmit={submit}>
<header><div><p className="eyebrow">PÉRIMÈTRE PARTAGÉ</p><h2 id="project-group-dialog-title">{group?'Modifier le groupe':'Créer un groupe de projets'}</h2><p>Choisissez les projets qui travaillent autour du même système, comme les applications connectées à la TV. Un projet peut appartenir à plusieurs groupes.</p></div><button type="button" onClick={onClose} disabled={busy} aria-label="Fermer">×</button></header>
<div className="group-dialog-fields"><label>Nom du groupe<input autoFocus value={name} maxLength={80} onChange={event=>setName(event.target.value)} placeholder="TV" required/></label><label>Objectif commun<textarea value={description} maxLength={2000} onChange={event=>setDescription(event.target.value)} placeholder="Applications qui pilotent ou affichent du contenu sur la télévision."/></label></div>
<div className="group-permission-note"><b>Ce que le groupe autorise</b><span>Lecture du code des autres membres, partage des comptes rendus et exécution séquentielle. En FULL ACCESS, les chemins sont partagés avec une consigne stricte de ne modifier un autre projet que sur demande explicite. Ajouter un projet ici ne le retire d’aucun autre groupe.</span></div>
<fieldset><legend>Projets membres <b>{selected.size}</b></legend><div className="group-project-picker">{candidates.map(project=>{
const remote=project.kind==='remote',differentOwner=Boolean(selectedOwner&&project.owner_id!==selectedOwner&&!selected.has(project.id)),disabled=remote||differentOwner
const otherGroups=(project.groups||[]).filter(item=>item.id!==group?.id)
const detail=remote?'Projet distant — indisponible':otherGroups.length?`FULL ACCESS · déjà dans : ${otherGroups.map(item=>item.name).join(', ')}`:project.full_access?`FULL ACCESS · ${project.path}`:project.path
return <label className={`${selected.has(project.id)?'selected':''} ${disabled?'disabled':''}`} key={project.id}><input type="checkbox" checked={selected.has(project.id)} disabled={disabled||busy} onChange={()=>toggle(project)}/><span>{project.name.slice(0,2).toUpperCase()}</span><div><b>{project.name}</b><small>{detail}</small></div><i>{selected.has(project.id)?'✓':'+'}</i></label>
})}</div></fieldset>
{error&&<p className="inline-error" role="alert">{error}</p>}
<footer>{group&&<button className="danger-button" type="button" onClick={()=>void remove()} disabled={busy}>Supprimer le groupe</button>}<span/><button className="button secondary" type="button" onClick={onClose} disabled={busy}>Annuler</button><button className="button primary" disabled={busy||selected.size<2||!name.trim()}>{busy?'Enregistrement…':group?'Enregistrer':'Créer le groupe'}</button></footer>
</form>
</div>
}

View File

@@ -0,0 +1,51 @@
import { useCallback, useMemo, useState } from 'react'
import { api } from './api'
import { StatusDot } from './Common'
import { CodexQuota, DashboardData, Project, QuotaLimit, QuotaWindow, ToolStatus } from './types'
import { formatBytes, formatUptime, relativeDate } from './utils'
import { sharedApi } from './sharedApi'
import { useVisiblePolling } from './useVisiblePolling'
export function Dashboard({projects,archivedProjects,onOpen,onAdd,onError}:{projects:Project[];archivedProjects:Project[];onOpen:(p:Project)=>void;onAdd:()=>void;onError:(e:Error)=>void}){
const[data,setData]=useState<DashboardData|null>(null),[refreshing,setRefreshing]=useState(false),[showArchived,setShowArchived]=useState(false),[query,setQuery]=useState(''),[sort,setSort]=useState<'recent'|'name'|'activity'>('recent')
const refresh=useCallback(async()=>{setRefreshing(true);try{setData(await sharedApi<DashboardData>('/api/dashboard'))}catch(error){onError(error as Error)}finally{setRefreshing(false)}},[onError])
useVisiblePolling(refresh,15_000)
const visibleProjects=useMemo(()=>{
const needle=query.trim().toLocaleLowerCase('fr')
const rows=(showArchived?archivedProjects:projects).filter(item=>!needle||`${item.name} ${item.path} ${item.branch||''} ${item.owner_username||''}`.toLocaleLowerCase('fr').includes(needle))
return [...rows].sort((left,right)=>sort==='name'?left.name.localeCompare(right.name,'fr'):sort==='activity'?Number(Boolean(right.active_session))-Number(Boolean(left.active_session))||Number(right.modified_files||0)-Number(left.modified_files||0):(Date.parse(right.last_used||'')||0)-(Date.parse(left.last_used||'')||0))
},[archivedProjects,projects,query,showArchived,sort])
return <div className="cp-page dashboard-page">
<div className="page-header"><div><p className="eyebrow">PORTEFEUILLE · {projects.length} PROJET{projects.length>1?'S':''}</p><h1>Projets et chefs d’équipe</h1><p>{data?.hostname||'Connexion…'} · {data?formatUptime(data.uptime_seconds):'—'} · ressources du serveur</p></div><button className="icon-button" onClick={refresh} aria-label="Actualiser">↻</button></div>
<section className="system-strip">
<Metric label="CPU" value={data?`${Math.round(data.cpu_percent)}%`:'—'} progress={data?.cpu_percent} detail={data?`${data.cpu_count} cores`:''}/>
<Metric label="Mémoire" value={data?`${Math.round(data.ram_percent)}%`:'—'} progress={data?.ram_percent} detail={data?`${formatBytes(data.ram_used)} / ${formatBytes(data.ram_total)}`:''}/>
<Metric label="Température" value={data?.temperature_c!=null?`${Math.round(data.temperature_c)}°C`:'—'} progress={data?.temperature_c||0} detail={data?.temperature_c==null?'Indisponible':'Température CPU'}/>
<Metric label="Stockage" value={data?`${Math.round(data.disk_percent)}%`:'—'} progress={data?.disk_percent} detail={data?`${formatBytes(data.disk_used)} / ${formatBytes(data.disk_total)}`:''}/>
</section>
<div className="section-heading"><div><h2>{showArchived?'Projets archivés':'Portefeuille'}</h2><p>{showArchived?'Historique des projets retirés de la liste active.':'Espaces de travail enregistrés dans les répertoires autorisés.'}</p></div><div className="project-heading-actions"><label className="project-filter"><span className="sr-only">Rechercher un projet</span><input type="search" value={query} onChange={event=>setQuery(event.target.value)} placeholder="Rechercher…"/></label><label className="project-sort"><span className="sr-only">Trier les projets</span><select value={sort} onChange={event=>setSort(event.target.value as typeof sort)}><option value="recent">Plus récents</option><option value="name">Nom</option><option value="activity">Activité</option></select></label><button className={`button secondary ${showArchived?'active':''}`} onClick={()=>setShowArchived(value=>!value)}>{showArchived?'← Projets actifs':`▣ Archives (${archivedProjects.length})`}</button>{!showArchived&&<button className="button secondary" onClick={onAdd}>+ Projet</button>}</div></div>
<div className="project-table"><div className="project-table-head"><span>Projet</span><span>Source</span><span>Modifications</span><span>État</span><span>Dernière utilisation</span></div>{visibleProjects.map(item=><button key={item.id} className={item.kind==='remote'?'remote-project-row':''} onClick={()=>onOpen(item)}><span className="project-cell"><i>{item.kind==='remote'?'⌁':'◇'}</i><span><b>{item.name}</b><code>{item.kind==='remote'?(item.remote_machine_name||'Machine distante'):item.path}</code></span></span><span><code>{item.kind==='remote'?'Machine distante':item.branch||'Aucun dépôt Git'}</code></span><span>{item.kind==='remote'?<em className="change-count">—</em>:<em className={item.modified_files?'change-count dirty':'change-count'}>{item.modified_files||0}</em>}</span><span><StatusDot status={item.archived||item.remote_revoked?'failed':item.kind==='remote'?(item.remote_online?'running':'failed'):(item.active_session?'running':'idle')}/> {item.archived?'Archivé':item.kind==='remote'?(item.remote_revoked?'Révoqué':item.remote_online?'En ligne':'Hors ligne'):(item.active_session?'Actif':'Disponible')}</span><span>{relativeDate(item.last_used)}</span></button>)}{!visibleProjects.length&&<div className="table-empty">{query?'Aucun projet ne correspond à la recherche.':showArchived?'Aucun projet archivé.':'Aucun projet enregistré.'}</div>}</div>
<section className="runtime-row">
<Runtime name="Raspberry Pi" available={Boolean(data?.connected)} detail={data?.platform||'Connexion…'}/>
<Runtime name="Docker" {...(data?.docker||{available:false,version:null})}/><Runtime name="Codex CLI" {...(data?.codex||{available:false,version:null})}/><Runtime name="Git" {...(data?.git||{available:false,version:null})}/>
<Runtime name="Sessions" available={Boolean(data)} detail={data?`${data.active_sessions} active${data.active_sessions>1?'s':''} · ${data.total_sessions} au total`:'—'}/>
</section>
<QuotaCard quota={data?.codex_quota} onRefresh={refresh} refreshing={refreshing} onError={onError}/>
</div>
}
function quotaLabel(minutes:number){if(minutes%10080===0)return`${minutes/10080} semaine${minutes>10080?'s':''}`;if(minutes%1440===0)return`${minutes/1440} jour${minutes>1440?'s':''}`;if(minutes%60===0)return`${minutes/60} h`;return`${minutes} min`}
function resetLabel(value:number){return new Date(value*1000).toLocaleString('fr-FR',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'})}
function windowTitle(minutes:number){if(minutes===10080)return"Limite d’utilisation hebdomadaire";if(minutes===300)return"Limite d’utilisation sur 5 heures";return`Limite d’utilisation sur ${quotaLabel(minutes)}`}
function QuotaWindowView({value}:{value:QuotaWindow}){return <div className="quota-window"><div className="quota-window-copy"><b>{windowTitle(value.window_minutes)}</b><small>Réinitialisation : <time dateTime={new Date(value.resets_at*1000).toISOString()}>{resetLabel(value.resets_at)}</time></small></div><div className="quota-progress"><div className="meter"><i style={{width:`${value.remaining_percent}%`}}/></div><strong>{Math.round(value.remaining_percent)} % restants</strong></div></div>}
function limitTitle(limit:QuotaLimit,index:number){if(limit.id==='codex')return'Limites d’utilisation générales';return`Limites d’utilisation de ${limit.name||limit.id.replace(/^codex[-_]?/i,'Codex ')||`modèle ${index+1}`}`}
function QuotaGroup({limit,index}:{limit:QuotaLimit;index:number}){const windows=[limit.primary,limit.secondary].filter((item):item is QuotaWindow=>Boolean(item));return <section className="quota-limit-group"><h3>{limitTitle(limit,index)}</h3><div className="quota-limit-box">{windows.map((value,windowIndex)=><QuotaWindowView key={`${value.window_minutes}-${windowIndex}`} value={value}/>)}</div></section>}
function QuotaCard({quota,onRefresh,refreshing,onError}:{quota:CodexQuota|undefined;onRefresh:()=>Promise<void>;refreshing:boolean;onError:(e:Error)=>void}){
const[tab,setTab]=useState<'available'|'history'>('available'),[redeeming,setRedeeming]=useState<string|null>(null),[notice,setNotice]=useState('')
const fallback:QuotaLimit[] = quota?.available?[{id:'codex',name:null,primary:quota.primary,secondary:quota.secondary,credits:quota.credits||{has_credits:false,unlimited:false,balance:'0'},plan_type:quota.plan_type,observed_at:quota.observed_at}]:[]
const limits=quota?.limits?.length?quota.limits:fallback
const resetCredits=quota?.reset_credits,availableCount=resetCredits?.available_count||0,available=resetCredits?.credits||[],history=quota?.reset_history||[]
async function redeem(creditId:string|null){if(!confirm('Utiliser cette réinitialisation complète maintenant ? Ce crédit sera consommé.'))return;setRedeeming(creditId||'next');setNotice('');try{const result=await api<{message:string}>('/api/codex/quota/reset',{method:'POST',body:JSON.stringify({credit_id:creditId})});setNotice(result.message);await onRefresh()}catch(error){onError(error as Error)}finally{setRedeeming(null)}}
return <section className="quota-card"><div className="quota-heading"><div><p className="eyebrow">CODEX</p><h2>Limites d’utilisation</h2>{quota?.observed_at&&<small>Mesuré {relativeDate(quota.observed_at)}</small>}</div><button className="button secondary quota-refresh" onClick={()=>void onRefresh()} disabled={refreshing}>{refreshing?'Actualisation…':'↻ Actualiser'}</button></div>{limits.length?<><div className="quota-groups">{limits.map((limit,index)=><QuotaGroup key={limit.id} limit={limit} index={index}/>)}</div><section className="quota-reset-section"><h3>Réinitialisation des limites d’utilisation</h3><div className="quota-reset-box"><div className="quota-tabs" role="tablist"><button className={tab==='available'?'active':''} onClick={()=>setTab('available')} role="tab">Disponibles <b>{availableCount}</b></button><button className={tab==='history'?'active':''} onClick={()=>setTab('history')} role="tab">Historique</button></div>{notice&&<p className="quota-reset-notice" role="status">{notice}</p>}<ul>{tab==='available'?<>{available.map(credit=><li key={credit.id}><div><b>{credit.title==='Full reset'?'Réinitialisation complète':credit.title||'Réinitialisation complète'}</b>{credit.description&&<small>{credit.description}</small>}{credit.expires_at&&<time dateTime={new Date(credit.expires_at*1000).toISOString()}>Expire le {resetLabel(credit.expires_at)}</time>}</div><button className="button secondary" onClick={()=>void redeem(credit.id)} disabled={Boolean(redeeming)}>{redeeming===credit.id?'Utilisation…':'Utiliser la réinitialisation'}</button></li>)}{available.length<availableCount&&<li><div><b>{availableCount-available.length} réinitialisation{availableCount-available.length>1?'s':''} supplémentaire{availableCount-available.length>1?'s':''}</b><small>Codex n’a pas fourni le détail de ces crédits.</small></div><button className="button secondary" onClick={()=>void redeem(null)} disabled={Boolean(redeeming)}>{redeeming==='next'?'Utilisation…':'Utiliser la prochaine'}</button></li>}{availableCount===0&&<li className="quota-reset-empty">Aucune réinitialisation disponible.</li>}</>:<>{history.map((item,index)=><li key={`${item.consumed_at}-${index}`}><div><b>{item.title}</b><time dateTime={item.consumed_at}>Utilisée le {new Date(item.consumed_at).toLocaleString('fr-FR',{dateStyle:'short',timeStyle:'short'})}</time></div></li>)}{history.length===0&&<li className="quota-reset-empty">Aucune réinitialisation utilisée depuis CodexPi.</li>}</>}</ul></div></section></>:<p className="quota-unavailable">{quota?'Aucune mesure de quota disponible. Lance une demande Codex puis actualise les quotas.':'Chargement du quota…'}</p>}</section>
}
function Metric({label,value,progress=0,detail}:{label:string;value:string;progress?:number;detail:string}){return <div className="metric"><div><span>{label}</span><b>{value}</b></div><div className="meter"><i style={{width:`${Math.min(100,Math.max(0,progress))}%`}}/></div><small>{detail}</small></div>}
function Runtime({name,available,version,detail}:{name:string;available:boolean;version?:string|null;detail?:string}){return <div className="runtime"><span className={available?'online-dot':'offline-dot'}/><div><b>{name}</b><small>{detail||version||'Indisponible'}</small></div></div>}

View File

@@ -0,0 +1,38 @@
import {type FormEvent,useCallback,useMemo,useState} from 'react'
import {api} from './api'
import {ApplicationRegistryItem,ApplicationDeploymentStatus,MissionSupervision} from './types'
import {useVisiblePolling} from './useVisiblePolling'
import {Modal} from './Common'
import {relativeDate,statusLabel} from './utils'
type DeploymentHistory={id:string;environment:string;status:string;commit_sha:string;internal_url:string;created_at:string;steps_total:number;steps_passed:number;steps_failed:number}
const STATUS_LABEL:Record<ApplicationDeploymentStatus,string>={
DEVELOPMENT_READY:'Développement prêt',TESTING:'Tests',INTEGRATION_READY:'Intégration prête',DEPLOYED:'Déployée',OPERATIONAL:'Opérationnelle',BLOCKED:'Bloquée',
}
function duration(seconds:number|null|undefined){
if(seconds==null)return '—'
if(seconds<60)return `${seconds} s`
const minutes=Math.floor(seconds/60),hours=Math.floor(minutes/60)
return hours?`${hours} h ${minutes%60} min`:`${minutes} min`
}
export function DeploymentRegistry({isAdmin,onError}:{isAdmin:boolean;onError:(error:Error|string)=>void}){
const[items,setItems]=useState<ApplicationRegistryItem[]>([]),[mission,setMission]=useState<MissionSupervision|null>(null)
const[query,setQuery]=useState(''),[editing,setEditing]=useState<ApplicationRegistryItem|null>(null),[busy,setBusy]=useState(false)
const[prepared,setPrepared]=useState<{id:string;environment:string;steps:{kind:string;status:string;summary:string}[]}|null>(null)
const[history,setHistory]=useState<DeploymentHistory[]>([]),[historyLoading,setHistoryLoading]=useState(false)
const load=useCallback(async()=>{try{const[registry,state]=await Promise.all([api<ApplicationRegistryItem[]>('/api/application-registry'),api<MissionSupervision>('/api/application-registry/mission')]);setItems(registry);setMission(state)}catch(error){onError(error as Error)}},[onError])
useVisiblePolling(load,10_000)
const visible=useMemo(()=>{const value=query.trim().toLocaleLowerCase('fr');return value?items.filter(item=>`${item.name} ${item.slug} ${item.backend} ${item.frontend} ${item.deployment_status}`.toLocaleLowerCase('fr').includes(value)):items},[items,query])
async function save(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!editing)return;const form=new FormData(event.currentTarget);setBusy(true);try{await api(`/api/application-registry/${editing.id}`,{method:'PATCH',body:JSON.stringify({git_commit:form.get('git_commit'),internal_url:form.get('internal_url'),deployment_status:form.get('deployment_status'),deployment_notes:form.get('deployment_notes')})});setEditing(null);await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
async function prepare(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!editing)return;const form=new FormData(event.currentTarget);setBusy(true);try{const result=await api<{id:string;environment:string;steps:{kind:string;status:string;summary:string}[]}>(`/api/application-registry/${editing.id}/deployments/prepare`,{method:'POST',body:JSON.stringify({environment:form.get('environment'),commit_sha:form.get('commit_sha'),internal_url:form.get('target_url')})});setPrepared(result);setHistory(await api<DeploymentHistory[]>(`/api/application-registry/${editing.id}/deployments`))}catch(error){onError(error as Error)}finally{setBusy(false)}}
async function open(item:ApplicationRegistryItem){setPrepared(null);setEditing(item);setHistory([]);setHistoryLoading(true);try{setHistory(await api<DeploymentHistory[]>(`/api/application-registry/${item.id}/deployments`))}catch(error){onError(error as Error)}finally{setHistoryLoading(false)}}
return <div className="ecosystem-page deployment-registry"><header className="ecosystem-hero"><div><p className="eyebrow">MISE EN SERVICE CONTRÔLÉE</p><h1>Registre des applications</h1><p>Code, preuves, intégration et déploiement des 17 applications. Aucun plan préparé ici n’exécute une mise en production.</p></div><div className="registry-count"><strong>{items.length}</strong><span>applications suivies</span></div></header>
{mission&&<section className={`mission-monitor ${mission.alert?'alert':''}`} aria-live="polite"><div><span className="monitor-dot"/><div><small>MISSION ACTIVE</small><strong>{mission.active?.name||'Aucune mission active'}</strong><p>{mission.active?`${mission.active.status} · heartbeat ${duration(mission.active.heartbeat_age_seconds)} · durée ${duration(mission.active.elapsed_seconds)}`:(mission.alert_reason||'File au repos')}</p></div></div><dl><div><dt>Worker</dt><dd>{mission.active?.worker_alive?'Actif':'Inactif'}</dd></div><div><dt>Prochaine</dt><dd>{mission.next?.name||'—'}</dd></div><div><dt>Restantes</dt><dd>{mission.remaining}</dd></div><div><dt>Tentative</dt><dd>{mission.active?`${mission.active.attempts}/${mission.active.max_attempts}`:'—'}</dd></div></dl>{mission.alert&&<p className="monitor-warning">{mission.alert_reason}</p>}</section>}
<label className="registry-search">Rechercher dans le registre<input type="search" value={query} onChange={event=>setQuery(event.target.value)} placeholder="Application, stack ou statut"/></label>
<section className="registry-table-wrap"><table className="registry-table"><thead><tr><th>Application</th><th>Code</th><th>Tests</th><th>Intégration</th><th>Déploiement</th><th>Opérationnel</th><th/></tr></thead><tbody>{visible.map(item=><tr key={item.id}><td><strong>{item.name}</strong><small>{item.app_type.replaceAll('_',' ')} · {item.project_status.replaceAll('_',' ')}</small></td><td><code>{item.git_commit||'commit manquant'}</code><small>{item.backend}<br/>{item.frontend}</small></td><td><strong className={item.test_state.failed?'danger':''}>{item.test_state.passed} ✓ · {item.test_state.failed} ✕</strong><small>{item.test_state.checked_at?'preuves enregistrées':'aucune preuve structurée'}</small></td><td><code>{item.open_route}</code><small>{item.internal_url||'URL interne manquante'}</small></td><td><span className={`deployment-badge status-${item.deployment_status.toLowerCase()}`}>{STATUS_LABEL[item.deployment_status]}</span><small>{item.deployment_notes}</small></td><td><strong className={item.operational?'operational':'not-operational'}>{item.operational?'OUI':'NON'}</strong></td><td>{isAdmin&&<button className="button secondary" onClick={()=>void open(item)}>Préparer</button>}</td></tr>)}{!visible.length&&<tr><td colSpan={7}>Aucune application trouvée.</td></tr>}</tbody></table></section>
{editing&&<Modal title={`Préparer ${editing.name}`} onClose={()=>setEditing(null)} wide><div className="registry-modal"><form className="modal-form" onSubmit={save}><h3>Preuves d’intégration</h3><label>Commit Git<input name="git_commit" defaultValue={editing.git_commit} placeholder="7 à 40 caractères hexadécimaux"/></label><label>URL interne privée<input name="internal_url" defaultValue={editing.internal_url} placeholder="http://127.0.0.1:19000"/></label><label>Statut<select name="deployment_status" defaultValue={editing.deployment_status}>{(['DEVELOPMENT_READY','TESTING','INTEGRATION_READY','BLOCKED'] as ApplicationDeploymentStatus[]).map(status=><option value={status} key={status}>{STATUS_LABEL[status]}</option>)}</select><small>Les statuts « Déployée » et « Opérationnelle » ne peuvent pas être déclarés manuellement.</small></label><label>Notes<textarea name="deployment_notes" defaultValue={editing.deployment_notes}/></label><div className="modal-actions"><button className="button primary" disabled={busy}>{busy?'Enregistrement…':'Enregistrer les preuves'}</button></div></form><form className="modal-form deployment-prepare-form" onSubmit={prepare}><h3>Plan sans exécution</h3><label>Environnement<select name="environment" defaultValue="staging"><option value="staging">Recette isolée</option><option value="production">Production — préparation uniquement</option></select></label><label>Commit à préparer<input name="commit_sha" defaultValue={editing.git_commit} required/></label><label>Destination privée<input name="target_url" defaultValue={editing.internal_url} required/></label><button className="button secondary" disabled={busy||!editing.git_commit||!editing.internal_url}>Générer le plan progressif</button></form>{prepared&&<section className="prepared-plan"><h3>Plan {prepared.id.slice(0,8)} préparé</h3><p>{prepared.environment} · aucune étape exécutée</p><ol>{prepared.steps.map(step=><li key={step.kind}><span>{statusLabel(step.status)}</span><div><strong>{step.kind}</strong><small>{step.summary}</small></div></li>)}</ol></section>}<section className="deployment-history"><h3>Historique des préparations</h3>{historyLoading?<p>Chargement…</p>:history.length?<ol>{history.map(item=><li key={item.id}><div><strong>{item.environment==='production'?'Production':'Recette'} · {statusLabel(item.status)}</strong><small><code>{item.commit_sha}</code> · {relativeDate(item.created_at)}</small></div><span>{item.steps_passed}/{item.steps_total} étapes{item.steps_failed?` · ${item.steps_failed} échec(s)`:''}</span></li>)}</ol>:<p>Aucun plan préparé pour cette application.</p>}</section></div></Modal>}
</div>
}

View File

@@ -0,0 +1,48 @@
import {useCallback,useState,type FormEvent} from 'react'
import {api} from './api'
import type {Project,StudioRequest} from './types'
import {useVisiblePolling} from './useVisiblePolling'
const statuses:Record<string,string>={PLANNED:'Plan à valider',QUEUED:'En file',RUNNING:'Génération en cours',TESTING:'Préparation isolée',READY:'V0 à réviser',BLOCKED:'Vérification nécessaire',REJECTED:'Annulée'}
export function DevStudio({onError,onOpenWorkspace}:{onError:(error:Error|string)=>void;onOpenWorkspace:(id:string)=>void}){
const[requests,setRequests]=useState<StudioRequest[]>([])
const[projects,setProjects]=useState<Project[]>([])
const[selected,setSelected]=useState<string>('')
const[busy,setBusy]=useState(false)
const[loading,setLoading]=useState(true)
const load=useCallback(async()=>{
try{const[r,p]=await Promise.all([api<StudioRequest[]>('/api/dev-studio/requests'),api<Project[]>('/api/projects')]);setRequests(r);setProjects(p.filter(item=>item.kind!=='remote'&&!item.archived))}
catch(error){onError(error as Error)}finally{setLoading(false)}
},[onError])
useVisiblePolling(load,8000)
const current=requests.find(item=>item.id===selected)
async function submit(event:FormEvent<HTMLFormElement>){
event.preventDefault();const element=event.currentTarget;const form=new FormData(element);setBusy(true)
try{const row=await api<StudioRequest>('/api/dev-studio/requests',{method:'POST',body:JSON.stringify({project_id:form.get('project_id'),name:form.get('name'),description:form.get('description')})});setSelected(row.id);element.reset();await load()}
catch(error){onError(error as Error)}finally{setBusy(false)}
}
async function approve(){if(!current)return;setBusy(true);try{await api(`/api/dev-studio/requests/${current.id}/approve`,{method:'POST',body:JSON.stringify({plan_hash:current.plan_hash})});await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
function download(){if(!current)return;const body=`# Rapport V0 — ${current.name}\n\nÉtat : ${statuses[current.status]||current.status}\nBranche : ${current.branch_name||'non créée'}\nBase : ${current.base_sha}\nPlan SHA-256 : ${current.plan_hash}\n\n${current.specification}\n\n${current.architecture}\n\n## Rapport de l’agent\n${current.report||'Aucun résultat disponible.'}\n\n## Preuves des commandes de vérification\n${(current.tests||[]).map(test=>`${test.command}\nÉtat : ${test.status} · code de sortie : ${test.exit_code??'inconnu'}\n${test.output}`).join('\n\n')}\n\n## État Git\n${JSON.stringify(current.git,null,2)}\n\nLimite : les événements CLI attestent les commandes, la pertinence des tests exige une revue humaine. Aucun déploiement effectué par Studio.\n`;const url=URL.createObjectURL(new Blob([body],{type:'text/markdown;charset=utf-8'}));const link=document.createElement('a');link.href=url;link.download=`studio-${current.id}.md`;link.click();setTimeout(()=>URL.revokeObjectURL(url),1000)}
return <div className="ecosystem-page studio-page">
<header className="ecosystem-hero"><div><p className="eyebrow">DU BESOIN À LA REVUE</p><h1>Autonomous Dev Studio</h1><p>Cadrez votre V0, validez son plan et suivez sa réalisation dans un worktree dédié.</p></div><button className="button secondary" onClick={()=>void load()}>Actualiser</button></header>
<ol className="studio-steps" aria-label="Parcours V0"><li>1. Cadrage</li><li>2. Approbation</li><li>3. Génération et tests</li><li>4. Revue V0</li></ol>
<section className="ecosystem-columns"><article><h2>Préparer une demande</h2><form className="ecosystem-form" onSubmit={submit}>
<label>Projet cible<select name="project_id" required defaultValue=""><option value="" disabled>Choisir un projet local</option>{projects.map(project=><option key={project.id} value={project.id}>{project.name}{project.full_access?' · FULL ACCESS':''}</option>)}</select></label>
<p className="muted">Un dépôt avec un premier commit est requis. La génération part de ce commit, sans reprendre les modifications locales non commitées.</p>
<label>Nom de la V0<input name="name" required maxLength={120}/></label><label>Besoin et critères de réussite<textarea name="description" required minLength={20} maxLength={30000} rows={6} placeholder="Utilisateurs, parcours essentiel, résultats attendus…"/></label>
<button className="button primary" disabled={busy||!projects.length}>{busy?'Préparation…':'Créer le plan à valider'}</button>
{!loading&&!projects.length&&<p role="status">Ajoutez d’abord un projet local actif depuis le tableau de bord.</p>}
</form></article><article><h2>Demandes persistantes</h2><div className="studio-requests" aria-live="polite">{loading&&<p>Chargement…</p>}{requests.map(item=><button type="button" key={item.id} aria-pressed={selected===item.id} onClick={()=>setSelected(item.id)}><b>{item.name}</b><span>{statuses[item.status]||item.status}</span></button>)}{!loading&&!requests.length&&<p>Aucune demande. Préparez votre premier plan.</p>}</div></article></section>
{current&&<section className="ecosystem-card studio-detail"><header className="ecosystem-hero"><div><h2>{current.name}</h2><p role="status">{statuses[current.status]||current.status}</p></div><button className="button secondary" onClick={download}>Télécharger le rapport</button></header>
<details open={current.status==='PLANNED'}><summary>Cahier des charges et plan</summary><pre>{current.specification}</pre><h3>Architecture</h3><p>{current.architecture}</p><p>Commit de départ : <code>{current.base_sha}</code></p><p>Empreinte du plan : <code>{current.plan_hash}</code></p></details>
{current.status==='PLANNED'&&<div className="studio-approval"><p>Cette validation autorise les modifications et les tests dans un worktree isolé avec le mode d’autonomie du projet. Les actions de production, la publication et les installations globales nécessitent une autorisation distincte.</p><button className="button primary" disabled={busy} onClick={()=>void approve()}>{busy?'Lancement…':'Approuver ce plan et générer la V0'}</button></div>}
{current.error&&<p role="alert" className="studio-warning">{current.error}</p>}
{current.conversation_id&&<><p>Branche : <code>{current.branch_name}</code></p><p>Arbre de travail : <code>{current.worktree_path}</code></p><button className="button secondary" onClick={()=>onOpenWorkspace(current.project_id)}>Ouvrir les sessions, modifications et aperçus</button><p className="muted">Sélectionnez la discussion « Studio — {current.name} ». Modifications permet de consulter le diff, et Aperçus les serveurs de l’arbre de travail.</p></>}
<h3>Prévisualisation privée</h3>{(current.previews||[]).map(preview=><p key={preview.port}><a className="button secondary" href={preview.url} target="_blank" rel="noopener noreferrer">Ouvrir la prévisualisation · {preview.port}</a></p>)}{!(current.previews||[]).length&&<p>Aucun serveur local détecté dans ce worktree. Le rapport indique la commande de lancement si une interface est disponible.</p>}
<h3>Vérifications enregistrées</h3>{!(current.tests||[]).length&&<p>Aucune commande de test attestée pour cette demande.</p>}{(current.tests||[]).map((test,index)=><details key={index}><summary>{test.status==='done'?'✓':'⚠'} {test.command} · code {test.exit_code??'inconnu'}</summary><pre>{test.output||'Sortie vide'}</pre></details>)}
{current.git&&<><h3>Revue Git</h3><p>{current.git.files.length} fichier(s) modifié(s) dans le worktree</p><ul>{current.git.files.map(file=><li key={file.path}><code>{file.status} {file.path}</code></li>)}</ul></>}
<h3>Rapport de l’agent</h3><pre>{current.report||'Aucun rapport disponible.'}</pre><p className="muted">« V0 à réviser » indique un rapport et des commandes de vérification terminées sans erreur. Leur couverture et le fonctionnement métier restent à valider en revue humaine.</p>
</section>}
</div>
}

View File

@@ -0,0 +1,42 @@
import {DevStudio} from './DevStudio'
import {FormEvent,useCallback,useEffect,useState} from 'react'
import {api} from './api'
import {Portfolio} from './Portfolio'
import {AgentAssignment,PiDoctorDashboard,PortfolioProject,SpecializedAgent,WorkspaceTab} from './types'
import {relativeDate,statusLabel} from './utils'
import {AppStore} from './AppStore'
import {DeploymentRegistry} from './DeploymentRegistry'
import {useVisiblePolling} from './useVisiblePolling'
type View='projects'|'store'|'registry'|'doctor'|'agents'|'studio'
export function Ecosystem({isAdmin,onOpenWorkspace,onError,initialView='projects',showNav=true}:{isAdmin:boolean;onOpenWorkspace:(id:string,tab?:WorkspaceTab)=>void;onError:(error:Error|string)=>void;initialView?:View;showNav?:boolean}){
const[view,setView]=useState<View>(()=>{const requested=new URLSearchParams(location.search).get('ecosystem');return requested==='store'||requested==='registry'||requested==='doctor'||requested==='agents'||requested==='studio'?requested:initialView})
function select(next:View){setView(next);const url=new URL(location.href);url.searchParams.set('view','portfolio');url.searchParams.set('ecosystem',next);history.pushState({view:'portfolio',ecosystem:next},'',`${url.pathname}?${url.searchParams.toString()}`)}
useEffect(()=>{const restore=()=>{const requested=new URLSearchParams(location.search).get('ecosystem');if(['projects','store','registry','doctor','agents','studio'].includes(requested||''))setView(requested as View)};addEventListener('popstate',restore);return()=>removeEventListener('popstate',restore)},[])
return <div className={`ecosystem-shell ${showNav?'':'ecosystem-focused'}`}>{showNav&&<nav className="ecosystem-nav" aria-label="Écosystème CodexPi"><div><small>Produits</small><button aria-current={view==='projects'?'page':undefined} className={view==='projects'?'active':''} onClick={()=>select('projects')}>Vue globale</button><button aria-current={view==='store'?'page':undefined} className={view==='store'?'active':''} onClick={()=>select('store')}>App Store</button></div><div><small>Exploitation</small><button aria-current={view==='registry'?'page':undefined} className={view==='registry'?'active':''} onClick={()=>select('registry')}>Mise en service</button><button aria-current={view==='doctor'?'page':undefined} className={view==='doctor'?'active':''} onClick={()=>select('doctor')}>Pi Doctor</button></div><div><small>Construction</small><button aria-current={view==='agents'?'page':undefined} className={view==='agents'?'active':''} onClick={()=>select('agents')}>Codex Commander</button><button aria-current={view==='studio'?'page':undefined} className={view==='studio'?'active':''} onClick={()=>select('studio')}>Dev Studio</button></div></nav>}{view==='projects'&&<Portfolio isAdmin={isAdmin} onOpenWorkspace={onOpenWorkspace} onError={onError}/>} {view==='store'&&<AppStore isAdmin={isAdmin} onOpenWorkspace={onOpenWorkspace} onError={onError}/>} {view==='registry'&&<DeploymentRegistry isAdmin={isAdmin} onError={onError}/>} {view==='doctor'&&<PiDoctor isAdmin={isAdmin} onOpenWorkspace={onOpenWorkspace} onError={onError}/>} {view==='agents'&&<Commander onOpenWorkspace={onOpenWorkspace} onError={onError}/>} {view==='studio'&&<DevStudio onError={onError} onOpenWorkspace={onOpenWorkspace}/>}</div>
}
function PiDoctor({isAdmin,onOpenWorkspace,onError}:{isAdmin:boolean;onOpenWorkspace:(id:string)=>void;onError:(error:Error|string)=>void}){
const[data,setData]=useState<PiDoctorDashboard|null>(null),[busy,setBusy]=useState(false)
const load=useCallback(()=>api<PiDoctorDashboard>('/api/pi-doctor').then(setData).catch(error=>onError(error as Error)),[onError])
useVisiblePolling(load,10_000)
async function diagnostic(){if(!isAdmin||busy)return;setBusy(true);try{const plan=await api<{id:string;plan_hash:string}>('/api/pi-doctor/repair-plans',{method:'POST',body:JSON.stringify({operation:'diagnostic_snapshot',parameters:{},summary:'Actualiser un instantané système en lecture seule',risks:'Charge CPU brève liée à la collecte; aucune mutation',backup_required:false,rollback_plan:'Aucune restauration nécessaire : opération sans écriture'})});if(!confirm(`Valider ce plan de diagnostic immuable ?\nEmpreinte : ${plan.plan_hash}`))return;await api(`/api/pi-doctor/repair-plans/${plan.id}/approve`,{method:'POST',body:JSON.stringify({plan_hash:plan.plan_hash})});await api(`/api/pi-doctor/repair-plans/${plan.id}/execute`,{method:'POST',body:JSON.stringify({plan_hash:plan.plan_hash})});await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
if(!data)return <div className="ecosystem-page"><p>Chargement de Pi Doctor…</p></div>
const s=data.snapshot
const nas=s.network?.nas_smb,containers=s.docker?.containers||[],services=s.services||{}
return <div className="ecosystem-page"><header className="ecosystem-hero"><div><p className="eyebrow">DIAGNOSTIC SÛR</p><h1>Pi Doctor</h1><p>Collecte indépendante, incidents persistants et exécution des réparations en mode diagnostic uniquement.</p><p className="muted">Source : {s.source==='collector'?'collecteur Raspberry Pi':'repli du conteneur CodexPi'} · {relativeDate(s.received_at||s.timestamp)}</p></div>{isAdmin&&<button className="button primary" disabled={busy} onClick={()=>void diagnostic()}>{busy?'Diagnostic…':'Lancer un diagnostic validé'}</button>}</header><section className="health-grid"><Health label="CPU" value={`${s.cpu_percent.toFixed(1)} %`}/><Health label="RAM" value={`${s.memory_percent.toFixed(1)} %`}/><Health label="Température" value={s.temperature_c==null?'Indisponible':`${s.temperature_c.toFixed(1)} °C`}/><Health label="Disque système" value={`${s.root_disk_percent.toFixed(1)} %`}/><Health label="NAS QNAP" value={!nas?.configured?'Non configuré':nas.reachable?'Joignable':'Indisponible'} alert={nas?.configured===true&&!nas.reachable}/><Health label="Docker" value={s.docker?.available?'Disponible':'Indisponible'} alert={s.docker?.available===false}/><Health label="Incidents ouverts" value={String(s.incidents_open)} alert={s.incidents_open>0}/></section><section className="ecosystem-columns"><article><h2>Incidents</h2><div className="compact-list">{data.incidents.map(item=><div key={item.id}><i className={item.severity}/><span><b>{item.service}</b><small>{item.symptoms} · {item.occurrences} occurrence(s) · {relativeDate(item.last_seen)}</small></span><em>{statusLabel(item.status)}</em></div>)}{!data.incidents.length&&<p>Aucun incident transmis.</p>}</div><h2>Système et redémarrages</h2><div className="compact-list"><div><span><b>Erreurs kernel récentes</b><small>Démarrage courant / précédent</small></span><em>{s.kernel_error_count} / {s.previous_boot_error_count}</em></div><div><span><b>Redémarrages anormaux</b></span><em>{s.abnormal_restart_count}</em></div></div></article><article><h2>Agent Codex</h2>{data.agent?<><h3>{data.agent.name}</h3><p>Session persistante : {data.agent.session_title||'non associée'}</p><p>Permissions : {data.agent.permissions.join(', ')}</p>{data.agent.portfolio_project_id&&<button className="button secondary" onClick={()=>{const project=data.agent?.portfolio_project_id;api<PortfolioProject>(`/api/portfolio/projects/${project}`).then(value=>value.workspace_id&&onOpenWorkspace(value.workspace_id)).catch(error=>onError(error as Error))}}>Ouvrir le diagnostic Codex</button>}</>:<p>Agent non initialisé.</p>}<h2>Montages surveillés</h2><div className="compact-list">{s.mounts.map(item=><div key={item.mountpoint}><span><b>{item.mountpoint}</b><small>{item.fstype||'non monté'} · {statusLabel(item.status)}{item.error?` · ${item.error}`:''}</small></span><em>{item.percent==null?'—':`${item.percent}%`}</em></div>)}{!s.mounts.length&&<p>Aucun instantané de montage reçu.</p>}</div></article></section><section className="ecosystem-columns"><article><h2>Docker</h2><div className="compact-list">{containers.map((item,index)=><div key={`${item.name}-${index}`}><span><b>{item.name||'Conteneur'}</b><small>{item.status||'État indisponible'}</small></span><em>{statusLabel(item.state)}</em></div>)}{!containers.length&&<p>{s.docker?.error||'Aucun conteneur signalé.'}</p>}</div></article><article><h2>Services essentiels</h2><div className="compact-list"><ServiceState label="CodexPi" state={services.codexpi}/><ServiceState label="Jellyfin" state={services.jellyfin}/>{Object.entries(services.systemd||{}).map(([name,state])=><div key={name}><span><b>{name}</b></span><em>{statusLabel(state)}</em></div>)}</div></article></section><section className="ecosystem-card"><h2>Réparation — plans immuables</h2><div className="compact-list">{data.plans.map(plan=><details key={plan.id}><summary><b>{plan.summary}</b> · <em>{statusLabel(plan.status)}</em></summary><p>Opération autorisée : <code>{plan.operation}</code></p><p>Risques : {plan.risks}</p><p>Sauvegarde : {plan.backup_required?'requise':'non requise pour ce diagnostic'}</p><p>Retour arrière : {plan.rollback_plan}</p><small>{relativeDate(plan.created_at)} · SHA-256 {plan.plan_hash}</small></details>)}{!data.plans.length&&<p>Aucun plan.</p>}</div><h2>Historique des exécutions</h2><div className="compact-list">{data.executions.map(item=><div key={item.id}><span><b>{item.operation}</b><small>{relativeDate(item.finished_at||item.started_at)}</small></span><em>{statusLabel(item.status)}</em></div>)}{!data.executions.length&&<p>Aucune intervention. Les réparations restent désactivées.</p>}</div></section></div>
}
function Commander({onOpenWorkspace,onError}:{onOpenWorkspace:(id:string)=>void;onError:(error:Error|string)=>void}){
const[agents,setAgents]=useState<SpecializedAgent[]>([]),[projects,setProjects]=useState<PortfolioProject[]>([]),[selected,setSelected]=useState<SpecializedAgent|null>(null),[busy,setBusy]=useState(false)
const load=useCallback(async()=>{try{const[a,p]=await Promise.all([api<SpecializedAgent[]>('/api/ecosystem/agents'),api<PortfolioProject[]>('/api/portfolio/projects')]);setAgents(a);setProjects(p);setSelected(current=>current&&a.find(item=>item.id===current.id)||null)}catch(error){onError(error as Error)}},[onError])
useEffect(()=>{void load()},[load])
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!selected)return;const form=new FormData(event.currentTarget);setBusy(true);try{await api(`/api/ecosystem/agents/${selected.id}/assign`,{method:'POST',body:JSON.stringify({project_id:form.get('project_id'),title:form.get('title'),instructions:form.get('instructions'),file_scope:String(form.get('file_scope')||'').split(',').map(value=>value.trim()).filter(Boolean)})});event.currentTarget.reset();await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
async function action(item:AgentAssignment,value:'pause'|'resume'|'retry'|'cancel'|'approve'|'reject'){setBusy(true);try{await api(`/api/ecosystem/assignments/${item.id}/action`,{method:'POST',body:JSON.stringify({action:value})});await load()}catch(error){onError(error as Error)}finally{setBusy(false)}}
return <div className="ecosystem-page"><header className="ecosystem-hero"><div><p className="eyebrow">AGENTS SPÉCIALISÉS</p><h1>Codex Commander</h1><p>Attributions structurées, permissions par rôle, réservation des fichiers et exécutions parallèles bornées selon les ressources du Pi.</p></div></header><section className="agent-grid">{agents.map(agent=><button key={agent.id} className={selected?.id===agent.id?'selected':''} onClick={()=>setSelected(agent)}><span>✦</span><b>{agent.name}</b><small>{agent.permissions.join(' · ')}</small><em>{statusLabel(agent.status)}</em></button>)}</section>{selected&&<section className="ecosystem-columns"><article><h2>Attribuer une tâche à {selected.name}</h2><form className="ecosystem-form" onSubmit={submit}><label>Projet<select name="project_id" required>{projects.map(project=><option key={project.id} value={project.id}>{project.name}</option>)}</select></label><label>Titre<input name="title" required maxLength={200}/></label><label>Instructions<textarea name="instructions" required rows={5}/></label><label>Périmètre de fichiers (séparé par des virgules)<input name="file_scope" placeholder="backend/api, tests"/></label><button className="button primary" disabled={busy}>{busy?'Ajout…':'Démarrer et ajouter à la file'}</button></form></article><article><h2>Travaux et validations</h2><div className="commander-assignments">{selected.assignments.map(item=><details key={item.id}><summary><b>{item.title}</b> · <em>{statusLabel(item.status)}</em></summary><p>{relativeDate(item.updated_at)}</p>{item.task_error&&<p role="alert">{item.task_error}</p>}<div className="task-actions">{item.status==='QUEUED'&&<button disabled={busy} onClick={()=>void action(item,'pause')}>Pause</button>}{item.status==='PAUSED'&&<button disabled={busy} onClick={()=>void action(item,'resume')}>Reprendre</button>}{['RUNNING','QUEUED'].includes(item.status)&&<button disabled={busy} onClick={()=>void action(item,'cancel')}>Interrompre</button>}{['FAILED','BLOCKED'].includes(item.status)&&<button disabled={busy} onClick={()=>void action(item,'retry')}>Relancer</button>}{item.status==='SUCCEEDED'&&<><button disabled={busy} onClick={()=>void action(item,'approve')}>Approuver</button><button disabled={busy} onClick={()=>void action(item,'reject')}>Refuser</button></>}</div><h4>Fichiers modifiés</h4>{item.files?.length?<ul>{item.files.map(file=><li key={file.path}><code>{file.status} {file.path}</code></li>)}</ul>:<p>Aucun fichier attesté.</p>}<h4>Tests</h4>{item.tests?.length?<ul>{item.tests.map((test,index)=><li key={index}>{statusLabel(test.status)} · {test.summary||test.command_json} · {test.passed} réussi(s), {test.failed} échoué(s)</li>)}</ul>:<p>Aucune preuve de test enregistrée.</p>}{item.result&&<details><summary>Résultat structuré</summary><pre>{item.result}</pre></details>}</details>)}{!selected.assignments.length&&<p>Aucune attribution.</p>}</div>{selected.conversation_id&&selected.portfolio_project_id&&<button className="button secondary" onClick={()=>{const project=projects.find(item=>item.id===selected.portfolio_project_id);if(project?.workspace_id)onOpenWorkspace(project.workspace_id)}}>Ouvrir la conversation, les modifications et les aperçus</button>}</article></section>}</div>
}
function Health({label,value,alert=false}:{label:string;value:string;alert?:boolean}){return <div className={alert?'alert':''}><span>{label}</span><b>{value}</b></div>}
function ServiceState({label,state}:{label:string;state?:{configured?:boolean;available?:boolean;status?:number;error?:string}}){return <div><span><b>{label}</b><small>{state?.error||(!state?.configured?'Non configuré':state.status?`HTTP ${state.status}`:'')}</small></span><em>{!state?.configured?'—':state.available?'Disponible':'Indisponible'}</em></div>}

View File

@@ -0,0 +1,27 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { copyText } from './Common'
import { GitState, Project } from './types'
type GitHubStatus={key_ready:boolean;public_key:string;account:string|null;verified_at:string|null}
const emptyStatus:GitHubStatus={key_ready:false,public_key:'',account:null,verified_at:null}
export function GitHubSettings({project,onSaved}:{project:Project|null;onSaved:()=>Promise<void>}){
const[status,setStatus]=useState<GitHubStatus>(emptyStatus),[repository,setRepository]=useState(''),[loading,setLoading]=useState(true),[busy,setBusy]=useState(''),[message,setMessage]=useState(''),[error,setError]=useState(''),[copied,setCopied]=useState(false)
const load=useCallback(async()=>{setLoading(true);setError('');try{const connection=await api<GitHubStatus>('/api/github/status');setStatus(connection);if(project&&project.kind!=='remote'){const git=await api<GitState>(`/api/projects/${project.id}/git`);setRepository(git.remote_url?.includes('github.com')?git.remote_url:'')}}catch(reason){setError((reason as Error).message)}finally{setLoading(false)}},[project?.id,project?.kind])
useEffect(()=>{void load()},[load])
async function generate(){setBusy('key');setError('');setMessage('');try{const value=await api<GitHubStatus>('/api/github/key',{method:'POST'});setStatus(value);setMessage('Clé créée. Ajoutez maintenant la clé publique à votre profil GitHub.')}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
async function copy(){const success=await copyText(status.public_key);setCopied(success);if(!success)setError('Impossible de copier automatiquement la clé.');window.setTimeout(()=>setCopied(false),1800)}
async function verify(){setBusy('test');setError('');setMessage('');try{const value=await api<GitHubStatus>('/api/github/test',{method:'POST'});setStatus(value);setMessage(`Connexion confirmée avec le compte ${value.account}.`)}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
async function connect(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!project)return;setBusy('repo');setError('');setMessage('');try{await api(`/api/projects/${project.id}/github`,{method:'POST',body:JSON.stringify({repository_url:repository.trim()})});await onSaved();setMessage('Dépôt GitHub connecté au projet. Les prochains commits pourront être poussés depuis CodexPi.')}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
return <section className="github-settings"><header><div className="github-mark">⑂</div><div><h3>GitHub</h3><p>Connectez CodexPi par SSH, sans enregistrer de mot de passe ni de token dans le dépôt.</p></div><span className={`github-state ${status.account?'connected':status.key_ready?'pending':''}`}>{status.account?`Connecté · ${status.account}`:status.key_ready?'Clé prête':'Non configuré'}</span></header>
{loading?<p className="github-loading">Chargement de la configuration GitHub…</p>:<div className="github-flow">
<div className="github-step"><span>1</span><div><b>Clé SSH de CodexPi</b><p>Cette clé reste sur le Raspberry Pi. Seule sa partie publique doit être envoyée à GitHub.</p>{status.key_ready?<div className="github-key"><code>{status.public_key}</code><button type="button" className="button secondary" onClick={()=>void copy()}>{copied?'✓ Copiée':'Copier'}</button></div>:<button type="button" className="button primary" disabled={Boolean(busy)} onClick={()=>void generate()}>{busy==='key'?'Génération…':'Générer la clé SSH'}</button>}</div></div>
{status.key_ready&&<div className="github-step"><span>2</span><div><b>Ajouter la clé au profil GitHub</b><p>Ouvrez GitHub, choisissez « New SSH key », collez la clé et donnez-lui le nom CodexPi.</p><div className="github-actions"><a className="button secondary" href="https://github.com/settings/ssh/new" target="_blank" rel="noreferrer">Ouvrir GitHub ↗</a><button type="button" className="button secondary" disabled={Boolean(busy)} onClick={()=>void verify()}>{busy==='test'?'Vérification…':'Vérifier la connexion'}</button></div></div></div>}
{status.key_ready&&project&&project.kind!=='remote'&&<div className="github-step"><span>3</span><div><b>Relier le projet · {project.name}</b><p>Copiez l’URL SSH affichée par GitHub dans Code → Local → SSH.</p><form onSubmit={connect}><input value={repository} onChange={event=>setRepository(event.target.value)} required maxLength={2048} spellCheck={false} autoCapitalize="none" placeholder="git@github.com:compte/depot.git"/><button className="button primary" disabled={Boolean(busy)||!repository.trim()}>{busy==='repo'?'Connexion…':'Connecter le dépôt'}</button></form></div></div>}
{status.key_ready&&(!project||project.kind==='remote')&&<p className="github-hint">Sélectionnez un projet local pour lui associer un dépôt GitHub.</p>}
{error&&<p className="inline-error" role="alert">{error}</p>}{message&&<p className="github-success" role="status">{message}</p>}
</div>}
</section>
}

View File

@@ -0,0 +1,35 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { Project } from './types'
type GitLabStatus={configured:boolean;server_url:string|null;account:string|null;name:string|null;verified_at:string|null;verify_tls:boolean}
type Namespace={id:number;name:string;full_path:string;kind:string}
type CreatedRepository={repository:{name:string;path_with_namespace:string;web_url:string;ssh_url_to_repo:string}}
const emptyStatus:GitLabStatus={configured:false,server_url:null,account:null,name:null,verified_at:null,verify_tls:true}
const slug=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9_.-]+/g,'-').replace(/^-+|-+$/g,'')
export function GitLabSettings({project,onSaved}:{project:Project|null;onSaved:()=>Promise<void>}){
const[status,setStatus]=useState<GitLabStatus>(emptyStatus),[namespaces,setNamespaces]=useState<Namespace[]>([]),[editing,setEditing]=useState(false)
const[serverUrl,setServerUrl]=useState(''),[token,setToken]=useState(''),[allowHttp,setAllowHttp]=useState(false),[verifyTls,setVerifyTls]=useState(true)
const[name,setName]=useState(project?.name||''),[path,setPath]=useState(slug(project?.name||'')),[namespaceId,setNamespaceId]=useState(''),[visibility,setVisibility]=useState('private')
const[loading,setLoading]=useState(true),[busy,setBusy]=useState(''),[error,setError]=useState(''),[message,setMessage]=useState(''),[createdUrl,setCreatedUrl]=useState('')
const loadNamespaces=useCallback(async()=>{const rows=await api<Namespace[]>('/api/gitlab/namespaces');setNamespaces(rows);setNamespaceId(current=>current||String(rows[0]?.id||''))},[])
const load=useCallback(async()=>{setLoading(true);setError('');try{const value=await api<GitLabStatus>('/api/gitlab/status');setStatus(value);setServerUrl(value.server_url||'');setVerifyTls(value.verify_tls);setEditing(!value.configured);if(value.configured)await loadNamespaces()}catch(reason){setError((reason as Error).message)}finally{setLoading(false)}},[loadNamespaces])
useEffect(()=>{void load()},[load])
useEffect(()=>{setName(project?.name||'');setPath(slug(project?.name||''));setCreatedUrl('')},[project?.id])
async function connect(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy('connect');setError('');setMessage('');try{const value=await api<GitLabStatus>('/api/gitlab/connect',{method:'POST',body:JSON.stringify({server_url:serverUrl.trim(),token:token.trim(),allow_insecure_http:allowHttp,verify_tls:verifyTls})});setStatus(value);setToken('');setEditing(false);await loadNamespaces();setMessage(`Connexion confirmée avec ${value.account}. La clé SSH CodexPi a été ajoutée automatiquement.`)}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
async function create(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!project)return;setBusy('repo');setError('');setMessage('');setCreatedUrl('');try{const value=await api<CreatedRepository>(`/api/projects/${project.id}/gitlab/repositories`,{method:'POST',body:JSON.stringify({name:name.trim(),path:path.trim()||null,namespace_id:namespaceId?Number(namespaceId):null,visibility})});setCreatedUrl(value.repository.web_url);setMessage(`Dépôt ${value.repository.path_with_namespace} créé et relié au projet. Il est prêt pour le premier commit et le push.`);await onSaved()}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
const tokenPage=/^https?:\/\//.test(serverUrl)?`${serverUrl.replace(/\/$/,'')}/-/user_settings/personal_access_tokens`:''
return <section className="github-settings gitlab-settings"><header><div className="github-mark gitlab-mark">GL</div><div><h3>GitLab self-hosted</h3><p>Connectez votre instance locale, puis créez et reliez un dépôt sans quitter CodexPi.</p></div><span className={`github-state ${status.configured?'connected':''}`}>{status.configured?`Connecté · ${status.account}`:'Non configuré'}</span></header>
{loading?<p className="github-loading">Chargement de la configuration GitLab…</p>:<div className="github-flow">
{(!status.configured||editing)&&<div className="github-step"><span>1</span><div><b>Connexion au serveur</b><p>Créez un jeton d’accès personnel avec le scope <code>api</code>. Il sera chiffré sur le Pi et ne sera jamais placé dans le dépôt.</p><form className="gitlab-connect-form" onSubmit={connect}><label>Adresse du GitLab<input type="url" value={serverUrl} onChange={event=>setServerUrl(event.target.value)} required maxLength={2048} spellCheck={false} autoCapitalize="none" placeholder="https://gitlab.maison.local"/></label><label>Jeton personnel<input type="password" value={token} onChange={event=>setToken(event.target.value)} required maxLength={512} autoComplete="off" placeholder="glpat-…"/></label><div className="gitlab-options"><label><input type="checkbox" checked={allowHttp} onChange={event=>setAllowHttp(event.target.checked)}/> Autoriser HTTP local</label><label><input type="checkbox" checked={verifyTls} onChange={event=>setVerifyTls(event.target.checked)}/> Vérifier le certificat TLS</label></div><div className="github-actions">{tokenPage&&<a className="button secondary" href={tokenPage} target="_blank" rel="noreferrer">Créer le jeton ↗</a>}<button className="button primary" disabled={Boolean(busy)||!serverUrl.trim()||!token.trim()}>{busy==='connect'?'Connexion…':'Connecter GitLab'}</button></div></form></div></div>}
{status.configured&&!editing&&<div className="github-step"><span>✓</span><div><b>{status.name} · @{status.account}</b><p>Serveur connecté : <code>{status.server_url}</code>. La clé SSH est prête pour les dépôts de ce compte.</p><button type="button" className="button secondary" onClick={()=>{setEditing(true);setMessage('')}}>Modifier la connexion</button></div></div>}
{status.configured&&project&&project.kind!=='remote'&&<div className="github-step"><span>2</span><div><b>Créer le dépôt pour {project.name}</b><p>Le dépôt est créé vide sur GitLab, puis ajouté comme remote <code>origin</code> au projet local.</p><form className="gitlab-repo-form" onSubmit={create}><label>Nom<input value={name} onChange={event=>setName(event.target.value)} required maxLength={255}/></label><label>Chemin<input value={path} onChange={event=>setPath(event.target.value)} pattern="[A-Za-z0-9_.-]*" maxLength={255} spellCheck={false}/></label><label>Espace de noms<select value={namespaceId} onChange={event=>setNamespaceId(event.target.value)} required><option value="" disabled>Choisir…</option>{namespaces.map(item=><option key={item.id} value={item.id}>{item.full_path||item.name}</option>)}</select></label><label>Visibilité<select value={visibility} onChange={event=>setVisibility(event.target.value)}><option value="private">Privé</option><option value="internal">Interne</option><option value="public">Public</option></select></label><button className="button primary" disabled={Boolean(busy)||!name.trim()||!namespaceId}>{busy==='repo'?'Création…':'Créer et connecter'}</button></form></div></div>}
{status.configured&&(!project||project.kind==='remote')&&<p className="github-hint">Sélectionnez un projet local pour créer son dépôt GitLab.</p>}
{error&&<p className="inline-error" role="alert">{error}</p>}{message&&<p className="github-success" role="status">{message}{createdUrl&&<> <a href={createdUrl} target="_blank" rel="noreferrer">Ouvrir le dépôt ↗</a></>}</p>}
</div>}
</section>
}

View File

@@ -0,0 +1,35 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { Project } from './types'
type GiteaStatus={configured:boolean;server_url:string|null;account:string|null;name:string|null;verified_at:string|null;verify_tls:boolean}
type Namespace={name:string;full_name:string;kind:'user'|'organization'}
type CreatedRepository={repository:{name:string;full_name:string;web_url:string;ssh_url:string};transport:'ssh'|'https'|'http'}
const emptyStatus:GiteaStatus={configured:false,server_url:null,account:null,name:null,verified_at:null,verify_tls:true}
const slug=(value:string)=>value.normalize('NFD').replace(/[\u0300-\u036f]/g,'').toLowerCase().replace(/[^a-z0-9_.-]+/g,'-').replace(/^-+|-+$/g,'')
export function GiteaSettings({project,onSaved}:{project:Project|null;onSaved:()=>Promise<void>}){
const[status,setStatus]=useState<GiteaStatus>(emptyStatus),[namespaces,setNamespaces]=useState<Namespace[]>([]),[editing,setEditing]=useState(false)
const[serverUrl,setServerUrl]=useState(''),[token,setToken]=useState(''),[allowHttp,setAllowHttp]=useState(false),[verifyTls,setVerifyTls]=useState(true)
const[name,setName]=useState(slug(project?.name||'')),[owner,setOwner]=useState(''),[privateRepo,setPrivateRepo]=useState(true)
const[loading,setLoading]=useState(true),[busy,setBusy]=useState(''),[error,setError]=useState(''),[message,setMessage]=useState(''),[createdUrl,setCreatedUrl]=useState('')
const loadNamespaces=useCallback(async()=>{const rows=await api<Namespace[]>('/api/gitea/namespaces');setNamespaces(rows);setOwner(current=>current||rows[0]?.name||'')},[])
const load=useCallback(async()=>{setLoading(true);setError('');try{const value=await api<GiteaStatus>('/api/gitea/status');setStatus(value);setServerUrl(value.server_url||'');setVerifyTls(value.verify_tls);setEditing(!value.configured);if(value.configured)await loadNamespaces()}catch(reason){setError((reason as Error).message)}finally{setLoading(false)}},[loadNamespaces])
useEffect(()=>{void load()},[load])
useEffect(()=>{setName(slug(project?.name||''));setCreatedUrl('')},[project?.id])
async function connect(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy('connect');setError('');setMessage('');try{const value=await api<GiteaStatus>('/api/gitea/connect',{method:'POST',body:JSON.stringify({server_url:serverUrl.trim(),token:token.trim(),allow_insecure_http:allowHttp,verify_tls:verifyTls})});setStatus(value);setToken('');setEditing(false);await loadNamespaces();setMessage(`Connexion confirmée avec ${value.account}. La clé SSH CodexPi a été ajoutée automatiquement.`)}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
async function create(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!project)return;const target=namespaces.find(item=>item.name===owner);if(!target)return;setBusy('repo');setError('');setMessage('');setCreatedUrl('');try{const value=await api<CreatedRepository>(`/api/projects/${project.id}/gitea/repositories`,{method:'POST',body:JSON.stringify({name:name.trim(),owner:target.name,owner_kind:target.kind,private:privateRepo})});setCreatedUrl(value.repository.web_url);setMessage(`Dépôt ${value.repository.full_name} relié au projet via ${value.transport.toUpperCase()}. Il est prêt pour le premier commit et le push.`);await onSaved()}catch(reason){setError((reason as Error).message)}finally{setBusy('')}}
const tokenPage=/^https?:\/\//.test(serverUrl)?`${serverUrl.replace(/\/$/,'')}/user/settings/applications`:''
return <section className="github-settings gitlab-settings gitea-settings"><header><div className="github-mark gitea-mark">GT</div><div><h3>Gitea</h3><p>Connectez votre instance Gitea et créez le dépôt du projet directement depuis CodexPi.</p></div><span className={`github-state ${status.configured?'connected':''}`}>{status.configured?`Connecté · ${status.account}`:'Non configuré'}</span></header>
{loading?<p className="github-loading">Chargement de la configuration Gitea…</p>:<div className="github-flow">
{(!status.configured||editing)&&<div className="github-step"><span>1</span><div><b>Connexion au serveur</b><p>Créez un jeton d’accès dans les paramètres Gitea. Il sera chiffré sur le Pi et la clé SSH sera ajoutée automatiquement.</p><form className="gitlab-connect-form" onSubmit={connect}><label>Adresse du Gitea<input type="url" value={serverUrl} onChange={event=>setServerUrl(event.target.value)} required maxLength={2048} spellCheck={false} autoCapitalize="none" placeholder="https://gitea.maison.local"/></label><label>Jeton personnel<input type="password" value={token} onChange={event=>setToken(event.target.value)} required maxLength={512} autoComplete="off" placeholder="Jeton Gitea"/></label><div className="gitlab-options"><label><input type="checkbox" checked={allowHttp} onChange={event=>setAllowHttp(event.target.checked)}/> Autoriser HTTP local</label><label><input type="checkbox" checked={verifyTls} onChange={event=>setVerifyTls(event.target.checked)}/> Vérifier le certificat TLS</label></div><div className="github-actions">{tokenPage&&<a className="button secondary" href={tokenPage} target="_blank" rel="noreferrer">Créer le jeton ↗</a>}<button className="button primary" disabled={Boolean(busy)||!serverUrl.trim()||!token.trim()}>{busy==='connect'?'Connexion…':'Connecter Gitea'}</button></div></form></div></div>}
{status.configured&&!editing&&<div className="github-step"><span>✓</span><div><b>{status.name} · @{status.account}</b><p>Serveur connecté : <code>{status.server_url}</code>. La clé SSH est prête.</p><button type="button" className="button secondary" onClick={()=>{setEditing(true);setMessage('')}}>Modifier la connexion</button></div></div>}
{status.configured&&project&&project.kind!=='remote'&&<div className="github-step"><span>2</span><div><b>Créer le dépôt pour {project.name}</b><p>Choisissez votre compte ou une organisation, puis CodexPi créera le dépôt vide et configurera <code>origin</code>.</p><form className="gitlab-repo-form" onSubmit={create}><label>Nom du dépôt<input value={name} onChange={event=>setName(event.target.value)} required pattern="[A-Za-z0-9_.-]+" maxLength={255}/></label><label>Propriétaire<select value={owner} onChange={event=>setOwner(event.target.value)} required><option value="" disabled>Choisir…</option>{namespaces.map(item=><option key={`${item.kind}-${item.name}`} value={item.name}>{item.full_name} · {item.kind==='user'?'compte':'organisation'}</option>)}</select></label><div className="gitlab-options"><label><input type="checkbox" checked={privateRepo} onChange={event=>setPrivateRepo(event.target.checked)}/> Dépôt privé</label></div><button className="button primary" disabled={Boolean(busy)||!name.trim()||!owner}>{busy==='repo'?'Création…':'Créer et connecter'}</button></form></div></div>}
{status.configured&&(!project||project.kind==='remote')&&<p className="github-hint">Sélectionnez un projet local pour créer son dépôt Gitea.</p>}
{error&&<p className="inline-error" role="alert">{error}</p>}{message&&<p className="github-success" role="status">{message}{createdUrl&&<> <a href={createdUrl} target="_blank" rel="noreferrer">Ouvrir le dépôt ↗</a></>}</p>}
</div>}
</section>
}

20
frontend/src/Icon.tsx Normal file
View File

@@ -0,0 +1,20 @@
import type { ReactNode } from 'react'
export type IconName='company'|'projects'|'workspace'|'activity'|'server'|'ecosystem'|'settings'|'orchestrator'|'master'|'remote'|'search'|'more'
const paths:Record<IconName,ReactNode>={
company:<><rect x="3" y="10" width="18" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3M7 15h2m3 0h2m3 0h2"/></>,
projects:<><rect x="3" y="4" width="7" height="7" rx="1.5"/><rect x="14" y="4" width="7" height="7" rx="1.5"/><rect x="3" y="15" width="7" height="5" rx="1.5"/><rect x="14" y="15" width="7" height="5" rx="1.5"/></>,
workspace:<><path d="M4 5h16v11H4z"/><path d="m8 9 2 2-2 2m4 0h4M8 20h8"/></>,
activity:<><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></>,
server:<><rect x="4" y="3" width="16" height="7" rx="2"/><rect x="4" y="14" width="16" height="7" rx="2"/><path d="M8 6.5h.01M8 17.5h.01M12 6.5h5M12 17.5h5"/></>,
ecosystem:<><path d="m12 3 8 4.5v9L12 21l-8-4.5v-9z"/><path d="m4 7.5 8 4.5 8-4.5M12 12v9"/></>,
settings:<><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z"/></>,
orchestrator:<><circle cx="12" cy="5" r="2"/><circle cx="6" cy="18" r="2"/><circle cx="18" cy="18" r="2"/><path d="M12 7v4M6 16v-3h12v3"/></>,
master:<><path d="m12 3 3 5 5 .8-3.7 3.8.9 5.4-5.2-2.5L6.8 18l.9-5.4L4 8.8 9 8z"/></>,
remote:<><rect x="3" y="4" width="18" height="13" rx="2"/><path d="M8 21h8m-4-4v4M8 10h8m-2-2 2 2-2 2"/></>,
search:<><circle cx="10.5" cy="10.5" r="6.5"/><path d="m16 16 5 5"/></>,
more:<><circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/></>,
}
export function Icon({name}:{name:IconName}){return <svg className="cp-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">{paths[name]}</svg>}

View File

@@ -0,0 +1,16 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { MasterSession } from './types'
import { useVisiblePolling } from './useVisiblePolling'
export function MasterAgent({onError}:{onError:(error:Error|string)=>void}){
const[channel,setChannel]=useState<{available:boolean;socket:string;token_configured:boolean;scope:string}|null>(null),[sessions,setSessions]=useState<MasterSession[]>([]),[session,setSession]=useState<MasterSession|null>(null),[busy,setBusy]=useState(false)
const load=useCallback(async()=>{try{const value=await api<{channel:typeof channel;sessions:MasterSession[]}>('/api/master-agent');setChannel(value.channel);setSessions(value.sessions);setSession(current=>value.sessions.find(item=>item.id===current?.id)||value.sessions[0]||null)}catch(reason){onError(reason as Error)}},[onError])
useEffect(()=>{void load()},[load])
useVisiblePolling(load,3_000,sessions.some(item=>item.status==='running'))
async function create(privileged:boolean){setBusy(true);try{const created=await api<MasterSession>('/api/master-agent/sessions',{method:'POST',body:JSON.stringify({title:privileged?'Administration privilégiée':'Diagnostic de l’hôte',privileged})});setSession(created);await load()}catch(reason){onError(reason as Error)}finally{setBusy(false)}}
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();if(!session)return;const data=new FormData(event.currentTarget),prompt=String(data.get('prompt')||'');setBusy(true);try{await api(`/api/master-agent/sessions/${session.id}/messages`,{method:'POST',body:JSON.stringify({prompt,acknowledge_privileged:Boolean(session.privileged)})});event.currentTarget.reset();await load()}catch(reason){onError(reason as Error)}finally{setBusy(false)}}
return <div className="master-page"><header><div><p className="eyebrow">PROPRIÉTAIRE UNIQUEMENT</p><h1>Agent maître</h1><p>Administration de l’hôte Raspberry Pi via un canal Unix privé. Contrairement au Chef d’orchestre, il agit sur le serveur et non sur le cycle de développement d’un projet.</p></div><span className={channel?.available?'host-online':'host-offline'}>{channel?.available?'● Canal hôte disponible':'○ Canal hôte non installé'}</span></header><section className="host-scope"><div><b>♜ {channel?.scope||'Hôte Raspberry Pi'}</b><small>Les privilèges proviennent uniquement de l’identité du service et de la politique sudo de l’hôte.</small></div><code>{channel?.socket}</code></section>{!channel?.available&&<section className="host-install"><h2>Activation explicite requise</h2><p>Depuis un terminal de l’hôte, le propriétaire installe le service et son jeton privé. L’API ne peut pas s’auto-accorder cet accès.</p><pre>sudo ./scripts/host-agent-channel.sh install</pre><p>Révocation : <code>sudo ./scripts/host-agent-channel.sh revoke</code></p></section>}<div className="master-layout"><aside><h2>Historique</h2><button disabled={busy||!channel?.available} onClick={()=>void create(false)}>+ Diagnostic hôte</button><button className="privileged" disabled={busy||!channel?.available} onClick={()=>void create(true)}>+ Session privilégiée</button>{sessions.map(item=><button className={item.id===session?.id?'active':''} onClick={()=>setSession(item)} key={item.id}><b>{item.title}</b><small>{item.hostname} · {item.privileged?'privilégiée':'standard'} · {masterStatus(item.status)}</small></button>)}</aside><main>{session?<><header><div><h2>{session.title}</h2><p>{session.hostname} · périmètre serveur · {session.privileged?'confirmation privilégiée par demande':'droits du service'}</p></div><span aria-live="polite">{masterStatus(session.status)}</span></header><div className="master-messages">{session.messages?.map(message=><article className={message.role} key={message.id}><b>{message.role==='user'?'Vous':'Agent maître'}</b><p>{message.content}</p></article>)}</div><form onSubmit={submit}><textarea name="prompt" required placeholder="Demander une opération sur l’hôte…"/><button className="button primary" disabled={busy||session.status==='running'}>{busy||session.status==='running'?'Exécution…':'Envoyer'}</button>{Boolean(session.privileged)&&<small>Cette action confirme l’usage des privilèges configurés sur l’hôte.</small>}</form></>:<div className="empty-pane"><h2>Aucune session maître</h2><p>Créez un diagnostic ou une session privilégiée après installation du canal.</p></div>}</main></div></div>
}
function masterStatus(status:string){return({idle:'Prête',running:'En cours',completed:'Terminée',failed:'Échec'} as Record<string,string>)[status]||status}

View File

@@ -0,0 +1,35 @@
import { FormEvent, ReactNode, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { parseCommandLine } from './commandLine'
import { Orchestration, Project } from './types'
import { useVisiblePolling } from './useVisiblePolling'
export function Orchestrator({projects,selectedId,onOpenWorkspace,onError}:{projects:Project[];selectedId?:string|null;onOpenWorkspace:(projectId:string,conversationId:string)=>void;onError:(error:Error|string)=>void}){
const[flows,setFlows]=useState<Orchestration[]>([]),[flow,setFlow]=useState<Orchestration|null>(null),[creating,setCreating]=useState(!selectedId),[feedback,setFeedback]=useState(''),[busy,setBusy]=useState(false)
const load=useCallback(async()=>{try{const rows=await api<Orchestration[]>('/api/orchestrations');setFlows(rows);const id=selectedId||flow?.id||rows[0]?.id;if(id)setFlow(await api<Orchestration>(`/api/orchestrations/${id}`))}catch(reason){onError(reason as Error)}},[selectedId,flow?.id,onError])
useEffect(()=>{void load()},[selectedId])
useVisiblePolling(load,10_000,flows.some(item=>['WORKING','AWAITING_VERIFICATION'].includes(item.status)))
async function open(item:Orchestration){try{setCreating(false);setFlow(await api<Orchestration>(`/api/orchestrations/${item.id}`))}catch(reason){onError(reason as Error)}}
async function perform(path:string,body?:unknown){if(!flow)return;setBusy(true);try{setFlow(await api<Orchestration>(`/api/orchestrations/${flow.id}/${path}`,{method:'POST',body:body?JSON.stringify(body):undefined}));setFeedback('');await load()}catch(reason){onError(reason as Error)}finally{setBusy(false)}}
return <div className="orchestrator-page"><header><div><p className="eyebrow">CHEF D’ORCHESTRE</p><h1>Chantiers orchestrés</h1><p>Brief, ébauche, branche isolée, recette et fusion avec validations persistantes.</p></div><button className="button primary" onClick={()=>setCreating(true)}>+ Nouvelle demande</button></header><div className="orchestrator-layout"><aside><h2>Historique</h2>{flows.map(item=><button className={item.id===flow?.id?'active':''} onClick={()=>void open(item)} key={item.id}><b>{item.name}</b><small>{item.project_name} · {label(item.status)}</small></button>)}{!flows.length&&<div className="orchestration-empty"><b>Aucun chantier</b><p>Créez une demande pour faire préparer une ébauche, une branche et sa recette.</p></div>}</aside><main>{creating?<CreateFlow projects={projects} onCreated={value=>{setFlow(value);setCreating(false);void load()}} onError={onError}/>:flow?<><section className="orchestration-head"><div><span className={`flow-status ${flow.status.toLowerCase()}`}>{label(flow.status)}</span><h2>{flow.name}</h2><p>{flow.request}</p></div>{flow.conversation_id&&<button className="button secondary" onClick={()=>onOpenWorkspace(flow.project_id,flow.conversation_id!)}>Ouvrir l’Atelier</button>}</section><ol className="flow-steps"><Step done={flow.status!=='DRAFT_REVIEW'} active={flow.status==='DRAFT_REVIEW'}>Ébauche</Step><Step done={['USER_REVIEW','AWAITING_MERGE_CONFIRMATION','APPROVED','MERGED'].includes(flow.status)} active={['WORKING','AWAITING_VERIFICATION','CORRECTION_REQUIRED'].includes(flow.status)}>Réalisation</Step><Step done={['AWAITING_MERGE_CONFIRMATION','APPROVED','MERGED'].includes(flow.status)} active={flow.status==='USER_REVIEW'}>Recette</Step><Step done={flow.status==='MERGED'} active={['AWAITING_MERGE_CONFIRMATION','APPROVED'].includes(flow.status)}>Fusion</Step></ol>{flow.status==='DRAFT_REVIEW'&&<section className="draft-review"><header><div><h3>Ébauche v{flow.draft_version}</h3><code>{flow.draft_hash.slice(0,12)}</code></div></header><pre>{flow.draft}</pre><label>Retour sur l’ébauche<textarea value={feedback} onChange={event=>setFeedback(event.target.value)} placeholder="Points à reprendre avant lancement…"/></label><div><button className="button secondary" disabled={busy||!feedback.trim()} onClick={()=>void perform('draft/reject',{feedback})}>Demander une nouvelle ébauche</button><button className="button primary" disabled={busy} onClick={()=>void perform('draft/approve',{draft_hash:flow.draft_hash})}>Valider et lancer</button></div></section>}{flow.status==='AWAITING_VERIFICATION'&&<Callout title="Travail agent terminé" text="Le chef d’orchestre doit exécuter ses propres tests et attacher les preuves à la révision."><button className="button primary" disabled={busy} onClick={()=>void perform('verify')}>Lancer la recette technique</button></Callout>}{flow.status==='CORRECTION_REQUIRED'&&<Correction feedback={feedback} setFeedback={setFeedback} busy={busy} onSubmit={()=>void perform('corrections',{feedback})}/>} {flow.status==='USER_REVIEW'&&<><Evidence flow={flow}/><section className="user-recipe"><h3>Recette propriétaire</h3>{flow.previews?.map(item=><a className="button secondary" href={item.url} target="_blank" rel="noreferrer" key={item.port}>Ouvrir la prévisualisation :{item.port} ↗</a>)}<label>Points bloquants<textarea value={feedback} onChange={event=>setFeedback(event.target.value)} placeholder="Décrire précisément les corrections…"/></label><div><button className="button secondary" disabled={busy||!feedback.trim()} onClick={()=>void perform('corrections',{feedback})}>Demander des corrections</button><button className="button primary" disabled={busy} onClick={()=>void perform('approve',{revision:flow.verified_revision})}>Valider la recette</button></div></section></>}{['AWAITING_MERGE_CONFIRMATION','APPROVED'].includes(flow.status)&&<Callout title="Révision approuvée" text={`La fusion vise ${flow.base_branch} avec la révision ${flow.approved_revision?.slice(0,12)}.`}><button className="button primary" disabled={busy} onClick={()=>void perform('merge',{confirm:flow.status==='AWAITING_MERGE_CONFIRMATION'})}>{flow.status==='AWAITING_MERGE_CONFIRMATION'?'Confirmer et fusionner':'Fusionner'}</button></Callout>}{flow.status==='MERGED'&&<Callout title="Fusion terminée" text={`Référence fusionnée : ${flow.merged_revision}`}/>} {flow.error&&<p className="inline-error">{flow.error}</p>}<section className="flow-timeline"><h3>Journal</h3>{flow.events?.map(event=><div key={event.id}><span/><p><b>{event.summary}</b><small>{new Date(event.created_at).toLocaleString('fr-FR')}</small></p></div>)}</section></>:<div className="empty-pane"><span>♙</span><h2>Sélectionnez un chantier</h2><p>Choisissez un élément de l’historique ou créez une nouvelle demande.</p></div>}</main></div></div>
}
function CreateFlow({projects,onCreated,onError}:{projects:Project[];onCreated:(flow:Orchestration)=>void;onError:(error:Error|string)=>void}){
const[busy,setBusy]=useState(false),[formError,setFormError]=useState('')
const eligible=projects.filter(item=>item.kind!=='remote'&&!item.archived)
async function submit(event:FormEvent<HTMLFormElement>){
event.preventDefault();setBusy(true);setFormError('')
const data=new FormData(event.currentTarget)
try{
const criteria=String(data.get('criteria')||'').split('\n').map(value=>value.trim()).filter(Boolean)
const tests=String(data.get('tests')||'').split('\n').map(line=>line.trim()).filter(Boolean).map(parseCommandLine)
onCreated(await api('/api/orchestrations',{method:'POST',body:JSON.stringify({project_id:data.get('project'),name:data.get('name'),request:data.get('request'),acceptance_criteria:criteria,tests,engine:data.get('engine')})}))
}catch(reason){const error=reason as Error;setFormError(error.message);onError(error);setBusy(false)}
}
return <form className="create-flow" onSubmit={submit}><h2>Nouvelle demande au chef d’orchestre</h2><label>Projet<select name="project" required disabled={!eligible.length}>{eligible.map(item=><option value={item.id} key={item.id}>{item.name}{item.full_access?' · FULL ACCESS':''}</option>)}{!eligible.length&&<option value="">Aucun projet local actif</option>}</select></label><label>Nom du chantier<input name="name" required placeholder="Refonte de l’interface"/></label><label>Demande<textarea name="request" required minLength={20} rows={6} placeholder="Décrivez le résultat attendu…"/></label><label>Critères d’acceptation <small>un par ligne</small><textarea name="criteria" required rows={5}/></label><label>Commandes de test <small>une commande structurée par ligne, sans shell ; les arguments entre guillemets sont conservés</small><textarea name="tests" rows={4} placeholder={'pytest -q\nnpm --prefix frontend run build\npython -m pytest "tests/interface mobile.py"'}/></label><label>Agent<select name="engine"><option value="codex">Codex</option><option value="claude">Claude Code</option><option value="opencode">OpenCode</option></select></label>{formError&&<p className="inline-error" role="alert">{formError}</p>}<button className="button primary" disabled={busy||!eligible.length}>{busy?'Préparation…':'Préparer le brief et l’ébauche'}</button></form>
}
function Step({children,done,active}:{children:string;done:boolean;active:boolean}){return <li className={done?'done':active?'active':''}><span>{done?'✓':'•'}</span>{children}</li>}
function label(status:string){return({DRAFT_REVIEW:'Ébauche à valider',WORKING:'Agent en cours',AWAITING_VERIFICATION:'Vérification requise',CORRECTION_REQUIRED:'Corrections requises',USER_REVIEW:'Recette propriétaire',AWAITING_MERGE_CONFIRMATION:'Confirmation de fusion',APPROVED:'Prêt à fusionner',MERGED:'Fusionné',BLOCKED:'Arbitrage requis'} as Record<string,string>)[status]||status}
function Callout({title,text,children}:{title:string;text:string;children?:ReactNode}){return <section className="flow-callout"><h3>{title}</h3><p>{text}</p>{children}</section>}
function Correction({feedback,setFeedback,busy,onSubmit}:{feedback:string;setFeedback:(value:string)=>void;busy:boolean;onSubmit:()=>void}){return <section className="user-recipe"><h3>Corrections techniques</h3><label>Instruction précise<textarea value={feedback} onChange={event=>setFeedback(event.target.value)}/></label><button className="button primary" disabled={busy||!feedback.trim()} onClick={onSubmit}>Relancer le même agent</button></section>}
function Evidence({flow}:{flow:Orchestration}){return <section className="flow-evidence"><h3>Preuves liées à {flow.verified_revision?.slice(0,12)}</h3>{flow.tests?.map(test=><details key={test.id}><summary><b>{test.status==='PASSED'?'✓':'×'} {JSON.parse(test.command_json).join(' ')}</b></summary><pre>{test.output||'Aucune sortie.'}</pre></details>)}</section>}

View File

@@ -0,0 +1,55 @@
import {type FormEvent,type ReactNode,useCallback,useEffect,useMemo,useState} from 'react'
import {api} from './api'
import {Modal} from './Common'
import {PortfolioProject,PortfolioTask,Project,WorkspaceTab} from './types'
import {relativeDate} from './utils'
import {useVisiblePolling} from './useVisiblePolling'
type Filter='projects'|'active'|'queue'|'incidents'
type DetailTab='overview'|'tasks'|'sessions'|'logs'|'tests'
const STATUS_LABEL:Record<PortfolioProject['status'],string>={SCAFFOLD:'À développer',IN_DEVELOPMENT:'En développement',FUNCTIONAL_PARTIAL:'Partiellement fonctionnel',CREATED:'Créé',INITIALIZED:'Initialisé',QUEUED:'En attente',RUNNING:'En cours',TESTING:'À vérifier',READY_V0:'V0 disponible',BLOCKED:'Bloqué',FAILED:'Échec',PAUSED:'En pause'}
export function Portfolio({isAdmin,onOpenWorkspace,onError}:{isAdmin:boolean;onOpenWorkspace:(workspaceId:string,tab?:WorkspaceTab)=>void;onError:(error:Error|string)=>void}){
const[projects,setProjects]=useState<PortfolioProject[]>([]),[selected,setSelected]=useState<PortfolioProject|null>(null),[filter,setFilter]=useState<Filter>('projects'),[loading,setLoading]=useState(true),[initializing,setInitializing]=useState(false),[reordering,setReordering]=useState(false),[creating,setCreating]=useState(false)
const requestedProject=new URLSearchParams(location.search).get('portfolio')
const load=useCallback(async()=>{try{const rows=await api<PortfolioProject[]>('/api/portfolio/projects');setProjects(rows);if(selected){const current=rows.find(item=>item.id===selected.id);if(current)setSelected(value=>value?{...value,...current}:current)}}catch(error){onError(error as Error)}finally{setLoading(false)}},[onError,selected?.id])
useVisiblePolling(load,8000)
useEffect(()=>{if(!requestedProject||selected||!projects.some(item=>item.id===requestedProject))return;api<PortfolioProject>(`/api/portfolio/projects/${requestedProject}`).then(setSelected).catch(error=>onError(error as Error))},[onError,projects,requestedProject,selected])
const visible=useMemo(()=>projects.filter(project=>filter==='active'?project.status==='RUNNING':filter==='queue'?project.status==='QUEUED':filter==='incidents'?['BLOCKED','FAILED'].includes(project.status):true),[filter,projects])
const summary={running:projects.filter(item=>item.status==='RUNNING').length,queued:projects.filter(item=>item.status==='QUEUED').length,ready:projects.filter(item=>item.status==='READY_V0').length,incidents:projects.filter(item=>['BLOCKED','FAILED'].includes(item.status)).length}
async function open(project:PortfolioProject){try{setSelected(await api<PortfolioProject>(`/api/portfolio/projects/${project.id}`))}catch(error){onError(error as Error)}}
async function initialize(){if(!confirm('Initialiser les espaces, sessions et tâches V0 sans modifier la production existante ?'))return;setInitializing(true);try{await api('/api/portfolio/catalog/initialize',{method:'POST'});await load()}catch(error){onError(error as Error)}finally{setInitializing(false)}}
async function applyRecommendedOrder(){if(!confirm('Remplacer les priorités actuelles par l’ordre recommandé du catalogue ?'))return;setReordering(true);try{await api('/api/portfolio/catalog/priorities',{method:'POST'});await load()}catch(error){onError(error as Error)}finally{setReordering(false)}}
if(selected)return <PortfolioDetail project={selected} onBack={()=>{setSelected(null);void load()}} onRefresh={()=>open(selected)} onOpenWorkspace={onOpenWorkspace} onError={onError}/>
return <><div className="portfolio-page"><header className="portfolio-header"><div><p className="eyebrow">ÉCOSYSTÈME LOUTRE</p><h1>Orchestration des projets</h1><p>Plusieurs discussions peuvent travailler en parallèle. Les écritures d’un même espace restent sérialisées et toutes les files sont persistantes.</p></div><div className="portfolio-row-actions"><button className="button secondary" onClick={()=>setCreating(true)}>+ Nouveau projet</button>{isAdmin&&<><button className="button secondary" disabled={reordering} onClick={()=>void applyRecommendedOrder()}>{reordering?'Ordonnancement…':'Appliquer l’ordre recommandé'}</button><button className="button primary" disabled={initializing} onClick={()=>void initialize()}>{initializing?'Initialisation…':'Initialiser le catalogue'}</button></>}</div></header>
<section className="portfolio-metrics"><Metric label="Projets" value={projects.length}/><Metric label="En cours" value={summary.running}/><Metric label="File" value={summary.queued}/><Metric label="V0 vérifiées" value={summary.ready}/><Metric label="Incidents" value={summary.incidents} alert={summary.incidents>0}/></section>
<nav className="portfolio-filters" aria-label="Vues du portefeuille"><FilterButton active={filter==='projects'} onClick={()=>setFilter('projects')}>Projets</FilterButton><FilterButton active={filter==='active'} onClick={()=>setFilter('active')}>Tâches en cours</FilterButton><FilterButton active={filter==='queue'} onClick={()=>setFilter('queue')}>File d’attente</FilterButton><FilterButton active={filter==='incidents'} onClick={()=>setFilter('incidents')}>Incidents</FilterButton></nav>
{loading?<div className="portfolio-empty">Chargement du portefeuille…</div>:<section className="portfolio-grid">{visible.map(project=><article className="portfolio-card" key={project.id}><header><span>{project.icon}</span><div><small>{project.category.replaceAll('_',' ')}</small><h2>{project.name}</h2></div><i className={`portfolio-status ${project.status.toLowerCase()}`}>{STATUS_LABEL[project.status]}</i></header><p>{project.description}</p><div className="portfolio-progress"><div><span>Progression vérifiée</span><b>{project.progress}%</b></div><div className="meter"><i style={{width:`${project.progress}%`}}/></div></div><dl><div><dt>Agent</dt><dd>{project.agent}</dd></div><div><dt>Tests</dt><dd className={project.tests_failed?'failed':''}>{project.tests_passed} réussis · {project.tests_failed} échoués</dd></div><div><dt>Activité</dt><dd>{relativeDate(project.last_activity_at)}</dd></div></dl><footer><button className="button primary" onClick={()=>void open(project)}>Ouvrir</button><button className="button secondary" disabled={!project.workspace_id} onClick={()=>project.workspace_id&&onOpenWorkspace(project.workspace_id)}>Discuter</button><button className="button secondary" onClick={()=>void open(project)}>Journaux</button></footer></article>)}{!visible.length&&<div className="portfolio-empty">Aucun projet dans cette vue.</div>}</section>}</div>{creating&&<PortfolioCreateDialog onClose={()=>setCreating(false)} onCreated={async()=>{setCreating(false);await load()}}/>}</>
}
function PortfolioDetail({project,onBack,onRefresh,onOpenWorkspace,onError}:{project:PortfolioProject;onBack:()=>void;onRefresh:()=>Promise<void>;onOpenWorkspace:(id:string,tab?:WorkspaceTab)=>void;onError:(error:Error|string)=>void}){
const[tab,setTab]=useState<DetailTab>('overview'),[priority,setPriority]=useState(String(project.priority)),[busy,setBusy]=useState(''),[editing,setEditing]=useState(false)
async function savePriority(){setBusy('priority');try{await api(`/api/portfolio/projects/${project.id}/priority`,{method:'PUT',body:JSON.stringify({priority:Number(priority)})});await onRefresh()}catch(error){onError(error as Error)}finally{setBusy('')}}
async function taskAction(task:PortfolioTask,action:'pause'|'resume'|'retry'|'cancel'){setBusy(task.id);try{await api(`/api/portfolio/tasks/${task.id}/action`,{method:'POST',body:JSON.stringify({action})});await onRefresh()}catch(error){onError(error as Error)}finally{setBusy('')}}
return <><div className="portfolio-detail"><header><button className="icon-button" onClick={onBack} aria-label="Retour au portefeuille">←</button><span className="portfolio-detail-icon">{project.icon}</span><div><p className="eyebrow">{project.category.replaceAll('_',' ')}</p><h1>{project.name}</h1><p>{project.description}</p></div><i className={`portfolio-status ${project.status.toLowerCase()}`}>{STATUS_LABEL[project.status]}</i></header><div className="portfolio-detail-actions"><label>Priorité<input type="number" min="0" max="10000" value={priority} onChange={event=>setPriority(event.target.value)}/><button disabled={busy==='priority'} onClick={()=>void savePriority()}>Enregistrer</button></label><button className="button secondary" onClick={()=>setEditing(true)}>Modifier la fiche</button>{project.workspace_id&&<><button className="button primary" onClick={()=>onOpenWorkspace(project.workspace_id!,'chat')}>Démarrer / reprendre</button><button className="button secondary" onClick={()=>onOpenWorkspace(project.workspace_id!,'preview')}>Prévisualiser</button><button className="button secondary" onClick={()=>onOpenWorkspace(project.workspace_id!,'git')}>Modifications et versions</button></>}</div><nav className="portfolio-tabs"><Tab active={tab==='overview'} onClick={()=>setTab('overview')}>Vue d’ensemble</Tab><Tab active={tab==='tasks'} onClick={()=>setTab('tasks')}>Tâches ({project.tasks?.length||0})</Tab><Tab active={tab==='sessions'} onClick={()=>setTab('sessions')}>Sessions</Tab><Tab active={tab==='logs'} onClick={()=>setTab('logs')}>Journaux</Tab><Tab active={tab==='tests'} onClick={()=>setTab('tests')}>Tests</Tab></nav>
<section className="portfolio-detail-content">{tab==='overview'&&<div className="portfolio-overview"><article><h2>Périmètre</h2><dl><div><dt>Dépôt</dt><dd>{project.repository||'Dépôt local'}</dd></div><div><dt>Branche principale</dt><dd>{project.branch}</dd></div><div><dt>Répertoire</dt><dd><code>{project.target_directory}</code></dd></div><div><dt>Statut</dt><dd>{STATUS_LABEL[project.status]}</dd></div></dl></article><details><summary>Prompt système enregistré</summary><pre>{project.system_prompt}</pre></details><details><summary>Prompt initial enregistré</summary><pre>{project.initial_prompt}</pre></details><article><h2>Documentation</h2>{project.documents?.map(document=><div key={document.id}><b>{document.title}</b><br/><code>{document.relative_path}</code></div>)}{!project.documents?.length&&<p>Aucun document lié.</p>}</article><article><h2>Relations</h2>{project.links?.map(link=><p key={`${link.source_id}-${link.target_id}-${link.relation}`}><b>{link.relation.replaceAll('_',' ')}</b> · {link.source_name} → {link.target_name}</p>)}{!project.links?.length&&<p>Aucune relation spéciale.</p>}</article></div>}{tab==='tasks'&&<div className="portfolio-list">{project.tasks?.map(task=><article key={task.id}><Status value={task.status}/><div><b>{task.title}</b><small>Tentative {task.attempts}/{task.max_attempts} · priorité {task.priority}</small>{task.error&&<p className="task-error">{task.error}</p>}</div><div className="portfolio-row-actions">{task.status==='RUNNING'&&<button disabled={busy===task.id} onClick={()=>void taskAction(task,'cancel')}>Arrêter</button>}{['QUEUED','BLOCKED'].includes(task.status)&&<button disabled={busy===task.id} onClick={()=>void taskAction(task,'pause')}>Pause</button>}{task.status==='PAUSED'&&<button disabled={busy===task.id} onClick={()=>void taskAction(task,'resume')}>Reprendre</button>}{['BLOCKED','FAILED'].includes(task.status)&&<button disabled={busy===task.id} onClick={()=>void taskAction(task,'retry')}>Relancer à zéro</button>}</div></article>)}{!project.tasks?.length&&<p>Aucune tâche.</p>}</div>}{tab==='sessions'&&<div className="portfolio-list">{project.sessions?.map(session=><article key={session.id}><Status value={session.status}/><div><b>{session.title}</b><small>{session.branch_name||'Branche non créée'} · {session.codex_thread_id?'Reprise Codex disponible':'Premier envoi en attente'}</small><code>{session.worktree_path||project.workspace_path}</code></div></article>)}{!project.sessions?.length&&<p>Aucune session associée.</p>}</div>}{tab==='logs'&&<div className="portfolio-timeline">{project.events?.map(event=><article className={event.severity} key={event.id}><span/><div><b>{event.summary}</b><small>{event.kind} · {relativeDate(event.created_at)}</small></div></article>)}{!project.events?.length&&<p>Aucun événement.</p>}</div>}{tab==='tests'&&<div className="portfolio-list">{project.tests?.map(test=><article key={test.id}><Status value={test.status}/><div><b>{JSON.parse(test.command_json).join(' ')}</b><small>{test.passed} réussi · {test.failed} échoué</small>{test.summary&&<pre>{test.summary}</pre>}</div></article>)}{!project.tests?.length&&<p>Aucun résultat de test conservé. Le projet ne peut pas être déclaré V0 sur cette seule base.</p>}</div>}</section></div>{editing&&<PortfolioEditDialog project={project} onClose={()=>setEditing(false)} onSaved={async()=>{setEditing(false);await onRefresh()}}/>}</>
}
function PortfolioCreateDialog({onClose,onCreated}:{onClose:()=>void;onCreated:()=>Promise<void>}){
const[workspaces,setWorkspaces]=useState<Project[]>([]),[busy,setBusy]=useState(false),[error,setError]=useState('')
useEffect(()=>{api<Project[]>('/api/projects').then(rows=>setWorkspaces(rows.filter(item=>!item.archived))).catch(reason=>setError((reason as Error).message))},[])
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy(true);setError('');const form=new FormData(event.currentTarget);try{await api('/api/portfolio/projects',{method:'POST',body:JSON.stringify({workspace_id:form.get('workspace_id'),name:form.get('name'),description:form.get('description'),category:form.get('category'),icon:form.get('icon'),repository:form.get('repository'),branch:form.get('branch'),system_prompt:form.get('system_prompt'),initial_prompt:form.get('initial_prompt'),priority:Number(form.get('priority'))})});await onCreated()}catch(reason){setError((reason as Error).message);setBusy(false)}}
return <Modal title="Nouvelle fiche projet" onClose={onClose} wide><form className="modal-form" onSubmit={submit}><p>La fiche est rattachée à un espace existant : aucune copie de projet n’est créée.</p><label>Espace de travail<select name="workspace_id" required><option value="">Choisir…</option>{workspaces.map(item=><option value={item.id} key={item.id}>{item.name} · {item.path}</option>)}</select></label><label>Nom<input name="name" required maxLength={120}/></label><label>Description<textarea name="description" required minLength={10} maxLength={4000} rows={3}/></label><div className="project-settings-grid"><label>Catégorie<input name="category" defaultValue="APPLICATION_PERSONNELLE" pattern="[A-Z0-9_]+" required/></label><label>Icône<input name="icon" defaultValue="◇" maxLength={8} required/></label><label>Priorité<input name="priority" type="number" min="0" max="10000" defaultValue="100" required/></label><label>Dépôt<input name="repository" maxLength={255}/></label><label>Branche<input name="branch" defaultValue="main" required maxLength={200}/></label></div><label>Prompt système<textarea name="system_prompt" required rows={3} defaultValue="Travailler uniquement dans le worktree isolé du projet. Ne jamais lire de secrets ni modifier la production."/></label><label>Prompt initial<textarea name="initial_prompt" required rows={5} defaultValue="Inspecter les fichiers existants, définir la V0, implémenter les fonctions essentielles, exécuter les tests et conserver leurs résultats."/></label>{error&&<p className="inline-error" role="alert">{error}</p>}<div className="modal-actions"><button type="button" className="button secondary" onClick={onClose}>Annuler</button><button className="button primary" disabled={busy||!workspaces.length}>{busy?'Création…':'Créer la fiche'}</button></div></form></Modal>
}
function PortfolioEditDialog({project,onClose,onSaved}:{project:PortfolioProject;onClose:()=>void;onSaved:()=>Promise<void>}){
const[busy,setBusy]=useState(false),[error,setError]=useState('')
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy(true);setError('');const form=new FormData(event.currentTarget);try{await api(`/api/portfolio/projects/${project.id}`,{method:'PUT',body:JSON.stringify({name:form.get('name'),description:form.get('description'),category:form.get('category'),icon:form.get('icon'),repository:form.get('repository'),branch:form.get('branch'),system_prompt:form.get('system_prompt'),initial_prompt:form.get('initial_prompt')})});await onSaved()}catch(reason){setError((reason as Error).message);setBusy(false)}}
return <Modal title={`Modifier · ${project.name}`} onClose={onClose} wide><form className="modal-form" onSubmit={submit}><label>Nom<input name="name" defaultValue={project.name} required maxLength={120}/></label><label>Description<textarea name="description" defaultValue={project.description} required minLength={10} maxLength={4000} rows={3}/></label><div className="project-settings-grid"><label>Catégorie<input name="category" defaultValue={project.category} pattern="[A-Z0-9_]+" required/></label><label>Icône<input name="icon" defaultValue={project.icon} maxLength={8} required/></label><label>Dépôt<input name="repository" defaultValue={project.repository}/></label><label>Branche<input name="branch" defaultValue={project.branch} required/></label></div><label>Prompt système<textarea name="system_prompt" defaultValue={project.system_prompt} required rows={4}/></label><label>Prompt initial<textarea name="initial_prompt" defaultValue={project.initial_prompt} required rows={8}/></label>{error&&<p className="inline-error" role="alert">{error}</p>}<div className="modal-actions"><button type="button" className="button secondary" onClick={onClose}>Annuler</button><button className="button primary" disabled={busy}>{busy?'Enregistrement…':'Enregistrer'}</button></div></form></Modal>
}
function Metric({label,value,alert=false}:{label:string;value:number;alert?:boolean}){return <div className={alert?'alert':''}><span>{label}</span><b>{value}</b></div>}
function FilterButton({active,onClick,children}:{active:boolean;onClick:()=>void;children:string}){return <button className={active?'active':''} onClick={onClick}>{children}</button>}
function Tab({active,onClick,children}:{active:boolean;onClick:()=>void;children:ReactNode}){return <button className={active?'active':''} onClick={onClick}>{children}</button>}
function Status({value}:{value:string}){return <i className={`portfolio-task-status ${value.toLowerCase()}`}>{value}</i>}

View File

@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react'
import { api } from './api'
function supported(){return 'serviceWorker' in navigator&&'PushManager' in window&&'Notification' in window}
async function save(subscription:PushSubscription){await api('/api/push/subscriptions',{method:'POST',body:JSON.stringify(subscription.toJSON())})}
export async function disablePush(){
if(!supported())return
const registration=await navigator.serviceWorker.getRegistration()
const subscription=await registration?.pushManager.getSubscription()
if(subscription){await api('/api/push/subscriptions',{method:'DELETE',body:JSON.stringify({endpoint:subscription.endpoint})});await subscription.unsubscribe()}
}
export function PushSettings(){
const[registration,setRegistration]=useState<ServiceWorkerRegistration|null>(null),[key,setKey]=useState(''),[enabled,setEnabled]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState(''),[permission,setPermission]=useState(typeof Notification==='undefined'?'default':Notification.permission)
const ios=/iPad|iPhone|iPod/.test(navigator.userAgent)||(navigator.platform==='MacIntel'&&navigator.maxTouchPoints>1)
const standalone=window.matchMedia('(display-mode: standalone)').matches||Boolean((navigator as Navigator&{standalone?:boolean}).standalone)
const needsInstall=ios&&!standalone
useEffect(()=>{if(!supported()||needsInstall)return;let disposed=false;Promise.all([navigator.serviceWorker.ready,api<{public_key:string}>('/api/push/key')]).then(async([reg,config])=>{const sub=await reg.pushManager.getSubscription();if(sub&&Notification.permission==='granted')await save(sub);if(!disposed){setRegistration(reg);setKey(config.public_key);setEnabled(Boolean(sub)&&Notification.permission==='granted')}}).catch(reason=>{if(!disposed)setError(reason.message)});return()=>{disposed=true}},[needsInstall])
async function enable(){
if(!registration||!key||busy)return
setBusy(true);setError('')
try{
// Keep the permission request directly inside the user's click on iOS.
const granted=await Notification.requestPermission();setPermission(granted)
if(granted!=='granted')throw new Error('Autorisez les notifications de CodexPi dans les réglages iOS, puis réessayez.')
const bytes=Uint8Array.from(atob(key.replace(/-/g,'+').replace(/_/g,'/')+'='.repeat((4-key.length%4)%4)),char=>char.charCodeAt(0))
const sub=await registration.pushManager.getSubscription()||await registration.pushManager.subscribe({userVisibleOnly:true,applicationServerKey:bytes})
await save(sub);setEnabled(true)
}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}
}
async function disable(){setBusy(true);setError('');try{await disablePush();setEnabled(false)}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
return <section className="push-settings"><div><h3>Notifications de fin de tâche</h3><p>Recevez une notification quand une de vos demandes se termine ou échoue, même si CodexPi est fermé. Touchez-la pour ouvrir la session.</p>{needsInstall?<p>Sur iPhone : Safari → Partager → Sur l’écran d’accueil. Ouvrez ensuite CodexPi depuis son icône pour activer les notifications (iOS 16.4 ou ultérieur).</p>:!supported()?<p>Les notifications Push ne sont pas disponibles dans ce navigateur.</p>:permission==='denied'?<p>Notifications bloquées : autorisez CodexPi dans Réglages → Notifications.</p>:<p role="status">{enabled?'Activées sur cet appareil.':'Désactivées sur cet appareil.'}</p>}{error&&<p className="inline-error" role="alert">{error}</p>}</div><button className="button secondary" onClick={()=>void(enabled?disable():enable())} disabled={busy||needsInstall||!supported()||!registration||!key}>{busy?'Enregistrement…':enabled?'Désactiver':'Activer sur cet appareil'}</button></section>
}

View File

@@ -0,0 +1,61 @@
import { useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { copyText } from './Common'
import { Project, RemoteMachine, RemotePairing } from './types'
import { useVisiblePolling } from './useVisiblePolling'
function safeName(value:string){return value.normalize('NFKD').replace(/[^a-zA-Z0-9_-]+/g,'-').replace(/^-+|-+$/g,'').toLowerCase()||'projet'}
export function RemotePanel({projects,initialProject,onError,onChanged,onOpenProject}:{projects:Project[];initialProject?:Project|null;onError:(error:Error|string)=>void;onChanged?:()=>Promise<void>;onOpenProject?:(projectId:string)=>void}){
const[machines,setMachines]=useState<RemoteMachine[]>([])
const[pairing,setPairing]=useState<RemotePairing|null>(null)
const[loading,setLoading]=useState(true)
const[creating,setCreating]=useState(false)
const[server,setServer]=useState(location.origin)
const[copied,setCopied]=useState('')
const eligibleProjects=projects.filter(item=>item.kind!=='remote'&&!item.archived)
const[targetId,setTargetId]=useState(()=>eligibleProjects.some(item=>item.id===initialProject?.id)?initialProject!.id:eligibleProjects[0]?.id||'')
const project=eligibleProjects.find(item=>item.id===targetId)||eligibleProjects[0]||null
const refresh=useCallback(async()=>{try{setMachines(await api<RemoteMachine[]>('/api/remote/machines'));await onChanged?.()}catch(error){onError(error as Error)}finally{setLoading(false)}},[onChanged,onError])
useEffect(()=>{if(project&&!targetId)setTargetId(project.id)},[project?.id,targetId])
useEffect(()=>{setPairing(null)},[targetId])
useVisiblePolling(refresh,5000)
async function createPairing(){if(!project){onError('Ajoutez d’abord un projet local afin de créer une association.');return}setCreating(true);try{setPairing(await api<RemotePairing>(`/api/projects/${project.id}/remote-pairings`,{method:'POST'}))}catch(error){onError(error as Error)}finally{setCreating(false)}}
function downloadPairing(){
if(!pairing)return
let endpoint:string
try{endpoint=new URL(server).origin}catch{onError('Adresse CodexPi invalide. Utilisez par exemple http://192.168.1.50:8765');return}
const value={version:pairing.version,server:endpoint,pairing_id:pairing.pairing_id,code:pairing.code,expires_at:pairing.expires_at,project_id:pairing.project_id,project_name:pairing.project_name}
const blob=new Blob([JSON.stringify(value,null,2)+'\n'],{type:'application/json'}),url=URL.createObjectURL(blob),link=document.createElement('a')
link.href=url;link.download=`codexpi-${safeName(pairing.project_name)}.codexpi`;document.body.appendChild(link);link.click();link.remove();window.setTimeout(()=>URL.revokeObjectURL(url),1000)
}
async function revoke(machine:RemoteMachine){if(!confirm(`Révoquer ${machine.name} ? Codex ne pourra plus accéder à son dossier.`))return;try{await api(`/api/remote/machines/${machine.id}`,{method:'DELETE'});await refresh()}catch(error){onError(error as Error)}}
async function copyCommand(name:string,value:string){const success=await copyText(value);setCopied(success?name:'error');window.setTimeout(()=>setCopied(''),1800)}
const pairingName=safeName(pairing?.project_name||project?.name||'projet')
const windowsCommand=`py codexpi_bridge.py --pairing codexpi-${pairingName}.codexpi --folder "C:\\MonProjet"`
const unixCommand=`python3 codexpi_bridge.py --pairing codexpi-${pairingName}.codexpi --folder "$HOME/MonProjet"`
return <div className="remote-page">
<header className="remote-header"><div><p className="eyebrow">FICHIERS DISTANTS</p><h2>Machines distantes</h2><p>Consultez toutes les machines associées et partagez un dossier depuis un autre ordinateur, sans terminal distant ni port entrant.</p></div><div className="remote-header-actions"><label>Profil de départ<select value={project?.id||''} onChange={event=>setTargetId(event.target.value)} disabled={creating||!eligibleProjects.length}>{eligibleProjects.map(item=><option value={item.id} key={item.id}>{item.name}</option>)}{!eligibleProjects.length&&<option value="">Aucun projet local</option>}</select><small>Copie seulement le propriétaire et les réglages IA vers le futur projet distant.</small></label><button className="button primary" onClick={()=>void createPairing()} disabled={creating||!project}>{creating?'Création…':'+ Ajouter une machine distante'}</button></div></header>
<section className="remote-tutorial">
<div className="remote-tutorial-head"><span>⌁</span><div><p className="eyebrow">GUIDE RAPIDE</p><h3>Donner à Codex l’accès aux fichiers d’un autre PC</h3><p>Le bridge tourne sur le PC et vient chercher les demandes auprès du Pi. Il n’ouvre aucun port sur le PC.</p></div></div>
<ol>
<li><span>1</span><div><b>Vérifiez l’adresse du Pi</b><p>Depuis le PC, ouvrez <code>http://IP_DU_PI:8765</code>. Les deux appareils doivent être sur le même réseau si le PC n’utilise pas Tailscale.</p></div></li>
<li><span>2</span><div><b>Créez les fichiers du bridge</b><p>Touchez « Ajouter une machine distante », puis téléchargez le fichier d’association <code>.codexpi</code> et <code>codexpi_bridge.py</code>.</p></div></li>
<li><span>3</span><div><b>Lancez le bridge sur le PC</b><p>Placez les deux fichiers ensemble, ouvrez un terminal dans ce dossier puis exécutez l’une de ces commandes.</p></div></li>
</ol>
<div className="remote-code-grid">
<div><header><span>Windows · PowerShell</span><button type="button" onClick={()=>void copyCommand('windows',windowsCommand)}>{copied==='windows'?'✓ Copié':copied==='error'?'Échec':'Copier'}</button></header><pre><code>{windowsCommand}</code></pre></div>
<div><header><span>macOS / Linux</span><button type="button" onClick={()=>void copyCommand('unix',unixCommand)}>{copied==='unix'?'✓ Copié':copied==='error'?'Échec':'Copier'}</button></header><pre><code>{unixCommand}</code></pre></div>
</div>
<div className="remote-tutorial-finish"><span>4</span><p>Laissez ce terminal ouvert. Quand la machine affiche « En ligne », demandez par exemple : <b>« Lis puis modifie README.md sur ma machine distante. »</b></p></div>
</section>
{pairing&&<section className="remote-pairing">
<div className="remote-pairing-title"><span>1</span><div><h3>Fichiers d’association prêts</h3><p>Valables jusqu’à {new Date(pairing.expires_at).toLocaleTimeString('fr-FR',{hour:'2-digit',minute:'2-digit'})}. Le code ne fonctionne qu’une seule fois.</p></div><button type="button" onClick={()=>setPairing(null)} aria-label="Fermer">×</button></div>
<label className="remote-endpoint"><span>Adresse joignable depuis le PC</span><input value={server} onChange={event=>setServer(event.target.value)} inputMode="url" placeholder="http://192.168.1.50:8765"/><small>Via Tailscale actuellement ? Remplacez cette adresse par l’IP locale du Pi et le port 8765.</small></label>
<div className="remote-downloads"><button className="remote-download" onClick={downloadPairing}><span>◇</span><div><b>Fichier d’association</b><small>.codexpi · adresse et code temporaire</small></div><i>⇩</i></button><a className="remote-download" href="/api/remote/connector" download><span>⌁</span><div><b>Connecteur du PC</b><small>codexpi_bridge.py · Python 3</small></div><i>⇩</i></a></div>
<div className="remote-instructions"><div><span>2</span><p>Placez les deux fichiers dans le même dossier sur le PC distant.</p></div><div><span>3</span><p>Ouvrez un terminal dans ce dossier et choisissez le dossier à partager :</p></div><code><b>Windows</b> py codexpi_bridge.py --pairing codexpi-{pairingName}.codexpi --folder "C:\Votre\Dossier"</code><code><b>macOS / Linux</b> python3 codexpi_bridge.py --pairing codexpi-{pairingName}.codexpi --folder "/votre/dossier"</code><small>Laissez la fenêtre du connecteur ouverte pendant que Codex travaille. Aucun mot de passe CodexPi n’est enregistré dans le fichier.</small></div>
</section>}
<section className="remote-machines"><div className="remote-section-head"><div><h3>Machines associées</h3><p>{machines.length?`${machines.length} connecteur${machines.length>1?'s':''}`:'Aucun connecteur associé'}</p></div><button className="icon-button" onClick={()=>void refresh()} disabled={loading} aria-label="Actualiser">↻</button></div>{machines.map(machine=><article key={machine.id}><div className={`remote-machine-icon ${machine.online?'online':''}`}>⌁</div><div className="remote-machine-info"><div><h4>{machine.name}</h4><span className={machine.online?'online':''}><i/>{machine.online?'En ligne':'Hors ligne'}</span></div><p><b>Projet</b> {machine.project_name||machine.folder_name} · <b>Dossier partagé</b> {machine.folder_name}</p><small>{machine.platform||'Système inconnu'} · {machine.last_seen?`vu ${new Date(machine.last_seen).toLocaleString('fr-FR')}`:'jamais connecté'}</small></div><div className="remote-machine-actions">{machine.project_id&&<button className="button primary" onClick={()=>onOpenProject?.(machine.project_id!)}>Ouvrir le projet</button>}<button className="button secondary remote-revoke" onClick={()=>void revoke(machine)}>Révoquer</button></div></article>)}{!loading&&!machines.length&&<div className="remote-empty"><span>⌁</span><h3>Ajoutez votre premier PC</h3><p>Le connecteur initie toutes les connexions vers le Pi. Le PC n’expose aucun service réseau.</p></div>}</section>
<aside className="remote-safety"><span>▣</span><div><b>Périmètre limité</b><p>Codex peut lister, lire et enregistrer des fichiers UTF-8 de moins de 2 Mo uniquement dans le dossier choisi. Les liens symboliques sortants, les fichiers binaires et les commandes distantes sont refusés.</p></div></aside>
</div>
}

View File

@@ -0,0 +1,17 @@
import { useState } from 'react'
import { GitHubSettings } from './GitHubSettings'
import { GitLabSettings } from './GitLabSettings'
import { GiteaSettings } from './GiteaSettings'
import { Project } from './types'
type Provider='gitea'|'gitlab'|'github'
export function RepositorySettings({project,onSaved}:{project:Project|null;onSaved:()=>Promise<void>}){
const[open,setOpen]=useState<Provider|null>('gitea')
const providers:[Provider,string,string,React.ReactNode][]=[
['gitea','Gitea','Instance légère auto-hébergée',<GiteaSettings project={project} onSaved={onSaved}/>],
['gitlab','GitLab','GitLab local ou self-hosted',<GitLabSettings project={project} onSaved={onSaved}/>],
['github','GitHub','Dépôts hébergés sur GitHub',<GitHubSettings project={project} onSaved={onSaved}/>],
]
return <section className="repository-settings"><header><div><h3>Dépôts</h3><p>Connectez le fournisseur Git du projet et gérez son dépôt distant.</p></div></header><div className="repository-accordions">{providers.map(([id,label,description,content])=><div className={`repository-accordion ${open===id?'open':''}`} key={id}><button type="button" aria-expanded={open===id} aria-controls={`repository-${id}`} onClick={()=>setOpen(current=>current===id?null:id)}><span className={`repository-provider-icon ${id}`}>{id==='github'?'⑂':id==='gitlab'?'GL':'GT'}</span><span><b>{label}</b><small>{description}</small></span><i>⌄</i></button>{open===id&&<div id={`repository-${id}`} className="repository-accordion-panel">{content}</div>}</div>)}</div></section>
}

View File

@@ -0,0 +1,8 @@
import { FormEvent, useEffect, useState } from 'react'
import { api } from './api'
import { Project } from './types'
type RuleVersion={id:string;version:number;content:string;created_at:string}
export function RulesSettings(){const[current,setCurrent]=useState<RuleVersion|null>(null),[history,setHistory]=useState<RuleVersion[]>([]),[content,setContent]=useState(''),[busy,setBusy]=useState(false),[error,setError]=useState('');async function load(){try{const value=await api<{current:RuleVersion;history:RuleVersion[]}>('/api/settings/rules');setCurrent(value.current);setHistory(value.history);setContent(value.current.content)}catch(reason){setError((reason as Error).message)}}useEffect(()=>{void load()},[]);async function submit(event:FormEvent){event.preventDefault();setBusy(true);setError('');try{await api('/api/settings/rules',{method:'PUT',body:JSON.stringify({content})});await load()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}return <section className="rules-settings"><header><div><h3>Règles communes des agents</h3><p>Transmises avec les consignes du projet à Codex, Claude Code et OpenCode. Une modification s’applique aux prochains messages; une exécution déjà en file conserve sa version.</p></div><span>Version {current?.version||'—'}</span></header><form onSubmit={submit}><textarea value={content} onChange={event=>setContent(event.target.value)} rows={18}/>{error&&<p className="inline-error">{error}</p>}<button className="button primary" disabled={busy||content.trim()===current?.content.trim()}>{busy?'Enregistrement…':'Publier une nouvelle version'}</button></form><details><summary>Historique ({history.length})</summary>{history.map(item=><article key={item.id}><b>v{item.version}</b><time>{new Date(item.created_at).toLocaleString('fr-FR')}</time><pre>{item.content}</pre></article>)}</details></section>}
export function ProjectRulesSettings({project}:{project:Project}){const[content,setContent]=useState(''),[effort,setEffort]=useState(project.reasoning_effort||'medium'),[merge,setMerge]=useState(false),[busy,setBusy]=useState(false),[notice,setNotice]=useState('');useEffect(()=>{api<{project_rules:string;reasoning_effort:string;merge_confirmation_required:boolean}>(`/api/projects/${project.id}/settings`).then(value=>{setContent(value.project_rules||'');setEffort(value.reasoning_effort);setMerge(value.merge_confirmation_required)})},[project.id]);async function save(){setBusy(true);setNotice('');try{await api(`/api/projects/${project.id}/settings`,{method:'PUT',body:JSON.stringify({reasoning_effort:effort,project_rules:content,merge_confirmation_required:merge})});setNotice('Consignes enregistrées pour les prochains messages.')}finally{setBusy(false)}}return <section className="rules-settings project-rules-setting"><header><div><h3>Consignes de {project.name}</h3><p>Ajoutées à la feuille commune. Le backend conserve l’instantané réellement envoyé à chaque exécution.</p></div></header><textarea value={content} onChange={event=>setContent(event.target.value)} rows={9} placeholder="Tests, conventions et critères propres à ce dépôt…"/><label className="checkbox-row"><input type="checkbox" checked={merge} onChange={event=>setMerge(event.target.checked)}/> Exiger une confirmation supplémentaire avant la fusion orchestrée</label>{notice&&<p className="settings-notice">{notice}</p>}<button className="button primary" disabled={busy} onClick={()=>void save()}>{busy?'Enregistrement…':'Enregistrer les consignes du projet'}</button></section>}

View File

@@ -0,0 +1,33 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import { api, wsUrl } from './api'
import { Modal, StatusDot } from './Common'
import { Project } from './types'
import { useVisiblePolling } from './useVisiblePolling'
function InteractiveTerminal({path,label,detail}:{path:string;label:string;detail:string}){
const host=useRef<HTMLDivElement>(null),[connection,setConnection]=useState<'connecting'|'online'|'offline'>('connecting')
useEffect(()=>{if(!host.current)return;const term=new Terminal({cursorBlink:true,fontFamily:'ui-monospace, SFMono-Regular, Menlo, monospace',fontSize:13,lineHeight:1.35,theme:{background:'#090a0c',foreground:'#d4d7d5',cursor:'#74d393',selectionBackground:'#38584388'},scrollback:5000});const fit=new FitAddon();term.loadAddon(fit);term.open(host.current);fit.fit();let ws:WebSocket|null=null,disposed=false,timer=0,attempts=0;const connect=()=>{if(disposed)return;setConnection('connecting');ws=new WebSocket(wsUrl(path));ws.binaryType='arraybuffer';ws.onopen=()=>{attempts=0;setConnection('online');ws?.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}));term.focus()};ws.onmessage=event=>{if(typeof event.data==='string'){try{const payload=JSON.parse(event.data);term.writeln(`\r\n\x1b[31m${payload.message||event.data}\x1b[0m`)}catch{term.write(event.data)}}else term.write(new Uint8Array(event.data))};ws.onclose=()=>{setConnection('offline');if(!disposed){term.writeln('\r\n\x1b[33mConnexion interrompue — reconnexion…\x1b[0m');timer=window.setTimeout(connect,Math.min(10000,500*2**attempts++))}};ws.onerror=()=>ws?.close()};connect();const input=term.onData(data=>ws?.readyState===WebSocket.OPEN&&ws.send(JSON.stringify({type:'input',data})));const resize=new ResizeObserver(()=>{fit.fit();ws?.readyState===WebSocket.OPEN&&ws.send(JSON.stringify({type:'resize',cols:term.cols,rows:term.rows}))});resize.observe(host.current);return()=>{disposed=true;clearTimeout(timer);input.dispose();resize.disconnect();ws?.close();term.dispose()}},[path])
return <div className="terminal-view"><div className="terminal-toolbar"><span className={connection==='online'?'online-dot':'offline-dot'}/><b>{label}</b><code>{detail}</code><small>{connection==='online'?'Connecté':connection==='connecting'?'Connexion…':'Reconnexion…'}</small></div><div className="terminal-surface" ref={host}/></div>
}
export function TerminalPanel({project}:{project:Project}){
return <InteractiveTerminal path={`/ws/projects/${project.id}/terminal`} label={`Terminal · ${project.name}`} detail={project.path}/>
}
function LiveLogs({project,name,onError}:{project:Project;name:string;onError:(e:Error|string)=>void}){
const[value,setValue]=useState(''),[connected,setConnected]=useState(false),[follow,setFollow]=useState(true),surface=useRef<HTMLPreElement>(null)
useEffect(()=>{const socket=new WebSocket(wsUrl(`/ws/projects/${project.id}/docker/${encodeURIComponent(name)}/logs`));socket.binaryType='arraybuffer';const decoder=new TextDecoder();socket.onopen=()=>setConnected(true);socket.onmessage=event=>setValue(current=>(current+(typeof event.data==='string'?event.data:decoder.decode(event.data,{stream:true}))).slice(-300000));socket.onerror=()=>onError('Impossible de suivre les logs de ce conteneur.');socket.onclose=()=>setConnected(false);return()=>socket.close()},[project.id,name,onError])
useEffect(()=>{if(follow&&surface.current)surface.current.scrollTop=surface.current.scrollHeight},[value,follow])
return <div className="live-logs"><div className="live-logs-toolbar"><span className={connected?'online-dot':'offline-dot'}/><b>{connected?'Logs en direct':'Flux déconnecté'}</b><button type="button" className={follow?'active':''} onClick={()=>setFollow(current=>!current)}>{follow?'↓ Suivi actif':'Suivre'}</button><button type="button" onClick={()=>setValue('')}>Effacer</button></div><pre ref={surface} className="log-output">{value||'En attente de logs…'}</pre></div>
}
export function DockerPanel({project,onError}:{project:Project;onError:(e:Error|string)=>void}){
const[containers,setContainers]=useState<Record<string,string>[]>([]),[logs,setLogs]=useState<string|null>(null),[terminal,setTerminal]=useState<string|null>(null),[busy,setBusy]=useState('')
const refresh=useCallback(()=>api<{stdout:string}>(`/api/projects/${project.id}/docker/containers`).then(result=>setContainers(result.stdout.split('\n').filter(Boolean).map(line=>JSON.parse(line)))).catch(onError),[project.id,onError])
useEffect(()=>setContainers([]),[project.id])
useVisiblePolling(refresh,10_000)
async function action(name:string,value:string){const actionLabel=({stop:'Arrêter',restart:'Redémarrer',rebuild:'Reconstruire',delete:'Supprimer',start:'Démarrer'} as Record<string,string>)[value]||value;const question=value==='delete'?`Supprimer définitivement le conteneur arrêté ${name} ?`: `${actionLabel} ${name} ?`;if(['stop','restart','rebuild','delete'].includes(value)&&!confirm(question))return;setBusy(`${name}:${value}`);try{await api(`/api/projects/${project.id}/docker/action`,{method:'POST',body:JSON.stringify({name,action:value})});await refresh()}catch(error){onError(error as Error)}finally{setBusy('')}}
return <div className="docker-page"><div className="content-header"><div><p className="eyebrow">EXÉCUTION DU PROJET</p><h2>Conteneurs Docker</h2><p>Uniquement les conteneurs rattachés à <b>{project.name}</b>.</p></div><button className="icon-button" onClick={()=>void refresh()} aria-label="Actualiser">↻</button></div><div className="container-table"><div className="container-head"><span>Conteneur</span><span>État</span><span>Image</span><span>Ports</span><span>Durée</span><span>Actions</span></div>{containers.map(container=>{const name=container.Names,running=container.State==='running';return <div className="container-row" key={container.ID}><span><StatusDot status={running?'running':'idle'}/><b>{name}</b></span><span className={`container-status ${container.State}`}>{running?'En cours':container.State}</span><code>{container.Image}</code><code>{container.Ports||'—'}</code><span>{container.Status||'—'}</span><span className="row-actions"><button onClick={()=>setLogs(name)}>Journaux en direct</button>{running&&<button onClick={()=>setTerminal(name)}>Terminal</button>}{running?<><button disabled={Boolean(busy)} onClick={()=>void action(name,'restart')}>Redémarrer</button><button disabled={Boolean(busy)} onClick={()=>void action(name,'stop')}>Arrêter</button></>:<><button disabled={Boolean(busy)} onClick={()=>void action(name,'start')}>Démarrer</button><button className="delete-container" disabled={Boolean(busy)} onClick={()=>void action(name,'delete')}>{busy===`${name}:delete`?'Suppression…':'Supprimer'}</button></>}<button disabled={Boolean(busy)} onClick={()=>void action(name,'rebuild')}>{busy===`${name}:rebuild`?'Construction…':'Reconstruire'}</button></span></div>})}{!containers.length&&<div className="container-empty">Aucun conteneur rattaché à ce projet. Les projets Compose sont détectés par leurs labels et leurs volumes.</div>}</div>{logs&&<Modal title={`Journaux en direct · ${logs}`} onClose={()=>setLogs(null)} wide><LiveLogs project={project} name={logs} onError={onError}/></Modal>}{terminal&&<Modal title={`Terminal · ${terminal}`} onClose={()=>setTerminal(null)} wide><div className="docker-terminal-modal"><InteractiveTerminal path={`/ws/projects/${project.id}/docker/${encodeURIComponent(terminal)}/terminal`} label="Shell du conteneur" detail={terminal}/></div></Modal>}</div>
}

View File

@@ -0,0 +1,16 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { copyText } from './Common'
export type SSHServer = {id:string;name:string;host:string;port:number;username:string;auth_method:'key'|'password';created_at:string;updated_at:string}
export function SSHServersSettings(){
const[servers,setServers]=useState<SSHServer[]>([]),[open,setOpen]=useState(false),[name,setName]=useState(''),[host,setHost]=useState(''),[port,setPort]=useState('22'),[username,setUsername]=useState(''),[authMethod,setAuthMethod]=useState<'key'|'password'>('key'),[password,setPassword]=useState(''),[publicKey,setPublicKey]=useState(''),[copied,setCopied]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState('')
const load=useCallback(async()=>{try{setServers(await api<SSHServer[]>('/api/ssh/servers'))}catch(reason){setError((reason as Error).message)}},[])
useEffect(()=>{void load()},[load])
useEffect(()=>{if(open&&authMethod==='key'&&!publicKey)api<{public_key:string}>('/api/ssh/key').then(value=>setPublicKey(value.public_key)).catch(reason=>setError(reason.message))},[open,authMethod,publicKey])
async function submit(event:FormEvent<HTMLFormElement>){event.preventDefault();setBusy(true);setError('');try{await api('/api/ssh/servers',{method:'POST',body:JSON.stringify({name:name.trim(),host:host.trim(),port:Number(port),username:username.trim(),auth_method:authMethod,password:authMethod==='password'?password:null})});setName('');setHost('');setPort('22');setUsername('');setPassword('');setOpen(false);await load()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
async function remove(server:SSHServer){if(!confirm(`Supprimer le serveur « ${server.name} » ? Les projets déjà créés continueront de fonctionner.`))return;try{await api(`/api/ssh/servers/${server.id}`,{method:'DELETE'});await load()}catch(reason){setError((reason as Error).message)}}
async function copyKey(){setCopied(await copyText(publicKey));window.setTimeout(()=>setCopied(false),1600)}
return <section className="ssh-server-settings"><header><div><h3>Serveurs distants</h3><p>Enregistrez des serveurs SSH réutilisables comme sources de projets.</p></div><button className="button secondary" onClick={()=>setOpen(value=>!value)}>{open?'Fermer':'+ Ajouter un serveur'}</button></header>{open&&<form className="ssh-server-form" onSubmit={submit}><label>Nom du serveur<input value={name} onChange={event=>setName(event.target.value)} required maxLength={80} placeholder="Serveur maison"/></label><label>Adresse ou IP<input value={host} onChange={event=>setHost(event.target.value)} required maxLength={253} autoCapitalize="none" placeholder="192.168.1.172"/></label><label>Port<input value={port} onChange={event=>setPort(event.target.value)} required inputMode="numeric"/></label><label>Utilisateur<input value={username} onChange={event=>setUsername(event.target.value)} required maxLength={64} autoCapitalize="none" placeholder="ubuntu"/></label><div className="ssh-auth-tabs"><button type="button" className={authMethod==='key'?'active':''} onClick={()=>{setAuthMethod('key');setPassword('')}}>Clé SSH</button><button type="button" className={authMethod==='password'?'active':''} onClick={()=>setAuthMethod('password')}>Mot de passe</button></div>{authMethod==='password'?<label>Mot de passe<input type="password" value={password} onChange={event=>setPassword(event.target.value)} required maxLength={512} autoComplete="off"/></label>:publicKey&&<div className="saved-server-key"><code>{publicKey}</code><button type="button" onClick={()=>void copyKey()}>{copied?'✓ Copiée':'Copier la clé'}</button><small>À ajouter dans <code>~/.ssh/authorized_keys</code> avant l’enregistrement.</small></div>}<button className="button primary" disabled={busy||!name.trim()||!host.trim()||!username.trim()||(authMethod==='password'&&!password)}>{busy?'Test et enregistrement…':'Tester et enregistrer'}</button></form>}{error&&<p className="inline-error" role="alert">{error}</p>}<div className="ssh-server-list">{servers.map(server=><article key={server.id}><span>⌁</span><div><b>{server.name}</b><small>{server.username}@{server.host}:{server.port} · {server.auth_method==='key'?'clé SSH':'mot de passe chiffré'}</small></div><button type="button" onClick={()=>void remove(server)}>Supprimer</button></article>)}{!servers.length&&!open&&<p>Aucun serveur distant enregistré.</p>}</div></section>
}

View File

@@ -0,0 +1,13 @@
import { FormEvent, useCallback, useEffect, useState } from 'react'
import { api } from './api'
import { ManagedUser } from './types'
export function UserManagement({currentUser}:{currentUser:{username:string;is_admin:boolean}}){
const[users,setUsers]=useState<ManagedUser[]>([]),[username,setUsername]=useState(''),[password,setPassword]=useState(''),[admin,setAdmin]=useState(false),[busy,setBusy]=useState(false),[error,setError]=useState('')
const refresh=useCallback(()=>api<ManagedUser[]>('/api/admin/users').then(setUsers).catch(reason=>setError(reason.message)),[])
useEffect(()=>{void refresh()},[refresh])
async function create(event:FormEvent){event.preventDefault();setBusy(true);setError('');try{await api('/api/admin/users',{method:'POST',body:JSON.stringify({username,password,is_admin:admin})});setUsername('');setPassword('');setAdmin(false);await refresh()}catch(reason){setError((reason as Error).message)}finally{setBusy(false)}}
async function toggle(user:ManagedUser){try{await api(`/api/admin/users/${user.id}`,{method:'PUT',body:JSON.stringify({is_admin:!user.is_admin})});await refresh()}catch(reason){setError((reason as Error).message)}}
async function remove(user:ManagedUser){if(!confirm(`Supprimer le compte ${user.username} et tous ses projets CodexPi ?`))return;try{await api(`/api/admin/users/${user.id}`,{method:'DELETE'});await refresh()}catch(reason){setError((reason as Error).message)}}
return <section className="user-management"><header><div><h3>Utilisateurs</h3><p>Chaque utilisateur ne voit que ses projets et ses discussions. Seuls les administrateurs gèrent les comptes.</p></div><span>{users.length} compte{users.length>1?'s':''}</span></header><div className="managed-users">{users.map(user=><article key={user.id}><span>{user.username.slice(0,2).toUpperCase()}</span><div><b>{user.username}</b><small>{user.is_admin?'Administrateur':'Utilisateur'}</small></div><button type="button" disabled={user.username===currentUser.username} onClick={()=>void toggle(user)}>{user.is_admin?'Rendre utilisateur':'Rendre admin'}</button><button type="button" className="danger-button" disabled={user.username===currentUser.username} onClick={()=>void remove(user)}>Supprimer</button></article>)}</div><form className="user-create" onSubmit={create}><label>Nouvel utilisateur<input value={username} onChange={event=>setUsername(event.target.value)} minLength={3} maxLength={64} required autoCapitalize="none"/></label><label>Mot de passe initial<input type="password" value={password} onChange={event=>setPassword(event.target.value)} minLength={12} maxLength={512} required autoComplete="new-password"/></label><label className="checkbox-line"><input type="checkbox" checked={admin} onChange={event=>setAdmin(event.target.checked)}/> Administrateur</label><button className="button primary" disabled={busy}>{busy?'Création…':'Créer le compte'}</button></form>{error&&<p className="inline-error">{error}</p>}</section>
}

300
frontend/src/Workspace.tsx Normal file

File diff suppressed because one or more lines are too long

41
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,41 @@
import { readJsonPayload } from './apiPayload'
import { csrfTokenFromBootstrap } from './csrfRefresh'
let csrfToken = sessionStorage.getItem('codexpi-csrf') || ''
export function setCsrf(value: string) {
csrfToken = value
sessionStorage.setItem('codexpi-csrf', value)
}
export async function refreshCsrf(): Promise<string> {
const token = csrfTokenFromBootstrap(await api<unknown>('/api/bootstrap'))
setCsrf(token)
return token
}
export async function api<T>(path: string, options: RequestInit = {}): Promise<T> {
const method = (options.method || 'GET').toUpperCase()
const headers = new Headers(options.headers)
if (options.body && !(options.body instanceof FormData) && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && csrfToken) headers.set('X-CSRF-Token', csrfToken)
const response = await fetch(path, { ...options, headers, credentials: 'same-origin' })
if (response.status === 401 && path !== '/api/auth/login') {
csrfToken = ''
sessionStorage.removeItem('codexpi-csrf')
window.dispatchEvent(new Event('codexpi:unauthorized'))
}
let payload: any = {}
try {
payload = await readJsonPayload(response)
} catch (reason) {
if (response.ok) throw reason
}
if (!response.ok) throw new Error(payload.detail || `Erreur HTTP ${response.status}`)
return payload as T
}
export function wsUrl(path: string) {
const scheme = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${scheme}//${location.host}${path}`
}

View File

@@ -0,0 +1,19 @@
export async function readJsonPayload(response: Response): Promise<unknown> {
let body: string
try {
body = await response.text()
} catch {
throw new Error('Réponse serveur incomplète. Réessayez dans un instant.')
}
if (!body.trim()) return {}
try {
return JSON.parse(body)
} catch {
throw new Error('Réponse serveur incomplète. Réessayez dans un instant.')
}
}
export function expectArrayPayload<T>(value: unknown, label: string): T[] {
if (!Array.isArray(value)) throw new Error(`Réponse ${label} invalide. Actualisez la page.`)
return value as T[]
}

24
frontend/src/appRoutes.ts Normal file
View File

@@ -0,0 +1,24 @@
import type { Project, WorkspaceTab } from './types'
export type MainView='company'|'activity'|'dashboard'|'portfolio'|'server'|'workspace'|'orchestrator'|'master'|'remote'|'settings'
export type RouteOptions={project?:({id:string}&Record<string,unknown>)|Project|null;conversationId?:string|null;tab?:WorkspaceTab;orchestrationId?:string|null;replace?:boolean}
export const mainViews:MainView[]=['company','activity','dashboard','portfolio','server','workspace','orchestrator','master','remote','settings']
export function routeView(search=''):MainView{
const query=new URLSearchParams(search),requested=query.get('view')
if(query.has('portfolio')||query.has('ecosystem'))return'portfolio'
if(query.has('project')&&query.has('conversation'))return'workspace'
return mainViews.includes(requested as MainView)?requested as MainView:'company'
}
export function routeHref(view:MainView,options:RouteOptions={}){
const query=new URLSearchParams({view}),project=options.project
if(project&&['workspace','settings'].includes(view))query.set('project',project.id)
if(view==='workspace'){
if(options.conversationId)query.set('conversation',options.conversationId)
query.set('tab',options.tab||'chat')
}
if(view==='orchestrator'&&options.orchestrationId)query.set('orchestration',options.orchestrationId)
return`/?${query.toString()}`
}

Some files were not shown because too many files have changed in this diff Show More