before claude

This commit is contained in:
Gilles Soulier
2026-01-17 13:40:26 +01:00
parent d0b73b9319
commit cf7c415e22
35 changed files with 3411 additions and 221 deletions

15
analytics-ui/Dockerfile Normal file
View File

@@ -0,0 +1,15 @@
FROM python:3.12-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY app.py .
EXPOSE 80
CMD ["python", "app.py"]

381
analytics-ui/app.py Normal file
View File

@@ -0,0 +1,381 @@
import os
from typing import Any, Dict, List, Optional, Tuple
from decimal import Decimal
from psycopg2.extras import RealDictCursor
import psycopg2
import redis
from flask import Flask, jsonify, render_template_string
app = Flask(__name__)
def _env_int(name: str, default: int) -> int:
try:
return int(os.getenv(name, "") or default)
except ValueError:
return default
def get_db_connection():
return psycopg2.connect(
host=os.getenv("PW_DB_HOST", "postgres"),
port=_env_int("PW_DB_PORT", 5432),
dbname=os.getenv("PW_DB_NAME", "pricewatch"),
user=os.getenv("PW_DB_USER", "pricewatch"),
password=os.getenv("PW_DB_PASSWORD", "pricewatch"),
)
def fetch_db_metrics() -> Tuple[Dict[str, Any], Optional[str]]:
data: Dict[str, Any] = {"counts": {}, "latest_products": []}
try:
with get_db_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) FROM products")
data["counts"]["products"] = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM price_history")
data["counts"]["price_history"] = cur.fetchone()[0]
cur.execute(
"SELECT COUNT(*) FROM scraping_logs"
)
data["counts"]["scraping_logs"] = cur.fetchone()[0]
cur.execute(
"""
SELECT id, source, reference, title, last_updated_at
FROM products
ORDER BY last_updated_at DESC
LIMIT 5
"""
)
rows = cur.fetchall()
data["latest_products"] = [
{
"id": row[0],
"source": row[1],
"reference": row[2],
"title": row[3] or "Sans titre",
"updated": row[4].strftime("%Y-%m-%d %H:%M:%S")
if row[4]
else "n/a",
}
for row in rows
]
return data, None
except Exception as exc: # pragma: no cover (simple explorer)
return data, str(exc)
def _serialize_decimal(value):
if isinstance(value, Decimal):
return float(value)
return value
def fetch_products_list(limit: int = 200) -> Tuple[List[Dict[str, Any]], Optional[str]]:
rows: List[Dict[str, Any]] = []
try:
with get_db_connection() as conn:
with conn.cursor(cursor_factory=RealDictCursor) as cur:
cur.execute(
"""
SELECT
p.id,
p.source,
p.reference,
p.title,
p.url,
p.category,
p.description,
p.currency,
p.msrp,
p.last_updated_at,
ph.price,
ph.stock_status,
ph.fetch_status,
ph.fetch_method,
ph.fetched_at
FROM products p
LEFT JOIN LATERAL (
SELECT price, stock_status, fetch_status, fetch_method, fetched_at
FROM price_history
WHERE product_id = p.id
ORDER BY fetched_at DESC
LIMIT 1
) ph ON true
ORDER BY p.last_updated_at DESC
LIMIT %s
""",
(limit,),
)
fetched = cur.fetchall()
for item in fetched:
serialized = {key: _serialize_decimal(value) for key, value in item.items()}
if serialized.get("last_updated_at"):
serialized["last_updated_at"] = serialized["last_updated_at"].strftime(
"%Y-%m-%d %H:%M:%S"
)
if serialized.get("fetched_at"):
serialized["fetched_at"] = serialized["fetched_at"].strftime(
"%Y-%m-%d %H:%M:%S"
)
rows.append(serialized)
return rows, None
except Exception as exc:
return rows, str(exc)
def get_redis_client() -> redis.Redis:
return redis.Redis(
host=os.getenv("PW_REDIS_HOST", "redis"),
port=_env_int("PW_REDIS_PORT", 6379),
db=_env_int("PW_REDIS_DB", 0),
socket_connect_timeout=2,
socket_timeout=2,
)
def check_redis() -> Tuple[str, Optional[str]]:
client = get_redis_client()
try:
client.ping()
return "OK", None
except Exception as exc:
return "KO", str(exc)
TEMPLATE = """
<!doctype html>
<html lang="fr">
<head>
<meta charset="utf-8" />
<title>PriceWatch Analytics UI</title>
<style>
body { font-family: "JetBrains Mono", system-ui, monospace; background:#1f1f1b; color:#ebe0c8; margin:0; padding:32px; }
main { max-width: 960px; margin: 0 auto; }
h1 { margin-bottom: 0; }
section { margin-top: 24px; background:#282828; border:1px solid rgba(255,255,255,0.08); padding:16px; border-radius:14px; box-shadow:0 14px 30px rgba(0,0,0,0.35); }
table { width:100%; border-collapse:collapse; margin-top:12px; }
th, td { text-align:left; padding:6px 8px; border-bottom:1px solid rgba(255,255,255,0.08); }
.status { display:inline-flex; align-items:center; gap:6px; font-size:14px; padding:4px 10px; border-radius:999px; background:rgba(255,255,255,0.05); }
.status.ok { background:rgba(184,187,38,0.15); }
.status.ko { background:rgba(251,73,52,0.2); }
.muted { color:rgba(255,255,255,0.5); font-size:13px; }
.browser-panel { margin-top: 16px; display: flex; flex-direction: column; gap: 12px; }
.browser-controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.browser-controls button { border-radius: 8px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.04); color: inherit; padding: 6px 12px; cursor: pointer; transition: transform 0.15s ease; }
.browser-controls button:hover { transform: translateY(-1px); }
.browser-display { padding: 12px; border-radius: 12px; background: rgba(255,255,255,0.02); border: 1px solid rgba(255,255,255,0.08); min-height: 150px; font-size: 0.85rem; }
.browser-display dt { font-weight: 700; }
.browser-display dd { margin: 0 0 8px 0; }
.browser-indicator { font-size: 0.9rem; }
</style>
</head>
<body>
<main>
<header>
<h1>PriceWatch Analytics UI</h1>
<p class="muted">PostgreSQL : {{ db_status }} · Redis : {{ redis_status }}</p>
</header>
<section>
<h2>Vue rapide</h2>
<div class="status {{ 'ok' if db_error is none else 'ko' }}">
Base : {{ db_status }}
</div>
<div class="status {{ 'ok' if redis_status == 'OK' else 'ko' }}">
Redis : {{ redis_status }}
</div>
{% if db_error or redis_error %}
<p class="muted">Erreurs : {{ db_error or '' }} {{ redis_error or '' }}</p>
{% endif %}
</section>
<section>
<h2>Stats métier</h2>
<table>
<tr><th>Produits</th><td>{{ metrics.counts.products }}</td></tr>
<tr><th>Historique prix</th><td>{{ metrics.counts.price_history }}</td></tr>
<tr><th>Logs de scraping</th><td>{{ metrics.counts.scraping_logs }}</td></tr>
</table>
</section>
<section>
<h2>Produits récemment mis à jour</h2>
{% if metrics.latest_products %}
<table>
<thead>
<tr><th>ID</th><th>Store</th><th>Référence</th><th>Révision</th><th>Mis à jour</th></tr>
</thead>
<tbody>
{% for item in metrics.latest_products %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.source }}</td>
<td>{{ item.reference }}</td>
<td>{{ item.title[:40] }}{% if item.title|length > 40 %}…{% endif %}</td>
<td>{{ item.updated }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="muted">Aucun produit enregistré.</p>
{% endif %}
</section>
<section>
<h2>Parcourir la base (produits)</h2>
<div class="browser-panel">
<div class="browser-controls">
<button id="load-products">Charger les produits</button>
<button id="product-prev" disabled>Précédent</button>
<button id="product-next" disabled>Suivant</button>
<strong class="browser-indicator" id="product-indicator">0 / 0</strong>
<span class="muted" id="product-message"></span>
</div>
<dl class="browser-display" id="product-details">
<dt data-field="title">Titre</dt>
<dd id="product-title">-</dd>
<dt data-field="store">Store</dt>
<dd data-field="store">-</dd>
<dt data-field="reference">Référence</dt>
<dd data-field="reference">-</dd>
<dt data-field="price">Dernier prix</dt>
<dd data-field="price">-</dd>
<dt data-field="currency">Devise</dt>
<dd data-field="currency">-</dd>
<dt data-field="msrp">Prix conseillé</dt>
<dd data-field="msrp">-</dd>
<dt data-field="stock_status">Stock</dt>
<dd data-field="stock_status">-</dd>
<dt data-field="category">Catégorie</dt>
<dd data-field="category">-</dd>
<dt data-field="description">Description</dt>
<dd data-field="description">-</dd>
<dt data-field="last_updated_at">Dernière mise à jour</dt>
<dd data-field="last_updated_at">-</dd>
<dt data-field="fetched_at">Historique dernier scrap</dt>
<dd data-field="fetched_at">-</dd>
</dl>
</div>
</section>
</main>
<script>
document.addEventListener("DOMContentLoaded", () => {
const loadBtn = document.getElementById("load-products");
const prevBtn = document.getElementById("product-prev");
const nextBtn = document.getElementById("product-next");
const indicator = document.getElementById("product-indicator");
const message = document.getElementById("product-message");
const titleEl = document.getElementById("product-title");
const fields = Array.from(document.querySelectorAll("[data-field]")).reduce((acc, el) => {
acc[el.getAttribute("data-field")] = el;
return acc;
}, {});
let products = [];
let cursor = 0;
const setStatus = (text) => {
message.textContent = text || "";
};
const renderProduct = () => {
if (!products.length) {
indicator.textContent = "0 / 0";
titleEl.textContent = "-";
Object.values(fields).forEach((el) => (el.textContent = "-"));
prevBtn.disabled = true;
nextBtn.disabled = true;
return;
}
const current = products[cursor];
indicator.textContent = `${cursor + 1} / ${products.length}`;
titleEl.textContent = current.title || "Sans titre";
const mapField = {
store: current.source,
reference: current.reference,
price: current.price !== null && current.price !== undefined ? current.price : "n/a",
currency: current.currency || "EUR",
msrp: current.msrp || "-",
stock_status: current.stock_status || "n/a",
category: current.category || "n/a",
description: (current.description || "n/a").slice(0, 200),
last_updated_at: current.last_updated_at || "n/a",
fetched_at: current.fetched_at || "n/a",
};
Object.entries(mapField).forEach(([key, value]) => {
if (fields[key]) {
fields[key].textContent = value;
}
});
prevBtn.disabled = cursor === 0;
nextBtn.disabled = cursor >= products.length - 1;
};
const fetchProducts = async () => {
setStatus("Chargement…");
try {
const response = await fetch("/products.json");
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (!Array.isArray(data)) {
throw new Error("Réponse invalide");
}
products = data;
cursor = 0;
setStatus(`Chargé ${products.length} produit(s)`);
renderProduct();
} catch (err) {
setStatus(`Erreur: ${err.message}`);
products = [];
renderProduct();
}
};
loadBtn.addEventListener("click", fetchProducts);
prevBtn.addEventListener("click", () => {
if (cursor > 0) {
cursor -= 1;
renderProduct();
}
});
nextBtn.addEventListener("click", () => {
if (cursor + 1 < products.length) {
cursor += 1;
renderProduct();
}
});
});
</script>
</body>
</html>
"""
@app.route("/")
def root():
metrics, db_error = fetch_db_metrics()
redis_status, redis_error = check_redis()
return render_template_string(
TEMPLATE,
metrics=metrics,
db_status="connecté" if db_error is None else "erreur",
db_error=db_error,
redis_status=redis_status,
redis_error=redis_error,
)
@app.route("/products.json")
def products_json():
products, error = fetch_products_list()
if error:
return jsonify({"error": error}), 500
return jsonify(products)
if __name__ == "__main__":
app.run(host="0.0.0.0", port=80)

View File

@@ -0,0 +1,3 @@
Flask==3.0.0
psycopg2-binary==2.9.11
redis==5.0.0