Skip to content

Commit 72c88f3

Browse files
committed
Ajout galerie d'images et flux vidéo au dashboard 1BIP
Fonctionnalités ajoutées: - 📸 Galerie d'images capturées dans l'onglet "Alertes de Sécurité" - Affichage des 20 dernières images capturées par la caméra - Vue en grille avec miniatures cliquables - Modal pour visualisation en plein écran - Bouton de téléchargement pour chaque image - Horodatage en français pour chaque capture - 📹 Section flux vidéo en direct dans l'onglet "Moniteur en Direct" - Placeholder pour streaming vidéo (prochainement) - Infrastructure prête pour MJPEG/WebRTC - 🔌 API Backend (dashboard-service/src/app.py): - GET /api/images/<filename> - Servir une image capturée - GET /api/images/latest - Liste des 20 dernières images - Volume partagé avec camera-service pour accès aux images - 🎨 Styles CSS complets pour galerie et modal - Thème militaire cohérent - Responsive design (mobile/tablet) - Animations et transitions fluides Modifications techniques: - dashboard.html: Nouvelle section galerie dans tab-unauthorized - dashboard.js: Fonctions refreshCapturedImages(), viewFullImage(), closeImageModal() - dashboard.css: Styles .image-grid, .image-modal, .live-stream-section - app.py: Routes /api/images/* avec serving depuis /app/camera_logs - docker-compose.yml: Volume mapping camera-service/logs → dashboard 🇲🇦 1BIP - Forces Armées Royales Marocaines - Troupes Aéroportées 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
1 parent cc19660 commit 72c88f3

5 files changed

Lines changed: 405 additions & 0 deletions

File tree

dashboard-service/src/app.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -459,6 +459,64 @@ def serve_static(filename):
459459
return send_from_directory('static', filename)
460460

461461

462+
# ============================================
463+
# CAPTURED IMAGES (from camera service)
464+
# ============================================
465+
466+
@app.route('/api/images/<path:filename>')
467+
def serve_captured_image(filename):
468+
"""Serve captured images from camera service"""
469+
try:
470+
camera_logs_path = os.getenv('CAMERA_LOGS_PATH', '/app/camera_logs')
471+
image_path = os.path.join(camera_logs_path, 'debug_images', filename)
472+
473+
if os.path.exists(image_path):
474+
return send_from_directory(
475+
os.path.join(camera_logs_path, 'debug_images'),
476+
filename,
477+
mimetype='image/jpeg'
478+
)
479+
else:
480+
logger.warning(f"Image not found: {image_path}")
481+
# Return placeholder image
482+
return jsonify({'error': 'Image not found'}), 404
483+
except Exception as e:
484+
logger.error(f"Error serving image {filename}: {e}")
485+
return jsonify({'error': str(e)}), 500
486+
487+
488+
@app.route('/api/images/latest')
489+
def get_latest_images():
490+
"""Get list of latest captured images"""
491+
try:
492+
camera_logs_path = os.getenv('CAMERA_LOGS_PATH', '/app/camera_logs')
493+
debug_images_path = os.path.join(camera_logs_path, 'debug_images')
494+
495+
if not os.path.exists(debug_images_path):
496+
return jsonify({'images': []})
497+
498+
# Get all images sorted by modification time
499+
images = []
500+
for filename in os.listdir(debug_images_path):
501+
if filename.endswith(('.jpg', '.jpeg', '.png')):
502+
filepath = os.path.join(debug_images_path, filename)
503+
mtime = os.path.getmtime(filepath)
504+
images.append({
505+
'filename': filename,
506+
'timestamp': mtime,
507+
'url': f'/api/images/{filename}'
508+
})
509+
510+
# Sort by timestamp (newest first)
511+
images.sort(key=lambda x: x['timestamp'], reverse=True)
512+
513+
# Return last 20 images
514+
return jsonify({'images': images[:20]})
515+
except Exception as e:
516+
logger.error(f"Error getting latest images: {e}")
517+
return jsonify({'error': str(e), 'images': []}), 500
518+
519+
462520
# ============================================
463521
# ERROR HANDLERS
464522
# ============================================

