mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 22:59:59 -07:00
- Add help modal system with keyboard shortcuts reference - Add Main Dashboard button in navigation bar - Make settings modal accessible from all dashboards - Dashboard CSS improvements and consistency fixes Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
569 lines
20 KiB
HTML
569 lines
20 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>iNTERCEPT // Remote Agents</title>
|
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/base.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/global-nav.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/agents.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/help-modal.css') }}">
|
|
<style>
|
|
.agents-container {
|
|
max-width: 1200px;
|
|
margin: 0 auto;
|
|
padding: 20px;
|
|
}
|
|
|
|
.agents-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 30px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid var(--border-color);
|
|
}
|
|
|
|
.agents-header h1 {
|
|
font-size: 24px;
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.agents-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
|
|
gap: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.agent-card {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
transition: border-color 0.2s;
|
|
}
|
|
|
|
.agent-card:hover {
|
|
border-color: var(--accent-cyan);
|
|
}
|
|
|
|
.agent-card.offline {
|
|
opacity: 0.7;
|
|
}
|
|
|
|
.agent-card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: flex-start;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.agent-name {
|
|
font-size: 18px;
|
|
font-weight: bold;
|
|
color: var(--text-primary);
|
|
}
|
|
|
|
.agent-status {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
font-size: 12px;
|
|
}
|
|
|
|
.agent-status-dot {
|
|
width: 8px;
|
|
height: 8px;
|
|
border-radius: 50%;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.agent-url {
|
|
font-size: 12px;
|
|
color: #888;
|
|
font-family: var(--font-mono);
|
|
margin-bottom: 10px;
|
|
}
|
|
|
|
.agent-capabilities {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 6px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.capability-badge {
|
|
font-size: 10px;
|
|
padding: 2px 8px;
|
|
border-radius: 10px;
|
|
background: rgba(0, 212, 255, 0.1);
|
|
color: var(--accent-cyan);
|
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
|
}
|
|
|
|
.capability-badge.disabled {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: #666;
|
|
border-color: #333;
|
|
}
|
|
|
|
.agent-meta {
|
|
font-size: 11px;
|
|
color: #666;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.agent-actions {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
|
|
.agent-btn {
|
|
flex: 1;
|
|
padding: 8px 12px;
|
|
font-size: 12px;
|
|
border: 1px solid var(--border-color);
|
|
background: transparent;
|
|
color: var(--text-primary);
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.agent-btn:hover {
|
|
border-color: var(--accent-cyan);
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.agent-btn.danger:hover {
|
|
border-color: var(--accent-red);
|
|
color: var(--accent-red);
|
|
}
|
|
|
|
.agent-btn.primary {
|
|
background: var(--accent-cyan);
|
|
color: #000;
|
|
border-color: var(--accent-cyan);
|
|
}
|
|
|
|
.agent-btn.primary:hover {
|
|
background: #00b8d9;
|
|
}
|
|
|
|
/* Add Agent Form */
|
|
.add-agent-section {
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 8px;
|
|
padding: 20px;
|
|
margin-bottom: 30px;
|
|
}
|
|
|
|
.add-agent-section h2 {
|
|
font-size: 16px;
|
|
margin-bottom: 15px;
|
|
color: var(--accent-cyan);
|
|
}
|
|
|
|
.form-row {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 15px;
|
|
}
|
|
|
|
.form-group {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 5px;
|
|
}
|
|
|
|
.form-group label {
|
|
font-size: 12px;
|
|
color: #888;
|
|
}
|
|
|
|
.form-group input {
|
|
padding: 10px;
|
|
background: var(--bg-primary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 4px;
|
|
color: var(--text-primary);
|
|
font-family: var(--font-mono);
|
|
}
|
|
|
|
.form-group input:focus {
|
|
outline: none;
|
|
border-color: var(--accent-cyan);
|
|
}
|
|
|
|
/* Empty state */
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 60px 20px;
|
|
color: #666;
|
|
}
|
|
|
|
.empty-state-icon {
|
|
font-size: 48px;
|
|
margin-bottom: 15px;
|
|
opacity: 0.5;
|
|
}
|
|
|
|
/* Toast notifications */
|
|
.toast {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
right: 20px;
|
|
padding: 12px 20px;
|
|
background: var(--bg-secondary);
|
|
border: 1px solid var(--border-color);
|
|
border-radius: 6px;
|
|
color: var(--text-primary);
|
|
font-size: 14px;
|
|
z-index: 1000;
|
|
animation: slideIn 0.3s ease;
|
|
}
|
|
|
|
.toast.success {
|
|
border-color: var(--accent-green);
|
|
}
|
|
|
|
.toast.error {
|
|
border-color: var(--accent-red);
|
|
}
|
|
|
|
@keyframes slideIn {
|
|
from {
|
|
transform: translateX(100%);
|
|
opacity: 0;
|
|
}
|
|
to {
|
|
transform: translateX(0);
|
|
opacity: 1;
|
|
}
|
|
}
|
|
</style>
|
|
</head>
|
|
|
|
<body>
|
|
<header style="padding: 15px 20px; display: flex; align-items: center; gap: 12px;">
|
|
<div class="logo">
|
|
<svg width="40" height="40" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
<path d="M15 30 Q5 50, 15 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
|
<path d="M22 35 Q14 50, 22 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
|
<path d="M29 40 Q23 50, 29 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
|
<path d="M85 30 Q95 50, 85 70" stroke="#00d4ff" stroke-width="3" fill="none" stroke-linecap="round" opacity="0.5"/>
|
|
<path d="M78 35 Q86 50, 78 65" stroke="#00d4ff" stroke-width="2.5" fill="none" stroke-linecap="round" opacity="0.7"/>
|
|
<path d="M71 40 Q77 50, 71 60" stroke="#00d4ff" stroke-width="2" fill="none" stroke-linecap="round"/>
|
|
<circle cx="50" cy="22" r="6" fill="#00ff88"/>
|
|
<rect x="44" y="35" width="12" height="45" rx="2" fill="#00d4ff"/>
|
|
<rect x="38" y="35" width="24" height="4" rx="1" fill="#00d4ff"/>
|
|
<rect x="38" y="76" width="24" height="4" rx="1" fill="#00d4ff"/>
|
|
</svg>
|
|
</div>
|
|
<h1 style="margin: 0;">
|
|
iNTERCEPT <span class="tagline">// Remote Agents</span>
|
|
</h1>
|
|
</header>
|
|
|
|
{% include 'partials/nav.html' with context %}
|
|
|
|
<div class="agents-container">
|
|
<div class="agents-header">
|
|
<h1>Remote Agents</h1>
|
|
</div>
|
|
|
|
<!-- Add Agent Form -->
|
|
<div class="add-agent-section">
|
|
<h2>Register New Agent</h2>
|
|
<form id="addAgentForm" onsubmit="return addAgent(event)">
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="agentName">Agent Name *</label>
|
|
<input type="text" id="agentName" placeholder="sensor-node-1" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="agentUrl">Base URL *</label>
|
|
<input type="url" id="agentUrl" placeholder="http://192.168.1.50:8020" required>
|
|
</div>
|
|
<div class="form-group">
|
|
<label for="agentApiKey">API Key (optional)</label>
|
|
<input type="text" id="agentApiKey" placeholder="shared-secret">
|
|
<small style="color: #888; font-size: 11px;">Required if agent has push mode enabled with API key</small>
|
|
</div>
|
|
</div>
|
|
<div class="form-row">
|
|
<div class="form-group">
|
|
<label for="agentDescription">Description (optional)</label>
|
|
<input type="text" id="agentDescription" placeholder="Rooftop sensor node">
|
|
</div>
|
|
</div>
|
|
<div style="display: flex; gap: 10px;">
|
|
<button type="submit" class="agent-btn primary" style="width: auto; padding: 10px 24px;">
|
|
Register Agent
|
|
</button>
|
|
<button type="button" class="agent-btn" style="width: auto; padding: 10px 24px;" onclick="refreshAllAgents()">
|
|
Refresh All
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
|
|
<!-- Agents Grid -->
|
|
<div id="agentsGrid" class="agents-grid">
|
|
<!-- Populated by JavaScript -->
|
|
</div>
|
|
|
|
<div id="emptyState" class="empty-state" style="display: none;">
|
|
<div class="empty-state-icon">
|
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
<rect x="2" y="2" width="20" height="8" rx="2"/>
|
|
<rect x="2" y="14" width="20" height="8" rx="2"/>
|
|
<line x1="6" y1="6" x2="6.01" y2="6"/>
|
|
<line x1="6" y1="18" x2="6.01" y2="18"/>
|
|
</svg>
|
|
</div>
|
|
<h3>No Remote Agents</h3>
|
|
<p>Register your first remote agent to get started with distributed signal intelligence.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
// Agent management functions
|
|
let agents = [];
|
|
|
|
async function loadAgents() {
|
|
try {
|
|
const response = await fetch('/controller/agents?refresh=true', {
|
|
credentials: 'same-origin'
|
|
});
|
|
const data = await response.json();
|
|
agents = data.agents || [];
|
|
renderAgents();
|
|
} catch (error) {
|
|
console.error('Failed to load agents:', error);
|
|
showToast('Failed to load agents', 'error');
|
|
}
|
|
}
|
|
|
|
function renderAgents() {
|
|
const grid = document.getElementById('agentsGrid');
|
|
const emptyState = document.getElementById('emptyState');
|
|
|
|
if (agents.length === 0) {
|
|
grid.innerHTML = '';
|
|
emptyState.style.display = 'block';
|
|
return;
|
|
}
|
|
|
|
emptyState.style.display = 'none';
|
|
|
|
grid.innerHTML = agents.map(agent => {
|
|
const isOnline = agent.healthy !== false && agent.is_active;
|
|
const statusClass = isOnline ? 'online' : 'offline';
|
|
const statusText = isOnline ? 'Online' : 'Offline';
|
|
|
|
const capabilities = agent.capabilities || {};
|
|
const capBadges = Object.entries(capabilities)
|
|
.filter(([k, v]) => v === true)
|
|
.map(([k]) => `<span class="capability-badge">${k}</span>`)
|
|
.join('');
|
|
|
|
const lastSeen = agent.last_seen
|
|
? new Date(agent.last_seen).toLocaleString()
|
|
: 'Never';
|
|
|
|
return `
|
|
<div class="agent-card ${statusClass}">
|
|
<div class="agent-card-header">
|
|
<span class="agent-name">${escapeHtml(agent.name)}</span>
|
|
<span class="agent-status">
|
|
<span class="agent-status-dot ${statusClass}"></span>
|
|
${statusText}
|
|
</span>
|
|
</div>
|
|
<div class="agent-url">${escapeHtml(agent.base_url)}</div>
|
|
<div class="agent-capabilities">
|
|
${capBadges || '<span class="capability-badge disabled">No capabilities detected</span>'}
|
|
</div>
|
|
<div class="agent-meta">
|
|
Last seen: ${lastSeen}<br>
|
|
${agent.description ? `Note: ${escapeHtml(agent.description)}` : ''}
|
|
</div>
|
|
<div class="agent-actions">
|
|
<button class="agent-btn" onclick="refreshAgent(${agent.id})">Refresh</button>
|
|
<button class="agent-btn" onclick="testAgent(${agent.id})">Test</button>
|
|
<button class="agent-btn danger" onclick="deleteAgent(${agent.id}, '${escapeHtml(agent.name)}')">Remove</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
}
|
|
|
|
async function addAgent(event) {
|
|
event.preventDefault();
|
|
|
|
const name = document.getElementById('agentName').value.trim();
|
|
const baseUrl = document.getElementById('agentUrl').value.trim();
|
|
const apiKey = document.getElementById('agentApiKey').value.trim();
|
|
const description = document.getElementById('agentDescription').value.trim();
|
|
|
|
// Validate URL format
|
|
try {
|
|
const url = new URL(baseUrl);
|
|
if (!url.port && !baseUrl.includes(':80') && !baseUrl.includes(':443')) {
|
|
showToast('URL should include a port (e.g., http://192.168.1.50:8020)', 'error');
|
|
return;
|
|
}
|
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
showToast('URL must start with http:// or https://', 'error');
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
showToast('Invalid URL format. Use: http://IP_ADDRESS:PORT', 'error');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/controller/agents', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
name,
|
|
base_url: baseUrl,
|
|
api_key: apiKey || null,
|
|
description: description || null
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast(`Agent "${name}" registered successfully`, 'success');
|
|
document.getElementById('addAgentForm').reset();
|
|
loadAgents();
|
|
} else {
|
|
showToast(data.message || 'Failed to register agent', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error adding agent:', error);
|
|
showToast('Failed to register agent', 'error');
|
|
}
|
|
}
|
|
|
|
async function refreshAgent(agentId) {
|
|
try {
|
|
const response = await fetch(`/controller/agents/${agentId}/refresh`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
showToast('Agent refreshed', 'success');
|
|
loadAgents();
|
|
} else {
|
|
showToast(data.message || 'Failed to refresh agent', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to refresh agent', 'error');
|
|
}
|
|
}
|
|
|
|
async function refreshAllAgents() {
|
|
showToast('Refreshing all agents...', 'info');
|
|
await loadAgents();
|
|
showToast('All agents refreshed', 'success');
|
|
}
|
|
|
|
async function testAgent(agentId) {
|
|
const agent = agents.find(a => a.id === agentId);
|
|
if (!agent) return;
|
|
|
|
try {
|
|
const response = await fetch(`/controller/agents/${agentId}?refresh=true`);
|
|
const data = await response.json();
|
|
|
|
if (data.agent && data.agent.healthy !== false) {
|
|
showToast(`Agent "${agent.name}" is responding`, 'success');
|
|
} else {
|
|
showToast(`Agent "${agent.name}" is not responding`, 'error');
|
|
}
|
|
loadAgents();
|
|
} catch (error) {
|
|
showToast(`Cannot reach agent "${agent.name}"`, 'error');
|
|
}
|
|
}
|
|
|
|
async function deleteAgent(agentId, agentName) {
|
|
if (!confirm(`Are you sure you want to remove agent "${agentName}"?`)) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/controller/agents/${agentId}`, {
|
|
method: 'DELETE'
|
|
});
|
|
|
|
if (response.ok) {
|
|
showToast(`Agent "${agentName}" removed`, 'success');
|
|
loadAgents();
|
|
} else {
|
|
showToast('Failed to remove agent', 'error');
|
|
}
|
|
} catch (error) {
|
|
showToast('Failed to remove agent', 'error');
|
|
}
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
// Remove existing toasts
|
|
document.querySelectorAll('.toast').forEach(t => t.remove());
|
|
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast ${type}`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => toast.remove(), 3000);
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
const div = document.createElement('div');
|
|
div.textContent = text;
|
|
return div.innerHTML;
|
|
}
|
|
|
|
// Load agents on page load
|
|
document.addEventListener('DOMContentLoaded', loadAgents);
|
|
</script>
|
|
|
|
<!-- Settings Modal -->
|
|
{% include 'partials/settings-modal.html' %}
|
|
|
|
<!-- Help Modal -->
|
|
{% include 'partials/help-modal.html' %}
|
|
|
|
<script src="{{ url_for('static', filename='js/core/settings-manager.js') }}"></script>
|
|
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></script>
|
|
</body>
|
|
</html>
|