Plakar UI v2 : Architecture d'une Interface React Moderne pour un Système de Backup

Jérémie Poutrin

Jérémie Poutrin

23 septembre 2025 • il y a 3 jours

Plakar UI v2 : Architecture d'une Interface React Moderne pour un Système de Backup

Découvrez l'architecture React de Plakar UI v2, une interface moderne pour système de backup. Cet article détaille l'implémentation d'une navigation temporelle dans des
snapshots, un visualiseur de fichiers polymorphe, l'utilisation de Redux Toolkit, et une approche "Demo-First" permettant un développement découplé. Focus sur les choix
architecturaux pragmatiques, incluant la décision délibérée de reporter les tests unitaires en phase de prototype.

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é

  1. Mode Demo complet : L'application fonctionne entièrement sur des données mockées via DemoRepo.js
  2. Redux classique : Utilisation de legacy_createStore avec redux-thunk et redux-persist
  3. Material-UI : Framework UI principal avec composants personnalisés
  4. Viewers spécialisés : Support pour texte, images, vidéos et audio avec preview conditionnelle
  5. Tests unitaires : Présence de tests pour les utilitaires critiques
  6. 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

  1. Pas de gestion d'état client : Tout l'état est géré côté serveur, éliminant la complexité de Redux
  2. Pas de build process : Fini webpack, npm, et les dépendances JavaScript
  3. HTML as the engine of application state : Retour aux fondamentaux du web
  4. Interactions riches préservées : HTMX permet les mises à jour partielles, les transitions, et une UX moderne
  5. 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.


Publié le 23 septembre 2025

Mis à jour le 26 septembre 2025