Add data-driven dashboard with API backend

- New cheddahbot/api.py: FastAPI router with endpoints for tasks,
  link building, press releases, agents, system health, notifications,
  KV states, and cache management (all cached 5min)
- Rewrote dashboard/index.html: replaces all hardcoded data with JS
  that fetches from /api/ endpoints. Tabs: Overview, Link Building,
  Press Releases, By Company, System Health, Agents, Notifications
- Updated __main__.py: mounts API router, removes old inline
  /api/linkbuilding/status endpoint
- Fixed run_link_building to reject empty LB Method instead of
  defaulting to Cora Backlinks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
cora-start
PeninsulaInd 2026-02-19 21:53:51 -06:00
parent dd39fa2e94
commit e1992fa049
5 changed files with 2240 additions and 72 deletions

View File

@ -1,6 +1,5 @@
"""Entry point: python -m cheddahbot""" """Entry point: python -m cheddahbot"""
import json
import logging import logging
from pathlib import Path from pathlib import Path
@ -146,6 +145,13 @@ def main():
fastapi_app = FastAPI() fastapi_app = FastAPI()
# Mount API endpoints
from .api import create_api_router
api_router = create_api_router(config, db, registry)
fastapi_app.include_router(api_router)
log.info("API router mounted at /api/")
# Mount the dashboard as static files (must come before Gradio's catch-all) # Mount the dashboard as static files (must come before Gradio's catch-all)
dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard" dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard"
if dashboard_dir.is_dir(): if dashboard_dir.is_dir():
@ -161,76 +167,6 @@ def main():
) )
log.info("Dashboard mounted at /dashboard/ (serving %s)", dashboard_dir) log.info("Dashboard mounted at /dashboard/ (serving %s)", dashboard_dir)
# Link building status API endpoint (for dashboard consumption)
@fastapi_app.get("/api/linkbuilding/status")
async def linkbuilding_status():
"""Return link building pipeline status for dashboard consumption."""
result = {
"pending_cora_runs": [],
"in_progress": [],
"completed": [],
"failed": [],
}
# Query KV store for tracked link building states
try:
for key, value in db.kv_scan("linkbuilding:watched:"):
try:
state = json.loads(value)
entry = {
"filename": state.get("filename", key.split(":")[-1]),
"status": state.get("status", "unknown"),
"task_id": state.get("task_id", ""),
}
if state.get("status") == "completed":
entry["completed_at"] = state.get("completed_at", "")
result["completed"].append(entry)
elif state.get("status") == "failed":
entry["error"] = state.get("error", "")
entry["failed_at"] = state.get("failed_at", "")
result["failed"].append(entry)
elif state.get("status") == "processing":
entry["started_at"] = state.get("started_at", "")
result["in_progress"].append(entry)
except json.JSONDecodeError:
pass
except Exception as e:
log.warning("Error reading linkbuilding KV state: %s", e)
# Query ClickUp for pending tasks (to do + Link Building + Cora Backlinks)
if config.clickup.enabled:
try:
from .clickup import ClickUpClient
cu = ClickUpClient(
api_token=config.clickup.api_token,
workspace_id=config.clickup.workspace_id,
task_type_field_name=config.clickup.task_type_field_name,
)
try:
tasks = cu.get_tasks_from_space(config.clickup.space_id, statuses=["to do"])
for task in tasks:
if task.task_type != "Link Building":
continue
lb_method = task.custom_fields.get("LB Method", "")
if lb_method and lb_method != "Cora Backlinks":
continue
result["pending_cora_runs"].append(
{
"keyword": task.custom_fields.get("Keyword", ""),
"url": task.custom_fields.get("IMSURL", ""),
"client": task.custom_fields.get("Client", ""),
"task_id": task.id,
"task_name": task.name,
}
)
finally:
cu.close()
except Exception as e:
log.warning("Error querying ClickUp for pending link building: %s", e)
return result
# Mount Gradio at the root # Mount Gradio at the root
gr.mount_gradio_app(fastapi_app, blocks, path="/", pwa=True, show_error=True) gr.mount_gradio_app(fastapi_app, blocks, path="/", pwa=True, show_error=True)

297
cheddahbot/api.py 100644
View File

