Initial commit: Check-My-IRVE SaaS (OCPP 1.6, Audit, Diagnostics)

This commit is contained in:
Adam Belkacemi 2026-08-25 19:08:15 +02:00
commit 192be86487
44 changed files with 2070 additions and 0 deletions

3
frontend/.dockerignore Normal file
View file

@ -0,0 +1,3 @@
node_modules
dist
.git

24
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
frontend/.oxlintrc.json Normal file
View file

@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}

7
frontend/Dockerfile Normal file
View file

@ -0,0 +1,7 @@
FROM node:20-slim
WORKDIR /app
COPY package.json ./
RUN npm cache clean --force && rm -rf node_modules package-lock.json && npm install --legacy-peer-deps
COPY . .
EXPOSE 5173
CMD ["npm", "run", "dev"]

16
frontend/README.md Normal file
View file

@ -0,0 +1,16 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and Oxlint's TypeScript related rules in your project.

13
frontend/index.html Normal file
View file

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

19
frontend/package.json Normal file
View file

@ -0,0 +1,19 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.0.0",
"@vitejs/plugin-react": "^4.3.1",
"tailwindcss": "^4.0.0",
"vite": "5.4.8"
}
}

View file

@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {}
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
frontend/public/icons.svg Normal file
View file

@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
frontend/src/App.css Normal file
View file

@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

240
frontend/src/App.jsx Normal file
View file

