feat(web): add full-generation sync control

This commit is contained in:
fy59 2026-09-17 13:06:59 +02:00
parent ef435148f8
commit ae4e493253
8 changed files with 108 additions and 3 deletions

View file

@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""Real Firefox click through the production C server and owned worker."""
import json, os, signal, socket, subprocess, tempfile, time, unittest, warnings
from pathlib import Path
ROOT=Path(__file__).resolve().parents[1]
FIXTURE=ROOT/"tests/sync_orchestrator_peer_fixture.py"
GECKO=Path(os.environ.get("TRAINLOG_GECKODRIVER","/home/fy59/.cache/trainlog/web-e2e-tools/geckodriver"))
warnings.simplefilter("ignore", ResourceWarning)
class BrowserSyncTest(unittest.TestCase):
def test_button_starts_worker_reconnects_and_displays_committed_draft(self):
if not GECKO.is_file(): self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
with tempfile.TemporaryDirectory(prefix="trainlog-web-sync-") as directory:
root=Path(directory);xdg=root/"data";xdg.mkdir();config=root/"config.json"
config.write_text(json.dumps({"format":"trainlog-sync-orchestrator-config","version":1,"enabled":True,"mode":"directory-test-double","capabilities":["generation-manifest-v1","generation-ack-v1","causal-delete-v1","mobile-history-v4","execution-draft-v1"],"peer_command":["python3",str(FIXTURE)],"timeout_seconds":30}))
with socket.socket() as reserved:
reserved.bind(("127.0.0.1",0));port=reserved.getsockname()[1]
env=os.environ.copy();env.update({"XDG_DATA_HOME":str(xdg),"XDG_CONFIG_HOME":str(root/"config"),"XDG_CACHE_HOME":str(root/"cache"),"TRAINLOG_SYNC_GENERATION_CONFIG":str(config)})
server=subprocess.Popen([str(ROOT/"build/tui/trainlog"),"--web","--port",str(port)],env=env,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,start_new_session=True)
driver=None
try:
for _ in range(100):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1",port))==0: break
time.sleep(.05)
options=webdriver.FirefoxOptions();options.add_argument("-headless")
driver=webdriver.Firefox(options=options,service=Service(str(GECKO)))
driver.get(f"http://127.0.0.1:{port}/")
wait=WebDriverWait(driver,15)
button=wait.until(lambda d:d.find_element(By.XPATH,"//button[normalize-space()='Synchroniser']"))
wait.until(lambda _ : button.is_enabled());button.click()
wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
summary=driver.find_element(By.XPATH,"//summary[contains(.,'Synchronisation confirmée')]");summary.click()
wait.until(lambda d:"Brouillons reçus" in d.page_source and "pending" in d.page_source)
driver.refresh();wait.until(lambda d:"Synchronisation confirmée" in d.page_source)
state=json.loads(Path(str(xdg/"trainlog"/"trainlog.db")+".sync-run.json").read_text())
self.assertEqual(state["phase"],"completed");self.assertEqual(state["sessions_reconciled"],1)
finally:
if driver is not None: driver.quit()
os.killpg(server.pid,signal.SIGTERM)
server.wait(timeout=10)
if __name__=="__main__":unittest.main()

8
web/src/api/sync.test.ts Normal file
View file

@ -0,0 +1,8 @@
import { beforeEach,describe,expect,it,vi } from 'vitest'
import { fetchSyncStatus,startSync,syncIsActive } from './sync'
const token='b'.repeat(64)
function response(value:unknown,status=200){return {ok:status>=200&&status<300,status,headers:new Headers({'X-Trainlog-CSRF-Token':token}),json:()=>Promise.resolve(value)}}
describe('sync API',()=>{beforeEach(()=>vi.restoreAllMocks())
it('obtient passivement le statut et utilise le jeton pour la mutation',async()=>{const fetch=vi.fn().mockResolvedValueOnce(response({phase:'idle',result:'disabled'})).mockResolvedValueOnce(response({phase:'requested',result:'running'},202));vi.stubGlobal('fetch',fetch);await fetchSyncStatus();await startSync('sy_11111111-1111-4111-8111-111111111111');expect(fetch).toHaveBeenCalledTimes(2);const init=fetch.mock.calls[1][1];expect(init.method).toBe('POST');expect(init.headers['X-Trainlog-CSRF-Token']).toBe(token)})
it('rejette un statut inconnu et classe les phases actives',async()=>{vi.stubGlobal('fetch',vi.fn().mockResolvedValue(response({phase:'invented',result:'ok'})));await expect(fetchSyncStatus()).rejects.toThrow();expect(syncIsActive('waiting_acknowledgement')).toBe(true);expect(syncIsActive('completed')).toBe(false)})
})

10
web/src/api/sync.ts Normal file
View file

@ -0,0 +1,10 @@
export type SyncPhase = 'idle' | 'requested' | 'waiting_android_publication' | 'running' | 'local_import_committed' | 'published' | 'waiting_acknowledgement' | 'peer_consumed' | 'completed' | 'failed' | 'interrupted' | 'explicitly_degraded'
export interface SyncDraftSummary { draft_id: string; state: 'active'|'pending'|'finalized'|'stale'; session_type: string; occurrence_count: number }
export interface SyncStatus { api_version?: number; enabled?: boolean; phase: SyncPhase; result: string; run_id?: string; request_id?: string; progress_revision?: number; diagnostic?: string; sessions_reconciled?: number|null; inbound_generation_id?: string|null; outbound_generation_id?: string|null; missing_capabilities?: string[]; domains?: Record<string,boolean>; drafts?: SyncDraftSummary[]; finished_at?: string|null }
let csrfToken=''
const phases=new Set<SyncPhase>(['idle','requested','waiting_android_publication','running','local_import_committed','published','waiting_acknowledgement','peer_consumed','completed','failed','interrupted','explicitly_degraded'])
function parse(value:unknown):SyncStatus { if(typeof value!=='object'||value===null)throw new Error('statut sync invalide');const status=value as Partial<SyncStatus>;if(typeof status.phase!=='string'||!phases.has(status.phase as SyncPhase)||typeof status.result!=='string')throw new Error('statut sync invalide');if(status.drafts!==undefined&&(!Array.isArray(status.drafts)||status.drafts.length>32))throw new Error('résumé des brouillons invalide');return status as SyncStatus }
export async function fetchSyncStatus(signal?:AbortSignal):Promise<SyncStatus>{const response=await fetch('/api/v1/sync/status',{headers:{Accept:'application/json'},signal});if(!response.ok)throw new Error(`sync status HTTP ${response.status}`);const token=response.headers.get('X-Trainlog-CSRF-Token')??'';if(!/^[0-9a-f]{64}$/.test(token))throw new Error('jeton CSRF absent');csrfToken=token;return parse(await response.json())}
export async function startSync(requestId:string):Promise<SyncStatus>{if(!/^sy_[0-9a-f-]{36}$/.test(requestId)||csrfToken.length!==64)throw new Error('requête sync invalide');const response=await fetch('/api/v1/sync',{method:'POST',headers:{Accept:'application/json','Content-Type':'application/json','X-Trainlog-CSRF-Token':csrfToken},body:JSON.stringify({request_id:requestId,trigger:'web'})});const value=await response.json();if(!response.ok)throw new Error(typeof value?.error==='string'?value.error:`sync HTTP ${response.status}`);return parse(value)}
export function newRequestId():string{return `sy_${crypto.randomUUID()}`}
export function syncIsActive(phase:SyncPhase):boolean{return !['idle','completed','failed','interrupted','explicitly_degraded'].includes(phase)}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { fetchHealth, type Health } from '../api/health'
import { fetchDashboard, type DashboardSnapshot } from '../api/dashboard'
import { Footer } from '../components/Footer'
@ -23,6 +23,10 @@ export function App() {
const [dashboard, setDashboard] = useState<DashboardSnapshot | null>(null)
const [dashboardPending, setDashboardPending] = useState(true)
const [dashboardFailed, setDashboardFailed] = useState(false)
const reloadDashboard = useCallback(() => {
fetchDashboard().then((value) => { setDashboard(value); setDashboardFailed(false) })
.catch(() => setDashboardFailed(true)).finally(() => setDashboardPending(false))
}, [])
useEffect(() => {
const controller = new AbortController()
@ -46,7 +50,7 @@ export function App() {
return (
<div className="app-shell">
<a className="skip-link" href="#main-content">Aller au contenu</a>
<Header activeRoute={route} onNavigate={navigate} />
<Header activeRoute={route} onNavigate={navigate} onSyncCommitted={reloadDashboard} />
<main className="app-main" id="main-content">{content}</main>
<Footer health={health} healthPending={healthPending} healthFailed={healthFailed} dashboard={dashboard} />
</div>

View file

@ -1,12 +1,14 @@
import type { MouseEvent } from 'react'
import { routes, type AppRoute } from '../app/routes'
import { SyncControl } from './SyncControl'
interface HeaderProps {
activeRoute: AppRoute
onNavigate: (path: string) => void
onSyncCommitted: () => void
}
export function Header({ activeRoute, onNavigate }: HeaderProps) {
export function Header({ activeRoute, onNavigate, onSyncCommitted }: HeaderProps) {
const follow = (event: MouseEvent<HTMLAnchorElement>, path: string) => {
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return
event.preventDefault()
@ -30,6 +32,7 @@ export function Header({ activeRoute, onNavigate }: HeaderProps) {
</a>
))}
</nav>
<SyncControl onCommitted={onSyncCommitted} />
</header>
)
}

View file

@ -0,0 +1,11 @@
import { fireEvent,render,screen,waitFor } from '@testing-library/react'
import { describe,expect,it,vi } from 'vitest'
import { SyncControl } from './SyncControl'
const headers=new Headers({'X-Trainlog-CSRF-Token':'c'.repeat(64)})
const reply=(value:unknown,status=200)=>({ok:status<300,status,headers,json:()=>Promise.resolve(value)})
describe('contrôle global de synchronisation',()=>{
it('démarre une fois, confirme par polling et expose le brouillon sans le relabeller',async()=>{const fetch=vi.fn()
.mockResolvedValueOnce(reply({phase:'idle',result:'idle'}))
.mockResolvedValueOnce(reply({phase:'requested',result:'running'},202))
.mockResolvedValueOnce(reply({phase:'completed',result:'completed',progress_revision:7,sessions_reconciled:1,finished_at:'2026-09-17T12:00:00Z',drafts:[{draft_id:'draft_1',state:'pending',session_type:'strength',occurrence_count:2}]}));vi.stubGlobal('fetch',fetch);const committed=vi.fn();render(<SyncControl onCommitted={committed}/>);await waitFor(()=>expect(screen.getByRole('button',{name:'Synchroniser'})).toBeEnabled());fireEvent.click(screen.getByRole('button',{name:'Synchroniser'}));await waitFor(()=>expect(screen.getByText('Synchronisation confirmée')).toBeInTheDocument(),{timeout:2500});fireEvent.click(screen.getByText('Synchronisation confirmée'));expect(screen.getByRole('region',{name:'Brouillons synchronisés'})).toHaveTextContent('pending');expect(committed).toHaveBeenCalledTimes(1);expect(fetch.mock.calls.filter(call=>String(call[0]).endsWith('/api/v1/sync'))).toHaveLength(1)})
})

View file

@ -0,0 +1,10 @@
import { useEffect,useRef,useState } from 'react'
import { fetchSyncStatus,newRequestId,startSync,syncIsActive,type SyncStatus } from '../api/sync'
const labels:Record<string,string>={idle:'Synchronisation inactive',requested:'Synchronisation demandée',waiting_android_publication:'Publication Android requise',running:'Synchronisation en cours',local_import_committed:'Données desktop validées',published:'Génération retour publiée',waiting_acknowledgement:'Accusé Android attendu',peer_consumed:'Pair confirmé',completed:'Synchronisation confirmée',failed:'Échec de synchronisation',interrupted:'Synchronisation interrompue',explicitly_degraded:'Pair incompatible'}
export function SyncControl({onCommitted}:{onCommitted:()=>void}){const [status,setStatus]=useState<SyncStatus|null>(null);const [error,setError]=useState('');const committed=useRef(-1)
const accept=(next:SyncStatus)=>{setStatus(next);setError('');if(['local_import_committed','published','waiting_acknowledgement','peer_consumed','completed'].includes(next.phase)&&(next.progress_revision??0)>committed.current){committed.current=next.progress_revision??0;onCommitted()}}
useEffect(()=>{let stopped=false;let timer:number|undefined;const poll=async()=>{try{const next=await fetchSyncStatus();if(stopped)return;accept(next);if(syncIsActive(next.phase))timer=window.setTimeout(poll,750)}catch{if(!stopped)setError('Statut de synchronisation indisponible')}};void poll();return()=>{stopped=true;if(timer!==undefined)window.clearTimeout(timer)}},[onCommitted])
const start=async()=>{setError('');try{accept(await startSync(newRequestId()));const poll=async()=>{try{const next=await fetchSyncStatus();accept(next);if(syncIsActive(next.phase))window.setTimeout(poll,750)}catch{setError('Résultat inconnu : vérifiez le statut avant de réessayer')}};window.setTimeout(poll,250)}catch(reason){setError(reason instanceof Error?reason.message:'Démarrage impossible')}}
const active=status?syncIsActive(status.phase):false
return <div className="sync-control"><button type="button" onClick={start} disabled={active||status?.enabled===false}>Synchroniser</button><details className="sync-details"><summary aria-live="polite">{error||labels[status?.phase??'idle']}</summary>{status?.diagnostic&&<p>{status.diagnostic}</p>}{status?.sessions_reconciled!==null&&status?.sessions_reconciled!==undefined&&<p>Séances rapprochées : {status.sessions_reconciled}</p>}{status?.phase==='waiting_android_publication'&&<p>Ouvrez l’écran de synchronisation Android pour publier une génération fraîche.</p>}{(status?.drafts?.length??0)>0&&<section aria-label="Brouillons synchronisés"><h2>Brouillons reçus</h2><ul>{status!.drafts!.map(d=><li key={d.draft_id}><code>{d.draft_id}</code> · {d.state} · {d.session_type} · {d.occurrence_count} occurrence(s)</li>)}</ul></section>}{status?.finished_at&&<p>Dernier résultat : {status.finished_at}</p>}</details></div>
}

View file

@ -38,6 +38,15 @@
.nav-link:hover { color: var(--text); background: rgb(49 50 68 / 48%); }
.nav-link.is-active { color: var(--lavender); }
.nav-link.is-active::after { content: ""; position: absolute; left: .85rem; right: .85rem; bottom: .22rem; height: 2px; background: var(--lavender); border-radius: 2px; }
.sync-control { margin-left: auto; display: flex; align-items: center; gap: .6rem; position: relative; }
.sync-control button { border: 1px solid var(--lavender); border-radius: .55rem; padding: .55rem .8rem; background: transparent; color: var(--text); cursor: pointer; }
.sync-control button:focus-visible { outline: 3px solid var(--lavender); outline-offset: 2px; }
.sync-control button:disabled { cursor: not-allowed; opacity: .55; }
.sync-details { max-width: 28rem; color: var(--subtext-0); }
.sync-details summary { cursor: pointer; }
.sync-details[open] { position: absolute; z-index: 20; right: 0; top: 2.8rem; min-width: 20rem; padding: .8rem; border: 1px solid var(--surface-2); border-radius: .7rem; background: var(--base); box-shadow: 0 .5rem 2rem #0008; }
.sync-details h2 { font-size: 1rem; }
.sync-details ul { padding-left: 1.2rem; }
.app-main { width: 100%; min-width: 0; padding: clamp(2.25rem, 5vw, 5rem) var(--shell-gutter) clamp(3rem, 7vw, 7rem); }
.page { width: min(100%, 92rem); min-width: 0; margin: 0 auto; }
@ -170,6 +179,8 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.app-header { align-items: stretch; flex-direction: column; gap: .55rem; padding-block: .7rem .45rem; }
.brand { align-self: start; }
.main-nav { margin-inline: calc(var(--shell-gutter) * -1); padding-inline: var(--shell-gutter); }
.sync-control { margin-left: 0; justify-content: space-between; }
.sync-details[open] { position: static; min-width: 0; }
.page-heading { align-items: start; flex-direction: column; gap: 1rem; }
.tile-grid { display: flex; flex-direction: column; }
.tile, .tile-wide, .tile-feature, .principle-panel { width: 100%; grid-column: auto; }