@ -0,0 +1,297 @@
"""Dashboard API endpoints.
Provides JSON data for the standalone HTML dashboard.
All ClickUp data is cached for 5 minutes to avoid hammering the API.
"""
from __future__ import annotations
import json
import logging
import shutil
import threading
import time
from typing import TYPE_CHECKING
from fastapi import APIRouter
if TYPE_CHECKING:
from .agent_registry import AgentRegistry
from .config import Config
from .db import Database
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
# Module-level refs wired at startup by create_api_router()
_config: Config | None = None
_db: Database | None = None
_registry: AgentRegistry | None = None
# Simple in-memory cache for ClickUp data
_cache: dict[str, dict] = {}
_cache_lock = threading.Lock()
CACHE_TTL = 300 # 5 minutes
def create_api_router(
config: Config, db: Database, registry: AgentRegistry
) -> APIRouter:
"""Wire dependencies and return the router."""
global _config, _db, _registry
_config = config
_db = db
_registry = registry
return router
def _get_cached(key: str) -> dict | None:
with _cache_lock:
entry = _cache.get(key)
if entry and time.time() - entry["ts"] < CACHE_TTL:
return entry["data"]
return None
def _set_cached(key: str, data):
with _cache_lock:
_cache[key] = {"data": data, "ts": time.time()}
def _get_clickup_client():
from .clickup import ClickUpClient
return ClickUpClient(
api_token=_config.clickup.api_token,
workspace_id=_config.clickup.workspace_id,
task_type_field_name=_config.clickup.task_type_field_name,
)
# ── Tasks ──
@router.get("/tasks")
async def get_tasks():
"""All ClickUp tasks grouped by work category."""
cached = _get_cached("tasks")
if cached is not None:
return cached
if not _config or not _config.clickup.enabled:
return {"tasks": [], "error": "ClickUp not configured"}
client = _get_clickup_client()
try:
raw_tasks = client.get_tasks_from_space(_config.clickup.space_id)
tasks = []
for t in raw_tasks:
tasks.append(
{
"id": t.id,
"name": t.name,
"status": t.status,
"task_type": t.task_type,
"url": t.url,
"due_date": t.due_date,
"list_name": t.list_name,
"custom_fields": t.custom_fields,
}
)
result = {"tasks": tasks, "count": len(tasks)}
_set_cached("tasks", result)
return result
except Exception as e:
log.error("Failed to fetch tasks for dashboard: %s", e)
return {"tasks": [], "error": str(e)}
finally:
client.close()
@router.get("/tasks/by-company")
async def get_tasks_by_company():
"""Tasks grouped by Client custom field."""
data = await get_tasks()
by_company: dict[str, list] = {}
for task in data.get("tasks", []):
company = task["custom_fields"].get("Client") or "Unassigned"
by_company.setdefault(company, []).append(task)
# Sort companies by task count descending
sorted_companies = sorted(by_company.items(), key=lambda x: -len(x[1]))
return {
"companies": [
{"name": name, "tasks": tasks, "count": len(tasks)}
for name, tasks in sorted_companies
]
}
@router.get("/tasks/link-building")
async def get_link_building_tasks():
"""Link building tasks with KV state merged in."""
data = await get_tasks()
lb_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Link Building"]
# Merge KV state
if _db:
for task in lb_tasks:
kv_key = f"clickup:task:{task['id']}:state"
raw = _db.kv_get(kv_key)
if raw:
try:
task["kv_state"] = json.loads(raw)
except json.JSONDecodeError:
task["kv_state"] = None
else:
task["kv_state"] = None
# Group by company
by_company: dict[str, list] = {}
for task in lb_tasks:
company = task["custom_fields"].get("Client") or "Unassigned"
by_company.setdefault(company, []).append(task)
return {
"total": len(lb_tasks),
"companies": [
{"name": name, "tasks": tasks, "count": len(tasks)}
for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1]))
],
"status_counts": _count_statuses(lb_tasks),
}
@router.get("/tasks/press-releases")
async def get_press_release_tasks():
"""Press release tasks with KV state merged in."""
data = await get_tasks()
pr_tasks = [t for t in data.get("tasks", []) if t["task_type"] == "Press Release"]
if _db:
for task in pr_tasks:
kv_key = f"clickup:task:{task['id']}:state"
raw = _db.kv_get(kv_key)
if raw:
try:
task["kv_state"] = json.loads(raw)
except json.JSONDecodeError:
task["kv_state"] = None
else:
task["kv_state"] = None
by_company: dict[str, list] = {}
for task in pr_tasks:
company = task["custom_fields"].get("Client") or "Unassigned"
by_company.setdefault(company, []).append(task)
return {
"total": len(pr_tasks),
"companies": [
{"name": name, "tasks": tasks, "count": len(tasks)}
for name, tasks in sorted(by_company.items(), key=lambda x: -len(x[1]))
],
"status_counts": _count_statuses(pr_tasks),
}
def _count_statuses(tasks: list[dict]) -> dict[str, int]:
counts: dict[str, int] = {}
for t in tasks:
s = t.get("status", "unknown")
counts[s] = counts.get(s, 0) + 1
return counts
# ── Agents ──
@router.get("/agents")
async def get_agents():
"""Agent configurations."""
if not _config:
return {"agents": []}
agents = []
for ac in _config.agents:
agents.append(
{
"name": ac.name,
"display_name": ac.display_name,
"personality_file": ac.personality_file,
"model": ac.model or _config.chat_model,
"tools": ac.tools,
"skills": ac.skills,
"memory_scope": ac.memory_scope or "shared",
}
)
return {"agents": agents}
# ── System ──
@router.get("/system/health")
async def get_system_health():
"""System health: disk space, brains, integrations."""
health = {
"execution_brain": shutil.which("claude") is not None,
"clickup_enabled": _config.clickup.enabled if _config else False,
"chat_model": _config.chat_model if _config else "unknown",
"execution_model": _config.default_model if _config else "unknown",
"disks": [],
}
# Disk space (Windows drives)
for drive in ["C:", "D:", "E:", "Z:"]:
try:
usage = shutil.disk_usage(drive + "/")
health["disks"].append(
{
"drive": drive,
"total_gb": round(usage.total / (1024**3), 1),
"free_gb": round(usage.free / (1024**3), 1),
"percent_free": round(usage.free / usage.total * 100, 1),
}
)
except (FileNotFoundError, OSError):
pass
return health
@router.get("/system/notifications")
async def get_notifications():
"""Recent notifications from the DB."""
if not _db:
return {"notifications": []}
notes = _db.get_notifications_after(0, limit=100)
return {"notifications": notes}
@router.get("/system/kv-states")
async def get_kv_states():
"""All ClickUp task states from KV store."""
if not _db:
return {"states": []}
pairs = _db.kv_scan("clickup:task:")
states = []
for _key, val in pairs:
try:
state = json.loads(val)
states.append(state)
except json.JSONDecodeError:
pass
return {"states": states}
@router.post("/cache/clear")
async def clear_cache():
"""Clear the ClickUp data cache."""
with _cache_lock:
_cache.clear()
return {"status": "cleared"}

