This commit is contained in:
2026-01-25 14:48:26 +01:00
parent 5c3e6b84a4
commit c56a4632a2
958 changed files with 1149102 additions and 123 deletions

View File

@@ -14,6 +14,7 @@ const Header = () => {
const { fetchProducts, scrapeAll, loading } = useProductStore();
const [showAddModal, setShowAddModal] = useState(false);
const [backendVersion, setBackendVersion] = useState(null);
const [stats, setStats] = useState(null);
useEffect(() => {
api.fetchBackendVersion()
@@ -21,6 +22,19 @@ const Header = () => {
.catch(() => setBackendVersion("?"));
}, []);
// Fetch system stats toutes les 30 secondes
useEffect(() => {
const fetchStats = () => {
api.fetchSystemStats()
.then((data) => setStats(data))
.catch(() => setStats(null));
};
fetchStats();
const interval = setInterval(fetchStats, 30000);
return () => clearInterval(interval);
}, []);
const handleRefresh = () => {
fetchProducts();
};
@@ -38,7 +52,10 @@ const Header = () => {
<>
<header className="app-header">
<div className="brand">
<NavLink to="/">suivi_produits</NavLink>
<NavLink to="/">
<img className="brand-logo" src="/store.svg" alt="Store" />
suivi_produits
</NavLink>
</div>
<nav className="nav-links">
<NavLink to="/" end className={({ isActive }) => isActive ? "active" : ""}>
@@ -62,6 +79,19 @@ const Header = () => {
<i className={`fa-solid fa-refresh ${loading ? "fa-spin" : ""}`}></i>
</button>
</div>
{stats && (
<div className="system-stats">
<span title="CPU du process backend">
<i className="fa-solid fa-microchip"></i> {stats.cpu_percent}%
</span>
<span title="Mémoire du process backend">
<i className="fa-solid fa-memory"></i> {stats.memory_mb} Mo
</span>
<span title="Taille données (data + logs)">
<i className="fa-solid fa-database"></i> {stats.data_size_mb < 1000 ? `${stats.data_size_mb} Mo` : `${(stats.data_size_mb / 1024).toFixed(1)} Go`}
</span>
</div>
)}
<div className="version-info">
<span title="Version Frontend">FE v{FRONTEND_VERSION}</span>
<span title="Version Backend">BE v{backendVersion || "..."}</span>

View File

@@ -75,8 +75,8 @@ export const scrapePreview = async (url) => {
};
// Snapshots
export const fetchSnapshots = async (productId, limit = 30) => {
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?limit=${limit}`);
export const fetchSnapshots = async (productId, days = 30) => {
const response = await fetch(`${BASE_URL}/products/${productId}/snapshots?days=${days}`);
return handleResponse(response);
};
@@ -126,3 +126,31 @@ export const fetchBackendVersion = async () => {
const response = await fetch(`${BASE_URL}/version`);
return handleResponse(response);
};
// System stats
export const fetchSystemStats = async () => {
const response = await fetch(`${BASE_URL}/stats`);
return handleResponse(response);
};
// Database backup
export const fetchDatabaseInfo = async () => {
const response = await fetch(`${BASE_URL}/config/database/info`);
return handleResponse(response);
};
export const downloadDatabaseBackup = () => {
// Téléchargement direct via le navigateur
window.location.href = `${BASE_URL}/config/database/backup`;
};
export const restoreDatabase = async (file) => {
const formData = new FormData();
formData.append("file", file);
const response = await fetch(`${BASE_URL}/config/database/restore`, {
method: "POST",
body: formData,
});
return handleResponse(response);
};

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import {
Chart as ChartJS,
CategoryScale,
TimeScale,
LinearScale,
PointElement,
LineElement,
@@ -9,13 +9,15 @@ import {
Tooltip,
Filler,
} from "chart.js";
import "chartjs-adapter-date-fns";
import { fr } from "date-fns/locale";
import annotationPlugin from "chartjs-plugin-annotation";
import { Line } from "react-chartjs-2";
import * as api from "../../api/client";
// Enregistrer les composants Chart.js
ChartJS.register(
CategoryScale,
TimeScale,
LinearScale,
PointElement,
LineElement,
@@ -174,8 +176,15 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
}
}
const labels = sortedSnapshots.map((s) => formatDateLabel(s.scrape_le, spanDays));
const prices = sortedSnapshots.map((s) => s.prix_actuel);
// Données au format {x: Date, y: prix} pour TimeScale
const chartPoints = sortedSnapshots
.filter((s) => s.prix_actuel != null)
.map((s) => ({
x: parseUTCDate(s.scrape_le),
y: s.prix_actuel,
}));
const prices = chartPoints.map((p) => p.y);
// Calculer min/max
const validPrices = prices.filter((p) => p != null);
@@ -229,10 +238,9 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
}
const chartData = {
labels,
datasets: [
{
data: prices,
data: chartPoints,
borderColor: trend.color,
backgroundColor: `${trend.color}20`,
fill: true,
@@ -245,6 +253,14 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
],
};
// Déterminer l'unité de temps appropriée selon la période
const getTimeUnit = () => {
if (spanDays < 1) return "hour";
if (spanDays <= 7) return "day";
if (spanDays <= 90) return "week";
return "month";
};
const chartOptions = {
responsive: true,
maintainAspectRatio: false,
@@ -260,7 +276,18 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
borderWidth: 1,
padding: 10,
callbacks: {
label: (context) => formatPrice(context.raw),
title: (context) => {
const date = context[0]?.raw?.x;
if (!date) return "";
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
},
label: (context) => formatPrice(context.raw?.y),
},
},
annotation: {
@@ -269,6 +296,21 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
},
scales: {
x: {
type: "time",
time: {
unit: getTimeUnit(),
displayFormats: {
hour: "HH:mm",
day: "dd/MM",
week: "dd/MM",
month: "MMM yyyy",
},
},
adapters: {
date: {
locale: fr,
},
},
grid: {
display: false,
},
@@ -276,7 +318,7 @@ const PriceChart = ({ productId, prixConseille, prixMin30j }) => {
color: COLORS.muted,
font: { size: 10 },
maxRotation: 0,
maxTicksLimit: selectedPeriod <= 7 ? 7 : 5,
maxTicksLimit: 6,
},
},
y: {

View File

@@ -1,6 +1,7 @@
import React, { useState } from "react";
import ReactDOM from "react-dom";
import Lightbox from "../common/Lightbox";
import useProductStore from "../../stores/useProductStore";
const formatPrice = (price) => {
if (price == null) return null;
@@ -30,6 +31,41 @@ const formatDate = (dateStr) => {
const ProductDetailModal = ({ product, onClose }) => {
const [showLightbox, setShowLightbox] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [editForm, setEditForm] = useState({
titre: product.titre || "",
categorie: product.categorie || "",
type: product.type || "",
actif: product.actif ?? true,
});
const { updateProduct } = useProductStore();
const handleEditChange = (field, value) => {
setEditForm((prev) => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
setIsSaving(true);
try {
await updateProduct(product.id, editForm);
setIsEditing(false);
} catch (err) {
console.error("Erreur lors de la sauvegarde:", err);
} finally {
setIsSaving(false);
}
};
const handleCancelEdit = () => {
setEditForm({
titre: product.titre || "",
categorie: product.categorie || "",
type: product.type || "",
actif: product.actif ?? true,
});
setIsEditing(false);
};
const handleBackdropClick = (e) => {
if (e.target === e.currentTarget) {
@@ -57,9 +93,29 @@ const ProductDetailModal = ({ product, onClose }) => {
<h2>
<i className="fa-brands fa-amazon"></i> Détail du produit
</h2>
<button className="btn-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
<div className="modal-header-actions">
{!isEditing ? (
<button className="btn btn-edit" onClick={() => setIsEditing(true)}>
<i className="fa-solid fa-pen"></i> Éditer
</button>
) : (
<>
<button className="btn btn-save" onClick={handleSave} disabled={isSaving}>
{isSaving ? (
<><i className="fa-solid fa-spinner fa-spin"></i> Enregistrement...</>
) : (
<><i className="fa-solid fa-check"></i> Enregistrer</>
)}
</button>
<button className="btn btn-cancel" onClick={handleCancelEdit} disabled={isSaving}>
<i className="fa-solid fa-times"></i> Annuler
</button>
</>
)}
<button className="btn-close" onClick={onClose}>
<i className="fa-solid fa-times"></i>
</button>
</div>
</div>
<div className="modal-body">
@@ -90,14 +146,37 @@ const ProductDetailModal = ({ product, onClose }) => {
<span className="boutique">
<i className="fa-brands fa-amazon"></i> {product.boutique}
</span>
{product.actif ? (
<span className="status active">Actif</span>
{isEditing ? (
<label className="status-toggle">
<input
type="checkbox"
checked={editForm.actif}
onChange={(e) => handleEditChange("actif", e.target.checked)}
/>
<span className={`status ${editForm.actif ? "active" : "inactive"}`}>
{editForm.actif ? "Actif" : "Inactif"}
</span>
</label>
) : (
<span className="status inactive">Inactif</span>
product.actif ? (
<span className="status active">Actif</span>
) : (
<span className="status inactive">Inactif</span>
)
)}
</div>
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
{isEditing ? (
<input
type="text"
className="edit-input edit-title"
value={editForm.titre}
onChange={(e) => handleEditChange("titre", e.target.value)}
placeholder="Titre du produit"
/>
) : (
<h3 className="detail-title-large">{product.titre || "Titre non disponible"}</h3>
)}
{/* Badges */}
{hasBadges && (
@@ -197,18 +276,40 @@ const ProductDetailModal = ({ product, onClose }) => {
<span className="label">ASIN</span>
<span className="value mono">{product.asin}</span>
</div>
{product.categorie && (
{product.categorie_amazon && (
<div className="detail-data-row">
<span className="label">Catégorie</span>
<span className="value">{product.categorie}</span>
</div>
)}
{product.type && (
<div className="detail-data-row">
<span className="label">Type</span>
<span className="value">{product.type}</span>
<span className="label">Catégorie Amazon</span>
<span className="value category-path">{product.categorie_amazon}</span>
</div>
)}
<div className="detail-data-row">
<span className="label">Catégorie</span>
{isEditing ? (
<input
type="text"
className="edit-input"
value={editForm.categorie}
onChange={(e) => handleEditChange("categorie", e.target.value)}
placeholder="Catégorie personnalisée"
/>
) : (
<span className="value">{product.categorie || "-"}</span>
)}
</div>
<div className="detail-data-row">
<span className="label">Type</span>
{isEditing ? (
<input
type="text"
className="edit-input"
value={editForm.type}
onChange={(e) => handleEditChange("type", e.target.value)}
placeholder="Type de produit"
/>
) : (
<span className="value">{product.type || "-"}</span>
)}
</div>
{product.cree_le && (
<div className="detail-data-row">
<span className="label">Ajouté le</span>

View File

@@ -1,4 +1,4 @@
import React, { useEffect } from "react";
import React, { useEffect, useRef } from "react";
import useProductStore from "../stores/useProductStore";
import useConfigStore from "../stores/useConfigStore";
import ProductGrid from "../components/products/ProductGrid";
@@ -6,12 +6,39 @@ import ProductGrid from "../components/products/ProductGrid";
const HomePage = () => {
const { products, loading, error, fetchProducts, clearError } = useProductStore();
const { config, fetchConfig } = useConfigStore();
const refreshIntervalRef = useRef(null);
// Chargement initial
useEffect(() => {
fetchConfig();
fetchProducts();
}, [fetchProducts, fetchConfig]);
// Auto-refresh basé sur la config (rechargement complet de la page)
useEffect(() => {
const refreshInterval = config.ui?.refresh_auto_seconds || 0;
// Nettoyer l'ancien intervalle
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
refreshIntervalRef.current = null;
}
// Configurer le nouvel intervalle si > 0
if (refreshInterval > 0) {
refreshIntervalRef.current = setInterval(() => {
window.location.reload();
}, refreshInterval * 1000);
}
// Cleanup à la destruction du composant
return () => {
if (refreshIntervalRef.current) {
clearInterval(refreshIntervalRef.current);
}
};
}, [config.ui?.refresh_auto_seconds]);
const columns = config.ui?.columns_desktop || 3;
return (

View File

@@ -56,6 +56,11 @@ const useConfigStore = create((set) => ({
return state.config.ui?.image_ratio || 40;
},
getRefreshInterval: () => {
const state = useConfigStore.getState();
return state.config.ui?.refresh_auto_seconds || 0;
},
// Setter local pour mise à jour en temps réel (sans sauvegarder)
setImageRatioLocal: (ratio) => {
set((state) => ({

View File

@@ -54,6 +54,9 @@ a {
font-size: 1.5rem;
font-weight: 600;
a {
display: inline-flex;
align-items: center;
gap: 8px;
color: $text;
&:hover {
color: $accent;
@@ -61,6 +64,11 @@ a {
}
}
}
.brand-logo {
width: 22px;
height: 22px;
display: block;
}
.nav-links {
display: flex;
@@ -93,6 +101,28 @@ a {
gap: 8px;
}
.system-stats {
display: flex;
gap: 12px;
font-size: 0.75rem;
color: $gray;
font-family: monospace;
span {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
background: rgba($card, 0.5);
border-radius: 4px;
i {
color: $accent;
font-size: 0.7rem;
}
}
}
.version-info {
display: flex;
gap: 12px;