Compare commits

..

1 Commits

Author SHA1 Message Date
Timmy Swarm (mimo-v2-pro)
43455e9c83 fix: [PANELS] Add heartbeat / morning briefing panel tied to Hermes state (closes #698) 2026-04-10 20:19:36 -04:00
6 changed files with 415 additions and 344 deletions

3
.gitignore vendored
View File

@@ -7,3 +7,6 @@ mempalace/__pycache__/
# Prevent agents from writing to wrong path (see issue #1145)
public/nexus/
__pycache__/
*.pyc

234
app.js
View File

@@ -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));

View File

@@ -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
&nbsp;&nbsp;
<span class="status-indicator standby"></span> <span id="atlas-standby-count">0</span> STANDBY
&nbsp;&nbsp;
<span class="status-indicator rebuilding"></span> <span id="atlas-rebuilding-count">0</span> REBUILDING
&nbsp;&nbsp;
<span class="status-indicator local-only"></span> <span id="atlas-local-count">0</span> LOCAL
&nbsp;&nbsp;
<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>

View File

@@ -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
View File

@@ -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
View File

@@ -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