last
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user