Autonomous Publishing System — full stack: orchestrator, 8 vertical sites, admin dashboard, analytics, cron pipeline
This commit is contained in:
309
dashboard/app.py
Normal file
309
dashboard/app.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Autonomous Publishing System — Admin Dashboard
|
||||
Flask app for monitoring and controlling the publishing pipeline.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Flask, render_template_string, jsonify, request, redirect, url_for
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DB_PATH = BASE_DIR / "core" / "publisher.db"
|
||||
sys.path.insert(0, str(BASE_DIR / "core"))
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config["SECRET_KEY"] = os.environ.get("DASHBOARD_SECRET", "auto-publisher-dev")
|
||||
|
||||
VERTICALS = ["ai", "tech", "science", "crypto", "linux", "gaming", "diy", "guides"]
|
||||
|
||||
|
||||
def get_db():
|
||||
db = sqlite3.connect(str(DB_PATH))
|
||||
db.row_factory = sqlite3.Row
|
||||
return db
|
||||
|
||||
|
||||
# ─── Templates ──────────────────────────────────────────────────────
|
||||
DASHBOARD_HTML = """<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Auto Publisher — Admin Dashboard</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f172a; --card: #1e293b; --text: #e2e8f0;
|
||||
--text2: #94a3b8; --primary: #3b82f6; --green: #10b981;
|
||||
--red: #ef4444; --yellow: #f59e0b; --border: #334155;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: var(--font); background: var(--bg); color: var(--text); padding: 2rem; }
|
||||
h1 { font-size: 1.8rem; margin-bottom: 0.5rem; }
|
||||
h2 { font-size: 1.2rem; color: var(--text2); margin-bottom: 1.5rem; font-weight: 400; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
|
||||
.stat {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1.25rem;
|
||||
}
|
||||
.stat .label { font-size: 0.8rem; color: var(--text2); text-transform: uppercase; letter-spacing: 0.05em; }
|
||||
.stat .value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
|
||||
.stat .value.green { color: var(--green); }
|
||||
.stat .value.yellow { color: var(--yellow); }
|
||||
.stat .value.red { color: var(--red); }
|
||||
.panel {
|
||||
background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1.5rem; margin-bottom: 1.5rem;
|
||||
}
|
||||
.panel h3 { margin-bottom: 1rem; font-size: 1.1rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th { text-align: left; color: var(--text2); padding: 0.5rem; border-bottom: 1px solid var(--border); font-weight: 500; }
|
||||
td { padding: 0.5rem; border-bottom: 1px solid var(--border); }
|
||||
.badge {
|
||||
display: inline-block; padding: 0.15rem 0.5rem; border-radius: 4px;
|
||||
font-size: 0.75rem; font-weight: 600; text-transform: uppercase;
|
||||
}
|
||||
.badge.published { background: rgba(16, 185, 129, 0.2); color: var(--green); }
|
||||
.badge.draft { background: rgba(245, 158, 11, 0.2); color: var(--yellow); }
|
||||
.badge.discovered { background: rgba(59, 130, 246, 0.2); color: var(--primary); }
|
||||
button, .btn {
|
||||
background: var(--primary); color: white; border: none;
|
||||
padding: 0.5rem 1.25rem; border-radius: 6px; cursor: pointer;
|
||||
font-size: 0.9rem; font-weight: 500;
|
||||
}
|
||||
button:hover { opacity: 0.9; }
|
||||
button.danger { background: var(--red); }
|
||||
.actions { display: flex; gap: 0.75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
|
||||
.log-entry { font-family: monospace; font-size: 0.8rem; padding: 0.25rem 0; color: var(--text2); }
|
||||
.log-entry.error { color: var(--red); }
|
||||
.log-entry.success { color: var(--green); }
|
||||
.site-card {
|
||||
display: inline-block; background: var(--card); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 1rem 1.5rem; margin: 0.5rem;
|
||||
}
|
||||
.site-card .domain { font-weight: 600; }
|
||||
.site-card .status { font-size: 0.8rem; }
|
||||
.site-card .status.live { color: var(--green); }
|
||||
.site-card .status.pending { color: var(--yellow); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🤖 Auto Publisher</h1>
|
||||
<h2>Autonomous Publishing System — Admin Dashboard</h2>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid">
|
||||
<div class="stat">
|
||||
<div class="label">Topics Discovered</div>
|
||||
<div class="value">{{ stats.topics }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Articles Written</div>
|
||||
<div class="value">{{ stats.articles }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Published</div>
|
||||
<div class="value green">{{ stats.published }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Last Run</div>
|
||||
<div class="value" style="font-size:1rem;">{{ stats.last_run or 'Never' }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Total Pageviews</div>
|
||||
<div class="value">{{ stats.total_views }}</div>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<div class="label">Avg Score</div>
|
||||
<div class="value yellow">{{ stats.avg_score }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites -->
|
||||
<div class="panel">
|
||||
<h3>📡 Sites</h3>
|
||||
<div style="display:flex;flex-wrap:wrap;">
|
||||
{% for v in verticals %}
|
||||
<div class="site-card">
|
||||
<div class="domain">{{ v }}.thetempleofdoom.com</div>
|
||||
<div class="status {{ 'live' if v in live_sites else 'pending' }}">
|
||||
{{ '● Live' if v in live_sites else '○ Pending' }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="panel">
|
||||
<h3>🎮 Controls</h3>
|
||||
<div class="actions">
|
||||
<form method="POST" action="/api/run" style="display:inline">
|
||||
<button type="submit">▶ Run Pipeline Now</button>
|
||||
</form>
|
||||
<form method="POST" action="/api/discover" style="display:inline">
|
||||
<button type="submit">🔍 Discover Topics</button>
|
||||
</form>
|
||||
<button onclick="location.reload()">🔄 Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Topics -->
|
||||
<div class="panel">
|
||||
<h3>📋 Recent Topics</h3>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Title</th><th>Vertical</th><th>Score</th><th>Status</th><th>Discovered</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for t in topics %}
|
||||
<tr>
|
||||
<td>{{ t.title[:80] }}</td>
|
||||
<td><span class="badge">{{ t.vertical }}</span></td>
|
||||
<td>{{ t.composite_score }}</td>
|
||||
<td><span class="badge {{ t.status }}">{{ t.status }}</span></td>
|
||||
<td>{{ t.created_at[:10] if t.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Recent Articles -->
|
||||
<div class="panel">
|
||||
<h3>📝 Recent Articles</h3>
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Title</th><th>Vertical</th><th>Words</th><th>Status</th><th>Created</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for a in articles %}
|
||||
<tr>
|
||||
<td>{{ a.title[:80] if a.title else 'Untitled' }}</td>
|
||||
<td><span class="badge">{{ a.vertical }}</span></td>
|
||||
<td>{{ a.word_count }}</td>
|
||||
<td><span class="badge {{ a.status }}">{{ a.status }}</span></td>
|
||||
<td>{{ a.created_at[:10] if a.created_at else '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pipeline Log -->
|
||||
<div class="panel">
|
||||
<h3>📜 Pipeline Log</h3>
|
||||
<div style="max-height:300px;overflow-y:auto;">
|
||||
{% for entry in log_lines %}
|
||||
<div class="log-entry {{ entry.level }}">[{{ entry.time }}] {{ entry.msg }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def index():
|
||||
db = get_db()
|
||||
stats = {
|
||||
"topics": db.execute("SELECT COUNT(*) FROM topics").fetchone()[0],
|
||||
"articles": db.execute("SELECT COUNT(*) FROM articles").fetchone()[0],
|
||||
"published": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
|
||||
"last_run": None,
|
||||
"total_views": db.execute("SELECT COALESCE(SUM(pageviews), 0) FROM analytics").fetchone()[0],
|
||||
"avg_score": round(db.execute("SELECT COALESCE(AVG(composite_score), 0) FROM topics WHERE composite_score > 0").fetchone()[0], 1),
|
||||
}
|
||||
last_run = db.execute("SELECT started_at FROM pipeline_runs ORDER BY id DESC LIMIT 1").fetchone()
|
||||
if last_run:
|
||||
stats["last_run"] = last_run[0]
|
||||
|
||||
topics = db.execute("SELECT * FROM topics ORDER BY created_at DESC LIMIT 20").fetchall()
|
||||
articles = db.execute("SELECT * FROM articles ORDER BY created_at DESC LIMIT 20").fetchall()
|
||||
|
||||
# Read log
|
||||
log_path = BASE_DIR / "core" / "orchestrator.log"
|
||||
log_lines = []
|
||||
if log_path.exists():
|
||||
for line in log_path.read_text().split("\n")[-30:]:
|
||||
if not line.strip():
|
||||
continue
|
||||
level = ""
|
||||
if "ERROR" in line:
|
||||
level = "error"
|
||||
elif "✓" in line or "successful" in line.lower():
|
||||
level = "success"
|
||||
log_lines.append({
|
||||
"time": line[:19] if len(line) > 19 else "",
|
||||
"msg": line,
|
||||
"level": level,
|
||||
})
|
||||
|
||||
live_sites = ["ai", "tech"] # Will be dynamically checked
|
||||
|
||||
return render_template_string(
|
||||
DASHBOARD_HTML,
|
||||
stats=stats,
|
||||
topics=topics,
|
||||
articles=articles,
|
||||
log_lines=log_lines,
|
||||
verticals=VERTICALS,
|
||||
live_sites=live_sites,
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/run", methods=["POST"])
|
||||
def api_run():
|
||||
"""Trigger a pipeline run."""
|
||||
from orchestrator import run_daily_pipeline
|
||||
import threading
|
||||
def _run():
|
||||
run_daily_pipeline(max_articles=3)
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return jsonify({"status": "started", "message": "Pipeline running in background"})
|
||||
|
||||
|
||||
@app.route("/api/discover", methods=["POST"])
|
||||
def api_discover():
|
||||
"""Trigger trend discovery."""
|
||||
from orchestrator import discover_trends
|
||||
import threading
|
||||
def _run():
|
||||
discover_trends()
|
||||
t = threading.Thread(target=_run, daemon=True)
|
||||
t.start()
|
||||
return jsonify({"status": "started", "message": "Trend discovery running"})
|
||||
|
||||
|
||||
@app.route("/api/stats")
|
||||
def api_stats():
|
||||
db = get_db()
|
||||
return jsonify({
|
||||
"topics_total": db.execute("SELECT COUNT(*) FROM topics").fetchone()[0],
|
||||
"articles_total": db.execute("SELECT COUNT(*) FROM articles").fetchone()[0],
|
||||
"published": db.execute("SELECT COUNT(*) FROM articles WHERE status='published'").fetchone()[0],
|
||||
"by_vertical": {
|
||||
v: db.execute("SELECT COUNT(*) FROM topics WHERE vertical=?", (v,)).fetchone()[0]
|
||||
for v in VERTICALS
|
||||
},
|
||||
"pipeline_runs": db.execute("SELECT COUNT(*) FROM pipeline_runs").fetchone()[0],
|
||||
})
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
return jsonify({"status": "healthy", "timestamp": datetime.now().isoformat()})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--port", type=int, default=5106)
|
||||
ap.add_argument("--host", type=str, default="127.0.0.1")
|
||||
args = ap.parse_args()
|
||||
print(f"Admin Dashboard → http://{args.host}:{args.port}")
|
||||
app.run(host=args.host, port=args.port, debug=False)
|
||||
Reference in New Issue
Block a user