View File

@ -413,7 +413,13 @@ def run_link_building(
ctx: dict | None = None, ctx: dict | None = None,
) -> str: ) -> str:
"""Dispatch to the correct link building pipeline based on lb_method.""" """Dispatch to the correct link building pipeline based on lb_method."""
method = (lb_method or "Cora Backlinks").strip() method = (lb_method or "").strip()
if not method:
return (
"Skipped: 'LB Method' field is empty. Each Link Building task must have "
"an LB Method set (e.g. 'Cora Backlinks') before processing can begin."
)
if method == "Cora Backlinks": if method == "Cora Backlinks":
# For Cora Backlinks, xlsx_path is required # For Cora Backlinks, xlsx_path is required

View File

@ -0,0 +1,816 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>CheddahBot Command Dashboard</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=Playfair+Display:wght@700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="dashboard">
<!-- ============ TOP BAR ============ -->
<header class="topbar">
<div class="topbar__brand">
<button class="mobile-toggle" onclick="toggleSidebar()" aria-label="Toggle navigation">&#9776;</button>
<div class="topbar__logo">C</div>
<span class="topbar__title">CheddahBot</span>
<span class="topbar__subtitle">Command Dashboard</span>
</div>
<div class="topbar__right">
<div class="topbar__status" id="system-status">
<span class="dot"></span>
<span>Loading...</span>
</div>
<button class="mobile-toggle" onclick="refreshAll()" aria-label="Refresh" style="display:inline-flex;font-size:0.85rem;" title="Refresh data">&#8635;</button>
<time class="topbar__time" id="clock"></time>
</div>
</header>
<!-- ============ SIDEBAR ============ -->
<nav class="sidebar" id="sidebar">
<div class="sidebar__section">
<div class="sidebar__label">Operations</div>
<button class="sidebar__link active" data-tab="overview">
<span class="icon">&#9788;</span>
Overview
</button>
<button class="sidebar__link" data-tab="linkbuilding">
<span class="icon">&#128279;</span>
Link Building
<span class="badge" id="badge-lb">-</span>
</button>
<button class="sidebar__link" data-tab="pressreleases">
<span class="icon">&#128240;</span>
Press Releases
<span class="badge" id="badge-pr">-</span>
</button>
<button class="sidebar__link" data-tab="bycompany">
<span class="icon">&#127970;</span>
By Company
</button>
</div>
<div class="sidebar__divider"></div>
<div class="sidebar__section">
<div class="sidebar__label">System</div>
<button class="sidebar__link" data-tab="health">
<span class="icon">&#9881;</span>
System Health
</button>
<button class="sidebar__link" data-tab="agents">
<span class="icon">&#129302;</span>
Agents
</button>
<button class="sidebar__link" data-tab="notifications">
<span class="icon">&#128220;</span>
Notifications
</button>
</div>
<div class="sidebar__divider"></div>
<div class="sidebar__section">
<div class="sidebar__label">Quick Links</div>
<a class="sidebar__link" href="/" target="_blank">
<span class="icon">&#128172;</span>
Open Chat
</a>
</div>
</nav>
<!-- ============ MAIN CONTENT ============ -->
<main class="main">
<!-- ========== TAB: OVERVIEW ========== -->
<div class="tab-panel active" id="tab-overview">
<div class="briefing-header">
<div class="briefing-header__left">
<h1 class="briefing-header__greeting" id="greeting-text">Good Morning, Bryan.</h1>
<p class="briefing-header__date" id="greeting-date"></p>
</div>
<blockquote class="briefing-header__quote">
&ldquo;Continuous effort &mdash; not strength or intelligence &mdash; is the key to unlocking our potential.&rdquo;
<cite>&mdash; Churchill</cite>
</blockquote>
</div>
<!-- Stats Row -->
<div class="stats-row stats-row--compact" id="overview-stats">
<div class="stat-card stat-card--gold stat-card--mini">
<div class="stat-card__value" id="stat-total">-</div>
<div class="stat-card__label">Total Tasks</div>
<div class="stat-card__detail" id="stat-total-detail">Loading...</div>
</div>
<div class="stat-card stat-card--blue stat-card--mini">
<div class="stat-card__value" id="stat-lb">-</div>
<div class="stat-card__label">Link Building</div>
<div class="stat-card__detail" id="stat-lb-detail">Loading...</div>
</div>
<div class="stat-card stat-card--green stat-card--mini">
<div class="stat-card__value" id="stat-pr">-</div>
<div class="stat-card__label">Press Releases</div>
<div class="stat-card__detail" id="stat-pr-detail">Loading...</div>
</div>
<div class="stat-card stat-card--amber stat-card--mini">
<div class="stat-card__value" id="stat-companies">-</div>
<div class="stat-card__label">Companies</div>
<div class="stat-card__detail" id="stat-companies-detail">Loading...</div>
</div>
<div class="stat-card stat-card--green stat-card--mini">
<div class="stat-card__value" id="stat-agents">-</div>
<div class="stat-card__label">Agents</div>
<div class="stat-card__detail" id="stat-agents-detail">Loading...</div>
</div>
</div>
<!-- Top Link Building Tasks -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128279;</span> Link Building Tasks</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View all</a></span>
</div>
<div class="task-table-wrap task-table-wrap--compact" id="overview-lb-table">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- Top Press Release Tasks -->
<div class="section section--tight">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128240;</span> Press Release Tasks</h2>
<span class="section__badge"><a href="#" class="section__link" data-tab="pressreleases">View all</a></span>
</div>
<div id="overview-pr-cards">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- System Health Inline -->
<div class="section section--tight" id="briefing-health">
<div class="health-inline" id="overview-health">
<span class="health-inline__dot health-inline__dot--ok"></span>
<span class="health-inline__text">Loading system health...</span>
<span class="health-inline__detail"></span>
</div>
</div>
</div>
<!-- ========== TAB: LINK BUILDING ========== -->
<div class="tab-panel" id="tab-linkbuilding">
<div class="page-header">
<h1 class="page-header__greeting">Link Building Operations</h1>
<p class="page-header__date" id="lb-subtitle">Loading...</p>
</div>
<div class="stats-row" id="lb-stats"></div>
<!-- Company Breakdown -->
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#127970;</span> By Company</h2>
</div>
<div class="health-grid" id="lb-company-grid"></div>
</div>
<!-- Full task table -->
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128203;</span> Full Task List</h2>
</div>
<div class="task-table-wrap" id="lb-full-table">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
</div>
<!-- ========== TAB: PRESS RELEASES ========== -->
<div class="tab-panel" id="tab-pressreleases">
<div class="page-header">
<h1 class="page-header__greeting">Press Releases</h1>
<p class="page-header__date" id="pr-subtitle">Loading...</p>
</div>
<div class="stats-row" id="pr-stats"></div>
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128240;</span> All Press Releases</h2>
</div>
<div id="pr-cards-container">
<p style="color:var(--text-muted);">Loading...</p>
</div>
</div>
</div>
<!-- ========== TAB: BY COMPANY ========== -->
<div class="tab-panel" id="tab-bycompany">
<div class="page-header">
<h1 class="page-header__greeting">Tasks by Company</h1>
<p class="page-header__date" id="company-subtitle">Loading...</p>
</div>
<div id="company-sections">
<p style="color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- ========== TAB: SYSTEM HEALTH ========== -->
<div class="tab-panel" id="tab-health">
<div class="page-header">
<h1 class="page-header__greeting">System Health</h1>
<p class="page-header__date" id="health-subtitle">Loading...</p>
</div>
<div class="stats-row" id="health-stats"></div>
<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#128190;</span> Disk Space</h2>
</div>
<div class="health-grid" id="health-disks"></div>
</div>
</div>
<!-- ========== TAB: AGENTS ========== -->
<div class="tab-panel" id="tab-agents">
<div class="page-header">
<h1 class="page-header__greeting">Agent Configuration</h1>
<p class="page-header__date">Registered agents and their capabilities</p>
</div>
<div class="health-grid" id="agents-grid">
<p style="color:var(--text-muted);">Loading...</p>
</div>
</div>
<!-- ========== TAB: NOTIFICATIONS ========== -->
<div class="tab-panel" id="tab-notifications">
<div class="page-header">
<h1 class="page-header__greeting">Notifications</h1>
<p class="page-header__date">Recent system notifications and events</p>
</div>
<div class="task-table-wrap" id="notifications-list">
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
</div>
</div>
</main>
</div>
<script>
// ============================================================
// CheddahBot Dashboard - Data-Driven JS
// ============================================================
const API = '/api';
let _cache = {};
// --- Helpers ---
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
function statusPillClass(status) {
if (!status) return 'status-pill--todo';
const s = status.toLowerCase();
if (s.includes('done') || s.includes('complete') || s.includes('closed')) return 'status-pill--done';
if (s.includes('progress') || s.includes('review') || s.includes('active')) return 'status-pill--progress';
return 'status-pill--todo';
}
function statusPill(status) {
return `<span class="status-pill ${statusPillClass(status)}">${esc(status || 'Unknown')}</span>`;
}
async function fetchJSON(path) {
try {
const res = await fetch(API + path);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.error(`Failed to fetch ${path}:`, e);
return null;
}
}
// --- Greeting ---
function updateGreeting() {
const now = new Date();
const hour = now.getHours();
let greeting = 'Good Evening';
if (hour < 12) greeting = 'Good Morning';
else if (hour < 17) greeting = 'Good Afternoon';
document.getElementById('greeting-text').textContent = `${greeting}, Bryan.`;
const opts = { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' };
document.getElementById('greeting-date').textContent = now.toLocaleDateString('en-US', opts);
}
// --- Overview Tab ---
async function loadOverview() {
const [tasks, lb, pr, agents, health] = await Promise.all([
fetchJSON('/tasks'),
fetchJSON('/tasks/link-building'),
fetchJSON('/tasks/press-releases'),
fetchJSON('/agents'),
fetchJSON('/system/health'),
]);
// Cache for other tabs
_cache.tasks = tasks;
_cache.lb = lb;
_cache.pr = pr;
_cache.agents = agents;
_cache.health = health;
// Stats
if (tasks) {
document.getElementById('stat-total').textContent = tasks.count || 0;
document.getElementById('stat-total-detail').textContent = `ClickUp tasks`;
}
if (lb) {
document.getElementById('stat-lb').textContent = lb.total || 0;
document.getElementById('stat-lb-detail').textContent =
`${lb.companies ? lb.companies.length : 0} companies`;
document.getElementById('badge-lb').textContent = lb.total || 0;
}
if (pr) {
document.getElementById('stat-pr').textContent = pr.total || 0;
document.getElementById('stat-pr-detail').textContent =
`${pr.companies ? pr.companies.length : 0} companies`;
document.getElementById('badge-pr').textContent = pr.total || 0;
}
if (tasks && tasks.tasks) {
const companies = new Set(tasks.tasks.map(t => t.custom_fields?.Client || 'Unassigned'));
document.getElementById('stat-companies').textContent = companies.size;
const names = [...companies].filter(c => c !== 'Unassigned').slice(0, 4).join(', ');
document.getElementById('stat-companies-detail').textContent = names || 'None';
}
if (agents) {
document.getElementById('stat-agents').textContent = agents.agents?.length || 0;
document.getElementById('stat-agents-detail').textContent = 'Configured';
}
// Top LB tasks (first 5)
if (lb && lb.companies) {
const allTasks = lb.companies.flatMap(c => c.tasks);
renderTaskTable('overview-lb-table', allTasks.slice(0, 8), true);
}
// Top PR tasks
if (pr && pr.companies) {
const allPR = pr.companies.flatMap(c => c.tasks);
renderPRCards('overview-pr-cards', allPR.slice(0, 3));
}
// Health inline
if (health) {
renderHealthInline(health);
}
// System status in topbar
updateSystemStatus(health);
}
function renderTaskTable(containerId, tasks, compact) {
const container = document.getElementById(containerId);
if (!tasks || tasks.length === 0) {
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">No tasks found.</p>';
return;
}
const cls = compact ? 'task-table task-table--dense' : 'task-table';
let html = `<table class="${cls}">
<thead><tr>
<th>#</th><th>Task</th><th>Company</th><th>Keyword</th><th>Status</th>
</tr></thead><tbody>`;
tasks.forEach((t, i) => {
const company = t.custom_fields?.Client || 'Unassigned';
const keyword = t.custom_fields?.Keyword || '';
const link = t.url ? `<a href="${esc(t.url)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(t.name)}</a>` : esc(t.name);
html += `<tr>
<td class="task-table__num">${i + 1}</td>
<td class="task-table__title">${link}</td>
<td class="task-table__company">${esc(company)}</td>
<td class="task-table__keyword">${esc(keyword)}</td>
<td class="task-table__status">${statusPill(t.status)}</td>
</tr>`;
});
html += '</tbody></table>';
container.innerHTML = html;
}
function renderPRCards(containerId, tasks) {
const container = document.getElementById(containerId);
if (!tasks || tasks.length === 0) {
container.innerHTML = '<p style="color:var(--text-muted);">No press release tasks found.</p>';
return;
}
let html = '';
tasks.forEach(t => {
const company = t.custom_fields?.Client || 'Unassigned';
const link = t.url ? ` <a href="${esc(t.url)}" target="_blank" style="color:var(--gold-light);font-size:0.75rem;">[ClickUp]</a>` : '';
const kvState = t.kv_state;
let stateInfo = '';
if (kvState) {
stateInfo = `KV: ${kvState.state || 'unknown'}`;
if (kvState.completed_at) stateInfo += ` | Completed: ${kvState.completed_at}`;
}
html += `<div class="pr-card">
<div class="pr-card__header">
<div>
<div class="pr-card__company">${esc(company)}</div>
<div class="pr-card__headline">${esc(t.name)}${link}</div>
</div>
${statusPill(t.status)}
</div>
<div class="pr-card__detail">Task ID: ${esc(t.id)}${stateInfo ? ' | ' + esc(stateInfo) : ''}</div>
</div>`;
});
container.innerHTML = html;
}
function renderHealthInline(health) {
const el = document.getElementById('overview-health');
const diskInfo = health.disks?.map(d => `${d.drive} ${d.percent_free}% free`).join(' | ') || 'No disk info';
const brainOk = health.execution_brain;
const clickupOk = health.clickup_enabled;
const dotClass = (brainOk && clickupOk) ? 'health-inline__dot--ok' : 'health-inline__dot--warn';
const statusText = (brainOk && clickupOk) ? 'All systems nominal' : 'Some systems degraded';
el.innerHTML = `
<span class="health-inline__dot ${dotClass}"></span>
<span class="health-inline__text">${statusText}</span>
<span class="health-inline__detail">${esc(diskInfo)} | Brain: ${brainOk ? 'OK' : 'Missing'} | ClickUp: ${clickupOk ? 'OK' : 'Off'}</span>
`;
}
function updateSystemStatus(health) {
const el = document.getElementById('system-status');
if (!health) {
el.innerHTML = '<span class="dot" style="background:var(--red)"></span><span>API Error</span>';
return;
}
const ok = health.execution_brain && health.clickup_enabled;
el.innerHTML = `<span class="dot" style="background:${ok ? 'var(--green)' : 'var(--amber)'}"></span>
<span>${ok ? 'All Systems Operational' : 'Degraded'}</span>`;
}
// --- Link Building Tab ---
async function loadLinkBuilding() {
const data = _cache.lb || await fetchJSON('/tasks/link-building');
if (!data) return;
_cache.lb = data;
document.getElementById('lb-subtitle').textContent =
`${data.total || 0} tasks across ${data.companies?.length || 0} companies`;
// Stats
const statsHtml = `
<div class="stat-card stat-card--gold">
<div class="stat-card__label">Total Tasks</div>
<div class="stat-card__value">${data.total || 0}</div>
<div class="stat-card__detail">Work Category: Link Building</div>
</div>
<div class="stat-card stat-card--blue">
<div class="stat-card__label">Companies</div>
<div class="stat-card__value">${data.companies?.length || 0}</div>
<div class="stat-card__detail">${data.companies?.map(c => c.name).join(', ') || 'None'}</div>
</div>
${renderStatusCountCards(data.status_counts)}
`;
document.getElementById('lb-stats').innerHTML = statsHtml;
// Company breakdown
const grid = document.getElementById('lb-company-grid');
if (data.companies && data.companies.length > 0) {
let html = '';
data.companies.forEach(co => {
const done = co.tasks.filter(t => {
const s = (t.status || '').toLowerCase();
return s.includes('complete') || s.includes('done') || s.includes('closed');
}).length;
const pct = co.count > 0 ? Math.round((done / co.count) * 100) : 0;
const barColor = pct >= 100 ? 'green' : pct > 0 ? 'amber' : 'amber';
html += `<div class="health-card">
<div class="health-card__header">
<span class="health-card__title">${esc(co.name)}</span>
<span class="section__badge">${co.count} task${co.count !== 1 ? 's' : ''}</span>
</div>
<div class="health-bar"><div class="health-bar__fill health-bar__fill--${barColor}" style="width:${pct}%"></div></div>
<div class="health-card__meta"><span>${done} / ${co.count} completed</span><span>${pct}%</span></div>
</div>`;
});
grid.innerHTML = html;
} else {
grid.innerHTML = '<p style="color:var(--text-muted);">No link building tasks found.</p>';
}
// Full task table
const allTasks = data.companies?.flatMap(c => c.tasks) || [];
renderTaskTable('lb-full-table', allTasks, false);
}
function renderStatusCountCards(counts) {
if (!counts) return '';
let html = '';
for (const [status, count] of Object.entries(counts)) {
const color = statusToCardColor(status);
html += `<div class="stat-card stat-card--${color}">
<div class="stat-card__label">${esc(status)}</div>
<div class="stat-card__value">${count}</div>
</div>`;
}
return html;
}
function statusToCardColor(status) {
const s = (status || '').toLowerCase();
if (s.includes('complete') || s.includes('done') || s.includes('closed')) return 'green';
if (s.includes('progress') || s.includes('review') || s.includes('active')) return 'amber';
if (s.includes('fail') || s.includes('error') || s.includes('block')) return 'red';
return 'blue';
}
// --- Press Releases Tab ---
async function loadPressReleases() {
const data = _cache.pr || await fetchJSON('/tasks/press-releases');
if (!data) return;
_cache.pr = data;
document.getElementById('pr-subtitle').textContent =
`${data.total || 0} tasks across ${data.companies?.length || 0} companies`;
// Stats
let statsHtml = `
<div class="stat-card stat-card--gold">
<div class="stat-card__label">Total PRs</div>
<div class="stat-card__value">${data.total || 0}</div>
<div class="stat-card__detail">Work Category: Press Release</div>
</div>
${renderStatusCountCards(data.status_counts)}
`;
document.getElementById('pr-stats').innerHTML = statsHtml;
// PR cards
const allPR = data.companies?.flatMap(c => c.tasks) || [];
renderPRCards('pr-cards-container', allPR);
}
// --- By Company Tab ---
async function loadByCompany() {
const data = _cache.tasks ? { companies: groupByCompany(_cache.tasks.tasks || []) }
: await fetchJSON('/tasks/by-company');
if (!data) return;
const companies = data.companies || [];
document.getElementById('company-subtitle').textContent =
`${companies.length} companies with active tasks`;
let html = '';
companies.forEach(co => {
const coData = co.tasks || co;
const tasks = Array.isArray(coData) ? coData : (co.tasks || []);
const name = co.name || 'Unknown';
html += `<div class="section">
<div class="section__header">
<h2 class="section__title"><span class="icon">&#127970;</span> ${esc(name)}</h2>
<span class="section__badge">${tasks.length} task${tasks.length !== 1 ? 's' : ''}</span>
</div>`;
if (tasks.length > 0) {
html += '<div class="task-table-wrap"><table class="task-table"><thead><tr>';
html += '<th>#</th><th>Task</th><th>Type</th><th>Status</th>';
html += '</tr></thead><tbody>';
tasks.forEach((t, i) => {
const link = t.url ? `<a href="${esc(t.url)}" target="_blank" style="color:var(--cream-light);text-decoration:none;">${esc(t.name)}</a>` : esc(t.name);
html += `<tr>
<td class="task-table__num">${i + 1}</td>
<td class="task-table__title">${link}</td>
<td class="task-table__company">${esc(t.task_type || '-')}</td>
<td class="task-table__status">${statusPill(t.status)}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
html += '</div>';
});
document.getElementById('company-sections').innerHTML = html || '<p style="color:var(--text-muted);">No tasks found.</p>';
}
function groupByCompany(tasks) {
const map = {};
tasks.forEach(t => {
const co = t.custom_fields?.Client || 'Unassigned';
if (!map[co]) map[co] = { name: co, tasks: [], count: 0 };
map[co].tasks.push(t);
map[co].count++;
});
return Object.values(map).sort((a, b) => b.count - a.count);
}
// --- System Health Tab ---
async function loadHealth() {
const health = _cache.health || await fetchJSON('/system/health');
if (!health) return;
_cache.health = health;
document.getElementById('health-subtitle').textContent =
`Chat model: ${health.chat_model || 'unknown'} | Execution model: ${health.execution_model || 'unknown'}`;
// Stats
let statsHtml = `
<div class="stat-card stat-card--${health.execution_brain ? 'green' : 'red'}">
<div class="stat-card__label">Execution Brain</div>
<div class="stat-card__value">${health.execution_brain ? 'OK' : 'MISSING'}</div>
<div class="stat-card__detail">Claude Code CLI</div>
</div>
<div class="stat-card stat-card--${health.clickup_enabled ? 'green' : 'amber'}">
<div class="stat-card__label">ClickUp</div>
<div class="stat-card__value">${health.clickup_enabled ? 'ON' : 'OFF'}</div>
<div class="stat-card__detail">Task integration</div>
</div>
<div class="stat-card stat-card--green">
<div class="stat-card__label">Drives</div>
<div class="stat-card__value">${health.disks?.length || 0}</div>
<div class="stat-card__detail">Monitored</div>
</div>
`;
document.getElementById('health-stats').innerHTML = statsHtml;
// Disk cards
const diskGrid = document.getElementById('health-disks');
if (health.disks && health.disks.length > 0) {
let html = '';
health.disks.forEach(d => {
const pct = d.percent_free || 0;
const color = pct < 10 ? 'red' : pct < 25 ? 'amber' : 'green';
const statusCls = pct < 10 ? 'error' : pct < 25 ? 'warn' : 'ok';
html += `<div class="health-card">
<div class="health-card__header">
<span class="health-card__title">${esc(d.drive)}</span>
<span class="health-card__status health-card__status--${statusCls}">${pct}% FREE</span>
</div>
<div class="health-bar"><div class="health-bar__fill health-bar__fill--${color}" style="width:${pct}%"></div></div>
<div class="health-card__meta"><span>${d.free_gb} GB / ${d.total_gb} GB</span><span>Threshold: 10%</span></div>
</div>`;
});
diskGrid.innerHTML = html;
} else {
diskGrid.innerHTML = '<p style="color:var(--text-muted);">No disk data available.</p>';
}
}
// --- Agents Tab ---
async function loadAgents() {
const data = _cache.agents || await fetchJSON('/agents');
if (!data || !data.agents) return;
_cache.agents = data;
const grid = document.getElementById('agents-grid');
if (data.agents.length === 0) {
grid.innerHTML = '<p style="color:var(--text-muted);">No agents configured.</p>';
return;
}
let html = '';
data.agents.forEach(a => {
const toolList = a.tools ? a.tools.join(', ') : 'All tools';
html += `<div class="health-card">
<div class="health-card__header">
<span class="health-card__title">${esc(a.display_name || a.name)}</span>
<span class="health-card__status health-card__status--ok">READY</span>
</div>
<div class="health-card__meta" style="margin-top:0.5rem;">
<span>Model: ${esc(a.model || 'default')}</span>
<span>Scope: ${esc(a.memory_scope || 'shared')}</span>
</div>
<div style="margin-top:0.5rem;font-size:0.75rem;color:var(--text-muted);">
Tools: ${esc(toolList)}
</div>
</div>`;
});
grid.innerHTML = html;
}
// --- Notifications Tab ---
async function loadNotifications() {
const data = await fetchJSON('/system/notifications');
if (!data) return;
const container = document.getElementById('notifications-list');
const notes = data.notifications || [];
if (notes.length === 0) {
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">No notifications yet.</p>';
return;
}
let html = '<ul class="log-list">';
notes.slice(0, 100).forEach(n => {
const time = n.created_at ? new Date(n.created_at * 1000).toLocaleTimeString('en-GB', { hour: '2-digit', minute: '2-digit' }) : '';
const level = (n.level || 'info').toLowerCase();
const typeCls = level === 'error' ? 'error' : level === 'warning' ? 'error' : 'heartbeat';
html += `<li class="log-item">
<span class="log-item__time">${esc(time)}</span>
<span class="log-item__type log-item__type--${typeCls}">${esc(n.level || 'info')}</span>
<span class="log-item__msg">${esc(n.message || '')}</span>
</li>`;
});
html += '</ul>';
container.innerHTML = html;
}
// --- Tab Navigation ---
function switchTab(tabId) {
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
document.querySelectorAll('.sidebar__link').forEach(l => l.classList.remove('active'));
const panel = document.getElementById('tab-' + tabId);
if (panel) panel.classList.add('active');
const btn = document.querySelector('.sidebar__link[data-tab="' + tabId + '"]');
if (btn) btn.classList.add('active');
document.getElementById('sidebar').classList.remove('open');
// Lazy-load tab data
const loaders = {
overview: loadOverview,
linkbuilding: loadLinkBuilding,
pressreleases: loadPressReleases,
bycompany: loadByCompany,
health: loadHealth,
agents: loadAgents,
notifications: loadNotifications,
};
if (loaders[tabId]) loaders[tabId]();
}
document.querySelectorAll('[data-tab]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.preventDefault();
switchTab(btn.getAttribute('data-tab'));
});
});
// --- Mobile Sidebar ---
function toggleSidebar() {
document.getElementById('sidebar').classList.toggle('open');
}
// --- Clock ---
function updateClock() {
const now = new Date();
const opts = { hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false };
document.getElementById('clock').textContent = now.toLocaleTimeString('en-GB', opts);
}
updateClock();
setInterval(updateClock, 1000);
// --- Refresh ---
async function refreshAll() {
_cache = {};
await fetchJSON('/cache/clear').catch(() => {});
const activeTab = document.querySelector('.tab-panel.active')?.id?.replace('tab-', '') || 'overview';
switchTab(activeTab);
}
// --- Init ---
updateGreeting();
loadOverview();
// Auto-refresh every 5 minutes
setInterval(() => {
_cache = {};
const activeTab = document.querySelector('.tab-panel.active')?.id?.replace('tab-', '') || 'overview';
switchTab(activeTab);
}, 300000);
</script>
</body>
</html>

1113
dashboard/styles.css 100644

File diff suppressed because it is too large Load Diff