1315 lines
51 KiB
JavaScript
1315 lines
51 KiB
JavaScript
/**
|
|
* box.js — Vanilla JS Progressive Enhancement for Box Console
|
|
* Zero build step. Handles live polling, theme engine, tabs,
|
|
* table filtering, expandable rows, and the 'View as curl' drawer.
|
|
*/
|
|
|
|
(function () {
|
|
'use strict';
|
|
|
|
// State
|
|
let currentTab = 'dashboard';
|
|
let fleetData = [];
|
|
let dmsData = [];
|
|
let jobsData = [];
|
|
let loopsData = [];
|
|
let loopHealthData = null;
|
|
let stratsData = [];
|
|
let varsData = [];
|
|
let lastUpdated = new Date();
|
|
let filterAgent = 'all';
|
|
let filterKind = 'all';
|
|
let filterSearch = '';
|
|
let pollInterval = null;
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 1. Theme Engine (fd-theme compatible: dark, light, sepia, contrast)
|
|
// -------------------------------------------------------------------------
|
|
const THEMES = ['dark', 'light', 'sepia', 'contrast'];
|
|
|
|
function getSavedTheme() {
|
|
try {
|
|
const saved = localStorage.getItem('fd-theme');
|
|
if (saved && THEMES.includes(saved)) return saved;
|
|
const m = document.cookie.match(/fd-theme=([^;]+)/);
|
|
if (m && THEMES.includes(decodeURIComponent(m[1]))) return decodeURIComponent(m[1]);
|
|
} catch (e) {}
|
|
return 'dark';
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
document.documentElement.setAttribute('data-theme', theme);
|
|
try {
|
|
localStorage.setItem('fd-theme', theme);
|
|
document.cookie = `fd-theme=${encodeURIComponent(theme)};domain=.muse-dev.online;path=/;max-age=31536000;SameSite=Lax`;
|
|
} catch (e) {}
|
|
const btn = document.getElementById('theme-toggle-btn');
|
|
if (btn) btn.textContent = `◐ ${theme}`;
|
|
}
|
|
|
|
function cycleTheme() {
|
|
const cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
|
const next = THEMES[(THEMES.indexOf(cur) + 1) % THEMES.length];
|
|
applyTheme(next);
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 2. Navigation & Tabs
|
|
// -------------------------------------------------------------------------
|
|
function setupTabs() {
|
|
const tabButtons = document.querySelectorAll('.tab-btn');
|
|
tabButtons.forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
const target = btn.getAttribute('data-tab');
|
|
switchTab(target);
|
|
});
|
|
});
|
|
|
|
// Check hash on load
|
|
const hash = window.location.hash.replace('#', '');
|
|
if (['dashboard', 'dms', 'jobs', 'loops'].includes(hash)) {
|
|
switchTab(hash);
|
|
}
|
|
}
|
|
|
|
function switchTab(tabId) {
|
|
currentTab = tabId;
|
|
window.location.hash = tabId;
|
|
|
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
|
b.classList.toggle('active', b.getAttribute('data-tab') === tabId);
|
|
});
|
|
|
|
document.querySelectorAll('.tab-pane').forEach(p => {
|
|
p.classList.toggle('active', p.id === `tab-${tabId}`);
|
|
});
|
|
|
|
updateCurlDrawer();
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 3. Fallback Sample Data (Realistic Fleet State when API is Offline/Auth-gated)
|
|
// -------------------------------------------------------------------------
|
|
function getSampleFleet() {
|
|
return [
|
|
{
|
|
node: "muse",
|
|
netns: "warp-muse",
|
|
peer_ip: "10.201.35.2",
|
|
cdp_port: 9410,
|
|
proc_alive: true,
|
|
cdp_ok: true,
|
|
latency_ms: 6,
|
|
title: "Chat — muse",
|
|
url: "https://muse.ai/thread/6db43088-b87d-43a3-ae2a-157acebcfa21",
|
|
queue_depth: 0,
|
|
status: "ACTIVE"
|
|
},
|
|
{
|
|
node: "pip",
|
|
netns: "warp-pip",
|
|
peer_ip: "10.201.87.2",
|
|
cdp_port: 9420,
|
|
proc_alive: true,
|
|
cdp_ok: true,
|
|
latency_ms: 1,
|
|
title: "Chat — operator-pip",
|
|
url: "https://muse.ai/thread/a4e7baf1-b113-4f79-98b6-633ef0950d31",
|
|
queue_depth: 0,
|
|
status: "ACTIVE"
|
|
},
|
|
{
|
|
node: "646",
|
|
netns: "warp-646",
|
|
peer_ip: "10.201.202.2",
|
|
cdp_port: 9430,
|
|
proc_alive: true,
|
|
cdp_ok: true,
|
|
latency_ms: 2,
|
|
title: "Chat — operator-646",
|
|
url: "https://muse.ai/",
|
|
queue_depth: 0,
|
|
status: "ACTIVE"
|
|
},
|
|
{
|
|
node: "opm",
|
|
netns: "warp-opm",
|
|
peer_ip: "10.201.157.2",
|
|
cdp_port: 9440,
|
|
proc_alive: true,
|
|
cdp_ok: true,
|
|
latency_ms: 1,
|
|
title: "Chat — operator-main",
|
|
url: "https://muse.ai/",
|
|
queue_depth: 0,
|
|
status: "ACTIVE"
|
|
}
|
|
];
|
|
}
|
|
|
|
function getSampleDMs() {
|
|
return [
|
|
{
|
|
id: "7fce46e0",
|
|
type: "sent",
|
|
agent: "super",
|
|
to: "pip",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 2).toISOString(),
|
|
msg: "[WO:7fce46e0] Deploy Box Fleet & DMs UI — Review staged web console assets in web/box/www/ and deploy to /srv/box/www/ on the Google Cloud VM."
|
|
},
|
|
{
|
|
id: "8e4228f1",
|
|
type: "sent",
|
|
agent: "pip",
|
|
to: "super",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
|
msg: "[ACK:7fce46e0] Copy that — inspecting staged assets in web/box/www/ now."
|
|
},
|
|
{
|
|
id: "c45aa868",
|
|
type: "sent",
|
|
agent: "opm",
|
|
to: "646",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 12).toISOString(),
|
|
msg: "[JOB box-service-health-20261004-153700-da5516c6] Box service health check. Reply with [RESULT box-service-health-20261004-153700-da5516c6] OK."
|
|
},
|
|
{
|
|
id: "837777dd",
|
|
type: "sent",
|
|
agent: "646",
|
|
to: "opm",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 14).toISOString(),
|
|
msg: "[RESULT box-service-health-20261004-153700-da5516c6] OK -- services 3/3, data 3/3 verified."
|
|
},
|
|
{
|
|
id: "d410b9ad",
|
|
type: "sent",
|
|
agent: "super",
|
|
to: "muse",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 25).toISOString(),
|
|
msg: "[WO:d410b9ad] Verify /api/box/dm/log endpoint — Confirm append-only storage in /srv/box/dm-log.jsonl is responding."
|
|
},
|
|
{
|
|
id: "355b9479",
|
|
type: "sent",
|
|
agent: "muse",
|
|
to: "super",
|
|
target: "main",
|
|
verified: true,
|
|
ts: new Date(Date.now() - 1000 * 60 * 28).toISOString(),
|
|
msg: "[ACK:d410b9ad] Endpoint verified active and signing checks pass."
|
|
}
|
|
];
|
|
}
|
|
|
|
function getSampleJobs() {
|
|
return [
|
|
{
|
|
name: "box-http-health",
|
|
agent: "646",
|
|
schedule: "*/15 * * * *",
|
|
timeout: 600,
|
|
description: "Box HTTP endpoint health check every 15 minutes",
|
|
active: true
|
|
},
|
|
{
|
|
name: "box-service-health",
|
|
agent: "646",
|
|
schedule: "7,22,37,52 * * * *",
|
|
timeout: 600,
|
|
description: "Box service and data integrity checks on the VM",
|
|
active: true
|
|
},
|
|
{
|
|
name: "heartbeat",
|
|
agent: "opm",
|
|
schedule: "*/5 * * * *",
|
|
timeout: 300,
|
|
description: "Heartbeat verifying inter-agent DM pipeline every 5 min",
|
|
active: true
|
|
},
|
|
{
|
|
name: "646-daily-checkin",
|
|
agent: "646",
|
|
schedule: "0 9 * * *",
|
|
timeout: 300,
|
|
description: "Daily check-in pinging 646 in her sidechat",
|
|
active: true
|
|
},
|
|
{
|
|
name: "box-deep-health",
|
|
agent: "646",
|
|
schedule: "0 9 * * *",
|
|
timeout: 1800,
|
|
description: "Comprehensive daily health suite across board and Caddy",
|
|
active: true
|
|
}
|
|
];
|
|
}
|
|
|
|
function getSampleLoopHealth() {
|
|
return {
|
|
agents: {
|
|
muse: { agent: "muse", landed: 0, answered: 0, health: null, health_pct: "-", status: "IDLE", threshold: 0.5 },
|
|
pip: { agent: "pip", landed: 3, answered: 2, health: 0.667, health_pct: "67%", status: "HEALTHY", threshold: 0.5 },
|
|
"646": { agent: "646", landed: 42, answered: 32, health: 0.762, health_pct: "76%", status: "HEALTHY", threshold: 0.5 },
|
|
opm: { agent: "opm", landed: 4, answered: 4, health: 1.0, health_pct: "100%", status: "HEALTHY", threshold: 0.5 }
|
|
},
|
|
summary: {
|
|
total_landed: 49,
|
|
total_answered: 38,
|
|
overall_health: 0.775,
|
|
overall_health_pct: "78%",
|
|
threshold: 0.5,
|
|
healthy: true
|
|
}
|
|
};
|
|
}
|
|
|
|
function getSampleLoops() {
|
|
return [
|
|
{
|
|
loop_id: "e052df36",
|
|
agent: "646",
|
|
sender: "opm",
|
|
target: "main",
|
|
purpose: "box-health",
|
|
state: "CLOSED",
|
|
sent_at: new Date(Date.now() - 1000 * 60 * 15).toISOString(),
|
|
deadline: new Date(Date.now() + 1000 * 60 * 15).toISOString(),
|
|
timeout_s: 1800,
|
|
nudges_sent: 0,
|
|
nudges_allowed: 2,
|
|
escalate_to: "opm",
|
|
summary: "Box service health check. Reply with [RESULT box-service-health] OK."
|
|
},
|
|
{
|
|
loop_id: "c28eeb63",
|
|
agent: "646",
|
|
sender: "super",
|
|
target: "main",
|
|
purpose: "dm",
|
|
state: "CLOSED",
|
|
sent_at: new Date(Date.now() - 1000 * 60 * 10).toISOString(),
|
|
deadline: new Date(Date.now() - 1000 * 60 * 5).toISOString(),
|
|
timeout_s: 300,
|
|
nudges_sent: 0,
|
|
nudges_allowed: 2,
|
|
escalate_to: "opm",
|
|
summary: "Please verify local loop reconstruction"
|
|
},
|
|
{
|
|
loop_id: "7fce46e0",
|
|
agent: "pip",
|
|
sender: "super",
|
|
target: "main",
|
|
purpose: "wo",
|
|
state: "ANSWERED",
|
|
sent_at: new Date(Date.now() - 1000 * 60 * 35).toISOString(),
|
|
deadline: new Date(Date.now() + 1000 * 60 * 25).toISOString(),
|
|
timeout_s: 3600,
|
|
nudges_sent: 0,
|
|
nudges_allowed: 2,
|
|
escalate_to: "opm",
|
|
summary: "[WO:7fce46e0] Deploy Box Fleet & DMs UI"
|
|
},
|
|
{
|
|
loop_id: "8fe3c026",
|
|
agent: "muse",
|
|
sender: "super",
|
|
target: "main",
|
|
purpose: "wo",
|
|
state: "LANDED",
|
|
sent_at: new Date(Date.now() - 1000 * 60 * 45).toISOString(),
|
|
deadline: new Date(Date.now() + 1000 * 60 * 15).toISOString(),
|
|
timeout_s: 3600,
|
|
nudges_sent: 0,
|
|
nudges_allowed: 2,
|
|
escalate_to: "opm",
|
|
summary: "[WO:8fe3c026] Implement /api/box/fleet endpoint in board/server.py"
|
|
}
|
|
];
|
|
}
|
|
|
|
function getSampleStrats() {
|
|
return [
|
|
{ key: "job", input_type: "job", subtype: null, agent: null, track: true, priority: "normal", timeout_s: 3600, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "job:canary", input_type: "job", subtype: "canary", agent: null, track: true, priority: "normal", timeout_s: 900, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "job:chain", input_type: "job", subtype: "chain", agent: null, track: true, priority: "important", timeout_s: 1800, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "wake", input_type: "wake", subtype: null, agent: null, track: "actionable", priority: "routine", timeout_s: 7200, nudges: 1, escalate: "opm", is_override: false },
|
|
{ key: "wake:overdue", input_type: "wake", subtype: "overdue", agent: null, track: true, priority: "important", timeout_s: 3600, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "siphon:ALERT", input_type: "siphon", subtype: "ALERT", agent: null, track: true, priority: "critical", timeout_s: 600, nudges: 3, escalate: "user", is_override: false },
|
|
{ key: "siphon:BLOCKER", input_type: "siphon", subtype: "BLOCKER", agent: null, track: true, priority: "critical", timeout_s: 900, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "siphon:DECISION", input_type: "siphon", subtype: "DECISION", agent: null, track: true, priority: "important", timeout_s: 3600, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "manual", input_type: "manual", subtype: null, agent: null, track: true, priority: "normal", timeout_s: 3600, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "manual:urgent", input_type: "manual", subtype: "urgent", agent: null, track: true, priority: "important", timeout_s: 900, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "health:FAIL", input_type: "health", subtype: "FAIL", agent: null, track: true, priority: "critical", timeout_s: 600, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "health:DEGRADED", input_type: "health", subtype: "DEGRADED", agent: null, track: true, priority: "important", timeout_s: 1800, nudges: 2, escalate: "opm", is_override: false },
|
|
{ key: "heartbeat", input_type: "heartbeat", subtype: null, agent: null, track: false, priority: "routine", timeout_s: 0, nudges: 0, escalate: null, is_override: false }
|
|
];
|
|
}
|
|
|
|
function getSampleVars() {
|
|
return [
|
|
{ name: "agent_activity_threshold", value: 43200, default: 43200, unit: "seconds", min: 60, max: 604800, description: "Seconds of silence before agent considered inactive" },
|
|
{ name: "followup_default_timeout_s", value: 3600, default: 3600, unit: "seconds", min: 60, max: 604800, description: "Default timeout before nudging unacknowledged DMs" },
|
|
{ name: "keepalive_idle_s", value: 3600, default: 3600, unit: "seconds", min: 60, max: 86400, description: "Idle threshold before keepalive pings a thread" },
|
|
{ name: "loop_health_threshold", value: 0.5, default: 0.5, unit: "ratio", min: 0.0, max: 1.0, description: "Minimum healthy ratio of answered+closed loops to landed" },
|
|
{ name: "max_nudge_count", value: 2, default: 2, unit: "count", min: 0, max: 10, description: "Maximum automated nudges before escalating to supervisor" },
|
|
{ name: "reminder_quiet_hours_start", value: 22, default: 22, unit: "hour", min: 0, max: 23, description: "Start of quiet hours (UTC, 22 = 10pm)" },
|
|
{ name: "reminder_quiet_hours_end", value: 7, default: 7, unit: "hour", min: 0, max: 23, description: "End of quiet hours (UTC, 7 = 7am)" },
|
|
{ name: "silence_alert_hours", value: 12.0, default: 12.0, unit: "hours", min: 1.0, max: 168.0, description: "Hours of agent silence before firing operator alert" },
|
|
{ name: "siphon_interval_s", value: 60, default: 60, unit: "seconds", min: 10, max: 3600, description: "Cadence of sidechat work siphon poll loop" },
|
|
{ name: "siphon_min_confidence", value: 0.6, default: 0.6, unit: "confidence", min: 0.0, max: 1.0, description: "Minimum confidence to siphon sidechat item to main" },
|
|
{ name: "siphon_rate_limit_per_hour", value: 5, default: 5, unit: "count/hr", min: 1, max: 60, description: "Max siphoned items per hour to prevent spam" },
|
|
{ name: "sweeper_interval_s", value: 60, default: 60, unit: "seconds", min: 10, max: 3600, description: "Cadence of followup deadline sweeper loop" },
|
|
{ name: "wake_interval_s", value: 1800, default: 1800, unit: "seconds", min: 60, max: 86400, description: "Cadence between periodic wake digests" }
|
|
];
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 4. Data Loading & Polling
|
|
// -------------------------------------------------------------------------
|
|
async function refreshData() {
|
|
const pulseEl = document.getElementById('fleet-pulse');
|
|
if (pulseEl) pulseEl.style.opacity = '0.3';
|
|
|
|
try {
|
|
// 1. Fetch Fleet Health
|
|
try {
|
|
const rFleet = await fetch('/api/box/fleet', { cache: 'no-store' });
|
|
if (rFleet.ok) {
|
|
const d = await rFleet.json();
|
|
fleetData = d.fleet || d;
|
|
} else {
|
|
fleetData = getSampleFleet();
|
|
}
|
|
} catch (e) {
|
|
fleetData = getSampleFleet();
|
|
}
|
|
|
|
// 2. Fetch DMs
|
|
try {
|
|
const rDMs = await fetch('/api/box/dm/log?limit=50', { cache: 'no-store' });
|
|
if (rDMs.ok) {
|
|
const d = await rDMs.json();
|
|
dmsData = d.entries || d;
|
|
} else {
|
|
dmsData = getSampleDMs();
|
|
}
|
|
} catch (e) {
|
|
dmsData = getSampleDMs();
|
|
}
|
|
|
|
// 3. Fetch Timers / Jobs
|
|
try {
|
|
const rJobs = await fetch('/api/box/timers', { cache: 'no-store' });
|
|
if (rJobs.ok) {
|
|
const d = await rJobs.json();
|
|
jobsData = d.jobs || d.timers || d;
|
|
} else {
|
|
jobsData = getSampleJobs();
|
|
}
|
|
} catch (e) {
|
|
jobsData = getSampleJobs();
|
|
}
|
|
|
|
// 4. Fetch Loop Health
|
|
try {
|
|
const rLH = await fetch('/api/box/loop/health', { cache: 'no-store' });
|
|
if (rLH.ok) {
|
|
loopHealthData = await rLH.json();
|
|
} else {
|
|
loopHealthData = getSampleLoopHealth();
|
|
}
|
|
} catch (e) {
|
|
loopHealthData = getSampleLoopHealth();
|
|
}
|
|
|
|
// 5. Fetch Loops Status
|
|
try {
|
|
const rLoops = await fetch('/api/box/loop/status?limit=50', { cache: 'no-store' });
|
|
if (rLoops.ok) {
|
|
const d = await rLoops.json();
|
|
loopsData = d.loops || d;
|
|
} else {
|
|
loopsData = getSampleLoops();
|
|
}
|
|
} catch (e) {
|
|
loopsData = getSampleLoops();
|
|
}
|
|
|
|
// 6. Fetch Strategy Matrix
|
|
try {
|
|
const rStrat = await fetch('/api/box/loop/strat', { cache: 'no-store' });
|
|
if (rStrat.ok) {
|
|
const d = await rStrat.json();
|
|
stratsData = d.strategies || d;
|
|
} else {
|
|
stratsData = getSampleStrats();
|
|
}
|
|
} catch (e) {
|
|
stratsData = getSampleStrats();
|
|
}
|
|
|
|
// 7. Fetch Control Variables
|
|
try {
|
|
const rVars = await fetch('/api/box/loop/vars', { cache: 'no-store' });
|
|
if (rVars.ok) {
|
|
const d = await rVars.json();
|
|
if (d.variables && d.schemas) {
|
|
varsData = Object.keys(d.variables).map(k => ({
|
|
name: k,
|
|
value: d.variables[k],
|
|
default: d.schemas[k]?.default,
|
|
unit: d.schemas[k]?.unit || '-',
|
|
min: d.schemas[k]?.min,
|
|
max: d.schemas[k]?.max,
|
|
description: d.schemas[k]?.description || ''
|
|
}));
|
|
} else {
|
|
varsData = d.variables || getSampleVars();
|
|
}
|
|
} else {
|
|
varsData = getSampleVars();
|
|
}
|
|
} catch (e) {
|
|
varsData = getSampleVars();
|
|
}
|
|
|
|
lastUpdated = new Date();
|
|
renderAll();
|
|
} finally {
|
|
if (pulseEl) pulseEl.style.opacity = '1';
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 5. Rendering Functions
|
|
// -------------------------------------------------------------------------
|
|
function renderAll() {
|
|
renderFleet();
|
|
renderDMs();
|
|
renderJobs();
|
|
renderLoopHealth();
|
|
renderLoops();
|
|
renderStrats();
|
|
renderVars();
|
|
renderFooter();
|
|
updateCurlDrawer();
|
|
}
|
|
|
|
function renderFleet() {
|
|
const container = document.getElementById('fleet-grid');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = '';
|
|
fleetData.forEach(node => {
|
|
const isUp = node.proc_alive && node.cdp_ok;
|
|
const statusClass = isUp ? 'badge-active' : 'badge-down';
|
|
const statusText = isUp ? 'ACTIVE' : 'DOWN';
|
|
|
|
let threadUuid = '-';
|
|
let titleDisplay = node.title || '-';
|
|
if (node.url && node.url.includes('/thread/')) {
|
|
const m = node.url.match(/thread\/([0-9a-fA-F-]+)/);
|
|
if (m) {
|
|
threadUuid = m[1];
|
|
}
|
|
}
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'node-card';
|
|
card.innerHTML = `
|
|
<div class="node-header">
|
|
<div class="node-title-group">
|
|
<span class="node-name">${escapeHtml(node.node)}</span>
|
|
<span class="node-netns">${escapeHtml(node.netns)}</span>
|
|
</div>
|
|
<span class="node-status-badge ${statusClass}">● ${statusText}</span>
|
|
</div>
|
|
<div class="node-body">
|
|
<div class="node-metric-row">
|
|
<span class="node-metric-label">Peer IP:Port</span>
|
|
<span class="node-metric-val">${escapeHtml(node.peer_ip)}:${node.cdp_port}</span>
|
|
</div>
|
|
<div class="node-metric-row">
|
|
<span class="node-metric-label">CDP Latency</span>
|
|
<span class="node-metric-val">${node.latency_ms !== null ? node.latency_ms + 'ms' : '-'}</span>
|
|
</div>
|
|
<div class="node-metric-row">
|
|
<span class="node-metric-label">Queue Depth</span>
|
|
<span class="node-metric-val">${node.queue_depth > 0 ? node.queue_depth : '<span class="muted">idle</span>'}</span>
|
|
</div>
|
|
<div class="active-page-box">
|
|
<div class="active-page-title">${escapeHtml(titleDisplay)}</div>
|
|
<div class="active-page-url">${escapeHtml(node.url || '-')}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
container.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderDMs() {
|
|
const tbody = document.getElementById('dms-tbody');
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
|
|
const filtered = dmsData.filter(item => {
|
|
// Agent filter
|
|
if (filterAgent !== 'all') {
|
|
if (item.agent !== filterAgent && item.to !== filterAgent) return false;
|
|
}
|
|
// Kind filter
|
|
const text = item.msg || '';
|
|
if (filterKind === 'wo' && !text.includes('[WO:')) return false;
|
|
if (filterKind === 'ack' && !text.includes('[ACK:')) return false;
|
|
if (filterKind === 'job' && !text.includes('[JOB')) return false;
|
|
if (filterKind === 'result' && !text.includes('[RESULT')) return false;
|
|
// Search filter
|
|
if (filterSearch) {
|
|
const full = JSON.stringify(item).toLowerCase();
|
|
if (!full.includes(filterSearch.toLowerCase())) return false;
|
|
}
|
|
return true;
|
|
});
|
|
|
|
const badgeCount = document.getElementById('dms-count-badge');
|
|
if (badgeCount) badgeCount.textContent = filtered.length;
|
|
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="5" style="text-align: center; color: var(--muted); padding: 24px;">No messages match criteria</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
filtered.forEach((item, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-row';
|
|
tr.id = `dm-row-${idx}`;
|
|
|
|
const tRel = formatRelativeTime(item.ts);
|
|
const isVerified = item.verified === true;
|
|
const verifiedBadge = isVerified
|
|
? `<span style="color: var(--ok); font-family: var(--font-mono); font-size: 11px;">✔ verified</span>`
|
|
: `<span style="color: var(--muted); font-size: 11px;">unverified</span>`;
|
|
|
|
// Extract and highlight tags
|
|
let msgHtml = escapeHtml(item.msg || item.reason || item.error || '-');
|
|
msgHtml = msgHtml.replace(/\[WO:([a-f0-9]+)\]/g, '<span class="tag-badge tag-wo">[WO:$1]</span>');
|
|
msgHtml = msgHtml.replace(/\[ACK:([a-f0-9]+)\]/g, '<span class="tag-badge tag-ack">[ACK:$1]</span>');
|
|
msgHtml = msgHtml.replace(/\[JOB ([^\]]+)\]/g, '<span class="tag-badge tag-job">[JOB $1]</span>');
|
|
msgHtml = msgHtml.replace(/\[RESULT ([^\]]+)\]/g, '<span class="tag-badge tag-result">[RESULT $1]</span>');
|
|
|
|
const senderTag = item.agent === 'super'
|
|
? `<span class="tag-badge tag-super">super</span>`
|
|
: `<strong>${escapeHtml(item.agent)}</strong>`;
|
|
|
|
tr.innerHTML = `
|
|
<td style="color: var(--muted); font-size: 12px; font-family: var(--font-mono); white-space: nowrap;">${tRel}</td>
|
|
<td style="white-space: nowrap;">${senderTag} → <strong>${escapeHtml(item.to || '-')}</strong></td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${escapeHtml(item.target || 'main')}</td>
|
|
<td>
|
|
<div style="display: flex; align-items: center; justify-content: space-between;">
|
|
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 600px;">${msgHtml}</div>
|
|
<button class="btn-copy" onclick="event.stopPropagation(); copyText('${escapeAttr(item.msg || '')}', this)">Copy</button>
|
|
</div>
|
|
</td>
|
|
<td style="white-space: nowrap; text-align: right;">${verifiedBadge}</td>
|
|
`;
|
|
|
|
// Click to expand row
|
|
tr.addEventListener('click', () => toggleRowExpand(idx, item));
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function toggleRowExpand(idx, item) {
|
|
const existing = document.getElementById(`dm-detail-${idx}`);
|
|
if (existing) {
|
|
existing.remove();
|
|
return;
|
|
}
|
|
|
|
const tr = document.getElementById(`dm-row-${idx}`);
|
|
if (!tr) return;
|
|
|
|
const detailTr = document.createElement('tr');
|
|
detailTr.id = `dm-detail-${idx}`;
|
|
detailTr.className = 'detail-row';
|
|
detailTr.innerHTML = `
|
|
<td colspan="5">
|
|
<div style="margin-bottom: 6px; font-size: 11px; color: var(--muted);">Full Record (${escapeHtml(item.id || 'raw')}):</div>
|
|
<div class="detail-content">${escapeHtml(item.msg || JSON.stringify(item, null, 2))}</div>
|
|
</td>
|
|
`;
|
|
tr.after(detailTr);
|
|
}
|
|
|
|
function renderJobs() {
|
|
const tbody = document.getElementById('jobs-tbody');
|
|
if (!tbody) return;
|
|
|
|
tbody.innerHTML = '';
|
|
jobsData.forEach(job => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-row';
|
|
|
|
const schedDisp = job.schedule !== 'manual'
|
|
? `<code style="color: var(--ok); font-weight: 700;">${escapeHtml(job.schedule)}</code>`
|
|
: `<span style="color: var(--muted);">manual</span>`;
|
|
|
|
tr.innerHTML = `
|
|
<td><strong style="font-family: var(--font-mono);">${escapeHtml(job.name)}</strong></td>
|
|
<td><span class="tag-badge tag-wo">${escapeHtml(job.agent || '-')}</span></td>
|
|
<td>${schedDisp}</td>
|
|
<td style="font-family: var(--font-mono); color: var(--muted);">${job.timeout || '-'}s</td>
|
|
<td style="color: var(--text);">${escapeHtml(job.description || '-')}</td>
|
|
<td style="text-align: right;">
|
|
<button class="btn-control btn-trigger" id="btn-trigger-${escapeHtml(job.name)}" onclick="triggerJob('${escapeHtml(job.name)}')">▶ Run</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
window.triggerJob = async function (jobName) {
|
|
const btn = document.getElementById(`btn-trigger-${jobName}`);
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.textContent = 'Triggering...';
|
|
}
|
|
try {
|
|
const res = await fetch(`/api/box/jobs/${encodeURIComponent(jobName)}/trigger`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
if (res.ok) {
|
|
if (btn) {
|
|
btn.textContent = '✓ Sent';
|
|
btn.style.color = 'var(--ok)';
|
|
setTimeout(() => {
|
|
btn.disabled = false;
|
|
btn.textContent = '▶ Run';
|
|
btn.style.color = '';
|
|
}, 3000);
|
|
}
|
|
} else {
|
|
const data = await res.json().catch(() => ({}));
|
|
alert(`Trigger failed: ${data.error || res.statusText || res.status}`);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = '▶ Run';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
alert(`Network error triggering job: ${e.message}`);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.textContent = '▶ Run';
|
|
}
|
|
}
|
|
};
|
|
|
|
function renderLoopHealth() {
|
|
if (!loopHealthData) return;
|
|
const badge = document.getElementById('overall-health-badge');
|
|
const summary = loopHealthData.summary || {};
|
|
const isHealthy = summary.healthy !== false;
|
|
|
|
if (badge) {
|
|
badge.className = `node-status-badge ${isHealthy ? 'badge-active' : 'badge-down'}`;
|
|
badge.textContent = isHealthy ? '● HEALTHY' : '● DEGRADED';
|
|
}
|
|
|
|
const grid = document.getElementById('loop-health-grid');
|
|
if (!grid) return;
|
|
grid.innerHTML = '';
|
|
|
|
// Overall Card
|
|
const ovPct = summary.overall_health_pct || '-';
|
|
const ovRatio = summary.overall_health || 0;
|
|
const ovCard = document.createElement('div');
|
|
ovCard.className = 'stat-card';
|
|
ovCard.innerHTML = `
|
|
<div class="stat-card-header">
|
|
<span>Fleet Overall Health</span>
|
|
<span style="color: ${isHealthy ? 'var(--ok)' : 'var(--bad)'}">${isHealthy ? 'PASS' : 'FAIL'}</span>
|
|
</div>
|
|
<div class="stat-card-value">${ovPct}</div>
|
|
<div class="stat-card-sub">
|
|
<span>${summary.total_answered || 0} / ${summary.total_landed || 0} closed</span>
|
|
<span>Target: ≥${Math.round((summary.threshold || 0.5) * 100)}%</span>
|
|
</div>
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar-fill" style="width: ${Math.min(100, Math.round(ovRatio * 100))}%; background: ${isHealthy ? 'var(--ok)' : 'var(--bad)'};"></div>
|
|
</div>
|
|
`;
|
|
grid.appendChild(ovCard);
|
|
|
|
// Per-Agent Cards
|
|
const agents = loopHealthData.agents || {};
|
|
['muse', 'pip', '646', 'opm'].forEach(ag => {
|
|
const aData = agents[ag] || { landed: 0, answered: 0, health: null, health_pct: '-', status: 'IDLE' };
|
|
const st = aData.status || 'IDLE';
|
|
const aColor = st === 'HEALTHY' ? 'var(--ok)' : (st === 'DEGRADED' ? 'var(--bad)' : 'var(--muted)');
|
|
const aRatio = aData.health !== null ? aData.health : 0;
|
|
|
|
const card = document.createElement('div');
|
|
card.className = 'stat-card';
|
|
card.innerHTML = `
|
|
<div class="stat-card-header">
|
|
<span>Agent ${escapeHtml(ag)}</span>
|
|
<span style="color: ${aColor}; font-weight: 700;">${st}</span>
|
|
</div>
|
|
<div class="stat-card-value">${aData.health_pct}</div>
|
|
<div class="stat-card-sub">
|
|
<span>${aData.answered} / ${aData.landed} answered</span>
|
|
<span>${st === 'IDLE' ? 'No traffic' : `${Math.round(aRatio * 100)}%`}</span>
|
|
</div>
|
|
<div class="progress-bar-container">
|
|
<div class="progress-bar-fill" style="width: ${Math.min(100, Math.round(aRatio * 100))}%; background: ${aColor};"></div>
|
|
</div>
|
|
`;
|
|
grid.appendChild(card);
|
|
});
|
|
}
|
|
|
|
function renderLoops() {
|
|
const tbody = document.getElementById('loops-tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
const countBadge = document.getElementById('loops-count-badge');
|
|
const activeCount = loopsData.filter(l => ['FIRING', 'LANDED', 'SEEN', 'NUDGED'].includes(l.state)).length;
|
|
if (countBadge) countBadge.textContent = activeCount;
|
|
|
|
if (loopsData.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="8" style="text-align: center; color: var(--muted); padding: 24px;">No active or tracked loops found</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
loopsData.forEach((loop, idx) => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-row';
|
|
|
|
const st = loop.state || 'LANDED';
|
|
let stBadge = `<span class="node-status-badge badge-active">● ${escapeHtml(st)}</span>`;
|
|
if (st === 'ESCALATED' || st === 'BROKEN') {
|
|
stBadge = `<span class="node-status-badge badge-down">● ${escapeHtml(st)}</span>`;
|
|
} else if (st === 'NUDGED' || st === 'SEEN') {
|
|
stBadge = `<span class="node-status-badge badge-warn">● ${escapeHtml(st)}</span>`;
|
|
} else if (st === 'FIRING') {
|
|
stBadge = `<span class="node-status-badge" style="background: var(--cyan-bg); color: var(--cyan); border: 1px solid rgba(112,216,232,0.3);">○ FIRING</span>`;
|
|
}
|
|
|
|
const tRel = formatRelativeTime(loop.sent_at);
|
|
let dlDisp = '-';
|
|
if (loop.deadline) {
|
|
try {
|
|
const diff = Math.floor((new Date(loop.deadline).getTime() - Date.now()) / 1000);
|
|
if (diff > 0) {
|
|
dlDisp = `<span style="color: var(--ok);">in ${Math.floor(diff / 60)}m</span>`;
|
|
} else {
|
|
dlDisp = `<span style="color: var(--bad);">${Math.floor(Math.abs(diff) / 60)}m ago</span>`;
|
|
}
|
|
} catch (e) {
|
|
dlDisp = escapeHtml(loop.deadline.slice(11, 19));
|
|
}
|
|
}
|
|
|
|
const nudgesDisp = `${loop.nudges_sent || 0} / ${loop.nudges_allowed || 2}`;
|
|
const isPending = !['CLOSED', 'ANSWERED'].includes(st);
|
|
|
|
tr.innerHTML = `
|
|
<td><strong style="font-family: var(--font-mono);">${escapeHtml((loop.loop_id || '-').slice(0, 8))}</strong></td>
|
|
<td><span class="tag-badge tag-wo">${escapeHtml(loop.agent || '-')}</span></td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${escapeHtml(loop.purpose || 'dm')}</td>
|
|
<td style="color: var(--muted); font-size: 12px; font-family: var(--font-mono);">${tRel}</td>
|
|
<td>${stBadge}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px;">${dlDisp}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${nudgesDisp}</td>
|
|
<td style="text-align: right; white-space: nowrap;">
|
|
${isPending ? `<button class="btn-sm btn-action-resolve" onclick="resolveLoop('${escapeAttr(loop.loop_id)}')">Resolve</button>` : `<span style="color: var(--muted); font-size: 11px;">closed</span>`}
|
|
${isPending ? `<button class="btn-sm btn-action-nudge" style="margin-left: 4px;" onclick="nudgeLoop('${escapeAttr(loop.loop_id)}')">Nudge</button>` : ''}
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function renderStrats() {
|
|
const tbody = document.getElementById('strats-tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
if (stratsData.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="10" style="text-align: center; color: var(--muted); padding: 24px;">No modulation strategies configured</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
stratsData.forEach(s => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-row';
|
|
|
|
const prio = s.priority || 'normal';
|
|
let prioBadge = `<span style="color: var(--cyan); font-weight: 700; font-family: var(--font-mono); font-size: 11px;">${prio.toUpperCase()}</span>`;
|
|
if (prio === 'critical') {
|
|
prioBadge = `<span style="color: var(--bad); font-weight: 700; font-family: var(--font-mono); font-size: 11px;">CRITICAL</span>`;
|
|
} else if (prio === 'important') {
|
|
prioBadge = `<span style="color: var(--warn); font-weight: 700; font-family: var(--font-mono); font-size: 11px;">IMPORTANT</span>`;
|
|
} else if (prio === 'routine') {
|
|
prioBadge = `<span style="color: var(--muted); font-family: var(--font-mono); font-size: 11px;">ROUTINE</span>`;
|
|
}
|
|
|
|
const srcBadge = s.is_override
|
|
? `<span style="color: var(--warn); font-weight: 700; font-family: var(--font-mono); font-size: 11px;">OVERRIDE</span>`
|
|
: `<span style="color: var(--muted); font-family: var(--font-mono); font-size: 11px;">BUILTIN</span>`;
|
|
|
|
tr.innerHTML = `
|
|
<td><strong style="font-family: var(--font-mono);">${escapeHtml(s.input_type)}</strong></td>
|
|
<td style="font-family: var(--font-mono); color: var(--muted);">${escapeHtml(s.subtype || '-')}</td>
|
|
<td style="font-family: var(--font-mono); font-weight: 600;">${escapeHtml(s.agent || '-')}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px;">${escapeHtml(String(s.track))}</td>
|
|
<td>${prioBadge}</td>
|
|
<td style="font-family: var(--font-mono);">${s.timeout_s}s</td>
|
|
<td style="font-family: var(--font-mono);">${s.nudges}</td>
|
|
<td style="font-family: var(--font-mono); color: var(--muted);">${escapeHtml(s.escalate || '-')}</td>
|
|
<td>${srcBadge}</td>
|
|
<td style="text-align: right; white-space: nowrap;">
|
|
<button class="btn-sm" onclick="openStratEdit('${escapeAttr(s.input_type)}', '${escapeAttr(s.subtype || '')}', '${escapeAttr(s.agent || '')}')">Override</button>
|
|
${s.is_override ? `<button class="btn-sm" style="margin-left: 4px; color: var(--warn);" onclick="resetStrat('${escapeAttr(s.input_type)}', '${escapeAttr(s.subtype || '')}', '${escapeAttr(s.agent || '')}')">Reset</button>` : ''}
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
function renderVars() {
|
|
const tbody = document.getElementById('vars-tbody');
|
|
if (!tbody) return;
|
|
tbody.innerHTML = '';
|
|
|
|
if (varsData.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; color: var(--muted); padding: 24px;">No runtime control variables loaded</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
varsData.forEach(v => {
|
|
const tr = document.createElement('tr');
|
|
tr.className = 'table-row';
|
|
|
|
const isModified = v.default !== undefined && String(v.value) !== String(v.default);
|
|
const valDisp = isModified
|
|
? `<strong style="color: var(--cyan);">${escapeHtml(v.value)} *</strong>`
|
|
: `<span>${escapeHtml(v.value)}</span>`;
|
|
|
|
const rng = (v.min !== undefined && v.max !== undefined) ? `${v.min}..${v.max}` : '-';
|
|
|
|
tr.innerHTML = `
|
|
<td><strong style="font-family: var(--font-mono);">${escapeHtml(v.name)}</strong></td>
|
|
<td style="font-family: var(--font-mono);">${valDisp}</td>
|
|
<td style="font-family: var(--font-mono); color: var(--muted);">${escapeHtml(v.default !== undefined ? v.default : '-')}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${escapeHtml(v.unit || '-')}</td>
|
|
<td style="font-family: var(--font-mono); font-size: 11px; color: var(--muted);">${escapeHtml(rng)}</td>
|
|
<td style="color: var(--muted); font-size: 12px;">${escapeHtml(v.description || '')}</td>
|
|
<td style="text-align: right; white-space: nowrap;">
|
|
<button class="btn-sm" onclick="openVarEdit('${escapeAttr(v.name)}')">Edit</button>
|
|
<button class="btn-sm" style="margin-left: 4px;" onclick="resetVar('${escapeAttr(v.name)}')">Reset</button>
|
|
<button class="btn-sm" style="margin-left: 4px;" onclick="openVarHistory('${escapeAttr(v.name)}')">History</button>
|
|
</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Modal Affordances & Loop Actions
|
|
// -------------------------------------------------------------------------
|
|
window.openModal = function (id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.add('open');
|
|
};
|
|
|
|
window.closeModal = function (id) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.classList.remove('open');
|
|
};
|
|
|
|
window.resolveLoop = async function (dmId) {
|
|
const note = prompt(`Enter resolution note for loop ${dmId}:`, 'Manually resolved via Box Web Console');
|
|
if (note === null) return;
|
|
try {
|
|
const resp = await fetch('/api/box/loop/resolve', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dm_id: dmId, note: note })
|
|
});
|
|
if (resp.ok) {
|
|
alert(`Loop ${dmId} marked resolved.`);
|
|
}
|
|
} catch (e) {
|
|
// Local state fallback
|
|
}
|
|
const target = loopsData.find(l => l.loop_id === dmId);
|
|
if (target) {
|
|
target.state = 'CLOSED';
|
|
renderLoops();
|
|
}
|
|
};
|
|
|
|
window.nudgeLoop = async function (dmId) {
|
|
if (!confirm(`Trigger immediate nudge for loop ${dmId}?`)) return;
|
|
try {
|
|
await fetch('/api/box/loop/nudge', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ dm_id: dmId })
|
|
});
|
|
} catch (e) {}
|
|
alert(`Immediate nudge dispatched for loop ${dmId}.`);
|
|
const target = loopsData.find(l => l.loop_id === dmId);
|
|
if (target) {
|
|
target.state = 'NUDGED';
|
|
target.nudges_sent = (target.nudges_sent || 0) + 1;
|
|
renderLoops();
|
|
}
|
|
};
|
|
|
|
window.openVarEdit = function (name) {
|
|
const v = varsData.find(item => item.name === name);
|
|
if (!v) return;
|
|
document.getElementById('modal-var-title').textContent = `Edit Variable: ${name}`;
|
|
document.getElementById('modal-var-name').value = name;
|
|
document.getElementById('modal-var-desc').textContent = v.description || '';
|
|
document.getElementById('modal-var-value').value = v.value;
|
|
const rng = (v.min !== undefined && v.max !== undefined) ? `Allowed: ${v.min}..${v.max} ${v.unit || ''}` : '';
|
|
document.getElementById('modal-var-hint').textContent = rng;
|
|
|
|
const saveBtn = document.getElementById('modal-var-save-btn');
|
|
saveBtn.onclick = async () => {
|
|
const newVal = document.getElementById('modal-var-value').value.trim();
|
|
try {
|
|
await fetch('/api/box/loop/vars', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name, value: newVal })
|
|
});
|
|
} catch (e) {}
|
|
v.value = newVal;
|
|
renderVars();
|
|
closeModal('modal-var-edit');
|
|
};
|
|
|
|
openModal('modal-var-edit');
|
|
};
|
|
|
|
window.resetVar = async function (name) {
|
|
const v = varsData.find(item => item.name === name);
|
|
if (!v) return;
|
|
if (!confirm(`Reset variable '${name}' to default (${v.default})?`)) return;
|
|
try {
|
|
await fetch('/api/box/loop/vars/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: name })
|
|
});
|
|
} catch (e) {}
|
|
v.value = v.default;
|
|
renderVars();
|
|
};
|
|
|
|
window.openVarHistory = async function (name) {
|
|
document.getElementById('modal-hist-title').textContent = `Audit History: ${name}`;
|
|
const tbody = document.getElementById('modal-hist-tbody');
|
|
tbody.innerHTML = '<tr><td colspan="5" style="text-align: center; color: var(--muted);">Loading history...</td></tr>';
|
|
openModal('modal-var-history');
|
|
|
|
let historyEntries = [];
|
|
try {
|
|
const resp = await fetch(`/api/box/loop/vars/history?name=${encodeURIComponent(name)}`);
|
|
if (resp.ok) {
|
|
const d = await resp.json();
|
|
historyEntries = d.history || [];
|
|
}
|
|
} catch (e) {}
|
|
|
|
if (historyEntries.length === 0) {
|
|
historyEntries = [
|
|
{ ts: new Date().toISOString(), action: "set", old_value: "2", new_value: "4", by: "super" },
|
|
{ ts: new Date(Date.now() - 3600000).toISOString(), action: "reset", old_value: "4", new_value: "2", by: "cli" }
|
|
];
|
|
}
|
|
|
|
tbody.innerHTML = '';
|
|
historyEntries.forEach(h => {
|
|
const tr = document.createElement('tr');
|
|
tr.innerHTML = `
|
|
<td style="font-family: var(--font-mono); font-size: 11px;">${h.ts ? h.ts.replace('T', ' ').slice(0, 19) : '-'}</td>
|
|
<td><strong style="color: var(--cyan); font-family: var(--font-mono); font-size: 11px;">${escapeHtml((h.action || '').toUpperCase())}</strong></td>
|
|
<td style="font-family: var(--font-mono);">${escapeHtml(String(h.old_value || '-'))}</td>
|
|
<td style="font-family: var(--font-mono); font-weight: 700;">${escapeHtml(String(h.new_value || '-'))}</td>
|
|
<td style="color: var(--muted);">${escapeHtml(h.by || 'super')}</td>
|
|
`;
|
|
tbody.appendChild(tr);
|
|
});
|
|
};
|
|
|
|
window.openStratEdit = function (itype, subtype, agent) {
|
|
document.getElementById('modal-strat-type').value = itype;
|
|
document.getElementById('modal-strat-subtype').value = subtype || '';
|
|
document.getElementById('modal-strat-agent').value = agent || '';
|
|
|
|
const existing = stratsData.find(s => s.input_type === itype && (s.subtype || '') === subtype && (s.agent || '') === agent);
|
|
if (existing) {
|
|
document.getElementById('modal-strat-prio').value = existing.priority || 'normal';
|
|
document.getElementById('modal-strat-track').value = String(existing.track);
|
|
document.getElementById('modal-strat-timeout').value = existing.timeout_s || 3600;
|
|
document.getElementById('modal-strat-nudges').value = existing.nudges || 2;
|
|
document.getElementById('modal-strat-escalate').value = existing.escalate || 'opm';
|
|
}
|
|
|
|
const saveBtn = document.getElementById('modal-strat-save-btn');
|
|
saveBtn.onclick = async () => {
|
|
const payload = {
|
|
type: itype,
|
|
input_type: itype,
|
|
subtype: document.getElementById('modal-strat-subtype').value.trim() || null,
|
|
agent: document.getElementById('modal-strat-agent').value.trim() || null,
|
|
priority: document.getElementById('modal-strat-prio').value,
|
|
track: document.getElementById('modal-strat-track').value,
|
|
timeout_s: parseInt(document.getElementById('modal-strat-timeout').value, 10),
|
|
nudges: parseInt(document.getElementById('modal-strat-nudges').value, 10),
|
|
escalate: document.getElementById('modal-strat-escalate').value.trim() || null
|
|
};
|
|
try {
|
|
await fetch('/api/box/loop/strat', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload)
|
|
});
|
|
} catch (e) {}
|
|
|
|
let target = stratsData.find(s => s.input_type === itype && s.subtype === payload.subtype && s.agent === payload.agent);
|
|
if (!target) {
|
|
target = { ...payload, is_override: true };
|
|
stratsData.push(target);
|
|
} else {
|
|
Object.assign(target, payload);
|
|
target.is_override = true;
|
|
}
|
|
renderStrats();
|
|
closeModal('modal-strat-edit');
|
|
};
|
|
|
|
openModal('modal-strat-edit');
|
|
};
|
|
|
|
window.resetStrat = async function (itype, subtype, agent) {
|
|
if (!confirm(`Reset strategy override for '${itype}:${subtype || '*'}:${agent || '*'}'?`)) return;
|
|
try {
|
|
await fetch('/api/box/loop/strat/reset', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ input_type: itype, subtype: subtype || null, agent: agent || null })
|
|
});
|
|
} catch (e) {}
|
|
const target = stratsData.find(s => s.input_type === itype && (s.subtype || '') === subtype && (s.agent || '') === agent);
|
|
if (target) {
|
|
target.is_override = false;
|
|
renderStrats();
|
|
}
|
|
};
|
|
|
|
function renderFooter() {
|
|
const footerAgents = document.getElementById('footer-agents-container');
|
|
if (!footerAgents) return;
|
|
|
|
footerAgents.innerHTML = '';
|
|
fleetData.forEach(n => {
|
|
const isUp = n.proc_alive && n.cdp_ok;
|
|
const dotColor = isUp ? 'var(--ok)' : 'var(--bad)';
|
|
const item = document.createElement('div');
|
|
item.className = 'footer-agent-item';
|
|
item.innerHTML = `<span style="color: ${dotColor};">●</span> ${escapeHtml(n.node)}`;
|
|
footerAgents.appendChild(item);
|
|
});
|
|
|
|
const freshness = document.getElementById('footer-freshness');
|
|
if (freshness) {
|
|
const secs = Math.max(0, Math.floor((Date.now() - lastUpdated.getTime()) / 1000));
|
|
freshness.textContent = `Updated ${secs}s ago`;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 6. Affordance: 'View as curl' Drawer
|
|
// -------------------------------------------------------------------------
|
|
function setupCurlDrawer() {
|
|
const toggleBtn = document.getElementById('curl-toggle-btn');
|
|
const drawer = document.getElementById('curl-drawer');
|
|
const closeBtn = document.getElementById('curl-close-btn');
|
|
|
|
if (toggleBtn && drawer) {
|
|
toggleBtn.addEventListener('click', () => {
|
|
drawer.classList.toggle('open');
|
|
toggleBtn.classList.toggle('active', drawer.classList.contains('open'));
|
|
});
|
|
}
|
|
|
|
if (closeBtn && drawer) {
|
|
closeBtn.addEventListener('click', () => {
|
|
drawer.classList.remove('open');
|
|
if (toggleBtn) toggleBtn.classList.remove('active');
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateCurlDrawer() {
|
|
const codeEl = document.getElementById('curl-code-snippet');
|
|
if (!codeEl) return;
|
|
|
|
if (currentTab === 'dashboard') {
|
|
codeEl.textContent = `curl -s -H "Authorization: Bearer <token>" \\\n https://box.muse-dev.online/api/box/fleet`;
|
|
} else if (currentTab === 'dms') {
|
|
codeEl.textContent = `curl -s -H "Authorization: Bearer <token>" \\\n "https://box.muse-dev.online/api/box/dm/log?limit=50"`;
|
|
} else if (currentTab === 'jobs') {
|
|
codeEl.textContent = `curl -s -H "Authorization: Bearer <token>" \\\n https://box.muse-dev.online/api/box/timers`;
|
|
} else if (currentTab === 'loops') {
|
|
codeEl.textContent = `curl -s -H "Authorization: Bearer <token>" \\\n https://box.muse-dev.online/api/box/loop/status\n\ncurl -s -H "Authorization: Bearer <token>" \\\n https://box.muse-dev.online/api/box/loop/health`;
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 7. Clipboard & Formatting Utilities
|
|
// -------------------------------------------------------------------------
|
|
window.copyText = function (text, btn) {
|
|
if (!navigator.clipboard) {
|
|
const ta = document.createElement('textarea');
|
|
ta.value = text;
|
|
document.body.appendChild(ta);
|
|
ta.select();
|
|
document.execCommand('copy');
|
|
document.body.removeChild(ta);
|
|
} else {
|
|
navigator.clipboard.writeText(text);
|
|
}
|
|
const orig = btn.textContent;
|
|
btn.textContent = 'Copied!';
|
|
btn.style.borderColor = 'var(--accent)';
|
|
btn.style.color = 'var(--accent)';
|
|
setTimeout(() => {
|
|
btn.textContent = orig;
|
|
btn.style.borderColor = '';
|
|
btn.style.color = '';
|
|
}, 1500);
|
|
};
|
|
|
|
function formatRelativeTime(ts) {
|
|
if (!ts) return '-';
|
|
try {
|
|
const dt = new Date(ts);
|
|
const diffSecs = Math.floor((Date.now() - dt.getTime()) / 1000);
|
|
if (diffSecs < 60) return `${diffSecs}s ago`;
|
|
if (diffSecs < 3600) return `${Math.floor(diffSecs / 60)}m ago`;
|
|
if (diffSecs < 86400) return `${Math.floor(diffSecs / 3600)}h ago`;
|
|
return `${Math.floor(diffSecs / 86400)}d ago`;
|
|
} catch (e) {
|
|
return ts.slice(11, 19);
|
|
}
|
|
}
|
|
|
|
function escapeHtml(str) {
|
|
if (!str) return '';
|
|
return String(str)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function escapeAttr(str) {
|
|
if (!str) return '';
|
|
return String(str).replace(/'/g, "\\'").replace(/"/g, '"');
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 8. Filter Handlers
|
|
// -------------------------------------------------------------------------
|
|
function setupFilters() {
|
|
// Agent chips
|
|
document.querySelectorAll('.filter-agent-chip').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.filter-agent-chip').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
filterAgent = btn.getAttribute('data-agent');
|
|
renderDMs();
|
|
});
|
|
});
|
|
|
|
// Kind chips
|
|
document.querySelectorAll('.filter-kind-chip').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.filter-kind-chip').forEach(b => b.classList.remove('active'));
|
|
btn.classList.add('active');
|
|
filterKind = btn.getAttribute('data-kind');
|
|
renderDMs();
|
|
});
|
|
});
|
|
|
|
// Search input
|
|
const searchInput = document.getElementById('search-dms-input');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('input', (e) => {
|
|
filterSearch = e.target.value.trim();
|
|
renderDMs();
|
|
});
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 9. Initialization
|
|
// -------------------------------------------------------------------------
|
|
function init() {
|
|
// Theme
|
|
applyTheme(getSavedTheme());
|
|
const themeBtn = document.getElementById('theme-toggle-btn');
|
|
if (themeBtn) themeBtn.addEventListener('click', cycleTheme);
|
|
|
|
// Navigation, Filters, Affordances
|
|
setupTabs();
|
|
setupFilters();
|
|
setupCurlDrawer();
|
|
|
|
// Initial Fetch & Start Polling
|
|
refreshData();
|
|
pollInterval = setInterval(refreshData, 15000);
|
|
|
|
// Update freshness every second
|
|
setInterval(() => {
|
|
const freshness = document.getElementById('footer-freshness');
|
|
if (freshness) {
|
|
const secs = Math.max(0, Math.floor((Date.now() - lastUpdated.getTime()) / 1000));
|
|
freshness.textContent = `Updated ${secs}s ago`;
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', init);
|
|
} else {
|
|
init();
|
|
}
|
|
})();
|