@ -0,0 +1,240 @@
import React, { useState, useRef, useEffect } from 'react';
import NetworkScene from './components/NetworkScene';
import SnifferPanel from './components/SnifferPanel';
import AnalysisPanel from './components/AnalysisPanel';
import ControlPanel from './components/ControlPanel';
import AuditPanel from './components/AuditPanel';
import CommissioningAssistant from './components/CommissioningAssistant';
import FleetView from './components/FleetView';
import CdrView from './components/CdrView';
const Icon = ({ path, size = 20, className = "" }) => (
<svg xmlns="http://www.w3.org/2000/svg" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}><path d={path} /></svg>
);
const icons = { play: "M5 3l14 9-14 9V3z", close: "M18 6L6 18M6 6l12 12", stop: "M6 6h12v12H6z", car: "M5 17h14v-5l-2-5H7L5 12v5zM5 17v2h2v-2h10v2h2v-2M7 12h10" };
export default function App() {
const [config, setConfig] = useState({
raccordement: 'BT tri 400V', puissance_souscrite: 36, source: 'Réseau Enedis', disj_calibre: 40, diff_type: 'Type B', rdc_dd: false,
borne_fabricant: 'Schneider', borne_puissance: 22, comm_type: 'SIM 4G M2M',
csms_url: 'ws://localhost:8000/ocpp/WEB-TEST-01', csms_version: '1.6J', csms_auth: 'Basic'
});
const [status, setStatus] = useState({ grid: 'idle', box: 'idle', evse: 'idle', csms: 'idle', roaming: 'idle' });
const [logs, setLogs] = useState([{ time: '00:00:00', msg: "Système prêt.", type: "info" }]);
const [panelOpen, setPanelOpen] = useState(true);
const [activeTab, setActiveTab] = useState('ocpp');
const [midValue, setMidValue] = useState(0.0);
const [metrics, setMetrics] = useState({ boot_time_ms: 0, rtt_avg_ms: 0, reconnections: 0, clock_drift: 'n/a', subprotocol: 'n/a' });
const [frameCount, setFrameCount] = useState(0);
const [frames, setFrames] = useState([]);
const [getConfigResp, setGetConfigResp] = useState(null);
const [mainView, setMainView] = useState('parc');
const [selectedCp, setSelectedCp] = useState('WEB-TEST-01');
const [isDemoRunning, setIsDemoRunning] = useState(false);
const demoWsRef = useRef(null);
const demoIntervalRef = useRef(null);
const msgIdCounter = useRef(200);
const wsRef = useRef(null);
const timeoutRef = useRef(null);
const handleChange = (e) => setConfig(prev => ({ ...prev, [e.target.name]: e.target.type === 'checkbox' ? e.target.checked : e.target.value }));
const addLog = (msg, type) => setLogs(prev => [...prev, { time: new Date().toLocaleTimeString('fr-FR'), msg, type }]);
useEffect(() => {
const fetchMetrics = async () => {
try {
const res = await fetch('http://localhost:8000/api/chargepoints');
if (res.ok) {
const data = await res.json();
const cp = data.find(c => c.id === selectedCp);
if (cp) {
setMetrics({ boot_time_ms: cp.boot_time_ms || 0, rtt_avg_ms: cp.rtt_avg_ms || 0, reconnections: cp.reconnections || 0, clock_drift: cp.clock_drift === null ? 'n/a' : Math.round(cp.clock_drift), subprotocol: cp.subprotocol || 'n/a' });
setFrameCount(cp.msg_count || 0);
}
}
const fRes = await fetch(`http://localhost:8000/api/chargepoints/${selectedCp}/frames`);
if (fRes.ok) setFrames(await fRes.json());
} catch (e) {}
};
const interval = setInterval(fetchMetrics, 2000);
return () => clearInterval(interval);
}, [selectedCp]);
const launchTest = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) { wsRef.current.close(); return; }
setStatus({ grid: 'active', box: 'active', evse: 'loading', csms: 'loading', roaming: 'idle' });
addLog(`Connexion à ${config.csms_url}...`, "info");
try {
const ws = new WebSocket(config.csms_url, ['ocpp1.6']);
wsRef.current = ws;
timeoutRef.current = setTimeout(() => { if (ws.readyState !== WebSocket.OPEN) { addLog("Timeout.", "error"); setStatus({ grid: 'active', box: 'active', evse: 'error', csms: 'error', roaming: 'idle' }); ws.close(); } }, 10000);
ws.onopen = () => {
clearTimeout(timeoutRef.current);
addLog("WebSocket ouverte. Envoi BootNotification...", "info");
setStatus({ grid: 'active', box: 'active', evse: 'active', csms: 'loading', roaming: 'idle' });
ws.send(JSON.stringify([2, "101", "BootNotification", { "chargePointVendor": config.borne_fabricant, "chargePointModel": "CheckMyIRVE" }]));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg[0] === 3 && msg[1] === "101") {
if (msg[2].status === "Accepted") { addLog("BootNotification Accepted.", "success"); setStatus({ grid: 'active', box: 'active', evse: 'active', csms: 'active', roaming: 'active' }); }
else { addLog(`Rejetée: ${msg[2].status}`, "error"); setStatus({ grid: 'active', box: 'active', evse: 'error', csms: 'error', roaming: 'idle' }); }
} else if (msg[0] === 2) {
const action = msg[2], msgId = msg[1], payload = msg[3];
addLog(`Req CSMS: ${action}`, "info");
let respPayload = {};
if (action === "GetConfiguration") {
respPayload = { "configurationKey": [{"key":"WebSocketPingInterval","value":"0","readonly":false},{"key":"HeartbeatInterval","value":"30","readonly":false},{"key":"MeterValueSampleInterval","value":"5","readonly":false},{"key":"ConnectionTimeOut","value":"30","readonly":false},{"key":"MaxCurrent","value":"16","readonly":false}], "unknownKey": []
};
setGetConfigResp(respPayload);
}
else if (action === "TriggerMessage") { respPayload = { "status": "Accepted" }; if (payload.requestedMessage === "StatusNotification") setTimeout(() => ws.send(JSON.stringify([2, "trig-status-1", "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }])), 500); else if (payload.requestedMessage === "MeterValues") setTimeout(() => ws.send(JSON.stringify([2, "trig-meter-1", "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": new Date().toISOString(), "sampledValue": [{ "value": "1.5" }] }] }])), 500); }
else if (action === "RemoteStartTransaction") { respPayload = { "status": "Accepted" }; setTimeout(() => { ws.send(JSON.stringify([2, "auth-tx-1", "Authorize", { "idTag": "CHARGEMAP_PASS" }])); setTimeout(() => { ws.send(JSON.stringify([2, "start-tx-1", "StartTransaction", { "connectorId": 1, "idTag": "CHARGEMAP_PASS", "meterStart": 0, "timestamp": new Date().toISOString() }])); setMidValue(0.1); setTimeout(() => { ws.send(JSON.stringify([2, "meter-tx-1", "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": new Date().toISOString(), "sampledValue": [{ "value": "2.5" }] }] }])); setMidValue(2.5); setTimeout(() => ws.send(JSON.stringify([2, "stop-tx-1", "StopTransaction", { "transactionId": 99, "meterStop": 2500, "timestamp": new Date().toISOString() }])), 1500); }, 1000); }, 500); }, 500); }
else if (action === "Reset") respPayload = { "status": "Accepted" };
else if (action === "ChangeConfiguration") { respPayload = { "status": "Accepted" }; if (payload?.key && payload?.value) setGetConfigResp(prev => { if (!prev || !prev.configurationKey) return prev; return { ...prev, configurationKey: prev.configurationKey.map(c => c.key === payload.key ? { ...c, value: payload.value } : c) }; }); }
ws.send(JSON.stringify([3, msgId, respPayload]));
}
} catch (err) { addLog("Parsing error.", "error"); }
};
ws.onerror = () => addLog("Erreur WebSocket.", "error");
ws.onclose = (e) => { clearTimeout(timeoutRef.current); addLog(`Fermée (${e.code}).`, "info"); setStatus({ grid: 'idle', box: 'idle', evse: 'idle', csms: 'idle', roaming: 'idle' }); wsRef.current = null; };
} catch (err) { addLog(`Critique: ${err.message}`, "error"); }
};
const runCarCycle = async () => {
const ws = demoWsRef.current;
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const id = () => `demo-${msgIdCounter.current++}`;
const ts = () => new Date().toISOString();
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Preparing", "errorCode": "NoError" }]));
await new Promise(r => setTimeout(r, 1000));
ws.send(JSON.stringify([2, id(), "Authorize", { "idTag": "CHARGEMAP_PASS" }]));
await new Promise(r => setTimeout(r, 1000));
ws.send(JSON.stringify([2, id(), "StartTransaction", { "connectorId": 1, "idTag": "CHARGEMAP_PASS", "meterStart": 0, "timestamp": ts() }]));
await new Promise(r => setTimeout(r, 1000));
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Charging", "errorCode": "NoError" }]));
for(let i=1; i<=3; i++) {
await new Promise(r => setTimeout(r, 1500));
ws.send(JSON.stringify([2, id(), "MeterValues", { "connectorId": 1, "transactionId": 99, "meterValue": [{ "timestamp": ts(), "sampledValue": [{ "value": String(i * 1500) }] }] }]));
}
ws.send(JSON.stringify([2, id(), "StopTransaction", { "transactionId": 99, "meterStop": 4500, "timestamp": ts() }]));
await new Promise(r => setTimeout(r, 1000));
ws.send(JSON.stringify([2, id(), "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }]));
};
const toggleDemo = () => {
if (isDemoRunning) {
clearInterval(demoIntervalRef.current);
demoWsRef.current?.close();
setIsDemoRunning(false);
addLog("Simulation arrêtée.", "info");
} else {
const ws = new WebSocket("ws://localhost:8000/ocpp/SIM-DEMO-01", ["ocpp1.6"]);
demoWsRef.current = ws;
ws.onopen = () => {
ws.send(JSON.stringify([2, "demo-boot", "BootNotification", { "chargePointVendor": "Tesla", "chargePointModel": "Model3" }]));
ws.send(JSON.stringify([2, "demo-status-init", "StatusNotification", { "connectorId": 1, "status": "Available", "errorCode": "NoError" }]));
setIsDemoRunning(true);
addLog("Démo lancée: SIM-DEMO-01 connectée.", "success");
runCarCycle();
demoIntervalRef.current = setInterval(runCarCycle, 12000);
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg[0] === 2) {
const action = msg[2], msgId = msg[1];
let resp = {};
if (action === "GetConfiguration") resp = { configurationKey: [{ key: "MaxCurrent", value: "16", readonly: false }, { key: "HeartbeatInterval", value: "300", readonly: false }, { key: "WebSocketPingInterval", value: "0", readonly: false }], unknownKey: [] };
else if (action === "TriggerMessage") resp = { status: "Accepted" };
else if (action === "Reset") resp = { status: "Accepted" };
ws.send(JSON.stringify([3, msgId, resp]));
}
};
ws.onclose = () => { setIsDemoRunning(false); };
}
};
const handleSelectCp = (id) => {
setSelectedCp(id);
setPanelOpen(true);
setActiveTab('ocpp');
setMainView('topo');
};
return (
<div className="h-screen w-screen flex flex-col bg-slate-50 text-slate-900 font-sans overflow-hidden">
<header className="h-14 bg-white border-b border-slate-200 px-6 flex items-center justify-between flex-shrink-0">
<div className="flex items-center gap-3 w-64">
<div className="w-8 h-8 bg-slate-900 rounded-md flex items-center justify-center text-white text-xs font-bold">IRVE</div>
<h1 className="text-sm font-semibold tracking-tight text-slate-900">Check-My-IRVE Console</h1>
</div>
<div className="flex-1 flex justify-center items-center gap-2">
<span className="text-[10px] uppercase text-slate-500 font-semibold">URL CSMS :</span>
<input type="text" name="csms_url" value={config.csms_url} onChange={handleChange} className="bg-slate-100 text-xs font-mono text-blue-700 px-3 py-1.5 rounded-md border border-slate-200 focus:ring-1 focus:ring-blue-500 outline-none w-80" />
</div>
<div className="flex items-center gap-4 text-xs text-slate-600 w-64 justify-end">
<button onClick={toggleDemo} className={`px-3 py-2 rounded-md text-xs font-semibold transition-colors flex items-center gap-1 ${isDemoRunning ? 'bg-red-100 text-red-700 hover:bg-red-200' : 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200'}`}>
<Icon path={isDemoRunning ? icons.stop : icons.car} size={14} />
{isDemoRunning ? 'Stop Démo' : 'Démo Voiture'}
</button>
<button onClick={() => setPanelOpen(!panelOpen)} className="px-3 py-2 rounded-md text-xs font-semibold bg-slate-100 text-slate-600 hover:bg-slate-200">Panneaux</button>
</div>
</header>
<div className="flex flex-1 overflow-hidden p-6 gap-6 min-h-0">
<div className="flex-1 flex gap-6 overflow-hidden min-h-0">
<aside className="w-[300px] flex-shrink-0 bg-white border border-slate-200 rounded-lg shadow-sm p-4 flex flex-col gap-4 overflow-y-auto">
<div><h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Infrastructure</h2><div className="space-y-3"><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Raccordement</label><select name="raccordement" value={config.raccordement} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>BT mono 230V</option><option>BT tri 400V</option><option>HTA avec poste privé</option></select></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Puissance souscrite (kVA)</label><input type="number" name="puissance_souscrite" value={config.puissance_souscrite} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Source</label><select name="source" value={config.source} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Réseau Enedis</option><option>Autoconsommation PV</option><option>PV + stockage</option></select></div><div className="flex gap-2"><div className="flex-1"><label className="block text-[11px] font-medium text-slate-600 mb-1">Disj (A)</label><input type="number" name="disj_calibre" value={config.disj_calibre} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div className="flex-1"><label className="block text-[11px] font-medium text-slate-600 mb-1">Diff</label><select name="diff_type" value={config.diff_type} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Type A</option><option>Type B</option></select></div></div><label className="flex items-center gap-2 text-[11px] text-slate-600"><input type="checkbox" name="rdc_dd" checked={config.rdc_dd} onChange={handleChange} className="rounded border-slate-300 text-blue-600 focus:ring-blue-500" />Borne intègre RDC-DD 6mA</label></div></div>
<div><h2 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Borne & Comms</h2><div className="space-y-3"><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Fabricant</label><select name="borne_fabricant" value={config.borne_fabricant} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>ABB</option><option>Schneider</option><option>Wallbox</option><option>Alfen</option><option>EVBox</option><option>Hager</option></select></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Puissance borne (kW)</label><input type="number" name="borne_puissance" value={config.borne_puissance} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs font-mono text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none" /></div><div><label className="block text-[11px] font-medium text-slate-600 mb-1">Communication</label><select name="comm_type" value={config.comm_type} onChange={handleChange} className="w-full h-9 bg-slate-50 border border-slate-200 rounded-md px-2 text-xs text-slate-900 focus:ring-1 focus:ring-blue-500 outline-none"><option>Ethernet RJ45</option><option>WiFi</option><option>SIM 4G M2M</option><option>CPL</option></select></div></div></div>
<ControlPanel cpId={selectedCp} />
<div className="mt-auto"><button onClick={launchTest} className={`w-full h-10 text-white rounded-md text-sm font-semibold transition-colors flex items-center justify-center gap-2 shadow-sm ${wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? 'bg-red-600 hover:bg-red-700' : 'bg-blue-600 hover:bg-blue-700'}`}><Icon path={wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? icons.stop : icons.play} size={16} />{wsRef.current && wsRef.current.readyState === WebSocket.OPEN ? 'Arrêter' : 'Lancer Test'}</button></div>
</aside>
<div className="flex-1 flex flex-col gap-6 overflow-hidden min-h-0">
<div className="flex-1 bg-white border border-slate-200 rounded-lg shadow-sm flex flex-col overflow-hidden min-h-0">
<div className="h-10 border-b border-slate-200 flex items-center bg-slate-50 flex-shrink-0">
<button onClick={() => setMainView('parc')} className={`h-full px-4 text-xs font-semibold ${mainView === 'parc' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Parc</button>
<button onClick={() => setMainView('topo')} className={`h-full px-4 text-xs font-semibold ${mainView === 'topo' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Topologie</button>
<button onClick={() => setMainView('audit')} className={`h-full px-4 text-xs font-semibold ${mainView === 'audit' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Audit Config</button>
<button onClick={() => setMainView('comm')} className={`h-full px-4 text-xs font-semibold ${mainView === 'comm' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Mise en Service</button>
<button onClick={() => setMainView('cdr')} className={`h-full px-4 text-xs font-semibold ${mainView === 'cdr' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>Réconciliation CDR</button>
</div>
<div className="flex-1 p-4 overflow-auto min-h-0">
{mainView === 'parc' && <FleetView onSelectCp={handleSelectCp} />}
{mainView === 'topo' && <NetworkScene status={status} onInterceptClick={(id) => { setActiveTab(id); setPanelOpen(true); }} midValue={midValue} config={config} />}
{mainView === 'audit' && <AuditPanel getConfigResponse={getConfigResp} installerConfig={config} />}
{mainView === 'comm' && <CommissioningAssistant frames={frames} status={status} />}
{mainView === 'cdr' && <CdrView cpId={selectedCp} />}
</div>
</div>
<div className="h-40 bg-slate-900 rounded-lg border border-slate-800 shadow-sm flex flex-col overflow-hidden flex-shrink-0">
<div className="h-8 border-b border-slate-800 px-4 flex items-center justify-between bg-slate-900"><span className="text-xs text-slate-400 font-mono">terminal_logs.sh</span></div>
<div className="flex-1 p-4 overflow-y-auto font-mono text-xs flex flex-col gap-1 min-h-0">{logs.map((log, i) => <div key={i} className={log.type === 'error' ? 'text-red-400' : log.type === 'success' ? 'text-emerald-400' : 'text-slate-300'}><span className="text-slate-600">[{log.time}]</span> {log.msg}</div>)}</div>
</div>
</div>
</div>
{panelOpen && (
<div className="w-[420px] flex-shrink-0 flex flex-col gap-6 overflow-hidden min-h-0">
<div className="h-[260px] flex-shrink-0 flex flex-col overflow-hidden">
<AnalysisPanel cpId={selectedCp} frameCount={frameCount} installerConfig={config} />
</div>
<div className="flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex border-b border-slate-200 bg-white rounded-t-lg flex-shrink-0">
<button onClick={() => setActiveTab('ocpp')} className={`flex-1 py-2 text-xs font-semibold ${activeTab === 'ocpp' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>OCPP (WebSocket)</button>
<button onClick={() => setActiveTab('ocpi')} className={`flex-1 py-2 text-xs font-semibold ${activeTab === 'ocpi' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-slate-500'}`}>OCPI (REST Roaming)</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
<SnifferPanel cpId={selectedCp} activeTab={activeTab} />
</div>
</div>
</div>
)}
</div>
</div>
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View file

@ -0,0 +1,61 @@
import React, { useState, useEffect } from 'react';
export default function AnalysisPanel({ cpId, frameCount, installerConfig }) {
const [findings, setFindings] = useState([]);
useEffect(() => {
const fetchDiag = async () => {
try {
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId}/diagnostics`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ installer_config: installerConfig })
});
if (res.ok) {
const data = await res.json();
setFindings(data.sort((a, b) => {
const order = { critical: 0, warning: 1, info: 2 };
return order[a.severity] - order[b.severity];
}));
}
} catch (e) {}
};
fetchDiag();
const interval = setInterval(fetchDiag, 3000);
return () => clearInterval(interval);
}, [cpId, installerConfig]);
const styles = {
critical: { border: 'border-red-500', bg: 'bg-red-50', text: 'text-red-700', label: 'Critique' },
warning: { border: 'border-amber-500', bg: 'bg-amber-50', text: 'text-amber-700', label: 'Avertissement' },
info: { border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-700', label: 'Info' }
};
return (
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50 flex justify-between items-center flex-shrink-0">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Analyse & Verdicts</h3>
<span className="text-[10px] text-slate-400 font-mono">{frameCount} trames</span>
</div>
<div className="flex-1 p-3 space-y-3 overflow-y-auto min-h-0">
{frameCount < 3 && findings.length === 0 && <div className="text-slate-400 italic text-sm text-center pt-4">Observation en cours...</div>}
{frameCount >= 3 && findings.length === 0 && <div className="text-emerald-600 italic text-sm text-center pt-4">Aucune anomalie.</div>}
{findings.map((f, i) => {
const s = styles[f.severity];
return (
<div key={i} className={`p-3 rounded-lg border-l-4 ${s.border} ${s.bg}`}>
<div className="flex justify-between items-start mb-1">
<h4 className={`text-sm font-bold ${s.text}`}>{f.title}</h4>
<div className="flex gap-1">
<span className={`text-[9px] font-semibold px-1.5 py-0.5 rounded ${s.text} ${s.bg} border ${s.border}`}>{s.label}</span>
</div>
</div>
<p className="text-xs text-slate-600 mb-2">{f.explication}</p>
<div className="text-xs text-slate-800 bg-white/50 p-2 rounded border border-slate-200"><span className="font-bold">Action: </span>{f.action}</div>
</div>
);
})}
</div>
</div>
);
}

View file

@ -0,0 +1,50 @@
import React, { useState, useEffect } from 'react';
export default function AuditPanel({ getConfigResponse, installerConfig }) {
const [audit, setAudit] = useState(null);
const [loading, setLoading] = useState(false);
const runAudit = async (configData) => {
if (!configData || !configData.configurationKey) return;
setLoading(true);
try {
const res = await fetch(`http://localhost:8000/api/chargepoints/WEB-TEST-01/audit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ borne_config: configData, installer_config: installerConfig })
});
const data = await res.json();
setAudit(data);
} catch (e) {}
setLoading(false);
};
useEffect(() => { if (getConfigResponse) runAudit(getConfigResponse); }, [getConfigResponse, installerConfig]);
return (
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50 flex justify-between items-center">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Audit de Configuration</h3>
<button onClick={() => runAudit(getConfigResponse)} disabled={!getConfigResponse} className={`px-3 py-1 text-[10px] font-semibold rounded ${getConfigResponse ? 'bg-blue-600 text-white hover:bg-blue-700' : 'bg-slate-200 text-slate-400 cursor-not-allowed'}`}>{loading ? 'Analyse...' : 'Relancer'}</button>
</div>
<div className="flex-1 p-3 overflow-y-auto min-h-0">
{!getConfigResponse && <p className="text-slate-400 italic text-sm text-center pt-4">Cliquez sur Get Config.</p>}
{audit && (
<table className="w-full text-xs">
<thead><tr className="border-b border-slate-200 text-slate-500"><th className="text-left py-1 font-medium">Clé</th><th className="text-left py-1 font-medium">Borne</th><th className="text-left py-1 font-medium">Attendu</th><th className="text-left py-1 font-medium">Statut</th></tr></thead>
<tbody>
{audit.map((row, i) => (
<tr key={i} className={`border-b border-slate-100 ${row.match ? 'bg-emerald-50/50' : row.severity === 'critical' ? 'bg-red-50/50' : 'bg-amber-50/50'}`}>
<td className="py-1.5 font-mono text-slate-700">{row.key}</td>
<td className="py-1.5 font-mono text-slate-900 font-bold">{row.borne_val}</td>
<td className="py-1.5 font-mono text-slate-500">{row.expected_val}</td>
<td className="py-1.5">{row.match ? <span className="text-emerald-600 text-[10px] font-bold">OK</span> : <span className="text-red-600 text-[10px] font-bold">ÉCART</span>}</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,41 @@
import React, { useState, useEffect } from 'react';
export default function CdrView({ cpId }) {
const [cdr, setCdr] = useState(null);
useEffect(() => {
const fetchCdr = async () => { try { const res = await fetch(`/api/chargepoints/${cpId}/cdr`); if (res.ok) { const data = await res.json(); if (!data.error) setCdr(data); } } catch (e) {} };
fetchCdr();
const interval = setInterval(fetchCdr, 3000);
return () => clearInterval(interval);
}, [cpId]);
if (!cdr) return <div className="p-4 text-slate-400 italic text-sm">Aucune transaction terminée trouvée.</div>;
return (
<div className="flex flex-col h-full p-4 overflow-auto">
<div className="grid grid-cols-2 gap-4 mb-4">
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<h4 className="text-xs font-bold uppercase text-slate-500 mb-2">Données Borne (OCPP)</h4>
<div className="space-y-1 text-sm font-mono">
<div className="flex justify-between"><span className="text-slate-500">Transaction ID:</span> <span className="font-bold">{cdr.ocpp_transaction.transactionId}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Compteur Fin (Wh):</span> <span className="font-bold">{cdr.ocpp_transaction.meterStop}</span></div>
</div>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-4">
<h4 className="text-xs font-bold uppercase text-slate-500 mb-2">Facture eMSP (CDR OCPI)</h4>
<div className="space-y-1 text-sm font-mono">
<div className="flex justify-between"><span className="text-slate-500">CDR ID:</span> <span className="font-bold">{cdr.ocpi_cdr.id}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Énergie (kWh):</span> <span className="font-bold">{cdr.ocpi_cdr.total_energy_kwh.toFixed(2)}</span></div>
<div className="flex justify-between"><span className="text-slate-500">Coût (€):</span> <span className="font-bold">{cdr.ocpi_cdr.total_cost.toFixed(2)}</span></div>
</div>
</div>
</div>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<h4 className="text-xs font-bold uppercase text-amber-700 mb-2">Analyse des Écarts</h4>
<div className="flex items-center justify-between bg-white p-2 rounded border border-amber-100">
<span className="text-sm text-slate-700">Frais de parking appliqués par l'eMSP</span>
<span className="text-sm font-bold text-amber-600">+{cdr.delta.cost_diff.toFixed(2)} €</span>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,51 @@
import React from 'react';
export default function CommissioningAssistant({ frames, status }) {
const steps = [
{ id: 'connect', label: 'Connectivité WebSocket', check: () => status.csms === 'active' || status.csms === 'loading' },
{ id: 'boot', label: 'BootNotification acceptée', check: () => frames.some(f => f.action === 'BootNotification' && f.direction === 'OUT') },
{ id: 'status', label: 'Statut Available reçu', check: () => frames.some(f => f.action === 'StatusNotification') },
{ id: 'auth', label: 'Badge de test autorisé', check: () => frames.some(f => f.action === 'Authorize') },
{ id: 'tx', label: 'Transaction démarrée', check: () => frames.some(f => f.action === 'StartTransaction') },
{ id: 'meter', label: 'MeterValues cohérentes', check: () => frames.some(f => f.action === 'MeterValues') },
];
const completed = steps.filter(s => s.check()).length;
const progress = (completed / steps.length) * 100;
const isDone = completed === steps.length;
return (
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Assistant de Mise en Service</h3>
</div>
<div className="p-4 flex flex-col h-full">
<div className="w-full bg-slate-200 rounded-full h-2.5 mb-4">
<div className={`h-2.5 rounded-full transition-all duration-500 ${isDone ? 'bg-emerald-500' : 'bg-blue-600'}`} style={{ width: `${progress}%` }}></div>
</div>
<div className="space-y-2 flex-1">
{steps.map(step => {
const ok = step.check();
return (
<div key={step.id} className="flex items-center gap-2">
<div className={`w-5 h-5 rounded-full flex items-center justify-center text-[10px] font-bold ${ok ? 'bg-emerald-500 text-white' : 'bg-slate-200 text-slate-400'}`}>
{ok ? '✓' : '•'}
</div>
<span className={`text-sm ${ok ? 'text-slate-900 font-medium' : 'text-slate-400'}`}>{step.label}</span>
</div>
);
})}
</div>
{isDone ? (
<div className="mt-4 p-2 bg-emerald-50 border border-emerald-200 rounded text-xs text-emerald-700 font-bold text-center">
Mise en service conforme. Autorisation de mise en production accordée.
</div>
) : (
<div className="mt-4 p-2 bg-slate-50 border border-slate-200 rounded text-xs text-slate-500 text-center">
En attente des étapes de validation automatique...
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,45 @@
import React, { useState } from 'react';
export default function ControlPanel({ cpId }) {
const [response, setResponse] = useState(null);
const [loading, setLoading] = useState(false);
const sendCommand = async (action, payload = {}) => {
setLoading(true); setResponse(null);
try {
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId}/command`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, payload }) });
const data = await res.json();
setResponse(data);
} catch (e) { setResponse({ error: e.message }); }
setLoading(false);
};
const generateReport = () => {
window.open(`http://localhost:8000/api/chargepoints/${cpId}/report`, '_blank');
};
const buttons = [
{ label: "Trigger Status", action: "TriggerMessage", payload: { requested_message: "StatusNotification", connector_id: 0 } },
{ label: "Get Config", action: "GetConfiguration", payload: { key: [] } },
{ label: "Remote Start", action: "RemoteStartTransaction", payload: {} },
{ label: "Soft Reset", action: "Reset", payload: {} },
];
return (
<div className="bg-slate-800 p-3 rounded-lg border border-slate-700 mt-4">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-400 mb-2">Commandes CSMS</h3>
<div className="grid grid-cols-2 gap-2 mb-2">
{buttons.map((btn) => (
<button key={btn.label} onClick={() => sendCommand(btn.action, btn.payload)} className="px-2 py-1.5 rounded text-[11px] font-semibold bg-slate-700 text-slate-200 hover:bg-slate-600 border border-slate-600">{btn.label}</button>
))}
</div>
<button onClick={generateReport} className="w-full mt-2 px-2 py-1.5 rounded text-[11px] font-semibold bg-blue-600 text-white hover:bg-blue-700 border border-blue-500">
📄 Générer Rapport HTML
</button>
{loading && <p className="text-[10px] text-slate-500 mt-2 animate-pulse">Envoi...</p>}
{response && <div className="mt-2 p-2 bg-slate-950 rounded text-[10px] font-mono text-slate-300 max-h-24 overflow-y-auto border border-slate-700">{response.error ? <span className="text-red-400">Erreur: {response.error}</span> : JSON.stringify(response.response, null, 2)}</div>}
</div>
);
}

View file

@ -0,0 +1,34 @@
import React, { useState, useEffect } from 'react';
export default function FleetView({ onSelectCp }) {
const [cps, setCps] = useState([]);
useEffect(() => {
const fetchCps = async () => { try { const res = await fetch('/api/chargepoints'); if (res.ok) setCps(await res.json()); } catch (e) {} };
fetchCps();
const interval = setInterval(fetchCps, 3000);
return () => clearInterval(interval);
}, []);
return (
<div className="flex flex-col h-full bg-white rounded-lg border border-slate-200 shadow-sm overflow-hidden">
<div className="px-4 py-2 border-b border-slate-200 bg-slate-50"><h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Vue Parc (Multi-bornes)</h3></div>
<div className="flex-1 p-4 overflow-auto">
<table className="w-full text-xs">
<thead><tr className="border-b border-slate-200 text-slate-500"><th className="text-left py-2 px-2 font-medium">ID Borne</th><th className="text-left py-2 px-2 font-medium">Modèle</th><th className="text-left py-2 px-2 font-medium">Statut</th><th className="text-left py-2 px-2 font-medium">Trames</th><th className="text-left py-2 px-2 font-medium">Action</th></tr></thead>
<tbody>
{cps.length === 0 && <tr><td colSpan="5" className="text-center py-4 text-slate-400 italic">Aucune borne connectée.</td></tr>}
{cps.map((cp, i) => (
<tr key={i} className="border-b border-slate-100 hover:bg-slate-50">
<td className="py-2 px-2 font-mono text-blue-600 font-semibold">{cp.id}</td>
<td className="py-2 px-2 text-slate-600">{cp.vendor} {cp.model}</td>
<td className="py-2 px-2"><span className={`px-2 py-0.5 rounded-full text-[10px] font-bold ${cp.status === 'Connected' || cp.status === 'Available' ? 'bg-emerald-100 text-emerald-700' : 'bg-slate-100 text-slate-500'}`}>{cp.status}</span></td>
<td className="py-2 px-2 font-mono text-slate-500">{cp.msg_count}</td>
<td className="py-2 px-2"><button onClick={() => onSelectCp(cp.id)} className="px-2 py-1 bg-blue-600 text-white rounded text-[10px] hover:bg-blue-700">Diagnostiquer</button></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View file

@ -0,0 +1,165 @@
import React from 'react';
const stateClass = (state) => {
switch (state) {
case 'active': return 'stroke-emerald-500 fill-emerald-50 text-emerald-700';
case 'error': return 'stroke-red-500 fill-red-50 text-red-700';
case 'loading': return 'stroke-amber-500 fill-amber-50 text-amber-700';
default: return 'stroke-slate-400 fill-slate-50 text-slate-500';
}
};
const dotClass = (state) => {
switch (state) {
case 'active': return 'fill-emerald-500';
case 'error': return 'fill-red-500';
case 'loading': return 'fill-amber-500 animate-pulse';
default: return 'fill-slate-300';
}
};
export default function NetworkScene({ status, onInterceptClick, midValue, config }) {
const isPV = config.source.includes("PV");
const isMono = config.raccordement.includes("mono");
const powerLabel = isMono ? "230V AC Mono" : "400V AC Tri";
// Icône de communication
const commIcon = config.comm_type.includes("SIM") ? "📶" : config.comm_type.includes("WiFi") ? "📡" : "🔌";
return (
<div className="w-full h-full flex items-center justify-center p-4">
<svg viewBox="0 0 1400 620" preserveAspectRatio="xMidYMid meet" className="w-full h-full font-mono">
<defs>
<style>{`
.dash-anim { stroke-dasharray: 8 4; animation: dash 1s linear infinite; }
@keyframes dash { to { stroke-dashoffset: -12; } }
.intercept-group:hover .halo { opacity: 0.4; r: 18; }
.halo { transition: all 0.2s ease; opacity: 0; }
`}</style>
</defs>
<g id="legend" transform="translate(20, 580)">
<line x1="0" y1="0" x2="30" y2="0" stroke="#f59e0b" strokeWidth="8" strokeLinecap="round" />
<text x="40" y="4" fontSize="11" fill="#334155" className="font-sans">Flux Puissance (AC)</text>
<line x1="180" y1="0" x2="210" y2="0" stroke="#3b82f6" strokeWidth="2" strokeDasharray="8 4" />
<text x="220" y="4" fontSize="11" fill="#334155" className="font-sans">Flux Données (OCPP/OCPI)</text>
</g>
<g id="flux-power">
<path d="M 160 140 L 480 140" fill="none" stroke="#f59e0b" strokeWidth="8" strokeLinecap="round" />
<circle cx="160" cy="140" r="6" fill="#f59e0b" />
<circle cx="480" cy="140" r="6" fill="#f59e0b" />
</g>
{/* 1. SOURCE ÉNERGIE */}
<g id="enedis-tableau" transform="translate(40, 100)">
<rect x="0" y="0" width="160" height="220" rx="4" className={`${stateClass(status.grid)} stroke-[2.5]`} />
<text x="80" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">
{isPV ? "Onduleur PV" : "Enedis / Tableau"}
</text>
{isPV ? (
<>
<rect x="20" y="20" width="120" height="60" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
<text x="80" y="45" fontSize="9" textAnchor="middle" className="fill-slate-500">Panneaux PV</text>
<text x="80" y="65" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{config.puissance_souscrite} kVA</text>
</>
) : (
<>
<rect x="20" y="20" width="120" height="60" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
<text x="80" y="45" fontSize="9" textAnchor="middle" className="fill-slate-500">Compteur Linky</text>
<text x="80" y="65" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{powerLabel} ({config.puissance_souscrite}kVA)</text>
</>
)}
<rect x="20" y="100" width="120" height="40" rx="2" className="fill-slate-100 stroke-slate-300 stroke-[1.5]" />
<line x1="40" y1="110" x2="40" y2="130" stroke="#334155" strokeWidth="1.5" />
<line x1="120" y1="110" x2="120" y2="130" stroke="#334155" strokeWidth="1.5" />
<text x="80" y="155" fontSize="9" textAnchor="middle" className="fill-slate-500">AGCP {config.disj_calibre}A — diff. 500mA</text>
</g>
{/* 2. COFFRET AC */}
<g id="coffret-ac" transform="translate(240, 100)">
<title>{config.rdc_dd ? "RDC-DD 6mA actif: Type A suffisant." : "Type B obligatoire (courant continu)."}</title>
<rect x="0" y="0" width="160" height="220" rx="4" className={`${stateClass(status.box)} stroke-[2.5]`} />
<text x="80" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">Coffret AC</text>
<rect x="20" y="20" width="120" height="50" rx="2" className="fill-white stroke-slate-300 stroke-[1.5]" />
<text x="80" y="40" fontSize="9" textAnchor="middle" className="fill-slate-500">Différentiel 30mA</text>
<text x="80" y="55" fontSize="11" fontWeight="700" textAnchor="middle" className="fill-slate-800">{config.rdc_dd && config.diff_type === "Type B" ? "Type A (RDC-DD)" : config.diff_type}</text>
<rect x="20" y="90" width="120" height="40" rx="2" className="fill-slate-100 stroke-slate-300 stroke-[1.5]" />
<text x="80" y="115" fontSize="9" textAnchor="middle" className="fill-slate-500">Disj. Courbe C {config.disj_calibre}A</text>
</g>
{/* 3. BORNE EVSE */}
<g id="evse-cutaway" transform="translate(460, 80)">
<rect x="0" y="0" width="380" height="280" rx="4" className={`${stateClass(status.evse)} stroke-[2.5]`} />
<text x="190" y="-10" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">{config.borne_fabricant} - {config.borne_puissance}kW</text>
<g id="evse-contactor">
<rect x="20" y="20" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
<text x="30" y="40" fontSize="9" className="fill-slate-600">Contacteur de Puissance</text>
<circle cx="320" cy="45" r="4" className={dotClass(status.evse)} />
</g>
<g id="evse-mid">
<rect x="20" y="80" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
<text x="30" y="100" fontSize="9" className="fill-slate-600">Compteur d'énergie (MID)</text>
<text x="340" y="110" fontSize="11" textAnchor="end" className="fill-slate-700 font-bold">{midValue} kWh</text>
</g>
<g id="evse-mcu">
<rect x="20" y="140" width="340" height="50" rx="2" className={`${stateClass(status.evse)} stroke-[1.5]`} />
<text x="30" y="160" fontSize="9" className="fill-slate-600">Micro-contrôleur (OCPP {config.csms_version})</text>
<rect x="280" y="150" width="60" height="20" rx="2" className="fill-slate-200 stroke-slate-400 stroke-[1]" />
</g>
<g id="evse-comm">
<rect x="20" y="200" width="340" height="50" rx="2" className={`${stateClass(status.box)} stroke-[1.5]`} />
<text x="30" y="220" fontSize="9" className="fill-slate-600">Module Comms: {config.comm_type}</text>
<text x="300" y="230" fontSize="14" className="fill-slate-700">{commIcon}</text>
</g>
<g id="evse-leds" transform="translate(20, 260)">
<circle cx="10" cy="10" r="4" className={dotClass(status.evse)} />
<circle cx="25" cy="10" r="4" className="fill-slate-200" />
<circle cx="40" cy="10" r="4" className="fill-slate-200" />
</g>
</g>
<g id="csms-cloud" transform="translate(920, 200)">
<path d="M 50 0 C 20 0 10 30 30 40 C 10 60 40 80 60 70 C 70 90 110 90 120 70 C 150 80 170 50 140 40 C 160 20 130 0 100 10 C 90 -10 60 -10 50 0 Z"
className={`${stateClass(status.csms)} stroke-[2.5]`} />
<text x="85" y="45" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">CSMS</text>
<text x="85" y="60" fontSize="9" textAnchor="middle" className="fill-slate-500">{config.csms_auth}</text>
</g>
<g id="roaming-hub" transform="translate(1180, 200)">
<path d="M 50 0 C 20 0 10 30 30 40 C 10 60 40 80 60 70 C 70 90 110 90 120 70 C 150 80 170 50 140 40 C 160 20 130 0 100 10 C 90 -10 60 -10 50 0 Z"
className={`${stateClass(status.roaming)} stroke-[2.5]`} />
<text x="85" y="45" fontSize="11" fontWeight="600" textAnchor="middle" className="font-sans fill-slate-700">Roaming</text>
<text x="85" y="60" fontSize="9" textAnchor="middle" className="fill-slate-500">Gireve / eMSP</text>
</g>
<g id="flux-data">
<path id="link-ocpp" d="M 820 230 L 820 160 L 950 160 L 950 220" fill="none" stroke="#3b82f6" strokeWidth="2" className="dash-anim" />
<path id="link-ocpi" d="M 1050 240 L 1100 240 L 1100 240 L 1180 240" fill="none" stroke="#3b82f6" strokeWidth="2" className="dash-anim" />
</g>
<g className="intercept-group cursor-pointer" onClick={() => onInterceptClick('ocpp')}>
<circle cx="885" cy="160" r="12" className="halo fill-blue-500" />
<circle cx="885" cy="160" r="8" className="fill-white stroke-blue-600 stroke-[2]" />
<text x="885" y="163" fontSize="8" textAnchor="middle" className="font-sans fill-blue-600 font-bold">OCPP</text>
</g>
<g className="intercept-group cursor-pointer" onClick={() => onInterceptClick('ocpi')}>
<circle cx="1115" cy="240" r="12" className="halo fill-blue-500" />
<circle cx="1115" cy="240" r="8" className="fill-white stroke-blue-600 stroke-[2]" />
<text x="1115" y="243" fontSize="8" textAnchor="middle" className="font-sans fill-blue-600 font-bold">OCPI</text>
</g>
</svg>
</div>
);
}

View file

@ -0,0 +1,207 @@
import React, { useState, useEffect, useRef } from 'react';
export default function SnifferPanel({ cpId, activeTab }) {
const [frames, setFrames] = useState([]);
const [wsStatus, setWsStatus] = useState('disconnected');
const [isReplaying, setIsReplaying] = useState(false);
const [ocpiLogs] = useState([
{ method: 'GET', endpoint: '/ocpi/2.2.1/tokens/CHARGEMAP_PASS', status: 200, latency: '820ms', payload: { valid: true } },
{ method: 'POST', endpoint: '/ocpi/2.2.1/sessions/SESS-99', status: 201, latency: '450ms', payload: { id: 'SESS-99', status: 'ACTIVE' } }
]);
const [filter, setFilter] = useState('');
const [expanded, setExpanded] = useState(null);
const wsRef = useRef(null);
const reconnectTimer = useRef(null);
const backoff = useRef(1000);
const replayTimer = useRef(null);
const fileInputRef = useRef(null);
const fetchHistory = async () => {
try {
const res = await fetch(`http://localhost:8000/api/chargepoints/${cpId || 'SIM-001'}/frames`);
if (res.ok) {
const data = await res.json();
setFrames(data.reverse().slice(0, 50));
}
} catch (err) {}
};
useEffect(() => {
if (isReplaying) return;
let isClosedByUser = false;
const connect = () => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) return;
const ws = new WebSocket(`ws://localhost:8000/api/live`);
wsRef.current = ws;
setWsStatus('connecting');
ws.onopen = () => { backoff.current = 1000; setWsStatus('connected'); fetchHistory(); };
ws.onmessage = (e) => {
try {
const frame = JSON.parse(e.data);
if (!cpId || frame.cp_id === cpId) {
setFrames(prev => {
if (prev.some(f => f.uniqueId === frame.uniqueId && f.direction === frame.direction && f.timestamp === frame.timestamp)) {
console.warn(`[Sniffer] Doublon écarté: ${frame.action} ${frame.uniqueId}`);
return prev;
}
return [frame, ...prev].slice(0, 100);
});
}
} catch (err) {}
};
ws.onclose = (e) => {
setWsStatus('disconnected');
if (!isClosedByUser) {
setWsStatus('reconnecting');
reconnectTimer.current = setTimeout(() => {
backoff.current = Math.min(backoff.current * 2, 10000);
connect();
}, backoff.current);
}
};
};
connect();
return () => {
isClosedByUser = true;
if (reconnectTimer.current) clearTimeout(reconnectTimer.current);
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) wsRef.current.close();
};
}, [cpId, isReplaying]);
const handleExport = () => {
const blob = new Blob([JSON.stringify(frames, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `ocpp_session_${cpId || 'export'}.json`;
a.click();
};
const handlePurge = async () => {
if (window.confirm(`Purger l'historique de ${cpId} ?`)) {
try {
await fetch(`http://localhost:8000/api/chargepoints/${cpId}/frames`, { method: 'DELETE' });
setFrames([]);
} catch (e) {}
}
};
const handleImportClick = () => fileInputRef.current?.click();
const handleFileChange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importedFrames = JSON.parse(event.target.result);
if (Array.isArray(importedFrames)) {
setIsReplaying(true);
setFrames([]);
let i = 0;
replayTimer.current = setInterval(() => {
if (i < importedFrames.length) {
setFrames(prev => [importedFrames[i], ...prev].slice(0, 100));
i++;
} else {
clearInterval(replayTimer.current);
setIsReplaying(false);
}
}, 800);
}
} catch (err) {
alert("Fichier JSON invalide");
}
};
reader.readAsText(file);
e.target.value = null;
};
const filteredFrames = frames.filter(f =>
(!filter || String(f.action).toLowerCase().includes(filter.toLowerCase()))
);
const statusColors = {
connected: 'bg-emerald-500', connecting: 'bg-amber-500 animate-pulse',
reconnecting: 'bg-amber-500 animate-pulse', disconnected: 'bg-red-500'
};
const formatDate = (ts) => {
if (!ts) return '';
const d = new Date(ts * 1000);
return d.toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
};
return (
<div className="flex flex-col h-full bg-slate-900 text-slate-200 font-mono text-xs overflow-hidden">
<input type="file" ref={fileInputRef} onChange={handleFileChange} accept=".json" className="hidden" />
<div className="p-2 border-b border-slate-700 flex justify-between items-center bg-slate-800/50">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${isReplaying ? 'bg-purple-500 animate-pulse' : statusColors[wsStatus]}`}></span>
<span>{isReplaying ? 'Mode Replay' : 'Sniffer'} {cpId ? `(${cpId})` : ''} - {activeTab.toUpperCase()}</span>
</div>
{activeTab === 'ocpp' && (
<div className="flex gap-1 items-center">
<input type="text" placeholder="Filtrer..." value={filter} onChange={(e) => setFilter(e.target.value)} className="bg-slate-700 text-slate-200 px-2 py-1 rounded border border-slate-600 outline-none focus:border-blue-500 w-20" />
<button onClick={handlePurge} className="px-2 py-1 bg-red-900 hover:bg-red-800 rounded text-[10px] uppercase">Purger</button>
<button onClick={handleImportClick} className="px-2 py-1 bg-purple-700 hover:bg-purple-600 rounded text-[10px] uppercase">Replay</button>
<button onClick={handleExport} className="px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded text-[10px] uppercase">Export</button>
</div>
)}
</div>
{activeTab === 'ocpp' ? (
<div className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
{filteredFrames.length === 0 && (
<div className="text-slate-500 italic p-4 text-center">
{isReplaying ? 'Rejeu en cours...' : wsStatus === 'connected' ? `En attente de trames pour ${cpId}...` : `Sniffer ${wsStatus}...`}
</div>
)}
{filteredFrames.map((f, i) => (
<div key={i} className={`border rounded p-1.5 hover:bg-slate-800 cursor-pointer ${f.schema_error ? 'border-red-500 bg-red-950/30' : 'border-slate-700'}`} onClick={() => setExpanded(expanded === i ? null : i)}>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<span className={f.direction === 'IN' ? 'text-emerald-400' : 'text-blue-400'}>{f.direction === 'IN' ? 'IN' : 'OUT'}</span>
<span className="font-semibold text-slate-300">{String(f.action)}</span>
{f.schema_error && <span className="text-red-500 text-[9px] font-bold ml-1">SCHEMA</span>}
</div>
<span className="text-slate-500 text-[10px]">{formatDate(f.timestamp)}</span>
</div>
{expanded === i && (
<pre className="mt-1 text-[10px] text-slate-400 bg-slate-950 p-1 rounded overflow-x-auto max-h-40 overflow-y-auto">
{JSON.stringify(f.payload, null, 2)}
</pre>
)}
</div>
))}
</div>
) : (
<div className="flex-1 overflow-y-auto p-2 space-y-1 min-h-0">
<div className="text-slate-500 italic p-2 text-center text-[10px]">Flux REST CPO - Roaming Hub. Simulé.</div>
{ocpiLogs.map((log, i) => (
<div key={i} className="border border-slate-700 rounded p-1.5 hover:bg-slate-800 cursor-pointer" onClick={() => setExpanded(expanded === i ? null : i)}>
<div className="flex justify-between items-center">
<div className="flex gap-2 items-center">
<span className="text-purple-400 font-bold">{log.method}</span>
<span className="text-slate-300">{log.endpoint}</span>
</div>
<div className="flex gap-2 items-center">
<span className="text-amber-400">{log.latency}</span>
<span className="text-emerald-400 font-bold">{log.status}</span>
</div>
</div>
{expanded === i && <pre className="mt-1 text-[10px] text-slate-400 bg-slate-950 p-1 rounded overflow-x-auto">{JSON.stringify(log.payload, null, 2)}</pre>}
</div>
))}
</div>
)}
</div>
);
}

11
frontend/src/index.css Normal file
View file

@ -0,0 +1,11 @@
@import "tailwindcss";
@keyframes flow {
to { background-position: 200% 0; }
}
.animate-flow {
background-image: linear-gradient(90deg, transparent 0%, #3b82f6 50%, transparent 100%);
background-size: 200% 100%;
animation: flow 1.5s linear infinite;
}

10
frontend/src/main.jsx Normal file
View file

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

View file

@ -0,0 +1,6 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: { extend: {} },
plugins: [],
}

17
frontend/vite.config.js Normal file
View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: ['localhost', '0.0.0.0', '127.0.0.1'],
proxy: {
'/api': 'http://backend:8000',
'/ocpp': {
target: 'ws://backend:8000',
ws: true
}
}
}
})