SGO/templates/index.html
Eduardo Figueroa 6886c8871c
Initial Commit
2025-11-20 12:03:30 -08:00

1248 lines
59 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SGO</title>
<link rel="icon" href="{{ url_for('static', filename='images/logo.svg') }}" type="image/svg+xml">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
</head>
<body>
<header>
<div class="header-content">
<div style="display: flex; justify-content: space-between; align-items: center;">
<div>
<h1>🔭 SGO: Security Groups (and Instances) Observatory</h1>
<p class="subtitle">Search and explore EC2 instances and Security Groups</p>
</div>
<div style="display: flex; gap: 0.5rem;">
<button onclick="window.location.href='/'" style="padding: 0.5rem 1rem; background: #64748b; color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;" title="Change AWS profiles">
Change Profiles
</button>
<button onclick="refreshData()" id="refreshBtn" style="padding: 0.5rem 1rem; background: var(--primary-color); color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;">
Refresh Data
</button>
</div>
</div>
</div>
</header>
<div class="container">
<div class="stats-bar" id="statsBar">
<div class="stat-item">
<span class="stat-label">Security Groups</span>
<span class="stat-value" id="sgCount">-</span>
</div>
<div class="stat-item">
<span class="stat-label">EC2 Instances</span>
<span class="stat-value" id="ec2Count">-</span>
</div>
<div class="stat-item">
<span class="stat-label">Accounts</span>
<span class="stat-value" id="accountCount">-</span>
</div>
<div class="stat-item" id="lastRefreshStat" style="position: relative;">
<span class="stat-label">Last Refresh</span>
<span class="stat-value" id="lastRefreshTime">-</span>
<div class="refresh-tooltip" id="refreshTooltip"></div>
</div>
</div>
<div class="search-section">
<div class="tag-filters">
<div class="tag-filter-group">
<label class="tag-filter-label">Wave</label>
<select id="waveFilter" class="tag-filter-select">
<option value="">All Waves</option>
</select>
</div>
<div class="tag-filter-group">
<label class="tag-filter-label">Git Repo</label>
<select id="repoFilter" class="tag-filter-select">
<option value="">All Repos</option>
</select>
</div>
<button class="clear-filters-btn" id="clearFiltersBtn">Clear Filters</button>
</div>
<div class="search-box">
<input
type="text"
id="searchInput"
class="search-input"
placeholder="Search by name, ID, or IP address..."
autocomplete="off"
>
<label class="regex-checkbox">
<input type="checkbox" id="regexCheckbox">
<span>Regex</span>
</label>
<label class="regex-checkbox">
<input type="checkbox" id="caseInsensitiveCheckbox" checked>
<span>Case Insensitive</span>
</label>
<label class="regex-checkbox">
<input type="checkbox" id="fuzzyCheckbox">
<span>Fuzzy Search</span>
</label>
</div>
<div class="view-controls">
<div class="filter-buttons">
<button class="filter-btn active" data-filter="all">All Resources</button>
<button class="filter-btn" data-filter="ec2">EC2 Instances</button>
<button class="filter-btn" data-filter="sg">Security Groups</button>
</div>
<div class="view-toggle">
<button class="view-toggle-btn active" data-view="cards">Cards</button>
<button class="view-toggle-btn" data-view="table">Table</button>
<button class="view-toggle-btn" onclick="exportSearchResults()" style="background: var(--primary-color); color: white; margin-left: 0.5rem;" title="Export to CSV">Export</button>
</div>
</div>
</div>
<div class="search-results" id="searchResults"></div>
<div class="results-table-view" id="resultsTableView">
<div class="results-table-container">
<table class="results-table">
<thead>
<tr>
<th onclick="sortTable('type')" style="cursor: pointer;">Type <span id="sort-type"></span></th>
<th onclick="sortTable('name')" style="cursor: pointer;">Name <span id="sort-name"></span></th>
<th onclick="sortTable('id')" style="cursor: pointer;">ID <span id="sort-id"></span></th>
<th onclick="sortTable('account_name')" style="cursor: pointer;">Account <span id="sort-account_name"></span></th>
<th>Source</th>
<th onclick="sortTable('tag_wave')" style="cursor: pointer;">Wave <span id="sort-tag_wave"></span></th>
<th>State/Rules/SGs</th>
<th onclick="sortTable('private_ip_address')" style="cursor: pointer;">IP Address <span id="sort-private_ip_address"></span></th>
</tr>
</thead>
<tbody id="tableResultsBody"></tbody>
</table>
</div>
</div>
<div class="details-view" id="detailsView"></div>
</div>
<script>
let currentFilter = 'all';
let currentView = 'cards';
let searchTimeout = null;
let currentResults = [];
let sortColumn = null;
let sortDirection = 'asc';
function buildGitHubUrl(gitOrg, gitRepo, gitFile) {
if (!gitOrg || !gitRepo || !gitFile) {
return null;
}
return `https://github.com/${gitOrg}/${gitRepo}/blob/main/${gitFile}`;
}
function hashString(str) {
// Simple hash function to generate a consistent number from a string
let hash = 0;
for (let i = 0; i < str.length; i++) {
const char = str.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return Math.abs(hash);
}
function getColorFromName(name) {
if (!name) return '#64748b'; // Default gray
const hash = hashString(name);
// Generate HSL color with consistent hue, good saturation, and readable lightness
const hue = hash % 360;
const saturation = 65 + (hash % 20); // 65-85%
const lightness = 45 + (hash % 15); // 45-60%
return `hsl(${hue}, ${saturation}%, ${lightness}%)`;
}
function getAccountClass(accountName) {
// No longer needed for classes, but keeping for backwards compatibility
return '';
}
function getAccountStyle(accountName) {
if (!accountName) return '';
return `color: ${getColorFromName(accountName)}; font-weight: 600;`;
}
// Load stats and tags on page load
loadStats();
loadTags();
// Load all resources on page load
performSearch('');
// Search input handler
document.getElementById('searchInput').addEventListener('input', function(e) {
clearTimeout(searchTimeout);
const query = e.target.value.trim();
searchTimeout = setTimeout(() => {
performSearch(query);
}, 300);
});
// Search option checkbox handlers
document.getElementById('regexCheckbox').addEventListener('change', function() {
if (this.checked) {
document.getElementById('fuzzyCheckbox').checked = false;
}
const query = document.getElementById('searchInput').value.trim();
performSearch(query);
});
document.getElementById('caseInsensitiveCheckbox').addEventListener('change', function() {
const query = document.getElementById('searchInput').value.trim();
performSearch(query);
});
document.getElementById('fuzzyCheckbox').addEventListener('change', function() {
if (this.checked) {
document.getElementById('regexCheckbox').checked = false;
}
const query = document.getElementById('searchInput').value.trim();
performSearch(query);
});
// Filter button handlers
document.querySelectorAll('.filter-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
currentFilter = this.dataset.filter;
const query = document.getElementById('searchInput').value.trim();
performSearch(query);
});
});
// View toggle handlers
document.querySelectorAll('.view-toggle-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.view-toggle-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
currentView = this.dataset.view;
toggleView();
});
});
// Tag filter handlers
document.getElementById('waveFilter').addEventListener('change', function() {
performSearch(document.getElementById('searchInput').value.trim());
});
document.getElementById('repoFilter').addEventListener('change', function() {
performSearch(document.getElementById('searchInput').value.trim());
});
// Clear filters button
document.getElementById('clearFiltersBtn').addEventListener('click', function() {
document.getElementById('waveFilter').value = '';
document.getElementById('repoFilter').value = '';
performSearch(document.getElementById('searchInput').value.trim());
});
function toggleView() {
const resultsDiv = document.getElementById('searchResults');
const tableDiv = document.getElementById('resultsTableView');
if (currentView === 'table') {
resultsDiv.style.display = 'none';
tableDiv.classList.add('active');
} else {
resultsDiv.style.display = 'block';
tableDiv.classList.remove('active');
}
}
async function loadTags() {
try {
const response = await fetch('/api/tags');
const data = await response.json();
const waveFilter = document.getElementById('waveFilter');
const repoFilter = document.getElementById('repoFilter');
data.waves.forEach(wave => {
const option = document.createElement('option');
option.value = wave;
option.textContent = wave;
waveFilter.appendChild(option);
});
data.repos.forEach(repo => {
const option = document.createElement('option');
option.value = repo;
option.textContent = repo;
repoFilter.appendChild(option);
});
} catch (error) {
console.error('Error loading tags:', error);
}
}
function formatRelativeTime(timestamp) {
if (!timestamp) return 'Never';
// SQLite CURRENT_TIMESTAMP is in UTC, so append 'Z' to ensure proper parsing
const utcTimestamp = timestamp.includes('Z') ? timestamp : timestamp + 'Z';
const now = new Date();
const then = new Date(utcTimestamp);
const diffMs = now - then;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return then.toLocaleDateString('en-US', { timeZone: 'America/Los_Angeles' });
}
function formatDateTime(timestamp) {
if (!timestamp) return 'Never';
// SQLite CURRENT_TIMESTAMP is in UTC, so append 'Z' to ensure proper parsing
const utcTimestamp = timestamp.includes('Z') ? timestamp : timestamp + 'Z';
const date = new Date(utcTimestamp);
return date.toLocaleString('en-US', {
timeZone: 'America/Los_Angeles',
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
timeZoneName: 'short'
});
}
async function loadStats() {
try {
const response = await fetch('/api/stats');
const data = await response.json();
document.getElementById('sgCount').textContent = data.security_groups;
document.getElementById('ec2Count').textContent = data.ec2_instances;
document.getElementById('accountCount').textContent = data.accounts.length;
// Update last refresh info
if (data.refresh_timestamps && data.refresh_timestamps.length > 0) {
const mostRecent = data.refresh_timestamps[0];
document.getElementById('lastRefreshTime').textContent = formatRelativeTime(mostRecent.timestamp);
// Build tooltip with all account refresh times
const tooltip = document.getElementById('refreshTooltip');
tooltip.innerHTML = data.refresh_timestamps.map(item => {
const color = getColorFromName(item.account);
// Brighten color for dark tooltip background
const hsl = color.match(/hsl\((\d+),\s*(\d+)%,\s*(\d+)%\)/);
const brightColor = hsl ? `hsl(${hsl[1]}, ${hsl[2]}%, ${Math.min(parseInt(hsl[3]) + 30, 85)}%)` : color;
return `
<div class="refresh-tooltip-item">
<div class="refresh-tooltip-account" style="color: ${brightColor};">${item.account}</div>
<div class="refresh-tooltip-time">${formatDateTime(item.timestamp)}</div>
</div>
`;
}).join('');
} else {
document.getElementById('lastRefreshTime').textContent = 'Never';
}
} catch (error) {
console.error('Error loading stats:', error);
}
}
async function refreshData() {
const btn = document.getElementById('refreshBtn');
const originalText = btn.textContent;
btn.disabled = true;
btn.textContent = '🔄 Refreshing...';
try {
const response = await fetch('/api/refresh-cached', {
method: 'POST'
});
// Check if it's a JSON response (error/redirect)
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
const data = await response.json();
if (data.redirect) {
// No cached sessions, redirect to import page
alert('No active AWS sessions found. Please import data first.');
window.location.href = '/';
return;
}
}
// Stream response
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const {value, done} = await reader.read();
if (done) break;
const text = decoder.decode(value);
const lines = text.split('\n').filter(l => l.trim());
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = JSON.parse(line.substring(6));
if (data.status === 'redirect' || data.message === 'REDIRECT') {
alert('AWS sessions expired. Please re-authenticate.');
window.location.href = '/';
return;
}
if (data.status === 'complete' || data.message === 'COMPLETE') {
// Reload stats (including refresh timestamps) and search results
await loadStats();
performSearch(document.getElementById('searchInput').value.trim());
btn.textContent = originalText;
btn.disabled = false;
return;
}
}
}
}
} catch (error) {
console.error('Refresh error:', error);
alert('Failed to refresh data. Please re-import from AWS.');
window.location.href = '/';
}
btn.textContent = originalText;
btn.disabled = false;
}
function fuzzyMatch(str, pattern) {
// Simple fuzzy matching: checks if pattern characters appear in order in str
if (!pattern) return true;
let patternIdx = 0;
let strIdx = 0;
while (strIdx < str.length && patternIdx < pattern.length) {
if (str[strIdx] === pattern[patternIdx]) {
patternIdx++;
}
strIdx++;
}
return patternIdx === pattern.length;
}
function clientSideFilter(results, query, useFuzzy, useCaseInsensitive) {
if (!query || (!useFuzzy && useCaseInsensitive)) {
return results; // Let server handle normal case insensitive
}
const processedQuery = useCaseInsensitive ? query.toLowerCase() : query;
return results.filter(item => {
const fields = [
item.name,
item.id,
item.group_name,
item.tag_name,
item.private_ip_address,
item.account_name,
item.tag_git_repo
].filter(f => f);
return fields.some(field => {
const processedField = useCaseInsensitive ? field.toLowerCase() : field;
if (useFuzzy) {
return fuzzyMatch(processedField, processedQuery);
} else {
return processedField.includes(processedQuery);
}
});
});
}
async function performSearch(query) {
const resultsDiv = document.getElementById('searchResults');
const tableBody = document.getElementById('tableResultsBody');
resultsDiv.innerHTML = '<div class="loading"><div class="spinner"></div>Searching...</div>';
tableBody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 2rem;">Searching...</td></tr>';
const useRegex = document.getElementById('regexCheckbox').checked;
const useFuzzy = document.getElementById('fuzzyCheckbox').checked;
const useCaseInsensitive = document.getElementById('caseInsensitiveCheckbox').checked;
const waveFilter = document.getElementById('waveFilter').value;
const repoFilter = document.getElementById('repoFilter').value;
try {
// For fuzzy search, fetch all and filter client-side
let url = `/api/search?q=${useFuzzy || useRegex ? '' : encodeURIComponent(query)}&type=${currentFilter}&regex=${useRegex}`;
if (waveFilter) url += `&wave=${encodeURIComponent(waveFilter)}`;
if (repoFilter) url += `&repo=${encodeURIComponent(repoFilter)}`;
const response = await fetch(url);
const data = await response.json();
if (data.error) {
resultsDiv.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">⚠️</div>
<p>Error: ${data.error}</p>
</div>
`;
tableBody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 2rem;">Error loading results</td></tr>';
return;
}
let filteredResults = data.results;
// Apply client-side fuzzy filtering if enabled
if (useFuzzy && query) {
filteredResults = clientSideFilter(filteredResults, query, true, useCaseInsensitive);
}
if (filteredResults.length === 0) {
resultsDiv.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🔍</div>
<p>No results found</p>
</div>
`;
tableBody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 2rem;">No results found</td></tr>';
return;
}
currentResults = filteredResults;
renderCardView(currentResults, resultsDiv);
renderTableView(currentResults, tableBody);
} catch (error) {
console.error('Error searching:', error);
resultsDiv.innerHTML = '<div class="empty-state">Error performing search</div>';
tableBody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 2rem;">Error loading results</td></tr>';
}
}
function renderCardView(results, resultsDiv) {
resultsDiv.innerHTML = results.map(item => {
const isEC2 = item.type === 'ec2';
const githubUrl = buildGitHubUrl(item.tag_git_org, item.tag_git_repo, item.tag_git_file);
const sourceLink = githubUrl
? `<a href="${githubUrl}" target="_blank" onclick="event.stopPropagation()" style="color: var(--primary-color); text-decoration: underline;">${item.tag_git_repo || 'GitHub'}</a>`
: (item.tag_git_repo || 'N/A');
const accountStyle = getAccountStyle(item.account_name);
// Count security groups for EC2 instances
let sgCount = 0;
if (isEC2 && item.security_groups_id_list) {
sgCount = item.security_groups_id_list.split(';').filter(id => id.trim()).length;
}
return `
<div class="result-item" onclick="showDetails('${item.type}', '${item.id}')">
<div class="result-header">
<span class="result-type-badge ${item.type}">${item.type.toUpperCase()}</span>
<span class="result-name">${item.name || item.id}</span>
</div>
<div class="result-meta">
${isEC2 ? `
<strong>Instance:</strong> ${item.id} |
<strong>IP:</strong> ${item.private_ip_address || 'N/A'} |
<strong>State:</strong> ${item.state} |
<strong>Security Groups:</strong> ${sgCount} |
<strong>Account:</strong> <span style="${accountStyle}">${item.account_name}</span> |
<strong>Source:</strong> ${sourceLink}
` : `
<strong>Group ID:</strong> ${item.id} |
<strong>Rules:</strong> ${item.ingress_rule_count} ingress |
<strong>Wave:</strong> ${item.tag_wave || 'N/A'} |
<strong>Account:</strong> <span style="${accountStyle}">${item.account_name}</span> |
<strong>Source:</strong> ${sourceLink}
`}
</div>
</div>
`;
}).join('');
}
function renderTableView(results, tableBody) {
tableBody.innerHTML = results.map(item => {
const isEC2 = item.type === 'ec2';
const githubUrl = buildGitHubUrl(item.tag_git_org, item.tag_git_repo, item.tag_git_file);
const sourceCell = githubUrl
? `<a href="${githubUrl}" target="_blank" onclick="event.stopPropagation()" style="color: var(--primary-color); text-decoration: underline;">${item.tag_git_repo || 'GitHub'}</a>`
: (item.tag_git_repo || '-');
const accountStyle = getAccountStyle(item.account_name);
// Count security groups for EC2 instances
let sgCount = 0;
if (isEC2 && item.security_groups_id_list) {
sgCount = item.security_groups_id_list.split(';').filter(id => id.trim()).length;
}
return `
<tr onclick="showDetails('${item.type}', '${item.id}')">
<td><span class="table-type-badge ${item.type}">${item.type.toUpperCase()}</span></td>
<td>${item.name || item.id}</td>
<td class="table-cell-mono">${item.id}</td>
<td><span style="${accountStyle}">${item.account_name}</span></td>
<td class="table-cell-secondary">${sourceCell}</td>
<td class="table-cell-secondary">${isEC2 ? '-' : (item.tag_wave || '-')}</td>
<td>
${isEC2 ?
`<span class="table-status-badge ${item.state}">${item.state}</span> (${sgCount} SGs)` :
`${item.ingress_rule_count} ingress`
}
</td>
<td class="table-cell-mono">${isEC2 ? (item.private_ip_address || '-') : '-'}</td>
</tr>
`;
}).join('');
}
function sortTable(column) {
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
currentResults.sort((a, b) => {
let aVal = a[column] || '';
let bVal = b[column] || '';
if (typeof aVal === 'string') {
aVal = aVal.toLowerCase();
bVal = bVal.toLowerCase();
}
if (sortDirection === 'asc') {
return aVal > bVal ? 1 : aVal < bVal ? -1 : 0;
} else {
return aVal < bVal ? 1 : aVal > bVal ? -1 : 0;
}
});
// Update sort indicators
document.querySelectorAll('[id^="sort-"]').forEach(el => el.textContent = '');
const indicator = document.getElementById(`sort-${column}`);
if (indicator) {
indicator.textContent = sortDirection === 'asc' ? ' ▲' : ' ▼';
}
renderTableView(currentResults, document.getElementById('tableResultsBody'));
}
async function showDetails(type, id) {
const detailsDiv = document.getElementById('detailsView');
detailsDiv.innerHTML = '<div class="loading"><div class="spinner"></div>Loading details...</div>';
detailsDiv.classList.add('active');
detailsDiv.scrollIntoView({ behavior: 'smooth' });
try {
const endpoint = type === 'ec2' ? `/api/ec2/${id}` : `/api/sg/${id}`;
const response = await fetch(endpoint);
const data = await response.json();
if (type === 'ec2') {
displayEC2Details(data);
} else {
displaySGDetails(data);
}
} catch (error) {
console.error('Error loading details:', error);
detailsDiv.innerHTML = '<div class="empty-state">Error loading details</div>';
}
}
function displayEC2Details(data) {
const ec2 = data.ec2;
const sgs = data.security_groups;
// Parse tags_json
let tags = {};
try {
tags = ec2.tags_json ? JSON.parse(ec2.tags_json) : {};
} catch (e) {
console.error('Error parsing tags:', e);
}
// Build GitHub link if available
const githubUrl = buildGitHubUrl(ec2.tag_git_org, ec2.tag_git_repo, ec2.tag_git_file);
const githubLink = githubUrl ? `<a href="${githubUrl}" target="_blank" style="color: var(--primary-color); text-decoration: underline;">${ec2.tag_git_repo}</a>` : ec2.tag_git_repo;
const html = `
<button class="back-button" onclick="hideDetails()">← Back to Search</button>
<div class="main-card">
<div class="card-header">
<div>
<div class="card-title">${ec2.tag_name || 'Unnamed EC2 Instance'}</div>
<div class="card-subtitle">${ec2.instance_id}</div>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button onclick="exportEC2Details('${ec2.instance_id}')" style="padding: 0.5rem 1rem; background: var(--primary-color); color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;" title="Export to CSV">💾 Export</button>
<span class="status-badge ${ec2.state}">${ec2.state}</span>
</div>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Instance ID</span>
<span class="info-value">${ec2.instance_id}</span>
</div>
<div class="info-item">
<span class="info-label">Private IP</span>
<span class="info-value">${ec2.private_ip_address || 'N/A'}</span>
</div>
<div class="info-item">
<span class="info-label">Account</span>
<span class="info-value">${ec2.account_name}</span>
</div>
<div class="info-item">
<span class="info-label">Account ID</span>
<span class="info-value">${ec2.account_id}</span>
</div>
${githubUrl ? `
<div class="info-item">
<span class="info-label">Source</span>
<span class="info-value">${githubLink}</span>
</div>
` : ''}
</div>
<div class="tag-list">
${Object.keys(tags).length > 0 ? Object.entries(tags).map(([key, value]) => `
<div class="tag">
<span class="tag-key">${key}:</span>
<span class="tag-value">${value}</span>
</div>
`).join('') : '<div class="tag"><span class="tag-value">No tags</span></div>'}
</div>
${sgs.length > 0 ? `
<div class="nested-cards">
<div class="nested-cards-header">Attached Security Groups (${sgs.length})</div>
${sgs.map(sg => {
const sgGithubUrl = buildGitHubUrl(sg.tag_git_org, sg.tag_git_repo, sg.tag_git_file);
const sgGithubLink = sgGithubUrl ? `<a href="${sgGithubUrl}" target="_blank" onclick="event.stopPropagation()" style="color: var(--primary-color); text-decoration: underline;">${sg.tag_git_repo}</a>` : sg.tag_git_repo;
return `
<div class="nested-card" style="cursor: pointer;" onclick="event.stopPropagation(); showDetails('sg', '${sg.group_id}')">
<div class="nested-card-title">${sg.tag_name || sg.group_name}</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Group ID</span>
<span class="info-value">${sg.group_id}</span>
</div>
<div class="info-item">
<span class="info-label">Group Name</span>
<span class="info-value">${sg.group_name}</span>
</div>
<div class="info-item">
<span class="info-label">Ingress Rules</span>
<span class="info-value">${sg.ingress_rule_count}</span>
</div>
<div class="info-item">
<span class="info-label">Wave</span>
<span class="info-value">${sg.tag_wave || 'N/A'}</span>
</div>
${sgGithubUrl ? `
<div class="info-item">
<span class="info-label">Source</span>
<span class="info-value">${sgGithubLink}</span>
</div>
` : ''}
</div>
<button class="view-rules-btn" onclick="event.stopPropagation(); viewRules('${sg.group_id}', '${sg.tag_name || sg.group_name}')">View Rules</button>
</div>
`;
}).join('')}
</div>
` : '<p>No security groups attached</p>'}
</div>
`;
document.getElementById('detailsView').innerHTML = html;
}
function displaySGDetails(data) {
const sg = data.security_group;
const instances = data.ec2_instances;
// Parse tags_json
let tags = {};
try {
tags = sg.tags_json ? JSON.parse(sg.tags_json) : {};
} catch (e) {
console.error('Error parsing tags:', e);
}
// Build GitHub link if available
const githubUrl = buildGitHubUrl(sg.tag_git_org, sg.tag_git_repo, sg.tag_git_file);
const githubLink = githubUrl ? `<a href="${githubUrl}" target="_blank" style="color: var(--primary-color); text-decoration: underline;">${sg.tag_git_repo}</a>` : sg.tag_git_repo;
const html = `
<button class="back-button" onclick="hideDetails()">← Back to Search</button>
<div class="main-card">
<div class="card-header">
<div>
<div class="card-title">${sg.tag_name || sg.group_name}</div>
<div class="card-subtitle">${sg.group_id}</div>
</div>
<button onclick="exportSGDetails('${sg.group_id}')" style="padding: 0.5rem 1rem; background: var(--primary-color); color: white; border: none; border-radius: 0.375rem; cursor: pointer; font-size: 0.875rem;" title="Export to CSV">💾 Export</button>
</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Group ID</span>
<span class="info-value">${sg.group_id}</span>
</div>
<div class="info-item">
<span class="info-label">Group Name</span>
<span class="info-value">${sg.group_name}</span>
</div>
<div class="info-item">
<span class="info-label">Ingress Rules</span>
<span class="info-value">${sg.ingress_rule_count}</span>
</div>
<div class="info-item">
<span class="info-label">Egress Rules</span>
<span class="info-value">${sg.egress_rule_count}</span>
</div>
<div class="info-item">
<span class="info-label">Account</span>
<span class="info-value">${sg.account_name}</span>
</div>
<div class="info-item">
<span class="info-label">Account ID</span>
<span class="info-value">${sg.account_id}</span>
</div>
<div class="info-item">
<span class="info-label">Wave</span>
<span class="info-value">${sg.tag_wave || 'N/A'}</span>
</div>
${githubUrl ? `
<div class="info-item">
<span class="info-label">Source</span>
<span class="info-value">${githubLink}</span>
</div>
` : ''}
</div>
<div class="tag-list">
${Object.keys(tags).length > 0 ? Object.entries(tags).map(([key, value]) => `
<div class="tag">
<span class="tag-key">${key}:</span>
<span class="tag-value">${value}</span>
</div>
`).join('') : '<div class="tag"><span class="tag-value">No tags</span></div>'}
</div>
<button class="view-rules-btn" onclick="viewRules('${sg.group_id}', '${sg.tag_name || sg.group_name}')" style="margin-top: 1rem; padding: 0.5rem 1rem; font-size: 0.875rem;">View Rules</button>
${instances.length > 0 ? `
<div class="nested-cards">
<div class="nested-cards-header">Attached EC2 Instances (${instances.length})</div>
${instances.map(ec2 => {
const ec2GithubUrl = buildGitHubUrl(ec2.tag_git_org, ec2.tag_git_repo, ec2.tag_git_file);
const ec2GithubLink = ec2GithubUrl ? `<a href="${ec2GithubUrl}" target="_blank" onclick="event.stopPropagation()" style="color: var(--primary-color); text-decoration: underline;">${ec2.tag_git_repo}</a>` : ec2.tag_git_repo;
return `
<div class="nested-card" style="cursor: pointer;" onclick="showDetails('ec2', '${ec2.instance_id}')">
<div class="nested-card-title">${ec2.tag_name || ec2.instance_id}</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Instance ID</span>
<span class="info-value">${ec2.instance_id}</span>
</div>
<div class="info-item">
<span class="info-label">Private IP</span>
<span class="info-value">${ec2.private_ip_address || 'N/A'}</span>
</div>
<div class="info-item">
<span class="info-label">State</span>
<span class="info-value">
<span class="status-badge ${ec2.state}">${ec2.state}</span>
</span>
</div>
<div class="info-item">
<span class="info-label">Account</span>
<span class="info-value">${ec2.account_name}</span>
</div>
${ec2GithubUrl ? `
<div class="info-item">
<span class="info-label">Source</span>
<span class="info-value">${ec2GithubLink}</span>
</div>
` : ''}
</div>
</div>
`;
}).join('')}
</div>
` : '<p>No EC2 instances using this security group</p>'}
</div>
`;
document.getElementById('detailsView').innerHTML = html;
}
function hideDetails() {
document.getElementById('detailsView').classList.remove('active');
document.getElementById('detailsView').innerHTML = '';
}
async function viewRules(groupId, groupName) {
const response = await fetch(`/api/sg/${groupId}/rules`);
const data = await response.json();
const modalHtml = `
<div style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.5); z-index: 1000; display: flex; align-items: center; justify-content: center;" onclick="this.remove()">
<div style="background: white; border-radius: 0.5rem; width: 85vw; height: 85vh; display: flex; flex-direction: column; box-shadow: 0 25px 50px -12px rgb(0 0 0 / 0.25);" onclick="event.stopPropagation()">
<div style="display: flex; justify-content: space-between; align-items: center; padding: 1.5rem; border-bottom: 2px solid #e2e8f0; flex-shrink: 0;">
<h3 style="margin: 0; font-size: 1.25rem; color: #1e293b;">Rules for ${groupName}</h3>
<div style="display: flex; gap: 0.5rem;">
<button onclick="exportSGRules('${groupId}', '${groupName}')" style="padding: 0.5rem 1rem; cursor: pointer; background: var(--primary-color); color: white; border: none; border-radius: 0.375rem; font-weight: 500;" title="Export to CSV">💾 Export</button>
<button onclick="this.closest('div[style*=fixed]').remove()" style="padding: 0.5rem 1rem; cursor: pointer; background: #ef4444; color: white; border: none; border-radius: 0.375rem; font-weight: 500; transition: all 0.2s;" onmouseover="this.style.background='#dc2626'" onmouseout="this.style.background='#ef4444'">Close</button>
</div>
</div>
<div style="flex: 1; overflow: auto; padding: 1.5rem;">
${renderRulesTable(data)}
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHtml);
}
function renderRulesTable(data) {
let html = '<div class="rules-section">';
// Ingress and egress tabs
html += '<div class="rules-tabs">';
html += '<button class="rule-tab active" onclick="switchRuleTab(event, \'ingress\')">Ingress (' + data.ingress.length + ')</button>';
html += '<button class="rule-tab" onclick="switchRuleTab(event, \'egress\')">Egress (' + data.egress.length + ')</button>';
html += '</div>';
html += '<div class="rules-header">';
html += '<span class="rules-title">Filter:</span>';
html += '<input type="text" class="rules-search" placeholder="Search rules..." onkeyup="filterRules(this.value)">';
html += '</div>';
html += '<div id="ingress-rules" class="rules-tab-content">';
html += renderRulesTableHTML(data.ingress);
html += '</div>';
html += '<div id="egress-rules" class="rules-tab-content" style="display:none;">';
html += renderRulesTableHTML(data.egress);
html += '</div>';
html += '</div>';
return html;
}
function renderRulesTableHTML(rules) {
if (!rules || rules.length === 0) {
return '<div class="no-rules">No rules configured</div>';
}
let html = '<div class="rules-table-container"><table class="rules-table">';
html += '<thead><tr>';
html += '<th>Protocol</th><th>Port Range</th><th>Source Type</th><th>Source</th><th>Description</th>';
html += '</tr></thead><tbody>';
rules.forEach(rule => {
html += '<tr class="rule-row">';
html += `<td class="rule-protocol">${rule.protocol}</td>`;
html += `<td class="rule-port">${rule.port_range}</td>`;
html += `<td>${rule.source_type}</td>`;
html += `<td class="rule-source">${rule.source}</td>`;
html += `<td class="rule-description">${rule.description || '-'}</td>`;
html += '</tr>';
});
html += '</tbody></table></div>';
return html;
}
function switchRuleTab(event, tabName) {
const tabs = event.target.closest('.rules-section').querySelectorAll('.rule-tab');
tabs.forEach(t => t.classList.remove('active'));
event.target.classList.add('active');
document.getElementById('ingress-rules').style.display = tabName === 'ingress' ? 'block' : 'none';
document.getElementById('egress-rules').style.display = tabName === 'egress' ? 'block' : 'none';
}
function filterRules(query) {
const rows = document.querySelectorAll('.rule-row');
const lowerQuery = query.toLowerCase();
rows.forEach(row => {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(lowerQuery) ? '' : 'none';
});
}
// CSV Export Functions
function escapeCSV(value) {
if (value === null || value === undefined) return '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
}
function downloadCSV(csvContent, filename) {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
function exportSearchResults() {
if (!currentResults || currentResults.length === 0) {
alert('No results to export');
return;
}
const headers = ['Type', 'Name', 'ID', 'Account', 'Account ID', 'State', 'IP Address', 'Security Groups', 'Wave', 'Git Repo', 'Git Org', 'Git File', 'Ingress Rules'];
const rows = currentResults.map(item => {
const isEC2 = item.type === 'ec2';
let sgCount = 0;
if (isEC2 && item.security_groups_id_list) {
sgCount = item.security_groups_id_list.split(';').filter(id => id.trim()).length;
}
return [
escapeCSV(item.type.toUpperCase()),
escapeCSV(item.name || item.id),
escapeCSV(item.id),
escapeCSV(item.account_name),
escapeCSV(item.account_id),
escapeCSV(isEC2 ? item.state : ''),
escapeCSV(isEC2 ? item.private_ip_address : ''),
escapeCSV(isEC2 ? sgCount : ''),
escapeCSV(item.tag_wave || ''),
escapeCSV(item.tag_git_repo || ''),
escapeCSV(item.tag_git_org || ''),
escapeCSV(item.tag_git_file || ''),
escapeCSV(isEC2 ? '' : item.ingress_rule_count)
].join(',');
});
const csv = [headers.join(','), ...rows].join('\n');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
downloadCSV(csv, `sgo-search-results-${timestamp}.csv`);
}
async function exportEC2Details(instanceId) {
try {
const response = await fetch(`/api/ec2/${instanceId}`);
const data = await response.json();
const ec2 = data.ec2;
const sgs = data.security_groups;
let tags = {};
try {
tags = ec2.tags_json ? JSON.parse(ec2.tags_json) : {};
} catch (e) {
console.error('Error parsing tags:', e);
}
// Main EC2 info section
let csv = '# EC2 Instance Details\n';
csv += 'Field,Value\n';
csv += `Instance ID,${escapeCSV(ec2.instance_id)}\n`;
csv += `Name,${escapeCSV(ec2.tag_name)}\n`;
csv += `State,${escapeCSV(ec2.state)}\n`;
csv += `Private IP,${escapeCSV(ec2.private_ip_address)}\n`;
csv += `Account,${escapeCSV(ec2.account_name)}\n`;
csv += `Account ID,${escapeCSV(ec2.account_id)}\n`;
csv += `Git Repo,${escapeCSV(ec2.tag_git_repo)}\n`;
csv += `Git Org,${escapeCSV(ec2.tag_git_org)}\n`;
csv += `Git File,${escapeCSV(ec2.tag_git_file)}\n`;
// Tags section
csv += '\n# Tags\n';
csv += 'Tag Key,Tag Value\n';
for (const [key, value] of Object.entries(tags)) {
csv += `${escapeCSV(key)},${escapeCSV(value)}\n`;
}
// Security Groups section
csv += '\n# Attached Security Groups\n';
csv += 'Group ID,Group Name,Tag Name,Wave,Ingress Rules,Egress Rules,Git Repo,Git File\n';
sgs.forEach(sg => {
csv += [
escapeCSV(sg.group_id),
escapeCSV(sg.group_name),
escapeCSV(sg.tag_name),
escapeCSV(sg.tag_wave),
escapeCSV(sg.ingress_rule_count),
escapeCSV(sg.egress_rule_count),
escapeCSV(sg.tag_git_repo),
escapeCSV(sg.tag_git_file)
].join(',') + '\n';
});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
downloadCSV(csv, `ec2-${instanceId}-${timestamp}.csv`);
} catch (error) {
console.error('Error exporting EC2 details:', error);
alert('Failed to export EC2 details');
}
}
async function exportSGDetails(groupId) {
try {
const response = await fetch(`/api/sg/${groupId}`);
const data = await response.json();
const sg = data.security_group;
const instances = data.ec2_instances;
let tags = {};
try {
tags = sg.tags_json ? JSON.parse(sg.tags_json) : {};
} catch (e) {
console.error('Error parsing tags:', e);
}
// Main SG info section
let csv = '# Security Group Details\n';
csv += 'Field,Value\n';
csv += `Group ID,${escapeCSV(sg.group_id)}\n`;
csv += `Group Name,${escapeCSV(sg.group_name)}\n`;
csv += `Tag Name,${escapeCSV(sg.tag_name)}\n`;
csv += `Wave,${escapeCSV(sg.tag_wave)}\n`;
csv += `Ingress Rules,${escapeCSV(sg.ingress_rule_count)}\n`;
csv += `Egress Rules,${escapeCSV(sg.egress_rule_count)}\n`;
csv += `Account,${escapeCSV(sg.account_name)}\n`;
csv += `Account ID,${escapeCSV(sg.account_id)}\n`;
csv += `Git Repo,${escapeCSV(sg.tag_git_repo)}\n`;
csv += `Git Org,${escapeCSV(sg.tag_git_org)}\n`;
csv += `Git File,${escapeCSV(sg.tag_git_file)}\n`;
// Tags section
csv += '\n# Tags\n';
csv += 'Tag Key,Tag Value\n';
for (const [key, value] of Object.entries(tags)) {
csv += `${escapeCSV(key)},${escapeCSV(value)}\n`;
}
// Attached EC2 instances section
csv += '\n# Attached EC2 Instances\n';
csv += 'Instance ID,Name,State,Private IP,Git Repo,Git File\n';
instances.forEach(ec2 => {
csv += [
escapeCSV(ec2.instance_id),
escapeCSV(ec2.tag_name),
escapeCSV(ec2.state),
escapeCSV(ec2.private_ip_address),
escapeCSV(ec2.tag_git_repo),
escapeCSV(ec2.tag_git_file)
].join(',') + '\n';
});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
downloadCSV(csv, `sg-${groupId}-${timestamp}.csv`);
} catch (error) {
console.error('Error exporting SG details:', error);
alert('Failed to export SG details');
}
}
async function exportSGRules(groupId, groupName) {
try {
// Fetch SG details for metadata
const sgResponse = await fetch(`/api/sg/${groupId}`);
const sgData = await sgResponse.json();
const sg = sgData.security_group;
// Fetch rules
const rulesResponse = await fetch(`/api/sg/${groupId}/rules`);
const rulesData = await rulesResponse.json();
// Extract git_commit tag from tags_json
let gitCommit = '';
try {
const tags = sg.tags_json ? JSON.parse(sg.tags_json) : {};
gitCommit = tags.git_commit || tags.GitCommit || '';
} catch (e) {
console.error('Error parsing tags:', e);
}
let csv = '# Security Group Rules Export\n';
csv += `# Group ID: ${sg.group_id}\n`;
csv += `# Group Name: ${sg.group_name}\n`;
csv += `# Account ID: ${sg.account_id}\n`;
csv += `# Git File: ${sg.tag_git_file || ''}\n`;
csv += `# Git Commit: ${gitCommit}\n\n`;
// Ingress rules
csv += '# Ingress Rules\n';
csv += 'Direction,Protocol,Port Range,Source Type,Source,Description,Group ID,Account ID,Git File,Git Commit\n';
rulesData.ingress.forEach(rule => {
csv += [
'ingress',
escapeCSV(rule.protocol),
escapeCSV(rule.port_range),
escapeCSV(rule.source_type),
escapeCSV(rule.source),
escapeCSV(rule.description),
escapeCSV(sg.group_id),
escapeCSV(sg.account_id),
escapeCSV(sg.tag_git_file),
escapeCSV(gitCommit)
].join(',') + '\n';
});
// Egress rules
csv += '\n# Egress Rules\n';
csv += 'Direction,Protocol,Port Range,Source Type,Source,Description,Group ID,Account ID,Git File,Git Commit\n';
rulesData.egress.forEach(rule => {
csv += [
'egress',
escapeCSV(rule.protocol),
escapeCSV(rule.port_range),
escapeCSV(rule.source_type),
escapeCSV(rule.source),
escapeCSV(rule.description),
escapeCSV(sg.group_id),
escapeCSV(sg.account_id),
escapeCSV(sg.tag_git_file),
escapeCSV(gitCommit)
].join(',') + '\n';
});
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, -5);
downloadCSV(csv, `sg-rules-${groupId}-${timestamp}.csv`);
} catch (error) {
console.error('Error exporting SG rules:', error);
alert('Failed to export SG rules');
}
}
</script>
</body>
</html>