Add distributed agent architecture for multi-node signal intelligence

Features:
- Standalone agent server (intercept_agent.py) for remote sensor nodes
- Controller API blueprint for agent management and data aggregation
- Push mechanism for agents to send data to controller
- Pull mechanism for controller to proxy requests to agents
- Multi-agent SSE stream for combined data view
- Agent management page at /controller/manage
- Agent selector dropdown in main UI
- GPS integration for location tagging
- API key authentication for secure agent communication
- Integration with Intercept's dependency checking system

New files:
- intercept_agent.py: Remote agent HTTP server
- intercept_agent.cfg: Agent configuration template
- routes/controller.py: Controller API endpoints
- utils/agent_client.py: HTTP client for agents
- utils/trilateration.py: Multi-agent position calculation
- static/js/core/agents.js: Frontend agent management
- templates/agents.html: Agent management page
- docs/DISTRIBUTED_AGENTS.md: System documentation

Modified:
- app.py: Register controller blueprint
- utils/database.py: Add agents and push_payloads tables
- templates/index.html: Add agent selector section
This commit is contained in:
cemaxecuter
2026-01-26 06:14:42 -05:00
parent ada6d5f1f1
commit f980e2e76d
20 changed files with 8809 additions and 19 deletions

321
static/css/agents.css Normal file
View File

