Plakar UI v2 : Architecture d'une Interface React pour un Système de Backup
Auteur : Jérémie Poutrin
Date de développement : Octobre-Novembre 2023
Date de publication : Novembre 2024
Repository : github.com/PlakarLabs/plakar
Introduction
La création d'une interface utilisateur pour un système de backup présente des défis uniques : navigation temporelle dans des snapshots, visualisation de fichiers, et gestion de multiples types de contenus. Cet article détaille l'architecture React que j'ai développée pour Plakar en octobre-novembre 2023, présentant une approche pragmatique centrée sur le développement en mode démo. Un an plus tard, je partage également ma vision de ce que serait cette interface si elle était développée aujourd'hui.
Architecture Demo-First : Le Développement Découplé
L'architecture repose entièrement sur un système de mock API, permettant à l'UI de fonctionner sans backend réel. Cette approche facilite le développement et les démonstrations.
graph TB subgraph "Architecture Actuelle" UI[React UI] API[PlakarApiClient.js] UI --> API API --> DemoRepo[DemoRepo.js] DemoRepo --> DataGen[DataGenerator.js] DemoRepo --> LocalStorage[localStorage] end
Implémentation du Mock API
Le système utilise des fonctions exportées pour simuler les appels API :
// utils/DemoRepo.js
export function createOrRestoreSnapshots(size) {
let items = localStorage.getItem('snapshots')
if (!items) {
console.log('creating new snapshots')
items = JSON.stringify(createDummySnapshotItems(size))
localStorage.setItem('snapshots', items);
}
console.log('restoring snapshots')
return JSON.parse(items);
}
const snapshots = createOrRestoreSnapshots(54);
export async function dummyFetchSnapshotPage(apiUrl, page, pageSize) {
return fetchSnapshotPage(snapshots, page, pageSize);
}
export const dummyFetchSnapshotsPath = async (apiUrl, pathId, page, pageSize) => {
const snapshotId = pathId.split(':')[0];
const path = pathId.split(':')[1];
const r = snapshots.filter((elem) => elem.id === snapshotId);
const s = r.length > 0 ? r[0] : null;
// Retourne différents types de fichiers selon le path
if (pathId.endsWith('demo-small.jpg')) {
return [demoJpegSmallFile(apiUrl, pathId, page, pageSize)];
} else if (pathId.endsWith('demo.mp4')) {
return [demoMp4File(apiUrl, pathId, page, pageSize)];
}
// ... autres types
}
Le client API unifie tous les appels vers les fonctions de démonstration :
// utils/PlakarApiClient.js
import {dummmyFetchConfig, dummyFetchSnapshotPage, dummyFetchSnapshotsPath, dummySearch} from "./DemoRepo";
export function fetchConfig(apiUrl) {
return dummmyFetchConfig();
}
export function fetchSnapshots(apiUrl, page, pageSize) {
return dummyFetchSnapshotPage(apiUrl, page, pageSize);
}
export async function fetchSnapshotsPath(apiUrl, pathId, page, pageSize) {
return dummyFetchSnapshotsPath(apiUrl, pathId, page, pageSize);
}
Navigation et Routing
La navigation utilise React Router v6 avec une structure de routes basée sur les paramètres d'URL :
Structure des Routes
// App.js
function App() {
return (
<Provider store={store}>
<PersistGate loading={null} persistor={persistor}>
<ReduxRouter history={history} routerSelector={routerSelector}>
<Routes>
<Route path={'/'} element={<Welcome/>}/>
<Route path={'/search'} element={<SearchResults/>}/>
<Route path={SNAPSHOT_ROUTE} element={<SnapshotList/>}/>
<Route path={'snapshot/:snapshotId/*'} element={<Explorer/>}/>
<Route path={CONFIG_ROUTE} element={<Config/>}/>
</Routes>
</ReduxRouter>
</PersistGate>
</Provider>
);
}
Gestion des Paramètres de Navigation
Le composant Explorer gère la navigation en distinguant fichiers et dossiers :
// pages/Explorer.js
export function prepareParams({snapshotId, '*': path}) {
let isDirectory = false
// remove : at end of snapshotId
if (snapshotId.endsWith(':')) {
snapshotId = snapshotId.slice(0, -1);
}
// add slash at to the path
if (!path.startsWith('/')) {
path = '/' + path;
}
// if path ends with slash, it's a directory
if (path.endsWith('/')) {
isDirectory = true;
}
return {snapshotId, path, isDirectory};
}
function Explorer() {
let params = useParams();
const {snapshotId, path, isDirectory} = useMemo(() => prepareParams(params), [params]);
return (
<TwoColumnLayout
leftComponent={<>
{isDirectory && <PathList snapshotId={snapshotId} path={path}/>}
{!isDirectory && <FileViewer snapshotId={snapshotId} path={path}/>}
</>}
rightComponent={<>
{isDirectory && <SnapshotDetails/>}
{!isDirectory && <FileDetails/>}
</>}
/>
)
}
Visualiseur de Fichiers
Le système de visualisation des fichiers est implémenté avec des composants spécialisés par type MIME :
Composant FileViewer Principal
// screens/FileViewer.js
const PREVIEW_FROM_SIZE = 10485760; // 10 MB
function FileDetails({snapshotId, path, fileDetails}) {
const dispatch = useDispatch();
const apiUrl = useSelector(selectApiUrl, shallowEqual);
let [preview, setPreview] = useState(false);
React.useEffect(() => {
dispatch(fetchPath(apiUrl, snapshotId, path, 1, 1));
}, [dispatch, apiUrl, snapshotId, path]);
// Gestion des gros fichiers avec avertissement
if (fileDetails && fileDetails.byteSize > PREVIEW_FROM_SIZE && !preview) {
return (
<Card variant="outlined">
<CardContent>
<Typography>This is a very large file...</Typography>
<Typography>The preview has been disabled to prevent unexpected performance issues.</Typography>
</CardContent>
<CardActions>
<Button onClick={handlePreview}>Preview Anyway</Button>
<Button onClick={handleDownloadFile}>Download Raw File</Button>
</CardActions>
</Card>
);
}
// Sélection du viewer selon le type MIME
return fileDetails && (() => {
switch (fileDetails.mimeType) {
case 'text/javascript':
case 'text/plain':
return <TextFileViewer/>
case 'image/jpeg':
return <ImageFileViewer />
case 'video/mp4':
return <VideoFileViewer />
case 'audio/mp3':
return <AudioFileViewer />
default:
return <UnsupportedFileViewer/>
}
})();
}
Viewer de Texte avec Coloration Syntaxique
Le viewer de texte intègre DOMPurify pour la sécurité et react-syntax-highlighter pour la coloration :
// components/fileviewer/TextFileViewer.js
import DOMPurify from 'dompurify';
import {Prism as SyntaxHighlighter} from 'react-syntax-highlighter';
import {a11yDark} from 'react-syntax-highlighter/dist/esm/styles/prism';
const loadFile = (url, callback) => {
fetch(url)
.then((r) => r.text())
.then((rawText) => {
const sanitizedContent = DOMPurify.sanitize(rawText);
callback(sanitizedContent);
});
}
function TextFileViewer({fileDetails}) {
const [text, setText] = useState('Loading...');
const [showRaw, setShowRaw] = useState(false);
React.useEffect(() => {
loadFile(fileDetails.rawPath, setText);
}, [fileDetails.rawPath]);
return (
<Stack>
{/* Boutons flottants pour Raw/Copy/Download */}
<FloatingBox>
<Button onClick={() => setShowRaw(!showRaw)}>Raw</Button>
<Button onClick={handleCopyToClipboard}><ContentCopyIcon/></Button>
<Button onClick={handleDownloadClick}><DownloadIcon/></Button>
</FloatingBox>
{/* Affichage du contenu */}
{showRaw ? (
<pre>{text}</pre>
) : (
<SyntaxHighlighter
showLineNumbers={true}
language={fileDetails.mimeType.split('/')[1]}
style={a11yDark}>
{text}
</SyntaxHighlighter>
)}
</Stack>
);
}
State Management avec Redux Classique et Redux-Persist
L'application utilise Redux dans sa forme classique avec redux-thunk pour les actions asynchrones et redux-persist pour la persistance :
Configuration du Store
// utils/Store.js
import {legacy_createStore as createStore, applyMiddleware, combineReducers, compose} from 'redux';
import thunk from 'redux-thunk';
import {persistStore, persistReducer} from 'redux-persist';
import storage from 'redux-persist/lib/storage';
import {reducer as formReducer} from 'redux-form';
import {snapshotsReducer, confReducer, pathViewReducer, searchReducer} from '../state/Root';
import {createRouterMiddleware, createRouterReducer} from "@lagunovsky/redux-react-router";
const rootReducer = combineReducers({
form: formReducer,
snapshots: snapshotsReducer,
pathView: pathViewReducer,
conf: confReducer,
navigator: createRouterReducer(history),
search: searchReducer,
});
const persistConfig = {
key: 'plakar_state',
storage,
};
const persistedReducer = persistReducer(persistConfig, rootReducer);
const enhancers = compose(
applyMiddleware(createRouterMiddleware(history), thunk),
);
export const store = createStore(persistedReducer, {}, enhancers);
export const persistor = persistStore(store);
Reducers et Actions
Les reducers suivent le pattern Redux classique avec switch statements :
// state/Root.js
const initialState = {
snapshotsPage: null,
loading: false,
error: null,
};
export const snapshotsReducer = (state = initialState, action) => {
switch (action.type) {
case 'FETCH_SNAPSHOTS_REQUESTS':
return {...state, loading: true};
case 'FETCH_SNAPSHOTS_SUCCESS':
return {...state, loading: false, snapshotsPage: action.payload};
case 'FETCH_SNAPSHOTS_FAILURE':
return {...state, loading: false, error: action.error};
default:
return state;
}
};
// Action creator avec redux-thunk
export const fetchSnapshots = (apiUrl, page = 1, pageSize = 10) => async dispatch => {
dispatch({type: 'FETCH_SNAPSHOTS_REQUESTS'});
try {
await fetchSnapshotsPathWithApiClient(apiUrl, page, pageSize).then((data) => {
dispatch({type: 'FETCH_SNAPSHOTS_SUCCESS', payload: data});
});
} catch (error) {
dispatch({type: 'FETCH_SNAPSHOTS_FAILURE', error});
}
};
Gestion du Path View
const pathViewState = {
snapshot: {id: null},
path: null,
items: [],
page: 1,
pageSize: 10,
totalPages: 1,
loading: false,
error: null,
}
export const pathViewReducer = (state = pathViewState, action) => {
switch (action.type) {
case 'FETCH_PATH_REQUEST':
return {
...state,
loading: true,
snapshot: {id: action.payload.snapshotId},
items: [],
path: action.payload.path,
};
case 'FETCH_PATH_SUCCESS':
return {
...state,
loading: false,
snapshot: action.payload.snapshot,
path: action.payload.path,
items: action.payload.items,
};
case 'FETCH_PATH_FAILURE':
return {...state, loading: false, error: action.error, items: []};
default:
return state;
}
};
Composants UI et Layouts
L'application utilise Material-UI avec une architecture de layouts réutilisables :
Layout à Deux Colonnes
// layouts/TwoColumnLayout.js
function TwoColumnLayout({leftComponent, rightComponent}) {
return (
<Grid container>
<Grid item xs={8}>
{leftComponent}
</Grid>
<Grid item xs={4}>
{rightComponent}
</Grid>
</Grid>
);
}
Composants Stylisés Personnalisés
// components/StyledTableCell.js et StyledTableRow.js
// Composants Material-UI personnalisés pour les tables
// components/FileBreadcrumb.js
// Système de breadcrumb pour la navigation dans les dossiers
// components/SearchBar.js
// Barre de recherche intégrée avec Redux
// components/ConfigShield.js
// Protection de configuration
Tests Unitaires
Contrairement à une approche sans tests, le projet inclut plusieurs fichiers de tests :
// Tests existants dans le projet:
- utils/DataGenerator.test.js
- utils/Path.test.js
- utils/DemoRepo.test.js
- App.test.js
Ces tests valident les fonctionnalités critiques du générateur de données et des utilitaires de chemins.
Sécurité avec DOMPurify
Le viewer de texte utilise DOMPurify pour sanitiser le contenu avant affichage :
// components/fileviewer/TextFileViewer.js
import DOMPurify from 'dompurify';
const loadFile = (url, callback) => {
fetch(url)
.then((r) => r.text())
.then((rawText) => {
const sanitizedContent = DOMPurify.sanitize(rawText);
callback(sanitizedContent);
});
}
Stack Technique
Dependencies Principales (package.json)
{
"name": "plakar-ui",
"version": "2.0.0",
"dependencies": {
"@faker-js/faker": "^8.2.0",
"@lagunovsky/redux-react-router": "^4.3.0",
"@mui/icons-material": "^5.14.16",
"@mui/lab": "^5.0.0-alpha.150",
"@mui/material": "^5.14.15",
"@mui/styled-engine-sc": "^6.0.0-alpha.3",
"@reduxjs/toolkit": "^1.9.7",
"dompurify": "^3.0.6",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-redux": "^8.1.3",
"react-router-dom": "^6.17.0",
"react-syntax-highlighter": "^15.5.0",
"redux": "^4.2.1",
"redux-form": "^8.3.10",
"redux-persist": "^6.0.0",
"redux-thunk": "^2.4.2",
"styled-components": "^6.1.0"
}
}
Note: Bien que @reduxjs/toolkit soit présent dans les dépendances, le code utilise actuellement Redux classique avec legacy_createStore
.
Architecture des Dossiers
plakar/ui/v2/frontend/src/
├── components/ # Composants réutilisables
│ ├── fileviewer/ # Viewers spécialisés (lowercase)
│ │ ├── AudioFileViewer.js
│ │ ├── ImageFileViewer.js
│ │ ├── TextFileViewer.js
│ │ ├── VideoFileViewer.js
│ │ └── UnsupportedFileViewer.js
│ ├── FileBreadcrumb.js
│ ├── SearchBar.js
│ ├── ConfigShield.js
│ └── StyledTableCell.js
├── pages/ # Composants de pages/routes
│ ├── Config.js
│ ├── Explorer.js
│ ├── SearchResults.js
│ ├── SnapshotList.js
│ └── Welcome.js
├── screens/ # Écrans feature-specific
│ ├── FileDetails.js
│ ├── FileViewer.js
│ ├── PathList.js
│ └── SnapshotDetails.js
├── layouts/ # Templates de mise en page
│ ├── DefaultLayout.js
│ ├── SingleScreenLayout.js
│ └── TwoColumnLayout.js
├── state/ # Redux store
│ └── Root.js # Reducers et actions
├── utils/ # Utilitaires et helpers
│ ├── BrowserInteraction.js
│ ├── DataGenerator.js
│ ├── DemoRepo.js
│ ├── Path.js
│ ├── PlakarApiClient.js
│ ├── Routes.js
│ └── Store.js
└── Theme.js # Configuration du thème Material-UI
Points Clés de l'Architecture
Ce qui est réellement implémenté
- Mode Demo complet : L'application fonctionne entièrement sur des données mockées via DemoRepo.js
- Redux classique : Utilisation de
legacy_createStore
avec redux-thunk et redux-persist - Material-UI : Framework UI principal avec composants personnalisés
- Viewers spécialisés : Support pour texte, images, vidéos et audio avec preview conditionnelle
- Tests unitaires : Présence de tests pour les utilitaires critiques
- Sécurisation : DOMPurify pour la sanitisation du contenu texte
Architecture Simplifiée
L'architecture actuelle privilégie la simplicité avec :
- Routing direct sans lazy loading
- Redux classique plutôt que Redux Toolkit
- Fonctions exportées plutôt que classes pour le mock API
- Connection directe des composants avec
connect()
plutôt que les hooks Redux
Si je devais le refaire aujourd'hui
Un an après avoir développé cette interface, avec le recul et l'évolution du projet Plakar, mon approche serait radicalement différente. Je choisirais HTMX plutôt que React, et voici pourquoi :
Alignement avec l'équipe
Plakar est principalement développé par des développeurs Go. HTMX permettrait de :
- Minimiser le changement de contexte : Les développeurs Go peuvent continuer à travailler dans leur langage de prédilection
- Réduire la courbe d'apprentissage : Pas besoin de maîtriser React, Redux, et l'écosystème JavaScript moderne
- Unifier les compétences : Une seule équipe peut maintenir frontend et backend
Architecture simplifiée
Avec HTMX, l'architecture serait considérablement plus simple :
<!-- Au lieu de Redux + React Router + composants complexes -->
<!-- Simple HTML avec attributs HTMX -->
<div hx-get="/api/snapshots"
hx-trigger="load"
hx-target="#snapshot-list">
Loading snapshots...
</div>
<div id="file-browser"
hx-get="/api/browse"
hx-push-url="true">
<!-- Le serveur Go renvoie directement le HTML -->
</div>
Avantages de l'approche HTMX
- Pas de gestion d'état client : Tout l'état est géré côté serveur, éliminant la complexité de Redux
- Pas de build process : Fini webpack, npm, et les dépendances JavaScript
- HTML as the engine of application state : Retour aux fondamentaux du web
- Interactions riches préservées : HTMX permet les mises à jour partielles, les transitions, et une UX moderne
- Performance prédictible : Pas de bundle JavaScript de plusieurs MB à charger
Exemple de migration
Le FileViewer actuel en React :
// ~200 lignes de code JavaScript
function FileViewer({snapshotId, path, fileDetails}) {
// État local, effects, callbacks...
return <ComplexComponent />;
}
Deviendrait en HTMX :
// Handler Go simple
func FileViewerHandler(w http.ResponseWriter, r *http.Request) {
file := getFile(r.URL.Query().Get("path"))
tmpl.ExecuteTemplate(w, "file-viewer.html", file)
}
<!-- Template HTML -->
<div class="file-viewer"
hx-get="/api/file/{{ .Path }}/content"
hx-trigger="revealed">
{{ if .IsText }}
<pre><code>{{ .Content }}</code></pre>
{{ else if .IsImage }}
<img src="{{ .URL }}" />
{{ end }}
</div>
Le pragmatisme avant tout
Cette approche HTMX alignerait parfaitement la technologie avec :
- Les compétences de l'équipe
- La philosophie Go de simplicité
- Les besoins réels du projet
- La maintenance à long terme
L'interface actuelle fonctionne, mais elle représente une dette technique pour une équipe Go. HTMX offrirait la même richesse d'interaction avec une fraction de la complexité.
Conclusion
Plakar UI v2 représente une approche pragmatique du développement d'interface pour un système de backup. L'architecture Demo-First permet un développement et des démonstrations sans infrastructure backend, facilitant l'itération rapide sur l'expérience utilisateur.
Les choix techniques - Redux classique, Material-UI, et l'absence de TypeScript - reflètent une phase exploratoire du projet où la flexibilité prime sur la complexité. Le système de mock API avec localStorage assure la persistance des données de démonstration, offrant une expérience utilisateur cohérente pour les tests et les présentations.
Le code est disponible sur GitHub pour ceux qui souhaitent explorer l'implémentation en détail.