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
parent
dd39fa2e94
commit
e1992fa049
|
|
@ -1,6 +1,5 @@
|
|||
"""Entry point: python -m cheddahbot"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -146,6 +145,13 @@ def main():
|
|||
|
||||
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)
|
||||
dashboard_dir = Path(__file__).resolve().parent.parent / "dashboard"
|
||||
if dashboard_dir.is_dir():
|
||||
|
|
@ -161,76 +167,6 @@ def main():
|
|||
)
|
||||
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
|
||||
gr.mount_gradio_app(fastapi_app, blocks, path="/", pwa=True, show_error=True)
|
||||
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
@ -413,7 +413,13 @@ def run_link_building(
|
|||
ctx: dict | None = None,
|
||||
) -> str:
|
||||
"""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":
|
||||
# For Cora Backlinks, xlsx_path is required
|
||||
|
|
|
|||
|
|
@ -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">☰</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">↻</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">☼</span>
|
||||
Overview
|
||||
</button>
|
||||
<button class="sidebar__link" data-tab="linkbuilding">
|
||||
<span class="icon">🔗</span>
|
||||
Link Building
|
||||
<span class="badge" id="badge-lb">-</span>
|
||||
</button>
|
||||
<button class="sidebar__link" data-tab="pressreleases">
|
||||
<span class="icon">📰</span>
|
||||
Press Releases
|
||||
<span class="badge" id="badge-pr">-</span>
|
||||
</button>
|
||||
<button class="sidebar__link" data-tab="bycompany">
|
||||
<span class="icon">🏢</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">⚙</span>
|
||||
System Health
|
||||
</button>
|
||||
<button class="sidebar__link" data-tab="agents">
|
||||
<span class="icon">🤖</span>
|
||||
Agents
|
||||
</button>
|
||||
<button class="sidebar__link" data-tab="notifications">
|
||||
<span class="icon">📜</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">💬</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">
|
||||
“Continuous effort — not strength or intelligence — is the key to unlocking our potential.”
|
||||
<cite>— 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">🔗</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">📰</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">🏢</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">📋</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">📰</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">💾</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">🏢</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>
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue