Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1268530871 |
5
app.js
5
app.js
@@ -10,6 +10,7 @@ import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
|
||||
import { MemoryInspect } from './nexus/components/memory-inspect.js';
|
||||
import { MemoryPulse } from './nexus/components/memory-pulse.js';
|
||||
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
|
||||
import { PortalHealthCheck } from './nexus/components/portal-health-check.js';
|
||||
|
||||
// ═══════════════════════════════════════════
|
||||
// NEXUS v1.1 — Portal System Update
|
||||
@@ -730,6 +731,10 @@ async function init() {
|
||||
const response = await fetch('./portals.json');
|
||||
const portalData = await response.json();
|
||||
createPortals(portalData);
|
||||
|
||||
// Initialize portal health check system
|
||||
PortalHealthCheck.init(portals);
|
||||
PortalHealthCheck.start();
|
||||
} catch (e) {
|
||||
console.error('Failed to load portals.json:', e);
|
||||
addChatMessage('error', 'Portal registry offline. Check logs.');
|
||||
|
||||
301
nexus/components/portal-health-check.js
Normal file
301
nexus/components/portal-health-check.js
Normal file
@@ -0,0 +1,301 @@
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// PORTAL HEALTH CHECK SYSTEM
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
//
|
||||
// Monitors portal health and updates status automatically.
|
||||
// Unreachable portals are dimmed and marked as "Offline".
|
||||
// Auto-re-enables when reachable again.
|
||||
//
|
||||
// Usage:
|
||||
// PortalHealthCheck.init(portals);
|
||||
// PortalHealthCheck.start();
|
||||
// PortalHealthCheck.stop();
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
const PortalHealthCheck = (() => {
|
||||
let _portals = [];
|
||||
let _checkInterval = null;
|
||||
let _checkFrequency = 5 * 60 * 1000; // 5 minutes
|
||||
let _healthStatus = {}; // portalId -> { healthy: bool, lastCheck: timestamp, error: string }
|
||||
|
||||
// ─── Configuration ──────────────────────────────────────
|
||||
const CONFIG = {
|
||||
checkTimeout: 10000, // 10 seconds timeout for health check
|
||||
retryAttempts: 2, // Number of retry attempts before marking unhealthy
|
||||
unhealthyThreshold: 3, // Number of consecutive failures before marking offline
|
||||
checkFrequency: 5 * 60 * 1000, // 5 minutes
|
||||
};
|
||||
|
||||
// ─── Health Check Functions ─────────────────────────────
|
||||
|
||||
async function checkPortalHealth(portal) {
|
||||
const portalId = portal.config.id;
|
||||
const destination = portal.config.destination;
|
||||
|
||||
// Skip portals without URLs (harness-type portals)
|
||||
if (!destination || !destination.url) {
|
||||
return {
|
||||
healthy: true,
|
||||
error: null,
|
||||
type: 'no_url'
|
||||
};
|
||||
}
|
||||
|
||||
const url = destination.url;
|
||||
|
||||
// Skip non-http URLs (e.g., harness://, local://)
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return {
|
||||
healthy: true,
|
||||
error: null,
|
||||
type: 'non_http'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Create abort controller for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), CONFIG.checkTimeout);
|
||||
|
||||
// Perform health check
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD', // Use HEAD to minimize bandwidth
|
||||
signal: controller.signal,
|
||||
mode: 'no-cors', // Handle CORS issues
|
||||
cache: 'no-cache'
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Consider 2xx and 3xx as healthy
|
||||
const healthy = response.ok || (response.status >= 200 && response.status < 400);
|
||||
|
||||
return {
|
||||
healthy,
|
||||
error: healthy ? null : `HTTP ${response.status}`,
|
||||
type: 'http',
|
||||
status: response.status
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
// Network errors, timeouts, etc.
|
||||
let errorMessage = error.message;
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
errorMessage = 'Timeout';
|
||||
} else if (error.name === 'TypeError' && error.message.includes('Failed to fetch')) {
|
||||
errorMessage = 'Network error';
|
||||
}
|
||||
|
||||
return {
|
||||
healthy: false,
|
||||
error: errorMessage,
|
||||
type: 'error'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function performHealthChecks() {
|
||||
console.log('[PortalHealthCheck] Starting health checks...');
|
||||
|
||||
const checkPromises = _portals.map(async (portal) => {
|
||||
const portalId = portal.config.id;
|
||||
const portalName = portal.config.name;
|
||||
|
||||
// Get current health status
|
||||
const currentStatus = _healthStatus[portalId] || {
|
||||
healthy: true,
|
||||
lastCheck: 0,
|
||||
error: null,
|
||||
consecutiveFailures: 0
|
||||
};
|
||||
|
||||
// Perform health check
|
||||
const result = await checkPortalHealth(portal);
|
||||
|
||||
// Update health status
|
||||
const newStatus = {
|
||||
healthy: result.healthy,
|
||||
lastCheck: Date.now(),
|
||||
error: result.error,
|
||||
consecutiveFailures: result.healthy ? 0 : currentStatus.consecutiveFailures + 1,
|
||||
type: result.type
|
||||
};
|
||||
|
||||
// Store updated status
|
||||
_healthStatus[portalId] = newStatus;
|
||||
|
||||
// Update portal visual state
|
||||
updatePortalVisualState(portal, newStatus);
|
||||
|
||||
// Log health check result
|
||||
if (!result.healthy) {
|
||||
console.warn(`[PortalHealthCheck] Portal "${portalName}" unhealthy: ${result.error}`);
|
||||
} else if (currentStatus.consecutiveFailures > 0) {
|
||||
console.log(`[PortalHealthCheck] Portal "${portalName}" recovered`);
|
||||
}
|
||||
|
||||
return { portalId, status: newStatus };
|
||||
});
|
||||
|
||||
await Promise.allSettled(checkPromises);
|
||||
console.log('[PortalHealthCheck] Health checks complete');
|
||||
}
|
||||
|
||||
function updatePortalVisualState(portal, healthStatus) {
|
||||
const portalId = portal.config.id;
|
||||
const isHealthy = healthStatus.healthy;
|
||||
const wasHealthy = portal.config._lastHealthStatus !== false;
|
||||
|
||||
// Store previous health status
|
||||
portal.config._lastHealthStatus = isHealthy;
|
||||
|
||||
// Update portal config status
|
||||
if (!isHealthy && healthStatus.consecutiveFailures >= CONFIG.unhealthyThreshold) {
|
||||
portal.config.status = 'offline';
|
||||
portal.config.blocked_reason = `Health check failed: ${healthStatus.error}`;
|
||||
} else if (isHealthy && portal.config.status === 'offline') {
|
||||
portal.config.status = 'online';
|
||||
portal.config.blocked_reason = null;
|
||||
}
|
||||
|
||||
// Update visual appearance
|
||||
updatePortalAppearance(portal, isHealthy);
|
||||
|
||||
// Update tooltip
|
||||
updatePortalTooltip(portal, healthStatus);
|
||||
}
|
||||
|
||||
function updatePortalAppearance(portal, isHealthy) {
|
||||
// Update ring material
|
||||
if (portal.ring && portal.ring.material) {
|
||||
portal.ring.material.emissiveIntensity = isHealthy ? 1.5 : 0.3;
|
||||
portal.ring.material.opacity = isHealthy ? 1.0 : 0.4;
|
||||
}
|
||||
|
||||
// Update swirl material
|
||||
if (portal.swirl && portal.swirl.material) {
|
||||
portal.swirl.material.opacity = isHealthy ? 0.8 : 0.2;
|
||||
}
|
||||
|
||||
// Update particles
|
||||
if (portal.particles) {
|
||||
portal.particles.forEach(particle => {
|
||||
if (particle.material) {
|
||||
particle.material.opacity = isHealthy ? 0.6 : 0.1;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Update portal group visibility
|
||||
if (portal.group) {
|
||||
portal.group.visible = true; // Always visible, but dimmed when unhealthy
|
||||
}
|
||||
}
|
||||
|
||||
function updatePortalTooltip(portal, healthStatus) {
|
||||
const portalId = portal.config.id;
|
||||
const tooltipElement = document.querySelector(`[data-portal-tooltip="${portalId}"]`);
|
||||
|
||||
if (tooltipElement) {
|
||||
if (!healthStatus.healthy) {
|
||||
tooltipElement.textContent = `Offline: ${healthStatus.error || 'Unreachable'}`;
|
||||
tooltipElement.classList.add('portal-offline');
|
||||
} else {
|
||||
tooltipElement.textContent = portal.config.description || portal.config.name;
|
||||
tooltipElement.classList.remove('portal-offline');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API ─────────────────────────────────────────
|
||||
|
||||
function init(portals) {
|
||||
_portals = portals || [];
|
||||
|
||||
// Initialize health status for all portals
|
||||
_portals.forEach(portal => {
|
||||
const portalId = portal.config.id;
|
||||
_healthStatus[portalId] = {
|
||||
healthy: true,
|
||||
lastCheck: 0,
|
||||
error: null,
|
||||
consecutiveFailures: 0,
|
||||
type: 'initialized'
|
||||
};
|
||||
});
|
||||
|
||||
console.log(`[PortalHealthCheck] Initialized with ${_portals.length} portals`);
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (_checkInterval) {
|
||||
console.warn('[PortalHealthCheck] Already running');
|
||||
return;
|
||||
}
|
||||
|
||||
// Perform initial health check
|
||||
performHealthChecks();
|
||||
|
||||
// Set up interval for periodic checks
|
||||
_checkInterval = setInterval(performHealthChecks, CONFIG.checkFrequency);
|
||||
|
||||
console.log(`[PortalHealthCheck] Started with ${CONFIG.checkFrequency / 1000}s interval`);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (_checkInterval) {
|
||||
clearInterval(_checkInterval);
|
||||
_checkInterval = null;
|
||||
console.log('[PortalHealthCheck] Stopped');
|
||||
}
|
||||
}
|
||||
|
||||
function getStatus() {
|
||||
return {
|
||||
running: _checkInterval !== null,
|
||||
portalCount: _portals.length,
|
||||
healthStatus: { ..._healthStatus },
|
||||
config: { ...CONFIG }
|
||||
};
|
||||
}
|
||||
|
||||
function getPortalHealth(portalId) {
|
||||
return _healthStatus[portalId] || null;
|
||||
}
|
||||
|
||||
function forceCheck() {
|
||||
console.log('[PortalHealthCheck] Forcing immediate health check');
|
||||
return performHealthChecks();
|
||||
}
|
||||
|
||||
function updateConfig(newConfig) {
|
||||
Object.assign(CONFIG, newConfig);
|
||||
|
||||
// Update check frequency if changed
|
||||
if (newConfig.checkFrequency && _checkInterval) {
|
||||
stop();
|
||||
start();
|
||||
}
|
||||
|
||||
console.log('[PortalHealthCheck] Configuration updated');
|
||||
}
|
||||
|
||||
// ─── Return Public API ──────────────────────────────────
|
||||
|
||||
return {
|
||||
init,
|
||||
start,
|
||||
stop,
|
||||
getStatus,
|
||||
getPortalHealth,
|
||||
forceCheck,
|
||||
updateConfig,
|
||||
CONFIG
|
||||
};
|
||||
})();
|
||||
|
||||
// Export for use in app.js
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = PortalHealthCheck;
|
||||
}
|
||||
42
style.css
42
style.css
@@ -1130,6 +1130,48 @@ canvas#nexus-canvas {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Portal Health Check Styles */
|
||||
.portal-offline {
|
||||
color: var(--color-danger) !important;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.portal-offline::before {
|
||||
content: "⚠ ";
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.portal-status-indicator {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--color-surface);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.portal-status-indicator.online {
|
||||
background: var(--color-success);
|
||||
box-shadow: 0 0 8px var(--color-success);
|
||||
}
|
||||
|
||||
.portal-status-indicator.offline {
|
||||
background: var(--color-danger);
|
||||
box-shadow: 0 0 8px var(--color-danger);
|
||||
}
|
||||
|
||||
.portal-status-indicator.checking {
|
||||
background: var(--color-warning);
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
|
||||
/* === CHAT PANEL === */
|
||||
.chat-panel {
|
||||
position: absolute;
|
||||
|
||||
Reference in New Issue
Block a user