## 🎯 Nouveau système d'erreurs graduées (architecture SmartTouch) ### Architecture procédurale intelligente : - **3 niveaux de gravité** : Légère (50%) → Moyenne (30%) → Grave (10%) - **14 types d'erreurs** réalistes et subtiles - **Sélection procédurale** selon contexte (longueur, technique, heure) - **Distribution contrôlée** : max 1 grave, 2 moyennes, 3 légères par article ### 1. Erreurs GRAVES (10% articles max) : - Accord sujet-verbe : "ils sont" → "ils est" - Mot manquant : "pour garantir la qualité" → "pour garantir qualité" - Double mot : "pour garantir" → "pour pour garantir" - Négation oubliée : "n'est pas" → "est pas" ### 2. Erreurs MOYENNES (30% articles) : - Accord pluriel : "plaques résistantes" → "plaques résistant" - Virgule manquante : "Ainsi, il" → "Ainsi il" - Registre inapproprié : "Par conséquent" → "Du coup" - Préposition incorrecte : "résistant aux" → "résistant des" - Connecteur illogique : "cependant" → "donc" ### 3. Erreurs LÉGÈRES (50% articles) : - Double espace : "de votre" → "de votre" - Trait d'union : "c'est-à-dire" → "c'est à dire" - Espace ponctuation : "qualité ?" → "qualité?" - Majuscule : "Toutenplaque" → "toutenplaque" - Apostrophe droite : "l'article" → "l'article" ## ✅ Système anti-répétition complet : ### Corrections critiques : - **HumanSimulationTracker.js** : Tracker centralisé global - **Word boundaries (\b)** sur TOUS les regex → FIX "maison" → "néanmoinson" - **Protection 30+ expressions idiomatiques** françaises - **Anti-répétition** : max 2× même mot, jamais 2× même développement - **Diversification** : 48 variantes (hésitations, développements, connecteurs) ### Nouvelle structure (comme SmartTouch) : ``` lib/human-simulation/ ├── error-profiles/ (NOUVEAU) │ ├── ErrorProfiles.js (définitions + probabilités) │ ├── ErrorGrave.js (10% articles) │ ├── ErrorMoyenne.js (30% articles) │ ├── ErrorLegere.js (50% articles) │ └── ErrorSelector.js (sélection procédurale) ├── HumanSimulationCore.js (orchestrateur) ├── HumanSimulationTracker.js (anti-répétition) └── [autres modules] ``` ## 🔄 Remplace ancien système : - ❌ SpellingErrors.js (basique, répétitif, "et" → "." × 8) - ✅ error-profiles/ (gradué, procédural, intelligent, diversifié) ## 🎲 Fonctionnalités procédurales : - Analyse contexte : longueur texte, complexité technique, heure rédaction - Multiplicateurs adaptatifs selon contexte - Conditions application intelligentes - Tracking global par batch (respecte limites 10%/30%/50%) ## 📊 Résultats validation : Sur 100 articles → ~40-50 avec erreurs subtiles et diverses (plus de spam répétitif) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1404 lines
49 KiB
HTML
1404 lines
49 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Pipeline Validator - SEO Generator</title>
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
|
|
:root {
|
|
--primary: #667eea;
|
|
--secondary: #764ba2;
|
|
--success: #48bb78;
|
|
--warning: #ed8936;
|
|
--error: #f56565;
|
|
--bg-light: #f7fafc;
|
|
--bg-dark: #1a202c;
|
|
--text-dark: #2d3748;
|
|
--text-light: #a0aec0;
|
|
--border-light: #e2e8f0;
|
|
}
|
|
|
|
body {
|
|
font-family: 'SF Pro Display', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
min-height: 100vh;
|
|
color: var(--text-dark);
|
|
padding: 20px;
|
|
}
|
|
|
|
.container { max-width: 1400px; margin: 0 auto; }
|
|
|
|
header {
|
|
background: white;
|
|
border-radius: 10px;
|
|
padding: 20px 30px;
|
|
margin-bottom: 20px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
|
|
header h1 { color: var(--text-dark); font-size: 1.8em; }
|
|
|
|
.btn-back {
|
|
background: var(--bg-light);
|
|
color: var(--text-dark);
|
|
padding: 10px 20px;
|
|
border-radius: 8px;
|
|
text-decoration: none;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.btn-back:hover { background: var(--border-light); }
|
|
|
|
/* Tabs Navigation */
|
|
.tabs-nav {
|
|
display: flex;
|
|
gap: 10px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.tab-btn {
|
|
flex: 1;
|
|
background: white;
|
|
border: none;
|
|
padding: 15px 20px;
|
|
border-radius: 10px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
font-size: 14px;
|
|
}
|
|
|
|
.tab-btn:hover { transform: translateY(-2px); box-shadow: 0 4px 15px rgba(0,0,0,0.15); }
|
|
|
|
.tab-btn.active {
|
|
background: linear-gradient(135deg, var(--primary), var(--secondary));
|
|
color: white;
|
|
}
|
|
|
|
/* Tab Content */
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
|
|
.panel {
|
|
background: white;
|
|
border-radius: 10px;
|
|
padding: 25px;
|
|
box-shadow: 0 5px 20px rgba(0,0,0,0.1);
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.panel h2 {
|
|
color: var(--text-dark);
|
|
margin-bottom: 15px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 2px solid var(--border-light);
|
|
}
|
|
|
|
.form-group { margin-bottom: 20px; }
|
|
|
|
.form-group label {
|
|
display: block;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
color: var(--text-dark);
|
|
}
|
|
|
|
.form-group select,
|
|
.form-group input {
|
|
width: 100%;
|
|
padding: 12px;
|
|
border: 2px solid var(--border-light);
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
}
|
|
|
|
button {
|
|
padding: 12px 20px;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.btn-primary {
|
|
background: linear-gradient(135deg, var(--success), #38a169);
|
|
color: white;
|
|
width: 100%;
|
|
font-size: 16px;
|
|
}
|
|
|
|
.btn-primary:hover:not(:disabled) {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 5px 15px rgba(72, 187, 120, 0.4);
|
|
}
|
|
|
|
.btn-primary:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.btn-small {
|
|
padding: 8px 16px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.btn-stop {
|
|
background: var(--error);
|
|
color: white;
|
|
}
|
|
|
|
.btn-view {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.btn-export {
|
|
background: var(--warning);
|
|
color: white;
|
|
}
|
|
|
|
/* Validation Cards */
|
|
.validation-list {
|
|
display: grid;
|
|
gap: 15px;
|
|
}
|
|
|
|
.validation-card {
|
|
background: var(--bg-light);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
border-left: 4px solid var(--primary);
|
|
}
|
|
|
|
.validation-card.completed { border-left-color: var(--success); }
|
|
.validation-card.error { border-left-color: var(--error); }
|
|
|
|
.validation-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.validation-title {
|
|
font-weight: 700;
|
|
font-size: 16px;
|
|
color: var(--text-dark);
|
|
}
|
|
|
|
.validation-id {
|
|
font-size: 11px;
|
|
color: var(--text-light);
|
|
font-family: monospace;
|
|
}
|
|
|
|
.validation-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.progress-bar {
|
|
width: 100%;
|
|
height: 8px;
|
|
background: var(--border-light);
|
|
border-radius: 4px;
|
|
overflow: hidden;
|
|
margin: 10px 0;
|
|
}
|
|
|
|
.progress-fill {
|
|
height: 100%;
|
|
background: linear-gradient(90deg, var(--primary), var(--secondary));
|
|
transition: width 0.5s ease;
|
|
}
|
|
|
|
.progress-text {
|
|
font-size: 12px;
|
|
color: var(--text-light);
|
|
margin-top: 5px;
|
|
}
|
|
|
|
.validation-meta {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.meta-item {
|
|
font-size: 13px;
|
|
}
|
|
|
|
.meta-label {
|
|
color: var(--text-light);
|
|
font-size: 11px;
|
|
}
|
|
|
|
.meta-value {
|
|
color: var(--text-dark);
|
|
font-weight: 600;
|
|
}
|
|
|
|
/* Score Badge */
|
|
.score-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-weight: 700;
|
|
font-size: 14px;
|
|
}
|
|
|
|
.score-high { background: #c6f6d5; color: #22543d; }
|
|
.score-medium { background: #feebc8; color: #7c2d12; }
|
|
.score-low { background: #fed7d7; color: #822727; }
|
|
|
|
/* Charts Container */
|
|
.charts-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.chart-container {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 10px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
}
|
|
|
|
.chart-title {
|
|
font-weight: 600;
|
|
margin-bottom: 15px;
|
|
color: var(--text-dark);
|
|
}
|
|
|
|
/* Details Section */
|
|
.details-section {
|
|
margin-top: 20px;
|
|
}
|
|
|
|
.timeline {
|
|
position: relative;
|
|
padding-left: 30px;
|
|
}
|
|
|
|
.timeline::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: 8px;
|
|
top: 0;
|
|
bottom: 0;
|
|
width: 2px;
|
|
background: var(--border-light);
|
|
}
|
|
|
|
.timeline-item {
|
|
position: relative;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
.timeline-item::before {
|
|
content: '';
|
|
position: absolute;
|
|
left: -26px;
|
|
top: 5px;
|
|
width: 12px;
|
|
height: 12px;
|
|
border-radius: 50%;
|
|
background: var(--primary);
|
|
border: 2px solid white;
|
|
}
|
|
|
|
.timeline-content {
|
|
background: var(--bg-light);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
}
|
|
|
|
.timeline-phase {
|
|
font-weight: 600;
|
|
color: var(--text-dark);
|
|
margin-bottom: 5px;
|
|
}
|
|
|
|
.timeline-duration {
|
|
font-size: 12px;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
/* Sample Cards */
|
|
.samples-list {
|
|
display: grid;
|
|
gap: 15px;
|
|
}
|
|
|
|
.sample-card {
|
|
background: var(--bg-light);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
border-left: 3px solid var(--primary);
|
|
}
|
|
|
|
.sample-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.sample-tag {
|
|
font-family: monospace;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
|
|
.sample-type {
|
|
font-size: 11px;
|
|
color: var(--text-light);
|
|
background: white;
|
|
padding: 4px 8px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.sample-scores {
|
|
display: flex;
|
|
gap: 10px;
|
|
flex-wrap: wrap;
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.criterion-score {
|
|
font-size: 12px;
|
|
padding: 4px 8px;
|
|
background: white;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.accordion {
|
|
cursor: pointer;
|
|
background: white;
|
|
padding: 10px;
|
|
border-radius: 4px;
|
|
margin-top: 10px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
user-select: none;
|
|
}
|
|
|
|
.accordion:hover { background: var(--border-light); }
|
|
|
|
.accordion-content {
|
|
display: none;
|
|
padding: 10px;
|
|
margin-top: 5px;
|
|
background: white;
|
|
border-radius: 4px;
|
|
font-size: 12px;
|
|
color: var(--text-dark);
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.accordion-content.active {
|
|
display: block;
|
|
}
|
|
|
|
/* Empty State */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: var(--text-light);
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 4em;
|
|
margin-bottom: 20px;
|
|
}
|
|
|
|
/* Status Badge */
|
|
.status-badge {
|
|
display: inline-block;
|
|
padding: 4px 12px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.status-running { background: #bee3f8; color: #2b6cb0; }
|
|
.status-completed { background: #c6f6d5; color: #22543d; }
|
|
.status-error { background: #fed7d7; color: #822727; }
|
|
.status-stopped { background: #e2e8f0; color: #4a5568; }
|
|
|
|
/* Responsive */
|
|
@media (max-width: 768px) {
|
|
.charts-grid {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
|
|
.validation-header {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 10px;
|
|
}
|
|
|
|
.validation-actions {
|
|
width: 100%;
|
|
}
|
|
|
|
.validation-actions button {
|
|
flex: 1;
|
|
}
|
|
}
|
|
|
|
/* Loading Spinner */
|
|
.spinner {
|
|
display: inline-block;
|
|
width: 20px;
|
|
height: 20px;
|
|
border: 3px solid var(--border-light);
|
|
border-top-color: var(--primary);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
|
|
.pipeline-preview {
|
|
background: var(--bg-light);
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
margin-top: 15px;
|
|
display: none;
|
|
}
|
|
|
|
.pipeline-preview.visible {
|
|
display: block;
|
|
}
|
|
|
|
.summary-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 10px;
|
|
margin-top: 10px;
|
|
}
|
|
|
|
.summary-item {
|
|
background: white;
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
text-align: center;
|
|
}
|
|
|
|
.summary-label {
|
|
font-size: 11px;
|
|
color: var(--text-light);
|
|
margin-bottom: 3px;
|
|
}
|
|
|
|
.summary-value {
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: var(--primary);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<header>
|
|
<h1>🧪 Pipeline Validator</h1>
|
|
<a href="index.html" class="btn-back">← Retour Accueil</a>
|
|
</header>
|
|
|
|
<!-- Tabs Navigation -->
|
|
<div class="tabs-nav">
|
|
<button class="tab-btn active" onclick="switchTab('new')">
|
|
🚀 Nouvelle Validation
|
|
</button>
|
|
<button class="tab-btn" onclick="switchTab('active')">
|
|
⚡ Validations Actives <span id="activeCount" style="opacity: 0.7;">(0)</span>
|
|
</button>
|
|
<button class="tab-btn" onclick="switchTab('results')">
|
|
📊 Résultats & Détails
|
|
</button>
|
|
</div>
|
|
|
|
<!-- TAB 1: Nouvelle Validation -->
|
|
<div id="tab-new" class="tab-content active">
|
|
<div class="panel">
|
|
<h2>📂 Configuration</h2>
|
|
|
|
<div class="form-group">
|
|
<label for="pipelineSelect">Pipeline à valider :</label>
|
|
<select id="pipelineSelect" onchange="loadPipelinePreview()">
|
|
<option value="">-- Sélectionner un pipeline --</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div id="pipelinePreview" class="pipeline-preview">
|
|
<h3 style="margin-bottom: 10px; color: var(--text-dark);" id="previewName"></h3>
|
|
<p style="font-size: 13px; color: var(--text-light);" id="previewDesc"></p>
|
|
|
|
<div class="summary-grid">
|
|
<div class="summary-item">
|
|
<div class="summary-label">Étapes</div>
|
|
<div class="summary-value" id="previewSteps">-</div>
|
|
</div>
|
|
<div class="summary-item">
|
|
<div class="summary-label">Durée Estimée</div>
|
|
<div class="summary-value" id="previewDuration">-</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="form-group">
|
|
<label for="rowNumber">Ligne Google Sheets :</label>
|
|
<input type="number" id="rowNumber" value="2" min="2" max="1000">
|
|
</div>
|
|
|
|
<div class="form-group" style="margin-top: 25px;">
|
|
<label>Mode de validation :</label>
|
|
<div id="presetsContainer" style="margin-top: 10px;">
|
|
<div style="margin-bottom: 8px;">
|
|
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
|
|
<input type="radio" name="validationPreset" value="economical" checked style="margin-right: 10px;">
|
|
<div style="flex: 1;">
|
|
<strong>Économique</strong> <span style="color: var(--success); font-weight: 600;">($0.12, 1min)</span><br>
|
|
<span style="font-size: 12px; color: var(--text-light);">v1.0 + v2.0, 3 critères, 5 échantillons</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div style="margin-bottom: 8px;">
|
|
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
|
|
<input type="radio" name="validationPreset" value="standard" style="margin-right: 10px;">
|
|
<div style="flex: 1;">
|
|
<strong>Standard</strong> <span style="color: var(--warning); font-weight: 600;">($0.30, 2min)</span><br>
|
|
<span style="font-size: 12px; color: var(--text-light);">3 versions, tous critères, 5 échantillons</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
<div style="margin-bottom: 8px;">
|
|
<label style="display: flex; align-items: center; padding: 10px; background: var(--bg-light); border-radius: 6px; cursor: pointer;">
|
|
<input type="radio" name="validationPreset" value="complete" style="margin-right: 10px;">
|
|
<div style="flex: 1;">
|
|
<strong>Complet</strong> <span style="color: var(--error); font-weight: 600;">($1.00, 5min)</span><br>
|
|
<span style="font-size: 12px; color: var(--text-light);">Toutes versions, tous critères, tous échantillons</span>
|
|
</div>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<button class="btn-primary" id="btnStart" onclick="startValidation()" disabled>
|
|
🚀 Démarrer Validation
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB 2: Validations Actives -->
|
|
<div id="tab-active" class="tab-content">
|
|
<div class="panel">
|
|
<h2>⚡ Validations en cours</h2>
|
|
<div id="activeValidations" class="validation-list">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">💤</div>
|
|
<p>Aucune validation active</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<h2>📜 Historique</h2>
|
|
<div id="historyValidations" class="validation-list">
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📭</div>
|
|
<p>Aucune validation terminée</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- TAB 3: Résultats & Détails -->
|
|
<div id="tab-results" class="tab-content">
|
|
<div class="panel">
|
|
<h2>📊 Sélection</h2>
|
|
<div class="form-group">
|
|
<label for="validationSelect">Choisir une validation :</label>
|
|
<select id="validationSelect" onchange="loadValidationResults()">
|
|
<option value="">-- Sélectionner --</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="resultsContainer" style="display: none;">
|
|
<!-- Summary Panel -->
|
|
<div class="panel">
|
|
<h2>📈 Résumé</h2>
|
|
<div class="summary-grid">
|
|
<div class="summary-item">
|
|
<div class="summary-label">Score Global</div>
|
|
<div class="summary-value" id="summaryScore">-</div>
|
|
</div>
|
|
<div class="summary-item">
|
|
<div class="summary-label">Progression v1.0 → v2.0</div>
|
|
<div class="summary-value" id="summaryDelta">-</div>
|
|
</div>
|
|
<div class="summary-item">
|
|
<div class="summary-label">Total Évaluations</div>
|
|
<div class="summary-value" id="summaryTotal">-</div>
|
|
</div>
|
|
<div class="summary-item">
|
|
<div class="summary-label">Durée</div>
|
|
<div class="summary-value" id="summaryDurationResult">-</div>
|
|
</div>
|
|
</div>
|
|
<div style="margin-top: 15px; text-align: right;">
|
|
<button class="btn-small btn-export" onclick="exportReport()">
|
|
💾 Export JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Charts -->
|
|
<div class="charts-grid">
|
|
<div class="chart-container">
|
|
<div class="chart-title">📈 Évolution par Version</div>
|
|
<canvas id="chartVersions"></canvas>
|
|
</div>
|
|
<div class="chart-container">
|
|
<div class="chart-title">📊 Scores par Critère</div>
|
|
<canvas id="chartCriteria"></canvas>
|
|
</div>
|
|
<div class="chart-container" style="grid-column: span 2;">
|
|
<div class="chart-title">🎯 Vue Radar Multi-Critères</div>
|
|
<canvas id="chartRadar"></canvas>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Samples Details -->
|
|
<div class="panel">
|
|
<h2>🔍 Détails Échantillons</h2>
|
|
<div id="samplesContainer" class="samples-list"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// ========================================
|
|
// GLOBAL STATE
|
|
// ========================================
|
|
let ws = null;
|
|
let activeValidations = new Map();
|
|
let currentPipelines = [];
|
|
let currentReport = null;
|
|
let currentEvaluations = null;
|
|
let charts = { versions: null, criteria: null, radar: null };
|
|
|
|
// ========================================
|
|
// INITIALIZATION
|
|
// ========================================
|
|
async function init() {
|
|
console.log('🚀 Initialisation...');
|
|
await loadPipelines();
|
|
await loadValidationsList();
|
|
connectWebSocket();
|
|
|
|
// Auto-refresh every 5s
|
|
setInterval(loadValidationsList, 5000);
|
|
}
|
|
|
|
// ========================================
|
|
// WEBSOCKET
|
|
// ========================================
|
|
function connectWebSocket() {
|
|
try {
|
|
ws = new WebSocket('ws://localhost:8081');
|
|
|
|
ws.onopen = () => {
|
|
console.log('✅ WebSocket connecté');
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
try {
|
|
const data = JSON.parse(event.data);
|
|
|
|
if (data.type === 'validation_progress') {
|
|
updateValidationProgress(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur parsing WebSocket:', error);
|
|
}
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('❌ WebSocket erreur:', error);
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('🔌 WebSocket déconnecté, reconnexion dans 5s...');
|
|
setTimeout(connectWebSocket, 5000);
|
|
};
|
|
} catch (error) {
|
|
console.error('❌ Erreur connexion WebSocket:', error);
|
|
setTimeout(connectWebSocket, 5000);
|
|
}
|
|
}
|
|
|
|
function updateValidationProgress(data) {
|
|
const card = document.querySelector(`[data-validation-id="${data.validationId}"]`);
|
|
if (!card) return;
|
|
|
|
const progressBar = card.querySelector('.progress-fill');
|
|
const progressText = card.querySelector('.progress-text');
|
|
|
|
if (progressBar) {
|
|
progressBar.style.width = `${data.progress.percentage}%`;
|
|
}
|
|
|
|
if (progressText) {
|
|
progressText.textContent = `${data.progress.phase}: ${data.progress.message}`;
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// TABS
|
|
// ========================================
|
|
function switchTab(tabName) {
|
|
// Update buttons
|
|
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
|
|
event.target.classList.add('active');
|
|
|
|
// Update content
|
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
|
document.getElementById(`tab-${tabName}`).classList.add('active');
|
|
|
|
// Load data if needed
|
|
if (tabName === 'active') {
|
|
loadValidationsList();
|
|
} else if (tabName === 'results') {
|
|
populateValidationSelect();
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// TAB 1: NEW VALIDATION
|
|
// ========================================
|
|
async function loadPipelines() {
|
|
try {
|
|
const response = await fetch('/api/pipeline/list');
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.pipelines) {
|
|
currentPipelines = data.pipelines;
|
|
const select = document.getElementById('pipelineSelect');
|
|
|
|
data.pipelines.forEach(pipeline => {
|
|
const option = document.createElement('option');
|
|
option.value = pipeline.filename;
|
|
option.textContent = pipeline.name || pipeline.filename;
|
|
select.appendChild(option);
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur chargement pipelines:', error);
|
|
}
|
|
}
|
|
|
|
async function loadPipelinePreview() {
|
|
const select = document.getElementById('pipelineSelect');
|
|
const filename = select.value;
|
|
const btnStart = document.getElementById('btnStart');
|
|
|
|
if (!filename || filename === 'undefined' || filename === '') {
|
|
document.getElementById('pipelinePreview').classList.remove('visible');
|
|
btnStart.disabled = true;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/pipeline/load?filename=${encodeURIComponent(filename)}`);
|
|
const data = await response.json();
|
|
|
|
if (data.success && data.pipeline) {
|
|
const pipeline = data.pipeline;
|
|
|
|
document.getElementById('previewName').textContent = pipeline.name || 'Sans nom';
|
|
document.getElementById('previewDesc').textContent = pipeline.description || 'Aucune description';
|
|
document.getElementById('previewSteps').textContent = pipeline.pipeline.length;
|
|
|
|
// Estimate duration (rough estimate)
|
|
const estimatedMinutes = pipeline.pipeline.length * 1.5;
|
|
document.getElementById('previewDuration').textContent = `~${estimatedMinutes.toFixed(0)}min`;
|
|
|
|
document.getElementById('pipelinePreview').classList.add('visible');
|
|
btnStart.disabled = false;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur chargement preview:', error);
|
|
}
|
|
}
|
|
|
|
async function startValidation() {
|
|
const pipelineFilename = document.getElementById('pipelineSelect').value;
|
|
const rowNumber = parseInt(document.getElementById('rowNumber').value);
|
|
|
|
if (!pipelineFilename) {
|
|
alert('Veuillez sélectionner un pipeline');
|
|
return;
|
|
}
|
|
|
|
// ✅ NOUVEAU: Récupérer le preset sélectionné
|
|
const selectedPreset = document.querySelector('input[name="validationPreset"]:checked')?.value || 'economical';
|
|
|
|
try {
|
|
// Load pipeline config
|
|
const pipelineResponse = await fetch(`/api/pipeline/load?filename=${encodeURIComponent(pipelineFilename)}`);
|
|
const pipelineData = await pipelineResponse.json();
|
|
|
|
if (!pipelineData.success) {
|
|
throw new Error('Impossible de charger le pipeline');
|
|
}
|
|
|
|
// Start validation with preset
|
|
const response = await fetch('/api/validation/start', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
pipelineConfig: pipelineData.pipeline,
|
|
rowNumber,
|
|
config: selectedPreset // ✅ Passer le preset au lieu d'objet vide
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
console.log('✅ Validation démarrée:', data.data.validationId);
|
|
|
|
// Switch to active tab
|
|
document.querySelectorAll('.tab-btn')[1].click();
|
|
|
|
// Reload list
|
|
setTimeout(loadValidationsList, 1000);
|
|
} else {
|
|
alert(`Erreur: ${data.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur démarrage validation:', error);
|
|
alert(`Erreur: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// TAB 2: ACTIVE VALIDATIONS
|
|
// ========================================
|
|
async function loadValidationsList() {
|
|
try {
|
|
const response = await fetch('/api/validation/list?limit=100');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const active = data.data.validations.filter(v => v.status === 'running');
|
|
const history = data.data.validations.filter(v => v.status !== 'running');
|
|
|
|
renderActiveValidations(active);
|
|
renderHistoryValidations(history);
|
|
|
|
// Update count badge
|
|
document.getElementById('activeCount').textContent = `(${active.length})`;
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur chargement liste:', error);
|
|
}
|
|
}
|
|
|
|
function renderActiveValidations(validations) {
|
|
const container = document.getElementById('activeValidations');
|
|
|
|
if (validations.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">💤</div>
|
|
<p>Aucune validation active</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = validations.map(v => `
|
|
<div class="validation-card" data-validation-id="${v.validationId}">
|
|
<div class="validation-header">
|
|
<div>
|
|
<div class="validation-title">${v.pipelineName || 'Sans nom'}</div>
|
|
<div class="validation-id">${v.validationId}</div>
|
|
</div>
|
|
<div class="validation-actions">
|
|
<button class="btn-small btn-view" onclick="viewValidation('${v.validationId}')">
|
|
👁️ Voir
|
|
</button>
|
|
<button class="btn-small btn-stop" onclick="stopValidation('${v.validationId}')">
|
|
⏹️ Stop
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="progress-bar">
|
|
<div class="progress-fill" style="width: ${v.progress?.percentage || 0}%"></div>
|
|
</div>
|
|
<div class="progress-text">${v.progress?.message || 'Démarrage...'}</div>
|
|
|
|
<div class="validation-meta">
|
|
<div class="meta-item">
|
|
<div class="meta-label">Phase</div>
|
|
<div class="meta-value">${v.progress?.phase || 'init'}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Durée</div>
|
|
<div class="meta-value">${formatDuration(v.duration)}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Statut</div>
|
|
<div class="meta-value">
|
|
<span class="status-badge status-${v.status}">${v.status}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function renderHistoryValidations(validations) {
|
|
const container = document.getElementById('historyValidations');
|
|
|
|
if (validations.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="empty-state">
|
|
<div class="empty-state-icon">📭</div>
|
|
<p>Aucune validation terminée</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = validations.map(v => `
|
|
<div class="validation-card ${v.status}">
|
|
<div class="validation-header">
|
|
<div>
|
|
<div class="validation-title">${v.pipelineName || 'Sans nom'}</div>
|
|
<div class="validation-id">${v.validationId}</div>
|
|
</div>
|
|
<div class="validation-actions">
|
|
<button class="btn-small btn-view" onclick="viewValidation('${v.validationId}')">
|
|
📊 Voir Résultats
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="validation-meta">
|
|
<div class="meta-item">
|
|
<div class="meta-label">Statut</div>
|
|
<div class="meta-value">
|
|
<span class="status-badge status-${v.status}">${v.status}</span>
|
|
</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Durée</div>
|
|
<div class="meta-value">${formatDuration(v.duration)}</div>
|
|
</div>
|
|
<div class="meta-item">
|
|
<div class="meta-label">Date</div>
|
|
<div class="meta-value">${new Date(v.endTime).toLocaleString('fr-FR')}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async function stopValidation(validationId) {
|
|
if (!confirm('Arrêter cette validation ?')) return;
|
|
|
|
try {
|
|
const response = await fetch(`/api/validation/stop/${validationId}`, {
|
|
method: 'POST'
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
console.log('✅ Validation arrêtée');
|
|
loadValidationsList();
|
|
} else {
|
|
alert(`Erreur: ${data.error}`);
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur arrêt validation:', error);
|
|
alert(`Erreur: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function viewValidation(validationId) {
|
|
// Set select value and load results
|
|
const select = document.getElementById('validationSelect');
|
|
select.value = validationId;
|
|
|
|
// Switch to results tab
|
|
document.querySelectorAll('.tab-btn')[2].click();
|
|
|
|
// Load results
|
|
loadValidationResults();
|
|
}
|
|
|
|
// ========================================
|
|
// TAB 3: RESULTS & DETAILS
|
|
// ========================================
|
|
async function populateValidationSelect() {
|
|
try {
|
|
const response = await fetch('/api/validation/list?limit=100');
|
|
const data = await response.json();
|
|
|
|
if (data.success) {
|
|
const select = document.getElementById('validationSelect');
|
|
|
|
// Keep current selection
|
|
const currentValue = select.value;
|
|
|
|
// Clear and repopulate
|
|
select.innerHTML = '<option value="">-- Sélectionner --</option>';
|
|
|
|
data.data.validations
|
|
.filter(v => v.status === 'completed')
|
|
.forEach(v => {
|
|
const option = document.createElement('option');
|
|
option.value = v.validationId;
|
|
option.textContent = `${v.pipelineName || 'Sans nom'} - ${new Date(v.endTime).toLocaleString('fr-FR')}`;
|
|
select.appendChild(option);
|
|
});
|
|
|
|
// Restore selection if exists
|
|
if (currentValue) {
|
|
select.value = currentValue;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur population select:', error);
|
|
}
|
|
}
|
|
|
|
async function loadValidationResults() {
|
|
const validationId = document.getElementById('validationSelect').value;
|
|
|
|
if (!validationId) {
|
|
document.getElementById('resultsContainer').style.display = 'none';
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Load report and evaluations in parallel
|
|
const [reportResponse, evalResponse] = await Promise.all([
|
|
fetch(`/api/validation/${validationId}/report`),
|
|
fetch(`/api/validation/${validationId}/evaluations`)
|
|
]);
|
|
|
|
const reportData = await reportResponse.json();
|
|
const evalData = await evalResponse.json();
|
|
|
|
if (reportData.success && evalData.success) {
|
|
currentReport = reportData.data;
|
|
currentEvaluations = evalData.data;
|
|
|
|
renderSummary();
|
|
renderCharts();
|
|
renderSamples();
|
|
|
|
document.getElementById('resultsContainer').style.display = 'block';
|
|
} else {
|
|
alert('Erreur chargement résultats');
|
|
}
|
|
} catch (error) {
|
|
console.error('❌ Erreur chargement résultats:', error);
|
|
alert(`Erreur: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
function renderSummary() {
|
|
const evals = currentReport.evaluations;
|
|
|
|
document.getElementById('summaryScore').textContent = evals.overallScore.toFixed(1);
|
|
|
|
// Calculate delta
|
|
const versions = Object.keys(evals.byVersion).sort();
|
|
const firstVersion = evals.byVersion[versions[0]];
|
|
const lastVersion = evals.byVersion[versions[versions.length - 1]];
|
|
const delta = lastVersion.avgScore - firstVersion.avgScore;
|
|
|
|
const deltaEl = document.getElementById('summaryDelta');
|
|
deltaEl.textContent = `${delta > 0 ? '+' : ''}${delta.toFixed(1)}`;
|
|
deltaEl.style.color = delta >= 0 ? 'var(--success)' : 'var(--error)';
|
|
|
|
document.getElementById('summaryTotal').textContent = evals.totalEvaluations;
|
|
document.getElementById('summaryDurationResult').textContent = formatDuration(currentReport.pipeline.totalDuration);
|
|
}
|
|
|
|
function renderCharts() {
|
|
renderVersionsChart();
|
|
renderCriteriaChart();
|
|
renderRadarChart();
|
|
}
|
|
|
|
function renderVersionsChart() {
|
|
const ctx = document.getElementById('chartVersions');
|
|
|
|
if (charts.versions) {
|
|
charts.versions.destroy();
|
|
}
|
|
|
|
const versions = Object.keys(currentReport.evaluations.byVersion).sort();
|
|
const scores = versions.map(v => currentReport.evaluations.byVersion[v].avgScore);
|
|
|
|
charts.versions = new Chart(ctx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: versions,
|
|
datasets: [{
|
|
label: 'Score Global',
|
|
data: scores,
|
|
borderColor: 'rgb(102, 126, 234)',
|
|
backgroundColor: 'rgba(102, 126, 234, 0.1)',
|
|
tension: 0.4,
|
|
fill: true
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 10
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderCriteriaChart() {
|
|
const ctx = document.getElementById('chartCriteria');
|
|
|
|
if (charts.criteria) {
|
|
charts.criteria.destroy();
|
|
}
|
|
|
|
const criteria = Object.keys(currentReport.evaluations.byCriteria);
|
|
const scores = criteria.map(c => currentReport.evaluations.byCriteria[c].avgScore);
|
|
|
|
charts.criteria = new Chart(ctx, {
|
|
type: 'bar',
|
|
data: {
|
|
labels: criteria.map(c => c.charAt(0).toUpperCase() + c.slice(1)),
|
|
datasets: [{
|
|
label: 'Score Moyen',
|
|
data: scores,
|
|
backgroundColor: [
|
|
'rgba(102, 126, 234, 0.8)',
|
|
'rgba(118, 75, 162, 0.8)',
|
|
'rgba(72, 187, 120, 0.8)',
|
|
'rgba(237, 137, 54, 0.8)',
|
|
'rgba(245, 101, 101, 0.8)'
|
|
]
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: { display: false }
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true,
|
|
max: 10
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderRadarChart() {
|
|
const ctx = document.getElementById('chartRadar');
|
|
|
|
if (charts.radar) {
|
|
charts.radar.destroy();
|
|
}
|
|
|
|
const criteria = Object.keys(currentReport.evaluations.byCriteria);
|
|
const versions = Object.keys(currentReport.evaluations.byVersion).sort();
|
|
|
|
// Get first and last version for comparison
|
|
const firstVersion = versions[0];
|
|
const lastVersion = versions[versions.length - 1];
|
|
|
|
const datasets = [
|
|
{
|
|
label: firstVersion,
|
|
data: criteria.map(c => {
|
|
const evalsByCriteria = currentEvaluations.aggregated.byCriteria[c];
|
|
return evalsByCriteria.byVersion[firstVersion]?.avgScore || 0;
|
|
}),
|
|
borderColor: 'rgba(237, 137, 54, 0.8)',
|
|
backgroundColor: 'rgba(237, 137, 54, 0.2)'
|
|
},
|
|
{
|
|
label: lastVersion,
|
|
data: criteria.map(c => {
|
|
const evalsByCriteria = currentEvaluations.aggregated.byCriteria[c];
|
|
return evalsByCriteria.byVersion[lastVersion]?.avgScore || 0;
|
|
}),
|
|
borderColor: 'rgba(72, 187, 120, 0.8)',
|
|
backgroundColor: 'rgba(72, 187, 120, 0.2)'
|
|
}
|
|
];
|
|
|
|
charts.radar = new Chart(ctx, {
|
|
type: 'radar',
|
|
data: {
|
|
labels: criteria.map(c => c.charAt(0).toUpperCase() + c.slice(1)),
|
|
datasets
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
scales: {
|
|
r: {
|
|
beginAtZero: true,
|
|
max: 10
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function renderSamples() {
|
|
const container = document.getElementById('samplesContainer');
|
|
const evaluations = currentEvaluations.evaluations;
|
|
|
|
const samples = Object.keys(evaluations).map(tag => {
|
|
const sampleEvals = evaluations[tag];
|
|
const criteria = Object.keys(sampleEvals);
|
|
|
|
// Calculate average score for this sample
|
|
let totalScore = 0;
|
|
let count = 0;
|
|
|
|
criteria.forEach(criterion => {
|
|
Object.values(sampleEvals[criterion]).forEach(versionData => {
|
|
totalScore += versionData.score;
|
|
count++;
|
|
});
|
|
});
|
|
|
|
const avgScore = totalScore / count;
|
|
|
|
return { tag, evaluations: sampleEvals, avgScore };
|
|
});
|
|
|
|
container.innerHTML = samples.map(sample => {
|
|
const scoreClass = sample.avgScore >= 7 ? 'score-high' : sample.avgScore >= 5 ? 'score-medium' : 'score-low';
|
|
|
|
return `
|
|
<div class="sample-card">
|
|
<div class="sample-header">
|
|
<span class="sample-tag">${sample.tag}</span>
|
|
<span class="score-badge ${scoreClass}">${sample.avgScore.toFixed(1)}/10</span>
|
|
</div>
|
|
|
|
<div class="sample-scores">
|
|
${Object.keys(sample.evaluations).map(criterion => {
|
|
const criterionData = sample.evaluations[criterion];
|
|
const versions = Object.keys(criterionData).sort();
|
|
const lastVersion = versions[versions.length - 1];
|
|
const score = criterionData[lastVersion].score;
|
|
|
|
return `<div class="criterion-score">${criterion}: ${score}/10</div>`;
|
|
}).join('')}
|
|
</div>
|
|
|
|
${Object.keys(sample.evaluations).map(criterion => {
|
|
const versions = Object.keys(sample.evaluations[criterion]).sort();
|
|
const lastVersion = versions[versions.length - 1];
|
|
const reasoning = sample.evaluations[criterion][lastVersion].reasoning;
|
|
|
|
return `
|
|
<div class="accordion" onclick="toggleAccordion(this)">
|
|
📝 ${criterion} - Reasoning (${lastVersion})
|
|
</div>
|
|
<div class="accordion-content">
|
|
${reasoning}
|
|
</div>
|
|
`;
|
|
}).join('')}
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
function toggleAccordion(element) {
|
|
const content = element.nextElementSibling;
|
|
content.classList.toggle('active');
|
|
}
|
|
|
|
function exportReport() {
|
|
const dataStr = JSON.stringify(currentReport, null, 2);
|
|
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
|
|
|
const exportFileDefaultName = `validation-report-${currentReport.validationId}.json`;
|
|
|
|
const linkElement = document.createElement('a');
|
|
linkElement.setAttribute('href', dataUri);
|
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
linkElement.click();
|
|
}
|
|
|
|
// ========================================
|
|
// UTILITIES
|
|
// ========================================
|
|
function formatDuration(ms) {
|
|
if (!ms) return '-';
|
|
|
|
const seconds = Math.floor(ms / 1000);
|
|
const minutes = Math.floor(seconds / 60);
|
|
const hours = Math.floor(minutes / 60);
|
|
|
|
if (hours > 0) {
|
|
return `${hours}h ${minutes % 60}m`;
|
|
} else if (minutes > 0) {
|
|
return `${minutes}m ${seconds % 60}s`;
|
|
} else {
|
|
return `${seconds}s`;
|
|
}
|
|
}
|
|
|
|
// ========================================
|
|
// STARTUP
|
|
// ========================================
|
|
window.onload = init;
|
|
</script>
|
|
</body>
|
|
</html>
|