Initial commit
This commit is contained in:
447
omop/frontend/src/App.css
Normal file
447
omop/frontend/src/App.css
Normal file
@@ -0,0 +1,447 @@
|
||||
.app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: #2c3e50;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
position: fixed;
|
||||
height: 100vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
margin-bottom: 30px;
|
||||
font-size: 24px;
|
||||
border-bottom: 2px solid #3498db;
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.nav-links {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.nav-links li {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.nav-links a {
|
||||
color: #ecf0f1;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
padding: 12px 15px;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.nav-links a:hover {
|
||||
background: #34495e;
|
||||
transform: translateX(5px);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 250px;
|
||||
flex: 1;
|
||||
padding: 30px;
|
||||
width: calc(100% - 250px);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
font-size: 32px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.page-header p {
|
||||
color: #7f8c8d;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 25px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
font-size: 20px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
border-left: 4px solid #3498db;
|
||||
}
|
||||
|
||||
.stat-card.success {
|
||||
border-left-color: #27ae60;
|
||||
}
|
||||
|
||||
.stat-card.warning {
|
||||
border-left-color: #f39c12;
|
||||
}
|
||||
|
||||
.stat-card.error {
|
||||
border-left-color: #e74c3c;
|
||||
}
|
||||
|
||||
.stat-card h3 {
|
||||
font-size: 14px;
|
||||
color: #7f8c8d;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2980b9;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background: #27ae60;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background: #229954;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
color: #2c3e50;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3498db;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f8f9fa;
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table tr:hover {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background: #d1ecf1;
|
||||
color: #0c5460;
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 40px;
|
||||
color: #7f8c8d;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #f8d7da;
|
||||
color: #721c24;
|
||||
padding: 15px;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* Documentation Page Styles */
|
||||
.documentation-page {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.doc-layout {
|
||||
display: flex;
|
||||
gap: 30px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
width: 250px;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
position: sticky;
|
||||
top: 20px;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.doc-sidebar h3 {
|
||||
font-size: 16px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 15px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.doc-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.doc-nav-item {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 12px 15px;
|
||||
text-align: left;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
color: #7f8c8d;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.doc-nav-item:hover {
|
||||
background: #f8f9fa;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.doc-nav-item.active {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.doc-content {
|
||||
flex: 1;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 30px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
max-width: 900px;
|
||||
}
|
||||
|
||||
.doc-content h2 {
|
||||
font-size: 28px;
|
||||
color: #2c3e50;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 3px solid #3498db;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.doc-content h3 {
|
||||
font-size: 22px;
|
||||
color: #2c3e50;
|
||||
margin-top: 25px;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.doc-content h4 {
|
||||
font-size: 18px;
|
||||
color: #34495e;
|
||||
margin-top: 20px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.doc-content p {
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.doc-content ul,
|
||||
.doc-content ol {
|
||||
line-height: 1.8;
|
||||
color: #555;
|
||||
margin-bottom: 15px;
|
||||
padding-left: 25px;
|
||||
}
|
||||
|
||||
.doc-content li {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.doc-content code {
|
||||
background: #f8f9fa;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.doc-content strong {
|
||||
color: #2c3e50;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-card {
|
||||
background: #f8f9fa;
|
||||
border-left: 4px solid #3498db;
|
||||
border-radius: 5px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.doc-card h3 {
|
||||
margin-top: 0;
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
.doc-card h4 {
|
||||
margin-top: 15px;
|
||||
color: #2c3e50;
|
||||
}
|
||||
|
||||
.doc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 15px 0;
|
||||
}
|
||||
|
||||
.doc-table th,
|
||||
.doc-table td {
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
border: 1px solid #ddd;
|
||||
}
|
||||
|
||||
.doc-table th {
|
||||
background: #3498db;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.doc-table tr:nth-child(even) {
|
||||
background: #f8f9fa;
|
||||
}
|
||||
|
||||
.glossary {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.glossary dt {
|
||||
font-weight: 600;
|
||||
color: #2c3e50;
|
||||
margin-top: 15px;
|
||||
margin-bottom: 5px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.glossary dd {
|
||||
margin-left: 20px;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #ecf0f1;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 1024px) {
|
||||
.doc-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.doc-sidebar {
|
||||
width: 100%;
|
||||
position: static;
|
||||
}
|
||||
|
||||
.doc-nav {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
44
omop/frontend/src/App.jsx
Normal file
44
omop/frontend/src/App.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import ETLManager from './pages/ETLManager'
|
||||
import SchemaManager from './pages/SchemaManager'
|
||||
import Validation from './pages/Validation'
|
||||
import Logs from './pages/Logs'
|
||||
import Documentation from './pages/Documentation'
|
||||
import './App.css'
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="app">
|
||||
<nav className="sidebar">
|
||||
<div className="logo">
|
||||
<h2>OMOP Pipeline</h2>
|
||||
</div>
|
||||
<ul className="nav-links">
|
||||
<li><Link to="/">📊 Dashboard</Link></li>
|
||||
<li><Link to="/etl">⚙️ ETL Manager</Link></li>
|
||||
<li><Link to="/schema">🗄️ Schema</Link></li>
|
||||
<li><Link to="/validation">✅ Validation</Link></li>
|
||||
<li><Link to="/logs">📝 Logs</Link></li>
|
||||
<li><Link to="/documentation">📖 Documentation</Link></li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<main className="main-content">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/etl" element={<ETLManager />} />
|
||||
<Route path="/schema" element={<SchemaManager />} />
|
||||
<Route path="/validation" element={<Validation />} />
|
||||
<Route path="/logs" element={<Logs />} />
|
||||
<Route path="/documentation" element={<Documentation />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
53
omop/frontend/src/api/client.js
Normal file
53
omop/frontend/src/api/client.js
Normal file
@@ -0,0 +1,53 @@
|
||||
import axios from 'axios'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:8001/api'
|
||||
|
||||
const client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
export const api = {
|
||||
// ETL endpoints
|
||||
etl: {
|
||||
run: (data) => client.post('/etl/run', data),
|
||||
getJob: (jobId) => client.get(`/etl/jobs/${jobId}`),
|
||||
listJobs: () => client.get('/etl/jobs'),
|
||||
extract: (sourceTable, batchSize) =>
|
||||
client.post('/etl/extract', null, { params: { source_table: sourceTable, batch_size: batchSize } }),
|
||||
transform: (targetTable) =>
|
||||
client.post('/etl/transform', null, { params: { target_table: targetTable } }),
|
||||
load: (targetTable) =>
|
||||
client.post('/etl/load', null, { params: { target_table: targetTable } })
|
||||
},
|
||||
|
||||
// Schema endpoints
|
||||
schema: {
|
||||
create: (schemaType) => client.post('/schema/create', { schema_type: schemaType }),
|
||||
validate: () => client.get('/schema/validate'),
|
||||
info: () => client.get('/schema/info')
|
||||
},
|
||||
|
||||
// Stats endpoints
|
||||
stats: {
|
||||
etl: (limit) => client.get('/stats/etl', { params: { limit } }),
|
||||
dataQuality: () => client.get('/stats/data-quality'),
|
||||
summary: () => client.get('/stats/summary')
|
||||
},
|
||||
|
||||
// Validation endpoints
|
||||
validation: {
|
||||
run: (tableName) => client.post('/validation/run', null, { params: { table_name: tableName } }),
|
||||
unmappedCodes: (limit) => client.get('/validation/unmapped-codes', { params: { limit } })
|
||||
},
|
||||
|
||||
// Logs endpoints
|
||||
logs: {
|
||||
get: (lines, level) => client.get('/logs/', { params: { lines, level } }),
|
||||
errors: (limit) => client.get('/logs/errors', { params: { limit } })
|
||||
}
|
||||
}
|
||||
|
||||
export default client
|
||||
28
omop/frontend/src/components/HelpIcon.jsx
Normal file
28
omop/frontend/src/components/HelpIcon.jsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React from 'react'
|
||||
import Tooltip from './Tooltip'
|
||||
|
||||
function HelpIcon({ text }) {
|
||||
return (
|
||||
<Tooltip text={text}>
|
||||
<span style={{
|
||||
display: 'inline-block',
|
||||
width: '18px',
|
||||
height: '18px',
|
||||
borderRadius: '50%',
|
||||
background: '#3498db',
|
||||
color: 'white',
|
||||
fontSize: '12px',
|
||||
fontWeight: 'bold',
|
||||
textAlign: 'center',
|
||||
lineHeight: '18px',
|
||||
cursor: 'help',
|
||||
marginLeft: '6px',
|
||||
verticalAlign: 'middle'
|
||||
}}>
|
||||
?
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default HelpIcon
|
||||
50
omop/frontend/src/components/Tooltip.jsx
Normal file
50
omop/frontend/src/components/Tooltip.jsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import React, { useState } from 'react'
|
||||
|
||||
function Tooltip({ text, children }) {
|
||||
const [show, setShow] = useState(false)
|
||||
|
||||
return (
|
||||
<span
|
||||
style={{ position: 'relative', display: 'inline-block' }}
|
||||
onMouseEnter={() => setShow(true)}
|
||||
onMouseLeave={() => setShow(false)}
|
||||
>
|
||||
{children}
|
||||
{show && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
marginBottom: '8px',
|
||||
padding: '8px 12px',
|
||||
background: '#2c3e50',
|
||||
color: 'white',
|
||||
borderRadius: '6px',
|
||||
fontSize: '13px',
|
||||
whiteSpace: 'nowrap',
|
||||
zIndex: 1000,
|
||||
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
||||
maxWidth: '300px',
|
||||
whiteSpace: 'normal',
|
||||
textAlign: 'center'
|
||||
}}>
|
||||
{text}
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
top: '100%',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
width: 0,
|
||||
height: 0,
|
||||
borderLeft: '6px solid transparent',
|
||||
borderRight: '6px solid transparent',
|
||||
borderTop: '6px solid #2c3e50'
|
||||
}} />
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export default Tooltip
|
||||
18
omop/frontend/src/index.css
Normal file
18
omop/frontend/src/index.css
Normal file
@@ -0,0 +1,18 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
|
||||
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace;
|
||||
}
|
||||
15
omop/frontend/src/main.jsx
Normal file
15
omop/frontend/src/main.jsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import App from './App'
|
||||
import './index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>
|
||||
)
|
||||
127
omop/frontend/src/pages/Dashboard.jsx
Normal file
127
omop/frontend/src/pages/Dashboard.jsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function Dashboard() {
|
||||
const { data: summary, isLoading: summaryLoading } = useQuery({
|
||||
queryKey: ['summary'],
|
||||
queryFn: () => api.stats.summary().then(res => res.data),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
|
||||
const { data: etlStats, isLoading: etlLoading } = useQuery({
|
||||
queryKey: ['etl-stats'],
|
||||
queryFn: () => api.stats.etl(10).then(res => res.data),
|
||||
refetchInterval: 5000
|
||||
})
|
||||
|
||||
if (summaryLoading || etlLoading) {
|
||||
return <div className="loading">Chargement...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
Dashboard OMOP Pipeline
|
||||
<HelpIcon text="Vue d'ensemble en temps réel de votre pipeline de données OMOP CDM. Suivez les statistiques des tables, les exécutions ETL et l'état général du système." />
|
||||
</h1>
|
||||
<p>Vue d'ensemble du système ETL</p>
|
||||
</div>
|
||||
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card success">
|
||||
<h3>
|
||||
Patients OMOP
|
||||
<HelpIcon text="Nombre total de patients dans la table OMOP 'person'. Ces données ont été transformées et validées selon le standard OMOP CDM 5.4." />
|
||||
</h3>
|
||||
<div className="value">{summary?.summary?.omop_records?.person || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>
|
||||
Visites
|
||||
<HelpIcon text="Nombre de visites médicales enregistrées dans 'visit_occurrence'. Chaque visite représente une interaction patient-établissement de santé." />
|
||||
</h3>
|
||||
<div className="value">{summary?.summary?.omop_records?.visit_occurrence || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<h3>
|
||||
Conditions
|
||||
<HelpIcon text="Nombre de diagnostics/conditions médicales dans 'condition_occurrence'. Inclut les maladies, symptômes et diagnostics des patients." />
|
||||
</h3>
|
||||
<div className="value">{summary?.summary?.omop_records?.condition_occurrence || 0}</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card warning">
|
||||
<h3>
|
||||
En attente
|
||||
<HelpIcon text="Nombre d'enregistrements dans les tables de staging avec le statut 'pending'. Ces données attendent d'être traitées par le pipeline ETL." />
|
||||
</h3>
|
||||
<div className="value">{summary?.summary?.staging_pending || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Exécutions récentes (24h)
|
||||
<HelpIcon text="Statistiques des pipelines ETL exécutés dans les dernières 24 heures. Permet de suivre le taux de succès et d'identifier les problèmes." />
|
||||
</h2>
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<h3>Total</h3>
|
||||
<div className="value">{summary?.summary?.executions_24h?.total || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card success">
|
||||
<h3>Réussies</h3>
|
||||
<div className="value">{summary?.summary?.executions_24h?.completed || 0}</div>
|
||||
</div>
|
||||
<div className="stat-card error">
|
||||
<h3>Échouées</h3>
|
||||
<div className="value">{summary?.summary?.executions_24h?.failed || 0}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Historique ETL
|
||||
<HelpIcon text="Liste détaillée des 10 dernières exécutions ETL avec leur statut, nombre d'enregistrements traités et durée d'exécution." />
|
||||
</h2>
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Pipeline</th>
|
||||
<th>Début</th>
|
||||
<th>Statut</th>
|
||||
<th>Enregistrements</th>
|
||||
<th>Échecs</th>
|
||||
<th>Durée (s)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{etlStats?.stats?.map((stat, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{stat.pipeline_name}</td>
|
||||
<td>{new Date(stat.start_time).toLocaleString('fr-FR')}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${stat.status === 'completed' ? 'success' : stat.status === 'failed' ? 'error' : 'warning'}`}>
|
||||
{stat.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{stat.records_processed}</td>
|
||||
<td>{stat.records_failed}</td>
|
||||
<td>{stat.duration_seconds?.toFixed(2)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
423
omop/frontend/src/pages/Documentation.jsx
Normal file
423
omop/frontend/src/pages/Documentation.jsx
Normal file
@@ -0,0 +1,423 @@
|
||||
import React, { useState } from 'react'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function Documentation() {
|
||||
const [activeSection, setActiveSection] = useState('overview')
|
||||
|
||||
const sections = {
|
||||
overview: {
|
||||
title: '📖 Vue d\'ensemble',
|
||||
content: (
|
||||
<>
|
||||
<h2>Bienvenue dans OMOP Pipeline</h2>
|
||||
<p>
|
||||
Cette application vous permet de transformer vos données de santé brutes en format
|
||||
<strong> OMOP CDM 5.4</strong> (Observational Medical Outcomes Partnership Common Data Model).
|
||||
</p>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>🎯 Objectif</h3>
|
||||
<p>
|
||||
Le pipeline OMOP standardise vos données de santé pour permettre des analyses
|
||||
interopérables et des études observationnelles à grande échelle.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>🔄 Workflow Général</h3>
|
||||
<ol>
|
||||
<li><strong>Staging</strong> : Chargement des données brutes</li>
|
||||
<li><strong>ETL</strong> : Transformation au format OMOP</li>
|
||||
<li><strong>Validation</strong> : Vérification de la qualité</li>
|
||||
<li><strong>Exploitation</strong> : Analyses et requêtes</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>📊 Architecture</h3>
|
||||
<ul>
|
||||
<li><strong>Schéma OMOP</strong> : Tables standardisées (person, visit_occurrence, etc.)</li>
|
||||
<li><strong>Schéma Staging</strong> : Tables temporaires pour données brutes</li>
|
||||
<li><strong>Schéma Audit</strong> : Logs et traçabilité des transformations</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
etl: {
|
||||
title: '⚙️ ETL (Extract-Transform-Load)',
|
||||
content: (
|
||||
<>
|
||||
<h2>Processus ETL</h2>
|
||||
<p>
|
||||
<strong>ETL</strong> signifie Extract-Transform-Load (Extraire-Transformer-Charger).
|
||||
C'est le cœur du pipeline OMOP.
|
||||
</p>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>1️⃣ Extract (Extraction)</h3>
|
||||
<p>
|
||||
Les données sont extraites des tables de staging où elles ont été chargées
|
||||
depuis vos sources (fichiers CSV, bases de données, APIs, etc.).
|
||||
</p>
|
||||
<ul>
|
||||
<li>Tables source : <code>staging.raw_patients</code>, <code>staging.raw_visits</code>, etc.</li>
|
||||
<li>Seuls les enregistrements avec <code>status='pending'</code> sont traités</li>
|
||||
<li>Traitement par lots (batch) pour optimiser les performances</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>2️⃣ Transform (Transformation)</h3>
|
||||
<p>
|
||||
Les données sont transformées pour correspondre au modèle OMOP CDM 5.4 :
|
||||
</p>
|
||||
<ul>
|
||||
<li><strong>Mapping des codes</strong> : Conversion vers vocabulaires OMOP (SNOMED, ICD10, etc.)</li>
|
||||
<li><strong>Normalisation</strong> : Formats de dates, types de données, unités</li>
|
||||
<li><strong>Enrichissement</strong> : Ajout de métadonnées et références</li>
|
||||
<li><strong>Validation</strong> : Vérification des contraintes et règles métier</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>3️⃣ Load (Chargement)</h3>
|
||||
<p>
|
||||
Les données transformées sont chargées dans les tables OMOP finales :
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>person</code> : Informations démographiques des patients</li>
|
||||
<li><code>visit_occurrence</code> : Visites et séjours hospitaliers</li>
|
||||
<li><code>condition_occurrence</code> : Diagnostics et conditions médicales</li>
|
||||
<li><code>drug_exposure</code> : Prescriptions et administrations médicamenteuses</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>⚡ Paramètres de Performance</h3>
|
||||
<table className="doc-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Paramètre</th>
|
||||
<th>Description</th>
|
||||
<th>Recommandation</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Batch Size</strong></td>
|
||||
<td>Nombre d'enregistrements par lot</td>
|
||||
<td>1000-5000 (selon RAM disponible)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Workers</strong></td>
|
||||
<td>Processus parallèles</td>
|
||||
<td>4-8 (selon CPU disponibles)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Mode séquentiel</strong></td>
|
||||
<td>Désactive la parallélisation</td>
|
||||
<td>Uniquement pour débogage</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
schemas: {
|
||||
title: '🗄️ Schémas de Base de Données',
|
||||
content: (
|
||||
<>
|
||||
<h2>Architecture des Schémas</h2>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>📦 Schéma OMOP</h3>
|
||||
<p>
|
||||
Contient les tables standardisées selon OMOP CDM 5.4. C'est le schéma principal
|
||||
pour vos analyses.
|
||||
</p>
|
||||
<h4>Tables principales :</h4>
|
||||
<ul>
|
||||
<li><code>person</code> : Patients (démographie, genre, année de naissance)</li>
|
||||
<li><code>visit_occurrence</code> : Visites médicales et hospitalisations</li>
|
||||
<li><code>condition_occurrence</code> : Diagnostics et conditions</li>
|
||||
<li><code>drug_exposure</code> : Prescriptions médicamenteuses</li>
|
||||
<li><code>procedure_occurrence</code> : Actes et procédures médicales</li>
|
||||
<li><code>measurement</code> : Mesures et résultats de laboratoire</li>
|
||||
<li><code>observation</code> : Observations cliniques diverses</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>📥 Schéma Staging</h3>
|
||||
<p>
|
||||
Zone de transit pour les données brutes avant transformation. Les données
|
||||
y sont chargées depuis vos sources externes.
|
||||
</p>
|
||||
<h4>Tables de staging :</h4>
|
||||
<ul>
|
||||
<li><code>raw_patients</code> : Données patients brutes</li>
|
||||
<li><code>raw_visits</code> : Données de visites brutes</li>
|
||||
<li><code>raw_conditions</code> : Diagnostics bruts</li>
|
||||
<li><code>raw_drugs</code> : Prescriptions brutes</li>
|
||||
</ul>
|
||||
<p>
|
||||
Chaque enregistrement a un <code>status</code> :
|
||||
<span className="badge badge-warning">pending</span>,
|
||||
<span className="badge badge-success">processed</span>, ou
|
||||
<span className="badge badge-error">failed</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>📝 Schéma Audit</h3>
|
||||
<p>
|
||||
Traçabilité complète des transformations ETL pour conformité et débogage.
|
||||
</p>
|
||||
<h4>Tables d'audit :</h4>
|
||||
<ul>
|
||||
<li><code>etl_execution</code> : Historique des exécutions ETL</li>
|
||||
<li><code>etl_execution_stats</code> : Statistiques détaillées par exécution</li>
|
||||
<li><code>data_quality_errors</code> : Erreurs de validation détectées</li>
|
||||
<li><code>unmapped_codes</code> : Codes sources sans mapping OMOP</li>
|
||||
</ul>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
validation: {
|
||||
title: '✅ Validation et Qualité',
|
||||
content: (
|
||||
<>
|
||||
<h2>Validation des Données</h2>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>🎯 Objectifs de la Validation</h3>
|
||||
<ul>
|
||||
<li>Vérifier la conformité au standard OMOP CDM 5.4</li>
|
||||
<li>Détecter les erreurs de transformation</li>
|
||||
<li>Identifier les codes non mappés</li>
|
||||
<li>Assurer l'intégrité référentielle</li>
|
||||
<li>Valider les contraintes métier</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>🔍 Types de Validation</h3>
|
||||
|
||||
<h4>1. Validation Structurelle</h4>
|
||||
<ul>
|
||||
<li>Présence des champs obligatoires</li>
|
||||
<li>Types de données corrects</li>
|
||||
<li>Formats de dates valides</li>
|
||||
<li>Valeurs dans les plages autorisées</li>
|
||||
</ul>
|
||||
|
||||
<h4>2. Validation Référentielle</h4>
|
||||
<ul>
|
||||
<li>Existence des patients référencés</li>
|
||||
<li>Cohérence des dates (visite avant diagnostic, etc.)</li>
|
||||
<li>Validité des codes dans les vocabulaires OMOP</li>
|
||||
</ul>
|
||||
|
||||
<h4>3. Validation Métier</h4>
|
||||
<ul>
|
||||
<li>Âge cohérent avec l'année de naissance</li>
|
||||
<li>Genre compatible avec les conditions</li>
|
||||
<li>Durées de séjour réalistes</li>
|
||||
<li>Dosages médicamenteux dans les normes</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>⚠️ Codes Non Mappés</h3>
|
||||
<p>
|
||||
Les codes non mappés sont des codes sources (ICD10, CIM10, etc.) qui n'ont pas
|
||||
de correspondance dans les vocabulaires OMOP standard.
|
||||
</p>
|
||||
<h4>Actions recommandées :</h4>
|
||||
<ol>
|
||||
<li>Vérifier si le code existe dans le vocabulaire source</li>
|
||||
<li>Chercher un code équivalent ou parent</li>
|
||||
<li>Créer un mapping personnalisé si nécessaire</li>
|
||||
<li>Documenter les codes non mappables</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
glossary: {
|
||||
title: '📚 Glossaire',
|
||||
content: (
|
||||
<>
|
||||
<h2>Glossaire des Termes</h2>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>A-E</h3>
|
||||
<dl className="glossary">
|
||||
<dt>Audit</dt>
|
||||
<dd>Traçabilité des transformations et modifications de données</dd>
|
||||
|
||||
<dt>Batch</dt>
|
||||
<dd>Lot d'enregistrements traités ensemble pour optimiser les performances</dd>
|
||||
|
||||
<dt>CDM (Common Data Model)</dt>
|
||||
<dd>Modèle de données commun standardisé par OHDSI</dd>
|
||||
|
||||
<dt>Concept</dt>
|
||||
<dd>Terme standardisé dans un vocabulaire OMOP (maladie, médicament, etc.)</dd>
|
||||
|
||||
<dt>ETL</dt>
|
||||
<dd>Extract-Transform-Load : processus de transformation des données</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>M-S</h3>
|
||||
<dl className="glossary">
|
||||
<dt>Mapping</dt>
|
||||
<dd>Correspondance entre un code source et un concept OMOP standard</dd>
|
||||
|
||||
<dt>OHDSI</dt>
|
||||
<dd>Observational Health Data Sciences and Informatics (consortium international)</dd>
|
||||
|
||||
<dt>OMOP</dt>
|
||||
<dd>Observational Medical Outcomes Partnership</dd>
|
||||
|
||||
<dt>Pipeline</dt>
|
||||
<dd>Chaîne de traitement automatisée des données</dd>
|
||||
|
||||
<dt>Staging</dt>
|
||||
<dd>Zone temporaire de stockage des données brutes avant transformation</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>V-W</h3>
|
||||
<dl className="glossary">
|
||||
<dt>Vocabulaire</dt>
|
||||
<dd>Ensemble standardisé de termes médicaux (SNOMED, ICD10, RxNorm, etc.)</dd>
|
||||
|
||||
<dt>Worker</dt>
|
||||
<dd>Processus parallèle qui traite une partie des données</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
},
|
||||
faq: {
|
||||
title: '❓ FAQ',
|
||||
content: (
|
||||
<>
|
||||
<h2>Questions Fréquentes</h2>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>🚀 Démarrage</h3>
|
||||
|
||||
<h4>Comment démarrer avec OMOP Pipeline ?</h4>
|
||||
<ol>
|
||||
<li>Créez les schémas (page Schema Manager)</li>
|
||||
<li>Chargez vos données brutes dans les tables staging</li>
|
||||
<li>Lancez un pipeline ETL (page ETL Manager)</li>
|
||||
<li>Validez les résultats (page Validation)</li>
|
||||
</ol>
|
||||
|
||||
<h4>Mes données sont-elles sécurisées ?</h4>
|
||||
<p>
|
||||
Oui. Les données restent dans votre base PostgreSQL locale. Aucune donnée
|
||||
n'est envoyée à l'extérieur. Assurez-vous de sécuriser votre base de données
|
||||
selon vos politiques de sécurité.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>⚙️ ETL</h3>
|
||||
|
||||
<h4>Combien de temps prend un pipeline ETL ?</h4>
|
||||
<p>
|
||||
Cela dépend du volume de données et des paramètres :
|
||||
</p>
|
||||
<ul>
|
||||
<li>100 patients : ~10-30 secondes</li>
|
||||
<li>1000 patients : ~1-3 minutes</li>
|
||||
<li>10000 patients : ~10-30 minutes</li>
|
||||
</ul>
|
||||
|
||||
<h4>Que faire si un pipeline échoue ?</h4>
|
||||
<ol>
|
||||
<li>Consultez les logs (page Logs)</li>
|
||||
<li>Vérifiez les erreurs de validation</li>
|
||||
<li>Corrigez les données sources si nécessaire</li>
|
||||
<li>Relancez le pipeline</li>
|
||||
</ol>
|
||||
|
||||
<h4>Puis-je relancer un pipeline sur les mêmes données ?</h4>
|
||||
<p>
|
||||
Oui, mais seuls les enregistrements avec <code>status='pending'</code> seront
|
||||
traités. Les enregistrements déjà traités sont ignorés.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="doc-card">
|
||||
<h3>📊 Données</h3>
|
||||
|
||||
<h4>Pourquoi ai-je des codes non mappés ?</h4>
|
||||
<p>
|
||||
Les codes non mappés apparaissent quand un code source n'a pas de correspondance
|
||||
dans les vocabulaires OMOP. Cela peut arriver si :
|
||||
</p>
|
||||
<ul>
|
||||
<li>Le code est obsolète ou incorrect</li>
|
||||
<li>Le vocabulaire OMOP n'est pas à jour</li>
|
||||
<li>Un mapping personnalisé est nécessaire</li>
|
||||
</ul>
|
||||
|
||||
<h4>Comment améliorer la qualité de mes données ?</h4>
|
||||
<ol>
|
||||
<li>Utilisez la page Validation régulièrement</li>
|
||||
<li>Corrigez les codes non mappés</li>
|
||||
<li>Vérifiez les erreurs dans les logs</li>
|
||||
<li>Assurez-vous que vos données sources sont complètes</li>
|
||||
</ol>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="documentation-page">
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
📖 Documentation
|
||||
<HelpIcon text="Documentation complète de l'application OMOP Pipeline. Consultez les guides, le glossaire et les FAQ pour maîtriser l'outil." />
|
||||
</h1>
|
||||
<p>Guide complet d'utilisation de OMOP Pipeline</p>
|
||||
</div>
|
||||
|
||||
<div className="doc-layout">
|
||||
<aside className="doc-sidebar">
|
||||
<h3>Sections</h3>
|
||||
<nav className="doc-nav">
|
||||
{Object.entries(sections).map(([key, section]) => (
|
||||
<button
|
||||
key={key}
|
||||
className={`doc-nav-item ${activeSection === key ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(key)}
|
||||
>
|
||||
{section.title}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="doc-content">
|
||||
{sections[activeSection].content}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Documentation
|
||||
175
omop/frontend/src/pages/ETLManager.jsx
Normal file
175
omop/frontend/src/pages/ETLManager.jsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function ETLManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [formData, setFormData] = useState({
|
||||
source_table: 'staging.raw_patients',
|
||||
target_table: 'person',
|
||||
batch_size: 1000,
|
||||
num_workers: 8,
|
||||
sequential: false
|
||||
})
|
||||
|
||||
const { data: jobs } = useQuery({
|
||||
queryKey: ['etl-jobs'],
|
||||
queryFn: () => api.etl.listJobs().then(res => res.data),
|
||||
refetchInterval: 2000
|
||||
})
|
||||
|
||||
const runMutation = useMutation({
|
||||
mutationFn: (data) => api.etl.run(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['etl-jobs'])
|
||||
alert('Pipeline ETL démarré avec succès!')
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Erreur: ${error.response?.data?.detail || error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault()
|
||||
runMutation.mutate(formData)
|
||||
}
|
||||
|
||||
const handleChange = (e) => {
|
||||
const value = e.target.type === 'checkbox' ? e.target.checked : e.target.value
|
||||
setFormData({ ...formData, [e.target.name]: value })
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
Gestionnaire ETL
|
||||
<HelpIcon text="ETL signifie Extract-Transform-Load (Extraire-Transformer-Charger). Ce processus extrait les données brutes du staging, les transforme au format OMOP CDM, et les charge dans les tables OMOP finales." />
|
||||
</h1>
|
||||
<p>Lancer et gérer les pipelines ETL</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Nouveau Pipeline ETL
|
||||
<HelpIcon text="Configurez et lancez un nouveau pipeline ETL pour transformer vos données brutes en format OMOP CDM standardisé." />
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Table source
|
||||
<HelpIcon text="Table de staging contenant les données brutes à traiter. Les données doivent avoir le statut 'pending' pour être traitées." />
|
||||
</label>
|
||||
<select name="source_table" value={formData.source_table} onChange={handleChange}>
|
||||
<option value="staging.raw_patients">staging.raw_patients</option>
|
||||
<option value="staging.raw_visits">staging.raw_visits</option>
|
||||
<option value="staging.raw_conditions">staging.raw_conditions</option>
|
||||
<option value="staging.raw_drugs">staging.raw_drugs</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Table cible
|
||||
<HelpIcon text="Table OMOP CDM de destination où les données transformées seront chargées. Doit correspondre au type de données source." />
|
||||
</label>
|
||||
<select name="target_table" value={formData.target_table} onChange={handleChange}>
|
||||
<option value="person">person</option>
|
||||
<option value="visit_occurrence">visit_occurrence</option>
|
||||
<option value="condition_occurrence">condition_occurrence</option>
|
||||
<option value="drug_exposure">drug_exposure</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Taille de batch
|
||||
<HelpIcon text="Nombre d'enregistrements traités par lot. Des valeurs plus élevées (1000-5000) améliorent les performances mais consomment plus de mémoire." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="batch_size"
|
||||
value={formData.batch_size}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
Nombre de workers
|
||||
<HelpIcon text="Nombre de processus parallèles pour le traitement. Recommandé: 4-8 workers. Plus de workers = traitement plus rapide mais plus de charge CPU." />
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
name="num_workers"
|
||||
value={formData.num_workers}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="sequential"
|
||||
checked={formData.sequential}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{' '}Mode séquentiel (pas de parallélisation)
|
||||
<HelpIcon text="Active le traitement séquentiel (un enregistrement à la fois). Plus lent mais utile pour le débogage ou les petits volumes de données." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="btn btn-primary" disabled={runMutation.isPending}>
|
||||
{runMutation.isPending ? 'Démarrage...' : '🚀 Lancer le pipeline'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Jobs en cours
|
||||
<HelpIcon text="Liste des pipelines ETL actuellement en cours d'exécution avec leur progression en temps réel. Rafraîchissement automatique toutes les 2 secondes." />
|
||||
</h2>
|
||||
{Object.keys(jobs || {}).length === 0 ? (
|
||||
<p>Aucun job en cours</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Job ID</th>
|
||||
<th>Statut</th>
|
||||
<th>Progression</th>
|
||||
<th>Détails</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(jobs || {}).map(([jobId, job]) => (
|
||||
<tr key={jobId}>
|
||||
<td>{jobId}</td>
|
||||
<td>
|
||||
<span className={`badge badge-${job.status === 'completed' ? 'success' : job.status === 'failed' ? 'error' : 'warning'}`}>
|
||||
{job.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>{job.progress || 0}%</td>
|
||||
<td>
|
||||
{job.stats && (
|
||||
<span>
|
||||
{job.stats.records_processed} enregistrements traités
|
||||
</span>
|
||||
)}
|
||||
{job.error && <span className="error-message">{job.error}</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ETLManager
|
||||
116
omop/frontend/src/pages/Logs.jsx
Normal file
116
omop/frontend/src/pages/Logs.jsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function Logs() {
|
||||
const [lines, setLines] = useState(100)
|
||||
const [level, setLevel] = useState('')
|
||||
|
||||
const { data: logs } = useQuery({
|
||||
queryKey: ['logs', lines, level],
|
||||
queryFn: () => api.logs.get(lines, level).then(res => res.data),
|
||||
refetchInterval: 3000
|
||||
})
|
||||
|
||||
const { data: errors } = useQuery({
|
||||
queryKey: ['error-logs'],
|
||||
queryFn: () => api.logs.errors(50).then(res => res.data)
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
Logs système
|
||||
<HelpIcon text="Consultez les logs d'application et les erreurs de validation. Utile pour diagnostiquer les problèmes et suivre l'activité du système." />
|
||||
</h1>
|
||||
<p>Consulter les logs et erreurs</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Filtres
|
||||
<HelpIcon text="Filtrez les logs par nombre de lignes et niveau de sévérité (INFO, WARNING, ERROR, CRITICAL). Les logs se rafraîchissent automatiquement toutes les 3 secondes." />
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '15px', marginBottom: '20px' }}>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Nombre de lignes</label>
|
||||
<select value={lines} onChange={(e) => setLines(Number(e.target.value))}>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
<option value={500}>500</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: 0 }}>
|
||||
<label>Niveau</label>
|
||||
<select value={level} onChange={(e) => setLevel(e.target.value)}>
|
||||
<option value="">Tous</option>
|
||||
<option value="INFO">INFO</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Logs récents
|
||||
<HelpIcon text="Affichage en temps réel des logs d'application. Les messages incluent l'horodatage, le niveau de sévérité et les détails de l'événement." />
|
||||
</h2>
|
||||
<div style={{
|
||||
background: '#1e1e1e',
|
||||
color: '#d4d4d4',
|
||||
padding: '15px',
|
||||
borderRadius: '5px',
|
||||
fontFamily: 'monospace',
|
||||
fontSize: '12px',
|
||||
maxHeight: '400px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
{logs?.logs?.map((line, idx) => (
|
||||
<div key={idx}>{line}</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Erreurs de validation
|
||||
<HelpIcon text="Erreurs détectées lors de la validation des données OMOP. Chaque erreur indique la table, l'enregistrement concerné et le type de problème rencontré." />
|
||||
</h2>
|
||||
{errors?.errors?.length === 0 ? (
|
||||
<p>Aucune erreur trouvée</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Table</th>
|
||||
<th>Record ID</th>
|
||||
<th>Type</th>
|
||||
<th>Message</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{errors?.errors?.map((error) => (
|
||||
<tr key={error.error_id}>
|
||||
<td>{error.table_name}</td>
|
||||
<td>{error.record_id}</td>
|
||||
<td><span className="badge badge-error">{error.error_type}</span></td>
|
||||
<td>{error.error_message}</td>
|
||||
<td>{new Date(error.error_time).toLocaleString('fr-FR')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logs
|
||||
111
omop/frontend/src/pages/SchemaManager.jsx
Normal file
111
omop/frontend/src/pages/SchemaManager.jsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function SchemaManager() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: schemaInfo } = useQuery({
|
||||
queryKey: ['schema-info'],
|
||||
queryFn: () => api.schema.info().then(res => res.data)
|
||||
})
|
||||
|
||||
const { data: validation } = useQuery({
|
||||
queryKey: ['schema-validation'],
|
||||
queryFn: () => api.schema.validate().then(res => res.data)
|
||||
})
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (schemaType) => api.schema.create(schemaType),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries(['schema-info'])
|
||||
alert('Schéma créé avec succès!')
|
||||
},
|
||||
onError: (error) => {
|
||||
alert(`Erreur: ${error.response?.data?.detail || error.message}`)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
Gestion des Schémas
|
||||
<HelpIcon text="Gérez les schémas de base de données PostgreSQL. Le schéma OMOP contient les tables standardisées, Staging les données brutes, et Audit les logs d'exécution." />
|
||||
</h1>
|
||||
<p>Créer et valider les schémas de base de données</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Créer les schémas
|
||||
<HelpIcon text="Créez les schémas et tables nécessaires dans PostgreSQL. Utilisez 'Créer tous les schémas' pour une installation complète ou créez-les individuellement." />
|
||||
</h2>
|
||||
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => createMutation.mutate('all')}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Créer tous les schémas
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => createMutation.mutate('omop')}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Schéma OMOP
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => createMutation.mutate('staging')}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Schéma Staging
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => createMutation.mutate('audit')}
|
||||
disabled={createMutation.isPending}
|
||||
>
|
||||
Schéma Audit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
État des schémas
|
||||
<HelpIcon text="Validation automatique des schémas. Vérifie que toutes les tables requises existent et sont correctement structurées selon OMOP CDM 5.4." />
|
||||
</h2>
|
||||
{validation && (
|
||||
<div className={validation.valid ? 'badge-success' : 'badge-error'} style={{ padding: '15px', borderRadius: '5px', marginBottom: '20px' }}>
|
||||
{validation.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{schemaInfo?.schemas && (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Schéma</th>
|
||||
<th>Nombre de tables</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.entries(schemaInfo.schemas).map(([schema, count]) => (
|
||||
<tr key={schema}>
|
||||
<td><strong>{schema}</strong></td>
|
||||
<td>{count}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default SchemaManager
|
||||
82
omop/frontend/src/pages/Validation.jsx
Normal file
82
omop/frontend/src/pages/Validation.jsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { api } from '../api/client'
|
||||
import HelpIcon from '../components/HelpIcon'
|
||||
|
||||
function Validation() {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: unmappedCodes } = useQuery({
|
||||
queryKey: ['unmapped-codes'],
|
||||
queryFn: () => api.validation.unmappedCodes(50).then(res => res.data)
|
||||
})
|
||||
|
||||
const runValidation = useMutation({
|
||||
mutationFn: () => api.validation.run(),
|
||||
onSuccess: () => {
|
||||
alert('Validation lancée avec succès!')
|
||||
queryClient.invalidateQueries(['unmapped-codes'])
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="page-header">
|
||||
<h1>
|
||||
Validation des données
|
||||
<HelpIcon text="Vérifiez la qualité et la conformité de vos données OMOP. Identifiez les codes non mappés, les valeurs manquantes et les problèmes de cohérence." />
|
||||
</h1>
|
||||
<p>Vérifier la qualité et la conformité OMOP</p>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Actions
|
||||
<HelpIcon text="Lancez une validation complète des données OMOP. Le processus vérifie l'intégrité référentielle, les valeurs obligatoires et la conformité aux vocabulaires." />
|
||||
</h2>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => runValidation.mutate()}
|
||||
disabled={runValidation.isPending}
|
||||
>
|
||||
{runValidation.isPending ? 'Validation en cours...' : '✅ Lancer la validation'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>
|
||||
Codes non mappés
|
||||
<HelpIcon text="Liste des codes sources qui n'ont pas pu être mappés vers les vocabulaires OMOP standard. Ces codes nécessitent une attention pour améliorer la qualité des données." />
|
||||
</h2>
|
||||
{unmappedCodes?.unmapped_codes?.length === 0 ? (
|
||||
<p>Aucun code non mappé trouvé</p>
|
||||
) : (
|
||||
<table className="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vocabulaire</th>
|
||||
<th>Code</th>
|
||||
<th>Nom</th>
|
||||
<th>Fréquence</th>
|
||||
<th>Dernière occurrence</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{unmappedCodes?.unmapped_codes?.map((code, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>{code.source_vocabulary}</td>
|
||||
<td><code>{code.source_code}</code></td>
|
||||
<td>{code.source_name}</td>
|
||||
<td><span className="badge badge-warning">{code.frequency}</span></td>
|
||||
<td>{new Date(code.last_seen).toLocaleString('fr-FR')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Validation
|
||||
Reference in New Issue
Block a user