Compare commits
1 Commits
mimo/code/
...
mimo/creat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43455e9c83 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -7,3 +7,6 @@ mempalace/__pycache__/
|
||||
|
||||
# Prevent agents from writing to wrong path (see issue #1145)
|
||||
public/nexus/
|
||||
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
234
app.js
234
app.js
@@ -713,6 +713,8 @@ async function init() {
|
||||
connectHermes();
|
||||
fetchGiteaData();
|
||||
setInterval(fetchGiteaData, 30000); // Refresh every 30s
|
||||
updateHeartbeatBriefing(); // Initial briefing load
|
||||
setInterval(updateHeartbeatBriefing, 60000); // Refresh briefing every 60s
|
||||
|
||||
composer = new EffectComposer(renderer);
|
||||
composer.addPass(new RenderPass(scene, camera));
|
||||
@@ -1140,6 +1142,7 @@ async function fetchGiteaData() {
|
||||
const worldState = JSON.parse(atob(content.content));
|
||||
updateNexusCommand(worldState);
|
||||
updateSovereignHealth();
|
||||
updateHeartbeatBriefing();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch Gitea data:', e);
|
||||
@@ -1235,6 +1238,127 @@ function updateNexusCommand(state) {
|
||||
terminal.updatePanelText(lines);
|
||||
}
|
||||
|
||||
// ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════════
|
||||
async function updateHeartbeatBriefing() {
|
||||
const container = document.getElementById('heartbeat-briefing-content');
|
||||
const pulseDot = document.querySelector('.hb-pulse-dot');
|
||||
if (!container) return;
|
||||
|
||||
let data = null;
|
||||
try {
|
||||
// Derive briefing endpoint from current location or fallback to localhost
|
||||
const briefingUrl = window.location.protocol === 'file:'
|
||||
? 'http://localhost:8766/api/briefing'
|
||||
: `${window.location.protocol}//${window.location.hostname}:8766/api/briefing`;
|
||||
const res = await fetch(briefingUrl);
|
||||
if (res.ok) data = await res.json();
|
||||
} catch (e) {
|
||||
// Server not reachable — show honest offline state
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (pulseDot) pulseDot.classList.add('offline');
|
||||
container.innerHTML = '<div class="hb-empty">Briefing offline<br>No connection to Nexus gateway</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
if (pulseDot) pulseDot.classList.remove('offline');
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── Core Heartbeat ──
|
||||
html += '<div class="briefing-section">';
|
||||
html += '<div class="briefing-section-label">Nexus Core</div>';
|
||||
const hb = data.core_heartbeat;
|
||||
if (hb) {
|
||||
const age = hb.age_secs != null ? hb.age_secs : '?';
|
||||
const ageLabel = typeof age === 'number'
|
||||
? (age < 60 ? `${age.toFixed(0)}s ago` : `${(age / 60).toFixed(1)}m ago`)
|
||||
: age;
|
||||
const isAlive = typeof age === 'number' && age < 120;
|
||||
html += `<div class="hb-core-row">
|
||||
<span class="hb-core-status ${isAlive ? 'alive' : 'dead'}">${isAlive ? '● ALIVE' : '○ STALE'}</span>
|
||||
<span class="hb-core-meta">cycle ${hb.cycle || '?'} · ${hb.model || 'unknown'} · ${ageLabel}</span>
|
||||
</div>`;
|
||||
html += `<div class="hb-core-meta">status: ${hb.status || '?'}</div>`;
|
||||
} else {
|
||||
html += '<div class="hb-core-row"><span class="hb-core-status dead">○ NO DATA</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
|
||||
// ── Cron Heartbeats ──
|
||||
const cron = data.cron_heartbeat;
|
||||
if (cron && cron.jobs && cron.jobs.length > 0) {
|
||||
html += '<div class="briefing-section">';
|
||||
html += '<div class="briefing-section-label">Cron Heartbeats</div>';
|
||||
html += `<div class="hb-cron-row">
|
||||
<span class="hb-cron-healthy">● ${cron.healthy_count} healthy</span>
|
||||
${cron.stale_count > 0 ? `<span class="hb-cron-stale">⚠ ${cron.stale_count} stale</span>` : ''}
|
||||
</div>`;
|
||||
for (const job of cron.jobs.slice(0, 5)) {
|
||||
const cls = job.healthy ? 'healthy' : 'stale';
|
||||
html += `<div class="hb-cron-job">
|
||||
<span class="hb-cron-job-name">${esc(job.job)}</span>
|
||||
<span class="hb-cron-job-status ${cls}">${esc(job.message)}</span>
|
||||
</div>`;
|
||||
}
|
||||
if (cron.jobs.length > 5) {
|
||||
html += `<div class="hb-core-meta">+${cron.jobs.length - 5} more jobs</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Morning Report ──
|
||||
const report = data.morning_report;
|
||||
if (report) {
|
||||
html += '<div class="briefing-section">';
|
||||
html += '<div class="briefing-section-label">Latest Report</div>';
|
||||
|
||||
// Aggregate stats
|
||||
let totalClosed = 0, totalMerged = 0;
|
||||
if (report.repos) {
|
||||
for (const r of Object.values(report.repos)) {
|
||||
totalClosed += r.closed_issues || 0;
|
||||
totalMerged += r.merged_prs || 0;
|
||||
}
|
||||
}
|
||||
html += `<div class="hb-stats-row">
|
||||
<div class="hb-stat"><span class="hb-stat-value" style="color:#4af0c0">${totalClosed}</span><span class="hb-stat-label">Closed</span></div>
|
||||
<div class="hb-stat"><span class="hb-stat-value" style="color:#7b5cff">${totalMerged}</span><span class="hb-stat-label">Merged</span></div>
|
||||
<div class="hb-stat"><span class="hb-stat-value" style="color:#ffd700">${(report.blockers || []).length}</span><span class="hb-stat-label">Blockers</span></div>
|
||||
</div>`;
|
||||
|
||||
// Highlights (up to 3)
|
||||
if (report.highlights && report.highlights.length > 0) {
|
||||
for (const h of report.highlights.slice(0, 3)) {
|
||||
html += `<div class="hb-core-meta">+ ${esc(h)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Blockers
|
||||
if (report.blockers && report.blockers.length > 0) {
|
||||
for (const b of report.blockers) {
|
||||
html += `<div class="hb-blocker">⚠ ${esc(b)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
html += `<div class="hb-timestamp">Report: ${esc(report.generated_at || '?')}</div>`;
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
// ── Timestamp ──
|
||||
html += `<div class="hb-timestamp">Briefing updated: ${new Date().toLocaleTimeString('en-US', { hour12: false })}</div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
|
||||
function esc(str) {
|
||||
if (!str) return '';
|
||||
const d = document.createElement('div');
|
||||
d.textContent = String(str);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
// ═══ AGENT PRESENCE SYSTEM ═══
|
||||
function createAgentPresences() {
|
||||
const agentData = [
|
||||
@@ -2516,46 +2640,24 @@ function populateAtlas() {
|
||||
const grid = document.getElementById('atlas-grid');
|
||||
grid.innerHTML = '';
|
||||
|
||||
// Counters by status
|
||||
let onlineCount = 0;
|
||||
let standbyCount = 0;
|
||||
let rebuildingCount = 0;
|
||||
let localCount = 0;
|
||||
let blockedCount = 0;
|
||||
|
||||
// Group portals by environment
|
||||
const byEnv = { production: [], staging: [], local: [] };
|
||||
|
||||
portals.forEach(portal => {
|
||||
const config = portal.config;
|
||||
const status = config.status || 'online';
|
||||
const env = config.environment || 'production';
|
||||
if (config.status === 'online') onlineCount++;
|
||||
if (config.status === 'standby') standbyCount++;
|
||||
|
||||
// Count statuses
|
||||
if (status === 'online' || status === 'active') onlineCount++;
|
||||
else if (status === 'standby') standbyCount++;
|
||||
else if (status === 'rebuilding') rebuildingCount++;
|
||||
else if (status === 'local-only') localCount++;
|
||||
else if (status === 'blocked') blockedCount++;
|
||||
|
||||
// Group by environment
|
||||
if (byEnv[env]) {
|
||||
byEnv[env].push({ config, status });
|
||||
} else {
|
||||
byEnv['production'].push({ config, status });
|
||||
}
|
||||
|
||||
// Create atlas card
|
||||
const card = document.createElement('div');
|
||||
card.className = 'atlas-card';
|
||||
card.style.setProperty('--portal-color', config.color);
|
||||
|
||||
const statusClass = `status-${status}`;
|
||||
const statusClass = `status-${config.status || 'online'}`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="atlas-card-header">
|
||||
<div class="atlas-card-name">${config.name}</div>
|
||||
<div class="atlas-card-status ${statusClass}">${status.toUpperCase()}</div>
|
||||
<div class="atlas-card-status ${statusClass}">${config.status || 'ONLINE'}</div>
|
||||
</div>
|
||||
<div class="atlas-card-desc">${config.description}</div>
|
||||
<div class="atlas-card-footer">
|
||||
@@ -2572,18 +2674,9 @@ function populateAtlas() {
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
// Update footer counts
|
||||
document.getElementById('atlas-online-count').textContent = onlineCount;
|
||||
document.getElementById('atlas-standby-count').textContent = standbyCount;
|
||||
document.getElementById('atlas-rebuilding-count').textContent = rebuildingCount;
|
||||
document.getElementById('atlas-local-count').textContent = localCount;
|
||||
document.getElementById('atlas-blocked-count').textContent = blockedCount;
|
||||
|
||||
// Populate status wall by environment
|
||||
populateStatusWallEnv('production', byEnv.production);
|
||||
populateStatusWallEnv('staging', byEnv.staging);
|
||||
populateStatusWallEnv('local', byEnv.local);
|
||||
|
||||
|
||||
// Update Bannerlord HUD status
|
||||
const bannerlord = portals.find(p => p.config.id === 'bannerlord');
|
||||
if (bannerlord) {
|
||||
@@ -2592,75 +2685,6 @@ function populateAtlas() {
|
||||
}
|
||||
}
|
||||
|
||||
function populateStatusWallEnv(envName, portalList) {
|
||||
const container = document.getElementById(`${envName}-portals`);
|
||||
const summary = document.getElementById(`${envName}-summary`);
|
||||
container.innerHTML = '';
|
||||
|
||||
if (portalList.length === 0) {
|
||||
container.innerHTML = '<div class="status-portal-row"><span class="status-portal-name" style="font-style: italic; color: rgba(160,184,208,0.4);">No worlds</span></div>';
|
||||
summary.textContent = 'No portals in this environment';
|
||||
return;
|
||||
}
|
||||
|
||||
// Count statuses in this environment
|
||||
const statusCounts = {};
|
||||
portalList.forEach(({ config, status }) => {
|
||||
statusCounts[status] = (statusCounts[status] || 0) + 1;
|
||||
});
|
||||
|
||||
// Create portal rows
|
||||
portalList.forEach(({ config, status }) => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'status-portal-row';
|
||||
|
||||
const indicator = document.createElement('span');
|
||||
indicator.className = `status-portal-indicator status-dot ${status}`;
|
||||
|
||||
const nameSpan = document.createElement('span');
|
||||
nameSpan.className = 'status-portal-name';
|
||||
nameSpan.textContent = config.name;
|
||||
|
||||
const statusSpan = document.createElement('span');
|
||||
statusSpan.style.fontSize = '9px';
|
||||
statusSpan.style.textTransform = 'uppercase';
|
||||
statusSpan.style.marginLeft = '8px';
|
||||
statusSpan.style.color = getStatusColor(status);
|
||||
statusSpan.textContent = status;
|
||||
|
||||
row.appendChild(nameSpan);
|
||||
row.appendChild(statusSpan);
|
||||
row.appendChild(indicator);
|
||||
container.appendChild(row);
|
||||
});
|
||||
|
||||
// Create summary
|
||||
const summaryParts = Object.entries(statusCounts).map(([status, count]) =>
|
||||
`${count} ${status}`
|
||||
);
|
||||
summary.textContent = summaryParts.join(' · ');
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
case 'active':
|
||||
return 'var(--color-primary)';
|
||||
case 'standby':
|
||||
return 'var(--color-gold)';
|
||||
case 'rebuilding':
|
||||
return '#ffa500';
|
||||
case 'local-only':
|
||||
return '#00ff88';
|
||||
case 'blocked':
|
||||
return '#ff0000';
|
||||
case 'offline':
|
||||
return 'var(--color-danger)';
|
||||
default:
|
||||
return 'var(--color-text-muted)';
|
||||
}
|
||||
}
|
||||
|
||||
function focusPortal(portal) {
|
||||
// Teleport player to a position in front of the portal
|
||||
const offset = new THREE.Vector3(0, 0, 6).applyEuler(new THREE.Euler(0, portal.config.rotation?.y || 0, 0));
|
||||
|
||||
52
index.html
52
index.html
@@ -96,6 +96,12 @@
|
||||
<div class="panel-header">SOVEREIGN HEALTH</div>
|
||||
<div id="sovereign-health-content" class="panel-content"></div>
|
||||
</div>
|
||||
<div class="hud-panel hud-panel-briefing" id="heartbeat-briefing-log">
|
||||
<div class="panel-header">
|
||||
<span class="hb-pulse-dot"></span> HEARTBEAT BRIEFING
|
||||
</div>
|
||||
<div id="heartbeat-briefing-content" class="panel-content"></div>
|
||||
</div>
|
||||
<div class="hud-panel" id="calibrator-log">
|
||||
<div class="panel-header">ADAPTIVE CALIBRATOR</div>
|
||||
<div id="calibrator-log-content" class="panel-content"></div>
|
||||
@@ -264,57 +270,11 @@
|
||||
<div class="atlas-grid" id="atlas-grid">
|
||||
<!-- Portals will be injected here -->
|
||||
</div>
|
||||
<!-- Portal Status Wall -->
|
||||
<div class="atlas-status-wall">
|
||||
<div class="status-wall-header">
|
||||
<span class="status-wall-title">WORLD STATUS WALL</span>
|
||||
<span class="status-wall-subtitle">Real-time portal health</span>
|
||||
</div>
|
||||
<div class="status-wall-grid">
|
||||
<div class="status-wall-env" id="status-wall-production">
|
||||
<div class="status-env-header">
|
||||
<span class="status-env-dot production"></span>
|
||||
<span class="status-env-label">PRODUCTION</span>
|
||||
</div>
|
||||
<div class="status-env-portals" id="production-portals"></div>
|
||||
<div class="status-env-summary" id="production-summary"></div>
|
||||
</div>
|
||||
<div class="status-wall-env" id="status-wall-staging">
|
||||
<div class="status-env-header">
|
||||
<span class="status-env-dot staging"></span>
|
||||
<span class="status-env-label">STAGING</span>
|
||||
</div>
|
||||
<div class="status-env-portals" id="staging-portals"></div>
|
||||
<div class="status-env-summary" id="staging-summary"></div>
|
||||
</div>
|
||||
<div class="status-wall-env" id="status-wall-local">
|
||||
<div class="status-env-header">
|
||||
<span class="status-env-dot local"></span>
|
||||
<span class="status-env-label">LOCAL</span>
|
||||
</div>
|
||||
<div class="status-env-portals" id="local-portals"></div>
|
||||
<div class="status-env-summary" id="local-summary"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-wall-legend">
|
||||
<div class="legend-item"><span class="status-dot online"></span> Online</div>
|
||||
<div class="legend-item"><span class="status-dot rebuilding"></span> Rebuilding</div>
|
||||
<div class="legend-item"><span class="status-dot local-only"></span> Local-only</div>
|
||||
<div class="legend-item"><span class="status-dot blocked"></span> Blocked</div>
|
||||
<div class="legend-item"><span class="status-dot offline"></span> Offline</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="atlas-footer">
|
||||
<div class="atlas-status-summary">
|
||||
<span class="status-indicator online"></span> <span id="atlas-online-count">0</span> ONLINE
|
||||
|
||||
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
|
||||
|
||||
<span class="status-indicator rebuilding"></span> <span id="atlas-rebuilding-count">0</span> REBUILDING
|
||||
|
||||
<span class="status-indicator local-only"></span> <span id="atlas-local-count">0</span> LOCAL
|
||||
|
||||
<span class="status-indicator blocked"></span> <span id="atlas-blocked-count">0</span> BLOCKED
|
||||
</div>
|
||||
<div class="atlas-hint">Click a portal to focus or teleport</div>
|
||||
</div>
|
||||
|
||||
25
portals.json
25
portals.json
@@ -4,7 +4,6 @@
|
||||
"name": "Morrowind",
|
||||
"description": "The Vvardenfell harness. Ash storms and ancient mysteries.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"color": "#ff6600",
|
||||
"position": { "x": 15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": -0.5 },
|
||||
@@ -18,13 +17,13 @@
|
||||
"id": "bannerlord",
|
||||
"name": "Bannerlord",
|
||||
"description": "Calradia battle harness. Massive armies, tactical command.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"status": "active",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -15, "y": 0, "z": -10 },
|
||||
"rotation": { "y": 0.5 },
|
||||
"portal_type": "game-world",
|
||||
"world_category": "strategy-rpg",
|
||||
"environment": "production",
|
||||
"access_mode": "operator",
|
||||
"readiness_state": "active",
|
||||
"telemetry_source": "hermes-harness:bannerlord",
|
||||
@@ -43,7 +42,6 @@
|
||||
"name": "Workshop",
|
||||
"description": "The creative harness. Build, script, and manifest.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 0, "y": 0, "z": -20 },
|
||||
"rotation": { "y": 0 },
|
||||
@@ -58,7 +56,6 @@
|
||||
"name": "Archive",
|
||||
"description": "The repository of all knowledge. History, logs, and ancient data.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"color": "#0066ff",
|
||||
"position": { "x": 25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": -1.57 },
|
||||
@@ -73,7 +70,6 @@
|
||||
"name": "Chapel",
|
||||
"description": "A sanctuary for reflection and digital peace.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"color": "#ffd700",
|
||||
"position": { "x": -25, "y": 0, "z": 0 },
|
||||
"rotation": { "y": 1.57 },
|
||||
@@ -88,7 +84,6 @@
|
||||
"name": "Courtyard",
|
||||
"description": "The open nexus. A place for agents to gather and connect.",
|
||||
"status": "online",
|
||||
"environment": "production",
|
||||
"color": "#4af0c0",
|
||||
"position": { "x": 15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": -2.5 },
|
||||
@@ -103,7 +98,6 @@
|
||||
"name": "Gate",
|
||||
"description": "The transition point. Entry and exit from the Nexus core.",
|
||||
"status": "standby",
|
||||
"environment": "staging",
|
||||
"color": "#ff4466",
|
||||
"position": { "x": -15, "y": 0, "z": 10 },
|
||||
"rotation": { "y": 2.5 },
|
||||
@@ -112,20 +106,5 @@
|
||||
"type": "harness",
|
||||
"params": { "mode": "transit" }
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "dev",
|
||||
"name": "Dev Sandbox",
|
||||
"description": "Local development world. Unstable, experimental, honest.",
|
||||
"status": "local-only",
|
||||
"environment": "local",
|
||||
"color": "#00ff88",
|
||||
"position": { "x": 0, "y": 0, "z": 20 },
|
||||
"rotation": { "y": 0 },
|
||||
"destination": {
|
||||
"url": "http://localhost:3000",
|
||||
"type": "local",
|
||||
"params": { "mode": "dev" }
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
144
server.py
144
server.py
@@ -3,17 +3,156 @@
|
||||
The Nexus WebSocket Gateway — Robust broadcast bridge for Timmy's consciousness.
|
||||
This server acts as the central hub for the-nexus, connecting the mind (nexus_think.py),
|
||||
the body (Evennia/Morrowind), and the visualization surface.
|
||||
|
||||
Serves HTTP alongside WebSocket:
|
||||
GET /api/briefing — heartbeat + morning report data for the HUD briefing panel
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from typing import Set
|
||||
from datetime import datetime, timezone
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
from pathlib import Path
|
||||
from threading import Thread
|
||||
from typing import Any, Dict, Set
|
||||
|
||||
# Branch protected file - see POLICY.md
|
||||
import websockets
|
||||
|
||||
|
||||
# ── HTTP Briefing Endpoint ─────────────────────────────────────────────
|
||||
|
||||
HEARTBEAT_PATH = Path.home() / ".nexus" / "heartbeat.json"
|
||||
REPORTS_DIR = Path.home() / ".local" / "timmy" / "reports"
|
||||
CRON_HEARTBEAT_DIR_PRIMARY = Path("/var/run/bezalel/heartbeats")
|
||||
CRON_HEARTBEAT_DIR_FALLBACK = Path.home() / ".bezalel" / "heartbeats"
|
||||
|
||||
|
||||
def _read_json_file(path: Path) -> Any:
|
||||
"""Read and parse a JSON file. Returns None on failure."""
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_cron_dir() -> Path:
|
||||
"""Return the first writable cron heartbeat directory."""
|
||||
for d in [CRON_HEARTBEAT_DIR_PRIMARY, CRON_HEARTBEAT_DIR_FALLBACK]:
|
||||
if d.exists() and os.access(str(d), os.R_OK):
|
||||
return d
|
||||
return CRON_HEARTBEAT_DIR_FALLBACK
|
||||
|
||||
|
||||
def _read_cron_heartbeats() -> list:
|
||||
"""Read all .last files from the cron heartbeat directory."""
|
||||
hb_dir = _resolve_cron_dir()
|
||||
if not hb_dir.exists():
|
||||
return []
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
jobs = []
|
||||
for f in sorted(hb_dir.glob("*.last")):
|
||||
data = _read_json_file(f)
|
||||
if data is None:
|
||||
jobs.append({"job": f.stem, "healthy": False, "message": "corrupt"})
|
||||
continue
|
||||
ts = float(data.get("timestamp", 0))
|
||||
interval = int(data.get("interval", 3600))
|
||||
age = now - ts
|
||||
is_stale = age > (2 * interval)
|
||||
jobs.append({
|
||||
"job": f.stem,
|
||||
"healthy": not is_stale,
|
||||
"age_secs": round(age, 1),
|
||||
"interval": interval,
|
||||
"last_seen": datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() if ts else None,
|
||||
"message": f"{'STALE' if is_stale else 'OK'} ({age:.0f}s / {interval}s)" if ts else "no timestamp",
|
||||
})
|
||||
return jobs
|
||||
|
||||
|
||||
def _latest_morning_report() -> Dict[str, Any] | None:
|
||||
"""Find the most recent morning report file."""
|
||||
if not REPORTS_DIR.exists():
|
||||
return None
|
||||
reports = sorted(REPORTS_DIR.glob("morning-*.json"), reverse=True)
|
||||
if not reports:
|
||||
return None
|
||||
return _read_json_file(reports[0])
|
||||
|
||||
|
||||
def _build_briefing() -> Dict[str, Any]:
|
||||
"""Assemble the full briefing payload from real files."""
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Core heartbeat
|
||||
core_hb = _read_json_file(HEARTBEAT_PATH)
|
||||
if core_hb:
|
||||
beat_ts = float(core_hb.get("timestamp", 0))
|
||||
core_hb["age_secs"] = round(now.timestamp() - beat_ts, 1) if beat_ts else None
|
||||
|
||||
# Cron heartbeats
|
||||
cron_jobs = _read_cron_heartbeats()
|
||||
healthy_count = sum(1 for j in cron_jobs if j.get("healthy"))
|
||||
stale_count = sum(1 for j in cron_jobs if not j.get("healthy"))
|
||||
|
||||
# Morning report
|
||||
report = _latest_morning_report()
|
||||
|
||||
return {
|
||||
"generated_at": now.isoformat(),
|
||||
"core_heartbeat": core_hb,
|
||||
"cron_heartbeat": {
|
||||
"jobs": cron_jobs,
|
||||
"healthy_count": healthy_count,
|
||||
"stale_count": stale_count,
|
||||
},
|
||||
"morning_report": report,
|
||||
}
|
||||
|
||||
|
||||
class BriefingHandler(SimpleHTTPRequestHandler):
|
||||
"""Minimal HTTP handler that only serves /api/briefing."""
|
||||
|
||||
def do_GET(self):
|
||||
if self.path == "/api/briefing":
|
||||
try:
|
||||
data = _build_briefing()
|
||||
body = json.dumps(data).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
except Exception as e:
|
||||
self.send_error(500, str(e))
|
||||
elif self.path == "/api/health":
|
||||
body = json.dumps({"status": "ok"}).encode("utf-8")
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
self.send_error(404)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
pass # Suppress HTTP access logs — WS gateway logs are enough
|
||||
|
||||
|
||||
def start_http_server(port: int = 8766):
|
||||
"""Run the HTTP server in a daemon thread."""
|
||||
server = HTTPServer(("0.0.0.0", port), BriefingHandler)
|
||||
thread = Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
logger = logging.getLogger("nexus-gateway")
|
||||
logger.info(f"Briefing HTTP server started on http://0.0.0.0:{port}")
|
||||
return server
|
||||
|
||||
# Configuration
|
||||
PORT = 8765
|
||||
HOST = "0.0.0.0" # Allow external connections if needed
|
||||
@@ -80,6 +219,9 @@ async def broadcast_handler(websocket: websockets.WebSocketServerProtocol):
|
||||
|
||||
async def main():
|
||||
"""Main server loop with graceful shutdown."""
|
||||
# Start HTTP briefing endpoint alongside WS
|
||||
http_server = start_http_server(port=8766)
|
||||
|
||||
logger.info(f"Starting Nexus WS gateway on ws://{HOST}:{PORT}")
|
||||
|
||||
# Set up signal handlers for graceful shutdown
|
||||
|
||||
301
style.css
301
style.css
@@ -365,11 +365,7 @@ canvas#nexus-canvas {
|
||||
}
|
||||
|
||||
.status-online { background: rgba(74, 240, 192, 0.2); color: var(--color-primary); border: 1px solid var(--color-primary); }
|
||||
.status-active { background: rgba(74, 240, 192, 0.2); color: var(--color-primary); border: 1px solid var(--color-primary); }
|
||||
.status-standby { background: rgba(255, 215, 0, 0.2); color: var(--color-gold); border: 1px solid var(--color-gold); }
|
||||
.status-rebuilding { background: rgba(255, 165, 0, 0.2); color: #ffa500; border: 1px solid #ffa500; }
|
||||
.status-local-only { background: rgba(0, 255, 136, 0.2); color: #00ff88; border: 1px solid #00ff88; }
|
||||
.status-blocked { background: rgba(255, 0, 0, 0.2); color: #ff0000; border: 1px solid #ff0000; }
|
||||
.status-offline { background: rgba(255, 68, 102, 0.2); color: var(--color-danger); border: 1px solid var(--color-danger); }
|
||||
|
||||
.atlas-card-desc {
|
||||
@@ -414,165 +410,6 @@ canvas#nexus-canvas {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Portal Status Wall */
|
||||
.atlas-status-wall {
|
||||
padding: 20px 30px;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: rgba(10, 15, 40, 0.5);
|
||||
}
|
||||
|
||||
.status-wall-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-wall-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.status-wall-subtitle {
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-wall-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.status-wall-env {
|
||||
background: rgba(20, 30, 60, 0.3);
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.status-env-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.status-env-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-env-dot.production {
|
||||
background: var(--color-primary);
|
||||
box-shadow: 0 0 5px var(--color-primary);
|
||||
}
|
||||
|
||||
.status-env-dot.staging {
|
||||
background: var(--color-gold);
|
||||
box-shadow: 0 0 5px var(--color-gold);
|
||||
}
|
||||
|
||||
.status-env-dot.local {
|
||||
background: #00ff88;
|
||||
box-shadow: 0 0 5px #00ff88;
|
||||
}
|
||||
|
||||
.status-env-label {
|
||||
font-family: var(--font-display);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.status-env-portals {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-bottom: 10px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.status-portal-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
font-family: var(--font-body);
|
||||
font-size: 11px;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.status-portal-name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-portal-indicator {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-env-summary {
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.status-wall-legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.status-dot.online { background: var(--color-primary); box-shadow: 0 0 4px var(--color-primary); }
|
||||
.status-dot.active { background: var(--color-primary); box-shadow: 0 0 4px var(--color-primary); }
|
||||
.status-dot.standby { background: var(--color-gold); box-shadow: 0 0 4px var(--color-gold); }
|
||||
.status-dot.rebuilding { background: #ffa500; box-shadow: 0 0 4px #ffa500; }
|
||||
.status-dot.local-only { background: #00ff88; box-shadow: 0 0 4px #00ff88; }
|
||||
.status-dot.blocked { background: #ff0000; box-shadow: 0 0 4px #ff0000; }
|
||||
.status-dot.offline { background: var(--color-danger); box-shadow: 0 0 4px var(--color-danger); }
|
||||
|
||||
/* Additional status indicators for footer */
|
||||
.status-indicator.rebuilding { background: #ffa500; box-shadow: 0 0 5px #ffa500; }
|
||||
.status-indicator.local-only { background: #00ff88; box-shadow: 0 0 5px #00ff88; }
|
||||
.status-indicator.blocked { background: #ff0000; box-shadow: 0 0 5px #ff0000; }
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
@@ -586,12 +423,6 @@ canvas#nexus-canvas {
|
||||
.atlas-content {
|
||||
max-height: 90vh;
|
||||
}
|
||||
.status-wall-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.atlas-status-wall {
|
||||
padding: 15px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Debug overlay */
|
||||
@@ -1393,6 +1224,138 @@ canvas#nexus-canvas {
|
||||
|
||||
.pse-status { color: #4af0c0; font-weight: 600; }
|
||||
|
||||
/* ═══ HEARTBEAT BRIEFING PANEL ═════════════════════════════════════ */
|
||||
.hud-panel-briefing {
|
||||
width: 320px;
|
||||
max-height: 420px;
|
||||
border-left-color: #7b5cff;
|
||||
}
|
||||
.hud-panel-briefing .panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: #7b5cff;
|
||||
}
|
||||
.hud-panel-briefing .panel-content {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Pulse dot */
|
||||
.hb-pulse-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4af0c0;
|
||||
box-shadow: 0 0 6px #4af0c0;
|
||||
animation: hb-dot-pulse 2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.hb-pulse-dot.offline {
|
||||
background: #ff4466;
|
||||
box-shadow: 0 0 6px #ff4466;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes hb-dot-pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
/* Briefing sections */
|
||||
.briefing-section {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 6px;
|
||||
border-bottom: 1px solid rgba(123, 92, 255, 0.12);
|
||||
}
|
||||
.briefing-section:last-child { border-bottom: none; margin-bottom: 0; }
|
||||
.briefing-section-label {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: #8a9ab8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* Core heartbeat row */
|
||||
.hb-core-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.hb-core-status {
|
||||
font-weight: 700;
|
||||
font-size: 11px;
|
||||
}
|
||||
.hb-core-status.alive { color: #4af0c0; }
|
||||
.hb-core-status.dead { color: #ff4466; }
|
||||
.hb-core-meta {
|
||||
font-size: 10px;
|
||||
color: #8a9ab8;
|
||||
}
|
||||
/* Cron jobs */
|
||||
.hb-cron-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
font-size: 10px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.hb-cron-healthy { color: #4af0c0; }
|
||||
.hb-cron-stale { color: #ff4466; font-weight: 700; }
|
||||
.hb-cron-job {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 10px;
|
||||
padding: 1px 0;
|
||||
}
|
||||
.hb-cron-job-name { color: #e0f0ff; }
|
||||
.hb-cron-job-status.healthy { color: #4af0c0; }
|
||||
.hb-cron-job-status.stale { color: #ff4466; font-weight: 700; }
|
||||
/* Morning report stats */
|
||||
.hb-stats-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 10px;
|
||||
}
|
||||
.hb-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.hb-stat-value {
|
||||
font-family: 'Orbitron', sans-serif;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
}
|
||||
.hb-stat-label {
|
||||
font-size: 9px;
|
||||
color: #8a9ab8;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
/* Blockers */
|
||||
.hb-blocker {
|
||||
font-size: 10px;
|
||||
color: #ff4466;
|
||||
padding: 1px 0;
|
||||
}
|
||||
/* Narrative */
|
||||
.hb-narrative {
|
||||
font-size: 10px;
|
||||
color: #8a9ab8;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
/* Empty / offline state */
|
||||
.hb-empty {
|
||||
font-size: 10px;
|
||||
color: #8a9ab8;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
}
|
||||
/* Timestamp */
|
||||
.hb-timestamp {
|
||||
font-size: 9px;
|
||||
color: rgba(138, 154, 184, 0.6);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* ═══════════════════════════════════════════
|
||||
MNEMOSYNE — MEMORY CRYSTAL INSPECTION PANEL
|
||||
|
||||
Reference in New Issue
Block a user