dashboard-service/src/static/css/dashboard.css

Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -593,3 +593,226 @@ body {
593593
.fade-in {
594594
animation: fadeIn 0.3s ease-in-out;
595595
}
596+
597+
/* ==================== IMAGE GALLERY ==================== */
598+
.captured-images-section {
599+
margin-top: 2rem;
600+
padding-top: 2rem;
601+
border-top: 2px solid var(--border-color);
602+
}
603+
604+
.captured-images-section h3 {
605+
font-size: 1.3rem;
606+
color: var(--primary-color);
607+
margin-bottom: 1rem;
608+
}
609+
610+
.image-count {
611+
font-weight: 600;
612+
color: var(--text-muted);
613+
margin-left: 1rem;
614+
}
615+
616+
.image-grid {
617+
display: grid;
618+
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
619+
gap: 1rem;
620+
margin-top: 1rem;
621+
}
622+
623+
.image-card {
624+
background: var(--card-bg);
625+
border: 2px solid var(--border-color);
626+
border-radius: 8px;
627+
overflow: hidden;
628+
cursor: pointer;
629+
transition: all 0.3s ease;
630+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
631+
}
632+
633+
.image-card:hover {
634+
transform: translateY(-4px);
635+
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
636+
border-color: var(--primary-color);
637+
}
638+
639+
.image-wrapper {
640+
width: 100%;
641+
height: 200px;
642+
overflow: hidden;
643+
background-color: #f0f0f0;
644+
display: flex;
645+
align-items: center;
646+
justify-content: center;
647+
}
648+
649+
.image-wrapper img {
650+
width: 100%;
651+
height: 100%;
652+
object-fit: cover;
653+
}
654+
655+
.image-info {
656+
padding: 0.75rem;
657+
background-color: var(--card-bg);
658+
}
659+
660+
.image-filename {
661+
font-size: 0.75rem;
662+
color: var(--text-muted);
663+
overflow: hidden;
664+
text-overflow: ellipsis;
665+
white-space: nowrap;
666+
margin-bottom: 0.25rem;
667+
}
668+
669+
.image-timestamp {
670+
font-size: 0.875rem;
671+
font-weight: 600;
672+
color: var(--primary-color);
673+
}
674+
675+
/* ==================== IMAGE MODAL ==================== */
676+
.image-modal {
677+
position: fixed;
678+
top: 0;
679+
left: 0;
680+
width: 100%;
681+
height: 100%;
682+
background-color: rgba(0, 0, 0, 0.85);
683+
display: flex;
684+
align-items: center;
685+
justify-content: center;
686+
z-index: 10000;
687+
animation: fadeIn 0.2s ease-in-out;
688+
}
689+
690+
.image-modal-content {
691+
background: var(--card-bg);
692+
border-radius: 12px;
693+
max-width: 90vw;
694+
max-height: 90vh;
695+
display: flex;
696+
flex-direction: column;
697+
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
698+
}
699+
700+
.image-modal-header {
701+
display: flex;
702+
justify-content: space-between;
703+
align-items: center;
704+
padding: 1rem 1.5rem;
705+
border-bottom: 2px solid var(--border-color);
706+
background-color: var(--header-bg);
707+
color: var(--header-text);
708+
border-radius: 12px 12px 0 0;
709+
}
710+
711+
.image-modal-header h3 {
712+
margin: 0;
713+
font-size: 1.1rem;
714+
}
715+
716+
.image-modal-close {
717+
background: none;
718+
border: none;
719+
color: var(--header-text);
720+
font-size: 2rem;
721+
cursor: pointer;
722+
padding: 0;
723+
width: 40px;
724+
height: 40px;
725+
display: flex;
726+
align-items: center;
727+
justify-content: center;
728+
border-radius: 4px;
729+
transition: background-color 0.2s;
730+
}
731+
732+
.image-modal-close:hover {
733+
background-color: rgba(255, 255, 255, 0.1);
734+
}
735+
736+
.image-modal-body {
737+
padding: 1rem;
738+
overflow: auto;
739+
max-height: calc(90vh - 140px);
740+
display: flex;
741+
align-items: center;
742+
justify-content: center;
743+
}
744+
745+
.image-modal-body img {
746+
max-width: 100%;
747+
max-height: 70vh;
748+
object-fit: contain;
749+
border-radius: 4px;
750+
}
751+
752+
.image-modal-footer {
753+
display: flex;
754+
gap: 1rem;
755+
padding: 1rem 1.5rem;
756+
border-top: 2px solid var(--border-color);
757+
justify-content: flex-end;
758+
}
759+
760+
/* ==================== LIVE STREAM VIDEO ==================== */
761+
.live-stream-section {
762+
margin-bottom: 2rem;
763+
background: var(--card-bg);
764+
border: 2px solid var(--border-color);
765+
border-radius: 8px;
766+
padding: 1rem;
767+
}
768+
769+
.live-stream-section h3 {
770+
font-size: 1.3rem;
771+
color: var(--primary-color);
772+
margin-bottom: 1rem;
773+
}
774+
775+
.video-container {
776+
position: relative;
777+
width: 100%;
778+
background-color: #000;
779+
border-radius: 4px;
780+
overflow: hidden;
781+
}
782+
783+
.video-stream {
784+
width: 100%;
785+
height: auto;
786+
display: block;
787+
}
788+
789+
.video-placeholder {
790+
width: 100%;
791+
height: 400px;
792+
display: flex;
793+
align-items: center;
794+
justify-content: center;
795+
background-color: #1a1a1a;
796+
color: #888;
797+
font-size: 1.2rem;
798+
}
799+
800+
/* ==================== RESPONSIVE ADJUSTMENTS ==================== */
801+
@media (max-width: 768px) {
802+
.image-grid {
803+
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
804+
}
805+
806+
.image-wrapper {
807+
height: 150px;
808+
}
809+
810+
.image-modal-content {
811+
max-width: 95vw;
812+
max-height: 95vh;
813+
}
814+
815+
.image-modal-body img {
816+
max-height: 60vh;
817+
}
818+
}

dashboard-service/src/static/js/dashboard.js

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ function loadTabData(tabName) {
8383
break;
8484
case 'unauthorized':
8585
refreshUnauthorized();
86+
refreshCapturedImages();
8687
break;
8788
case 'cameras':
8889
refreshCameraStatus();
@@ -243,6 +244,95 @@ async function refreshUnauthorized() {
243244
}
244245
}
245246

247+
// ==================== CAPTURED IMAGES ====================
248+
async function refreshCapturedImages() {
249+
const imageGrid = document.getElementById('capturedImagesGrid');
250+
const imageCount = document.getElementById('imageCount');
251+
252+
imageGrid.innerHTML = '<div class="loading">Chargement des images capturées...</div>';
253+
254+
try {
255+
const response = await fetch(`${CONFIG.API_BASE_URL}/api/images/latest`);
256+
if (!response.ok) throw new Error('Failed to fetch images');
257+
258+
const data = await response.json();
259+
const images = data.images || [];
260+
261+
imageCount.textContent = `${images.length} image(s) trouvée(s)`;
262+
263+
if (images.length === 0) {
264+
imageGrid.innerHTML = '<div class="empty">Aucune image capturée trouvée</div>';
265+
return;
266+
}
267+
268+
imageGrid.innerHTML = images.map(img => {
269+
const date = new Date(img.timestamp * 1000);
270+
const timeStr = date.toLocaleString('fr-FR', {
271+
day: '2-digit',
272+
month: '2-digit',
273+
hour: '2-digit',
274+
minute: '2-digit',
275+
second: '2-digit'
276+
});
277+
278+
return `
279+
<div class="image-card" onclick="viewFullImage('${img.url}', '${escapeHtml(img.filename)}')">
280+
<div class="image-wrapper">
281+
<img src="${img.url}" alt="${escapeHtml(img.filename)}" loading="lazy" />
282+
</div>
283+
<div class="image-info">
284+
<div class="image-filename">${escapeHtml(img.filename)}</div>
285+
<div class="image-timestamp">🕒 ${timeStr}</div>
286+
</div>
287+
</div>
288+
`;
289+
}).join('');
290+
291+
} catch (error) {
292+
console.error('Error loading captured images:', error);
293+
imageGrid.innerHTML = '<div class="empty">Erreur lors du chargement des images</div>';
294+
imageCount.textContent = 'Erreur';
295+
}
296+
}
297+
298+
function viewFullImage(url, filename) {
299+
// Create modal overlay
300+
const modal = document.createElement('div');
301+
modal.className = 'image-modal';
302+
modal.innerHTML = `
303+
<div class="image-modal-content">
304+
<div class="image-modal-header">
305+
<h3>📸 ${escapeHtml(filename)}</h3>
306+
<button class="image-modal-close" onclick="closeImageModal()">&times;</button>
307+
</div>
308+
<div class="image-modal-body">
309+
<img src="${url}" alt="${escapeHtml(filename)}" />
310+
</div>
311+
<div class="image-modal-footer">
312+
<button class="btn btn-secondary" onclick="closeImageModal()">Fermer</button>
313+
<a href="${url}" download="${filename}" class="btn btn-primary">📥 Télécharger</a>
314+
</div>
315+
</div>
316+
`;
317+
318+
modal.onclick = function(e) {
319+
if (e.target === modal) {
320+
closeImageModal();
321+
}
322+
};
323+
324+
document.body.appendChild(modal);
325+
document.body.style.overflow = 'hidden';
326+
}
327+
328+
function closeImageModal() {
329+
const modal = document.querySelector('.image-modal');
330+
if (modal) {
331+
modal.remove();
332+
document.body.style.overflow = '';
333+
}
334+
}
335+
246336
// ==================== CAMERA STATUS ====================
247337
async function refreshCameraStatus() {
248338
const cameraGrid = document.getElementById('cameraGrid');

0 commit comments

Comments
 (0)