1055 lines
39 KiB
HTML
1055 lines
39 KiB
HTML
<!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>
|
|
|
|
<!-- Up Next (today/tomorrow, or next 5 by due date) -->
|
|
<div class="section section--tight">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">⏰</span> Up Next</h2>
|
|
<span class="section__badge" id="up-next-count">-</span>
|
|
</div>
|
|
<div class="task-table-wrap task-table-wrap--compact" id="overview-up-next">
|
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- This Month (tagged with current month) -->
|
|
<div class="section section--tight">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">📅</span> This Month</h2>
|
|
<span class="section__badge" id="this-month-count">-</span>
|
|
</div>
|
|
<div class="task-table-wrap task-table-wrap--compact" id="overview-this-month">
|
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Cora Reports Needed -->
|
|
<div class="section section--tight">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">📋</span> Cora Reports Needed</h2>
|
|
<span onclick="navigator.clipboard.writeText('Z:\\cora-inbox');this.textContent='Copied!';setTimeout(()=>this.textContent='Z:\\cora-inbox',1500)" style="font-size:0.75rem;color:var(--gold-light);cursor:pointer;margin-left:0.5rem;" title="Click to copy path">Z:\cora-inbox</span>
|
|
<span class="section__badge"><a href="#" class="section__link" data-tab="linkbuilding">View LB</a></span>
|
|
</div>
|
|
<div class="task-table-wrap task-table-wrap--compact" id="overview-cora">
|
|
<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>
|
|
|
|
<!-- Cora Reports to Run -->
|
|
<div class="section">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">📋</span> Cora Reports to Run</h2>
|
|
<span class="section__badge" id="lb-cora-count">-</span>
|
|
</div>
|
|
<div class="task-table-wrap" id="lb-cora-table">
|
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recently Completed (Past 7 Days) -->
|
|
<div class="section">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">✅</span> Recently Completed (Past 7 Days)</h2>
|
|
<span class="section__badge" id="lb-recent-count">-</span>
|
|
</div>
|
|
<div class="task-table-wrap" id="lb-recent-table">
|
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- In Progress - Not Started -->
|
|
<div class="section">
|
|
<div class="section__header">
|
|
<h2 class="section__title"><span class="icon">⚠</span> In Progress — Not Started</h2>
|
|
<span class="section__badge" id="lb-not-started-count">-</span>
|
|
</div>
|
|
<div class="task-table-wrap" id="lb-not-started-table">
|
|
<p style="padding:1rem;color:var(--text-muted);">Loading...</p>
|
|
</div>
|
|
</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, agents, health] = await Promise.all([
|
|
fetchJSON('/tasks'),
|
|
fetchJSON('/agents'),
|
|
fetchJSON('/system/health'),
|
|
]);
|
|
|
|
// Cache for other tabs
|
|
_cache.tasks = tasks;
|
|
_cache.agents = agents;
|
|
_cache.health = health;
|
|
|
|
// Stats — derive LB/PR counts from tasks data
|
|
if (tasks && tasks.tasks) {
|
|
document.getElementById('stat-total').textContent = tasks.count || 0;
|
|
document.getElementById('stat-total-detail').textContent = `ClickUp tasks`;
|
|
|
|
const lbTasks = tasks.tasks.filter(t => t.task_type === 'Link Building');
|
|
const prTasks = tasks.tasks.filter(t => t.task_type === 'Press Release');
|
|
|
|
document.getElementById('stat-lb').textContent = lbTasks.length;
|
|
const lbCompanies = new Set(lbTasks.map(t => t.custom_fields?.Customer || 'Unassigned'));
|
|
document.getElementById('stat-lb-detail').textContent = `${lbCompanies.size} companies`;
|
|
document.getElementById('badge-lb').textContent = lbTasks.length;
|
|
|
|
document.getElementById('stat-pr').textContent = prTasks.length;
|
|
const prCompanies = new Set(prTasks.map(t => t.custom_fields?.Customer || 'Unassigned'));
|
|
document.getElementById('stat-pr-detail').textContent = `${prCompanies.size} companies`;
|
|
document.getElementById('badge-pr').textContent = prTasks.length;
|
|
|
|
const companies = new Set(tasks.tasks.map(t => t.custom_fields?.Customer || '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';
|
|
}
|
|
|
|
// -- Up Next: tasks due today/tomorrow, or next 5 by due date --
|
|
if (tasks && tasks.tasks) {
|
|
const now = new Date();
|
|
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
|
|
const endOfTomorrow = startOfToday + 2 * 24 * 60 * 60 * 1000;
|
|
|
|
// Only consider open tasks
|
|
const openTasks = tasks.tasks.filter(t => {
|
|
const s = (t.status || '').toLowerCase();
|
|
return !(s.includes('complete') || s.includes('done') || s.includes('closed'));
|
|
});
|
|
|
|
let upNext = openTasks
|
|
.filter(t => {
|
|
if (!t.due_date) return false;
|
|
const due = parseInt(t.due_date, 10);
|
|
return due >= startOfToday && due < endOfTomorrow;
|
|
})
|
|
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date));
|
|
|
|
// Fallback: if nothing due today/tomorrow, show next 5 with a due date
|
|
if (upNext.length === 0) {
|
|
upNext = openTasks
|
|
.filter(t => t.due_date && parseInt(t.due_date, 10) >= startOfToday)
|
|
.sort((a, b) => parseInt(a.due_date) - parseInt(b.due_date))
|
|
.slice(0, 5);
|
|
}
|
|
document.getElementById('up-next-count').textContent = upNext.length;
|
|
renderOverviewTable('overview-up-next', upNext, true);
|
|
|
|
// -- This Month: tagged with month tag OR due date this month --
|
|
const monthNames = ['jan','feb','mar','apr','may','jun','jul','aug','sep','oct','nov','dec'];
|
|
const monthTag = monthNames[now.getMonth()] + String(now.getFullYear()).slice(2);
|
|
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).getTime();
|
|
const startOfNextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1).getTime();
|
|
|
|
const thisMonthSet = new Set();
|
|
const thisMonth = [];
|
|
for (const t of openTasks) {
|
|
if (thisMonthSet.has(t.id)) continue;
|
|
const hasTag = (t.tags || []).some(tag => tag.toLowerCase() === monthTag);
|
|
const dueThisMonth = t.due_date &&
|
|
parseInt(t.due_date, 10) >= startOfMonth &&
|
|
parseInt(t.due_date, 10) < startOfNextMonth;
|
|
if (hasTag || dueThisMonth) {
|
|
thisMonth.push(t);
|
|
thisMonthSet.add(t.id);
|
|
}
|
|
}
|
|
document.getElementById('this-month-count').textContent = thisMonth.length;
|
|
renderOverviewTable('overview-this-month', thisMonth, true);
|
|
|
|
// -- Cora Reports Needed: LB tasks with LB Method = Cora Backlinks --
|
|
const needCora = openTasks.filter(t =>
|
|
t.task_type === 'Link Building'
|
|
&& t.custom_fields?.['LB Method'] === 'Cora Backlinks'
|
|
);
|
|
renderOverviewTable('overview-cora', needCora, false);
|
|
}
|
|
|
|
// 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?.Customer || '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 renderRecentTable(containerId, tasks) {
|
|
const container = document.getElementById(containerId);
|
|
if (!tasks || tasks.length === 0) {
|
|
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">No recently completed tasks.</p>';
|
|
return;
|
|
}
|
|
|
|
let html = `<table class="task-table">
|
|
<thead><tr>
|
|
<th>#</th><th>Task</th><th>Company</th><th>Keyword</th><th>Completed</th>
|
|
</tr></thead><tbody>`;
|
|
|
|
tasks.forEach((t, i) => {
|
|
const company = t.custom_fields?.Customer || '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);
|
|
let doneDate = '-';
|
|
if (t.date_done) {
|
|
const d = new Date(parseInt(t.date_done, 10));
|
|
doneDate = d.toLocaleDateString('en-US', {month:'short', day:'numeric', year:'numeric'});
|
|
}
|
|
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 style="white-space:nowrap;">${esc(doneDate)}</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
function renderCoraQueue(containerId, tasks) {
|
|
const container = document.getElementById(containerId);
|
|
if (!tasks || tasks.length === 0) {
|
|
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">No Cora reports queued.</p>';
|
|
return;
|
|
}
|
|
|
|
const PAGE_SIZE = 10;
|
|
let showAll = false;
|
|
|
|
function render() {
|
|
const visible = showAll ? tasks : tasks.slice(0, PAGE_SIZE);
|
|
let html = `<table class="task-table task-table--dense">
|
|
<thead><tr>
|
|
<th class="cora-hide-mobile">#</th>
|
|
<th class="cora-hide-mobile">Task</th>
|
|
<th>Keyword</th>
|
|
<th>Company</th>
|
|
<th class="cora-hide-mobile">Due Date</th>
|
|
</tr></thead><tbody>`;
|
|
|
|
visible.forEach((t, i) => {
|
|
const company = t.custom_fields?.Customer || '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);
|
|
let dueStr = '-';
|
|
if (t.due_date) {
|
|
const d = new Date(parseInt(t.due_date, 10));
|
|
dueStr = d.toLocaleDateString('en-US', {month:'short', day:'numeric'});
|
|
}
|
|
html += `<tr>
|
|
<td class="task-table__num cora-hide-mobile">${i + 1}</td>
|
|
<td class="task-table__title cora-hide-mobile">${link}</td>
|
|
<td class="task-table__keyword">${esc(keyword)}</td>
|
|
<td class="task-table__company">${esc(company)}</td>
|
|
<td class="cora-hide-mobile" style="white-space:nowrap;">${esc(dueStr)}</td>
|
|
</tr>`;
|
|
});
|
|
|
|
html += '</tbody></table>';
|
|
|
|
if (tasks.length > PAGE_SIZE) {
|
|
const remaining = tasks.length - PAGE_SIZE;
|
|
const label = showAll ? 'Show less' : `Show more (${remaining} remaining)`;
|
|
html += `<div style="text-align:center;padding:0.75rem;">
|
|
<button id="cora-toggle-btn" style="background:none;border:1px solid var(--border);color:var(--gold-light);padding:0.4rem 1.2rem;border-radius:var(--radius);cursor:pointer;font-size:0.8rem;">${label}</button>
|
|
</div>`;
|
|
}
|
|
|
|
container.innerHTML = html;
|
|
|
|
const btn = document.getElementById('cora-toggle-btn');
|
|
if (btn) {
|
|
btn.addEventListener('click', () => {
|
|
showAll = !showAll;
|
|
render();
|
|
});
|
|
}
|
|
}
|
|
|
|
render();
|
|
}
|
|
|
|
function renderOverviewTable(containerId, tasks, showDueDate) {
|
|
const container = document.getElementById(containerId);
|
|
if (!tasks || tasks.length === 0) {
|
|
container.innerHTML = '<p style="padding:1rem;color:var(--text-muted);">None right now.</p>';
|
|
return;
|
|
}
|
|
|
|
const dueDateHeader = showDueDate ? '<th>Due</th>' : '';
|
|
let html = `<table class="task-table task-table--dense">
|
|
<thead><tr>
|
|
<th>#</th><th>Task</th><th>Company</th><th>Type</th><th>Status</th>${dueDateHeader}
|
|
</tr></thead><tbody>`;
|
|
|
|
tasks.forEach((t, i) => {
|
|
const company = t.custom_fields?.Customer || 'Unassigned';
|
|
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);
|
|
let dueDateCell = '';
|
|
if (showDueDate && t.due_date) {
|
|
const d = new Date(parseInt(t.due_date, 10));
|
|
dueDateCell = `<td style="white-space:nowrap;">${d.toLocaleDateString('en-US', {month:'short', day:'numeric'})}</td>`;
|
|
} else if (showDueDate) {
|
|
dueDateCell = '<td>-</td>';
|
|
}
|
|
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__company">${esc(t.task_type || '-')}</td>
|
|
<td class="task-table__status">${statusPill(t.status)}</td>
|
|
${dueDateCell}
|
|
</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?.Customer || '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;
|
|
|
|
// Cora Reports to Run
|
|
const coraQueue = data.need_cora || [];
|
|
document.getElementById('lb-cora-count').textContent = coraQueue.length;
|
|
renderCoraQueue('lb-cora-table', coraQueue);
|
|
|
|
// Recently Completed
|
|
const recent = data.recently_completed || [];
|
|
document.getElementById('lb-recent-count').textContent = recent.length;
|
|
renderRecentTable('lb-recent-table', recent);
|
|
|
|
// In Progress - Not Started
|
|
const notStarted = data.in_progress_not_started || [];
|
|
document.getElementById('lb-not-started-count').textContent = notStarted.length;
|
|
renderTaskTable('lb-not-started-table', notStarted, false);
|
|
|
|
// 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?.Customer || '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).toLocaleString('en-US', { month:'short', day:'numeric', 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>
|