Author SHA1 Message Date
Antho b5e4741178 merge: intègre fix/tracabilite-build dans dev
Build & Deploy / build (push) Successful in 23s
2026-08-15 14:47:00 +02:00
Antho 4fd0f5dd37 chore(ci): expose le SHA du build sur /version.json
Rien ne permettait de vérifier quelle version du front était réellement
servie : un déploiement non appliqué était indiscernable d'un déploiement
réussi, d'autant que le cache navigateur masque le problème.

La CI injecte désormais le SHA du commit au build, écrit dans
dist/version.json et servi par nginx. Le pendant côté API est le champ
"build" de GET /health.
2026-08-15 14:47:00 +02:00
Antho 58674dde4d merge: intègre refactor/normes-xefi-admin-crud dans dev
Build & Deploy / build (push) Successful in 18s
- refactor(front): centralise la règle de statut et les colonnes dupliquées
- chore(front): extrait KpiCard, unifie la clé de session, retire du code mort
- feat(front): rend l'écran d'administration réellement fonctionnel
- fix(front): corrige les 4 erreurs eslint restantes
2026-08-15 14:32:31 +02:00
Antho 6ad76ca773 fix(front): corrige les 4 erreurs eslint restantes
Le dépôt sortait 7 erreurs eslint au début de cette série de correctifs,
dont certaines antérieures. Il n'en reste aucune.

- Dashboard et Users appelaient setState de façon synchrone dans un
  effet, ce qui provoque des rendus en cascade. Le chargement initial
  passe par une fonction asynchrone qui n'écrit qu'après l'await, avec
  annulation au démontage. Sur le Dashboard, l'indicateur de chargement
  est allumé par les gestionnaires d'événement, et le bouton Actualiser
  relance l'effet via un compteur plutôt qu'en appelant le chargement.
- L'import ExternalLink de Documentation.jsx n'était pas utilisé.
- AuthContext.jsx exportait à la fois un composant, le contexte et un
  hook, ce qui désactivait le rafraîchissement à chaud de Vite pour ce
  fichier. Le contexte et le hook vivent désormais dans leurs propres
  modules ; les 5 fichiers concernés sont mis à jour.
2026-08-15 14:32:02 +02:00
Antho 82a1b93211 feat(front): rend l'écran d'administration réellement fonctionnel
Les boutons Modifier et Désactiver de la page Administration
n'appelaient rien : handleDelete se contentait d'un console.log et le
bouton Enregistrer de la modale ne faisait que la refermer. L'écran
donnait l'illusion d'un CRUD.

Branche les 4 onglets sur les endpoints POST/PUT/DELETE ajoutés côté
API, avec confirmation avant suppression, rechargement après
enregistrement et remontée des erreurs de l'API (dont le refus 409
quand des enregistrements sont rattachés).

Les 4 blocs de colonnes de tableau, quasi identiques d'un onglet à
l'autre, laissent place à une description déclarative
(referentielsConfig.js) : un seul tableau et un seul formulaire
générique (ReferentielForm) servent les 4 référentiels. Les colonnes
qui portaient un identifiant brut affichent maintenant le libellé.

La modale, jusqu'ici déclarée dans Admin.jsx, devient un composant
commun réutilisable.
2026-08-15 14:29:29 +02:00
Antho ddd0197130 chore(front): extrait KpiCard, unifie la clé de session, retire du code mort
- KpiCard était déclaré dans Dashboard.jsx : sorti dans son propre
  fichier avec ses styles, les règles CSS devenues orphelines sont
  supprimées de Dashboard.module.css.
- La clé localStorage 'ds_user' était définie séparément dans api.js
  (USER_KEY) et AuthContext.jsx (STORAGE_KEY). AuthContext importe
  désormais la constante exportée par api.js : un renommage ne peut
  plus casser silencieusement la restauration de session.
- hooks/useApi.js n'était importé nulle part : supprimé.
2026-08-15 14:29:18 +02:00
Antho 8b8121ca1c refactor(front): centralise la règle de statut et les colonnes dupliquées
Le seuil de 10 erreurs qui fait basculer un monitoring en « Critique »
était réécrit dans trois fichiers (MonitoringCard, MonitoringDetail,
VueConso), la version de VueConso ne renvoyant qu'une couleur. Faire
évoluer ce seuil imposait de corriger trois endroits sans en oublier.
Il vit maintenant dans utils/statutMonitoring.js, avec la constante
SEUIL_ATTENTION nommée.

Les colonnes du tableau de monitorings étaient également identiques
entre VueConso et ServiceMonitorings : extraites dans
utils/monitoringColumns.jsx.
2026-08-15 14:29:09 +02:00
Antho 270283e96d merge: intègre feature/dashboard-filtres-et-historique dans dev
Build & Deploy / build (push) Successful in 36s
- feat(dashboard): filtres services/catégories restreints aux résultats
- fix(historique): période par défaut du 1er janvier à aujourd'hui
2026-08-15 13:35:50 +02:00
30 changed files with 644 additions and 390 deletions
+1
View File
@@ -23,6 +23,7 @@ jobs:
set -e set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker build --build-arg VITE_API_URL="${VITE_API_URL}" \ docker build --build-arg VITE_API_URL="${VITE_API_URL}" \
--build-arg APP_BUILD="${GITHUB_SHA::12}" \
-t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel -t "${IMAGE}:latest" -t "${IMAGE}:${GITHUB_SHA::12}" ./DataSentinel
docker push --all-tags "${IMAGE}" docker push --all-tags "${IMAGE}"
echo "pushed ${IMAGE}" echo "pushed ${IMAGE}"
+5
View File
@@ -11,6 +11,11 @@ ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build RUN pnpm build
# SHA du commit construit, servi sur /version.json : permet de vérifier quelle
# version est réellement déployée sans se fier au cache du navigateur.
ARG APP_BUILD=local
RUN printf '{"build":"%s"}' "$APP_BUILD" > /app/dist/version.json
FROM nginx:alpine FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf COPY nginx.conf /etc/nginx/conf.d/default.conf
+2 -1
View File
@@ -1,7 +1,8 @@
// src/App.jsx // src/App.jsx
// Configuration du routing React Router v6 avec protection des routes // Configuration du routing React Router v6 avec protection des routes
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from './context/AuthContext'; import { AuthProvider } from './context/AuthContext';
import { useAuth } from './context/useAuth';
import Layout from './components/Layout/Layout'; import Layout from './components/Layout/Layout';
import Login from './pages/Login/Login'; import Login from './pages/Login/Login';
import Dashboard from './pages/Dashboard/Dashboard'; import Dashboard from './pages/Dashboard/Dashboard';
@@ -1,7 +1,8 @@
/* Tests du contexte d'authentification : login / logout */ /* Tests du contexte d'authentification : login / logout */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { renderHook, act } from '@testing-library/react'; import { renderHook, act } from '@testing-library/react';
import { AuthProvider, useAuth } from '../context/AuthContext'; import { AuthProvider } from '../context/AuthContext';
import { useAuth } from '../context/useAuth';
import * as api from '../services/api'; import * as api from '../services/api';
describe('AuthContext', () => { describe('AuthContext', () => {
@@ -1,7 +1,7 @@
/* En-tête fixe de l'application Data Sentinel */ /* En-tête fixe de l'application Data Sentinel */
import { Bell, LogOut, Menu } from 'lucide-react'; import { Bell, LogOut, Menu } from 'lucide-react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import styles from './Header.module.css'; import styles from './Header.module.css';
@@ -13,7 +13,7 @@ import {
FileText, FileText,
ScrollText, ScrollText,
} from 'lucide-react'; } from 'lucide-react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import styles from './Sidebar.module.css'; import styles from './Sidebar.module.css';
/* Un lien de navigation avec icône */ /* Un lien de navigation avec icône */
@@ -0,0 +1,10 @@
import styles from './KpiCard.module.css';
export default function KpiCard({ label, value, color }) {
return (
<div className={styles.kpiCard}>
<span className={styles.kpiValue} style={{ color }}>{value}</span>
<span className={styles.kpiLabel}>{label}</span>
</div>
);
}
@@ -0,0 +1,28 @@
.kpiCard {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: var(--color-white);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.kpiValue {
font-size: 36px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
}
.kpiLabel {
font-size: 12px;
color: var(--color-muted, #888);
text-align: center;
}
@media (max-width: 768px) {
.kpiValue {
font-size: 28px;
}
}
@@ -0,0 +1,19 @@
import styles from './Modal.module.css';
export default function Modal({ isOpen, onClose, title, children }) {
if (!isOpen) return null;
return (
<div className={styles.modalOverlay} onClick={onClose}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<div className={styles.modalHeader}>
<h2>{title}</h2>
<button className={styles.closeBtn} onClick={onClose} aria-label="Fermer">×</button>
</div>
<div className={styles.modalBody}>
{children}
</div>
</div>
</div>
);
}
@@ -0,0 +1,49 @@
.modalOverlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background-color: var(--color-white);
border-radius: var(--radius);
border-top: 4px solid var(--color-primary);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-gray-border);
}
.modalHeader h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text);
}
.closeBtn {
background: none;
border: none;
font-size: 24px;
color: var(--color-text-secondary);
cursor: pointer;
}
.modalBody {
padding: 24px;
}
@@ -2,20 +2,14 @@
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import Badge from './Badge'; import Badge from './Badge';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import styles from './MonitoringCard.module.css'; import styles from './MonitoringCard.module.css';
/* Détermine le statut visuel selon le nombre d'erreurs */
function getStatut(nbErreurs) {
if (nbErreurs === 0) return { label: 'OK', variant: 'ok', color: 'var(--color-ok)' };
if (nbErreurs < 10) return { label: 'Attention', variant: 'warning', color: 'var(--color-warning)' };
return { label: 'Critique', variant: 'critical', color: 'var(--color-primary)' };
}
export default function MonitoringCard({ monitoring }) { export default function MonitoringCard({ monitoring }) {
const navigate = useNavigate(); const navigate = useNavigate();
const nbErreurs = monitoring.nb_erreurs ?? monitoring.count ?? 0; const nbErreurs = monitoring.nb_erreurs ?? monitoring.count ?? 0;
const monitoringId = monitoring.id ?? monitoring.id_monito; const monitoringId = monitoring.id ?? monitoring.id_monito;
const statut = getStatut(nbErreurs); const statut = getStatutMonitoring(nbErreurs);
/* Calcul du pourcentage de la barre de progression (max 100%) */ /* Calcul du pourcentage de la barre de progression (max 100%) */
const progressPct = Math.min((nbErreurs / 20) * 100, 100); const progressPct = Math.min((nbErreurs / 20) * 100, 100);
+6 -16
View File
@@ -1,17 +1,15 @@
/* Contexte d'authentification global : gestion de l'utilisateur connecté */ /* Contexte d'authentification global : gestion de l'utilisateur connecté */
import { createContext, useContext, useState } from 'react'; import { useState } from 'react';
import * as api from '../services/api'; import * as api from '../services/api';
import { USER_KEY } from '../services/api';
const AuthContext = createContext(null); import { AuthContext } from './authContextObject';
const STORAGE_KEY = 'ds_user';
/* Fournisseur du contexte d'authentification */ /* Fournisseur du contexte d'authentification */
export function AuthProvider({ children }) { export function AuthProvider({ children }) {
const [user, setUser] = useState(() => { const [user, setUser] = useState(() => {
/* Restauration de la session depuis le localStorage au montage */ /* Restauration de la session depuis le localStorage au montage */
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(USER_KEY);
return stored ? JSON.parse(stored) : null; return stored ? JSON.parse(stored) : null;
}); });
@@ -19,7 +17,7 @@ export function AuthProvider({ children }) {
async function login(username, password) { async function login(username, password) {
const connectedUser = await api.login(username, password); const connectedUser = await api.login(username, password);
setUser(connectedUser); setUser(connectedUser);
localStorage.setItem(STORAGE_KEY, JSON.stringify(connectedUser)); localStorage.setItem(USER_KEY, JSON.stringify(connectedUser));
return connectedUser; return connectedUser;
} }
@@ -27,7 +25,7 @@ export function AuthProvider({ children }) {
function logout() { function logout() {
api.clearToken(); api.clearToken();
setUser(null); setUser(null);
localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(USER_KEY);
} }
/* Booléen pratique pour savoir si l'utilisateur est connecté */ /* Booléen pratique pour savoir si l'utilisateur est connecté */
@@ -40,11 +38,3 @@ export function AuthProvider({ children }) {
); );
} }
/* Hook personnalisé pour accéder au contexte d'authentification */
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
}
@@ -0,0 +1,7 @@
/* Objet de contexte isolé : un fichier qui exporte un composant ne doit
exporter que des composants, sans quoi le rafraîchissement à chaud de Vite
(react-refresh) cesse de fonctionner pour ce fichier. */
import { createContext } from 'react';
export const AuthContext = createContext(null);
+15
View File
@@ -0,0 +1,15 @@
/* Hook d'accès au contexte d'authentification.
Séparé de AuthContext.jsx : un fichier qui exporte à la fois un composant
et autre chose casse le rafraîchissement à chaud de Vite (react-refresh). */
import { useContext } from 'react';
import { AuthContext } from './authContextObject';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth doit être utilisé dans un AuthProvider');
}
return context;
}
-33
View File
@@ -1,33 +0,0 @@
/* Hook générique pour les appels API : retourne { data, loading, error, refetch } */
import { useState, useEffect, useCallback } from 'react';
/**
* @param {Function} fetchFn - Fonction async à appeler (depuis api.js)
* @param {Array} deps - Dépendances qui déclenchent un re-fetch (optionnel)
*/
export function useApi(fetchFn, deps = []) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetch = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await fetchFn();
setData(result);
} catch (err) {
setError(err.message || 'Une erreur est survenue.');
} finally {
setLoading(false);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);
useEffect(() => {
fetch();
}, [fetch]);
return { data, loading, error, refetch: fetch };
}
+130 -173
View File
@@ -1,229 +1,186 @@
// src/pages/Admin/Admin.jsx // src/pages/Admin/Admin.jsx
// Page Administration : onglets pour gérer monitorings, services, catégories, contacts // Page Administration : CRUD sur les référentiels (monitorings, services,
import { useState, useEffect } from 'react'; // catégories, contacts). Réservée au rôle Admin côté API.
import { useState, useEffect, useCallback } from 'react';
import { Plus, Edit, Trash2 } from 'lucide-react'; import { Plus, Edit, Trash2 } from 'lucide-react';
import { getMonitorings, getServices, getCategories, getContacts } from '../../services/api'; import { getMonitorings, getServices, getCategories, getContacts } from '../../services/api';
import DataTable from '../../components/common/DataTable'; import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import Modal from '../../components/common/Modal';
import ReferentielForm from './ReferentielForm';
import { REFERENTIELS, LIBELLES_REFERENTIEL } from './referentielsConfig';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
/* Modale générique pour ajouter/modifier */ async function chargerReferentiels() {
function Modal({ isOpen, onClose, title, children }) { const [monitorings, services, categories, contacts] = await Promise.all([
if (!isOpen) return null; getMonitorings(),
getServices(),
return ( getCategories(),
<div className={styles.modalOverlay} onClick={onClose}> getContacts(),
<div className={styles.modal} onClick={e => e.stopPropagation()}> ]);
<div className={styles.modalHeader}> return {
<h2>{title}</h2> monitorings: Array.isArray(monitorings) ? monitorings : [],
<button className={styles.closeBtn} onClick={onClose}>×</button> services: Array.isArray(services) ? services : [],
</div> categories: Array.isArray(categories) ? categories : [],
<div className={styles.modalBody}> contacts: Array.isArray(contacts) ? contacts : [],
{children} };
</div>
</div>
</div>
);
} }
export default function Admin() { export default function Admin() {
const [activeTab, setActiveTab] = useState('monitorings'); const [ongletActif, setOngletActif] = useState(REFERENTIELS[0].key);
const [data, setData] = useState({}); const [donnees, setDonnees] = useState({});
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [modaleOuverte, setModaleOuverte] = useState(false);
const [editingItem, setEditingItem] = useState(null); const [elementEnEdition, setElementEnEdition] = useState(null);
useEffect(() => { const recharger = useCallback(async () => {
async function loadData() { setErreur('');
setLoading(true); try {
setErreur(''); setDonnees(await chargerReferentiels());
try { } catch (e) {
const [monitorings, services, categories, contacts] = await Promise.all([ setErreur(e.message || 'Erreur lors du chargement des données administratives.');
getMonitorings(),
getServices(),
getCategories(),
getContacts()
]);
setData({
monitorings: Array.isArray(monitorings) ? monitorings : [],
services: Array.isArray(services) ? services : [],
categories: Array.isArray(categories) ? categories : [],
contacts: Array.isArray(contacts) ? contacts : []
});
} catch (e) {
setErreur(e.message || 'Erreur lors du chargement des données administratives.');
} finally {
setLoading(false);
}
} }
loadData();
}, []); }, []);
function handleAdd() { useEffect(() => {
setEditingItem(null); let annule = false;
setModalOpen(true); async function chargerAuMontage() {
try {
const resultat = await chargerReferentiels();
if (!annule) setDonnees(resultat);
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement des données administratives.');
} finally {
if (!annule) setLoading(false);
}
}
chargerAuMontage();
return () => { annule = true; };
}, []);
const referentiel = REFERENTIELS.find(r => r.key === ongletActif);
function ouvrirCreation() {
setElementEnEdition(null);
setModaleOuverte(true);
} }
function handleEdit(item) { function ouvrirEdition(element) {
setEditingItem(item); setElementEnEdition(element);
setModalOpen(true); setModaleOuverte(true);
} }
function handleDelete(item) { async function handleEnregistrer(corps) {
// Simulation de suppression (pas d'API pour ça) if (elementEnEdition) {
console.log('Supprimer', item); await referentiel.modifier(elementEnEdition[referentiel.idField], corps);
} else {
await referentiel.creer(corps);
}
setModaleOuverte(false);
await recharger();
} }
const tabs = [ async function handleSupprimer(element) {
{ key: 'monitorings', label: 'Monitorings' }, const action = referentiel.suppressionEstDesactivation ? 'Désactiver' : 'Supprimer';
{ key: 'services', label: 'Services' }, const nom = element[referentiel.colonnes[0].key];
{ key: 'categories', label: 'Catégories' }, if (!window.confirm(`${action} « ${nom} » ?`)) return;
{ key: 'contacts', label: 'Contacts' }
setErreur('');
try {
await referentiel.supprimer(element[referentiel.idField]);
await recharger();
} catch (e) {
setErreur(e.message || 'La suppression a échoué.');
}
}
/* Affiche le libellé d'un référentiel lié plutôt que son identifiant brut. */
function libelleLie(nomReferentiel, id) {
const { idField, labelField } = LIBELLES_REFERENTIEL[nomReferentiel];
const trouve = (donnees[nomReferentiel] || []).find(item => item[idField] === id);
return trouve ? trouve[labelField] : id;
}
const colonnes = [
...referentiel.colonnes.map(colonne => ({
key: colonne.key,
label: colonne.label,
render: colonne.referentiel
? val => libelleLie(colonne.referentiel, val)
: undefined,
})),
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => ouvrirEdition(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleSupprimer(row)}>
<Trash2 size={14} />
{referentiel.suppressionEstDesactivation ? 'Désactiver' : 'Supprimer'}
</button>
</div>
),
},
]; ];
const columns = {
monitorings: [
{ key: 'monito_intitule', label: 'Nom' },
{ key: 'id_service', label: 'Service' },
{ key: 'id_categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'table_source', label: 'Table' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
services: [
{ key: 'nom_service', label: 'Nom' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
categories: [
{ key: 'intitule_categorie', label: 'Nom' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
],
contacts: [
{ key: 'intitule_contact', label: 'Intitulé' },
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'mail', label: 'Email' },
{ key: 'id_service', label: 'Service' },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<div className={styles.actions}>
<button className={styles.actionBtn} onClick={() => handleEdit(row)}>
<Edit size={14} />
Modifier
</button>
<button className={styles.actionBtn} onClick={() => handleDelete(row)}>
<Trash2 size={14} />
Désactiver
</button>
</div>
)
}
]
};
if (loading) return <LoadingSpinner message="Chargement des données administratives..." />; if (loading) return <LoadingSpinner message="Chargement des données administratives..." />;
if (erreur) return <ErrorMessage message={erreur} />;
return ( return (
<div className={styles.page}> <div className={styles.page}>
<h1 className={styles.title}>Administration</h1> <h1 className={styles.title}>Administration</h1>
{/* Onglets */}
<div className={styles.tabs}> <div className={styles.tabs}>
{tabs.map(tab => ( {REFERENTIELS.map(item => (
<button <button
key={tab.key} key={item.key}
className={`${styles.tab} ${activeTab === tab.key ? styles.active : ''}`} className={`${styles.tab} ${ongletActif === item.key ? styles.active : ''}`}
onClick={() => setActiveTab(tab.key)} onClick={() => setOngletActif(item.key)}
> >
{tab.label} {item.label}
</button> </button>
))} ))}
</div> </div>
{/* Contenu de l'onglet actif */} {erreur && <ErrorMessage message={erreur} />}
<div className={styles.tabContent}> <div className={styles.tabContent}>
<div className={styles.header}> <div className={styles.header}>
<h2>{tabs.find(t => t.key === activeTab)?.label}</h2> <h2>{referentiel.label}</h2>
<Button variant="primary" onClick={handleAdd}> <Button variant="primary" onClick={ouvrirCreation}>
<Plus size={14} /> <Plus size={14} />
Ajouter Ajouter
</Button> </Button>
</div> </div>
<DataTable <DataTable
columns={columns[activeTab]} columns={colonnes}
data={data[activeTab] || []} data={donnees[ongletActif] || []}
pageSize={50} pageSize={50}
emptyMessage={`Aucun ${activeTab.slice(0, -1)} trouvé.`} emptyMessage={`Aucun ${referentiel.singulier} trouvé.`}
/> />
</div> </div>
{/* Modale */}
<Modal <Modal
isOpen={modalOpen} isOpen={modaleOuverte}
onClose={() => setModalOpen(false)} onClose={() => setModaleOuverte(false)}
title={`${editingItem ? 'Modifier' : 'Ajouter'} ${tabs.find(t => t.key === activeTab)?.label.slice(0, -1)}`} title={`${elementEnEdition ? 'Modifier' : 'Ajouter'} ${referentiel.singulier}`}
> >
<p>Formulaire à implémenter selon les besoins.</p> <ReferentielForm
<div className={styles.modalActions}> /* Remonte le formulaire à zéro quand on change d'onglet ou d'élément */
<Button variant="secondary" onClick={() => setModalOpen(false)}> key={`${referentiel.key}-${elementEnEdition?.[referentiel.idField] ?? 'nouveau'}`}
Annuler referentiel={referentiel}
</Button> valeurInitiale={elementEnEdition}
<Button variant="primary" onClick={() => setModalOpen(false)}> donnees={donnees}
Enregistrer onEnregistrer={handleEnregistrer}
</Button> onAnnuler={() => setModaleOuverte(false)}
</div> />
</Modal> </Modal>
</div> </div>
); );
+20 -42
View File
@@ -83,55 +83,33 @@
color: var(--color-primary); color: var(--color-primary);
} }
/* Modale */ /* Formulaire de la modale (le conteneur vit dans Modal.module.css) */
.modalOverlay { .field {
position: fixed; margin-bottom: 16px;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
} }
.modal { .label {
background-color: var(--color-white); display: block;
border-radius: var(--radius); margin-bottom: 6px;
border-top: 4px solid var(--color-primary); font-size: 13px;
width: 90%; font-weight: 500;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modalHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 24px;
border-bottom: 1px solid var(--color-gray-border);
}
.modalHeader h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: var(--color-text); color: var(--color-text);
} }
.closeBtn { .input {
background: none; width: 100%;
border: none; padding: 8px 12px;
font-size: 24px; border: 1px solid var(--color-gray-border);
color: var(--color-text-secondary); border-radius: var(--radius);
cursor: pointer; font-size: 14px;
font-family: inherit;
color: var(--color-text);
background-color: var(--color-white);
} }
.modalBody { .input:focus {
padding: 24px; outline: 2px solid var(--color-primary);
outline-offset: -1px;
} }
.modalActions { .modalActions {
@@ -0,0 +1,91 @@
import { useState } from 'react';
import Button from '../../components/common/Button';
import ErrorMessage from '../../components/common/ErrorMessage';
import { LIBELLES_REFERENTIEL } from './referentielsConfig';
import styles from './Admin.module.css';
/* Formulaire générique piloté par la description des champs du référentiel
(voir referentielsConfig.js) : les 4 onglets partagent ce composant. */
export default function ReferentielForm({ referentiel, valeurInitiale, donnees, onEnregistrer, onAnnuler }) {
const [valeurs, setValeurs] = useState(() =>
Object.fromEntries(
referentiel.champs.map(champ => [champ.name, valeurInitiale?.[champ.name] ?? ''])
)
);
const [enCours, setEnCours] = useState(false);
const [erreur, setErreur] = useState('');
function handleChange(name, valeur) {
setValeurs(precedentes => ({ ...precedentes, [name]: valeur }));
}
async function handleSubmit(event) {
event.preventDefault();
setErreur('');
setEnCours(true);
try {
/* Les identifiants de référentiel arrivent en chaîne depuis <select> :
l'API attend des entiers. */
const corps = Object.fromEntries(
referentiel.champs.map(champ => [
champ.name,
champ.type === 'select' ? Number(valeurs[champ.name]) : valeurs[champ.name],
])
);
await onEnregistrer(corps);
} catch (e) {
setErreur(e.message || "L'enregistrement a échoué.");
} finally {
setEnCours(false);
}
}
return (
<form onSubmit={handleSubmit}>
{referentiel.champs.map(champ => (
<div key={champ.name} className={styles.field}>
<label className={styles.label} htmlFor={champ.name}>{champ.label}</label>
{champ.type === 'select' ? (
<select
id={champ.name}
className={styles.input}
value={valeurs[champ.name]}
onChange={e => handleChange(champ.name, e.target.value)}
required={champ.required}
>
<option value=""> Choisir </option>
{(donnees[champ.options] || []).map(option => {
const { idField, labelField } = LIBELLES_REFERENTIEL[champ.options];
return (
<option key={option[idField]} value={option[idField]}>
{option[labelField]}
</option>
);
})}
</select>
) : (
<input
id={champ.name}
type={champ.type}
className={styles.input}
value={valeurs[champ.name]}
onChange={e => handleChange(champ.name, e.target.value)}
required={champ.required}
/>
)}
</div>
))}
{erreur && <ErrorMessage message={erreur} />}
<div className={styles.modalActions}>
<Button variant="secondary" type="button" onClick={onAnnuler} disabled={enCours}>
Annuler
</Button>
<Button variant="primary" type="submit" disabled={enCours}>
{enCours ? 'Enregistrement…' : 'Enregistrer'}
</Button>
</div>
</form>
);
}
@@ -0,0 +1,89 @@
import {
createService, updateService, deleteService,
createCategorie, updateCategorie, deleteCategorie,
createContact, updateContact, deleteContact,
createMonitoring, updateMonitoring, desactiverMonitoring,
} from '../../services/api';
/* Description déclarative des 4 référentiels administrables.
Les colonnes du tableau, les champs du formulaire et les appels API sont
décrits ici plutôt que répétés onglet par onglet : les 4 onglets partagent
ainsi un seul tableau et un seul formulaire. */
export const REFERENTIELS = [
{
key: 'monitorings',
label: 'Monitorings',
singulier: 'monitoring',
idField: 'id_monito',
/* Désactivation logique : TABLE_FINAL référence le monitoring. */
suppressionEstDesactivation: true,
colonnes: [
{ key: 'monito_intitule', label: 'Nom' },
{ key: 'id_service', label: 'Service', referentiel: 'services' },
{ key: 'id_categorie', label: 'Catégorie', referentiel: 'categories' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'table_source', label: 'Table' },
],
champs: [
{ name: 'monito_intitule', label: 'Nom', type: 'text', required: true },
{ name: 'id_service', label: 'Service', type: 'select', options: 'services', required: true },
{ name: 'id_categorie', label: 'Catégorie', type: 'select', options: 'categories', required: true },
{ name: 'table_source', label: 'Table source', type: 'text', required: true },
{ name: 'bdd_source', label: 'Base source', type: 'text', required: true },
],
creer: createMonitoring,
modifier: updateMonitoring,
supprimer: desactiverMonitoring,
},
{
key: 'services',
label: 'Services',
singulier: 'service',
idField: 'id_service',
colonnes: [{ key: 'nom_service', label: 'Nom' }],
champs: [{ name: 'nom_service', label: 'Nom du service', type: 'text', required: true }],
creer: createService,
modifier: updateService,
supprimer: deleteService,
},
{
key: 'categories',
label: 'Catégories',
singulier: 'catégorie',
idField: 'id_categorie',
colonnes: [{ key: 'intitule_categorie', label: 'Nom' }],
champs: [{ name: 'intitule_categorie', label: 'Intitulé', type: 'text', required: true }],
creer: createCategorie,
modifier: updateCategorie,
supprimer: deleteCategorie,
},
{
key: 'contacts',
label: 'Contacts',
singulier: 'contact',
idField: 'id_contact',
colonnes: [
{ key: 'intitule_contact', label: 'Intitulé' },
{ key: 'nom', label: 'Nom' },
{ key: 'prenom', label: 'Prénom' },
{ key: 'mail', label: 'Email' },
{ key: 'id_service', label: 'Service', referentiel: 'services' },
],
champs: [
{ name: 'intitule_contact', label: 'Intitulé', type: 'text', required: true },
{ name: 'nom', label: 'Nom', type: 'text', required: true },
{ name: 'prenom', label: 'Prénom', type: 'text', required: true },
{ name: 'mail', label: 'Email', type: 'email', required: true },
{ name: 'id_service', label: 'Service', type: 'select', options: 'services', required: true },
],
creer: createContact,
modifier: updateContact,
supprimer: deleteContact,
},
];
/* Libellés des listes déroulantes et des colonnes qui affichent un identifiant. */
export const LIBELLES_REFERENTIEL = {
services: { idField: 'id_service', labelField: 'nom_service' },
categories: { idField: 'id_categorie', labelField: 'intitule_categorie' },
};
+41 -31
View File
@@ -4,20 +4,11 @@ import { useState, useEffect, useCallback, useMemo } from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api'; import { getDashboard, getDashboardSummary, getDashboardFiltres } from '../../services/api';
import MonitoringCard from '../../components/common/MonitoringCard'; import MonitoringCard from '../../components/common/MonitoringCard';
import KpiCard from '../../components/common/KpiCard';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import styles from './Dashboard.module.css'; import styles from './Dashboard.module.css';
/* Carte KPI affichant un indicateur clé */
function KpiCard({ label, value, color }) {
return (
<div className={styles.kpiCard}>
<span className={styles.kpiValue} style={{ color }}>{value}</span>
<span className={styles.kpiLabel}>{label}</span>
</div>
);
}
export default function Dashboard() { export default function Dashboard() {
const [summary, setSummary] = useState(null); const [summary, setSummary] = useState(null);
const [monitorings, setMonitorings] = useState([]); const [monitorings, setMonitorings] = useState([]);
@@ -27,6 +18,7 @@ export default function Dashboard() {
const [filtreCategorie, setFiltreCategorie] = useState(''); const [filtreCategorie, setFiltreCategorie] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [erreur, setErreur] = useState(''); const [erreur, setErreur] = useState('');
const [rafraichissement, setRafraichissement] = useState(0);
/* Chargement initial des filtres. /* Chargement initial des filtres.
On n'utilise pas les référentiels SERVICE / CATEGORIE complets : ils On n'utilise pas les référentiels SERVICE / CATEGORIE complets : ils
@@ -61,39 +53,57 @@ export default function Dashboard() {
/* Changer de service peut rendre la catégorie sélectionnée sans résultat : /* Changer de service peut rendre la catégorie sélectionnée sans résultat :
on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */ on la remet à zéro dans le même rendu, pour ne déclencher qu'un seul appel. */
function handleServiceChange(service) { function handleServiceChange(service) {
setLoading(true);
setFiltreService(service); setFiltreService(service);
if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) { if (filtreCategorie && !categoriesDisponibles(service).includes(filtreCategorie)) {
setFiltreCategorie(''); setFiltreCategorie('');
} }
} }
/* Chargement des données dashboard */ function handleCategorieChange(categorie) {
const loadData = useCallback(async () => {
setLoading(true); setLoading(true);
setErreur(''); setFiltreCategorie(categorie);
try { }
const [sum, data] = await Promise.all([
getDashboardSummary(),
getDashboard(filtreService, filtreCategorie),
]);
setSummary(sum);
setMonitorings(Array.isArray(data) ? data : []);
} catch (e) {
setErreur(e.message || 'Erreur lors du chargement du dashboard.');
} finally {
setLoading(false);
}
}, [filtreService, filtreCategorie]);
/* Le bouton Actualiser relance l'effet en changeant cette valeur. */
function handleActualiser() {
setLoading(true);
setRafraichissement(compteur => compteur + 1);
}
/* Les données sont rechargées à chaque changement de filtre. L'indicateur de
chargement est allumé par les gestionnaires d'événement ci-dessus, jamais
dans l'effet : y appeler setState de façon synchrone provoquerait un rendu
en cascade. */
useEffect(() => { useEffect(() => {
loadData(); let annule = false;
}, [loadData]);
async function charger() {
try {
const [sum, data] = await Promise.all([
getDashboardSummary(),
getDashboard(filtreService, filtreCategorie),
]);
if (annule) return;
setSummary(sum);
setMonitorings(Array.isArray(data) ? data : []);
setErreur('');
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement du dashboard.');
} finally {
if (!annule) setLoading(false);
}
}
charger();
return () => { annule = true; };
}, [filtreService, filtreCategorie, rafraichissement]);
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.pageHeader}> <div className={styles.pageHeader}>
<h1 className={styles.title}>Dashboard</h1> <h1 className={styles.title}>Dashboard</h1>
<button className={styles.refreshBtn} onClick={loadData} disabled={loading}> <button className={styles.refreshBtn} onClick={handleActualiser} disabled={loading}>
<RefreshCw size={14} className={loading ? styles.spinning : ''} /> <RefreshCw size={14} className={loading ? styles.spinning : ''} />
Actualiser Actualiser
</button> </button>
@@ -140,7 +150,7 @@ export default function Dashboard() {
<select <select
className={styles.select} className={styles.select}
value={filtreCategorie} value={filtreCategorie}
onChange={e => setFiltreCategorie(e.target.value)} onChange={e => handleCategorieChange(e.target.value)}
> >
<option value="">Toutes les catégories</option> <option value="">Toutes les catégories</option>
{categories.map(c => ( {categories.map(c => (
@@ -153,7 +163,7 @@ export default function Dashboard() {
{loading ? ( {loading ? (
<LoadingSpinner message="Chargement du dashboard..." /> <LoadingSpinner message="Chargement du dashboard..." />
) : erreur ? ( ) : erreur ? (
<ErrorMessage message={erreur} onRetry={loadData} /> <ErrorMessage message={erreur} onRetry={handleActualiser} />
) : ( ) : (
<> <>
{monitorings.length === 0 ? ( {monitorings.length === 0 ? (
@@ -55,29 +55,6 @@
margin-bottom: 32px; margin-bottom: 32px;
} }
.kpiCard {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
background: var(--color-white);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.kpiValue {
font-size: 36px;
font-weight: 700;
line-height: 1;
margin-bottom: 6px;
}
.kpiLabel {
font-size: 12px;
color: var(--color-muted, #888);
text-align: center;
}
.filtres { .filtres {
display: flex; display: flex;
gap: 12px; gap: 12px;
@@ -131,10 +108,6 @@
gap: 10px; gap: 10px;
} }
.kpiValue {
font-size: 28px;
}
.filtres { .filtres {
flex-direction: column; flex-direction: column;
} }
@@ -1,6 +1,6 @@
// src/pages/Documentation/Documentation.jsx // src/pages/Documentation/Documentation.jsx
// Page documentation technique de Data Sentinel // Page documentation technique de Data Sentinel
import { ExternalLink, Book, Code, Database, Shield } from 'lucide-react'; import { Book, Code, Database, Shield } from 'lucide-react';
import styles from './Documentation.module.css'; import styles from './Documentation.module.css';
const sections = [ const sections = [
+1 -1
View File
@@ -2,7 +2,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/useAuth';
import styles from './Login.module.css'; import styles from './Login.module.css';
/* Génère deux entiers aléatoires pour le captcha additif */ /* Génère deux entiers aléatoires pour le captcha additif */
@@ -9,15 +9,9 @@ import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Badge from '../../components/common/Badge'; import Badge from '../../components/common/Badge';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import styles from './MonitoringDetail.module.css'; import styles from './MonitoringDetail.module.css';
/* Détermine le statut selon le nombre d'erreurs */
function getStatut(nbErreurs) {
if (nbErreurs === 0) return { label: 'OK', variant: 'ok' };
if (nbErreurs < 10) return { label: 'Attention', variant: 'warning' };
return { label: 'Critique', variant: 'critical' };
}
export default function MonitoringDetail() { export default function MonitoringDetail() {
const { id } = useParams(); const { id } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -72,7 +66,7 @@ export default function MonitoringDetail() {
if (loading) return <LoadingSpinner message="Chargement du monitoring..." />; if (loading) return <LoadingSpinner message="Chargement du monitoring..." />;
if (erreur) return <ErrorMessage message={erreur} />; if (erreur) return <ErrorMessage message={erreur} />;
const statut = getStatut(count); const statut = getStatutMonitoring(count);
return ( return (
<div className={styles.page}> <div className={styles.page}>
@@ -7,6 +7,7 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { buildMonitoringColumns } from '../../utils/monitoringColumns';
import styles from './ServiceMonitorings.module.css'; import styles from './ServiceMonitorings.module.css';
export default function ServiceMonitorings({ serviceLabel, serviceName }) { export default function ServiceMonitorings({ serviceLabel, serviceName }) {
@@ -34,22 +35,7 @@ export default function ServiceMonitorings({ serviceLabel, serviceName }) {
if (loading) return <LoadingSpinner message={`Chargement des monitorings ${serviceLabel.toLowerCase()}...`} />; if (loading) return <LoadingSpinner message={`Chargement des monitorings ${serviceLabel.toLowerCase()}...`} />;
if (erreur) return <ErrorMessage message={erreur} />; if (erreur) return <ErrorMessage message={erreur} />;
const columns = [ const columns = buildMonitoringColumns(navigate);
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
return ( return (
<div className={styles.page}> <div className={styles.page}>
+18 -2
View File
@@ -43,9 +43,25 @@ export default function Users() {
} }
}, []); }, []);
/* Chargement initial. Le drapeau `annule` évite d'écrire dans un composant
déjà démonté si l'utilisateur quitte la page pendant la requête. */
useEffect(() => { useEffect(() => {
charger(); let annule = false;
}, [charger]);
async function chargerAuMontage() {
try {
const data = await getUsers();
if (!annule) setUsers(Array.isArray(data) ? data : []);
} catch (e) {
if (!annule) setErreur(e.message || 'Erreur lors du chargement des utilisateurs.');
} finally {
if (!annule) setLoading(false);
}
}
chargerAuMontage();
return () => { annule = true; };
}, []);
/* Ouverture des modales */ /* Ouverture des modales */
function ouvrirCreation() { function ouvrirCreation() {
+4 -25
View File
@@ -9,15 +9,10 @@ import DataTable from '../../components/common/DataTable';
import LoadingSpinner from '../../components/common/LoadingSpinner'; import LoadingSpinner from '../../components/common/LoadingSpinner';
import ErrorMessage from '../../components/common/ErrorMessage'; import ErrorMessage from '../../components/common/ErrorMessage';
import Button from '../../components/common/Button'; import Button from '../../components/common/Button';
import { getStatutMonitoring } from '../../utils/statutMonitoring';
import { buildMonitoringColumns } from '../../utils/monitoringColumns';
import styles from './VueConso.module.css'; import styles from './VueConso.module.css';
/* Détermine la couleur selon le statut */
function getStatusColor(nbErreurs) {
if (nbErreurs === 0) return 'var(--color-ok)';
if (nbErreurs < 10) return 'var(--color-warning)';
return 'var(--color-primary)';
}
export default function VueConso() { export default function VueConso() {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState([]); const [data, setData] = useState([]);
@@ -47,26 +42,10 @@ export default function VueConso() {
const chartData = data.map(item => ({ const chartData = data.map(item => ({
name: item.nom || item.nom_monito || item.libelle || 'Monitoring', name: item.nom || item.nom_monito || item.libelle || 'Monitoring',
erreurs: item.nb_erreurs ?? item.count ?? 0, erreurs: item.nb_erreurs ?? item.count ?? 0,
fill: getStatusColor(item.nb_erreurs ?? item.count ?? 0) fill: getStatutMonitoring(item.nb_erreurs ?? item.count ?? 0).color
})); }));
/* Colonnes du tableau */ const columns = buildMonitoringColumns(navigate);
const columns = [
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
return ( return (
<div className={styles.page}> <div className={styles.page}>
+56 -2
View File
@@ -3,9 +3,11 @@
/* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */ /* Utilise la variable d'environnement Vite si définie, sinon localhost par défaut */
const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000'; const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8000';
/* Clés de stockage local pour le token JWT et l'utilisateur connecté */ /* Clés de stockage local pour le token JWT et l'utilisateur connecté.
USER_KEY est exporté : AuthContext lit/écrit la même entrée, et une clé
dupliquée des deux côtés divergerait silencieusement au premier renommage. */
const TOKEN_KEY = 'ds_token'; const TOKEN_KEY = 'ds_token';
const USER_KEY = 'ds_user'; export const USER_KEY = 'ds_user';
/* Helpers de gestion du token JWT dans le localStorage */ /* Helpers de gestion du token JWT dans le localStorage */
export function getToken() { export function getToken() {
@@ -188,6 +190,58 @@ export async function getContacts(id_service) {
return fetchApi('/contacts', { id_service }); return fetchApi('/contacts', { id_service });
} }
/* ---------- Administration du référentiel (réservé Admin) ---------- */
export async function createService(body) {
return sendJson('/services', 'POST', body);
}
export async function updateService(id_service, body) {
return sendJson(`/services/${id_service}`, 'PUT', body);
}
export async function deleteService(id_service) {
return sendJson(`/services/${id_service}`, 'DELETE');
}
export async function createCategorie(body) {
return sendJson('/categories', 'POST', body);
}
export async function updateCategorie(id_categorie, body) {
return sendJson(`/categories/${id_categorie}`, 'PUT', body);
}
export async function deleteCategorie(id_categorie) {
return sendJson(`/categories/${id_categorie}`, 'DELETE');
}
export async function createContact(body) {
return sendJson('/contacts', 'POST', body);
}
export async function updateContact(id_contact, body) {
return sendJson(`/contacts/${id_contact}`, 'PUT', body);
}
export async function deleteContact(id_contact) {
return sendJson(`/contacts/${id_contact}`, 'DELETE');
}
export async function createMonitoring(body) {
return sendJson('/monitorings', 'POST', body);
}
export async function updateMonitoring(id_monito, body) {
return sendJson(`/monitorings/${id_monito}`, 'PUT', body);
}
/* Désactivation (actif = 0) : l'historique référence le monitoring,
il n'est jamais supprimé physiquement. */
export async function desactiverMonitoring(id_monito) {
return sendJson(`/monitorings/${id_monito}`, 'DELETE');
}
/* Récupère la nomenclature (monitorings), filtrable par service et catégorie */ /* Récupère la nomenclature (monitorings), filtrable par service et catégorie */
export async function getMonitorings(id_service, id_categorie) { export async function getMonitorings(id_service, id_categorie) {
const data = await fetchApi('/monitorings', { id_service, id_categorie }); const data = await fetchApi('/monitorings', { id_service, id_categorie });
@@ -0,0 +1,24 @@
import Button from '../components/common/Button';
/* Colonnes du tableau de monitorings partagées par VueConso et
ServiceMonitorings (Contrat/Fournisseur) : même jeu de données
(GET /dashboard), même tableau. Centralisé pour qu'une évolution des
colonnes (ajout, renommage) ne soit faite qu'à un seul endroit. */
export function buildMonitoringColumns(navigate) {
return [
{ key: 'nom', label: 'Monitoring' },
{ key: 'service', label: 'Service' },
{ key: 'categorie', label: 'Catégorie' },
{ key: 'bdd_source', label: 'Source' },
{ key: 'nb_erreurs', label: 'Erreurs', render: (val) => val ?? 0 },
{
key: 'actions',
label: 'Actions',
render: (val, row) => (
<Button variant="link" onClick={() => navigate(`/monitoring/${row.id || row.id_monito}`)}>
Voir détail
</Button>
)
}
];
}
@@ -0,0 +1,16 @@
/* Règle de statut partagée : un monitoring est "OK" sans erreur, "Attention"
en-dessous du seuil, "Critique" au-delà. Centralisé ici pour que le seuil
ne soit corrigé qu'à un seul endroit (il était dupliqué dans MonitoringCard,
MonitoringDetail et VueConso, avec le risque que l'une des copies diverge). */
export const SEUIL_ATTENTION = 10;
export function getStatutMonitoring(nbErreurs) {
if (nbErreurs === 0) {
return { label: 'OK', variant: 'ok', color: 'var(--color-ok)' };
}
if (nbErreurs < SEUIL_ATTENTION) {
return { label: 'Attention', variant: 'warning', color: 'var(--color-warning)' };
}
return { label: 'Critique', variant: 'critical', color: 'var(--color-primary)' };
}