@@ -0,0 +1,321 @@
/*
* Agents Management CSS
* Styles for the remote agent management interface
*/
/* CSS Variables (inherited from main theme) */
:root {
--bg-primary: #0a0a0f;
--bg-secondary: #12121a;
--text-primary: #e0e0e0;
--text-secondary: #888;
--border-color: #1a1a2e;
--accent-cyan: #00d4ff;
--accent-green: #00ff88;
--accent-red: #ff3366;
--accent-orange: #ff9f1c;
}
/* Agent indicator in navigation */
.agent-indicator {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: rgba(0, 212, 255, 0.1);
border: 1px solid rgba(0, 212, 255, 0.3);
border-radius: 20px;
cursor: pointer;
transition: all 0.2s;
}
.agent-indicator:hover {
background: rgba(0, 212, 255, 0.2);
border-color: var(--accent-cyan);
}
.agent-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-indicator-dot.remote {
background: var(--accent-cyan);
box-shadow: 0 0 6px var(--accent-cyan);
}
.agent-indicator-dot.multiple {
background: var(--accent-orange);
box-shadow: 0 0 6px var(--accent-orange);
}
.agent-indicator-label {
font-size: 11px;
color: var(--text-primary);
font-family: 'JetBrains Mono', monospace;
}
.agent-indicator-count {
font-size: 10px;
padding: 2px 6px;
background: rgba(0, 212, 255, 0.2);
border-radius: 10px;
color: var(--accent-cyan);
}
/* Agent selector dropdown */
.agent-selector {
position: relative;
}
.agent-selector-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
min-width: 280px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1000;
display: none;
}
.agent-selector-dropdown.show {
display: block;
}
.agent-selector-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 15px;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-header h4 {
margin: 0;
font-size: 12px;
color: var(--accent-cyan);
text-transform: uppercase;
letter-spacing: 1px;
}
.agent-selector-manage {
font-size: 11px;
color: var(--accent-cyan);
text-decoration: none;
}
.agent-selector-manage:hover {
text-decoration: underline;
}
.agent-selector-list {
max-height: 300px;
overflow-y: auto;
}
.agent-selector-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 15px;
cursor: pointer;
transition: background 0.2s;
border-bottom: 1px solid var(--border-color);
}
.agent-selector-item:last-child {
border-bottom: none;
}
.agent-selector-item:hover {
background: rgba(0, 212, 255, 0.1);
}
.agent-selector-item.selected {
background: rgba(0, 212, 255, 0.15);
border-left: 3px solid var(--accent-cyan);
}
.agent-selector-item.local {
border-left: 3px solid var(--accent-green);
}
.agent-selector-item-status {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-selector-item-status.online {
background: var(--accent-green);
}
.agent-selector-item-status.offline {
background: var(--accent-red);
}
.agent-selector-item-info {
flex: 1;
min-width: 0;
}
.agent-selector-item-name {
font-size: 13px;
color: var(--text-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-url {
font-size: 10px;
color: var(--text-secondary);
font-family: 'JetBrains Mono', monospace;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.agent-selector-item-check {
color: var(--accent-green);
opacity: 0;
}
.agent-selector-item.selected .agent-selector-item-check {
opacity: 1;
}
/* Agent badge in data displays */
.agent-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 8px;
font-size: 10px;
background: rgba(0, 212, 255, 0.1);
color: var(--accent-cyan);
border-radius: 10px;
font-family: 'JetBrains Mono', monospace;
}
.agent-badge.local {
background: rgba(0, 255, 136, 0.1);
color: var(--accent-green);
}
.agent-badge-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
/* Agent column in data tables */
.data-table .agent-col {
width: 120px;
max-width: 120px;
}
/* Multi-agent stream indicator */
.multi-agent-indicator {
position: fixed;
bottom: 20px;
left: 20px;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 20px;
font-size: 11px;
color: var(--text-secondary);
z-index: 100;
}
.multi-agent-indicator.active {
border-color: var(--accent-cyan);
color: var(--accent-cyan);
}
.multi-agent-indicator-pulse {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--accent-cyan);
animation: pulse 2s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(0.8); }
}
/* Agent connection status toast */
.agent-toast {
position: fixed;
top: 80px;
right: 20px;
padding: 10px 15px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 6px;
font-size: 12px;
z-index: 1001;
animation: slideInRight 0.3s ease;
}
.agent-toast.connected {
border-color: var(--accent-green);
color: var(--accent-green);
}
.agent-toast.disconnected {
border-color: var(--accent-red);
color: var(--accent-red);
}
@keyframes slideInRight {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Responsive adjustments */
@media (max-width: 768px) {
.agent-indicator {
padding: 4px 8px;
}
.agent-indicator-label {
display: none;
}
.agent-selector-dropdown {
position: fixed;
top: auto;
bottom: 0;
left: 0;
right: 0;
margin: 0;
border-radius: 16px 16px 0 0;
max-height: 60vh;
}
.agents-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1840,6 +1840,27 @@ header h1 .tagline {
letter-spacing: 1px;
}
/* Agent status indicator */
.agent-status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.agent-status-dot.online {
background: var(--accent-green);
box-shadow: 0 0 6px var(--accent-green);
}
.agent-status-dot.offline {
background: var(--accent-red);
}
.agent-status-dot.unknown {
background: #666;
}
.header-controls {
display: flex;
align-items: center;

450
static/js/core/agents.js Normal file
View File

@@ -0,0 +1,450 @@
/**
* Intercept - Agent Manager
* Handles remote agent selection and API routing
*/
// ============== AGENT STATE ==============
let agents = [];
let currentAgent = 'local';
let agentEventSource = null;
let multiAgentMode = false; // Show combined results from all agents
let multiAgentPollInterval = null;
// ============== AGENT LOADING ==============
async function loadAgents() {
try {
const response = await fetch('/controller/agents');
const data = await response.json();
agents = data.agents || [];
updateAgentSelector();
return agents;
} catch (error) {
console.error('Failed to load agents:', error);
agents = [];
updateAgentSelector();
return [];
}
}
function updateAgentSelector() {
const selector = document.getElementById('agentSelect');
if (!selector) return;
// Keep current selection if possible
const currentValue = selector.value;
// Clear and rebuild options
selector.innerHTML = '<option value="local">Local (This Device)</option>';
agents.forEach(agent => {
const option = document.createElement('option');
option.value = agent.id;
const status = agent.healthy !== false ? '●' : '○';
option.textContent = `${status} ${agent.name}`;
option.dataset.baseUrl = agent.base_url;
option.dataset.healthy = agent.healthy !== false;
selector.appendChild(option);
});
// Restore selection if still valid
if (currentValue && selector.querySelector(`option[value="${currentValue}"]`)) {
selector.value = currentValue;
}
updateAgentStatus();
}
function updateAgentStatus() {
const selector = document.getElementById('agentSelect');
const statusDot = document.getElementById('agentStatusDot');
const statusText = document.getElementById('agentStatusText');
if (!selector || !statusDot) return;
if (currentAgent === 'local') {
statusDot.className = 'agent-status-dot online';
if (statusText) statusText.textContent = 'Local';
} else {
const agent = agents.find(a => a.id == currentAgent);
if (agent) {
const isOnline = agent.healthy !== false;
statusDot.className = `agent-status-dot ${isOnline ? 'online' : 'offline'}`;
if (statusText) statusText.textContent = isOnline ? 'Connected' : 'Offline';
}
}
}
// ============== AGENT SELECTION ==============
function selectAgent(agentId) {
currentAgent = agentId;
updateAgentStatus();
// Update device list based on selected agent
if (agentId === 'local') {
// Use local devices - call refreshDevices if it exists (defined in main page)
if (typeof refreshDevices === 'function') {
refreshDevices();
}
console.log('Agent selected: Local');
} else {
// Fetch devices from remote agent
refreshAgentDevices(agentId);
const agentName = agents.find(a => a.id == agentId)?.name || 'Unknown';
console.log(`Agent selected: ${agentName}`);
// Show visual feedback
const statusText = document.getElementById('agentStatusText');
if (statusText) {
statusText.textContent = `Loading ${agentName}...`;
setTimeout(() => updateAgentStatus(), 2000);
}
}
}
async function refreshAgentDevices(agentId) {
console.log(`Refreshing devices for agent ${agentId}...`);
try {
const response = await fetch(`/controller/agents/${agentId}?refresh=true`, {
credentials: 'same-origin'
});
const data = await response.json();
console.log('Agent data received:', data);
if (data.agent && data.agent.interfaces) {
const devices = data.agent.interfaces.devices || [];
console.log(`Found ${devices.length} devices on agent`);
populateDeviceSelect(devices);
// Update SDR type dropdown if device has sdr_type
if (devices.length > 0 && devices[0].sdr_type) {
const sdrTypeSelect = document.getElementById('sdrTypeSelect');
if (sdrTypeSelect) {
sdrTypeSelect.value = devices[0].sdr_type;
}
}
} else {
console.warn('No interfaces found in agent data');
}
} catch (error) {
console.error('Failed to refresh agent devices:', error);
}
}
function populateDeviceSelect(devices) {
const select = document.getElementById('deviceSelect');
if (!select) return;
select.innerHTML = '';
if (devices.length === 0) {
const option = document.createElement('option');
option.value = '0';
option.textContent = 'No devices found';
select.appendChild(option);
} else {
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.index;
option.dataset.sdrType = device.sdr_type || 'rtlsdr';
option.textContent = `${device.index}: ${device.name}`;
select.appendChild(option);
});
}
}
// ============== API ROUTING ==============
/**
* Route an API call to local or remote agent based on current selection.
* @param {string} localPath - Local API path (e.g., '/sensor/start')
* @param {Object} options - Fetch options
* @returns {Promise<Response>}
*/
async function agentFetch(localPath, options = {}) {
if (currentAgent === 'local') {
return fetch(localPath, options);
}
// Route through controller proxy
const proxyPath = `/controller/agents/${currentAgent}${localPath}`;
return fetch(proxyPath, options);
}
/**
* Start a mode on the selected agent.
* @param {string} mode - Mode name (pager, sensor, adsb, wifi, etc.)
* @param {Object} params - Mode parameters
* @returns {Promise<Object>}
*/
async function agentStartMode(mode, params = {}) {
const path = `/${mode}/start`;
const options = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(params)
};
try {
const response = await agentFetch(path, options);
return await response.json();
} catch (error) {
console.error(`Failed to start ${mode} on agent:`, error);
throw error;
}
}
/**
* Stop a mode on the selected agent.
* @param {string} mode - Mode name
* @returns {Promise<Object>}
*/
async function agentStopMode(mode) {
const path = `/${mode}/stop`;
const options = { method: 'POST' };
try {
const response = await agentFetch(path, options);
return await response.json();
} catch (error) {
console.error(`Failed to stop ${mode} on agent:`, error);
throw error;
}
}
/**
* Get data from a mode on the selected agent.
* @param {string} mode - Mode name
* @returns {Promise<Object>}
*/
async function agentGetData(mode) {
const path = `/${mode}/data`;
try {
const response = await agentFetch(path);
return await response.json();
} catch (error) {
console.error(`Failed to get ${mode} data from agent:`, error);
throw error;
}
}
// ============== SSE STREAM ==============
/**
* Connect to SSE stream (local or multi-agent).
* @param {string} mode - Mode name for the stream
* @param {function} onMessage - Callback for messages
* @returns {EventSource}
*/
function connectAgentStream(mode, onMessage) {
// Close existing connection
if (agentEventSource) {
agentEventSource.close();
}
let streamUrl;
if (currentAgent === 'local') {
streamUrl = `/${mode}/stream`;
} else {
// For remote agents, we could either:
// 1. Use the multi-agent stream: /controller/stream/all
// 2. Or proxy through controller (not implemented yet)
// For now, use multi-agent stream which includes agent_name tagging
streamUrl = '/controller/stream/all';
}
agentEventSource = new EventSource(streamUrl);
agentEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// If using multi-agent stream, filter by current agent if needed
if (streamUrl === '/controller/stream/all' && currentAgent !== 'local') {
const agent = agents.find(a => a.id == currentAgent);
if (agent && data.agent_name && data.agent_name !== agent.name) {
return; // Skip messages from other agents
}
}
onMessage(data);
} catch (e) {
console.error('Error parsing SSE message:', e);
}
};
agentEventSource.onerror = (error) => {
console.error('SSE connection error:', error);
};
return agentEventSource;
}
function disconnectAgentStream() {
if (agentEventSource) {
agentEventSource.close();
agentEventSource = null;
}
}
// ============== INITIALIZATION ==============
function initAgentManager() {
// Load agents on page load
loadAgents();
// Set up agent selector change handler
const selector = document.getElementById('agentSelect');
if (selector) {
selector.addEventListener('change', (e) => {
selectAgent(e.target.value);
});
}
// Refresh agents periodically
setInterval(loadAgents, 30000);
}
// ============== MULTI-AGENT MODE ==============
/**
* Toggle multi-agent mode to show combined results from all agents.
*/
function toggleMultiAgentMode() {
const checkbox = document.getElementById('showAllAgents');
multiAgentMode = checkbox ? checkbox.checked : false;
const selector = document.getElementById('agentSelect');
const statusText = document.getElementById('agentStatusText');
if (multiAgentMode) {
// Disable individual agent selection
if (selector) selector.disabled = true;
if (statusText) statusText.textContent = 'All Agents';
// Connect to multi-agent stream
connectMultiAgentStream();
console.log('Multi-agent mode enabled - showing all agents');
} else {
// Re-enable individual selection
if (selector) selector.disabled = false;
updateAgentStatus();
// Disconnect multi-agent stream
disconnectMultiAgentStream();
console.log('Multi-agent mode disabled');
}
}
/**
* Connect to the combined multi-agent SSE stream.
*/
function connectMultiAgentStream() {
disconnectMultiAgentStream();
agentEventSource = new EventSource('/controller/stream/all');
agentEventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// Skip keepalive messages
if (data.type === 'keepalive') return;
// Route to appropriate handler based on scan_type
handleMultiAgentData(data);
} catch (e) {
console.error('Error parsing multi-agent SSE:', e);
}
};
agentEventSource.onerror = (error) => {
console.error('Multi-agent SSE error:', error);
};
}
function disconnectMultiAgentStream() {
if (agentEventSource) {
agentEventSource.close();
agentEventSource = null;
}
if (multiAgentPollInterval) {
clearInterval(multiAgentPollInterval);
multiAgentPollInterval = null;
}
}
/**
* Handle data from multi-agent stream and route to display.
*/
function handleMultiAgentData(data) {
const agentName = data.agent_name || 'Unknown';
const scanType = data.scan_type;
const payload = data.payload;
// Add agent badge to the data for display
if (payload) {
payload._agent = agentName;
}
// Route based on scan type
switch (scanType) {
case 'sensor':
if (payload && payload.sensors) {
payload.sensors.forEach(sensor => {
sensor._agent = agentName;
if (typeof displaySensorMessage === 'function') {
displaySensorMessage(sensor);
}
});
}
break;
case 'pager':
if (payload && payload.messages) {
payload.messages.forEach(msg => {
msg._agent = agentName;
// Display pager message if handler exists
if (typeof addPagerMessage === 'function') {
addPagerMessage(msg);
}
});
}
break;
case 'adsb':
if (payload && payload.aircraft) {
Object.values(payload.aircraft).forEach(ac => {
ac._agent = agentName;
// Update aircraft display if handler exists
if (typeof updateAircraft === 'function') {
updateAircraft(ac);
}
});
}
break;
case 'wifi':
if (payload && payload.networks) {
Object.values(payload.networks).forEach(net => {
net._agent = agentName;
});
// Update WiFi display if handler exists
if (typeof WiFiMode !== 'undefined' && WiFiMode.updateNetworks) {
WiFiMode.updateNetworks(payload.networks);
}
}
break;
default:
console.log(`Multi-agent data from ${agentName}: ${scanType}`, payload);
}
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', initAgentManager);