before claude
This commit is contained in:
15
analytics-ui/Dockerfile
Normal file
15
analytics-ui/Dockerfile
Normal 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
381
analytics-ui/app.py
Normal 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)
|
||||
3
analytics-ui/requirements.txt
Normal file
3
analytics-ui/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
Flask==3.0.0
|
||||
psycopg2-binary==2.9.11
|
||||
redis==5.0.0
|
||||
Reference in New Issue
Block a user